Files
pipeline/frontend/app/(main)/admin/aiAssistant/knowledge/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

280 lines
14 KiB
TypeScript

"use client";
import { useEffect, useState, useCallback, useRef } from "react";
import { aiAgentApi } from "@/lib/api/aiAgent";
import { Button } from "@/components/ui/button";
import { Input } from "@/components/ui/input";
import { Textarea } from "@/components/ui/textarea";
import { Dialog, DialogContent, DialogHeader, DialogTitle } from "@/components/ui/dialog";
import { Select, SelectContent, SelectItem, SelectTrigger, SelectValue } from "@/components/ui/select";
import { Label } from "@/components/ui/label";
import { Badge } from "@/components/ui/badge";
import { FileText, Plus, Search, Pencil, Trash2, Loader2, Upload, BookOpen, Eye } from "lucide-react";
import { toast } from "sonner";
const CATEGORIES = [
{ value: "구매", label: "구매", color: "bg-blue-100 text-blue-700" },
{ value: "영업", label: "영업", color: "bg-green-100 text-green-700" },
{ value: "설계", label: "설계", color: "bg-violet-100 text-violet-700" },
{ value: "생산", label: "생산", color: "bg-amber-100 text-amber-700" },
{ value: "품질", label: "품질", color: "bg-rose-100 text-rose-700" },
{ value: "프로젝트", label: "프로젝트", color: "bg-indigo-100 text-indigo-700" },
{ value: "공통", label: "공통", color: "bg-gray-100 text-gray-700" },
];
function getCategoryColor(cat: string) {
return CATEGORIES.find((c) => c.value === cat)?.color || "bg-gray-100 text-gray-700";
}
export default function KnowledgeLibraryPage() {
const [files, setFiles] = useState<any[]>([]);
const [loading, setLoading] = useState(true);
const [search, setSearch] = useState("");
const [categoryFilter, setCategoryFilter] = useState("all");
const [modalOpen, setModalOpen] = useState(false);
const [editing, setEditing] = useState<any>(null);
const [form, setForm] = useState({ name: "", file_name: "", category: "공통", description: "", content: "" });
const [saving, setSaving] = useState(false);
const [previewFile, setPreviewFile] = useState<any>(null);
const fileInputRef = useRef<HTMLInputElement>(null);
const loadFiles = useCallback(async () => {
setLoading(true);
try {
const res = await aiAgentApi.listKnowledge({ category: categoryFilter !== "all" ? categoryFilter : undefined, search: search || undefined });
setFiles(res.data || []);
} catch { toast.error("지식 파일 목록 로드 실패"); }
setLoading(false);
}, [search, categoryFilter]);
useEffect(() => { loadFiles(); }, [loadFiles]);
const openCreate = () => {
setEditing(null);
setForm({ name: "", file_name: "", category: "공통", description: "", content: "" });
setModalOpen(true);
};
const openEdit = async (file: any) => {
try {
const res = await aiAgentApi.getKnowledge(file.id);
const data = res.data;
setEditing(data);
setForm({ name: data.name, file_name: data.file_name, category: data.category, description: data.description || "", content: data.content });
setModalOpen(true);
} catch { toast.error("파일 불러오기 실패"); }
};
const openPreview = async (file: any) => {
try {
const res = await aiAgentApi.getKnowledge(file.id);
setPreviewFile(res.data);
} catch { toast.error("미리보기 실패"); }
};
const handleSave = async () => {
if (!form.name || !form.content) { toast.error("이름과 내용은 필수입니다."); return; }
setSaving(true);
try {
if (editing) {
await aiAgentApi.updateKnowledge(editing.id, form);
toast.success("수정 완료");
} else {
await aiAgentApi.createKnowledge({ ...form, file_name: form.file_name || `${form.name}.md` });
toast.success("등록 완료");
}
setModalOpen(false);
loadFiles();
} catch (e: any) { toast.error(e.response?.data?.message || "저장 실패"); }
setSaving(false);
};
const handleDelete = async (id: number) => {
if (!confirm("이 지식 파일을 삭제하시겠습니까? 이 파일을 사용하는 에이전트에 영향을 줄 수 있습니다.")) return;
try { await aiAgentApi.deleteKnowledge(id); toast.success("삭제 완료"); loadFiles(); }
catch { toast.error("삭제 실패"); }
};
const handleFileUpload = (e: React.ChangeEvent<HTMLInputElement>) => {
const file = e.target.files?.[0];
if (!file) return;
const reader = new FileReader();
reader.onload = (ev) => {
const content = ev.target?.result as string;
setForm((prev) => ({
...prev,
content,
file_name: file.name,
name: prev.name || file.name.replace(/\.[^.]+$/, ""),
}));
toast.success(`${file.name} 내용이 로드되었습니다`);
};
reader.readAsText(file);
e.target.value = "";
};
// 카테고리별 그룹핑
const groupedFiles = (() => {
const groups = new Map<string, any[]>();
files.forEach((f) => {
if (!groups.has(f.category)) groups.set(f.category, []);
groups.get(f.category)!.push(f);
});
return groups;
})();
return (
<div className="h-full overflow-y-auto bg-background">
<div className="space-y-4 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"> </h1>
<p className="mt-0.5 text-xs text-muted-foreground"> MD/TXT . .</p>
</div>
<Button onClick={openCreate} size="sm" className="h-8 gap-1 text-xs">
<Plus className="h-3.5 w-3.5" />
</Button>
</div>
{/* 검색 + 필터 */}
<div className="flex items-center gap-2">
<div className="relative w-56">
<Search className="absolute left-2.5 top-1/2 h-3.5 w-3.5 -translate-y-1/2 text-muted-foreground" />
<Input placeholder="검색..." value={search} onChange={(e) => setSearch(e.target.value)} className="h-8 pl-8 text-xs" />
</div>
<Select value={categoryFilter} onValueChange={setCategoryFilter}>
<SelectTrigger className="h-8 w-28 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
<SelectItem value="all" className="text-xs"></SelectItem>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
<span className="text-[11px] text-muted-foreground ml-auto"> {files.length}</span>
</div>
{/* 목록 */}
{loading ? (
<div className="flex items-center justify-center py-16"><Loader2 className="h-5 w-5 animate-spin" /></div>
) : files.length === 0 ? (
<div className="flex flex-col items-center justify-center rounded-lg border border-dashed py-16">
<BookOpen className="mb-2 h-6 w-6 text-muted-foreground" />
<p className="text-xs text-muted-foreground"> </p>
<p className="mt-1 text-[10px] text-muted-foreground"> , , MD/TXT </p>
</div>
) : (
<div className="space-y-4">
{[...groupedFiles.entries()].map(([category, catFiles]) => (
<div key={category}>
<div className="mb-2 flex items-center gap-2">
<Badge className={`text-[10px] ${getCategoryColor(category)}`}>{category}</Badge>
<span className="text-[10px] text-muted-foreground">{catFiles.length}</span>
</div>
<div className="grid gap-2 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{catFiles.map((file: any) => (
<div key={file.id} className="group rounded-lg border bg-card p-3 transition-all hover:shadow-md hover:border-primary/30">
<div className="mb-2 flex items-start justify-between">
<div className="flex items-center gap-2">
<FileText className="h-4 w-4 text-muted-foreground" />
<div>
<p className="text-xs font-semibold">{file.name}</p>
<p className="text-[10px] text-muted-foreground font-mono">{file.file_name}</p>
</div>
</div>
</div>
{file.description && <p className="mb-2 text-[10px] text-muted-foreground line-clamp-2">{file.description}</p>}
<div className="mb-2 text-[9px] text-muted-foreground">
{file.file_size ? `${(file.file_size / 1024).toFixed(1)}KB` : ""} · {new Date(file.created_at).toLocaleDateString("ko")}
</div>
<div className="flex items-center gap-1">
<Button variant="outline" size="sm" className="h-6 flex-1 text-[10px]" onClick={() => openPreview(file)}>
<Eye className="mr-1 h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px]" onClick={() => openEdit(file)}>
<Pencil className="h-3 w-3" />
</Button>
<Button variant="outline" size="sm" className="h-6 px-2 text-[10px] text-destructive hover:bg-destructive/10" onClick={() => handleDelete(file.id)}>
<Trash2 className="h-3 w-3" />
</Button>
</div>
</div>
))}
</div>
</div>
))}
</div>
)}
</div>
{/* 등록/수정 모달 */}
<Dialog open={modalOpen} onOpenChange={setModalOpen}>
<DialogContent className="max-w-2xl max-h-[85vh] overflow-y-auto">
<DialogHeader><DialogTitle className="text-sm">{editing ? "지식 파일 수정" : "지식 파일 등록"}</DialogTitle></DialogHeader>
<div className="space-y-3 pt-1">
<div className="grid grid-cols-2 gap-3">
<div>
<Label className="text-[11px]"></Label>
<Input value={form.name} onChange={(e) => setForm({ ...form, name: e.target.value })} placeholder="예: 구매 업무 매뉴얼" className="mt-1 h-8 text-xs" />
</div>
<div>
<Label className="text-[11px]"></Label>
<Select value={form.category} onValueChange={(v) => setForm({ ...form, category: v })}>
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue /></SelectTrigger>
<SelectContent>
{CATEGORIES.map((c) => (
<SelectItem key={c.value} value={c.value} className="text-xs">{c.label}</SelectItem>
))}
</SelectContent>
</Select>
</div>
</div>
<div>
<Label className="text-[11px]"> ()</Label>
<Input value={form.description} onChange={(e) => setForm({ ...form, description: e.target.value })} placeholder="이 파일의 내용을 간단히 설명" className="mt-1 h-8 text-xs" />
</div>
<div>
<div className="flex items-center justify-between mb-1">
<Label className="text-[11px]"></Label>
<div className="flex gap-1">
<input ref={fileInputRef} type="file" accept=".md,.txt,.csv" className="hidden" onChange={handleFileUpload} />
<Button variant="outline" size="sm" className="h-6 gap-1 text-[10px]" onClick={() => fileInputRef.current?.click()}>
<Upload className="h-3 w-3" />
</Button>
</div>
</div>
<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">{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>
<Button size="sm" className="h-7 text-xs" onClick={handleSave} disabled={saving}>
{saving && <Loader2 className="mr-1 h-3 w-3 animate-spin" />}{editing ? "저장" : "등록"}
</Button>
</div>
</div>
</DialogContent>
</Dialog>
{/* 미리보기 모달 */}
<Dialog open={!!previewFile} onOpenChange={() => setPreviewFile(null)}>
<DialogContent className="max-w-2xl max-h-[80vh] overflow-y-auto">
<DialogHeader>
<DialogTitle className="text-sm flex items-center gap-2">
<FileText className="h-4 w-4" />
{previewFile?.name}
<Badge className={`text-[9px] ${getCategoryColor(previewFile?.category || "")}`}>{previewFile?.category}</Badge>
</DialogTitle>
</DialogHeader>
<div className="rounded-lg border bg-muted/30 p-4 max-h-[60vh] overflow-auto">
<pre className="whitespace-pre-wrap text-xs font-mono">{previewFile?.content}</pre>
</div>
</DialogContent>
</Dialog>
</div>
);
}