From b4dc9b192751cc6f97b358ead162007f9b337bed Mon Sep 17 00:00:00 2001 From: chpark Date: Tue, 28 Apr 2026 18:41:52 +0900 Subject: [PATCH] =?UTF-8?q?feat(ai):=20LLM=20=EB=9D=BC=EC=9A=B0=ED=8C=85?= =?UTF-8?q?=20+=20=EC=97=90=EC=9D=B4=EC=A0=84=ED=8A=B8/=EC=A7=80=EC=8B=9D?= =?UTF-8?q?=20UX=20=EB=8B=A4=EC=88=98=20=EA=B0=9C=EC=84=A0?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- backend-node/src/services/aiAgentService.ts | 19 +- backend-node/src/services/llmClient.ts | 35 +-- .../src/services/multiAgentExecutionEngine.ts | 3 +- .../(main)/admin/aiAssistant/agents/page.tsx | 203 +++++++++++++----- .../aiAssistant/api-keys-manage/page.tsx | 6 +- .../admin/aiAssistant/conversations/page.tsx | 108 +++++++++- .../admin/aiAssistant/knowledge/page.tsx | 2 +- .../admin/aiAssistant/providers/page.tsx | 100 ++++++++- 8 files changed, 397 insertions(+), 79 deletions(-) diff --git a/backend-node/src/services/aiAgentService.ts b/backend-node/src/services/aiAgentService.ts index 3099a188..5a12f642 100644 --- a/backend-node/src/services/aiAgentService.ts +++ b/backend-node/src/services/aiAgentService.ts @@ -11,6 +11,9 @@ export class AiAgentService { if (filters?.status) { sql += ` AND status = $${idx++}`; params.push(filters.status); + } else { + // 기본: archived 제외 (소프트 삭제된 것은 목록에서 숨김) + sql += ` AND COALESCE(status, '') <> 'archived'`; } if (filters?.company_code) { sql += ` AND (company_code = $${idx++} OR company_code IS NULL)`; @@ -88,10 +91,18 @@ export class AiAgentService { } static async delete(id: number): Promise { - const result = await query( - "UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = $1", - [id] - ); + // 1) 관련 row 정리 (외래키 위반 방지) — 그룹 멤버, 대화, 사용량/감사 로그 등 + await query("DELETE FROM ai_agent_group_members WHERE agent_id = $1", [id]).catch(() => undefined); + await query("DELETE FROM ai_agent_conversations WHERE agent_id = $1", [id]).catch(() => undefined); + await query("DELETE FROM ai_analysis_logs WHERE agent_id = $1", [id]).catch(() => undefined); + await query("DELETE FROM ai_agent_usage WHERE agent_id = $1", [id]).catch(() => undefined); + // 2) 본 row hard delete + try { + await query("DELETE FROM ai_agents WHERE id = $1", [id]); + } catch (err) { + // 추가 외래키가 있어 실패하면 fallback 으로 archived + await query("UPDATE ai_agents SET status='archived', updated_at=NOW() WHERE id = $1", [id]); + } return true; } diff --git a/backend-node/src/services/llmClient.ts b/backend-node/src/services/llmClient.ts index 8f589c54..dba25625 100644 --- a/backend-node/src/services/llmClient.ts +++ b/backend-node/src/services/llmClient.ts @@ -60,13 +60,13 @@ const DEFAULT_ENDPOINTS: Record = { ollama: "http://localhost:11434", }; -// 모델명 → 프로바이더 매핑 (프리픽스) +// 모델명 → 프로바이더 매핑 (프리픽스, 대소문자 무시) const MODEL_PROVIDER_MAP: Array<[RegExp, string]> = [ - [/^claude-/, "anthropic"], - [/^gpt-|^o[1-9]|^o3/, "openai"], - [/^gemini-/, "google"], - [/^deepseek-/, "deepseek"], - [/^llama|^mistral|^codellama|^phi|^qwen/, "ollama"], + [/^claude-/i, "anthropic"], + [/^gpt-|^o[1-9]|^o3/i, "openai"], + [/^gemini-/i, "google"], + [/^deepseek-/i, "deepseek"], + [/^llama|^mistral|^codellama|^phi|^qwen/i, "ollama"], ]; // ── 메인 클라이언트 ────────────────────────────── @@ -92,8 +92,18 @@ export class LlmClient { provider = rows[0]; } + // 1순위: 등록된 model_name 정확 매칭 (case-insensitive) + // 사용자가 "Qwen3.6-35B-A3B" 같은 커스텀 모델명을 prefix regex 가 못 잡는 케이스 대응 + if (!provider && model) { + const rows = await query( + "SELECT * FROM ai_llm_providers WHERE LOWER(model_name) = LOWER($1) AND is_active = true ORDER BY priority LIMIT 1", + [model] + ); + provider = rows[0]; + } + + // 2순위: 모델명 prefix 로 provider name 추론 if (!provider) { - // 모델명으로 프로바이더 이름 추론 let providerName: string | null = null; for (const [re, name] of MODEL_PROVIDER_MAP) { if (re.test(model)) { providerName = name; break; } @@ -195,7 +205,7 @@ export class LlmClient { model, system: systemMsg?.content || undefined, messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })), - max_tokens: params.max_tokens || provider.max_tokens || 4096, + max_tokens: params.max_tokens || provider.max_tokens || 16384, temperature: params.temperature ?? provider.temperature ?? 0.7, }, { @@ -248,7 +258,7 @@ export class LlmClient { model, system: systemMsg?.content || undefined, messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })), - max_tokens: params.max_tokens || provider.max_tokens || 4096, + max_tokens: params.max_tokens || provider.max_tokens || 16384, temperature: params.temperature ?? provider.temperature ?? 0.7, stream: true, }, @@ -342,13 +352,14 @@ export class LlmClient { { model, messages: params.messages, - max_tokens: params.max_tokens || provider.max_tokens || 4096, + max_tokens: params.max_tokens || provider.max_tokens || 16384, temperature: params.temperature ?? provider.temperature ?? 0.7, }, { headers: { "Content-Type": "application/json", - Authorization: `Bearer ${apiKey}`, + // 빈 키이면 Authorization 헤더 생략 (인증 안 받는 로컬 Ollama 등) + ...(apiKey && apiKey.trim() ? { Authorization: `Bearer ${apiKey}` } : {}), }, timeout: 120000, } @@ -370,7 +381,7 @@ export class LlmClient { { model, messages: params.messages, - max_tokens: params.max_tokens || provider.max_tokens || 4096, + max_tokens: params.max_tokens || provider.max_tokens || 16384, temperature: params.temperature ?? provider.temperature ?? 0.7, stream: true, }, diff --git a/backend-node/src/services/multiAgentExecutionEngine.ts b/backend-node/src/services/multiAgentExecutionEngine.ts index 9ab9488c..ee74d54f 100644 --- a/backend-node/src/services/multiAgentExecutionEngine.ts +++ b/backend-node/src/services/multiAgentExecutionEngine.ts @@ -313,7 +313,8 @@ export class MultiAgentExecutionEngine { { role: "system", content: systemPrompt }, { role: "user", content: userMessage }, ], - max_tokens: agent.config?.max_tokens || 2000, + // agent 별 명시값 > provider DB max_tokens > 16384 (LlmClient 내부 fallback 으로 위임) + max_tokens: agent.config?.max_tokens, temperature: agent.config?.temperature || 0.7, }); diff --git a/frontend/app/(main)/admin/aiAssistant/agents/page.tsx b/frontend/app/(main)/admin/aiAssistant/agents/page.tsx index 126fd426..7361a4ee 100644 --- a/frontend/app/(main)/admin/aiAssistant/agents/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/agents/page.tsx @@ -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 = { + anthropic: "#D97757", + openai: "#10A37F", + google: "#4285F4", + deepseek: "#0066FF", + ollama: "#1D1D1D", +}; +const PROVIDER_LABEL: Record = { + 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([]); 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([]); + + 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() {
+

+ 등록된 LLM 프로바이더만 표시됩니다. (현재 {registeredProviders.length}개 등록됨) +

@@ -322,14 +360,24 @@ export default function AgentListPage() { {form.config.knowledge_files.length > 0 ? (
{form.config.knowledge_files.map((f: any, i) => ( -
+
setKnowledgeDetail({ name: f.name, content: f.content || "", source: f.source, size: f.content?.length || 0 })} + title="클릭하면 상세 내용을 볼 수 있습니다" + >
{f.name} {(f.content.length / 1024).toFixed(1)}KB {f.source === "library" && 라이브러리}
- +
))}
@@ -405,9 +453,23 @@ export default function AgentListPage() {
- setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 4096 } })} - className="mt-1 h-8 w-32 text-xs" /> +
+ setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 16384 } })} + className="h-8 w-32 text-xs" /> +
+ {[4096, 16384, 32768, 65536].map((v) => ( + + ))} +
+
+

