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>
280 lines
14 KiB
TypeScript
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="마크다운 형식으로 지식 내용을 작성하세요... ## 구매 프로세스 1. 구매 요청서 작성 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>
|
|
);
|
|
}
|