b4dc9b1927
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>
493 lines
25 KiB
TypeScript
493 lines
25 KiB
TypeScript
"use client";
|
|
|
|
import { useEffect, useState, useCallback, useRef } from "react";
|
|
import { aiAgentApi } from "@/lib/api/aiAgent";
|
|
import { apiClient } from "@/lib/api/client";
|
|
import { Button } from "@/components/ui/button";
|
|
import { Input } from "@/components/ui/input";
|
|
import { Textarea } from "@/components/ui/textarea";
|
|
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
|
|
import { Label } from "@/components/ui/label";
|
|
import { Badge } from "@/components/ui/badge";
|
|
import { KeyRound, Plus, Trash2, Loader2, Copy, Check, AlertTriangle, Send, Zap, Terminal, X, MessageSquare, Bot, User, ChevronDown, ChevronUp } from "lucide-react";
|
|
import { toast } from "sonner";
|
|
|
|
export default function ApiKeysManagePage() {
|
|
const [keys, setKeys] = useState<any[]>([]);
|
|
const [loading, setLoading] = useState(true);
|
|
const [modalOpen, setModalOpen] = useState(false);
|
|
const [newKeyResult, setNewKeyResult] = useState<string | null>(null);
|
|
const [copied, setCopied] = useState(false);
|
|
const [form, setForm] = useState({ name: "", rate_limit: 60, monthly_token_limit: 1000000 });
|
|
const [saving, setSaving] = useState(false);
|
|
|
|
// 그룹 호출 테스트
|
|
const [groups, setGroups] = useState<any[]>([]);
|
|
const [testGroupId, setTestGroupId] = useState<string>("");
|
|
const [testMessage, setTestMessage] = useState("");
|
|
const [testApiKey, setTestApiKey] = useState("");
|
|
const [testLoading, setTestLoading] = useState(false);
|
|
const [testResult, setTestResult] = useState<any>(null);
|
|
const [consoleLogs, setConsoleLogs] = useState<{ time: string; type: "info" | "success" | "error" | "data" | "step"; text: string }[]>([]);
|
|
const [consoleOpen, setConsoleOpen] = useState(false);
|
|
const consoleEndRef = useRef<HTMLDivElement>(null);
|
|
|
|
// 최근 대화 모니터링
|
|
const [recentConvs, setRecentConvs] = useState<any[]>([]);
|
|
const [convsLoading, setConvsLoading] = useState(false);
|
|
const [expandedConvId, setExpandedConvId] = useState<number | null>(null);
|
|
const [convMessages, setConvMessages] = useState<any[]>([]);
|
|
const [msgsLoading, setMsgsLoading] = useState(false);
|
|
|
|
const loadKeys = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const [keyRes, groupRes] = await Promise.all([
|
|
aiAgentApi.listKeys(),
|
|
aiAgentApi.listGroups(),
|
|
]);
|
|
setKeys(keyRes.data || []);
|
|
setGroups(groupRes.data || []);
|
|
} catch { toast.error("데이터 로드 실패"); }
|
|
setLoading(false);
|
|
}, []);
|
|
|
|
const loadRecentConvs = useCallback(async () => {
|
|
setConvsLoading(true);
|
|
try {
|
|
const res = await aiAgentApi.listConversations({ page: 1, limit: 10 });
|
|
setRecentConvs(res.data || []);
|
|
} catch { /* silent */ }
|
|
setConvsLoading(false);
|
|
}, []);
|
|
|
|
const toggleConvDetail = async (convId: number) => {
|
|
if (expandedConvId === convId) {
|
|
setExpandedConvId(null);
|
|
setConvMessages([]);
|
|
return;
|
|
}
|
|
setExpandedConvId(convId);
|
|
setMsgsLoading(true);
|
|
try {
|
|
const res = await aiAgentApi.getConversation(convId);
|
|
setConvMessages(res.data?.messages || []);
|
|
} catch { toast.error("대화 상세 로드 실패"); }
|
|
setMsgsLoading(false);
|
|
};
|
|
|
|
useEffect(() => { loadKeys(); loadRecentConvs(); }, [loadKeys, loadRecentConvs]);
|
|
|
|
const handleCreate = async () => {
|
|
if (!form.name) { toast.error("키 이름을 입력하세요."); return; }
|
|
setSaving(true);
|
|
try {
|
|
const res = await aiAgentApi.createKey(form);
|
|
setNewKeyResult(res.data.plain_key);
|
|
loadKeys();
|
|
} catch (e: any) { toast.error(e.response?.data?.message || "키 생성 실패"); }
|
|
setSaving(false);
|
|
};
|
|
|
|
const handleRevoke = async (id: number) => {
|
|
if (!confirm("이 API 키를 폐기하시겠습니까?")) return;
|
|
try { await aiAgentApi.revokeKey(id); toast.success("키가 폐기되었습니다."); loadKeys(); }
|
|
catch { toast.error("폐기 실패"); }
|
|
};
|
|
|
|
const copyKey = () => {
|
|
if (newKeyResult) {
|
|
navigator.clipboard.writeText(newKeyResult);
|
|
setCopied(true);
|
|
setTimeout(() => setCopied(false), 2000);
|
|
}
|
|
};
|
|
|
|
const ts = () => new Date().toLocaleTimeString("ko-KR", { hour12: false, hour: "2-digit", minute: "2-digit", second: "2-digit", fractionalSecondDigits: 3 } as any);
|
|
|
|
const addLog = (type: "info" | "success" | "error" | "data" | "step", text: string) => {
|
|
setConsoleLogs((prev) => [...prev, { time: ts(), type, text }]);
|
|
};
|
|
|
|
// 콘솔 자동 스크롤
|
|
useEffect(() => {
|
|
consoleEndRef.current?.scrollIntoView({ behavior: "smooth" });
|
|
}, [consoleLogs]);
|
|
|
|
const handleTestGroup = async () => {
|
|
if (!testGroupId) { toast.error("그룹을 선택하세요"); return; }
|
|
if (!testMessage.trim()) { toast.error("메시지를 입력하세요"); return; }
|
|
if (!testApiKey.trim()) { toast.error("API 키를 입력하세요"); return; }
|
|
|
|
const groupName = groups.find((g: any) => String(g.id) === testGroupId)?.name || testGroupId;
|
|
|
|
setTestLoading(true);
|
|
setTestResult(null);
|
|
setConsoleLogs([]);
|
|
setConsoleOpen(true);
|
|
|
|
addLog("info", `POST /api/ai/v1/groups/${testGroupId}`);
|
|
addLog("info", `Authorization: Bearer ${testApiKey.substring(0, 16)}...`);
|
|
addLog("info", `Group: ${groupName}`);
|
|
addLog("info", `Message: "${testMessage}"`);
|
|
addLog("info", "요청 전송 중...");
|
|
|
|
const startTime = Date.now();
|
|
|
|
try {
|
|
const res = await apiClient.post(
|
|
`/ai/v1/groups/${testGroupId}`,
|
|
{ message: testMessage },
|
|
{
|
|
headers: { Authorization: `Bearer ${testApiKey}` },
|
|
// multi-agent 실행은 모델 추론 시간이 길어질 수 있으므로 클라이언트 타임아웃을 5분으로 확장
|
|
timeout: 5 * 60 * 1000,
|
|
}
|
|
);
|
|
const elapsed = Date.now() - startTime;
|
|
|
|
setTestResult(res.data);
|
|
|
|
addLog("success", `--- 응답 수신 (${elapsed}ms) ---`);
|
|
|
|
if (res.data?.data?.steps) {
|
|
for (const step of res.data.data.steps) {
|
|
addLog("step", `[Step ${step.order}] ${step.role} (${step.agent}) [${step.model || "unknown"}] - ${step.tokens} 토큰, ${step.duration_ms}ms`);
|
|
addLog("data", step.response || "(빈 응답)");
|
|
}
|
|
}
|
|
|
|
if (res.data?.data?.summary) {
|
|
addLog("info", "--- 최종 요약 ---");
|
|
addLog("data", res.data.data.summary);
|
|
}
|
|
|
|
addLog("success", `완료: 총 ${res.data.data?.total_tokens || 0} 토큰 · ${res.data.data?.duration_ms || elapsed}ms`);
|
|
addLog("info", "--- Raw JSON ---");
|
|
addLog("data", JSON.stringify(res.data, null, 2));
|
|
|
|
toast.success("실행 완료");
|
|
loadRecentConvs(); // 대화 목록 새로고침
|
|
} catch (e: any) {
|
|
const elapsed = Date.now() - startTime;
|
|
const errData = e.response?.data || { message: e.message };
|
|
setTestResult({ error: errData });
|
|
|
|
addLog("error", `--- 오류 발생 (${elapsed}ms) ---`);
|
|
addLog("error", `Status: ${e.response?.status || "NETWORK_ERROR"}`);
|
|
addLog("error", JSON.stringify(errData, null, 2));
|
|
toast.error("실행 실패");
|
|
}
|
|
setTestLoading(false);
|
|
};
|
|
|
|
return (
|
|
<div className="h-full overflow-y-auto bg-background">
|
|
<div className="space-y-5 p-4 sm:p-5">
|
|
{/* 헤더 */}
|
|
<div className="flex items-center justify-between border-b pb-3">
|
|
<div>
|
|
<h1 className="text-lg font-bold tracking-tight">API 키 관리</h1>
|
|
<p className="mt-0.5 text-xs text-muted-foreground">외부 서비스에서 AI 에이전트를 호출하기 위한 API 키</p>
|
|
</div>
|
|
<Button onClick={() => { setModalOpen(true); setNewKeyResult(null); setForm({ name: "", rate_limit: 60, monthly_token_limit: 1000000 }); }} size="sm" className="h-8 gap-1 text-xs">
|
|
<Plus className="h-3.5 w-3.5" />키 발급
|
|
</Button>
|
|
</div>
|
|
|
|
{/* API 키 목록 */}
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-12"><Loader2 className="h-5 w-5 animate-spin" /></div>
|
|
) : keys.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-12">
|
|
<KeyRound className="mb-2 h-5 w-5 text-muted-foreground" />
|
|
<p className="text-xs text-muted-foreground">발급된 API 키가 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="grid grid-cols-1 gap-2 sm:grid-cols-2 xl:grid-cols-3">
|
|
{keys.map((k) => (
|
|
<div key={k.id} className="rounded-lg border bg-card p-3.5">
|
|
<div className="mb-2 flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<KeyRound className={`h-4 w-4 ${k.status === "active" ? "text-emerald-500" : "text-muted-foreground"}`} />
|
|
<span className="text-xs font-semibold">{k.name}</span>
|
|
</div>
|
|
<Badge variant={k.status === "active" ? "default" : "secondary"} className="h-5 text-[10px]">
|
|
{k.status === "active" ? "활성" : "폐기"}
|
|
</Badge>
|
|
</div>
|
|
<p className="mb-1 font-mono text-[10px] text-muted-foreground">{k.key_prefix}...</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
사용 {Number(k.usage_count).toLocaleString()}회 · 토큰 {Number(k.total_tokens).toLocaleString()} · {Number(k.rate_limit)}/분
|
|
</p>
|
|
{k.status === "active" && (
|
|
<Button variant="ghost" size="sm" className="mt-2 h-6 text-[10px] text-destructive" onClick={() => handleRevoke(k.id)}>
|
|
<Trash2 className="mr-1 h-3 w-3" />폐기
|
|
</Button>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* 에이전트 그룹 호출 테스트 */}
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="border-b px-4 py-3">
|
|
<div className="flex items-center gap-2">
|
|
<Zap className="h-4 w-4 text-primary" />
|
|
<h2 className="text-sm font-bold">에이전트 그룹 호출 테스트</h2>
|
|
</div>
|
|
<p className="mt-0.5 text-[11px] text-muted-foreground">API 키로 멀티 에이전트 그룹을 호출해 결과를 확인합니다</p>
|
|
</div>
|
|
<div className="space-y-3 p-4">
|
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
|
<div>
|
|
<Label className="text-[11px]">API 키</Label>
|
|
<Input
|
|
value={testApiKey}
|
|
onChange={(e) => setTestApiKey(e.target.value)}
|
|
placeholder="sk-pipe-..."
|
|
className="mt-1 h-8 font-mono text-xs"
|
|
/>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">에이전트 그룹</Label>
|
|
<Select value={testGroupId} onValueChange={setTestGroupId}>
|
|
<SelectTrigger className="mt-1 h-8 text-xs">
|
|
<SelectValue placeholder="그룹 선택..." />
|
|
</SelectTrigger>
|
|
<SelectContent>
|
|
{groups.map((g: any) => (
|
|
<SelectItem key={g.id} value={String(g.id)} className="text-xs">
|
|
{g.name} <span className="text-muted-foreground">({g.member_count || 0}명)</span>
|
|
</SelectItem>
|
|
))}
|
|
</SelectContent>
|
|
</Select>
|
|
</div>
|
|
<div className="flex items-end">
|
|
<Button onClick={handleTestGroup} disabled={testLoading} size="sm" className="h-8 w-full gap-1 text-xs">
|
|
{testLoading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Send className="h-3.5 w-3.5" />}
|
|
{testLoading ? "실행 중..." : "실행"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">메시지</Label>
|
|
<Textarea
|
|
value={testMessage}
|
|
onChange={(e) => setTestMessage(e.target.value)}
|
|
placeholder="예: 최근 매출 데이터를 분석해주세요"
|
|
className="mt-1 h-20 text-xs"
|
|
/>
|
|
</div>
|
|
|
|
{/* 콘솔 출력 */}
|
|
{consoleOpen && (
|
|
<div className="rounded-lg border border-zinc-700 bg-zinc-950 overflow-hidden">
|
|
{/* 콘솔 헤더 */}
|
|
<div className="flex items-center justify-between border-b border-zinc-800 px-3 py-1.5 bg-zinc-900">
|
|
<div className="flex items-center gap-2">
|
|
<Terminal className="h-3.5 w-3.5 text-emerald-400" />
|
|
<span className="text-[11px] font-mono text-zinc-300">Console Output</span>
|
|
{testLoading && <Loader2 className="h-3 w-3 animate-spin text-yellow-400" />}
|
|
</div>
|
|
<div className="flex items-center gap-1">
|
|
<button onClick={() => setConsoleLogs([])} className="text-[10px] text-zinc-500 hover:text-zinc-300 px-1.5 py-0.5 rounded hover:bg-zinc-800">clear</button>
|
|
<button onClick={() => setConsoleOpen(false)} className="text-zinc-500 hover:text-zinc-300 p-0.5 rounded hover:bg-zinc-800">
|
|
<X className="h-3.5 w-3.5" />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
{/* 콘솔 본문 */}
|
|
<div className="max-h-[400px] overflow-y-auto p-3 font-mono text-[11px] leading-relaxed scrollbar-thin">
|
|
{consoleLogs.map((log, i) => (
|
|
<div key={i} className="flex gap-2">
|
|
<span className="shrink-0 text-zinc-600 select-none">{log.time}</span>
|
|
{log.type === "info" && <span className="text-sky-400">{log.text}</span>}
|
|
{log.type === "success" && <span className="text-emerald-400">{log.text}</span>}
|
|
{log.type === "error" && <span className="text-red-400 whitespace-pre-wrap">{log.text}</span>}
|
|
{log.type === "step" && <span className="text-yellow-400">{log.text}</span>}
|
|
{log.type === "data" && <pre className="text-zinc-300 whitespace-pre-wrap break-all">{log.text}</pre>}
|
|
</div>
|
|
))}
|
|
{testLoading && (
|
|
<div className="flex items-center gap-2 text-zinc-500">
|
|
<span className="animate-pulse">_</span>
|
|
<span>응답 대기 중...</span>
|
|
</div>
|
|
)}
|
|
<div ref={consoleEndRef} />
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 최근 대화 모니터링 */}
|
|
<div className="rounded-lg border bg-card">
|
|
<div className="border-b px-4 py-3">
|
|
<div className="flex items-center justify-between">
|
|
<div className="flex items-center gap-2">
|
|
<MessageSquare className="h-4 w-4 text-primary" />
|
|
<h2 className="text-sm font-bold">최근 대화</h2>
|
|
<span className="text-[10px] text-muted-foreground">({recentConvs.length}건)</span>
|
|
</div>
|
|
<Button variant="ghost" size="sm" className="h-7 text-[10px]" onClick={loadRecentConvs} disabled={convsLoading}>
|
|
{convsLoading ? <Loader2 className="h-3 w-3 animate-spin" /> : "새로고침"}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<div className="max-h-[500px] overflow-y-auto">
|
|
{convsLoading && recentConvs.length === 0 ? (
|
|
<div className="flex items-center justify-center py-8"><Loader2 className="h-4 w-4 animate-spin" /></div>
|
|
) : recentConvs.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-8 text-muted-foreground">
|
|
<MessageSquare className="h-5 w-5 mb-1.5" />
|
|
<p className="text-xs">대화 내역이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="divide-y">
|
|
{recentConvs.map((conv) => (
|
|
<div key={conv.id}>
|
|
{/* 대화 행 */}
|
|
<button
|
|
onClick={() => toggleConvDetail(conv.id)}
|
|
className="flex w-full items-center justify-between px-4 py-2.5 text-left transition-colors hover:bg-accent/50"
|
|
>
|
|
<div className="flex items-center gap-3 min-w-0">
|
|
<MessageSquare className="h-3.5 w-3.5 shrink-0 text-muted-foreground" />
|
|
<div className="min-w-0">
|
|
<p className="text-xs font-medium truncate">{conv.title || conv.conversation_id}</p>
|
|
<p className="text-[10px] text-muted-foreground">
|
|
{conv.agent_name || conv.metadata?.group_name || "멀티 에이전트"} · {conv.message_count}개 메시지 · {Number(conv.total_tokens || 0).toLocaleString()} 토큰
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<div className="flex items-center gap-2 shrink-0">
|
|
<span className="text-[10px] text-muted-foreground">{new Date(conv.created_at).toLocaleString("ko-KR", { month: "short", day: "numeric", hour: "2-digit", minute: "2-digit" })}</span>
|
|
{expandedConvId === conv.id ? <ChevronUp className="h-3.5 w-3.5 text-muted-foreground" /> : <ChevronDown className="h-3.5 w-3.5 text-muted-foreground" />}
|
|
</div>
|
|
</button>
|
|
{/* 메시지 내역 (펼침) */}
|
|
{expandedConvId === conv.id && (
|
|
<div className="border-t bg-muted/30 px-4 py-3">
|
|
{msgsLoading ? (
|
|
<div className="flex items-center justify-center py-4"><Loader2 className="h-4 w-4 animate-spin" /></div>
|
|
) : convMessages.length === 0 ? (
|
|
<p className="text-center text-[11px] text-muted-foreground py-3">메시지가 없습니다</p>
|
|
) : (
|
|
<div className="space-y-2.5 max-h-[400px] overflow-y-auto">
|
|
{convMessages.map((msg) => (
|
|
<div key={msg.id} className={`flex gap-2 ${msg.role === "user" ? "justify-end" : ""}`}>
|
|
{msg.role !== "user" && (
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-primary/10">
|
|
<Bot className="h-3 w-3 text-primary" />
|
|
</div>
|
|
)}
|
|
<div className={`max-w-[85%] rounded-lg px-2.5 py-1.5 text-[11px] ${msg.role === "user" ? "bg-primary text-primary-foreground" : "bg-background border"}`}>
|
|
{msg.tool_calls?.role_name && (
|
|
<div className="flex items-center gap-1.5 mb-1 flex-wrap">
|
|
<Badge variant="outline" className="h-4 text-[9px] px-1.5">{msg.tool_calls.role_name}</Badge>
|
|
<span className="text-[9px] text-muted-foreground">{msg.tool_calls.agent_name}</span>
|
|
{msg.tool_calls.model_name && <Badge variant="secondary" className="h-4 text-[9px] px-1.5 font-mono">{msg.tool_calls.model_name}</Badge>}
|
|
<span className="text-[9px] text-muted-foreground">{msg.tool_calls.duration_ms}ms</span>
|
|
</div>
|
|
)}
|
|
<p className="whitespace-pre-wrap break-words">{msg.content}</p>
|
|
{msg.token_count > 0 && <span className="block text-[9px] opacity-50 mt-0.5">{msg.token_count} tokens</span>}
|
|
</div>
|
|
{msg.role === "user" && (
|
|
<div className="flex h-6 w-6 shrink-0 items-center justify-center rounded-full bg-muted">
|
|
<User className="h-3 w-3" />
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
|
|
{/* 사용 예시 */}
|
|
<div className="rounded-lg border bg-muted/30 p-4">
|
|
<p className="mb-2 text-xs font-semibold">API 호출 예시</p>
|
|
<div className="space-y-2">
|
|
<div>
|
|
<p className="text-[10px] text-muted-foreground mb-1">채팅 (OpenAI 호환)</p>
|
|
<pre className="rounded bg-background p-2 text-[10px] font-mono overflow-x-auto">{`curl -X POST http://localhost:8080/api/ai/v1/chat/completions \\
|
|
-H "Authorization: Bearer sk-pipe-YOUR-KEY" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"model":"claude-sonnet-4-20250514","messages":[{"role":"user","content":"안녕"}]}'`}</pre>
|
|
</div>
|
|
<div>
|
|
<p className="text-[10px] text-muted-foreground mb-1">멀티 에이전트 그룹 실행</p>
|
|
<pre className="rounded bg-background p-2 text-[10px] font-mono overflow-x-auto">{`curl -X POST http://localhost:8080/api/ai/v1/groups/{groupId} \\
|
|
-H "Authorization: Bearer sk-pipe-YOUR-KEY" \\
|
|
-H "Content-Type: application/json" \\
|
|
-d '{"message":"매출 데이터를 분석해주세요"}'`}</pre>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{/* 키 생성 모달 */}
|
|
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
|
|
<DialogContent className="max-w-md">
|
|
<DialogHeader><DialogTitle className="text-sm">API 키 발급</DialogTitle></DialogHeader>
|
|
|
|
{newKeyResult ? (
|
|
<div className="space-y-3 pt-1">
|
|
<div className="rounded-lg border-2 border-amber-300 bg-amber-50 p-3 dark:bg-amber-950 dark:border-amber-700">
|
|
<div className="flex items-center gap-1.5 text-amber-700 dark:text-amber-400 mb-1.5">
|
|
<AlertTriangle className="h-3.5 w-3.5" />
|
|
<p className="text-xs font-semibold">키를 안전하게 보관하세요</p>
|
|
</div>
|
|
<p className="text-[10px] text-amber-600 dark:text-amber-500 mb-2">이 키는 다시 표시되지 않습니다.</p>
|
|
<div className="flex items-center gap-2">
|
|
<code className="flex-1 rounded bg-background px-2 py-1.5 text-[10px] font-mono break-all border">{newKeyResult}</code>
|
|
<Button size="sm" variant="outline" className="h-7" onClick={copyKey}>
|
|
{copied ? <Check className="h-3 w-3 text-green-500" /> : <Copy className="h-3 w-3" />}
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
<Button className="w-full h-8 text-xs" onClick={() => setModalOpen(false)}>확인</Button>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3 pt-1">
|
|
<div>
|
|
<Label className="text-[11px]">키 이름</Label>
|
|
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="예: ERP 연동 키" className="mt-1 h-8 text-xs" />
|
|
</div>
|
|
<div className="grid grid-cols-2 gap-2">
|
|
<div>
|
|
<Label className="text-[11px]">분당 요청 제한</Label>
|
|
<Input type="number" value={form.rate_limit} onChange={(e) => setForm({ ...form, rate_limit: parseInt(e.target.value) || 60 })} className="mt-1 h-8 text-xs" />
|
|
</div>
|
|
<div>
|
|
<Label className="text-[11px]">월간 토큰 제한</Label>
|
|
<Input type="number" value={form.monthly_token_limit} onChange={(e) => setForm({ ...form, monthly_token_limit: parseInt(e.target.value) || 1000000 })} className="mt-1 h-8 text-xs" />
|
|
</div>
|
|
</div>
|
|
<div className="flex justify-end gap-2">
|
|
<Button variant="outline" size="sm" className="h-8 text-xs" onClick={() => setModalOpen(false)}>취소</Button>
|
|
<Button size="sm" className="h-8 text-xs" onClick={handleCreate} disabled={saving}>
|
|
{saving && <Loader2 className="mr-1 h-3.5 w-3.5 animate-spin" />}
|
|
키 생성
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|