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(),