// frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/api키/timeout) 편집·테스트·적용 // 지원 provider: ollama | openai(호환 엔드포인트) | gemini. provider 별로 입력 필드가 달라진다. "use client"; import { useEffect, useState } from "react"; import { Button } from "@/components/Button"; import { Icon } from "@/components/Icon"; import { SegmentToggle } from "@/components/SegmentToggle"; import { settingsApi } from "@/lib/settings/api"; import { useLlmConfig } from "@/lib/hooks/useSettings"; import type { LlmProvider, LlmTestResult } from "@/lib/types"; import type { ToastFn } from "./SettingsClient"; const PROVIDER_LABEL: Record = { auto: "자동", ollama: "Ollama", heuristic: "규칙(오프라인)", openai: "OpenAI 호환", gemini: "Gemini", }; type ProviderMeta = { hint: string; hostLabel: string; hostPlaceholder: string; modelPlaceholder: string; needsHost: boolean; needsKey: boolean; keyPlaceholder: string; presets: { label: string; url: string }[]; }; // provider 별 입력 메타. needsHost=호스트/베이스URL 필드 표시, needsKey=API 키 필드 표시. const PROVIDER_META: Record = { ollama: { hint: "Ollama 서버(로컬/원격)를 사용해요.", hostLabel: "Ollama 호스트 (API 주소)", hostPlaceholder: "https://ollama.yirugi.synology.me", modelPlaceholder: "예: gemma4:26b-a4b-it-q8_0", needsHost: true, needsKey: false, keyPlaceholder: "", presets: [ { label: "Synology 원격", url: "https://ollama.yirugi.synology.me" }, { label: "로컬", url: "http://localhost:11434" }, ], }, openai: { hint: "OpenAI 호환 엔드포인트(OpenAI·OpenRouter·Groq·로컬 vLLM/LM Studio 등).", hostLabel: "API 베이스 URL", hostPlaceholder: "https://api.openai.com/v1", modelPlaceholder: "예: gpt-4o-mini", needsHost: true, needsKey: true, keyPlaceholder: "sk-…", presets: [ { label: "OpenAI", url: "https://api.openai.com/v1" }, { label: "OpenRouter", url: "https://openrouter.ai/api/v1" }, { label: "Gemini(호환)", url: "https://generativelanguage.googleapis.com/v1beta/openai" }, ], }, gemini: { hint: "Google Gemini API. 키만 입력하면 기본 엔드포인트를 사용해요.", hostLabel: "API 베이스 URL (선택)", hostPlaceholder: "https://generativelanguage.googleapis.com/v1beta", modelPlaceholder: "예: gemini-2.5-flash", needsHost: false, needsKey: true, keyPlaceholder: "AIza…", presets: [], }, }; function metaFor(p: string): ProviderMeta { return PROVIDER_META[p] ?? PROVIDER_META.ollama; } export function LlmTab({ toast }: { toast: ToastFn }) { const { data: cfg, isLoading, refresh } = useLlmConfig(); const [provider, setProvider] = useState("ollama"); const [model, setModel] = useState(""); const [host, setHost] = useState(""); const [apiKey, setApiKey] = useState(""); const [timeout, setTimeoutVal] = useState(""); const [test, setTest] = useState(null); const [testing, setTesting] = useState(false); const [saving, setSaving] = useState(false); // 호스트/키에서 불러온 사용 가능한 모델 목록(드롭다운 선택). reloadKey 증가 시 강제 재조회. const [models, setModels] = useState([]); const [loadingModels, setLoadingModels] = useState(false); const [reloadKey, setReloadKey] = useState(0); useEffect(() => { if (cfg) { setProvider(cfg.provider); setModel(cfg.model); setHost(cfg.host); setTimeoutVal(String(cfg.timeout)); setApiKey(""); // 키는 절대 내려받지 않음 — 비워두고 'set' 여부만 표시. } }, [cfg]); const meta = metaFor(provider); const keySet = !!cfg?.api_key_set; // provider 전환: 잔여 host/key 혼입을 막고 새 provider 기본 호스트로 초기화. const changeProvider = (p: LlmProvider) => { setProvider(p); setTest(null); setModels([]); setModel(""); // provider 마다 모델명 체계가 달라 초기화(목록서 다시 선택) setApiKey(""); const presets = metaFor(p).presets; setHost(presets[0]?.url ?? ""); }; // host/key 가 준비되면 모델 목록을 자동 조회 → 드롭다운. 디바운스 450ms. 새로고침=reloadKey. useEffect(() => { if (provider === "heuristic") { setModels([]); return; } const needsKey = provider === "openai" || provider === "gemini"; const needsHost = provider === "ollama" || provider === "openai"; if (needsHost && !host.trim()) { setModels([]); return; } if (needsKey && !apiKey.trim() && !cfg?.api_key_set) { setModels([]); return; } let cancelled = false; const run = async () => { setLoadingModels(true); try { const r = await settingsApi.testLlm({ provider, host, api_key: apiKey || undefined }); if (!cancelled) setModels(r.reachable ? r.models : []); } catch { if (!cancelled) setModels([]); } finally { if (!cancelled) setLoadingModels(false); } }; const t = setTimeout(run, 450); return () => { cancelled = true; clearTimeout(t); }; }, [host, provider, apiKey, reloadKey, cfg?.api_key_set]); const onTest = async () => { setTesting(true); setTest(null); try { const r = await settingsApi.testLlm({ provider, host, model, api_key: apiKey || undefined }); setTest(r); if (r.reachable) setModels(r.models); toast( r.reachable ? "연결에 성공했어요" : "연결할 수 없어요", r.reachable ? "green" : "coral", ); } catch { toast("연결 테스트에 실패했어요", "coral"); } finally { setTesting(false); } }; const onSave = async () => { setSaving(true); try { const t = parseFloat(timeout); await settingsApi.updateLlm({ provider, model, host, timeout: Number.isFinite(t) && t > 0 ? t : undefined, // 입력했을 때만 키 전송(빈칸=기존 키 유지). 지우려면 '기본값으로'. api_key: apiKey.trim() ? apiKey : undefined, }); await refresh(); setApiKey(""); toast("LLM 설정을 저장했어요", "green"); } catch { toast("저장에 실패했어요", "coral"); } finally { setSaving(false); } }; const onReset = async () => { setSaving(true); try { await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0, api_key: "" }); const next = await refresh(); if (next) { setProvider(next.provider); setModel(next.model); setHost(next.host); setTimeoutVal(String(next.timeout)); } setApiKey(""); setTest(null); toast("기본값(.env)으로 되돌렸어요", "blue"); } catch { toast("초기화에 실패했어요", "coral"); } finally { setSaving(false); } }; return (

