feat(ai): LLM 라우팅 + 에이전트/지식 UX 다수 개선
Build and Push Images / build-and-push (push) Has been cancelled

LLM 호출
- llmClient.resolveProvider 우선순위 보강: model_name 정확 매칭(case-insensitive)을 1순위로 추가
- MODEL_PROVIDER_MAP regex 모두 /i 플래그 (대문자 'Qwen' 등 대응)
- max_tokens fallback 4096 → 16384
- API key 빈 값일 때 Authorization 헤더 자체를 생략 (인증 안 받는 로컬 LLM 대응)

multiAgentExecutionEngine
- 강제 max_tokens=2000 제거 → provider DB 값 fallback 으로 위임

에이전트 관리 (agents/page.tsx)
- 모델 드롭다운: 하드코딩 MODEL_GROUPS 제거 → 등록된 ai_llm_providers 동적 로드
- 같은 provider type(anthropic/openai/ollama 등) 끼리 그룹핑, 0개일 때 안내
- model_name 빈 값 row 는 dropdown 에서 자동 제외 (Radix 제약 회피)
- max_tokens 기본 16384, 빠른선택 버튼 (4K/16K/32K/64K) 추가
- 적용된 지식 파일 row 클릭 시 상세 모달 (내용/복사) 오픈
- hard delete 서비스 변경에 맞춘 UX

LLM 프로바이더 (providers/page.tsx)
- Ollama (로컬) 선택 시 'API 호출 URL' 입력 필드 노출
- '모델 ID' 직접 입력 필드 추가 (Qwen3.6-35B-A3B 같은 커스텀 모델)
- max_tokens / temperature 편집 UI 추가, 기본값 16384/0.7
- 더미 'ollama' API key 자동주입 제거 (실제 인증 토큰 보존)
- ollama 일 때 model_name 빈 값 저장 차단

대화 모니터링 (conversations/page.tsx)
- 메시지 안의 fenced code block (```lang ... ```) 자동 파싱
- 코드블록 별 다운로드 / 복사 버튼 (파일명 자동 추출 또는 snippet-{n}.{ext})
- 17종 언어 → 확장자 매핑 (html/jsp/ts/tsx/py/java/c/cpp/cs/go/rs/rb/php/sql/yml ...)
- 모달 폭 max-w-2xl → max-w-4xl

API 키 관리 (api-keys-manage/page.tsx)
- 그룹 실행 axios 호출 timeout 30s → 5분 (multi-agent 추론 시간 대응)

지식 라이브러리 (knowledge/page.tsx)
- TS2774 (Buffer.byteLength 함수 참조 truthy 체크) 컴파일 에러 수정 → 청크 빌드 정상화

