feat(v031): VisionSection UI — dropdown + 다시 감지 + 마지막 감지 시각
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
@@ -1,6 +1,7 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../../api.js';
|
||||
import { VisionSection } from './VisionSection.js';
|
||||
|
||||
const endpointSchema = z.string().url();
|
||||
|
||||
@@ -192,6 +193,7 @@ export function AiProviderSection(): React.ReactElement {
|
||||
{recheckResult && (
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
|
||||
)}
|
||||
<VisionSection />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
81
src/renderer/inbox/components/settings/VisionSection.tsx
Normal file
81
src/renderer/inbox/components/settings/VisionSection.tsx
Normal file
@@ -0,0 +1,81 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
export function VisionSection(): React.ReactElement {
|
||||
const [models, setModels] = useState<string[]>([]);
|
||||
const [at, setAt] = useState<string | null>(null);
|
||||
const [selected, setSelected] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState<'select' | 'refresh' | null>(null);
|
||||
const [feedback, setFeedback] = useState<string | null>(null);
|
||||
|
||||
async function load() {
|
||||
const r = await inboxApi.getVisionModels();
|
||||
setModels(r.models);
|
||||
setAt(r.at);
|
||||
setSelected(r.selected);
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
}, []);
|
||||
|
||||
async function onSelect(value: string) {
|
||||
const next = value === '' ? null : value;
|
||||
setBusy('select');
|
||||
setFeedback(null);
|
||||
await inboxApi.setVisionModel(next);
|
||||
setSelected(next);
|
||||
setBusy(null);
|
||||
}
|
||||
|
||||
async function onRefresh() {
|
||||
setBusy('refresh');
|
||||
setFeedback(null);
|
||||
const r = await inboxApi.refreshVisionCache();
|
||||
setBusy(null);
|
||||
if (r.ok) {
|
||||
await load();
|
||||
setFeedback(`감지 완료 (${r.models.length}개)`);
|
||||
} else {
|
||||
setFeedback(`감지 실패: ${r.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<section style={{ marginTop: 16 }}>
|
||||
<h4 style={{ fontSize: 13, marginBottom: 6 }}>이미지 분석 모델 (선택사항)</h4>
|
||||
<div style={{ display: 'flex', gap: 6, alignItems: 'center', marginBottom: 6 }}>
|
||||
<select
|
||||
aria-label="이미지 분석 모델"
|
||||
value={selected ?? ''}
|
||||
onChange={(e) => { void onSelect(e.target.value); }}
|
||||
disabled={busy !== null}
|
||||
style={{ flex: 1, fontSize: 12, padding: '4px 8px', border: '1px solid #ccc', borderRadius: 4 }}
|
||||
>
|
||||
<option value="">(비활성)</option>
|
||||
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
||||
</select>
|
||||
<button
|
||||
onClick={() => { void onRefresh(); }}
|
||||
disabled={busy !== null}
|
||||
style={{ background: '#0a4b80', color: '#fff', border: 'none', cursor: 'pointer', fontSize: 12, padding: '4px 10px', borderRadius: 4 }}
|
||||
>
|
||||
{busy === 'refresh' ? '감지 중…' : '다시 감지'}
|
||||
</button>
|
||||
</div>
|
||||
{at !== null && (
|
||||
<div style={{ fontSize: 11, color: '#888' }}>
|
||||
마지막 감지: {new Date(at).toLocaleString('ko-KR')}
|
||||
</div>
|
||||
)}
|
||||
{feedback !== null && (
|
||||
<div style={{ fontSize: 11, color: '#444', marginTop: 4 }}>{feedback}</div>
|
||||
)}
|
||||
{models.length === 0 && (
|
||||
<div style={{ fontSize: 11, color: '#aaa', marginTop: 4 }}>
|
||||
감지된 모델 없음. Ollama 에 vision 모델을 설치하고 "다시 감지" 클릭.
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
);
|
||||
}
|
||||
@@ -11,7 +11,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
getSettings: vi.fn(async () => ({ ai_enabled: true })),
|
||||
setAiEnabled: vi.fn(async () => ({ ok: true })),
|
||||
getDisabledCount: vi.fn(async () => 0),
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 }))
|
||||
enqueueDisabled: vi.fn(async () => ({ count: 0 })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -62,7 +62,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
@@ -52,7 +52,11 @@ vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
setSyncAutoEnabled: vi.fn(async () => ({ ok: true as const })),
|
||||
setSyncIntervalMin: vi.fn(async () => ({ ok: true as const })),
|
||||
configureSync: vi.fn(async () => ({ ok: true as const })),
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const }))
|
||||
testSyncConnection: vi.fn(async () => ({ ok: true as const })),
|
||||
// v0.3.1 Cut F — VisionSection 이 AiProviderSection 에 마운트되어 호출.
|
||||
getVisionModels: vi.fn(async () => ({ models: [], at: null, selected: null })),
|
||||
setVisionModel: vi.fn(async () => ({ ok: true as const })),
|
||||
refreshVisionCache: vi.fn(async () => ({ ok: true as const, models: [] }))
|
||||
}
|
||||
}));
|
||||
|
||||
|
||||
75
tests/unit/VisionSection.test.tsx
Normal file
75
tests/unit/VisionSection.test.tsx
Normal file
@@ -0,0 +1,75 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup, waitFor } from '@testing-library/react';
|
||||
import React from 'react';
|
||||
|
||||
const { mockGet, mockSet, mockRefresh } = vi.hoisted(() => ({
|
||||
mockGet: vi.fn(),
|
||||
mockSet: vi.fn(),
|
||||
mockRefresh: vi.fn()
|
||||
}));
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
getVisionModels: mockGet,
|
||||
setVisionModel: mockSet,
|
||||
refreshVisionCache: mockRefresh
|
||||
}
|
||||
}));
|
||||
|
||||
import { VisionSection } from '../../src/renderer/inbox/components/settings/VisionSection';
|
||||
|
||||
describe('VisionSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
mockGet.mockResolvedValue({
|
||||
models: ['gemma3:12b-vision', 'llava:13b'],
|
||||
at: '2026-05-10T05:00:00Z',
|
||||
selected: 'gemma3:12b-vision'
|
||||
});
|
||||
mockSet.mockResolvedValue({ ok: true });
|
||||
mockRefresh.mockResolvedValue({ ok: true, models: ['gemma3:12b-vision', 'llava:13b'] });
|
||||
});
|
||||
|
||||
it('open 시 cache 로드 + dropdown 옵션 표시 + 선택된 모델 default', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => {
|
||||
expect(screen.getByLabelText('이미지 분석 모델')).toHaveValue('gemma3:12b-vision');
|
||||
});
|
||||
expect(screen.getByText('gemma3:12b-vision')).toBeInTheDocument();
|
||||
expect(screen.getByText('llava:13b')).toBeInTheDocument();
|
||||
expect(screen.getByText(/마지막 감지/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('dropdown 변경 → setVisionModel 호출', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
|
||||
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: 'llava:13b' } });
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith('llava:13b');
|
||||
});
|
||||
});
|
||||
|
||||
it('비활성 선택 → setVisionModel(null)', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByLabelText('이미지 분석 모델'));
|
||||
fireEvent.change(screen.getByLabelText('이미지 분석 모델'), { target: { value: '' } });
|
||||
await waitFor(() => {
|
||||
expect(mockSet).toHaveBeenCalledWith(null);
|
||||
});
|
||||
});
|
||||
|
||||
it('다시 감지 클릭 → refreshVisionCache 호출 + 결과 표시', async () => {
|
||||
render(<VisionSection />);
|
||||
await waitFor(() => screen.getByRole('button', { name: /다시 감지/ }));
|
||||
fireEvent.click(screen.getByRole('button', { name: /다시 감지/ }));
|
||||
await waitFor(() => {
|
||||
expect(mockRefresh).toHaveBeenCalled();
|
||||
});
|
||||
await waitFor(() => {
|
||||
expect(screen.getByText(/감지 완료/)).toBeInTheDocument();
|
||||
});
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user