feat(v029): Banner + HealthChecker ai_enabled=false 시 비활성 (store ai_enabled field)
This commit is contained in:
@@ -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);
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) 일
|
||||
|
||||
46
tests/unit/FailedBanner.test.tsx
Normal file
46
tests/unit/FailedBanner.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
@@ -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);
|
||||
});
|
||||
});
|
||||
|
||||
46
tests/unit/OllamaBanner.test.tsx
Normal file
46
tests/unit/OllamaBanner.test.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user