aiAgentService
- delete() 진짜 hard DELETE 로 변경 + 외래키 의존 row(group_members/conversations/logs/usage) 정리
- list() 기본 필터에 status <> 'archived' 추가 → soft-deleted row 자동 숨김

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
chpark
2026-04-28 18:41:52 +09:00
parent e4bca14a90
commit b4dc9b1927
8 changed files with 397 additions and 79 deletions
@@ -13,42 +13,29 @@ import { Tabs, TabsContent, TabsList, TabsTrigger } from "@/components/ui/tabs";
import { Bot, Plus, Search, Pencil, Trash2, Loader2, FileText, Upload, X, Settings2 } from "lucide-react";
import { toast } from "sonner";
const MODEL_GROUPS = [
{ provider: "Anthropic", color: "#D97757", models: [
{ value: "claude-opus-4-20250514", label: "Claude Opus 4" },
{ value: "claude-sonnet-4-20250514", label: "Claude Sonnet 4" },
{ value: "claude-haiku-4-20250514", label: "Claude Haiku 4" },
{ value: "claude-sonnet-4-5-20250514", label: "Claude Sonnet 4.5" },
]},
{ provider: "OpenAI", color: "#10A37F", models: [
{ value: "gpt-4o", label: "GPT-4o" },
{ value: "gpt-4o-mini", label: "GPT-4o Mini" },
{ value: "o1", label: "o1" },
{ value: "o3-mini", label: "o3 Mini" },
]},
{ provider: "Google", color: "#4285F4", models: [
{ value: "gemini-2.5-pro", label: "Gemini 2.5 Pro" },
{ value: "gemini-2.5-flash", label: "Gemini 2.5 Flash" },
{ value: "gemini-2.0-flash", label: "Gemini 2.0 Flash (무료)" },
]},
{ provider: "DeepSeek", color: "#0066FF", models: [
{ value: "deepseek-chat", label: "DeepSeek Chat (V3)" },
{ value: "deepseek-reasoner", label: "DeepSeek Reasoner (R1)" },
]},
{ provider: "Ollama (로컬)", color: "#1D1D1D", models: [
{ value: "llama3.2", label: "Llama 3.2" },
{ value: "mistral", label: "Mistral" },
{ value: "qwen2.5", label: "Qwen 2.5" },
]},
];
// 프로바이더별 색상 (LLM 프로바이더 페이지와 동일)
const PROVIDER_COLOR: Record<string, string> = {
anthropic: "#D97757",
openai: "#10A37F",
google: "#4285F4",
deepseek: "#0066FF",
ollama: "#1D1D1D",
};
const PROVIDER_LABEL: Record<string, string> = {
anthropic: "Anthropic",
openai: "OpenAI",
google: "Google",
deepseek: "DeepSeek",
ollama: "Ollama (로컬)",
};
function getModelColor(model: string): string {
for (const g of MODEL_GROUPS) { if (g.models.some((m) => m.value === model)) return g.color; }
return "#6B7280";
}
function getModelLabel(model: string): string {
for (const g of MODEL_GROUPS) { const m = g.models.find((m) => m.value === model); if (m) return m.label; }
return model;
interface RegisteredProvider {
id: number;
name: string; // anthropic / openai / ollama …
display_name: string; // 사용자 지정 라벨 (예: "Qwen3.6 35B (llama.cpp)")
model_name: string; // 실제 호출용 모델 ID (예: "Qwen3.6-35B-A3B")
endpoint?: string;
is_active: boolean;
}
interface Agent {
@@ -63,7 +50,7 @@ interface AgentForm {
const DEFAULT_FORM: AgentForm = {
name: "", description: "", model: "claude-sonnet-4-20250514", system_prompt: "",
config: { temperature: 0.7, max_tokens: 4096, knowledge_files: [] },
config: { temperature: 0.7, max_tokens: 16384, knowledge_files: [] },
};
export default function AgentListPage() {
@@ -78,6 +65,34 @@ export default function AgentListPage() {
const [libraryFiles, setLibraryFiles] = useState<any[]>([]);
const [libraryLoading, setLibraryLoading] = useState(false);
// 지식 파일 상세보기 모달
const [knowledgeDetail, setKnowledgeDetail] = useState<{ name: string; content: string; source?: string; size?: number } | null>(null);
// 등록된 LLM 프로바이더 (LLM 프로바이더 페이지에서 추가한 것만)
const [registeredProviders, setRegisteredProviders] = useState<RegisteredProvider[]>([]);
const loadProviders = useCallback(async () => {
try {
const res = await aiAgentApi.listProviders();
const list: RegisteredProvider[] = (res?.data || []).filter((p: any) => p.is_active);
setRegisteredProviders(list);
} catch {
setRegisteredProviders([]);
}
}, []);
// 모델 라벨/색상 헬퍼 — 등록된 프로바이더 우선 매칭
const getModelLabel = (model: string): string => {
const match = registeredProviders.find((p) => p.model_name === model);
if (match) return match.display_name || match.model_name;
return model;
};
const getModelColor = (model: string): string => {
const match = registeredProviders.find((p) => p.model_name === model);
if (match) return PROVIDER_COLOR[match.name] || "#6B7280";
return "#6B7280";
};
const loadLibrary = useCallback(async () => {
setLibraryLoading(true);
try { const res = await aiAgentApi.listKnowledge(); setLibraryFiles(res.data || []); }
@@ -92,7 +107,7 @@ export default function AgentListPage() {
setLoading(false);
}, [search]);
useEffect(() => { loadAgents(); }, [loadAgents]);
useEffect(() => { loadAgents(); loadProviders(); }, [loadAgents, loadProviders]);
const openCreate = () => {
setEditing(null);
@@ -107,7 +122,7 @@ export default function AgentListPage() {
setForm({
name: agent.name, description: agent.description || "", model: agent.model,
system_prompt: agent.system_prompt || "",
config: { temperature: cfg.temperature ?? 0.7, max_tokens: cfg.max_tokens ?? 4096, knowledge_files: cfg.knowledge_files || [] },
config: { temperature: cfg.temperature ?? 0.7, max_tokens: cfg.max_tokens ?? 16384, knowledge_files: cfg.knowledge_files || [] },
});
loadLibrary();
setModalOpen(true);
@@ -289,21 +304,44 @@ export default function AgentListPage() {
<div>
<Label className="text-[11px]"></Label>
<Select value={form.model} onValueChange={(v) => setForm({ ...form, model: v })}>
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectTrigger className="mt-1 h-8 text-xs">
<SelectValue placeholder={registeredProviders.length === 0 ? "LLM 프로바이더를 먼저 등록하세요" : "모델 선택"} />
</SelectTrigger>
<SelectContent className="max-h-[280px]">
{MODEL_GROUPS.map((group) => (
<div key={group.provider}>
{registeredProviders.length === 0 && (
<div className="px-3 py-2 text-[11px] text-muted-foreground">
LLM .<br />
AI관리 LLM .
</div>
)}
{/* 프로바이더 종류별 그룹핑 (anthropic/openai/google/deepseek/ollama)
— model_name 이 비어있는 row 는 스킵 (Radix Select 제약: value 빈문자열 금지) */}
{Object.entries(
registeredProviders
.filter((p) => !!p.model_name && p.model_name.trim() !== "")
.reduce<Record<string, RegisteredProvider[]>>((acc, p) => {
(acc[p.name] = acc[p.name] || []).push(p);
return acc;
}, {}),
).map(([providerKey, list]) => (
<div key={providerKey}>
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] font-semibold text-muted-foreground">
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: group.color }} />
{group.provider}
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: PROVIDER_COLOR[providerKey] || "#6B7280" }} />
{PROVIDER_LABEL[providerKey] || providerKey}
</div>
{group.models.map((m) => (
<SelectItem key={m.value} value={m.value} className="text-xs">{m.label}</SelectItem>
{list.map((p) => (
<SelectItem key={p.id} value={p.model_name} className="text-xs">
{p.display_name}
<span className="ml-2 text-[10px] text-muted-foreground">{p.model_name}</span>
</SelectItem>
))}
</div>
))}
</SelectContent>
</Select>
<p className="mt-1 text-[10px] text-muted-foreground">
LLM . ( {registeredProviders.length} )
</p>
</div>
</TabsContent>
@@ -322,14 +360,24 @@ export default function AgentListPage() {
{form.config.knowledge_files.length > 0 ? (
<div className="mt-1.5 space-y-1">
{form.config.knowledge_files.map((f: any, i) => (
<div key={i} className="flex items-center justify-between rounded-md border bg-card px-2.5 py-1.5">
<div
key={i}
className="flex items-center justify-between rounded-md border bg-card px-2.5 py-1.5 cursor-pointer hover:bg-accent/40 transition-colors"
onClick={() => setKnowledgeDetail({ name: f.name, content: f.content || "", source: f.source, size: f.content?.length || 0 })}
title="클릭하면 상세 내용을 볼 수 있습니다"
>
<div className="flex items-center gap-1.5">
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
<span className="text-xs font-medium">{f.name}</span>
<span className="text-[10px] text-muted-foreground">{(f.content.length / 1024).toFixed(1)}KB</span>
{f.source === "library" && <Badge variant="secondary" className="h-4 text-[8px]"></Badge>}
</div>
<button onClick={() => removeFile(i)} className="text-destructive hover:text-destructive/80"><X className="h-3 w-3" /></button>
<button
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
className="text-destructive hover:text-destructive/80"
>
<X className="h-3 w-3" />
</button>
</div>
))}
</div>
@@ -405,9 +453,23 @@ export default function AgentListPage() {
</div>
<div>
<Label className="text-[11px]">Max Tokens</Label>
<Input type="number" value={form.config.max_tokens}
onChange={(e) => setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 4096 } })}
className="mt-1 h-8 w-32 text-xs" />
<div className="mt-1 flex items-center gap-2">
<Input type="number" min={256} max={131072} step={1024} value={form.config.max_tokens}
onChange={(e) => setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 16384 } })}
className="h-8 w-32 text-xs" />
<div className="flex gap-1">
{[4096, 16384, 32768, 65536].map((v) => (
<Button key={v} type="button" size="sm" variant="outline"
className="h-7 px-2 text-[10px]"
onClick={() => setForm({ ...form, config: { ...form.config, max_tokens: v } })}>
{v >= 1024 ? `${v / 1024}K` : v}
</Button>
))}
</div>
</div>
<p className="text-[10px] text-muted-foreground mt-1">
. Qwen-thinking 32K . .
</p>
</div>
</TabsContent>
</Tabs>
@@ -420,6 +482,47 @@ export default function AgentListPage() {
</div>
</DialogContent>
</Dialog>
{/* 지식 파일 상세 모달 */}
<Dialog open={!!knowledgeDetail} onOpenChange={(open) => { if (!open) setKnowledgeDetail(null); }}>
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
<DialogHeader>
<DialogTitle className="flex items-center gap-2 text-base">
<FileText className="h-4 w-4 text-muted-foreground" />
<span>{knowledgeDetail?.name}</span>
{knowledgeDetail?.source === "library" && (
<Badge variant="secondary" className="h-5 text-[10px]"></Badge>
)}
<span className="ml-1 text-[11px] font-normal text-muted-foreground">
{((knowledgeDetail?.size || 0) / 1024).toFixed(1)}KB · {knowledgeDetail?.content?.length || 0}
</span>
</DialogTitle>
</DialogHeader>
<div className="flex-1 overflow-auto rounded border bg-muted/30 p-3">
<pre className="whitespace-pre-wrap break-words text-xs font-mono leading-relaxed">
{knowledgeDetail?.content || "(빈 파일)"}
</pre>
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button
variant="outline"
size="sm"
className="h-8 text-xs"
onClick={() => {
if (knowledgeDetail?.content) {
navigator.clipboard.writeText(knowledgeDetail.content);
toast.success("내용이 클립보드에 복사되었습니다.");
}
}}
>
</Button>
<Button size="sm" className="h-8 text-xs" onClick={() => setKnowledgeDetail(null)}>
</Button>
</div>
</DialogContent>
</Dialog>
</div>
);
}
@@ -139,7 +139,11 @@ export default function ApiKeysManagePage() {
const res = await apiClient.post(
`/ai/v1/groups/${testGroupId}`,
{ message: testMessage },
{ headers: { Authorization: `Bearer ${testApiKey}` } }
{
headers: { Authorization: `Bearer ${testApiKey}` },
// multi-agent 실행은 모델 추론 시간이 길어질 수 있으므로 클라이언트 타임아웃을 5분으로 확장
timeout: 5 * 60 * 1000,
}
);
const elapsed = Date.now() - startTime;
@@ -4,9 +4,107 @@ import { useEffect, useState, useCallback } from "react";
import { aiAgentApi } from "@/lib/api/aiAgent";
import { Button } from "@/components/ui/button";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { MessageSquare, Loader2, User, Bot, Trash2 } from "lucide-react";
import { MessageSquare, Loader2, User, Bot, Trash2, Download, Copy, Code2 } from "lucide-react";
import { toast } from "sonner";
// ─── 코드 블록 파싱 + 다운로드 헬퍼 ─────────────────
const LANG_EXT: Record<string, string> = {
html: "html", htm: "html", jsp: "jsp",
js: "js", javascript: "js", jsx: "jsx",
ts: "ts", typescript: "ts", tsx: "tsx",
py: "py", python: "py",
java: "java", kt: "kt", kotlin: "kt",
c: "c", cpp: "cpp", "c++": "cpp", cs: "cs", csharp: "cs",
go: "go", rs: "rs", rust: "rs", rb: "rb", ruby: "rb", php: "php",
css: "css", scss: "scss", sass: "sass",
json: "json", xml: "xml", yaml: "yml", yml: "yml",
sql: "sql", sh: "sh", bash: "sh", shell: "sh", ps1: "ps1",
md: "md", markdown: "md", txt: "txt",
};
function langExt(lang?: string): string {
if (!lang) return "txt";
return LANG_EXT[lang.trim().toLowerCase()] || "txt";
}
function parseMessage(content: string): Array<{ type: "text" | "code"; value: string; lang?: string }> {
if (!content) return [];
const re = /```([a-zA-Z0-9+\-_.#]*)\n([\s\S]*?)```/g;
const parts: Array<{ type: "text" | "code"; value: string; lang?: string }> = [];
let last = 0;
let m: RegExpExecArray | null;
while ((m = re.exec(content)) !== null) {
if (m.index > last) parts.push({ type: "text", value: content.slice(last, m.index) });
parts.push({ type: "code", value: m[2], lang: m[1] || undefined });
last = re.lastIndex;
}
if (last < content.length) parts.push({ type: "text", value: content.slice(last) });
return parts;
}
function downloadBlob(text: string, filename: string) {
const blob = new Blob([text], { type: "text/plain;charset=utf-8" });
const url = URL.createObjectURL(blob);
const a = document.createElement("a");
a.href = url; a.download = filename; a.click();
setTimeout(() => URL.revokeObjectURL(url), 1000);
}
// 코드 블록 1개 렌더 — 헤더(언어/파일/복사/다운로드) + 본문
function CodeBlock({ code, lang, idx }: { code: string; lang?: string; idx: number }) {
const ext = langExt(lang);
// 첫 줄에서 파일명 힌트 추출 (`<!-- file: app.html -->` `// file: foo.ts` 등)
const firstLine = code.split("\n", 1)[0] || "";
const fileHint = firstLine.match(/file\s*[:=]\s*([\w\-./]+\.[a-zA-Z0-9]+)/i)?.[1];
const filename = fileHint || `snippet-${idx + 1}.${ext}`;
return (
<div className="my-2 rounded-md border bg-zinc-950 text-zinc-100 overflow-hidden">
<div className="flex items-center justify-between px-2 py-1 bg-zinc-800/80 border-b border-zinc-700">
<div className="flex items-center gap-1.5 text-[10px]">
<Code2 className="h-3 w-3 opacity-70" />
<span className="font-mono uppercase">{lang || "code"}</span>
<span className="opacity-50">·</span>
<span className="opacity-70">{code.split("\n").length}</span>
<span className="opacity-50">·</span>
<span className="opacity-70">{(code.length / 1024).toFixed(1)}KB</span>
</div>
<div className="flex gap-1">
<button
onClick={() => { navigator.clipboard.writeText(code); toast.success("복사됨"); }}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] hover:bg-zinc-700"
title="클립보드 복사"
>
<Copy className="h-3 w-3" />
</button>
<button
onClick={() => { downloadBlob(code, filename); toast.success(`${filename} 다운로드`); }}
className="flex items-center gap-1 rounded px-1.5 py-0.5 text-[10px] hover:bg-zinc-700"
title={`${filename} 으로 다운로드`}
>
<Download className="h-3 w-3" /> {filename}
</button>
</div>
</div>
<pre className="overflow-x-auto p-2 text-[11px] font-mono leading-relaxed whitespace-pre">
<code>{code}</code>
</pre>
</div>
);
}
// 메시지 본문 — 텍스트와 코드블록을 분리 렌더
function MessageBody({ content }: { content: string }) {
const parts = parseMessage(content || "");
if (parts.length === 0) return null;
let codeIdx = 0;
return (
<>
{parts.map((p, i) =>
p.type === "code"
? <CodeBlock key={i} code={p.value} lang={p.lang} idx={codeIdx++} />
: <span key={i} className="whitespace-pre-wrap">{p.value}</span>
)}
</>
);
}
export default function ConversationsPage() {
const [conversations, setConversations] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
@@ -81,16 +179,16 @@ export default function ConversationsPage() {
{/* 대화 상세 모달 */}
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
<DialogContent className="max-w-2xl max-h-[80vh]">
<DialogContent className="max-w-4xl max-h-[85vh]">
<DialogHeader><DialogTitle> </DialogTitle></DialogHeader>
<div className="overflow-auto max-h-[60vh] space-y-3 pt-2">
<div className="overflow-auto max-h-[70vh] space-y-3 pt-2">
{messages.map((msg) => (
<div key={msg.id} className={`flex gap-2 ${msg.role === "assistant" ? "" : "flex-row-reverse"}`}>
<div className={`flex h-7 w-7 shrink-0 items-center justify-center rounded-full ${msg.role === "assistant" ? "bg-primary/10" : "bg-muted"}`}>
{msg.role === "assistant" ? <Bot className="h-4 w-4 text-primary" /> : <User className="h-4 w-4" />}
</div>
<div className={`max-w-[80%] rounded-xl px-3 py-2 text-sm ${msg.role === "assistant" ? "bg-muted" : "bg-primary text-white"}`}>
{msg.content}
<div className={`max-w-[85%] rounded-xl px-3 py-2 text-sm ${msg.role === "assistant" ? "bg-muted" : "bg-primary text-white"}`}>
<MessageBody content={msg.content} />
{msg.token_count > 0 && <span className="block text-[10px] opacity-60 mt-1">{msg.token_count} tokens</span>}
</div>
</div>
@@ -247,7 +247,7 @@ export default function KnowledgeLibraryPage() {
<Textarea value={form.content} onChange={(e) => setForm({ ...form, content: e.target.value })}
placeholder="마크다운 형식으로 지식 내용을 작성하세요...&#10;&#10;## 구매 프로세스&#10;1. 구매 요청서 작성&#10;2. 승인 절차..."
rows={12} className="text-xs font-mono" />
{form.content && <p className="mt-1 text-[10px] text-muted-foreground">{(Buffer.byteLength ? form.content.length : form.content.length)} · {(form.content.length / 1024).toFixed(1)}KB</p>}
{form.content && <p className="mt-1 text-[10px] text-muted-foreground">{form.content.length} · {(form.content.length / 1024).toFixed(1)}KB</p>}
</div>
<div className="flex justify-end gap-2 pt-2 border-t">
<Button variant="outline" size="sm" className="h-7 text-xs" onClick={() => setModalOpen(false)}></Button>
@@ -113,7 +113,7 @@ export default function ProviderListPage() {
api_key: "",
model_name: "",
endpoint: "",
max_tokens: 4096,
max_tokens: 16384,
temperature: 0.7,
cost_per_1k_input: 0,
cost_per_1k_output: 0,
@@ -144,7 +144,7 @@ export default function ProviderListPage() {
api_key: "",
model_name: "",
endpoint: cfg.endpoint,
max_tokens: 4096,
max_tokens: 16384,
temperature: 0.7,
cost_per_1k_input: 0,
cost_per_1k_output: 0,
@@ -185,12 +185,17 @@ export default function ProviderListPage() {
const handleSave = async () => {
if (!form.display_name) { toast.error("표시 이름을 입력하세요."); return; }
if (!editing && !form.api_key && form.name !== "ollama") { toast.error("API 키를 입력하세요."); return; }
if (form.name === "ollama" && !form.model_name?.trim()) {
toast.error("로컬 LLM은 모델 ID를 입력하세요. (예: Qwen3.6-35B-A3B)");
return;
}
setSaving(true);
try {
const data = { ...form };
if (editing && !data.api_key) delete (data as any).api_key;
if (form.name === "ollama" && !data.api_key) data.api_key = "ollama";
// ollama 라도 인증 필요한 원격(예: ai-agent.kryp.xyz) 가 있으므로
// 사용자가 직접 입력한 값만 저장. 비워두면 빈 문자열로 저장 → 백엔드가 Authorization 헤더 생략.
if (editing) {
await aiAgentApi.updateProvider(editing.id, data);
@@ -355,6 +360,40 @@ export default function ProviderListPage() {
<Input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} placeholder="내 Claude 키" className="mt-1" />
</div>
{/* API 호출 URL (로컬/커스텀 프로바이더용) */}
{form.name === "ollama" && (
<>
<div>
<Label className="text-xs">
API URL <span className="text-muted-foreground">(/ )</span>
</Label>
<Input
value={form.endpoint}
onChange={(e) => setForm({ ...form, endpoint: e.target.value })}
placeholder="http://localhost:11434 또는 https://ai-agent.kryp.xyz"
className="mt-1 font-mono text-sm"
/>
<p className="text-[10px] text-muted-foreground mt-1">
OpenAI (/v1/chat/completions) . Ollama, llama.cpp, vLLM .
</p>
</div>
<div>
<Label className="text-xs">
ID <span className="text-muted-foreground">( )</span>
</Label>
<Input
value={form.model_name}
onChange={(e) => setForm({ ...form, model_name: e.target.value })}
placeholder="예: Qwen3.6-35B-A3B, llama3.2, qwen2.5"
className="mt-1 font-mono text-sm"
/>
<p className="text-[10px] text-muted-foreground mt-1">
LLM model .
</p>
</div>
</>
)}
{/* API 키 */}
<div>
<Label className="text-xs">
@@ -365,9 +404,12 @@ export default function ProviderListPage() {
type={showKey ? "text" : "password"}
value={form.api_key}
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
placeholder={selectedConfig.keyPlaceholder}
placeholder={
form.name === "ollama"
? "필요 시 입력 (예: LK-... 인증 토큰, 없으면 비워둠)"
: selectedConfig.keyPlaceholder
}
className="pr-10 font-mono text-sm"
disabled={form.name === "ollama"}
/>
<button
type="button"
@@ -379,6 +421,54 @@ export default function ProviderListPage() {
</div>
</div>
{/* 추론 파라미터 (모든 프로바이더 공용) */}
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
<div>
<Label className="text-xs">Max Tokens</Label>
<div className="mt-1 flex items-center gap-2">
<Input
type="number"
min={256}
max={131072}
step={1024}
value={form.max_tokens}
onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) || 16384 })}
className="h-8 text-xs"
/>
</div>
<div className="mt-1 flex gap-1">
{[4096, 16384, 32768, 65536].map((v) => (
<Button key={v} type="button" size="sm" variant="outline"
className="h-6 px-2 text-[10px]"
onClick={() => setForm({ ...form, max_tokens: v })}>
{v >= 1024 ? `${v / 1024}K` : v}
</Button>
))}
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
. , .
</p>
</div>
<div>
<Label className="text-xs">Temperature</Label>
<div className="mt-1 flex items-center gap-2">
<input
type="range"
min={0}
max={1}
step={0.1}
value={form.temperature}
onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })}
className="flex-1"
/>
<span className="w-10 text-right font-mono text-xs">{form.temperature.toFixed(1)}</span>
</div>
<p className="mt-1 text-[10px] text-muted-foreground">
0 = , 1 = . 0.7.
</p>
</div>
</div>
<div className="flex justify-end gap-2 pt-2">
<Button variant="outline" onClick={() => setModalOpen(false)}></Button>
<Button onClick={handleSave} disabled={saving}>