Merge pull request 'feature/v2-unified-renewal' (#385) from feature/v2-unified-renewal into main
Reviewed-on: http://39.117.244.52:3000/kjs/ERP-node/pulls/385
This commit is contained in:
commit
fedd75ddf5
|
|
@ -0,0 +1,8 @@
|
|||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/gbpark/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -2266,6 +2266,9 @@ export class TableManagementService {
|
|||
? `WHERE ${whereConditions.join(" AND ")}`
|
||||
: "";
|
||||
|
||||
// 안전한 테이블명 검증
|
||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
|
||||
// ORDER BY 조건 구성
|
||||
let orderClause = "";
|
||||
if (sortBy) {
|
||||
|
|
@ -2274,12 +2277,15 @@ export class TableManagementService {
|
|||
sortOrder.toLowerCase() === "desc" ? "DESC" : "ASC";
|
||||
orderClause = `ORDER BY ${safeSortBy} ${safeSortOrder}`;
|
||||
} else {
|
||||
// sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시)
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
const hasCreatedDate = await query<any>(
|
||||
`SELECT 1 FROM information_schema.columns WHERE table_name = $1 AND column_name = 'created_date' LIMIT 1`,
|
||||
[safeTableName]
|
||||
);
|
||||
if (hasCreatedDate.length > 0) {
|
||||
orderClause = `ORDER BY main.created_date DESC`;
|
||||
}
|
||||
|
||||
// 안전한 테이블명 검증
|
||||
const safeTableName = tableName.replace(/[^a-zA-Z0-9_]/g, "");
|
||||
}
|
||||
|
||||
// 전체 개수 조회 (main 별칭 추가 - buildWhereClause가 main. 접두사를 사용하므로 필요)
|
||||
const countQuery = `SELECT COUNT(*) as count FROM ${safeTableName} main ${whereClause}`;
|
||||
|
|
@ -3188,10 +3194,13 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
// ORDER BY 절 구성
|
||||
// sortBy가 없으면 created_date DESC를 기본 정렬로 사용 (신규 데이터가 최상단에 표시)
|
||||
// sortBy가 없으면 created_date 컬럼이 있는 경우에만 기본 정렬 적용
|
||||
const hasCreatedDateColumn = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `main."created_date" DESC`;
|
||||
: hasCreatedDateColumn
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
|
|
@ -3401,6 +3410,7 @@ export class TableManagementService {
|
|||
const entitySearchColumns: string[] = [];
|
||||
|
||||
// Entity 조인 쿼리 생성하여 별칭 매핑 얻기
|
||||
const hasCreatedDateForSearch = selectColumns.includes("created_date");
|
||||
const joinQueryResult = entityJoinService.buildJoinQuery(
|
||||
tableName,
|
||||
joinConfigs,
|
||||
|
|
@ -3408,7 +3418,9 @@ export class TableManagementService {
|
|||
"", // WHERE 절은 나중에 추가
|
||||
options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder || "ASC"}`
|
||||
: `main."created_date" DESC`,
|
||||
: hasCreatedDateForSearch
|
||||
? `main."created_date" DESC`
|
||||
: undefined,
|
||||
options.size,
|
||||
(options.page - 1) * options.size
|
||||
);
|
||||
|
|
@ -3594,9 +3606,12 @@ export class TableManagementService {
|
|||
}
|
||||
|
||||
const whereClause = whereConditions.join(" AND ");
|
||||
const hasCreatedDateForOrder = selectColumns.includes("created_date");
|
||||
const orderBy = options.sortBy
|
||||
? `main."${options.sortBy}" ${options.sortOrder === "desc" ? "DESC" : "ASC"}`
|
||||
: `main."created_date" DESC`;
|
||||
: hasCreatedDateForOrder
|
||||
? `main."created_date" DESC`
|
||||
: "";
|
||||
|
||||
// 페이징 계산
|
||||
const offset = (options.page - 1) * options.size;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,214 @@
|
|||
# 이미지/파일 저장 방식 가이드
|
||||
|
||||
## 개요
|
||||
|
||||
WACE 솔루션에서 이미지 및 파일은 **attach_file_info 테이블**에 메타데이터를 저장하고, 실제 파일은 **서버 디스크**에 저장하는 이중 구조를 사용합니다.
|
||||
|
||||
---
|
||||
|
||||
## 1. 데이터 흐름
|
||||
|
||||
```
|
||||
[사용자 업로드] → [백엔드 API] → [디스크 저장] + [DB 메타데이터 저장]
|
||||
↓ ↓
|
||||
/uploads/COMPANY_7/ attach_file_info 테이블
|
||||
2026/02/06/ (objid, file_path, ...)
|
||||
1770346704685_5.png
|
||||
```
|
||||
|
||||
### 저장 과정
|
||||
|
||||
1. 사용자가 파일 업로드 → `POST /api/files/upload`
|
||||
2. 백엔드가 파일을 디스크에 저장: `/uploads/{company_code}/{YYYY}/{MM}/{DD}/{timestamp}_{filename}`
|
||||
3. `attach_file_info` 테이블에 메타데이터 INSERT (objid, file_path, target_objid 등)
|
||||
4. 비즈니스 테이블의 이미지 컬럼에 **파일 objid** 저장 (예: `item_info.image = '433765011963536400'`)
|
||||
|
||||
### 조회 과정
|
||||
|
||||
1. 비즈니스 테이블에서 이미지 컬럼 값(objid) 로드
|
||||
2. `GET /api/files/preview/{objid}` 로 이미지 프리뷰 요청
|
||||
3. 백엔드가 `attach_file_info`에서 objid로 파일 정보 조회
|
||||
4. 디스크에서 실제 파일을 읽어 응답
|
||||
|
||||
---
|
||||
|
||||
## 2. 테이블 구조
|
||||
|
||||
### attach_file_info (파일 메타데이터)
|
||||
|
||||
| 컬럼 | 타입 | 설명 |
|
||||
|------|------|------|
|
||||
| objid | numeric | 파일 고유 ID (PK, 큰 숫자) |
|
||||
| real_file_name | varchar | 원본 파일명 |
|
||||
| saved_file_name | varchar | 저장된 파일명 (timestamp_원본명) |
|
||||
| file_path | varchar | 저장 경로 (/uploads/COMPANY_7/2026/02/06/...) |
|
||||
| file_ext | varchar | 파일 확장자 |
|
||||
| file_size | numeric | 파일 크기 (bytes) |
|
||||
| target_objid | varchar | 연결 대상 (아래 패턴 참조) |
|
||||
| company_code | varchar | 회사 코드 (멀티테넌시) |
|
||||
| status | varchar | 상태 (ACTIVE, DELETED) |
|
||||
| writer | varchar | 업로더 ID |
|
||||
| regdate | timestamp | 등록일시 |
|
||||
| is_representative | boolean | 대표 이미지 여부 |
|
||||
|
||||
### 비즈니스 테이블 (예: item_info, company_mng)
|
||||
|
||||
이미지 컬럼에 `attach_file_info.objid` 값을 문자열로 저장합니다.
|
||||
|
||||
```sql
|
||||
-- item_info.image = '433765011963536400'
|
||||
-- company_mng.company_image = '413276787660035200'
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 3. target_objid 패턴
|
||||
|
||||
`attach_file_info.target_objid`는 파일이 어디에 연결되어 있는지를 나타냅니다.
|
||||
|
||||
| 패턴 | 예시 | 설명 |
|
||||
|------|------|------|
|
||||
| 템플릿 모드 | `screen_files:140:comp_z4yffowb:image` | 화면 설계 시 업로드 (screenId:componentId:columnName) |
|
||||
| 레코드 모드 | `item_info:uuid-xxx:image` | 특정 레코드에 연결 (tableName:recordId:columnName) |
|
||||
|
||||
---
|
||||
|
||||
## 4. 파일 조회 API
|
||||
|
||||
### GET /api/files/preview/{objid}
|
||||
|
||||
이미지 프리뷰 (공개 접근 허용).
|
||||
|
||||
```
|
||||
GET /api/files/preview/433765011963536400
|
||||
→ 200 OK (이미지 바이너리)
|
||||
```
|
||||
|
||||
**주의: objid를 parseInt()로 변환하면 안 됩니다.** JavaScript의 `Number.MAX_SAFE_INTEGER`(9007199254740991)를 초과하는 큰 숫자이므로 **정밀도 손실**이 발생합니다. 반드시 **문자열**로 전달해야 합니다.
|
||||
|
||||
```typescript
|
||||
// 잘못된 방법
|
||||
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [parseInt(objid)]);
|
||||
// → parseInt("433765011963536400") = 433765011963536416 (16 차이!)
|
||||
// → DB에서 찾을 수 없음 → 404
|
||||
|
||||
// 올바른 방법
|
||||
const fileRecord = await query("SELECT * FROM attach_file_info WHERE objid = $1", [objid]);
|
||||
// → PostgreSQL이 문자열 → numeric 자동 캐스팅
|
||||
```
|
||||
|
||||
### GET /api/files/component-files
|
||||
|
||||
컴포넌트별 파일 목록 조회 (인증 필요).
|
||||
|
||||
```
|
||||
GET /api/files/component-files?screenId=149&componentId=comp_z4yffowb&tableName=item_info&recordId=uuid-xxx&columnName=image
|
||||
```
|
||||
|
||||
**조회 우선순위:**
|
||||
1. **데이터 파일**: `target_objid = '{tableName}:{recordId}:{columnName}'` 패턴으로 조회
|
||||
2. **템플릿 파일**: `target_objid = 'screen_files:{screenId}:{componentId}:{columnName}'` 패턴으로 조회
|
||||
3. **레코드 컬럼 값 조회 (fallback)**: 위 두 방법으로 파일을 찾지 못하면, 비즈니스 테이블의 레코드에서 해당 컬럼 값(파일 objid)을 읽어 직접 조회
|
||||
|
||||
```sql
|
||||
-- fallback: 레코드의 image 컬럼에 저장된 objid로 직접 조회
|
||||
SELECT "image" FROM "item_info" WHERE id = $1;
|
||||
-- → '433765011963536400'
|
||||
SELECT * FROM attach_file_info WHERE objid = '433765011963536400' AND status = 'ACTIVE';
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 5. 프론트엔드 컴포넌트
|
||||
|
||||
### v2-file-upload (FileUploadComponent.tsx)
|
||||
|
||||
현재 사용되는 V2 파일 업로드 컴포넌트입니다.
|
||||
|
||||
**파일 경로**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
|
||||
|
||||
#### 이미지 로드 방식
|
||||
|
||||
1. **formData의 컬럼 값으로 로드**: `formData[columnName]`에 파일 objid가 있으면 `/api/files/preview/{objid}`로 이미지 표시
|
||||
2. **getComponentFiles API로 로드**: target_objid 패턴으로 서버에서 파일 목록 조회
|
||||
|
||||
#### 상태 관리
|
||||
|
||||
- `uploadedFiles` state: 현재 표시 중인 파일 목록
|
||||
- `localStorage` 백업: `fileUpload_{componentId}_{columnName}` 키로 저장
|
||||
- `window.globalFileState`: 전역 파일 상태 (컴포넌트 간 동기화)
|
||||
|
||||
#### 등록/수정 모드 구분
|
||||
|
||||
- **수정 모드** (isRecordMode=true, recordId 있음): localStorage/서버에서 기존 파일 복원
|
||||
- **등록 모드** (isRecordMode=false, recordId 없음): localStorage 복원 스킵, 빈 상태로 시작
|
||||
- **단일 폼 화면** (회사정보 등): `formData[columnName]`의 objid 값으로 이미지 자동 로드
|
||||
|
||||
### file-upload (레거시)
|
||||
|
||||
**파일 경로**: `frontend/lib/registry/components/file-upload/FileUploadComponent.tsx`
|
||||
|
||||
V2MediaRenderer에서 사용하는 레거시 컴포넌트. v2-file-upload와 유사하지만 별도 파일입니다.
|
||||
|
||||
### ImageWidget
|
||||
|
||||
**파일 경로**: `frontend/components/screen/widgets/types/ImageWidget.tsx`
|
||||
|
||||
단순 이미지 표시용 위젯. 파일 업로드 기능은 있으나, `getFullImageUrl()`로 URL을 변환하여 `<img>` 태그로 직접 표시합니다. 파일 관리(목록, 삭제 등) 기능은 없습니다.
|
||||
|
||||
---
|
||||
|
||||
## 6. 디스크 저장 구조
|
||||
|
||||
```
|
||||
backend-node/uploads/
|
||||
├── COMPANY_7/ # 회사별 격리
|
||||
│ ├── 2026/
|
||||
│ │ ├── 01/
|
||||
│ │ │ └── 08/
|
||||
│ │ │ └── 1767863580718_img.jpg
|
||||
│ │ └── 02/
|
||||
│ │ └── 06/
|
||||
│ │ ├── 1770346704685_5.png
|
||||
│ │ └── 1770352493105_5.png
|
||||
├── COMPANY_9/
|
||||
│ └── ...
|
||||
└── company_*/ # 최고 관리자 전용
|
||||
└── ...
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 7. 수정 이력 (2026-02-06)
|
||||
|
||||
### parseInt 정밀도 손실 수정
|
||||
|
||||
**파일**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
`attach_file_info.objid`는 `numeric` 타입으로 `433765011963536400` 같은 매우 큰 숫자입니다. JavaScript의 `parseInt()`는 `Number.MAX_SAFE_INTEGER`(약 9 * 10^15)를 초과하면 정밀도 손실이 발생합니다.
|
||||
|
||||
| objid (원본) | parseInt 결과 | 차이 |
|
||||
|:---|:---|:---:|
|
||||
| 396361999644927100 | 396361999644927104 | -4 |
|
||||
| 433765011963536400 | 433765011963536384 | +16 |
|
||||
| 1128460590844245000 | 1128460590844244992 | +8 |
|
||||
|
||||
**수정**: `parseInt(objid)` → `objid` (문자열 직접 전달, 8곳)
|
||||
|
||||
### getComponentFiles fallback 추가
|
||||
|
||||
**파일**: `backend-node/src/controllers/fileController.ts`
|
||||
|
||||
수정 모달에서 이미지가 안 보이는 문제. `target_objid` 패턴이 일치하지 않을 때, 비즈니스 테이블의 레코드 컬럼 값으로 파일을 직접 조회하는 fallback 로직 추가.
|
||||
|
||||
### v2-file-upload 등록 모드 파일 잔존 방지
|
||||
|
||||
**파일**: `frontend/lib/registry/components/v2-file-upload/FileUploadComponent.tsx`
|
||||
|
||||
연속 등록 시 이전 등록의 이미지가 남아있는 문제. `loadComponentFiles`와 fallback 로직에서 등록 모드(recordId 없음)일 때 파일 복원을 스킵하도록 수정.
|
||||
|
||||
### ORDER BY 기본 정렬 추가
|
||||
|
||||
**파일**: `backend-node/src/services/tableManagementService.ts`
|
||||
|
||||
`sortBy` 파라미터가 없을 때 `ORDER BY created_date DESC`를 기본값으로 적용. 4곳 수정.
|
||||
|
|
@ -0,0 +1,989 @@
|
|||
# Multi-Agent 협업 시스템 설계서
|
||||
|
||||
> Cursor 에이전트 간 협업을 통한 효율적인 개발 시스템
|
||||
|
||||
## 목차
|
||||
|
||||
1. [개요](#개요)
|
||||
2. [아키텍처](#아키텍처)
|
||||
3. [에이전트 역할 정의](#에이전트-역할-정의)
|
||||
4. [통신 프로토콜](#통신-프로토콜)
|
||||
5. [워크플로우](#워크플로우)
|
||||
6. [프롬프트 템플릿](#프롬프트-템플릿)
|
||||
7. [MCP 서버 구현](#mcp-서버-구현)
|
||||
8. [비용 분석](#비용-분석)
|
||||
9. [한계점 및 해결방안](#한계점-및-해결방안)
|
||||
|
||||
---
|
||||
|
||||
## 개요
|
||||
|
||||
### 문제점: 단일 에이전트의 한계
|
||||
|
||||
```
|
||||
단일 에이전트 문제:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ • 컨텍스트 폭발 (50k+ 토큰 → 까먹음) │
|
||||
│ • 전문성 분산 (모든 영역 얕게 앎) │
|
||||
│ • 재작업 빈번 (실수, 누락) │
|
||||
│ • 검증 부재 (크로스체크 없음) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 해결책: Multi-Agent 협업
|
||||
|
||||
```
|
||||
멀티 에이전트 장점:
|
||||
┌─────────────────────────────────────────┐
|
||||
│ • 컨텍스트 분리 (각자 작은 컨텍스트) │
|
||||
│ • 전문성 집중 (영역별 깊은 이해) │
|
||||
│ • 크로스체크 (서로 검증) │
|
||||
│ • 병렬 처리 (동시 작업) │
|
||||
└─────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### 모델 티어링 전략
|
||||
|
||||
| 에이전트 | 모델 | 역할 | 비용 |
|
||||
|----------|------|------|------|
|
||||
| Agent A (PM) | Claude Opus 4.5 | 분석, 계획, 조율 | 높음 |
|
||||
| Agent B (Backend) | Claude Sonnet | 백엔드 구현 | 낮음 |
|
||||
| Agent C (DB) | Claude Sonnet | DB/쿼리 담당 | 낮음 |
|
||||
| Agent D (Frontend) | Claude Sonnet | 프론트 구현 | 낮음 |
|
||||
|
||||
**예상 비용 절감: 50-60%**
|
||||
|
||||
---
|
||||
|
||||
## 아키텍처
|
||||
|
||||
### 전체 구조
|
||||
|
||||
```
|
||||
┌─────────────┐
|
||||
│ USER │
|
||||
└──────┬──────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ Agent A (PM) │
|
||||
│ Claude Opus 4.5 │
|
||||
│ │
|
||||
│ • 사용자 의도 파악 │
|
||||
│ • 작업 분배 │
|
||||
│ • 결과 통합 │
|
||||
│ • 품질 검증 │
|
||||
└───────────┬───────────┘
|
||||
│
|
||||
┌─────────────────┼─────────────────┐
|
||||
│ │ │
|
||||
▼ ▼ ▼
|
||||
┌─────────────────┐ ┌─────────────────┐ ┌─────────────────┐
|
||||
│ Agent B │ │ Agent C │ │ Agent D │
|
||||
│ (Backend) │ │ (Database) │ │ (Frontend) │
|
||||
│ Sonnet │ │ Sonnet │ │ Sonnet │
|
||||
│ │ │ │ │ │
|
||||
│ • API 설계/구현 │ │ • 스키마 설계 │ │ • 컴포넌트 구현 │
|
||||
│ • 서비스 로직 │ │ • 쿼리 작성 │ │ • 페이지 구현 │
|
||||
│ • 라우팅 │ │ • 마이그레이션 │ │ • 스타일링 │
|
||||
└─────────────────┘ └─────────────────┘ └─────────────────┘
|
||||
│ │ │
|
||||
└─────────────────┴─────────────────┘
|
||||
│
|
||||
▼
|
||||
┌───────────────────────┐
|
||||
│ MCP Orchestrator │
|
||||
│ │
|
||||
│ • 메시지 라우팅 │
|
||||
│ • 병렬 실행 │
|
||||
│ • 결과 수집 │
|
||||
└───────────────────────┘
|
||||
```
|
||||
|
||||
### 폴더별 담당 영역
|
||||
|
||||
| 에이전트 | 담당 폴더 | 파일 유형 |
|
||||
|----------|-----------|-----------|
|
||||
| Agent B (Backend) | `backend-node/src/` | `.ts`, `.js` |
|
||||
| Agent C (DB) | `src/com/pms/mapper/`, `db/` | `.xml`, `.sql` |
|
||||
| Agent D (Frontend) | `frontend/` | `.tsx`, `.ts`, `.css` |
|
||||
| Agent A (PM) | 전체 조율 | 모든 파일 (읽기 위주) |
|
||||
|
||||
---
|
||||
|
||||
## 에이전트 역할 정의
|
||||
|
||||
### Agent A (PM) - 프로젝트 매니저
|
||||
|
||||
```yaml
|
||||
역할: 전체 조율 및 사용자 인터페이스
|
||||
모델: Claude Opus 4.5
|
||||
|
||||
핵심 책임:
|
||||
의도 파악:
|
||||
- 사용자 요청 분석
|
||||
- 모호한 요청 명확화
|
||||
- 숨겨진 요구사항 발굴
|
||||
|
||||
작업 분배:
|
||||
- 작업을 세부 태스크로 분해
|
||||
- 적절한 에이전트에게 할당
|
||||
- 우선순위 및 의존성 결정
|
||||
|
||||
품질 관리:
|
||||
- 결과물 검증
|
||||
- 일관성 체크
|
||||
- 충돌 해결
|
||||
|
||||
통합:
|
||||
- 개별 결과물 취합
|
||||
- 최종 결과 생성
|
||||
- 사용자에게 보고
|
||||
|
||||
하지 않는 것:
|
||||
- 직접 코드 구현 (전문가에게 위임)
|
||||
- 특정 영역 깊이 분석 (전문가에게 요청)
|
||||
```
|
||||
|
||||
### Agent B (Backend) - 백엔드 전문가
|
||||
|
||||
```yaml
|
||||
역할: API 및 서버 로직 담당
|
||||
모델: Claude Sonnet
|
||||
|
||||
담당 영역:
|
||||
폴더:
|
||||
- backend-node/src/controllers/
|
||||
- backend-node/src/services/
|
||||
- backend-node/src/routes/
|
||||
- backend-node/src/middleware/
|
||||
- backend-node/src/utils/
|
||||
|
||||
작업:
|
||||
- REST API 엔드포인트 설계/구현
|
||||
- 비즈니스 로직 구현
|
||||
- 미들웨어 작성
|
||||
- 에러 핸들링
|
||||
- 인증/인가 로직
|
||||
|
||||
담당 아닌 것:
|
||||
- frontend/ 폴더 (Agent D 담당)
|
||||
- SQL 쿼리 직접 작성 (Agent C에게 요청)
|
||||
- DB 스키마 변경 (Agent C 담당)
|
||||
|
||||
협업 필요 시:
|
||||
- DB 쿼리 필요 → Agent C에게 요청
|
||||
- 프론트 연동 문제 → Agent D와 협의
|
||||
```
|
||||
|
||||
### Agent C (Database) - DB 전문가
|
||||
|
||||
```yaml
|
||||
역할: 데이터베이스 및 쿼리 담당
|
||||
모델: Claude Sonnet
|
||||
|
||||
담당 영역:
|
||||
폴더:
|
||||
- src/com/pms/mapper/
|
||||
- db/
|
||||
- backend-node/src/database/
|
||||
|
||||
작업:
|
||||
- 테이블 스키마 설계
|
||||
- MyBatis 매퍼 XML 작성
|
||||
- SQL 쿼리 최적화
|
||||
- 인덱스 설계
|
||||
- 마이그레이션 스크립트
|
||||
|
||||
담당 아닌 것:
|
||||
- API 로직 (Agent B 담당)
|
||||
- 프론트엔드 (Agent D 담당)
|
||||
- 비즈니스 로직 판단 (Agent A에게 확인)
|
||||
|
||||
협업 필요 시:
|
||||
- API에서 필요한 데이터 구조 → Agent B와 협의
|
||||
- 쿼리 결과 사용법 → Agent B에게 전달
|
||||
```
|
||||
|
||||
### Agent D (Frontend) - 프론트엔드 전문가
|
||||
|
||||
```yaml
|
||||
역할: UI/UX 및 클라이언트 로직 담당
|
||||
모델: Claude Sonnet
|
||||
|
||||
담당 영역:
|
||||
폴더:
|
||||
- frontend/components/
|
||||
- frontend/pages/
|
||||
- frontend/lib/
|
||||
- frontend/hooks/
|
||||
- frontend/styles/
|
||||
|
||||
작업:
|
||||
- React 컴포넌트 구현
|
||||
- 페이지 레이아웃
|
||||
- 상태 관리
|
||||
- API 연동 (호출)
|
||||
- 스타일링
|
||||
|
||||
담당 아닌 것:
|
||||
- API 구현 (Agent B 담당)
|
||||
- DB 쿼리 (Agent C 담당)
|
||||
- API 스펙 결정 (Agent A/B와 협의)
|
||||
|
||||
협업 필요 시:
|
||||
- API 엔드포인트 필요 → Agent B에게 요청
|
||||
- 데이터 구조 확인 → Agent C에게 문의
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 통신 프로토콜
|
||||
|
||||
### 메시지 포맷
|
||||
|
||||
```typescript
|
||||
// 요청 메시지
|
||||
interface TaskRequest {
|
||||
id: string; // 고유 ID (예: "task-001")
|
||||
from: 'A' | 'B' | 'C' | 'D'; // 발신자
|
||||
to: 'A' | 'B' | 'C' | 'D'; // 수신자
|
||||
type: 'info_request' | 'work_request' | 'question';
|
||||
priority: 'high' | 'medium' | 'low';
|
||||
content: {
|
||||
task: string; // 작업 내용
|
||||
context?: string; // 배경 정보
|
||||
expected_output?: string; // 기대 결과
|
||||
depends_on?: string[]; // 선행 작업 ID
|
||||
};
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 응답 메시지
|
||||
interface TaskResponse {
|
||||
id: string; // 요청 ID와 매칭
|
||||
from: 'A' | 'B' | 'C' | 'D';
|
||||
to: 'A' | 'B' | 'C' | 'D';
|
||||
status: 'success' | 'partial' | 'failed' | 'need_clarification';
|
||||
confidence: 'high' | 'medium' | 'low';
|
||||
|
||||
result?: {
|
||||
summary: string; // 한 줄 요약
|
||||
details: string; // 상세 내용
|
||||
files_affected?: string[]; // 영향받는 파일
|
||||
code_changes?: CodeChange[]; // 코드 변경사항
|
||||
};
|
||||
|
||||
// 메타 정보
|
||||
scope_violations?: string[]; // 스코프 벗어난 요청
|
||||
dependencies?: string[]; // 필요한 선행 작업
|
||||
side_effects?: string[]; // 부작용
|
||||
alternatives?: string[]; // 대안
|
||||
|
||||
// 추가 요청
|
||||
questions?: string[]; // 명확화 필요
|
||||
needs_from_others?: {
|
||||
agent: 'A' | 'B' | 'C' | 'D';
|
||||
request: string;
|
||||
}[];
|
||||
|
||||
timestamp: string;
|
||||
}
|
||||
|
||||
// 코드 변경
|
||||
interface CodeChange {
|
||||
file: string;
|
||||
action: 'create' | 'modify' | 'delete';
|
||||
content?: string; // 전체 코드 또는 diff
|
||||
line_start?: number;
|
||||
line_end?: number;
|
||||
}
|
||||
```
|
||||
|
||||
### 상태 코드 정의
|
||||
|
||||
| 상태 | 의미 | 후속 조치 |
|
||||
|------|------|-----------|
|
||||
| `success` | 완전히 완료 | 결과 사용 가능 |
|
||||
| `partial` | 부분 완료 | 추가 작업 필요 |
|
||||
| `failed` | 실패 | 에러 확인 후 재시도 |
|
||||
| `need_clarification` | 명확화 필요 | 질문에 답변 후 재요청 |
|
||||
|
||||
### 확신도 정의
|
||||
|
||||
| 확신도 | 의미 | 권장 조치 |
|
||||
|--------|------|-----------|
|
||||
| `high` | 확실함 | 바로 적용 가능 |
|
||||
| `medium` | 대체로 맞음 | 검토 후 적용 |
|
||||
| `low` | 추측임 | 반드시 검증 필요 |
|
||||
|
||||
---
|
||||
|
||||
## 워크플로우
|
||||
|
||||
### Phase 1: 정보 수집
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 1: 정보 수집 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. User → Agent A: "주문 관리 기능 만들어줘" │
|
||||
│ │
|
||||
│ 2. Agent A 분석: │
|
||||
│ - 기능 범위 파악 │
|
||||
│ - 필요한 정보 식별 │
|
||||
│ - 정보 수집 요청 생성 │
|
||||
│ │
|
||||
│ 3. Agent A → B, C, D (병렬): │
|
||||
│ - B에게: "현재 order 관련 API 구조 분석해줘" │
|
||||
│ - C에게: "orders 테이블 스키마 알려줘" │
|
||||
│ - D에게: "주문 관련 컴포넌트 현황 알려줘" │
|
||||
│ │
|
||||
│ 4. B, C, D → Agent A (응답): │
|
||||
│ - B: API 현황 보고 │
|
||||
│ - C: 스키마 정보 보고 │
|
||||
│ - D: 컴포넌트 현황 보고 │
|
||||
│ │
|
||||
│ 5. Agent A: 정보 취합 및 계획 수립 │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Phase 2: 작업 분배
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 2: 작업 분배 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. Agent A: 종합 계획 수립 │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 분석 결과: │ │
|
||||
│ │ - API에 pagination 추가 필요 │ │
|
||||
│ │ - DB는 현재 구조 유지 │ │
|
||||
│ │ - 프론트 무한스크롤 → 페이지네이션 │ │
|
||||
│ │ │ │
|
||||
│ │ 작업 순서: │ │
|
||||
│ │ 1. C: 페이징 쿼리 준비 │ │
|
||||
│ │ 2. B: API 수정 (C 결과 의존) │ │
|
||||
│ │ 3. D: 프론트 수정 (B 결과 의존) │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
│ 2. Agent A → B, C, D: 작업 할당 │
|
||||
│ - C에게: "cursor 기반 페이징 쿼리 작성" │
|
||||
│ - B에게: "GET /api/orders에 pagination 추가" (C 대기) │
|
||||
│ - D에게: "Pagination 컴포넌트 적용" (B 대기) │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
### Phase 3: 실행 및 통합
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────────┐
|
||||
│ Phase 3: 실행 및 통합 │
|
||||
├─────────────────────────────────────────────────────────────┤
|
||||
│ │
|
||||
│ 1. 순차/병렬 실행: │
|
||||
│ - C: 쿼리 작성 → 완료 보고 │
|
||||
│ - B: API 수정 (C 완료 후) → 완료 보고 │
|
||||
│ - D: 프론트 수정 (B 완료 후) → 완료 보고 │
|
||||
│ │
|
||||
│ 2. Agent A: 결과 검증 │
|
||||
│ - 일관성 체크 │
|
||||
│ - 누락 확인 │
|
||||
│ - 충돌 해결 │
|
||||
│ │
|
||||
│ 3. Agent A → User: 최종 보고 │
|
||||
│ ┌─────────────────────────────────────────┐ │
|
||||
│ │ 완료된 작업: │ │
|
||||
│ │ ✅ orders.xml - 페이징 쿼리 추가 │ │
|
||||
│ │ ✅ OrderController.ts - pagination 적용 │ │
|
||||
│ │ ✅ OrderListPage.tsx - UI 수정 │ │
|
||||
│ │ │ │
|
||||
│ │ 테스트 필요: │ │
|
||||
│ │ - GET /api/orders?page=1&limit=10 │ │
|
||||
│ └─────────────────────────────────────────┘ │
|
||||
│ │
|
||||
└─────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 프롬프트 템플릿
|
||||
|
||||
### Agent A (PM) 시스템 프롬프트
|
||||
|
||||
```markdown
|
||||
# 역할
|
||||
너는 PM(Project Manager) 에이전트야.
|
||||
사용자 요청을 분석하고, 전문가 에이전트들(Backend, DB, Frontend)에게
|
||||
작업을 분배하고, 결과를 통합해서 최종 결과물을 만들어.
|
||||
|
||||
# 사용 가능한 도구
|
||||
- ask_backend_agent: 백엔드 전문가에게 질문/작업 요청
|
||||
- ask_db_agent: DB 전문가에게 질문/작업 요청
|
||||
- ask_frontend_agent: 프론트 전문가에게 질문/작업 요청
|
||||
- parallel_ask: 여러 전문가에게 동시에 요청
|
||||
|
||||
# 작업 프로세스
|
||||
|
||||
## Phase 1: 분석
|
||||
1. 사용자 요청 분석
|
||||
2. 필요한 정보 식별
|
||||
3. 정보 수집 요청 (parallel_ask 활용)
|
||||
|
||||
## Phase 2: 계획
|
||||
1. 수집된 정보 분석
|
||||
2. 작업 분해 및 의존성 파악
|
||||
3. 우선순위 결정
|
||||
4. 작업 분배 계획 수립
|
||||
|
||||
## Phase 3: 실행
|
||||
1. 의존성 순서대로 작업 요청
|
||||
2. 결과 검증
|
||||
3. 필요시 재요청
|
||||
|
||||
## Phase 4: 통합
|
||||
1. 모든 결과 취합
|
||||
2. 일관성 검증
|
||||
3. 사용자에게 보고
|
||||
|
||||
# 작업 분배 기준
|
||||
- Backend Agent: API, 서비스 로직, 라우팅 (backend-node/)
|
||||
- DB Agent: 스키마, 쿼리, 마이그레이션 (mapper/, db/)
|
||||
- Frontend Agent: 컴포넌트, 페이지, 스타일 (frontend/)
|
||||
|
||||
# 판단 기준
|
||||
- 불확실하면 사용자에게 물어봐
|
||||
- 에이전트 결과가 이상하면 재요청
|
||||
- 영향 범위 크면 사용자 확인
|
||||
- 충돌 시 더 안전한 방향 선택
|
||||
|
||||
# 응답 형식
|
||||
작업 분배 시:
|
||||
```json
|
||||
{
|
||||
"phase": "info_gathering | work_distribution | integration",
|
||||
"reasoning": "왜 이렇게 분배하는지",
|
||||
"tasks": [
|
||||
{
|
||||
"agent": "backend | db | frontend",
|
||||
"priority": 1,
|
||||
"task": "구체적인 작업 내용",
|
||||
"depends_on": [],
|
||||
"expected_output": "기대 결과"
|
||||
}
|
||||
]
|
||||
}
|
||||
```
|
||||
|
||||
최종 보고 시:
|
||||
```json
|
||||
{
|
||||
"summary": "한 줄 요약",
|
||||
"completed_tasks": ["완료된 작업들"],
|
||||
"files_changed": ["변경된 파일들"],
|
||||
"next_steps": ["다음 단계 (있다면)"],
|
||||
"test_instructions": ["테스트 방법"]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Agent B (Backend) 시스템 프롬프트
|
||||
|
||||
```markdown
|
||||
# 역할
|
||||
너는 Backend 전문가 에이전트야.
|
||||
backend-node/ 폴더의 API, 서비스, 라우팅을 담당해.
|
||||
|
||||
# 담당 영역 (이것만!)
|
||||
- backend-node/src/controllers/
|
||||
- backend-node/src/services/
|
||||
- backend-node/src/routes/
|
||||
- backend-node/src/middleware/
|
||||
- backend-node/src/utils/
|
||||
|
||||
# 담당 아닌 것 (절대 건들지 마)
|
||||
- frontend/ → Frontend Agent 담당
|
||||
- src/com/pms/mapper/ → DB Agent 담당
|
||||
- SQL 쿼리 직접 작성 → DB Agent에게 요청
|
||||
|
||||
# 코드 작성 규칙
|
||||
1. TypeScript 사용
|
||||
2. 에러 핸들링 필수
|
||||
3. 주석은 한글로
|
||||
4. 기존 코드 스타일 따르기
|
||||
5. ... 생략 없이 완전한 코드
|
||||
|
||||
# 응답 형식
|
||||
```json
|
||||
{
|
||||
"status": "success | partial | failed | need_clarification",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "한 줄 요약",
|
||||
"details": "상세 설명",
|
||||
"files_affected": ["파일 경로들"],
|
||||
"code_changes": [
|
||||
{
|
||||
"file": "경로",
|
||||
"action": "create | modify | delete",
|
||||
"content": "전체 코드"
|
||||
}
|
||||
]
|
||||
},
|
||||
"needs_from_others": [
|
||||
{"agent": "db", "request": "필요한 것"}
|
||||
],
|
||||
"side_effects": ["영향받는 것들"],
|
||||
"questions": ["명확하지 않은 것들"]
|
||||
}
|
||||
```
|
||||
|
||||
# 협업 규칙
|
||||
1. 내 영역 아니면 즉시 보고 (scope_violation)
|
||||
2. 확실하지 않으면 confidence: "low"
|
||||
3. 다른 에이전트 필요하면 needs_from_others에 명시
|
||||
4. 부작용 있으면 반드시 보고
|
||||
```
|
||||
|
||||
### Agent C (Database) 시스템 프롬프트
|
||||
|
||||
```markdown
|
||||
# 역할
|
||||
너는 Database 전문가 에이전트야.
|
||||
DB 스키마, 쿼리, 마이그레이션을 담당해.
|
||||
|
||||
# 담당 영역 (이것만!)
|
||||
- src/com/pms/mapper/ (MyBatis XML)
|
||||
- db/ (스키마, 마이그레이션)
|
||||
- backend-node/src/database/
|
||||
|
||||
# 담당 아닌 것 (절대 건들지 마)
|
||||
- API 로직 → Backend Agent 담당
|
||||
- 프론트엔드 → Frontend Agent 담당
|
||||
- 비즈니스 로직 판단 → PM에게 확인
|
||||
|
||||
# 코드 작성 규칙
|
||||
1. PostgreSQL 문법 사용
|
||||
2. 파라미터 바인딩 (#{}) 필수 - SQL 인젝션 방지
|
||||
3. 인덱스 고려
|
||||
4. 성능 최적화 (EXPLAIN 결과 고려)
|
||||
|
||||
# MyBatis 매퍼 규칙
|
||||
```xml
|
||||
<!-- 파라미터 바인딩 (안전) -->
|
||||
WHERE id = #{id}
|
||||
|
||||
<!-- 동적 쿼리 -->
|
||||
<if test="name != null and name != ''">
|
||||
AND name LIKE '%' || #{name} || '%'
|
||||
</if>
|
||||
|
||||
<!-- 페이징 -->
|
||||
LIMIT #{limit} OFFSET #{offset}
|
||||
```
|
||||
|
||||
# 응답 형식
|
||||
```json
|
||||
{
|
||||
"status": "success | partial | failed | need_clarification",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "한 줄 요약",
|
||||
"details": "상세 설명",
|
||||
"schema_info": {
|
||||
"tables": ["관련 테이블"],
|
||||
"columns": ["주요 컬럼"],
|
||||
"indexes": ["인덱스"]
|
||||
},
|
||||
"code_changes": [
|
||||
{
|
||||
"file": "경로",
|
||||
"action": "create | modify",
|
||||
"content": "쿼리/스키마"
|
||||
}
|
||||
]
|
||||
},
|
||||
"performance_notes": ["성능 관련 참고사항"],
|
||||
"questions": ["명확하지 않은 것들"]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
### Agent D (Frontend) 시스템 프롬프트
|
||||
|
||||
```markdown
|
||||
# 역할
|
||||
너는 Frontend 전문가 에이전트야.
|
||||
React/Next.js 기반 UI 구현을 담당해.
|
||||
|
||||
# 담당 영역 (이것만!)
|
||||
- frontend/components/
|
||||
- frontend/pages/ (또는 app/)
|
||||
- frontend/lib/
|
||||
- frontend/hooks/
|
||||
- frontend/styles/
|
||||
|
||||
# 담당 아닌 것 (절대 건들지 마)
|
||||
- backend-node/ → Backend Agent 담당
|
||||
- DB 관련 → DB Agent 담당
|
||||
- API 스펙 결정 → PM/Backend와 협의
|
||||
|
||||
# 코드 작성 규칙
|
||||
1. TypeScript 사용
|
||||
2. React 함수형 컴포넌트
|
||||
3. 커스텀 훅 활용
|
||||
4. 주석은 한글로
|
||||
5. Tailwind CSS 또는 기존 스타일 시스템 따르기
|
||||
|
||||
# API 호출 규칙
|
||||
- 절대 fetch 직접 사용 금지
|
||||
- lib/api/ 클라이언트 사용
|
||||
- 에러 핸들링 필수
|
||||
|
||||
# 응답 형식
|
||||
```json
|
||||
{
|
||||
"status": "success | partial | failed | need_clarification",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "한 줄 요약",
|
||||
"details": "상세 설명",
|
||||
"components_affected": ["컴포넌트 목록"],
|
||||
"code_changes": [
|
||||
{
|
||||
"file": "경로",
|
||||
"action": "create | modify",
|
||||
"content": "전체 코드"
|
||||
}
|
||||
]
|
||||
},
|
||||
"needs_from_others": [
|
||||
{"agent": "backend", "request": "필요한 API"}
|
||||
],
|
||||
"ui_notes": ["UX 관련 참고사항"],
|
||||
"questions": ["명확하지 않은 것들"]
|
||||
}
|
||||
```
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## MCP 서버 구현
|
||||
|
||||
### 프로젝트 구조
|
||||
|
||||
```
|
||||
mcp-agent-orchestrator/
|
||||
├── package.json
|
||||
├── tsconfig.json
|
||||
├── src/
|
||||
│ ├── index.ts # 메인 서버
|
||||
│ ├── agents/
|
||||
│ │ ├── types.ts # 타입 정의
|
||||
│ │ ├── pm.ts # PM 에이전트 프롬프트
|
||||
│ │ ├── backend.ts # Backend 에이전트 프롬프트
|
||||
│ │ ├── database.ts # DB 에이전트 프롬프트
|
||||
│ │ └── frontend.ts # Frontend 에이전트 프롬프트
|
||||
│ └── utils/
|
||||
│ └── logger.ts # 로깅
|
||||
└── build/
|
||||
└── index.js # 컴파일된 파일
|
||||
```
|
||||
|
||||
### 핵심 코드
|
||||
|
||||
```typescript
|
||||
// src/index.ts
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import Anthropic from "@anthropic-ai/sdk";
|
||||
import { PM_PROMPT, BACKEND_PROMPT, DB_PROMPT, FRONTEND_PROMPT } from "./agents";
|
||||
|
||||
const server = new Server({
|
||||
name: "agent-orchestrator",
|
||||
version: "1.0.0",
|
||||
});
|
||||
|
||||
const anthropic = new Anthropic();
|
||||
|
||||
// 에이전트별 설정
|
||||
const AGENT_CONFIG = {
|
||||
pm: { model: "claude-opus-4-5-20250214", prompt: PM_PROMPT },
|
||||
backend: { model: "claude-sonnet-4-20250514", prompt: BACKEND_PROMPT },
|
||||
db: { model: "claude-sonnet-4-20250514", prompt: DB_PROMPT },
|
||||
frontend: { model: "claude-sonnet-4-20250514", prompt: FRONTEND_PROMPT },
|
||||
};
|
||||
|
||||
// 도구 목록
|
||||
server.setRequestHandler("tools/list", async () => ({
|
||||
tools: [
|
||||
{
|
||||
name: "ask_backend_agent",
|
||||
description: "백엔드 전문가에게 질문하거나 작업 요청",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
task: { type: "string", description: "작업 내용" },
|
||||
context: { type: "string", description: "배경 정보 (선택)" },
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask_db_agent",
|
||||
description: "DB 전문가에게 질문하거나 작업 요청",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
task: { type: "string", description: "작업 내용" },
|
||||
context: { type: "string", description: "배경 정보 (선택)" },
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask_frontend_agent",
|
||||
description: "프론트엔드 전문가에게 질문하거나 작업 요청",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
task: { type: "string", description: "작업 내용" },
|
||||
context: { type: "string", description: "배경 정보 (선택)" },
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parallel_ask",
|
||||
description: "여러 전문가에게 동시에 질문 (병렬 실행)",
|
||||
inputSchema: {
|
||||
type: "object",
|
||||
properties: {
|
||||
requests: {
|
||||
type: "array",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: {
|
||||
type: "string",
|
||||
enum: ["backend", "db", "frontend"]
|
||||
},
|
||||
task: { type: "string" },
|
||||
context: { type: "string" },
|
||||
},
|
||||
required: ["agent", "task"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["requests"],
|
||||
},
|
||||
},
|
||||
],
|
||||
}));
|
||||
|
||||
// 도구 실행
|
||||
server.setRequestHandler("tools/call", async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
const callAgent = async (agentType: string, task: string, context?: string) => {
|
||||
const config = AGENT_CONFIG[agentType];
|
||||
const response = await anthropic.messages.create({
|
||||
model: config.model,
|
||||
max_tokens: 8192,
|
||||
system: config.prompt,
|
||||
messages: [
|
||||
{
|
||||
role: "user",
|
||||
content: context ? `${task}\n\n배경 정보:\n${context}` : task,
|
||||
},
|
||||
],
|
||||
});
|
||||
return response.content[0].text;
|
||||
};
|
||||
|
||||
switch (name) {
|
||||
case "ask_backend_agent":
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: await callAgent("backend", args.task, args.context) },
|
||||
],
|
||||
};
|
||||
|
||||
case "ask_db_agent":
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: await callAgent("db", args.task, args.context) },
|
||||
],
|
||||
};
|
||||
|
||||
case "ask_frontend_agent":
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: await callAgent("frontend", args.task, args.context) },
|
||||
],
|
||||
};
|
||||
|
||||
case "parallel_ask":
|
||||
const results = await Promise.all(
|
||||
args.requests.map(async (req) => ({
|
||||
agent: req.agent,
|
||||
result: await callAgent(req.agent, req.task, req.context),
|
||||
}))
|
||||
);
|
||||
return {
|
||||
content: [
|
||||
{ type: "text", text: JSON.stringify(results, null, 2) },
|
||||
],
|
||||
};
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
});
|
||||
|
||||
// 서버 시작
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
```
|
||||
|
||||
### Cursor 설정
|
||||
|
||||
```json
|
||||
// .cursor/mcp.json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["C:/Users/defaultuser0/mcp-agent-orchestrator/build/index.js"],
|
||||
"env": {
|
||||
"ANTHROPIC_API_KEY": "your-api-key-here"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 비용 분석
|
||||
|
||||
### 토큰 사용량 비교
|
||||
|
||||
| 시나리오 | 단일 에이전트 | 멀티 에이전트 | 절감 |
|
||||
|----------|--------------|--------------|------|
|
||||
| 기능 1개 추가 | 100,000 토큰 | 60,000 토큰 | 40% |
|
||||
| 시스템 리팩토링 | 300,000 토큰 | 150,000 토큰 | 50% |
|
||||
| 새 모듈 개발 | 500,000 토큰 | 200,000 토큰 | 60% |
|
||||
|
||||
### 비용 계산 (예시)
|
||||
|
||||
```
|
||||
단일 에이전트 (전부 Opus):
|
||||
- 300,000 토큰 × $15/M = $4.50
|
||||
|
||||
멀티 에이전트 (Opus PM + Sonnet Workers):
|
||||
- PM (Opus): 50,000 토큰 × $15/M = $0.75
|
||||
- Workers (Sonnet): 100,000 토큰 × $3/M = $0.30
|
||||
- 총: $1.05
|
||||
|
||||
절감: $4.50 - $1.05 = $3.45 (76% 절감!)
|
||||
```
|
||||
|
||||
### ROI 분석
|
||||
|
||||
```
|
||||
초기 투자:
|
||||
- MCP 서버 개발: 4-6시간
|
||||
- 프롬프트 튜닝: 2-4시간
|
||||
- 테스트: 2시간
|
||||
- 총: 8-12시간
|
||||
|
||||
회수:
|
||||
- 대규모 작업당 $3-5 절감
|
||||
- 재작업 시간 70% 감소
|
||||
- 품질 30% 향상
|
||||
|
||||
손익분기점: 대규모 작업 3-5회
|
||||
```
|
||||
|
||||
---
|
||||
|
||||
## 한계점 및 해결방안
|
||||
|
||||
### 현재 한계
|
||||
|
||||
| 한계 | 설명 | 해결방안 |
|
||||
|------|------|----------|
|
||||
| 완전 자동화 불가 | Cursor 에이전트 간 직접 통신 없음 | MCP 서버로 우회 |
|
||||
| 파일 읽기 제한 | 각 에이전트가 모든 파일 접근 어려움 | 컨텍스트에 필요한 정보 전달 |
|
||||
| 실시간 동기화 | 변경사항 즉시 반영 어려움 | 명시적 갱신 요청 |
|
||||
| 에러 복구 | 자동 롤백 메커니즘 없음 | 수동 복구 또는 git 활용 |
|
||||
|
||||
### 향후 개선 방향
|
||||
|
||||
1. **파일 시스템 연동**
|
||||
- MCP 서버에 파일 읽기/쓰기 도구 추가
|
||||
- 에이전트가 직접 코드 확인 가능
|
||||
|
||||
2. **결과 자동 적용**
|
||||
- 코드 변경사항 자동 적용
|
||||
- git 커밋 자동화
|
||||
|
||||
3. **피드백 루프**
|
||||
- 테스트 자동 실행
|
||||
- 실패 시 자동 재시도
|
||||
|
||||
4. **히스토리 관리**
|
||||
- 대화 이력 저장
|
||||
- 컨텍스트 캐싱
|
||||
|
||||
---
|
||||
|
||||
## 체크리스트
|
||||
|
||||
### 구현 전 준비
|
||||
|
||||
- [ ] Node.js 18+ 설치
|
||||
- [ ] Anthropic API 키 발급
|
||||
- [ ] 프로젝트 폴더 생성
|
||||
|
||||
### MCP 서버 구현
|
||||
|
||||
- [ ] package.json 설정
|
||||
- [ ] TypeScript 설정
|
||||
- [ ] 기본 서버 구조
|
||||
- [ ] 도구 정의 (4개)
|
||||
- [ ] 에이전트 프롬프트 작성
|
||||
- [ ] 빌드 및 테스트
|
||||
|
||||
### Cursor 연동
|
||||
|
||||
- [ ] mcp.json 설정
|
||||
- [ ] Cursor 재시작
|
||||
- [ ] 도구 호출 테스트
|
||||
- [ ] 실제 작업 테스트
|
||||
|
||||
### 튜닝
|
||||
|
||||
- [ ] 프롬프트 개선
|
||||
- [ ] 에러 핸들링 강화
|
||||
- [ ] 로깅 추가
|
||||
- [ ] 성능 최적화
|
||||
|
||||
---
|
||||
|
||||
## 참고 자료
|
||||
|
||||
- [MCP SDK 문서](https://modelcontextprotocol.io/)
|
||||
- [Anthropic API 문서](https://docs.anthropic.com/)
|
||||
- [CrewAI](https://github.com/joaomdmoura/crewAI) - 멀티에이전트 프레임워크 참고
|
||||
- [AutoGen](https://github.com/microsoft/autogen) - Microsoft 멀티에이전트 참고
|
||||
|
||||
---
|
||||
|
||||
*작성일: 2026-02-05*
|
||||
*버전: 1.0*
|
||||
|
|
@ -35,6 +35,17 @@ import {
|
|||
snapSizeToGrid,
|
||||
snapToGrid,
|
||||
} from "@/lib/utils/gridUtils";
|
||||
import {
|
||||
alignComponents,
|
||||
distributeComponents,
|
||||
matchComponentSize,
|
||||
toggleAllLabels,
|
||||
nudgeComponents,
|
||||
AlignMode,
|
||||
DistributeDirection,
|
||||
MatchSizeMode,
|
||||
} from "@/lib/utils/alignmentUtils";
|
||||
import { KeyboardShortcutsModal } from "./modals/KeyboardShortcutsModal";
|
||||
|
||||
// 10px 단위 스냅 함수
|
||||
const snapTo10px = (value: number): number => {
|
||||
|
|
@ -170,6 +181,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
// 메뉴 할당 모달 상태
|
||||
const [showMenuAssignmentModal, setShowMenuAssignmentModal] = useState(false);
|
||||
|
||||
// 단축키 도움말 모달 상태
|
||||
const [showShortcutsModal, setShowShortcutsModal] = useState(false);
|
||||
|
||||
// 파일첨부 상세 모달 상태
|
||||
const [showFileAttachmentModal, setShowFileAttachmentModal] = useState(false);
|
||||
const [selectedFileComponent, setSelectedFileComponent] = useState<ComponentData | null>(null);
|
||||
|
|
@ -360,6 +374,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const [zoomLevel, setZoomLevel] = useState(1); // 1 = 100%
|
||||
const MIN_ZOOM = 0.1; // 10%
|
||||
const MAX_ZOOM = 3; // 300%
|
||||
const zoomRafRef = useRef<number | null>(null); // 줌 RAF throttle용
|
||||
|
||||
// 전역 파일 상태 변경 시 강제 리렌더링을 위한 상태
|
||||
const [forceRenderTrigger, setForceRenderTrigger] = useState(0);
|
||||
|
|
@ -1647,7 +1662,7 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
panState.innerScrollTop,
|
||||
]);
|
||||
|
||||
// 마우스 휠로 줌 제어
|
||||
// 마우스 휠로 줌 제어 (RAF throttle 적용으로 깜빡임 방지)
|
||||
useEffect(() => {
|
||||
const handleWheel = (e: WheelEvent) => {
|
||||
// 캔버스 컨테이너 내에서만 동작
|
||||
|
|
@ -1660,10 +1675,17 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const delta = e.deltaY;
|
||||
const zoomFactor = 0.001; // 줌 속도 조절
|
||||
|
||||
// RAF throttle: 프레임당 한 번만 상태 업데이트
|
||||
if (zoomRafRef.current !== null) {
|
||||
cancelAnimationFrame(zoomRafRef.current);
|
||||
}
|
||||
zoomRafRef.current = requestAnimationFrame(() => {
|
||||
setZoomLevel((prevZoom) => {
|
||||
const newZoom = prevZoom - delta * zoomFactor;
|
||||
return Math.max(MIN_ZOOM, Math.min(MAX_ZOOM, newZoom));
|
||||
});
|
||||
zoomRafRef.current = null;
|
||||
});
|
||||
}
|
||||
}
|
||||
};
|
||||
|
|
@ -1674,6 +1696,9 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
const containerRef = canvasContainerRef.current;
|
||||
return () => {
|
||||
containerRef?.removeEventListener("wheel", handleWheel);
|
||||
if (zoomRafRef.current !== null) {
|
||||
cancelAnimationFrame(zoomRafRef.current);
|
||||
}
|
||||
};
|
||||
}, [MIN_ZOOM, MAX_ZOOM]);
|
||||
|
||||
|
|
@ -1785,6 +1810,103 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
toast.success(`${adjustedComponents.length}개 컴포넌트가 격자에 맞게 재정렬되었습니다.`);
|
||||
}, [layout, screenResolution, saveToHistory]);
|
||||
|
||||
// === 정렬/배분/동일크기/라벨토글/Nudge 핸들러 ===
|
||||
|
||||
// 컴포넌트 정렬
|
||||
const handleGroupAlign = useCallback(
|
||||
(mode: AlignMode) => {
|
||||
if (groupState.selectedComponents.length < 2) {
|
||||
toast.warning("2개 이상의 컴포넌트를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
saveToHistory(layout);
|
||||
const newComponents = alignComponents(layout.components, groupState.selectedComponents, mode);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
|
||||
const modeNames: Record<AlignMode, string> = {
|
||||
left: "좌측", right: "우측", centerX: "가로 중앙",
|
||||
top: "상단", bottom: "하단", centerY: "세로 중앙",
|
||||
};
|
||||
toast.success(`${modeNames[mode]} 정렬 완료`);
|
||||
},
|
||||
[groupState.selectedComponents, layout, saveToHistory]
|
||||
);
|
||||
|
||||
// 컴포넌트 균등 배분
|
||||
const handleGroupDistribute = useCallback(
|
||||
(direction: DistributeDirection) => {
|
||||
if (groupState.selectedComponents.length < 3) {
|
||||
toast.warning("3개 이상의 컴포넌트를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
saveToHistory(layout);
|
||||
const newComponents = distributeComponents(layout.components, groupState.selectedComponents, direction);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
toast.success(`${direction === "horizontal" ? "가로" : "세로"} 균등 배분 완료`);
|
||||
},
|
||||
[groupState.selectedComponents, layout, saveToHistory]
|
||||
);
|
||||
|
||||
// 동일 크기 맞추기
|
||||
const handleMatchSize = useCallback(
|
||||
(mode: MatchSizeMode) => {
|
||||
if (groupState.selectedComponents.length < 2) {
|
||||
toast.warning("2개 이상의 컴포넌트를 선택해주세요.");
|
||||
return;
|
||||
}
|
||||
saveToHistory(layout);
|
||||
const newComponents = matchComponentSize(
|
||||
layout.components,
|
||||
groupState.selectedComponents,
|
||||
mode,
|
||||
selectedComponent?.id
|
||||
);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
|
||||
const modeNames: Record<MatchSizeMode, string> = {
|
||||
width: "너비", height: "높이", both: "크기",
|
||||
};
|
||||
toast.success(`${modeNames[mode]} 맞추기 완료`);
|
||||
},
|
||||
[groupState.selectedComponents, layout, selectedComponent?.id, saveToHistory]
|
||||
);
|
||||
|
||||
// 라벨 일괄 토글
|
||||
const handleToggleAllLabels = useCallback(() => {
|
||||
saveToHistory(layout);
|
||||
const newComponents = toggleAllLabels(layout.components);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
|
||||
const hasHidden = layout.components.some(
|
||||
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
|
||||
);
|
||||
toast.success(hasHidden ? "모든 라벨 표시" : "모든 라벨 숨기기");
|
||||
}, [layout, saveToHistory]);
|
||||
|
||||
// Nudge (화살표 키 이동)
|
||||
const handleNudge = useCallback(
|
||||
(direction: "up" | "down" | "left" | "right", distance: number) => {
|
||||
const targetIds =
|
||||
groupState.selectedComponents.length > 0
|
||||
? groupState.selectedComponents
|
||||
: selectedComponent
|
||||
? [selectedComponent.id]
|
||||
: [];
|
||||
|
||||
if (targetIds.length === 0) return;
|
||||
|
||||
const newComponents = nudgeComponents(layout.components, targetIds, direction, distance);
|
||||
setLayout((prev) => ({ ...prev, components: newComponents }));
|
||||
|
||||
// 선택된 컴포넌트 업데이트
|
||||
if (selectedComponent && targetIds.includes(selectedComponent.id)) {
|
||||
const updated = newComponents.find((c) => c.id === selectedComponent.id);
|
||||
if (updated) setSelectedComponent(updated);
|
||||
}
|
||||
},
|
||||
[groupState.selectedComponents, selectedComponent, layout.components]
|
||||
);
|
||||
|
||||
// 저장
|
||||
const handleSave = useCallback(async () => {
|
||||
if (!selectedScreen?.screenId) {
|
||||
|
|
@ -5359,6 +5481,105 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}
|
||||
return false;
|
||||
}
|
||||
|
||||
// === 9. 화살표 키 Nudge (컴포넌트 미세 이동) ===
|
||||
if (["ArrowUp", "ArrowDown", "ArrowLeft", "ArrowRight"].includes(e.key)) {
|
||||
// 입력 필드에서는 무시
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
active?.getAttribute("contenteditable") === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
if (selectedComponent || groupState.selectedComponents.length > 0) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
const distance = e.shiftKey ? 10 : 1; // Shift 누르면 10px
|
||||
const dirMap: Record<string, "up" | "down" | "left" | "right"> = {
|
||||
ArrowUp: "up", ArrowDown: "down", ArrowLeft: "left", ArrowRight: "right",
|
||||
};
|
||||
handleNudge(dirMap[e.key], distance);
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === 10. 정렬 단축키 (Alt + 키) - 다중 선택 시 ===
|
||||
if (e.altKey && !e.ctrlKey && !e.metaKey) {
|
||||
const alignKey = e.key?.toLowerCase();
|
||||
const alignMap: Record<string, AlignMode> = {
|
||||
l: "left", r: "right", c: "centerX",
|
||||
t: "top", b: "bottom", m: "centerY",
|
||||
};
|
||||
|
||||
if (alignMap[alignKey] && groupState.selectedComponents.length >= 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleGroupAlign(alignMap[alignKey]);
|
||||
return false;
|
||||
}
|
||||
|
||||
// 균등 배분 (Alt+H: 가로, Alt+V: 세로)
|
||||
if (alignKey === "h" && groupState.selectedComponents.length >= 3) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleGroupDistribute("horizontal");
|
||||
return false;
|
||||
}
|
||||
if (alignKey === "v" && groupState.selectedComponents.length >= 3) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleGroupDistribute("vertical");
|
||||
return false;
|
||||
}
|
||||
|
||||
// 동일 크기 맞추기 (Alt+W: 너비, Alt+E: 높이)
|
||||
if (alignKey === "w" && groupState.selectedComponents.length >= 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleMatchSize("width");
|
||||
return false;
|
||||
}
|
||||
if (alignKey === "e" && groupState.selectedComponents.length >= 2) {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleMatchSize("height");
|
||||
return false;
|
||||
}
|
||||
}
|
||||
|
||||
// === 11. 라벨 일괄 토글 (Alt+Shift+L) ===
|
||||
if (e.altKey && e.shiftKey && e.key?.toLowerCase() === "l") {
|
||||
e.preventDefault();
|
||||
e.stopPropagation();
|
||||
e.stopImmediatePropagation();
|
||||
handleToggleAllLabels();
|
||||
return false;
|
||||
}
|
||||
|
||||
// === 12. 단축키 도움말 (? 키) ===
|
||||
if (e.key === "?" && !e.ctrlKey && !e.metaKey && !e.altKey) {
|
||||
// 입력 필드에서는 무시
|
||||
const active = document.activeElement;
|
||||
if (
|
||||
active instanceof HTMLInputElement ||
|
||||
active instanceof HTMLTextAreaElement ||
|
||||
active?.getAttribute("contenteditable") === "true"
|
||||
) {
|
||||
return;
|
||||
}
|
||||
e.preventDefault();
|
||||
setShowShortcutsModal(true);
|
||||
return false;
|
||||
}
|
||||
};
|
||||
|
||||
// window 레벨에서 캡처 단계에서 가장 먼저 처리
|
||||
|
|
@ -5376,6 +5597,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
groupState.selectedComponents,
|
||||
layout,
|
||||
selectedScreen,
|
||||
handleNudge,
|
||||
handleGroupAlign,
|
||||
handleGroupDistribute,
|
||||
handleMatchSize,
|
||||
handleToggleAllLabels,
|
||||
]);
|
||||
|
||||
// 플로우 위젯 높이 자동 업데이트 이벤트 리스너
|
||||
|
|
@ -5503,6 +5729,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
onOpenMultilangSettings={() => setShowMultilangSettingsModal(true)}
|
||||
isPanelOpen={panelStates.v2?.isOpen || false}
|
||||
onTogglePanel={() => togglePanel("v2")}
|
||||
selectedCount={groupState.selectedComponents.length}
|
||||
onAlign={handleGroupAlign}
|
||||
onDistribute={handleGroupDistribute}
|
||||
onMatchSize={handleMatchSize}
|
||||
onToggleLabels={handleToggleAllLabels}
|
||||
onShowShortcuts={() => setShowShortcutsModal(true)}
|
||||
/>
|
||||
{/* 메인 컨테이너 (패널들 + 캔버스) */}
|
||||
<div className="flex flex-1 overflow-hidden">
|
||||
|
|
@ -6013,8 +6245,12 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</div>
|
||||
)}
|
||||
|
||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - 좌우 최소화, 위아래 넉넉한 여유 */}
|
||||
<div ref={canvasContainerRef} className="bg-muted relative flex-1 overflow-auto px-16 py-6">
|
||||
{/* 메인 캔버스 영역 (스크롤 가능한 컨테이너) - GPU 가속 스크롤 적용 */}
|
||||
<div
|
||||
ref={canvasContainerRef}
|
||||
className="bg-muted relative flex-1 overflow-auto px-16 py-6"
|
||||
style={{ willChange: "scroll-position" }}
|
||||
>
|
||||
{/* Pan 모드 안내 - 제거됨 */}
|
||||
{/* 줌 레벨 표시 */}
|
||||
<div className="bg-card text-foreground border-border pointer-events-none fixed right-6 bottom-6 z-50 rounded-lg border px-4 py-2 text-sm font-medium shadow-md">
|
||||
|
|
@ -6123,12 +6359,13 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
</div>
|
||||
);
|
||||
})()}
|
||||
{/* 🔥 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬로 변경 */}
|
||||
{/* 줌 적용 시 스크롤 영역 확보를 위한 래퍼 - 중앙 정렬 + contain 최적화 */}
|
||||
<div
|
||||
className="flex justify-center"
|
||||
style={{
|
||||
width: "100%",
|
||||
minHeight: screenResolution.height * zoomLevel,
|
||||
contain: "layout style", // 레이아웃 재계산 범위 제한
|
||||
}}
|
||||
>
|
||||
{/* 실제 작업 캔버스 (해상도 크기) - 고정 크기 + 줌 적용 */}
|
||||
|
|
@ -6141,8 +6378,10 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
maxWidth: `${screenResolution.width}px`,
|
||||
minHeight: `${screenResolution.height}px`,
|
||||
flexShrink: 0,
|
||||
transform: `scale(${zoomLevel})`,
|
||||
transform: `scale3d(${zoomLevel}, ${zoomLevel}, 1)`,
|
||||
transformOrigin: "top center", // 중앙 기준으로 스케일
|
||||
willChange: "transform", // GPU 가속 레이어 생성
|
||||
backfaceVisibility: "hidden" as const, // 리페인트 최적화
|
||||
}}
|
||||
>
|
||||
<div
|
||||
|
|
@ -6842,6 +7081,11 @@ export default function ScreenDesigner({ selectedScreen, onBackToList, onScreenU
|
|||
}
|
||||
}}
|
||||
/>
|
||||
{/* 단축키 도움말 모달 */}
|
||||
<KeyboardShortcutsModal
|
||||
isOpen={showShortcutsModal}
|
||||
onClose={() => setShowShortcutsModal(false)}
|
||||
/>
|
||||
</div>
|
||||
</TableOptionsProvider>
|
||||
</LayerProvider>
|
||||
|
|
|
|||
|
|
@ -365,7 +365,7 @@ export function ScreenSettingModal({
|
|||
|
||||
return (
|
||||
<>
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<Dialog open={isOpen && !showDesignerModal} onOpenChange={onClose}>
|
||||
<DialogContent className="flex h-[90vh] max-h-[950px] w-[98vw] max-w-[1600px] flex-col">
|
||||
<DialogHeader className="flex-shrink-0">
|
||||
<DialogTitle className="flex items-center gap-2 text-lg">
|
||||
|
|
@ -525,11 +525,10 @@ export function ScreenSettingModal({
|
|||
</DialogContent>
|
||||
</Dialog>
|
||||
|
||||
{/* ScreenDesigner 전체 화면 모달 */}
|
||||
<Dialog open={showDesignerModal} onOpenChange={setShowDesignerModal}>
|
||||
<DialogContent className="max-w-[98vw] h-[95vh] p-0 overflow-hidden">
|
||||
<DialogTitle className="sr-only">화면 디자이너</DialogTitle>
|
||||
<div className="flex flex-col h-full">
|
||||
{/* ScreenDesigner 전체 화면 - Dialog 중첩 시 오버레이 컴포지팅으로 깜빡임 발생하여 직접 렌더링 */}
|
||||
{/* ScreenDesigner 자체 SlimToolbar에 "목록으로" 닫기 버튼이 있으므로 별도 X 버튼 불필요 */}
|
||||
{showDesignerModal && (
|
||||
<div className="bg-background fixed inset-0 z-[1000] flex flex-col">
|
||||
<ScreenDesigner
|
||||
selectedScreen={{
|
||||
screenId: currentScreenId,
|
||||
|
|
@ -544,15 +543,12 @@ export function ScreenSettingModal({
|
|||
}}
|
||||
onBackToList={async () => {
|
||||
setShowDesignerModal(false);
|
||||
// 디자이너에서 저장 후 모달 닫으면 데이터 새로고침
|
||||
await loadData();
|
||||
// 데이터 로드 완료 후 iframe 갱신
|
||||
setIframeKey(prev => prev + 1);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
)}
|
||||
|
||||
{/* TableSettingModal */}
|
||||
{tableSettingTarget && (
|
||||
|
|
|
|||
|
|
@ -0,0 +1,144 @@
|
|||
"use client";
|
||||
|
||||
import React from "react";
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogDescription,
|
||||
} from "@/components/ui/dialog";
|
||||
|
||||
interface ShortcutItem {
|
||||
keys: string[];
|
||||
description: string;
|
||||
}
|
||||
|
||||
interface ShortcutGroup {
|
||||
title: string;
|
||||
shortcuts: ShortcutItem[];
|
||||
}
|
||||
|
||||
const shortcutGroups: ShortcutGroup[] = [
|
||||
{
|
||||
title: "기본 조작",
|
||||
shortcuts: [
|
||||
{ keys: ["Ctrl", "S"], description: "레이아웃 저장" },
|
||||
{ keys: ["Ctrl", "Z"], description: "실행취소" },
|
||||
{ keys: ["Ctrl", "Y"], description: "다시실행" },
|
||||
{ keys: ["Ctrl", "A"], description: "전체 선택" },
|
||||
{ keys: ["Delete"], description: "선택 삭제" },
|
||||
{ keys: ["Esc"], description: "선택 해제" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "복사/붙여넣기",
|
||||
shortcuts: [
|
||||
{ keys: ["Ctrl", "C"], description: "컴포넌트 복사" },
|
||||
{ keys: ["Ctrl", "V"], description: "컴포넌트 붙여넣기" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "그룹 관리",
|
||||
shortcuts: [
|
||||
{ keys: ["Ctrl", "G"], description: "그룹 생성" },
|
||||
{ keys: ["Ctrl", "Shift", "G"], description: "그룹 해제" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "이동 (Nudge)",
|
||||
shortcuts: [
|
||||
{ keys: ["Arrow"], description: "1px 이동" },
|
||||
{ keys: ["Shift", "Arrow"], description: "10px 이동" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "정렬 (다중 선택 시)",
|
||||
shortcuts: [
|
||||
{ keys: ["Alt", "L"], description: "좌측 정렬" },
|
||||
{ keys: ["Alt", "R"], description: "우측 정렬" },
|
||||
{ keys: ["Alt", "C"], description: "가로 중앙 정렬" },
|
||||
{ keys: ["Alt", "T"], description: "상단 정렬" },
|
||||
{ keys: ["Alt", "B"], description: "하단 정렬" },
|
||||
{ keys: ["Alt", "M"], description: "세로 중앙 정렬" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "배분/크기 (다중 선택 시)",
|
||||
shortcuts: [
|
||||
{ keys: ["Alt", "H"], description: "가로 균등 배분" },
|
||||
{ keys: ["Alt", "V"], description: "세로 균등 배분" },
|
||||
{ keys: ["Alt", "W"], description: "너비 맞추기" },
|
||||
{ keys: ["Alt", "E"], description: "높이 맞추기" },
|
||||
],
|
||||
},
|
||||
{
|
||||
title: "보기/탐색",
|
||||
shortcuts: [
|
||||
{ keys: ["Space", "Drag"], description: "캔버스 팬(이동)" },
|
||||
{ keys: ["Wheel"], description: "줌 인/아웃" },
|
||||
{ keys: ["P"], description: "패널 열기/닫기" },
|
||||
{ keys: ["Alt", "Shift", "L"], description: "라벨 일괄 표시/숨기기" },
|
||||
{ keys: ["?"], description: "단축키 도움말" },
|
||||
],
|
||||
},
|
||||
];
|
||||
|
||||
interface KeyboardShortcutsModalProps {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
}
|
||||
|
||||
export const KeyboardShortcutsModal: React.FC<KeyboardShortcutsModalProps> = ({
|
||||
isOpen,
|
||||
onClose,
|
||||
}) => {
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={onClose}>
|
||||
<DialogContent className="max-w-[95vw] sm:max-w-[600px] max-h-[80vh] overflow-y-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle className="text-base sm:text-lg">
|
||||
키보드 단축키
|
||||
</DialogTitle>
|
||||
<DialogDescription className="text-xs sm:text-sm">
|
||||
화면 디자이너에서 사용할 수 있는 단축키 목록입니다. Mac에서는 Ctrl 대신 Cmd를 사용합니다.
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="space-y-5">
|
||||
{shortcutGroups.map((group) => (
|
||||
<div key={group.title}>
|
||||
<h3 className="text-sm font-semibold text-foreground mb-2">
|
||||
{group.title}
|
||||
</h3>
|
||||
<div className="space-y-1">
|
||||
{group.shortcuts.map((shortcut, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="flex items-center justify-between rounded-md px-3 py-1.5 hover:bg-muted/50 transition-colors"
|
||||
>
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{shortcut.description}
|
||||
</span>
|
||||
<div className="flex items-center gap-1">
|
||||
{shortcut.keys.map((key, kidx) => (
|
||||
<React.Fragment key={kidx}>
|
||||
{kidx > 0 && (
|
||||
<span className="text-xs text-muted-foreground">+</span>
|
||||
)}
|
||||
<kbd className="inline-flex h-6 min-w-[24px] items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs text-muted-foreground shadow-sm">
|
||||
{key}
|
||||
</kbd>
|
||||
</React.Fragment>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
};
|
||||
|
|
@ -22,6 +22,18 @@ import {
|
|||
Settings2,
|
||||
PanelLeft,
|
||||
PanelLeftClose,
|
||||
AlignStartVertical,
|
||||
AlignCenterVertical,
|
||||
AlignEndVertical,
|
||||
AlignStartHorizontal,
|
||||
AlignCenterHorizontal,
|
||||
AlignEndHorizontal,
|
||||
AlignHorizontalSpaceAround,
|
||||
AlignVerticalSpaceAround,
|
||||
RulerIcon,
|
||||
Tag,
|
||||
Keyboard,
|
||||
Equal,
|
||||
} from "lucide-react";
|
||||
import { ScreenResolution, SCREEN_RESOLUTIONS } from "@/types/screen";
|
||||
import {
|
||||
|
|
@ -50,6 +62,10 @@ interface GridSettings {
|
|||
gridOpacity?: number;
|
||||
}
|
||||
|
||||
type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
|
||||
type DistributeDirection = "horizontal" | "vertical";
|
||||
type MatchSizeMode = "width" | "height" | "both";
|
||||
|
||||
interface SlimToolbarProps {
|
||||
screenName?: string;
|
||||
tableName?: string;
|
||||
|
|
@ -67,6 +83,13 @@ interface SlimToolbarProps {
|
|||
// 패널 토글 기능
|
||||
isPanelOpen?: boolean;
|
||||
onTogglePanel?: () => void;
|
||||
// 정렬/배분/크기 기능
|
||||
selectedCount?: number;
|
||||
onAlign?: (mode: AlignMode) => void;
|
||||
onDistribute?: (direction: DistributeDirection) => void;
|
||||
onMatchSize?: (mode: MatchSizeMode) => void;
|
||||
onToggleLabels?: () => void;
|
||||
onShowShortcuts?: () => void;
|
||||
}
|
||||
|
||||
export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
||||
|
|
@ -85,6 +108,12 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
onOpenMultilangSettings,
|
||||
isPanelOpen = false,
|
||||
onTogglePanel,
|
||||
selectedCount = 0,
|
||||
onAlign,
|
||||
onDistribute,
|
||||
onMatchSize,
|
||||
onToggleLabels,
|
||||
onShowShortcuts,
|
||||
}) => {
|
||||
// 사용자 정의 해상도 상태
|
||||
const [customWidth, setCustomWidth] = useState("");
|
||||
|
|
@ -325,8 +354,100 @@ export const SlimToolbar: React.FC<SlimToolbarProps> = ({
|
|||
)}
|
||||
</div>
|
||||
|
||||
{/* 중앙: 정렬/배분 도구 (다중 선택 시 표시) */}
|
||||
{selectedCount >= 2 && (onAlign || onDistribute || onMatchSize) && (
|
||||
<div className="flex items-center space-x-1 rounded-md bg-blue-50 px-2 py-1">
|
||||
{/* 정렬 */}
|
||||
{onAlign && (
|
||||
<>
|
||||
<span className="mr-1 text-xs font-medium text-blue-700">정렬</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("left")} title="좌측 정렬 (Alt+L)">
|
||||
<AlignStartVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerX")} title="가로 중앙 (Alt+C)">
|
||||
<AlignCenterVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("right")} title="우측 정렬 (Alt+R)">
|
||||
<AlignEndVertical className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<div className="mx-0.5 h-4 w-px bg-blue-200" />
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("top")} title="상단 정렬 (Alt+T)">
|
||||
<AlignStartHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("centerY")} title="세로 중앙 (Alt+M)">
|
||||
<AlignCenterHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onAlign("bottom")} title="하단 정렬 (Alt+B)">
|
||||
<AlignEndHorizontal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 배분 (3개 이상 선택 시) */}
|
||||
{onDistribute && selectedCount >= 3 && (
|
||||
<>
|
||||
<div className="mx-1 h-4 w-px bg-blue-200" />
|
||||
<span className="mr-1 text-xs font-medium text-blue-700">배분</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("horizontal")} title="가로 균등 배분 (Alt+H)">
|
||||
<AlignHorizontalSpaceAround className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onDistribute("vertical")} title="세로 균등 배분 (Alt+V)">
|
||||
<AlignVerticalSpaceAround className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* 크기 맞추기 */}
|
||||
{onMatchSize && (
|
||||
<>
|
||||
<div className="mx-1 h-4 w-px bg-blue-200" />
|
||||
<span className="mr-1 text-xs font-medium text-blue-700">크기</span>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("width")} title="너비 맞추기 (Alt+W)">
|
||||
<RulerIcon className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("height")} title="높이 맞추기 (Alt+E)">
|
||||
<RulerIcon className="h-3.5 w-3.5 rotate-90" />
|
||||
</Button>
|
||||
<Button variant="ghost" size="icon" className="h-7 w-7" onClick={() => onMatchSize("both")} title="크기 모두 맞추기">
|
||||
<Equal className="h-3.5 w-3.5" />
|
||||
</Button>
|
||||
</>
|
||||
)}
|
||||
|
||||
<div className="mx-1 h-4 w-px bg-blue-200" />
|
||||
<span className="text-xs text-blue-600">{selectedCount}개 선택</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* 우측: 버튼들 */}
|
||||
<div className="flex items-center space-x-2">
|
||||
{/* 라벨 토글 버튼 */}
|
||||
{onToggleLabels && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onToggleLabels}
|
||||
className="flex items-center space-x-1"
|
||||
title="라벨 일괄 표시/숨기기 (Alt+Shift+L)"
|
||||
>
|
||||
<Tag className="h-4 w-4" />
|
||||
<span>라벨</span>
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{/* 단축키 도움말 */}
|
||||
{onShowShortcuts && (
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={onShowShortcuts}
|
||||
className="h-9 w-9"
|
||||
title="단축키 도움말 (?)"
|
||||
>
|
||||
<Keyboard className="h-4 w-4" />
|
||||
</Button>
|
||||
)}
|
||||
|
||||
{onPreview && (
|
||||
<Button variant="outline" onClick={onPreview} className="flex items-center space-x-2">
|
||||
<Smartphone className="h-4 w-4" />
|
||||
|
|
|
|||
|
|
@ -122,7 +122,13 @@ const DropdownSelect = forwardRef<HTMLButtonElement, {
|
|||
role="combobox"
|
||||
aria-expanded={open}
|
||||
disabled={disabled}
|
||||
className={cn("w-full justify-between font-normal", className)}
|
||||
className={cn(
|
||||
"w-full justify-between font-normal",
|
||||
"bg-transparent hover:bg-transparent", // 표준 Select와 동일한 투명 배경
|
||||
"border-input shadow-xs", // 표준 Select와 동일한 테두리
|
||||
"h-6 px-2 py-0 text-sm", // 표준 Select xs와 동일한 높이
|
||||
className,
|
||||
)}
|
||||
style={style}
|
||||
>
|
||||
<span className="truncate flex-1 text-left">
|
||||
|
|
|
|||
|
|
@ -0,0 +1,265 @@
|
|||
/**
|
||||
* 화면 디자이너 정렬/배분/동일크기 유틸리티
|
||||
*
|
||||
* 다중 선택된 컴포넌트에 대해 정렬, 균등 배분, 동일 크기 맞추기 기능을 제공합니다.
|
||||
*/
|
||||
|
||||
import { ComponentData } from "@/types/screen";
|
||||
|
||||
// 정렬 모드 타입
|
||||
export type AlignMode = "left" | "centerX" | "right" | "top" | "centerY" | "bottom";
|
||||
|
||||
// 배분 방향 타입
|
||||
export type DistributeDirection = "horizontal" | "vertical";
|
||||
|
||||
// 크기 맞추기 모드 타입
|
||||
export type MatchSizeMode = "width" | "height" | "both";
|
||||
|
||||
/**
|
||||
* 컴포넌트 정렬
|
||||
* 선택된 컴포넌트들을 지정된 방향으로 정렬합니다.
|
||||
*/
|
||||
export function alignComponents(
|
||||
components: ComponentData[],
|
||||
selectedIds: string[],
|
||||
mode: AlignMode
|
||||
): ComponentData[] {
|
||||
const selected = components.filter((c) => selectedIds.includes(c.id));
|
||||
if (selected.length < 2) return components;
|
||||
|
||||
let targetValue: number;
|
||||
|
||||
switch (mode) {
|
||||
case "left":
|
||||
// 가장 왼쪽 x값으로 정렬
|
||||
targetValue = Math.min(...selected.map((c) => c.position.x));
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
return { ...c, position: { ...c.position, x: targetValue } };
|
||||
});
|
||||
|
||||
case "right":
|
||||
// 가장 오른쪽 (x + width)로 정렬
|
||||
targetValue = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const width = c.size?.width || 100;
|
||||
return { ...c, position: { ...c.position, x: targetValue - width } };
|
||||
});
|
||||
|
||||
case "centerX":
|
||||
// 가로 중앙 정렬 (전체 범위의 중앙)
|
||||
{
|
||||
const minX = Math.min(...selected.map((c) => c.position.x));
|
||||
const maxX = Math.max(...selected.map((c) => c.position.x + (c.size?.width || 100)));
|
||||
const centerX = (minX + maxX) / 2;
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const width = c.size?.width || 100;
|
||||
return { ...c, position: { ...c.position, x: Math.round(centerX - width / 2) } };
|
||||
});
|
||||
}
|
||||
|
||||
case "top":
|
||||
// 가장 위쪽 y값으로 정렬
|
||||
targetValue = Math.min(...selected.map((c) => c.position.y));
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
return { ...c, position: { ...c.position, y: targetValue } };
|
||||
});
|
||||
|
||||
case "bottom":
|
||||
// 가장 아래쪽 (y + height)로 정렬
|
||||
targetValue = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const height = c.size?.height || 40;
|
||||
return { ...c, position: { ...c.position, y: targetValue - height } };
|
||||
});
|
||||
|
||||
case "centerY":
|
||||
// 세로 중앙 정렬 (전체 범위의 중앙)
|
||||
{
|
||||
const minY = Math.min(...selected.map((c) => c.position.y));
|
||||
const maxY = Math.max(...selected.map((c) => c.position.y + (c.size?.height || 40)));
|
||||
const centerY = (minY + maxY) / 2;
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const height = c.size?.height || 40;
|
||||
return { ...c, position: { ...c.position, y: Math.round(centerY - height / 2) } };
|
||||
});
|
||||
}
|
||||
|
||||
default:
|
||||
return components;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 균등 배분
|
||||
* 선택된 컴포넌트들 간의 간격을 균등하게 분배합니다.
|
||||
* 최소 3개 이상의 컴포넌트가 필요합니다.
|
||||
*/
|
||||
export function distributeComponents(
|
||||
components: ComponentData[],
|
||||
selectedIds: string[],
|
||||
direction: DistributeDirection
|
||||
): ComponentData[] {
|
||||
const selected = components.filter((c) => selectedIds.includes(c.id));
|
||||
if (selected.length < 3) return components;
|
||||
|
||||
if (direction === "horizontal") {
|
||||
// x 기준 정렬
|
||||
const sorted = [...selected].sort((a, b) => a.position.x - b.position.x);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
|
||||
// 첫 번째 ~ 마지막 컴포넌트 사이의 총 공간
|
||||
const totalSpace = last.position.x + (last.size?.width || 100) - first.position.x;
|
||||
// 컴포넌트들이 차지하는 총 너비
|
||||
const totalComponentWidth = sorted.reduce((sum, c) => sum + (c.size?.width || 100), 0);
|
||||
// 균등 간격
|
||||
const gap = (totalSpace - totalComponentWidth) / (sorted.length - 1);
|
||||
|
||||
// ID -> 새 x 좌표 매핑
|
||||
const newPositions = new Map<string, number>();
|
||||
let currentX = first.position.x;
|
||||
for (const comp of sorted) {
|
||||
newPositions.set(comp.id, Math.round(currentX));
|
||||
currentX += (comp.size?.width || 100) + gap;
|
||||
}
|
||||
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const newX = newPositions.get(c.id);
|
||||
if (newX === undefined) return c;
|
||||
return { ...c, position: { ...c.position, x: newX } };
|
||||
});
|
||||
} else {
|
||||
// y 기준 정렬
|
||||
const sorted = [...selected].sort((a, b) => a.position.y - b.position.y);
|
||||
const first = sorted[0];
|
||||
const last = sorted[sorted.length - 1];
|
||||
|
||||
const totalSpace = last.position.y + (last.size?.height || 40) - first.position.y;
|
||||
const totalComponentHeight = sorted.reduce((sum, c) => sum + (c.size?.height || 40), 0);
|
||||
const gap = (totalSpace - totalComponentHeight) / (sorted.length - 1);
|
||||
|
||||
const newPositions = new Map<string, number>();
|
||||
let currentY = first.position.y;
|
||||
for (const comp of sorted) {
|
||||
newPositions.set(comp.id, Math.round(currentY));
|
||||
currentY += (comp.size?.height || 40) + gap;
|
||||
}
|
||||
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
const newY = newPositions.get(c.id);
|
||||
if (newY === undefined) return c;
|
||||
return { ...c, position: { ...c.position, y: newY } };
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 동일 크기 맞추기
|
||||
* 선택된 컴포넌트들의 크기를 첫 번째 선택된 컴포넌트 기준으로 맞춥니다.
|
||||
*/
|
||||
export function matchComponentSize(
|
||||
components: ComponentData[],
|
||||
selectedIds: string[],
|
||||
mode: MatchSizeMode,
|
||||
referenceId?: string
|
||||
): ComponentData[] {
|
||||
const selected = components.filter((c) => selectedIds.includes(c.id));
|
||||
if (selected.length < 2) return components;
|
||||
|
||||
// 기준 컴포넌트 (지정하지 않으면 첫 번째 선택된 컴포넌트)
|
||||
const reference = referenceId
|
||||
? selected.find((c) => c.id === referenceId) || selected[0]
|
||||
: selected[0];
|
||||
|
||||
const refWidth = reference.size?.width || 100;
|
||||
const refHeight = reference.size?.height || 40;
|
||||
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
|
||||
const currentWidth = c.size?.width || 100;
|
||||
const currentHeight = c.size?.height || 40;
|
||||
|
||||
let newWidth = currentWidth;
|
||||
let newHeight = currentHeight;
|
||||
|
||||
if (mode === "width" || mode === "both") {
|
||||
newWidth = refWidth;
|
||||
}
|
||||
if (mode === "height" || mode === "both") {
|
||||
newHeight = refHeight;
|
||||
}
|
||||
|
||||
return {
|
||||
...c,
|
||||
size: {
|
||||
...c.size,
|
||||
width: newWidth,
|
||||
height: newHeight,
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 라벨 일괄 토글
|
||||
* 모든 컴포넌트의 라벨 표시/숨기기를 토글합니다.
|
||||
* 숨겨진 라벨이 하나라도 있으면 모두 표시, 모두 표시되어 있으면 모두 숨기기
|
||||
*/
|
||||
export function toggleAllLabels(components: ComponentData[], forceShow?: boolean): ComponentData[] {
|
||||
// 현재 라벨이 숨겨진(labelDisplay === false) 컴포넌트가 있는지 확인
|
||||
const hasHiddenLabel = components.some(
|
||||
(c) => c.type === "widget" && (c.style as any)?.labelDisplay === false
|
||||
);
|
||||
|
||||
// forceShow가 지정되면 그 값 사용, 아니면 자동 판단
|
||||
// 숨겨진 라벨이 있으면 모두 표시, 아니면 모두 숨기기
|
||||
const shouldShow = forceShow !== undefined ? forceShow : hasHiddenLabel;
|
||||
|
||||
return components.map((c) => {
|
||||
// 위젯 타입만 라벨 토글 대상
|
||||
if (c.type !== "widget") return c;
|
||||
|
||||
return {
|
||||
...c,
|
||||
style: {
|
||||
...(c.style || {}),
|
||||
labelDisplay: shouldShow,
|
||||
} as any,
|
||||
};
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* 컴포넌트 nudge (화살표 키 이동)
|
||||
* 선택된 컴포넌트를 지정 방향으로 이동합니다.
|
||||
*/
|
||||
export function nudgeComponents(
|
||||
components: ComponentData[],
|
||||
selectedIds: string[],
|
||||
direction: "up" | "down" | "left" | "right",
|
||||
distance: number = 1 // 기본 1px, Shift 누르면 10px
|
||||
): ComponentData[] {
|
||||
const dx = direction === "left" ? -distance : direction === "right" ? distance : 0;
|
||||
const dy = direction === "up" ? -distance : direction === "down" ? distance : 0;
|
||||
|
||||
return components.map((c) => {
|
||||
if (!selectedIds.includes(c.id)) return c;
|
||||
return {
|
||||
...c,
|
||||
position: {
|
||||
...c.position,
|
||||
x: Math.max(0, c.position.x + dx),
|
||||
y: Math.max(0, c.position.y + dy),
|
||||
},
|
||||
};
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,189 @@
|
|||
# Multi-Agent Orchestrator MCP Server v2.0
|
||||
|
||||
Cursor Agent CLI를 활용한 멀티에이전트 시스템입니다.
|
||||
**Cursor Team Plan만으로 동작** - 외부 API 키 불필요!
|
||||
|
||||
## 아키텍처
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────┐
|
||||
│ Cursor IDE (PM Agent) │
|
||||
│ Claude Opus 4.5 │
|
||||
└────────────────────┬────────────────────┘
|
||||
│ MCP Tools
|
||||
┌────────────────┼────────────────┐
|
||||
▼ ▼ ▼
|
||||
┌────────┐ ┌────────┐ ┌────────┐
|
||||
│Backend │ │ DB │ │Frontend│
|
||||
│ Agent │ │ Agent │ │ Agent │
|
||||
│ via CLI│ │ via CLI│ │ via CLI│
|
||||
│Sonnet │ │Sonnet │ │Sonnet │
|
||||
└────────┘ └────────┘ └────────┘
|
||||
↑ ↑ ↑
|
||||
└──────────────┴───────────────┘
|
||||
Cursor Agent CLI
|
||||
(Team Plan 크레딧 사용)
|
||||
```
|
||||
|
||||
## 특징
|
||||
|
||||
- **API 키 불필요**: Cursor Team Plan 크레딧만 사용
|
||||
- **크로스 플랫폼**: Windows, Mac, Linux 지원
|
||||
- **진짜 병렬 실행**: `parallel_ask`로 동시 작업
|
||||
- **모델 티어링**: PM=Opus, Sub-agents=Sonnet
|
||||
|
||||
## 사전 요구사항
|
||||
|
||||
1. **Cursor Team/Pro Plan** 구독
|
||||
2. **Cursor Agent CLI** 설치 및 로그인
|
||||
```bash
|
||||
# 설치 후 로그인 확인
|
||||
agent status
|
||||
```
|
||||
|
||||
## 설치
|
||||
|
||||
```bash
|
||||
cd mcp-agent-orchestrator
|
||||
npm install
|
||||
npm run build
|
||||
```
|
||||
|
||||
## Cursor 설정
|
||||
|
||||
### Windows
|
||||
|
||||
`.cursor/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["C:/Users/YOUR_USERNAME/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Mac
|
||||
|
||||
`.cursor/mcp.json`:
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agent-orchestrator": {
|
||||
"command": "node",
|
||||
"args": ["/Users/YOUR_USERNAME/ERP-node/mcp-agent-orchestrator/build/index.js"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
**주의**: Mac에서 agent CLI가 PATH에 있어야 합니다.
|
||||
```bash
|
||||
# agent CLI 위치 확인
|
||||
which agent
|
||||
# 보통: ~/.cursor-agent/bin/agent 또는 /usr/local/bin/agent
|
||||
|
||||
# PATH에 없으면 추가 (.zshrc 또는 .bashrc)
|
||||
export PATH="$HOME/.cursor-agent/bin:$PATH"
|
||||
```
|
||||
|
||||
## 사용 가능한 도구
|
||||
|
||||
### ask_backend_agent
|
||||
백엔드 전문가에게 질문/작업 요청
|
||||
- API 설계, 서비스 로직, 라우팅
|
||||
- 담당 폴더: `backend-node/src/`
|
||||
|
||||
### ask_db_agent
|
||||
DB 전문가에게 질문/작업 요청
|
||||
- 스키마, 쿼리, MyBatis 매퍼
|
||||
- 담당 폴더: `src/com/pms/mapper/`, `db/`
|
||||
|
||||
### ask_frontend_agent
|
||||
프론트엔드 전문가에게 질문/작업 요청
|
||||
- React 컴포넌트, 페이지, 스타일
|
||||
- 담당 폴더: `frontend/`
|
||||
|
||||
### parallel_ask
|
||||
여러 전문가에게 동시에 질문 (진짜 병렬 실행!)
|
||||
- 정보 수집 단계에서 유용
|
||||
|
||||
### get_agent_info
|
||||
에이전트 시스템 정보 확인
|
||||
|
||||
## 워크플로우 예시
|
||||
|
||||
### 1단계: 정보 수집 (병렬)
|
||||
```
|
||||
parallel_ask([
|
||||
{ agent: "backend", task: "현재 order 관련 API 구조 분석" },
|
||||
{ agent: "db", task: "orders 테이블 스키마 분석" },
|
||||
{ agent: "frontend", task: "주문 관련 컴포넌트 현황 분석" }
|
||||
])
|
||||
```
|
||||
|
||||
### 2단계: 개별 작업 (순차)
|
||||
```
|
||||
ask_db_agent("cursor 기반 페이징 쿼리 작성")
|
||||
ask_backend_agent("GET /api/orders에 pagination 추가")
|
||||
ask_frontend_agent("Pagination 컴포넌트 적용")
|
||||
```
|
||||
|
||||
## 모델 설정
|
||||
|
||||
| Agent | Model | 역할 |
|
||||
|-------|-------|------|
|
||||
| PM (Cursor IDE) | Opus 4.5 | 전체 조율, 사용자 대화 |
|
||||
| Backend | Sonnet 4.5 | API, 서비스 로직 |
|
||||
| DB | Sonnet 4.5 | 스키마, 쿼리 |
|
||||
| Frontend | Sonnet 4.5 | 컴포넌트, UI |
|
||||
|
||||
**비용 최적화**: PM만 Opus, 나머지는 Sonnet 사용
|
||||
|
||||
## 환경 변수
|
||||
|
||||
- `LOG_LEVEL`: 로그 레벨 (debug, info, warn, error)
|
||||
|
||||
## 트러블슈팅
|
||||
|
||||
### Windows: agent 명령어가 안 됨
|
||||
```powershell
|
||||
# PowerShell 실행 정책 확인
|
||||
Get-ExecutionPolicy -List
|
||||
|
||||
# 필요시 변경
|
||||
Set-ExecutionPolicy -ExecutionPolicy RemoteSigned -Scope CurrentUser
|
||||
```
|
||||
|
||||
### Mac: agent 명령어를 찾을 수 없음
|
||||
```bash
|
||||
# agent CLI 위치 확인
|
||||
ls -la ~/.cursor-agent/bin/
|
||||
|
||||
# PATH 추가
|
||||
echo 'export PATH="$HOME/.cursor-agent/bin:$PATH"' >> ~/.zshrc
|
||||
source ~/.zshrc
|
||||
```
|
||||
|
||||
### 응답이 오래 걸림
|
||||
- 정상입니다! 각 에이전트 호출에 15-30초 소요
|
||||
- `parallel_ask`로 병렬 처리하면 시간 절약
|
||||
|
||||
## 개발
|
||||
|
||||
```bash
|
||||
# 개발 모드 (watch)
|
||||
npm run dev
|
||||
|
||||
# 빌드
|
||||
npm run build
|
||||
|
||||
# 테스트 실행
|
||||
npm start
|
||||
```
|
||||
|
||||
## 라이선스
|
||||
|
||||
MIT
|
||||
File diff suppressed because it is too large
Load Diff
|
|
@ -0,0 +1,29 @@
|
|||
{
|
||||
"name": "mcp-agent-orchestrator",
|
||||
"version": "2.0.0",
|
||||
"description": "Multi-Agent Orchestrator MCP Server using Cursor Agent CLI (Team Plan)",
|
||||
"type": "module",
|
||||
"main": "build/index.js",
|
||||
"scripts": {
|
||||
"build": "tsc",
|
||||
"start": "node build/index.js",
|
||||
"dev": "tsc --watch"
|
||||
},
|
||||
"dependencies": {
|
||||
"@modelcontextprotocol/sdk": "^1.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@types/node": "^20.0.0",
|
||||
"typescript": "^5.0.0"
|
||||
},
|
||||
"engines": {
|
||||
"node": ">=18.0.0"
|
||||
},
|
||||
"keywords": [
|
||||
"cursor",
|
||||
"mcp",
|
||||
"multi-agent",
|
||||
"ai",
|
||||
"orchestrator"
|
||||
]
|
||||
}
|
||||
|
|
@ -0,0 +1,6 @@
|
|||
/**
|
||||
* 에이전트 모듈 내보내기
|
||||
*/
|
||||
|
||||
export * from "./types.js";
|
||||
export * from "./prompts.js";
|
||||
|
|
@ -0,0 +1,258 @@
|
|||
/**
|
||||
* Agent System Prompts v2.1
|
||||
* All prompts in English for better token efficiency and model performance.
|
||||
* Agents will respond in Korean based on user preferences.
|
||||
*/
|
||||
|
||||
export const PM_PROMPT = `# Role
|
||||
You are a PM (Project Manager) agent for ERP-node project.
|
||||
Analyze user requests, distribute tasks to specialist agents (Backend, DB, Frontend),
|
||||
and integrate results to create the final deliverable.
|
||||
|
||||
# Available Tools
|
||||
- ask_backend_agent: Backend expert (API, services, routing)
|
||||
- ask_db_agent: DB expert (schema, queries, migrations)
|
||||
- ask_frontend_agent: Frontend expert (components, pages, styles)
|
||||
- parallel_ask: Multiple experts simultaneously
|
||||
|
||||
# Work Process
|
||||
1. Analyze request -> identify scope
|
||||
2. If cross-domain (FE+BE+DB): use parallel_ask
|
||||
3. If single domain: use specific agent
|
||||
4. Integrate results -> report to user
|
||||
|
||||
# Task Distribution
|
||||
- Backend Agent: backend-node/src/ (controllers, services, routes)
|
||||
- DB Agent: db/, mapper/ (schema, migrations, queries)
|
||||
- Frontend Agent: frontend/ (components, pages, lib)
|
||||
|
||||
# Response: Always concise! Summarize key findings only.`;
|
||||
|
||||
export const BACKEND_PROMPT = `# Role
|
||||
You are a Backend specialist for ERP-node project.
|
||||
Stack: Node.js + Express + TypeScript + PostgreSQL Raw Query.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. Multi-tenancy (ABSOLUTE MUST!)
|
||||
- ALL queries MUST include company_code filter
|
||||
- Use req.user!.companyCode from auth middleware
|
||||
- NEVER trust client-sent company_code
|
||||
- Super Admin (company_code = "*") sees all data
|
||||
- Regular users CANNOT see company_code = "*" data
|
||||
|
||||
## 2. Super Admin Visibility
|
||||
- If req.user.companyCode !== "*", add: WHERE company_code != '*'
|
||||
- Super admin users must be hidden from regular company users
|
||||
|
||||
## 3. Required Code Pattern
|
||||
\`\`\`typescript
|
||||
const companyCode = req.user!.companyCode;
|
||||
if (companyCode === "*") {
|
||||
query = "SELECT * FROM table ORDER BY company_code";
|
||||
} else {
|
||||
query = "SELECT * FROM table WHERE company_code = $1 AND company_code != '*'";
|
||||
params = [companyCode];
|
||||
}
|
||||
\`\`\`
|
||||
|
||||
# Your Domain (ONLY these!)
|
||||
- backend-node/src/controllers/
|
||||
- backend-node/src/services/
|
||||
- backend-node/src/routes/
|
||||
- backend-node/src/middleware/
|
||||
- backend-node/src/utils/
|
||||
|
||||
# NOT Your Domain
|
||||
- frontend/ -> Frontend Agent
|
||||
- db/migrations/ -> DB Agent
|
||||
- Direct SQL schema design -> DB Agent
|
||||
|
||||
# Code Rules
|
||||
1. TypeScript strict mode
|
||||
2. Error handling with try/catch
|
||||
3. Comments in Korean
|
||||
4. Follow existing code patterns
|
||||
5. Use logger for important operations
|
||||
|
||||
# Response Format (JSON) - BE CONCISE!
|
||||
{
|
||||
"status": "success | partial | failed",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "one line summary",
|
||||
"details": "brief explanation",
|
||||
"files_affected": ["paths"],
|
||||
"code_changes": [{"file": "path", "action": "create|modify", "content": "code"}]
|
||||
},
|
||||
"needs_from_others": [],
|
||||
"questions": []
|
||||
}
|
||||
|
||||
# IMPORTANT: Keep responses SHORT. No unnecessary explanations.`;
|
||||
|
||||
export const DB_PROMPT = `# Role
|
||||
You are a Database specialist for ERP-node project.
|
||||
Stack: PostgreSQL + Raw Query (no ORM). Migrations in db/migrations/.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. Multi-tenancy (ABSOLUTE MUST!)
|
||||
- ALL tables MUST have company_code VARCHAR(20) NOT NULL
|
||||
- ALL queries MUST filter by company_code
|
||||
- JOINs MUST include company_code matching condition
|
||||
- Subqueries MUST include company_code filter
|
||||
- Aggregates (COUNT, SUM) MUST filter by company_code
|
||||
- CREATE INDEX on company_code for every table
|
||||
|
||||
## 2. company_code = "*" Meaning
|
||||
- NOT shared/common data!
|
||||
- Super admin ONLY data
|
||||
- Regular companies CANNOT see it: WHERE company_code != '*'
|
||||
|
||||
## 3. Required SQL Patterns
|
||||
\`\`\`sql
|
||||
-- Standard query pattern
|
||||
SELECT * FROM table_name
|
||||
WHERE company_code = $1
|
||||
AND company_code != '*'
|
||||
ORDER BY created_date DESC;
|
||||
|
||||
-- JOIN pattern (company_code matching required!)
|
||||
SELECT a.*, b.name
|
||||
FROM table_a a
|
||||
LEFT JOIN table_b b ON a.ref_id = b.id
|
||||
AND a.company_code = b.company_code
|
||||
WHERE a.company_code = $1;
|
||||
\`\`\`
|
||||
|
||||
## 4. Migration Rules
|
||||
- File naming: NNN_description.sql (e.g., 034_add_new_table.sql)
|
||||
- Always include company_code column
|
||||
- Always create index on company_code
|
||||
- Add foreign key to company_info(company_code) when possible
|
||||
|
||||
# Your Domain (ONLY these!)
|
||||
- db/migrations/
|
||||
- SQL schema design
|
||||
- Query optimization
|
||||
- Index strategy
|
||||
|
||||
# NOT Your Domain
|
||||
- API logic -> Backend Agent
|
||||
- Frontend -> Frontend Agent
|
||||
- Business logic decisions -> PM Agent
|
||||
|
||||
# Code Rules
|
||||
1. PostgreSQL syntax only
|
||||
2. Parameter binding ($1, $2) - prevent SQL injection
|
||||
3. Consider indexes for frequently queried columns
|
||||
4. Use COALESCE for NULL handling
|
||||
5. Use TIMESTAMPTZ for dates
|
||||
|
||||
# Response Format (JSON) - BE CONCISE!
|
||||
{
|
||||
"status": "success | partial | failed",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "one line summary",
|
||||
"details": "brief explanation",
|
||||
"schema_info": {"tables": [], "columns": [], "indexes": []},
|
||||
"code_changes": [{"file": "path", "action": "create|modify", "content": "sql"}]
|
||||
},
|
||||
"performance_notes": [],
|
||||
"questions": []
|
||||
}
|
||||
|
||||
# IMPORTANT: Keep responses SHORT. Focus on schema and queries only.`;
|
||||
|
||||
export const FRONTEND_PROMPT = `# Role
|
||||
You are a Frontend specialist for ERP-node project.
|
||||
Stack: Next.js 14 + React + TypeScript + Tailwind CSS + shadcn/ui.
|
||||
|
||||
# CRITICAL PROJECT RULES
|
||||
|
||||
## 1. API Client (ABSOLUTE RULE!)
|
||||
- NEVER use fetch() directly!
|
||||
- ALWAYS use lib/api/ clients (Axios-based)
|
||||
\`\`\`typescript
|
||||
// FORBIDDEN
|
||||
const res = await fetch('/api/flow/definitions');
|
||||
|
||||
// MUST USE
|
||||
import { getFlowDefinitions } from '@/lib/api/flow';
|
||||
const res = await getFlowDefinitions();
|
||||
\`\`\`
|
||||
|
||||
## 2. shadcn/ui Style Rules
|
||||
- Use CSS variables: bg-primary, text-muted-foreground (NOT bg-blue-500)
|
||||
- No nested boxes: Card inside Card is FORBIDDEN
|
||||
- Button variants: default, secondary, outline, ghost, destructive
|
||||
- Responsive: mobile-first approach (sm:, md:, lg:)
|
||||
- Modal standard: max-w-[95vw] sm:max-w-[500px]
|
||||
|
||||
## 3. Component Rules
|
||||
- Functional components only
|
||||
- Korean comments for code documentation
|
||||
- Custom hooks for reusable logic
|
||||
- TypeScript strict typing required
|
||||
|
||||
# Your Domain (ONLY these!)
|
||||
- frontend/components/
|
||||
- frontend/app/ or frontend/pages/
|
||||
- frontend/lib/
|
||||
- frontend/hooks/
|
||||
- frontend/styles/
|
||||
|
||||
# NOT Your Domain
|
||||
- backend-node/ -> Backend Agent
|
||||
- DB schema -> DB Agent
|
||||
- API endpoint decisions -> PM/Backend Agent
|
||||
|
||||
# Code Rules
|
||||
1. TypeScript strict mode
|
||||
2. React functional components with hooks
|
||||
3. Prefer shadcn/ui components
|
||||
4. Use cn() utility for conditional classes
|
||||
5. Comments in Korean
|
||||
|
||||
# Response Format (JSON) - BE CONCISE!
|
||||
{
|
||||
"status": "success | partial | failed",
|
||||
"confidence": "high | medium | low",
|
||||
"result": {
|
||||
"summary": "one line summary",
|
||||
"details": "brief explanation",
|
||||
"components_affected": ["list"],
|
||||
"code_changes": [{"file": "path", "action": "create|modify", "content": "code"}]
|
||||
},
|
||||
"needs_from_others": [],
|
||||
"ui_notes": [],
|
||||
"questions": []
|
||||
}
|
||||
|
||||
# IMPORTANT: Keep responses SHORT. No lengthy analysis unless explicitly asked.`;
|
||||
|
||||
// Agent configuration map
|
||||
export const AGENT_CONFIGS = {
|
||||
pm: {
|
||||
model: 'claude-opus-4-5-20250214',
|
||||
systemPrompt: PM_PROMPT,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
backend: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: BACKEND_PROMPT,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
db: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: DB_PROMPT,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
frontend: {
|
||||
model: 'claude-sonnet-4-20250514',
|
||||
systemPrompt: FRONTEND_PROMPT,
|
||||
maxTokens: 4096,
|
||||
},
|
||||
} as const;
|
||||
|
|
@ -0,0 +1,63 @@
|
|||
/**
|
||||
* Multi-Agent System 타입 정의
|
||||
*/
|
||||
|
||||
// 에이전트 타입
|
||||
export type AgentType = 'pm' | 'backend' | 'db' | 'frontend';
|
||||
|
||||
// 에이전트 설정
|
||||
export interface AgentConfig {
|
||||
model: string;
|
||||
systemPrompt: string;
|
||||
maxTokens: number;
|
||||
}
|
||||
|
||||
// 작업 요청
|
||||
export interface TaskRequest {
|
||||
agent: AgentType;
|
||||
task: string;
|
||||
context?: string;
|
||||
}
|
||||
|
||||
// 작업 응답 상태
|
||||
export type ResponseStatus = 'success' | 'partial' | 'failed' | 'need_clarification';
|
||||
|
||||
// 확신도
|
||||
export type ConfidenceLevel = 'high' | 'medium' | 'low';
|
||||
|
||||
// 코드 변경
|
||||
export interface CodeChange {
|
||||
file: string;
|
||||
action: 'create' | 'modify' | 'delete';
|
||||
content?: string;
|
||||
lineStart?: number;
|
||||
lineEnd?: number;
|
||||
}
|
||||
|
||||
// 에이전트 응답
|
||||
export interface AgentResponse {
|
||||
status: ResponseStatus;
|
||||
confidence: ConfidenceLevel;
|
||||
result?: {
|
||||
summary: string;
|
||||
details: string;
|
||||
filesAffected?: string[];
|
||||
codeChanges?: CodeChange[];
|
||||
};
|
||||
scopeViolations?: string[];
|
||||
dependencies?: string[];
|
||||
sideEffects?: string[];
|
||||
alternatives?: string[];
|
||||
questions?: string[];
|
||||
needsFromOthers?: {
|
||||
agent: AgentType;
|
||||
request: string;
|
||||
}[];
|
||||
}
|
||||
|
||||
// 병렬 요청 결과
|
||||
export interface ParallelResult {
|
||||
agent: AgentType;
|
||||
result: string;
|
||||
error?: string;
|
||||
}
|
||||
|
|
@ -0,0 +1,407 @@
|
|||
#!/usr/bin/env node
|
||||
/**
|
||||
b * Multi-Agent Orchestrator MCP Server v2.0
|
||||
*
|
||||
* Cursor Agent CLI를 활용한 멀티에이전트 시스템
|
||||
* - PM (Cursor IDE): 전체 조율
|
||||
* - Sub-agents (agent CLI): 전문가별 작업 수행
|
||||
*
|
||||
* 모든 AI 호출이 Cursor Team Plan으로 처리됨!
|
||||
* API 키 불필요!
|
||||
*/
|
||||
|
||||
import { Server } from "@modelcontextprotocol/sdk/server/index.js";
|
||||
import { StdioServerTransport } from "@modelcontextprotocol/sdk/server/stdio.js";
|
||||
import {
|
||||
CallToolRequestSchema,
|
||||
ListToolsRequestSchema,
|
||||
} from "@modelcontextprotocol/sdk/types.js";
|
||||
import { exec } from "child_process";
|
||||
import { promisify } from "util";
|
||||
import { platform } from "os";
|
||||
import { AGENT_CONFIGS } from "./agents/prompts.js";
|
||||
import { AgentType, ParallelResult } from "./agents/types.js";
|
||||
import { logger } from "./utils/logger.js";
|
||||
|
||||
const execAsync = promisify(exec);
|
||||
|
||||
// OS 감지
|
||||
const isWindows = platform() === "win32";
|
||||
logger.info(`Platform detected: ${platform()} (isWindows: ${isWindows})`);
|
||||
|
||||
// MCP 서버 생성
|
||||
const server = new Server(
|
||||
{
|
||||
name: "agent-orchestrator",
|
||||
version: "2.0.0",
|
||||
},
|
||||
{
|
||||
capabilities: {
|
||||
tools: {},
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
/**
|
||||
* Cursor Agent CLI를 통해 에이전트 호출
|
||||
* Cursor Team Plan 사용 - API 키 불필요!
|
||||
*
|
||||
* 크로스 플랫폼 지원:
|
||||
* - Windows: cmd /c "echo. | agent ..." (stdin 닫기 위해)
|
||||
* - Mac/Linux: ~/.local/bin/agent 사용
|
||||
*/
|
||||
async function callAgentCLI(
|
||||
agentType: AgentType,
|
||||
task: string,
|
||||
context?: string
|
||||
): Promise<string> {
|
||||
const config = AGENT_CONFIGS[agentType];
|
||||
|
||||
// 모델 선택: PM은 opus, 나머지는 sonnet
|
||||
const model = agentType === 'pm' ? 'opus-4.5' : 'sonnet-4.5';
|
||||
|
||||
logger.info(`Calling ${agentType} agent via CLI`, { model, task: task.substring(0, 100) });
|
||||
|
||||
try {
|
||||
const userMessage = context
|
||||
? `${task}\n\n배경 정보:\n${context}`
|
||||
: task;
|
||||
|
||||
// 프롬프트를 임시 파일에 저장하여 쉘 이스케이프 문제 회피
|
||||
const fullPrompt = `${config.systemPrompt}\n\n---\n\n${userMessage}`;
|
||||
|
||||
// Base64 인코딩으로 특수문자 문제 해결
|
||||
const encodedPrompt = Buffer.from(fullPrompt).toString('base64');
|
||||
|
||||
let cmd: string;
|
||||
let shell: string;
|
||||
const agentPath = isWindows ? 'agent' : `${process.env.HOME}/.local/bin/agent`;
|
||||
|
||||
if (isWindows) {
|
||||
// Windows: PowerShell을 통해 Base64 디코딩 후 실행
|
||||
cmd = `powershell -Command "$prompt = [System.Text.Encoding]::UTF8.GetString([System.Convert]::FromBase64String('${encodedPrompt}')); echo $prompt | ${agentPath} --model ${model} --print"`;
|
||||
shell = 'powershell.exe';
|
||||
} else {
|
||||
// Mac/Linux: echo로 base64 디코딩 후 파이프
|
||||
cmd = `echo "${encodedPrompt}" | base64 -d | ${agentPath} --model ${model} --print`;
|
||||
shell = '/bin/bash';
|
||||
}
|
||||
|
||||
logger.debug(`Executing: ${agentPath} --model ${model} --print`);
|
||||
|
||||
const { stdout, stderr } = await execAsync(cmd, {
|
||||
cwd: process.cwd(),
|
||||
maxBuffer: 10 * 1024 * 1024, // 10MB buffer
|
||||
timeout: 300000, // 5분 타임아웃
|
||||
shell,
|
||||
env: {
|
||||
...process.env,
|
||||
PATH: `${process.env.HOME}/.local/bin:${process.env.PATH}`,
|
||||
},
|
||||
});
|
||||
|
||||
if (stderr && !stderr.includes('warning') && !stderr.includes('info')) {
|
||||
logger.warn(`${agentType} agent stderr`, { stderr: stderr.substring(0, 500) });
|
||||
}
|
||||
|
||||
logger.info(`${agentType} agent completed via CLI`);
|
||||
return stdout.trim();
|
||||
} catch (error) {
|
||||
logger.error(`${agentType} agent CLI error`, error);
|
||||
throw error;
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 도구 목록 핸들러
|
||||
*/
|
||||
server.setRequestHandler(ListToolsRequestSchema, async () => {
|
||||
return {
|
||||
tools: [
|
||||
{
|
||||
name: "ask_backend_agent",
|
||||
description:
|
||||
"백엔드 전문가에게 질문하거나 작업을 요청합니다. " +
|
||||
"API 설계, 서비스 로직, 라우팅, 미들웨어 관련 작업에 사용하세요. " +
|
||||
"담당 폴더: backend-node/src/ (Cursor Agent CLI, sonnet-4.5 모델)" +
|
||||
"주의: 단순 파일 읽기/수정은 PM이 직접 처리하세요. 깊은 분석이 필요할 때만 호출!",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
task: {
|
||||
type: "string",
|
||||
description: "백엔드 에이전트에게 요청할 작업 내용",
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
description: "작업에 필요한 배경 정보 (선택사항)",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask_db_agent",
|
||||
description:
|
||||
"DB 전문가에게 질문하거나 작업을 요청합니다. " +
|
||||
"스키마 설계, SQL 쿼리, MyBatis 매퍼, 마이그레이션 관련 작업에 사용하세요. " +
|
||||
"담당 폴더: src/com/pms/mapper/, db/ (Cursor Agent CLI, sonnet-4.5 모델)" +
|
||||
"주의: 단순 스키마 확인은 PM이 직접 처리하세요. 복잡한 쿼리 설계/최적화 시에만 호출!",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
task: {
|
||||
type: "string",
|
||||
description: "DB 에이전트에게 요청할 작업 내용",
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
description: "작업에 필요한 배경 정보 (선택사항)",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "ask_frontend_agent",
|
||||
description:
|
||||
"프론트엔드 전문가에게 질문하거나 작업을 요청합니다. " +
|
||||
"React 컴포넌트, 페이지, 스타일링, 상태관리 관련 작업에 사용하세요. " +
|
||||
"담당 폴더: frontend/ (Cursor Agent CLI, sonnet-4.5 모델)" +
|
||||
"주의: 단순 컴포넌트 읽기/수정은 PM이 직접 처리하세요. 구조 분석이 필요할 때만 호출!",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
task: {
|
||||
type: "string",
|
||||
description: "프론트엔드 에이전트에게 요청할 작업 내용",
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
description: "작업에 필요한 배경 정보 (선택사항)",
|
||||
},
|
||||
},
|
||||
required: ["task"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "parallel_ask",
|
||||
description:
|
||||
"여러 전문가에게 동시에 질문합니다 (진짜 병렬 실행!). " +
|
||||
"3개 영역(FE+BE+DB) 크로스도메인 분석이 필요할 때만 사용하세요. " +
|
||||
"주의: 호출 시간이 오래 걸림! 단순 작업은 PM이 직접 처리하는 게 훨씬 빠릅니다. " +
|
||||
"적합한 경우: 전체 아키텍처 파악, 대규모 리팩토링 계획, 크로스도메인 영향 분석",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {
|
||||
requests: {
|
||||
type: "array",
|
||||
description: "각 에이전트에게 보낼 요청 목록",
|
||||
items: {
|
||||
type: "object",
|
||||
properties: {
|
||||
agent: {
|
||||
type: "string",
|
||||
enum: ["backend", "db", "frontend"],
|
||||
description: "요청할 에이전트 타입",
|
||||
},
|
||||
task: {
|
||||
type: "string",
|
||||
description: "해당 에이전트에게 요청할 작업",
|
||||
},
|
||||
context: {
|
||||
type: "string",
|
||||
description: "배경 정보 (선택사항)",
|
||||
},
|
||||
},
|
||||
required: ["agent", "task"],
|
||||
},
|
||||
},
|
||||
},
|
||||
required: ["requests"],
|
||||
},
|
||||
},
|
||||
{
|
||||
name: "get_agent_info",
|
||||
description:
|
||||
"에이전트 시스템의 현재 상태와 사용 가능한 에이전트 정보를 확인합니다.",
|
||||
inputSchema: {
|
||||
type: "object" as const,
|
||||
properties: {},
|
||||
},
|
||||
},
|
||||
],
|
||||
};
|
||||
});
|
||||
|
||||
/**
|
||||
* 도구 호출 핸들러
|
||||
*/
|
||||
server.setRequestHandler(CallToolRequestSchema, async (request) => {
|
||||
const { name, arguments: args } = request.params;
|
||||
|
||||
logger.info(`Tool called: ${name}`);
|
||||
|
||||
try {
|
||||
switch (name) {
|
||||
case "ask_backend_agent": {
|
||||
const { task, context } = args as { task: string; context?: string };
|
||||
const result = await callAgentCLI("backend", task, context);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: result }],
|
||||
};
|
||||
}
|
||||
|
||||
case "ask_db_agent": {
|
||||
const { task, context } = args as { task: string; context?: string };
|
||||
const result = await callAgentCLI("db", task, context);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: result }],
|
||||
};
|
||||
}
|
||||
|
||||
case "ask_frontend_agent": {
|
||||
const { task, context } = args as { task: string; context?: string };
|
||||
const result = await callAgentCLI("frontend", task, context);
|
||||
return {
|
||||
content: [{ type: "text" as const, text: result }],
|
||||
};
|
||||
}
|
||||
|
||||
case "parallel_ask": {
|
||||
const { requests } = args as {
|
||||
requests: Array<{
|
||||
agent: "backend" | "db" | "frontend";
|
||||
task: string;
|
||||
context?: string;
|
||||
}>;
|
||||
};
|
||||
|
||||
logger.info(`Parallel ask to ${requests.length} agents (TRUE PARALLEL!)`);
|
||||
|
||||
// 진짜 병렬 실행! 모든 에이전트가 동시에 작업
|
||||
const results: ParallelResult[] = await Promise.all(
|
||||
requests.map(async (req) => {
|
||||
try {
|
||||
const result = await callAgentCLI(req.agent, req.task, req.context);
|
||||
return { agent: req.agent, result };
|
||||
} catch (error) {
|
||||
return {
|
||||
agent: req.agent,
|
||||
result: "",
|
||||
error: error instanceof Error ? error.message : "Unknown error",
|
||||
};
|
||||
}
|
||||
})
|
||||
);
|
||||
|
||||
// 결과를 보기 좋게 포맷팅
|
||||
const formattedResults = results.map((r) => {
|
||||
const header = `\n${"=".repeat(60)}\n## ${r.agent.toUpperCase()} Agent 응답\n${"=".repeat(60)}\n`;
|
||||
if (r.error) {
|
||||
return `${header}❌ 에러: ${r.error}`;
|
||||
}
|
||||
return `${header}${r.result}`;
|
||||
});
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: formattedResults.join("\n"),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
case "get_agent_info": {
|
||||
const info = {
|
||||
system: "Multi-Agent Orchestrator v2.0",
|
||||
version: "2.0.0",
|
||||
backend: "Cursor Agent CLI (Team Plan)",
|
||||
cliPath: `${process.env.HOME}/.local/bin/agent`,
|
||||
apiKey: "NOT REQUIRED! Using Cursor Team Plan credits",
|
||||
agents: {
|
||||
pm: {
|
||||
role: "Project Manager",
|
||||
model: "opus-4.5 (Cursor IDE에서 직접)",
|
||||
description: "전체 조율, 사용자 의도 파악, 작업 분배",
|
||||
},
|
||||
backend: {
|
||||
role: "Backend Specialist",
|
||||
model: "sonnet-4.5 (via Agent CLI)",
|
||||
description: "API, 서비스 로직, 라우팅 담당",
|
||||
folder: "backend-node/src/",
|
||||
},
|
||||
db: {
|
||||
role: "Database Specialist",
|
||||
model: "sonnet-4.5 (via Agent CLI)",
|
||||
description: "스키마, 쿼리, 마이그레이션 담당",
|
||||
folder: "src/com/pms/mapper/, db/",
|
||||
},
|
||||
frontend: {
|
||||
role: "Frontend Specialist",
|
||||
model: "sonnet-4.5 (via Agent CLI)",
|
||||
description: "컴포넌트, 페이지, 스타일링 담당",
|
||||
folder: "frontend/",
|
||||
},
|
||||
},
|
||||
features: {
|
||||
parallel_execution: true,
|
||||
cursor_team_plan: true,
|
||||
cursor_agent_cli: true,
|
||||
separate_api_key: false,
|
||||
cross_platform: true,
|
||||
},
|
||||
usage: {
|
||||
single_agent: "ask_backend_agent, ask_db_agent, ask_frontend_agent",
|
||||
parallel: "parallel_ask로 여러 에이전트 동시 호출",
|
||||
workflow: "1. parallel_ask로 정보 수집 → 2. 개별 에이전트로 작업 분배",
|
||||
},
|
||||
};
|
||||
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: JSON.stringify(info, null, 2),
|
||||
},
|
||||
],
|
||||
};
|
||||
}
|
||||
|
||||
default:
|
||||
throw new Error(`Unknown tool: ${name}`);
|
||||
}
|
||||
} catch (error) {
|
||||
logger.error(`Tool error: ${name}`, error);
|
||||
return {
|
||||
content: [
|
||||
{
|
||||
type: "text" as const,
|
||||
text: `❌ 에러 발생: ${error instanceof Error ? error.message : "Unknown error"}`,
|
||||
},
|
||||
],
|
||||
isError: true,
|
||||
};
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* 서버 시작
|
||||
*/
|
||||
async function main() {
|
||||
logger.info("Starting Multi-Agent Orchestrator MCP Server v2.0...");
|
||||
logger.info(`Backend: Cursor Agent CLI (${process.env.HOME}/.local/bin/agent)`);
|
||||
logger.info("Credits: Cursor Team Plan - No API Key Required!");
|
||||
|
||||
const transport = new StdioServerTransport();
|
||||
await server.connect(transport);
|
||||
|
||||
logger.info("MCP Server connected and ready!");
|
||||
}
|
||||
|
||||
main().catch((error) => {
|
||||
logger.error("Server failed to start", error);
|
||||
process.exit(1);
|
||||
});
|
||||
|
|
@ -0,0 +1,55 @@
|
|||
/**
|
||||
* 간단한 로깅 유틸리티
|
||||
*/
|
||||
|
||||
type LogLevel = 'debug' | 'info' | 'warn' | 'error';
|
||||
|
||||
const LOG_LEVELS: Record<LogLevel, number> = {
|
||||
debug: 0,
|
||||
info: 1,
|
||||
warn: 2,
|
||||
error: 3,
|
||||
};
|
||||
|
||||
// 환경변수로 로그 레벨 설정 (기본: info)
|
||||
const currentLevel = (process.env.LOG_LEVEL as LogLevel) || 'info';
|
||||
|
||||
function shouldLog(level: LogLevel): boolean {
|
||||
return LOG_LEVELS[level] >= LOG_LEVELS[currentLevel];
|
||||
}
|
||||
|
||||
function formatMessage(level: LogLevel, message: string, data?: unknown): string {
|
||||
const timestamp = new Date().toISOString();
|
||||
const prefix = `[${timestamp}] [${level.toUpperCase()}]`;
|
||||
|
||||
if (data) {
|
||||
return `${prefix} ${message} ${JSON.stringify(data, null, 2)}`;
|
||||
}
|
||||
return `${prefix} ${message}`;
|
||||
}
|
||||
|
||||
export const logger = {
|
||||
debug(message: string, data?: unknown): void {
|
||||
if (shouldLog('debug')) {
|
||||
console.error(formatMessage('debug', message, data));
|
||||
}
|
||||
},
|
||||
|
||||
info(message: string, data?: unknown): void {
|
||||
if (shouldLog('info')) {
|
||||
console.error(formatMessage('info', message, data));
|
||||
}
|
||||
},
|
||||
|
||||
warn(message: string, data?: unknown): void {
|
||||
if (shouldLog('warn')) {
|
||||
console.error(formatMessage('warn', message, data));
|
||||
}
|
||||
},
|
||||
|
||||
error(message: string, data?: unknown): void {
|
||||
if (shouldLog('error')) {
|
||||
console.error(formatMessage('error', message, data));
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,19 @@
|
|||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2022",
|
||||
"module": "NodeNext",
|
||||
"moduleResolution": "NodeNext",
|
||||
"outDir": "./build",
|
||||
"rootDir": "./src",
|
||||
"strict": true,
|
||||
"esModuleInterop": true,
|
||||
"skipLibCheck": true,
|
||||
"forceConsistentCasingInFileNames": true,
|
||||
"resolveJsonModule": true,
|
||||
"declaration": true,
|
||||
"declarationMap": true,
|
||||
"sourceMap": true
|
||||
},
|
||||
"include": ["src/**/*"],
|
||||
"exclude": ["node_modules", "build"]
|
||||
}
|
||||
Loading…
Reference in New Issue