You cannot select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.

400 lines
14 KiB
TypeScript

// 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<string, string> = {
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<string, ProviderMeta> = {
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<LlmProvider>("ollama");
const [model, setModel] = useState("");
const [host, setHost] = useState("");
const [apiKey, setApiKey] = useState("");
const [timeout, setTimeoutVal] = useState("");
const [test, setTest] = useState<LlmTestResult | null>(null);
const [testing, setTesting] = useState(false);
const [saving, setSaving] = useState(false);
// 호스트/키에서 불러온 사용 가능한 모델 목록(드롭다운 선택). reloadKey 증가 시 강제 재조회.
const [models, setModels] = useState<string[]>([]);
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 (
<section className="set-sec" aria-label="AI · LLM">
<header className="set-sechead">
<h2>AI · LLM </h2>
<p> · . .</p>
{cfg?.overridden && <span className="set-tag set-tag-on"> </span>}
</header>
<div className="set-card">
<div className="set-field">
<label></label>
<SegmentToggle
options={(cfg?.provider_options ?? ["ollama", "openai", "gemini"]).map((p) => ({
id: p,
label: PROVIDER_LABEL[p] ?? p,
}))}
value={provider}
onChange={(id) => changeProvider(id as LlmProvider)}
/>
<small className="set-hint">{meta.hint}</small>
</div>
{meta.needsHost && (
<div className="set-field">
<label htmlFor="llm-host">{meta.hostLabel}</label>
<input
id="llm-host"
className="set-input set-mono"
value={host}
onChange={(e) => setHost(e.target.value)}
placeholder={meta.hostPlaceholder}
/>
{meta.presets.length > 0 && (
<div className="set-chips" role="list" aria-label="자주 쓰는 호스트">
{meta.presets.map((h) => (
<button
key={h.url}
type="button"
role="listitem"
className={"set-chip" + (h.url === host ? " on" : "")}
onClick={() => setHost(h.url)}
title={h.url}
>
{h.label}
</button>
))}
</div>
)}
</div>
)}
{meta.needsKey && (
<div className="set-field">
<label htmlFor="llm-key">API </label>
<input
id="llm-key"
type="password"
autoComplete="off"
className="set-input set-mono"
value={apiKey}
onChange={(e) => setApiKey(e.target.value)}
placeholder={keySet ? "설정됨 · 새 키를 입력하면 교체돼요" : meta.keyPlaceholder}
/>
<small className="set-hint">
{keySet
? "키가 저장돼 있어요(암호화). 비워 두면 그대로 유지, 입력하면 교체돼요."
: "키는 암호화해서 저장하고, 화면에는 다시 표시하지 않아요."}
</small>
</div>
)}
<div className="set-field">
<label htmlFor="llm-model"></label>
{models.length > 0 ? (
<select
id="llm-model"
className="set-input set-mono"
value={model}
onChange={(e) => setModel(e.target.value)}
>
{model && !models.includes(model) && (
<option value={model}>{model} ( · )</option>
)}
{models.map((m) => (
<option key={m} value={m}>
{m}
</option>
))}
</select>
) : (
<input
id="llm-model"
className="set-input set-mono"
value={model}
onChange={(e) => setModel(e.target.value)}
placeholder={meta.modelPlaceholder}
/>
)}
<div className="set-modelmeta">
<small className="set-hint">
{loadingModels
? "모델 목록 불러오는 중…"
: models.length > 0
? `${models.length}개 모델 사용 가능 · 드롭다운에서 선택`
: meta.needsKey
? "키를 입력하면 모델 목록을 자동으로 불러와요"
: "호스트에 연결되면 모델 목록을 자동으로 불러와요"}
</small>
<button
type="button"
className="set-chip"
onClick={() => setReloadKey((k) => k + 1)}
disabled={loadingModels}
>
<Icon name="refresh" />
</button>
</div>
</div>
<div className="set-field set-field-narrow">
<label htmlFor="llm-timeout"> ()</label>
<input
id="llm-timeout"
type="number"
min={1}
className="set-input set-mono"
value={timeout}
onChange={(e) => setTimeoutVal(e.target.value)}
/>
</div>
<div className="set-actions">
<Button variant="glass" icon="zap" onClick={onTest} disabled={testing}>
{testing ? "테스트 중…" : "연결 테스트"}
</Button>
<Button variant="lime" icon="tick" onClick={onSave} disabled={saving || isLoading}>
{saving ? "저장 중…" : "저장 후 적용"}
</Button>
{cfg?.overridden && (
<Button variant="glass" icon="refresh" onClick={onReset} disabled={saving}>
</Button>
)}
</div>
{test && (
<div className={"set-test " + (test.reachable ? "ok" : "bad")} role="status">
<Icon name={test.reachable ? "tick" : "x"} />
<span>
{test.reachable
? `연결됨 · ${test.provider}${test.model ? " · " + test.model : ""}`
: `연결 실패 · ${test.detail || "응답 없음"}`}
</span>
</div>
)}
</div>
<div className="set-card">
<header className="set-cardhead">
<h3> </h3>
<span className="set-hint">(.env) · </span>
</header>
<dl className="set-kv">
<div>
<dt>(RAG)</dt>
<dd>
{cfg?.embed_provider ?? "—"}
{cfg?.embed_model ? ` · ${cfg.embed_model}` : ""}
</dd>
</div>
<div>
<dt></dt>
<dd>{cfg?.agent_provider ?? "—"}</dd>
</div>
<div>
<dt> (STT)</dt>
<dd>{cfg?.stt_provider ?? "—"}</dd>
</div>
<div>
<dt> (Vision)</dt>
<dd>{cfg?.vision_provider ?? "—"}</dd>
</div>
</dl>
</div>
</section>
);
}