diff --git a/Front/.gitignore b/Front/.gitignore new file mode 100644 index 0000000..e76158b --- /dev/null +++ b/Front/.gitignore @@ -0,0 +1,115 @@ +# Dependencies +**/node_modules +.pnpm-store/ + +# Build outputs +dist/ +build/ +*.dist + +# Environment variables +.env +.env.local +.env.development.local +.env.test.local +.env.production.local + +# IDE and editor files +.vscode/ +.idea/ +*.swp +*.swo +*~ + +# OS generated files +.DS_Store +.DS_Store? +._* +.Spotlight-V100 +.Trashes +ehthumbs.db +Thumbs.db + +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +# Runtime data +pids +*.pid +*.seed +*.pid.lock + +# Coverage directory used by tools like istanbul +coverage/ +*.lcov + +# nyc test coverage +.nyc_output + +# Dependency directories +jspm_packages/ + +# TypeScript cache +*.tsbuildinfo + +# Optional npm cache directory +.npm + +# Optional eslint cache +.eslintcache + +# Microbundle cache +.rpt2_cache/ +.rts2_cache_cjs/ +.rts2_cache_es/ +.rts2_cache_umd/ + +# Optional REPL history +.node_repl_history + +# Output of 'npm pack' +*.tgz + +# Yarn Integrity file +.yarn-integrity + +# parcel-bundler cache (https://parceljs.org/) +.cache +.parcel-cache + +# Next.js build output +.next + +# Nuxt.js build / generate output +.nuxt + +# Gatsby files +.cache/ + +# Storybook build outputs +.out +.storybook-out + +# Temporary folders +tmp/ +temp/ + +# Database +*.db +*.sqlite +*.sqlite3 + +# Webdev artifacts (checkpoint zips, migrations, etc.) +.webdev/ + +# Manus version file (auto-generated, not part of source) +client/public/__manus__/version.json + +# Manus dev tooling (browser console / network logs) +.manus-logs/ diff --git a/Front/.gitkeep b/Front/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Front/.prettierignore b/Front/.prettierignore new file mode 100644 index 0000000..27a587d --- /dev/null +++ b/Front/.prettierignore @@ -0,0 +1,5 @@ +dist +node_modules +.git +*.min.js +*.min.css diff --git a/Front/.prettierrc b/Front/.prettierrc new file mode 100644 index 0000000..67c0bc8 --- /dev/null +++ b/Front/.prettierrc @@ -0,0 +1,15 @@ +{ + "semi": true, + "trailingComma": "es5", + "singleQuote": false, + "printWidth": 80, + "tabWidth": 2, + "useTabs": false, + "bracketSpacing": true, + "bracketSameLine": false, + "arrowParens": "avoid", + "endOfLine": "lf", + "quoteProps": "as-needed", + "jsxSingleQuote": false, + "proseWrap": "preserve" +} diff --git a/Front/8_body_layouts_final.html b/Front/8_body_layouts_final.html new file mode 100644 index 0000000..1e5093f --- /dev/null +++ b/Front/8_body_layouts_final.html @@ -0,0 +1,269 @@ + + + + + 8 Body Layouts for slide-base.html + + + + + + + +
+
01. Focus Split (65:35 Ratio)
+
+
+
+
+

DX 역량 내재화 및 추진 체계 고도화

+

+ 단순 기술 도입을 넘어 조직 전반의 데이터 리터러시를 확보하고, 전담 조직 구성을 통해 실행력을 극대화해야 합니다. +

+
+
+
+
+
+

Strategic Goal

+

데이터 기반 의사결정 체계 구축을 통한 효율성 40% 향상

+
+
+
+
+ + +
+
02. Quadrant Grid (Equal 4 Sectors)
+
+
+
+
+ 01 +

Strategy

+
+

전사적 DX 비전 수립 및 세부 로드맵 도출

+
+
+
+
+ 02 +

Data

+
+

통합 데이터 댐 구축 및 거버넌스 체계 강화

+
+
+
+
+ 03 +

AI Tech

+
+

LLM 기반 비즈니스 자동화 모델 현장 적용

+
+
+
+
+ 04 +

Agile

+
+

도메인 전문가 육성 및 애자일 문화 확산

+
+
+
+
+
+ + +
+
03. Triple Pillars (Horizontal Comparison)
+
+
+
+

Phase 01

+

인프라 현대화 및 클라우드 네이티브 전환

+
Foundation
+
+
+

Phase 02

+

데이터 통합 및 AI 서비스 파일럿 실행

+
Integration
+
+
+

Phase 03

+

전사 확산 및 지속 가능한 AI 운영(MLOps)

+
Optimization
+
+
+
+
+ + +
+
04. Vertical Stack (Step-by-step)
+
+
+
+
01
+
+

데이터 기반 의사결정 문화 정착

+

정량적 데이터 분석 결과에 근거한 객관적 판단 프로세스 구축

+
+
+
+
+
02
+
+

AI 기술 리터러시 전사 확산

+

생성형 AI 등 최신 기술 활용 역량 강화를 위한 단계별 교육 지원

+
+
+
+
+
03
+
+

디지털 기술을 통한 업무 혁신

+

단순 반복 업무의 자동화를 통해 고부가가치 창의적 업무 집중 환경 조성

+
+
+
+
+
+ + +
+
05. Key Metric Emphasis
+
+
+
+

DX 추진 성과 가시화

+

지난 1년간의 디지털 전환 노력을 통해 달성한 핵심 지표입니다.

+
+
+
+
35%
+
비용 절감 효과
+
+
+
2.4x
+
업무 속도 향상
+
+
+
92%
+
직원 만족도
+
+
+
0건
+
보안 사고 발생
+
+
+
+
+
+ + +
+
06. Comparison Table (As-Is vs To-Be)
+
+
+
+
AS-IS
+
TO-BE
+
+
+
+
수작업 기반의 비정형 데이터 관리 및 보고
+
실시간 데이터 대시보드 및 자동 리포팅
+
+
+
파편화된 시스템으로 인한 업무 단절 발생
+
클라우드 기반의 통합 협업 플랫폼 운영
+
+
+
단기적 성과 위주의 산발적 과제 추진
+
중장기 로드맵 기반의 체계적 가치 창출
+
+
+
+
+
+ + +
+
07. Hero Statement (Top Focus + Details)
+
+
+
+

미래 경쟁력 확보를 위한 AI 혁신

+
+
+
+
+
Infrastructure
+

GPU 서버 확보 및 데이터 레이크 고도화

+
+
+
Application
+

워크플로우 내 생성형 AI 에이전트 통합

+
+
+
Capability
+

AI 특화 인재 채용 및 리터러시 교육 프로그램

+
+
+
+
+
+ + +
+
08. Asymmetric (Detailed View)
+
+
+
+
+ Core Highlight +

지능형 운영 체계로의 전환

+
+

기존의 수동적인 운영 방식에서 벗어나, AI가 실시간으로 이상 징후를 감지하고 최적의 대응 방안을 제안하는 능동형 체계로 진화합니다.

+
+
+
+

Real-time Analysis

+

전 공정 데이터의 초단위 수집 및 분석

+
+
+

Predictive Maint.

+

장애 발생 전 사전 알림 및 조치 가이드

+
+
+

Auto Optimization

+

수요 예측 기반 생산량 자동 최적화

