refactor: Pipeline 네이밍 통일 및 AI 에이전트/장비 연결 기능 추가
- Docker/K8s 배포 설정을 pipeline-backend/pipeline-front로 통일 - 네임스페이스, 서비스, PVC 등 k8s 리소스명 pipeline-* 로 변경 - AI 에이전트 관리 기능 추가 (에이전트, 그룹, 프로바이더, 대화, API 키, 지식베이스) - 장비 연결 관리 기능 추가 (PLC/Modbus/OPC-UA/MQTT) - 배치 스케줄러에 AI agent/device collection/crawling 타입 추가 - 배치 편집 UI 개선 (6가지 실행 방식 지원) - 회사별 페이지(COMPANY_*) 제거 및 AdminPageRenderer 최적화 - 메뉴 재구성: 장비 연결 관리 시스템관리로 이동, 에이전트 오케스트레이션으로 개명 - ai-assistant 디렉토리 제거 (backend-node로 통합) Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
+33
-2
@@ -92,6 +92,7 @@ import screenFileRoutes from "./routes/screenFileRoutes";
|
||||
//import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes";
|
||||
import batchRoutes from "./routes/batchRoutes";
|
||||
import batchManagementRoutes from "./routes/batchManagementRoutes";
|
||||
import knowledgeRoutes from "./routes/knowledgeRoutes";
|
||||
import batchExecutionLogRoutes from "./routes/batchExecutionLogRoutes";
|
||||
// import dbTypeCategoryRoutes from "./routes/dbTypeCategoryRoutes"; // 파일이 존재하지 않음
|
||||
import ddlRoutes from "./routes/ddlRoutes";
|
||||
@@ -150,6 +151,11 @@ import categoryValueCascadingRoutes from "./routes/categoryValueCascadingRoutes"
|
||||
import categoryTreeRoutes from "./routes/categoryTreeRoutes"; // 카테고리 트리 (테스트)
|
||||
import processWorkStandardRoutes from "./routes/processWorkStandardRoutes"; // 공정 작업기준
|
||||
import aiAssistantProxy from "./routes/aiAssistantProxy"; // AI 어시스턴트 API 프록시 (같은 포트로 서비스)
|
||||
import aiAgentRoutes from "./routes/aiAgentRoutes"; // AI 에이전트 관리
|
||||
import aiAgentGroupRoutes from "./routes/aiAgentGroupRoutes"; // 멀티 에이전트 워크스페이스
|
||||
import aiScheduleRoutes from "./routes/aiScheduleRoutes"; // AI 스케줄러
|
||||
import aiProxyRoutes from "./routes/openClawProxyRoutes"; // AI 엔진 (자체 LLM 클라이언트)
|
||||
import pipelineDeviceConnectionRoutes from "./routes/pipelineDeviceConnectionRoutes"; // 파이프라인 장비 연결
|
||||
import auditLogRoutes from "./routes/auditLogRoutes"; // 통합 변경 이력
|
||||
import moldRoutes from "./routes/moldRoutes"; // 금형 관리
|
||||
import shippingPlanRoutes from "./routes/shippingPlanRoutes"; // 출하계획 관리
|
||||
@@ -323,7 +329,7 @@ app.use("/api/external-db-connections", externalDbConnectionRoutes);
|
||||
app.use("/api/external-rest-api-connections", externalRestApiConnectionRoutes);
|
||||
app.use("/api/multi-connection", multiConnectionRoutes);
|
||||
app.use("/api/screen-files", screenFileRoutes);
|
||||
app.use("/api/batch-configs", batchRoutes);
|
||||
// app.use("/api/batch-configs", batchRoutes); // 레거시 → batchManagementRoutes로 통합
|
||||
app.use("/api/excel-mapping", excelMappingRoutes); // 엑셀 매핑 템플릿
|
||||
app.use("/api/batch-management", batchManagementRoutes);
|
||||
app.use("/api/batch-execution-logs", batchExecutionLogRoutes);
|
||||
@@ -386,7 +392,13 @@ app.use("/api/receiving", receivingRoutes); // 입고관리
|
||||
app.use("/api/outbound", outboundRoutes); // 출고관리
|
||||
app.use("/api/quotes", quoteRoutes); // 견적관리
|
||||
app.use("/api", screenEmbeddingRoutes); // 화면 임베딩 및 데이터 전달
|
||||
app.use("/api/ai/v1", aiAssistantProxy); // AI 어시스턴트 (동일 서비스 내 프록시 → AI 서비스 포트)
|
||||
// app.use("/api/ai/v1", aiAssistantProxy); // 레거시 AI 어시스턴트 (비활성)
|
||||
app.use("/api/ai-agents", aiAgentRoutes); // AI 에이전트 관리
|
||||
app.use("/api/ai-agent-groups", aiAgentGroupRoutes); // 멀티 에이전트 워크스페이스
|
||||
app.use("/api/ai-knowledge", knowledgeRoutes); // AI 지식 파일 라이브러리
|
||||
app.use("/api/ai-schedules", aiScheduleRoutes); // AI 스케줄러
|
||||
app.use("/api/ai/v1", aiProxyRoutes); // AI 엔진 (자체 LLM 클라이언트)
|
||||
app.use("/api/pipeline-device-connections", pipelineDeviceConnectionRoutes); // 파이프라인 장비 연결
|
||||
app.use("/api/vehicle", vehicleTripRoutes); // 차량 운행 이력 관리
|
||||
app.use("/api/approval", approvalRoutes); // 결재 시스템
|
||||
app.use("/api/user-mail", userMailRoutes); // 사용자 메일 계정
|
||||
@@ -465,6 +477,11 @@ async function initializeServices() {
|
||||
runMessengerMigration,
|
||||
runSmartFactoryLogMigration,
|
||||
runSmartFactoryScheduleMigration,
|
||||
runOpenClawMigration,
|
||||
runMultiAgentMigration,
|
||||
runMenuRenameMigration,
|
||||
runKnowledgeLibraryMigration,
|
||||
runPipelineDeviceMigration,
|
||||
} = await import("./database/runMigration");
|
||||
|
||||
await runDashboardMigration();
|
||||
@@ -475,6 +492,11 @@ async function initializeServices() {
|
||||
await runMessengerMigration();
|
||||
await runSmartFactoryLogMigration();
|
||||
await runSmartFactoryScheduleMigration();
|
||||
await runOpenClawMigration();
|
||||
await runMultiAgentMigration();
|
||||
await runMenuRenameMigration();
|
||||
await runKnowledgeLibraryMigration();
|
||||
await runPipelineDeviceMigration();
|
||||
} catch (error) {
|
||||
logger.error(`❌ 마이그레이션 실패:`, error);
|
||||
}
|
||||
@@ -539,6 +561,15 @@ async function initializeServices() {
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ AI 어시스턴트 기동 스킵:", error);
|
||||
}
|
||||
|
||||
// AI 스케줄러 초기화
|
||||
try {
|
||||
const { AiSchedulerService } = await import("./services/aiSchedulerService");
|
||||
await AiSchedulerService.initializeSchedules();
|
||||
logger.info("🤖 AI 엔진 초기화 완료 (자체 LLM 클라이언트)");
|
||||
} catch (error) {
|
||||
logger.warn("⚠️ AI 스케줄러 초기화 스킵:", error);
|
||||
}
|
||||
}
|
||||
|
||||
export default app;
|
||||
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Request, Response } from "express";
|
||||
import { AiAgentService } from "../services/aiAgentService";
|
||||
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
|
||||
import { AiAgentProviderService } from "../services/aiAgentProviderService";
|
||||
import { AiAgentConversationService } from "../services/aiAgentConversationService";
|
||||
import { AiAgentUsageService } from "../services/aiAgentUsageService";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ========== 에이전트 CRUD ==========
|
||||
export class AiAgentController {
|
||||
static async list(req: AuthenticatedRequest, res: Response) {
|
||||
const { status, company_code, search } = req.query;
|
||||
// company_code=* 이면 전체 조회, 아니면 해당 회사만
|
||||
const isSuperAdmin = req.user?.userType === "SUPER_ADMIN";
|
||||
const companyFilter = (company_code as string) === "*" || isSuperAdmin
|
||||
? undefined
|
||||
: (company_code as string) || req.user?.companyCode;
|
||||
const agents = await AiAgentService.list({
|
||||
status: status as string,
|
||||
company_code: companyFilter,
|
||||
search: search as string,
|
||||
});
|
||||
res.json({ success: true, data: agents });
|
||||
}
|
||||
|
||||
static async getById(req: AuthenticatedRequest, res: Response) {
|
||||
const agent = await AiAgentService.getById(parseInt(req.params.id));
|
||||
if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: agent });
|
||||
}
|
||||
|
||||
static async create(req: AuthenticatedRequest, res: Response) {
|
||||
const agent = await AiAgentService.create(req.body, req.user!.userId);
|
||||
res.status(201).json({ success: true, data: agent, message: "에이전트가 생성되었습니다." });
|
||||
}
|
||||
|
||||
static async update(req: AuthenticatedRequest, res: Response) {
|
||||
const agent = await AiAgentService.update(parseInt(req.params.id), req.body);
|
||||
if (!agent) return res.status(404).json({ success: false, message: "에이전트를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: agent, message: "에이전트가 수정되었습니다." });
|
||||
}
|
||||
|
||||
static async delete(req: AuthenticatedRequest, res: Response) {
|
||||
await AiAgentService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true, message: "에이전트가 삭제되었습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
// ========== API 키 관리 ==========
|
||||
export class AiAgentApiKeyController {
|
||||
static async list(req: AuthenticatedRequest, res: Response) {
|
||||
const isAdmin = req.user?.userType === "SUPER_ADMIN" || req.user?.userType === "COMPANY_ADMIN";
|
||||
const keys = await AiAgentApiKeyService.list(req.user!.userId, isAdmin);
|
||||
res.json({ success: true, data: keys });
|
||||
}
|
||||
|
||||
static async create(req: AuthenticatedRequest, res: Response) {
|
||||
const { key, plainKey } = await AiAgentApiKeyService.create(req.body, req.user!.userId, req.user?.companyCode);
|
||||
res.status(201).json({
|
||||
success: true,
|
||||
data: { ...key, plain_key: plainKey },
|
||||
message: "API 키가 생성되었습니다. 키는 한 번만 표시됩니다.",
|
||||
});
|
||||
}
|
||||
|
||||
static async revoke(req: AuthenticatedRequest, res: Response) {
|
||||
await AiAgentApiKeyService.revoke(parseInt(req.params.id), req.user!.userId);
|
||||
res.json({ success: true, message: "API 키가 폐기되었습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
// ========== LLM 프로바이더 관리 ==========
|
||||
export class AiAgentProviderController {
|
||||
static async list(req: Request, res: Response) {
|
||||
const providers = await AiAgentProviderService.list();
|
||||
res.json({ success: true, data: providers });
|
||||
}
|
||||
|
||||
static async create(req: Request, res: Response) {
|
||||
const provider = await AiAgentProviderService.create(req.body);
|
||||
res.status(201).json({ success: true, data: provider, message: "프로바이더가 추가되었습니다." });
|
||||
}
|
||||
|
||||
static async update(req: Request, res: Response) {
|
||||
const provider = await AiAgentProviderService.update(parseInt(req.params.id), req.body);
|
||||
if (!provider) return res.status(404).json({ success: false, message: "프로바이더를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: provider, message: "프로바이더가 수정되었습니다." });
|
||||
}
|
||||
|
||||
static async delete(req: Request, res: Response) {
|
||||
await AiAgentProviderService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true, message: "프로바이더가 삭제되었습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 대화 모니터링 ==========
|
||||
export class AiAgentConversationController {
|
||||
static async list(req: Request, res: Response) {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const agentId = req.query.agent_id ? parseInt(req.query.agent_id as string) : undefined;
|
||||
const result = await AiAgentConversationService.list(page, limit, agentId);
|
||||
res.json({ success: true, data: result.conversations, total: result.total });
|
||||
}
|
||||
|
||||
static async getById(req: Request, res: Response) {
|
||||
const result = await AiAgentConversationService.getById(parseInt(req.params.id));
|
||||
if (!result.conversation) return res.status(404).json({ success: false, message: "대화를 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: result });
|
||||
}
|
||||
|
||||
static async delete(req: Request, res: Response) {
|
||||
await AiAgentConversationService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true, message: "대화가 삭제되었습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
// ========== 사용량 ==========
|
||||
export class AiAgentUsageController {
|
||||
static async summary(req: Request, res: Response) {
|
||||
const summary = await AiAgentUsageService.getSummary();
|
||||
res.json({ success: true, data: summary });
|
||||
}
|
||||
|
||||
static async logs(req: Request, res: Response) {
|
||||
const page = parseInt(req.query.page as string) || 1;
|
||||
const limit = parseInt(req.query.limit as string) || 20;
|
||||
const result = await AiAgentUsageService.getLogs(page, limit);
|
||||
res.json({ success: true, data: result.logs, total: result.total });
|
||||
}
|
||||
|
||||
static async daily(req: Request, res: Response) {
|
||||
const days = parseInt(req.query.days as string) || 30;
|
||||
const data = await AiAgentUsageService.getDailyUsage(days);
|
||||
res.json({ success: true, data });
|
||||
}
|
||||
}
|
||||
@@ -420,6 +420,26 @@ export class BatchManagementController {
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 설정 삭제
|
||||
*/
|
||||
static async deleteBatchConfig(req: AuthenticatedRequest, res: Response) {
|
||||
try {
|
||||
const id = parseInt(req.params.id);
|
||||
if (isNaN(id)) {
|
||||
return res.status(400).json({ success: false, message: "유효하지 않은 배치 ID입니다." });
|
||||
}
|
||||
const result = await BatchService.deleteBatchConfig(
|
||||
id,
|
||||
req.user?.userId,
|
||||
req.user?.companyCode
|
||||
);
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
res.status(500).json({ success: false, message: error.message || "배치 삭제에 실패했습니다." });
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* REST API 데이터 미리보기
|
||||
*/
|
||||
|
||||
@@ -257,3 +257,91 @@ export async function runDtgManagementLogMigration() {
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw 멀티 에이전트 AI 통합 테이블 마이그레이션
|
||||
* ai_agents, ai_agent_api_keys, ai_agent_conversations,
|
||||
* ai_agent_messages, ai_agent_usage_logs, ai_llm_providers
|
||||
*/
|
||||
export async function runMultiAgentMigration() {
|
||||
try {
|
||||
console.log("🔄 멀티 에이전트 워크스페이스 마이그레이션 시작...");
|
||||
const sqlFilePath = path.join(__dirname, "../../db/migrations/301_create_multi_agent_tables.sql");
|
||||
if (!fs.existsSync(sqlFilePath)) { console.log("⚠️ 멀티 에이전트 마이그레이션 파일 없음"); return; }
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 멀티 에이전트 워크스페이스 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ 멀티 에이전트 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 멀티 에이전트 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runMenuRenameMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(__dirname, "../../db/migrations/302_rename_menu_datasource.sql");
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 메뉴명 변경 마이그레이션 완료 (외부 커넥션 관리 → 데이터 소스)");
|
||||
} catch (error) {
|
||||
// 이미 변경되었거나 menu_info 테이블 구조가 다르면 무시
|
||||
console.log("ℹ️ 메뉴명 마이그레이션 스킵");
|
||||
}
|
||||
}
|
||||
|
||||
export async function runKnowledgeLibraryMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(__dirname, "../../db/migrations/303_create_knowledge_library.sql");
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 지식 파일 라이브러리 테이블 생성 완료");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 지식 파일 테이블 이미 존재");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runPipelineDeviceMigration() {
|
||||
try {
|
||||
const sqlFilePath = path.join(__dirname, "../../db/migrations/304_create_pipeline_device_tables.sql");
|
||||
if (!fs.existsSync(sqlFilePath)) return;
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
console.log("✅ 파이프라인 장비 연결 테이블 생성 완료");
|
||||
} catch (error) {
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ 파이프라인 장비 테이블 이미 존재");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function runOpenClawMigration() {
|
||||
try {
|
||||
console.log("🔄 OpenClaw AI 에이전트 마이그레이션 시작...");
|
||||
|
||||
const sqlFilePath = path.join(
|
||||
__dirname,
|
||||
"../../db/migrations/300_create_openclaw_tables.sql"
|
||||
);
|
||||
|
||||
if (!fs.existsSync(sqlFilePath)) {
|
||||
console.log("⚠️ OpenClaw 마이그레이션 파일이 없습니다:", sqlFilePath);
|
||||
return;
|
||||
}
|
||||
|
||||
const sqlContent = fs.readFileSync(sqlFilePath, "utf8");
|
||||
await PostgreSQLService.query(sqlContent);
|
||||
|
||||
console.log("✅ OpenClaw AI 에이전트 마이그레이션 완료!");
|
||||
} catch (error) {
|
||||
console.error("❌ OpenClaw 마이그레이션 실패:", error);
|
||||
if (error instanceof Error && error.message.includes("already exists")) {
|
||||
console.log("ℹ️ OpenClaw 테이블이 이미 존재합니다.");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Request, Response, NextFunction } from "express";
|
||||
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
|
||||
import { AiAgentApiKey } from "../types/aiAgent";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface AiApiKeyRequest extends Request {
|
||||
apiKey?: AiAgentApiKey;
|
||||
apiKeyUserId?: string;
|
||||
}
|
||||
|
||||
/**
|
||||
* 외부 서비스 API 키 인증 미들웨어
|
||||
* sk-pipe-xxx 형태의 키를 검증하고 rate limit 체크
|
||||
*/
|
||||
export const aiApiKeyAuth = async (
|
||||
req: AiApiKeyRequest,
|
||||
res: Response,
|
||||
next: NextFunction
|
||||
): Promise<void> => {
|
||||
const authHeader = req.get("Authorization");
|
||||
const token = authHeader && authHeader.split(" ")[1];
|
||||
|
||||
if (!token) {
|
||||
res.status(401).json({ error: { message: "API key required", type: "auth_error" } });
|
||||
return;
|
||||
}
|
||||
|
||||
// Pipeline API 키 (sk-pipe-*)
|
||||
if (token.startsWith("sk-pipe-")) {
|
||||
const apiKey = await AiAgentApiKeyService.validateKey(token);
|
||||
if (!apiKey) {
|
||||
res.status(401).json({ error: { message: "Invalid API key", type: "auth_error" } });
|
||||
return;
|
||||
}
|
||||
|
||||
// 월간 토큰 제한 체크
|
||||
if (apiKey.monthly_token_limit > 0 && apiKey.total_tokens >= apiKey.monthly_token_limit) {
|
||||
res.status(429).json({ error: { message: "Monthly token limit exceeded", type: "rate_limit_error" } });
|
||||
return;
|
||||
}
|
||||
|
||||
req.apiKey = apiKey;
|
||||
req.apiKeyUserId = apiKey.user_id;
|
||||
next();
|
||||
return;
|
||||
}
|
||||
|
||||
// JWT 토큰도 허용 (Pipeline 내부 사용자)
|
||||
try {
|
||||
const { JwtUtils } = await import("../utils/jwtUtils");
|
||||
const userInfo = JwtUtils.verifyToken(token);
|
||||
(req as any).user = userInfo;
|
||||
next();
|
||||
} catch {
|
||||
res.status(401).json({ error: { message: "Invalid authentication", type: "auth_error" } });
|
||||
}
|
||||
};
|
||||
@@ -0,0 +1,57 @@
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { AiAgentGroupService } from "../services/aiAgentGroupService";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
// 그룹 CRUD
|
||||
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const groups = await AiAgentGroupService.list(req.user?.companyCode);
|
||||
res.json({ success: true, data: groups });
|
||||
});
|
||||
|
||||
router.get("/connectors", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const connectors = await AiAgentGroupService.getAvailableConnectors();
|
||||
res.json({ success: true, data: connectors });
|
||||
});
|
||||
|
||||
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const group = await AiAgentGroupService.getById(parseInt(req.params.id));
|
||||
if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: group });
|
||||
});
|
||||
|
||||
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const group = await AiAgentGroupService.create(req.body, req.user!.userId);
|
||||
res.status(201).json({ success: true, data: group, message: "멀티 에이전트 그룹이 생성되었습니다." });
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const group = await AiAgentGroupService.update(parseInt(req.params.id), req.body);
|
||||
if (!group) return res.status(404).json({ success: false, message: "그룹을 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: group, message: "그룹이 수정되었습니다." });
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
await AiAgentGroupService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true, message: "그룹이 삭제되었습니다." });
|
||||
});
|
||||
|
||||
// 멤버 관리
|
||||
router.post("/:id/members", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const member = await AiAgentGroupService.addMember(parseInt(req.params.id), req.body);
|
||||
res.status(201).json({ success: true, data: member, message: "멤버가 추가되었습니다." });
|
||||
});
|
||||
|
||||
router.put("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const member = await AiAgentGroupService.updateMember(parseInt(req.params.memberId), req.body);
|
||||
res.json({ success: true, data: member, message: "멤버가 수정되었습니다." });
|
||||
});
|
||||
|
||||
router.delete("/members/:memberId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
await AiAgentGroupService.removeMember(parseInt(req.params.memberId));
|
||||
res.json({ success: true, message: "멤버가 제거되었습니다." });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Router } from "express";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import {
|
||||
AiAgentController,
|
||||
AiAgentApiKeyController,
|
||||
AiAgentProviderController,
|
||||
AiAgentConversationController,
|
||||
AiAgentUsageController,
|
||||
} from "../controllers/aiAgentController";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ===== 에이전트 CRUD =====
|
||||
router.get("/", AiAgentController.list);
|
||||
router.get("/:id", AiAgentController.getById);
|
||||
router.post("/", AiAgentController.create);
|
||||
router.put("/:id", AiAgentController.update);
|
||||
router.delete("/:id", AiAgentController.delete);
|
||||
|
||||
// ===== API 키 관리 =====
|
||||
router.get("/keys/list", AiAgentApiKeyController.list);
|
||||
router.post("/keys", AiAgentApiKeyController.create);
|
||||
router.delete("/keys/:id", AiAgentApiKeyController.revoke);
|
||||
|
||||
// ===== LLM 프로바이더 관리 =====
|
||||
router.get("/providers/list", AiAgentProviderController.list);
|
||||
router.post("/providers", AiAgentProviderController.create);
|
||||
router.put("/providers/:id", AiAgentProviderController.update);
|
||||
router.delete("/providers/:id", AiAgentProviderController.delete);
|
||||
|
||||
// ===== 대화 모니터링 =====
|
||||
router.get("/conversations/list", AiAgentConversationController.list);
|
||||
router.get("/conversations/:id", AiAgentConversationController.getById);
|
||||
router.delete("/conversations/:id", AiAgentConversationController.delete);
|
||||
|
||||
// ===== 사용량 =====
|
||||
router.get("/usage/summary", AiAgentUsageController.summary);
|
||||
router.get("/usage/logs", AiAgentUsageController.logs);
|
||||
router.get("/usage/daily", AiAgentUsageController.daily);
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,28 @@
|
||||
import { Router, Response } from "express";
|
||||
import { authenticateToken, AuthenticatedRequest } from "../middleware/authMiddleware";
|
||||
import { AiSchedulerService } from "../services/aiSchedulerService";
|
||||
|
||||
const router = Router();
|
||||
router.use(authenticateToken);
|
||||
|
||||
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const schedules = await AiSchedulerService.list();
|
||||
res.json({ success: true, data: schedules });
|
||||
});
|
||||
|
||||
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const schedule = await AiSchedulerService.create(req.body, req.user!.userId);
|
||||
res.status(201).json({ success: true, data: schedule, message: "스케줄이 생성되었습니다." });
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
const schedule = await AiSchedulerService.update(parseInt(req.params.id), req.body);
|
||||
res.json({ success: true, data: schedule, message: "스케줄이 수정되었습니다." });
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
await AiSchedulerService.delete(parseInt(req.params.id));
|
||||
res.json({ success: true, message: "스케줄이 삭제되었습니다." });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -86,6 +86,12 @@ router.get("/batch-configs/:id/recent-logs", authenticateToken, BatchManagementC
|
||||
*/
|
||||
router.put("/batch-configs/:id", authenticateToken, BatchManagementController.updateBatchConfig);
|
||||
|
||||
/**
|
||||
* DELETE /api/batch-management/batch-configs/:id
|
||||
* 배치 설정 삭제
|
||||
*/
|
||||
router.delete("/batch-configs/:id", authenticateToken, BatchManagementController.deleteBatchConfig);
|
||||
|
||||
/**
|
||||
* POST /api/batch-management/batch-configs/:id/execute
|
||||
* 배치 수동 실행
|
||||
|
||||
@@ -0,0 +1,110 @@
|
||||
import { Router, Response } from "express";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const router = Router();
|
||||
|
||||
/**
|
||||
* GET /api/ai-knowledge
|
||||
* 지식 파일 라이브러리 목록 조회
|
||||
*/
|
||||
router.get("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { category, search } = req.query;
|
||||
let sql = "SELECT id, name, file_name, category, description, file_size, company_code, created_by, created_at, updated_at FROM ai_knowledge_files WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (category && category !== "all") {
|
||||
sql += ` AND category = $${idx++}`;
|
||||
params.push(category);
|
||||
}
|
||||
if (search) {
|
||||
sql += ` AND (name ILIKE $${idx} OR description ILIKE $${idx} OR file_name ILIKE $${idx})`;
|
||||
params.push(`%${search}%`);
|
||||
idx++;
|
||||
}
|
||||
sql += " ORDER BY category, name";
|
||||
|
||||
const files = await query(sql, params);
|
||||
res.json({ success: true, data: files });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai-knowledge/:id
|
||||
* 지식 파일 내용 포함 상세 조회
|
||||
*/
|
||||
router.get("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const file = await queryOne("SELECT * FROM ai_knowledge_files WHERE id = $1", [req.params.id]);
|
||||
if (!file) return res.status(404).json({ success: false, message: "파일을 찾을 수 없습니다." });
|
||||
res.json({ success: true, data: file });
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai-knowledge
|
||||
* 지식 파일 업로드 (라이브러리에 등록)
|
||||
*/
|
||||
router.post("/", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { name, file_name, category, description, content } = req.body;
|
||||
if (!name || !content || !category) {
|
||||
return res.status(400).json({ success: false, message: "name, content, category는 필수입니다." });
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO ai_knowledge_files (name, file_name, category, description, content, file_size, company_code, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8) RETURNING id, name, file_name, category, description, file_size, created_at`,
|
||||
[name, file_name || name, category, description || null, content, Buffer.byteLength(content, "utf8"), req.user?.companyCode || null, req.user?.userId]
|
||||
);
|
||||
|
||||
logger.info(`지식 파일 등록: ${name} (${category})`);
|
||||
res.status(201).json({ success: true, data: result[0] });
|
||||
});
|
||||
|
||||
/**
|
||||
* PUT /api/ai-knowledge/:id
|
||||
* 지식 파일 수정
|
||||
*/
|
||||
router.put("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const { name, category, description, content } = req.body;
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (name !== undefined) { sets.push(`name = $${idx++}`); params.push(name); }
|
||||
if (category !== undefined) { sets.push(`category = $${idx++}`); params.push(category); }
|
||||
if (description !== undefined) { sets.push(`description = $${idx++}`); params.push(description); }
|
||||
if (content !== undefined) {
|
||||
sets.push(`content = $${idx++}`); params.push(content);
|
||||
sets.push(`file_size = $${idx++}`); params.push(Buffer.byteLength(content, "utf8"));
|
||||
}
|
||||
if (sets.length === 0) return res.json({ success: true });
|
||||
|
||||
sets.push("updated_at = NOW()");
|
||||
params.push(req.params.id);
|
||||
|
||||
await query(`UPDATE ai_knowledge_files SET ${sets.join(", ")} WHERE id = $${idx}`, params);
|
||||
res.json({ success: true, message: "수정 완료" });
|
||||
});
|
||||
|
||||
/**
|
||||
* DELETE /api/ai-knowledge/:id
|
||||
* 지식 파일 삭제
|
||||
*/
|
||||
router.delete("/:id", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
await query("DELETE FROM ai_knowledge_files WHERE id = $1", [req.params.id]);
|
||||
res.json({ success: true, message: "삭제 완료" });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai-knowledge/categories/list
|
||||
* 카테고리 목록 (파일 수 포함)
|
||||
*/
|
||||
router.get("/categories/list", authenticateToken, async (req: AuthenticatedRequest, res: Response) => {
|
||||
const cats = await query(
|
||||
"SELECT category, COUNT(*) as count FROM ai_knowledge_files GROUP BY category ORDER BY category"
|
||||
);
|
||||
res.json({ success: true, data: cats });
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,205 @@
|
||||
import { Router, Request, Response } from "express";
|
||||
import { aiApiKeyAuth, AiApiKeyRequest } from "../middleware/aiApiKeyAuthMiddleware";
|
||||
import { AiAgentUsageService } from "../services/aiAgentUsageService";
|
||||
import { AiAgentApiKeyService } from "../services/aiAgentApiKeyService";
|
||||
import { MultiAgentExecutionEngine } from "../services/multiAgentExecutionEngine";
|
||||
import { LlmClient } from "../services/llmClient";
|
||||
import { logger } from "../utils/logger";
|
||||
import { query } from "../database/db";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트에 API 키 인증 적용
|
||||
router.use(aiApiKeyAuth);
|
||||
|
||||
/**
|
||||
* POST /api/ai/v1/chat/completions
|
||||
* OpenAI 호환 채팅 엔드포인트 → DB 프로바이더로 직접 호출
|
||||
*/
|
||||
router.post("/chat/completions", async (req: AiApiKeyRequest, res: Response) => {
|
||||
const startTime = Date.now();
|
||||
|
||||
try {
|
||||
// 스트리밍 요청
|
||||
if (req.body.stream) {
|
||||
const stream = await LlmClient.chatCompletionStream({
|
||||
model: req.body.model,
|
||||
messages: req.body.messages,
|
||||
max_tokens: req.body.max_tokens,
|
||||
temperature: req.body.temperature,
|
||||
});
|
||||
|
||||
res.setHeader("Content-Type", "text/event-stream");
|
||||
res.setHeader("Cache-Control", "no-cache");
|
||||
res.setHeader("Connection", "keep-alive");
|
||||
stream.pipe(res);
|
||||
return;
|
||||
}
|
||||
|
||||
// 비스트리밍 요청
|
||||
const result = await LlmClient.chatCompletion({
|
||||
model: req.body.model,
|
||||
messages: req.body.messages,
|
||||
max_tokens: req.body.max_tokens,
|
||||
temperature: req.body.temperature,
|
||||
});
|
||||
|
||||
// 사용량 추적
|
||||
const usage = result.usage;
|
||||
if (usage) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
await AiAgentUsageService.log({
|
||||
user_id: req.apiKeyUserId || (req as any).user?.userId,
|
||||
api_key_id: req.apiKey?.id,
|
||||
provider_name: result.model?.split("-")[0] || "unknown",
|
||||
model_name: result.model || req.body.model,
|
||||
prompt_tokens: usage.prompt_tokens || 0,
|
||||
completion_tokens: usage.completion_tokens || 0,
|
||||
total_tokens: usage.total_tokens || 0,
|
||||
response_time_ms: elapsed,
|
||||
success: true,
|
||||
request_path: "/v1/chat/completions",
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
if (req.apiKey) {
|
||||
await AiAgentApiKeyService.addTokenUsage(req.apiKey.id, usage.total_tokens || 0);
|
||||
}
|
||||
}
|
||||
|
||||
res.json(result);
|
||||
} catch (error: any) {
|
||||
const elapsed = Date.now() - startTime;
|
||||
|
||||
await AiAgentUsageService.log({
|
||||
user_id: req.apiKeyUserId || (req as any).user?.userId,
|
||||
api_key_id: req.apiKey?.id,
|
||||
model_name: req.body.model,
|
||||
response_time_ms: elapsed,
|
||||
success: false,
|
||||
error_message: error.message,
|
||||
request_path: "/v1/chat/completions",
|
||||
ip_address: req.ip,
|
||||
});
|
||||
|
||||
// LLM 프로바이더 에러 응답 전달
|
||||
const status = error.response?.status || 500;
|
||||
res.status(status).json(
|
||||
error.response?.data || { error: { message: error.message, type: "server_error" } }
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* POST /api/ai/v1/groups/:groupId
|
||||
* 멀티 에이전트 그룹 실행
|
||||
*/
|
||||
router.post("/groups/:groupId", async (req: AiApiKeyRequest, res: Response) => {
|
||||
const groupId = parseInt(req.params.groupId);
|
||||
const { message } = req.body;
|
||||
|
||||
if (!message) {
|
||||
return res.status(400).json({ error: { message: "message is required", type: "invalid_request" } });
|
||||
}
|
||||
|
||||
// group_id(문자열)로도 조회 가능
|
||||
let actualGroupId = groupId;
|
||||
if (isNaN(groupId)) {
|
||||
const group = await query<any>(
|
||||
"SELECT id FROM ai_agent_groups WHERE group_id = $1 AND status = 'active'",
|
||||
[req.params.groupId]
|
||||
).catch(() => []);
|
||||
if (group.length === 0) {
|
||||
return res.status(404).json({ error: { message: "Group not found", type: "not_found" } });
|
||||
}
|
||||
actualGroupId = group[0].id;
|
||||
}
|
||||
|
||||
try {
|
||||
const result = await MultiAgentExecutionEngine.execute(actualGroupId, message, {
|
||||
userId: req.apiKeyUserId || (req as any).user?.userId,
|
||||
apiKeyId: req.apiKey?.id,
|
||||
});
|
||||
|
||||
res.json({
|
||||
success: true,
|
||||
data: {
|
||||
group: result.groupName,
|
||||
execution_mode: result.executionMode,
|
||||
total_tokens: result.totalTokens,
|
||||
duration_ms: result.totalDurationMs,
|
||||
steps: result.steps.map((s) => ({
|
||||
order: s.executionOrder,
|
||||
role: s.roleName,
|
||||
agent: s.agentName,
|
||||
model: s.modelName,
|
||||
response: s.response,
|
||||
tokens: s.tokensUsed,
|
||||
duration_ms: s.durationMs,
|
||||
})),
|
||||
summary: result.finalSummary,
|
||||
},
|
||||
});
|
||||
} catch (e: any) {
|
||||
res.status(500).json({
|
||||
error: { message: e.message, type: "execution_error" },
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai/v1/groups
|
||||
* 사용 가능한 멀티 에이전트 그룹 목록
|
||||
*/
|
||||
router.get("/groups", async (req: AiApiKeyRequest, res: Response) => {
|
||||
const groups = await query<any>(
|
||||
`SELECT g.id, g.group_id, g.name, g.description, g.execution_mode,
|
||||
(SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count
|
||||
FROM ai_agent_groups g WHERE g.status = 'active' ORDER BY g.name`
|
||||
).catch(() => []);
|
||||
res.json({ success: true, data: groups });
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai/v1/models
|
||||
* 사용 가능한 모델 목록 (DB 프로바이더 기반)
|
||||
*/
|
||||
router.get("/models", async (req: AiApiKeyRequest, res: Response) => {
|
||||
try {
|
||||
const models = await LlmClient.listModels();
|
||||
res.json(models);
|
||||
} catch {
|
||||
res.json({
|
||||
object: "list",
|
||||
data: [
|
||||
{ id: "claude-sonnet-4-20250514", object: "model", owned_by: "anthropic" },
|
||||
{ id: "gpt-4o", object: "model", owned_by: "openai" },
|
||||
{ id: "gpt-4o-mini", object: "model", owned_by: "openai" },
|
||||
],
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* GET /api/ai/v1/health
|
||||
* AI 엔진 상태 확인
|
||||
*/
|
||||
router.get("/health", async (req: Request, res: Response) => {
|
||||
try {
|
||||
const models = await LlmClient.listModels();
|
||||
res.json({
|
||||
status: "running",
|
||||
engine: "pipeline-native",
|
||||
providers: models.data?.length || 0,
|
||||
});
|
||||
} catch {
|
||||
res.json({
|
||||
status: "no_providers",
|
||||
engine: "pipeline-native",
|
||||
providers: 0,
|
||||
});
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,147 @@
|
||||
import { Router, Response } from "express";
|
||||
import { PipelineDeviceConnectionService } from "../services/pipelineDeviceConnectionService";
|
||||
import { PROTOCOL_OPTIONS, PROTOCOL_DEFAULTS, TAG_DATA_TYPE_OPTIONS, ADDRESS_TYPE_OPTIONS } from "../types/pipelineDeviceTypes";
|
||||
import { authenticateToken } from "../middleware/authMiddleware";
|
||||
import { AuthenticatedRequest } from "../types/auth";
|
||||
|
||||
const router = Router();
|
||||
|
||||
// 모든 라우트 인증 필요
|
||||
router.use(authenticateToken);
|
||||
|
||||
// ===== 프로토콜 목록 (정적 경로 우선) =====
|
||||
router.get("/protocols", async (req: AuthenticatedRequest, res: Response) => {
|
||||
res.json({
|
||||
success: true,
|
||||
data: { protocols: PROTOCOL_OPTIONS, defaults: PROTOCOL_DEFAULTS, tagDataTypes: TAG_DATA_TYPE_OPTIONS, addressTypes: ADDRESS_TYPE_OPTIONS },
|
||||
});
|
||||
});
|
||||
|
||||
// ===== 태그 수정/삭제 (정적 경로 우선) =====
|
||||
router.put("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.updateTagMapping(parseInt(req.params.tagId), req.body);
|
||||
res.status(result.success ? 200 : 400).json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/tags/:tagId", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.deleteTagMapping(parseInt(req.params.tagId));
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 연결 CRUD =====
|
||||
router.get("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const userCompanyCode = req.user?.companyCode;
|
||||
let companyCodeFilter: string | undefined;
|
||||
if (userCompanyCode === "*") {
|
||||
companyCodeFilter = req.query.company_code as string;
|
||||
} else {
|
||||
companyCodeFilter = userCompanyCode;
|
||||
}
|
||||
|
||||
const filter = {
|
||||
protocol: req.query.protocol as string,
|
||||
is_active: req.query.is_active as string,
|
||||
company_code: companyCodeFilter,
|
||||
search: req.query.search as string,
|
||||
status: req.query.status as string,
|
||||
};
|
||||
|
||||
Object.keys(filter).forEach((key) => {
|
||||
if (!filter[key as keyof typeof filter]) delete filter[key as keyof typeof filter];
|
||||
});
|
||||
|
||||
const result = await PipelineDeviceConnectionService.getConnections(filter, userCompanyCode);
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.get("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.getConnectionById(parseInt(req.params.id));
|
||||
res.status(result.success ? 200 : 404).json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const data = {
|
||||
...req.body,
|
||||
company_code: req.body.company_code || req.user?.companyCode,
|
||||
created_by: req.user?.userId,
|
||||
};
|
||||
const result = await PipelineDeviceConnectionService.createConnection(data);
|
||||
res.status(result.success ? 201 : 400).json(result);
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes("duplicate") || e.code === "23505") {
|
||||
res.status(409).json({ success: false, message: "동일한 연결명이 이미 존재합니다." });
|
||||
} else {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
router.put("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.updateConnection(parseInt(req.params.id), req.body);
|
||||
res.status(result.success ? 200 : 404).json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.delete("/:id", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.deleteConnection(parseInt(req.params.id));
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 연결 테스트 =====
|
||||
router.post("/:id/test", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.testConnection(parseInt(req.params.id));
|
||||
res.json({ success: result.success, data: result });
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
// ===== 태그 매핑 =====
|
||||
router.get("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.getTagMappings(parseInt(req.params.id));
|
||||
res.json(result);
|
||||
} catch (e: any) {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
});
|
||||
|
||||
router.post("/:id/tags", async (req: AuthenticatedRequest, res: Response) => {
|
||||
try {
|
||||
const result = await PipelineDeviceConnectionService.createTagMapping(parseInt(req.params.id), req.body);
|
||||
res.status(result.success ? 201 : 400).json(result);
|
||||
} catch (e: any) {
|
||||
if (e.message?.includes("duplicate") || e.code === "23505") {
|
||||
res.status(409).json({ success: false, message: "동일한 태그명이 이미 존재합니다." });
|
||||
} else {
|
||||
res.status(500).json({ success: false, message: e.message });
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
export default router;
|
||||
@@ -0,0 +1,89 @@
|
||||
import crypto from "crypto";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { AiAgentApiKey, CreateApiKeyRequest } from "../types/aiAgent";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AiAgentApiKeyService {
|
||||
private static generateKey(): { plainKey: string; hash: string; prefix: string } {
|
||||
const randomBytes = crypto.randomBytes(32).toString("hex");
|
||||
const plainKey = `sk-pipe-${randomBytes}`;
|
||||
const hash = crypto.createHash("sha256").update(plainKey).digest("hex");
|
||||
const prefix = plainKey.substring(0, 16);
|
||||
return { plainKey, hash, prefix };
|
||||
}
|
||||
|
||||
static async list(userId: string, isAdmin: boolean): Promise<AiAgentApiKey[]> {
|
||||
if (isAdmin) {
|
||||
return query<AiAgentApiKey>(
|
||||
"SELECT * FROM ai_agent_api_keys ORDER BY created_at DESC"
|
||||
);
|
||||
}
|
||||
return query<AiAgentApiKey>(
|
||||
"SELECT * FROM ai_agent_api_keys WHERE user_id = $1 ORDER BY created_at DESC",
|
||||
[userId]
|
||||
);
|
||||
}
|
||||
|
||||
static async create(data: CreateApiKeyRequest, userId: string, companyCode?: string): Promise<{ key: AiAgentApiKey; plainKey: string }> {
|
||||
const { plainKey, hash, prefix } = this.generateKey();
|
||||
|
||||
const result = await query<AiAgentApiKey>(
|
||||
`INSERT INTO ai_agent_api_keys (name, key_hash, key_prefix, user_id, company_code, agent_id, permissions, rate_limit, monthly_token_limit, expires_at)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.name,
|
||||
hash,
|
||||
prefix,
|
||||
userId,
|
||||
companyCode || null,
|
||||
data.agent_id || null,
|
||||
JSON.stringify(data.permissions || ["chat"]),
|
||||
data.rate_limit || 60,
|
||||
data.monthly_token_limit || 1000000,
|
||||
data.expires_at || null,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`API 키 생성: ${prefix}... (by ${userId})`);
|
||||
return { key: result[0], plainKey };
|
||||
}
|
||||
|
||||
static async revoke(id: number, userId: string): Promise<boolean> {
|
||||
await query(
|
||||
"DELETE FROM ai_agent_api_keys WHERE id = $1 AND (user_id = $2 OR $2 = 'wace')",
|
||||
[id, userId]
|
||||
);
|
||||
logger.info(`API 키 삭제: id=${id} (by ${userId})`);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async validateKey(plainKey: string): Promise<AiAgentApiKey | null> {
|
||||
const hash = crypto.createHash("sha256").update(plainKey).digest("hex");
|
||||
const key = await queryOne<AiAgentApiKey>(
|
||||
"SELECT * FROM ai_agent_api_keys WHERE key_hash = $1 AND status = 'active'",
|
||||
[hash]
|
||||
);
|
||||
|
||||
if (!key) return null;
|
||||
|
||||
if (key.expires_at && new Date(key.expires_at) < new Date()) {
|
||||
return null;
|
||||
}
|
||||
|
||||
// 마지막 사용 시간 업데이트
|
||||
await query(
|
||||
"UPDATE ai_agent_api_keys SET last_used_at = NOW(), usage_count = usage_count + 1 WHERE id = $1",
|
||||
[key.id]
|
||||
);
|
||||
|
||||
return key;
|
||||
}
|
||||
|
||||
static async addTokenUsage(keyId: number, tokens: number): Promise<void> {
|
||||
await query(
|
||||
"UPDATE ai_agent_api_keys SET total_tokens = total_tokens + $1 WHERE id = $2",
|
||||
[tokens, keyId]
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,87 @@
|
||||
import crypto from "crypto";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { AiConversation, AiMessage } from "../types/aiAgent";
|
||||
|
||||
export class AiAgentConversationService {
|
||||
static async list(page: number = 1, limit: number = 20, agentId?: number): Promise<{ conversations: AiConversation[]; total: number }> {
|
||||
const offset = (page - 1) * limit;
|
||||
let where = "1=1";
|
||||
const params: any[] = [limit, offset];
|
||||
|
||||
if (agentId) {
|
||||
where += " AND agent_id = $3";
|
||||
params.push(agentId);
|
||||
}
|
||||
|
||||
const totalResult = await queryOne<{ cnt: string }>(
|
||||
`SELECT COUNT(*) as cnt FROM ai_agent_conversations WHERE ${where}`,
|
||||
agentId ? [agentId] : []
|
||||
);
|
||||
|
||||
const conversations = await query<AiConversation>(
|
||||
`SELECT c.*, a.name as agent_name,
|
||||
COALESCE(a.name, c.metadata->>'group_name') as display_name
|
||||
FROM ai_agent_conversations c
|
||||
LEFT JOIN ai_agents a ON c.agent_id = a.id
|
||||
WHERE ${where}
|
||||
ORDER BY c.updated_at DESC LIMIT $1 OFFSET $2`,
|
||||
params
|
||||
);
|
||||
|
||||
return { conversations, total: parseInt(totalResult?.cnt || "0") };
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<{ conversation: AiConversation | null; messages: AiMessage[] }> {
|
||||
const conversation = await queryOne<AiConversation>(
|
||||
`SELECT c.*, a.name as agent_name
|
||||
FROM ai_agent_conversations c
|
||||
LEFT JOIN ai_agents a ON c.agent_id = a.id
|
||||
WHERE c.id = $1`,
|
||||
[id]
|
||||
);
|
||||
|
||||
const messages = conversation
|
||||
? await query<AiMessage>(
|
||||
"SELECT * FROM ai_agent_messages WHERE conversation_id = $1 ORDER BY created_at",
|
||||
[id]
|
||||
)
|
||||
: [];
|
||||
|
||||
return { conversation, messages };
|
||||
}
|
||||
|
||||
static async createConversation(agentId?: number, userId?: string, apiKeyId?: number): Promise<AiConversation> {
|
||||
const conversationId = `conv-${crypto.randomUUID()}`;
|
||||
|
||||
const result = await query<AiConversation>(
|
||||
`INSERT INTO ai_agent_conversations (conversation_id, agent_id, user_id, api_key_id)
|
||||
VALUES ($1, $2, $3, $4) RETURNING *`,
|
||||
[conversationId, agentId || null, userId || null, apiKeyId || null]
|
||||
);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async addMessage(conversationId: number, role: string, content: string, tokenCount: number = 0, toolCalls?: any): Promise<AiMessage> {
|
||||
const result = await query<AiMessage>(
|
||||
`INSERT INTO ai_agent_messages (conversation_id, role, content, token_count, tool_calls)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb) RETURNING *`,
|
||||
[conversationId, role, content, tokenCount, toolCalls ? JSON.stringify(toolCalls) : null]
|
||||
);
|
||||
|
||||
// 대화 통계 업데이트
|
||||
await query(
|
||||
`UPDATE ai_agent_conversations
|
||||
SET message_count = message_count + 1, total_tokens = total_tokens + $1, updated_at = NOW()
|
||||
WHERE id = $2`,
|
||||
[tokenCount, conversationId]
|
||||
);
|
||||
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async delete(id: number): Promise<boolean> {
|
||||
await query("DELETE FROM ai_agent_conversations WHERE id = $1", [id]);
|
||||
return true;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export interface AgentGroup {
|
||||
id: number;
|
||||
group_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
status: string;
|
||||
company_code?: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
members?: GroupMember[];
|
||||
}
|
||||
|
||||
export interface GroupMember {
|
||||
id: number;
|
||||
group_id: number;
|
||||
agent_id: number;
|
||||
role_name: string;
|
||||
connectors: ConnectorRef[];
|
||||
execution_order: number;
|
||||
config: Record<string, any>;
|
||||
// JOIN 필드
|
||||
agent_name?: string;
|
||||
agent_model?: string;
|
||||
}
|
||||
|
||||
export interface ConnectorRef {
|
||||
type: "database" | "rest_api" | "crawler" | "file" | "plc";
|
||||
connection_id?: number;
|
||||
config_id?: number;
|
||||
path?: string;
|
||||
name: string;
|
||||
}
|
||||
|
||||
export class AiAgentGroupService {
|
||||
static async list(companyCode?: string): Promise<AgentGroup[]> {
|
||||
const groups = await query<AgentGroup>(
|
||||
`SELECT g.*,
|
||||
(SELECT COUNT(*) FROM ai_agent_group_members WHERE group_id = g.id) as member_count
|
||||
FROM ai_agent_groups g
|
||||
WHERE g.status != 'archived'
|
||||
${companyCode ? "AND (g.company_code = $1 OR g.company_code IS NULL)" : ""}
|
||||
ORDER BY g.created_at DESC`,
|
||||
companyCode ? [companyCode] : []
|
||||
);
|
||||
return groups;
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<AgentGroup | null> {
|
||||
const group = await queryOne<AgentGroup>(
|
||||
"SELECT * FROM ai_agent_groups WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
if (!group) return null;
|
||||
|
||||
const members = await query<GroupMember>(
|
||||
`SELECT m.*, a.name as agent_name, a.model as agent_model
|
||||
FROM ai_agent_group_members m
|
||||
LEFT JOIN ai_agents a ON m.agent_id = a.id
|
||||
WHERE m.group_id = $1
|
||||
ORDER BY m.execution_order`,
|
||||
[id]
|
||||
);
|
||||
group.members = members;
|
||||
return group;
|
||||
}
|
||||
|
||||
static async create(data: { name: string; description?: string; company_code?: string; execution_mode?: string }, userId: string): Promise<AgentGroup> {
|
||||
const groupId = `group-${Date.now().toString(36)}`;
|
||||
const result = await query<AgentGroup>(
|
||||
`INSERT INTO ai_agent_groups (group_id, name, description, company_code, created_by, execution_mode)
|
||||
VALUES ($1, $2, $3, $4, $5, $6) RETURNING *`,
|
||||
[groupId, data.name, data.description || null, data.company_code || null, userId, data.execution_mode || "mixed"]
|
||||
);
|
||||
logger.info(`멀티 에이전트 그룹 생성: ${data.name} (by ${userId})`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: { name?: string; description?: string; status?: string; execution_mode?: string }): Promise<AgentGroup | null> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
|
||||
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
|
||||
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
|
||||
if (data.execution_mode !== undefined) { sets.push(`execution_mode = $${idx++}`); params.push(data.execution_mode); }
|
||||
if (sets.length === 0) return this.getById(id);
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const result = await query<AgentGroup>(
|
||||
`UPDATE ai_agent_groups SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
static async delete(id: number): Promise<boolean> {
|
||||
await query("UPDATE ai_agent_groups SET status = 'archived', updated_at = NOW() WHERE id = $1", [id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== 멤버 관리 =====
|
||||
|
||||
static async addMember(groupId: number, data: {
|
||||
agent_id: number;
|
||||
role_name: string;
|
||||
connectors?: ConnectorRef[];
|
||||
execution_order?: number;
|
||||
}): Promise<GroupMember> {
|
||||
const result = await query<GroupMember>(
|
||||
`INSERT INTO ai_agent_group_members (group_id, agent_id, role_name, connectors, execution_order)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5) RETURNING *`,
|
||||
[groupId, data.agent_id, data.role_name, JSON.stringify(data.connectors || []), data.execution_order || 1]
|
||||
);
|
||||
await query("UPDATE ai_agent_groups SET updated_at = NOW() WHERE id = $1", [groupId]);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async updateMember(memberId: number, data: {
|
||||
role_name?: string;
|
||||
connectors?: ConnectorRef[];
|
||||
execution_order?: number;
|
||||
}): Promise<GroupMember | null> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.role_name !== undefined) { sets.push(`role_name = $${idx++}`); params.push(data.role_name); }
|
||||
if (data.connectors !== undefined) { sets.push(`connectors = $${idx++}::jsonb`); params.push(JSON.stringify(data.connectors)); }
|
||||
if (data.execution_order !== undefined) { sets.push(`execution_order = $${idx++}`); params.push(data.execution_order); }
|
||||
if (sets.length === 0) return null;
|
||||
|
||||
params.push(memberId);
|
||||
const result = await query<GroupMember>(
|
||||
`UPDATE ai_agent_group_members SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
static async removeMember(memberId: number): Promise<boolean> {
|
||||
await query("DELETE FROM ai_agent_group_members WHERE id = $1", [memberId]);
|
||||
return true;
|
||||
}
|
||||
|
||||
// ===== 사용 가능한 커넥터 목록 =====
|
||||
|
||||
static async getAvailableConnectors(): Promise<any[]> {
|
||||
// DB 연결
|
||||
const dbConnections = await query(
|
||||
"SELECT id as connection_id, connection_name as name, db_type, host, port, database_name, 'database' as type FROM external_db_connections WHERE is_active = 'Y' ORDER BY connection_name"
|
||||
).catch(() => []);
|
||||
|
||||
// REST API 연결
|
||||
const restConnections = await query(
|
||||
"SELECT id as connection_id, connection_name as name, base_url, auth_type, 'rest_api' as type FROM external_rest_api_connections WHERE is_active = 'Y' ORDER BY connection_name"
|
||||
).catch(() => []);
|
||||
|
||||
// 크롤링 설정
|
||||
const crawlConfigs = await query(
|
||||
"SELECT id as connection_id, name, url, 'crawler' as type FROM crawl_configs WHERE is_active = true ORDER BY name"
|
||||
).catch(() => []);
|
||||
|
||||
// PLC / 장비 연결
|
||||
const plcConnections = await query(
|
||||
`SELECT id as connection_id, connection_name as name, host, port, protocol, status,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = pipeline_device_connections.id AND is_active = 'Y') as tag_count,
|
||||
'plc' as type
|
||||
FROM pipeline_device_connections WHERE is_active = 'Y' ORDER BY connection_name`
|
||||
).catch(() => []);
|
||||
|
||||
return [
|
||||
...dbConnections.map((c: any) => ({ ...c, type: "database" })),
|
||||
...restConnections.map((c: any) => ({ ...c, type: "rest_api" })),
|
||||
...crawlConfigs.map((c: any) => ({ ...c, type: "crawler" })),
|
||||
...plcConnections.map((c: any) => ({ ...c, type: "plc" })),
|
||||
];
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,95 @@
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { AiLlmProvider, CreateProviderRequest } from "../types/aiAgent";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AiAgentProviderService {
|
||||
static async list(): Promise<Omit<AiLlmProvider, "api_key_encrypted">[]> {
|
||||
const providers = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers ORDER BY priority, name"
|
||||
);
|
||||
// API 키는 마스킹해서 반환
|
||||
return providers.map(({ api_key_encrypted, ...rest }) => ({
|
||||
...rest,
|
||||
api_key_masked: api_key_encrypted ? "****" + api_key_encrypted.slice(-4) : "",
|
||||
})) as any;
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<AiLlmProvider | null> {
|
||||
return queryOne<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
}
|
||||
|
||||
static async create(data: CreateProviderRequest): Promise<AiLlmProvider> {
|
||||
const encrypted = EncryptUtil.encrypt(data.api_key);
|
||||
|
||||
const result = await query<AiLlmProvider>(
|
||||
`INSERT INTO ai_llm_providers (name, display_name, api_key_encrypted, model_name, endpoint, priority, max_tokens, temperature, cost_per_1k_input, cost_per_1k_output)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.name,
|
||||
data.display_name,
|
||||
encrypted,
|
||||
data.model_name,
|
||||
data.endpoint || null,
|
||||
data.priority || 1,
|
||||
data.max_tokens || 4096,
|
||||
data.temperature || 0.7,
|
||||
data.cost_per_1k_input || 0,
|
||||
data.cost_per_1k_output || 0,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`LLM 프로바이더 추가: ${data.name} (${data.model_name})`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: Partial<CreateProviderRequest> & { is_active?: boolean }): Promise<AiLlmProvider | null> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.display_name !== undefined) { sets.push(`display_name = $${idx++}`); params.push(data.display_name); }
|
||||
if (data.api_key !== undefined) { sets.push(`api_key_encrypted = $${idx++}`); params.push(EncryptUtil.encrypt(data.api_key)); }
|
||||
if (data.model_name !== undefined) { sets.push(`model_name = $${idx++}`); params.push(data.model_name); }
|
||||
if (data.endpoint !== undefined) { sets.push(`endpoint = $${idx++}`); params.push(data.endpoint); }
|
||||
if (data.priority !== undefined) { sets.push(`priority = $${idx++}`); params.push(data.priority); }
|
||||
if (data.max_tokens !== undefined) { sets.push(`max_tokens = $${idx++}`); params.push(data.max_tokens); }
|
||||
if (data.temperature !== undefined) { sets.push(`temperature = $${idx++}`); params.push(data.temperature); }
|
||||
if (data.cost_per_1k_input !== undefined) { sets.push(`cost_per_1k_input = $${idx++}`); params.push(data.cost_per_1k_input); }
|
||||
if (data.cost_per_1k_output !== undefined) { sets.push(`cost_per_1k_output = $${idx++}`); params.push(data.cost_per_1k_output); }
|
||||
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
|
||||
|
||||
if (sets.length === 0) return this.getById(id);
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const result = await query<AiLlmProvider>(
|
||||
`UPDATE ai_llm_providers SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
static async delete(id: number): Promise<boolean> {
|
||||
await query("DELETE FROM ai_llm_providers WHERE id = $1", [id]);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async getActiveProviders(): Promise<AiLlmProvider[]> {
|
||||
return query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
|
||||
);
|
||||
}
|
||||
|
||||
static async getDecryptedKey(id: number): Promise<string | null> {
|
||||
const provider = await this.getById(id);
|
||||
if (!provider) return null;
|
||||
return EncryptUtil.decrypt(provider.api_key_encrypted);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,101 @@
|
||||
import { query, queryOne, transaction } from "../database/db";
|
||||
import { AiAgent, CreateAgentRequest, UpdateAgentRequest } from "../types/aiAgent";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AiAgentService {
|
||||
static async list(filters?: { status?: string; company_code?: string; search?: string }): Promise<AiAgent[]> {
|
||||
let sql = "SELECT * FROM ai_agents WHERE 1=1";
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (filters?.status) {
|
||||
sql += ` AND status = $${idx++}`;
|
||||
params.push(filters.status);
|
||||
}
|
||||
if (filters?.company_code) {
|
||||
sql += ` AND (company_code = $${idx++} OR company_code IS NULL)`;
|
||||
params.push(filters.company_code);
|
||||
}
|
||||
if (filters?.search) {
|
||||
sql += ` AND (name ILIKE $${idx} OR agent_id ILIKE $${idx} OR description ILIKE $${idx})`;
|
||||
params.push(`%${filters.search}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
sql += " ORDER BY created_at DESC";
|
||||
return query<AiAgent>(sql, params);
|
||||
}
|
||||
|
||||
static async getById(id: number): Promise<AiAgent | null> {
|
||||
return queryOne<AiAgent>("SELECT * FROM ai_agents WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
static async getByAgentId(agentId: string): Promise<AiAgent | null> {
|
||||
return queryOne<AiAgent>("SELECT * FROM ai_agents WHERE agent_id = $1", [agentId]);
|
||||
}
|
||||
|
||||
static async create(data: CreateAgentRequest, userId: string): Promise<AiAgent> {
|
||||
const existing = await this.getByAgentId(data.agent_id);
|
||||
if (existing) {
|
||||
throw new Error("이미 존재하는 에이전트 ID입니다.");
|
||||
}
|
||||
|
||||
const result = await query<AiAgent>(
|
||||
`INSERT INTO ai_agents (agent_id, name, description, model, system_prompt, tools, config, company_code, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7::jsonb, $8, $9)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.agent_id,
|
||||
data.name,
|
||||
data.description || null,
|
||||
data.model || "claude-sonnet-4-20250514",
|
||||
data.system_prompt || null,
|
||||
JSON.stringify(data.tools || []),
|
||||
JSON.stringify(data.config || {}),
|
||||
data.company_code || null,
|
||||
userId,
|
||||
]
|
||||
);
|
||||
|
||||
logger.info(`에이전트 생성: ${data.agent_id} (by ${userId})`);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
static async update(id: number, data: UpdateAgentRequest): Promise<AiAgent | null> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
|
||||
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
|
||||
if (data.model !== undefined) { sets.push(`model = $${idx++}`); params.push(data.model); }
|
||||
if (data.system_prompt !== undefined) { sets.push(`system_prompt = $${idx++}`); params.push(data.system_prompt); }
|
||||
if (data.tools !== undefined) { sets.push(`tools = $${idx++}::jsonb`); params.push(JSON.stringify(data.tools)); }
|
||||
if (data.config !== undefined) { sets.push(`config = $${idx++}::jsonb`); params.push(JSON.stringify(data.config)); }
|
||||
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
|
||||
|
||||
if (sets.length === 0) return this.getById(id);
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const result = await query<AiAgent>(
|
||||
`UPDATE ai_agents SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
return result[0] || null;
|
||||
}
|
||||
|
||||
static async delete(id: number): Promise<boolean> {
|
||||
const result = await query(
|
||||
"UPDATE ai_agents SET status = 'archived', updated_at = NOW() WHERE id = $1",
|
||||
[id]
|
||||
);
|
||||
return true;
|
||||
}
|
||||
|
||||
static async getActiveAgents(): Promise<AiAgent[]> {
|
||||
return query<AiAgent>("SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,88 @@
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { AiUsageLog, UsageSummary } from "../types/aiAgent";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AiAgentUsageService {
|
||||
static async log(data: Partial<AiUsageLog>): Promise<void> {
|
||||
await query(
|
||||
`INSERT INTO ai_agent_usage_logs (user_id, api_key_id, agent_id, conversation_id, provider_name, model_name, prompt_tokens, completion_tokens, total_tokens, cost_usd, response_time_ms, success, error_message, request_path, ip_address)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13, $14, $15)`,
|
||||
[
|
||||
data.user_id || null,
|
||||
data.api_key_id || null,
|
||||
data.agent_id || null,
|
||||
data.conversation_id || null,
|
||||
data.provider_name || null,
|
||||
data.model_name || null,
|
||||
data.prompt_tokens || 0,
|
||||
data.completion_tokens || 0,
|
||||
data.total_tokens || 0,
|
||||
data.cost_usd || 0,
|
||||
data.response_time_ms || null,
|
||||
data.success !== false,
|
||||
data.error_message || null,
|
||||
data.request_path || null,
|
||||
data.ip_address || null,
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
static async getSummary(): Promise<UsageSummary> {
|
||||
const todayResult = await queryOne<{ tokens: string; requests: string; cost: string }>(
|
||||
`SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost
|
||||
FROM ai_agent_usage_logs WHERE created_at >= CURRENT_DATE`
|
||||
);
|
||||
|
||||
const monthResult = await queryOne<{ tokens: string; requests: string; cost: string }>(
|
||||
`SELECT COALESCE(SUM(total_tokens), 0) as tokens, COUNT(*) as requests, COALESCE(SUM(cost_usd), 0) as cost
|
||||
FROM ai_agent_usage_logs WHERE created_at >= DATE_TRUNC('month', CURRENT_DATE)`
|
||||
);
|
||||
|
||||
const agentCount = await queryOne<{ cnt: string }>(
|
||||
"SELECT COUNT(*) as cnt FROM ai_agents WHERE status = 'active'"
|
||||
);
|
||||
|
||||
const keyCount = await queryOne<{ cnt: string }>(
|
||||
"SELECT COUNT(*) as cnt FROM ai_agent_api_keys WHERE status = 'active'"
|
||||
);
|
||||
|
||||
return {
|
||||
today_tokens: parseInt(todayResult?.tokens || "0"),
|
||||
today_requests: parseInt(todayResult?.requests || "0"),
|
||||
today_cost: parseFloat(todayResult?.cost || "0"),
|
||||
month_tokens: parseInt(monthResult?.tokens || "0"),
|
||||
month_requests: parseInt(monthResult?.requests || "0"),
|
||||
month_cost: parseFloat(monthResult?.cost || "0"),
|
||||
active_agents: parseInt(agentCount?.cnt || "0"),
|
||||
active_keys: parseInt(keyCount?.cnt || "0"),
|
||||
};
|
||||
}
|
||||
|
||||
static async getLogs(page: number = 1, limit: number = 20): Promise<{ logs: AiUsageLog[]; total: number }> {
|
||||
const offset = (page - 1) * limit;
|
||||
|
||||
const totalResult = await queryOne<{ cnt: string }>(
|
||||
"SELECT COUNT(*) as cnt FROM ai_agent_usage_logs"
|
||||
);
|
||||
|
||||
const logs = await query<AiUsageLog>(
|
||||
"SELECT * FROM ai_agent_usage_logs ORDER BY created_at DESC LIMIT $1 OFFSET $2",
|
||||
[limit, offset]
|
||||
);
|
||||
|
||||
return { logs, total: parseInt(totalResult?.cnt || "0") };
|
||||
}
|
||||
|
||||
static async getDailyUsage(days: number = 30): Promise<any[]> {
|
||||
return query(
|
||||
`SELECT DATE(created_at) as date,
|
||||
SUM(total_tokens) as tokens,
|
||||
COUNT(*) as requests,
|
||||
SUM(cost_usd) as cost
|
||||
FROM ai_agent_usage_logs
|
||||
WHERE created_at >= CURRENT_DATE - INTERVAL '${days} days'
|
||||
GROUP BY DATE(created_at)
|
||||
ORDER BY date`
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
export class AiAnalysisLogService {
|
||||
/**
|
||||
* 분석 결과 저장
|
||||
*/
|
||||
static async save(data: {
|
||||
group_id?: number;
|
||||
agent_id?: number;
|
||||
schedule_id?: number;
|
||||
execution_type?: string;
|
||||
input_message: string;
|
||||
analysis_result: string;
|
||||
prediction?: any;
|
||||
tokens_used?: number;
|
||||
duration_ms?: number;
|
||||
metadata?: any;
|
||||
}): Promise<any> {
|
||||
const result = await query(
|
||||
`INSERT INTO ai_analysis_logs (group_id, agent_id, schedule_id, execution_type, input_message, analysis_result, prediction, tokens_used, duration_ms, metadata)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7::jsonb, $8, $9, $10::jsonb) RETURNING *`,
|
||||
[
|
||||
data.group_id || null,
|
||||
data.agent_id || null,
|
||||
data.schedule_id || null,
|
||||
data.execution_type || "manual",
|
||||
data.input_message,
|
||||
data.analysis_result,
|
||||
data.prediction ? JSON.stringify(data.prediction) : null,
|
||||
data.tokens_used || 0,
|
||||
data.duration_ms || 0,
|
||||
JSON.stringify(data.metadata || {}),
|
||||
]
|
||||
);
|
||||
return result[0];
|
||||
}
|
||||
|
||||
/**
|
||||
* 최근 이력 조회 (에이전트가 참고할 수 있도록)
|
||||
*/
|
||||
static async getRecentLogs(groupId: number, days: number = 30, limit: number = 20): Promise<any[]> {
|
||||
return query(
|
||||
`SELECT id, execution_type, input_message, analysis_result, prediction, actual_result, accuracy_score, created_at
|
||||
FROM ai_analysis_logs
|
||||
WHERE group_id = $1 AND created_at >= NOW() - INTERVAL '${days} days'
|
||||
ORDER BY created_at DESC LIMIT $2`,
|
||||
[groupId, limit]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 예측 정확도 업데이트 (실제 결과 입력 시)
|
||||
*/
|
||||
static async updateActualResult(logId: number, actualResult: any, accuracyScore: number): Promise<void> {
|
||||
await query(
|
||||
"UPDATE ai_analysis_logs SET actual_result = $1::jsonb, accuracy_score = $2 WHERE id = $3",
|
||||
[JSON.stringify(actualResult), accuracyScore, logId]
|
||||
);
|
||||
}
|
||||
|
||||
/**
|
||||
* 평균 예측 정확도 조회
|
||||
*/
|
||||
static async getAverageAccuracy(groupId: number): Promise<number> {
|
||||
const result = await queryOne<{ avg: string }>(
|
||||
"SELECT AVG(accuracy_score)::text as avg FROM ai_analysis_logs WHERE group_id = $1 AND accuracy_score IS NOT NULL",
|
||||
[groupId]
|
||||
);
|
||||
return parseFloat(result?.avg || "0");
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,185 @@
|
||||
import cron from "node-cron";
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { MultiAgentExecutionEngine } from "./multiAgentExecutionEngine";
|
||||
import { logger } from "../utils/logger";
|
||||
import axios from "axios";
|
||||
|
||||
interface ScheduleJob {
|
||||
id: number;
|
||||
cronTask: cron.ScheduledTask;
|
||||
}
|
||||
|
||||
const activeJobs = new Map<number, ScheduleJob>();
|
||||
|
||||
export class AiSchedulerService {
|
||||
/**
|
||||
* 스케줄 CRUD
|
||||
*/
|
||||
static async list(): Promise<any[]> {
|
||||
return query(
|
||||
`SELECT s.*, g.name as group_name
|
||||
FROM ai_agent_schedules s
|
||||
LEFT JOIN ai_agent_groups g ON s.group_id = g.id
|
||||
ORDER BY s.created_at DESC`
|
||||
);
|
||||
}
|
||||
|
||||
static async create(data: {
|
||||
name: string;
|
||||
group_id: number;
|
||||
cron_expression: string;
|
||||
input_message: string;
|
||||
notification?: any;
|
||||
}, userId: string): Promise<any> {
|
||||
// cron 표현식 유효성 체크
|
||||
if (!cron.validate(data.cron_expression)) {
|
||||
throw new Error("유효하지 않은 cron 표현식입니다.");
|
||||
}
|
||||
|
||||
const result = await query(
|
||||
`INSERT INTO ai_agent_schedules (name, group_id, cron_expression, input_message, notification, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5::jsonb, $6) RETURNING *`,
|
||||
[data.name, data.group_id, data.cron_expression, data.input_message, JSON.stringify(data.notification || {}), userId]
|
||||
);
|
||||
|
||||
const schedule = result[0];
|
||||
this.registerJob(schedule);
|
||||
logger.info(`AI 스케줄 생성: ${data.name} (${data.cron_expression})`);
|
||||
return schedule;
|
||||
}
|
||||
|
||||
static async update(id: number, data: any): Promise<any> {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.name !== undefined) { sets.push(`name = $${idx++}`); params.push(data.name); }
|
||||
if (data.cron_expression !== undefined) {
|
||||
if (!cron.validate(data.cron_expression)) throw new Error("유효하지 않은 cron 표현식입니다.");
|
||||
sets.push(`cron_expression = $${idx++}`); params.push(data.cron_expression);
|
||||
}
|
||||
if (data.input_message !== undefined) { sets.push(`input_message = $${idx++}`); params.push(data.input_message); }
|
||||
if (data.notification !== undefined) { sets.push(`notification = $${idx++}::jsonb`); params.push(JSON.stringify(data.notification)); }
|
||||
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
|
||||
|
||||
if (sets.length === 0) return null;
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const result = await query(`UPDATE ai_agent_schedules SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`, params);
|
||||
const schedule = result[0];
|
||||
|
||||
// 재등록
|
||||
this.unregisterJob(id);
|
||||
if (schedule?.is_active) this.registerJob(schedule);
|
||||
|
||||
return schedule;
|
||||
}
|
||||
|
||||
static async delete(id: number): Promise<void> {
|
||||
this.unregisterJob(id);
|
||||
await query("DELETE FROM ai_agent_schedules WHERE id = $1", [id]);
|
||||
}
|
||||
|
||||
/**
|
||||
* 스케줄 작업 등록
|
||||
*/
|
||||
private static registerJob(schedule: any): void {
|
||||
if (!schedule.is_active) return;
|
||||
|
||||
const task = cron.schedule(schedule.cron_expression, async () => {
|
||||
logger.info(`AI 스케줄 실행: ${schedule.name} (ID: ${schedule.id})`);
|
||||
|
||||
try {
|
||||
const result = await MultiAgentExecutionEngine.execute(
|
||||
schedule.group_id,
|
||||
schedule.input_message,
|
||||
{ userId: schedule.created_by }
|
||||
);
|
||||
|
||||
// 실행 기록 업데이트
|
||||
await query(
|
||||
"UPDATE ai_agent_schedules SET last_run_at = NOW(), run_count = run_count + 1 WHERE id = $1",
|
||||
[schedule.id]
|
||||
);
|
||||
|
||||
// 알림 발송
|
||||
await this.sendNotification(schedule, result);
|
||||
|
||||
logger.info(`AI 스케줄 완료: ${schedule.name} - ${result.totalTokens} tokens`);
|
||||
} catch (e: any) {
|
||||
logger.error(`AI 스케줄 실패: ${schedule.name} - ${e.message}`);
|
||||
}
|
||||
});
|
||||
|
||||
activeJobs.set(schedule.id, { id: schedule.id, cronTask: task });
|
||||
}
|
||||
|
||||
private static unregisterJob(id: number): void {
|
||||
const job = activeJobs.get(id);
|
||||
if (job) {
|
||||
job.cronTask.stop();
|
||||
activeJobs.delete(id);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 알림 발송
|
||||
*/
|
||||
private static async sendNotification(schedule: any, result: any): Promise<void> {
|
||||
const notification = schedule.notification || {};
|
||||
|
||||
// 시스템 공지
|
||||
if (notification.system_notice) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at)
|
||||
VALUES ($1, $2, 'info', true, 'AI_SCHEDULER', NOW())`,
|
||||
[
|
||||
`[AI] ${schedule.name} 실행 결과`,
|
||||
result.finalSummary.substring(0, 2000),
|
||||
]
|
||||
);
|
||||
} catch (e) { logger.warn("시스템 공지 저장 실패:", e); }
|
||||
}
|
||||
|
||||
// 웹훅 (슬랙 등)
|
||||
if (notification.webhook) {
|
||||
try {
|
||||
await axios.post(notification.webhook, {
|
||||
text: `🤖 [${schedule.name}] 실행 완료\n\n${result.finalSummary.substring(0, 1000)}`,
|
||||
}, { timeout: 10000 });
|
||||
} catch (e) { logger.warn("웹훅 발송 실패:", e); }
|
||||
}
|
||||
|
||||
// 이메일
|
||||
if (notification.email && notification.email.length > 0) {
|
||||
try {
|
||||
// 기존 메일 발송 서비스 활용
|
||||
const { mailSendSimpleService } = await import("./mailSendSimpleService");
|
||||
for (const to of notification.email) {
|
||||
await mailSendSimpleService.sendMail({
|
||||
to,
|
||||
subject: `[AI 분석] ${schedule.name} 실행 결과`,
|
||||
html: `<h3>${schedule.name} 분석 결과</h3><pre>${result.finalSummary.substring(0, 3000)}</pre>`,
|
||||
}).catch(() => {});
|
||||
}
|
||||
} catch (e) { logger.warn("이메일 발송 실패:", e); }
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 서버 시작 시 활성 스케줄 모두 등록
|
||||
*/
|
||||
static async initializeSchedules(): Promise<void> {
|
||||
try {
|
||||
const schedules = await query("SELECT * FROM ai_agent_schedules WHERE is_active = true");
|
||||
for (const schedule of schedules) {
|
||||
this.registerJob(schedule);
|
||||
}
|
||||
logger.info(`AI 스케줄러 초기화: ${schedules.length}개 활성 스케줄`);
|
||||
} catch (e) {
|
||||
logger.warn("AI 스케줄러 초기화 실패:", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -132,7 +132,7 @@ export class BatchSchedulerService {
|
||||
logger.info(`배치 실행 시작: ${config.batch_name} (ID: ${config.id}, type: ${config.execution_type || "mapping"})`);
|
||||
|
||||
// 상세 조회 (매핑 또는 노드플로우 정보가 없을 수 있음)
|
||||
if (!config.execution_type || config.execution_type === "mapping") {
|
||||
if (!config.execution_type || config.execution_type === "mapping" || config.execution_type === "rest_api_sync") {
|
||||
if (!config.batch_mappings || config.batch_mappings.length === 0) {
|
||||
const fullConfig = await BatchService.getBatchConfigById(config.id);
|
||||
if (fullConfig.success && fullConfig.data) {
|
||||
@@ -171,6 +171,15 @@ export class BatchSchedulerService {
|
||||
|
||||
if (config.execution_type === "node_flow") {
|
||||
result = await this.executeNodeFlow(config);
|
||||
} else if (config.execution_type === "ai_agent") {
|
||||
result = await this.executeAiAgent(config);
|
||||
} else if (config.execution_type === "rest_api_sync") {
|
||||
// REST API 동기화 (mapping과 동일 로직이지만 타입 구분)
|
||||
result = await this.executeBatchMappings(config);
|
||||
} else if (config.execution_type === "device_collection") {
|
||||
result = await this.executeDeviceCollection(config);
|
||||
} else if (config.execution_type === "crawling") {
|
||||
result = await this.executeCrawling(config);
|
||||
} else {
|
||||
result = await this.executeBatchMappings(config);
|
||||
}
|
||||
@@ -248,6 +257,137 @@ export class BatchSchedulerService {
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* AI 멀티 에이전트 실행 - MultiAgentExecutionEngine에 위임
|
||||
*/
|
||||
private static async executeAiAgent(config: any) {
|
||||
const { MultiAgentExecutionEngine } = await import("./multiAgentExecutionEngine");
|
||||
const { AiAnalysisLogService } = await import("./aiAnalysisLogService");
|
||||
|
||||
const groupId = config.node_flow_context?.ai_group_id || config.ai_group_id;
|
||||
const inputMessage = config.node_flow_context?.ai_input_message || config.description || "분석을 실행해주세요";
|
||||
|
||||
if (!groupId) {
|
||||
throw new Error("AI 에이전트 그룹 ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
logger.info(`AI 에이전트 실행: groupId=${groupId}, batch=${config.batch_name}`);
|
||||
|
||||
const result = await MultiAgentExecutionEngine.execute(groupId, inputMessage, {
|
||||
userId: config.created_by || "batch_scheduler",
|
||||
});
|
||||
|
||||
// 알림 발송 (notification 설정이 있으면)
|
||||
const notification = config.node_flow_context?.notification;
|
||||
if (notification) {
|
||||
// 시스템 공지
|
||||
if (notification.system_notice) {
|
||||
try {
|
||||
const { query: dbQuery } = await import("../database/db");
|
||||
await dbQuery(
|
||||
`INSERT INTO system_notice (title, content, type, is_active, created_by, created_at)
|
||||
VALUES ($1, $2, 'info', true, 'AI_BATCH', NOW())`,
|
||||
[`[AI] ${config.batch_name} 실행 결과`, result.finalSummary.substring(0, 2000)]
|
||||
);
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
// 웹훅
|
||||
if (notification.webhook) {
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
await axios.post(notification.webhook, {
|
||||
text: `🤖 [${config.batch_name}] 실행 완료\n${result.finalSummary.substring(0, 1000)}`,
|
||||
}, { timeout: 10000 });
|
||||
} catch { /* ignore */ }
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
totalRecords: result.steps.length,
|
||||
successRecords: result.steps.filter((s) => !s.response.startsWith("[실행 실패]")).length,
|
||||
failedRecords: result.steps.filter((s) => s.response.startsWith("[실행 실패]")).length,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 장비 데이터 수집 실행 — 실제 PLC 통신
|
||||
*/
|
||||
private static async executeDeviceCollection(config: any) {
|
||||
const context = config.node_flow_context || {};
|
||||
const connectionId = context.device_connection_id;
|
||||
|
||||
if (!connectionId) {
|
||||
throw new Error("장비 연결 ID가 설정되지 않았습니다.");
|
||||
}
|
||||
|
||||
// DeviceCollectorService로 실제 PLC 데이터 수집
|
||||
const { collectDevice } = await import("./collector/deviceCollectorService");
|
||||
const result = await collectDevice(connectionId);
|
||||
|
||||
const tagCount = Object.keys(result.tags).length;
|
||||
const successCount = Object.values(result.tags).filter(v => v !== null).length;
|
||||
const failedCount = tagCount - successCount;
|
||||
|
||||
logger.info(
|
||||
`장비 데이터 수집 완료: ${result.connectionName} (${result.protocol}) - ` +
|
||||
`${successCount}/${tagCount}개 태그 | PLC: ${result.plcState}`
|
||||
);
|
||||
|
||||
// 대상 테이블에 수집 결과 저장 (설정된 경우)
|
||||
const targetTable = context.target_table;
|
||||
if (targetTable) {
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO ${targetTable} (connection_id, connection_name, collected_at, plc_state, tag_values, error_message)
|
||||
VALUES ($1, $2, NOW(), $3, $4::jsonb, $5)`,
|
||||
[connectionId, result.connectionName, result.plcState, JSON.stringify(result.tags), result.errorMessage]
|
||||
);
|
||||
} catch (err) {
|
||||
logger.warn(`수집 결과 저장 실패 (${targetTable}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
return { totalRecords: tagCount, successRecords: successCount, failedRecords: failedCount };
|
||||
}
|
||||
|
||||
/**
|
||||
* 크롤링 실행
|
||||
*/
|
||||
private static async executeCrawling(config: any) {
|
||||
const context = config.node_flow_context || {};
|
||||
const configId = context.crawl_config_id;
|
||||
|
||||
if (!configId) {
|
||||
throw new Error("크롤링 설정 ID가 지정되지 않았습니다.");
|
||||
}
|
||||
|
||||
const crawlConfig = await query<any>("SELECT * FROM crawl_configs WHERE id = $1", [configId]);
|
||||
if (!crawlConfig.length) throw new Error("크롤링 설정을 찾을 수 없습니다.");
|
||||
|
||||
const cfg = crawlConfig[0];
|
||||
logger.info(`크롤링 실행: ${cfg.name} (${cfg.url})`);
|
||||
|
||||
// 간단한 HTTP GET으로 데이터 수집
|
||||
try {
|
||||
const axios = (await import("axios")).default;
|
||||
const response = await axios.get(cfg.url, { timeout: 30000 });
|
||||
|
||||
const targetTable = context.target_table;
|
||||
if (targetTable) {
|
||||
// 결과를 지정된 테이블에 저장
|
||||
await query(
|
||||
`INSERT INTO ${targetTable} (url, content, status_code, crawled_at) VALUES ($1, $2, $3, NOW())`,
|
||||
[cfg.url, typeof response.data === "string" ? response.data : JSON.stringify(response.data), response.status]
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return { totalRecords: 1, successRecords: 1, failedRecords: 0 };
|
||||
} catch (e: any) {
|
||||
logger.warn(`크롤링 실패: ${cfg.url} - ${e.message}`);
|
||||
return { totalRecords: 1, successRecords: 0, failedRecords: 1 };
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 배치 매핑 실행 (수동 실행과 동일한 로직)
|
||||
*/
|
||||
|
||||
@@ -0,0 +1,324 @@
|
||||
/**
|
||||
* Device Collector Service
|
||||
* - pipeline_device_connections + pipeline_tag_mappings 설정 기반
|
||||
* - 프로토콜별 PLC 읽기 (XGT, Modbus 등)
|
||||
* - 읽은 데이터 → MQTT 발행 + DB 저장
|
||||
* - 오프라인 버퍼 (발행 실패 시 재시도)
|
||||
*
|
||||
* Python data-collector의 EdgeAgent + CollectorManager 포팅
|
||||
*/
|
||||
|
||||
import { query } from "../../database/db";
|
||||
import { logger } from "../../utils/logger";
|
||||
import { XgtClient, getXgtClient, closeAllXgtConnections } from "./protocols/xgtClient";
|
||||
import { ModbusClient } from "./protocols/modbusClient";
|
||||
import type { XgtTagConfig, XgtReadResult } from "./protocols/xgtClient";
|
||||
import type { ModbusTagConfig, ModbusReadResult } from "./protocols/modbusClient";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────
|
||||
|
||||
interface DeviceConnection {
|
||||
id: number;
|
||||
connection_name: string;
|
||||
protocol: string;
|
||||
host: string;
|
||||
port: number;
|
||||
protocol_config: Record<string, unknown>;
|
||||
polling_interval_ms: number;
|
||||
timeout_ms: number;
|
||||
retry_count: number;
|
||||
status: string;
|
||||
company_code: string;
|
||||
}
|
||||
|
||||
interface TagMapping {
|
||||
id: number;
|
||||
connection_id: number;
|
||||
tag_name: string;
|
||||
tag_display_name: string | null;
|
||||
tag_unit: string | null;
|
||||
tag_data_type: string;
|
||||
address: string;
|
||||
address_type: string | null;
|
||||
scale_factor: number;
|
||||
offset_value: number;
|
||||
min_value: number | null;
|
||||
max_value: number | null;
|
||||
}
|
||||
|
||||
export interface CollectedData {
|
||||
connectionId: number;
|
||||
connectionName: string;
|
||||
protocol: string;
|
||||
companyCode: string;
|
||||
timestamp: string;
|
||||
plcState: "connected" | "disconnected" | "error";
|
||||
errorMessage: string | null;
|
||||
tags: Record<string, number | boolean | string | null>;
|
||||
}
|
||||
|
||||
// ─── 폴링 타이머 관리 ─────────────────────────────
|
||||
|
||||
const pollingTimers = new Map<number, NodeJS.Timeout>();
|
||||
const clientCache = new Map<number, XgtClient | ModbusClient>();
|
||||
|
||||
// ─── 오프라인 버퍼 (메모리 기반, 추후 SQLite 확장 가능) ───
|
||||
|
||||
const retryQueue: CollectedData[] = [];
|
||||
const MAX_RETRY_QUEUE = 10000;
|
||||
|
||||
// ─── MQTT 발행 (옵션) ─────────────────────────────
|
||||
|
||||
let mqttClient: { publish: (topic: string, message: string) => void } | null = null;
|
||||
let mqttConfig: { brokerUrl: string; topic: string } | null = null;
|
||||
|
||||
export function setMqttPublisher(config: { brokerUrl: string; topic: string }, client: { publish: (topic: string, message: string) => void }) {
|
||||
mqttConfig = config;
|
||||
mqttClient = client;
|
||||
logger.info(`[Collector] MQTT 퍼블리셔 설정: ${config.brokerUrl} → ${config.topic}`);
|
||||
}
|
||||
|
||||
// ─── 태그 매핑 → 프로토콜 태그 변환 ────────────────
|
||||
|
||||
function toXgtTags(tags: TagMapping[]): XgtTagConfig[] {
|
||||
return tags.map(t => ({
|
||||
tagName: t.tag_name,
|
||||
address: t.address,
|
||||
dataType: mapDataType(t.tag_data_type),
|
||||
scaleFactor: t.scale_factor ?? 1,
|
||||
offsetValue: t.offset_value ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function toModbusTags(tags: TagMapping[]): ModbusTagConfig[] {
|
||||
return tags.map(t => ({
|
||||
tagName: t.tag_name,
|
||||
address: t.address,
|
||||
dataType: t.tag_data_type as ModbusTagConfig["dataType"],
|
||||
scaleFactor: t.scale_factor ?? 1,
|
||||
offsetValue: t.offset_value ?? 0,
|
||||
}));
|
||||
}
|
||||
|
||||
function mapDataType(dt: string): "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32" {
|
||||
switch (dt.toUpperCase()) {
|
||||
case "BOOLEAN": return "BOOL";
|
||||
case "INT16": return "INT16";
|
||||
case "INT32": return "INT32";
|
||||
case "FLOAT32": return "FLOAT32";
|
||||
case "FLOAT64": return "FLOAT32"; // Node.js에서는 FLOAT32로 처리
|
||||
default: return "INT16";
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 단일 디바이스 수집 실행 ──────────────────────
|
||||
|
||||
export async function collectDevice(connectionId: number): Promise<CollectedData> {
|
||||
// DB에서 연결 + 태그 조회
|
||||
const connections = await query<DeviceConnection>(
|
||||
"SELECT * FROM pipeline_device_connections WHERE id = $1",
|
||||
[connectionId]
|
||||
);
|
||||
if (!connections.length) throw new Error(`연결 ID ${connectionId}를 찾을 수 없습니다.`);
|
||||
const device = connections[0];
|
||||
|
||||
const tags = await query<TagMapping>(
|
||||
"SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name",
|
||||
[connectionId]
|
||||
);
|
||||
if (!tags.length) throw new Error(`태그가 없습니다 (connection_id=${connectionId})`);
|
||||
|
||||
const result: CollectedData = {
|
||||
connectionId: device.id,
|
||||
connectionName: device.connection_name,
|
||||
protocol: device.protocol,
|
||||
companyCode: device.company_code || "",
|
||||
timestamp: new Date().toISOString(),
|
||||
plcState: "disconnected",
|
||||
errorMessage: null,
|
||||
tags: {},
|
||||
};
|
||||
|
||||
try {
|
||||
switch (device.protocol) {
|
||||
case "PLC_ETHERNET": {
|
||||
// LS XGT FEnet
|
||||
const xgtPort = device.port || 2004;
|
||||
const client = getXgtClient(device.host, xgtPort, device.timeout_ms || 3000);
|
||||
if (!client.isConnected()) await client.connect();
|
||||
clientCache.set(device.id, client);
|
||||
|
||||
const xgtTags = toXgtTags(tags);
|
||||
const readings = await client.readTags(xgtTags);
|
||||
|
||||
for (const r of readings) {
|
||||
result.tags[r.tagName] = r.quality === "good" ? r.value : null;
|
||||
}
|
||||
result.plcState = "connected";
|
||||
break;
|
||||
}
|
||||
|
||||
case "MODBUS_TCP": {
|
||||
const unitId = (device.protocol_config?.unit_id as number) || 1;
|
||||
let client = clientCache.get(device.id) as ModbusClient;
|
||||
if (!client || !client.isConnected()) {
|
||||
client = new ModbusClient(device.host, device.port || 502, unitId, device.timeout_ms || 3000);
|
||||
await client.connect();
|
||||
clientCache.set(device.id, client);
|
||||
}
|
||||
|
||||
const modbusTags = toModbusTags(tags);
|
||||
const readings = await client.readTags(modbusTags);
|
||||
|
||||
for (const r of readings) {
|
||||
result.tags[r.tagName] = r.quality === "good" ? r.value : null;
|
||||
}
|
||||
result.plcState = "connected";
|
||||
break;
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`지원하지 않는 프로토콜: ${device.protocol}`);
|
||||
}
|
||||
|
||||
// 연결 상태 업데이트
|
||||
await query(
|
||||
"UPDATE pipeline_device_connections SET status = 'active', last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2",
|
||||
[`수집 성공: ${Object.keys(result.tags).length}개 태그`, device.id]
|
||||
).catch(() => {});
|
||||
|
||||
} catch (err) {
|
||||
result.plcState = "error";
|
||||
result.errorMessage = (err as Error).message;
|
||||
logger.error(`[Collector] 수집 실패 (${device.connection_name}): ${result.errorMessage}`);
|
||||
|
||||
await query(
|
||||
"UPDATE pipeline_device_connections SET status = 'error', last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2",
|
||||
[result.errorMessage, device.id]
|
||||
).catch(() => {});
|
||||
}
|
||||
|
||||
return result;
|
||||
}
|
||||
|
||||
// ─── 수집 결과 발행 ───────────────────────────────
|
||||
|
||||
async function publishData(data: CollectedData): Promise<void> {
|
||||
// 1. MQTT 발행
|
||||
if (mqttClient && mqttConfig) {
|
||||
try {
|
||||
const topic = `${mqttConfig.topic}/${data.companyCode}/${data.connectionId}`;
|
||||
mqttClient.publish(topic, JSON.stringify(data));
|
||||
} catch (err) {
|
||||
logger.warn(`[Collector] MQTT 발행 실패 — 재시도 큐에 추가`);
|
||||
if (retryQueue.length < MAX_RETRY_QUEUE) retryQueue.push(data);
|
||||
}
|
||||
}
|
||||
|
||||
// 2. DB 저장 (collected_data 테이블이 있으면)
|
||||
try {
|
||||
await query(
|
||||
`INSERT INTO pipeline_collected_data (connection_id, collected_at, plc_state, tag_values, error_message)
|
||||
VALUES ($1, $2, $3, $4::jsonb, $5)
|
||||
ON CONFLICT DO NOTHING`,
|
||||
[data.connectionId, data.timestamp, data.plcState, JSON.stringify(data.tags), data.errorMessage]
|
||||
);
|
||||
} catch {
|
||||
// 테이블이 없을 수 있음 — 무시
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 폴링 시작/중지 ──────────────────────────────
|
||||
|
||||
export async function startPolling(connectionId: number): Promise<void> {
|
||||
if (pollingTimers.has(connectionId)) {
|
||||
logger.warn(`[Collector] 이미 폴링 중: connection_id=${connectionId}`);
|
||||
return;
|
||||
}
|
||||
|
||||
const connections = await query<DeviceConnection>(
|
||||
"SELECT * FROM pipeline_device_connections WHERE id = $1 AND is_active = 'Y'",
|
||||
[connectionId]
|
||||
);
|
||||
if (!connections.length) throw new Error(`활성 연결을 찾을 수 없습니다: ${connectionId}`);
|
||||
|
||||
const device = connections[0];
|
||||
const interval = device.polling_interval_ms || 1000;
|
||||
|
||||
logger.info(`[Collector] 폴링 시작: ${device.connection_name} (${device.protocol}, ${interval}ms 간격)`);
|
||||
|
||||
// 즉시 한 번 실행
|
||||
const data = await collectDevice(connectionId);
|
||||
await publishData(data);
|
||||
|
||||
// 주기적 폴링
|
||||
const timer = setInterval(async () => {
|
||||
try {
|
||||
const collected = await collectDevice(connectionId);
|
||||
await publishData(collected);
|
||||
} catch (err) {
|
||||
logger.error(`[Collector] 폴링 에러 (${device.connection_name}): ${(err as Error).message}`);
|
||||
}
|
||||
}, interval);
|
||||
|
||||
pollingTimers.set(connectionId, timer);
|
||||
}
|
||||
|
||||
export function stopPolling(connectionId: number): void {
|
||||
const timer = pollingTimers.get(connectionId);
|
||||
if (timer) {
|
||||
clearInterval(timer);
|
||||
pollingTimers.delete(connectionId);
|
||||
logger.info(`[Collector] 폴링 중지: connection_id=${connectionId}`);
|
||||
}
|
||||
|
||||
// 클라이언트 연결도 정리
|
||||
const client = clientCache.get(connectionId);
|
||||
if (client) {
|
||||
client.disconnect();
|
||||
clientCache.delete(connectionId);
|
||||
}
|
||||
}
|
||||
|
||||
export function stopAllPolling(): void {
|
||||
for (const [id] of pollingTimers) {
|
||||
stopPolling(id);
|
||||
}
|
||||
closeAllXgtConnections();
|
||||
logger.info("[Collector] 모든 폴링 중지");
|
||||
}
|
||||
|
||||
// ─── 활성 연결 전체 폴링 시작 ────────────────────
|
||||
|
||||
export async function startAllActivePolling(): Promise<number> {
|
||||
const connections = await query<DeviceConnection>(
|
||||
"SELECT * FROM pipeline_device_connections WHERE is_active = 'Y' AND status != 'error' ORDER BY id"
|
||||
);
|
||||
|
||||
let started = 0;
|
||||
for (const conn of connections) {
|
||||
try {
|
||||
await startPolling(conn.id);
|
||||
started++;
|
||||
} catch (err) {
|
||||
logger.error(`[Collector] 폴링 시작 실패 (${conn.connection_name}): ${(err as Error).message}`);
|
||||
}
|
||||
}
|
||||
|
||||
logger.info(`[Collector] 전체 폴링 시작: ${started}/${connections.length}개 연결`);
|
||||
return started;
|
||||
}
|
||||
|
||||
// ─── 상태 조회 ────────────────────────────────────
|
||||
|
||||
export function getPollingStatus(): { connectionId: number; active: boolean }[] {
|
||||
const result: { connectionId: number; active: boolean }[] = [];
|
||||
for (const [id] of pollingTimers) {
|
||||
result.push({ connectionId: id, active: true });
|
||||
}
|
||||
return result;
|
||||
}
|
||||
|
||||
export function getRetryQueueSize(): number {
|
||||
return retryQueue.length;
|
||||
}
|
||||
@@ -0,0 +1,283 @@
|
||||
/**
|
||||
* Modbus TCP Client
|
||||
* - 순수 TCP 소켓 기반 (외부 의존성 없음)
|
||||
* - Python data-collector의 modbus_collector.py 포팅
|
||||
*
|
||||
* Modbus TCP 프레임:
|
||||
* [0-1] Transaction ID
|
||||
* [2-3] Protocol ID (0x0000)
|
||||
* [4-5] Length (Unit ID + PDU)
|
||||
* [6] Unit ID (slave)
|
||||
* [7] Function Code (0x03=Holding, 0x04=Input)
|
||||
* [8-9] Start Address
|
||||
* [10-11] Quantity
|
||||
*/
|
||||
|
||||
import net from "net";
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────
|
||||
|
||||
export interface ModbusReadResult {
|
||||
tagName: string;
|
||||
address: string;
|
||||
rawValue: number;
|
||||
value: number | boolean;
|
||||
quality: "good" | "bad";
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface ModbusTagConfig {
|
||||
tagName: string;
|
||||
address: string; // 예: HR100, IR200 (Holding Register 100, Input Register 200)
|
||||
dataType: "UINT16" | "INT16" | "UINT32" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN";
|
||||
byteOrder?: "BIG_ENDIAN" | "LITTLE_ENDIAN" | "BIG_ENDIAN_SWAP" | "LITTLE_ENDIAN_SWAP";
|
||||
scaleFactor?: number;
|
||||
offsetValue?: number;
|
||||
}
|
||||
|
||||
// ─── 주소 파싱 ────────────────────────────────────
|
||||
|
||||
function parseModbusAddress(address: string): { functionCode: number; register: number } {
|
||||
const match = address.match(/^(HR|IR|CO|DI)(\d+)$/i);
|
||||
if (!match) {
|
||||
// 숫자만 오면 Holding Register로 간주
|
||||
const numMatch = address.match(/^(\d+)$/);
|
||||
if (numMatch) return { functionCode: 0x03, register: parseInt(numMatch[1], 10) };
|
||||
throw new Error(`잘못된 Modbus 주소: ${address}`);
|
||||
}
|
||||
|
||||
const [, prefix, numStr] = match;
|
||||
const register = parseInt(numStr, 10);
|
||||
|
||||
switch (prefix.toUpperCase()) {
|
||||
case "HR": return { functionCode: 0x03, register }; // Holding Register (FC03)
|
||||
case "IR": return { functionCode: 0x04, register }; // Input Register (FC04)
|
||||
case "CO": return { functionCode: 0x01, register }; // Coil (FC01)
|
||||
case "DI": return { functionCode: 0x02, register }; // Discrete Input (FC02)
|
||||
default: return { functionCode: 0x03, register };
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 레지스터 수 계산 ─────────────────────────────
|
||||
|
||||
function getRegisterCount(dataType: string): number {
|
||||
switch (dataType) {
|
||||
case "BOOLEAN":
|
||||
case "UINT16":
|
||||
case "INT16":
|
||||
return 1;
|
||||
case "UINT32":
|
||||
case "INT32":
|
||||
case "FLOAT32":
|
||||
return 2;
|
||||
case "FLOAT64":
|
||||
return 4;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Modbus TCP 프레임 빌더 ───────────────────────
|
||||
|
||||
function buildReadRequest(transactionId: number, unitId: number, functionCode: number, startAddr: number, quantity: number): Buffer {
|
||||
const buf = Buffer.alloc(12);
|
||||
|
||||
// MBAP Header
|
||||
buf.writeUInt16BE(transactionId, 0); // Transaction ID
|
||||
buf.writeUInt16BE(0x0000, 2); // Protocol ID
|
||||
buf.writeUInt16BE(6, 4); // Length (Unit + FC + Addr + Qty)
|
||||
buf.writeUInt8(unitId, 6); // Unit ID
|
||||
|
||||
// PDU
|
||||
buf.writeUInt8(functionCode, 7); // Function Code
|
||||
buf.writeUInt16BE(startAddr, 8); // Start Address
|
||||
buf.writeUInt16BE(quantity, 10); // Quantity
|
||||
|
||||
return buf;
|
||||
}
|
||||
|
||||
// ─── 응답 파싱 ────────────────────────────────────
|
||||
|
||||
function parseReadResponse(response: Buffer, expectedTxId: number): number[] {
|
||||
if (response.length < 9) throw new Error("응답이 너무 짧음");
|
||||
|
||||
const txId = response.readUInt16BE(0);
|
||||
if (txId !== expectedTxId) throw new Error(`Transaction ID 불일치: ${txId} != ${expectedTxId}`);
|
||||
|
||||
const fc = response.readUInt8(7);
|
||||
// Error response (FC | 0x80)
|
||||
if (fc & 0x80) {
|
||||
const errorCode = response.readUInt8(8);
|
||||
throw new Error(`Modbus 에러 FC=0x${fc.toString(16)} Code=${errorCode}`);
|
||||
}
|
||||
|
||||
const byteCount = response.readUInt8(8);
|
||||
const registers: number[] = [];
|
||||
|
||||
for (let i = 0; i < byteCount / 2; i++) {
|
||||
if (9 + i * 2 + 2 <= response.length) {
|
||||
registers.push(response.readUInt16BE(9 + i * 2));
|
||||
}
|
||||
}
|
||||
|
||||
return registers;
|
||||
}
|
||||
|
||||
// ─── 데이터 타입 변환 ─────────────────────────────
|
||||
|
||||
function convertValue(registers: number[], dataType: string, byteOrder: string = "BIG_ENDIAN"): number | boolean {
|
||||
if (registers.length === 0) return 0;
|
||||
|
||||
if (dataType === "BOOLEAN") return registers[0] !== 0;
|
||||
if (dataType === "UINT16") return registers[0];
|
||||
if (dataType === "INT16") {
|
||||
const v = registers[0];
|
||||
return v >= 0x8000 ? v - 0x10000 : v;
|
||||
}
|
||||
|
||||
// 32bit: byte order 처리
|
||||
if (registers.length >= 2 && (dataType === "UINT32" || dataType === "INT32" || dataType === "FLOAT32")) {
|
||||
let r0 = registers[0], r1 = registers[1];
|
||||
|
||||
// Byte order swap
|
||||
if (byteOrder === "LITTLE_ENDIAN" || byteOrder === "LITTLE_ENDIAN_SWAP") {
|
||||
[r0, r1] = [r1, r0];
|
||||
}
|
||||
|
||||
const buf = Buffer.alloc(4);
|
||||
if (byteOrder === "BIG_ENDIAN_SWAP" || byteOrder === "LITTLE_ENDIAN_SWAP") {
|
||||
// Word swap: ABCD → CDAB
|
||||
buf.writeUInt16BE(r1, 0);
|
||||
buf.writeUInt16BE(r0, 2);
|
||||
} else {
|
||||
buf.writeUInt16BE(r0, 0);
|
||||
buf.writeUInt16BE(r1, 2);
|
||||
}
|
||||
|
||||
if (dataType === "FLOAT32") return buf.readFloatBE(0);
|
||||
if (dataType === "UINT32") return buf.readUInt32BE(0);
|
||||
if (dataType === "INT32") return buf.readInt32BE(0);
|
||||
}
|
||||
|
||||
return registers[0];
|
||||
}
|
||||
|
||||
// ─── Modbus TCP 클라이언트 ────────────────────────
|
||||
|
||||
export class ModbusClient {
|
||||
private host: string;
|
||||
private port: number;
|
||||
private unitId: number;
|
||||
private timeout: number;
|
||||
private socket: net.Socket | null = null;
|
||||
private connected = false;
|
||||
private transactionId = 0;
|
||||
|
||||
constructor(host: string, port: number = 502, unitId: number = 1, timeout: number = 3000) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.unitId = unitId;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected && this.socket) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = new net.Socket();
|
||||
this.socket.setTimeout(this.timeout);
|
||||
|
||||
this.socket.connect(this.port, this.host, () => {
|
||||
this.connected = true;
|
||||
logger.info(`[Modbus] 연결 성공: ${this.host}:${this.port} (Unit ${this.unitId})`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on("error", (err) => {
|
||||
this.connected = false;
|
||||
reject(new Error(`[Modbus] 연결 실패: ${err.message}`));
|
||||
});
|
||||
|
||||
this.socket.on("timeout", () => {
|
||||
this.socket?.destroy();
|
||||
this.connected = false;
|
||||
reject(new Error(`[Modbus] 타임아웃: ${this.timeout}ms`));
|
||||
});
|
||||
|
||||
this.socket.on("close", () => { this.connected = false; });
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean { return this.connected; }
|
||||
|
||||
private async rawRead(functionCode: number, startAddr: number, quantity: number): Promise<number[]> {
|
||||
if (!this.socket || !this.connected) throw new Error("[Modbus] 연결되지 않음");
|
||||
|
||||
const txId = this.transactionId++ & 0xffff;
|
||||
const frame = buildReadRequest(txId, this.unitId, functionCode, startAddr, quantity);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
this.socket?.removeAllListeners("data");
|
||||
reject(new Error("[Modbus] 읽기 타임아웃"));
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
chunks.push(data);
|
||||
const response = Buffer.concat(chunks);
|
||||
if (response.length >= 9 + quantity * 2) {
|
||||
clearTimeout(timeout);
|
||||
resolved = true;
|
||||
this.socket?.removeListener("data", onData);
|
||||
try {
|
||||
resolve(parseReadResponse(response, txId));
|
||||
} catch (e) { reject(e); }
|
||||
}
|
||||
};
|
||||
|
||||
this.socket!.on("data", onData);
|
||||
this.socket!.write(frame);
|
||||
});
|
||||
}
|
||||
|
||||
async readTags(tags: ModbusTagConfig[]): Promise<ModbusReadResult[]> {
|
||||
const results: ModbusReadResult[] = [];
|
||||
|
||||
for (const tag of tags) {
|
||||
const now = new Date();
|
||||
try {
|
||||
const { functionCode, register } = parseModbusAddress(tag.address);
|
||||
const count = getRegisterCount(tag.dataType);
|
||||
const registers = await this.rawRead(functionCode, register, count);
|
||||
const rawValue = registers[0] ?? 0;
|
||||
let value = convertValue(registers, tag.dataType, tag.byteOrder);
|
||||
|
||||
if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) {
|
||||
value = value * tag.scaleFactor + (tag.offsetValue ?? 0);
|
||||
}
|
||||
|
||||
results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now });
|
||||
} catch (err) {
|
||||
logger.warn(`[Modbus] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`);
|
||||
results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now });
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,392 @@
|
||||
/**
|
||||
* LS XGT FEnet Protocol Client
|
||||
* - LS Electric PLC (XGK/XGI/XGR) 통신
|
||||
* - Python data-collector의 xgt_collector.py 포팅
|
||||
*
|
||||
* 프레임 구조 (20byte header + application data):
|
||||
* [0-3] Company ID: "LSIS" (0x4C534953)
|
||||
* [4-5] Reserved
|
||||
* [6-7] PLC Info
|
||||
* [8] CPU Info: 0xA0 (XGK)
|
||||
* [9] Source: 0x33 (PC→PLC)
|
||||
* [10-11] Invoke ID
|
||||
* [12-13] Data Length (little-endian)
|
||||
* [14] Station No (0x00)
|
||||
* [15] Network No (0x00)
|
||||
* [16-17] Data Length repeated
|
||||
* [18-19] Reserved
|
||||
*
|
||||
* Command: 0x0054 = Read, 0x0058 = Write
|
||||
*/
|
||||
|
||||
import net from "net";
|
||||
import { logger } from "../../../utils/logger";
|
||||
|
||||
// ─── 타입 ──────────────────────────────────────────
|
||||
|
||||
export interface XgtReadResult {
|
||||
tagName: string;
|
||||
address: string;
|
||||
rawValue: number;
|
||||
value: number | boolean | string;
|
||||
quality: "good" | "bad";
|
||||
timestamp: Date;
|
||||
}
|
||||
|
||||
export interface XgtTagConfig {
|
||||
tagName: string;
|
||||
address: string; // 예: D100, M0, K100
|
||||
dataType: "BOOL" | "INT16" | "UINT16" | "INT32" | "UINT32" | "FLOAT32";
|
||||
bitIndex?: number; // BOOL일 때 비트 위치 (0-15)
|
||||
scaleFactor?: number;
|
||||
offsetValue?: number;
|
||||
}
|
||||
|
||||
// ─── XGT 메모리 영역 ──────────────────────────────
|
||||
|
||||
const MEMORY_TYPES: Record<string, string> = {
|
||||
P: "%PW", // Input
|
||||
M: "%MW", // Auxiliary relay
|
||||
K: "%KW", // Keep relay
|
||||
L: "%LW", // Link relay
|
||||
F: "%FW", // Special relay
|
||||
T: "%TW", // Timer
|
||||
C: "%CW", // Counter
|
||||
D: "%DW", // Data register
|
||||
R: "%RW", // Retain
|
||||
};
|
||||
|
||||
// ─── 주소 파싱 ────────────────────────────────────
|
||||
|
||||
function parseAddress(address: string): { memType: string; xgtAddress: string; offset: number } {
|
||||
// D100, M0, K100, D100.5 (bit) 등 파싱
|
||||
const match = address.match(/^([A-Z])(\d+)(?:\.(\d+))?$/);
|
||||
if (!match) throw new Error(`잘못된 XGT 주소: ${address}`);
|
||||
|
||||
const [, memLetter, numStr] = match;
|
||||
const num = parseInt(numStr, 10);
|
||||
const prefix = MEMORY_TYPES[memLetter];
|
||||
if (!prefix) throw new Error(`지원하지 않는 메모리 영역: ${memLetter}`);
|
||||
|
||||
// XGT는 %DW 뒤에 주소를 바이트 오프셋이 아닌 워드 번호로 씀
|
||||
// D100 → %DW100 (워드 100번)
|
||||
const xgtAddress = `${prefix}${String(num).padStart(5, "0")}`;
|
||||
return { memType: memLetter, xgtAddress, offset: num };
|
||||
}
|
||||
|
||||
// ─── XGT FEnet 프레임 빌더 ────────────────────────
|
||||
|
||||
function buildReadFrame(xgtAddress: string, wordCount: number, invokeId: number = 0): Buffer {
|
||||
// Application data
|
||||
const addrBytes = Buffer.from(xgtAddress, "ascii");
|
||||
const addrLen = addrBytes.length;
|
||||
|
||||
// App data: command(2) + dataType(2) + reserved(2) + blockCount(2) + addrLen(2) + addr + readCount(2)
|
||||
const appDataLen = 2 + 2 + 2 + 2 + 2 + addrLen + 2;
|
||||
const appData = Buffer.alloc(appDataLen);
|
||||
let offset = 0;
|
||||
|
||||
// Command: 0x0054 = Read request
|
||||
appData.writeUInt16LE(0x0054, offset); offset += 2;
|
||||
// Data type: 0x0014 = Word (continuous)
|
||||
appData.writeUInt16LE(0x0014, offset); offset += 2;
|
||||
// Reserved
|
||||
appData.writeUInt16LE(0x0000, offset); offset += 2;
|
||||
// Block count: 1
|
||||
appData.writeUInt16LE(0x0001, offset); offset += 2;
|
||||
// Address string length
|
||||
appData.writeUInt16LE(addrLen, offset); offset += 2;
|
||||
// Address string
|
||||
addrBytes.copy(appData, offset); offset += addrLen;
|
||||
// Read count (words)
|
||||
appData.writeUInt16LE(wordCount, offset); offset += 2;
|
||||
|
||||
// Header (20 bytes)
|
||||
const header = Buffer.alloc(20);
|
||||
// Company ID: "LSIS"
|
||||
header.write("LSIS", 0, 4, "ascii");
|
||||
// Reserved (4-5)
|
||||
header.writeUInt16LE(0x0000, 4);
|
||||
// PLC Info (6-7)
|
||||
header.writeUInt16LE(0x0000, 6);
|
||||
// CPU Info: XGK
|
||||
header.writeUInt8(0xa0, 8);
|
||||
// Source: 0x33 (PC → PLC)
|
||||
header.writeUInt8(0x33, 9);
|
||||
// Invoke ID
|
||||
header.writeUInt16LE(invokeId & 0xffff, 10);
|
||||
// Data length
|
||||
header.writeUInt16LE(appDataLen, 12);
|
||||
// Station No
|
||||
header.writeUInt8(0x00, 14);
|
||||
// Network No
|
||||
header.writeUInt8(0x00, 15);
|
||||
// Data length (repeated)
|
||||
header.writeUInt16LE(appDataLen, 16);
|
||||
// Reserved
|
||||
header.writeUInt16LE(0x0000, 18);
|
||||
|
||||
return Buffer.concat([header, appData]);
|
||||
}
|
||||
|
||||
// ─── 응답 파싱 ────────────────────────────────────
|
||||
|
||||
function parseReadResponse(response: Buffer): number[] {
|
||||
if (response.length < 20) throw new Error("응답이 너무 짧음");
|
||||
|
||||
// Header 확인
|
||||
const companyId = response.toString("ascii", 0, 4);
|
||||
if (companyId !== "LSIS") throw new Error(`잘못된 응답 헤더: ${companyId}`);
|
||||
|
||||
// Data length
|
||||
const dataLen = response.readUInt16LE(12);
|
||||
if (response.length < 20 + dataLen) throw new Error("응답 데이터 불완전");
|
||||
|
||||
// Application data 시작: offset 20
|
||||
// Response: command(2) + dataType(2) + reserved(2) + errorState(2) + blockCount(2) + dataLen(2) + data...
|
||||
const appOffset = 20;
|
||||
|
||||
// Error state 확인
|
||||
const errorState = response.readUInt16LE(appOffset + 6);
|
||||
if (errorState !== 0) throw new Error(`PLC 에러 코드: 0x${errorState.toString(16)}`);
|
||||
|
||||
// Block count
|
||||
const blockCount = response.readUInt16LE(appOffset + 8);
|
||||
if (blockCount === 0) return [];
|
||||
|
||||
// Data length (bytes)
|
||||
const wordDataLen = response.readUInt16LE(appOffset + 10);
|
||||
const wordCount = wordDataLen / 2;
|
||||
|
||||
// Word 데이터 읽기
|
||||
const words: number[] = [];
|
||||
const dataStart = appOffset + 12;
|
||||
for (let i = 0; i < wordCount; i++) {
|
||||
if (dataStart + i * 2 + 2 <= response.length) {
|
||||
words.push(response.readUInt16LE(dataStart + i * 2));
|
||||
}
|
||||
}
|
||||
|
||||
return words;
|
||||
}
|
||||
|
||||
// ─── 데이터 타입 변환 ─────────────────────────────
|
||||
|
||||
function convertValue(words: number[], dataType: string, bitIndex?: number): number | boolean {
|
||||
if (words.length === 0) return 0;
|
||||
|
||||
switch (dataType) {
|
||||
case "BOOL": {
|
||||
const bit = bitIndex ?? 0;
|
||||
return Boolean((words[0] >> bit) & 1);
|
||||
}
|
||||
case "UINT16":
|
||||
return words[0];
|
||||
case "INT16": {
|
||||
const v = words[0];
|
||||
return v >= 0x8000 ? v - 0x10000 : v;
|
||||
}
|
||||
case "UINT32": {
|
||||
if (words.length < 2) return words[0];
|
||||
return (words[1] << 16) | words[0]; // little-endian
|
||||
}
|
||||
case "INT32": {
|
||||
if (words.length < 2) return words[0];
|
||||
const v = (words[1] << 16) | words[0];
|
||||
return v >= 0x80000000 ? v - 0x100000000 : v;
|
||||
}
|
||||
case "FLOAT32": {
|
||||
if (words.length < 2) return 0;
|
||||
const buf = Buffer.alloc(4);
|
||||
buf.writeUInt16LE(words[0], 0);
|
||||
buf.writeUInt16LE(words[1], 2);
|
||||
return buf.readFloatLE(0);
|
||||
}
|
||||
default:
|
||||
return words[0];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 워드 수 계산 ─────────────────────────────────
|
||||
|
||||
function getWordCount(dataType: string): number {
|
||||
switch (dataType) {
|
||||
case "BOOL":
|
||||
case "UINT16":
|
||||
case "INT16":
|
||||
return 1;
|
||||
case "UINT32":
|
||||
case "INT32":
|
||||
case "FLOAT32":
|
||||
return 2;
|
||||
case "FLOAT64":
|
||||
return 4;
|
||||
default:
|
||||
return 1;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── XGT 클라이언트 클래스 ────────────────────────
|
||||
|
||||
export class XgtClient {
|
||||
private host: string;
|
||||
private port: number;
|
||||
private timeout: number;
|
||||
private socket: net.Socket | null = null;
|
||||
private connected = false;
|
||||
private invokeId = 0;
|
||||
|
||||
constructor(host: string, port: number = 2004, timeout: number = 3000) {
|
||||
this.host = host;
|
||||
this.port = port;
|
||||
this.timeout = timeout;
|
||||
}
|
||||
|
||||
async connect(): Promise<void> {
|
||||
if (this.connected && this.socket) return;
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
this.socket = new net.Socket();
|
||||
this.socket.setTimeout(this.timeout);
|
||||
|
||||
this.socket.connect(this.port, this.host, () => {
|
||||
this.connected = true;
|
||||
logger.info(`[XGT] PLC 연결 성공: ${this.host}:${this.port}`);
|
||||
resolve();
|
||||
});
|
||||
|
||||
this.socket.on("error", (err) => {
|
||||
this.connected = false;
|
||||
reject(new Error(`[XGT] 연결 실패: ${err.message}`));
|
||||
});
|
||||
|
||||
this.socket.on("timeout", () => {
|
||||
this.socket?.destroy();
|
||||
this.connected = false;
|
||||
reject(new Error(`[XGT] 연결 타임아웃: ${this.timeout}ms`));
|
||||
});
|
||||
|
||||
this.socket.on("close", () => {
|
||||
this.connected = false;
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
disconnect(): void {
|
||||
if (this.socket) {
|
||||
this.socket.destroy();
|
||||
this.socket = null;
|
||||
this.connected = false;
|
||||
}
|
||||
}
|
||||
|
||||
isConnected(): boolean {
|
||||
return this.connected;
|
||||
}
|
||||
|
||||
// 단일 주소 읽기
|
||||
private async rawRead(xgtAddress: string, wordCount: number): Promise<number[]> {
|
||||
if (!this.socket || !this.connected) throw new Error("[XGT] 연결되지 않음");
|
||||
|
||||
const frame = buildReadFrame(xgtAddress, wordCount, this.invokeId++);
|
||||
|
||||
return new Promise((resolve, reject) => {
|
||||
const chunks: Buffer[] = [];
|
||||
let resolved = false;
|
||||
|
||||
const timeout = setTimeout(() => {
|
||||
if (!resolved) {
|
||||
resolved = true;
|
||||
this.socket?.removeAllListeners("data");
|
||||
reject(new Error(`[XGT] 읽기 타임아웃: ${xgtAddress}`));
|
||||
}
|
||||
}, this.timeout);
|
||||
|
||||
const onData = (data: Buffer) => {
|
||||
chunks.push(data);
|
||||
const response = Buffer.concat(chunks);
|
||||
|
||||
// 헤더(20) + 최소 응답 데이터(12) 이상 받았는지 확인
|
||||
if (response.length >= 32) {
|
||||
clearTimeout(timeout);
|
||||
resolved = true;
|
||||
this.socket?.removeListener("data", onData);
|
||||
try {
|
||||
const words = parseReadResponse(response);
|
||||
resolve(words);
|
||||
} catch (e) {
|
||||
reject(e);
|
||||
}
|
||||
}
|
||||
};
|
||||
|
||||
this.socket!.on("data", onData);
|
||||
this.socket!.write(frame);
|
||||
});
|
||||
}
|
||||
|
||||
// 태그 배열 읽기 (배치 최적화)
|
||||
async readTags(tags: XgtTagConfig[]): Promise<XgtReadResult[]> {
|
||||
const results: XgtReadResult[] = [];
|
||||
|
||||
// 메모리 영역별 그루핑
|
||||
const groups = new Map<string, XgtTagConfig[]>();
|
||||
for (const tag of tags) {
|
||||
const { memType } = parseAddress(tag.address);
|
||||
const key = memType;
|
||||
if (!groups.has(key)) groups.set(key, []);
|
||||
groups.get(key)!.push(tag);
|
||||
}
|
||||
|
||||
// 그룹별 읽기
|
||||
for (const [, groupTags] of groups) {
|
||||
for (const tag of groupTags) {
|
||||
const now = new Date();
|
||||
try {
|
||||
const { xgtAddress } = parseAddress(tag.address);
|
||||
const wordCount = getWordCount(tag.dataType);
|
||||
const words = await this.rawRead(xgtAddress, wordCount);
|
||||
const rawValue = words[0] ?? 0;
|
||||
let value = convertValue(words, tag.dataType, tag.bitIndex);
|
||||
|
||||
// 스케일링 적용
|
||||
if (typeof value === "number" && tag.scaleFactor !== undefined && tag.scaleFactor !== 1) {
|
||||
value = value * tag.scaleFactor + (tag.offsetValue ?? 0);
|
||||
}
|
||||
|
||||
results.push({ tagName: tag.tagName, address: tag.address, rawValue, value, quality: "good", timestamp: now });
|
||||
} catch (err) {
|
||||
logger.warn(`[XGT] 태그 읽기 실패: ${tag.tagName} (${tag.address}) - ${(err as Error).message}`);
|
||||
results.push({ tagName: tag.tagName, address: tag.address, rawValue: 0, value: 0, quality: "bad", timestamp: now });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
// 단일 태그 읽기 (간편 API)
|
||||
async readTag(tag: XgtTagConfig): Promise<XgtReadResult> {
|
||||
const results = await this.readTags([tag]);
|
||||
return results[0];
|
||||
}
|
||||
}
|
||||
|
||||
// ─── 커넥션 풀 (같은 IP:Port 재사용) ──────────────
|
||||
|
||||
const connectionPool = new Map<string, XgtClient>();
|
||||
|
||||
export function getXgtClient(host: string, port: number = 2004, timeout: number = 3000): XgtClient {
|
||||
const key = `${host}:${port}`;
|
||||
if (!connectionPool.has(key)) {
|
||||
connectionPool.set(key, new XgtClient(host, port, timeout));
|
||||
}
|
||||
return connectionPool.get(key)!;
|
||||
}
|
||||
|
||||
export function closeAllXgtConnections(): void {
|
||||
for (const [key, client] of connectionPool) {
|
||||
client.disconnect();
|
||||
connectionPool.delete(key);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
/**
|
||||
* 자체 LLM 클라이언트
|
||||
* DB에 등록된 프로바이더 설정(API 키, 엔드포인트)을 읽어 직접 호출
|
||||
*
|
||||
* 지원 프로바이더:
|
||||
* - anthropic → Anthropic Messages API
|
||||
* - openai → OpenAI Chat Completions API
|
||||
* - google → Gemini OpenAI-compatible API
|
||||
* - deepseek → DeepSeek (OpenAI-compatible)
|
||||
* - ollama → Ollama (OpenAI-compatible, 로컬)
|
||||
*/
|
||||
import axios, { AxiosResponse } from "axios";
|
||||
import { Readable } from "stream";
|
||||
import { query } from "../database/db";
|
||||
import { AiLlmProvider } from "../types/aiAgent";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
// ── 타입 ──────────────────────────────────────────
|
||||
|
||||
export interface ChatMessage {
|
||||
role: "system" | "user" | "assistant";
|
||||
content: string;
|
||||
}
|
||||
|
||||
/** OpenAI 호환 응답 형식 (내부 표준) */
|
||||
export interface ChatCompletionResponse {
|
||||
id: string;
|
||||
object: "chat.completion";
|
||||
model: string;
|
||||
choices: Array<{
|
||||
index: number;
|
||||
message: { role: "assistant"; content: string };
|
||||
finish_reason: string;
|
||||
}>;
|
||||
usage: {
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
};
|
||||
}
|
||||
|
||||
export interface LlmRequestParams {
|
||||
model: string;
|
||||
messages: ChatMessage[];
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
stream?: boolean;
|
||||
/** 특정 프로바이더 ID를 직접 지정 (모델명 자동매칭 대신) */
|
||||
provider_id?: number;
|
||||
}
|
||||
|
||||
// ── 프로바이더별 기본 엔드포인트 ──────────────────
|
||||
|
||||
const DEFAULT_ENDPOINTS: Record<string, string> = {
|
||||
anthropic: "https://api.anthropic.com",
|
||||
openai: "https://api.openai.com",
|
||||
google: "https://generativelanguage.googleapis.com/v1beta/openai", // OpenAI-compatible
|
||||
deepseek: "https://api.deepseek.com",
|
||||
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"],
|
||||
];
|
||||
|
||||
// ── 메인 클라이언트 ──────────────────────────────
|
||||
|
||||
export class LlmClient {
|
||||
/**
|
||||
* 프로바이더를 DB에서 resolve
|
||||
* 1) provider_id 직접 지정 → 해당 프로바이더
|
||||
* 2) 모델명으로 프로바이더 이름 추론 → DB에서 해당 프로바이더 조회
|
||||
* 3) 매칭 실패 → 우선순위 가장 높은 활성 프로바이더
|
||||
*/
|
||||
static async resolveProvider(
|
||||
model: string,
|
||||
providerId?: number
|
||||
): Promise<{ provider: AiLlmProvider; apiKey: string }> {
|
||||
let provider: AiLlmProvider | undefined;
|
||||
|
||||
if (providerId) {
|
||||
const rows = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE id = $1 AND is_active = true",
|
||||
[providerId]
|
||||
);
|
||||
provider = rows[0];
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
// 모델명으로 프로바이더 이름 추론
|
||||
let providerName: string | null = null;
|
||||
for (const [re, name] of MODEL_PROVIDER_MAP) {
|
||||
if (re.test(model)) { providerName = name; break; }
|
||||
}
|
||||
|
||||
if (providerName) {
|
||||
const rows = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE name = $1 AND is_active = true ORDER BY priority LIMIT 1",
|
||||
[providerName]
|
||||
);
|
||||
provider = rows[0];
|
||||
}
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
// 폴백: 우선순위 가장 높은 활성 프로바이더
|
||||
const rows = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority LIMIT 1"
|
||||
);
|
||||
provider = rows[0];
|
||||
}
|
||||
|
||||
if (!provider) {
|
||||
throw new Error("활성화된 LLM 프로바이더가 없습니다. 관리자 설정에서 프로바이더를 등록해주세요.");
|
||||
}
|
||||
|
||||
const apiKey = EncryptUtil.decrypt(provider.api_key_encrypted);
|
||||
return { provider, apiKey };
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 완성 (비스트리밍)
|
||||
*/
|
||||
static async chatCompletion(params: LlmRequestParams): Promise<ChatCompletionResponse> {
|
||||
const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id);
|
||||
const model = params.model || provider.model_name;
|
||||
|
||||
logger.info(`[LLM] ${provider.name}/${model} 호출 (messages: ${params.messages.length})`);
|
||||
|
||||
if (provider.name === "anthropic") {
|
||||
return this.callAnthropic(provider, apiKey, model, params);
|
||||
}
|
||||
|
||||
// OpenAI / Google / DeepSeek / Ollama → 모두 OpenAI-compatible
|
||||
return this.callOpenAICompatible(provider, apiKey, model, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 채팅 완성 (스트리밍) → Readable stream 반환 (SSE 형식)
|
||||
*/
|
||||
static async chatCompletionStream(params: LlmRequestParams): Promise<Readable> {
|
||||
const { provider, apiKey } = await this.resolveProvider(params.model, params.provider_id);
|
||||
const model = params.model || provider.model_name;
|
||||
|
||||
logger.info(`[LLM] ${provider.name}/${model} 스트리밍 호출`);
|
||||
|
||||
if (provider.name === "anthropic") {
|
||||
return this.streamAnthropic(provider, apiKey, model, params);
|
||||
}
|
||||
|
||||
return this.streamOpenAICompatible(provider, apiKey, model, params);
|
||||
}
|
||||
|
||||
/**
|
||||
* 사용 가능한 모델 목록 (DB 기반)
|
||||
*/
|
||||
static async listModels(): Promise<any> {
|
||||
const providers = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
|
||||
);
|
||||
|
||||
const models = providers.map((p) => ({
|
||||
id: p.model_name,
|
||||
object: "model",
|
||||
owned_by: p.name,
|
||||
display_name: p.display_name,
|
||||
}));
|
||||
|
||||
return { object: "list", data: models };
|
||||
}
|
||||
|
||||
// ── Anthropic Messages API ──────────────────────
|
||||
|
||||
private static async callAnthropic(
|
||||
provider: AiLlmProvider,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
params: LlmRequestParams
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic;
|
||||
|
||||
// Anthropic 형식: system은 별도 파라미터
|
||||
const systemMsg = params.messages.find((m) => m.role === "system");
|
||||
const nonSystemMsgs = params.messages.filter((m) => m.role !== "system");
|
||||
|
||||
const response = await axios.post(
|
||||
`${baseUrl}/v1/messages`,
|
||||
{
|
||||
model,
|
||||
system: systemMsg?.content || undefined,
|
||||
messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })),
|
||||
max_tokens: params.max_tokens || provider.max_tokens || 4096,
|
||||
temperature: params.temperature ?? provider.temperature ?? 0.7,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
timeout: 120000,
|
||||
}
|
||||
);
|
||||
|
||||
// Anthropic → OpenAI 형식 변환
|
||||
const data = response.data;
|
||||
return {
|
||||
id: data.id || `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion",
|
||||
model: data.model || model,
|
||||
choices: [
|
||||
{
|
||||
index: 0,
|
||||
message: {
|
||||
role: "assistant",
|
||||
content: data.content?.map((c: any) => c.text).join("") || "",
|
||||
},
|
||||
finish_reason: data.stop_reason === "end_turn" ? "stop" : (data.stop_reason || "stop"),
|
||||
},
|
||||
],
|
||||
usage: {
|
||||
prompt_tokens: data.usage?.input_tokens || 0,
|
||||
completion_tokens: data.usage?.output_tokens || 0,
|
||||
total_tokens: (data.usage?.input_tokens || 0) + (data.usage?.output_tokens || 0),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
private static async streamAnthropic(
|
||||
provider: AiLlmProvider,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
params: LlmRequestParams
|
||||
): Promise<Readable> {
|
||||
const baseUrl = provider.endpoint || DEFAULT_ENDPOINTS.anthropic;
|
||||
const systemMsg = params.messages.find((m) => m.role === "system");
|
||||
const nonSystemMsgs = params.messages.filter((m) => m.role !== "system");
|
||||
|
||||
const response: AxiosResponse<Readable> = await axios.post(
|
||||
`${baseUrl}/v1/messages`,
|
||||
{
|
||||
model,
|
||||
system: systemMsg?.content || undefined,
|
||||
messages: nonSystemMsgs.map((m) => ({ role: m.role, content: m.content })),
|
||||
max_tokens: params.max_tokens || provider.max_tokens || 4096,
|
||||
temperature: params.temperature ?? provider.temperature ?? 0.7,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
"x-api-key": apiKey,
|
||||
"anthropic-version": "2023-06-01",
|
||||
},
|
||||
timeout: 120000,
|
||||
responseType: "stream",
|
||||
}
|
||||
);
|
||||
|
||||
// Anthropic SSE → OpenAI SSE 변환 스트림
|
||||
const transform = new Readable({ read() {} });
|
||||
let buffer = "";
|
||||
|
||||
response.data.on("data", (chunk: Buffer) => {
|
||||
buffer += chunk.toString();
|
||||
const lines = buffer.split("\n");
|
||||
buffer = lines.pop() || "";
|
||||
|
||||
for (const line of lines) {
|
||||
if (!line.startsWith("data: ")) continue;
|
||||
const payload = line.slice(6).trim();
|
||||
if (payload === "[DONE]") {
|
||||
transform.push("data: [DONE]\n\n");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
const event = JSON.parse(payload);
|
||||
if (event.type === "content_block_delta" && event.delta?.text) {
|
||||
const openaiChunk = {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
model,
|
||||
choices: [{ index: 0, delta: { content: event.delta.text }, finish_reason: null }],
|
||||
};
|
||||
transform.push(`data: ${JSON.stringify(openaiChunk)}\n\n`);
|
||||
} else if (event.type === "message_stop") {
|
||||
const stopChunk = {
|
||||
id: `chatcmpl-${Date.now()}`,
|
||||
object: "chat.completion.chunk",
|
||||
model,
|
||||
choices: [{ index: 0, delta: {}, finish_reason: "stop" }],
|
||||
};
|
||||
transform.push(`data: ${JSON.stringify(stopChunk)}\n\n`);
|
||||
transform.push("data: [DONE]\n\n");
|
||||
}
|
||||
} catch { /* 파싱 실패 무시 */ }
|
||||
}
|
||||
});
|
||||
|
||||
response.data.on("end", () => { transform.push(null); });
|
||||
response.data.on("error", (err: Error) => { transform.destroy(err); });
|
||||
|
||||
return transform;
|
||||
}
|
||||
|
||||
// ── OpenAI-compatible (OpenAI, Google, DeepSeek, Ollama) ──
|
||||
|
||||
private static getOpenAIBaseUrl(provider: AiLlmProvider): string {
|
||||
if (provider.endpoint) return provider.endpoint;
|
||||
return DEFAULT_ENDPOINTS[provider.name] || DEFAULT_ENDPOINTS.openai;
|
||||
}
|
||||
|
||||
private static getOpenAIChatUrl(provider: AiLlmProvider): string {
|
||||
const base = this.getOpenAIBaseUrl(provider);
|
||||
|
||||
// Google Gemini OpenAI-compatible 엔드포인트
|
||||
if (provider.name === "google") {
|
||||
return `${base}/chat/completions`;
|
||||
}
|
||||
|
||||
// Ollama, DeepSeek, OpenAI → /v1/chat/completions
|
||||
return `${base}/v1/chat/completions`;
|
||||
}
|
||||
|
||||
private static async callOpenAICompatible(
|
||||
provider: AiLlmProvider,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
params: LlmRequestParams
|
||||
): Promise<ChatCompletionResponse> {
|
||||
const url = this.getOpenAIChatUrl(provider);
|
||||
|
||||
const response = await axios.post(
|
||||
url,
|
||||
{
|
||||
model,
|
||||
messages: params.messages,
|
||||
max_tokens: params.max_tokens || provider.max_tokens || 4096,
|
||||
temperature: params.temperature ?? provider.temperature ?? 0.7,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
timeout: 120000,
|
||||
}
|
||||
);
|
||||
|
||||
return response.data;
|
||||
}
|
||||
|
||||
private static async streamOpenAICompatible(
|
||||
provider: AiLlmProvider,
|
||||
apiKey: string,
|
||||
model: string,
|
||||
params: LlmRequestParams
|
||||
): Promise<Readable> {
|
||||
const url = this.getOpenAIChatUrl(provider);
|
||||
|
||||
const response: AxiosResponse<Readable> = await axios.post(
|
||||
url,
|
||||
{
|
||||
model,
|
||||
messages: params.messages,
|
||||
max_tokens: params.max_tokens || provider.max_tokens || 4096,
|
||||
temperature: params.temperature ?? provider.temperature ?? 0.7,
|
||||
stream: true,
|
||||
},
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
Authorization: `Bearer ${apiKey}`,
|
||||
},
|
||||
timeout: 120000,
|
||||
responseType: "stream",
|
||||
}
|
||||
);
|
||||
|
||||
// OpenAI-compatible → 그대로 전달
|
||||
return response.data;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,478 @@
|
||||
import { query, queryOne } from "../database/db";
|
||||
import { AiAgentGroupService, GroupMember, ConnectorRef } from "./aiAgentGroupService";
|
||||
import { AiAgentUsageService } from "./aiAgentUsageService";
|
||||
import { AiAgentConversationService } from "./aiAgentConversationService";
|
||||
import { AiAnalysisLogService } from "./aiAnalysisLogService";
|
||||
import { LlmClient } from "./llmClient";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
interface ExecutionResult {
|
||||
memberId: number;
|
||||
roleName: string;
|
||||
agentName: string;
|
||||
modelName: string;
|
||||
executionOrder: number;
|
||||
response: string;
|
||||
tokensUsed: number;
|
||||
durationMs: number;
|
||||
connectorResults?: any[];
|
||||
}
|
||||
|
||||
interface GroupExecutionResult {
|
||||
groupId: number;
|
||||
groupName: string;
|
||||
executionMode: string;
|
||||
steps: ExecutionResult[];
|
||||
finalSummary: string;
|
||||
totalTokens: number;
|
||||
totalDurationMs: number;
|
||||
}
|
||||
|
||||
/**
|
||||
* 멀티 에이전트 실행 엔진
|
||||
* - sequential: 1→2→3 순차 실행, 이전 결과를 다음에 전달
|
||||
* - parallel: 전체 동시 실행, 결과 취합
|
||||
* - mixed: execution_order 같으면 병렬, 다르면 순차
|
||||
*/
|
||||
export class MultiAgentExecutionEngine {
|
||||
/**
|
||||
* 멀티 에이전트 그룹 실행
|
||||
*/
|
||||
static async execute(
|
||||
groupId: number,
|
||||
userMessage: string,
|
||||
options?: { userId?: string; apiKeyId?: number }
|
||||
): Promise<GroupExecutionResult> {
|
||||
const group = await AiAgentGroupService.getById(groupId);
|
||||
if (!group) throw new Error("멀티 에이전트 그룹을 찾을 수 없습니다.");
|
||||
if (!group.members || group.members.length === 0) throw new Error("그룹에 에이전트가 없습니다.");
|
||||
|
||||
const executionMode = (group as any).execution_mode || "mixed";
|
||||
const startTime = Date.now();
|
||||
let allResults: ExecutionResult[] = [];
|
||||
|
||||
logger.info(`멀티 에이전트 실행 시작: ${group.name} (${executionMode}) - "${userMessage.substring(0, 50)}..."`);
|
||||
|
||||
// 과거 분석 이력 조회 (에이전트 컨텍스트에 추가)
|
||||
let historyContext = "";
|
||||
try {
|
||||
const recentLogs = await AiAnalysisLogService.getRecentLogs(groupId, 30, 5);
|
||||
if (recentLogs.length > 0) {
|
||||
historyContext = "\n[과거 분석 이력 (최근 5건)]:\n" +
|
||||
recentLogs.map((log: any) =>
|
||||
`- ${new Date(log.created_at).toLocaleDateString("ko")}: ${log.analysis_result.substring(0, 200)}...`
|
||||
).join("\n");
|
||||
}
|
||||
const accuracy = await AiAnalysisLogService.getAverageAccuracy(groupId);
|
||||
if (accuracy > 0) {
|
||||
historyContext += `\n평균 예측 정확도: ${accuracy.toFixed(1)}%`;
|
||||
}
|
||||
} catch { /* 이력 없으면 무시 */ }
|
||||
|
||||
// 이력 컨텍스트를 메시지에 추가
|
||||
const enrichedMessage = historyContext
|
||||
? `${userMessage}\n\n${historyContext}`
|
||||
: userMessage;
|
||||
|
||||
if (executionMode === "parallel") {
|
||||
allResults = await this.executeParallel(group.members, enrichedMessage, "");
|
||||
} else if (executionMode === "sequential") {
|
||||
allResults = await this.executeSequential(group.members, enrichedMessage);
|
||||
} else {
|
||||
allResults = await this.executeMixed(group.members, enrichedMessage);
|
||||
}
|
||||
|
||||
// 최종 요약 생성
|
||||
const finalSummary = this.buildFinalSummary(allResults, userMessage);
|
||||
const totalTokens = allResults.reduce((sum, r) => sum + r.tokensUsed, 0);
|
||||
const totalDuration = Date.now() - startTime;
|
||||
|
||||
// 대화 기록 저장 (에이전트 간 대화 모니터링용)
|
||||
try {
|
||||
const conv = await AiAgentConversationService.createConversation(
|
||||
undefined,
|
||||
options?.userId,
|
||||
options?.apiKeyId
|
||||
);
|
||||
// 대화 제목 설정
|
||||
await query(
|
||||
"UPDATE ai_agent_conversations SET title = $1, metadata = $2 WHERE id = $3",
|
||||
[
|
||||
`[${group.name}] ${userMessage.substring(0, 100)}`,
|
||||
JSON.stringify({ group_id: groupId, group_name: group.name, execution_mode: executionMode }),
|
||||
conv.id,
|
||||
]
|
||||
);
|
||||
// 사용자 메시지 저장
|
||||
await AiAgentConversationService.addMessage(conv.id, "user", userMessage, 0);
|
||||
// 각 에이전트 스텝별 응답 저장
|
||||
for (const step of allResults) {
|
||||
await AiAgentConversationService.addMessage(
|
||||
conv.id,
|
||||
"assistant",
|
||||
`[${step.roleName} - ${step.agentName}]\n${step.response}`,
|
||||
step.tokensUsed,
|
||||
{ role_name: step.roleName, agent_name: step.agentName, model_name: step.modelName, execution_order: step.executionOrder, duration_ms: step.durationMs }
|
||||
);
|
||||
}
|
||||
logger.info(`멀티 에이전트 대화 저장 완료: conv_id=${conv.id}`);
|
||||
} catch (e) {
|
||||
logger.warn("멀티 에이전트 대화 저장 실패:", e);
|
||||
}
|
||||
|
||||
// 분석 이력 저장 (예측 진화용)
|
||||
await AiAnalysisLogService.save({
|
||||
group_id: groupId,
|
||||
execution_type: options?.apiKeyId ? "api" : "manual",
|
||||
input_message: userMessage,
|
||||
analysis_result: finalSummary,
|
||||
tokens_used: totalTokens,
|
||||
duration_ms: totalDuration,
|
||||
}).catch((e) => logger.warn("분석 이력 저장 실패:", e));
|
||||
|
||||
// 사용량 로깅
|
||||
await AiAgentUsageService.log({
|
||||
user_id: options?.userId,
|
||||
api_key_id: options?.apiKeyId,
|
||||
total_tokens: totalTokens,
|
||||
response_time_ms: totalDuration,
|
||||
success: true,
|
||||
request_path: `/groups/${groupId}`,
|
||||
});
|
||||
|
||||
logger.info(`멀티 에이전트 실행 완료: ${group.name} - ${totalTokens} tokens, ${totalDuration}ms`);
|
||||
|
||||
return {
|
||||
groupId: group.id,
|
||||
groupName: group.name,
|
||||
executionMode,
|
||||
steps: allResults,
|
||||
finalSummary,
|
||||
totalTokens,
|
||||
totalDurationMs: totalDuration,
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
* 순차 실행: 1→2→3, 이전 결과를 다음에 전달
|
||||
*/
|
||||
private static async executeSequential(
|
||||
members: GroupMember[],
|
||||
userMessage: string
|
||||
): Promise<ExecutionResult[]> {
|
||||
const sorted = [...members].sort((a, b) => a.execution_order - b.execution_order);
|
||||
const results: ExecutionResult[] = [];
|
||||
let previousContext = "";
|
||||
|
||||
for (const member of sorted) {
|
||||
const result = await this.executeSingleAgent(member, userMessage, previousContext);
|
||||
results.push(result);
|
||||
previousContext += `\n[${member.role_name} 결과]:\n${result.response}\n`;
|
||||
}
|
||||
|
||||
return results;
|
||||
}
|
||||
|
||||
/**
|
||||
* 병렬 실행: 동시 실행, 결과 취합
|
||||
*/
|
||||
private static async executeParallel(
|
||||
members: GroupMember[],
|
||||
userMessage: string,
|
||||
previousContext: string
|
||||
): Promise<ExecutionResult[]> {
|
||||
const promises = members.map((member) =>
|
||||
this.executeSingleAgent(member, userMessage, previousContext)
|
||||
);
|
||||
return Promise.all(promises);
|
||||
}
|
||||
|
||||
/**
|
||||
* 혼합 실행: execution_order 같으면 병렬, 다르면 순차
|
||||
*/
|
||||
private static async executeMixed(
|
||||
members: GroupMember[],
|
||||
userMessage: string
|
||||
): Promise<ExecutionResult[]> {
|
||||
// execution_order로 그룹핑
|
||||
const orderGroups = new Map<number, GroupMember[]>();
|
||||
for (const member of members) {
|
||||
const order = member.execution_order;
|
||||
if (!orderGroups.has(order)) orderGroups.set(order, []);
|
||||
orderGroups.get(order)!.push(member);
|
||||
}
|
||||
|
||||
const sortedOrders = [...orderGroups.keys()].sort((a, b) => a - b);
|
||||
const allResults: ExecutionResult[] = [];
|
||||
let previousContext = "";
|
||||
|
||||
for (const order of sortedOrders) {
|
||||
const groupMembers = orderGroups.get(order)!;
|
||||
|
||||
if (groupMembers.length === 1) {
|
||||
// 단독 → 순차
|
||||
const result = await this.executeSingleAgent(groupMembers[0], userMessage, previousContext);
|
||||
allResults.push(result);
|
||||
previousContext += `\n[${groupMembers[0].role_name} 결과]:\n${result.response}\n`;
|
||||
} else {
|
||||
// 같은 order → 병렬
|
||||
const parallelResults = await this.executeParallel(groupMembers, userMessage, previousContext);
|
||||
allResults.push(...parallelResults);
|
||||
for (const r of parallelResults) {
|
||||
previousContext += `\n[${r.roleName} 결과]:\n${r.response}\n`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return allResults;
|
||||
}
|
||||
|
||||
/**
|
||||
* 단일 에이전트 실행
|
||||
*/
|
||||
private static async executeSingleAgent(
|
||||
member: GroupMember,
|
||||
userMessage: string,
|
||||
previousContext: string
|
||||
): Promise<ExecutionResult> {
|
||||
const startTime = Date.now();
|
||||
|
||||
// 에이전트 정보 조회
|
||||
const agent = await queryOne<any>(
|
||||
"SELECT * FROM ai_agents WHERE id = $1",
|
||||
[member.agent_id]
|
||||
);
|
||||
|
||||
if (!agent) {
|
||||
return {
|
||||
memberId: member.id,
|
||||
roleName: member.role_name,
|
||||
agentName: "알 수 없음",
|
||||
modelName: "unknown",
|
||||
executionOrder: member.execution_order,
|
||||
response: "에이전트를 찾을 수 없습니다.",
|
||||
tokensUsed: 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
};
|
||||
}
|
||||
|
||||
// 커넥터로 데이터 수집 (MCP 도구 시뮬레이션)
|
||||
let connectorContext = "";
|
||||
const connectorResults: any[] = [];
|
||||
|
||||
for (const connector of (member.connectors || [])) {
|
||||
try {
|
||||
const data = await this.executeConnector(connector);
|
||||
connectorResults.push({ connector: connector.name, type: connector.type, data });
|
||||
connectorContext += `\n[데이터 소스: ${connector.name} (${connector.type})]:\n${JSON.stringify(data).substring(0, 2000)}\n`;
|
||||
} catch (e: any) {
|
||||
connectorResults.push({ connector: connector.name, type: connector.type, error: e.message });
|
||||
connectorContext += `\n[데이터 소스: ${connector.name}]: 조회 실패 - ${e.message}\n`;
|
||||
}
|
||||
}
|
||||
|
||||
// 지식 파일 주입 (커스텀 업로드 + 라이브러리 파일)
|
||||
let knowledgeContext = "";
|
||||
const knowledgeFiles = agent.config?.knowledge_files;
|
||||
if (knowledgeFiles && Array.isArray(knowledgeFiles) && knowledgeFiles.length > 0) {
|
||||
const resolvedFiles: Array<{ name: string; content: string }> = [];
|
||||
|
||||
for (const f of knowledgeFiles) {
|
||||
if (f.library_id) {
|
||||
// 라이브러리 파일: DB에서 최신 내용 조회
|
||||
const libFile = await queryOne<any>(
|
||||
"SELECT name, content FROM ai_knowledge_files WHERE id = $1",
|
||||
[f.library_id]
|
||||
);
|
||||
if (libFile) resolvedFiles.push({ name: libFile.name, content: libFile.content });
|
||||
} else if (f.content) {
|
||||
// 커스텀 업로드: 저장된 내용 그대로 사용
|
||||
resolvedFiles.push({ name: f.name, content: f.content });
|
||||
}
|
||||
}
|
||||
|
||||
if (resolvedFiles.length > 0) {
|
||||
knowledgeContext = "\n[참고 지식 문서]:\n" +
|
||||
resolvedFiles.map((f) => `--- ${f.name} ---\n${f.content.substring(0, 10000)}`).join("\n\n");
|
||||
}
|
||||
}
|
||||
|
||||
// LLM 호출
|
||||
const systemPrompt = [
|
||||
agent.system_prompt || "당신은 도움이 되는 AI 어시스턴트입니다.",
|
||||
`\n당신의 역할: ${member.role_name}`,
|
||||
knowledgeContext,
|
||||
connectorContext ? `\n사용 가능한 데이터:\n${connectorContext}` : "",
|
||||
previousContext ? `\n이전 에이전트들의 분석 결과:\n${previousContext}` : "",
|
||||
].join("");
|
||||
|
||||
try {
|
||||
const result = await LlmClient.chatCompletion({
|
||||
model: agent.model,
|
||||
messages: [
|
||||
{ role: "system", content: systemPrompt },
|
||||
{ role: "user", content: userMessage },
|
||||
],
|
||||
max_tokens: agent.config?.max_tokens || 2000,
|
||||
temperature: agent.config?.temperature || 0.7,
|
||||
});
|
||||
|
||||
const choice = result.choices?.[0];
|
||||
const usage = result.usage;
|
||||
|
||||
return {
|
||||
memberId: member.id,
|
||||
roleName: member.role_name,
|
||||
agentName: agent.name,
|
||||
modelName: agent.model,
|
||||
executionOrder: member.execution_order,
|
||||
response: choice?.message?.content || "응답 없음",
|
||||
tokensUsed: usage?.total_tokens || 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
connectorResults,
|
||||
};
|
||||
} catch (e: any) {
|
||||
const errDetail = e.response?.data
|
||||
? JSON.stringify(e.response.data, null, 2)
|
||||
: e.message;
|
||||
logger.warn(`에이전트 실행 실패 (${member.role_name}): ${errDetail}`);
|
||||
return {
|
||||
memberId: member.id,
|
||||
roleName: member.role_name,
|
||||
agentName: agent.name,
|
||||
modelName: agent.model,
|
||||
executionOrder: member.execution_order,
|
||||
response: `[실행 실패] ${errDetail}`,
|
||||
tokensUsed: 0,
|
||||
durationMs: Date.now() - startTime,
|
||||
connectorResults,
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 커넥터 실행 (DB 쿼리, REST API 호출 등)
|
||||
*/
|
||||
private static async executeConnector(connector: ConnectorRef): Promise<any> {
|
||||
if (connector.type === "database" && connector.connection_id) {
|
||||
// 외부 DB 커넥션으로 샘플 데이터 조회
|
||||
const conn = await queryOne<any>(
|
||||
"SELECT * FROM external_db_connections WHERE id = $1",
|
||||
[connector.connection_id]
|
||||
);
|
||||
if (!conn) return { error: "커넥션을 찾을 수 없습니다." };
|
||||
|
||||
// 커넥션 정보 반환 (실제 쿼리는 MCP 도구에서 수행)
|
||||
return {
|
||||
type: "database",
|
||||
name: conn.connection_name,
|
||||
db_type: conn.db_type,
|
||||
database: conn.database_name,
|
||||
status: conn.status,
|
||||
info: `${conn.db_type} 데이터베이스 (${conn.host}:${conn.port}/${conn.database_name}) 연결 가능`,
|
||||
};
|
||||
}
|
||||
|
||||
if (connector.type === "rest_api" && connector.connection_id) {
|
||||
const conn = await queryOne<any>(
|
||||
"SELECT * FROM external_rest_api_connections WHERE id = $1",
|
||||
[connector.connection_id]
|
||||
);
|
||||
if (!conn) return { error: "커넥션을 찾을 수 없습니다." };
|
||||
|
||||
return {
|
||||
type: "rest_api",
|
||||
name: conn.connection_name,
|
||||
base_url: conn.base_url,
|
||||
method: conn.method,
|
||||
info: `REST API (${conn.method} ${conn.base_url}) 호출 가능`,
|
||||
};
|
||||
}
|
||||
|
||||
if (connector.type === "file" && connector.path) {
|
||||
try {
|
||||
const fs = require("fs");
|
||||
const path = require("path");
|
||||
const filePath = path.resolve(process.cwd(), connector.path);
|
||||
|
||||
if (!fs.existsSync(filePath)) {
|
||||
return { type: "file", name: connector.name, error: "파일을 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
const ext = path.extname(filePath).toLowerCase();
|
||||
const content = fs.readFileSync(filePath, "utf8");
|
||||
|
||||
if (ext === ".csv") {
|
||||
// CSV 파싱 (처음 50행)
|
||||
const lines = content.split("\n").slice(0, 50);
|
||||
return { type: "file", name: connector.name, format: "csv", rows: lines.length, preview: lines.join("\n") };
|
||||
} else if (ext === ".json") {
|
||||
const data = JSON.parse(content);
|
||||
return { type: "file", name: connector.name, format: "json", data: JSON.stringify(data).substring(0, 3000) };
|
||||
} else {
|
||||
return { type: "file", name: connector.name, format: ext, preview: content.substring(0, 2000) };
|
||||
}
|
||||
} catch (e: any) {
|
||||
return { type: "file", name: connector.name, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (connector.type === "crawler" && connector.config_id) {
|
||||
try {
|
||||
// 기존 크롤링 서비스 활용
|
||||
const crawlConfig = await queryOne<any>(
|
||||
"SELECT * FROM crawl_configs WHERE id = $1",
|
||||
[connector.config_id]
|
||||
);
|
||||
if (!crawlConfig) return { type: "crawler", name: connector.name, error: "크롤링 설정을 찾을 수 없습니다." };
|
||||
|
||||
return {
|
||||
type: "crawler",
|
||||
name: connector.name,
|
||||
url: crawlConfig.url,
|
||||
info: `크롤링 대상: ${crawlConfig.url} (${crawlConfig.name})`,
|
||||
};
|
||||
} catch (e: any) {
|
||||
return { type: "crawler", name: connector.name, error: e.message };
|
||||
}
|
||||
}
|
||||
|
||||
if (connector.type === "plc" && connector.connection_id) {
|
||||
const conn = await queryOne<any>(
|
||||
"SELECT * FROM pipeline_device_connections WHERE id = $1",
|
||||
[connector.connection_id]
|
||||
);
|
||||
if (!conn) return { type: "plc", name: connector.name, error: "장비 연결을 찾을 수 없습니다." };
|
||||
|
||||
const tags = await query<any>(
|
||||
"SELECT tag_name, tag_display_name, tag_unit, tag_data_type, address FROM pipeline_tag_mappings WHERE connection_id = $1 AND is_active = 'Y' ORDER BY tag_name",
|
||||
[connector.connection_id]
|
||||
);
|
||||
|
||||
return {
|
||||
type: "plc",
|
||||
name: conn.connection_name,
|
||||
protocol: conn.protocol,
|
||||
host: conn.host,
|
||||
port: conn.port,
|
||||
status: conn.status,
|
||||
tags: tags.map((t: any) => ({ name: t.tag_name, displayName: t.tag_display_name, unit: t.tag_unit, dataType: t.tag_data_type, address: t.address })),
|
||||
info: `${conn.protocol} 장비 (${conn.host}:${conn.port}) - ${tags.length}개 태그 수집 중`,
|
||||
};
|
||||
} else if (connector.type === "plc") {
|
||||
return { type: "plc", name: connector.name, info: "장비 연결 ID가 지정되지 않았습니다." };
|
||||
}
|
||||
|
||||
return { type: connector.type, name: connector.name, info: "커넥터 연결 준비됨" };
|
||||
}
|
||||
|
||||
/**
|
||||
* 최종 결과 요약 생성
|
||||
*/
|
||||
private static buildFinalSummary(results: ExecutionResult[], originalQuestion: string): string {
|
||||
const parts = results.map((r) =>
|
||||
`[${r.roleName} (${r.agentName})]:\n${r.response}`
|
||||
);
|
||||
return `질문: ${originalQuestion}\n\n${parts.join("\n\n---\n\n")}`;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,141 @@
|
||||
import fs from "fs";
|
||||
import path from "path";
|
||||
import os from "os";
|
||||
import { query } from "../database/db";
|
||||
import { AiAgent, AiLlmProvider } from "../types/aiAgent";
|
||||
import { EncryptUtil } from "../utils/encryptUtil";
|
||||
import { logger } from "../utils/logger";
|
||||
|
||||
const OPENCLAW_CONFIG_PATH = process.env.OPENCLAW_CONFIG_PATH || path.join(os.homedir(), ".openclaw", "openclaw.json");
|
||||
|
||||
/**
|
||||
* Pipeline DB → OpenClaw JSON config 동기화 서비스
|
||||
* 에이전트/프로바이더 변경 시 OpenClaw config에 반영
|
||||
*/
|
||||
export class OpenClawSyncService {
|
||||
private static readConfig(): any {
|
||||
try {
|
||||
if (!fs.existsSync(OPENCLAW_CONFIG_PATH)) return {};
|
||||
const raw = fs.readFileSync(OPENCLAW_CONFIG_PATH, "utf8");
|
||||
// JSON5 호환 (주석, trailing comma 허용)
|
||||
return JSON.parse(raw.replace(/\/\/.*$/gm, "").replace(/,(\s*[}\]])/g, "$1"));
|
||||
} catch (e) {
|
||||
logger.warn("OpenClaw config 읽기 실패:", e);
|
||||
return {};
|
||||
}
|
||||
}
|
||||
|
||||
private static writeConfig(config: any): void {
|
||||
try {
|
||||
const dir = path.dirname(OPENCLAW_CONFIG_PATH);
|
||||
if (!fs.existsSync(dir)) fs.mkdirSync(dir, { recursive: true });
|
||||
fs.writeFileSync(OPENCLAW_CONFIG_PATH, JSON.stringify(config, null, 2), "utf8");
|
||||
logger.info("OpenClaw config 동기화 완료");
|
||||
} catch (e) {
|
||||
logger.error("OpenClaw config 쓰기 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 프로바이더(LLM API 키)를 OpenClaw auth profiles에 동기화
|
||||
*/
|
||||
static async syncProviders(): Promise<void> {
|
||||
try {
|
||||
const providers = await query<AiLlmProvider>(
|
||||
"SELECT * FROM ai_llm_providers WHERE is_active = true ORDER BY priority"
|
||||
);
|
||||
|
||||
const config = this.readConfig();
|
||||
|
||||
// auth profiles 구성
|
||||
const authProfiles: Record<string, any> = {};
|
||||
for (const p of providers) {
|
||||
const decryptedKey = EncryptUtil.decrypt(p.api_key_encrypted);
|
||||
const profileKey = `pipeline-${p.name}-${p.id}`;
|
||||
|
||||
if (p.name === "anthropic") {
|
||||
authProfiles[profileKey] = {
|
||||
provider: "anthropic",
|
||||
apiKey: decryptedKey,
|
||||
};
|
||||
} else if (p.name === "openai") {
|
||||
authProfiles[profileKey] = {
|
||||
provider: "openai",
|
||||
apiKey: decryptedKey,
|
||||
};
|
||||
} else if (p.name === "google") {
|
||||
authProfiles[profileKey] = {
|
||||
provider: "google",
|
||||
apiKey: decryptedKey,
|
||||
};
|
||||
} else if (p.name === "deepseek") {
|
||||
authProfiles[profileKey] = {
|
||||
provider: "openai-compat",
|
||||
apiKey: decryptedKey,
|
||||
baseUrl: p.endpoint || "https://api.deepseek.com/v1",
|
||||
};
|
||||
} else if (p.name === "ollama") {
|
||||
authProfiles[profileKey] = {
|
||||
provider: "ollama",
|
||||
baseUrl: p.endpoint || "http://localhost:11434",
|
||||
};
|
||||
}
|
||||
}
|
||||
|
||||
config.authProfiles = authProfiles;
|
||||
|
||||
// 기본 모델 설정 (우선순위가 가장 높은 프로바이더)
|
||||
if (providers.length > 0) {
|
||||
const primary = providers[0];
|
||||
const profileKey = `pipeline-${primary.name}-${primary.id}`;
|
||||
config.models = config.models || {};
|
||||
config.models.default = `${primary.name}:${primary.model_name}`;
|
||||
config.models.authProfile = profileKey;
|
||||
}
|
||||
|
||||
this.writeConfig(config);
|
||||
logger.info(`OpenClaw 프로바이더 동기화: ${providers.length}개`);
|
||||
} catch (e) {
|
||||
logger.error("OpenClaw 프로바이더 동기화 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 에이전트를 OpenClaw agents에 동기화
|
||||
*/
|
||||
static async syncAgents(): Promise<void> {
|
||||
try {
|
||||
const agents = await query<AiAgent>(
|
||||
"SELECT * FROM ai_agents WHERE status = 'active' ORDER BY name"
|
||||
);
|
||||
|
||||
const config = this.readConfig();
|
||||
const clawAgents: Record<string, any> = {};
|
||||
|
||||
for (const agent of agents) {
|
||||
clawAgents[agent.agent_id] = {
|
||||
displayName: agent.name,
|
||||
description: agent.description || "",
|
||||
model: agent.model,
|
||||
systemPrompt: agent.system_prompt || "",
|
||||
tools: agent.tools || [],
|
||||
...(agent.config || {}),
|
||||
};
|
||||
}
|
||||
|
||||
config.agents = clawAgents;
|
||||
this.writeConfig(config);
|
||||
logger.info(`OpenClaw 에이전트 동기화: ${agents.length}개`);
|
||||
} catch (e) {
|
||||
logger.error("OpenClaw 에이전트 동기화 실패:", e);
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 전체 동기화 (서버 시작 시)
|
||||
*/
|
||||
static async syncAll(): Promise<void> {
|
||||
await this.syncProviders();
|
||||
await this.syncAgents();
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,280 @@
|
||||
import { query, queryOne } from "../database/db";
|
||||
import {
|
||||
PipelineDeviceConnection,
|
||||
PipelineDeviceConnectionFilter,
|
||||
PipelineTagMapping,
|
||||
DeviceConnectionTestResult,
|
||||
} from "../types/pipelineDeviceTypes";
|
||||
import { logger } from "../utils/logger";
|
||||
import net from "net";
|
||||
|
||||
export class PipelineDeviceConnectionService {
|
||||
// ===== 연결 CRUD =====
|
||||
|
||||
static async getConnections(
|
||||
filter: PipelineDeviceConnectionFilter,
|
||||
userCompanyCode?: string
|
||||
) {
|
||||
const whereConditions: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (userCompanyCode && userCompanyCode !== "*") {
|
||||
whereConditions.push(`d.company_code = $${idx++}`);
|
||||
params.push(userCompanyCode);
|
||||
} else if (filter.company_code) {
|
||||
whereConditions.push(`d.company_code = $${idx++}`);
|
||||
params.push(filter.company_code);
|
||||
}
|
||||
|
||||
if (filter.protocol) {
|
||||
whereConditions.push(`d.protocol = $${idx++}`);
|
||||
params.push(filter.protocol);
|
||||
}
|
||||
if (filter.is_active) {
|
||||
whereConditions.push(`d.is_active = $${idx++}`);
|
||||
params.push(filter.is_active);
|
||||
}
|
||||
if (filter.status) {
|
||||
whereConditions.push(`d.status = $${idx++}`);
|
||||
params.push(filter.status);
|
||||
}
|
||||
if (filter.search?.trim()) {
|
||||
whereConditions.push(
|
||||
`(d.connection_name ILIKE $${idx} OR d.description ILIKE $${idx})`
|
||||
);
|
||||
params.push(`%${filter.search.trim()}%`);
|
||||
idx++;
|
||||
}
|
||||
|
||||
const whereClause =
|
||||
whereConditions.length > 0 ? `WHERE ${whereConditions.join(" AND ")}` : "";
|
||||
|
||||
const connections = await query<any>(
|
||||
`SELECT d.*,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count,
|
||||
COALESCE(c.company_name, d.company_code) as company_name
|
||||
FROM pipeline_device_connections d
|
||||
LEFT JOIN company_mng c ON d.company_code = c.company_code
|
||||
${whereClause}
|
||||
ORDER BY d.is_active DESC, d.connection_name ASC`,
|
||||
params
|
||||
);
|
||||
|
||||
return { success: true, data: connections };
|
||||
}
|
||||
|
||||
static async getConnectionById(id: number) {
|
||||
const conn = await queryOne<any>(
|
||||
`SELECT d.*,
|
||||
(SELECT COUNT(*) FROM pipeline_tag_mappings WHERE connection_id = d.id AND is_active = 'Y') as tag_count
|
||||
FROM pipeline_device_connections d
|
||||
WHERE d.id = $1`,
|
||||
[id]
|
||||
);
|
||||
if (!conn) return { success: false, message: "연결을 찾을 수 없습니다." };
|
||||
return { success: true, data: conn };
|
||||
}
|
||||
|
||||
static async createConnection(data: Partial<PipelineDeviceConnection>) {
|
||||
if (!data.connection_name || !data.protocol || !data.host || !data.port) {
|
||||
return { success: false, message: "필수 필드가 누락되었습니다." };
|
||||
}
|
||||
|
||||
const result = await query<PipelineDeviceConnection>(
|
||||
`INSERT INTO pipeline_device_connections
|
||||
(connection_name, description, protocol, host, port, protocol_config,
|
||||
polling_interval_ms, timeout_ms, retry_count, status, company_code, is_active, created_by)
|
||||
VALUES ($1, $2, $3, $4, $5, $6::jsonb, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
data.connection_name,
|
||||
data.description || null,
|
||||
data.protocol,
|
||||
data.host,
|
||||
data.port,
|
||||
JSON.stringify(data.protocol_config || {}),
|
||||
data.polling_interval_ms || 1000,
|
||||
data.timeout_ms || 5000,
|
||||
data.retry_count || 3,
|
||||
data.status || "active",
|
||||
data.company_code || null,
|
||||
data.is_active || "Y",
|
||||
data.created_by || null,
|
||||
]
|
||||
);
|
||||
|
||||
return { success: true, data: result[0], message: "장비 연결이 생성되었습니다." };
|
||||
}
|
||||
|
||||
static async updateConnection(id: number, data: Partial<PipelineDeviceConnection>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.connection_name !== undefined) { sets.push(`connection_name = $${idx++}`); params.push(data.connection_name); }
|
||||
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
|
||||
if (data.protocol !== undefined) { sets.push(`protocol = $${idx++}`); params.push(data.protocol); }
|
||||
if (data.host !== undefined) { sets.push(`host = $${idx++}`); params.push(data.host); }
|
||||
if (data.port !== undefined) { sets.push(`port = $${idx++}`); params.push(data.port); }
|
||||
if (data.protocol_config !== undefined) { sets.push(`protocol_config = $${idx++}::jsonb`); params.push(JSON.stringify(data.protocol_config)); }
|
||||
if (data.polling_interval_ms !== undefined) { sets.push(`polling_interval_ms = $${idx++}`); params.push(data.polling_interval_ms); }
|
||||
if (data.timeout_ms !== undefined) { sets.push(`timeout_ms = $${idx++}`); params.push(data.timeout_ms); }
|
||||
if (data.retry_count !== undefined) { sets.push(`retry_count = $${idx++}`); params.push(data.retry_count); }
|
||||
if (data.status !== undefined) { sets.push(`status = $${idx++}`); params.push(data.status); }
|
||||
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
|
||||
|
||||
if (sets.length === 0) return this.getConnectionById(id);
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(id);
|
||||
|
||||
const result = await query<PipelineDeviceConnection>(
|
||||
`UPDATE pipeline_device_connections SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!result[0]) return { success: false, message: "연결을 찾을 수 없습니다." };
|
||||
return { success: true, data: result[0], message: "장비 연결이 수정되었습니다." };
|
||||
}
|
||||
|
||||
static async deleteConnection(id: number) {
|
||||
await query("DELETE FROM pipeline_device_connections WHERE id = $1", [id]);
|
||||
return { success: true, message: "장비 연결이 삭제되었습니다." };
|
||||
}
|
||||
|
||||
static async testConnection(id: number): Promise<DeviceConnectionTestResult> {
|
||||
const connResult = await this.getConnectionById(id);
|
||||
if (!connResult.success || !connResult.data) {
|
||||
return { success: false, message: "연결을 찾을 수 없습니다." };
|
||||
}
|
||||
|
||||
const conn = connResult.data;
|
||||
const startTime = Date.now();
|
||||
|
||||
return new Promise((resolve) => {
|
||||
const socket = new net.Socket();
|
||||
const timeout = conn.timeout_ms || 5000;
|
||||
|
||||
socket.setTimeout(timeout);
|
||||
|
||||
socket.connect(conn.port, conn.host, async () => {
|
||||
const elapsed = Date.now() - startTime;
|
||||
socket.destroy();
|
||||
|
||||
await query(
|
||||
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'success', last_test_message = $1 WHERE id = $2",
|
||||
[`TCP 연결 성공 (${elapsed}ms)`, id]
|
||||
);
|
||||
|
||||
resolve({
|
||||
success: true,
|
||||
message: `${conn.protocol} 연결 성공 (${elapsed}ms)`,
|
||||
details: { response_time: elapsed, protocol: conn.protocol, host: conn.host, port: conn.port },
|
||||
});
|
||||
});
|
||||
|
||||
socket.on("error", async (err) => {
|
||||
socket.destroy();
|
||||
const msg = `연결 실패: ${err.message}`;
|
||||
|
||||
await query(
|
||||
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1, status = 'error' WHERE id = $2",
|
||||
[msg, id]
|
||||
).catch(() => {});
|
||||
|
||||
resolve({ success: false, message: msg, error: { code: (err as any).code, details: err.message } });
|
||||
});
|
||||
|
||||
socket.on("timeout", async () => {
|
||||
socket.destroy();
|
||||
const msg = `연결 타임아웃 (${timeout}ms)`;
|
||||
|
||||
await query(
|
||||
"UPDATE pipeline_device_connections SET last_test_date = NOW(), last_test_result = 'failure', last_test_message = $1 WHERE id = $2",
|
||||
[msg, id]
|
||||
).catch(() => {});
|
||||
|
||||
resolve({ success: false, message: msg, error: { code: "TIMEOUT", details: msg } });
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
// ===== 태그 매핑 CRUD =====
|
||||
|
||||
static async getTagMappings(connectionId: number) {
|
||||
const tags = await query<PipelineTagMapping>(
|
||||
"SELECT * FROM pipeline_tag_mappings WHERE connection_id = $1 ORDER BY tag_name",
|
||||
[connectionId]
|
||||
);
|
||||
return { success: true, data: tags };
|
||||
}
|
||||
|
||||
static async createTagMapping(connectionId: number, data: Partial<PipelineTagMapping>) {
|
||||
if (!data.tag_name || !data.address) {
|
||||
return { success: false, message: "태그명과 주소는 필수입니다." };
|
||||
}
|
||||
|
||||
const result = await query<PipelineTagMapping>(
|
||||
`INSERT INTO pipeline_tag_mappings
|
||||
(connection_id, tag_name, tag_display_name, tag_unit, tag_data_type, address, address_type,
|
||||
scale_factor, offset_value, min_value, max_value, description, is_active)
|
||||
VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9, $10, $11, $12, $13)
|
||||
RETURNING *`,
|
||||
[
|
||||
connectionId,
|
||||
data.tag_name,
|
||||
data.tag_display_name || null,
|
||||
data.tag_unit || null,
|
||||
data.tag_data_type || "FLOAT32",
|
||||
data.address,
|
||||
data.address_type || null,
|
||||
data.scale_factor ?? 1.0,
|
||||
data.offset_value ?? 0.0,
|
||||
data.min_value ?? null,
|
||||
data.max_value ?? null,
|
||||
data.description || null,
|
||||
data.is_active || "Y",
|
||||
]
|
||||
);
|
||||
|
||||
return { success: true, data: result[0], message: "태그 매핑이 추가되었습니다." };
|
||||
}
|
||||
|
||||
static async updateTagMapping(tagId: number, data: Partial<PipelineTagMapping>) {
|
||||
const sets: string[] = [];
|
||||
const params: any[] = [];
|
||||
let idx = 1;
|
||||
|
||||
if (data.tag_name !== undefined) { sets.push(`tag_name = $${idx++}`); params.push(data.tag_name); }
|
||||
if (data.tag_display_name !== undefined) { sets.push(`tag_display_name = $${idx++}`); params.push(data.tag_display_name); }
|
||||
if (data.tag_unit !== undefined) { sets.push(`tag_unit = $${idx++}`); params.push(data.tag_unit); }
|
||||
if (data.tag_data_type !== undefined) { sets.push(`tag_data_type = $${idx++}`); params.push(data.tag_data_type); }
|
||||
if (data.address !== undefined) { sets.push(`address = $${idx++}`); params.push(data.address); }
|
||||
if (data.address_type !== undefined) { sets.push(`address_type = $${idx++}`); params.push(data.address_type); }
|
||||
if (data.scale_factor !== undefined) { sets.push(`scale_factor = $${idx++}`); params.push(data.scale_factor); }
|
||||
if (data.offset_value !== undefined) { sets.push(`offset_value = $${idx++}`); params.push(data.offset_value); }
|
||||
if (data.min_value !== undefined) { sets.push(`min_value = $${idx++}`); params.push(data.min_value); }
|
||||
if (data.max_value !== undefined) { sets.push(`max_value = $${idx++}`); params.push(data.max_value); }
|
||||
if (data.description !== undefined) { sets.push(`description = $${idx++}`); params.push(data.description); }
|
||||
if (data.is_active !== undefined) { sets.push(`is_active = $${idx++}`); params.push(data.is_active); }
|
||||
|
||||
if (sets.length === 0) return { success: false, message: "변경할 내용이 없습니다." };
|
||||
|
||||
sets.push(`updated_at = NOW()`);
|
||||
params.push(tagId);
|
||||
|
||||
const result = await query<PipelineTagMapping>(
|
||||
`UPDATE pipeline_tag_mappings SET ${sets.join(", ")} WHERE id = $${idx} RETURNING *`,
|
||||
params
|
||||
);
|
||||
|
||||
if (!result[0]) return { success: false, message: "태그를 찾을 수 없습니다." };
|
||||
return { success: true, data: result[0], message: "태그 매핑이 수정되었습니다." };
|
||||
}
|
||||
|
||||
static async deleteTagMapping(tagId: number) {
|
||||
await query("DELETE FROM pipeline_tag_mappings WHERE id = $1", [tagId]);
|
||||
return { success: true, message: "태그 매핑이 삭제되었습니다." };
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,153 @@
|
||||
// AI 에이전트 관련 타입 정의
|
||||
|
||||
export interface AiAgent {
|
||||
id: number;
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
model: string;
|
||||
system_prompt?: string;
|
||||
tools: any[];
|
||||
config: Record<string, any>;
|
||||
status: "active" | "inactive" | "archived";
|
||||
company_code?: string;
|
||||
created_by: string;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateAgentRequest {
|
||||
agent_id: string;
|
||||
name: string;
|
||||
description?: string;
|
||||
model?: string;
|
||||
system_prompt?: string;
|
||||
tools?: any[];
|
||||
config?: Record<string, any>;
|
||||
company_code?: string;
|
||||
}
|
||||
|
||||
export interface UpdateAgentRequest {
|
||||
name?: string;
|
||||
description?: string;
|
||||
model?: string;
|
||||
system_prompt?: string;
|
||||
tools?: any[];
|
||||
config?: Record<string, any>;
|
||||
status?: "active" | "inactive" | "archived";
|
||||
}
|
||||
|
||||
export interface AiAgentApiKey {
|
||||
id: number;
|
||||
name: string;
|
||||
key_hash: string;
|
||||
key_prefix: string;
|
||||
user_id: string;
|
||||
company_code?: string;
|
||||
agent_id?: number;
|
||||
permissions: string[];
|
||||
rate_limit: number;
|
||||
monthly_token_limit: number;
|
||||
status: "active" | "revoked";
|
||||
last_used_at?: string;
|
||||
usage_count: number;
|
||||
total_tokens: number;
|
||||
expires_at?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface CreateApiKeyRequest {
|
||||
name: string;
|
||||
agent_id?: number;
|
||||
permissions?: string[];
|
||||
rate_limit?: number;
|
||||
monthly_token_limit?: number;
|
||||
expires_at?: string;
|
||||
}
|
||||
|
||||
export interface AiConversation {
|
||||
id: number;
|
||||
conversation_id: string;
|
||||
agent_id?: number;
|
||||
user_id?: string;
|
||||
api_key_id?: number;
|
||||
title?: string;
|
||||
message_count: number;
|
||||
total_tokens: number;
|
||||
status: string;
|
||||
metadata: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface AiMessage {
|
||||
id: number;
|
||||
conversation_id: number;
|
||||
role: "system" | "user" | "assistant" | "tool";
|
||||
content: string;
|
||||
tool_calls?: any;
|
||||
token_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AiUsageLog {
|
||||
id: number;
|
||||
user_id?: string;
|
||||
api_key_id?: number;
|
||||
agent_id?: number;
|
||||
conversation_id?: number;
|
||||
provider_name?: string;
|
||||
model_name?: string;
|
||||
prompt_tokens: number;
|
||||
completion_tokens: number;
|
||||
total_tokens: number;
|
||||
cost_usd: number;
|
||||
response_time_ms?: number;
|
||||
success: boolean;
|
||||
error_message?: string;
|
||||
request_path?: string;
|
||||
ip_address?: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface AiLlmProvider {
|
||||
id: number;
|
||||
name: string;
|
||||
display_name: string;
|
||||
api_key_encrypted: string;
|
||||
model_name: string;
|
||||
endpoint?: string;
|
||||
priority: number;
|
||||
max_tokens: number;
|
||||
temperature: number;
|
||||
cost_per_1k_input: number;
|
||||
cost_per_1k_output: number;
|
||||
is_active: boolean;
|
||||
config: Record<string, any>;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface CreateProviderRequest {
|
||||
name: string;
|
||||
display_name: string;
|
||||
api_key: string;
|
||||
model_name: string;
|
||||
endpoint?: string;
|
||||
priority?: number;
|
||||
max_tokens?: number;
|
||||
temperature?: number;
|
||||
cost_per_1k_input?: number;
|
||||
cost_per_1k_output?: number;
|
||||
}
|
||||
|
||||
export interface UsageSummary {
|
||||
today_tokens: number;
|
||||
today_requests: number;
|
||||
today_cost: number;
|
||||
month_tokens: number;
|
||||
month_requests: number;
|
||||
month_cost: number;
|
||||
active_agents: number;
|
||||
active_keys: number;
|
||||
}
|
||||
@@ -0,0 +1,105 @@
|
||||
// 파이프라인 장비 연결 관련 타입 정의
|
||||
|
||||
export interface PipelineDeviceConnection {
|
||||
id?: number;
|
||||
connection_name: string;
|
||||
description?: string | null;
|
||||
protocol: "PLC_ETHERNET" | "MODBUS_TCP" | "OPCUA" | "MQTT" | "REST_API";
|
||||
host: string;
|
||||
port: number;
|
||||
protocol_config?: Record<string, unknown>;
|
||||
polling_interval_ms?: number;
|
||||
timeout_ms?: number;
|
||||
retry_count?: number;
|
||||
status?: "active" | "inactive" | "error";
|
||||
company_code?: string;
|
||||
is_active?: string;
|
||||
last_test_date?: Date;
|
||||
last_test_result?: string;
|
||||
last_test_message?: string;
|
||||
created_by?: string;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
// 조인 필드
|
||||
tag_count?: number;
|
||||
company_name?: string;
|
||||
}
|
||||
|
||||
export interface PipelineTagMapping {
|
||||
id?: number;
|
||||
connection_id: number;
|
||||
tag_name: string;
|
||||
tag_display_name?: string | null;
|
||||
tag_unit?: string | null;
|
||||
tag_data_type: "INT16" | "INT32" | "FLOAT32" | "FLOAT64" | "BOOLEAN" | "STRING";
|
||||
address: string;
|
||||
address_type?: "WORD" | "DWORD" | "FLOAT" | "BIT" | "STRING" | null;
|
||||
scale_factor?: number;
|
||||
offset_value?: number;
|
||||
min_value?: number | null;
|
||||
max_value?: number | null;
|
||||
description?: string | null;
|
||||
is_active?: string;
|
||||
created_at?: Date;
|
||||
updated_at?: Date;
|
||||
}
|
||||
|
||||
export interface PipelineDeviceConnectionFilter {
|
||||
protocol?: string;
|
||||
is_active?: string;
|
||||
company_code?: string;
|
||||
search?: string;
|
||||
status?: string;
|
||||
}
|
||||
|
||||
export interface DeviceConnectionTestResult {
|
||||
success: boolean;
|
||||
message: string;
|
||||
details?: {
|
||||
response_time?: number;
|
||||
protocol?: string;
|
||||
host?: string;
|
||||
port?: number;
|
||||
};
|
||||
error?: {
|
||||
code?: string;
|
||||
details?: string;
|
||||
};
|
||||
}
|
||||
|
||||
// 프로토콜 옵션
|
||||
export const PROTOCOL_OPTIONS = [
|
||||
{ value: "PLC_ETHERNET", label: "PLC Ethernet (MC Protocol)" },
|
||||
{ value: "MODBUS_TCP", label: "Modbus TCP" },
|
||||
{ value: "OPCUA", label: "OPC-UA" },
|
||||
{ value: "MQTT", label: "MQTT" },
|
||||
{ value: "REST_API", label: "REST API" },
|
||||
];
|
||||
|
||||
// 프로토콜별 기본 포트
|
||||
export const PROTOCOL_DEFAULTS: Record<string, { port: number }> = {
|
||||
PLC_ETHERNET: { port: 5000 },
|
||||
MODBUS_TCP: { port: 502 },
|
||||
OPCUA: { port: 4840 },
|
||||
MQTT: { port: 1883 },
|
||||
REST_API: { port: 443 },
|
||||
};
|
||||
|
||||
// 태그 데이터 타입 옵션
|
||||
export const TAG_DATA_TYPE_OPTIONS = [
|
||||
{ value: "INT16", label: "INT16 (정수 16비트)" },
|
||||
{ value: "INT32", label: "INT32 (정수 32비트)" },
|
||||
{ value: "FLOAT32", label: "FLOAT32 (실수 32비트)" },
|
||||
{ value: "FLOAT64", label: "FLOAT64 (실수 64비트)" },
|
||||
{ value: "BOOLEAN", label: "BOOLEAN (불리언)" },
|
||||
{ value: "STRING", label: "STRING (문자열)" },
|
||||
];
|
||||
|
||||
// 주소 타입 옵션
|
||||
export const ADDRESS_TYPE_OPTIONS = [
|
||||
{ value: "WORD", label: "WORD" },
|
||||
{ value: "DWORD", label: "DWORD" },
|
||||
{ value: "FLOAT", label: "FLOAT" },
|
||||
{ value: "BIT", label: "BIT" },
|
||||
{ value: "STRING", label: "STRING" },
|
||||
];
|
||||
@@ -0,0 +1,107 @@
|
||||
/**
|
||||
* OpenClaw 멀티 에이전트 Gateway를 자식 프로세스로 기동
|
||||
* - backend-node 서버 기동 시 함께 띄우고, 종료 시 함께 종료
|
||||
* - OpenClaw이 설치되어 있지 않으면 스킵 (backend는 계속 동작)
|
||||
*/
|
||||
import { spawn, ChildProcess } from "child_process";
|
||||
import { logger } from "./logger";
|
||||
|
||||
const OPENCLAW_PORT = process.env.OPENCLAW_GATEWAY_PORT || "18789";
|
||||
const OPENCLAW_ENABLED = process.env.OPENCLAW_ENABLED !== "false";
|
||||
|
||||
let openClawProcess: ChildProcess | null = null;
|
||||
|
||||
/**
|
||||
* OpenClaw Gateway 기동
|
||||
*/
|
||||
export function startOpenClaw(): void {
|
||||
if (!OPENCLAW_ENABLED) {
|
||||
logger.info("⏭️ OpenClaw Gateway 비활성화 (OPENCLAW_ENABLED=false)");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
// openclaw CLI가 설치되어 있는지 확인
|
||||
const which = require("child_process").execSync("which openclaw 2>/dev/null || where openclaw 2>nul", {
|
||||
encoding: "utf8",
|
||||
timeout: 5000,
|
||||
}).trim();
|
||||
|
||||
if (!which) {
|
||||
logger.info("⏭️ OpenClaw 스킵 (설치되지 않음)");
|
||||
return;
|
||||
}
|
||||
} catch {
|
||||
// npm global로 설치 안 됐으면 npx로 시도
|
||||
logger.info("⏭️ OpenClaw CLI 미발견 → npx openclaw로 시도");
|
||||
}
|
||||
|
||||
openClawProcess = spawn("npx", ["openclaw", "gateway", "--port", OPENCLAW_PORT], {
|
||||
stdio: "pipe",
|
||||
env: {
|
||||
...process.env,
|
||||
OPENCLAW_GATEWAY_PORT: OPENCLAW_PORT,
|
||||
},
|
||||
shell: true,
|
||||
});
|
||||
|
||||
openClawProcess.stdout?.on("data", (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) logger.info(`[OpenClaw] ${msg}`);
|
||||
});
|
||||
|
||||
openClawProcess.stderr?.on("data", (data: Buffer) => {
|
||||
const msg = data.toString().trim();
|
||||
if (msg) logger.warn(`[OpenClaw] ${msg}`);
|
||||
});
|
||||
|
||||
openClawProcess.on("error", (err) => {
|
||||
logger.warn(`⚠️ OpenClaw Gateway 프로세스 에러: ${err.message}`);
|
||||
openClawProcess = null;
|
||||
});
|
||||
|
||||
openClawProcess.on("exit", (code, signal) => {
|
||||
openClawProcess = null;
|
||||
if (code != null && code !== 0) {
|
||||
logger.warn(`⚠️ OpenClaw Gateway 종료 (code=${code}, signal=${signal})`);
|
||||
}
|
||||
});
|
||||
|
||||
logger.info(`🤖 OpenClaw Gateway 기동 (포트 ${OPENCLAW_PORT})`);
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw Gateway 프로세스 종료
|
||||
*/
|
||||
export function stopOpenClaw(): void {
|
||||
if (openClawProcess && openClawProcess.kill) {
|
||||
openClawProcess.kill("SIGTERM");
|
||||
openClawProcess = null;
|
||||
logger.info("🤖 OpenClaw Gateway 프로세스 종료");
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw Gateway 상태 확인 (프로세스 또는 포트 체크)
|
||||
*/
|
||||
export async function isOpenClawRunning(): Promise<boolean> {
|
||||
if (openClawProcess !== null && !openClawProcess.killed) return true;
|
||||
// 외부에서 이미 실행 중인 경우도 체크
|
||||
try {
|
||||
const http = await import("http");
|
||||
return new Promise((resolve) => {
|
||||
const req = http.get(`http://127.0.0.1:${OPENCLAW_PORT}/healthz`, (res) => {
|
||||
resolve(res.statusCode === 200);
|
||||
});
|
||||
req.on("error", () => resolve(false));
|
||||
req.setTimeout(2000, () => { req.destroy(); resolve(false); });
|
||||
});
|
||||
} catch { return false; }
|
||||
}
|
||||
|
||||
/**
|
||||
* OpenClaw Gateway URL
|
||||
*/
|
||||
export function getOpenClawUrl(): string {
|
||||
return `http://127.0.0.1:${OPENCLAW_PORT}`;
|
||||
}
|
||||
Reference in New Issue
Block a user