Files
pipeline/frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx
T
chpark b4dc9b1927
Build and Push Images / build-and-push (push) Has been cancelled
feat(ai): LLM 라우팅 + 에이전트/지식 UX 다수 개선
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>
2026-04-28 18:41:52 +09:00

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>
);
}