+
+
+
+
+
+ + + diff --git a/Front/PLAN.md b/Front/PLAN.md new file mode 100644 index 0000000..3f87056 --- /dev/null +++ b/Front/PLAN.md @@ -0,0 +1,33 @@ +# Design Agent Frontend MVP Implementation Plan + +이 문서는 프론트엔드 MVP 개발 계획 및 Phase Z 시뮬레이션 구현 로드맵입니다. + +## 📅 로드맵 + +### Phase 1: 동적 분석 시뮬레이션 (완료) +- [x] MDX 파일 텍스트 분석 로직 구현 (`STAGE 1`) +- [x] 섹션 개수에 따른 자동 레이아웃 및 존 매핑 로직 구현 (`STAGE 2`) +- [x] 분석 결과에 따른 캔버스 동적 렌더링 연결 + +### Phase 2: 사용자 인터랙션 강화 (진행 중) +- [ ] 레이아웃 프리셋 변경 시 캔버스 즉시 갱신 +- [ ] 프레임 선택 시 캔버스 내 카드 스타일 변경 시뮬레이션 +- [ ] Zone/Region 선택 상태에 따른 시각적 피드백 정교화 + +### Phase 3: 콘텐츠 상세화 및 팝업 시뮬레이션 +- [ ] MDX 원문 무손실 보존을 위한 팝업 UI 구현 +- [ ] `display_strategy` (Preview vs Full) 시뮬레이션 로직 추가 +- [ ] 최종 결과물 데이터 구조(JSON) 내보내기 기능 + +## 📐 핵심 설계 원칙 (Phase Z 준수) + +1. **Deterministic Mapping:** 동일한 MDX 입력에 대해 항상 동일한 초기 레이아웃과 프레임이 제안되어야 함. +2. **User Override Priority:** 사용자의 수동 선택은 시스템 추천보다 우선하며, 명시적으로 `overrides` 객체에 저장됨. +3. **No-Loss Principle:** 슬라이드 캔버스 공간 부족으로 생략된 텍스트는 반드시 팝업을 통해 전체 확인이 가능해야 함. + +## 🔗 STAGE별 연결 지점 + +- **STAGE 1 & 2:** `designAgentApi.ts`의 `parseMdxFile`, `generateSlidePlan` +- **STAGE 3:** `FramePanel.tsx`의 `candidates` 매핑 +- **STAGE 4:** `Home.tsx`의 `handleLayoutSelect`, `handleFrameSelect` +- **STAGE 5:** `Home.tsx`의 `Finalize Slide` 버튼 핸들러 diff --git a/Front/PROCESS.md b/Front/PROCESS.md new file mode 100644 index 0000000..db220a1 --- /dev/null +++ b/Front/PROCESS.md @@ -0,0 +1,54 @@ +# Design Agent Frontend MVP - Phase Z Simulation + +이 문서는 `design-agent` 프론트엔드 프로젝트가 백엔드 파이프라인(Phase Z)의 각 단계와 어떻게 연결되는지 설명합니다. + +--- + +## 📋 STAGE-to-CODE 매핑 가이드 + +본 MVP는 백엔드 없이도 파이프라인의 흐름을 시뮬레이션할 수 있도록 설계되었습니다. + +### STAGE 1: MDX 분석 (Normalization) +- **백엔드 로직:** `section_parser.py` (H2, H3 태그 및 텍스트 블록 추출) +- **프론트엔드 시뮬레이션:** `designAgentApi.ts` 내 `parseMdxFile()` + - 파일의 텍스트를 읽어 실제 `#`, `##`, `###` 패턴을 분석합니다. + - 결과를 `NormalizedContent` 객체로 변환합니다. + +### STAGE 2: 레이아웃 매칭 (Zone Mapping) +- **백엔드 로직:** `layout_selector.py` (섹션 개수/성격에 따른 8개 프리셋 선택) +- **프론트엔드 시뮬레이션:** `designAgentApi.ts` 내 `generateSlidePlan()` + - 분석된 섹션 개수에 따라 `single`, `horizontal-2`, `grid-2x2` 등을 자동으로 결정합니다. + - 각 섹션을 1280x720 캔버스의 특정 `Zone`에 할당합니다. + +### STAGE 3: 프레임 매칭 (V1~V4) +- **백엔드 로직:** `block_reference.py` (Figma 프레임 DB와 콘텐츠 매칭) +- **프론트엔드 시뮬레이션:** `mockDesignAgentData.ts` 및 `FramePanel.tsx` + - 각 Zone(또는 Region)에 대해 V4 매칭 점수가 높은 Top-3 프레임을 제안합니다. + +### STAGE 4: 사용자 조정 (Override) +- **백엔드 로직:** `pipeline.py` (사용자 수동 선택값 반영) +- **프론트엔드 구현:** `Home.tsx` 및 `slidePlanUtils.ts` + - 사용자가 선택한 Layout이나 Frame 정보를 `UserSelection.overrides`에 저장합니다. + - `applyLayout`, `applyFrame` 함수가 이 상태 변화를 관리합니다. + +### STAGE 5: 최종 조립 및 검증 +- **백엔드 로직:** `block_assembler.py` (Jinja2 템플릿 조립 및 Selenium 검증) +- **프론트엔드 구현:** 하단 액션 바의 **Finalize Slide** 버튼 + - 현재 구성된 플랜 데이터를 서버로 전송할 준비가 된 지점입니다. + +--- + +## 🛠️ 핵심 파일 구조 + +- `client/src/services/designAgentApi.ts`: 파이프라인 단계별 로직 시뮬레이션 핵심. +- `client/src/types/designAgent.ts`: Phase Z 위계(Slide > Zone > Region > Frame) 정의. +- `client/src/components/SlideCanvas.tsx`: 16:9 비율 및 레이아웃 프리셋 시각화. +- `client/src/utils/slidePlanUtils.ts`: 사용자 선택 및 데이터 가공 유틸리티. + +--- + +## 🚀 향후 통합 계획 + +1. **API 교체:** `designAgentApi.ts`의 시뮬레이션 함수들을 실제 백엔드 엔드포인트 호출로 교체합니다. +2. **실시간 프리뷰:** 백엔드에서 생성된 `final.html`을 캔버스 내 `iframe` 등으로 직접 렌더링하도록 확장합니다. +3. **팝업 연동:** `display_strategy`에 따른 상세 원문 보기 기능을 실제 MDX 원문과 연결합니다. diff --git a/Front/README.md b/Front/README.md new file mode 100644 index 0000000..b7fa989 --- /dev/null +++ b/Front/README.md @@ -0,0 +1,44 @@ +# Design Agent Frontend + +React + Vite + TypeScript frontend. + +## 위치 + +- `Front/` — 이 폴더 +- 원본 작업 위치 = `D:\ad-hoc\kei\design_agent_front\design-agent\` (로컬 dev) + +## 실행 + +``` +cd Front/ +pnpm install +pnpm dev +``` + +http://localhost:3000 에서 확인. `DESIGN_AGENT_ROOT` env 로 backend 경로 지정 가능. + +## 구조 + +``` +Front/ +├── client/ React frontend (Vite root) +│ └── src/ +│ ├── pages/ +│ ├── components/ +│ ├── services/ +│ └── ... +├── server/ production express +├── vite.config.ts dev plugin + backend 연결 +└── ... +``` + +## Backend 연결 (vite.config.ts) + +Vite dev plugin 이 다음 endpoint 등록 : + +| Endpoint | 동작 | +|---|---| +| `POST /api/run` | MDX + overrides → backend pipeline spawn → run_id 반환 | +| `GET /api/sample-mdx?mdx=...` | sample MDX serve | +| `GET /frame-preview/{n}` | frame thumbnail | +| `GET /data/runs/{run_id}/{path}` | pipeline 산출물 serve | diff --git a/Front/client/index.html b/Front/client/index.html new file mode 100644 index 0000000..d15d750 --- /dev/null +++ b/Front/client/index.html @@ -0,0 +1,15 @@ + + + + + + Design Agent - MDX Slide Planner + + + + + +
+ + + diff --git a/Front/client/public/.gitkeep b/Front/client/public/.gitkeep new file mode 100644 index 0000000..e69de29 diff --git a/Front/client/public/__manus__/debug-collector.js b/Front/client/public/__manus__/debug-collector.js new file mode 100644 index 0000000..0504555 --- /dev/null +++ b/Front/client/public/__manus__/debug-collector.js @@ -0,0 +1,821 @@ +/** + * Manus Debug Collector (agent-friendly) + * + * Captures: + * 1) Console logs + * 2) Network requests (fetch + XHR) + * 3) User interactions (semantic uiEvents: click/type/submit/nav/scroll/etc.) + * + * Data is periodically sent to /__manus__/logs + * Note: uiEvents are mirrored to sessionEvents for sessionReplay.log + */ +(function () { + "use strict"; + + // Prevent double initialization + if (window.__MANUS_DEBUG_COLLECTOR__) return; + + // ========================================================================== + // Configuration + // ========================================================================== + const CONFIG = { + reportEndpoint: "/__manus__/logs", + bufferSize: { + console: 500, + network: 200, + // semantic, agent-friendly UI events + ui: 500, + }, + reportInterval: 2000, + sensitiveFields: [ + "password", + "token", + "secret", + "key", + "authorization", + "cookie", + "session", + ], + maxBodyLength: 10240, + // UI event logging privacy policy: + // - inputs matching sensitiveFields or type=password are masked by default + // - non-sensitive inputs log up to 200 chars + uiInputMaxLen: 200, + uiTextMaxLen: 80, + // Scroll throttling: minimum ms between scroll events + scrollThrottleMs: 500, + }; + + // ========================================================================== + // Storage + // ========================================================================== + const store = { + consoleLogs: [], + networkRequests: [], + uiEvents: [], + lastReportTime: Date.now(), + lastScrollTime: 0, + }; + + // ========================================================================== + // Utility Functions + // ========================================================================== + + function sanitizeValue(value, depth) { + if (depth === void 0) depth = 0; + if (depth > 5) return "[Max Depth]"; + if (value === null) return null; + if (value === undefined) return undefined; + + if (typeof value === "string") { + return value.length > 1000 ? value.slice(0, 1000) + "...[truncated]" : value; + } + + if (typeof value !== "object") return value; + + if (Array.isArray(value)) { + return value.slice(0, 100).map(function (v) { + return sanitizeValue(v, depth + 1); + }); + } + + var sanitized = {}; + for (var k in value) { + if (Object.prototype.hasOwnProperty.call(value, k)) { + var isSensitive = CONFIG.sensitiveFields.some(function (f) { + return k.toLowerCase().indexOf(f) !== -1; + }); + if (isSensitive) { + sanitized[k] = "[REDACTED]"; + } else { + sanitized[k] = sanitizeValue(value[k], depth + 1); + } + } + } + return sanitized; + } + + function formatArg(arg) { + try { + if (arg instanceof Error) { + return { type: "Error", message: arg.message, stack: arg.stack }; + } + if (typeof arg === "object") return sanitizeValue(arg); + return String(arg); + } catch (e) { + return "[Unserializable]"; + } + } + + function formatArgs(args) { + var result = []; + for (var i = 0; i < args.length; i++) result.push(formatArg(args[i])); + return result; + } + + function pruneBuffer(buffer, maxSize) { + if (buffer.length > maxSize) buffer.splice(0, buffer.length - maxSize); + } + + function tryParseJson(str) { + if (typeof str !== "string") return str; + try { + return JSON.parse(str); + } catch (e) { + return str; + } + } + + // ========================================================================== + // Semantic UI Event Logging (agent-friendly) + // ========================================================================== + + function shouldIgnoreTarget(target) { + try { + if (!target || !(target instanceof Element)) return false; + return !!target.closest(".manus-no-record"); + } catch (e) { + return false; + } + } + + function compactText(s, maxLen) { + try { + var t = (s || "").trim().replace(/\s+/g, " "); + if (!t) return ""; + return t.length > maxLen ? t.slice(0, maxLen) + "…" : t; + } catch (e) { + return ""; + } + } + + function elText(el) { + try { + var t = el.innerText || el.textContent || ""; + return compactText(t, CONFIG.uiTextMaxLen); + } catch (e) { + return ""; + } + } + + function describeElement(el) { + if (!el || !(el instanceof Element)) return null; + + var getAttr = function (name) { + return el.getAttribute(name); + }; + + var tag = el.tagName ? el.tagName.toLowerCase() : null; + var id = el.id || null; + var name = getAttr("name") || null; + var role = getAttr("role") || null; + var ariaLabel = getAttr("aria-label") || null; + + var dataLoc = getAttr("data-loc") || null; + var testId = + getAttr("data-testid") || + getAttr("data-test-id") || + getAttr("data-test") || + null; + + var type = tag === "input" ? (getAttr("type") || "text") : null; + var href = tag === "a" ? getAttr("href") || null : null; + + // a small, stable hint for agents (avoid building full CSS paths) + var selectorHint = null; + if (testId) selectorHint = '[data-testid="' + testId + '"]'; + else if (dataLoc) selectorHint = '[data-loc="' + dataLoc + '"]'; + else if (id) selectorHint = "#" + id; + else selectorHint = tag || "unknown"; + + return { + tag: tag, + id: id, + name: name, + type: type, + role: role, + ariaLabel: ariaLabel, + testId: testId, + dataLoc: dataLoc, + href: href, + text: elText(el), + selectorHint: selectorHint, + }; + } + + function isSensitiveField(el) { + if (!el || !(el instanceof Element)) return false; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea") return false; + + var type = (el.getAttribute("type") || "").toLowerCase(); + if (type === "password") return true; + + var name = (el.getAttribute("name") || "").toLowerCase(); + var id = (el.id || "").toLowerCase(); + + return CONFIG.sensitiveFields.some(function (f) { + return name.indexOf(f) !== -1 || id.indexOf(f) !== -1; + }); + } + + function getInputValueSafe(el) { + if (!el || !(el instanceof Element)) return null; + var tag = el.tagName ? el.tagName.toLowerCase() : ""; + if (tag !== "input" && tag !== "textarea" && tag !== "select") return null; + + var v = ""; + try { + v = el.value != null ? String(el.value) : ""; + } catch (e) { + v = ""; + } + + if (isSensitiveField(el)) return { masked: true, length: v.length }; + + if (v.length > CONFIG.uiInputMaxLen) v = v.slice(0, CONFIG.uiInputMaxLen) + "…"; + return v; + } + + function logUiEvent(kind, payload) { + var entry = { + timestamp: Date.now(), + kind: kind, + url: location.href, + viewport: { width: window.innerWidth, height: window.innerHeight }, + payload: sanitizeValue(payload), + }; + store.uiEvents.push(entry); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + } + + function installUiEventListeners() { + // Clicks + document.addEventListener( + "click", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("click", { + target: describeElement(t), + x: e.clientX, + y: e.clientY, + }); + }, + true + ); + + // Typing "commit" events + document.addEventListener( + "change", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("change", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + document.addEventListener( + "focusin", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusin", { target: describeElement(t) }); + }, + true + ); + + document.addEventListener( + "focusout", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("focusout", { + target: describeElement(t), + value: getInputValueSafe(t), + }); + }, + true + ); + + // Enter/Escape are useful for form flows & modals + document.addEventListener( + "keydown", + function (e) { + if (e.key !== "Enter" && e.key !== "Escape") return; + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("keydown", { key: e.key, target: describeElement(t) }); + }, + true + ); + + // Form submissions + document.addEventListener( + "submit", + function (e) { + var t = e.target; + if (shouldIgnoreTarget(t)) return; + logUiEvent("submit", { target: describeElement(t) }); + }, + true + ); + + // Throttled scroll events + window.addEventListener( + "scroll", + function () { + var now = Date.now(); + if (now - store.lastScrollTime < CONFIG.scrollThrottleMs) return; + store.lastScrollTime = now; + + logUiEvent("scroll", { + scrollX: window.scrollX, + scrollY: window.scrollY, + documentHeight: document.documentElement.scrollHeight, + viewportHeight: window.innerHeight, + }); + }, + { passive: true } + ); + + // Navigation tracking for SPAs + function nav(reason) { + logUiEvent("navigate", { reason: reason }); + } + + var origPush = history.pushState; + history.pushState = function () { + origPush.apply(this, arguments); + nav("pushState"); + }; + + var origReplace = history.replaceState; + history.replaceState = function () { + origReplace.apply(this, arguments); + nav("replaceState"); + }; + + window.addEventListener("popstate", function () { + nav("popstate"); + }); + window.addEventListener("hashchange", function () { + nav("hashchange"); + }); + } + + // ========================================================================== + // Console Interception + // ========================================================================== + + var originalConsole = { + log: console.log.bind(console), + debug: console.debug.bind(console), + info: console.info.bind(console), + warn: console.warn.bind(console), + error: console.error.bind(console), + }; + + ["log", "debug", "info", "warn", "error"].forEach(function (method) { + console[method] = function () { + var args = Array.prototype.slice.call(arguments); + + var entry = { + timestamp: Date.now(), + level: method.toUpperCase(), + args: formatArgs(args), + stack: method === "error" ? new Error().stack : null, + }; + + store.consoleLogs.push(entry); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + originalConsole[method].apply(console, args); + }; + }); + + window.addEventListener("error", function (event) { + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UncaughtError", + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + stack: event.error ? event.error.stack : null, + }, + ], + stack: event.error ? event.error.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + // Mark an error moment in UI event stream for agents + logUiEvent("error", { + message: event.message, + filename: event.filename, + lineno: event.lineno, + colno: event.colno, + }); + }); + + window.addEventListener("unhandledrejection", function (event) { + var reason = event.reason; + store.consoleLogs.push({ + timestamp: Date.now(), + level: "ERROR", + args: [ + { + type: "UnhandledRejection", + reason: reason && reason.message ? reason.message : String(reason), + stack: reason && reason.stack ? reason.stack : null, + }, + ], + stack: reason && reason.stack ? reason.stack : null, + }); + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + + logUiEvent("unhandledrejection", { + reason: reason && reason.message ? reason.message : String(reason), + }); + }); + + // ========================================================================== + // Fetch Interception + // ========================================================================== + + var originalFetch = window.fetch.bind(window); + + window.fetch = function (input, init) { + init = init || {}; + var startTime = Date.now(); + // Handle string, Request object, or URL object + var url = typeof input === "string" + ? input + : (input && (input.url || input.href || String(input))) || ""; + var method = init.method || (input && input.method) || "GET"; + + // Don't intercept internal requests + if (url.indexOf("/__manus__/") === 0) { + return originalFetch(input, init); + } + + // Safely parse headers (avoid breaking if headers format is invalid) + var requestHeaders = {}; + try { + if (init.headers) { + requestHeaders = Object.fromEntries(new Headers(init.headers).entries()); + } + } catch (e) { + requestHeaders = { _parseError: true }; + } + + var entry = { + timestamp: startTime, + type: "fetch", + method: method.toUpperCase(), + url: url, + request: { + headers: requestHeaders, + body: init.body ? sanitizeValue(tryParseJson(init.body)) : null, + }, + response: null, + duration: null, + error: null, + }; + + return originalFetch(input, init) + .then(function (response) { + entry.duration = Date.now() - startTime; + + var contentType = (response.headers.get("content-type") || "").toLowerCase(); + var contentLength = response.headers.get("content-length"); + + entry.response = { + status: response.status, + statusText: response.statusText, + headers: Object.fromEntries(response.headers.entries()), + body: null, + }; + + // Semantic network hint for agents on failures (sync, no need to wait for body) + if (response.status >= 400) { + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + status: response.status, + statusText: response.statusText, + }); + } + + // Skip body capture for streaming responses (SSE, etc.) to avoid memory leaks + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + if (isStreaming) { + entry.response.body = "[Streaming response - not captured]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for large responses to avoid memory issues + if (contentLength && parseInt(contentLength, 10) > CONFIG.maxBodyLength) { + entry.response.body = "[Response too large: " + contentLength + " bytes]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + if (isBinary) { + entry.response.body = "[Binary content: " + contentType + "]"; + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + return response; + } + + // For text responses, clone and read body in background + var clonedResponse = response.clone(); + + // Async: read body in background, don't block the response + clonedResponse + .text() + .then(function (text) { + if (text.length <= CONFIG.maxBodyLength) { + entry.response.body = sanitizeValue(tryParseJson(text)); + } else { + entry.response.body = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } + }) + .catch(function () { + entry.response.body = "[Unable to read body]"; + }) + .finally(function () { + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + }); + + // Return response immediately, don't wait for body reading + return response; + }) + .catch(function (error) { + entry.duration = Date.now() - startTime; + entry.error = { message: error.message, stack: error.stack }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "fetch", + method: entry.method, + url: entry.url, + message: error.message, + }); + + throw error; + }); + }; + + // ========================================================================== + // XHR Interception + // ========================================================================== + + var originalXHROpen = XMLHttpRequest.prototype.open; + var originalXHRSend = XMLHttpRequest.prototype.send; + + XMLHttpRequest.prototype.open = function (method, url) { + this._manusData = { + method: (method || "GET").toUpperCase(), + url: url, + startTime: null, + }; + return originalXHROpen.apply(this, arguments); + }; + + XMLHttpRequest.prototype.send = function (body) { + var xhr = this; + + if ( + xhr._manusData && + xhr._manusData.url && + xhr._manusData.url.indexOf("/__manus__/") !== 0 + ) { + xhr._manusData.startTime = Date.now(); + xhr._manusData.requestBody = body ? sanitizeValue(tryParseJson(body)) : null; + + xhr.addEventListener("load", function () { + var contentType = (xhr.getResponseHeader("content-type") || "").toLowerCase(); + var responseBody = null; + + // Skip body capture for streaming responses + var isStreaming = contentType.indexOf("text/event-stream") !== -1 || + contentType.indexOf("application/stream") !== -1 || + contentType.indexOf("application/x-ndjson") !== -1; + + // Skip body capture for binary content types + var isBinary = contentType.indexOf("image/") !== -1 || + contentType.indexOf("video/") !== -1 || + contentType.indexOf("audio/") !== -1 || + contentType.indexOf("application/octet-stream") !== -1 || + contentType.indexOf("application/pdf") !== -1 || + contentType.indexOf("application/zip") !== -1; + + if (isStreaming) { + responseBody = "[Streaming response - not captured]"; + } else if (isBinary) { + responseBody = "[Binary content: " + contentType + "]"; + } else { + // Safe to read responseText for text responses + try { + var text = xhr.responseText || ""; + if (text.length > CONFIG.maxBodyLength) { + responseBody = text.slice(0, CONFIG.maxBodyLength) + "...[truncated]"; + } else { + responseBody = sanitizeValue(tryParseJson(text)); + } + } catch (e) { + // responseText may throw for non-text responses + responseBody = "[Unable to read response: " + e.message + "]"; + } + } + + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: { + status: xhr.status, + statusText: xhr.statusText, + body: responseBody, + }, + duration: Date.now() - xhr._manusData.startTime, + error: null, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + if (entry.response && entry.response.status >= 400) { + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + status: entry.response.status, + statusText: entry.response.statusText, + }); + } + }); + + xhr.addEventListener("error", function () { + var entry = { + timestamp: xhr._manusData.startTime, + type: "xhr", + method: xhr._manusData.method, + url: xhr._manusData.url, + request: { body: xhr._manusData.requestBody }, + response: null, + duration: Date.now() - xhr._manusData.startTime, + error: { message: "Network error" }, + }; + + store.networkRequests.push(entry); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + + logUiEvent("network_error", { + kind: "xhr", + method: entry.method, + url: entry.url, + message: "Network error", + }); + }); + } + + return originalXHRSend.apply(this, arguments); + }; + + // ========================================================================== + // Data Reporting + // ========================================================================== + + function reportLogs() { + var consoleLogs = store.consoleLogs.splice(0); + var networkRequests = store.networkRequests.splice(0); + var uiEvents = store.uiEvents.splice(0); + + // Skip if no new data + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return Promise.resolve(); + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + // agent-friendly semantic events + uiEvents: uiEvents, + }; + + return originalFetch(CONFIG.reportEndpoint, { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify(payload), + }).catch(function () { + // Put data back on failure (but respect limits) + store.consoleLogs = consoleLogs.concat(store.consoleLogs); + store.networkRequests = networkRequests.concat(store.networkRequests); + store.uiEvents = uiEvents.concat(store.uiEvents); + + pruneBuffer(store.consoleLogs, CONFIG.bufferSize.console); + pruneBuffer(store.networkRequests, CONFIG.bufferSize.network); + pruneBuffer(store.uiEvents, CONFIG.bufferSize.ui); + }); + } + + // Periodic reporting + setInterval(reportLogs, CONFIG.reportInterval); + + // Report on page unload + window.addEventListener("beforeunload", function () { + var consoleLogs = store.consoleLogs; + var networkRequests = store.networkRequests; + var uiEvents = store.uiEvents; + + if ( + consoleLogs.length === 0 && + networkRequests.length === 0 && + uiEvents.length === 0 + ) { + return; + } + + var payload = { + timestamp: Date.now(), + consoleLogs: consoleLogs, + networkRequests: networkRequests, + // Mirror uiEvents to sessionEvents for sessionReplay.log + sessionEvents: uiEvents, + uiEvents: uiEvents, + }; + + if (navigator.sendBeacon) { + var payloadStr = JSON.stringify(payload); + // sendBeacon has ~64KB limit, truncate if too large + var MAX_BEACON_SIZE = 60000; // Leave some margin + if (payloadStr.length > MAX_BEACON_SIZE) { + // Prioritize: keep recent events, drop older logs + var truncatedPayload = { + timestamp: Date.now(), + consoleLogs: consoleLogs.slice(-50), + networkRequests: networkRequests.slice(-20), + sessionEvents: uiEvents.slice(-100), + uiEvents: uiEvents.slice(-100), + _truncated: true, + }; + payloadStr = JSON.stringify(truncatedPayload); + } + navigator.sendBeacon(CONFIG.reportEndpoint, payloadStr); + } + }); + + // ========================================================================== + // Initialization + // ========================================================================== + + // Install semantic UI listeners ASAP + try { + installUiEventListeners(); + } catch (e) { + console.warn("[Manus] Failed to install UI listeners:", e); + } + + // Mark as initialized + window.__MANUS_DEBUG_COLLECTOR__ = { + version: "2.0-no-rrweb", + store: store, + forceReport: reportLogs, + }; + + console.debug("[Manus] Debug collector initialized (no rrweb, UI events only)"); +})(); diff --git a/Front/client/src/App.tsx b/Front/client/src/App.tsx new file mode 100644 index 0000000..ae6e706 --- /dev/null +++ b/Front/client/src/App.tsx @@ -0,0 +1,30 @@ +import { TooltipProvider } from "@/components/ui/tooltip"; +import NotFound from "@/pages/NotFound"; +import { Route, Switch } from "wouter"; +import ErrorBoundary from "./components/ErrorBoundary"; +import { ThemeProvider } from "./contexts/ThemeContext"; +import Home from "./pages/Home"; + +function Router() { + return ( + + + + + + ); +} + +function App() { + return ( + + + + + + + + ); +} + +export default App; diff --git a/Front/client/src/components/BottomActions.tsx b/Front/client/src/components/BottomActions.tsx new file mode 100644 index 0000000..e72278c --- /dev/null +++ b/Front/client/src/components/BottomActions.tsx @@ -0,0 +1,101 @@ +/** + * BottomActions - 하단 액션 버튼 영역 + * + * 생성하기, 다운로드, 연동하기 버튼 컴포넌트 + */ + +import { Sparkles, Download, Link2, Loader2 } from "lucide-react"; +import { Button } from "@/components/ui/button"; +import { toast } from "sonner"; +import type { SlidePlan, UserSelection } from "../types/designAgent"; +import { serializeSlidePlan } from "../utils/slidePlanUtils"; + +interface BottomActionsProps { + slidePlan: SlidePlan | null; + userSelection: UserSelection; + isLoading: boolean; + onGenerate: () => void; +} + +export default function BottomActions({ + slidePlan, + userSelection, + isLoading, + onGenerate, +}: BottomActionsProps) { + const handleDownload = () => { + if (!slidePlan) { + toast.error("슬라이드 플랜이 없습니다. 먼저 생성하기를 눌러주세요."); + return; + } + + const json = serializeSlidePlan(slidePlan, userSelection); + console.log("[Download] SlidePlan JSON:", json); + + // JSON 파일 다운로드 + const blob = new Blob([json], { type: "application/json" }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = `slide-plan-${Date.now()}.json`; + a.click(); + URL.revokeObjectURL(url); + + toast.success("SlidePlan JSON이 다운로드되었습니다."); + }; + + const handleConnect = () => { + toast.info("연동하기 기능은 파이프라인 연결 후 활성화됩니다."); + }; + + return ( +
+
+ 4 + 액션 +
+ {/* 생성하기 */} + + + {/* 다운로드 */} + + + {/* 연동하기 */} + +
+ ); +} diff --git a/Front/client/src/components/ErrorBoundary.tsx b/Front/client/src/components/ErrorBoundary.tsx new file mode 100644 index 0000000..1422986 --- /dev/null +++ b/Front/client/src/components/ErrorBoundary.tsx @@ -0,0 +1,62 @@ +import { cn } from "@/lib/utils"; +import { AlertTriangle, RotateCcw } from "lucide-react"; +import { Component, ReactNode } from "react"; + +interface Props { + children: ReactNode; +} + +interface State { + hasError: boolean; + error: Error | null; +} + +class ErrorBoundary extends Component { + constructor(props: Props) { + super(props); + this.state = { hasError: false, error: null }; + } + + static getDerivedStateFromError(error: Error): State { + return { hasError: true, error }; + } + + render() { + if (this.state.hasError) { + return ( +
+
+ + +

An unexpected error occurred.

+ +
+
+                {this.state.error?.stack}
+              
+
+ + +
+
+ ); + } + + return this.props.children; + } +} + +export default ErrorBoundary; diff --git a/Front/client/src/components/FramePanel.tsx b/Front/client/src/components/FramePanel.tsx new file mode 100644 index 0000000..be4ad24 --- /dev/null +++ b/Front/client/src/components/FramePanel.tsx @@ -0,0 +1,220 @@ +import React from 'react'; +import { + Check, + Zap, + Layout, + Trophy +} from 'lucide-react'; +import { Badge } from '@/components/ui/badge'; +import { motion } from 'framer-motion'; +import type { Zone, InternalRegion, UserSelection, FrameCandidate, SlidePlan } from '../types/designAgent'; +import { getSectionsForZone } from '../utils/slidePlanUtils'; + +interface FramePanelProps { + slidePlan: SlidePlan | null; + selectedZone: Zone | null; + selectedRegion: InternalRegion | null; + userSelection: UserSelection; + onFrameSelect: (frameId: string) => void; + onNoDesignToggle: () => void; +} + +export default function FramePanel({ + slidePlan, + selectedZone, + selectedRegion, + userSelection, + onFrameSelect, +}: FramePanelProps) { + + // Zone 중심 컨셉: 선택된 존에 할당된 섹션들의 프레임 후보들을 수집 + const assignedSectionIds = selectedZone ? getSectionsForZone(selectedZone, userSelection) : []; + + // 해당 섹션들이 포함된 원본 리전들에서 후보군 추출 + const candidates: FrameCandidate[] = React.useMemo(() => { + if (!slidePlan || !selectedZone) return []; + + // 단순화: 선택된 존의 첫 번째 리전의 후보군을 우선 사용 (Phase Z MVP1 기준) + // 실제로는 할당된 섹션에 따라 동적으로 필터링된 후보군이 필요함 + const targetRegion = selectedRegion || selectedZone.internal_regions[0]; + return targetRegion?.frame_candidates || []; + }, [slidePlan, selectedZone, selectedRegion]); + + const currentFrameId = React.useMemo(() => { + const targetRegion = selectedRegion || selectedZone?.internal_regions[0]; + if (!targetRegion) return null; + return userSelection.overrides.zone_frames[targetRegion.id] || targetRegion.frame_match_strategy.frame_id; + }, [selectedZone, selectedRegion, userSelection.overrides.zone_frames]); + + if (!selectedZone) { + return ( +
+ +

Select a Zone
to View Designs

+
+ ); + } + + return ( +
+ {/* Header */} +
+
+

+ + Design Wheel +

+ + TOP {candidates.length} + +
+

+ 현재 구역의 섹션({assignedSectionIds.join(', ')})에 최적화된 추천 디자인입니다. +

+
+ + {/* Vertical Wheel List */} +
+ {candidates.length === 0 ? ( +
+

No Candidates Available

+
+ ) : ( + candidates.map((candidate, index) => { + const isSelected = currentFrameId === candidate.id; + + const isReject = candidate.label === "reject"; + // catalog 미등록 = backend Step 7-A 가 override 시도해도 skip. + // catalogRegistered === false 만 체크 (undefined = 정보 없음, 일반 처리). + const isCatalogMissing = candidate.catalogRegistered === false; + return ( + + + + ); + }) + )} +
+
+
+ ); +} diff --git a/Front/client/src/components/LayoutPanel.tsx b/Front/client/src/components/LayoutPanel.tsx new file mode 100644 index 0000000..84dc6f5 --- /dev/null +++ b/Front/client/src/components/LayoutPanel.tsx @@ -0,0 +1,244 @@ +/** + * LayoutPanel - 우측 레이아웃 패널 + * + * Zone 내부 배치 구조를 선택하는 패널 + * 목표 이미지 기준: 8개 레이아웃 썸네일 (3×3 그리드) + */ + +import type { LayoutCandidate, Zone, UserSelection, LayoutPresetId } from "../types/designAgent"; +import { getEffectiveLayoutId } from "../utils/slidePlanUtils"; +import { MOCK_LAYOUT_CANDIDATES } from "../data/mockDesignAgentData"; + +interface LayoutPanelProps { + selectedZone: Zone | null; + userSelection: UserSelection; + onLayoutSelect: (layoutId: string) => void; + /** Phase 2 : 카드 별 "적용하기" 버튼 → 빈 layout 모드로 전환. */ + onApplyLayout?: (layoutId: LayoutPresetId) => void; + /** + * step07_layout.json `data.layout_candidates` (Phase Z 가 zone topology + unit_count + * 로 추려낸 가능한 preset list). 비어있거나 undefined 면 모든 8 preset 표시. + */ + availableLayoutIds?: string[]; + /** step07_layout.json `data.layout_preset` (Phase Z 가 default 로 결정한 preset). */ + pipelineSelectedLayoutId?: string; + /** Phase 2 : 현재 적용 중인 pending layout. 그 카드에는 "적용 중" 표시. */ + pendingLayoutId?: LayoutPresetId | null; +} + +// 레이아웃 타입별 SVG 썸네일 +function LayoutThumb({ type, id }: { type: LayoutCandidate["type"]; id: string }) { + switch (id) { + case "single": + return ( + + + + + + + ); + case "horizontal-2": + // backend: rows topology = 위/아래 2 zone (top, bottom) + return ( + + + + + + + ); + case "vertical-2": + // backend: cols topology = 좌/우 2 zone (left, right) + return ( + + + + + + + ); + case "top-1-bottom-2": + // backend: T topology = 위 1 zone + 아래 좌/우 2 zone + return ( + + + + + + + + ); + case "top-2-bottom-1": + // backend: inverted-T topology = 위 좌/우 2 zone + 아래 1 zone + return ( + + + + + + + + ); + case "left-1-right-2": + return ( + + + + + + + ); + case "left-2-right-1": + return ( + + + + + + + ); + case "grid-2x2": + return ( + + + + + + + + + ); + default: + return ( + + + + ); + } +} + +export default function LayoutPanel({ + selectedZone, + userSelection, + onLayoutSelect, + onApplyLayout, + availableLayoutIds, + pipelineSelectedLayoutId, + pendingLayoutId, +}: LayoutPanelProps) { + // Layout 패널은 항상 8 preset 전체 표시 (사용자 lock 2026-05-08). + // step07 의 layout_candidates 는 *호환성 표시용* 으로만 사용 — 호환 안 되는 preset + // 은 회색 / opacity 낮춤 / tooltip 으로 차별. 클릭 자체는 가능 (강제 적용 가능). + const candidates = MOCK_LAYOUT_CANDIDATES; + const compatibleSet = new Set( + availableLayoutIds && availableLayoutIds.length > 0 + ? availableLayoutIds + : candidates.map((c) => c.id) + ); + + const effectiveLayoutId = + userSelection.overrides.layout_preset || + pipelineSelectedLayoutId || + "horizontal-2"; + + return ( +
+ {/* 헤더 */} +
+

+ Slide Layout Presets +

+
+ + {/* 레이아웃 그리드 */} +
+ {selectedZone && ( +
+
+ Editing Zone: {selectedZone.zone_id} +
+ )} +
+ {candidates.map((candidate) => { + const isActive = effectiveLayoutId === candidate.id; + const isCompatible = compatibleSet.has(candidate.id); + const isPendingActive = pendingLayoutId === candidate.id; + return ( +
onLayoutSelect(candidate.id)} + title={ + isCompatible + ? candidate.description + : `${candidate.description}\n\n⚠ 현재 unit_count 와 호환 안 됨 — 강제 적용 시 zone 매핑 fail 가능` + } + > + {/* 썸네일 */} +
+ +
+ {/* 이름 */} + + {candidate.name} + + {/* 선택 체크 */} + {isActive && ( +
+ + + +
+ )} + {/* 호환 안 됨 배지 */} + {!isCompatible && !isActive && !isPendingActive && ( +
+ ! +
+ )} + {/* Pending 적용 배지 */} + {isPendingActive && ( +
+ 적용중 +
+ )} + + {/* "적용하기" 버튼 — 현재 적용된 layout 이 아니고 onApplyLayout 핸들러 + 있을 때 표시. 클릭 시 빈 layout 모드 진입 (이중 액션). */} + {!isActive && !isPendingActive && onApplyLayout && ( + + )} +
+ ); + })} +
+
+
+ ); +} diff --git a/Front/client/src/components/LeftMdxPanel.tsx b/Front/client/src/components/LeftMdxPanel.tsx new file mode 100644 index 0000000..60d9eb1 --- /dev/null +++ b/Front/client/src/components/LeftMdxPanel.tsx @@ -0,0 +1,344 @@ +/** + * LeftMdxPanel - 좌측 MDX 구조 탐색기 + * + * 기능: + * - MDX 파일 업로드 버튼 + * - 추출된 섹션들의 계층 구조(Level 1~3) 표시 + * - 섹션 클릭 시 중앙 캔버스의 해당 구역 하이라이트 + */ + +import React from 'react'; +import { + FileText, + ChevronRight, + Upload, + Zap, + Layers, + List, +} from 'lucide-react'; +import { Button } from '@/components/ui/button'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Progress } from '@/components/ui/progress'; +import { toast } from 'sonner'; +import type { NormalizedContent } from '../types/designAgent'; + +interface LeftMdxPanelProps { + normalizedContent: NormalizedContent | null; + uploadedFile: File | null; + isLoading: boolean; + selectedSectionId: string | null; + hasSlidePlan: boolean; + /** 사용자가 frame / section / layout / zone 등 override 를 변경했을 때 true. + * 하단 버튼이 "선택대로 재생성하기" 강조 버튼으로 전환. */ + hasPendingChanges?: boolean; + onFileUpload: (file: File) => void; + onGenerate: () => void; + onSectionClick: (sectionId: string) => void; + /** 사용자 lock 2026-05-14 — 좌측 패널에 03/04/05 fix 고정 list. 클릭 시 callback. */ + onSelectSample?: (which: "03" | "04" | "05") => void; + selectedSample?: "03" | "04" | "05" | null; +} + +const SAMPLE_MDX_LIST: { key: "03" | "04" | "05"; label: string; subtitle: string }[] = [ + { key: "03", label: "03. DX 시행을 위한 필수 요건", subtitle: "필수 요건 + Process/Product 혁신" }, + { key: "04", label: "04. DX 지연 요인", subtitle: "DX 인식 + 정책/조직 실태" }, + { key: "05", label: "05. 설계 방식의 왜곡", subtitle: "설계 자동화 오용 + S/W 한계" }, +]; + +export default function LeftMdxPanel({ + normalizedContent, + uploadedFile, + isLoading, + selectedSectionId, + hasSlidePlan, + hasPendingChanges = false, + onFileUpload, + onGenerate, + onSectionClick, + onSelectSample, + selectedSample = null, +}: LeftMdxPanelProps) { + const fileInputRef = React.useRef(null); + + const handleFileChange = (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (file) onFileUpload(file); + }; + + return ( +
+ {/* ── 헤더: 문서 정보 ── */} +
+

+ + MDX Source +

+ + {/* 2026-05-14 — 03/04/05 fix 고정 list. 클릭 시 해당 mdx 자동 fetch + 분석. + frame/layout override 는 분석 후 우측 패널에서 가능. */} + {onSelectSample && ( +
+ {SAMPLE_MDX_LIST.map((s) => { + const isActive = selectedSample === s.key; + return ( + + ); + })} +
+ )} + + {uploadedFile ? ( +
+
+ +
+
+

{uploadedFile.name}

+

{(uploadedFile.size / 1024).toFixed(1)} KB

+
+ {!hasSlidePlan && ( + + )} +
+ ) : ( + + )} +
+ + {/* ── 분석 진행 상태 (로딩 시) ── */} + {isLoading && ( +
+
+ 콘텐츠 분석 중... + STAGE 1 +
+ +
+ )} + + {/* ── 섹션 트리 리스트 ── */} + + {!normalizedContent ? ( +
+ +

파일을 업로드하면
문서 구조가 표시됩니다.

+
+ ) : ( +
+ {/* 문서 제목 (Level 1) */} +
+

+ {normalizedContent.title} +

+
+ + {/* 섹션들 (Level 2+) — native HTML5 drag (framer-motion drag 와 충돌 + 있어서 native 만 사용). dataTransfer 에 sectionId / section-id / + text/plain 다 set 해서 SlideCanvas drop 이 어떤 키로든 받게. */} + {normalizedContent.sections.map((section) => { + const isSelected = selectedSectionId === section.id; + const subSections = section.sub_sections ?? []; + + return ( +
+ {/* 중목차 (S1, S2 ...) */} + + + {/* 소목차 (S1.1, S1.2 ...) — 펼친 상태로 indent 표시. 각자 draggable. */} + {subSections.length > 0 && ( +
+ {subSections.map((sub) => { + const isSubSelected = selectedSectionId === sub.id; + return ( + + ); + })} +
+ )} +
+ ); + })} +
+ )} +
+ + {/* ── 하단 분석 버튼 ── */} +
+ {!normalizedContent ? ( +
+

MDX 분석 대기 중

+ +
+ ) : ( +
+ {!hasSlidePlan ? ( + + ) : hasPendingChanges ? ( + // override 변경 있음 — 강조 "선택대로 재생성하기" 버튼 +
+
+
+ + Pending Changes + +
+ +

+ 레이아웃 / 프레임 / 섹션 변경을 backend 에 적용해 새 final.html 을 + 생성합니다. (※ Step D backend forwarding 연결 후 활성) +

+
+ ) : ( +
+
+ + + 플랜 생성 완료 + + +
+ +
+ )} +
+ )} +
+
+ ); +} diff --git a/Front/client/src/components/ManusDialog.tsx b/Front/client/src/components/ManusDialog.tsx new file mode 100644 index 0000000..0aeff4b --- /dev/null +++ b/Front/client/src/components/ManusDialog.tsx @@ -0,0 +1,85 @@ +import { useEffect, useState } from "react"; + +import { Button } from "@/components/ui/button"; +import { + Dialog, + DialogContent, + DialogDescription, + DialogFooter, + DialogTitle, +} from "@/components/ui/dialog"; + +interface ManusDialogProps { + title?: string; + logo?: string; + open?: boolean; + onLogin: () => void; + onOpenChange?: (open: boolean) => void; + onClose?: () => void; +} + +export function ManusDialog({ + title, + logo, + open = false, + onLogin, + onOpenChange, + onClose, +}: ManusDialogProps) { + const [internalOpen, setInternalOpen] = useState(open); + + useEffect(() => { + if (!onOpenChange) { + setInternalOpen(open); + } + }, [open, onOpenChange]); + + const handleOpenChange = (nextOpen: boolean) => { + if (onOpenChange) { + onOpenChange(nextOpen); + } else { + setInternalOpen(nextOpen); + } + + if (!nextOpen) { + onClose?.(); + } + }; + + return ( + + +
+ {logo ? ( +
+ Dialog graphic +
+ ) : null} + + {/* Title and subtitle */} + {title ? ( + + {title} + + ) : null} + + Please login with Manus to continue + +
+ + + {/* Login button */} + + +
+
+ ); +} diff --git a/Front/client/src/components/Map.tsx b/Front/client/src/components/Map.tsx new file mode 100644 index 0000000..4849e05 --- /dev/null +++ b/Front/client/src/components/Map.tsx @@ -0,0 +1,155 @@ +/** + * GOOGLE MAPS FRONTEND INTEGRATION - ESSENTIAL GUIDE + * + * USAGE FROM PARENT COMPONENT: + * ====== + * + * const mapRef = useRef(null); + * + * { + * mapRef.current = map; // Store to control map from parent anytime, google map itself is in charge of the re-rendering, not react state. + * + * + * ====== + * Available Libraries and Core Features: + * ------------------------------- + * 📍 MARKER (from `marker` library) + * - Attaches to map using { map, position } + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: { lat: 37.7749, lng: -122.4194 }, + * title: "San Francisco", + * }); + * + * ------------------------------- + * 🏢 PLACES (from `places` library) + * - Does not attach directly to map; use data with your map manually. + * const place = new google.maps.places.Place({ id: PLACE_ID }); + * await place.fetchFields({ fields: ["displayName", "location"] }); + * map.setCenter(place.location); + * new google.maps.marker.AdvancedMarkerElement({ map, position: place.location }); + * + * ------------------------------- + * 🧭 GEOCODER (from `geocoding` library) + * - Standalone service; manually apply results to map. + * const geocoder = new google.maps.Geocoder(); + * geocoder.geocode({ address: "New York" }, (results, status) => { + * if (status === "OK" && results[0]) { + * map.setCenter(results[0].geometry.location); + * new google.maps.marker.AdvancedMarkerElement({ + * map, + * position: results[0].geometry.location, + * }); + * } + * }); + * + * ------------------------------- + * 📐 GEOMETRY (from `geometry` library) + * - Pure utility functions; not attached to map. + * const dist = google.maps.geometry.spherical.computeDistanceBetween(p1, p2); + * + * ------------------------------- + * 🛣️ ROUTES (from `routes` library) + * - Combines DirectionsService (standalone) + DirectionsRenderer (map-attached) + * const directionsService = new google.maps.DirectionsService(); + * const directionsRenderer = new google.maps.DirectionsRenderer({ map }); + * directionsService.route( + * { origin, destination, travelMode: "DRIVING" }, + * (res, status) => status === "OK" && directionsRenderer.setDirections(res) + * ); + * + * ------------------------------- + * 🌦️ MAP LAYERS (attach directly to map) + * - new google.maps.TrafficLayer().setMap(map); + * - new google.maps.TransitLayer().setMap(map); + * - new google.maps.BicyclingLayer().setMap(map); + * + * ------------------------------- + * ✅ SUMMARY + * - “map-attached” → AdvancedMarkerElement, DirectionsRenderer, Layers. + * - “standalone” → Geocoder, DirectionsService, DistanceMatrixService, ElevationService. + * - “data-only” → Place, Geometry utilities. + */ + +/// + +import { useEffect, useRef } from "react"; +import { usePersistFn } from "@/hooks/usePersistFn"; +import { cn } from "@/lib/utils"; + +declare global { + interface Window { + google?: typeof google; + } +} + +const API_KEY = import.meta.env.VITE_FRONTEND_FORGE_API_KEY; +const FORGE_BASE_URL = + import.meta.env.VITE_FRONTEND_FORGE_API_URL || + "https://forge.butterfly-effect.dev"; +const MAPS_PROXY_URL = `${FORGE_BASE_URL}/v1/maps/proxy`; + +function loadMapScript() { + return new Promise(resolve => { + const script = document.createElement("script"); + script.src = `${MAPS_PROXY_URL}/maps/api/js?key=${API_KEY}&v=weekly&libraries=marker,places,geocoding,geometry`; + script.async = true; + script.crossOrigin = "anonymous"; + script.onload = () => { + resolve(null); + script.remove(); // Clean up immediately + }; + script.onerror = () => { + console.error("Failed to load Google Maps script"); + }; + document.head.appendChild(script); + }); +} + +interface MapViewProps { + className?: string; + initialCenter?: google.maps.LatLngLiteral; + initialZoom?: number; + onMapReady?: (map: google.maps.Map) => void; +} + +export function MapView({ + className, + initialCenter = { lat: 37.7749, lng: -122.4194 }, + initialZoom = 12, + onMapReady, +}: MapViewProps) { + const mapContainer = useRef(null); + const map = useRef(null); + + const init = usePersistFn(async () => { + await loadMapScript(); + if (!mapContainer.current) { + console.error("Map container not found"); + return; + } + map.current = new window.google.maps.Map(mapContainer.current, { + zoom: initialZoom, + center: initialCenter, + mapTypeControl: true, + fullscreenControl: true, + zoomControl: true, + streetViewControl: true, + mapId: "DEMO_MAP_ID", + }); + if (onMapReady) { + onMapReady(map.current); + } + }); + + useEffect(() => { + init(); + }, [init]); + + return ( +
+ ); +} diff --git a/Front/client/src/components/SlideCanvas.tsx b/Front/client/src/components/SlideCanvas.tsx new file mode 100644 index 0000000..87783d4 --- /dev/null +++ b/Front/client/src/components/SlideCanvas.tsx @@ -0,0 +1,805 @@ +/** + * SlideCanvas — Phase Z 실제 결과 (final.html) iframe 표시. + * + * 역할 (Phase 1 — MDX upload → 파이프라인 실행 → 결과 표시): + * - MDX 업로드 후 server 가 Phase Z 실행 + * - run_id 의 final.html 을 iframe 으로 표시 + * - 시뮬레이션 / mock 슬라이드 layout 렌더 X + * + * 이전 simulation (FocusSplitLayout / QuadrantGridLayout / DEFAULT_GEOMETRIES / + * Phase Z Engine header / Summary footer) 모두 제거 — frontend 가 슬라이드를 *만들지 않음*, + * backend 결과를 *읽기만 함*. + * + * Layout / zone / region / frame 변경 UI 는 좌우 패널 (LayoutPanel / FramePanel) + * 에서 수행. 본 컴포넌트는 *결과 viewer*. + */ + +import { useRef, useEffect, useState } from "react"; +import { Loader2, Upload as UploadIcon } from "lucide-react"; +import type { + SlidePlan, + UserSelection, + NormalizedContent, +} from "../types/designAgent"; + +interface SlideCanvasProps { + slidePlan: SlidePlan | null; + normalizedContent: NormalizedContent | null; + userSelection: UserSelection; + /** Phase Z 가 만든 final.html URL (iframe 으로 표시). */ + finalHtmlUrl?: string; + /** 슬라이드 단위 inline CSS override (catalog/template 무변, iframe contentDocument 에 + * 동적 inject). Home 이 mdx 별 default visual 보완 등을 지정. 빈 문자열/undefined = + * inject 안 함. 사용자 lock 2026-05-14 — slide-level only. */ + slideOverrideCss?: string; + /** 파이프라인 실행 중 표시 (loading state). */ + isPipelineRunning?: boolean; + /** Phase 2 : pending layout 모드 — final.html iframe 숨기고 빈 layout zone 만 표시. */ + isPendingLayout?: boolean; + pendingLayoutId?: string | null; + onCancelPendingLayout?: () => void; + /** 편집 모드 텍스트 변경 발생 시 — Home.tsx 가 hasPendingChanges 트리거. */ + onContentEdit?: () => void; + /** Zone overlay 클릭 — 우측 Frame 탭으로 전환 + 그 zone 의 frame 후보 표시. */ + onZoneClick?: (zoneId: string) => void; + /** 슬라이드 박스 (zone overlay 외 빈 영역) 클릭 — 우측 Layout 탭으로 전환. */ + onSlideClick?: () => void; + onRegionClick?: (regionId: string) => void; + onFrameSelect?: (frameId: string) => void; + onSectionDrop?: (sectionId: string, zoneId: string) => void; + onLayoutResize?: (groupId: string, sizes: number[]) => void; + onZoneResize?: ( + geometries: Record + ) => void; +} + +const SLIDE_W = 1280; +const SLIDE_H = 720; + +export default function SlideCanvas({ + slidePlan, + userSelection, + finalHtmlUrl, + slideOverrideCss, + isPipelineRunning, + isPendingLayout, + pendingLayoutId, + onCancelPendingLayout, + onContentEdit, + onZoneClick, + onSlideClick, + onSectionDrop, + onZoneResize, +}: SlideCanvasProps) { + const containerRef = useRef(null); + const [scale, setScale] = useState(1); + // iframe 안 final.html 의 실제 .zone DOM boundingClientRect 측정값 (정규화 0~1). + // key = data-zone-position (e.g., "top" / "bottom" / "primary"). 이게 SlidePlan + // 의 zone.zone_id 와 1:1 일치하므로 overlay rendering 시 매칭 가능. + const [measuredZones, setMeasuredZones] = useState< + Record + >({}); + // iframe 안 .slide-body 의 bbox (정규화 0~1, 1280×720 기준). slide-base (title / + // divider / footer) 는 그 외부. pendingLayout 모드 시 이 영역 안에만 빈 layout + // overlay 를 깔아서 slide-base 는 그대로 유지. + const [measuredSlideBody, setMeasuredSlideBody] = useState<{ + x: number; + y: number; + w: number; + h: number; + } | null>(null); + // Step B : section drag-drop drop target. 사용자가 LeftMdxPanel 의 section 카드 + // 를 drag 해서 zone 에 drop 시 그 zone 에 section 할당. dragOver 시 강조 표시. + const [dragOverZoneId, setDragOverZoneId] = useState(null); + // HTML 편집 모드 — 글벗 패턴 (designMode + contentEditable + outline CSS) 차용. + // 활성 시 iframe 안 텍스트 element 직접 클릭하여 수정 가능. backend 반영은 별 작업. + // pendingLayout 과 배타적 (충돌 방지). + const [isEditMode, setIsEditMode] = useState(false); + const iframeRef = useRef(null); + + // 편집 모드 toggle 시 iframe contentDocument 에 글벗 패턴 적용 / 해제. + useEffect(() => { + const iframe = iframeRef.current; + if (!iframe) return; + const doc = iframe.contentDocument; + if (!doc) return; + + // 편집 모드 outline CSS — 한 번만 주입 (id 로 중복 방지) + let editStyle = doc.getElementById("phase-z-edit-style") as HTMLStyleElement | null; + if (!editStyle) { + editStyle = doc.createElement("style"); + editStyle.id = "phase-z-edit-style"; + editStyle.textContent = ` + [contenteditable]:hover { outline: 1px dashed rgba(0,200,83,0.5); cursor: text; } + [contenteditable]:focus { outline: 2px solid #00C853 !important; outline-offset: 2px; } + `; + doc.head.appendChild(editStyle); + } + + const editableTags = ["DIV", "P", "H1", "H2", "H3", "H4", "SPAN", "LI", "TD", "TH", "FIGCAPTION"]; + let inputHandler: ((e: Event) => void) | null = null; + if (isEditMode) { + doc.designMode = "on"; + doc.querySelectorAll(".slide *").forEach((el) => { + if (editableTags.includes((el as HTMLElement).tagName)) { + (el as HTMLElement).setAttribute("contenteditable", "true"); + } + }); + // 편집 발생 시 hasPendingChanges 트리거. + inputHandler = () => { + onContentEdit?.(); + }; + doc.addEventListener("input", inputHandler); + } else { + doc.designMode = "off"; + doc.querySelectorAll("[contenteditable]").forEach((el) => { + (el as HTMLElement).removeAttribute("contenteditable"); + }); + } + + return () => { + if (inputHandler && doc) { + doc.removeEventListener("input", inputHandler); + } + }; + }, [isEditMode, finalHtmlUrl, onContentEdit]); + + // pendingLayout 진입 시 편집 모드 자동 OFF (충돌 방지). + useEffect(() => { + if (isPendingLayout && isEditMode) setIsEditMode(false); + }, [isPendingLayout, isEditMode]); + + // finalHtmlUrl 이 바뀌면 (= 다른 run / 재실행) stale 측정값 reset. + // 새 iframe 의 onLoad 가 발화하면서 measuredZones 다시 채움. + useEffect(() => { + setMeasuredZones({}); + setMeasuredSlideBody(null); + }, [finalHtmlUrl]); + + // 16:9 비율 유지하며 컨테이너에 통째로 fit (스크롤 X). + useEffect(() => { + const el = containerRef.current; + if (!el) return; + + const updateScale = () => { + const padding = 32; // 컨테이너 양쪽 여백 (1rem * 2) + const cw = el.clientWidth - padding; + const ch = el.clientHeight - padding; + if (cw <= 0 || ch <= 0) return; + const sw = cw / SLIDE_W; + const sh = ch / SLIDE_H; + setScale(Math.min(sw, sh)); + }; + + updateScale(); + const ro = new ResizeObserver(updateScale); + ro.observe(el); + window.addEventListener("resize", updateScale); + return () => { + ro.disconnect(); + window.removeEventListener("resize", updateScale); + }; + }, []); + + // Empty state — MDX 업로드 전. + const isEmpty = !finalHtmlUrl && !slidePlan && !isPipelineRunning && !isPendingLayout; + // 슬라이드 박스 표시 조건 — final.html 있거나 pendingLayout 모드. + const showSlideBox = (finalHtmlUrl || isPendingLayout) && !isPipelineRunning; + + // wrapper 는 scaled 크기를 가지므로 layout 상 fit. 안의 슬라이드는 1280×720 으로 + // top-left origin scale 후 wrapper 안에 정확히 맞춤. + const W_SCALED = SLIDE_W * scale; + const H_SCALED = SLIDE_H * scale; + + return ( +
+ {isEmpty && ( +
+ +

왼쪽 패널에서 MDX 파일을 업로드하세요.

+

+ 업로드 후 하단 "슬라이드 플랜 생성하기" 버튼을 눌러주세요. +

+
+ )} + + {isPipelineRunning && ( +
+ +

Phase Z 파이프라인 실행 중...

+

+ MDX 분석 → V4 매칭 → 레이아웃 / 프레임 결정 → 렌더 +

+
+ )} + + {showSlideBox && ( +
{ + // wrapper 자체 클릭 (zone overlay 가 아닌 영역) = slide 전체 = Layout 탭. + // zone overlay 는 stopPropagation 으로 여기까지 안 옴. + if (e.target === e.currentTarget && onSlideClick) onSlideClick(); + }} + title={ + isPendingLayout + ? `Pending layout: ${pendingLayoutId} — slide-body 만 변경. section 카드 → zone drop → frame 선택 → 재생성` + : "슬라이드 전체 클릭 → 우측 Layout 후보" + } + > + {/* pendingLayout 일 때만 노출되는 우상단 취소 버튼 (slide 박스 외곽 유지) */} + {isPendingLayout && onCancelPendingLayout && ( + + )} + + {/* 편집 모드 toggle 버튼 — normal mode + final.html 있을 때만. + 글벗 패턴 차용 — designMode + contentEditable. backend 반영은 별 작업. */} + {!isPendingLayout && finalHtmlUrl && ( + + )} + +
+