From 49fbed050a65790f4f27ac7135a8f188718890bb Mon Sep 17 00:00:00 2001 From: altair823 Date: Sat, 9 May 2026 16:25:24 +0900 Subject: [PATCH] =?UTF-8?q?feat(v029):=20Banner=20+=20HealthChecker=20ai?= =?UTF-8?q?=5Fenabled=3Dfalse=20=EC=8B=9C=20=EB=B9=84=ED=99=9C=EC=84=B1=20?= =?UTF-8?q?(store=20ai=5Fenabled=20field)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- src/main/index.ts | 2 + src/main/services/HealthChecker.ts | 21 ++++++++- .../inbox/components/FailedBanner.tsx | 3 ++ .../inbox/components/OllamaBanner.tsx | 3 ++ src/renderer/inbox/store.ts | 18 +++++--- tests/unit/FailedBanner.test.tsx | 46 +++++++++++++++++++ tests/unit/HealthChecker.test.ts | 45 ++++++++++++++++++ tests/unit/OllamaBanner.test.tsx | 46 +++++++++++++++++++ 8 files changed, 176 insertions(+), 8 deletions(-) create mode 100644 tests/unit/FailedBanner.test.tsx create mode 100644 tests/unit/OllamaBanner.test.tsx diff --git a/src/main/index.ts b/src/main/index.ts index d586a12..b30372f 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); 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/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/OllamaBanner.tsx b/src/renderer/inbox/components/OllamaBanner.tsx index 0ad9120..0a0d40c 100644 --- a/src/renderer/inbox/components/OllamaBanner.tsx +++ b/src/renderer/inbox/components/OllamaBanner.tsx @@ -7,8 +7,11 @@ interface OllamaBannerProps { } export function OllamaBanner({ onOpenSettings }: OllamaBannerProps = {}): React.ReactElement | null { + const aiEnabled = useInbox((s) => s.ai_enabled); const status = useInbox((s) => s.ollamaStatus); const recheckOllama = useInbox((s) => s.recheckOllama); + // v0.2.9 Cut B Task 14 — AI-less mode 에서는 banner 자체 비활성. + if (!aiEnabled) return null; if (status.ok) return null; const isMissing = status.reason?.includes('not installed'); const message = isMissing diff --git a/src/renderer/inbox/store.ts b/src/renderer/inbox/store.ts index 7abe2fe..c8d136a 100644 --- a/src/renderer/inbox/store.ts +++ b/src/renderer/inbox/store.ts @@ -36,6 +36,9 @@ interface InboxState { failedCount: number; recallCandidate: Note | null; recallSnoozeUntilMs: number | null; + // v0.2.9 Cut B Task 14 — AI 비활성 모드에서는 OllamaBanner/FailedBanner render skip. + // 기본 true (기존 사용자 무영향). loadInitial / refreshMeta 가 settings 로드. + ai_enabled: boolean; loadInitial: () => Promise; refreshMeta: () => Promise; upsertNote: (note: Note) => void; @@ -84,9 +87,10 @@ export const useInbox = create((set, get) => ({ failedCount: 0, recallCandidate: null, recallSnoozeUntilMs: null, + ai_enabled: true, async loadInitial() { set({ loading: true }); - const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ + const [notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ inboxApi.listNotes({ limit: 50 }), inboxApi.getContinuity(), inboxApi.getPendingCount(), @@ -96,12 +100,13 @@ export const useInbox = create((set, get) => ({ inboxApi.listExpired(), inboxApi.getFailedCount(), inboxApi.listRecallCandidate(), - inboxApi.countsByStatus() + inboxApi.countsByStatus(), + inboxApi.getSettings() ]); - set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, loading: false }); + set({ notes, continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true, loading: false }); }, async refreshMeta() { - const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts] = await Promise.all([ + const [continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, settings] = await Promise.all([ inboxApi.getContinuity(), inboxApi.getPendingCount(), inboxApi.getOllamaStatus(), @@ -110,9 +115,10 @@ export const useInbox = create((set, get) => ({ inboxApi.listExpired(), inboxApi.getFailedCount(), inboxApi.listRecallCandidate(), - inboxApi.countsByStatus() + inboxApi.countsByStatus(), + inboxApi.getSettings() ]); - set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts }); + set({ continuity, pendingCount, ollamaStatus, todayCount, trashCount, expiredCandidates, failedCount, recallCandidate, counts, ai_enabled: settings.ai_enabled ?? true }); }, upsertNote(note) { // trashCount 는 server-authoritative. trashNotes 가 cache-loaded (showTrash=true) 일 diff --git a/tests/unit/FailedBanner.test.tsx b/tests/unit/FailedBanner.test.tsx new file mode 100644 index 0000000..40a519f --- /dev/null +++ b/tests/unit/FailedBanner.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + retryAllFailed: vi.fn(async () => {}) + } +})); + +import { FailedBanner } from '../../src/renderer/inbox/components/FailedBanner'; +import { useInbox } from '../../src/renderer/inbox/store'; + +describe('FailedBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => { + beforeEach(() => { + cleanup(); + }); + + it('renders nothing when ai_enabled=false (even with failedCount > 0)', () => { + useInbox.setState({ + ai_enabled: false, + failedCount: 3 + } as Partial>); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when ai_enabled=true and failedCount=0', () => { + useInbox.setState({ + ai_enabled: true, + failedCount: 0 + } as Partial>); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders banner when ai_enabled=true and failedCount > 0', () => { + useInbox.setState({ + ai_enabled: true, + failedCount: 5 + } as Partial>); + const { container } = render(); + expect(container).not.toBeEmptyDOMElement(); + }); +}); diff --git a/tests/unit/HealthChecker.test.ts b/tests/unit/HealthChecker.test.ts index 95c8251..98adc07 100644 --- a/tests/unit/HealthChecker.test.ts +++ b/tests/unit/HealthChecker.test.ts @@ -117,3 +117,48 @@ describe('HealthChecker — delta transitions + telemetry', () => { expect(events).toEqual([{ kind: 'ollama_recheck_manual' }]); }); }); + +describe('HealthChecker — ai_enabled gate (v0.2.9 Cut B Task 14)', () => { + beforeEach(() => { vi.useFakeTimers(); }); + afterEach(() => { vi.useRealTimers(); }); + + it('isAiEnabled=false 면 start() polling 이 healthCheck 호출 skip', async () => { + const provider = new FakeProvider(); + provider.results = [{ ok: true }]; + const hc = new HealthChecker(new ProviderHolder(provider), { + intervalMs: 1000, + isAiEnabled: async () => false + }); + hc.start(); + await vi.advanceTimersByTimeAsync(1000); + await vi.advanceTimersByTimeAsync(1000); + // 즉시 + 2 tick = 0회 — AI 비활성으로 모든 polling skip. + expect((provider as any).idx).toBe(0); + hc.stop(); + }); + + it('isAiEnabled=true 면 polling 정상 (gate 통과)', async () => { + const provider = new FakeProvider(); + provider.results = [{ ok: true }, { ok: true }]; + const hc = new HealthChecker(new ProviderHolder(provider), { + intervalMs: 1000, + isAiEnabled: async () => true + }); + hc.start(); + // start() 의 즉시 tick 이 microtask 에서 isAiEnabled 를 await 함 → flush 필요. + await vi.runOnlyPendingTimersAsync(); + await vi.advanceTimersByTimeAsync(1000); + expect((provider as any).idx).toBeGreaterThanOrEqual(2); + hc.stop(); + }); + + it('isAiEnabled=false 여도 manual runOnce 는 항상 실행 (사용자 의도)', async () => { + const provider = new FakeProvider(); + provider.results = [{ ok: true }]; + const hc = new HealthChecker(new ProviderHolder(provider), { + isAiEnabled: async () => false + }); + await hc.runOnce({ manual: true }); + expect((provider as any).idx).toBe(1); + }); +}); diff --git a/tests/unit/OllamaBanner.test.tsx b/tests/unit/OllamaBanner.test.tsx new file mode 100644 index 0000000..1426744 --- /dev/null +++ b/tests/unit/OllamaBanner.test.tsx @@ -0,0 +1,46 @@ +// @vitest-environment jsdom +import { describe, it, expect, beforeEach, vi } from 'vitest'; +import '@testing-library/jest-dom/vitest'; +import { render, cleanup } from '@testing-library/react'; + +vi.mock('../../src/renderer/inbox/api.js', () => ({ + inboxApi: { + ollamaRecheck: vi.fn(async () => ({ ok: true })) + } +})); + +import { OllamaBanner } from '../../src/renderer/inbox/components/OllamaBanner'; +import { useInbox } from '../../src/renderer/inbox/store'; + +describe('OllamaBanner — ai_enabled (v0.2.9 Cut B Task 14)', () => { + beforeEach(() => { + cleanup(); + }); + + it('renders nothing when ai_enabled=false (even if ollama unreachable)', () => { + useInbox.setState({ + ai_enabled: false, + ollamaStatus: { ok: false, reason: 'unreachable' } + } as Partial>); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders nothing when ai_enabled=true and ollama ok', () => { + useInbox.setState({ + ai_enabled: true, + ollamaStatus: { ok: true } + } as Partial>); + const { container } = render(); + expect(container).toBeEmptyDOMElement(); + }); + + it('renders banner when ai_enabled=true and ollama not ok', () => { + useInbox.setState({ + ai_enabled: true, + ollamaStatus: { ok: false, reason: 'unreachable' } + } as Partial>); + const { container } = render(); + expect(container).not.toBeEmptyDOMElement(); + }); +});