feat(ai): LLM 라우팅 + 에이전트/지식 UX 다수 개선
Build and Push Images / build-and-push (push) Has been cancelled
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>
This commit is contained in:
@@ -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<boolean> {
|
||||
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;
|
||||
}
|
||||
|
||||
|
||||
@@ -60,13 +60,13 @@ const DEFAULT_ENDPOINTS: Record<string, string> = {
|
||||
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<AiLlmProvider>(
|
||||
"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,
|
||||
},
|
||||
|
||||
@@ -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,
|
||||
});
|
||||
|
||||
|
||||
@@ -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<string, string> = {
|
||||
anthropic: "#D97757",
|
||||
openai: "#10A37F",
|
||||
google: "#4285F4",
|
||||
deepseek: "#0066FF",
|
||||
ollama: "#1D1D1D",
|
||||
};
|
||||
const PROVIDER_LABEL: Record<string, string> = {
|
||||
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<any[]>([]);
|
||||
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<RegisteredProvider[]>([]);
|
||||
|
||||
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() {
|
||||
<div>
|
||||
<Label className="text-[11px]">모델</Label>
|
||||
<Select value={form.model} onValueChange={(v) => setForm({ ...form, model: v })}>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs"><SelectValue /></SelectTrigger>
|
||||
<SelectTrigger className="mt-1 h-8 text-xs">
|
||||
<SelectValue placeholder={registeredProviders.length === 0 ? "LLM 프로바이더를 먼저 등록하세요" : "모델 선택"} />
|
||||
</SelectTrigger>
|
||||
<SelectContent className="max-h-[280px]">
|
||||
{MODEL_GROUPS.map((group) => (
|
||||
<div key={group.provider}>
|
||||
{registeredProviders.length === 0 && (
|
||||
<div className="px-3 py-2 text-[11px] text-muted-foreground">
|
||||
등록된 LLM 프로바이더가 없습니다.<br />
|
||||
AI관리 → LLM 프로바이더 메뉴에서 추가하세요.
|
||||
</div>
|
||||
)}
|
||||
{/* 프로바이더 종류별 그룹핑 (anthropic/openai/google/deepseek/ollama)
|
||||
— model_name 이 비어있는 row 는 스킵 (Radix Select 제약: value 빈문자열 금지) */}
|
||||
{Object.entries(
|
||||
registeredProviders
|
||||
.filter((p) => !!p.model_name && p.model_name.trim() !== "")
|
||||
.reduce<Record<string, RegisteredProvider[]>>((acc, p) => {
|
||||
(acc[p.name] = acc[p.name] || []).push(p);
|
||||
return acc;
|
||||
}, {}),
|
||||
).map(([providerKey, list]) => (
|
||||
<div key={providerKey}>
|
||||
<div className="flex items-center gap-1.5 px-2 py-1 text-[10px] font-semibold text-muted-foreground">
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: group.color }} />
|
||||
{group.provider}
|
||||
<span className="h-2 w-2 rounded-full" style={{ backgroundColor: PROVIDER_COLOR[providerKey] || "#6B7280" }} />
|
||||
{PROVIDER_LABEL[providerKey] || providerKey}
|
||||
</div>
|
||||
{group.models.map((m) => (
|
||||
<SelectItem key={m.value} value={m.value} className="text-xs">{m.label}</SelectItem>
|
||||
{list.map((p) => (
|
||||
<SelectItem key={p.id} value={p.model_name} className="text-xs">
|
||||
{p.display_name}
|
||||
<span className="ml-2 text-[10px] text-muted-foreground">{p.model_name}</span>
|
||||
</SelectItem>
|
||||
))}
|
||||
</div>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
등록된 LLM 프로바이더만 표시됩니다. (현재 {registeredProviders.length}개 등록됨)
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
|
||||
@@ -322,14 +360,24 @@ export default function AgentListPage() {
|
||||
{form.config.knowledge_files.length > 0 ? (
|
||||
<div className="mt-1.5 space-y-1">
|
||||
{form.config.knowledge_files.map((f: any, i) => (
|
||||
<div key={i} className="flex items-center justify-between rounded-md border bg-card px-2.5 py-1.5">
|
||||
<div
|
||||
key={i}
|
||||
className="flex items-center justify-between rounded-md border bg-card px-2.5 py-1.5 cursor-pointer hover:bg-accent/40 transition-colors"
|
||||
onClick={() => setKnowledgeDetail({ name: f.name, content: f.content || "", source: f.source, size: f.content?.length || 0 })}
|
||||
title="클릭하면 상세 내용을 볼 수 있습니다"
|
||||
>
|
||||
<div className="flex items-center gap-1.5">
|
||||
<FileText className="h-3.5 w-3.5 text-muted-foreground" />
|
||||
<span className="text-xs font-medium">{f.name}</span>
|
||||
<span className="text-[10px] text-muted-foreground">{(f.content.length / 1024).toFixed(1)}KB</span>
|
||||
{f.source === "library" && <Badge variant="secondary" className="h-4 text-[8px]">라이브러리</Badge>}
|
||||
</div>
|
||||
<button onClick={() => removeFile(i)} className="text-destructive hover:text-destructive/80"><X className="h-3 w-3" /></button>
|
||||
<button
|
||||
onClick={(e) => { e.stopPropagation(); removeFile(i); }}
|
||||
className="text-destructive hover:text-destructive/80"
|
||||
>
|
||||
<X className="h-3 w-3" />
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
@@ -405,9 +453,23 @@ export default function AgentListPage() {
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-[11px]">Max Tokens</Label>
|
||||
<Input type="number" value={form.config.max_tokens}
|
||||
onChange={(e) => setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 4096 } })}
|
||||
className="mt-1 h-8 w-32 text-xs" />
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Input type="number" min={256} max={131072} step={1024} value={form.config.max_tokens}
|
||||
onChange={(e) => setForm({ ...form, config: { ...form.config, max_tokens: parseInt(e.target.value) || 16384 } })}
|
||||
className="h-8 w-32 text-xs" />
|
||||
<div className="flex gap-1">
|
||||
{[4096, 16384, 32768, 65536].map((v) => (
|
||||
<Button key={v} type="button" size="sm" variant="outline"
|
||||
className="h-7 px-2 text-[10px]"
|
||||
onClick={() => setForm({ ...form, config: { ...form.config, max_tokens: v } })}>
|
||||
{v >= 1024 ? `${v / 1024}K` : v}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
응답 길이 상한. Qwen-thinking 같은 추론형 모델은 32K 이상 권장. 비워두면 프로바이더 설정값 사용.
|
||||
</p>
|
||||
</div>
|
||||
</TabsContent>
|
||||
</Tabs>
|
||||
@@ -420,6 +482,47 @@ export default function AgentListPage() {
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* 지식 파일 상세 모달 */}
|
||||
<Dialog open={!!knowledgeDetail} onOpenChange={(open) => { if (!open) setKnowledgeDetail(null); }}>
|
||||
<DialogContent className="max-w-3xl max-h-[80vh] overflow-hidden flex flex-col">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="flex items-center gap-2 text-base">
|
||||
<FileText className="h-4 w-4 text-muted-foreground" />
|
||||
<span>{knowledgeDetail?.name}</span>
|
||||
{knowledgeDetail?.source === "library" && (
|
||||
<Badge variant="secondary" className="h-5 text-[10px]">라이브러리</Badge>
|
||||
)}
|
||||
<span className="ml-1 text-[11px] font-normal text-muted-foreground">
|
||||
{((knowledgeDetail?.size || 0) / 1024).toFixed(1)}KB · {knowledgeDetail?.content?.length || 0}자
|
||||
</span>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
<div className="flex-1 overflow-auto rounded border bg-muted/30 p-3">
|
||||
<pre className="whitespace-pre-wrap break-words text-xs font-mono leading-relaxed">
|
||||
{knowledgeDetail?.content || "(빈 파일)"}
|
||||
</pre>
|
||||
</div>
|
||||
<div className="flex justify-end gap-2 pt-2 border-t">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="h-8 text-xs"
|
||||
onClick={() => {
|
||||
if (knowledgeDetail?.content) {
|
||||
navigator.clipboard.writeText(knowledgeDetail.content);
|
||||
toast.success("내용이 클립보드에 복사되었습니다.");
|
||||
}
|
||||
}}
|
||||
>
|
||||
내용 복사
|
||||
</Button>
|
||||
<Button size="sm" className="h-8 text-xs" onClick={() => setKnowledgeDetail(null)}>
|
||||
닫기
|
||||
</Button>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
|
||||
@@ -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<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);
|
||||
@@ -81,16 +179,16 @@ export default function ConversationsPage() {
|
||||
|
||||
{/* 대화 상세 모달 */}
|
||||
<Dialog open={detailOpen} onOpenChange={setDetailOpen}>
|
||||
<DialogContent className="max-w-2xl max-h-[80vh]">
|
||||
<DialogContent className="max-w-4xl max-h-[85vh]">
|
||||
<DialogHeader><DialogTitle>대화 상세</DialogTitle></DialogHeader>
|
||||
<div className="overflow-auto max-h-[60vh] space-y-3 pt-2">
|
||||
<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-[80%] rounded-xl px-3 py-2 text-sm ${msg.role === "assistant" ? "bg-muted" : "bg-primary text-white"}`}>
|
||||
{msg.content}
|
||||
<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>
|
||||
|
||||
@@ -247,7 +247,7 @@ export default function KnowledgeLibraryPage() {
|
||||
<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">{(Buffer.byteLength ? form.content.length : form.content.length)} 자 · 약 {(form.content.length / 1024).toFixed(1)}KB</p>}
|
||||
{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>
|
||||
|
||||
@@ -113,7 +113,7 @@ export default function ProviderListPage() {
|
||||
api_key: "",
|
||||
model_name: "",
|
||||
endpoint: "",
|
||||
max_tokens: 4096,
|
||||
max_tokens: 16384,
|
||||
temperature: 0.7,
|
||||
cost_per_1k_input: 0,
|
||||
cost_per_1k_output: 0,
|
||||
@@ -144,7 +144,7 @@ export default function ProviderListPage() {
|
||||
api_key: "",
|
||||
model_name: "",
|
||||
endpoint: cfg.endpoint,
|
||||
max_tokens: 4096,
|
||||
max_tokens: 16384,
|
||||
temperature: 0.7,
|
||||
cost_per_1k_input: 0,
|
||||
cost_per_1k_output: 0,
|
||||
@@ -185,12 +185,17 @@ export default function ProviderListPage() {
|
||||
const handleSave = async () => {
|
||||
if (!form.display_name) { toast.error("표시 이름을 입력하세요."); return; }
|
||||
if (!editing && !form.api_key && form.name !== "ollama") { toast.error("API 키를 입력하세요."); return; }
|
||||
if (form.name === "ollama" && !form.model_name?.trim()) {
|
||||
toast.error("로컬 LLM은 모델 ID를 입력하세요. (예: Qwen3.6-35B-A3B)");
|
||||
return;
|
||||
}
|
||||
|
||||
setSaving(true);
|
||||
try {
|
||||
const data = { ...form };
|
||||
if (editing && !data.api_key) delete (data as any).api_key;
|
||||
if (form.name === "ollama" && !data.api_key) data.api_key = "ollama";
|
||||
// ollama 라도 인증 필요한 원격(예: ai-agent.kryp.xyz) 가 있으므로
|
||||
// 사용자가 직접 입력한 값만 저장. 비워두면 빈 문자열로 저장 → 백엔드가 Authorization 헤더 생략.
|
||||
|
||||
if (editing) {
|
||||
await aiAgentApi.updateProvider(editing.id, data);
|
||||
@@ -355,6 +360,40 @@ export default function ProviderListPage() {
|
||||
<Input value={form.display_name} onChange={(e) => setForm({ ...form, display_name: e.target.value })} placeholder="내 Claude 키" className="mt-1" />
|
||||
</div>
|
||||
|
||||
{/* API 호출 URL (로컬/커스텀 프로바이더용) */}
|
||||
{form.name === "ollama" && (
|
||||
<>
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
API 호출 URL <span className="text-muted-foreground">(로컬/원격 엔드포인트)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.endpoint}
|
||||
onChange={(e) => setForm({ ...form, endpoint: e.target.value })}
|
||||
placeholder="http://localhost:11434 또는 https://ai-agent.kryp.xyz"
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
OpenAI 호환 (/v1/chat/completions) 서버 주소. 로컬 Ollama, llama.cpp, vLLM 등 지원.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
모델 ID <span className="text-muted-foreground">(서버에서 인식하는 정확한 이름)</span>
|
||||
</Label>
|
||||
<Input
|
||||
value={form.model_name}
|
||||
onChange={(e) => setForm({ ...form, model_name: e.target.value })}
|
||||
placeholder="예: Qwen3.6-35B-A3B, llama3.2, qwen2.5"
|
||||
className="mt-1 font-mono text-sm"
|
||||
/>
|
||||
<p className="text-[10px] text-muted-foreground mt-1">
|
||||
이 값이 LLM 호출 시 model 파라미터로 그대로 전달됩니다.
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* API 키 */}
|
||||
<div>
|
||||
<Label className="text-xs">
|
||||
@@ -365,9 +404,12 @@ export default function ProviderListPage() {
|
||||
type={showKey ? "text" : "password"}
|
||||
value={form.api_key}
|
||||
onChange={(e) => setForm({ ...form, api_key: e.target.value })}
|
||||
placeholder={selectedConfig.keyPlaceholder}
|
||||
placeholder={
|
||||
form.name === "ollama"
|
||||
? "필요 시 입력 (예: LK-... 인증 토큰, 없으면 비워둠)"
|
||||
: selectedConfig.keyPlaceholder
|
||||
}
|
||||
className="pr-10 font-mono text-sm"
|
||||
disabled={form.name === "ollama"}
|
||||
/>
|
||||
<button
|
||||
type="button"
|
||||
@@ -379,6 +421,54 @@ export default function ProviderListPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* 추론 파라미터 (모든 프로바이더 공용) */}
|
||||
<div className="grid grid-cols-2 gap-3 pt-2 border-t">
|
||||
<div>
|
||||
<Label className="text-xs">Max Tokens</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<Input
|
||||
type="number"
|
||||
min={256}
|
||||
max={131072}
|
||||
step={1024}
|
||||
value={form.max_tokens}
|
||||
onChange={(e) => setForm({ ...form, max_tokens: parseInt(e.target.value) || 16384 })}
|
||||
className="h-8 text-xs"
|
||||
/>
|
||||
</div>
|
||||
<div className="mt-1 flex gap-1">
|
||||
{[4096, 16384, 32768, 65536].map((v) => (
|
||||
<Button key={v} type="button" size="sm" variant="outline"
|
||||
className="h-6 px-2 text-[10px]"
|
||||
onClick={() => setForm({ ...form, max_tokens: v })}>
|
||||
{v >= 1024 ? `${v / 1024}K` : v}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
응답 토큰 상한. 에이전트별 별도 설정이 우선, 없으면 이 값 사용.
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<Label className="text-xs">Temperature</Label>
|
||||
<div className="mt-1 flex items-center gap-2">
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={1}
|
||||
step={0.1}
|
||||
value={form.temperature}
|
||||
onChange={(e) => setForm({ ...form, temperature: parseFloat(e.target.value) })}
|
||||
className="flex-1"
|
||||
/>
|
||||
<span className="w-10 text-right font-mono text-xs">{form.temperature.toFixed(1)}</span>
|
||||
</div>
|
||||
<p className="mt-1 text-[10px] text-muted-foreground">
|
||||
0 = 결정적, 1 = 창의적. 보통 0.7.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2 pt-2">
|
||||
<Button variant="outline" onClick={() => setModalOpen(false)}>취소</Button>
|
||||
<Button onClick={handleSave} disabled={saving}>
|
||||
|
||||
Reference in New Issue
Block a user