diff --git a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md index ee93a44..f18da64 100644 --- a/docs/superpowers/specs/2026-04-25-dogfood-feedback.md +++ b/docs/superpowers/specs/2026-04-25-dogfood-feedback.md @@ -1306,9 +1306,9 @@ app.on('activate', () => { - Risk: Windows 사용자 흐름 변경 — 트레이 한 클릭으로 끝나던 동작이 inbox 열기 → 설정 → 항목 클릭 으로 늘어남. 단, 빈도 낮은 동작 (Ollama 설정 변경, 백업 등) 만 이동하고 자주 쓰는 캡처/보관함 은 트레이 잔류 → 체감 마찰 ↓ 예상. --- -## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🌱 raw — v0.2.8 후보, 큰 design 결정) +## F17. 휴지통의 의미 혼재 — 완료/보관과 버림 구분 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md) -**진행 상태:** 🌱 raw — 본인 dogfood 발견. F18 (사유 입력) 와 강하게 연관. v0.2.8 brainstorm 시 함께 triage. +**진행 상태:** 🚀 promoted → v0.2.9 Cut B. status 4분기 (active/completed/archived/trashed) + AI 자동 분류 버튼 + 자유 텍스트 사유. **발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. @@ -1366,9 +1366,9 @@ app.on('activate', () => { --- -## F18. 메모 휴지통/보관 이동 시 사유 입력 (🌱 raw — v0.2.8 후보, F17 와 묶음) +## F18. 메모 휴지통/보관 이동 시 사유 입력 (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md) -**진행 상태:** 🌱 raw — F17 과 강한 연관. v0.2.8 brainstorm 시 함께 triage. +**진행 상태:** 🚀 promoted → v0.2.9 Cut B. notes.move_reason 자유 텍스트 컬럼 + MoveStatusModal 사유 입력. **발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. @@ -1710,9 +1710,9 @@ app.on('activate', () => { - v0.2.8 narrow scope 에 포함 가치 (1-2일 작업). --- -## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🌱 raw — v0.2.8 후보, 큰 영향) +## F23. 로컬 LLM 활성화 옵션 (Ollama-less 모드) (🚀 promoted → docs/superpowers/specs/2026-05-09-v029-cut-b-design.md) -**진행 상태:** 🌱 raw — Ollama 의존성 옵션화. v0.2.8 brainstorm 시 cut. F17/F19 와 연관. +**진행 상태:** 🚀 promoted → v0.2.9 Cut B. ai_status='disabled' enum + Onboarding wizard + 설정 토글 + Banner/HealthChecker 비활성 + requeueDisabled. **발견:** 2026-05-09 v0.2.7 release 후 본인 dogfood. 사용자 표현: "Ollama 를 쓰지 못하는 환경을 위해 로컬 llm 활성화 옵션을 만들고, Ollama 를 안 쓰는 경우 그냥 원문만 저장하고 보여주도록". diff --git a/package-lock.json b/package-lock.json index c293371..d9ecb20 100644 --- a/package-lock.json +++ b/package-lock.json @@ -1,12 +1,12 @@ { "name": "inkling", - "version": "0.2.7", + "version": "0.2.8", "lockfileVersion": 3, "requires": true, "packages": { "": { "name": "inkling", - "version": "0.2.7", + "version": "0.2.8", "dependencies": { "better-sqlite3": "12.9.0", "electron-log": "5.2.0", diff --git a/package.json b/package.json index 0a19a87..8f2844b 100644 --- a/package.json +++ b/package.json @@ -1,6 +1,6 @@ { "name": "inkling", - "version": "0.2.8", + "version": "0.2.9", "private": true, "description": "Inkling — local-first 한 줄 보관 도구", "author": "altair823 ", diff --git a/src/main/ai/InferenceProvider.ts b/src/main/ai/InferenceProvider.ts index 941d9a7..29b4fe8 100644 --- a/src/main/ai/InferenceProvider.ts +++ b/src/main/ai/InferenceProvider.ts @@ -16,4 +16,10 @@ export interface InferenceProvider { healthCheck(): Promise; /** v0.2.3.1 — 외부에서 in-flight generate 강제 중단. ProviderHolder.replace 시 사용. */ abort?: () => void; + /** + * v0.2.9 Cut B Task 9 — raw JSON 응답 호출. classifyStatus 같은 자체 prompt 호출용. + * Ollama `/api/generate` 의 raw `response` 문자열을 그대로 반환한다 (보통 JSON 문자열). + * 미구현 provider 는 undefined; classifyStatus 는 그 경우 안전 fallback 으로 동작. + */ + generateRaw?: (prompt: string) => Promise; } diff --git a/src/main/ai/LocalOllamaProvider.ts b/src/main/ai/LocalOllamaProvider.ts index b276b23..ea43858 100644 --- a/src/main/ai/LocalOllamaProvider.ts +++ b/src/main/ai/LocalOllamaProvider.ts @@ -66,6 +66,39 @@ export class LocalOllamaProvider implements InferenceProvider { this.abortController?.abort(); } + /** + * v0.2.9 Cut B Task 9 — raw JSON 호출 (classifyStatus 등 자체 prompt 용). + * `format: 'json'` + `stream: false` 로 Ollama 가 valid JSON 문자열을 반환하도록 강제. + * abortController / timeout 은 generate() 와 동일 패턴. + */ + async generateRaw(prompt: string): Promise { + this.abortController = new AbortController(); + const timer = setTimeout(() => this.abortController?.abort(), this.timeoutMs); + try { + const res = await request(`${this.endpoint}/api/generate`, { + method: 'POST', + headers: { 'content-type': 'application/json' }, + body: JSON.stringify({ + model: this.model, + prompt, + format: 'json', + stream: false, + options: { temperature: this.temperature, num_predict: this.numPredict } + }), + signal: this.abortController.signal + }); + if (res.statusCode < 200 || res.statusCode >= 300) { + throw new Error(`ollama http ${res.statusCode}`); + } + const body = (await res.body.json()) as { response?: string }; + if (!body.response) throw new Error('missing response field'); + return body.response; + } finally { + clearTimeout(timer); + this.abortController = null; + } + } + async healthCheck(): Promise { try { const res = await request(`${this.endpoint}/api/tags`, { method: 'GET' }); diff --git a/src/main/ai/classifyStatus.ts b/src/main/ai/classifyStatus.ts new file mode 100644 index 0000000..8f87ac1 --- /dev/null +++ b/src/main/ai/classifyStatus.ts @@ -0,0 +1,83 @@ +import type { InferenceProvider } from './InferenceProvider.js'; +import type { NoteStatus } from '@shared/types'; + +export interface ClassifyStatusInput { + provider: InferenceProvider; + rawText: string; + summary: string; + reason: string; +} + +export interface ClassifyStatusOutput { + recommended: NoteStatus; + rationale: string; +} + +const VALID: readonly NoteStatus[] = ['completed', 'archived', 'trashed']; + +const PROMPT_TEMPLATE = `다음 메모를 분류하세요. +가능한 status: +- completed: 작업이 끝났고 더 이상 행동 불필요 +- archived: 장기 보관 — 회수 가능, 지금은 보지 않음 +- trashed: 불필요, 의미 없는 메모 + +JSON 출력만 하세요: { "recommended": "completed|archived|trashed", "rationale": "<한 문장 한국어>" } + +메모 본문: +{{rawText}} + +메모 요약: +{{summary}} + +사용자 이동 사유: +{{reason}}`; + +const FALLBACK: ClassifyStatusOutput = { + recommended: 'archived', + rationale: '판단 실패 — 안전하게 보관 추천' +}; + +/** + * v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천). + * + * provider.generateRaw 가 있으면 raw JSON 응답 사용, 없으면 generate() 재사용 시도 + * (그 경우 응답 형태 불일치로 보통 fallback). 에러/parse 실패 시 'archived' 안전 default + * (사용자 데이터 보존 우선). + */ +export async function classifyStatus( + input: ClassifyStatusInput +): Promise { + const prompt = PROMPT_TEMPLATE + .replace('{{rawText}}', input.rawText.length > 0 ? input.rawText : '(빈 메모)') + .replace('{{summary}}', input.summary.length > 0 ? input.summary : '(요약 없음)') + .replace('{{reason}}', input.reason.length > 0 ? input.reason : '(사유 없음)'); + + let rawJson: string; + try { + if (typeof input.provider.generateRaw === 'function') { + rawJson = await input.provider.generateRaw(prompt); + } else { + // 호환 경로 — provider.generate 가 raw 응답을 노출하지 않으므로 안전 fallback. + return FALLBACK; + } + } catch { + return FALLBACK; + } + + let parsed: unknown; + try { + parsed = JSON.parse(rawJson); + } catch { + return FALLBACK; + } + + if (typeof parsed !== 'object' || parsed === null) return FALLBACK; + const obj = parsed as { recommended?: unknown; rationale?: unknown }; + if (typeof obj.recommended !== 'string' || !VALID.includes(obj.recommended as NoteStatus)) { + return FALLBACK; + } + return { + recommended: obj.recommended as NoteStatus, + rationale: typeof obj.rationale === 'string' ? obj.rationale : '' + }; +} diff --git a/src/main/db/migrations/index.ts b/src/main/db/migrations/index.ts index 61129bd..cbbfc00 100644 --- a/src/main/db/migrations/index.ts +++ b/src/main/db/migrations/index.ts @@ -2,8 +2,10 @@ import type Database from 'better-sqlite3'; import * as m001 from './m001_initial.js'; import * as m002 from './m002_due_date.js'; import * as m003 from './m003_soft_delete.js'; +import * as m004 from './m004_status.js'; +import * as m005 from './m005_ai_disabled.js'; -const migrations = [m001, m002, m003]; +const migrations = [m001, m002, m003, m004, m005]; export function latestVersion(): number { return migrations[migrations.length - 1]!.version; diff --git a/src/main/db/migrations/m004_status.ts b/src/main/db/migrations/m004_status.ts new file mode 100644 index 0000000..2225506 --- /dev/null +++ b/src/main/db/migrations/m004_status.ts @@ -0,0 +1,18 @@ +// v4: status 4분기 (active/completed/archived/trashed) + 사유 + status_changed_at. +// 기존 deleted_at != NULL 노트는 status='trashed' 로 migrate. deleted_at 컬럼은 +// backward compat 위해 잔류 (status='trashed' 와 동기화). 향후 cut 에서 제거 가능. +import type Database from 'better-sqlite3'; + +export const version = 4; + +export function up(db: Database.Database): void { + db.exec(` + ALTER TABLE notes ADD COLUMN status TEXT NOT NULL DEFAULT 'active'; + ALTER TABLE notes ADD COLUMN status_changed_at TEXT; + ALTER TABLE notes ADD COLUMN move_reason TEXT; + `); + db.prepare( + `UPDATE notes SET status='trashed', status_changed_at=deleted_at + WHERE deleted_at IS NOT NULL` + ).run(); +} diff --git a/src/main/db/migrations/m005_ai_disabled.ts b/src/main/db/migrations/m005_ai_disabled.ts new file mode 100644 index 0000000..e97b0e9 --- /dev/null +++ b/src/main/db/migrations/m005_ai_disabled.ts @@ -0,0 +1,65 @@ +// v5: ai_status enum 에 'disabled' 추가 (v0.2.9 Cut B). settings.ai_enabled=false 일 때 +// CaptureService 가 새 노트를 ai_status='disabled' 로 insert + pending_jobs enqueue skip. +// +// SQLite 는 ALTER COLUMN ... CHECK 미지원 → table recreate 패턴. +// 외래키 (note_tags / media / pending_jobs) 는 notes.id 를 참조 + ON DELETE CASCADE 라 +// FK off + DROP/RENAME 시 데이터 보존 위해 새 테이블 생성 → INSERT SELECT → DROP old → RENAME new. +// PRAGMA foreign_keys=OFF 안에서 single transaction (runMigrations 가 transaction 으로 감쌈). +import type Database from 'better-sqlite3'; + +export const version = 5; + +export function up(db: Database.Database): void { + // 기존 인덱스/CHECK 제약을 그대로 유지하되 ai_status 만 'disabled' 추가. + db.exec(` + PRAGMA foreign_keys=OFF; + CREATE TABLE notes_new ( + id TEXT PRIMARY KEY, + raw_text TEXT NOT NULL, + ai_title TEXT, + ai_summary TEXT, + ai_status TEXT NOT NULL + CHECK (ai_status IN ('pending','done','failed','disabled')), + ai_error TEXT, + ai_provider TEXT, + ai_generated_at TEXT, + title_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (title_edited_by_user IN (0,1)), + summary_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (summary_edited_by_user IN (0,1)), + user_intent TEXT, + intent_prompted_at TEXT, + created_at TEXT NOT NULL, + updated_at TEXT NOT NULL, + due_date TEXT, + due_date_edited_by_user INTEGER NOT NULL DEFAULT 0 + CHECK (due_date_edited_by_user IN (0,1)), + deleted_at TEXT, + last_recalled_at TEXT, + recall_dismissed_at TEXT, + status TEXT NOT NULL DEFAULT 'active', + status_changed_at TEXT, + move_reason TEXT + ); + INSERT INTO notes_new ( + id, raw_text, ai_title, ai_summary, ai_status, ai_error, 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, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + ) + SELECT + id, raw_text, ai_title, ai_summary, ai_status, ai_error, 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, + deleted_at, last_recalled_at, recall_dismissed_at, + status, status_changed_at, move_reason + FROM notes; + DROP TABLE notes; + ALTER TABLE notes_new RENAME TO notes; + CREATE INDEX idx_notes_created_at ON notes(created_at DESC); + CREATE INDEX idx_notes_ai_status ON notes(ai_status); + CREATE INDEX IF NOT EXISTS idx_notes_deleted_at ON notes(deleted_at); + PRAGMA foreign_keys=ON; + `); +} diff --git a/src/main/index.ts b/src/main/index.ts index 949d546..8aee3ec 100644 --- a/src/main/index.ts +++ b/src/main/index.ts @@ -122,6 +122,8 @@ app.whenReady().then(async () => { const provider = new LocalOllamaProvider({ endpoint: resolvedEndpoint, model: resolvedModel }); const providerHolder = new ProviderHolder(provider); const health = new HealthChecker(providerHolder, { + // v0.2.9 Cut B Task 14 — AI 비활성 시 health polling skip (Ollama 미설치 환경 무영향). + isAiEnabled: () => settingsSvc.isAiEnabled(), onUpdate: (status) => { logger.info('ai.health', { ...status } as Record); pushOllamaStatus(getInboxWindow, status); @@ -159,14 +161,17 @@ app.whenReady().then(async () => { const capture = new CaptureService(repo, store, { enqueue: (id) => worker.enqueue(id), celebrate: (id) => notify.celebrate(id), - telemetry + telemetry, + settings: settingsSvc }); registerCaptureApi(capture, getQuickCaptureWindow); registerInboxApi({ repo, continuity, capture, health, intent, getInboxWindow, settings: settingsSvc, providerHolder, - paths: { profileDir: paths.profileDir } + paths: { profileDir: paths.profileDir }, + // v0.2.9 Cut B Task 16 — disabled 메모 일괄 재투입 시 in-memory queue 갱신. + enqueue: (id) => worker.enqueue(id) }); // registerSettingsApi 는 backup / exportSvc / importSvc / syncSvc / telemetry 가 // 생성된 뒤에 호출 (Task 10) — 아래 BackupService/ExportService/... 초기화 직후로 이동. @@ -201,7 +206,7 @@ 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, getInboxWindow + backup, exportSvc, importSvc, syncSvc, telemetry, settings: settingsSvc, getInboxWindow }); let backupOnQuitDone = false; diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 4da6e71..6f33776 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -7,9 +7,10 @@ import type { ContinuityService } from '../services/ContinuityService.js'; import type { CaptureService } from '../services/CaptureService.js'; import type { HealthChecker } from '../services/HealthChecker.js'; import type { IntentService } from '../services/IntentService.js'; -import type { Note } from '@shared/types'; +import type { Note, NoteStatus } from '@shared/types'; import type { HealthResult } from '../ai/InferenceProvider.js'; import { LocalOllamaProvider } from '../ai/LocalOllamaProvider.js'; +import { classifyStatus } from '../ai/classifyStatus.js'; import type { SettingsService } from '../services/SettingsService.js'; import type { ProviderHolder } from '../ai/ProviderHolder.js'; @@ -24,6 +25,10 @@ export interface InboxIpcDeps { providerHolder: ProviderHolder; // v0.2.8 Cut A — `inbox:open-media` 의 path traversal 검사 baseline. paths: { profileDir: string }; + // v0.2.9 Cut B Task 16 — disabled 메모 일괄 처리 시 in-memory worker queue 갱신. + // 미주입 시 fire-and-forget skip (다음 launch 의 loadFromDb 가 처리). 본 hook 은 + // AiWorker 인스턴스 직접 주입을 피해 IPC 모듈이 worker import 를 갖지 않도록 분리. + enqueue?: (noteId: string) => Promise; } export function registerInboxApi(deps: InboxIpcDeps): void { @@ -172,6 +177,82 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: true as const }; }); + // v0.2.9 Cut B Task 4 — status 별 노트 목록. + ipcMain.handle( + 'inbox:list-by-status', + (_e, status: NoteStatus, opts: { limit?: number } = {}) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) return [] as Note[]; + return deps.repo.listByStatus(status, opts); + } + ); + + // v0.2.9 Cut B Task 4 — 4 status counts (헤더 4탭 badge). + ipcMain.handle('inbox:counts-by-status', () => ({ + active: deps.repo.countByStatus('active'), + completed: deps.repo.countByStatus('completed'), + archived: deps.repo.countByStatus('archived'), + trashed: deps.repo.countByStatus('trashed') + })); + + // v0.2.9 Cut B Task 8 — status 4분기 직접 전이 (사유 포함). + // Modal 의 "완료/보관/휴지통" 버튼 path. backward compat 동기화는 + // NoteRepository.setStatus 내부에서 처리 (deleted_at sync). + ipcMain.handle( + 'inbox:set-status', + async (_e, id: string, status: NoteStatus, reason: string | null) => { + const VALID: readonly NoteStatus[] = ['active', 'completed', 'archived', 'trashed']; + if (!VALID.includes(status)) { + return { ok: false as const, reason: 'invalid status' as const }; + } + deps.repo.setStatus(id, status, reason); + return { ok: true as const }; + } + ); + + // v0.2.9 Cut B Task 9 — AI 자동 분류 (status 추천). + // Ollama provider.generateRaw 호출 + JSON 응답 파싱. 에러/실패 시 archived fallback + // (사용자 데이터 보존 우선). 자세한 prompt + parse 로직은 src/main/ai/classifyStatus.ts. + ipcMain.handle('ai:classify-status', async (_e, id: string, reason: string) => { + const note = deps.repo.findById(id); + if (note === null) { + return { + recommended: 'archived' as const, + rationale: '메모를 찾을 수 없음 — 안전하게 보관 추천' + }; + } + const provider = deps.providerHolder.get(); + return classifyStatus({ + provider, + rawText: note.rawText, + summary: note.aiSummary ?? '', + reason: typeof reason === 'string' ? reason : '' + }); + }); + + // v0.2.9 Cut B Task 16 — disabled 메모 (ai_enabled OFF 시기 캡처) 일괄 재투입. + // OFF→ON 전환 후 사용자가 "지금 모두 처리" 버튼 클릭 path. repo.requeueDisabled 가 + // ai_status='pending' + pending_jobs row 보장, worker.enqueue 가 in-memory queue 갱신. + ipcMain.handle('inbox:enqueue-disabled', async () => { + // requeue 전 대상 id 수집 — UPDATE 가 status 바꾸므로 select 후 update 필요 없이 + // requeueDisabled 가 처리한 다음 pending_jobs 에서 다시 가져와 enqueue. + const targets = deps.repo.getAllPendingJobs().map((j) => j.noteId); + const before = new Set(targets); + const count = deps.repo.requeueDisabled(); + if (count > 0 && deps.enqueue) { + const after = deps.repo.getAllPendingJobs(); + // requeue 직후 새로 들어온 pending_jobs row 만 enqueue (기존 row 는 이미 in-memory queue 에). + for (const j of after) { + if (!before.has(j.noteId)) { + await deps.enqueue(j.noteId); + } + } + } + return { count }; + }); + + ipcMain.handle('inbox:get-disabled-count', () => deps.repo.countByAiStatus('disabled')); + ipcMain.handle('inbox:saveOllamaSettings', async (_e, value: { endpoint: string; model: string }) => { // 검증: 새 인스턴스로 healthCheck const trial = new LocalOllamaProvider({ endpoint: value.endpoint, model: value.model }); diff --git a/src/main/ipc/settingsApi.ts b/src/main/ipc/settingsApi.ts index c783f17..a1c86ad 100644 --- a/src/main/ipc/settingsApi.ts +++ b/src/main/ipc/settingsApi.ts @@ -8,6 +8,7 @@ import type { ExportService } from '../services/ExportService.js'; import type { ImportService } from '../services/ImportService.js'; import type { SyncService } from '../services/SyncService.js'; import type { TelemetryService } from '../services/TelemetryService.js'; +import type { SettingsService } from '../services/SettingsService.js'; import { collectAutostartState } from '../services/AutostartDiagnostic.js'; import { getInboxWindow as getInboxWindowSingleton } from '../windows/inboxWindow.js'; @@ -33,6 +34,7 @@ export interface SettingsIpcDeps { importSvc: ImportService; syncSvc: SyncService; telemetry: TelemetryService; + settings: SettingsService; getInboxWindow: () => BrowserWindow | null; } @@ -88,7 +90,21 @@ export function registerSettingsApi(deps?: SettingsIpcDeps): void { }); if (!deps) return; - const { backup, exportSvc, importSvc, syncSvc, telemetry, getInboxWindow } = deps; + const { backup, exportSvc, importSvc, syncSvc, telemetry, settings, getInboxWindow } = deps; + + // v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글. + // 첫 launch 시 OnboardingWizard 분기 (App.tsx) 와 SettingsPage 의 ai_enabled 토글 통합. + ipcMain.handle('settings:get', async () => settings.getAll()); + + ipcMain.handle('settings:set-ai-enabled', async (_e, enabled: boolean) => { + await settings.setAiEnabled(enabled); + return { ok: true as const }; + }); + + ipcMain.handle('settings:set-onboarding-completed', async (_e, completed: boolean) => { + await settings.setOnboardingCompleted(completed); + return { ok: true as const }; + }); ipcMain.handle('settings:run-backup', async () => { try { diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 3d47050..679ec69 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -1,9 +1,16 @@ import type Database from 'better-sqlite3'; import { v7 as uuidv7, v4 as uuidv4 } from 'uuid'; -import type { Note, NoteMedia, NoteTag } from '@shared/types'; +import type { AiStatus, Note, NoteMedia, NoteStatus, NoteTag } from '@shared/types'; import { kstTodayIso } from '../../shared/util/kstDate.js'; -export interface CreateNoteInput { rawText: string; } +export interface CreateNoteInput { + rawText: string; + /** + * v0.2.9 Cut B — settings.ai_enabled=false 일 때 'disabled' 로 insert + pending_jobs skip. + * 미지정 시 기존 'pending' default + pending_jobs enqueue (backward compat). + */ + aiStatus?: AiStatus; +} export interface NewMediaRow { noteId: string; @@ -48,15 +55,19 @@ export class NoteRepository { create(input: CreateNoteInput): { id: string } { const id = uuidv7(); const now = new Date().toISOString(); + const aiStatus: AiStatus = input.aiStatus ?? 'pending'; const tx = this.db.transaction(() => { this.db .prepare(`INSERT INTO notes (id, raw_text, ai_status, created_at, updated_at) - VALUES (?, ?, 'pending', ?, ?)`) - .run(id, input.rawText, now, now); - this.db - .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) - VALUES (?, 0, ?)`) - .run(id, now); + VALUES (?, ?, ?, ?, ?)`) + .run(id, input.rawText, aiStatus, now, now); + // pending_jobs 는 'pending' 일 때만 생성 — 'disabled' 노트는 worker 가 처리 안 함. + if (aiStatus === 'pending') { + this.db + .prepare(`INSERT INTO pending_jobs (note_id, attempts, next_run_at) + VALUES (?, 0, ?)`) + .run(id, now); + } }); tx(); return { id }; @@ -205,6 +216,50 @@ export class NoteRepository { return { ids }; } + /** + * v0.2.9 Cut B Task 16 — 모든 ai_status='disabled' 노트를 'pending' 으로 reset 하고 + * pending_jobs 재투입. 사용자가 settings.ai_enabled OFF→ON 전환 후 "지금 모두 처리" + * 버튼을 누른 path. 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). + * + * INSERT OR IGNORE — race 안전 (이미 pending_jobs row 존재 시 skip). + * 반환값 = 처리된 노트 수 (UI 가 "N건 처리됨" 토스트 등 표시용). + */ + requeueDisabled(now: Date = new Date()): number { + const tx = this.db.transaction(() => { + const ts = now.toISOString(); + const targets = this.db + .prepare(`SELECT id FROM notes WHERE ai_status='disabled'`) + .all() as Array<{ id: string }>; + for (const { id } of targets) { + this.db + .prepare(`UPDATE notes SET ai_status='pending', updated_at=? WHERE id=?`) + .run(ts, id); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, ts); + } + return targets.length; + }); + return tx(); + } + + /** + * v0.2.9 Cut B Task 16 — ai_status 별 row count. + * 설정 페이지의 "원문만 저장된 메모 N건" 표기용 (status='disabled' 카운트). + * deleted_at 필터 없음 — disabled 메모도 trash 갈 수 있는데 사용자 의도는 + * "AI 처리할 게 얼마나 남았나?" 라 trashed 까지 포함되면 안 됨. → deleted_at IS NULL 추가. + */ + countByAiStatus(status: AiStatus): number { + const row = this.db + .prepare( + `SELECT COUNT(*) AS c FROM notes WHERE ai_status=? AND deleted_at IS NULL` + ) + .get(status) as { c: number }; + return row.c; + } + /** * pending_jobs 의 next_run_at + last_error 만 갱신, attempts 변경 없음. * v0.2.3 #2 — unreachable/timeout 무한 retry 시 사용 (incrementJobAttempt 와 별도 경로). @@ -410,25 +465,101 @@ export class NoteRepository { .run(now, id); } + /** + * v0.2.9 Cut B — 노트 status 4분기 전이 (active/completed/archived/trashed). + * status + status_changed_at + move_reason + updated_at 갱신 + deleted_at + * backward-compat 동기화 (status='trashed' → deleted_at=ts, 그 외 → NULL). + * + * 단일 transaction. 호출자가 `now` 주입 가능 (테스트성). + */ + setStatus( + id: string, + status: NoteStatus, + reason: string | null, + now: Date = new Date() + ): void { + const tx = this.db.transaction(() => { + const ts = now.toISOString(); + this.db + .prepare( + `UPDATE notes + SET status = ?, + move_reason = ?, + status_changed_at = ?, + updated_at = ? + WHERE id = ?` + ) + .run(status, reason, ts, ts, id); + // backward compat: deleted_at 컬럼은 m004 이후로도 status='trashed' 와 동기화. + if (status === 'trashed') { + this.db.prepare(`UPDATE notes SET deleted_at = ? WHERE id = ?`).run(ts, id); + } else { + this.db.prepare(`UPDATE notes SET deleted_at = NULL WHERE id = ?`).run(id); + } + }); + tx(); + } + + /** + * v0.2.9 Cut B Task 4 — status 별 row count. 4탭 헤더 badge 용. + * tags/media hydrate 없음 (cheap path, listByStatus 와 별도). + */ + countByStatus(status: NoteStatus): number { + const row = this.db + .prepare(`SELECT COUNT(*) AS c FROM notes WHERE status = ?`) + .get(status) as { c: number }; + return row.c; + } + + /** + * v0.2.9 Cut B — status 별 노트 목록. status_changed_at DESC (최근 전이 우선), + * NULL 은 created_at fallback. limit cap 200 (list/listTrashed 와 동일). + */ + listByStatus(status: NoteStatus, opts: { limit?: number } = {}): Note[] { + const limit = Math.max(1, Math.min(200, opts.limit ?? 200)); + const rows = this.db + .prepare( + `SELECT * FROM notes + WHERE status = ? + ORDER BY COALESCE(status_changed_at, created_at) DESC, id DESC + LIMIT ?` + ) + .all(status, limit) as Record[]; + return rows.map((r) => this.hydrate(r)); + } + + /** + * 휴지통에서 active 로 복원. setStatus('active') 로 status + deleted_at 동기화 + + * v0.2.6 #10 round 1 fix 보존 (ai_status='failed' / 'pending' 시 pending_jobs 재투입). + */ restoreNote(id: string): void { const tx = this.db.transaction(() => { - const before = this.db.prepare(`SELECT ai_status FROM notes WHERE id = ?`).get(id) as { ai_status: string } | undefined; + const before = this.db + .prepare(`SELECT ai_status FROM notes WHERE id = ?`) + .get(id) as { ai_status: string } | undefined; + // setStatus('active', null) — reason clear + deleted_at NULL + updated_at 갱신. + this.setStatus(id, 'active', null); const now = new Date().toISOString(); - this.db.prepare(`UPDATE notes SET deleted_at = NULL, updated_at = ? WHERE id = ?`).run(now, id); // v0.2.6 #10 — failed 노트 restore 시 pending 으로 reset + pending_jobs 재생성 if (before?.ai_status === 'failed') { - this.db.prepare( - `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` - ).run(now, id); - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?` + ) + .run(now, id); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } else if (before?.ai_status === 'pending') { // pending 인 채로 trash 됐다면 pending_jobs 도 미정상 상태일 수 있음 — 재생성 (idempotent) - this.db.prepare( - `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` - ).run(id, now); + this.db + .prepare( + `INSERT OR IGNORE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)` + ) + .run(id, now); } // done 노트는 재처리 안 함 (이미 결과 있음) }); @@ -656,7 +787,7 @@ export class NoteRepository { rawText: row.raw_text as string, aiTitle: row.ai_title as string | null, aiSummary: row.ai_summary as string | null, - aiStatus: row.ai_status as 'pending' | 'done' | 'failed', + aiStatus: row.ai_status as AiStatus, aiError: row.ai_error as string | null, aiProvider: row.ai_provider as string | null, aiGeneratedAt: row.ai_generated_at as string | null, @@ -669,6 +800,9 @@ export class NoteRepository { deletedAt: (row.deleted_at as string | null) ?? null, lastRecalledAt: (row.last_recalled_at as string | null) ?? null, recallDismissedAt: (row.recall_dismissed_at as string | null) ?? null, + status: ((row.status as NoteStatus | undefined) ?? 'active'), + statusChangedAt: (row.status_changed_at as string | null) ?? null, + moveReason: (row.move_reason as string | null) ?? null, createdAt: row.created_at as string, updatedAt: row.updated_at as string, tags: tags as NoteTag[], diff --git a/src/main/services/CaptureService.ts b/src/main/services/CaptureService.ts index 4dc03e2..0ed33d7 100644 --- a/src/main/services/CaptureService.ts +++ b/src/main/services/CaptureService.ts @@ -2,6 +2,14 @@ import type { NoteRepository } from '../repository/NoteRepository.js'; import type { MediaStore } from './MediaStore.js'; import type { Note } from '@shared/types'; +/** + * v0.2.9 Cut B — CaptureService 가 ai_enabled 를 조회할 때만 의존하는 좁은 인터페이스. + * SettingsService 직접 의존을 피해 테스트 mock 이 단순해짐 (entire SettingsService 면 불필요). + */ +export interface AiEnabledSource { + isAiEnabled(): Promise; +} + export interface TelemetryEmitter { emit(input: | { kind: 'capture'; payload: { noteId: string; rawTextLength: number; hasMedia: boolean } } @@ -23,6 +31,9 @@ export interface CaptureDeps { enqueue: (noteId: string) => Promise; celebrate: (noteId: string) => void; telemetry?: TelemetryEmitter; + // v0.2.9 Cut B — settings.ai_enabled=false 면 새 노트는 ai_status='disabled' + enqueue skip. + // 미주입 시 기존 동작 (항상 enabled) 보존 — 기존 caller 무영향. + settings?: AiEnabledSource; } export interface SubmitInput { @@ -44,7 +55,12 @@ export class CaptureService { if (trimmed.length === 0 && input.images.length === 0) { throw new Error('empty submission'); } - const { id } = this.repo.create({ rawText: input.text }); + // v0.2.9 Cut B — settings 미주입 시 기본 enabled (backward compat). + const aiEnabled = this.deps.settings ? await this.deps.settings.isAiEnabled() : true; + const { id } = this.repo.create({ + rawText: input.text, + aiStatus: aiEnabled ? 'pending' : 'disabled' + }); if (input.images.length > 0) { const rows = []; for (const img of input.images) { @@ -70,7 +86,9 @@ export class CaptureService { } }).catch(() => {}); } - await this.deps.enqueue(id); + if (aiEnabled) { + await this.deps.enqueue(id); + } this.deps.celebrate(id); return { noteId: id }; } diff --git a/src/main/services/HealthChecker.ts b/src/main/services/HealthChecker.ts index e70ba03..8293cc5 100644 --- a/src/main/services/HealthChecker.ts +++ b/src/main/services/HealthChecker.ts @@ -11,6 +11,9 @@ export interface HealthCheckerOptions { onUpdate?: (status: HealthResult) => void; onTelemetry?: (event: HealthTelemetryEvent) => void; now?: () => number; + // v0.2.9 Cut B Task 14 — settings.ai_enabled=false 면 polling skip. + // 미설정 시 항상 enabled (backward-compat). + isAiEnabled?: () => Promise; } const DEFAULT_INTERVAL_MS = 60_000; @@ -72,8 +75,22 @@ export class HealthChecker { start(): void { if (this.timer !== null) return; - void this.runOnce(); - this.timer = setInterval(() => { void this.runOnce(); }, this.intervalMs); + void this.tickIfEnabled(); + this.timer = setInterval(() => { void this.tickIfEnabled(); }, this.intervalMs); + } + + // v0.2.9 Cut B Task 14 — polling tick. settings.ai_enabled=false 면 skip. + // 수동 runOnce({ manual: true }) 는 이 게이트와 무관하게 항상 실행 (사용자 의도). + private async tickIfEnabled(): Promise { + if (this.opts.isAiEnabled !== undefined) { + try { + const enabled = await this.opts.isAiEnabled(); + if (!enabled) return; + } catch { + // settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지). + } + } + await this.runOnce(); } stop(): void { diff --git a/src/main/services/SettingsService.ts b/src/main/services/SettingsService.ts index 31e4d9e..88a320e 100644 --- a/src/main/services/SettingsService.ts +++ b/src/main/services/SettingsService.ts @@ -8,7 +8,12 @@ const OllamaSettingsSchema = z.object({ }).strict(); const SettingsSchema = z.object({ - ollama: OllamaSettingsSchema.optional() + ollama: OllamaSettingsSchema.optional(), + // v0.2.9 Cut B — AI-less mode toggle. 기존 settings 파일에 없으면 isAiEnabled() 가 + // true 로 fallback (기본 enabled). zod default 는 file 이 존재 + 키 부재일 때만 적용 — + // load() 의 file-missing 분기에선 cache={} 라 isAiEnabled() 의 fallback 이 작동. + ai_enabled: z.boolean().optional(), + onboarding_completed: z.boolean().optional() }).strict(); export type Settings = z.infer; @@ -34,10 +39,49 @@ export class SettingsService { return this.cache; } + /** + * v0.2.9 Cut B Task 12 — settings:get IPC 핸들러용 read-only accessor. + * 첫 launch onboarding 분기에서 onboarding_completed 키 확인. + */ + async getAll(): Promise { + return this.load(); + } + async setOllama(value: OllamaSettings): Promise { const validated = OllamaSettingsSchema.parse(value); const current = await this.load(); const next: Settings = { ...current, ollama: validated }; + await this.persist(next); + } + + /** + * v0.2.9 Cut B — AI-less mode 의 기본값은 enabled (true). 기존 settings 파일을 + * 가진 사용자 (ai_enabled 키 부재) 도 무영향. + */ + async isAiEnabled(): Promise { + const s = await this.load(); + return s.ai_enabled ?? true; + } + + async setAiEnabled(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, ai_enabled: value }; + await this.persist(next); + } + + /** v0.2.9 Cut B — 첫 실행 onboarding completion 표지. 기본 false. */ + async isOnboardingCompleted(): Promise { + const s = await this.load(); + return s.onboarding_completed ?? false; + } + + async setOnboardingCompleted(value: boolean): Promise { + const current = await this.load(); + const next: Settings = { ...current, onboarding_completed: value }; + await this.persist(next); + } + + private async persist(next: Settings): Promise { await mkdir(dirname(this.filePath), { recursive: true }); const tmpPath = this.filePath + '.tmp'; await writeFile(tmpPath, JSON.stringify(next, null, 2), 'utf8'); diff --git a/src/preload/index.ts b/src/preload/index.ts index 6ffe824..33b6d42 100644 --- a/src/preload/index.ts +++ b/src/preload/index.ts @@ -68,6 +68,19 @@ const api: InklingApi = { copyAppInfo: () => ipcRenderer.invoke('settings:copy-app-info'), // v0.2.8 Cut A — 첨부 이미지를 OS 기본 뷰어로 열기 (Task 3). openMedia: (relPath: string) => ipcRenderer.invoke('inbox:open-media', relPath), + // v0.2.9 Cut B Task 4 — status 별 list + counts. + listByStatus: (status, opts) => ipcRenderer.invoke('inbox:list-by-status', status, opts ?? {}), + countsByStatus: () => ipcRenderer.invoke('inbox:counts-by-status'), + // v0.2.9 Cut B Task 8 — 4분기 status 전이 + AI 자동 분류 추천. + setStatus: (id, status, reason) => ipcRenderer.invoke('inbox:set-status', id, status, reason), + classifyStatus: (id, reason) => ipcRenderer.invoke('ai:classify-status', id, reason), + // v0.2.9 Cut B Task 12 — settings read + AI/onboarding 토글 (첫 launch wizard 분기 포함). + getSettings: () => ipcRenderer.invoke('settings:get'), + setAiEnabled: (enabled: boolean) => ipcRenderer.invoke('settings:set-ai-enabled', enabled), + setOnboardingCompleted: (completed: boolean) => ipcRenderer.invoke('settings:set-onboarding-completed', completed), + // v0.2.9 Cut B Task 16 — disabled 메모 재투입 + count. + enqueueDisabled: () => ipcRenderer.invoke('inbox:enqueue-disabled'), + getDisabledCount: () => ipcRenderer.invoke('inbox:get-disabled-count'), } }; diff --git a/src/renderer/inbox/App.tsx b/src/renderer/inbox/App.tsx index 8421d73..071b9e1 100644 --- a/src/renderer/inbox/App.tsx +++ b/src/renderer/inbox/App.tsx @@ -13,6 +13,7 @@ import { ExpiryBanner } from './components/ExpiryBanner.js'; import { FailedBanner } from './components/FailedBanner.js'; import { RecallBanner } from './components/RecallBanner.js'; import { SettingsPage } from './components/SettingsPage.js'; +import { OnboardingWizard } from './components/OnboardingWizard.js'; export function App(): React.ReactElement { const { @@ -23,7 +24,25 @@ export function App(): React.ReactElement { } = useInbox(); const showSettings = useInbox((s) => s.showSettings); const setShowSettings = useInbox((s) => s.setShowSettings); + // v0.2.9 Cut B Task 5 — 4탭 (Inbox/완료/보관/휴지통). + const view = useInbox((s) => s.view); + const counts = useInbox((s) => s.counts); + const setView = useInbox((s) => s.setView); const [recoveryDismissed, setRecoveryDismissed] = useState(isRecoveryDismissedToday()); + // v0.2.9 Cut B Task 12 — 첫 launch onboarding 분기. null = 로딩, true = 표시, false = 미표시. + const [showOnboarding, setShowOnboarding] = useState(null); + + useEffect(() => { + void (async () => { + try { + const settings = await inboxApi.getSettings(); + setShowOnboarding(!settings.onboarding_completed); + } catch { + // 안전한 fallback — settings 읽기 실패 시 wizard 미표시 (기존 사용자 무영향). + setShowOnboarding(false); + } + })(); + }, []); useEffect(() => { void loadInitial(); @@ -35,15 +54,8 @@ export function App(): React.ReactElement { useInbox.setState({ ollamaStatus: status }); }); const unsubNav = inboxApi.onNavigate((view) => { - if (view === 'settings') { - useInbox.getState().setShowSettings(true); - } else if (view === 'inbox') { - useInbox.getState().setShowSettings(false); - if (useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); - } else if (view === 'trash') { - useInbox.getState().setShowSettings(false); - if (!useInbox.getState().showTrash) void useInbox.getState().toggleShowTrash(); - } + // v0.2.9 Cut B Task 4 — setView 가 mirror state (showTrash/showSettings) 동기 갱신. + useInbox.getState().setView(view); }); const onFocus = () => { void refreshMeta(); }; window.addEventListener('focus', onFocus); @@ -52,6 +64,9 @@ export function App(): React.ReactElement { // deps array 에 추가 불필요. mount 시 1회 구독 + unmount 시 해제. }, [loadInitial, refreshMeta, upsertNote]); + if (showOnboarding === null) return <>; + if (showOnboarding) return setShowOnboarding(false)} />; + if (showSettings) return ; const showRecovery = continuity.showRecoveryToast && !recoveryDismissed; @@ -72,20 +87,23 @@ export function App(): React.ReactElement {

Inkling

- - + {( + [ + { key: 'inbox', label: 'Inbox', count: counts.active }, + { key: 'completed', label: '완료', count: counts.completed }, + { key: 'archived', label: '보관', count: counts.archived }, + { key: 'trash', label: '휴지통', count: counts.trashed } + ] as const + ).map((t) => ( + + ))}
diff --git a/src/renderer/inbox/components/FailedBanner.tsx b/src/renderer/inbox/components/FailedBanner.tsx index a41ccc2..ebb9373 100644 --- a/src/renderer/inbox/components/FailedBanner.tsx +++ b/src/renderer/inbox/components/FailedBanner.tsx @@ -3,8 +3,11 @@ import { useInbox } from '../store.js'; import { Banner } from './Banner.js'; export function FailedBanner(): React.ReactElement | null { + const aiEnabled = useInbox((s) => s.ai_enabled); const count = useInbox((s) => s.failedCount); const retryAllFailed = useInbox((s) => s.retryAllFailed); + // v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성. + if (!aiEnabled) return null; if (count === 0) return null; return ( diff --git a/src/renderer/inbox/components/MoveStatusModal.tsx b/src/renderer/inbox/components/MoveStatusModal.tsx new file mode 100644 index 0000000..814f0b7 --- /dev/null +++ b/src/renderer/inbox/components/MoveStatusModal.tsx @@ -0,0 +1,150 @@ +import React, { useState } from 'react'; +import { inboxApi } from '../api.js'; +import type { NoteStatus } from '@shared/types'; + +interface Props { + noteId: string; + rawText: string; + summary: string; + onClose: () => void; + onMoved: (status: NoteStatus, reason: string | null) => void; +} + +/** + * v0.2.9 Cut B Task 7 — 메모 이동 Modal. + * + * 사유 입력 + 3 status 버튼 (완료/보관/휴지통) + AI 자동 분류. + */ +export function MoveStatusModal({ + noteId, + onClose, + onMoved +}: Props): React.ReactElement { + const [reason, setReason] = useState(''); + const [recommendation, setRecommendation] = useState<{ + status: NoteStatus; + rationale: string; + } | null>(null); + const [classifying, setClassifying] = useState(false); + + async function move(status: NoteStatus): Promise { + const trimmedReason = reason.trim() === '' ? null : reason.trim(); + await inboxApi.setStatus(noteId, status, trimmedReason); + onMoved(status, trimmedReason); + } + + async function classify(): Promise { + setClassifying(true); + setRecommendation(null); + try { + const r = await inboxApi.classifyStatus(noteId, reason); + setRecommendation({ status: r.recommended, rationale: r.rationale }); + } finally { + setClassifying(false); + } + } + + return ( +
+
+

메모 이동

+