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.
225 lines
7.1 KiB
TypeScript
225 lines
7.1 KiB
TypeScript
// frontend/components/settings/LlmTab.tsx — LLM 연결(provider/model/host/timeout) 편집·테스트·적용
|
|
"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<LlmProvider, string> = {
|
|
auto: "자동",
|
|
ollama: "Ollama",
|
|
heuristic: "규칙(오프라인)",
|
|
};
|
|
|
|
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 [timeout, setTimeoutVal] = useState("");
|
|
const [test, setTest] = useState<LlmTestResult | null>(null);
|
|
const [testing, setTesting] = useState(false);
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
useEffect(() => {
|
|
if (cfg) {
|
|
setProvider(cfg.provider);
|
|
setModel(cfg.model);
|
|
setHost(cfg.host);
|
|
setTimeoutVal(String(cfg.timeout));
|
|
}
|
|
}, [cfg]);
|
|
|
|
const isHeuristic = provider === "heuristic";
|
|
|
|
const onTest = async () => {
|
|
setTesting(true);
|
|
setTest(null);
|
|
try {
|
|
const r = await settingsApi.testLlm({ provider, host, model });
|
|
setTest(r);
|
|
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,
|
|
});
|
|
await refresh();
|
|
toast("LLM 설정을 저장했어요", "green");
|
|
} catch {
|
|
toast("저장에 실패했어요", "coral");
|
|
} finally {
|
|
setSaving(false);
|
|
}
|
|
};
|
|
|
|
const onReset = async () => {
|
|
setSaving(true);
|
|
try {
|
|
await settingsApi.updateLlm({ provider: "", model: "", host: "", timeout: 0 });
|
|
const next = await refresh();
|
|
if (next) {
|
|
setProvider(next.provider);
|
|
setModel(next.model);
|
|
setHost(next.host);
|
|
setTimeoutVal(String(next.timeout));
|
|
}
|
|
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"]).map((p) => ({
|
|
id: p,
|
|
label: PROVIDER_LABEL[p as LlmProvider] ?? p,
|
|
}))}
|
|
value={provider}
|
|
onChange={(id) => setProvider(id as LlmProvider)}
|
|
/>
|
|
<small className="set-hint">로컬 Ollama 서버를 사용해요.</small>
|
|
</div>
|
|
|
|
<div className="set-field">
|
|
<label htmlFor="llm-host">Ollama 호스트</label>
|
|
<input
|
|
id="llm-host"
|
|
className="set-input set-mono"
|
|
value={host}
|
|
disabled={isHeuristic}
|
|
onChange={(e) => setHost(e.target.value)}
|
|
placeholder="http://localhost:11434"
|
|
/>
|
|
</div>
|
|
|
|
<div className="set-field">
|
|
<label htmlFor="llm-model">모델</label>
|
|
<input
|
|
id="llm-model"
|
|
className="set-input set-mono"
|
|
value={model}
|
|
disabled={isHeuristic}
|
|
onChange={(e) => setModel(e.target.value)}
|
|
placeholder="예: llama3.1"
|
|
/>
|
|
{test && test.models.length > 0 && (
|
|
<div className="set-chips" role="list" aria-label="사용 가능한 모델">
|
|
{test.models.map((m) => (
|
|
<button
|
|
key={m}
|
|
type="button"
|
|
role="listitem"
|
|
className={"set-chip" + (m === model ? " on" : "")}
|
|
onClick={() => setModel(m)}
|
|
>
|
|
{m}
|
|
</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}
|
|
disabled={isHeuristic}
|
|
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>
|
|
);
|
|
}
|