feat(v029): Banner + HealthChecker ai_enabled=false 시 비활성 (store ai_enabled field)

This commit is contained in:
altair823
2026-05-09 16:25:24 +09:00
parent bc67dea2c8
commit 49fbed050a
8 changed files with 176 additions and 8 deletions

View File

@@ -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<string, unknown>);
pushOllamaStatus(getInboxWindow, status);

View File

@@ -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<boolean>;
}
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<void> {
if (this.opts.isAiEnabled !== undefined) {
try {
const enabled = await this.opts.isAiEnabled();
if (!enabled) return;
} catch {
// settings 로드 실패 시 안전 측면 — polling 진행 (기존 동작 유지).
}
}
await this.runOnce();
}
stop(): void {

View File

@@ -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 (
<Banner severity="error">

View File

@@ -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

View File

@@ -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<void>;
refreshMeta: () => Promise<void>;
upsertNote: (note: Note) => void;
@@ -84,9 +87,10 @@ export const useInbox = create<InboxState>((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<InboxState>((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<InboxState>((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) 일

View File

@@ -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<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when ai_enabled=true and failedCount=0', () => {
useInbox.setState({
ai_enabled: true,
failedCount: 0
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders banner when ai_enabled=true and failedCount > 0', () => {
useInbox.setState({
ai_enabled: true,
failedCount: 5
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<FailedBanner />);
expect(container).not.toBeEmptyDOMElement();
});
});

View File

@@ -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);
});
});

View File

@@ -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<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
expect(container).toBeEmptyDOMElement();
});
it('renders nothing when ai_enabled=true and ollama ok', () => {
useInbox.setState({
ai_enabled: true,
ollamaStatus: { ok: true }
} as Partial<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
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<ReturnType<typeof useInbox.getState>>);
const { container } = render(<OllamaBanner />);
expect(container).not.toBeEmptyDOMElement();
});
});