From 352457189e6dfc8a704a07f1116e7d6b5953815d Mon Sep 17 00:00:00 2001 From: th-kim0823 Date: Thu, 14 May 2026 13:11:38 +0900 Subject: [PATCH] =?UTF-8?q?feat(notes):=20=EC=9B=90=EB=AC=B8=20=ED=8E=B8?= =?UTF-8?q?=EC=A7=91/=EC=9D=B4=EB=A0=A5=20=EB=B3=B5=EC=9B=90=20=EC=8B=9C?= =?UTF-8?q?=20AI=20=EC=9E=AC=EC=B2=98=EB=A6=AC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit dogfood: 사용자가 노트 본문 수정해도 기존 AI 제목/요약이 그대로 남는 문제. NoteRepository.markAiPendingForReprocess 추가 — done/failed/pending 노트를 pending 으로 reset + pending_jobs 재투입. disabled 는 사용자가 명시적으로 비활성화한 상태라 존중하여 no-op. inboxApi 의 update-raw-text / restore-revision 핸들러가 raw 갱신 후 위 헬퍼 + worker.enqueue + pushNoteUpdated 호출. NoteCard.saveRaw 는 optimistic 으로 aiStatus='pending' 즉시 반영 → UI 가 "Inkling이 정리하는 중…" 즉시 표시, 백엔드 push 로 자동 sync. updateAiResult 의 user-edit 가드가 사용자가 직접 편집한 title/summary 는 새 AI 결과로 덮어쓰지 않으므로 안전. Co-Authored-By: Claude Opus 4.7 (1M context) --- src/main/ipc/inboxApi.ts | 15 ++++++++++++++ src/main/repository/NoteRepository.ts | 23 ++++++++++++++++++++++ src/renderer/inbox/components/NoteCard.tsx | 10 +++++++++- tests/unit/inboxApi-revisions.test.ts | 1 + 4 files changed, 48 insertions(+), 1 deletion(-) diff --git a/src/main/ipc/inboxApi.ts b/src/main/ipc/inboxApi.ts index 80c80e4..43c464f 100644 --- a/src/main/ipc/inboxApi.ts +++ b/src/main/ipc/inboxApi.ts @@ -303,6 +303,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void { return { ok: false as const, reason: 'empty' as const }; } deps.repo.updateRawText(id, newText); + await reprocessAi(deps, id); return { ok: true as const }; }); @@ -311,6 +312,7 @@ export function registerInboxApi(deps: InboxIpcDeps): void { ipcMain.handle('inbox:restore-revision', async (_e, id: string, revId: number) => { try { deps.repo.restoreRevision(id, revId); + await reprocessAi(deps, id); return { ok: true as const }; } catch (e) { return { ok: false as const, reason: (e as Error).message }; @@ -344,6 +346,19 @@ export function pushNoteUpdated(getWin: () => BrowserWindow | null, note: Note): w.webContents.send('note:updated', note); } +/** + * 원문 변경 후 AI 재처리 트리거. ai_status='pending' 으로 reset + pending_jobs 재투입 + + * worker enqueue + renderer push. disabled 노트는 사용자 명시 비활성화 의도 존중하여 skip. + */ +async function reprocessAi(deps: InboxIpcDeps, id: string): Promise { + const now = new Date().toISOString(); + const r = deps.repo.markAiPendingForReprocess(id, now); + if (!r.ok) return; + if (deps.enqueue) await deps.enqueue(id); + const updated = deps.repo.findById(id); + if (updated !== null) pushNoteUpdated(deps.getInboxWindow, updated); +} + export function pushOllamaStatus(getWin: () => BrowserWindow | null, status: HealthResult): void { const w = getWin(); if (!w || w.isDestroyed()) return; diff --git a/src/main/repository/NoteRepository.ts b/src/main/repository/NoteRepository.ts index 97e99ab..181b35b 100644 --- a/src/main/repository/NoteRepository.ts +++ b/src/main/repository/NoteRepository.ts @@ -269,6 +269,29 @@ export class NoteRepository { return { ok: true }; } + /** + * raw_text 편집/복원 직후 AI 재처리 트리거. done/failed/pending 노트를 pending 으로 + * reset + pending_jobs row 보장 (attempts=0). disabled 는 사용자의 명시적 비활성화 + * 의도 존중 — no-op. updateAiResult 의 user-edit 가드 (title_edited_by_user 등) 가 + * 사용자가 직접 편집한 필드는 새 AI 결과로 덮어쓰지 않음. + */ + markAiPendingForReprocess(id: string, now: string): { ok: boolean } { + const row = this.db + .prepare(`SELECT ai_status FROM notes WHERE id=? AND deleted_at IS NULL`) + .get(id) as { ai_status: string } | undefined; + if (!row || row.ai_status === 'disabled') return { ok: false }; + const tx = this.db.transaction(() => { + this.db + .prepare(`UPDATE notes SET ai_status='pending', ai_error=NULL, updated_at=? WHERE id=?`) + .run(now, id); + this.db + .prepare(`INSERT OR REPLACE INTO pending_jobs (note_id, attempts, next_run_at) VALUES (?, 0, ?)`) + .run(id, now); + }); + tx(); + return { ok: true }; + } + /** * v0.3.9 — pending 노트의 AI 처리 cancel. ai_status='disabled' 로 전환 + pending_jobs 삭제. * raw_text 는 보존. 사용자가 무한 pending (Ollama 끊김 등) 에서 빠져나오는 path. diff --git a/src/renderer/inbox/components/NoteCard.tsx b/src/renderer/inbox/components/NoteCard.tsx index 474dd59..6abc476 100644 --- a/src/renderer/inbox/components/NoteCard.tsx +++ b/src/renderer/inbox/components/NoteCard.tsx @@ -154,7 +154,15 @@ export function NoteCard({ note, onDeleted, onUpdated, mode = 'inbox', onRestore if (next.trim().length === 0) return; const r = await inboxApi.updateRawText(note.id, next); if (!r.ok) return; - const updated = { ...local, rawText: next, updatedAt: new Date().toISOString() }; + // disabled 노트는 AI 재처리 안 됨 (서버에서 skip) — aiStatus 유지. + // 그 외는 optimistic 으로 pending 표시 → AiWorker 완료 시 push 로 자동 sync. + const willReprocess = local.aiStatus !== 'disabled'; + const updated = { + ...local, + rawText: next, + updatedAt: new Date().toISOString(), + ...(willReprocess ? { aiStatus: 'pending' as const, aiError: null } : {}) + }; setLocal(updated); onUpdated(updated); setEditingRaw(false); diff --git a/tests/unit/inboxApi-revisions.test.ts b/tests/unit/inboxApi-revisions.test.ts index 5e7fd3f..88b06c1 100644 --- a/tests/unit/inboxApi-revisions.test.ts +++ b/tests/unit/inboxApi-revisions.test.ts @@ -22,6 +22,7 @@ function makeDeps(overrides: Partial = {}): InboxIpcDeps { updateRawText: vi.fn(), listRevisions: vi.fn(() => []), restoreRevision: vi.fn(), + markAiPendingForReprocess: vi.fn(() => ({ ok: false })), findById: vi.fn(), list: vi.fn(), listByStatus: vi.fn(),