feat(v027): AiProviderSection — OllamaSettingsModal 흡수 + 지금 재확인
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import React from 'react';
|
||||
import { useInbox } from '../store.js';
|
||||
import { AiProviderSection } from './settings/AiProviderSection.js';
|
||||
|
||||
export function SettingsPage(): React.ReactElement {
|
||||
const setShowSettings = useInbox((s) => s.setShowSettings);
|
||||
@@ -22,7 +23,7 @@ export function SettingsPage(): React.ReactElement {
|
||||
</div>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>AI 제공자</h2>
|
||||
{/* AiProviderSection — Task 8 */}
|
||||
<AiProviderSection />
|
||||
</section>
|
||||
<section style={{ marginBottom: 24 }}>
|
||||
<h2 style={{ fontSize: 14, marginBottom: 8 }}>자동 실행</h2>
|
||||
|
||||
131
src/renderer/inbox/components/settings/AiProviderSection.tsx
Normal file
131
src/renderer/inbox/components/settings/AiProviderSection.tsx
Normal file
@@ -0,0 +1,131 @@
|
||||
import React, { useEffect, useState } from 'react';
|
||||
import { z } from 'zod';
|
||||
import { inboxApi } from '../../api.js';
|
||||
|
||||
const endpointSchema = z.string().url();
|
||||
|
||||
export function AiProviderSection(): React.ReactElement {
|
||||
const [endpoint, setEndpoint] = useState('');
|
||||
const [model, setModel] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [saveResult, setSaveResult] = useState<string | null>(null);
|
||||
const [recheckResult, setRecheckResult] = useState<string | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
void (async () => {
|
||||
const s = await inboxApi.loadOllamaSettings();
|
||||
if (s) {
|
||||
setEndpoint(s.endpoint);
|
||||
setModel(s.model);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
async function onSave(): Promise<void> {
|
||||
const r = endpointSchema.safeParse(endpoint);
|
||||
if (!r.success) {
|
||||
setError('올바른 URL 형식이 아닙니다 (예: http://localhost:11434)');
|
||||
setSaveResult(null);
|
||||
return;
|
||||
}
|
||||
if (model.trim() === '') {
|
||||
setError('모델 이름을 입력해주세요');
|
||||
setSaveResult(null);
|
||||
return;
|
||||
}
|
||||
setError(null);
|
||||
const result = await inboxApi.saveOllamaSettings({ endpoint, model });
|
||||
if (result.ok) {
|
||||
setSaveResult('저장됨');
|
||||
} else {
|
||||
setSaveResult(null);
|
||||
setError(`저장 실패: ${result.reason}`);
|
||||
}
|
||||
}
|
||||
|
||||
async function onRecheck(): Promise<void> {
|
||||
setRecheckResult('확인 중...');
|
||||
const r = await inboxApi.ollamaRecheck();
|
||||
setRecheckResult(r.ok ? '연결됨' : `연결 실패: ${r.reason ?? '알 수 없는 이유'}`);
|
||||
}
|
||||
|
||||
return (
|
||||
<div>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Endpoint
|
||||
<input
|
||||
type="text"
|
||||
value={endpoint}
|
||||
onChange={(e) => setEndpoint(e.target.value)}
|
||||
placeholder="http://localhost:11434"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
<label style={{ display: 'block', marginBottom: 8, fontSize: 12, color: '#666' }}>
|
||||
Model
|
||||
<input
|
||||
type="text"
|
||||
value={model}
|
||||
onChange={(e) => setModel(e.target.value)}
|
||||
placeholder="gemma2:2b"
|
||||
style={{
|
||||
display: 'block',
|
||||
width: '100%',
|
||||
padding: '6px 8px',
|
||||
marginTop: 4,
|
||||
fontSize: 13,
|
||||
border: '1px solid #ccc',
|
||||
borderRadius: 4
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
{error && (
|
||||
<div style={{ color: '#c33', fontSize: 12, marginBottom: 8 }}>{error}</div>
|
||||
)}
|
||||
{saveResult && (
|
||||
<div style={{ fontSize: 12, marginBottom: 8, color: '#0a4b80' }}>{saveResult}</div>
|
||||
)}
|
||||
<div style={{ display: 'flex', gap: 8 }}>
|
||||
<button
|
||||
onClick={() => void onSave()}
|
||||
style={{
|
||||
background: '#0a4b80',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
borderRadius: 4,
|
||||
padding: '6px 14px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
저장
|
||||
</button>
|
||||
<button
|
||||
onClick={() => void onRecheck()}
|
||||
style={{
|
||||
background: 'transparent',
|
||||
color: '#0a4b80',
|
||||
border: '1px solid #0a4b80',
|
||||
borderRadius: 4,
|
||||
padding: '6px 14px',
|
||||
fontSize: 12,
|
||||
cursor: 'pointer'
|
||||
}}
|
||||
>
|
||||
지금 재확인
|
||||
</button>
|
||||
</div>
|
||||
{recheckResult && (
|
||||
<div style={{ fontSize: 12, marginTop: 8 }}>{recheckResult}</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
44
tests/unit/AiProviderSection.test.tsx
Normal file
44
tests/unit/AiProviderSection.test.tsx
Normal file
@@ -0,0 +1,44 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
||||
import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => ({ endpoint: 'http://localhost:11434', model: 'gemma2:2b' })),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { AiProviderSection } from '../../src/renderer/inbox/components/settings/AiProviderSection';
|
||||
|
||||
describe('AiProviderSection', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks();
|
||||
cleanup();
|
||||
});
|
||||
|
||||
it('loads current settings on mount', async () => {
|
||||
render(<AiProviderSection />);
|
||||
expect(await screen.findByDisplayValue('http://localhost:11434')).toBeInTheDocument();
|
||||
expect(screen.getByDisplayValue('gemma2:2b')).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('rejects invalid endpoint URL', async () => {
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByDisplayValue('http://localhost:11434');
|
||||
const input = screen.getByLabelText(/Endpoint/);
|
||||
fireEvent.change(input, { target: { value: 'not-a-url' } });
|
||||
fireEvent.click(screen.getByRole('button', { name: /저장/ }));
|
||||
expect(await screen.findByText(/올바른 URL/)).toBeInTheDocument();
|
||||
});
|
||||
|
||||
it('"지금 재확인" calls ollamaRecheck and shows result', async () => {
|
||||
const { inboxApi } = await import('../../src/renderer/inbox/api.js');
|
||||
render(<AiProviderSection />);
|
||||
await screen.findByDisplayValue('http://localhost:11434');
|
||||
fireEvent.click(screen.getByRole('button', { name: /지금 재확인/ }));
|
||||
expect(inboxApi.ollamaRecheck).toHaveBeenCalled();
|
||||
});
|
||||
});
|
||||
@@ -4,8 +4,15 @@ import '@testing-library/jest-dom/vitest';
|
||||
import { render, screen, fireEvent, cleanup } from '@testing-library/react';
|
||||
|
||||
// inboxApi 는 window.inkling.inbox 를 참조하므로 jsdom 환경에서 import 자체가 throw.
|
||||
// 다른 inbox store 단위 테스트와 동일한 패턴으로 빈 mock 을 주입한다.
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({ inboxApi: {} }));
|
||||
// SettingsPage 가 마운트하는 AiProviderSection 의 useEffect 가 loadOllamaSettings 를 호출하므로
|
||||
// 빈 객체 대신 필요한 메서드를 stub 한다.
|
||||
vi.mock('../../src/renderer/inbox/api.js', () => ({
|
||||
inboxApi: {
|
||||
loadOllamaSettings: vi.fn(async () => null),
|
||||
saveOllamaSettings: vi.fn(async () => ({ ok: true })),
|
||||
ollamaRecheck: vi.fn(async () => ({ ok: true }))
|
||||
}
|
||||
}));
|
||||
|
||||
import { SettingsPage } from '../../src/renderer/inbox/components/SettingsPage';
|
||||
import { useInbox } from '../../src/renderer/inbox/store';
|
||||
|
||||
Reference in New Issue
Block a user