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:
chpark
2026-04-20 12:14:50 +09:00
parent fdaf07896a
commit 37cac72085
479 changed files with 11173 additions and 385275 deletions
+33 -2
View File
@@ -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 데이터 미리보기
*/
+88
View File
@@ -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;
+44
View File
@@ -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
* 배치 수동 실행
+110
View File
@@ -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);
}
}
+101
View File
@@ -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);
}
}
+390
View File
@@ -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: "태그 매핑이 삭제되었습니다." };
}
}
+153
View File
@@ -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" },
];
+107
View File
@@ -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}`;
}