- #15: IPC channel inbox:delete → inbox:trash (semantic = soft delete) channel name 만 변경, InboxApi method name (deleteNote) 은 backward compat 유지 - #29: getTopUsedTags(20) → VOCAB_TOP_N const (튜닝 자체는 dogfood telemetry 후) - #42: OllamaSettingsModal client-side URL validation (zod safeParse pre-check) + model 빈 문자열 가드. server-side healthCheck 전에 친화적 에러 메시지. - #9: 휴지통 회수율 ratio 의미 1줄 코멘트 (event-level, unique-note 아님) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
141 lines
4.5 KiB
TypeScript
141 lines
4.5 KiB
TypeScript
import React, { useEffect, useState } from 'react';
|
|
import { z } from 'zod';
|
|
import { inboxApi } from '../api.js';
|
|
import { DEFAULT_OLLAMA_ENDPOINT, DEFAULT_OLLAMA_MODEL } from '../../../shared/constants.js';
|
|
|
|
const EndpointSchema = z.string().url();
|
|
|
|
interface Props {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
}
|
|
|
|
export function OllamaSettingsModal({ open, onClose }: Props): React.ReactElement | null {
|
|
const [endpoint, setEndpoint] = useState(DEFAULT_OLLAMA_ENDPOINT);
|
|
const [model, setModel] = useState(DEFAULT_OLLAMA_MODEL);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 마운트/open 시 현재 설정 fetch
|
|
useEffect(() => {
|
|
if (!open) return;
|
|
void inboxApi.loadOllamaSettings().then((s) => {
|
|
if (s) {
|
|
setEndpoint(s.endpoint);
|
|
setModel(s.model);
|
|
}
|
|
setError(null);
|
|
});
|
|
}, [open]);
|
|
|
|
if (!open) return null;
|
|
|
|
async function handleSave() {
|
|
if (saving) return; // m4 fix: synchronous double-click 가드
|
|
setSaving(true);
|
|
setError(null);
|
|
try {
|
|
// v0.2.6 #42 — client-side URL validation, server-side healthCheck 전에 명확한 메시지
|
|
const parseResult = EndpointSchema.safeParse(endpoint);
|
|
if (!parseResult.success) {
|
|
setError('유효한 URL 형식이 아닙니다 (예: http://localhost:11434)');
|
|
return;
|
|
}
|
|
if (model.trim().length === 0) {
|
|
setError('모델명을 입력하세요');
|
|
return;
|
|
}
|
|
const r = await inboxApi.saveOllamaSettings({ endpoint, model });
|
|
if (r.ok) {
|
|
onClose();
|
|
} else {
|
|
setError(r.reason);
|
|
}
|
|
} catch (e) {
|
|
setError(String(e));
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
}
|
|
|
|
return (
|
|
<div
|
|
onKeyDown={(e) => {
|
|
if (e.key === 'Escape' && !saving) onClose();
|
|
if (e.key === 'Enter' && !saving) void handleSave();
|
|
}}
|
|
tabIndex={-1}
|
|
style={{
|
|
position: 'fixed', inset: 0, background: 'rgba(0,0,0,0.4)',
|
|
display: 'flex', alignItems: 'center', justifyContent: 'center', zIndex: 1000
|
|
}}
|
|
>
|
|
<div style={{
|
|
background: '#fff', borderRadius: 8, padding: 20, minWidth: 400, maxWidth: 500,
|
|
boxShadow: '0 4px 16px rgba(0,0,0,0.2)'
|
|
}}>
|
|
<h2 style={{ margin: '0 0 12px 0', fontSize: 16 }}>Ollama 설정</h2>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
|
|
Endpoint URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={endpoint}
|
|
onChange={(e) => setEndpoint(e.target.value)}
|
|
placeholder="http://localhost:11434"
|
|
autoFocus
|
|
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
<div style={{ marginBottom: 12 }}>
|
|
<label style={{ display: 'block', fontSize: 12, color: '#666', marginBottom: 4 }}>
|
|
Model
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={model}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder="gemma4:e4b"
|
|
style={{ width: '100%', padding: '6px 8px', fontSize: 13, border: '1px solid #ccc', borderRadius: 4 }}
|
|
disabled={saving}
|
|
/>
|
|
</div>
|
|
{error && (
|
|
<div style={{
|
|
background: '#fce4e4', color: '#a33', padding: '6px 10px', borderRadius: 4,
|
|
fontSize: 12, marginBottom: 12
|
|
}}>
|
|
저장 실패: {error}
|
|
</div>
|
|
)}
|
|
<div style={{ display: 'flex', gap: 8, justifyContent: 'flex-end' }}>
|
|
<button
|
|
onClick={onClose}
|
|
disabled={saving}
|
|
style={{
|
|
background: 'transparent', color: '#666',
|
|
border: '1px solid #ccc', borderRadius: 4,
|
|
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
|
|
}}
|
|
>
|
|
취소
|
|
</button>
|
|
<button
|
|
onClick={() => void handleSave()}
|
|
disabled={saving}
|
|
style={{
|
|
background: saving ? '#999' : '#0a4b80', color: '#fff',
|
|
border: 'none', borderRadius: 4,
|
|
padding: '6px 14px', fontSize: 12, cursor: saving ? 'not-allowed' : 'pointer'
|
|
}}
|
|
>
|
|
{saving ? '검증 중...' : '저장'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|