+ 응답 길이 상한. Qwen-thinking 같은 추론형 모델은 32K 이상 권장. 비워두면 프로바이더 설정값 사용. +

@@ -420,6 +482,47 @@ export default function AgentListPage() { + + {/* 지식 파일 상세 모달 */} + { if (!open) setKnowledgeDetail(null); }}> + + + + + {knowledgeDetail?.name} + {knowledgeDetail?.source === "library" && ( + 라이브러리 + )} + + {((knowledgeDetail?.size || 0) / 1024).toFixed(1)}KB · {knowledgeDetail?.content?.length || 0}자 + + + +
+
+              {knowledgeDetail?.content || "(빈 파일)"}
+            
+
+
+ + +
+
+
); } diff --git a/frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx b/frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx index 1b77c92f..e75cc513 100644 --- a/frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/api-keys-manage/page.tsx @@ -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; diff --git a/frontend/app/(main)/admin/aiAssistant/conversations/page.tsx b/frontend/app/(main)/admin/aiAssistant/conversations/page.tsx index 8d2cb6a0..66ee4497 100644 --- a/frontend/app/(main)/admin/aiAssistant/conversations/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/conversations/page.tsx @@ -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 = { + 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: 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 ( +
+
+
+ + {lang || "code"} + · + {code.split("\n").length}줄 + · + {(code.length / 1024).toFixed(1)}KB +
+
+ + +
+
+
+        {code}
+      
+
+ ); +} + +// 메시지 본문 — 텍스트와 코드블록을 분리 렌더 +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" + ? + : {p.value} + )} + + ); +} + export default function ConversationsPage() { const [conversations, setConversations] = useState([]); const [loading, setLoading] = useState(true); @@ -81,16 +179,16 @@ export default function ConversationsPage() { {/* 대화 상세 모달 */} - + 대화 상세 -
+
{messages.map((msg) => (
{msg.role === "assistant" ? : }
-
- {msg.content} +
+ {msg.token_count > 0 && {msg.token_count} tokens}
diff --git a/frontend/app/(main)/admin/aiAssistant/knowledge/page.tsx b/frontend/app/(main)/admin/aiAssistant/knowledge/page.tsx index 513363c3..a3d17413 100644 --- a/frontend/app/(main)/admin/aiAssistant/knowledge/page.tsx +++ b/frontend/app/(main)/admin/aiAssistant/knowledge/page.tsx @@ -247,7 +247,7 @@ export default function KnowledgeLibraryPage() {