AI · LLM 연결

아리의 분류·요약을 담당하는 모델 연결이에요. 저장하면 즉시 적용돼요.

{cfg?.overridden && 사용자 설정 적용 중}
({ id: p, label: PROVIDER_LABEL[p] ?? p, }))} value={provider} onChange={(id) => changeProvider(id as LlmProvider)} /> {meta.hint}
{meta.needsHost && (
setHost(e.target.value)} placeholder={meta.hostPlaceholder} /> {meta.presets.length > 0 && (
{meta.presets.map((h) => ( ))}
)}
)} {meta.needsKey && (
setApiKey(e.target.value)} placeholder={keySet ? "설정됨 · 새 키를 입력하면 교체돼요" : meta.keyPlaceholder} /> {keySet ? "키가 저장돼 있어요(암호화). 비워 두면 그대로 유지, 입력하면 교체돼요." : "키는 암호화해서 저장하고, 화면에는 다시 표시하지 않아요."}
)}
{models.length > 0 ? ( ) : ( setModel(e.target.value)} placeholder={meta.modelPlaceholder} /> )}
{loadingModels ? "모델 목록 불러오는 중…" : models.length > 0 ? `${models.length}개 모델 사용 가능 · 드롭다운에서 선택` : meta.needsKey ? "키를 입력하면 모델 목록을 자동으로 불러와요" : "호스트에 연결되면 모델 목록을 자동으로 불러와요"}
setTimeoutVal(e.target.value)} />
{cfg?.overridden && ( )}
{test && (
{test.reachable ? `연결됨 · ${test.provider}${test.model ? " · " + test.model : ""}` : `연결 실패 · ${test.detail || "응답 없음"}`}
)}

보조 프로바이더

환경변수(.env)로 관리 · 읽기 전용
임베딩(RAG)
{cfg?.embed_provider ?? "—"} {cfg?.embed_model ? ` · ${cfg.embed_model}` : ""}
에이전트
{cfg?.agent_provider ?? "—"}
음성 인식(STT)
{cfg?.stt_provider ?? "—"}
이미지 인식(Vision)
{cfg?.vision_provider ?? "—"}
); }