From 5f608d1f23e2ca0050d2da7fb3dd78876c48216c Mon Sep 17 00:00:00 2001 From: rudals252 Date: Thu, 7 Aug 2025 16:38:53 +0900 Subject: [PATCH] Update project files --- .claude/settings.local.json | 10 + README.md | 140 ++++++- requirements.txt | 74 +++- src/common/config.py | 85 +++++ src/config/__init__.py | 5 + src/config/app_config.py | 188 ++++++++++ src/services/client.py | 73 ++++ src/services/manager.py | 156 ++++++++ src/static/css/jquery.json-viewer.css | 57 +++ src/static/css/style.css | 465 +++++++++++++++++++++++- src/static/js/constants.js | 26 ++ src/static/js/init.js | 74 ++++ src/static/js/jquery-3.7.1.min.js | 2 + src/static/js/jquery.json-viewer.min.js | 8 + src/static/js/modules/config.js | 373 +++++++++++++++++++ src/static/js/modules/http.js | 159 ++++++++ src/static/js/modules/main.js | 213 +++++++++++ src/static/js/modules/state.js | 191 ++++++++++ src/static/js/modules/ui.js | 223 ++++++++++++ src/templates/index.html | 150 +++++--- src/web_server.py | 250 ++++++++++++- 21 files changed, 2845 insertions(+), 77 deletions(-) create mode 100644 .claude/settings.local.json create mode 100644 src/common/config.py create mode 100644 src/config/__init__.py create mode 100644 src/config/app_config.py create mode 100644 src/services/client.py create mode 100644 src/services/manager.py create mode 100644 src/static/css/jquery.json-viewer.css create mode 100644 src/static/js/constants.js create mode 100644 src/static/js/init.js create mode 100644 src/static/js/jquery-3.7.1.min.js create mode 100644 src/static/js/jquery.json-viewer.min.js create mode 100644 src/static/js/modules/config.js create mode 100644 src/static/js/modules/http.js create mode 100644 src/static/js/modules/main.js create mode 100644 src/static/js/modules/state.js create mode 100644 src/static/js/modules/ui.js diff --git a/.claude/settings.local.json b/.claude/settings.local.json new file mode 100644 index 0000000..3247245 --- /dev/null +++ b/.claude/settings.local.json @@ -0,0 +1,10 @@ +{ + "permissions": { + "allow": [ + "Bash(git add:*)", + "Bash(git commit:*)", + "Bash(git push:*)" + ], + "deny": [] + } +} \ No newline at end of file diff --git a/README.md b/README.md index 919a175..a54c00c 100755 --- a/README.md +++ b/README.md @@ -1,3 +1,137 @@ -# AI IMAGE GENERATOR WEB MONITOR - -AI 이미지 생성 웹 모니터 \ No newline at end of file +# AI Image Generator Web Monitor + +AI 이미지 생성 및 벡터 검색을 위한 웹 인터페이스 + +## 🛠️ 설치 및 실행 + +### 1. 환경 설정 + +```bash +# Python 3.8+ 필요 +conda activate eyewear +pip install -r requirements.txt +``` + +### 2. 서버 실행 + +```bash +python src/web_server.py +``` + +### 3. 웹 접속 + +``` +http://localhost:51003 +``` + +## ⚙️ 설정 관리 + +### API 엔드포인트 설정 (`src/common/config.py`) + +#### 기본 API URL(DEV3) + +```python +EXTERNAL_API_URL = "http://192.168.200.233:52000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data" +``` + +#### 사전 정의된 엔드포인트 + +```python +PREDEFINED_ENDPOINTS = [ + { + "name": "벡터이미지 검색(Dev3/Data)", + "url": "http://192.168.200.233:52000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data", + "description": "Imagen 모델을 사용한 이미지 생성후 Vector 검색 그후 결과 이미지 데이터 return" + }, + { + "name": "벡터이미지 검색(Dev2/Data)", + "url": "http://192.168.200.232:51000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data", + "description": "Imagen 모델을 사용한 이미지 생성후 Vector 검색 그후 결과 이미지 데이터 return" + } +] +``` + +#### 서버 설정 + +- `SERVICE_PORT`: 웹 서버 포트 (기본: 51003) +- `HOST`: 서버 바인딩 IP (기본: "0.0.0.0") +- `API_TIMEOUT_SECONDS`: API 타임아웃 (기본: 600초) +- `DEBUG_MODE`: 디버그 모드 (기본: False) + +### 동적 API 변경 사용법 + +1. **웹페이지 상단의 "API 설정" 클릭** +2. **사전 정의된 버튼 선택** 또는 **커스텀 URL 입력** +3. **실시간으로 API 연결 변경됨** +4. **현재 사용 중인 API가 표시됨** + +## 🔧 API 엔드포인트 + +### 메인 엔드포인트 + +- `GET /` - 메인 웹 페이지 +- `POST /create` - 이미지 생성 요청 +- `GET /health` - 서버 상태 확인 +- `GET /api-status` - 외부 API 연결 상태 + +### API 설정 엔드포인트 + +- `GET /api-endpoints` - 사용 가능한 엔드포인트 목록 +- `POST /change-api-url` - API URL 동적 변경 +- `GET /config` - 프론트엔드 설정 정보 + +## 📁 프로젝트 구조 + +``` +src/ +├── common/ +│ └── config.py # 애플리케이션 설정 (구 settings.py) +├── config/ +│ ├── __init__.py +│ └── app_config.py # 통합 설정 관리 +├── services/ +│ ├── manager.py # API 통합 관리 (구 api_manager.py) +│ └── client.py # 외부 API 호출 (구 image_api_service.py) +├── static/ +│ ├── css/ +│ │ └── style.css # CSS 스타일 (CSS 변수 사용) +│ └── js/ +│ ├── constants.js # 상수 정의 (구 const.js) +│ ├── init.js # 초기화 스크립트 +│ └── modules/ +│ ├── http.js # HTTP 통신 (구 apiClient.js) +│ ├── config.js # API 설정 관리 (구 apiConfigManager.js) ⭐ +│ ├── main.js # 메인 컨트롤러 (구 imageGenerator.js) +│ ├── state.js # 상태 관리 (구 stateManager.js) +│ └── ui.js # UI 업데이트 (구 uiManager.js) +├── templates/ +│ └── index.html # HTML 템플릿 +└── web_server.py # FastAPI 웹 서버 +``` + +## 🐛 문제 해결 + +### 포트 충돌 + +```bash +# 포트 사용 확인 +netstat -an | findstr :51003 + +# config.py에서 SERVICE_PORT 변경 +SERVICE_PORT = 51004 # 다른 포트로 변경 +``` + +### API 연결 문제 + +```bash +# API 상태 확인 +curl http://localhost:51003/api-status + +# 또는 브라우저에서 +http://localhost:51003/api-status +``` + +### 개발자 도구 디버깅 + +- **F12 → 콘솔 탭**: JavaScript 오류 및 API 호출 로그 확인 +- **네트워크 탭**: API 요청/응답 상세 분석 diff --git a/requirements.txt b/requirements.txt index f12a725..6a791ab 100755 --- a/requirements.txt +++ b/requirements.txt @@ -1,3 +1,71 @@ -fastapi==0.83.0 -uvicorn==0.16.0 -jinja2==3.0.3 \ No newline at end of file +# AI Image Generator Web Server Dependencies +# +# 프로젝트: AI Image Generator Web Monitor v2.0 +# 개발 환경: Python 3.10.18 +# 지원 버전: Python 3.8+ +# 최소 요구: Python 3.8 (async/await, typing, f-string 지원) + +# ============================================================================= +# 핵심 웹 프레임워크 +# ============================================================================= + +# FastAPI 웹 프레임워크 (async 지원, 자동 API 문서화) +fastapi==0.104.1 + +# ASGI 서버 (production-ready, auto-reload 지원) +uvicorn[standard]==0.24.0 + +# HTML 템플릿 엔진 (Jinja2) +jinja2==3.1.2 + +# 파일 업로드 및 폼 데이터 처리 +python-multipart==0.0.6 + +# ============================================================================= +# HTTP 클라이언트 & 네트워킹 +# ============================================================================= + +# 비동기 HTTP 클라이언트 (외부 API 호출, requests의 async 버전) +httpx==0.25.2 + +# ============================================================================= +# 개발 편의 패키지 (선택사항) +# ============================================================================= + +# 환경변수 파일(.env) 지원 - 향후 배포시 사용 권장 +# python-dotenv==1.0.0 + +# 코드 품질 도구들 (개발 중 사용 권장) +# flake8==6.0.0 # 코드 스타일 체크 +# black==23.0.0 # 코드 포매터 +# pytest==7.4.0 # 테스트 프레임워크 + +# ============================================================================= +# 설치 및 실행 가이드 +# ============================================================================= +# +# 1. 환경 활성화: +# conda activate eyewear +# +# 2. 패키지 설치: +# pip install -r requirements.txt +# +# 3. 서버 실행: +# python src/web_server.py +# +# 4. 웹 접속: +# http://localhost:51003 +# +# ============================================================================= +# 버전 호환성 정보 +# ============================================================================= +# +# Python 3.8+ : 모든 기능 지원 +# Python 3.7 : typing 일부 제한, 권장하지 않음 +# Python 3.6- : 지원 안함 (f-string, async/await 제한) +# +# 테스트된 환경: +# - Windows 10/11 + Python 3.10.18 +# - Conda 환경: eyewear +# +# ============================================================================= \ No newline at end of file diff --git a/src/common/config.py b/src/common/config.py new file mode 100644 index 0000000..3f29a05 --- /dev/null +++ b/src/common/config.py @@ -0,0 +1,85 @@ +# -*- coding: utf-8 -*- + +""" +@File: settings.py +@Date: 2025-08-01 +@Author: SGM +@Brief: 애플리케이션 전반의 상수 및 설정 관리 +@section MODIFYINFO 수정정보 +""" + +# ============================================================================= +# 웹 서버 설정 +# ============================================================================= + +# WEB SERVER PORT +# 다른 PC에서 접속할 때 포트 충돌이 있으면 변경하세요 +# 예: 51003, 51002, 8000 등 +SERVICE_PORT = 51001 + +# Uvicorn 서버 호스트 설정 +# "0.0.0.0": 모든 IP에서 접속 허용 (기본값, 권장) +# "127.0.0.1": 로컬에서만 접속 허용 +# "192.168.x.x": 특정 IP에서만 접속 허용 +HOST = "0.0.0.0" + +# ============================================================================= +# 외부 API 설정 +# ============================================================================= + +# AI 이미지 생성 API 서버 주소 +# 다른 PC나 서버에서 API 서버를 실행하는 경우 IP 주소를 변경하세요 +# 예: "http://192.168.1.100:51000/api/..." +# "http://localhost:51000/api/..." +#EXTERNAL_API_URL = "http://192.168.200.233:51000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data" +EXTERNAL_API_URL = "http://192.168.200.232:51000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data" + +# ============================================================================= +# 사전 정의된 API 엔드포인트 설정 +# ============================================================================= + +# 사용 가능한 API 엔드포인트 목록 (웹 UI에서 선택 가능) +PREDEFINED_ENDPOINTS = [ + { + "name": "벡터이미지 검색(Dev2/Data)", + "url": "http://192.168.200.232:51000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data", + "description": "Imagen 모델을 사용한 이미지 생성후 Vector 검색 그후 결과 이미지 데이터 return" + }, + { + "name": "벡터이미지 검색(Dev3/Data)", + "url": "http://192.168.200.233:51000/api/services/vactorImageSearch/vit/imageGenerate/imagen/data", + "description": "Imagen 모델을 사용한 이미지 생성후 Vector 검색 그후 결과 이미지 데이터 return" + }, +] + +# ============================================================================= +# 개발 편의 설정 +# ============================================================================= + +# 디버그 모드 (개발 중에는 True로 설정하면 더 자세한 로그 확인 가능) +DEBUG_MODE = False + +# API 타임아웃 설정 (초) +API_TIMEOUT_SECONDS = 600 + +# ============================================================================= +# 설정 변경 가이드 +# ============================================================================= +""" +🔧 다른 PC에서 사용할 때 확인할 것들: + +1. SERVICE_PORT: 포트 충돌 시 변경 + - Windows: netstat -an | findstr :51003 + - 사용 중이면 51004, 51005 등으로 변경 + +2. EXTERNAL_API_URL: API 서버 주소 확인 + - API 서버가 실행 중인지 확인 + - IP 주소가 정확한지 확인 + - 브라우저에서 http://localhost:51003/api-status 로 연결 상태 확인 + +3. 방화벽 설정: + - Windows Defender 방화벽에서 포트 허용 필요할 수 있음 + +4. 네트워크 확인: + - ping 192.168.200.233 로 API 서버 연결 확인 +""" diff --git a/src/config/__init__.py b/src/config/__init__.py new file mode 100644 index 0000000..f49b53f --- /dev/null +++ b/src/config/__init__.py @@ -0,0 +1,5 @@ +# -*- coding: utf-8 -*- + +""" +Config 패키지 초기화 +""" \ No newline at end of file diff --git a/src/config/app_config.py b/src/config/app_config.py new file mode 100644 index 0000000..367210b --- /dev/null +++ b/src/config/app_config.py @@ -0,0 +1,188 @@ +# -*- coding: utf-8 -*- + +""" +@File: app_config.py +@Date: 2025-08-05 +@Author: SGM +@Brief: 애플리케이션 전반의 설정을 통합 관리 +@section MODIFYINFO 수정정보 +""" + +import logging +from typing import Dict, Any +from common.config import ( + SERVICE_PORT, HOST, EXTERNAL_API_URL, + DEBUG_MODE, API_TIMEOUT_SECONDS +) + +logger = logging.getLogger(__name__) + +class AppConfig: + """ + 애플리케이션 전반의 설정을 통합 관리하는 클래스 + + - 서버 설정 + - API 설정 + - UI 설정 + - 프론트엔드에서 필요한 설정 제공 + """ + + def __init__(self): + self.server_config = self._load_server_config() + self.api_config = self._load_api_config() + self.ui_config = self._load_ui_config() + logger.info("애플리케이션 설정 로드 완료") + + def _load_server_config(self) -> Dict[str, Any]: + """서버 관련 설정""" + return { + 'host': HOST, + 'port': SERVICE_PORT, + 'debug': DEBUG_MODE + } + + def _load_api_config(self) -> Dict[str, Any]: + """API 관련 설정""" + return { + 'imagen': { + 'url': EXTERNAL_API_URL, + 'timeout': API_TIMEOUT_SECONDS, + 'name': 'Imagen API', + 'description': '구글 Imagen 기반 이미지 생성' + } + # 추후 추가될 API 설정들 + } + + def _load_ui_config(self) -> Dict[str, Any]: + """UI 관련 설정""" + return { + 'default_settings': { + 'model_type': 'l14', + 'index_type': 'cos', + 'search_num': 4 + }, + 'limits': { + 'max_search_num': 10, + 'min_search_num': 1, + 'max_prompt_length': 500 + }, + 'theme': { + 'primary_color': '#646464', + 'success_color': '#28a745', + 'error_color': '#dc3545' + } + } + + def get_config_for_frontend(self) -> Dict[str, Any]: + """ + 프론트엔드에서 사용할 설정만 반환 + (보안상 중요한 정보는 제외) + + Returns: + Dict: 프론트엔드용 설정 데이터 + """ + return { + 'apis': { + name: { + 'name': config['name'], + 'description': config['description'] + # URL이나 timeout 같은 민감한 정보는 제외 + } + for name, config in self.api_config.items() + }, + 'ui': self.ui_config, + 'server': { + 'debug': self.server_config['debug'] + # host, port 같은 서버 정보는 제외 + } + } + + def get_api_config(self, api_name: str) -> Dict[str, Any]: + """ + 특정 API의 설정 반환 + + Args: + api_name: API 이름 + + Returns: + Dict: API 설정 데이터 + + Raises: + KeyError: 존재하지 않는 API인 경우 + """ + if api_name not in self.api_config: + available_apis = list(self.api_config.keys()) + raise KeyError(f"API '{api_name}' 설정을 찾을 수 없습니다. 사용 가능한 API: {available_apis}") + + return self.api_config[api_name] + + def update_api_config(self, api_name: str, config: Dict[str, Any]) -> None: + """ + API 설정 업데이트 (런타임에서 설정 변경 시 사용) + + Args: + api_name: API 이름 + config: 새로운 설정 데이터 + """ + if api_name in self.api_config: + self.api_config[api_name].update(config) + logger.info(f"API '{api_name}' 설정 업데이트됨") + else: + self.api_config[api_name] = config + logger.info(f"새로운 API '{api_name}' 설정 추가됨") + + def validate_request_data(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + 요청 데이터 검증 및 정리 + + Args: + data: 클라이언트에서 온 요청 데이터 + + Returns: + Dict: 검증된 데이터 + + Raises: + ValueError: 유효하지 않은 데이터인 경우 + """ + validated = {} + + # 필수 필드 검증 + if not data.get('prompt'): + raise ValueError("프롬프트는 필수입니다") + + prompt = data['prompt'].strip() + if len(prompt) > self.ui_config['limits']['max_prompt_length']: + raise ValueError(f"프롬프트가 너무 깁니다 (최대 {self.ui_config['limits']['max_prompt_length']}자)") + + validated['prompt'] = prompt + + # 선택적 필드 검증 및 기본값 설정 (JavaScript camelCase -> Python snake_case 변환) + validated['model_type'] = data.get('modelType', data.get('model_type', self.ui_config['default_settings']['model_type'])) + validated['index_type'] = data.get('indexType', data.get('index_type', self.ui_config['default_settings']['index_type'])) + + # search_num 검증 (JavaScript camelCase 지원) + search_num = data.get('searchNum', data.get('search_num', self.ui_config['default_settings']['search_num'])) + try: + search_num = int(search_num) + if not (self.ui_config['limits']['min_search_num'] <= search_num <= self.ui_config['limits']['max_search_num']): + raise ValueError(f"Search Num은 {self.ui_config['limits']['min_search_num']}~{self.ui_config['limits']['max_search_num']} 사이여야 합니다") + + # 디버깅용 로그 추가 + logger.info(f"search_num 검증 통과: {search_num} (타입: {type(search_num)})") + except (ValueError, TypeError) as e: + logger.error(f"search_num 검증 실패: {search_num} (타입: {type(search_num)}), 오류: {e}") + raise ValueError("Search Num은 숫자여야 합니다") + + validated['search_num'] = search_num + validated['querySend'] = data.get('querySend', True) + + # *** 중요 수정: 외부 API는 camelCase를 기대함 *** + # 내부적으로는 snake_case 사용하지만, 외부 API 전송 시에는 원본 형태 유지 + validated['searchNum'] = search_num # 외부 API용 camelCase 버전 추가 + validated['modelType'] = validated['model_type'] # 외부 API용 + validated['indexType'] = validated['index_type'] # 외부 API용 + + return validated + +# 전역 설정 관리자 인스턴스 +app_config = AppConfig() \ No newline at end of file diff --git a/src/services/client.py b/src/services/client.py new file mode 100644 index 0000000..78a0d6e --- /dev/null +++ b/src/services/client.py @@ -0,0 +1,73 @@ +# -*- coding: utf-8 -*- + +""" +@File: image_api_service.py +@Date: 2025-08-01 +@Author: SGM +@Brief: 외부 이미지 API 서비스 +@section MODIFYINFO 수정정보 +""" + +import httpx +import logging +from common.config import API_TIMEOUT_SECONDS +import common.config as settings + +logger = logging.getLogger(__name__) + +async def call_image_generation_api(data: dict): + """ + 외부 이미지 생성 API 호출 + + Args: + data (dict): API 요청 데이터 + + Returns: + dict: API 응답 데이터 + + Raises: + httpx.HTTPStatusError: HTTP 에러 발생 시 + httpx.RequestError: 네트워크 연결 오류 시 + """ + timeout_config = httpx.Timeout(API_TIMEOUT_SECONDS, connect=10.0) # 설정 파일에서 타임아웃 시간 가져옴 + + logger.info(f"외부 API 호출 시작: {settings.EXTERNAL_API_URL}") + logger.info(f"외부 API로 전송할 데이터: {data}") + logger.info(f"search_num 전달 확인: {data.get('search_num', 'NOT_FOUND')}") + + async with httpx.AsyncClient(timeout=timeout_config) as client: + try: + response = await client.post(settings.EXTERNAL_API_URL, json=data) + response.raise_for_status() + + result = response.json() + logger.info(f"API 호출 성공: 응답 크기 {len(str(result))} 문자") + + # vectorResult 배열 크기 확인 + if 'vectorResult' in result: + vector_count = len(result['vectorResult']) if result['vectorResult'] else 0 + logger.info(f"요청한 이미지 개수: {data.get('search_num', 'N/A')}") + logger.info(f"실제 응답받은 이미지 개수: {vector_count}") + + if vector_count != data.get('search_num', 0): + logger.warning(f"이미지 개수 불일치! 요청: {data.get('search_num')}, 응답: {vector_count}") + + # 응답에 제한 정보 추가 (프론트엔드에서 사용자에게 알림) + result['_server_limitation'] = { + 'requested': data.get('search_num', 0), + 'actual': vector_count, + 'message': f"외부 API 서버에서 최대 {vector_count}개까지만 반환합니다." + } + + # 응답 구조 상세 분석 + if result['vectorResult']: + logger.info(f"첫 번째 이미지 구조: {list(result['vectorResult'][0].keys()) if result['vectorResult'][0] else 'N/A'}") + else: + logger.warning("응답에 vectorResult가 없습니다") + logger.info(f"전체 응답 구조: {list(result.keys())}") + + return result + + except httpx.TimeoutException as exc: + logger.error(f"API 타임아웃: {exc}") + raise httpx.RequestError(f"API 응답 시간 초과 ({API_TIMEOUT_SECONDS}초)") from exc diff --git a/src/services/manager.py b/src/services/manager.py new file mode 100644 index 0000000..147fe32 --- /dev/null +++ b/src/services/manager.py @@ -0,0 +1,156 @@ +# -*- coding: utf-8 -*- + +""" +@File: api_manager.py +@Date: 2025-08-05 +@Author: SGM +@Brief: API 관리자 - 여러 이미지 생성 API를 통합 관리 +@section MODIFYINFO 수정정보 + +""" + +import logging +from typing import Dict, Any, Optional +from .client import call_image_generation_api + +logger = logging.getLogger(__name__) + +class ApiManager: + """ + 이미지 생성 API들을 통합 관리하는 클래스 + + 현재는 Imagen API만 지원하지만, 나중에 DALL-E, Midjourney 등을 + 쉽게 추가할 수 있도록 설계됨 + """ + + def __init__(self): + self.apis = { + 'imagen': { + 'name': 'Imagen API', + 'description': '구글 Imagen 기반 이미지 생성', + 'supported_settings': { + 'model_type': ['b32', 'b16', 'l14', 'l14_336'], + 'index_type': ['l2', 'cos'], + 'search_num': {'min': 1, 'max': 10} + }, + 'handler': self._call_imagen_api + } + # 추후 추가될 API들: + # 'dalle': {...}, + # 'midjourney': {...} + } + self.default_api = 'imagen' + logger.info(f"API 관리자 초기화 완료: {list(self.apis.keys())}") + + async def generate_image(self, data: Dict[str, Any], api_name: Optional[str] = None) -> Dict[str, Any]: + """ + 이미지 생성 요청 처리 + + Args: + data: 이미지 생성 요청 데이터 + api_name: 사용할 API 이름 (None이면 기본 API 사용) + + Returns: + Dict: API 응답 데이터 + + Raises: + ValueError: 지원하지 않는 API인 경우 + Exception: API 호출 실패 시 + """ + if api_name is None: + api_name = self.default_api + + if api_name not in self.apis: + available_apis = list(self.apis.keys()) + raise ValueError(f"지원하지 않는 API: {api_name}. 사용 가능한 API: {available_apis}") + + api_info = self.apis[api_name] + logger.info(f"이미지 생성 요청 - API: {api_info['name']}") + + try: + # API별 핸들러 호출 + result = await api_info['handler'](data) + logger.info(f"이미지 생성 성공 - API: {api_name}") + return result + + except Exception as exc: + logger.error(f"이미지 생성 실패 - API: {api_name}, 오류: {exc}") + raise + + async def _call_imagen_api(self, data: Dict[str, Any]) -> Dict[str, Any]: + """ + Imagen API 호출 (기존 로직 재사용) + """ + return await call_image_generation_api(data) + + def get_available_apis(self) -> Dict[str, Dict[str, Any]]: + """ + 사용 가능한 API 목록과 각 API의 정보 반환 + + Returns: + Dict: API 이름을 키로 하는 API 정보 딕셔너리 + """ + return { + name: { + 'name': info['name'], + 'description': info['description'], + 'supported_settings': info['supported_settings'] + } + for name, info in self.apis.items() + } + + def validate_settings(self, settings: Dict[str, Any], api_name: Optional[str] = None) -> bool: + """ + API별 설정값 검증 + + Args: + settings: 검증할 설정값들 + api_name: 대상 API 이름 + + Returns: + bool: 설정값이 유효한지 여부 + """ + if api_name is None: + api_name = self.default_api + + if api_name not in self.apis: + return False + + supported = self.apis[api_name]['supported_settings'] + + # model_type 검증 + if 'model_type' in settings: + if settings['model_type'] not in supported['model_type']: + logger.warning(f"지원하지 않는 model_type: {settings['model_type']}") + return False + + # index_type 검증 + if 'index_type' in settings: + if settings['index_type'] not in supported['index_type']: + logger.warning(f"지원하지 않는 index_type: {settings['index_type']}") + return False + + # search_num 검증 + if 'search_num' in settings: + num = settings['search_num'] + if not (supported['search_num']['min'] <= num <= supported['search_num']['max']): + logger.warning(f"search_num 범위 초과: {num}") + return False + + return True + + def set_default_api(self, api_name: str) -> None: + """ + 기본 사용 API 설정 + + Args: + api_name: 기본으로 사용할 API 이름 + """ + if api_name in self.apis: + self.default_api = api_name + logger.info(f"기본 API 변경: {api_name}") + else: + raise ValueError(f"존재하지 않는 API: {api_name}") + +# 전역 API 관리자 인스턴스 +api_manager = ApiManager() \ No newline at end of file diff --git a/src/static/css/jquery.json-viewer.css b/src/static/css/jquery.json-viewer.css new file mode 100644 index 0000000..57aa450 --- /dev/null +++ b/src/static/css/jquery.json-viewer.css @@ -0,0 +1,57 @@ +/* Root element */ +.json-document { + padding: 1em 2em; +} + +/* Syntax highlighting for JSON objects */ +ul.json-dict, ol.json-array { + list-style-type: none; + margin: 0 0 0 1px; + border-left: 1px dotted #ccc; + padding-left: 2em; +} +.json-string { + color: #0B7500; +} +.json-literal { + color: #1A01CC; + font-weight: bold; +} + +/* Toggle button */ +a.json-toggle { + position: relative; + color: inherit; + text-decoration: none; +} +a.json-toggle:focus { + outline: none; +} +a.json-toggle:before { + font-size: 1.1em; + color: #c0c0c0; + content: "\25BC"; /* down arrow */ + position: absolute; + display: inline-block; + width: 1em; + text-align: center; + line-height: 1em; + left: -1.2em; +} +a.json-toggle:hover:before { + color: #aaa; +} +a.json-toggle.collapsed:before { + /* Use rotated down arrow, prevents right arrow appearing smaller than down arrow in some browsers */ + transform: rotate(-90deg); +} + +/* Collapsable placeholder links */ +a.json-placeholder { + color: #aaa; + padding: 0 1em; + text-decoration: none; +} +a.json-placeholder:hover { + text-decoration: underline; +} diff --git a/src/static/css/style.css b/src/static/css/style.css index 3006640..0c22feb 100755 --- a/src/static/css/style.css +++ b/src/static/css/style.css @@ -1,14 +1,36 @@ -body { - font-family: sans-serif; +/* static/css/style.css + * 2025-08-04 수정 – .result_block 스타일 추가 + */ + +:root { + --primary-color: #007bff; + --success-color: #28a745; + --error-color: #dc3545; + --warning-color: #ffc107; + --border-color: #e0e0e0; + --border-radius: 5px; + --border-radius-lg: 10px; + --max-width: 1200px; + --box-shadow: 0 2px 5px rgba(0, 0, 0, 0.1); + --box-shadow-lg: 0 3px 8px rgba(0, 0, 0, 0.15); +} + +html, body { + height: 100%; margin: 0; padding: 0; +} + +body { + font-family: sans-serif; background: linear-gradient(135deg, #ece9e6, #ffffff); color: #444; + min-height: 100vh; display: flex; flex-direction: column; - align-items: center; - justify-content: center; - height: 100vh; + align-items: center; /* 수평 중앙 정렬 */ + padding: 20px; /* 전체 여백 추가 */ + box-sizing: border-box; } .title_zone { @@ -23,15 +45,17 @@ body { text-shadow: 1px 1px 2px rgba(0, 0, 0, 0.1); } -.img_generator_model_zone_1, .img_generator_model_zone_2 { +.img_generator_model_zone_1 { background-color: #ffffff; - border: 1px solid #e0e0e0; - border-radius: 10px; - padding: 25px; - margin: 30px; - box-shadow: 0 3px 8px rgba(0, 0, 0, 0.15); - width: 35%; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 30px; + margin: 20px auto; /* 중앙 정렬 */ + box-shadow: var(--box-shadow-lg); + width: 100%; + max-width: var(--max-width); text-align: center; + box-sizing: border-box; /* 패딩이 너비에 포함되도록 */ } .model_name { @@ -50,6 +74,23 @@ body { box-sizing: border-box; } +.select_zone { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 15px; + margin-bottom: 20px; +} + +.select_zone label, +.select_zone select, +.select_zone input[type="number"] { + padding: 8px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 14px; +} + .generator_btn_zone { text-align: center; margin-top: 4px; @@ -65,11 +106,401 @@ body { cursor: pointer; } -#input_txt_box_3, #input_txt_box_4 { - width: 45px; -} - .error_zone { color: red; - font-size: 15px; + font-size: 15px; + margin-top: 10px; +} + +.result_container { + margin-top: 20px; +} + +/* ---- 이미지 출력 영역 ---- */ + +.query_image_zone { + text-align: center; + margin-bottom: 20px; +} + +.query_image { + max-width: 300px; + height: auto; + border: 2px solid #007bff; + border-radius: 5px; +} + +.result_image_zone { + display: flex; + flex-wrap: wrap; + justify-content: center; + gap: 16px; + margin-bottom: 20px; +} + +/* 이미지 + 매칭률 묶음 */ +.result_block { + text-align: center; + max-width: 200px; + flex-grow: 1; /* 공간이 남으면 늘어나도록 */ + flex-shrink: 1; /* 공간이 부족하면 줄어들도록 */ + flex-basis: calc(20% - 16px); /* 5개씩 표시 (20% x 5 = 100%) */ +} + +.result_image { + width: 100%; + height: auto; + border: 1px solid #ddd; + border-radius: 5px; + box-shadow: 0 2px 4px rgba(0,0,0,0.1); +} + +.result_percent { + margin-top: 4px; + font-size: 14px; + color: #333; +} + +.json_viewer { + max-width: 100%; + overflow-x: auto; + white-space: pre-wrap; + text-align: left; /* JSON 뷰어 텍스트 왼쪽 정렬 */ + padding: 15px; + border: 1px solid #e0e0e0; + border-radius: 8px; + background-color: #f9f9f9; +} + +/* ---- API 설정 영역 ---- */ + +.api_config_zone { + background-color: #ffffff; + border: 1px solid var(--border-color); + border-radius: var(--border-radius-lg); + padding: 15px; + margin: 10px auto 20px auto; + box-shadow: var(--box-shadow); + width: 100%; + max-width: var(--max-width); + box-sizing: border-box; +} + +.config_header { + display: flex; + justify-content: space-between; + align-items: center; + cursor: pointer; + user-select: none; +} + +.config_title { + font-size: 16px; + font-weight: bold; + color: #555; +} + +.toggle_btn { + background: none; + border: none; + font-size: 14px; + cursor: pointer; + padding: 5px; + color: #555; +} + +.config_content { + padding-top: 15px; + border-top: 1px solid #f0f0f0; + margin-top: 10px; +} + +.current_api_info { + margin-bottom: 15px; + padding: 12px; + background-color: #f8f9fa; + border-radius: var(--border-radius); + font-size: 14px; + border: 1px solid #e9ecef; +} + +.api_status_line { + margin-bottom: 8px; +} + +.api_status_line span:first-child { + font-weight: bold; +} + +.connection_status { + display: flex; + justify-content: space-between; + align-items: center; + margin-top: 8px; +} + +.status_indicator { + display: flex; + align-items: center; + gap: 8px; +} + +.status_dot { + width: 12px; + height: 12px; + border-radius: 50%; + background-color: var(--warning-color); + position: relative; + transition: all 0.3s ease; +} + +/* 연결 상태별 스타일 */ +.status_dot.connected { + background-color: var(--success-color); + box-shadow: 0 0 0 3px rgba(40, 167, 69, 0.3); +} + +.status_dot.disconnected { + background-color: var(--error-color); + box-shadow: 0 0 0 3px rgba(220, 53, 69, 0.3); +} + +.status_dot.checking { + background-color: var(--warning-color); + animation: pulse 1.5s infinite; +} + +/* 펄스 애니메이션 */ +@keyframes pulse { + 0% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0.7); + } + 50% { + transform: scale(1.1); + box-shadow: 0 0 0 6px rgba(255, 193, 7, 0.3); + } + 100% { + transform: scale(1); + box-shadow: 0 0 0 0 rgba(255, 193, 7, 0); + } +} + +/* 성공/실패 애니메이션 */ +@keyframes success-flash { + 0% { background-color: var(--success-color); } + 50% { background-color: #20c997; } + 100% { background-color: var(--success-color); } +} + +@keyframes error-flash { + 0% { background-color: var(--error-color); } + 50% { background-color: #e74c3c; } + 100% { background-color: var(--error-color); } +} + +.status_dot.flash-success { + animation: success-flash 0.6s ease-in-out; +} + +.status_dot.flash-error { + animation: error-flash 0.6s ease-in-out; +} + +#connection_status_text { + font-size: 13px; + font-weight: 500; + transition: color 0.3s ease; +} + +#connection_status_text.connected { + color: var(--success-color); +} + +#connection_status_text.disconnected { + color: var(--error-color); +} + +#connection_status_text.checking { + color: #856404; +} + +.refresh_btn { + background: none; + border: none; + cursor: pointer; + font-size: 14px; + padding: 4px 8px; + border-radius: var(--border-radius); + transition: all 0.3s ease; + opacity: 0.7; +} + +.refresh_btn:hover { + opacity: 1; + background-color: rgba(0, 0, 0, 0.05); + transform: rotate(180deg); +} + +.refresh_btn.spinning { + animation: spin 1s linear infinite; +} + +@keyframes spin { + from { transform: rotate(0deg); } + to { transform: rotate(360deg); } +} + +.predefined_endpoints { + margin-bottom: 15px; +} + +.endpoints_title { + display: block; + margin-bottom: 10px; + font-weight: bold; + color: #555; + font-size: 14px; +} + +.endpoint_buttons { + display: flex; + flex-wrap: wrap; + gap: 8px; +} + +.endpoint_btn { + background-color: var(--primary-color); + color: white; + border: none; + padding: 8px 12px; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 12px; + transition: background-color 0.3s; +} + +.endpoint_btn:hover { + background-color: #0056b3; +} + +.endpoint_btn.active { + background-color: var(--success-color); +} + +.custom_endpoint { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 10px; +} + +.custom_endpoint span { + font-weight: bold; + color: #555; + font-size: 14px; +} + +#custom_url_input { + flex: 1; + min-width: 200px; + padding: 8px; + border: 1px solid #ccc; + border-radius: 5px; + font-size: 14px; +} + +.config_btn { + background-color: var(--success-color); + color: white; + border: none; + padding: 8px 15px; + border-radius: var(--border-radius); + cursor: pointer; + font-size: 14px; + transition: background-color 0.3s; +} + +.config_btn:hover { + background-color: #218838; +} + +/* --- Media Queries --- */ + +/* Small screens (e.g., mobile phones) */ +@media (max-width: 768px) { + body { + padding: 10px; /* 모바일에서는 패딩 줄임 */ + } + + .title { + font-size: 28px; + } + + .img_generator_model_zone_1 { + margin: 10px auto; + padding: 20px; + } + + .api_config_zone { + margin: 5px auto 15px auto; + padding: 12px; + } + + .model_name { + font-size: 18px; + } + + .input_txt_box, + .select_zone label, + .select_zone select, + .select_zone input[type="number"], + .generator_btn, + .error_zone, + .result_percent { + font-size: 13px; + } + + .select_zone { + flex-direction: column; /* 세로로 쌓이도록 */ + align-items: center; + } + + .result_block { + flex-basis: calc(50% - 16px); /* 두 개씩 표시 */ + max-width: calc(50% - 16px); + } + + .query_image { + max-width: 200px; + } +} + +/* Medium screens (e.g., tablets) */ +@media (min-width: 769px) and (max-width: 1024px) { + body { + padding: 15px; + } + + .img_generator_model_zone_1 { + margin: 15px auto; + padding: 25px; + } + + .api_config_zone { + margin: 8px auto 18px auto; + } + + .result_block { + flex-basis: calc(33.33% - 16px); /* 세 개씩 표시 */ + max-width: calc(33.33% - 16px); + } +} + +/* Large screens (e.g., desktops, FHD) */ +@media (min-width: 1025px) { + .result_block { + flex-basis: calc(20% - 16px); /* 5개씩 표시 */ + max-width: calc(20% - 16px); + } } \ No newline at end of file diff --git a/src/static/js/constants.js b/src/static/js/constants.js new file mode 100644 index 0000000..40083a6 --- /dev/null +++ b/src/static/js/constants.js @@ -0,0 +1,26 @@ +/* + * @File: const.js + * @Date: 2025-08-01 + * @Author: SGM + * @Brief: 상수 관리 + * @section MODIFYINFO 수정정보 + */ + +const SELECTORS = { + PROMPT_INPUT: '#input_txt_box', + MODEL_TYPE_SELECT: '#model_type_select', + INDEX_TYPE_SELECT: '#index_type_select', + SEARCH_NUM_INPUT: '#search_num_input', + ERROR_ZONE: '#error_zone', + LOADING_ZONE: '#loading_zone', + QUERY_IMAGE_ZONE: '#query_image_zone', + RESULT_IMAGE_ZONE: '#result_image_zone', + JSON_VIEWER: '#json_viewer', + GENERATOR_BTN: '#generator_btn', +}; + +const API_ENDPOINTS = { + CREATE: '/create' +}; + +const BASE64_PREFIX = 'data:image/png;base64,'; diff --git a/src/static/js/init.js b/src/static/js/init.js new file mode 100644 index 0000000..2d6bc66 --- /dev/null +++ b/src/static/js/init.js @@ -0,0 +1,74 @@ +/* + * @File: init.js + * @Date: 2025-08-01 + * @Author: SGM + * @Brief: 초기화 스크립트 + * @section MODIFYINFO 수정정보 + */ + +$(document).ready(function() { + // 모든 클래스가 로드되었는지 확인 + if (typeof MainController === 'undefined') { + console.error('MainController가 로드되지 않았습니다.'); + return; + } + + // 메인 컨트롤러 인스턴스 생성 + window.imageGenerator = new MainController(); + + // API 설정 관리자 초기화 + if (typeof ApiSettings !== 'undefined') { + ApiSettings.init(); + console.log('API Settings 초기화 완료'); + } else { + console.warn('ApiSettings가 로드되지 않았습니다.'); + } + + // 이벤트 리스너 설정 + setupEventListeners(); + + // 애플리케이션 초기화 + initializeApp(); + + console.log('AI Image Generator 모듈화 초기화 완료'); +}); + +/** + * 이벤트 리스너 설정 + */ +function setupEventListeners() { + // 이미지 생성 버튼 + $(SELECTORS.GENERATOR_BTN).on('click', function() { + window.imageGenerator.generateImage(); + }); + + // 엔터키로 생성 + $(SELECTORS.PROMPT_INPUT).on('keypress', function(e) { + if (e.which === 13) { // Enter key + window.imageGenerator.generateImage(); + } + }); + + // 설정 변경 시 상태 관리자 업데이트 (선택사항) + $(SELECTORS.MODEL_TYPE_SELECT + ', ' + SELECTORS.INDEX_TYPE_SELECT).on('change', function() { + if (window.appState) { + const settings = window.imageGenerator.uiManager.getCurrentSettings(); + window.appState.updateSettings(settings); + } + }); +} + +/** + * 애플리케이션 초기화 + */ +async function initializeApp() { + // 서버 설정 로드 + if (window.appState && window.imageGenerator.apiClient) { + await window.appState.loadConfig(); + } + + // 개발 편의를 위한 초기값 설정 + $(SELECTORS.MODEL_TYPE_SELECT).val('l14'); + $(SELECTORS.SEARCH_NUM_INPUT).val(4); +} + diff --git a/src/static/js/jquery-3.7.1.min.js b/src/static/js/jquery-3.7.1.min.js new file mode 100644 index 0000000..7f37b5d --- /dev/null +++ b/src/static/js/jquery-3.7.1.min.js @@ -0,0 +1,2 @@ +/*! jQuery v3.7.1 | (c) OpenJS Foundation and other contributors | jquery.org/license */ +!function(e,t){"use strict";"object"==typeof module&&"object"==typeof module.exports?module.exports=e.document?t(e,!0):function(e){if(!e.document)throw new Error("jQuery requires a window with a document");return t(e)}:t(e)}("undefined"!=typeof window?window:this,function(ie,e){"use strict";var oe=[],r=Object.getPrototypeOf,ae=oe.slice,g=oe.flat?function(e){return oe.flat.call(e)}:function(e){return oe.concat.apply([],e)},s=oe.push,se=oe.indexOf,n={},i=n.toString,ue=n.hasOwnProperty,o=ue.toString,a=o.call(Object),le={},v=function(e){return"function"==typeof e&&"number"!=typeof e.nodeType&&"function"!=typeof e.item},y=function(e){return null!=e&&e===e.window},C=ie.document,u={type:!0,src:!0,nonce:!0,noModule:!0};function m(e,t,n){var r,i,o=(n=n||C).createElement("script");if(o.text=e,t)for(r in u)(i=t[r]||t.getAttribute&&t.getAttribute(r))&&o.setAttribute(r,i);n.head.appendChild(o).parentNode.removeChild(o)}function x(e){return null==e?e+"":"object"==typeof e||"function"==typeof e?n[i.call(e)]||"object":typeof e}var t="3.7.1",l=/HTML$/i,ce=function(e,t){return new ce.fn.init(e,t)};function c(e){var t=!!e&&"length"in e&&e.length,n=x(e);return!v(e)&&!y(e)&&("array"===n||0===t||"number"==typeof t&&0+~]|"+ge+")"+ge+"*"),x=new RegExp(ge+"|>"),j=new RegExp(g),A=new RegExp("^"+t+"$"),D={ID:new RegExp("^#("+t+")"),CLASS:new RegExp("^\\.("+t+")"),TAG:new RegExp("^("+t+"|[*])"),ATTR:new RegExp("^"+p),PSEUDO:new RegExp("^"+g),CHILD:new RegExp("^:(only|first|last|nth|nth-last)-(child|of-type)(?:\\("+ge+"*(even|odd|(([+-]|)(\\d*)n|)"+ge+"*(?:([+-]|)"+ge+"*(\\d+)|))"+ge+"*\\)|)","i"),bool:new RegExp("^(?:"+f+")$","i"),needsContext:new RegExp("^"+ge+"*[>+~]|:(even|odd|eq|gt|lt|nth|first|last)(?:\\("+ge+"*((?:-\\d)?\\d*)"+ge+"*\\)|)(?=[^-]|$)","i")},N=/^(?:input|select|textarea|button)$/i,q=/^h\d$/i,L=/^(?:#([\w-]+)|(\w+)|\.([\w-]+))$/,H=/[+~]/,O=new RegExp("\\\\[\\da-fA-F]{1,6}"+ge+"?|\\\\([^\\r\\n\\f])","g"),P=function(e,t){var n="0x"+e.slice(1)-65536;return t||(n<0?String.fromCharCode(n+65536):String.fromCharCode(n>>10|55296,1023&n|56320))},M=function(){V()},R=J(function(e){return!0===e.disabled&&fe(e,"fieldset")},{dir:"parentNode",next:"legend"});try{k.apply(oe=ae.call(ye.childNodes),ye.childNodes),oe[ye.childNodes.length].nodeType}catch(e){k={apply:function(e,t){me.apply(e,ae.call(t))},call:function(e){me.apply(e,ae.call(arguments,1))}}}function I(t,e,n,r){var i,o,a,s,u,l,c,f=e&&e.ownerDocument,p=e?e.nodeType:9;if(n=n||[],"string"!=typeof t||!t||1!==p&&9!==p&&11!==p)return n;if(!r&&(V(e),e=e||T,C)){if(11!==p&&(u=L.exec(t)))if(i=u[1]){if(9===p){if(!(a=e.getElementById(i)))return n;if(a.id===i)return k.call(n,a),n}else if(f&&(a=f.getElementById(i))&&I.contains(e,a)&&a.id===i)return k.call(n,a),n}else{if(u[2])return k.apply(n,e.getElementsByTagName(t)),n;if((i=u[3])&&e.getElementsByClassName)return k.apply(n,e.getElementsByClassName(i)),n}if(!(h[t+" "]||d&&d.test(t))){if(c=t,f=e,1===p&&(x.test(t)||m.test(t))){(f=H.test(t)&&U(e.parentNode)||e)==e&&le.scope||((s=e.getAttribute("id"))?s=ce.escapeSelector(s):e.setAttribute("id",s=S)),o=(l=Y(t)).length;while(o--)l[o]=(s?"#"+s:":scope")+" "+Q(l[o]);c=l.join(",")}try{return k.apply(n,f.querySelectorAll(c)),n}catch(e){h(t,!0)}finally{s===S&&e.removeAttribute("id")}}}return re(t.replace(ve,"$1"),e,n,r)}function W(){var r=[];return function e(t,n){return r.push(t+" ")>b.cacheLength&&delete e[r.shift()],e[t+" "]=n}}function F(e){return e[S]=!0,e}function $(e){var t=T.createElement("fieldset");try{return!!e(t)}catch(e){return!1}finally{t.parentNode&&t.parentNode.removeChild(t),t=null}}function B(t){return function(e){return fe(e,"input")&&e.type===t}}function _(t){return function(e){return(fe(e,"input")||fe(e,"button"))&&e.type===t}}function z(t){return function(e){return"form"in e?e.parentNode&&!1===e.disabled?"label"in e?"label"in e.parentNode?e.parentNode.disabled===t:e.disabled===t:e.isDisabled===t||e.isDisabled!==!t&&R(e)===t:e.disabled===t:"label"in e&&e.disabled===t}}function X(a){return F(function(o){return o=+o,F(function(e,t){var n,r=a([],e.length,o),i=r.length;while(i--)e[n=r[i]]&&(e[n]=!(t[n]=e[n]))})})}function U(e){return e&&"undefined"!=typeof e.getElementsByTagName&&e}function V(e){var t,n=e?e.ownerDocument||e:ye;return n!=T&&9===n.nodeType&&n.documentElement&&(r=(T=n).documentElement,C=!ce.isXMLDoc(T),i=r.matches||r.webkitMatchesSelector||r.msMatchesSelector,r.msMatchesSelector&&ye!=T&&(t=T.defaultView)&&t.top!==t&&t.addEventListener("unload",M),le.getById=$(function(e){return r.appendChild(e).id=ce.expando,!T.getElementsByName||!T.getElementsByName(ce.expando).length}),le.disconnectedMatch=$(function(e){return i.call(e,"*")}),le.scope=$(function(){return T.querySelectorAll(":scope")}),le.cssHas=$(function(){try{return T.querySelector(":has(*,:jqfake)"),!1}catch(e){return!0}}),le.getById?(b.filter.ID=function(e){var t=e.replace(O,P);return function(e){return e.getAttribute("id")===t}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n=t.getElementById(e);return n?[n]:[]}}):(b.filter.ID=function(e){var n=e.replace(O,P);return function(e){var t="undefined"!=typeof e.getAttributeNode&&e.getAttributeNode("id");return t&&t.value===n}},b.find.ID=function(e,t){if("undefined"!=typeof t.getElementById&&C){var n,r,i,o=t.getElementById(e);if(o){if((n=o.getAttributeNode("id"))&&n.value===e)return[o];i=t.getElementsByName(e),r=0;while(o=i[r++])if((n=o.getAttributeNode("id"))&&n.value===e)return[o]}return[]}}),b.find.TAG=function(e,t){return"undefined"!=typeof t.getElementsByTagName?t.getElementsByTagName(e):t.querySelectorAll(e)},b.find.CLASS=function(e,t){if("undefined"!=typeof t.getElementsByClassName&&C)return t.getElementsByClassName(e)},d=[],$(function(e){var t;r.appendChild(e).innerHTML="",e.querySelectorAll("[selected]").length||d.push("\\["+ge+"*(?:value|"+f+")"),e.querySelectorAll("[id~="+S+"-]").length||d.push("~="),e.querySelectorAll("a#"+S+"+*").length||d.push(".#.+[+~]"),e.querySelectorAll(":checked").length||d.push(":checked"),(t=T.createElement("input")).setAttribute("type","hidden"),e.appendChild(t).setAttribute("name","D"),r.appendChild(e).disabled=!0,2!==e.querySelectorAll(":disabled").length&&d.push(":enabled",":disabled"),(t=T.createElement("input")).setAttribute("name",""),e.appendChild(t),e.querySelectorAll("[name='']").length||d.push("\\["+ge+"*name"+ge+"*="+ge+"*(?:''|\"\")")}),le.cssHas||d.push(":has"),d=d.length&&new RegExp(d.join("|")),l=function(e,t){if(e===t)return a=!0,0;var n=!e.compareDocumentPosition-!t.compareDocumentPosition;return n||(1&(n=(e.ownerDocument||e)==(t.ownerDocument||t)?e.compareDocumentPosition(t):1)||!le.sortDetached&&t.compareDocumentPosition(e)===n?e===T||e.ownerDocument==ye&&I.contains(ye,e)?-1:t===T||t.ownerDocument==ye&&I.contains(ye,t)?1:o?se.call(o,e)-se.call(o,t):0:4&n?-1:1)}),T}for(e in I.matches=function(e,t){return I(e,null,null,t)},I.matchesSelector=function(e,t){if(V(e),C&&!h[t+" "]&&(!d||!d.test(t)))try{var n=i.call(e,t);if(n||le.disconnectedMatch||e.document&&11!==e.document.nodeType)return n}catch(e){h(t,!0)}return 0":{dir:"parentNode",first:!0}," ":{dir:"parentNode"},"+":{dir:"previousSibling",first:!0},"~":{dir:"previousSibling"}},preFilter:{ATTR:function(e){return e[1]=e[1].replace(O,P),e[3]=(e[3]||e[4]||e[5]||"").replace(O,P),"~="===e[2]&&(e[3]=" "+e[3]+" "),e.slice(0,4)},CHILD:function(e){return e[1]=e[1].toLowerCase(),"nth"===e[1].slice(0,3)?(e[3]||I.error(e[0]),e[4]=+(e[4]?e[5]+(e[6]||1):2*("even"===e[3]||"odd"===e[3])),e[5]=+(e[7]+e[8]||"odd"===e[3])):e[3]&&I.error(e[0]),e},PSEUDO:function(e){var t,n=!e[6]&&e[2];return D.CHILD.test(e[0])?null:(e[3]?e[2]=e[4]||e[5]||"":n&&j.test(n)&&(t=Y(n,!0))&&(t=n.indexOf(")",n.length-t)-n.length)&&(e[0]=e[0].slice(0,t),e[2]=n.slice(0,t)),e.slice(0,3))}},filter:{TAG:function(e){var t=e.replace(O,P).toLowerCase();return"*"===e?function(){return!0}:function(e){return fe(e,t)}},CLASS:function(e){var t=s[e+" "];return t||(t=new RegExp("(^|"+ge+")"+e+"("+ge+"|$)"))&&s(e,function(e){return t.test("string"==typeof e.className&&e.className||"undefined"!=typeof e.getAttribute&&e.getAttribute("class")||"")})},ATTR:function(n,r,i){return function(e){var t=I.attr(e,n);return null==t?"!="===r:!r||(t+="","="===r?t===i:"!="===r?t!==i:"^="===r?i&&0===t.indexOf(i):"*="===r?i&&-1:\x20\t\r\n\f]*)[\x20\t\r\n\f]*\/?>(?:<\/\1>|)$/i;function T(e,n,r){return v(n)?ce.grep(e,function(e,t){return!!n.call(e,t,e)!==r}):n.nodeType?ce.grep(e,function(e){return e===n!==r}):"string"!=typeof n?ce.grep(e,function(e){return-1)[^>]*|#([\w-]+))$/;(ce.fn.init=function(e,t,n){var r,i;if(!e)return this;if(n=n||k,"string"==typeof e){if(!(r="<"===e[0]&&">"===e[e.length-1]&&3<=e.length?[null,e,null]:S.exec(e))||!r[1]&&t)return!t||t.jquery?(t||n).find(e):this.constructor(t).find(e);if(r[1]){if(t=t instanceof ce?t[0]:t,ce.merge(this,ce.parseHTML(r[1],t&&t.nodeType?t.ownerDocument||t:C,!0)),w.test(r[1])&&ce.isPlainObject(t))for(r in t)v(this[r])?this[r](t[r]):this.attr(r,t[r]);return this}return(i=C.getElementById(r[2]))&&(this[0]=i,this.length=1),this}return e.nodeType?(this[0]=e,this.length=1,this):v(e)?void 0!==n.ready?n.ready(e):e(ce):ce.makeArray(e,this)}).prototype=ce.fn,k=ce(C);var E=/^(?:parents|prev(?:Until|All))/,j={children:!0,contents:!0,next:!0,prev:!0};function A(e,t){while((e=e[t])&&1!==e.nodeType);return e}ce.fn.extend({has:function(e){var t=ce(e,this),n=t.length;return this.filter(function(){for(var e=0;e\x20\t\r\n\f]*)/i,Ce=/^$|^module$|\/(?:java|ecma)script/i;xe=C.createDocumentFragment().appendChild(C.createElement("div")),(be=C.createElement("input")).setAttribute("type","radio"),be.setAttribute("checked","checked"),be.setAttribute("name","t"),xe.appendChild(be),le.checkClone=xe.cloneNode(!0).cloneNode(!0).lastChild.checked,xe.innerHTML="",le.noCloneChecked=!!xe.cloneNode(!0).lastChild.defaultValue,xe.innerHTML="",le.option=!!xe.lastChild;var ke={thead:[1,"","
"],col:[2,"","
"],tr:[2,"","
"],td:[3,"","
"],_default:[0,"",""]};function Se(e,t){var n;return n="undefined"!=typeof e.getElementsByTagName?e.getElementsByTagName(t||"*"):"undefined"!=typeof e.querySelectorAll?e.querySelectorAll(t||"*"):[],void 0===t||t&&fe(e,t)?ce.merge([e],n):n}function Ee(e,t){for(var n=0,r=e.length;n",""]);var je=/<|&#?\w+;/;function Ae(e,t,n,r,i){for(var o,a,s,u,l,c,f=t.createDocumentFragment(),p=[],d=0,h=e.length;d\s*$/g;function Re(e,t){return fe(e,"table")&&fe(11!==t.nodeType?t:t.firstChild,"tr")&&ce(e).children("tbody")[0]||e}function Ie(e){return e.type=(null!==e.getAttribute("type"))+"/"+e.type,e}function We(e){return"true/"===(e.type||"").slice(0,5)?e.type=e.type.slice(5):e.removeAttribute("type"),e}function Fe(e,t){var n,r,i,o,a,s;if(1===t.nodeType){if(_.hasData(e)&&(s=_.get(e).events))for(i in _.remove(t,"handle events"),s)for(n=0,r=s[i].length;n").attr(n.scriptAttrs||{}).prop({charset:n.scriptCharset,src:n.url}).on("load error",i=function(e){r.remove(),i=null,e&&t("error"===e.type?404:200,e.type)}),C.head.appendChild(r[0])},abort:function(){i&&i()}}});var Jt,Kt=[],Zt=/(=)\?(?=&|$)|\?\?/;ce.ajaxSetup({jsonp:"callback",jsonpCallback:function(){var e=Kt.pop()||ce.expando+"_"+jt.guid++;return this[e]=!0,e}}),ce.ajaxPrefilter("json jsonp",function(e,t,n){var r,i,o,a=!1!==e.jsonp&&(Zt.test(e.url)?"url":"string"==typeof e.data&&0===(e.contentType||"").indexOf("application/x-www-form-urlencoded")&&Zt.test(e.data)&&"data");if(a||"jsonp"===e.dataTypes[0])return r=e.jsonpCallback=v(e.jsonpCallback)?e.jsonpCallback():e.jsonpCallback,a?e[a]=e[a].replace(Zt,"$1"+r):!1!==e.jsonp&&(e.url+=(At.test(e.url)?"&":"?")+e.jsonp+"="+r),e.converters["script json"]=function(){return o||ce.error(r+" was not called"),o[0]},e.dataTypes[0]="json",i=ie[r],ie[r]=function(){o=arguments},n.always(function(){void 0===i?ce(ie).removeProp(r):ie[r]=i,e[r]&&(e.jsonpCallback=t.jsonpCallback,Kt.push(r)),o&&v(i)&&i(o[0]),o=i=void 0}),"script"}),le.createHTMLDocument=((Jt=C.implementation.createHTMLDocument("").body).innerHTML="
",2===Jt.childNodes.length),ce.parseHTML=function(e,t,n){return"string"!=typeof e?[]:("boolean"==typeof t&&(n=t,t=!1),t||(le.createHTMLDocument?((r=(t=C.implementation.createHTMLDocument("")).createElement("base")).href=C.location.href,t.head.appendChild(r)):t=C),o=!n&&[],(i=w.exec(e))?[t.createElement(i[1])]:(i=Ae([e],t,o),o&&o.length&&ce(o).remove(),ce.merge([],i.childNodes)));var r,i,o},ce.fn.load=function(e,t,n){var r,i,o,a=this,s=e.indexOf(" ");return-1").append(ce.parseHTML(e)).find(r):e)}).always(n&&function(e,t){a.each(function(){n.apply(this,o||[e.responseText,t,e])})}),this},ce.expr.pseudos.animated=function(t){return ce.grep(ce.timers,function(e){return t===e.elem}).length},ce.offset={setOffset:function(e,t,n){var r,i,o,a,s,u,l=ce.css(e,"position"),c=ce(e),f={};"static"===l&&(e.style.position="relative"),s=c.offset(),o=ce.css(e,"top"),u=ce.css(e,"left"),("absolute"===l||"fixed"===l)&&-1<(o+u).indexOf("auto")?(a=(r=c.position()).top,i=r.left):(a=parseFloat(o)||0,i=parseFloat(u)||0),v(t)&&(t=t.call(e,n,ce.extend({},s))),null!=t.top&&(f.top=t.top-s.top+a),null!=t.left&&(f.left=t.left-s.left+i),"using"in t?t.using.call(e,f):c.css(f)}},ce.fn.extend({offset:function(t){if(arguments.length)return void 0===t?this:this.each(function(e){ce.offset.setOffset(this,t,e)});var e,n,r=this[0];return r?r.getClientRects().length?(e=r.getBoundingClientRect(),n=r.ownerDocument.defaultView,{top:e.top+n.pageYOffset,left:e.left+n.pageXOffset}):{top:0,left:0}:void 0},position:function(){if(this[0]){var e,t,n,r=this[0],i={top:0,left:0};if("fixed"===ce.css(r,"position"))t=r.getBoundingClientRect();else{t=this.offset(),n=r.ownerDocument,e=r.offsetParent||n.documentElement;while(e&&(e===n.body||e===n.documentElement)&&"static"===ce.css(e,"position"))e=e.parentNode;e&&e!==r&&1===e.nodeType&&((i=ce(e).offset()).top+=ce.css(e,"borderTopWidth",!0),i.left+=ce.css(e,"borderLeftWidth",!0))}return{top:t.top-i.top-ce.css(r,"marginTop",!0),left:t.left-i.left-ce.css(r,"marginLeft",!0)}}},offsetParent:function(){return this.map(function(){var e=this.offsetParent;while(e&&"static"===ce.css(e,"position"))e=e.offsetParent;return e||J})}}),ce.each({scrollLeft:"pageXOffset",scrollTop:"pageYOffset"},function(t,i){var o="pageYOffset"===i;ce.fn[t]=function(e){return M(this,function(e,t,n){var r;if(y(e)?r=e:9===e.nodeType&&(r=e.defaultView),void 0===n)return r?r[i]:e[t];r?r.scrollTo(o?r.pageXOffset:n,o?n:r.pageYOffset):e[t]=n},t,e,arguments.length)}}),ce.each(["top","left"],function(e,n){ce.cssHooks[n]=Ye(le.pixelPosition,function(e,t){if(t)return t=Ge(e,n),_e.test(t)?ce(e).position()[n]+"px":t})}),ce.each({Height:"height",Width:"width"},function(a,s){ce.each({padding:"inner"+a,content:s,"":"outer"+a},function(r,o){ce.fn[o]=function(e,t){var n=arguments.length&&(r||"boolean"!=typeof e),i=r||(!0===e||!0===t?"margin":"border");return M(this,function(e,t,n){var r;return y(e)?0===o.indexOf("outer")?e["inner"+a]:e.document.documentElement["client"+a]:9===e.nodeType?(r=e.documentElement,Math.max(e.body["scroll"+a],r["scroll"+a],e.body["offset"+a],r["offset"+a],r["client"+a])):void 0===n?ce.css(e,t,i):ce.style(e,t,n,i)},s,n?e:void 0,n)}})}),ce.each(["ajaxStart","ajaxStop","ajaxComplete","ajaxError","ajaxSuccess","ajaxSend"],function(e,t){ce.fn[t]=function(e){return this.on(t,e)}}),ce.fn.extend({bind:function(e,t,n){return this.on(e,null,t,n)},unbind:function(e,t){return this.off(e,null,t)},delegate:function(e,t,n,r){return this.on(t,e,n,r)},undelegate:function(e,t,n){return 1===arguments.length?this.off(e,"**"):this.off(t,e||"**",n)},hover:function(e,t){return this.on("mouseenter",e).on("mouseleave",t||e)}}),ce.each("blur focus focusin focusout resize scroll click dblclick mousedown mouseup mousemove mouseover mouseout mouseenter mouseleave change select submit keydown keypress keyup contextmenu".split(" "),function(e,n){ce.fn[n]=function(e,t){return 00}function l(s){return s.replace(/&/g,"&").replace(//g,">").replace(/'/g,"'").replace(/"/g,""")}function t(s,n){var a="";if("string"==typeof s)s=l(s),n.withLinks&&function(s){for(var e=["http","https","ftp","ftps"],l=0;l'+s+"":a+='"'+(s=s.replace(/"/g,"\\""))+'"';else if("number"==typeof s||"bigint"==typeof s)a+=''+s+"";else if("boolean"==typeof s)a+=''+s+"";else if(null===s)a+='null';else if(s instanceof Array)if(s.length>0){a+='[
    ';for(var o=0;o",e(s[o])&&(a+=''),a+=t(s[o],n),o";a+="
]"}else a+="[]";else if("object"==typeof s)if(n.bigNumbers&&("function"==typeof s.toExponential||s.isLosslessNumber))a+=''+s.toString()+"";else{var i=Object.keys(s).length;if(i>0){for(var r in a+='{
    ',s)if(Object.prototype.hasOwnProperty.call(s,r)){r=l(r);var c=n.withQuotes?'"'+r+'"':r;a+="
  • ",e(s[r])?a+=''+c+"":a+=c,a+=": "+t(s[r],n),--i>0&&(a+=","),a+="
  • "}a+="
}"}else a+="{}"}return a}s.fn.jsonViewer=function(l,n){return n=Object.assign({},{collapsed:!1,rootCollapsable:!0,withQuotes:!1,withLinks:!0,bigNumbers:!1},n),this.each((function(){var a=t(l,n);n.rootCollapsable&&e(l)&&(a=''+a),s(this).html(a),s(this).addClass("json-document"),s(this).off("click"),s(this).on("click","a.json-toggle",(function(){var e=s(this).toggleClass("collapsed").siblings("ul.json-dict, ol.json-array");if(e.toggle(),e.is(":visible"))e.siblings(".json-placeholder").remove();else{var l=e.children("li").length,t=l+(l>1?" items":" item");e.after(''+t+"")}return!1})),s(this).on("click","a.json-placeholder",(function(){return s(this).siblings("a.json-toggle").click(),!1})),1==n.collapsed&&s(this).find("a.json-toggle").click()}))}}(jQuery); +//# sourceMappingURL=/sm/465958409b0102294d491b30ce64ca72a25b1f2a2eeb08f5c4d0e06c26fea020.map \ No newline at end of file diff --git a/src/static/js/modules/config.js b/src/static/js/modules/config.js new file mode 100644 index 0000000..3970db0 --- /dev/null +++ b/src/static/js/modules/config.js @@ -0,0 +1,373 @@ +/** + * @File: apiConfigManager.js + * @Date: 2025-08-06 + * @Brief: API 엔드포인트 설정 관리 모듈 + */ + +const ApiSettings = { + endpoints: [], + currentUrl: '', + connectionStatus: 'checking', // 'connected', 'disconnected', 'checking' + statusCheckInterval: null, + + async init() { + console.log('ApiSettings 초기화 시작...'); + try { + await this.loadEndpoints(); + this.setupEventListeners(); + this.updateUI(); + this.startConnectionMonitoring(); + console.log('ApiSettings 초기화 완료'); + } catch (error) { + console.error('API Config Manager 초기화 실패:', error); + this.showMessage('API 설정 로드 실패: ' + error.message, 'error'); + } + }, + + async loadEndpoints() { + console.log('API 엔드포인트 로드 시작...'); + try { + const response = await fetch('/api-endpoints'); + console.log('API 엔드포인트 응답 상태:', response.status); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}: ${response.statusText}`); + } + + const data = await response.json(); + console.log('API 엔드포인트 응답 데이터:', data); + + this.endpoints = data.predefined_endpoints || []; + this.currentUrl = data.current_url || ''; + + console.log('로드된 엔드포인트 개수:', this.endpoints.length); + console.log('현재 URL:', this.currentUrl); + } catch (error) { + console.error('API 엔드포인트 로드 실패:', error); + throw error; + } + }, + + setupEventListeners() { + console.log('이벤트 리스너 설정 시작...'); + + // 설정 토글 (전체 헤더 클릭 가능) + const configHeader = document.querySelector('.config_header'); + const toggleBtn = document.getElementById('toggle_config_btn'); + const configContent = document.getElementById('config_content'); + + console.log('설정 헤더:', configHeader); + console.log('토글 버튼:', toggleBtn); + console.log('설정 컨텐츠:', configContent); + + if (configHeader && configContent && toggleBtn) { + const toggleConfig = () => { + console.log('설정 패널 토글됨'); + const isVisible = configContent.style.display !== 'none'; + configContent.style.display = isVisible ? 'none' : 'block'; + toggleBtn.textContent = isVisible ? '▼' : '▲'; + console.log('패널 상태 변경:', isVisible ? '숨김' : '표시'); + }; + + configHeader.addEventListener('click', toggleConfig); + console.log('설정 헤더 클릭 이벤트 리스너 등록 완료'); + } else { + console.error('설정 헤더, 토글 버튼 또는 설정 컨텐츠를 찾을 수 없음'); + } + + // 커스텀 URL 설정 버튼 + const setCustomBtn = document.getElementById('set_custom_url_btn'); + const customInput = document.getElementById('custom_url_input'); + + console.log('커스텀 버튼:', setCustomBtn); + console.log('커스텀 입력:', customInput); + + if (setCustomBtn && customInput) { + setCustomBtn.addEventListener('click', () => { + console.log('커스텀 URL 버튼 클릭됨'); + const customUrl = customInput.value.trim(); + if (customUrl) { + this.changeApiUrl(customUrl); + } + }); + + // Enter 키로도 설정 가능 + customInput.addEventListener('keypress', (e) => { + if (e.key === 'Enter') { + console.log('Enter 키 눌림'); + const customUrl = customInput.value.trim(); + if (customUrl) { + this.changeApiUrl(customUrl); + } + } + }); + console.log('커스텀 URL 이벤트 리스너 등록 완료'); + } else { + console.error('커스텀 URL 요소들을 찾을 수 없음'); + } + + // 새로고침 버튼 + const refreshBtn = document.getElementById('refresh_connection_btn'); + if (refreshBtn) { + refreshBtn.addEventListener('click', () => { + console.log('연결 상태 새로고침 버튼 클릭됨'); + this.checkConnectionStatus(true); + }); + console.log('새로고침 버튼 이벤트 리스너 등록 완료'); + } else { + console.error('새로고침 버튼을 찾을 수 없음'); + } + }, + + updateUI() { + this.updateCurrentUrlDisplay(); + this.createEndpointButtons(); + this.updateConnectionStatusUI(); + }, + + updateCurrentUrlDisplay() { + const currentUrlElement = document.getElementById('current_api_url'); + if (currentUrlElement) { + currentUrlElement.textContent = this.currentUrl || '알 수 없음'; + } + }, + + createEndpointButtons() { + const buttonsContainer = document.getElementById('endpoint_buttons'); + if (!buttonsContainer) return; + + buttonsContainer.innerHTML = ''; + + this.endpoints.forEach((endpoint, index) => { + const button = document.createElement('button'); + button.className = 'endpoint_btn'; + button.textContent = endpoint.name; + button.title = endpoint.description; + + // 현재 URL과 일치하면 active 클래스 추가 + if (endpoint.url === this.currentUrl) { + button.classList.add('active'); + } + + button.addEventListener('click', () => { + this.changeApiUrl(endpoint.url); + }); + + buttonsContainer.appendChild(button); + }); + }, + + async changeApiUrl(newUrl) { + if (!newUrl) { + alert('URL을 입력하세요.'); + return; + } + + if (!newUrl.startsWith('http://') && !newUrl.startsWith('https://')) { + alert('올바른 URL 형식이 아닙니다. http:// 또는 https://로 시작해야 합니다.'); + return; + } + + try { + const response = await fetch('/change-api-url', { + method: 'POST', + headers: { + 'Content-Type': 'application/json', + }, + body: JSON.stringify({ url: newUrl }) + }); + + const data = await response.json(); + + if (response.ok) { + this.currentUrl = newUrl; + this.updateCurrentUrlDisplay(); + this.updateButtonStates(); + + // 커스텀 URL 입력 필드 초기화 + const customInput = document.getElementById('custom_url_input'); + if (customInput) { + customInput.value = ''; + } + + // 연결 상태 즉시 확인 + this.checkConnectionStatus(true); + + // 성공 메시지 표시 (선택적) + this.showMessage(`API URL이 변경되었습니다: ${newUrl}`, 'success'); + + console.log('API URL 변경 성공:', data); + } else { + throw new Error(data.detail || 'API URL 변경 실패'); + } + } catch (error) { + console.error('API URL 변경 오류:', error); + this.showMessage(`API URL 변경 실패: ${error.message}`, 'error'); + } + }, + + updateButtonStates() { + const buttons = document.querySelectorAll('.endpoint_btn'); + buttons.forEach(button => { + const endpoint = this.endpoints.find(ep => ep.name === button.textContent); + if (endpoint && endpoint.url === this.currentUrl) { + button.classList.add('active'); + } else { + button.classList.remove('active'); + } + }); + }, + + showMessage(message, type = 'info') { + // 간단한 메시지 표시 (기존 error_zone 활용) + const errorZone = document.getElementById('error_zone'); + if (errorZone) { + errorZone.textContent = message; + errorZone.style.color = type === 'error' ? 'red' : + type === 'success' ? 'green' : 'blue'; + + // 3초 후 메시지 제거 + setTimeout(() => { + errorZone.textContent = ''; + }, 3000); + } else { + // error_zone이 없으면 alert 사용 + alert(message); + } + }, + + // 연결 상태 모니터링 시작 + startConnectionMonitoring() { + console.log('연결 상태 모니터링 시작'); + // 초기 상태 확인 + this.checkConnectionStatus(); + + // 30초마다 상태 확인 + this.statusCheckInterval = setInterval(() => { + this.checkConnectionStatus(); + }, 30000); + }, + + // 연결 상태 확인 + async checkConnectionStatus(showAnimation = false) { + console.log('API 연결 상태 확인 중...'); + + // 확인 중 상태로 설정 + this.setConnectionStatus('checking'); + + // 새로고침 버튼 애니메이션 + if (showAnimation) { + const refreshBtn = document.getElementById('refresh_connection_btn'); + if (refreshBtn) { + refreshBtn.classList.add('spinning'); + } + } + + try { + const response = await fetch('/api-status', { + method: 'GET', + cache: 'no-cache' + }); + + const data = await response.json(); + + if (response.ok && data.status === 'connected') { + this.setConnectionStatus('connected'); + console.log('API 연결 상태: 정상'); + } else { + this.setConnectionStatus('disconnected'); + console.log('API 연결 상태: 실패', data); + } + } catch (error) { + console.error('연결 상태 확인 실패:', error); + this.setConnectionStatus('disconnected'); + } finally { + // 새로고침 버튼 애니메이션 제거 + if (showAnimation) { + setTimeout(() => { + const refreshBtn = document.getElementById('refresh_connection_btn'); + if (refreshBtn) { + refreshBtn.classList.remove('spinning'); + } + }, 1000); + } + } + }, + + // 연결 상태 설정 + setConnectionStatus(status) { + const previousStatus = this.connectionStatus; + this.connectionStatus = status; + + // UI 업데이트 + this.updateConnectionStatusUI(); + + // 상태 변경 시 플래시 효과 + if (previousStatus !== status && previousStatus !== 'checking') { + this.flashStatusChange(status); + } + }, + + // 연결 상태 UI 업데이트 + updateConnectionStatusUI() { + const statusDot = document.getElementById('status_dot'); + const statusText = document.getElementById('connection_status_text'); + + if (!statusDot || !statusText) return; + + // 기존 클래스 제거 + statusDot.className = 'status_dot'; + statusText.className = ''; + + // 상태별 클래스 및 텍스트 설정 + switch (this.connectionStatus) { + case 'connected': + statusDot.classList.add('connected'); + statusText.classList.add('connected'); + statusText.textContent = '연결됨'; + break; + case 'disconnected': + statusDot.classList.add('disconnected'); + statusText.classList.add('disconnected'); + statusText.textContent = '연결 실패'; + break; + case 'checking': + default: + statusDot.classList.add('checking'); + statusText.classList.add('checking'); + statusText.textContent = '연결 확인 중...'; + break; + } + }, + + // 상태 변경 플래시 효과 + flashStatusChange(newStatus) { + const statusDot = document.getElementById('status_dot'); + if (!statusDot) return; + + const flashClass = newStatus === 'connected' ? 'flash-success' : 'flash-error'; + + statusDot.classList.add(flashClass); + setTimeout(() => { + statusDot.classList.remove(flashClass); + }, 600); + }, + + // 정리 함수 (페이지 언로드시 호출) + cleanup() { + if (this.statusCheckInterval) { + clearInterval(this.statusCheckInterval); + this.statusCheckInterval = null; + } + } +}; + +// 페이지 언로드시 정리 +window.addEventListener('beforeunload', () => { + if (window.ApiSettings) { + window.ApiSettings.cleanup(); + } +}); + +// 전역에서 사용할 수 있도록 window 객체에 추가 +window.ApiSettings = ApiSettings; \ No newline at end of file diff --git a/src/static/js/modules/http.js b/src/static/js/modules/http.js new file mode 100644 index 0000000..dd64783 --- /dev/null +++ b/src/static/js/modules/http.js @@ -0,0 +1,159 @@ +/* + * @File: apiClient.js + * @Date: 2025-08-05 + * @Author: SGM + * @Brief: API 통신 클라이언트 모듈 + * @section MODIFYINFO 수정정보 + */ + +class HttpClient { + constructor() { + this.baseUrl = ''; // 현재 호스트 사용 + this.defaultTimeout = 600000; // 600초 (10분) - 백엔드와 동일하게 설정 + } + + /** + * 이미지 생성 API 호출 + */ + async generateImage(data, apiName = null) { + const endpoint = apiName ? `/create/${apiName}` : API_ENDPOINTS.CREATE; + + console.log('ApiClient.generateImage 호출:', { data, apiName, endpoint }); + + try { + const response = await this.makeRequest('POST', endpoint, data); + console.log('ApiClient 응답 성공:', response); + return response; + } catch (error) { + console.error('ApiClient 오류:', error); + throw this.handleApiError(error); + } + } + + /** + * 서버 설정 정보 가져오기 + */ + async getConfig() { + try { + const response = await this.makeRequest('GET', '/config'); + return response; + } catch (error) { + console.error('설정 로드 실패:', error); + return null; + } + } + + /** + * 서버 상태 확인 + */ + async checkHealth() { + try { + const response = await this.makeRequest('GET', '/health'); + return response; + } catch (error) { + console.error('헬스체크 실패:', error); + return null; + } + } + + /** + * API 연결 상태 확인 + */ + async checkApiStatus() { + try { + const response = await this.makeRequest('GET', '/api-status'); + return response; + } catch (error) { + console.error('API 상태 확인 실패:', error); + return null; + } + } + + /** + * HTTP 요청 실행 + */ + async makeRequest(method, endpoint, data = null) { + return new Promise((resolve, reject) => { + const ajaxOptions = { + url: this.baseUrl + endpoint, + type: method, + timeout: this.defaultTimeout, + success: (response, textStatus, jqXHR) => { + resolve(response); + }, + error: (jqXHR, textStatus, errorThrown) => { + const error = this.parseError(jqXHR, textStatus, errorThrown); + reject(error); + } + }; + + if (data && (method === 'POST' || method === 'PUT')) { + ajaxOptions.contentType = 'application/json'; + ajaxOptions.data = JSON.stringify(data); + } + + $.ajax(ajaxOptions); + }); + } + + /** + * 오류 응답 파싱 + */ + parseError(jqXHR, textStatus, errorThrown) { + let errorInfo = { + status: jqXHR.status, + statusText: jqXHR.statusText, + textStatus: textStatus, + errorThrown: errorThrown, + response: null + }; + + try { + if (jqXHR.responseText) { + errorInfo.response = JSON.parse(jqXHR.responseText); + } + } catch (e) { + errorInfo.response = { detail: jqXHR.responseText }; + } + + return errorInfo; + } + + /** + * API 오류 처리 + */ + handleApiError(error) { + // 구체적인 오류 정보 생성 + const processedError = { + message: '알 수 없는 오류가 발생했습니다.', + type: 'unknown', + status: error.status, + response: error.response + }; + + if (error.textStatus === 'timeout') { + processedError.message = '요청 시간이 초과되었습니다. 잠시 후 다시 시도해주세요.'; + processedError.type = 'timeout'; + } else if (error.textStatus === 'error') { + if (error.status === 0) { + processedError.message = '서버에 연결할 수 없습니다. 네트워크 연결을 확인해주세요.'; + processedError.type = 'network'; + } else if (error.response && error.response.detail) { + processedError.message = error.response.detail; + processedError.type = error.response.error_type || 'server_error'; + } + } + + return processedError; + } + + /** + * 요청 타임아웃 설정 + */ + setTimeout(timeout) { + this.defaultTimeout = timeout; + } +} + +// 전역으로 노출 +window.HttpClient = HttpClient; \ No newline at end of file diff --git a/src/static/js/modules/main.js b/src/static/js/modules/main.js new file mode 100644 index 0000000..1df4580 --- /dev/null +++ b/src/static/js/modules/main.js @@ -0,0 +1,213 @@ +/* + * @File: imageGenerator.js + * @Date: 2025-08-05 + * @Author: SGM + * @Brief: 이미지 생성 메인 컨트롤러 모듈 + * @section MODIFYINFO 수정정보 + */ + +class MainController { + constructor() { + this.isGenerating = false; + this.apiClient = new HttpClient(); + this.uiManager = new UiUpdater(); + + // 상태 관리자 연결 + if (window.appState) { + this.stateManager = window.appState; + this.setupStateSubscription(); + } + } + + /** + * 상태 변경 구독 설정 + */ + setupStateSubscription() { + this.stateManager.subscribe((newState, prevState) => { + // 로딩 상태 변경 시 UI 업데이트 + if (newState.isLoading !== prevState.isLoading) { + this.uiManager.toggleLoading(newState.isLoading); + } + + // 결과 상태 변경 시 UI 업데이트 + if (newState.lastResult !== prevState.lastResult) { + this.displayResults(newState.lastResult); + } + }); + } + + /** + * 이미지 생성 메인 함수 + */ + async generateImage() { + console.log('=== generateImage 시작 ==='); + + if (this.isGenerating) { + console.warn('이미지 생성이 이미 진행 중입니다.'); + return; + } + + try { + // 입력값 수집 및 검증 + const inputData = this.collectInputData(); + if (!this.validateInput(inputData)) { + return; + } + + this.isGenerating = true; + + // 상태 관리자 업데이트 + if (this.stateManager) { + this.stateManager.setLoading(true); + this.stateManager.clearResults(); + this.stateManager.updateSettings(inputData); + } + + // UI 초기화 + this.uiManager.clearResults(); + this.uiManager.showLoading(); + + // API 호출 + console.log('API 호출 시작:', inputData); + const result = await this.apiClient.generateImage(inputData); + console.log('API 호출 성공:', result); + + // 결과 처리 + this.handleSuccess(result); + + } catch (error) { + console.error('generateImage 오류:', error); + this.handleError(error); + } finally { + this.isGenerating = false; + this.uiManager.hideLoading(); + + if (this.stateManager) { + this.stateManager.setLoading(false); + } + } + } + + /** + * 입력 데이터 수집 + */ + collectInputData() { + return { + prompt: $(SELECTORS.PROMPT_INPUT).val().trim(), + modelType: $(SELECTORS.MODEL_TYPE_SELECT).val(), + indexType: $(SELECTORS.INDEX_TYPE_SELECT).val(), + searchNum: parseInt($(SELECTORS.SEARCH_NUM_INPUT).val(), 10), + querySend: true + }; + } + + /** + * 입력값 검증 + */ + validateInput(data) { + if (!data.prompt) { + this.uiManager.displayError('프롬프트를 입력해주세요.'); + return false; + } + + if (isNaN(data.searchNum) || data.searchNum < 1 || data.searchNum > 10) { + this.uiManager.displayError('검색 개수는 1-10 사이의 숫자여야 합니다.'); + return false; + } + + return true; + } + + /** + * 성공 응답 처리 + */ + handleSuccess(result) { + console.log('이미지 생성 성공:', result); + + // 상태 관리자 업데이트 + if (this.stateManager) { + this.stateManager.setResult(result); + } + + // UI 업데이트 + this.displayResults(result); + + // 서버 제한 알림 (백엔드에서 추가한 제한 정보) + if (result._server_limitation) { + this.uiManager.displayWarning(result._server_limitation.message); + } + } + + /** + * 오류 처리 + */ + handleError(error) { + console.error('이미지 생성 오류:', error); + + let errorMessage = '이미지 생성 중 오류가 발생했습니다.'; + + if (error.response) { + // HTTP 오류 + const data = error.response; + switch (data.error_type) { + case 'validation_error': + errorMessage = data.detail || '입력값이 유효하지 않습니다.'; + break; + case 'connection_error': + errorMessage = '외부 API 서버에 연결할 수 없습니다. 네트워크를 확인해주세요.'; + break; + case 'api_error': + errorMessage = `외부 API 오류 (${data.status_code}): ${data.detail}`; + break; + default: + errorMessage = data.detail || errorMessage; + } + } + + this.uiManager.displayError(errorMessage); + } + + /** + * 결과 표시 + */ + displayResults(result) { + if (!result || !result.result) { + return; + } + + // 쿼리 이미지 표시 + if (result.queryImage) { + this.uiManager.displayQueryImage(result.queryImage); + } + + // 결과 이미지들 표시 + if (result.vectorResult && result.vectorResult.length > 0) { + this.uiManager.displayResultImages(result.vectorResult); + } + + // JSON 뷰어 표시 + this.uiManager.displayJson(result); + } + + /** + * 프리셋 적용 (향후 확장용) + */ + applyPreset(presetName) { + console.log(`프리셋 적용: ${presetName}`); + // TODO: 프리셋 로직 구현 + } + + /** + * API 변경 (향후 확장용) + */ + changeApi(apiName) { + console.log(`API 변경: ${apiName}`); + if (this.stateManager) { + this.stateManager.setCurrentApi(apiName); + } + // TODO: API 변경 로직 구현 + } +} + +// 전역으로 노출 +window.MainController = MainController; \ No newline at end of file diff --git a/src/static/js/modules/state.js b/src/static/js/modules/state.js new file mode 100644 index 0000000..59492a5 --- /dev/null +++ b/src/static/js/modules/state.js @@ -0,0 +1,191 @@ +/* + * @File: stateManager.js + * @Date: 2025-08-05 + * @Author: SGM + * @Brief: 애플리케이션 상태 관리자 + * @section MODIFYINFO 수정정보 + */ + +class AppState { + constructor() { + this.state = { + // API 관련 상태 + currentApi: 'imagen', + availableApis: {}, + + // UI 상태 + isLoading: false, + currentPreset: null, + + // 설정 상태 + settings: { + modelType: 'l14', + indexType: 'cos', + searchNum: 4 + }, + + // 애플리케이션 설정 + config: null, + + // 결과 상태 + lastResult: null, + queryImage: null, + resultImages: [] + }; + + this.subscribers = []; + this.initialized = false; + } + + /** + * 상태 업데이트 + * @param {Object} newState - 업데이트할 상태 + */ + setState(newState) { + const prevState = { ...this.state }; + this.state = { ...this.state, ...newState }; + + // 상태 변경을 구독자들에게 알림 + this.notifySubscribers(prevState, this.state); + + console.log('State updated:', newState); + } + + /** + * 현재 상태 반환 + * @param {string} key - 특정 상태 키 (선택사항) + * @returns {*} 상태 값 + */ + getState(key = null) { + if (key) { + return this.state[key]; + } + return { ...this.state }; + } + + /** + * 상태 변경 구독 + * @param {Function} callback - 상태 변경 시 호출될 콜백 + */ + subscribe(callback) { + this.subscribers.push(callback); + + // 구독 해제 함수 반환 + return () => { + const index = this.subscribers.indexOf(callback); + if (index > -1) { + this.subscribers.splice(index, 1); + } + }; + } + + /** + * 구독자들에게 상태 변경 알림 + * @param {Object} prevState - 이전 상태 + * @param {Object} newState - 새로운 상태 + */ + notifySubscribers(prevState, newState) { + this.subscribers.forEach(callback => { + try { + callback(newState, prevState); + } catch (error) { + console.error('Error in state subscriber:', error); + } + }); + } + + /** + * 서버에서 설정 로드 + */ + async loadConfig() { + try { + const response = await fetch('/config'); + if (response.ok) { + const data = await response.json(); + this.setState({ + config: data.config, + availableApis: data.apis + }); + this.initialized = true; + console.log('애플리케이션 설정 로드 완료'); + } else { + console.error('설정 로드 실패:', response.status); + } + } catch (error) { + console.error('설정 로드 중 오류:', error); + } + } + + /** + * API 변경 + * @param {string} apiName - 새로운 API 이름 + */ + setCurrentApi(apiName) { + if (this.state.availableApis[apiName]) { + this.setState({ currentApi: apiName }); + console.log(`API 변경: ${apiName}`); + } else { + console.error(`알 수 없는 API: ${apiName}`); + } + } + + /** + * 설정 업데이트 + * @param {Object} newSettings - 새로운 설정 + */ + updateSettings(newSettings) { + this.setState({ + settings: { ...this.state.settings, ...newSettings } + }); + } + + /** + * 로딩 상태 변경 + * @param {boolean} isLoading - 로딩 여부 + */ + setLoading(isLoading) { + this.setState({ isLoading }); + } + + /** + * 결과 데이터 설정 + * @param {Object} result - API 응답 결과 + */ + setResult(result) { + this.setState({ + lastResult: result, + queryImage: result.queryImage || null, + resultImages: result.vectorResult || [] + }); + } + + /** + * 결과 초기화 + */ + clearResults() { + this.setState({ + lastResult: null, + queryImage: null, + resultImages: [] + }); + } + + /** + * 상태 초기화 여부 확인 + * @returns {boolean} 초기화 완료 여부 + */ + isInitialized() { + return this.initialized; + } +} + +// 전역 상태 관리자 인스턴스 +const appState = new AppState(); + +// 페이지 로드 시 설정 자동 로드 +document.addEventListener('DOMContentLoaded', () => { + appState.loadConfig(); +}); + +// 전역으로 노출 (다른 스크립트에서 사용할 수 있도록) +window.appState = appState; \ No newline at end of file diff --git a/src/static/js/modules/ui.js b/src/static/js/modules/ui.js new file mode 100644 index 0000000..88298e2 --- /dev/null +++ b/src/static/js/modules/ui.js @@ -0,0 +1,223 @@ +/* + * @File: uiManager.js + * @Date: 2025-08-05 + * @Author: Claude + * @Brief: UI 관리 모듈 + * @section MODIFYINFO 수정정보 + */ + +class UiUpdater { + constructor() { + this.elements = this.cacheElements(); + } + + /** + * DOM 요소 캐싱 + */ + cacheElements() { + return { + promptInput: $(SELECTORS.PROMPT_INPUT), + modelTypeSelect: $(SELECTORS.MODEL_TYPE_SELECT), + indexTypeSelect: $(SELECTORS.INDEX_TYPE_SELECT), + searchNumInput: $(SELECTORS.SEARCH_NUM_INPUT), + errorZone: $(SELECTORS.ERROR_ZONE), + loadingZone: $(SELECTORS.LOADING_ZONE), + queryImageZone: $(SELECTORS.QUERY_IMAGE_ZONE), + resultImageZone: $(SELECTORS.RESULT_IMAGE_ZONE), + jsonViewer: $(SELECTORS.JSON_VIEWER), + generatorBtn: $(SELECTORS.GENERATOR_BTN) + }; + } + + /** + * 로딩 상태 표시 + */ + showLoading() { + this.elements.loadingZone.show(); + this.elements.generatorBtn.prop('disabled', true); + } + + /** + * 로딩 상태 숨김 + */ + hideLoading() { + this.elements.loadingZone.hide(); + this.elements.generatorBtn.prop('disabled', false); + } + + /** + * 로딩 상태 토글 + */ + toggleLoading(isLoading) { + if (isLoading) { + this.showLoading(); + } else { + this.hideLoading(); + } + } + + /** + * 결과 영역 초기화 + */ + clearResults() { + this.elements.queryImageZone.empty(); + this.elements.resultImageZone.empty(); + this.elements.jsonViewer.hide(); + this.clearError(); + } + + /** + * 오류 메시지 표시 + */ + displayError(message) { + this.elements.errorZone.text(message).show(); + console.error('UI Error:', message); + } + + /** + * 경고 메시지 표시 + */ + displayWarning(message) { + // 경고는 에러보다 덜 강조되도록 스타일 구분 + this.elements.errorZone + .text(message) + .removeClass('error') + .addClass('warning') + .show(); + console.warn('UI Warning:', message); + } + + /** + * 오류 메시지 제거 + */ + clearError() { + this.elements.errorZone.text('').hide().removeClass('warning error'); + } + + /** + * 쿼리 이미지 표시 + */ + displayQueryImage(base64Image) { + if (!base64Image) return; + + const img = $('', { + src: BASE64_PREFIX + base64Image, + class: 'query_image', + alt: '생성된 쿼리 이미지' + }); + + this.elements.queryImageZone.empty().append(img); + } + + /** + * 결과 이미지들 표시 + */ + displayResultImages(vectorResult) { + if (!vectorResult || vectorResult.length === 0) { + return; + } + + this.elements.resultImageZone.empty(); + + vectorResult.forEach((item, index) => { + if (item.image && item.percents !== undefined) { + const resultBlock = this.createResultBlock(item, index); + this.elements.resultImageZone.append(resultBlock); + } + }); + + // 이미지 로드 완료 후 애니메이션 효과 (선택사항) + this.animateResults(); + } + + /** + * 결과 블록 생성 + */ + createResultBlock(item, index) { + const block = $('
'); + + const img = $('', { + src: BASE64_PREFIX + item.image, + class: 'result_image', + alt: `결과 이미지 ${index + 1}`, + loading: 'lazy' // 지연 로딩 + }); + + const percent = $('
').text( + `유사도: ${item.percents.toFixed(1)}%` + ); + + block.append(img, percent); + return block; + } + + /** + * JSON 뷰어 표시 + */ + displayJson(data) { + try { + // JSON 뷰어 라이브러리 사용 + this.elements.jsonViewer.jsonViewer(data, { + collapsed: true, // 기본적으로 접힌 상태 + withQuotes: false, + withLinks: false + }).show(); + } catch (error) { + console.error('JSON 뷰어 오류:', error); + // 라이브러리 실패 시 기본 JSON 표시 + this.elements.jsonViewer + .text(JSON.stringify(data, null, 2)) + .show(); + } + } + + /** + * 결과 애니메이션 효과 + */ + animateResults() { + this.elements.resultImageZone.find('.result_block').each(function(index) { + $(this).css('opacity', '0').delay(index * 100).animate({ + opacity: 1 + }, 300); + }); + } + + /** + * 입력값 초기화 + */ + resetInputs() { + this.elements.promptInput.val(''); + this.elements.searchNumInput.val(DEFAULT_VALUES.SEARCH_NUM); + this.clearResults(); + } + + /** + * 설정값 적용 (프리셋 등에서 사용) + */ + applySettings(settings) { + if (settings.modelType) { + this.elements.modelTypeSelect.val(settings.modelType); + } + if (settings.indexType) { + this.elements.indexTypeSelect.val(settings.indexType); + } + if (settings.searchNum) { + this.elements.searchNumInput.val(settings.searchNum); + } + } + + /** + * 현재 설정값 가져오기 + */ + getCurrentSettings() { + return { + prompt: this.elements.promptInput.val().trim(), + modelType: this.elements.modelTypeSelect.val(), + indexType: this.elements.indexTypeSelect.val(), + searchNum: parseInt(this.elements.searchNumInput.val(), 10) + }; + } +} + +// 전역으로 노출 +window.UiUpdater = UiUpdater; \ No newline at end of file diff --git a/src/templates/index.html b/src/templates/index.html index 40b51c0..8c6212d 100755 --- a/src/templates/index.html +++ b/src/templates/index.html @@ -1,58 +1,116 @@ - - - - + + + + ImageGenerator - - - - - + + + + +
- AI Image Generator + AI Image Generator +
+ + +
+
+ API 설정 + +
+
- Bing Art -
- -
-
- Download Count: - -
-
-
- -
+ AI Image Generator +
+ +
+
+ + + + + + +
+
+
+ +
+ +
+
+
+

+      
-
- Imagen -
- -
-
- Download Count: - -
-
-
- -
-
- - - - - \ No newline at end of file + + + + + + + + + + + + + + + + diff --git a/src/web_server.py b/src/web_server.py index ef733e5..4adbfdc 100755 --- a/src/web_server.py +++ b/src/web_server.py @@ -2,21 +2,32 @@ """ @File: web_server.py -@Date: 2025-01-16 -@author: DaoolDNS -@brief: AI 이미지 생성 웹 서버 +@Date: 2025-08-01 +@Author: SGM +@Brief: AI 이미지 생성 웹 서버 @section MODIFYINFO 수정정보 -- 수정자/수정일 : 수정내역 -- 2025-01-16/ksy : base """ import uvicorn +import httpx +import logging +from datetime import datetime from fastapi import FastAPI, Request -from fastapi.responses import HTMLResponse +from fastapi.responses import HTMLResponse, JSONResponse from fastapi.staticfiles import StaticFiles from fastapi.templating import Jinja2Templates -from common.const import SERVICE_PORT +from common.config import SERVICE_PORT, HOST, API_TIMEOUT_SECONDS, PREDEFINED_ENDPOINTS +import common.config as settings +from services.manager import api_manager +from config.app_config import app_config + +# 로깅 설정 +logging.basicConfig( + level=logging.INFO, + format='%(asctime)s - %(name)s - %(levelname)s - %(message)s' +) +logger = logging.getLogger(__name__) app = FastAPI() @@ -24,6 +35,8 @@ app.mount("/static", StaticFiles(directory="static"), name="static") templates = Jinja2Templates(directory="templates") + + @app.get("/", response_class=HTMLResponse) async def home(request: Request): """ @@ -33,5 +46,226 @@ async def home(request: Request): return templates.TemplateResponse("index.html", {"request": request}) +@app.get("/health") +async def health_check(): + """ + 서버 상태 확인 (헬스체크) + 개발 시 서버가 정상 동작하는지 빠르게 확인용 + """ + return { + "status": "healthy", + "timestamp": datetime.now().isoformat(), + "service": "AI Image Generator Web Server" + } + + +@app.get("/api-status") +async def api_status_check(): + """ + 외부 API 연결 상태 확인 + 개발 시 외부 API 서버 연결 상태를 미리 확인할 수 있음 + """ + try: + # 간단한 연결 테스트 (실제 API 호출 없이 연결만 확인) + async with httpx.AsyncClient(timeout=API_TIMEOUT_SECONDS) as client: + test_url = settings.EXTERNAL_API_URL.split('/api/')[0] if '/api/' in settings.EXTERNAL_API_URL else settings.EXTERNAL_API_URL + response = await client.get(test_url, timeout=API_TIMEOUT_SECONDS) + + return { + "status": "connected", + "api_url": settings.EXTERNAL_API_URL, + "connection_test": "success", + "timestamp": datetime.now().isoformat() + } + except Exception as exc: + logger.warning(f"API 연결 테스트 실패: {exc}") + return { + "status": "disconnected", + "api_url": settings.EXTERNAL_API_URL, + "connection_test": "failed", + "error": str(exc), + "timestamp": datetime.now().isoformat(), + "note": "외부 API 서버가 실행되지 않았거나 네트워크 문제일 수 있습니다." + } + + +@app.get("/config") +async def get_frontend_config(): + """ + 프론트엔드에서 필요한 설정 정보 반환 + """ + try: + config_data = app_config.get_config_for_frontend() + available_apis = api_manager.get_available_apis() + + return JSONResponse(status_code=200, content={ + "config": config_data, + "apis": available_apis + }) + except Exception as exc: + logger.error(f"설정 로드 오류: {exc}") + return JSONResponse( + status_code=500, + content={"detail": "설정을 로드할 수 없습니다.", "error_type": "config_error"} + ) + + +@app.post("/create") +async def create_item(request: Request): + """ + 이미지 생성 요청 처리 (기본 API 사용) + """ + return await _create_with_api(request, api_name=None) + + +@app.post("/create/{api_name}") +async def create_with_specific_api(api_name: str, request: Request): + """ + 특정 API로 이미지 생성 요청 처리 + + Args: + api_name: 사용할 API 이름 (예: 'imagen', 'dalle') + """ + return await _create_with_api(request, api_name=api_name) + + +@app.get("/api-endpoints") +async def get_api_endpoints(): + """ + 사용 가능한 API 엔드포인트 목록 반환 + """ + return { + "current_url": settings.EXTERNAL_API_URL, + "predefined_endpoints": PREDEFINED_ENDPOINTS, + "timestamp": datetime.now().isoformat() + } + + +@app.post("/change-api-url") +async def change_api_url(request: Request): + """ + API URL 동적 변경 + """ + try: + data = await request.json() + new_url = data.get("url") + + if not new_url: + return JSONResponse( + status_code=400, + content={"detail": "URL이 제공되지 않았습니다.", "error_type": "validation_error"} + ) + + # URL 형식 간단 검증 + if not (new_url.startswith("http://") or new_url.startswith("https://")): + return JSONResponse( + status_code=400, + content={"detail": "올바른 URL 형식이 아닙니다. http:// 또는 https://로 시작해야 합니다.", "error_type": "validation_error"} + ) + + # 설정 변경 + old_url = settings.EXTERNAL_API_URL + settings.EXTERNAL_API_URL = new_url + + logger.info(f"API URL 변경: {old_url} -> {new_url}") + + return { + "status": "success", + "old_url": old_url, + "new_url": new_url, + "timestamp": datetime.now().isoformat(), + "message": "API URL이 성공적으로 변경되었습니다." + } + + except Exception as exc: + logger.error(f"API URL 변경 오류: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "API URL 변경 중 오류가 발생했습니다.", + "error_type": "internal_error" + } + ) + + +async def _create_with_api(request: Request, api_name: str = None): + """ + API를 사용한 이미지 생성 공통 로직 + """ + try: + raw_data = await request.json() + logger.info(f"이미지 생성 요청 받음: {raw_data.get('prompt', 'N/A')[:50]}... (API: {api_name or 'default'})") + logger.info(f"요청 데이터 전체: {raw_data}") # 디버깅용 로그 추가 + + # 데이터 검증 및 정리 + validated_data = app_config.validate_request_data(raw_data) + logger.info(f"검증된 데이터: {validated_data}") # 디버깅용 로그 추가 + + # API 설정 검증 + if not api_manager.validate_settings(validated_data, api_name): + return JSONResponse( + status_code=400, + content={ + "detail": "유효하지 않은 설정값입니다.", + "error_type": "validation_error" + } + ) + + # 이미지 생성 요청 + response_data = await api_manager.generate_image(validated_data, api_name) + logger.info(f"이미지 생성 성공 (API: {api_name or 'default'})") + + return JSONResponse(status_code=200, content=response_data) + + except ValueError as exc: + # 입력 데이터 검증 오류 + logger.warning(f"입력 검증 오류: {exc}") + return JSONResponse( + status_code=400, + content={ + "detail": str(exc), + "error_type": "validation_error" + } + ) + except httpx.HTTPStatusError as exc: + error_msg = f"외부 API 오류: {exc.response.status_code}" + logger.error(f"{error_msg} - {exc.response.text}") + return JSONResponse( + status_code=exc.response.status_code, + content={ + "detail": error_msg, + "error_type": "api_error", + "status_code": exc.response.status_code + } + ) + except httpx.RequestError as exc: + error_msg = f"API 연결 오류: {exc.__class__.__name__}" + logger.error(f"{error_msg}: {exc}") + return JSONResponse( + status_code=502, + content={ + "detail": "외부 API 서버에 연결할 수 없습니다. 네트워크 연결을 확인하세요.", + "error_type": "connection_error", + "technical_detail": str(exc) + } + ) + except Exception as exc: + error_msg = f"예상치 못한 오류: {exc.__class__.__name__}" + logger.error(f"{error_msg}: {exc}") + return JSONResponse( + status_code=500, + content={ + "detail": "서버 내부 오류가 발생했습니다.", + "error_type": "internal_error" + } + ) + + if __name__ == '__main__': - uvicorn.run("web_server:app", host='0.0.0.0', port=SERVICE_PORT) \ No newline at end of file + logger.info(f"AI Image Generator 웹 서버를 시작합니다...") + logger.info(f"서버 주소: http://{HOST}:{SERVICE_PORT}") + logger.info(f"외부 API: {settings.EXTERNAL_API_URL}") + logger.info(f"헬스체크: http://localhost:{SERVICE_PORT}/health") + logger.info(f"API 상태 확인: http://localhost:{SERVICE_PORT}/api-status") + + uvicorn.run("web_server:app", host=HOST, port=SERVICE_PORT) \ No newline at end of file