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>
203 lines
9.2 KiB
TypeScript
203 lines
9.2 KiB
TypeScript
"use client";
|
|
|
|
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, 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);
|
|
const [total, setTotal] = useState(0);
|
|
const [page, setPage] = useState(1);
|
|
const [selectedConv, setSelectedConv] = useState<any>(null);
|
|
const [messages, setMessages] = useState<any[]>([]);
|
|
const [detailOpen, setDetailOpen] = useState(false);
|
|
|
|
const loadConversations = useCallback(async () => {
|
|
setLoading(true);
|
|
try {
|
|
const res = await aiAgentApi.listConversations({ page, limit: 20 });
|
|
setConversations(res.data || []);
|
|
setTotal(res.total || 0);
|
|
} catch { toast.error("대화 목록 로드 실패"); }
|
|
setLoading(false);
|
|
}, [page]);
|
|
|
|
useEffect(() => { loadConversations(); }, [loadConversations]);
|
|
|
|
const openDetail = async (conv: any) => {
|
|
try {
|
|
const res = await aiAgentApi.getConversation(conv.id);
|
|
setSelectedConv(res.data.conversation);
|
|
setMessages(res.data.messages || []);
|
|
setDetailOpen(true);
|
|
} catch { toast.error("대화 상세 로드 실패"); }
|
|
};
|
|
|
|
return (
|
|
<div className="flex h-full flex-col bg-background">
|
|
<div className="shrink-0 p-6 pb-4">
|
|
<h1 className="text-xl font-bold">대화 모니터링</h1>
|
|
<p className="text-sm text-muted-foreground">에이전트 대화 내역을 모니터링합니다 ({total}건)</p>
|
|
</div>
|
|
|
|
<div className="flex-1 overflow-auto px-6 pb-6">
|
|
{loading ? (
|
|
<div className="flex items-center justify-center py-20"><Loader2 className="h-6 w-6 animate-spin" /></div>
|
|
) : conversations.length === 0 ? (
|
|
<div className="flex flex-col items-center justify-center py-20 text-muted-foreground">
|
|
<MessageSquare className="h-12 w-12 mb-3" />
|
|
<p>대화 내역이 없습니다</p>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-2">
|
|
{conversations.map((conv) => (
|
|
<div key={conv.id} className="flex items-center justify-between rounded-xl border bg-card p-4 cursor-pointer hover:bg-accent/50 transition-colors" onClick={() => openDetail(conv)}>
|
|
<div className="flex items-center gap-3">
|
|
<MessageSquare className="h-5 w-5 text-primary" />
|
|
<div>
|
|
<p className="text-sm font-medium">{conv.title || conv.conversation_id}</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{conv.agent_name || "에이전트 미지정"} · {conv.message_count}개 메시지 · {conv.total_tokens?.toLocaleString()} 토큰
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">{new Date(conv.created_at).toLocaleDateString("ko")}</span>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
{total > 20 && (
|
|
<div className="flex justify-center gap-2 pt-4">
|
|
<Button variant="outline" size="sm" disabled={page <= 1} onClick={() => setPage(page - 1)}>이전</Button>
|
|
<span className="flex items-center text-sm text-muted-foreground">{page} / {Math.ceil(total / 20)}</span>
|
|
<Button variant="outline" size="sm" disabled={page >= Math.ceil(total / 20)} onClick={() => setPage(page + 1)}>다음</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
{/* 대화 상세 모달 */}
|
|
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
|
<DialogContent className="max-w-4xl max-h-[85vh]">
|
|
<DialogHeader><DialogTitle>대화 상세</DialogTitle></DialogHeader>
|
|
<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-[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>
|
|
))}
|
|
{messages.length === 0 && <p className="text-center text-sm text-muted-foreground py-8">메시지가 없습니다</p>}
|
|
</div>
|
|
</DialogContent>
|
|
</Dialog>
|
|
</div>
|
|
);
|
|
}
|