Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 0d2896e0cc | |||
|
|
2b3c3d727e | ||
|
|
81fae12a8c | ||
|
|
7b536409a8 | ||
|
|
7468217460 | ||
|
|
72e9b68923 | ||
|
|
d03098cfac | ||
|
|
2179cfbf39 | ||
|
|
5012b40c14 | ||
|
|
369d418c7e | ||
|
|
e2e8b9b921 | ||
|
|
3eb0ef1316 | ||
|
|
463be7cf26 | ||
|
|
7a56184ad2 | ||
| a54f134343 | |||
|
|
401414608b | ||
|
|
2ef4802050 | ||
|
|
e3f6c711a7 | ||
|
|
87c18a4c2d | ||
|
|
9e48624495 | ||
|
|
62e68dcfe7 | ||
|
|
8436846657 | ||
|
|
33588b09df | ||
|
|
9a1f0e269a | ||
|
|
bbfd0cccda | ||
|
|
dba64c546f | ||
|
|
662abdb508 |
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
1158
docs/superpowers/plans/2026-05-10-v030-cut-e-bidirectional-sync.md
Normal file
File diff suppressed because it is too large
Load Diff
841
docs/superpowers/plans/2026-05-10-v031-cut-f-vision.md
Normal file
841
docs/superpowers/plans/2026-05-10-v031-cut-f-vision.md
Normal file
@@ -0,0 +1,841 @@
|
||||
# v0.3.1 Cut F — 멀티모달 vision AI Implementation Plan
|
||||
|
||||
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
|
||||
|
||||
**Goal:** F24 — Ollama vision 모델 (gemma3 family default) 활용. 이미지 + raw_text 결합 prompt → title/summary/tags 자동 생성. Capability detection (app launch + manual refresh) + InferenceProvider 확장 + AiWorker 통합 + Configure UI dropdown.
|
||||
|
||||
**Architecture:** `isVisionCapable(model)` pure 함수 가 family/families/name 기반으로 vision 가능 모델 판정. `refreshVisionCache(deps)` 가 `/api/tags` 호출 후 settings 에 cache. AiWorker 가 `note.media.length > 0 && visionModel` 둘 다 충족 시 vision path (5MB cap + base64 변환). Configure UI 가 cache 기반 dropdown + manual refresh.
|
||||
|
||||
**Tech Stack:** undici/fetch (Ollama API), Node fs/promises (이미지 base64 변환), Electron IPC, React 19 + zustand 5, vitest 4 + RTL.
|
||||
|
||||
**선행 문서:**
|
||||
|
||||
- `docs/superpowers/specs/2026-05-09-v031-cut-f-design.md` — source spec (Cut F 정정 반영: 단위 679, 실제 SettingsService API, 'skipped' enum 미도입, fallback 미구현)
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24
|
||||
- `docs/superpowers/strategy/v028plus-roadmap.md` — Cut F 위치
|
||||
|
||||
---
|
||||
|
||||
## File Structure
|
||||
|
||||
**Create:**
|
||||
|
||||
- `src/main/services/VisionDetect.ts` — `isVisionCapable(model)` pure + `refreshVisionCache(deps)` async (Ollama /api/tags)
|
||||
- `src/main/ai/visionPrompt.ts` — `buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure
|
||||
- `src/renderer/inbox/components/settings/VisionSection.tsx` — AI 제공자 섹션 안 또는 별도 sub-section. dropdown + 다시 감지 버튼
|
||||
- `tests/unit/VisionDetect.test.ts` — isVisionCapable 5 + refreshVisionCache 4
|
||||
- `tests/unit/visionPrompt.test.ts` — buildVisionPrompt 2 (text only / image-only fallback)
|
||||
- `tests/unit/AiWorker.vision.test.ts` — vision path 3 (text-only / vision body / 5MB cap)
|
||||
- `tests/unit/VisionSection.test.tsx` — UI 1 (dropdown + 다시 감지)
|
||||
|
||||
**Modify:**
|
||||
|
||||
- `src/main/services/SettingsService.ts` — zod schema vision_model / vision_capable_cache / vision_cache_at + 4 메서드
|
||||
- `src/main/ai/InferenceProvider.ts` — `GenerateInput.images?: Array<{ base64: string; mime: string }>` + `generate(input, opts?: { visionModel?: string | null })`
|
||||
- `src/main/ai/LocalOllamaProvider.ts` — `generate` body 에 `images` 필드 (vision path) + 모델 분기
|
||||
- `src/main/ai/AiWorker.ts` — `note.media + visionModel` vision path + 5MB cap + base64 변환. 생성자에 `settings: SettingsService` 의존성 추가
|
||||
- `src/main/ipc/settingsApi.ts` — 3 IPC: `settings:get-vision-models` / `settings:set-vision-model` / `settings:refresh-vision-cache`
|
||||
- `src/preload/index.ts` — 3 bridge
|
||||
- `src/shared/types.ts` — `getSettings()` 반환에 vision_* 3 필드 + InboxApi 3 메서드
|
||||
- `src/main/index.ts` — `void refreshVisionCache(...)` whenReady 안 + AiWorker 생성자에 settings 주입
|
||||
- `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — VisionSection 마운트
|
||||
- `tests/unit/SettingsService.test.ts` — vision 4 메서드 round-trip
|
||||
- `tests/unit/LocalOllamaProvider.test.ts` — vision body 분기 회귀
|
||||
- `tests/unit/AiWorker.test.ts` — 기존 mock 에 settings stub 추가 (생성자 변경)
|
||||
- `package.json` — version 0.3.0 → 0.3.1
|
||||
- `docs/superpowers/specs/2026-04-25-dogfood-feedback.md` — F24 promoted
|
||||
|
||||
---
|
||||
|
||||
## 단위 목표
|
||||
|
||||
679 (v0.3.0) → 약 701 (+22), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
## Task 1: VisionDetect — `isVisionCapable` + `refreshVisionCache`
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/services/VisionDetect.ts`
|
||||
- Create: `tests/unit/VisionDetect.test.ts`
|
||||
|
||||
`isVisionCapable(model)` pure 함수 — family/families/name hints 기반 판정. `refreshVisionCache(deps)` async — `/api/tags` 호출 후 capable 추출 + settings cache 저장. fetch 주입 가능 (테스트).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/unit/VisionDetect.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isVisionCapable, refreshVisionCache } from '../../src/main/services/VisionDetect.js';
|
||||
|
||||
describe('isVisionCapable', () => {
|
||||
it('family=gemma3 → true', () => {
|
||||
expect(isVisionCapable({ name: 'gemma3:12b', details: { family: 'gemma3' } })).toBe(true);
|
||||
});
|
||||
it('families=[llava] → true', () => {
|
||||
expect(isVisionCapable({ name: 'llava-13b', details: { families: ['llava'] } })).toBe(true);
|
||||
});
|
||||
it('name hint "vision" → true', () => {
|
||||
expect(isVisionCapable({ name: 'custom-vision-7b' })).toBe(true);
|
||||
});
|
||||
it('text-only family=gemma → false', () => {
|
||||
expect(isVisionCapable({ name: 'gemma4:e4b', details: { family: 'gemma' } })).toBe(false);
|
||||
});
|
||||
it('no hints + unknown family → false', () => {
|
||||
expect(isVisionCapable({ name: 'mistral:7b', details: { family: 'mistral' } })).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('refreshVisionCache', () => {
|
||||
it('happy path — capable 추출 + settings cache 저장', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: true,
|
||||
status: 200,
|
||||
json: async () => ({
|
||||
models: [
|
||||
{ name: 'gemma4:e4b', details: { family: 'gemma' } },
|
||||
{ name: 'gemma3:12b-vision', details: { family: 'gemma3' } },
|
||||
{ name: 'llava:13b', details: { families: ['llava'] } }
|
||||
]
|
||||
})
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
fetchImpl
|
||||
});
|
||||
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
|
||||
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(['gemma3:12b-vision', 'llava:13b'], expect.any(Date));
|
||||
});
|
||||
|
||||
it('ai_disabled → 스킵', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => false),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x' });
|
||||
expect(r).toEqual({ ok: false, reason: 'ai_disabled' });
|
||||
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('http error → ok:false', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => ({
|
||||
ok: false,
|
||||
status: 500,
|
||||
json: async () => ({})
|
||||
})) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
|
||||
expect(r).toMatchObject({ ok: false });
|
||||
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('unreachable → ok:false', async () => {
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
setVisionCapableCache: vi.fn(async () => {})
|
||||
};
|
||||
const fetchImpl = vi.fn(async () => { throw new Error('ECONNREFUSED'); }) as unknown as typeof fetch;
|
||||
const r = await refreshVisionCache({ settings: settings as never, endpoint: 'http://x', fetchImpl });
|
||||
expect(r).toMatchObject({ ok: false });
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: implementation** — `src/main/services/VisionDetect.ts`:
|
||||
|
||||
```ts
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3'];
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
details?: { family?: string; families?: string[] };
|
||||
}
|
||||
|
||||
export function isVisionCapable(model: OllamaModel): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
|
||||
const lower = model.name.toLowerCase();
|
||||
return VISION_NAME_HINTS.some((h) => lower.includes(h));
|
||||
}
|
||||
|
||||
export interface RefreshDeps {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function refreshVisionCache(
|
||||
deps: RefreshDeps
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: OllamaModel[] };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = (await r.json()) as { models?: OllamaModel[] };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
await deps.settings.setVisionCapableCache(capable, now);
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/VisionDetect.test.ts
|
||||
git add src/main/services/VisionDetect.ts tests/unit/VisionDetect.test.ts
|
||||
git commit -m "feat(v031): VisionDetect — isVisionCapable + refreshVisionCache (fetch 주입)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 2: SettingsService — vision_model / vision_capable_cache + 4 메서드
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/services/SettingsService.ts`
|
||||
- Modify: `tests/unit/SettingsService.test.ts`
|
||||
|
||||
zod schema 확장 + 4 메서드 추가 (Cut E sync_* 패턴).
|
||||
|
||||
- [ ] **Step 1: zod schema 확장** — `src/main/services/SettingsService.ts`:
|
||||
|
||||
```ts
|
||||
const SettingsSchema = z.object({
|
||||
ollama: OllamaSettingsSchema.optional(),
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
sync_repo_url: z.string().nullable().optional(),
|
||||
sync_auto_enabled: z.boolean().optional(),
|
||||
sync_interval_min: z.number().int().min(5).optional(),
|
||||
// v0.3.1 Cut F — vision 모델 (이미지 분석). null/없음 = 비활성.
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
}).strict();
|
||||
```
|
||||
|
||||
- [ ] **Step 2: 4 메서드 추가** (`setSyncIntervalMin` 다음):
|
||||
|
||||
```ts
|
||||
async getVisionModel(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.vision_model ?? null;
|
||||
}
|
||||
|
||||
async setVisionModel(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_model: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
|
||||
const s = await this.load();
|
||||
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
|
||||
}
|
||||
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
|
||||
await this.persist(next);
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: failing test** — `tests/unit/SettingsService.test.ts` 의 마지막 describe (Cut E sync) 다음에 추가:
|
||||
|
||||
```ts
|
||||
describe('v0.3.1 Cut F — vision settings', () => {
|
||||
it('getVisionModel() 기본 null', async () => {
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('setVisionModel / getVisionModel round-trip + null clear', async () => {
|
||||
await svc.setVisionModel('gemma3:12b-vision');
|
||||
expect(await svc.getVisionModel()).toBe('gemma3:12b-vision');
|
||||
await svc.setVisionModel(null);
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('getVisionCapableCache() 기본 빈 배열 + null at', async () => {
|
||||
expect(await svc.getVisionCapableCache()).toEqual({ models: [], at: null });
|
||||
});
|
||||
|
||||
it('setVisionCapableCache 저장 + at ISO', async () => {
|
||||
const at = new Date('2026-05-10T05:00:00Z');
|
||||
await svc.setVisionCapableCache(['gemma3:12b', 'llava:13b'], at);
|
||||
const r = await svc.getVisionCapableCache();
|
||||
expect(r.models).toEqual(['gemma3:12b', 'llava:13b']);
|
||||
expect(r.at).toBe('2026-05-10T05:00:00.000Z');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 4: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/SettingsService.test.ts
|
||||
git add src/main/services/SettingsService.ts tests/unit/SettingsService.test.ts
|
||||
git commit -m "feat(v031): SettingsService.{getVisionModel,setVisionModel,getVisionCapableCache,setVisionCapableCache}"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 3: visionPrompt + InferenceProvider 인터페이스 확장
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/main/ai/visionPrompt.ts`
|
||||
- Modify: `src/main/ai/InferenceProvider.ts`
|
||||
- Create: `tests/unit/visionPrompt.test.ts`
|
||||
|
||||
`buildVisionPrompt(text, todayKst, dueCandidates, vocab)` pure — 이미지 + raw_text 결합 시나리오. 빈 text 도 처리 ("(이미지만 있음)" placeholder).
|
||||
|
||||
- [ ] **Step 1: failing test** — `tests/unit/visionPrompt.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildVisionPrompt } from '../../src/main/ai/visionPrompt.js';
|
||||
|
||||
describe('buildVisionPrompt', () => {
|
||||
it('text + 이미지 시 메모 본문 포함', () => {
|
||||
const r = buildVisionPrompt('회의 메모', '2026-05-10', ['2026-05-10'], ['회의']);
|
||||
expect(r).toContain('회의 메모');
|
||||
expect(r).toContain('2026-05-10');
|
||||
expect(r).toContain('회의');
|
||||
});
|
||||
|
||||
it('빈 text → "(이미지만 있음)" placeholder', () => {
|
||||
const r = buildVisionPrompt('', '2026-05-10', [], []);
|
||||
expect(r).toContain('(이미지만 있음)');
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
- [ ] **Step 2: implementation** — `src/main/ai/visionPrompt.ts`:
|
||||
|
||||
```ts
|
||||
/**
|
||||
* v0.3.1 Cut F — 멀티모달 vision prompt. 이미지 + raw_text 결합 분석 후
|
||||
* title/summary/tags/due_date JSON 응답 요청. 빈 raw_text 도 처리.
|
||||
*/
|
||||
export function buildVisionPrompt(
|
||||
text: string,
|
||||
todayKst: string,
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: InferenceProvider 인터페이스 확장** — `src/main/ai/InferenceProvider.ts`:
|
||||
|
||||
```ts
|
||||
export interface GenerateInput {
|
||||
text: string;
|
||||
todayKst: string;
|
||||
dueDateCandidates: string[];
|
||||
vocab?: string[];
|
||||
// v0.3.1 Cut F — 멀티모달 vision (옵션). LocalOllamaProvider 가 visionModel 과 함께 처리.
|
||||
images?: Array<{ base64: string; mime: string }>;
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
visionModel?: string | null;
|
||||
}
|
||||
|
||||
export interface InferenceProvider {
|
||||
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
|
||||
// ... 기존 abort / generateRaw
|
||||
}
|
||||
```
|
||||
|
||||
(기존 호출자는 `opts` 미전달이라 호환 — vision path off.)
|
||||
|
||||
- [ ] **Step 4: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/visionPrompt.test.ts
|
||||
git add src/main/ai/visionPrompt.ts src/main/ai/InferenceProvider.ts tests/unit/visionPrompt.test.ts
|
||||
git commit -m "feat(v031): buildVisionPrompt + GenerateInput.images + GenerateOptions.visionModel"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 4: LocalOllamaProvider — vision path
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ai/LocalOllamaProvider.ts`
|
||||
- Modify: `tests/unit/LocalOllamaProvider.test.ts`
|
||||
|
||||
`generate(input, opts)` 가 `opts.visionModel + input.images` 둘 다 있으면 vision body 생성 (model = visionModel, prompt = buildVisionPrompt, body.images = base64 array). 그 외는 기존 text-only path.
|
||||
|
||||
- [ ] **Step 1: failing test** — 기존 `LocalOllamaProvider.test.ts` 의 적절한 describe 안:
|
||||
|
||||
```ts
|
||||
describe('vision path (v0.3.1 Cut F)', () => {
|
||||
it('opts.visionModel + input.images 둘 다 있으면 vision body', async () => {
|
||||
let captured: { model?: string; prompt?: string; images?: string[] } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
expect(captured.model).toBe('gemma3:12b-vision');
|
||||
expect(captured.prompt).toContain('이미지');
|
||||
expect(captured.images).toEqual(['AAAA']);
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('visionModel 있어도 images 없으면 text-only path', async () => {
|
||||
let captured: { model?: string; images?: unknown } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
expect(captured.model).toBe('gemma4:e4b');
|
||||
expect(captured.images).toBeUndefined();
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
|
||||
it('opts 미전달 → 기존 text-only (회귀)', async () => {
|
||||
let captured: { model?: string; images?: unknown } = {};
|
||||
const undici = await import('undici');
|
||||
const requestSpy = vi.spyOn(undici, 'request').mockImplementation(async (_url, init) => {
|
||||
captured = JSON.parse(init?.body as string);
|
||||
return {
|
||||
statusCode: 200,
|
||||
body: { json: async () => ({ response: '{"title":"t","summary":"s","tags":[],"due_date":null}' }) }
|
||||
} as never;
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
|
||||
expect(captured.model).toBe('gemma4:e4b');
|
||||
expect(captured.images).toBeUndefined();
|
||||
requestSpy.mockRestore();
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(기존 LocalOllamaProvider.test.ts 의 mock 패턴 따름. test file 의 imports + vi.mock 은 그대로 사용.)
|
||||
|
||||
- [ ] **Step 2: implementation** — `LocalOllamaProvider.generate` body 분기:
|
||||
|
||||
```ts
|
||||
import { buildVisionPrompt } from './visionPrompt.js';
|
||||
// ...
|
||||
|
||||
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
const model = useVision ? opts!.visionModel! : this.model;
|
||||
const prompt = useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
|
||||
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map((i) => i.base64);
|
||||
}
|
||||
const res = await request(`${this.endpoint}/api/generate`, {
|
||||
method: 'POST',
|
||||
headers: { 'content-type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
// ... 기존 parse
|
||||
} finally {
|
||||
// ...
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- [ ] **Step 3: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/LocalOllamaProvider.test.ts
|
||||
git add src/main/ai/LocalOllamaProvider.ts tests/unit/LocalOllamaProvider.test.ts
|
||||
git commit -m "feat(v031): LocalOllamaProvider vision path (visionModel + images → body.images base64)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 5: AiWorker — vision integration + 5MB cap + settings 의존성
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/ai/AiWorker.ts`
|
||||
- Modify: `tests/unit/AiWorker.test.ts`
|
||||
- Create: `tests/unit/AiWorker.vision.test.ts`
|
||||
|
||||
AiWorker 가 `note.media + visionModel` 조건에서 base64 변환 (5MB cap) + provider.generate 에 images + visionModel 전달. 생성자에 `settings: SettingsService` 의존성 추가.
|
||||
|
||||
- [ ] **Step 1: AiWorker 생성자 변경** — settings 파라미터 추가. `src/main/index.ts` 의 인스턴스 생성도 갱신.
|
||||
|
||||
- [ ] **Step 2: AiWorker.processJob 갱신**:
|
||||
|
||||
```ts
|
||||
import { readFile } from 'node:fs/promises';
|
||||
// 클래스 안 generate 호출 직전:
|
||||
const visionModel = await this.settings.getVisionModel();
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0) {
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
|
||||
if (buf.byteLength > 5 * 1024 * 1024) {
|
||||
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
|
||||
}
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
const res = await this.holder.get().generate(
|
||||
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
|
||||
{ visionModel: visionModel ?? undefined }
|
||||
);
|
||||
```
|
||||
|
||||
`mediaStore: MediaStore` 도 AiWorker 생성자에 신규 파라미터 (현재 없으면 추가; main 에서 주입).
|
||||
|
||||
- [ ] **Step 3: failing test** — `tests/unit/AiWorker.vision.test.ts`:
|
||||
|
||||
```ts
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
import { AiWorker } from '../../src/main/ai/AiWorker.js';
|
||||
import { MediaStore } from '../../src/main/services/MediaStore.js';
|
||||
|
||||
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let workDir: string;
|
||||
let mediaStore: MediaStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
|
||||
mediaStore = new MediaStore(workDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
|
||||
const { id } = repo.create({ rawText: '이미지 메모' });
|
||||
const mediaPath = join(workDir, 'media', id, '1.png');
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(mediaPath, Buffer.from([0x89, 0x50, 0x4e, 0x47])); // 4 bytes PNG-ish
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
|
||||
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const provider = { name: 'fake', generate, abort: () => {} };
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ...deps with settings + mediaStore + repo + holder = { get: () => provider } */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
|
||||
expect(generate).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ images: expect.any(Array) }),
|
||||
expect.objectContaining({ visionModel: 'gemma3:12b-vision' })
|
||||
);
|
||||
const callArg = generate.mock.calls[0]![0] as { images: Array<{ base64: string; mime: string }> };
|
||||
expect(callArg.images).toHaveLength(1);
|
||||
expect(callArg.images[0]!.mime).toBe('image/png');
|
||||
});
|
||||
|
||||
it('visionModel 없으면 text-only (회귀)', async () => {
|
||||
const { id } = repo.create({ rawText: 'just text' });
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const provider = { name: 'fake', generate, abort: () => {} };
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => null),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ... */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
expect(generate).toHaveBeenCalledWith(
|
||||
expect.not.objectContaining({ images: expect.anything() }),
|
||||
expect.any(Object)
|
||||
);
|
||||
});
|
||||
|
||||
it('5MB 초과 이미지 → throw → ai_status=failed', async () => {
|
||||
const { id } = repo.create({ rawText: 'big image' });
|
||||
const mediaPath = join(workDir, 'media', id, '1.png');
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(mediaPath, Buffer.alloc(6 * 1024 * 1024)); // 6 MB
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
|
||||
|
||||
const generate = vi.fn(async () => ({ title: 't', summary: 's', tags: [], dueDate: null }));
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
} as unknown as never;
|
||||
const worker = new AiWorker(/* ... */);
|
||||
await worker['processJob']({ noteId: id, attempts: 0, nextRunAt: '' });
|
||||
// 5MB cap 초과 throw → AiWorker 의 attempts 증가 분기 → ai_status='failed'
|
||||
const note = repo.findById(id);
|
||||
expect(['failed', 'pending']).toContain(note!.aiStatus); // attempts 모두 소진 시 'failed'; 첫 시도 throw 시 'pending' 유지 가능 — 구현 의존
|
||||
});
|
||||
});
|
||||
```
|
||||
|
||||
(NOTE: 정확한 AiWorker 생성자 인자 — 기존 test 의 setup 패턴 따라 deps 전체 stub 구성. 위 코드는 outline; 실수행자가 기존 `AiWorker.test.ts` setup 참고하여 정확한 deps 구조 채움.)
|
||||
|
||||
- [ ] **Step 4: 기존 AiWorker.test.ts mock 갱신** — 생성자에 `settings` / `mediaStore` 파라미터 추가됨. 모든 기존 test 의 worker 생성 site 에 stub 추가.
|
||||
|
||||
- [ ] **Step 5: PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run tests/unit/AiWorker.test.ts tests/unit/AiWorker.vision.test.ts
|
||||
git add src/main/ai/AiWorker.ts \
|
||||
src/main/index.ts \
|
||||
tests/unit/AiWorker.test.ts \
|
||||
tests/unit/AiWorker.vision.test.ts
|
||||
git commit -m "feat(v031): AiWorker vision integration — note.media + visionModel + 5MB cap"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 6: types + IPC + preload
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/shared/types.ts` — `getSettings()` 반환에 vision_model / vision_capable_cache / vision_cache_at + InboxApi 3 메서드
|
||||
- Modify: `src/main/ipc/settingsApi.ts` — 3 IPC handler
|
||||
- Modify: `src/preload/index.ts` — 3 bridge
|
||||
- Create: `tests/unit/vision-ipc.test.ts`
|
||||
|
||||
3 채널:
|
||||
- `settings:get-vision-models` → `{ models: string[]; at: string | null; selected: string | null }` (cache 결과 + 현재 선택)
|
||||
- `settings:set-vision-model` (value: string | null) → `{ ok: true }`
|
||||
- `settings:refresh-vision-cache` → `{ ok: true; models: string[] } | { ok: false; reason: string }` (refreshVisionCache 호출)
|
||||
|
||||
상세 패턴은 Cut E sync IPC 와 동일.
|
||||
|
||||
- [ ] **Step 1: types** + **Step 2: failing test** + **Step 3: handlers** + **Step 4: preload bridges** — Cut E sync-ipc 패턴 그대로
|
||||
|
||||
- [ ] **Step 5: PASS + commit**
|
||||
|
||||
```bash
|
||||
git add src/shared/types.ts src/main/ipc/settingsApi.ts src/preload/index.ts tests/unit/vision-ipc.test.ts
|
||||
git commit -m "feat(v031): vision IPC + preload (get-vision-models / set / refresh)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 7: VisionSection UI + AI 제공자 섹션 통합
|
||||
|
||||
**Files:**
|
||||
|
||||
- Create: `src/renderer/inbox/components/settings/VisionSection.tsx`
|
||||
- Modify: `src/renderer/inbox/components/settings/AiProviderSection.tsx` 또는 SettingsPage — 마운트
|
||||
- Create: `tests/unit/VisionSection.test.tsx`
|
||||
|
||||
dropdown (cache 기반) + 다시 감지 버튼 + 마지막 감지 시각 표시. dropdown 변경 시 `setVisionModel` 호출. 다시 감지 → `refreshVisionCache` IPC + dropdown 갱신.
|
||||
|
||||
```tsx
|
||||
// 핵심 구조 (Cut E SyncSection 패턴)
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [at, setAt] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const r = await inboxApi.getVisionModels();
|
||||
setModels(r.models);
|
||||
setAt(r.at);
|
||||
setSelected(r.selected);
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSelect(value: string) {
|
||||
setBusy('select');
|
||||
await inboxApi.setVisionModel(value === '' ? null : value);
|
||||
setSelected(value === '' ? null : value);
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
setBusy('refresh');
|
||||
const r = await inboxApi.refreshVisionCache();
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
const cache = await inboxApi.getVisionModels();
|
||||
setModels(cache.models);
|
||||
setAt(cache.at);
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
UI:
|
||||
|
||||
```tsx
|
||||
<select value={selected ?? ''} onChange={(e) => void onSelect(e.target.value)} aria-label="이미지 분석 모델">
|
||||
<option value="">(비활성)</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<button onClick={() => void onRefresh()} disabled={busy === 'refresh'}>
|
||||
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
|
||||
</button>
|
||||
{at !== null && <span>마지막 감지: {new Date(at).toLocaleString('ko-KR')}</span>}
|
||||
```
|
||||
|
||||
- [ ] **Step 1-5: 컴포넌트 + test + 마운트 + commit**
|
||||
|
||||
```bash
|
||||
git add src/renderer/inbox/components/settings/VisionSection.tsx \
|
||||
src/renderer/inbox/components/settings/AiProviderSection.tsx \
|
||||
tests/unit/VisionSection.test.tsx
|
||||
git commit -m "feat(v031): VisionSection — dropdown + 다시 감지 + 마지막 감지 시각"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 8: main process — refreshVisionCache 자동 호출 + AiWorker settings 주입
|
||||
|
||||
**Files:**
|
||||
|
||||
- Modify: `src/main/index.ts`
|
||||
|
||||
`whenReady` 안 (Ollama provider 준비 후) `void refreshVisionCache(...)` fire-and-forget 호출. AiWorker 생성자에 settings + mediaStore 주입.
|
||||
|
||||
- [ ] **Step 1: imports + 호출** — `src/main/index.ts`:
|
||||
|
||||
```ts
|
||||
import { refreshVisionCache } from './services/VisionDetect.js';
|
||||
|
||||
// whenReady 안, AiWorker.start() 직후 또는 직전
|
||||
const ollama = providerHolder.get();
|
||||
void refreshVisionCache({
|
||||
settings: settingsSvc,
|
||||
endpoint: (ollama as LocalOllamaProvider).endpoint, // 또는 SettingsService 의 ollama 설정에서 가져옴
|
||||
}).catch(() => {});
|
||||
```
|
||||
|
||||
(LocalOllamaProvider 의 endpoint 가 private 이면 settings 에서 가져옴 또는 provider 에 getter 추가.)
|
||||
|
||||
- [ ] **Step 2: AiWorker 생성자 인자 갱신**
|
||||
|
||||
- [ ] **Step 3: typecheck + PASS + commit**
|
||||
|
||||
```bash
|
||||
npm run typecheck
|
||||
npx vitest run
|
||||
git add src/main/index.ts
|
||||
git commit -m "feat(v031): main — refreshVisionCache whenReady + AiWorker settings/mediaStore 주입"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Task 9: dogfood promoted + version bump + release commit
|
||||
|
||||
- [ ] F24 promoted 마킹 (`docs/superpowers/specs/2026-04-25-dogfood-feedback.md`):
|
||||
|
||||
```markdown
|
||||
## F24. 멀티모달 vision (✅ promoted v0.3.1 Cut F)
|
||||
|
||||
**상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) deferred v0.3.2+.
|
||||
```
|
||||
|
||||
- [ ] package.json: 0.3.0 → 0.3.1 + package-lock.json
|
||||
- [ ] full unit + typecheck
|
||||
|
||||
```bash
|
||||
git add docs/superpowers/specs/2026-04-25-dogfood-feedback.md package.json package-lock.json
|
||||
git commit -m "chore(release): v0.3.1 — Cut F (멀티모달 vision AI)"
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## Self-Review Checklist (수행자: 모든 task 완료 후 1회 점검)
|
||||
|
||||
- [ ] **Spec coverage**: §3 Capability Detection (Task 1) / §3-2 SettingsService (Task 2) / §3-3 main wiring (Task 8) / §3-4 UI (Task 7) / §4 Provider (Tasks 3-4) / §5 AiWorker (Task 5) / §6 image-only fallback ('skipped' enum 미도입 → 기존 'failed' 분기 활용)
|
||||
- [ ] **Single write path 강제 (Cut C/D/E 정책)**: 본 cut 은 새 데이터 path 추가 없음 — `notes_fts` / `note_revisions` / `note_tags` mutation 없음 (vision 결과는 기존 `updateAiResult` path 활용 → 이미 검증됨). 회귀 검사 4-path invariant 유지.
|
||||
- [ ] **Type 일관성**: `GenerateInput.images` ↔ `GenerateOptions.visionModel` ↔ AiWorker 호출 ↔ LocalOllamaProvider body 모두 동일 shape
|
||||
- [ ] **단위 카운트**: VisionDetect 9 (5+4) + SettingsService 4 + visionPrompt 2 + LocalOllamaProvider 3 + AiWorker 3 + IPC 3-5 + UI 1 = 약 25-27 신규. 목표 22 달성
|
||||
|
||||
---
|
||||
|
||||
## Risk
|
||||
|
||||
- **vision 모델 한국어 정확도**: gemma3 family 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책). dogfood 검증 필요
|
||||
- **Ollama 가 vision images 무시 (모델 misclassify)**: capability detection false-positive — 사용자가 dropdown 에서 다른 모델 선택해 우회. 자동 fallback 미구현 (YAGNI)
|
||||
- **base64 메모리 폭주**: 5MB cap 적용. 다중 이미지 시 N×5MB = 메모리 누적 — vision 호출 후 image array 즉시 GC. 본 cut 의 dogfood 규모 (메모당 < 3 이미지) 무시
|
||||
- **capability detection 실패 silent**: 첫 launch 시 network 실패 → cache 빈 채로 진행. 사용자가 설정 페이지에서 "다시 감지" 클릭 → 직접 trigger 가능
|
||||
- **AiWorker 생성자 변경**: 기존 test 모두 mock 갱신 필요 (typecheck 가 catch). 누락 시 typecheck red
|
||||
- **F23 OFF (ai_enabled=false) 시 자동 OFF**: refreshVisionCache 가 ai_enabled 체크 → ai_disabled 분기. AiWorker 의 vision path 진입 자체가 ai_enabled=true 가정 — F23 OFF 시 vision path 미도달 (자명)
|
||||
- **e2e**: Cut C/D/E 와 동일 — 본 cut 미수행, main 머지 후 검증
|
||||
@@ -1570,9 +1570,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F21. 다기기 git-based 동기화 (🌱 raw — v0.2.8 후보, **부분 구현됨**)
|
||||
## F21. 다기기 git-based 동기화 (✅ promoted v0.3.0 Cut E — 양방향 + Configure UI + Conflict)
|
||||
|
||||
**진행 상태:** 🌱 raw — `SyncService` + `GitClient` 가 이미 push-only 형태로 존재. **양방향 동기화 + UI 구성** 이 누락된 핵심 부분. v0.2.8 brainstorm 시 명확한 cut.
|
||||
**진행 상태:** ✅ promoted v0.3.0 Cut E — 옵션 A (자동 rebase) + B (Configure UI) + C (conflict UI). SyncService 양방향 6단계 (export → commit → fetch → rebase → re-import → push), `NoteRepository.upsertFromSync` (sync 전용 3 분기), `SettingsService.{getSyncRepoUrl,isAutoSyncEnabled,getSyncIntervalMin}` + `SyncTimer` (자동 주기 + reconfigure), `SyncSection` UI + `ConflictModal` (local/remote 2 choice, both deferred v0.3.1+). 단위 608 → 679. dogfood 1주 soak 후 Cut F (F24 vision) 진입.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "그 중심에 git repo 를 쓸 수 있으면 좋겠어".
|
||||
|
||||
@@ -1787,9 +1787,9 @@ app.on('activate', () => {
|
||||
|
||||
---
|
||||
|
||||
## F24. 이미지 멀티모달 AI 분석 (🌱 raw — v0.2.8/v0.3 후보, capability gated)
|
||||
## F24. 이미지 멀티모달 AI 분석 (✅ promoted v0.3.1 Cut F)
|
||||
|
||||
**진행 상태:** 🌱 raw — Ollama vision 모델 (llava / llama3.2-vision / gemma3-multimodal 등) 활용. 사용자 표현: "가능할 경우만 하면 될 것 같다" — capability detection + opt-in 명시.
|
||||
**진행 상태:** ✅ promoted v0.3.1 Cut F — Ollama vision 모델 (gemma3 family default) 활용. capability detection (app launch + manual refresh) + Configure UI dropdown + AiWorker vision integration (5MB cap + base64 변환). 자동 fallback (caption → text) + 'skipped' enum deferred v0.3.2+. 단위 679 → 710. dogfood: vision 결과 정확도 + 한국어 token 정확도 검증.
|
||||
|
||||
**발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. F22 (이미지 렌더링) + F23 (Ollama-less 모드) 와 강하게 연관.
|
||||
|
||||
|
||||
@@ -38,60 +38,81 @@ async sync(opts: { interval?: boolean } = {}): Promise<SyncStatus> {
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 2. local export (변경 감지 위해)
|
||||
// 1. local export — 현재 SQLite 상태를 syncDir 에 markdown 으로 출력
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
await git.addAll();
|
||||
const localChanged = await git.hasUncommittedChanges();
|
||||
|
||||
// 3. local commit (있으면)
|
||||
// 2. local commit (변경 있으면)
|
||||
let localSha: string | null = null;
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
|
||||
// 4. rebase
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase onto origin/main
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
// conflict — abort + 사용자에게 conflict UI 안내
|
||||
// conflict — abort + conflict 목록 반환 (UI 가 활성)
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts: await this.listConflicts() };
|
||||
return { ok: false, reason: 'conflict', conflicts: await this.listConflictsFromMarkdown() };
|
||||
}
|
||||
|
||||
// 5. re-import (rebase 후 markdown 변경 → SQLite 적용)
|
||||
const imported = await this.importSvc.importAll(this.syncDir);
|
||||
// 5. re-import (rebase 후 markdown 변경 → SQLite upsertFromSync)
|
||||
const imported = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
|
||||
// 6. push
|
||||
const pushR = await git.push();
|
||||
if (pushR.exitCode !== 0) return { ok: false, reason: `push failed: ${pushR.stderr}` };
|
||||
try { await git.push(); } catch (e) { return { ok: false, reason: `push failed: ${(e as Error).message}` }; }
|
||||
|
||||
return { ok: true, changed: localChanged || imported.changedCount > 0, localSha, importedCount: imported.changedCount, pushed: true };
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. ImportService 활용
|
||||
**6 단계 흐름 — local export 가 fetch 보다 먼저 (Cut E 정정)**: spec 초안은 fetch 우선이었으나, local export → commit 후 fetch + rebase 가 git workflow 표준 (rebase 가 local commit 위에 origin commit 적용). local export 안 한 상태로 fetch + rebase → 혼란 발생.
|
||||
|
||||
기존 ImportService (백업 복원 흐름) 가 markdown → SQLite 적재. sync 의 re-import 도 같은 service 활용:
|
||||
`SyncStatus` 인터페이스 확장:
|
||||
|
||||
```ts
|
||||
class ImportService {
|
||||
async importAll(dir: string): Promise<{ changedCount: number; conflicts: string[] }> {
|
||||
// dir 하위의 모든 .md 파일 → frontmatter parse → notes UPSERT
|
||||
// existing note 와 비교 — updated_at 더 최신이면 갱신, 아니면 skip
|
||||
// raw_text 다른 경우 → note_revisions 에 INSERT (new rev, edited_by='sync')
|
||||
}
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: Array<{ noteId: string; localText: string; remoteText: string }>; // reason='conflict' 시
|
||||
}
|
||||
```
|
||||
|
||||
**revision linear merge 정책**:
|
||||
### 3-2. ImportService 활용 (실제 코드 정정)
|
||||
|
||||
- 옛 rev (origin/main 의 rev_5) 가 local 에 없으면 → INSERT note_revisions (timestamp 기준 적절 위치)
|
||||
- local rev 와 origin rev 가 동일 timestamp + 다른 raw_text → conflict (사용자 prompt)
|
||||
- 일반적으로 다른 timestamp 면 timestamp 순 linear chain 으로 merge
|
||||
기존 ImportService 는 `run(sourceDir)` 메서드 (백업 복원 흐름) — `parsedToInput` → `repo.importNote()` 호출. spec 작성 시 가정한 `importAll(dir)` 시그니처는 실재 코드와 다름.
|
||||
|
||||
`repo.importNote()` 의 기존 conflict 정책 (export tree 복원용):
|
||||
|
||||
- id 없음 → INSERT (`status: 'inserted'`)
|
||||
- id 있음 + raw_text 동일 → no-op (`status: 'skipped'`)
|
||||
- id 있음 + raw_text 다름 → fork-on-id-collision (fresh uuidv7) (`status: 'forked'`)
|
||||
|
||||
**Cut E sync 정책 — fork 미적합, in-place update + revision 보존**:
|
||||
|
||||
sync 에서 양 기기 raw_text 가 다를 때 fork 하면 노트 갯수 무한 증가 → 부적합. 신설 메서드 `repo.upsertFromSync(input)`:
|
||||
|
||||
- id 없음 → INSERT (m006 trigger 가 capture revision 자동 생성)
|
||||
- id 있음 + raw_text 동일 → metadata 갱신 path
|
||||
- source.updatedAt > local.updatedAt 인 경우만 ai_title/ai_summary/tags/status/dueDate 갱신
|
||||
- tags 변경 시 `rebuildFtsTagsForNote` 호출 (Cut D single write path)
|
||||
- 동등/older 면 skip
|
||||
- id 있음 + raw_text 다름 → 옵션 분기:
|
||||
- source.updatedAt > local.updatedAt → `updateRawText(id, sourceRawText, sourceUpdatedAt)` (Cut C single write path) → 새 user revision INSERT, latest = source
|
||||
- local.updatedAt > source.updatedAt → skip (다음 push 가 source 갱신할 것)
|
||||
- 동일 timestamp + 다른 raw_text → SyncService 가 conflict 마킹 (rebase 단계 git markdown conflict 가 먼저 잡힘 — 본 분기는 rare)
|
||||
|
||||
**revision edited_by**: 'sync' enum 추가 안 함 — `updateRawText` 의 default 'user' 그대로 활용 (sync = user-edited 변경 전파 = 의미상 user). YAGNI: m008 회피.
|
||||
|
||||
### 3-3. GitClient 확장
|
||||
|
||||
@@ -193,7 +214,7 @@ settings: `sync_auto_enabled: boolean` (default true 단, configured 일 때만)
|
||||
| Conflict UI | 3 choice 별 sync 동작 |
|
||||
| 자동 주기 sync | timer + interval=true mode |
|
||||
|
||||
**목표**: 단위 528 → 약 555 (+27), typecheck 0.
|
||||
**목표**: 단위 608 → 약 635 (+27), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -12,7 +12,7 @@
|
||||
|
||||
## 1. Cut 정체성
|
||||
|
||||
Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
|
||||
Ollama vision 모델 (gemma family — gemma3 / gemma4 default capable) 활용 — 이미지 + raw_text 결합 prompt 또는 이미지 단독 분석 → title/summary/tags 자동 생성. F22 prerequisite (Cut A) 이미 완료.
|
||||
|
||||
---
|
||||
|
||||
@@ -20,7 +20,7 @@ Ollama vision 모델 (gemma3 family default) 활용 — 이미지 + raw_text 결
|
||||
|
||||
| 항목 | 결정 |
|
||||
|---|---|
|
||||
| **F24 default 모델** | gemma3 family (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
|
||||
| **F24 default 모델** | gemma family — gemma3 / gemma4 둘 다 vision-capable hint (한국어 + 이미지 둘 다 강함, 본인 메모 `gemma4:e4b` 텍스트 모델과 같은 가족) |
|
||||
| **prompt 모드** | 단일 vision 모델 호출 (vision 모델이 텍스트도 처리). 모델 capability 부족 시 2단계 fallback (자동) |
|
||||
| **capability detection** | app launch 시 1회 + 설정 페이지 manual refresh 버튼 |
|
||||
| **F23 OFF 시 자동 OFF** | `ai_enabled=false` → vision 도 자동 OFF (자명) |
|
||||
@@ -56,36 +56,59 @@ function isVisionCapable(model: { name: string; details?: { family?: string; fam
|
||||
}
|
||||
```
|
||||
|
||||
### 3-2. Settings storage
|
||||
### 3-2. Settings storage (실제 SettingsService API)
|
||||
|
||||
zod schema 확장 (기존 ai_enabled / sync_* 와 동일 strict 패턴):
|
||||
|
||||
```ts
|
||||
interface SettingsSchema {
|
||||
// ... 기존
|
||||
vision_model?: string; // 사용자 명시 모델 (빈 값 = 비활성)
|
||||
vision_capable_cache?: string[]; // launch 시 detected 결과 cache
|
||||
vision_cache_at?: string; // ISO timestamp
|
||||
}
|
||||
const SettingsSchema = z.object({
|
||||
// ... 기존 ollama / ai_enabled / onboarding_completed / sync_*
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
}).strict();
|
||||
```
|
||||
|
||||
신규 SettingsService 메서드 (개별 setter/getter — `get/set` 일반화 X):
|
||||
|
||||
```ts
|
||||
async getVisionModel(): Promise<string | null>;
|
||||
async setVisionModel(value: string | null): Promise<void>;
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }>;
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void>;
|
||||
```
|
||||
|
||||
### 3-3. AppLaunchDetect
|
||||
|
||||
```ts
|
||||
// src/main/index.ts whenReady 안 (settings 초기화 후)
|
||||
async function refreshVisionCache(): Promise<void> {
|
||||
if (!settingsService.get('ai_enabled', true)) return;
|
||||
try {
|
||||
const tags = await fetch(`${endpoint}/api/tags`).then(r => r.json());
|
||||
const capable = tags.models.filter(isVisionCapable).map((m: any) => m.name);
|
||||
settingsService.set('vision_capable_cache', capable);
|
||||
settingsService.set('vision_cache_at', new Date().toISOString());
|
||||
} catch {
|
||||
// network fail — silent, cache 유지
|
||||
}
|
||||
}
|
||||
`src/main/services/VisionDetect.ts` 신규 — pure 함수 + 외부 fetch 주입 (테스트 가능):
|
||||
|
||||
void refreshVisionCache();
|
||||
```ts
|
||||
export async function refreshVisionCache(deps: {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: Array<{ name: string; details?: { family?: string; families?: string[] } }> };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = await r.json();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
await deps.settings.setVisionCapableCache(capable, deps.now ? deps.now() : new Date());
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
```
|
||||
|
||||
main process `whenReady` 안에서 fire-and-forget 호출. 실패 silent (cache 유지). settings:refresh-vision-cache IPC 가 동일 함수 호출 (manual "다시 감지" 버튼).
|
||||
|
||||
### 3-4. 설정 페이지 UI (AI 제공자 섹션 확장)
|
||||
|
||||
```
|
||||
@@ -166,44 +189,53 @@ ${text || '(이미지만 있음)'}
|
||||
|
||||
---
|
||||
|
||||
## 5. AiWorker 통합
|
||||
## 5. AiWorker 통합 (실제 API 정정)
|
||||
|
||||
CaptureService 가 capture 시 image 첨부했으면 → notes.media 에 저장 + pending_jobs INSERT. AiWorker 가 job 처리 시:
|
||||
기존 `AiWorker.processJob` 이 `repo.findById(noteId)` 로 hydrate 된 `Note` 받음 — `note.media` 가 이미 join 결과로 채워져 있어 별도 `listMediaByNote` 호출 불필요. `MediaStore.absolutePath(relPath)` 로 디스크 path 추출.
|
||||
|
||||
```ts
|
||||
// src/main/ai/AiWorker.ts
|
||||
async processJob(noteId: string): Promise<void> {
|
||||
const note = this.repo.getById(noteId);
|
||||
const media = this.repo.listMediaByNote(noteId);
|
||||
const visionModel = this.settings.get('vision_model');
|
||||
// src/main/ai/AiWorker.ts processJob 흐름
|
||||
const note = this.repo.findById(job.noteId);
|
||||
if (!note || ...) return;
|
||||
const visionModel = await this.settings.getVisionModel();
|
||||
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && media.length > 0) {
|
||||
images = await Promise.all(media.map(async (m) => ({
|
||||
base64: (await fs.readFile(this.mediaStore.absolutePath(m.relPath))).toString('base64'),
|
||||
mime: m.mime
|
||||
})));
|
||||
}
|
||||
|
||||
const provider = this.providerHolder.get();
|
||||
const response = await provider.generate({ text: note.rawText, images, ... }, { visionModel });
|
||||
// ... 기존 결과 적용
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0) {
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore.absolutePath(m.relPath));
|
||||
// 이미지당 5MB cap (base64 메모리 폭주 방지)
|
||||
if (buf.byteLength > 5 * 1024 * 1024) {
|
||||
throw new Error(`image ${m.relPath} exceeds 5MB cap`);
|
||||
}
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
images,
|
||||
todayKst,
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
}, { visionModel });
|
||||
```
|
||||
|
||||
`media.length > 0 && visionModel` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only.
|
||||
`visionModel && note.media.length > 0` 둘 다 true 일 때만 vision path. 그 외는 기존 text-only path 유지 (호환 보존). image 5MB cap 초과 시 throw → 기존 AiWorker 의 attempts 카운트 + ai_status='failed' 분기 활용.
|
||||
|
||||
AiWorker 의 `settings: SettingsService` 의존성 추가 — 기존 생성자에 신규 파라미터.
|
||||
|
||||
---
|
||||
|
||||
## 6. 이미지만 있는 capture
|
||||
## 6. 이미지만 있는 capture (정정 — 신규 enum 도입 X)
|
||||
|
||||
`raw_text` 빈 값 + media 첨부만:
|
||||
`raw_text` 빈 값 + media 첨부만 케이스:
|
||||
|
||||
- 기존 동작: notes INSERT (raw_text=''), AiWorker 가 빈 prompt 로 호출 → ai_status='failed' 또는 무의미 응답
|
||||
- vision enabled: AiWorker 가 vision prompt + images → 의미 있는 title/summary/tags 응답
|
||||
- vision disabled (visionModel 빈 값): notes 저장만, ai_status='disabled' 신규 enum 활용 (Cut B 의 ai_enabled false 와 비슷한 의미 — 그러나 부분 disable, 즉 "이미지 only 라 처리 불가" 상태)
|
||||
- **vision enabled** (`visionModel` 설정 + media 있음): AiWorker 의 vision path → 의미 있는 title/summary/tags 응답
|
||||
- **vision disabled** (`visionModel` null): 기존 text-only 흐름 그대로 — 빈 prompt → AI 응답이 무의미하면 ai_status='failed' 분기 (재시도 가능). dogfood 시 빈도 측정 후 'skipped' enum 도입 여부 재평가.
|
||||
|
||||
추천: vision disabled + image-only capture 시 `ai_status='skipped'` 신규 enum (Cut B 의 'disabled' 와 다름). title fallback = "(이미지 N개)" 또는 첫 이미지 파일명.
|
||||
**'skipped' 신규 enum 미도입 (YAGNI)**: m008 마이그레이션 (CHECK relax via table recreate) 부담 + 이미지-only capture 가 본 cut 의 main use case 가 아님. 사용자가 vision 활성 후 retry 하거나 raw_text 추가 후 reprocess 하는 우회로 충분. 정책 검토는 dogfood 후 별도 cut.
|
||||
|
||||
---
|
||||
|
||||
@@ -219,7 +251,7 @@ async processJob(noteId: string): Promise<void> {
|
||||
| `AiWorker.processJob` vision integration | media + visionModel 있을 때만 base64 변환 |
|
||||
| 이미지 only capture | raw_text='' + media → vision 결과 정상 또는 'skipped' 분기 |
|
||||
|
||||
**목표**: 단위 555 → 약 575 (+20), typecheck 0.
|
||||
**목표**: 단위 679 → 약 701 (+22, isVisionCapable 5 + refreshVisionCache 4 + SettingsService vision 4 + LocalOllamaProvider vision path 3 + buildVisionPrompt 2 + AiWorker vision integration 3 + UI dropdown 1), typecheck 0.
|
||||
|
||||
---
|
||||
|
||||
@@ -231,7 +263,7 @@ async processJob(noteId: string): Promise<void> {
|
||||
| 이미지 base64 메모리 부담 | media 1개당 평균 < 1MB. 다중 이미지 시 N×base64 = 메모리 N배. cap (이미지당 max size 5MB) 적용 |
|
||||
| capability detection 실패 시 fallback | cache 부재 → vision dropdown 비어있음 표시 + "다시 감지" 안내 |
|
||||
| vision 모델 한국어 정확도 | dogfood 검증. gemma3 가 한국어 약하면 다른 family 추천 갱신 (메모리 정책 갱신) |
|
||||
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | 자동 2단계 fallback — vision 모델로 caption 추출 → 텍스트 모델로 종합 (capability 부족 시) |
|
||||
| Ollama 가 vision images 필드 무시 (모델이 multimodal 미지원) | **본 cut 미구현 (YAGNI)** — 자동 2단계 fallback (caption 추출 → 텍스트 모델 종합) 은 v0.3.2+ 검토. dogfood 시 capability detection 정확도 우선 |
|
||||
|
||||
---
|
||||
|
||||
|
||||
6
package-lock.json
generated
6
package-lock.json
generated
@@ -1,12 +1,12 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.11",
|
||||
"version": "0.3.1",
|
||||
"lockfileVersion": 3,
|
||||
"requires": true,
|
||||
"packages": {
|
||||
"": {
|
||||
"name": "inkling",
|
||||
"version": "0.2.11",
|
||||
"version": "0.3.1",
|
||||
"dependencies": {
|
||||
"better-sqlite3": "12.9.0",
|
||||
"electron-log": "5.2.0",
|
||||
@@ -3232,7 +3232,7 @@
|
||||
}
|
||||
},
|
||||
"node_modules/@tokenizer/token": {
|
||||
"version": "0.3.0",
|
||||
"version": "0.3.1",
|
||||
"resolved": "https://registry.npmjs.org/@tokenizer/token/-/token-0.3.0.tgz",
|
||||
"integrity": "sha512-OvjF+z51L3ov0OyAU0duzsYuvO01PH7x4t6DJx+guahgTnBHkhJdG7soQeTSFLWN3efnHyibZ4Z8l2EuWwJN3A==",
|
||||
"dev": true,
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
{
|
||||
"name": "inkling",
|
||||
"version": "0.2.11",
|
||||
"version": "0.3.1",
|
||||
"private": true,
|
||||
"description": "Inkling — local-first 한 줄 보관 도구",
|
||||
"author": "altair823 <dlsrks0734@gmail.com>",
|
||||
|
||||
@@ -1,6 +1,9 @@
|
||||
import { readFile } from 'node:fs/promises';
|
||||
import type { NoteRepository } from '../repository/NoteRepository.js';
|
||||
import type { Note } from '@shared/types';
|
||||
import type { AiFailedReason } from '../services/telemetryEvents.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { MediaStore } from '../services/MediaStore.js';
|
||||
import { ProviderHolder } from './ProviderHolder.js';
|
||||
import { parseAllCandidates } from '../services/dueDateParser.js';
|
||||
import { ZodError } from 'zod';
|
||||
@@ -41,6 +44,10 @@ export interface AiWorkerOptions {
|
||||
};
|
||||
now?: () => Date;
|
||||
telemetry?: AiTelemetryEmitter;
|
||||
/** v0.3.1 Cut F — vision 지원. 미전달 시 vision 비활성. */
|
||||
settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||
/** v0.3.1 Cut F — 첨부 이미지 절대경로 변환. settings 와 함께 전달 시 vision 활성. */
|
||||
mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||
}
|
||||
|
||||
interface Job { noteId: string; attempts: number; }
|
||||
@@ -56,6 +63,8 @@ export class AiWorker {
|
||||
private logger: NonNullable<AiWorkerOptions['logger']>;
|
||||
private now: () => Date;
|
||||
private telemetry?: AiTelemetryEmitter;
|
||||
private settings?: Pick<SettingsService, 'getVisionModel'>;
|
||||
private mediaStore?: Pick<MediaStore, 'absolutePath'>;
|
||||
|
||||
constructor(
|
||||
private repo: NoteRepository,
|
||||
@@ -68,6 +77,8 @@ export class AiWorker {
|
||||
this.logger = opts.logger ?? { info: () => {}, warn: () => {}, error: () => {} };
|
||||
this.now = opts.now ?? (() => new Date());
|
||||
this.telemetry = opts.telemetry;
|
||||
this.settings = opts.settings;
|
||||
this.mediaStore = opts.mediaStore;
|
||||
}
|
||||
|
||||
async enqueue(noteId: string): Promise<void> {
|
||||
@@ -128,12 +139,27 @@ export class AiWorker {
|
||||
const todayIso = kstTodayIso(nowDate);
|
||||
const candidates = parseAllCandidates(note.rawText, todayDate);
|
||||
const vocab = this.repo.getTopUsedTags(VOCAB_TOP_N);
|
||||
const res = await this.holder.get().generate({
|
||||
text: note.rawText,
|
||||
todayKst: todayIso,
|
||||
dueDateCandidates: candidates,
|
||||
vocab
|
||||
});
|
||||
// v0.3.1 Cut F — vision path: visionModel + note.media → base64 images
|
||||
// final review fix: note.media[].bytes 로 fast-fail (readFile/base64 비용 회피).
|
||||
// 5MB cap 초과 시 throw → AiWorker 의 'other' 분기 → markAiFailed 도달.
|
||||
const visionModel = this.settings ? await this.settings.getVisionModel() : null;
|
||||
let images: Array<{ base64: string; mime: string }> | undefined;
|
||||
if (visionModel && note.media.length > 0 && this.mediaStore) {
|
||||
const oversize = note.media.find((m) => m.bytes > 5 * 1024 * 1024);
|
||||
if (oversize) {
|
||||
throw new Error(`image ${oversize.relPath} exceeds 5MB cap (${oversize.bytes} bytes)`);
|
||||
}
|
||||
images = await Promise.all(
|
||||
note.media.map(async (m) => {
|
||||
const buf = await readFile(this.mediaStore!.absolutePath(m.relPath));
|
||||
return { base64: buf.toString('base64'), mime: m.mime };
|
||||
})
|
||||
);
|
||||
}
|
||||
const res = await this.holder.get().generate(
|
||||
{ text: note.rawText, images, todayKst: todayIso, dueDateCandidates: candidates, vocab },
|
||||
{ visionModel: visionModel ?? undefined }
|
||||
);
|
||||
// AI primary: AI's dueDate is final (no rule merge)
|
||||
this.repo.updateAiResult(job.noteId, {
|
||||
title: res.title,
|
||||
|
||||
@@ -6,13 +6,20 @@ export interface GenerateInput {
|
||||
todayKst: string; // ISO YYYY-MM-DD in KST
|
||||
dueDateCandidates: ParseResult[];
|
||||
vocab?: string[]; // v0.2.3 #3 — top-N kebab-case 태그. 미전달 시 빈 배열로 처리.
|
||||
// v0.3.1 Cut F — 첨부 이미지. 미전달 시 텍스트 전용 처리.
|
||||
images?: Array<{ base64: string; mime: string }>;
|
||||
}
|
||||
|
||||
export interface GenerateOptions {
|
||||
/** v0.3.1 Cut F — vision 전용 model 지정. null/미전달 시 기본 model 사용. */
|
||||
visionModel?: string | null;
|
||||
}
|
||||
|
||||
export interface HealthResult { ok: boolean; model?: string; reason?: string; }
|
||||
|
||||
export interface InferenceProvider {
|
||||
readonly name: string;
|
||||
generate(input: GenerateInput): Promise<AiResponse>;
|
||||
generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse>;
|
||||
healthCheck(): Promise<HealthResult>;
|
||||
/** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */
|
||||
abort?: () => void;
|
||||
|
||||
@@ -1,7 +1,8 @@
|
||||
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 { buildVisionPrompt } from './visionPrompt.js';
|
||||
import type { GenerateInput, GenerateOptions, HealthResult, InferenceProvider } from './InferenceProvider.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../shared/constants.js';
|
||||
|
||||
export interface LocalOllamaOptions {
|
||||
@@ -30,29 +31,39 @@ export class LocalOllamaProvider implements InferenceProvider {
|
||||
this.name = `local-ollama/${this.model}`;
|
||||
}
|
||||
|
||||
async generate(input: GenerateInput): Promise<AiResponse> {
|
||||
async generate(input: GenerateInput, opts?: GenerateOptions): Promise<AiResponse> {
|
||||
const useVision = !!opts?.visionModel && (input.images?.length ?? 0) > 0;
|
||||
const model = useVision ? opts!.visionModel! : this.model;
|
||||
const prompt = useVision
|
||||
? buildVisionPrompt(input.text, input.todayKst, input.dueDateCandidates.map((c) => c.iso ?? c.matchedToken ?? ''), input.vocab ?? [])
|
||||
: buildPrompt(input.text, input.todayKst, input.dueDateCandidates, input.vocab ?? []);
|
||||
|
||||
this.abortController = new AbortController();
|
||||
const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs);
|
||||
try {
|
||||
const body: Record<string, unknown> = {
|
||||
model,
|
||||
prompt,
|
||||
format: 'json',
|
||||
stream: false,
|
||||
options: { temperature: this.temperature, num_predict: this.numPredict }
|
||||
};
|
||||
if (useVision) {
|
||||
body.images = input.images!.map((i) => i.base64);
|
||||
}
|
||||
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 }
|
||||
}),
|
||||
body: JSON.stringify(body),
|
||||
signal: this.abortController.signal
|
||||
});
|
||||
if (res.statusCode < 200 || res.statusCode >= 300) {
|
||||
throw new Error(`ollama http ${res.statusCode}`);
|
||||
}
|
||||
const body = (await res.body.json()) as { response?: string };
|
||||
if (!body.response) throw new Error('missing response field');
|
||||
const responseBody = (await res.body.json()) as { response?: string };
|
||||
if (!responseBody.response) throw new Error('missing response field');
|
||||
let parsed: unknown;
|
||||
try { parsed = JSON.parse(body.response); }
|
||||
try { parsed = JSON.parse(responseBody.response); }
|
||||
catch (err) { throw new Error(`invalid json in response: ${String(err)}`); }
|
||||
return parseAiResponse(parsed);
|
||||
} finally {
|
||||
|
||||
17
src/main/ai/visionPrompt.ts
Normal file
17
src/main/ai/visionPrompt.ts
Normal file
@@ -0,0 +1,17 @@
|
||||
export function buildVisionPrompt(
|
||||
text: string,
|
||||
todayKst: string,
|
||||
dueCandidates: string[],
|
||||
vocab: string[]
|
||||
): string {
|
||||
return `다음 메모와 첨부 이미지를 종합 분석해 한국어로 요약하세요.
|
||||
|
||||
메모 본문 (비어 있을 수 있음):
|
||||
${text || '(이미지만 있음)'}
|
||||
|
||||
이미지 분석 시 주요 시각적 정보 (텍스트, 사람, 장면) 도 포함해 요약하세요.
|
||||
출력 JSON: { "title": "...", "summary": "...", "tags": [...], "due_date": "..." }
|
||||
오늘: ${todayKst}
|
||||
가능한 due 후보: ${dueCandidates.join(', ')}
|
||||
빈출 태그: ${vocab.slice(0, 20).join(', ')}`;
|
||||
}
|
||||
@@ -17,6 +17,7 @@ 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 { refreshVisionCache } from './services/VisionDetect.js';
|
||||
import { registerCaptureApi } from './ipc/captureApi.js';
|
||||
import { registerInboxApi, pushNoteUpdated, pushOllamaStatus } from './ipc/inboxApi.js';
|
||||
import { registerSettingsApi, navigateInbox } from './ipc/settingsApi.js';
|
||||
@@ -30,6 +31,7 @@ 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 { SyncTimer } from './services/SyncTimer.js';
|
||||
import { TelemetryService } from './services/TelemetryService.js';
|
||||
import { SettingsService } from './services/SettingsService.js';
|
||||
import { collectAutostartState } from './services/AutostartDiagnostic.js';
|
||||
@@ -121,6 +123,11 @@ app.whenReady().then(async () => {
|
||||
|
||||
const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel });
|
||||
const providerHolder = new ProviderHolder(provider);
|
||||
|
||||
// v0.3.1 Cut F — app launch 시 vision capability cache 갱신 (fire-and-forget).
|
||||
// 실패 silent (cache 유지). 사용자가 설정 페이지에서 "다시 감지" manual trigger 가능.
|
||||
void refreshVisionCache({ settings: settingsSvc, endpoint: resolvedEndpoint }).catch(() => {});
|
||||
|
||||
const health = new HealthChecker(providerHolder, {
|
||||
// v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향).
|
||||
isAiEnabled: () => settingsSvc.isAiEnabled(),
|
||||
@@ -148,7 +155,10 @@ app.whenReady().then(async () => {
|
||||
refreshTray({ todayCount: repo.countToday() });
|
||||
},
|
||||
logger,
|
||||
telemetry
|
||||
telemetry,
|
||||
// v0.3.1 Cut F — vision 지원
|
||||
settings: settingsSvc,
|
||||
mediaStore: store
|
||||
});
|
||||
|
||||
const notify = new NotificationService({
|
||||
@@ -196,7 +206,8 @@ app.whenReady().then(async () => {
|
||||
|
||||
const exportSvc = new ExportService(repo, store);
|
||||
const importSvc = new ImportService(repo, store);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc);
|
||||
const syncSvc = new SyncService(paths.profileDir, exportSvc, importSvc);
|
||||
const syncTimer = new SyncTimer(syncSvc, settingsSvc);
|
||||
|
||||
const backup = new BackupService(db, join(paths.profileDir, 'backups'));
|
||||
void backup.runDaily()
|
||||
@@ -206,14 +217,18 @@ app.whenReady().then(async () => {
|
||||
// v0.2.7 Task 10 — 설정 페이지 IPC (autostart + backup/export/import/sync/telemetry).
|
||||
// backup / exportSvc / importSvc / syncSvc / telemetry 가 모두 준비된 뒤 등록.
|
||||
registerSettingsApi({
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow
|
||||
backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow,
|
||||
syncTimer
|
||||
});
|
||||
|
||||
void syncTimer.start();
|
||||
|
||||
let backupOnQuitDone = false;
|
||||
let trayInterval: NodeJS.Timeout | null = null;
|
||||
app.on('before-quit', (e) => {
|
||||
// 모든 cleanup 한 곳에 통합 — sync (idempotent) → async backup chain.
|
||||
health.stop();
|
||||
syncTimer.stop();
|
||||
if (trayInterval !== null) {
|
||||
clearInterval(trayInterval);
|
||||
trayInterval = null;
|
||||
|
||||
@@ -7,10 +7,14 @@ import type { BackupService } from '../services/BackupService.js';
|
||||
import type { ExportService } from '../services/ExportService.js';
|
||||
import type { ImportService } from '../services/ImportService.js';
|
||||
import type { SyncService } from '../services/SyncService.js';
|
||||
import { GitClient } from '../services/GitClient.js';
|
||||
import type { TelemetryService } from '../services/TelemetryService.js';
|
||||
import type { SettingsService } from '../services/SettingsService.js';
|
||||
import type { SyncTimer } from '../services/SyncTimer.js';
|
||||
import { collectAutostartState } from '../services/AutostartDiagnostic.js';
|
||||
import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js';
|
||||
import { refreshVisionCache } from '../services/VisionDetect.js';
|
||||
import { DEFAULT_OLLAMA_ENDPOINT } from '../../shared/constants.js';
|
||||
|
||||
/**
|
||||
* 외부 (트레이 / second-instance / 기타 main 프로세스 호출자) 에서 inbox 창에 view 전환을
|
||||
@@ -36,6 +40,7 @@ export interface SettingsIpcDeps {
|
||||
telemetry: TelemetryService;
|
||||
settings: SettingsService;
|
||||
getInboxWindow: () => BrowserWindow | null;
|
||||
syncTimer?: SyncTimer;
|
||||
}
|
||||
|
||||
/**
|
||||
@@ -106,6 +111,22 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-auto-enabled', async (_e, value: boolean) => {
|
||||
await deps.settings.setAutoSyncEnabled(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-sync-interval-min', async (_e, value: number) => {
|
||||
try {
|
||||
await deps.settings.setSyncIntervalMin(value);
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: (e as Error).message };
|
||||
}
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:run-backup', async () => {
|
||||
try {
|
||||
const r = await backup.runDaily();
|
||||
@@ -239,7 +260,7 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
return { ok: true } as const;
|
||||
}
|
||||
if (r.changed) {
|
||||
logger.info('sync.done', { sha: r.sha, pushed: r.pushed });
|
||||
logger.info('sync.done', { sha: r.localSha, pushed: r.pushed });
|
||||
new Notification({ title: 'Inkling', body: '동기화 완료', silent: true }).show();
|
||||
} else {
|
||||
new Notification({ title: 'Inkling', body: '변경 사항 없음', silent: true }).show();
|
||||
@@ -281,4 +302,107 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void {
|
||||
}
|
||||
return { ok: true } as const;
|
||||
});
|
||||
|
||||
// v0.3.0 Cut E — sync IPC.
|
||||
|
||||
// settings:configure-sync — URL 저장 + git init + remote add (없으면).
|
||||
// null URL → 저장만 (init 안 함). 빈 문자열도 null 처리.
|
||||
ipcMain.handle('settings:configure-sync', async (_e, url: string | null) => {
|
||||
const trimmed = typeof url === 'string' ? url.trim() : '';
|
||||
const finalUrl = trimmed.length === 0 ? null : trimmed;
|
||||
|
||||
try {
|
||||
await deps.settings.setSyncRepoUrl(finalUrl);
|
||||
} catch (e) {
|
||||
return { ok: false as const, reason: `persist failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
if (finalUrl === null) {
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
}
|
||||
|
||||
// git init + remote add origin
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
|
||||
if (!(await git.isRepo())) {
|
||||
const init = await git.run(['init']);
|
||||
if (init.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `git init failed: ${init.stderr}` };
|
||||
}
|
||||
}
|
||||
if (!(await git.hasRemote())) {
|
||||
const add = await git.run(['remote', 'add', 'origin', finalUrl]);
|
||||
if (add.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote add failed: ${add.stderr}` };
|
||||
}
|
||||
} else {
|
||||
// remote exists — update URL
|
||||
const set = await git.run(['remote', 'set-url', 'origin', finalUrl]);
|
||||
if (set.exitCode !== 0) {
|
||||
return { ok: false as const, reason: `remote set-url failed: ${set.stderr}` };
|
||||
}
|
||||
}
|
||||
await deps.syncTimer?.reconfigure();
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// settings:test-sync-connection — git ls-remote 결과
|
||||
ipcMain.handle('settings:test-sync-connection', async () => {
|
||||
const syncDir = deps.syncSvc.getSyncDir();
|
||||
const git = new GitClient(syncDir);
|
||||
if (!(await git.isRepo())) return { ok: false as const, reason: 'not_initialized' };
|
||||
const r = await git.run(['ls-remote', 'origin']);
|
||||
if (r.exitCode !== 0) return { ok: false as const, reason: r.stderr || 'connection failed' };
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
// sync:list-conflicts — SyncService 캐시 결과
|
||||
ipcMain.handle('sync:list-conflicts', () => deps.syncSvc.listConflicts());
|
||||
|
||||
// sync:resolve-conflict — local/remote 2 choice. path = git index conflict 경로.
|
||||
ipcMain.handle('sync:resolve-conflict', async (_e, path: string, choice: 'local' | 'remote') => {
|
||||
if (choice !== 'local' && choice !== 'remote') {
|
||||
return { ok: false as const, reason: 'invalid choice' };
|
||||
}
|
||||
return deps.syncSvc.resolveConflict(path, choice);
|
||||
});
|
||||
|
||||
// sync:get-status — lastAt + lastResult + nextAt 계산
|
||||
ipcMain.handle('sync:get-status', async () => {
|
||||
const last = deps.syncSvc.getLastStatus();
|
||||
let nextAt: string | null = null;
|
||||
if (await deps.settings.isAutoSyncEnabled()) {
|
||||
const intervalMin = await deps.settings.getSyncIntervalMin();
|
||||
const baseMs = last.lastAt ? new Date(last.lastAt).getTime() : Date.now();
|
||||
nextAt = new Date(baseMs + intervalMin * 60 * 1000).toISOString();
|
||||
}
|
||||
return { lastAt: last.lastAt, lastResult: last.lastResult, nextAt };
|
||||
});
|
||||
|
||||
// v0.3.1 Cut F — vision IPC
|
||||
|
||||
ipcMain.handle('settings:get-vision-models', async () => {
|
||||
const cache = await deps.settings.getVisionCapableCache();
|
||||
const selected = await deps.settings.getVisionModel();
|
||||
return { models: cache.models, at: cache.at, selected };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:set-vision-model', async (_e, value: string | null) => {
|
||||
const sanitized = typeof value === 'string' && value.trim().length > 0 ? value.trim() : null;
|
||||
await deps.settings.setVisionModel(sanitized);
|
||||
return { ok: true as const };
|
||||
});
|
||||
|
||||
ipcMain.handle('settings:refresh-vision-cache', async () => {
|
||||
// Cut F final review fix — index.ts 의 resolvedEndpoint (settings → env → default)
|
||||
// 와 동일한 fallback 체인 사용. settings.ollama 미설정 + env / default 만 있는 dev
|
||||
// 환경에서도 manual "다시 감지" 가 동작하도록.
|
||||
const all = await deps.settings.getAll();
|
||||
const endpoint = all.ollama?.endpoint
|
||||
?? process.env.INKLING_OLLAMA_ENDPOINT
|
||||
?? DEFAULT_OLLAMA_ENDPOINT;
|
||||
return refreshVisionCache({ settings: deps.settings, endpoint });
|
||||
});
|
||||
}
|
||||
|
||||
@@ -51,6 +51,29 @@ export interface ImportNoteResult {
|
||||
status: ImportNoteStatus;
|
||||
}
|
||||
|
||||
export interface UpsertFromSyncInput {
|
||||
id: string;
|
||||
rawText: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
aiTitle: string | null;
|
||||
aiSummary: string | null;
|
||||
titleEditedByUser: boolean;
|
||||
summaryEditedByUser: boolean;
|
||||
aiProvider: string | null;
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
tags: { name: string; source: 'ai' | 'user' }[];
|
||||
status: NoteStatus;
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
}
|
||||
|
||||
export type UpsertFromSyncStatus = 'inserted' | 'updated' | 'skipped';
|
||||
|
||||
const KEBAB_CASE_RE = /^[a-z0-9-]+$/;
|
||||
|
||||
export class NoteRepository {
|
||||
@@ -863,6 +886,143 @@ export class NoteRepository {
|
||||
return { id: finalId, status };
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 전용 upsert. 기존 importNote 의 fork-on-id-collision 정책은
|
||||
* sync 에 부적합 (양 기기 raw_text 가 다를 때마다 fork → 노트 갯수 무한 증가).
|
||||
*
|
||||
* 3 분기:
|
||||
* - id 없음 → INSERT (capture revision + tags FTS sync)
|
||||
* - id 있음 + raw_text 동일 → source.updatedAt 가 더 최신일 때만 metadata 갱신
|
||||
* - id 있음 + raw_text 다름 → source 가 더 최신이면 updateRawText (new user revision),
|
||||
* local 이 더 최신이면 skip
|
||||
*
|
||||
* tags 변경 시 rebuildFtsTagsForNote 호출 — Cut D single write path 재사용.
|
||||
* raw_text 변경 시 updateRawText 호출 — Cut C single write path 재사용.
|
||||
*/
|
||||
upsertFromSync(input: UpsertFromSyncInput): { id: string; status: UpsertFromSyncStatus } {
|
||||
const existing = this.db
|
||||
.prepare(`SELECT raw_text, updated_at, status FROM notes WHERE id=?`)
|
||||
.get(input.id) as { raw_text: string; updated_at: string; status: NoteStatus } | undefined;
|
||||
|
||||
if (!existing) {
|
||||
// INSERT path
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO notes
|
||||
(id, raw_text, ai_title, ai_summary, ai_status, ai_provider, ai_generated_at,
|
||||
title_edited_by_user, summary_edited_by_user,
|
||||
user_intent, intent_prompted_at,
|
||||
created_at, updated_at,
|
||||
due_date, due_date_edited_by_user,
|
||||
status, status_changed_at, move_reason)
|
||||
VALUES (?, ?, ?, ?, 'done', ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`
|
||||
)
|
||||
.run(
|
||||
input.id,
|
||||
input.rawText,
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.titleEditedByUser ? 1 : 0,
|
||||
input.summaryEditedByUser ? 1 : 0,
|
||||
input.userIntent,
|
||||
input.intentPromptedAt,
|
||||
input.createdAt,
|
||||
input.updatedAt,
|
||||
input.dueDate,
|
||||
input.dueDateEditedByUser ? 1 : 0,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason
|
||||
);
|
||||
this.db
|
||||
.prepare(
|
||||
`INSERT INTO note_revisions (note_id, raw_text, edited_at, edited_by)
|
||||
VALUES (?, ?, ?, 'capture')`
|
||||
)
|
||||
.run(input.id, input.rawText, input.createdAt);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
}
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'inserted' };
|
||||
}
|
||||
|
||||
if (input.updatedAt <= existing.updated_at) {
|
||||
return { id: input.id, status: 'skipped' };
|
||||
}
|
||||
|
||||
if (existing.raw_text !== input.rawText) {
|
||||
this.updateRawText(input.id, input.rawText, new Date(input.updatedAt));
|
||||
}
|
||||
|
||||
const tx = this.db.transaction(() => {
|
||||
this.db
|
||||
.prepare(
|
||||
`UPDATE notes
|
||||
SET ai_title = CASE WHEN title_edited_by_user = 1 THEN ai_title ELSE ? END,
|
||||
ai_summary = CASE WHEN summary_edited_by_user = 1 THEN ai_summary ELSE ? END,
|
||||
ai_provider = ?,
|
||||
ai_generated_at = ?,
|
||||
due_date = CASE WHEN due_date_edited_by_user = 1 THEN due_date ELSE ? END,
|
||||
status = ?,
|
||||
status_changed_at = ?,
|
||||
move_reason = ?,
|
||||
updated_at = ?
|
||||
WHERE id = ?`
|
||||
)
|
||||
.run(
|
||||
input.aiTitle,
|
||||
input.aiSummary,
|
||||
input.aiProvider,
|
||||
input.aiGeneratedAt,
|
||||
input.dueDate,
|
||||
input.status,
|
||||
input.statusChangedAt,
|
||||
input.moveReason,
|
||||
input.updatedAt,
|
||||
input.id
|
||||
);
|
||||
this.db.prepare(`DELETE FROM note_tags WHERE note_id=?`).run(input.id);
|
||||
if (input.tags.length > 0) {
|
||||
const getOrInsertTag = this.db.prepare(
|
||||
`INSERT INTO tags(name) VALUES(?) ON CONFLICT(name) DO UPDATE SET name=name RETURNING id`
|
||||
);
|
||||
const linkAi = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'ai')`
|
||||
);
|
||||
const linkUser = this.db.prepare(
|
||||
`INSERT OR IGNORE INTO note_tags(note_id, tag_id, source) VALUES(?, ?, 'user')`
|
||||
);
|
||||
for (const t of input.tags) {
|
||||
const row = getOrInsertTag.get(t.name) as { id: number };
|
||||
if (t.source === 'ai') linkAi.run(input.id, row.id);
|
||||
else linkUser.run(input.id, row.id);
|
||||
}
|
||||
}
|
||||
this.rebuildFtsTagsForNote(input.id);
|
||||
});
|
||||
tx();
|
||||
return { id: input.id, status: 'updated' };
|
||||
}
|
||||
|
||||
getPendingCount(): number {
|
||||
const row = this.db
|
||||
.prepare(
|
||||
|
||||
@@ -64,6 +64,11 @@ function noteToExportNote(n: Note): ExportNote {
|
||||
aiGeneratedAt: n.aiGeneratedAt,
|
||||
userIntent: n.userIntent,
|
||||
intentPromptedAt: n.intentPromptedAt,
|
||||
status: n.status,
|
||||
statusChangedAt: n.statusChangedAt,
|
||||
moveReason: n.moveReason,
|
||||
dueDate: n.dueDate,
|
||||
dueDateEditedByUser: n.dueDateEditedByUser,
|
||||
tags: n.tags.map((t) => ({ name: t.name, source: t.source })),
|
||||
media: n.media.map((m, idx) => ({
|
||||
rel: `media/${n.id.slice(0, 8)}__${idx + 1}.${inferExt(m.mime)}`,
|
||||
|
||||
@@ -89,4 +89,33 @@ export class GitClient {
|
||||
if (r.exitCode !== 0) throw new Error(`git rev-parse failed: ${r.stderr}`);
|
||||
return r.stdout.trim();
|
||||
}
|
||||
|
||||
async fetch(remote: string = 'origin'): Promise<GitExecResult> {
|
||||
return this.run(['fetch', remote]);
|
||||
}
|
||||
|
||||
async rebaseOnto(ref: string): Promise<GitExecResult> {
|
||||
return this.run(['rebase', ref]);
|
||||
}
|
||||
|
||||
async rebaseAbort(): Promise<GitExecResult> {
|
||||
return this.run(['rebase', '--abort']);
|
||||
}
|
||||
|
||||
async hasUncommittedChanges(): Promise<boolean> {
|
||||
const r = await this.run(['status', '--porcelain']);
|
||||
return r.stdout.trim().length > 0;
|
||||
}
|
||||
|
||||
/** ref (branch, tag, remote branch) 존재 여부 확인. `git rev-parse --verify`. */
|
||||
async refExists(ref: string): Promise<boolean> {
|
||||
const r = await this.run(['rev-parse', '--verify', ref]);
|
||||
return r.exitCode === 0;
|
||||
}
|
||||
|
||||
/** rebase conflict 시 conflict 마킹된 파일 list. `git diff --name-only --diff-filter=U`. */
|
||||
async listConflicts(): Promise<string[]> {
|
||||
const r = await this.run(['diff', '--name-only', '--diff-filter=U']);
|
||||
return r.stdout.split('\n').map((s) => s.trim()).filter((s) => s.length > 0);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -130,6 +130,37 @@ export class ImportService {
|
||||
};
|
||||
}
|
||||
|
||||
async applySyncFromDir(dir: string): Promise<{ changedCount: number }> {
|
||||
const files = await this.scanNotes(dir);
|
||||
let changedCount = 0;
|
||||
for (const f of files) {
|
||||
const content = await readFile(f, 'utf8');
|
||||
const parsed = parseExportNote(content);
|
||||
const r = this.repo.upsertFromSync({
|
||||
id: parsed.id,
|
||||
rawText: parsed.rawText,
|
||||
createdAt: parsed.createdAt,
|
||||
updatedAt: parsed.updatedAt,
|
||||
aiTitle: parsed.aiTitle,
|
||||
aiSummary: parsed.aiSummary,
|
||||
titleEditedByUser: parsed.titleEditedByUser,
|
||||
summaryEditedByUser: parsed.summaryEditedByUser,
|
||||
aiProvider: parsed.aiProvider,
|
||||
aiGeneratedAt: parsed.aiGeneratedAt,
|
||||
userIntent: parsed.userIntent,
|
||||
intentPromptedAt: parsed.intentPromptedAt,
|
||||
tags: parsed.tags,
|
||||
status: parsed.status,
|
||||
statusChangedAt: parsed.statusChangedAt,
|
||||
moveReason: parsed.moveReason,
|
||||
dueDate: parsed.dueDate,
|
||||
dueDateEditedByUser: parsed.dueDateEditedByUser
|
||||
});
|
||||
if (r.status !== 'skipped') changedCount += 1;
|
||||
}
|
||||
return { changedCount };
|
||||
}
|
||||
|
||||
private async scanNotes(sourceDir: string): Promise<string[]> {
|
||||
const notesDir = join(sourceDir, 'notes');
|
||||
let entries: string[];
|
||||
|
||||
@@ -13,7 +13,15 @@ const SettingsSchema = z.object({
|
||||
// true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 —
|
||||
// load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동.
|
||||
ai_enabled: z.boolean().optional(),
|
||||
onboarding_completed: z.boolean().optional()
|
||||
onboarding_completed: z.boolean().optional(),
|
||||
// v0.3.0 Cut E — 양방향 git sync 설정. 모두 optional — 미구성 시 sync 비활성.
|
||||
sync_repo_url: z.string().nullable().optional(),
|
||||
sync_auto_enabled: z.boolean().optional(),
|
||||
sync_interval_min: z.number().int().min(5).optional(),
|
||||
// v0.3.1 Cut F
|
||||
vision_model: z.string().nullable().optional(),
|
||||
vision_capable_cache: z.array(z.string()).optional(),
|
||||
vision_cache_at: z.string().optional()
|
||||
}).strict();
|
||||
|
||||
export type Settings = z.infer<typeof SettingsSchema>;
|
||||
@@ -81,6 +89,72 @@ export class SettingsService {
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — sync 저장소 URL. null/빈 문자열 = sync 비활성. 본 메서드는 값만 저장,
|
||||
* git init/remote add 는 별도 호출자 (settings:configure-sync IPC) 가 담당.
|
||||
*/
|
||||
async getSyncRepoUrl(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.sync_repo_url ?? null;
|
||||
}
|
||||
|
||||
async setSyncRepoUrl(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_repo_url: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync 활성. configured 일 때만 의미 있음. 기본 true. */
|
||||
async isAutoSyncEnabled(): Promise<boolean> {
|
||||
const s = await this.load();
|
||||
return s.sync_auto_enabled ?? true;
|
||||
}
|
||||
|
||||
async setAutoSyncEnabled(value: boolean): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_auto_enabled: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.0 Cut E — 자동 주기 sync interval (분). 기본 30, min 5. */
|
||||
async getSyncIntervalMin(): Promise<number> {
|
||||
const s = await this.load();
|
||||
return s.sync_interval_min ?? 30;
|
||||
}
|
||||
|
||||
async setSyncIntervalMin(value: number): Promise<void> {
|
||||
if (!Number.isInteger(value) || value < 5) {
|
||||
throw new Error(`sync_interval_min must be an integer >= 5 (got ${value})`);
|
||||
}
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, sync_interval_min: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.1 Cut F — 선택된 vision model. null = 미선택. */
|
||||
async getVisionModel(): Promise<string | null> {
|
||||
const s = await this.load();
|
||||
return s.vision_model ?? null;
|
||||
}
|
||||
|
||||
async setVisionModel(value: string | null): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_model: value };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
/** v0.3.1 Cut F — /api/tags 조회 결과 캐시. 기본 빈 배열 + null timestamp. */
|
||||
async getVisionCapableCache(): Promise<{ models: string[]; at: string | null }> {
|
||||
const s = await this.load();
|
||||
return { models: s.vision_capable_cache ?? [], at: s.vision_cache_at ?? null };
|
||||
}
|
||||
|
||||
async setVisionCapableCache(models: string[], now: Date): Promise<void> {
|
||||
const current = await this.load();
|
||||
const next: Settings = { ...current, vision_capable_cache: models, vision_cache_at: now.toISOString() };
|
||||
await this.persist(next);
|
||||
}
|
||||
|
||||
private async persist(next: Settings): Promise<void> {
|
||||
await mkdir(dirname(this.filePath), { recursive: true });
|
||||
const tmpPath = this.filePath + '.tmp';
|
||||
|
||||
@@ -1,22 +1,41 @@
|
||||
import { join } from 'node:path';
|
||||
import { mkdir } from 'node:fs/promises';
|
||||
import type { ExportService } from './ExportService.js';
|
||||
import type { ImportService } from './ImportService.js';
|
||||
import { GitClient } from './GitClient.js';
|
||||
|
||||
/**
|
||||
* Cut E final review fix: 'noteId' was misleading — F5 export filenames are
|
||||
* `<date>-<id8>-<slug>.md` (composeFilename), not `<uuid>.md`. The git checkout /
|
||||
* resolve operations use the FULL relative path (e.g., `notes/2026-05-09-abc12345-회의.md`).
|
||||
* `path` matches what we actually pass to `git checkout --ours/theirs`.
|
||||
*/
|
||||
export interface SyncConflict {
|
||||
path: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string; // why the sync was skipped or failed
|
||||
changed?: boolean; // true if a new commit was created
|
||||
sha?: string | null;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export class SyncService {
|
||||
private syncDir: string;
|
||||
private lastConflicts: SyncConflict[] = [];
|
||||
private lastResult: SyncStatus | null = null;
|
||||
private lastAt: string | null = null;
|
||||
|
||||
constructor(
|
||||
private profileDir: string,
|
||||
private exportSvc: ExportService,
|
||||
private importSvc: ImportService,
|
||||
private now: () => Date = () => new Date()
|
||||
) {
|
||||
this.syncDir = join(profileDir, 'sync');
|
||||
@@ -33,31 +52,151 @@ export class SyncService {
|
||||
return true;
|
||||
}
|
||||
|
||||
getLastStatus(): { lastAt: string | null; lastResult: SyncStatus | null } {
|
||||
return { lastAt: this.lastAt, lastResult: this.lastResult };
|
||||
}
|
||||
|
||||
listConflicts(): SyncConflict[] {
|
||||
return this.lastConflicts;
|
||||
}
|
||||
|
||||
async sync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) {
|
||||
return { ok: false, reason: 'not_configured' };
|
||||
const result = await this.runSync();
|
||||
this.lastResult = result;
|
||||
this.lastAt = this.now().toISOString();
|
||||
if (result.reason === 'conflict' && result.conflicts) {
|
||||
this.lastConflicts = result.conflicts;
|
||||
} else if (result.ok) {
|
||||
this.lastConflicts = [];
|
||||
}
|
||||
// 1. Re-export the full tree into syncDir (idempotent).
|
||||
return result;
|
||||
}
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — conflict 해결. local/remote 2 choice (both deferred to v0.3.1+).
|
||||
* 사용자가 ConflictModal 에서 선택 → IPC → 본 메서드. 각 conflict 의 path 별 호출.
|
||||
*
|
||||
* - 'local' = 내 것 사용 (origin 변경 폐기) → git checkout --ours
|
||||
* - 'remote' = 원격 사용 → git checkout --theirs + applySyncFromDir (local DB 갱신)
|
||||
*
|
||||
* 모든 conflict 해결 후 rebase --continue 가 성공 → push.
|
||||
* UI 가 여러 conflict 를 loop 호출하면 마지막 호출에서 push 까지 완료.
|
||||
*
|
||||
* Cut E final review fix: 파라미터를 path 로 변경 (옛 noteId 는 export filename slug,
|
||||
* UUID 아님 — 혼동 회피).
|
||||
*/
|
||||
async resolveConflict(
|
||||
path: string,
|
||||
choice: 'local' | 'remote'
|
||||
): Promise<{ ok: true } | { ok: false; reason: string }> {
|
||||
const git = new GitClient(this.syncDir);
|
||||
const flag = choice === 'local' ? '--ours' : '--theirs';
|
||||
|
||||
const checkout = await git.run(['checkout', flag, path]);
|
||||
if (checkout.exitCode !== 0) {
|
||||
return { ok: false, reason: `checkout failed: ${checkout.stderr}` };
|
||||
}
|
||||
|
||||
await git.addAll();
|
||||
|
||||
const cont = await git.run(['rebase', '--continue']);
|
||||
if (cont.exitCode !== 0) {
|
||||
// Likely other unresolved files — UI will call resolveConflict for them.
|
||||
return { ok: false, reason: `rebase --continue failed: ${cont.stderr}` };
|
||||
}
|
||||
|
||||
if (choice === 'remote') {
|
||||
try {
|
||||
await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// Remove this path from cached conflicts list
|
||||
this.lastConflicts = this.lastConflicts.filter((c) => c.path !== path);
|
||||
|
||||
return { ok: true };
|
||||
}
|
||||
|
||||
private async runSync(): Promise<SyncStatus> {
|
||||
if (!(await this.isConfigured())) return { ok: false, reason: 'not_configured' };
|
||||
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 1. local export
|
||||
try {
|
||||
await mkdir(this.syncDir, { recursive: true });
|
||||
await this.exportSvc.export(this.syncDir, { includeMedia: true });
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `export failed: ${(e as Error).message}` };
|
||||
}
|
||||
// 2. git add + commit + push
|
||||
const git = new GitClient(this.syncDir);
|
||||
|
||||
// 2. local commit (only if changed)
|
||||
let localSha: string | null = null;
|
||||
let localChanged = false;
|
||||
try {
|
||||
await git.addAll();
|
||||
const ts = this.now().toISOString();
|
||||
const message = `chore(notes): sync ${ts}`;
|
||||
const commit = await git.commit(message);
|
||||
if (!commit.changed) {
|
||||
return { ok: true, changed: false, pushed: false };
|
||||
localChanged = await git.hasUncommittedChanges();
|
||||
if (localChanged) {
|
||||
const c = await git.commit(`chore(notes): sync ${this.now().toISOString()}`);
|
||||
localSha = c.sha;
|
||||
}
|
||||
await git.push();
|
||||
return { ok: true, changed: true, sha: commit.sha, pushed: true };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: (e as Error).message };
|
||||
return { ok: false, reason: `local commit failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 3. fetch
|
||||
const fetchR = await git.fetch();
|
||||
if (fetchR.exitCode !== 0) return { ok: false, reason: `fetch failed: ${fetchR.stderr}` };
|
||||
|
||||
// 4. rebase — skip if origin/main doesn't exist yet (first-push, empty remote)
|
||||
const hasOriginMain = await git.refExists('origin/main');
|
||||
if (hasOriginMain) {
|
||||
const rebaseR = await git.rebaseOnto('origin/main');
|
||||
if (rebaseR.exitCode !== 0) {
|
||||
const files = await git.listConflicts();
|
||||
// Cut E final review fix — populate localText/remoteText from rebase index
|
||||
// BEFORE aborting. `git show :2:<path>` = ours (local during rebase),
|
||||
// `:3:<path>` = theirs (remote being applied). UI shows side-by-side diff.
|
||||
const conflicts: SyncConflict[] = [];
|
||||
for (const path of files) {
|
||||
const ours = await git.run(['show', `:2:${path}`]);
|
||||
const theirs = await git.run(['show', `:3:${path}`]);
|
||||
conflicts.push({
|
||||
path,
|
||||
localText: ours.exitCode === 0 ? ours.stdout : '',
|
||||
remoteText: theirs.exitCode === 0 ? theirs.stdout : ''
|
||||
});
|
||||
}
|
||||
await git.rebaseAbort();
|
||||
return { ok: false, reason: 'conflict', conflicts };
|
||||
}
|
||||
}
|
||||
|
||||
// 5. re-import
|
||||
let importedCount = 0;
|
||||
try {
|
||||
const r = await this.importSvc.applySyncFromDir(this.syncDir);
|
||||
importedCount = r.changedCount;
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `re-import failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
// 6. push
|
||||
try {
|
||||
await git.push();
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `push failed: ${(e as Error).message}` };
|
||||
}
|
||||
|
||||
return { ok: true, changed: localChanged || importedCount > 0, localSha, importedCount, pushed: true };
|
||||
}
|
||||
|
||||
}
|
||||
|
||||
49
src/main/services/SyncTimer.ts
Normal file
49
src/main/services/SyncTimer.ts
Normal file
@@ -0,0 +1,49 @@
|
||||
import type { SyncService } from './SyncService.js';
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
/**
|
||||
* v0.3.0 Cut E — 자동 주기 sync timer.
|
||||
*
|
||||
* - start: settings 의 auto enabled + repo URL 모두 갖춰져야 시작
|
||||
* - reconfigure: settings 변경 시 stop + start (새 interval 적용)
|
||||
* - stop: clearInterval (idempotent)
|
||||
*
|
||||
* sync 결과는 무시 (interval mode = silent). conflict 발생 시 다음 manual sync /
|
||||
* 충돌 UI 진입 시 처리됨 — 사용자가 settings 페이지의 SyncSection 에서 확인 가능.
|
||||
*/
|
||||
export class SyncTimer {
|
||||
private handle: NodeJS.Timeout | null = null;
|
||||
|
||||
constructor(
|
||||
private syncSvc: SyncService,
|
||||
private settings: SettingsService
|
||||
) {}
|
||||
|
||||
async start(): Promise<void> {
|
||||
if (this.handle !== null) return; // idempotent
|
||||
const enabled = await this.settings.isAutoSyncEnabled();
|
||||
if (!enabled) return;
|
||||
const url = await this.settings.getSyncRepoUrl();
|
||||
if (url === null || url.trim().length === 0) return;
|
||||
const intervalMin = await this.settings.getSyncIntervalMin();
|
||||
const ms = Math.max(5, intervalMin) * 60 * 1000;
|
||||
this.handle = setInterval(() => {
|
||||
void this.syncSvc.sync().catch(() => {
|
||||
// silent — interval mode 의 실패는 다음 attempt 또는 사용자 manual 호출이 처리
|
||||
});
|
||||
}, ms);
|
||||
}
|
||||
|
||||
stop(): void {
|
||||
if (this.handle !== null) {
|
||||
clearInterval(this.handle);
|
||||
this.handle = null;
|
||||
}
|
||||
}
|
||||
|
||||
/** settings 변경 시 호출 — 현재 interval stop 후 새 값으로 start. */
|
||||
async reconfigure(): Promise<void> {
|
||||
this.stop();
|
||||
await this.start();
|
||||
}
|
||||
}
|
||||
47
src/main/services/VisionDetect.ts
Normal file
47
src/main/services/VisionDetect.ts
Normal file
@@ -0,0 +1,47 @@
|
||||
import type { SettingsService } from './SettingsService.js';
|
||||
|
||||
// v0.3.1 Cut F final fix — gemma 시리즈 default 정정. 본인 dogfood 환경 = gemma4:e4b
|
||||
// (텍스트). vision 변종은 gemma3 (현재 vision-capable) 또는 gemma4 (향후 출시 시).
|
||||
// 양 family 모두 hint 에 포함 — capability detection 이 future-proof.
|
||||
const VISION_FAMILIES = new Set(['gemma3', 'gemma4', 'llava', 'llama3.2-vision', 'minicpm-v', 'pixtral']);
|
||||
const VISION_NAME_HINTS = ['vision', 'vl', 'multimodal', 'gemma3', 'gemma4'];
|
||||
|
||||
export interface OllamaModel {
|
||||
name: string;
|
||||
details?: { family?: string; families?: string[] };
|
||||
}
|
||||
|
||||
export function isVisionCapable(model: OllamaModel): boolean {
|
||||
if (model.details?.family && VISION_FAMILIES.has(model.details.family)) return true;
|
||||
if (model.details?.families?.some((f) => VISION_FAMILIES.has(f))) return true;
|
||||
const lower = model.name.toLowerCase();
|
||||
return VISION_NAME_HINTS.some((h) => lower.includes(h));
|
||||
}
|
||||
|
||||
export interface RefreshDeps {
|
||||
settings: SettingsService;
|
||||
endpoint: string;
|
||||
now?: () => Date;
|
||||
fetchImpl?: typeof fetch;
|
||||
}
|
||||
|
||||
export async function refreshVisionCache(
|
||||
deps: RefreshDeps
|
||||
): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }> {
|
||||
if (!(await deps.settings.isAiEnabled())) {
|
||||
return { ok: false, reason: 'ai_disabled' };
|
||||
}
|
||||
const fetchFn = deps.fetchImpl ?? fetch;
|
||||
let body: { models?: OllamaModel[] };
|
||||
try {
|
||||
const r = await fetchFn(`${deps.endpoint}/api/tags`);
|
||||
if (!r.ok) return { ok: false, reason: `tags http ${r.status}` };
|
||||
body = (await r.json()) as { models?: OllamaModel[] };
|
||||
} catch (e) {
|
||||
return { ok: false, reason: `unreachable: ${(e as Error).message}` };
|
||||
}
|
||||
const capable = (body.models ?? []).filter(isVisionCapable).map((m) => m.name);
|
||||
const now = deps.now ? deps.now() : new Date();
|
||||
await deps.settings.setVisionCapableCache(capable, now);
|
||||
return { ok: true, models: capable };
|
||||
}
|
||||
@@ -29,6 +29,13 @@ export interface ExportNote {
|
||||
aiGeneratedAt: string | null;
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
// v0.3.0 Cut E — Cut B (status), Cut C (dueDate via m002), and dueDate user-edited flag
|
||||
// need to round-trip through F5 export and Cut E sync.
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ExportNoteTag[];
|
||||
media: ExportNoteMedia[];
|
||||
}
|
||||
@@ -155,6 +162,18 @@ export function composeFrontmatter(note: ExportNote): string {
|
||||
lines.push(`ai_generated_at: ${note.aiGeneratedAt}`);
|
||||
}
|
||||
|
||||
lines.push(`status: ${note.status}`);
|
||||
if (note.statusChangedAt !== null) {
|
||||
lines.push(`status_changed_at: ${note.statusChangedAt}`);
|
||||
}
|
||||
if (note.moveReason !== null) {
|
||||
lines.push(`move_reason: ${formatScalar(note.moveReason)}`);
|
||||
}
|
||||
if (note.dueDate !== null) {
|
||||
lines.push(`due_date: ${note.dueDate}`);
|
||||
lines.push(`due_date_source: ${note.dueDateEditedByUser ? 'user' : 'ai'}`);
|
||||
}
|
||||
|
||||
if (note.media.length > 0) {
|
||||
lines.push('images:');
|
||||
for (const m of note.media) {
|
||||
|
||||
@@ -34,6 +34,13 @@ export interface ParsedNote {
|
||||
userIntent: string | null;
|
||||
intentPromptedAt: string | null;
|
||||
deletedAt: string | null; // 신규 v0.2.3 #4
|
||||
// v0.3.0 Cut E — round-trip status / due_date / move_reason from frontmatter.
|
||||
// Default to 'active' / null / false when absent (older exports pre-Cut E).
|
||||
status: 'active' | 'completed' | 'archived' | 'trashed';
|
||||
statusChangedAt: string | null;
|
||||
moveReason: string | null;
|
||||
dueDate: string | null;
|
||||
dueDateEditedByUser: boolean;
|
||||
tags: ParsedNoteTag[];
|
||||
images: ParsedNoteImage[];
|
||||
exportVersion: number;
|
||||
@@ -335,6 +342,13 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
const versionRaw = get('inkling_export_version');
|
||||
const exportVersion = versionRaw === null ? 0 : Number.parseInt(versionRaw, 10) || 0;
|
||||
|
||||
const statusRaw = get('status');
|
||||
const validStatuses = ['active', 'completed', 'archived', 'trashed'] as const;
|
||||
const status = (validStatuses as readonly string[]).includes(statusRaw ?? 'active')
|
||||
? ((statusRaw ?? 'active') as ParsedNote['status'])
|
||||
: 'active';
|
||||
const dueDateSource = get('due_date_source');
|
||||
|
||||
return {
|
||||
id,
|
||||
createdAt,
|
||||
@@ -349,6 +363,11 @@ export function parseExportNote(markdown: string): ParsedNote {
|
||||
userIntent: get('user_intent'),
|
||||
intentPromptedAt: get('intent_prompted_at'),
|
||||
deletedAt: get('deleted_at'),
|
||||
status,
|
||||
statusChangedAt: get('status_changed_at'),
|
||||
moveReason: get('move_reason'),
|
||||
dueDate: get('due_date'),
|
||||
dueDateEditedByUser: dueDateSource === 'user',
|
||||
tags: fm.tags,
|
||||
images: fm.images,
|
||||
exportVersion
|
||||
|
||||
@@ -88,6 +88,19 @@ const api: InklingApi = {
|
||||
// v0.2.11 Cut D — search + 회고 aggregate.
|
||||
search: (query, opts) => ipcRenderer.invoke('inbox:search', query, opts ?? {}),
|
||||
reviewAggregate: (period) => ipcRenderer.invoke('inbox:review-aggregate', period),
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync: (url: string | null) => ipcRenderer.invoke('settings:configure-sync', url),
|
||||
testSyncConnection: () => ipcRenderer.invoke('settings:test-sync-connection'),
|
||||
listConflicts: () => ipcRenderer.invoke('sync:list-conflicts'),
|
||||
resolveConflict: (path: string, choice: 'local' | 'remote') =>
|
||||
ipcRenderer.invoke('sync:resolve-conflict', path, choice),
|
||||
getSyncStatus: () => ipcRenderer.invoke('sync:get-status'),
|
||||
setSyncAutoEnabled: (value: boolean) => ipcRenderer.invoke('settings:set-sync-auto-enabled', value),
|
||||
setSyncIntervalMin: (value: number) => ipcRenderer.invoke('settings:set-sync-interval-min', value),
|
||||
// v0.3.1 Cut F — vision capability + 모델 선택
|
||||
getVisionModels: () => ipcRenderer.invoke('settings:get-vision-models'),
|
||||
setVisionModel: (value: string | null) => ipcRenderer.invoke('settings:set-vision-model', value),
|
||||
refreshVisionCache: () => ipcRenderer.invoke('settings:refresh-vision-cache'),
|
||||
}
|
||||
};
|
||||
|
||||
|
||||
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
112
src/renderer/inbox/components/ConflictModal.tsx
Normal file
@@ -0,0 +1,112 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import type { SyncConflict } from '@shared/types';
|
||||
import { inboxApi } from '../api.js';
|
||||
|
||||
interface Props {
|
||||
onClose: () => void;
|
||||
onResolved: () => void;
|
||||
}
|
||||
|
||||
const overlayStyle: React.CSSProperties = {
|
||||
position: 'fixed', top: 0, left: 0, width: '100vw', height: '100vh',
|
||||
background: 'rgba(0,0,0,0.4)', display: 'flex', alignItems: 'center',
|
||||
justifyContent: 'center', zIndex: 100
|
||||
};
|
||||
|
||||
const modalStyle: React.CSSProperties = {
|
||||
background: '#fff', borderRadius: 8, padding: 20, width: 600,
|
||||
maxHeight: '70vh', overflow: 'auto', boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
||||
};
|
||||
|
||||
const rowStyle: React.CSSProperties = {
|
||||
border: '1px solid #eee', borderRadius: 6, padding: 10, marginTop: 8
|
||||
};
|
||||
|
||||
export function ConflictModal({ onClose, onResolved }: Props): React.ReactElement {
|
||||
const [conflicts, setConflicts] = useState<SyncConflict[]>([]);
|
||||
const [busy, setBusy] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
void (async () => {
|
||||
const c = await inboxApi.listConflicts();
|
||||
if (!cancelled) setConflicts(c);
|
||||
})();
|
||||
return () => { cancelled = true; };
|
||||
}, []);
|
||||
|
||||
async function onChoose(path: string, choice: 'local' | 'remote') {
|
||||
setBusy(path);
|
||||
setError(null);
|
||||
const r = await inboxApi.resolveConflict(path, choice);
|
||||
setBusy(null);
|
||||
if (!r.ok) {
|
||||
setError(`해결 실패: ${r.reason}`);
|
||||
return;
|
||||
}
|
||||
const next = conflicts.filter((c) => c.path !== path);
|
||||
setConflicts(next);
|
||||
if (next.length === 0) {
|
||||
onResolved();
|
||||
onClose();
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div style={overlayStyle} onClick={onClose}>
|
||||
<div style={modalStyle} onClick={(e) => e.stopPropagation()}>
|
||||
<div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
|
||||
<h3 style={{ margin: 0, fontSize: 16 }}>충돌 ({conflicts.length}건)</h3>
|
||||
<button onClick={onClose} aria-label="닫기" style={{ background: 'none', border: 'none', fontSize: 18, cursor: 'pointer', color: '#888' }}>×</button>
|
||||
</div>
|
||||
{error !== null && <div style={{ marginTop: 10, fontSize: 12, color: '#c93030' }}>{error}</div>}
|
||||
{conflicts.map((c) => (
|
||||
<div key={c.path} style={rowStyle}>
|
||||
<div style={{ fontSize: 12, color: '#888', marginBottom: 6 }}>{c.path}</div>
|
||||
<div style={{ display: 'flex', gap: 12 }}>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>내 기기</div>
|
||||
<pre style={preStyle()}>{c.localText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
<div style={{ flex: 1 }}>
|
||||
<div style={{ fontSize: 11, color: '#666', marginBottom: 4 }}>다른 기기</div>
|
||||
<pre style={preStyle()}>{c.remoteText || '(미리보기 없음)'}</pre>
|
||||
</div>
|
||||
</div>
|
||||
<div style={{ marginTop: 8, display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'local'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#0a4b80')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '내 것 사용'}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => { void onChoose(c.path, 'remote'); }}
|
||||
disabled={busy === c.path}
|
||||
style={chooseBtnStyle('#236b1a')}
|
||||
>
|
||||
{busy === c.path ? '처리 중…' : '원격 사용'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function preStyle(): React.CSSProperties {
|
||||
return {
|
||||
margin: 0, whiteSpace: 'pre-wrap', fontSize: 11, color: '#444',
|
||||
background: '#fafafa', padding: 6, borderRadius: 3, maxHeight: 120, overflow: 'auto'
|
||||
};
|
||||
}
|
||||
|
||||
function chooseBtnStyle(color: string): React.CSSProperties {
|
||||
return {
|
||||
background: 'none', border: `1px solid ${color}`, color, cursor: 'pointer',
|
||||
fontSize: 12, padding: '4px 10px', borderRadius: 4
|
||||
};
|
||||
}
|
||||
@@ -4,6 +4,7 @@ import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
import { AutostartSection } from './settings/AutostartSection.js';
|
||||
import { BackupSection } from './settings/BackupSection.js';
|
||||
import { InfoSection } from './settings/InfoSection.js';
|
||||
import { SyncSection } from './settings/SyncSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
@@ -40,6 +41,10 @@ export function SettingsPage(): React.ReactElement {
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>정보</h2>
|
||||
<InfoSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>동기화</h2>
|
||||
<SyncSection />
|
||||
</section>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { VisionSection } from './VisionSection.js';
|
||||
|
||||
const endpointSchema = z.string().url();
|
||||
|
||||
@@ -192,6 +193,7 @@ export function AiProviderSection(): React.ReactElement {
|
||||
{recheckResult && (
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
|
||||
)}
|
||||
<VisionSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
150
src/renderer/inbox/components/settings/SyncSection.tsx
Normal file
@@ -0,0 +1,150 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import type { SyncStatusSnapshot } from '@shared/types';
|
||||
import { ConflictModal } from '../ConflictModal.js';
|
||||
|
||||
export function SyncSection(): React.ReactElement {
|
||||
const [url, setUrl] = useState('');
|
||||
const [draftUrl, setDraftUrl] = useState('');
|
||||
const [autoEnabled, setAutoEnabled] = useState(true);
|
||||
const [intervalMin, setIntervalMin] = useState(30);
|
||||
const [status, setStatus] = useState<SyncStatusSnapshot | null>(null);
|
||||
const [busy, setBusy] = useState<'save' | 'test' | 'sync' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
const [showConflict, setShowConflict] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await inboxApi.getSettings();
|
||||
const u = s.sync_repo_url ?? '';
|
||||
setUrl(u);
|
||||
setDraftUrl(u);
|
||||
setAutoEnabled(s.sync_auto_enabled ?? true);
|
||||
setIntervalMin(s.sync_interval_min ?? 30);
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSaveUrl() {
|
||||
setBusy('save');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.configureSync(draftUrl.trim() === '' ? null : draftUrl.trim());
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
setUrl(draftUrl.trim());
|
||||
setFeedback('저장되었습니다');
|
||||
} else {
|
||||
setFeedback(`저장 실패: ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onTestConnection() {
|
||||
setBusy('test');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.testSyncConnection();
|
||||
setBusy(null);
|
||||
setFeedback(r.ok ? '연결 성공' : `연결 실패: ${r.reason}`);
|
||||
}
|
||||
|
||||
async function onToggleAuto(next: boolean) {
|
||||
await inboxApi.setSyncAutoEnabled(next);
|
||||
setAutoEnabled(next);
|
||||
}
|
||||
|
||||
async function onChangeInterval(value: number) {
|
||||
if (!Number.isInteger(value) || value < 5) return;
|
||||
const r = await inboxApi.setSyncIntervalMin(value);
|
||||
if (r.ok) setIntervalMin(value);
|
||||
}
|
||||
|
||||
const conflictCount = status?.lastResult?.conflicts?.length ?? 0;
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 24 }}>
|
||||
<h3 style={{ fontSize: 14, marginBottom: 8 }}>동기화 저장소</h3>
|
||||
|
||||
<div style={{ display: 'flex', gap: 6, marginBottom: 8 }}>
|
||||
<input
|
||||
type="text"
|
||||
aria-label="저장소 URL"
|
||||
placeholder="git@host:user/inkling-notes.git"
|
||||
value={draftUrl}
|
||||
onChange={(e) => setDraftUrl(e.target.value)}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
||||
/>
|
||||
<button onClick={() => { void onSaveUrl(); }} disabled={busy !== null} style={btnStyle()}>
|
||||
{busy === 'save' ? '저장 중…' : '저장'}
|
||||
</button>
|
||||
<button onClick={() => { void onTestConnection(); }} disabled={busy !== null || url.trim() === ''} style={btnStyle()}>
|
||||
{busy === 'test' ? '확인 중…' : '연결 테스트'}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{feedback !== null && (
|
||||
<div style={{ fontSize: 12, color: '#444', marginBottom: 8 }}>{feedback}</div>
|
||||
)}
|
||||
|
||||
{url.trim() !== '' && (
|
||||
<>
|
||||
<div style={{ fontSize: 12, color: '#666', marginBottom: 8 }}>
|
||||
마지막 sync: {status?.lastAt ?? '없음'} {status?.lastResult?.ok === false && status?.lastResult?.reason !== 'conflict' && (
|
||||
<span style={{ color: '#a55' }}> ({status.lastResult.reason})</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 6 }}>
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={autoEnabled}
|
||||
onChange={(e) => { void onToggleAuto(e.target.checked); }}
|
||||
/>
|
||||
자동 sync 사용
|
||||
</label>
|
||||
|
||||
<label style={{ display: 'flex', alignItems: 'center', gap: 6, fontSize: 12, marginBottom: 8 }}>
|
||||
interval:
|
||||
<input
|
||||
type="number"
|
||||
aria-label="sync interval (분)"
|
||||
min={5}
|
||||
value={intervalMin}
|
||||
onChange={(e) => { void onChangeInterval(Number.parseInt(e.target.value, 10)); }}
|
||||
disabled={!autoEnabled}
|
||||
style={{ width: 60, fontSize: 12, padding: '2px 4px' }}
|
||||
/>
|
||||
분
|
||||
</label>
|
||||
|
||||
{conflictCount > 0 && (
|
||||
<div style={{ marginTop: 8 }}>
|
||||
<button onClick={() => setShowConflict(true)} style={btnStyle()}>
|
||||
충돌 해결… ({conflictCount}건)
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showConflict && (
|
||||
<ConflictModal
|
||||
onClose={() => setShowConflict(false)}
|
||||
onResolved={async () => {
|
||||
setStatus(await inboxApi.getSyncStatus());
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
|
||||
function btnStyle(): React.CSSProperties {
|
||||
return {
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
fontSize: 12,
|
||||
padding: '4px 10px',
|
||||
borderRadius: 4
|
||||
};
|
||||
}
|
||||
81
src/renderer/inbox/components/settings/VisionSection.tsx
Normal file
81
src/renderer/inbox/components/settings/VisionSection.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
export function VisionSection(): React.ReactElement {
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [at, setAt] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const r = await inboxApi.getVisionModels();
|
||||
setModels(r.models);
|
||||
setAt(r.at);
|
||||
setSelected(r.selected);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function onSelect(value: string) {
|
||||
const next = value === '' ? null : value;
|
||||
setBusy('select');
|
||||
setFeedback(null);
|
||||
await inboxApi.setVisionModel(next);
|
||||
setSelected(next);
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
setBusy('refresh');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.refreshVisionCache();
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
await load();
|
||||
setFeedback(`감지 완료 (${r.models.length}개)`);
|
||||
} else {
|
||||
setFeedback(`감지 실패: ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h4 style={{ fontSize: 13, marginBottom: 6 }}>이미지 분석 모델 (선택사항)</h4>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<select
|
||||
aria-label="이미지 분석 모델"
|
||||
value={selected ?? ''}
|
||||
onChange={(e) => { void onSelect(e.target.value); }}
|
||||
disabled={busy !== null}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
||||
>
|
||||
<option value="">(비활성)</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { void onRefresh(); }}
|
||||
disabled={busy !== null}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4 }}
|
||||
>
|
||||
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
|
||||
</button>
|
||||
</div>
|
||||
{at !== null && (
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
마지막 감지: {new Date(at).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
)}
|
||||
{feedback !== null && (
|
||||
<div style={{ fontSize: 11, color: '#444', marginTop: 4 }}>{feedback}</div>
|
||||
)}
|
||||
{models.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>
|
||||
감지된 모델 없음. Ollama 에 vision 모델을 설치하고 "다시 감지" 클릭.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -40,6 +40,31 @@ export interface ReviewAggregate {
|
||||
dueProgress: { total: number; passed: number; pending: number };
|
||||
}
|
||||
|
||||
// v0.3.0 Cut E — 양방향 sync 결과 + conflict.
|
||||
// `path` = git index 의 conflict 파일 상대경로 (예: 'notes/2026-05-09-abc12345-회의.md').
|
||||
// F5 export 의 filename 은 date-id8-slug 패턴 — UUID 가 아니라 path 가 맞는 식별자.
|
||||
export interface SyncConflict {
|
||||
path: string;
|
||||
localText: string;
|
||||
remoteText: string;
|
||||
}
|
||||
|
||||
export interface SyncStatus {
|
||||
ok: boolean;
|
||||
reason?: string;
|
||||
changed?: boolean;
|
||||
localSha?: string | null;
|
||||
pushed?: boolean;
|
||||
importedCount?: number;
|
||||
conflicts?: SyncConflict[];
|
||||
}
|
||||
|
||||
export interface SyncStatusSnapshot {
|
||||
lastAt: string | null;
|
||||
lastResult: SyncStatus | null;
|
||||
nextAt: string | null;
|
||||
}
|
||||
|
||||
export interface Note {
|
||||
id: string;
|
||||
rawText: string;
|
||||
@@ -169,6 +194,13 @@ export interface InboxApi {
|
||||
ollama?: { endpoint: string; model: string };
|
||||
ai_enabled?: boolean;
|
||||
onboarding_completed?: boolean;
|
||||
sync_repo_url?: string | null;
|
||||
sync_auto_enabled?: boolean;
|
||||
sync_interval_min?: number;
|
||||
// v0.3.1 Cut F
|
||||
vision_model?: string | null;
|
||||
vision_capable_cache?: string[];
|
||||
vision_cache_at?: string;
|
||||
}>;
|
||||
setAiEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setOnboardingCompleted(completed: boolean): Promise<{ ok: true }>;
|
||||
@@ -182,6 +214,18 @@ export interface InboxApi {
|
||||
// v0.2.11 Cut D — FTS5 search + 회고 aggregate.
|
||||
search(query: string, opts?: { limit?: number; status?: NoteStatus }): Promise<Note[]>;
|
||||
reviewAggregate(period: ReviewPeriod): Promise<ReviewAggregate>;
|
||||
// v0.3.0 Cut E — 양방향 sync.
|
||||
configureSync(url: string | null): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
testSyncConnection(): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
listConflicts(): Promise<SyncConflict[]>;
|
||||
resolveConflict(path: string, choice: 'local' | 'remote'): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
getSyncStatus(): Promise<SyncStatusSnapshot>;
|
||||
setSyncAutoEnabled(enabled: boolean): Promise<{ ok: true }>;
|
||||
setSyncIntervalMin(value: number): Promise<{ ok: true } | { ok: false; reason: string }>;
|
||||
// v0.3.1 Cut F — vision capability detection + 모델 선택.
|
||||
getVisionModels(): Promise<{ models: string[]; at: string | null; selected: string | null }>;
|
||||
setVisionModel(value: string | null): Promise<{ ok: true }>;
|
||||
refreshVisionCache(): Promise<{ ok: true; models: string[] } | { ok: false; reason: string }>;
|
||||
}
|
||||
|
||||
export interface InklingApi {
|
||||
|
||||
@@ -11,7 +11,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -449,9 +449,10 @@ describe('AiWorker — vocab fetch + per-tag hit/miss (v0.2.3 #3 T7)', () => {
|
||||
});
|
||||
await w.enqueue(id);
|
||||
await w.drain();
|
||||
expect(generateMock).toHaveBeenCalledWith(expect.objectContaining({
|
||||
vocab: expect.arrayContaining(['design'])
|
||||
}));
|
||||
expect(generateMock).toHaveBeenCalledWith(
|
||||
expect.objectContaining({ vocab: expect.arrayContaining(['design']) }),
|
||||
expect.anything()
|
||||
);
|
||||
});
|
||||
|
||||
it('emits tag_vocab_hit for vocab tags + tag_vocab_miss for new tags', async () => {
|
||||
|
||||
125
tests/unit/AiWorker.vision.test.ts
Normal file
125
tests/unit/AiWorker.vision.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, afterEach, vi } from 'vitest';
|
||||
import { writeFile, mkdtemp, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { AiWorker } from '@main/ai/AiWorker.js';
|
||||
import { ProviderHolder } from '@main/ai/ProviderHolder.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
import type { AiResponse } from '@main/ai/schema.js';
|
||||
import type { InferenceProvider } from '@main/ai/InferenceProvider.js';
|
||||
|
||||
describe('AiWorker — vision path (v0.3.1 Cut F)', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let workDir: string;
|
||||
let mediaStore: MediaStore;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-vision-'));
|
||||
mediaStore = new MediaStore(workDir);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
function makeWorker(
|
||||
generate: (input: Parameters<InferenceProvider['generate']>[0], opts?: Parameters<InferenceProvider['generate']>[1]) => Promise<AiResponse>,
|
||||
getVisionModel: () => Promise<string | null>
|
||||
): AiWorker {
|
||||
const provider: InferenceProvider = {
|
||||
name: 'fake',
|
||||
generate,
|
||||
abort: () => {},
|
||||
healthCheck: vi.fn(async () => ({ ok: true }))
|
||||
};
|
||||
const holder = new ProviderHolder(provider);
|
||||
const settings = { getVisionModel };
|
||||
const logger = { info: vi.fn(), warn: vi.fn(), error: vi.fn() };
|
||||
return new AiWorker(repo, holder, {
|
||||
backoffsMs: [0, 0, 0],
|
||||
logger,
|
||||
settings,
|
||||
mediaStore,
|
||||
now: () => new Date('2026-05-10T05:00:00Z')
|
||||
});
|
||||
}
|
||||
|
||||
it('visionModel + media 있음 → provider.generate 가 images + opts 받음', async () => {
|
||||
const { id } = repo.create({ rawText: '이미지 메모' });
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.from([0x89, 0x50, 0x4e, 0x47]));
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 4 }]);
|
||||
|
||||
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
|
||||
const generate = vi.fn(async (
|
||||
input: Parameters<InferenceProvider['generate']>[0],
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
await worker.enqueue(id);
|
||||
await worker.drain();
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
const [callInput, callOpts] = calls[0]!;
|
||||
expect(callInput.images).toHaveLength(1);
|
||||
expect(callInput.images![0]!.mime).toBe('image/png');
|
||||
expect(callOpts?.visionModel).toBe('gemma3:12b-vision');
|
||||
});
|
||||
|
||||
it('visionModel null이면 text-only (images undefined)', async () => {
|
||||
const { id } = repo.create({ rawText: 'just text' });
|
||||
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
|
||||
const generate = vi.fn(async (
|
||||
input: Parameters<InferenceProvider['generate']>[0],
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => null);
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
await worker.enqueue(id);
|
||||
await worker.drain();
|
||||
|
||||
expect(calls.length).toBeGreaterThan(0);
|
||||
expect(calls[0]![0].images).toBeUndefined();
|
||||
});
|
||||
|
||||
it('5MB 초과 이미지 → throw → AiWorker 의 fail 분기 (generate 미호출)', async () => {
|
||||
const { id } = repo.create({ rawText: 'big image' });
|
||||
await mkdir(join(workDir, 'media', id), { recursive: true });
|
||||
await writeFile(join(workDir, 'media', id, '1.png'), Buffer.alloc(6 * 1024 * 1024));
|
||||
repo.insertMedia([{ noteId: id, kind: 'image', relPath: `media/${id}/1.png`, mime: 'image/png', bytes: 6 * 1024 * 1024 }]);
|
||||
|
||||
const calls: Array<Parameters<InferenceProvider['generate']>> = [];
|
||||
const generate = vi.fn(async (
|
||||
input: Parameters<InferenceProvider['generate']>[0],
|
||||
opts?: Parameters<InferenceProvider['generate']>[1]
|
||||
): Promise<AiResponse> => {
|
||||
calls.push([input, opts]);
|
||||
return { title: 't', summary: 'a\nb\nc', tags: [], dueDate: null };
|
||||
});
|
||||
const getVisionModel = vi.fn(async (): Promise<string | null> => 'gemma3:12b-vision');
|
||||
const worker = makeWorker(generate, getVisionModel);
|
||||
await worker.enqueue(id);
|
||||
await worker.drain();
|
||||
|
||||
expect(calls.length).toBe(0);
|
||||
// AiWorker catch 분기가 처리 — note 는 여전히 DB 에 존재
|
||||
const note = repo.findById(id);
|
||||
expect(note).toBeTruthy();
|
||||
});
|
||||
});
|
||||
@@ -56,7 +56,17 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.2.9 Cut B Task 16 — AiProviderSection 가 SettingsPage 렌더 시 호출.
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
61
tests/unit/ConflictModal.test.tsx
Normal file
61
tests/unit/ConflictModal.test.tsx
Normal file
@@ -0,0 +1,61 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockListConflicts, mockResolveConflict } = vi.hoisted(() => ({
|
||||
mockListConflicts: vi.fn(),
|
||||
mockResolveConflict: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: { listConflicts: mockListConflicts, resolveConflict: mockResolveConflict }
|
||||
}));
|
||||
|
||||
import { ConflictModal } from '../../src/renderer/inbox/components/ConflictModal';
|
||||
|
||||
describe('ConflictModal', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockListConflicts.mockResolvedValue([
|
||||
{ path: 'notes/n1.md', localText: 'local A', remoteText: 'remote A' },
|
||||
{ path: 'notes/n2.md', localText: 'local B', remoteText: 'remote B' }
|
||||
]);
|
||||
mockResolveConflict.mockResolvedValue({ ok: true });
|
||||
});
|
||||
|
||||
it('open 시 listConflicts 호출 + 양 conflict preview 표시', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
expect(screen.getByText(/local A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/remote A/)).toBeInTheDocument();
|
||||
expect(screen.getByText(/local B/)).toBeInTheDocument();
|
||||
// path 가 표시됨 (Cut E final review fix — noteId → path)
|
||||
expect(screen.getByText('notes/n1.md')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('내 것 사용 클릭 → resolveConflict(path, "local") 호출', async () => {
|
||||
render(<ConflictModal onClose={() => {}} onResolved={() => {}} />);
|
||||
await waitFor(() => screen.getByText(/local A/));
|
||||
const buttons = screen.getAllByRole('button', { name: /내 것 사용/ });
|
||||
fireEvent.click(buttons[0]!);
|
||||
await waitFor(() => {
|
||||
expect(mockResolveConflict).toHaveBeenCalledWith('notes/n1.md', 'local');
|
||||
});
|
||||
});
|
||||
|
||||
it('마지막 conflict 해결 → onResolved + onClose 호출', async () => {
|
||||
mockListConflicts.mockResolvedValueOnce([{ path: 'notes/n1.md', localText: 'a', remoteText: 'b' }]);
|
||||
const onResolved = vi.fn();
|
||||
const onClose = vi.fn();
|
||||
render(<ConflictModal onClose={onClose} onResolved={onResolved} />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /원격 사용/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /원격 사용/ }));
|
||||
await waitFor(() => {
|
||||
expect(onResolved).toHaveBeenCalled();
|
||||
expect(onClose).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
});
|
||||
51
tests/unit/GitClient.fetch.test.ts
Normal file
51
tests/unit/GitClient.fetch.test.ts
Normal file
@@ -0,0 +1,51 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('GitClient — fetch / rebase / conflict 메서드', () => {
|
||||
let client: GitClient;
|
||||
let runSpy: ReturnType<typeof vi.spyOn>;
|
||||
|
||||
beforeEach(() => {
|
||||
client = new GitClient('/tmp/sync');
|
||||
runSpy = vi.spyOn(client, 'run');
|
||||
});
|
||||
|
||||
it('fetch — git fetch origin 호출', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const r = await client.fetch();
|
||||
expect(runSpy).toHaveBeenCalledWith(['fetch', 'origin']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rebaseOnto — git rebase origin/main', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
const r = await client.rebaseOnto('origin/main');
|
||||
expect(runSpy).toHaveBeenCalledWith(['rebase', 'origin/main']);
|
||||
expect(r.exitCode).toBe(0);
|
||||
});
|
||||
|
||||
it('rebaseAbort — git rebase --abort', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
await client.rebaseAbort();
|
||||
expect(runSpy).toHaveBeenCalledWith(['rebase', '--abort']);
|
||||
});
|
||||
|
||||
it('hasUncommittedChanges — git status --porcelain 의 출력 있으면 true', async () => {
|
||||
runSpy.mockResolvedValueOnce({ stdout: ' M notes/abc.md\n', stderr: '', exitCode: 0 });
|
||||
expect(await client.hasUncommittedChanges()).toBe(true);
|
||||
|
||||
runSpy.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 });
|
||||
expect(await client.hasUncommittedChanges()).toBe(false);
|
||||
});
|
||||
|
||||
it('listConflicts — git diff --name-only --diff-filter=U 결과 파싱', async () => {
|
||||
runSpy.mockResolvedValueOnce({
|
||||
stdout: 'notes/aaa.md\nnotes/bbb.md\n',
|
||||
stderr: '',
|
||||
exitCode: 0
|
||||
});
|
||||
const r = await client.listConflicts();
|
||||
expect(runSpy).toHaveBeenCalledWith(['diff', '--name-only', '--diff-filter=U']);
|
||||
expect(r).toEqual(['notes/aaa.md', 'notes/bbb.md']);
|
||||
});
|
||||
});
|
||||
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
106
tests/unit/ImportService.applySyncFromDir.test.ts
Normal file
@@ -0,0 +1,106 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { mkdtemp, writeFile, mkdir, rm } from 'node:fs/promises';
|
||||
import { tmpdir } from 'node:os';
|
||||
import { join } from 'node:path';
|
||||
import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { ImportService } from '@main/services/ImportService.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
|
||||
describe('ImportService.applySyncFromDir', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
let svc: ImportService;
|
||||
let workDir: string;
|
||||
|
||||
beforeEach(async () => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
workDir = await mkdtemp(join(tmpdir(), 'inkling-sync-'));
|
||||
const mediaStore = new MediaStore(workDir);
|
||||
svc = new ImportService(repo, mediaStore);
|
||||
});
|
||||
|
||||
afterEach(async () => {
|
||||
db.close();
|
||||
await rm(workDir, { recursive: true, force: true });
|
||||
});
|
||||
|
||||
it('inserts new notes and reports changedCount', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000001\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: title\ntitle_source: ai\nsummary: summary\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# title\n\n> summary\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000001');
|
||||
expect(note?.rawText).toBe('body');
|
||||
});
|
||||
|
||||
it('skips unchanged notes (no changedCount increment)', async () => {
|
||||
const created = repo.create({ rawText: 'body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('returns changedCount=0 for an empty notes directory', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(0);
|
||||
});
|
||||
|
||||
it('updates a note when source updatedAt is newer', async () => {
|
||||
const created = repo.create({ rawText: 'old body' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-01T00:00:00Z', created.id);
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: ${created.id}\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nnew body\n`
|
||||
);
|
||||
const r = await svc.applySyncFromDir(workDir);
|
||||
expect(r.changedCount).toBe(1);
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('new body');
|
||||
});
|
||||
|
||||
it('preserves status field from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000002\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: archived\nstatus_changed_at: 2026-05-08T00:00:00Z\nmove_reason: done\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000002');
|
||||
expect(note?.status).toBe('archived');
|
||||
expect(note?.statusChangedAt).toBe('2026-05-08T00:00:00Z');
|
||||
expect(note?.moveReason).toBe('done');
|
||||
});
|
||||
|
||||
it('preserves dueDate from frontmatter', async () => {
|
||||
const notesDir = join(workDir, 'notes');
|
||||
await mkdir(notesDir, { recursive: true });
|
||||
await writeFile(
|
||||
join(notesDir, 'a.md'),
|
||||
`---\nid: 00000000-0000-0000-0000-000000000003\ncreated_at: 2026-05-09T00:00:00Z\nupdated_at: 2026-05-10T00:00:00Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\nstatus: active\ndue_date: 2026-06-01\ndue_date_source: user\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`
|
||||
);
|
||||
await svc.applySyncFromDir(workDir);
|
||||
const note = repo.findById('00000000-0000-0000-0000-000000000003');
|
||||
expect(note?.dueDate).toBe('2026-06-01');
|
||||
expect(note?.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
});
|
||||
@@ -34,6 +34,12 @@ function buildExportNote(overrides: Partial<ExportNote> = {}): ExportNote {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
// v0.3.0 Cut E — frontmatter round-trip 5 필드 (Cut B status + Cut C dueDate).
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }],
|
||||
media: [],
|
||||
...overrides
|
||||
|
||||
@@ -109,4 +109,58 @@ describe('LocalOllamaProvider', () => {
|
||||
const provider = new LocalOllamaProvider({ model: 'gemma4:26b' });
|
||||
expect(provider.name).toBe('local-ollama/gemma4:26b');
|
||||
});
|
||||
|
||||
describe('vision path (v0.3.1 Cut F)', () => {
|
||||
it('visionModel + images → body.images + model=visionModel + buildVisionPrompt', async () => {
|
||||
let capturedBody: string = '';
|
||||
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
|
||||
capturedBody = opts.body as string;
|
||||
return { statusCode: 200, data: JSON.stringify({
|
||||
response: JSON.stringify({ title: '비전테스트', summary: 'a\nb\nc', tags: [], due_date: null })
|
||||
}) };
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [], images: [{ base64: 'AAAA', mime: 'image/png' }] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
const parsed = JSON.parse(capturedBody) as { model: string; prompt: string; images?: string[] };
|
||||
expect(parsed.model).toBe('gemma3:12b-vision');
|
||||
expect(parsed.prompt).toContain('이미지');
|
||||
expect(parsed.images).toEqual(['AAAA']);
|
||||
});
|
||||
|
||||
it('visionModel 있어도 images 없으면 text-only (model = this.model, no body.images)', async () => {
|
||||
let capturedBody: string = '';
|
||||
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
|
||||
capturedBody = opts.body as string;
|
||||
return { statusCode: 200, data: JSON.stringify({
|
||||
response: JSON.stringify({ title: '텍스트전용', summary: 'a\nb\nc', tags: [], due_date: null })
|
||||
}) };
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate(
|
||||
{ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] },
|
||||
{ visionModel: 'gemma3:12b-vision' }
|
||||
);
|
||||
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
|
||||
expect(parsed.model).toBe('gemma4:e4b');
|
||||
expect(parsed.images).toBeUndefined();
|
||||
});
|
||||
|
||||
it('opts 미전달 → 기존 text-only (회귀)', async () => {
|
||||
let capturedBody: string = '';
|
||||
mock.get('http://x').intercept({ path: '/api/generate', method: 'POST' }).reply((opts) => {
|
||||
capturedBody = opts.body as string;
|
||||
return { statusCode: 200, data: JSON.stringify({
|
||||
response: JSON.stringify({ title: '기본텍스트', summary: 'a\nb\nc', tags: [], due_date: null })
|
||||
}) };
|
||||
});
|
||||
const provider = new LocalOllamaProvider({ endpoint: 'http://x', model: 'gemma4:e4b' });
|
||||
await provider.generate({ text: 'hi', todayKst: '2026-05-10', dueDateCandidates: [] });
|
||||
const parsed = JSON.parse(capturedBody) as { model: string; images?: string[] };
|
||||
expect(parsed.model).toBe('gemma4:e4b');
|
||||
expect(parsed.images).toBeUndefined();
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
98
tests/unit/NoteRepository.upsertFromSync.test.ts
Normal file
98
tests/unit/NoteRepository.upsertFromSync.test.ts
Normal file
@@ -0,0 +1,98 @@
|
||||
import { describe, it, expect, beforeEach, afterEach } from 'vitest';
|
||||
import Database from 'better-sqlite3';
|
||||
import { runMigrations } from '../../src/main/db/migrations/index.js';
|
||||
import { NoteRepository } from '../../src/main/repository/NoteRepository.js';
|
||||
|
||||
const baseInput = {
|
||||
id: '00000000-0000-0000-0000-000000000001',
|
||||
rawText: 'sync 본문',
|
||||
createdAt: '2026-05-09T00:00:00Z',
|
||||
updatedAt: '2026-05-10T00:00:00Z',
|
||||
aiTitle: 'sync 제목',
|
||||
aiSummary: 'sync 요약',
|
||||
titleEditedByUser: false,
|
||||
summaryEditedByUser: false,
|
||||
aiProvider: 'p',
|
||||
aiGeneratedAt: '2026-05-10T00:00:00Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
tags: [{ name: '동기', source: 'user' as const }],
|
||||
status: 'active' as const,
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false
|
||||
};
|
||||
|
||||
describe('NoteRepository.upsertFromSync', () => {
|
||||
let db: Database.Database;
|
||||
let repo: NoteRepository;
|
||||
|
||||
beforeEach(() => {
|
||||
db = new Database(':memory:');
|
||||
db.pragma('foreign_keys = ON');
|
||||
runMigrations(db);
|
||||
repo = new NoteRepository(db);
|
||||
});
|
||||
|
||||
afterEach(() => { db.close(); });
|
||||
|
||||
it('id 없음 → INSERT (status=inserted) + capture revision + tags FTS sync', () => {
|
||||
const r = repo.upsertFromSync(baseInput);
|
||||
expect(r.status).toBe('inserted');
|
||||
expect(r.id).toBe(baseInput.id);
|
||||
const note = repo.findById(baseInput.id);
|
||||
expect(note?.rawText).toBe('sync 본문');
|
||||
expect(note?.aiTitle).toBe('sync 제목');
|
||||
const revs = repo.listRevisions(baseInput.id);
|
||||
expect(revs).toHaveLength(1);
|
||||
expect(revs[0]!.editedBy).toBe('capture');
|
||||
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(baseInput.id) as { tags: string };
|
||||
expect(fts.tags).toBe('동기');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 동일 + source 더 최신 → metadata 갱신 (status=updated)', () => {
|
||||
const created = repo.create({ rawText: 'sync 본문' });
|
||||
repo.updateAiResult(created.id, { title: '옛 제목', summary: '옛 요약', tags: ['old'], provider: 'p' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id });
|
||||
expect(r.status).toBe('updated');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.aiTitle).toBe('sync 제목');
|
||||
expect(note?.tags.map((t) => t.name)).toEqual(['동기']);
|
||||
const fts = db.prepare(`SELECT tags FROM notes_fts WHERE note_id=?`).get(created.id) as { tags: string };
|
||||
expect(fts.tags).toBe('동기');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 동일 + source 더 옛 → skip (status=skipped)', () => {
|
||||
const created = repo.create({ rawText: 'sync 본문' });
|
||||
repo.updateAiResult(created.id, { title: '신선한 제목', summary: 'fresh', tags: ['x'], provider: 'p' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-12T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, updatedAt: '2026-05-10T00:00:00Z' });
|
||||
expect(r.status).toBe('skipped');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.aiTitle).toBe('신선한 제목');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 다름 + source 더 최신 → updateRawText (status=updated) + new user revision', () => {
|
||||
const created = repo.create({ rawText: 'old text' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-08T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'new sync text' });
|
||||
expect(r.status).toBe('updated');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('new sync text');
|
||||
const revs = repo.listRevisions(created.id);
|
||||
expect(revs).toHaveLength(2); // capture (old) + user (new)
|
||||
expect(revs[0]!.editedBy).toBe('user');
|
||||
expect(revs[0]!.rawText).toBe('new sync text');
|
||||
});
|
||||
|
||||
it('id 있음 + raw_text 다름 + source 더 옛 → skip', () => {
|
||||
const created = repo.create({ rawText: 'local fresh' });
|
||||
db.prepare(`UPDATE notes SET updated_at=? WHERE id=?`).run('2026-05-15T00:00:00Z', created.id);
|
||||
const r = repo.upsertFromSync({ ...baseInput, id: created.id, rawText: 'old sync text', updatedAt: '2026-05-10T00:00:00Z' });
|
||||
expect(r.status).toBe('skipped');
|
||||
const note = repo.findById(created.id);
|
||||
expect(note?.rawText).toBe('local fresh');
|
||||
});
|
||||
});
|
||||
@@ -46,7 +46,17 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setOnboardingCompleted: vi.fn(async () => ({ ok: true as const })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.0 Cut E — SyncSection 이 SettingsPage 에 마운트되어 호출.
|
||||
getSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
@@ -64,12 +74,13 @@ describe('SettingsPage', () => {
|
||||
expect(screen.getByRole('button', { name: /돌아가기/ })).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('renders 4 section headings', () => {
|
||||
it('renders 5 section headings', () => {
|
||||
render(<SettingsPage />);
|
||||
expect(screen.getByText('AI 제공자')).toBeInTheDocument();
|
||||
expect(screen.getByText('자동 실행')).toBeInTheDocument();
|
||||
expect(screen.getByText('백업 / 복원')).toBeInTheDocument();
|
||||
expect(screen.getByText('정보')).toBeInTheDocument();
|
||||
expect(screen.getByText('동기화')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('clicking "← 돌아가기" sets showSettings to false', () => {
|
||||
|
||||
@@ -54,4 +54,67 @@ describe('SettingsService', () => {
|
||||
expect(existsSync(join(dir, 'settings.json.tmp'))).toBe(false);
|
||||
expect(existsSync(join(dir, 'settings.json'))).toBe(true);
|
||||
});
|
||||
|
||||
describe('v0.3.0 Cut E — sync settings', () => {
|
||||
it('getSyncRepoUrl() defaults to null', async () => {
|
||||
expect(await svc.getSyncRepoUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('setSyncRepoUrl() / getSyncRepoUrl() round-trip', async () => {
|
||||
await svc.setSyncRepoUrl('git@gitea.example:user/notes.git');
|
||||
expect(await svc.getSyncRepoUrl()).toBe('git@gitea.example:user/notes.git');
|
||||
// setting null clears
|
||||
await svc.setSyncRepoUrl(null);
|
||||
expect(await svc.getSyncRepoUrl()).toBeNull();
|
||||
});
|
||||
|
||||
it('isAutoSyncEnabled() defaults to true', async () => {
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('setAutoSyncEnabled() persists', async () => {
|
||||
await svc.setAutoSyncEnabled(false);
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(false);
|
||||
await svc.setAutoSyncEnabled(true);
|
||||
expect(await svc.isAutoSyncEnabled()).toBe(true);
|
||||
});
|
||||
|
||||
it('getSyncIntervalMin() defaults to 30', async () => {
|
||||
expect(await svc.getSyncIntervalMin()).toBe(30);
|
||||
});
|
||||
|
||||
it('setSyncIntervalMin() persists + rejects values < 5 / non-integer', async () => {
|
||||
await svc.setSyncIntervalMin(15);
|
||||
expect(await svc.getSyncIntervalMin()).toBe(15);
|
||||
await expect(svc.setSyncIntervalMin(3)).rejects.toThrow();
|
||||
await expect(svc.setSyncIntervalMin(10.5)).rejects.toThrow();
|
||||
});
|
||||
});
|
||||
|
||||
describe('v0.3.1 Cut F — vision settings', () => {
|
||||
it('getVisionModel() defaults to null', async () => {
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('setVisionModel() / getVisionModel() round-trip including null clear', async () => {
|
||||
await svc.setVisionModel('llava:13b');
|
||||
expect(await svc.getVisionModel()).toBe('llava:13b');
|
||||
await svc.setVisionModel(null);
|
||||
expect(await svc.getVisionModel()).toBeNull();
|
||||
});
|
||||
|
||||
it('getVisionCapableCache() defaults to empty models + null at', async () => {
|
||||
const cache = await svc.getVisionCapableCache();
|
||||
expect(cache.models).toEqual([]);
|
||||
expect(cache.at).toBeNull();
|
||||
});
|
||||
|
||||
it('setVisionCapableCache() persists models + ISO timestamp', async () => {
|
||||
const now = new Date('2026-05-09T12:00:00.000Z');
|
||||
await svc.setVisionCapableCache(['llava:13b', 'llava:7b'], now);
|
||||
const cache = await svc.getVisionCapableCache();
|
||||
expect(cache.models).toEqual(['llava:13b', 'llava:7b']);
|
||||
expect(cache.at).toBe('2026-05-09T12:00:00.000Z');
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
75
tests/unit/SyncSection.test.tsx
Normal file
75
tests/unit/SyncSection.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockGetSettings, mockConfigureSync, mockTestSyncConnection, mockGetSyncStatus, mockSetAuto, mockSetInterval } = vi.hoisted(() => ({
|
||||
mockGetSettings: vi.fn(async () => ({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 })),
|
||||
mockConfigureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
mockTestSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
mockGetSyncStatus: vi.fn(async () => ({ lastAt: null, lastResult: null, nextAt: null })),
|
||||
mockSetAuto: vi.fn(async () => ({ ok: true as const })),
|
||||
mockSetInterval: vi.fn(async () => ({ ok: true as const }))
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getSettings: mockGetSettings,
|
||||
configureSync: mockConfigureSync,
|
||||
testSyncConnection: mockTestSyncConnection,
|
||||
getSyncStatus: mockGetSyncStatus,
|
||||
setSyncAutoEnabled: mockSetAuto,
|
||||
setSyncIntervalMin: mockSetInterval
|
||||
}
|
||||
}));
|
||||
|
||||
// ConflictModal is imported by SyncSection — mock it to avoid needing listConflicts
|
||||
vi.mock('../../src/renderer/inbox/components/ConflictModal.js', () => ({
|
||||
ConflictModal: () => null
|
||||
}));
|
||||
|
||||
import { SyncSection } from '../../src/renderer/inbox/components/settings/SyncSection';
|
||||
|
||||
describe('SyncSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockGetSettings.mockResolvedValue({ sync_repo_url: '', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
mockGetSyncStatus.mockResolvedValue({ lastAt: null, lastResult: null, nextAt: null });
|
||||
});
|
||||
|
||||
it('빈 URL — 저장/연결 테스트 버튼 + 자동 sync 옵션 hide', async () => {
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /저장/ }));
|
||||
expect(screen.queryByText(/자동 sync/)).not.toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('URL 입력 + 저장 → configureSync 호출 + 자동 sync 옵션 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByText(/자동 sync/));
|
||||
expect(screen.getByText(/자동 sync/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('연결 테스트 클릭 → testSyncConnection 호출 + 결과 표시', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /연결 테스트/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockTestSyncConnection).toHaveBeenCalled();
|
||||
expect(screen.getByText(/연결 성공/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
|
||||
it('자동 sync 토글 → setSyncAutoEnabled 호출', async () => {
|
||||
mockGetSettings.mockResolvedValueOnce({ sync_repo_url: 'git@host:u/r.git', sync_auto_enabled: true, sync_interval_min: 30 });
|
||||
render(<SyncSection />);
|
||||
await waitFor(() => screen.getByLabelText(/자동 sync/));
|
||||
fireEvent.click(screen.getByLabelText(/자동 sync/));
|
||||
await waitFor(() => {
|
||||
expect(mockSetAuto).toHaveBeenCalledWith(false);
|
||||
});
|
||||
});
|
||||
});
|
||||
107
tests/unit/SyncService.bidirectional.test.ts
Normal file
107
tests/unit/SyncService.bidirectional.test.ts
Normal file
@@ -0,0 +1,107 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SyncService } from '../../src/main/services/SyncService.js';
|
||||
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('SyncService.sync — 양방향', () => {
|
||||
let svc: SyncService;
|
||||
let exportSvc: { export: ReturnType<typeof vi.fn> };
|
||||
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
isRepo: ReturnType<typeof vi.fn>;
|
||||
hasRemote: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
hasUncommittedChanges: ReturnType<typeof vi.fn>;
|
||||
commit: ReturnType<typeof vi.fn>;
|
||||
fetch: ReturnType<typeof vi.fn>;
|
||||
refExists: ReturnType<typeof vi.fn>;
|
||||
rebaseOnto: ReturnType<typeof vi.fn>;
|
||||
rebaseAbort: ReturnType<typeof vi.fn>;
|
||||
listConflicts: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
exportSvc = { export: vi.fn(async () => {}) };
|
||||
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
|
||||
gitInstance = {
|
||||
isRepo: vi.fn(async () => true),
|
||||
hasRemote: vi.fn(async () => true),
|
||||
addAll: vi.fn(async () => {}),
|
||||
hasUncommittedChanges: vi.fn(async () => true),
|
||||
commit: vi.fn(async () => ({ changed: true, sha: 'abc' })),
|
||||
fetch: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
refExists: vi.fn(async () => true),
|
||||
rebaseOnto: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
rebaseAbort: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
listConflicts: vi.fn(async () => []),
|
||||
push: vi.fn(async () => {}),
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
svc = new SyncService(
|
||||
'/tmp/profile',
|
||||
exportSvc as unknown as never,
|
||||
importSvc as unknown as never
|
||||
);
|
||||
});
|
||||
|
||||
it('happy path — 6단계 모두 호출, ok:true', async () => {
|
||||
const r = await svc.sync();
|
||||
expect(exportSvc.export).toHaveBeenCalled();
|
||||
expect(gitInstance.addAll).toHaveBeenCalled();
|
||||
expect(gitInstance.commit).toHaveBeenCalled();
|
||||
expect(gitInstance.fetch).toHaveBeenCalled();
|
||||
expect(gitInstance.rebaseOnto).toHaveBeenCalledWith('origin/main');
|
||||
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.pushed).toBe(true);
|
||||
});
|
||||
|
||||
it('local 변경 없음 → commit skip + 다음 단계 진행', async () => {
|
||||
gitInstance.hasUncommittedChanges.mockResolvedValueOnce(false);
|
||||
const r = await svc.sync();
|
||||
expect(gitInstance.commit).not.toHaveBeenCalled();
|
||||
expect(gitInstance.fetch).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('rebase 실패 → abort + reason=conflict + conflicts 포함 (path + localText/remoteText)', async () => {
|
||||
gitInstance.rebaseOnto.mockResolvedValueOnce({ stdout: '', stderr: 'CONFLICT', exitCode: 1 });
|
||||
gitInstance.listConflicts.mockResolvedValueOnce(['notes/aaa.md', 'notes/bbb.md']);
|
||||
// Cut E final review fix — runSync calls git.run(['show', ':2:path']) and ':3:path'
|
||||
// for each conflict. Mock returns ours/theirs text per call.
|
||||
gitInstance.run
|
||||
.mockResolvedValueOnce({ stdout: 'aaa local', stderr: '', exitCode: 0 }) // :2:notes/aaa.md
|
||||
.mockResolvedValueOnce({ stdout: 'aaa remote', stderr: '', exitCode: 0 }) // :3:notes/aaa.md
|
||||
.mockResolvedValueOnce({ stdout: 'bbb local', stderr: '', exitCode: 0 }) // :2:notes/bbb.md
|
||||
.mockResolvedValueOnce({ stdout: 'bbb remote', stderr: '', exitCode: 0 }); // :3:notes/bbb.md
|
||||
const r = await svc.sync();
|
||||
expect(gitInstance.rebaseAbort).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('conflict');
|
||||
expect(r.conflicts).toEqual([
|
||||
{ path: 'notes/aaa.md', localText: 'aaa local', remoteText: 'aaa remote' },
|
||||
{ path: 'notes/bbb.md', localText: 'bbb local', remoteText: 'bbb remote' }
|
||||
]);
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('fetch 실패 → reason 반환', async () => {
|
||||
gitInstance.fetch.mockResolvedValueOnce({ stdout: '', stderr: 'no network', exitCode: 1 });
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toContain('fetch failed');
|
||||
expect(gitInstance.rebaseOnto).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('not configured → ok:false + reason=not_configured', async () => {
|
||||
gitInstance.isRepo.mockResolvedValueOnce(false);
|
||||
const r = await svc.sync();
|
||||
expect(r.ok).toBe(false);
|
||||
expect(r.reason).toBe('not_configured');
|
||||
});
|
||||
});
|
||||
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
60
tests/unit/SyncService.resolveConflict.test.ts
Normal file
@@ -0,0 +1,60 @@
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import { SyncService } from '../../src/main/services/SyncService.js';
|
||||
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
|
||||
describe('SyncService.resolveConflict', () => {
|
||||
let svc: SyncService;
|
||||
let importSvc: { applySyncFromDir: ReturnType<typeof vi.fn> };
|
||||
let gitInstance: {
|
||||
run: ReturnType<typeof vi.fn>;
|
||||
addAll: ReturnType<typeof vi.fn>;
|
||||
push: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
|
||||
beforeEach(() => {
|
||||
importSvc = { applySyncFromDir: vi.fn(async () => ({ changedCount: 0 })) };
|
||||
gitInstance = {
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 })),
|
||||
addAll: vi.fn(async () => {}),
|
||||
push: vi.fn(async () => {})
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
svc = new SyncService('/tmp', {} as never, importSvc as never);
|
||||
});
|
||||
|
||||
it('local 선택 → checkout --ours + add + rebase --continue + push', async () => {
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--ours', 'notes/note-id.md']);
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['rebase', '--continue']);
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('remote 선택 → checkout --theirs + add + rebase --continue + applySyncFromDir + push', async () => {
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'remote');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['checkout', '--theirs', 'notes/note-id.md']);
|
||||
expect(importSvc.applySyncFromDir).toHaveBeenCalled();
|
||||
expect(gitInstance.push).toHaveBeenCalled();
|
||||
expect(r.ok).toBe(true);
|
||||
});
|
||||
|
||||
it('checkout 실패 → ok:false + reason 반환', async () => {
|
||||
gitInstance.run.mockResolvedValueOnce({ stdout: '', stderr: 'fail', exitCode: 1 });
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(r.ok).toBe(false);
|
||||
expect((r as { reason: string }).reason).toContain('checkout failed');
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('rebase --continue 실패 (다른 파일 미해결) → ok:false', async () => {
|
||||
gitInstance.run
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: '', exitCode: 0 }) // checkout
|
||||
.mockResolvedValueOnce({ stdout: '', stderr: 'still unresolved', exitCode: 1 }); // rebase --continue
|
||||
const r = await svc.resolveConflict('notes/note-id.md', 'local');
|
||||
expect(r.ok).toBe(false);
|
||||
expect((r as { reason: string }).reason).toContain('rebase --continue failed');
|
||||
expect(gitInstance.push).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -9,6 +9,7 @@ import { runMigrations } from '@main/db/migrations/index.js';
|
||||
import { NoteRepository } from '@main/repository/NoteRepository.js';
|
||||
import { MediaStore } from '@main/services/MediaStore.js';
|
||||
import { ExportService } from '@main/services/ExportService.js';
|
||||
import { ImportService } from '@main/services/ImportService.js';
|
||||
import { SyncService } from '@main/services/SyncService.js';
|
||||
|
||||
const execFileAsync = promisify(execFile);
|
||||
@@ -47,6 +48,7 @@ describe('SyncService', () => {
|
||||
let repo: NoteRepository;
|
||||
let mediaStore: MediaStore;
|
||||
let exportSvc: ExportService;
|
||||
let importSvc: ImportService;
|
||||
let svc: SyncService;
|
||||
let remoteDir: string | null = null;
|
||||
let prevEnv: NodeJS.ProcessEnv;
|
||||
@@ -73,7 +75,8 @@ describe('SyncService', () => {
|
||||
repo = new NoteRepository(db);
|
||||
mediaStore = new MediaStore(profileDir);
|
||||
exportSvc = new ExportService(repo, mediaStore, () => new Date('2026-04-26T12:00:00Z'));
|
||||
svc = new SyncService(profileDir, exportSvc, () => new Date('2026-04-26T12:00:00Z'));
|
||||
importSvc = new ImportService(repo, mediaStore);
|
||||
svc = new SyncService(profileDir, exportSvc, importSvc, () => new Date('2026-04-26T12:00:00Z'));
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
@@ -110,7 +113,7 @@ describe('SyncService', () => {
|
||||
expect(r.ok).toBe(true);
|
||||
expect(r.changed).toBe(true);
|
||||
expect(r.pushed).toBe(true);
|
||||
expect(r.sha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(r.localSha).toMatch(/^[0-9a-f]{40}$/);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'manifest.json'))).toBe(true);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'notes'))).toBe(true);
|
||||
expect(existsSync(join(svc.getSyncDir(), 'index.jsonl'))).toBe(true);
|
||||
@@ -122,10 +125,11 @@ describe('SyncService', () => {
|
||||
const first = await svc.sync();
|
||||
expect(first.ok).toBe(true);
|
||||
expect(first.changed).toBe(true);
|
||||
// Re-sync without DB change. With fixed now() → identical files → git sees no change.
|
||||
// Re-sync without DB change. With fixed now() → identical files → git sees no local change.
|
||||
// New bidirectional flow: always does fetch+rebase+re-import+push.
|
||||
const second = await svc.sync();
|
||||
expect(second.ok).toBe(true);
|
||||
expect(second.changed).toBe(false);
|
||||
expect(second.pushed).toBe(false);
|
||||
expect(second.changed).toBe(false); // no local commit + importedCount=0
|
||||
expect(second.pushed).toBe(true); // push always runs on success
|
||||
});
|
||||
});
|
||||
|
||||
72
tests/unit/SyncTimer.test.ts
Normal file
72
tests/unit/SyncTimer.test.ts
Normal file
@@ -0,0 +1,72 @@
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
||||
import { SyncTimer } from '../../src/main/services/SyncTimer.js';
|
||||
|
||||
describe('SyncTimer', () => {
|
||||
let syncSvc: { sync: ReturnType<typeof vi.fn> };
|
||||
let settings: {
|
||||
isAutoSyncEnabled: ReturnType<typeof vi.fn>;
|
||||
getSyncIntervalMin: ReturnType<typeof vi.fn>;
|
||||
getSyncRepoUrl: ReturnType<typeof vi.fn>;
|
||||
};
|
||||
let timer: SyncTimer;
|
||||
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers();
|
||||
syncSvc = { sync: vi.fn(async () => ({ ok: true })) };
|
||||
settings = {
|
||||
isAutoSyncEnabled: vi.fn(async () => true),
|
||||
getSyncIntervalMin: vi.fn(async () => 5),
|
||||
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git')
|
||||
};
|
||||
timer = new SyncTimer(syncSvc as never, settings as never);
|
||||
});
|
||||
|
||||
afterEach(() => {
|
||||
timer.stop();
|
||||
vi.useRealTimers();
|
||||
});
|
||||
|
||||
it('start — interval 마다 syncSvc.sync 호출', async () => {
|
||||
await timer.start();
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('auto disabled → 시작 안 함 (sync 0회)', async () => {
|
||||
settings.isAutoSyncEnabled.mockResolvedValueOnce(false);
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('repo URL 미설정 → 시작 안 함', async () => {
|
||||
settings.getSyncRepoUrl.mockResolvedValueOnce(null);
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('reconfigure — stop + 새 interval 로 start', async () => {
|
||||
await timer.start();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
|
||||
settings.getSyncIntervalMin.mockResolvedValueOnce(10);
|
||||
await timer.reconfigure();
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
// not enough time for new interval — still 1 call
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(1);
|
||||
await vi.advanceTimersByTimeAsync(5 * 60 * 1000);
|
||||
expect(syncSvc.sync).toHaveBeenCalledTimes(2);
|
||||
});
|
||||
|
||||
it('stop — 호출 후 더 이상 sync 발생 안 함', async () => {
|
||||
await timer.start();
|
||||
timer.stop();
|
||||
await vi.advanceTimersByTimeAsync(60 * 60 * 1000);
|
||||
expect(syncSvc.sync).not.toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
121
tests/unit/VisionDetect.test.ts
Normal file
121
tests/unit/VisionDetect.test.ts
Normal file
@@ -0,0 +1,121 @@
|
||||
import { describe, it, expect, vi } from 'vitest';
|
||||
import { isVisionCapable, refreshVisionCache } from '@main/services/VisionDetect.js';
|
||||
import type { OllamaModel } from '@main/services/VisionDetect.js';
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// isVisionCapable
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('isVisionCapable', () => {
|
||||
it('returns true when details.family is in VISION_FAMILIES', () => {
|
||||
const model: OllamaModel = { name: 'some-model', details: { family: 'llava' } };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when details.families contains a vision family', () => {
|
||||
const model: OllamaModel = { name: 'some-model', details: { families: ['text', 'minicpm-v'] } };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when name contains a vision hint (case-insensitive)', () => {
|
||||
const model: OllamaModel = { name: 'My-Vision-Model:latest' };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true when name contains "vl" hint', () => {
|
||||
const model: OllamaModel = { name: 'qwen2-vl:7b' };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns false for a plain text model with no vision signals', () => {
|
||||
const model: OllamaModel = { name: 'gemma2:9b', details: { family: 'gemma', families: ['gemma'] } };
|
||||
expect(isVisionCapable(model)).toBe(false);
|
||||
});
|
||||
|
||||
// v0.3.1 Cut F final fix — gemma family default 정정. gemma4 도 vision-capable hint.
|
||||
it('returns true for gemma4 family (future-proof)', () => {
|
||||
const model: OllamaModel = { name: 'gemma4-vision:e4b', details: { family: 'gemma4' } };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
|
||||
it('returns true for gemma4 in name hints (no family)', () => {
|
||||
const model: OllamaModel = { name: 'custom-gemma4:latest' };
|
||||
expect(isVisionCapable(model)).toBe(true);
|
||||
});
|
||||
});
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// refreshVisionCache
|
||||
// ---------------------------------------------------------------------------
|
||||
describe('refreshVisionCache', () => {
|
||||
function makeSettings(overrides: Partial<{
|
||||
isAiEnabled: boolean;
|
||||
setCalled: { models: string[]; at: Date } | null;
|
||||
}> = {}) {
|
||||
const setCalled: { models: string[]; at: Date } | null = null;
|
||||
const settings = {
|
||||
isAiEnabled: vi.fn().mockResolvedValue(overrides.isAiEnabled ?? true),
|
||||
setVisionCapableCache: vi.fn().mockImplementation(async () => undefined),
|
||||
};
|
||||
return settings;
|
||||
}
|
||||
|
||||
it('returns ok:false with reason "ai_disabled" when AI is off', async () => {
|
||||
const settings = makeSettings({ isAiEnabled: false });
|
||||
const result = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: 'ai_disabled' });
|
||||
expect(settings.setVisionCapableCache).not.toHaveBeenCalled();
|
||||
});
|
||||
|
||||
it('returns ok:false with http reason on non-ok response', async () => {
|
||||
const settings = makeSettings();
|
||||
const fetchImpl = vi.fn().mockResolvedValue({ ok: false, status: 503 });
|
||||
const result = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
fetchImpl: fetchImpl as never,
|
||||
});
|
||||
expect(result).toEqual({ ok: false, reason: 'tags http 503' });
|
||||
});
|
||||
|
||||
it('returns ok:false with unreachable reason on fetch throw', async () => {
|
||||
const settings = makeSettings();
|
||||
const fetchImpl = vi.fn().mockRejectedValue(new Error('ECONNREFUSED'));
|
||||
const result = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
fetchImpl: fetchImpl as never,
|
||||
});
|
||||
expect(result.ok).toBe(false);
|
||||
if (!result.ok) expect(result.reason).toMatch(/unreachable/);
|
||||
});
|
||||
|
||||
it('filters vision-capable models, persists cache, returns ok:true + models', async () => {
|
||||
const settings = makeSettings();
|
||||
const fixedNow = new Date('2026-05-09T00:00:00.000Z');
|
||||
const responseBody = {
|
||||
models: [
|
||||
{ name: 'llava:13b', details: { family: 'llava' } },
|
||||
{ name: 'gemma2:9b', details: { family: 'gemma' } },
|
||||
{ name: 'qwen2-vl:7b' },
|
||||
],
|
||||
};
|
||||
const fetchImpl = vi.fn().mockResolvedValue({
|
||||
ok: true,
|
||||
json: () => Promise.resolve(responseBody),
|
||||
});
|
||||
const result = await refreshVisionCache({
|
||||
settings: settings as never,
|
||||
endpoint: 'http://localhost:11434',
|
||||
fetchImpl: fetchImpl as never,
|
||||
now: () => fixedNow,
|
||||
});
|
||||
expect(result).toEqual({ ok: true, models: ['llava:13b', 'qwen2-vl:7b'] });
|
||||
expect(settings.setVisionCapableCache).toHaveBeenCalledWith(
|
||||
['llava:13b', 'qwen2-vl:7b'],
|
||||
fixedNow
|
||||
);
|
||||
});
|
||||
});
|
||||
75
tests/unit/VisionSection.test.tsx
Normal file
75
tests/unit/VisionSection.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockGet, mockSet, mockRefresh } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockSet: vi.fn(),
|
||||
mockRefresh: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getVisionModels: mockGet,
|
||||
setVisionModel: mockSet,
|
||||
refreshVisionCache: mockRefresh
|
||||
}
|
||||
}));
|
||||
|
||||
import { VisionSection } from '../../src/renderer/inbox/components/settings/VisionSection';
|
||||
|
||||
describe('VisionSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockGet.mockResolvedValue({
|
||||
models: ['gemma3:12b-vision', 'llava:13b'],
|
||||
at: '2026-05-10T05:00:00Z',
|
||||
selected: 'gemma3:12b-vision'
|
||||
});
|
||||
mockSet.mockResolvedValue({ ok: true });
|
||||
mockRefresh.mockResolvedValue({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
|
||||
});
|
||||
|
||||
it('open 시 cache 로드 + dropdown 옵션 표시 + 선택된 모델 default', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('이미지 분석 모델')).toHaveValue('gemma3:12b-vision');
|
||||
});
|
||||
expect(screen.getByText('gemma3:12b-vision')).toBeInTheDocument();
|
||||
expect(screen.getByText('llava:13b')).toBeInTheDocument();
|
||||
expect(screen.getByText(/마지막 감지/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dropdown 변경 → setVisionModel 호출', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
|
||||
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: 'llava:13b' } });
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('llava:13b');
|
||||
});
|
||||
});
|
||||
|
||||
it('비활성 선택 → setVisionModel(null)', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
|
||||
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: '' } });
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('다시 감지 클릭 → refreshVisionCache 호출 + 결과 표시', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /다시 감지/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /다시 감지/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/감지 완료/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
@@ -22,6 +22,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -122,6 +127,54 @@ describe('composeFrontmatter', () => {
|
||||
expect(fm).toContain('mime: image/png');
|
||||
expect(fm).toContain('bytes: 1234');
|
||||
});
|
||||
|
||||
it('always emits status: active for a default note', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).toContain('status: active');
|
||||
});
|
||||
|
||||
it('emits due_date and due_date_source together when dueDate present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: true });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: user');
|
||||
});
|
||||
|
||||
it('emits due_date_source: ai when dueDateEditedByUser is false', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, dueDate: '2026-06-01', dueDateEditedByUser: false });
|
||||
expect(fm).toContain('due_date: 2026-06-01');
|
||||
expect(fm).toContain('due_date_source: ai');
|
||||
});
|
||||
|
||||
it('omits due_date and due_date_source when dueDate is null', () => {
|
||||
const fm = composeFrontmatter(baseNote);
|
||||
expect(fm).not.toContain('due_date:');
|
||||
expect(fm).not.toContain('due_date_source:');
|
||||
});
|
||||
|
||||
it('emits move_reason when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, status: 'archived', moveReason: 'done for now' });
|
||||
expect(fm).toContain('status: archived');
|
||||
expect(fm).toContain('move_reason: done for now');
|
||||
});
|
||||
|
||||
it('emits status_changed_at when present', () => {
|
||||
const fm = composeFrontmatter({ ...baseNote, statusChangedAt: '2026-05-01T00:00:00Z' });
|
||||
expect(fm).toContain('status_changed_at: 2026-05-01T00:00:00Z');
|
||||
});
|
||||
|
||||
it('status/due_date/move_reason fields appear before images: in frontmatter', () => {
|
||||
const fm = composeFrontmatter({
|
||||
...baseNote,
|
||||
dueDate: '2026-06-01',
|
||||
dueDateEditedByUser: false,
|
||||
media: [{ rel: 'media/014a3b9c__1.png', mime: 'image/png', bytes: 1 }]
|
||||
});
|
||||
const statusPos = fm.indexOf('status:');
|
||||
const imagesPos = fm.indexOf('images:');
|
||||
expect(statusPos).toBeGreaterThan(-1);
|
||||
expect(imagesPos).toBeGreaterThan(-1);
|
||||
expect(statusPos).toBeLessThan(imagesPos);
|
||||
});
|
||||
});
|
||||
|
||||
describe('composeMarkdown', () => {
|
||||
|
||||
@@ -18,6 +18,11 @@ const baseNote: ExportNote = {
|
||||
aiGeneratedAt: '2026-04-25T14:23:34.000Z',
|
||||
userIntent: null,
|
||||
intentPromptedAt: null,
|
||||
status: 'active',
|
||||
statusChangedAt: null,
|
||||
moveReason: null,
|
||||
dueDate: null,
|
||||
dueDateEditedByUser: false,
|
||||
tags: [{ name: 'pr', source: 'ai' }, { name: 'review', source: 'user' }],
|
||||
media: []
|
||||
};
|
||||
@@ -180,6 +185,66 @@ describe('parseExportNote — provenance', () => {
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — status/dueDate/moveReason round-trip (v0.3.0 Cut E)', () => {
|
||||
it('round-trips status=active (default)', () => {
|
||||
const md = composeMarkdown(baseNote);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.statusChangedAt).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('round-trips status=archived with statusChangedAt and moveReason', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
status: 'archived',
|
||||
statusChangedAt: '2026-05-01T10:00:00Z',
|
||||
moveReason: 'project done'
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('archived');
|
||||
expect(parsed.statusChangedAt).toBe('2026-05-01T10:00:00Z');
|
||||
expect(parsed.moveReason).toBe('project done');
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=true', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-06-15',
|
||||
dueDateEditedByUser: true
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-06-15');
|
||||
expect(parsed.dueDateEditedByUser).toBe(true);
|
||||
});
|
||||
|
||||
it('round-trips dueDate with dueDateEditedByUser=false (ai source)', () => {
|
||||
const note: ExportNote = {
|
||||
...baseNote,
|
||||
dueDate: '2026-07-01',
|
||||
dueDateEditedByUser: false
|
||||
};
|
||||
const md = composeMarkdown(note);
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.dueDate).toBe('2026-07-01');
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
|
||||
it('defaults to status=active for older exports without status field', () => {
|
||||
// Simulate a pre-Cut E export that has no status line
|
||||
const md = `---\nid: 014a3b9c-1234-7890-abcd-000000000001\ncreated_at: 2026-04-25T14:23:11.000Z\nupdated_at: 2026-04-25T14:24:02.000Z\ntitle: t\ntitle_source: ai\nsummary: s\nsummary_source: ai\ninkling_export_version: 1\n---\n\n# t\n\n> s\n\nbody\n`;
|
||||
const parsed = parseExportNote(md);
|
||||
expect(parsed.status).toBe('active');
|
||||
expect(parsed.dueDate).toBeNull();
|
||||
expect(parsed.moveReason).toBeNull();
|
||||
expect(parsed.dueDateEditedByUser).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
describe('parseExportNote — edge cases', () => {
|
||||
it('preserves user_intent when present', () => {
|
||||
const md = composeMarkdown({
|
||||
|
||||
250
tests/unit/sync-ipc.test.ts
Normal file
250
tests/unit/sync-ipc.test.ts
Normal file
@@ -0,0 +1,250 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
|
||||
import electron from 'electron';
|
||||
import { GitClient } from '../../src/main/services/GitClient.js';
|
||||
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
|
||||
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
const gitInstance = {
|
||||
isRepo: vi.fn(async () => false),
|
||||
hasRemote: vi.fn(async () => false),
|
||||
run: vi.fn(async () => ({ stdout: '', stderr: '', exitCode: 0 }))
|
||||
};
|
||||
(GitClient as unknown as ReturnType<typeof vi.fn>).mockImplementation(function () { return gitInstance; });
|
||||
|
||||
const syncSvc = {
|
||||
getSyncDir: vi.fn(() => '/tmp/sync'),
|
||||
listConflicts: vi.fn(() => [] as { path: string; localText: string; remoteText: string }[]),
|
||||
resolveConflict: vi.fn(async () => ({ ok: true as const })),
|
||||
getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null }))
|
||||
};
|
||||
const settings = {
|
||||
getSyncRepoUrl: vi.fn(async () => 'git@host:u/r.git'),
|
||||
setSyncRepoUrl: vi.fn(async () => {}),
|
||||
isAutoSyncEnabled: vi.fn(async () => false),
|
||||
getSyncIntervalMin: vi.fn(async () => 30),
|
||||
getAll: vi.fn(async () => ({})),
|
||||
setAiEnabled: vi.fn(async () => {}),
|
||||
setOnboardingCompleted: vi.fn(async () => {}),
|
||||
isAiEnabled: vi.fn(async () => true)
|
||||
};
|
||||
|
||||
const deps: Partial<SettingsIpcDeps> = {
|
||||
backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never,
|
||||
exportSvc: {} as never,
|
||||
importSvc: {} as never,
|
||||
syncSvc: syncSvc as never,
|
||||
telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never,
|
||||
settings: settings as never,
|
||||
getInboxWindow: () => null
|
||||
};
|
||||
|
||||
return { gitInstance, syncSvc, settings, deps };
|
||||
}
|
||||
|
||||
describe('sync IPC channels', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('5 sync channels registered', () => {
|
||||
const { deps } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const channels = handle.mock.calls.map((c) => c[0]);
|
||||
expect(channels).toContain('settings:configure-sync');
|
||||
expect(channels).toContain('settings:test-sync-connection');
|
||||
expect(channels).toContain('sync:list-conflicts');
|
||||
expect(channels).toContain('sync:resolve-conflict');
|
||||
expect(channels).toContain('sync:get-status');
|
||||
});
|
||||
|
||||
describe('settings:configure-sync', () => {
|
||||
it('null URL → setSyncRepoUrl(null), no git init', async () => {
|
||||
const { deps, settings, gitInstance } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, null);
|
||||
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
|
||||
expect(gitInstance.run).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('empty string URL → treated as null', async () => {
|
||||
const { deps, settings, gitInstance } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, ' ');
|
||||
expect(settings.setSyncRepoUrl).toHaveBeenCalledWith(null);
|
||||
expect(gitInstance.run).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid URL → isRepo=false → git init + remote add', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
gitInstance.hasRemote.mockResolvedValue(false);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['init']);
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'add', 'origin', 'git@github.com:user/repo.git']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid URL → isRepo=true, hasRemote=true → remote set-url', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.hasRemote.mockResolvedValue(true);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/new-repo.git');
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['remote', 'set-url', 'origin', 'git@github.com:user/new-repo.git']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('git init failure → ok: false', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'permission denied', exitCode: 1 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('git init failed') });
|
||||
});
|
||||
|
||||
it('setSyncRepoUrl throws → ok: false', async () => {
|
||||
const { deps, settings } = makeDeps();
|
||||
settings.setSyncRepoUrl.mockRejectedValue(new Error('disk full'));
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:configure-sync');
|
||||
const r = await h({}, 'git@github.com:user/repo.git');
|
||||
expect(r).toMatchObject({ ok: false, reason: expect.stringContaining('persist failed') });
|
||||
});
|
||||
});
|
||||
|
||||
describe('settings:test-sync-connection', () => {
|
||||
it('not initialized → ok: false, reason: not_initialized', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(false);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(r).toEqual({ ok: false, reason: 'not_initialized' });
|
||||
});
|
||||
|
||||
it('ls-remote success → ok: true', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.run.mockResolvedValue({ stdout: 'abc123\trefs/heads/main', stderr: '', exitCode: 0 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(gitInstance.run).toHaveBeenCalledWith(['ls-remote', 'origin']);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('ls-remote failure → ok: false', async () => {
|
||||
const { deps, gitInstance } = makeDeps();
|
||||
gitInstance.isRepo.mockResolvedValue(true);
|
||||
gitInstance.run.mockResolvedValue({ stdout: '', stderr: 'connection refused', exitCode: 128 });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:test-sync-connection');
|
||||
const r = await h({});
|
||||
expect(r).toMatchObject({ ok: false, reason: 'connection refused' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:list-conflicts', () => {
|
||||
it('returns syncSvc.listConflicts() result', () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
const conflicts = [{ path: 'notes/abc.md', localText: 'local', remoteText: 'remote' }];
|
||||
syncSvc.listConflicts.mockReturnValue(conflicts);
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:list-conflicts');
|
||||
const r = h({});
|
||||
expect(r).toEqual(conflicts);
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:resolve-conflict', () => {
|
||||
it('valid choice "local" → delegates to syncSvc.resolveConflict (path)', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
const r = await h({}, 'notes/note-1.md', 'local');
|
||||
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-1.md', 'local');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('valid choice "remote" → delegates to syncSvc.resolveConflict (path)', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
await h({}, 'notes/note-2.md', 'remote');
|
||||
expect(syncSvc.resolveConflict).toHaveBeenCalledWith('notes/note-2.md', 'remote');
|
||||
});
|
||||
|
||||
it('invalid choice → ok: false, reason: invalid choice', async () => {
|
||||
const { deps, syncSvc } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:resolve-conflict');
|
||||
const r = await h({}, 'notes/note-1.md', 'both');
|
||||
expect(syncSvc.resolveConflict).not.toHaveBeenCalled();
|
||||
expect(r).toEqual({ ok: false, reason: 'invalid choice' });
|
||||
});
|
||||
});
|
||||
|
||||
describe('sync:get-status', () => {
|
||||
it('auto-sync disabled → nextAt: null', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(false);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const r = await h({});
|
||||
expect(r).toMatchObject({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true }, nextAt: null });
|
||||
});
|
||||
|
||||
it('auto-sync enabled → nextAt computed from lastAt + interval', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(true);
|
||||
settings.getSyncIntervalMin.mockResolvedValue(30);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: '2026-05-09T10:00:00.000Z', lastResult: { ok: true } });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const r = (await h({})) as { lastAt: string; nextAt: string };
|
||||
const expectedNextAt = new Date(new Date('2026-05-09T10:00:00.000Z').getTime() + 30 * 60 * 1000).toISOString();
|
||||
expect(r.nextAt).toBe(expectedNextAt);
|
||||
});
|
||||
|
||||
it('no previous sync + auto-sync enabled → nextAt based on Date.now()', async () => {
|
||||
const { deps, syncSvc, settings } = makeDeps();
|
||||
settings.isAutoSyncEnabled.mockResolvedValue(true);
|
||||
settings.getSyncIntervalMin.mockResolvedValue(15);
|
||||
syncSvc.getLastStatus.mockReturnValue({ lastAt: null, lastResult: null });
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('sync:get-status');
|
||||
const before = Date.now();
|
||||
const r = (await h({})) as { lastAt: null; nextAt: string };
|
||||
const after = Date.now();
|
||||
const nextAtMs = new Date(r.nextAt).getTime();
|
||||
expect(nextAtMs).toBeGreaterThanOrEqual(before + 15 * 60 * 1000);
|
||||
expect(nextAtMs).toBeLessThanOrEqual(after + 15 * 60 * 1000);
|
||||
expect(r.lastAt).toBeNull();
|
||||
});
|
||||
});
|
||||
});
|
||||
125
tests/unit/vision-ipc.test.ts
Normal file
125
tests/unit/vision-ipc.test.ts
Normal file
@@ -0,0 +1,125 @@
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
||||
|
||||
vi.mock('electron', () => ({ default: { ipcMain: { handle: vi.fn() }, dialog: {}, shell: {} } }));
|
||||
vi.mock('../../src/main/services/VisionDetect.js', () => ({
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: ['gemma3:12b-vision'] }))
|
||||
}));
|
||||
vi.mock('../../src/main/services/GitClient.js');
|
||||
|
||||
import electron from 'electron';
|
||||
import { refreshVisionCache } from '../../src/main/services/VisionDetect.js';
|
||||
import { registerSettingsApi } from '../../src/main/ipc/settingsApi.js';
|
||||
import type { SettingsIpcDeps } from '../../src/main/ipc/settingsApi.js';
|
||||
|
||||
function getHandler(channel: string): (...args: unknown[]) => unknown {
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const call = handle.mock.calls.find((c) => c[0] === channel);
|
||||
if (!call) throw new Error(`channel ${channel} not registered`);
|
||||
return call[1] as (...args: unknown[]) => unknown;
|
||||
}
|
||||
|
||||
function makeDeps() {
|
||||
const settings = {
|
||||
getVisionModel: vi.fn(async () => 'gemma3:12b-vision'),
|
||||
setVisionModel: vi.fn(async () => {}),
|
||||
getVisionCapableCache: vi.fn(async () => ({
|
||||
models: ['gemma3:12b-vision', 'llava:13b'],
|
||||
at: '2026-05-10T05:00:00Z'
|
||||
})),
|
||||
setVisionCapableCache: vi.fn(async () => {}),
|
||||
// existing methods used by other handlers
|
||||
getAll: vi.fn(async () => ({
|
||||
ollama: { endpoint: 'http://localhost:11434', model: 'gemma2:2b' }
|
||||
})),
|
||||
setAiEnabled: vi.fn(async () => {}),
|
||||
setOnboardingCompleted: vi.fn(async () => {}),
|
||||
isAiEnabled: vi.fn(async () => true),
|
||||
getSyncRepoUrl: vi.fn(async () => null),
|
||||
setSyncRepoUrl: vi.fn(async () => {}),
|
||||
isAutoSyncEnabled: vi.fn(async () => false),
|
||||
getSyncIntervalMin: vi.fn(async () => 30),
|
||||
setSyncIntervalMin: vi.fn(async () => {}),
|
||||
setAutoSyncEnabled: vi.fn(async () => {})
|
||||
};
|
||||
|
||||
const syncSvc = {
|
||||
getSyncDir: vi.fn(() => '/tmp/sync'),
|
||||
listConflicts: vi.fn(() => []),
|
||||
resolveConflict: vi.fn(async () => ({ ok: true as const })),
|
||||
getLastStatus: vi.fn(() => ({ lastAt: null as string | null, lastResult: null as { ok: boolean } | null }))
|
||||
};
|
||||
|
||||
const deps: Partial<SettingsIpcDeps> = {
|
||||
backup: { runDaily: vi.fn(async () => ({ snapshotted: false })) } as never,
|
||||
exportSvc: {} as never,
|
||||
importSvc: {} as never,
|
||||
syncSvc: syncSvc as never,
|
||||
telemetry: { exportTo: vi.fn(async () => ({ eventCount: 0 })) } as never,
|
||||
settings: settings as never,
|
||||
getInboxWindow: () => null
|
||||
};
|
||||
|
||||
return { settings, syncSvc, deps };
|
||||
}
|
||||
|
||||
describe('vision IPC channels', () => {
|
||||
beforeEach(() => {
|
||||
(electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle.mockClear();
|
||||
vi.clearAllMocks();
|
||||
});
|
||||
|
||||
it('3 vision channels registered', () => {
|
||||
const { deps } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
|
||||
const handle = (electron.ipcMain as unknown as { handle: ReturnType<typeof vi.fn> }).handle;
|
||||
const channels = handle.mock.calls.map((c) => c[0]);
|
||||
expect(channels).toContain('settings:get-vision-models');
|
||||
expect(channels).toContain('settings:set-vision-model');
|
||||
expect(channels).toContain('settings:refresh-vision-cache');
|
||||
});
|
||||
|
||||
it('settings:get-vision-models returns { models, at, selected } from settings', async () => {
|
||||
const { deps, settings } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:get-vision-models');
|
||||
const r = await h({});
|
||||
expect(settings.getVisionCapableCache).toHaveBeenCalled();
|
||||
expect(settings.getVisionModel).toHaveBeenCalled();
|
||||
expect(r).toEqual({
|
||||
models: ['gemma3:12b-vision', 'llava:13b'],
|
||||
at: '2026-05-10T05:00:00Z',
|
||||
selected: 'gemma3:12b-vision'
|
||||
});
|
||||
});
|
||||
|
||||
it('settings:set-vision-model calls settings.setVisionModel(value) + returns { ok: true }', async () => {
|
||||
const { deps, settings } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:set-vision-model');
|
||||
const r = await h({}, 'llava:13b');
|
||||
expect(settings.setVisionModel).toHaveBeenCalledWith('llava:13b');
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
|
||||
it('settings:refresh-vision-cache calls refreshVisionCache and returns result', async () => {
|
||||
const { deps } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:refresh-vision-cache');
|
||||
const r = await h({});
|
||||
expect(refreshVisionCache).toHaveBeenCalledWith({
|
||||
settings: deps.settings,
|
||||
endpoint: 'http://localhost:11434'
|
||||
});
|
||||
expect(r).toEqual({ ok: true, models: ['gemma3:12b-vision'] });
|
||||
});
|
||||
|
||||
it('settings:set-vision-model with null clears the value', async () => {
|
||||
const { deps, settings } = makeDeps();
|
||||
registerSettingsApi(deps as SettingsIpcDeps);
|
||||
const h = getHandler('settings:set-vision-model');
|
||||
const r = await h({}, null);
|
||||
expect(settings.setVisionModel).toHaveBeenCalledWith(null);
|
||||
expect(r).toEqual({ ok: true });
|
||||
});
|
||||
});
|
||||
23
tests/unit/visionPrompt.test.ts
Normal file
23
tests/unit/visionPrompt.test.ts
Normal file
@@ -0,0 +1,23 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { buildVisionPrompt } from '@main/ai/visionPrompt.js';
|
||||
|
||||
describe('buildVisionPrompt', () => {
|
||||
it('includes text, todayKst, dueCandidates, and vocab slice when present', () => {
|
||||
const result = buildVisionPrompt(
|
||||
'회의 메모',
|
||||
'2026-05-09',
|
||||
['2026-05-10', '2026-05-15'],
|
||||
['work', 'meeting', 'project', 'todo']
|
||||
);
|
||||
expect(result).toContain('회의 메모');
|
||||
expect(result).toContain('2026-05-09');
|
||||
expect(result).toContain('2026-05-10, 2026-05-15');
|
||||
expect(result).toContain('work, meeting, project, todo');
|
||||
});
|
||||
|
||||
it('uses (이미지만 있음) placeholder when text is empty', () => {
|
||||
const result = buildVisionPrompt('', '2026-05-09', [], []);
|
||||
expect(result).toContain('(이미지만 있음)');
|
||||
expect(result).not.toContain('\n\n\n'); // no double-blank from empty text
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user