Compare commits

...

10 Commits

Author SHA1 Message Date
Hyungi Ahn
841178ed7e feat: Phase 3 보안 강화 - API 키 AES-256 암호화
- server/encryption.py: AES-256 암호화/복호화 함수 추가
- test_admin.py: API 키 암호화 저장 및 조회 로직 구현
- static/admin.js: 암호화 상태 표시 UI 추가
- static/admin.css: 암호화 배지 스타일 추가

API 키가 이제 AES-256으로 암호화되어 저장됩니다.
2025-08-19 15:29:53 +09:00
Hyungi Ahn
1e098999c1 feat: AI 서버 관리 페이지 Phase 3 보안 강화 - JWT 인증 시스템
🔐 JWT 기반 로그인 시스템:
- 로그인 페이지: 아름다운 애니메이션과 보안 정보 표시
- JWT 토큰: 24시간 또는 30일 (Remember Me) 만료 설정
- 비밀번호 암호화: bcrypt 해싱으로 안전한 저장
- 계정 잠금: 5회 실패 시 15분 자동 잠금

👥 사용자 계정 관리:
- admin/admin123 (관리자 권한)
- hyungi/hyungi123 (시스템 권한)
- 역할 기반 접근 제어 (RBAC)

🛡️ 보안 기능:
- 토큰 자동 검증 및 만료 처리
- 감사 로그: 로그인/로그아웃/관리 작업 추적
- 안전한 세션 관리 및 토큰 정리
- 클라이언트 사이드 토큰 검증

🎨 UI/UX 개선:
- 로그인 페이지: 그라디언트 배경, 플로팅 아이콘 애니메이션
- 사용자 메뉴: 헤더에 사용자명과 로그아웃 버튼 표시
- 보안 표시: SSL, 세션 타임아웃, JWT 인증 정보
- 반응형 디자인 및 다크모드 지원

🔧 기술 구현:
- FastAPI HTTPBearer 보안 스키마
- PyJWT 토큰 생성/검증
- bcrypt 비밀번호 해싱
- 클라이언트-서버 토큰 동기화

새 파일:
- templates/login.html: 로그인 페이지 HTML
- static/login.css: 로그인 페이지 스타일
- static/login.js: 로그인 JavaScript 로직
- server/auth.py: JWT 인증 시스템 (실제 서버용)

수정된 파일:
- test_admin.py: 테스트 서버에 JWT 인증 추가
- static/admin.js: JWT 토큰 기반 API 요청으로 변경
- templates/admin.html: 사용자 메뉴 및 로그아웃 버튼 추가
- static/admin.css: 사용자 메뉴 스타일 추가

보안 레벨: Phase 1 (API Key) → Phase 3 (JWT + 감사로그)
2025-08-18 15:24:01 +09:00
Hyungi Ahn
b752e56b94 feat: AI 서버 관리 페이지 Phase 2 고급 기능 구현
🤖 모델 관리 고도화:
- 모델 다운로드: 인기 모델들 원클릭 설치 (llama, qwen, gemma, codellama, mistral)
- 모델 삭제: 확인 모달과 함께 안전한 삭제 기능
- 사용 가능한 모델 목록: 태그별 분류 (chat, code, lightweight 등)
- 모델 상세 정보: 설명, 크기, 용도별 태그 표시

�� 실시간 시스템 모니터링:
- CPU/메모리/디스크/GPU 사용률 원형 프로그레스바
- 색상 코딩: 사용률에 따른 시각적 구분 (녹색/주황/빨강)
- 실시간 업데이트: 30초마다 자동 새로고침
- 시스템 리소스 상세 정보 (코어 수, 용량, 온도 등)

🎨 고급 UI/UX:
- 모달 창: 부드러운 애니메이션과 블러 효과
- 원형 프로그레스바: CSS 기반 실시간 업데이트
- 반응형 디자인: 모바일 최적화
- 태그 시스템: 모델 분류 및 시각화

🔧 새 API 엔드포인트:
- POST /admin/models/download - 모델 다운로드
- DELETE /admin/models/{model_name} - 모델 삭제
- GET /admin/models/available - 다운로드 가능한 모델 목록
- GET /admin/system/stats - 시스템 리소스 사용률

수정된 파일:
- server/main.py: Phase 2 API 엔드포인트 추가
- test_admin.py: 테스트 모드 Phase 2 기능 추가
- templates/admin.html: 시스템 모니터링 섹션, 모달 창 추가
- static/admin.css: 모니터링 차트, 모달 스타일 추가
- static/admin.js: Phase 2 기능 JavaScript 구현
2025-08-18 13:45:04 +09:00
Hyungi Ahn
e102ce6db9 feat: AI 서버 관리 페이지 Phase 1 구현
- 웹 기반 관리 대시보드 추가 (/admin)
- 시스템 상태 모니터링 (AI 서버, Ollama, 활성 모델, API 호출)
- 모델 관리 기능 (목록 조회, 테스트, 새로고침)
- API 키 관리 시스템 (생성, 조회, 삭제)
- 반응형 UI/UX 디자인 (모바일 지원)
- 테스트 모드 서버 (test_admin.py) 추가
- 보안: API 키 기반 인증, 키 마스킹
- 실시간 업데이트 (30초 자동 새로고침)

구현 파일:
- templates/admin.html: 관리 페이지 HTML
- static/admin.css: 관리 페이지 스타일
- static/admin.js: 관리 페이지 JavaScript
- server/main.py: 관리 API 엔드포인트 추가
- test_admin.py: 맥북프로 테스트용 서버
- README.md: 관리 페이지 문서 업데이트
2025-08-18 13:33:39 +09:00
hyungi
cb009f7393 feat: 완전한 웹 UI 구현 및 문서 처리 파이프라인 완성
 새로운 기능:
- FastAPI 기반 완전한 웹 UI 구현
- 반응형 디자인 (모바일/태블릿 지원)
- 드래그앤드롭 파일 업로드 인터페이스
- 실시간 AI 챗봇 인터페이스
- 문서 관리 및 검색 시스템
- 진행률 표시 및 상태 알림

🎨 UI 구성:
- 메인 대시보드: 서버 상태, 통계, 빠른 접근
- 파일 업로드: 드래그앤드롭, 처리 옵션, 진행률
- 문서 관리: 검색, 정렬, 미리보기, 다운로드
- AI 챗봇: 실시간 대화, 문서 기반 RAG, 빠른 질문

🔧 기술 스택:
- FastAPI + Jinja2 템플릿
- 모던 CSS (그라디언트, 애니메이션)
- Font Awesome 아이콘
- JavaScript (ES6+)

🚀 완성된 기능:
- 파일 업로드 → 텍스트 추출 → 임베딩 → 번역 → HTML 생성
- 벡터 검색 및 RAG 기반 질의응답
- 다중 모델 지원 (기본/부스팅/영어 전용)
- API 키 인증 및 CORS 설정
- NAS 연동 및 파일 내보내기
2025-08-14 08:09:48 +09:00
hyungi
ef64aaec84 feat: export pipeline outputs (HTML copy + upload archiving) via EXPORT_* envs 2025-08-13 08:53:05 +09:00
hyungi
8d87b1f46b feat: add summarization to pipeline (summarize + summary_sentences + summary_language) 2025-08-13 08:50:06 +09:00
hyungi
6346635ac1 feat: add /pipeline/ingest_file endpoint for .txt/.pdf upload 2025-08-13 08:48:17 +09:00
hyungi
6e7cf8eafa feat: pipeline options translate/target_language; allow HTML-only without translation 2025-08-13 08:46:08 +09:00
hyungi
a280304adc feat: document pipeline (embedding->Korean translation->HTML). Add /pipeline/ingest endpoint 2025-08-13 08:45:01 +09:00
24 changed files with 6558 additions and 5 deletions

View File

@@ -213,6 +213,42 @@ curl -s -X POST http://localhost:26000/paperless/hook \
해당 훅은 문서 도착을 통지받는 용도로 제공됩니다. 실제 본문 텍스트는 Paperless API로 조회해 `/index/upsert`로 추가하세요. 해당 훅은 문서 도착을 통지받는 용도로 제공됩니다. 실제 본문 텍스트는 Paperless API로 조회해 `/index/upsert`로 추가하세요.
### Paperless 배치 동기화(`/paperless/sync`) ### Paperless 배치 동기화(`/paperless/sync`)
### 문서 파이프라인(`/pipeline/ingest`)
첨부 문서(텍스트가 준비된 상태: OCR/추출 선행) → (옵션)요약 → (옵션)번역 → 임베딩 → HTML 생성까지 처리합니다.
```bash
curl -s -X POST http://localhost:26000/pipeline/ingest \
-H 'Content-Type: application/json' -H 'X-API-Key: <키>' \
-d '{
"doc_id": "doc-2025-08-13-001",
"text": "(여기에 문서 텍스트)",
"generate_html": true,
"translate": true,
"target_language": "ko",
"summarize": false,
"summary_sentences": 5,
"summary_language": null
}'
```
응답에 `html_path`가 포함됩니다.
- 요약 켜짐(`summarize=true`): 청크별 요약 후 통합 요약을 생성해 사용(기본 5문장). `summary_language`로 요약 언어 선택 가능(기본 번역 언어와 동일, 번역 off면 ko).
- 번역 켜짐(`translate=true`): 최종 텍스트를 대상 언어로 번역해 HTML+인덱스화.
- 번역 꺼짐(`translate=false`): 최종 텍스트(요약 또는 원문)로 HTML+인덱스화.
파일 업로드 버전(`/pipeline/ingest_file`): `.txt`/`.pdf` 지원
```bash
curl -s -X POST http://localhost:26000/pipeline/ingest_file \
-H 'X-API-Key: <키>' \
-F 'file=@/path/to/file.pdf' \
-F 'doc_id=doc-001' \
-F 'generate_html=true' \
-F 'translate=false' \
-F 'target_language=ko'
```
Paperless에서 다수 문서를 일괄 인덱싱합니다. Paperless에서 다수 문서를 일괄 인덱싱합니다.
@@ -251,6 +287,9 @@ curl -s -X POST http://localhost:26000/paperless/sync \
- `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용 - `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용
- `PAPERLESS_VERIFY_SSL`(기본 `true`): Paperless HTTPS 검증 비활성화는 `false` - `PAPERLESS_VERIFY_SSL`(기본 `true`): Paperless HTTPS 검증 비활성화는 `false`
- `PAPERLESS_CA_BUNDLE`(선택): 신뢰할 CA 번들 경로 지정 시 해당 번들로 검증 - `PAPERLESS_CA_BUNDLE`(선택): 신뢰할 CA 번들 경로 지정 시 해당 번들로 검증
- `OUTPUT_DIR`(기본 `outputs`): 파이프라인 산출물(HTML) 저장 루트
- `EXPORT_HTML_DIR`(선택): HTML 산출물 사본을 내보낼 디렉터리(예: 시놀로지 공유 폴더)
- `EXPORT_UPLOAD_DIR`(선택): 업로드 원본 파일 보관 디렉터리
- `API_KEY`(선택): 설정 시 모든 민감 엔드포인트 호출에 `X-API-Key` 헤더 필요 - `API_KEY`(선택): 설정 시 모든 민감 엔드포인트 호출에 `X-API-Key` 헤더 필요
- `CORS_ORIGINS`(선택): CORS 허용 오리진(쉼표 구분), 미설정 시 `*` - `CORS_ORIGINS`(선택): CORS 허용 오리진(쉼표 구분), 미설정 시 `*`
@@ -280,11 +319,55 @@ curl -s -X POST http://localhost:26000/v1/chat/completions \
}' }'
``` ```
## AI 서버 관리 페이지 (Admin Dashboard)
AI 서버의 효율적인 관리를 위한 웹 기반 관리 페이지를 제공합니다.
### 관리 페이지 접근
- **URL**: `http://localhost:26000/admin`
- **인증**: API 키 기반 (환경변수 `API_KEY` 설정 필요)
### 주요 기능
#### Phase 1: 기본 관리 기능 ✅
- **시스템 상태 대시보드**: 서버/Ollama/모델 상태 실시간 모니터링
- **모델 관리**: 설치된 모델 목록, 활성 모델 현황, 모델별 사용 통계
- **API 키 관리**: 키 생성/조회/삭제, 사용량 모니터링
#### Phase 2: 고급 기능 (계획)
- **모델 다운로드/삭제**: Ollama 모델 원격 관리
- **실시간 모니터링**: CPU/메모리/GPU 사용률, API 호출 통계
- **설정 관리**: 환경변수 편집, Paperless 연동 설정
#### Phase 3: 보안 강화 (계획)
- **인증 시스템**: JWT 기반 로그인, 2FA 지원
- **접근 제어**: IP 화이트리스트, 권한 관리
- **감사 로그**: 모든 관리 작업 기록 및 추적
### 보안 고려사항
- **API 키 암호화**: AES-256 암호화 저장
- **HTTPS 강제**: SSL/TLS 인증서 필수
- **접근 로그**: 모든 관리 페이지 접근 기록
- **민감 정보 보호**: 로그에서 API 키 자동 마스킹
### 사용 예시
```bash
# API 키 설정
export API_KEY=your-secure-api-key
# 서버 실행
python -m server.main
# 관리 페이지 접근
curl -H "X-API-Key: your-secure-api-key" http://localhost:26000/admin
```
## 이 저장소 사용 계획 ## 이 저장소 사용 계획
1) Ollama API를 감싸는 경량 서버(Express 또는 FastAPI) 추가 1) Ollama API를 감싸는 경량 서버(FastAPI) 구현 완료
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공 2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공 3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
4) 🚧 웹 기반 관리 페이지 구현 중 (Phase 1)
우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다. 우선 본 문서로 설치/선택 가이드를 정리했으며, 현재 관리 페이지와 고급 기능들을 단계적으로 추가하고 있습니다.

View File

@@ -0,0 +1,493 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 Industrial Safety and Health Management(7:ED)_0 Contents-2025-08-13 (원문)</h1>
<div class="chunk" id="c0">Industrial Safety and Health
Management
Seventh Edition
C. Ray Asfahl
David W. Rieske
University of Arkansas
Pearson
33o Hudson Street, NY NY 10013</div>
<div class="chunk" id="c1">
Senior Vice President Courseware Portfolio Management: Marcia Horton
Director, Portfolio Management: Engineering, Computer Science & Global Editions: Julian Partridge
Specialist, Higher Ed Portfolio Management: Holly Stark
Portfolio Management Assistant: Emily Egan
Managing Content Producer: Scott Disanno
Content Producer: Carole Snyder
Web Developer: Steve Wright
Rights and Permissions Manager: Ben Ferrini
Manufacturing Buyer, Higher Ed, Lake Side Communications Inc (LSC): Maura Zaldivar-Garcia
Inventory Manager: Ann Lam
Product Marketing Manager: Yvonne Vannatta
Field Marketing Manager: Demetrius Hall
Marketing Assistant: Jon Bryant
Cover Designer: Black Horse Designs
Full-Service Project Manager: Billu Suresh, SPi Global
Composition: SPi Global
Copyright © 2019 Pearson Education, Inc. All rights reserved. Manufactured in the United States of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmissio</div>
<div class="chunk" id="c2">tates of America. This
publication is protected by copyright, and permission should be obtained from the publisher prior to any prohibited
reproduction, storage in a retrieval system, or transmission in any form or by any means, electronic, mechanical,
photocopying, recording, or likewise. For information regarding permissions, request forms and the appropriate
contacts within the Pearson Education Global Rights & Permissions department, please visit http://www.pearsoned
.compermissions.
Many of the designations by manufacturers and sellers to distinguish their products are claimed as trademarks. Where
those designations appear in this book, and the publisher was aware of a trademark claim, the designations have been
printed in initial caps or all caps.
The author and publisher of this book have used their best efforts in preparing this book. These efforts include the
development, research, and testing of theories and programs to determine their effectiveness. The author and publisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for</div>
<div class="chunk" id="c3">ublisher
make no warranty of any kind, expressed or implied, with regard to these programs or the documentation contained
in this book. The author and publisher shall not be liable in any event for incidental or consequential damages with, or
arising out of, the furnishing, performance, or use of these programs.
Library of Congress Cataioging in-Publication Data
Names: Asfahl, C. Ray, 1938- author. Rieske, David W., author.
Title: Industrial safety and health management C. Ray Asfahl, David W.
Rieske, University of Arkansas.
Description: Seventh edition. NY, NY : Pearson, [2019]
Includes bibliographical references and index.
Identifiers: LCCN 2017050947 ISBN 9780134630564 (alk. paper)
ISBN 0134630564 (alk. paper)
Subjects: LCSH: Industrial safety. Industrial hygiene.
Classification: LCC T55 .A83 2019 DDC 658.408--dc23 LC record available at https://lccn.loc.gov/2017050947
Pearson ISBN-13: 978-0-13-463056-4
ISBN-10: 0-13-463056-4
17 2024</div>
<div class="chunk" id="c4">
Contents
Preface ix
CHAPTER 1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises</div>
<div class="chunk" id="c5">1 The Safety and Health Manager 1
A Reasonable Objective 2
Safety versus Health 4
Role in the Corporate Structure 5
Resources at Hand 6
Summary 12
Exercises and Study Questions 12
Research Exercises 13
CHAPTER 2 Development of the Safety and Health Function 15
Workers Compensation 16
Recordkeeping 21
Accident Cause Analysis 35
Organization of Committees 36
Safety and Health Economics 37
Training 41
Job Placement Testing 43
The Smoke-Free Workplace 44
Bloodborne Pathogens 45
Workplace Violence 47
Summary 48
Exercises and Study Questions 49
Research Exercises 53
CHAPTER Z Concepts of Hazard Avoidance 54
The Enforcement Approach 55
The Psychological Approach 57
The Engineering Approach 59
The Analytical Approach 67
Hazard-Classification Scale 76
Summary 82
Exercises and Study Questions 83
Research Exercises 86
Standards Research Questions 87
iii</div>
<div class="chunk" id="c6">
iv Contents
CHAPTER 4 Impact of Federal Regulation 88
Standards 88
NIOSH 93
Enforcement 94
Public Uproar IOO
Role of the States 102
Political Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
Inter</div>
<div class="chunk" id="c7">Trends 104
Immigrant Workers 111
Summary 111
Exercises and Study Questions 112
Research Exercises 113
Standards Research Questions 114
CHAPTER 5 Information Systems 115
Hazard Communication 116
International Standards 123
Environmental Protection Agency 123
Department of Homeland Security 128
Computer Information Systems 129
Summary 131
Exercises and Study Questions 131
Research Exercises 132
Standards Research Questions 133
CHAPTER 6 Process Safety and Disaster Preparedness 134
Process Information 135
Process Analysis 139
Operating Procedures 140
Training 141
Contractor Personnel 142
Acts of Terrorism 142
Workplace Security 145
Active Shooter Incidents 146
Summary 146
Exercises and Study Questions 147
Research Exercises 148
Standards Research Questions 148
CHAPTER 7 Buildings and Facilities 150
Walking and Working Surfaces 151
Exits 162
Illumination 164
Miscellaneous Facilities 165
Sanitation 169
Summary 169</div>
<div class="chunk" id="c8">
Contents v
Exercises and Study Questions 170
Research Exercises 171
Standards Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergon</div>
<div class="chunk" id="c9">rds Research Questions 171
CHAPTER 8 Ergonomics 172
Facets of Ergonomics 172
Workplace Musculoskeletal Disorders 176
Affected Industries 179
Ergonomics Standards 179
WMSD Management Programs 182
Ergonomic Risk Analysis 184
NIOSH Lifting Equation 185
Sources of Ergonomic Hazards 193
Summary 202
Exercises and Study Questions 203
Research Exercises 204
Standards Research Question 205
CHAPTER 9 Health and Toxic Substances 206
Baseline Examinations 206
Toxic Substances 207
Measures of Exposure 216
Standards Completion Project 220
Detecting Contaminants 222
Summary 229
Exercises and Study Questions 230
Research Exercises 234
Standards Research Questions 235
CHAPTER 10 Environmental Control and Noise 236
Ventilation 236
ASHRAE Standards and Indoor Air Quality 242
Industrial Noise 243
Radiation 260
Summary 260
Exercises and Study Questions 261
Research Exercises 265
Standards Research Questions 265
CHAPTER 11 Flammable and Explosive Materials 267
Flammable Liquids 267
Sources of Ignition 272
Standards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c10">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Quest</div>
<div class="chunk" id="c11">tandards Compliance 274
Combustible Liquids 276
Spray Finishing 278</div>
<div class="chunk" id="c12">
vi Contents
Dip Tanks 281
Explosives 281
Liquefied Petroleum Gas 282
Combustible Dust 284
Conclusion 285
Exercises and Study Questions 285
Research Exercises 287
Standards Research Questions 288
CHAPTER 12 Personal Protection and First Aid 289
Protection Need Assessment 290
Personal Protective Equipment (PPE) Training 291
Hearing Protection 292
Determining the Noise Reduction Rating 293
Eye and Face Protection 294
Respiratory Protection 296
Confined Space Entry 309
Head Protection 312
Miscellaneous Personal Protective Equipment 313
First Aid 315
Summary 316
Exercises and Study Questions 317
Research Exercises 319
Standards Research Questions 320
CHAPTER 13 Fire Protection 321
Mechanics of Fire 322
Industrial Fires 322
Fire Prevention 323
Dust Explosions 323
Emergency Evacuation 324
Fire Brigades 326
Fire Extinguishers 327
Standpipe and Hose Systems 329
Automatic Sprinkler Systems 330
Fixed Extinguishing Systems 330
Summary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passenger</div>
<div class="chunk" id="c13">ummary 331
Exercises and Study Questions 332
Research Exercises 334
Standards Research Questions 334
CHAPTER 14 Materials Handling and Storage 335
Materials Storage 336
Industrial Trucks 337
Passengers 343
Cranes 344</div>
<div class="chunk" id="c14">
Contents vii
Slings 358
Conveyors 362
Lifting 363
Summary 365
Exercises and Study Questions 365
Research Exercise 368
CHAPTER 15 Machine Guarding 369
General Machine Guarding 369
Safeguarding the Point of Operation 379
Power Presses 386
Heat Processes 406
Grinding Machines 406
Saws 408
Miscellaneous Machine Guarding 413
Miscellaneous Machines and Processes 416
Industrial Robots 417
Evolution in Robotics and Intelligent Machines 420
Summary 421
Exercises and Study Questions 422
Standards Research Questions 425
CHAPTER 16 Welding 426
Process Terminology 426
Gas Welding Hazards 430
Arc Welding Hazards 437
Resistance Welding Hazards 438
Fires and Explosions 439
Eye Protection 441
Protective Clothing 442
Gases and Fumes 443
Summary 446
Exercises and Study Questions 447
Research Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473</div>
<div class="chunk" id="c15">ch Exercises 449
Standards Research Questions 450
CHAPTER 17 Electrical Hazards 451
Electrocution Hazards 451
Fire Hazards 464
Arc Flash 469
Test Equipment 471
Exposure to High-Voltage Power Lines 473
Frequent Violations 473
Summary 474
Exercises and Study Questions 475</div>
<div class="chunk" id="c16">
viii Contents
Research Exercises 478
Standards Research Questions 478
CHAPTER 18 Construction 479
General Facilities 480
Personal Protective Equipment 482
Fire Protection 486
Tools 486
Electrical 488
Ladders and Scaffolds 490
Floors and Stairways 493
Cranes and Hoists 493
Heavy Vehicles and Equipment 498
ROPS 498
Trenching and Excavations 501
Concrete Work 505
Steel Erection 507
Demolition 508
Explosive Blasting 509
Electric Utilities 510
Summary 511
Exercises and Study Questions 512
Research Exercises 515
APPENDICES
A OSHA Permissible Exposure Limits 516
B Medical Treatment 535
C First-Aid Treatment 536
D Classification of Medical Treatment 538
E Highly Hazardous Chemicals, Toxics, and Reactives 540
F North American Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c17">Industry Classification System (NAICS) Code 544
G States Having Federally Approved State Plans for
Occupational Safety and Health Standards and Enforcement 548
Bibliography 549
Glossary 560
Index 568</div>
<div class="chunk" id="c18">
Preface
The seventh edition of Industrial Safety and Health Management remains true to
the purpose of engaging the reader in the common sense approaches to safety and
health from a concept, process, and compliance perspective. The book retains its
easy-to-read format while increasing the retention of the reader through additional
case studies and statistics, relevant topics, and additional explanation of difficult-to-
Understand concepts.
Much of the safety change we see comes on the heels of major disasters or
social trends and changes. The past decade has seen many. The explosion of a major
sugar processing plant has driven a renewed focus on combustible dust, an outbreak
of Ebola brought focus on contagious diseases, the sinking of a major oil derrick
initiated a discussion on regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brou</div>
<div class="chunk" id="c19">n regulatory oversight and process health, and numerous
acts of violence bring our attention to security in the workplace. Social trends such
as the rise of "gig" or "on-demand" employment have brought about questions
of the definition of an "employee" and coverage for safety nets such as workers
compensation. Regulatory changes have even precipitated the complete removal of
workers compensation in some states. In other areas, the effectiveness of workers
compensation led to a robust dialog on whether or not a permanently injured em­
ployee truly receives compensation commensurate to his or her injury. Meanwhile
rises in the number of states legalizing marijuana have caused companies to ques­
tion current drug screening programs and medical treatment programs.
Regulation has changed as well. The adoption of the Globally Harmonized System
for Hazard Communication or GHS has completely changed the way we think about
hazard communication. The new system crosses language barriers and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a st</div>
<div class="chunk" id="c20">rs and helps workers
who may not be able to read or may not be fluent in a given language with a series of
pictograms depicting the dangers of certain chemicals. Hazards are now categorized
in a standard way which drives increased consistency of approach. For the first time in
nearly 20 years, fines associated with citations have gone up considerably. Meanwhile,
record fines have been levied against corporations associated with major disasters. The
classification of companies has also been changed to the modernized North America
Industry Classification System (NAICS).
As the authors have used the text in their classrooms, a critical focus has been
on addressing the most common areas that students will be expected to apply in
an industrial setting. Additional explanation around the concepts of PELs has been
given to help students to understand the differences among PELS, Ceilings, and
other measures. Calculations around the Noise Reduction Rating (NRR) and how it
is practically used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers</div>
<div class="chunk" id="c21">ally used will help students address the prevalent danger of industrial noise
in their work environments. In addition, explained in more detail is sometimes the
confusing concept of applying workers compensation and practical aspects of pro­
tecting employees.</div>
<div class="chunk" id="c22">
X Preface
WHAT'S NEW IN THIS EDITION?
For easy reference, the authors have summarized the new features of this edition as
follows:
• Overhaul of hazard communication standard and incorporation of the Globally
Harmonized System
• Increased discussion on workers compensation rates and calculations
• Trends in workers compensation privatization and states “opting-out”
• Layers of coverage for permanent injuries
• Coverage of the trends in the gig economy and the changing nature of
employees
• OSHA usage of reporting in “Big Data”
• Changes in SIC to North American Industry Classification System (NAICS)
• Discussion of bloodborne pathogens and protecting workers from diseases such
as HIV and Ebola
• Increased coverage of workplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Targe</div>
<div class="chunk" id="c23">orkplace security
• Discussion of preparation and response techniques for active shooter scenarios
• Impact of medical marijuana
• Changes in OSHA citation penalty levels
• Increased coverage of Target Industry programs
• Coverage of fatigue and worker safety
• Practical discussion of PELs, STELs, Ceiling Limits and how they interact
• Changes to flammable liquid classification
• Coverage of calculations and usage of Noise Reduction Rating (NRR)
• Coverage of long-term health impact to World Trade Center first responders
• OSHA s work against the dangers of combustible dust
• Additional practical and pragmatic assessment of penalty levels
• Additional review of OSHA programs such as SHARP and VPP as OSHA is
increasing its collaborative approach in recent years
• Additional case studies to bring home to readers about the concepts of safety and
health</div>
<div class="chunk" id="c24">
Preface xi
ACKNOWLEDGMENTS
Both authors wish to express their appreciation to companies and individuals who have
contributed ideas and support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. And</div>
<div class="chunk" id="c25">d support for the seventh edition. Special thanks to Richard
Wallace, Jimmy Baker, and the entire team at Pratt & Whitney for ideas, pictures,
and best practices from their world-class facility. Andrew Hilliard, President of Safety
Maker, Inc. and E.C. Daven, President of Safety Services, Inc. provided valuable insights
and visual examples. Erica Asfahl provided mechanical engineering advice. David Trigg
and David Bryan answered questions and provided data on OSHA developments.
We are grateful to Ken Kolosh and the team at the National Safety Council for their
statistics provided in many areas of the text. Tara Mercer and the National Council on
Compensation Insurance shared valuable insights into trends and developments such
as the gig economy and the impact of medical marijuana. We learned from Alejandra
Nolibos about developments in state workers compensation changes. Finally, we
dedicate this edition to our patient and supportive families who have endured the
process of bringing forth this seventh edition.
C. Ray Asfahl
David W. Rieske</div>
</article>
</body>
</html>

20
outputs/html/test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 test-doc-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 test-doc-001 (원문)</h1>
<div class="chunk" id="c0">테스트 문서입니다.</div>
</article>
</body>
</html>

20
outputs/html/ui-test.html Normal file
View File

@@ -0,0 +1,20 @@
<!doctype html>
<html lang="ko">
<head>
<meta charset="utf-8"/>
<title>문서 ui-test-001 (원문)</title>
<style>
body{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}
article{max-width: 900px; margin: auto;}
h1{font-size: 1.6rem; margin-bottom: 1rem;}
.chunk{white-space: pre-wrap; margin: 1rem 0;}
</style>
</head>
<body>
<article>
<h1>문서 ui-test-001 (원문)</h1>
<div class="chunk" id="c0">UI 테스트용 문서입니다. 웹 인터페이스를 통한 업로드 테스트.</div>
</article>
</body>
</html>

View File

@@ -4,3 +4,5 @@ requests==2.32.4
pydantic==2.8.2 pydantic==2.8.2
pypdf==6.0.0 pypdf==6.0.0
tiktoken==0.11.0 tiktoken==0.11.0
python-multipart
jinja2

229
server/auth.py Normal file
View File

@@ -0,0 +1,229 @@
"""
JWT Authentication System for AI Server Admin
Phase 3: Security Enhancement
"""
import jwt
import bcrypt
import secrets
from datetime import datetime, timedelta
from typing import Optional, Dict, Any
from fastapi import HTTPException, Depends, Request
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import os
# JWT Configuration
JWT_SECRET_KEY = os.getenv("JWT_SECRET_KEY", secrets.token_urlsafe(32))
JWT_ALGORITHM = "HS256"
JWT_EXPIRATION_HOURS = 24
JWT_REMEMBER_DAYS = 30
# Security
security = HTTPBearer()
# In-memory user store (in production, use a proper database)
USERS_DB = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system",
"created_at": datetime.now().isoformat(),
"last_login": None,
"login_attempts": 0,
"locked_until": None
}
}
# Login attempt tracking
LOGIN_ATTEMPTS = {}
MAX_LOGIN_ATTEMPTS = 5
LOCKOUT_DURATION_MINUTES = 15
class AuthManager:
@staticmethod
def hash_password(password: str) -> str:
"""Hash password using bcrypt"""
return bcrypt.hashpw(password.encode('utf-8'), bcrypt.gensalt()).decode('utf-8')
@staticmethod
def verify_password(password: str, password_hash: str) -> bool:
"""Verify password against hash"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
@staticmethod
def create_jwt_token(user_data: Dict[str, Any], remember_me: bool = False) -> str:
"""Create JWT token"""
expiration = datetime.utcnow() + timedelta(
days=JWT_REMEMBER_DAYS if remember_me else 0,
hours=JWT_EXPIRATION_HOURS if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow(),
"type": "remember" if remember_me else "session"
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
@staticmethod
def verify_jwt_token(token: str) -> Dict[str, Any]:
"""Verify and decode JWT token"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
@staticmethod
def is_account_locked(username: str) -> bool:
"""Check if account is locked due to failed attempts"""
user = USERS_DB.get(username)
if not user:
return False
if user["locked_until"]:
locked_until = datetime.fromisoformat(user["locked_until"])
if datetime.now() < locked_until:
return True
else:
# Unlock account
user["locked_until"] = None
user["login_attempts"] = 0
return False
@staticmethod
def record_login_attempt(username: str, success: bool, ip_address: str = None):
"""Record login attempt"""
user = USERS_DB.get(username)
if not user:
return
if success:
user["login_attempts"] = 0
user["locked_until"] = None
user["last_login"] = datetime.now().isoformat()
else:
user["login_attempts"] += 1
# Lock account after max attempts
if user["login_attempts"] >= MAX_LOGIN_ATTEMPTS:
user["locked_until"] = (
datetime.now() + timedelta(minutes=LOCKOUT_DURATION_MINUTES)
).isoformat()
@staticmethod
def authenticate_user(username: str, password: str) -> Optional[Dict[str, Any]]:
"""Authenticate user credentials"""
user = USERS_DB.get(username)
if not user:
return None
if AuthManager.is_account_locked(username):
raise HTTPException(
status_code=423,
detail=f"Account locked due to too many failed attempts. Try again later."
)
if AuthManager.verify_password(password, user["password_hash"]):
return user
return None
# Dependency functions
async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(security)):
"""Get current authenticated user from JWT token"""
try:
payload = AuthManager.verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = USERS_DB.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"],
"token_type": payload.get("type", "session")
}
except Exception as e:
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""Require admin or system role"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
async def require_system_role(current_user: dict = Depends(get_current_user)):
"""Require system role"""
if current_user["role"] != "system":
raise HTTPException(status_code=403, detail="System privileges required")
return current_user
# Legacy API key support (for backward compatibility)
async def get_current_user_or_api_key(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = None
):
"""Support both JWT and API key authentication"""
# Try JWT first
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# Fall back to API key
api_key = x_api_key or request.headers.get("X-API-Key")
if api_key and api_key == os.getenv("API_KEY", "test-admin-key-123"):
return {
"username": "api_user",
"role": "system",
"token_type": "api_key"
}
raise HTTPException(status_code=401, detail="Authentication required")
# Audit logging
class AuditLogger:
@staticmethod
def log_login(username: str, success: bool, ip_address: str = None, user_agent: str = None):
"""Log login attempt"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "login_attempt",
"username": username,
"success": success,
"ip_address": ip_address,
"user_agent": user_agent
}
print(f"AUDIT: {log_entry}") # In production, use proper logging
@staticmethod
def log_admin_action(username: str, action: str, details: str = None, ip_address: str = None):
"""Log admin action"""
log_entry = {
"timestamp": datetime.now().isoformat(),
"event": "admin_action",
"username": username,
"action": action,
"details": details,
"ip_address": ip_address
}
print(f"AUDIT: {log_entry}") # In production, use proper logging

View File

@@ -13,6 +13,11 @@ class Settings:
english_ratio_threshold: float = float(os.getenv("ENGLISH_RATIO_THRESHOLD", "0.65")) english_ratio_threshold: float = float(os.getenv("ENGLISH_RATIO_THRESHOLD", "0.65"))
embedding_model: str = os.getenv("EMBEDDING_MODEL", "nomic-embed-text") embedding_model: str = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
index_path: str = os.getenv("INDEX_PATH", "data/index.jsonl") index_path: str = os.getenv("INDEX_PATH", "data/index.jsonl")
output_dir: str = os.getenv("OUTPUT_DIR", "outputs")
# Optional export targets (e.g., Synology NAS shares)
export_html_dir: str = os.getenv("EXPORT_HTML_DIR", "")
export_upload_dir: str = os.getenv("EXPORT_UPLOAD_DIR", "")
# Paperless (user will provide API details) # Paperless (user will provide API details)
paperless_base_url: str = os.getenv("PAPERLESS_BASE_URL", "") paperless_base_url: str = os.getenv("PAPERLESS_BASE_URL", "")

127
server/encryption.py Normal file
View File

@@ -0,0 +1,127 @@
"""
AES-256 Encryption Module for API Keys
Phase 3: Security Enhancement
"""
import os
import base64
from cryptography.fernet import Fernet
from cryptography.hazmat.primitives import hashes
from cryptography.hazmat.primitives.kdf.pbkdf2 import PBKDF2HMAC
from typing import Optional
import secrets
class APIKeyEncryption:
def __init__(self, master_password: Optional[str] = None):
"""
Initialize encryption with master password
If no password provided, uses environment variable or generates one
"""
self.master_password = master_password or os.getenv("ENCRYPTION_KEY") or self._generate_master_key()
self.salt = b'ai_server_salt_2025' # Fixed salt for consistency
self._fernet = self._create_fernet()
def _generate_master_key(self) -> str:
"""Generate a secure master key"""
key = secrets.token_urlsafe(32)
print(f"🔑 Generated new encryption key: {key}")
print("⚠️ IMPORTANT: Save this key in your environment variables!")
print(f" export ENCRYPTION_KEY='{key}'")
return key
def _create_fernet(self) -> Fernet:
"""Create Fernet cipher from master password"""
kdf = PBKDF2HMAC(
algorithm=hashes.SHA256(),
length=32,
salt=self.salt,
iterations=100000,
)
key = base64.urlsafe_b64encode(kdf.derive(self.master_password.encode()))
return Fernet(key)
def encrypt_api_key(self, api_key: str) -> str:
"""Encrypt API key using AES-256"""
try:
encrypted_bytes = self._fernet.encrypt(api_key.encode())
return base64.urlsafe_b64encode(encrypted_bytes).decode()
except Exception as e:
raise Exception(f"Encryption failed: {str(e)}")
def decrypt_api_key(self, encrypted_api_key: str) -> str:
"""Decrypt API key"""
try:
encrypted_bytes = base64.urlsafe_b64decode(encrypted_api_key.encode())
decrypted_bytes = self._fernet.decrypt(encrypted_bytes)
return decrypted_bytes.decode()
except Exception as e:
raise Exception(f"Decryption failed: {str(e)}")
def is_encrypted(self, api_key: str) -> bool:
"""Check if API key is already encrypted"""
try:
# Try to decode as base64 - encrypted keys are base64 encoded
base64.urlsafe_b64decode(api_key.encode())
# Try to decrypt - if successful, it's encrypted
self.decrypt_api_key(api_key)
return True
except:
return False
def rotate_encryption_key(self, new_master_password: str, encrypted_keys: list) -> list:
"""Rotate encryption key - decrypt with old key, encrypt with new key"""
old_fernet = self._fernet
# Create new Fernet with new password
self.master_password = new_master_password
self._fernet = self._create_fernet()
rotated_keys = []
for encrypted_key in encrypted_keys:
try:
# Decrypt with old key
decrypted = old_fernet.decrypt(base64.urlsafe_b64decode(encrypted_key.encode()))
# Encrypt with new key
new_encrypted = self.encrypt_api_key(decrypted.decode())
rotated_keys.append(new_encrypted)
except Exception as e:
print(f"Failed to rotate key: {e}")
rotated_keys.append(encrypted_key) # Keep original if rotation fails
return rotated_keys
# Global encryption instance
encryption = APIKeyEncryption()
def encrypt_api_key(api_key: str) -> str:
"""Convenience function to encrypt API key"""
return encryption.encrypt_api_key(api_key)
def decrypt_api_key(encrypted_api_key: str) -> str:
"""Convenience function to decrypt API key"""
return encryption.decrypt_api_key(encrypted_api_key)
def is_encrypted(api_key: str) -> bool:
"""Convenience function to check if API key is encrypted"""
return encryption.is_encrypted(api_key)
# Test the encryption system
if __name__ == "__main__":
# Test encryption/decryption
test_key = "test-api-key-12345"
print("🧪 Testing API Key Encryption...")
print(f"Original: {test_key}")
# Encrypt
encrypted = encrypt_api_key(test_key)
print(f"Encrypted: {encrypted}")
# Decrypt
decrypted = decrypt_api_key(encrypted)
print(f"Decrypted: {decrypted}")
# Verify
print(f"✅ Match: {test_key == decrypted}")
print(f"🔒 Is Encrypted: {is_encrypted(encrypted)}")
print(f"🔓 Is Plain: {not is_encrypted(test_key)}")

View File

@@ -1,9 +1,16 @@
from __future__ import annotations from __future__ import annotations
from fastapi import FastAPI, HTTPException, Depends from fastapi import FastAPI, HTTPException, Depends, UploadFile, File, Form, Request
from fastapi.middleware.cors import CORSMiddleware from fastapi.middleware.cors import CORSMiddleware
from fastapi.templating import Jinja2Templates
from fastapi.staticfiles import StaticFiles
from fastapi.responses import HTMLResponse
from pydantic import BaseModel from pydantic import BaseModel
from typing import List, Dict, Any from typing import List, Dict, Any
import shutil
from pathlib import Path
import os
from datetime import datetime
from .config import settings from .config import settings
from .ollama_client import OllamaClient from .ollama_client import OllamaClient
@@ -11,10 +18,19 @@ from .index_store import JsonlIndex
from .security import require_api_key from .security import require_api_key
from .paperless_client import PaperlessClient from .paperless_client import PaperlessClient
from .utils import chunk_text from .utils import chunk_text
from .pipeline import DocumentPipeline
app = FastAPI(title="Local AI Server", version="0.2.1") app = FastAPI(title="Local AI Server", version="0.2.1")
# 템플릿과 정적 파일 설정
templates = Jinja2Templates(directory="templates")
app.mount("/static", StaticFiles(directory="static"), name="static")
# HTML 출력 디렉토리도 정적 파일로 서빙
if Path("outputs/html").exists():
app.mount("/html", StaticFiles(directory="outputs/html"), name="html")
# CORS # CORS
import os import os
cors_origins = os.getenv("CORS_ORIGINS", "*") cors_origins = os.getenv("CORS_ORIGINS", "*")
@@ -28,6 +44,7 @@ app.add_middleware(
) )
ollama = OllamaClient(settings.ollama_host) ollama = OllamaClient(settings.ollama_host)
index = JsonlIndex(settings.index_path) index = JsonlIndex(settings.index_path)
pipeline = DocumentPipeline(ollama, settings.embedding_model, settings.boost_model, output_dir=settings.output_dir)
class ChatRequest(BaseModel): class ChatRequest(BaseModel):
@@ -55,6 +72,18 @@ class UpsertRequest(BaseModel):
batch: int = 16 batch: int = 16
class PipelineIngestRequest(BaseModel):
doc_id: str
text: str
generate_html: bool = True
translate: bool = True
target_language: str = "ko"
summarize: bool = False
summary_sentences: int = 5
summary_language: str | None = None
html_basename: str | None = None
@app.get("/health") @app.get("/health")
def health() -> Dict[str, Any]: def health() -> Dict[str, Any]:
return { return {
@@ -152,6 +181,89 @@ def index_reload() -> Dict[str, Any]:
return {"total": total} return {"total": total}
@app.post("/pipeline/ingest")
def pipeline_ingest(req: PipelineIngestRequest, _: None = Depends(require_api_key)) -> Dict[str, Any]:
result = pipeline.process(
doc_id=req.doc_id,
text=req.text,
index=index,
generate_html=req.generate_html,
translate=req.translate,
target_language=req.target_language,
summarize=req.summarize,
summary_sentences=req.summary_sentences,
summary_language=req.summary_language,
html_basename=req.html_basename,
)
exported_html: str | None = None
if result.html_path and settings.export_html_dir:
Path(settings.export_html_dir).mkdir(parents=True, exist_ok=True)
dst = str(Path(settings.export_html_dir) / Path(result.html_path).name)
shutil.copyfile(result.html_path, dst)
exported_html = dst
return {"status": "ok", "doc_id": result.doc_id, "added": result.added_chunks, "chunks": result.chunks, "html_path": result.html_path, "exported_html": exported_html}
@app.post("/pipeline/ingest_file")
async def pipeline_ingest_file(
_: None = Depends(require_api_key),
file: UploadFile = File(...),
doc_id: str = Form(...),
generate_html: bool = Form(True),
translate: bool = Form(True),
target_language: str = Form("ko"),
) -> Dict[str, Any]:
content_type = (file.content_type or "").lower()
raw = await file.read()
text = ""
if "text/plain" in content_type or file.filename.endswith(".txt"):
try:
text = raw.decode("utf-8")
except Exception:
text = raw.decode("latin-1", errors="ignore")
elif "pdf" in content_type or file.filename.endswith(".pdf"):
try:
from pypdf import PdfReader
from io import BytesIO
reader = PdfReader(BytesIO(raw))
parts: List[str] = []
for p in reader.pages:
try:
parts.append(p.extract_text() or "")
except Exception:
parts.append("")
text = "\n\n".join(parts)
except Exception as e:
raise HTTPException(status_code=400, detail=f"pdf_extract_error: {e}")
else:
raise HTTPException(status_code=400, detail="unsupported_file_type (only .txt/.pdf)")
if not text.strip():
raise HTTPException(status_code=400, detail="empty_text_after_extraction")
result = pipeline.process(
doc_id=doc_id,
text=text,
index=index,
generate_html=generate_html,
translate=translate,
target_language=target_language,
html_basename=file.filename,
)
exported_html: str | None = None
if result.html_path and settings.export_html_dir:
Path(settings.export_html_dir).mkdir(parents=True, exist_ok=True)
dst = str(Path(settings.export_html_dir) / Path(result.html_path).name)
shutil.copyfile(result.html_path, dst)
exported_html = dst
if settings.export_upload_dir:
Path(settings.export_upload_dir).mkdir(parents=True, exist_ok=True)
orig_name = f"{doc_id}__{file.filename}"
with open(str(Path(settings.export_upload_dir) / orig_name), "wb") as f:
f.write(raw)
return {"status": "ok", "doc_id": result.doc_id, "added": result.added_chunks, "chunks": result.chunks, "html_path": result.html_path, "exported_html": exported_html}
# Paperless webhook placeholder (to be wired with user-provided details) # Paperless webhook placeholder (to be wired with user-provided details)
class PaperlessHook(BaseModel): class PaperlessHook(BaseModel):
document_id: int document_id: int
@@ -260,3 +372,470 @@ def chat_completions(req: ChatCompletionsRequest, _: None = Depends(require_api_
], ],
} }
# =============================================================================
# UI 라우트들
# =============================================================================
@app.get("/", response_class=HTMLResponse)
async def dashboard(request: Request):
"""메인 대시보드 페이지"""
# 서버 상태 가져오기
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
# 최근 문서 (임시 데이터 - 실제로는 DB나 파일에서 가져올 것)
recent_documents = []
# 통계 (임시 데이터)
stats = {
"total_documents": len(index.rows) if index else 0,
"total_chunks": len(index.rows) if index else 0,
"today_processed": 0,
}
return templates.TemplateResponse("index.html", {
"request": request,
"status": status,
"recent_documents": recent_documents,
"stats": stats,
})
@app.get("/upload", response_class=HTMLResponse)
async def upload_page(request: Request):
"""파일 업로드 페이지"""
return templates.TemplateResponse("upload.html", {
"request": request,
"api_key": os.getenv("API_KEY", "")
})
def format_file_size(bytes_size):
"""파일 크기 포맷팅 헬퍼 함수"""
if bytes_size == 0:
return "0 Bytes"
k = 1024
sizes = ["Bytes", "KB", "MB", "GB"]
i = int(bytes_size / k)
if i >= len(sizes):
i = len(sizes) - 1
return f"{bytes_size / (k ** i):.2f} {sizes[i]}"
@app.get("/documents", response_class=HTMLResponse)
async def documents_page(request: Request):
"""문서 관리 페이지"""
# HTML 파일 목록 가져오기
html_dir = Path("outputs/html")
html_files = []
if html_dir.exists():
for file in html_dir.glob("*.html"):
stat = file.stat()
html_files.append({
"name": file.name,
"size": stat.st_size,
"created": datetime.fromtimestamp(stat.st_ctime).strftime("%Y-%m-%d %H:%M"),
"url": f"/html/{file.name}"
})
# 날짜순 정렬 (최신순)
html_files.sort(key=lambda x: x["created"], reverse=True)
return templates.TemplateResponse("documents.html", {
"request": request,
"documents": html_files,
"formatFileSize": format_file_size,
})
@app.get("/chat", response_class=HTMLResponse)
async def chat_page(request: Request):
"""AI 챗봇 페이지"""
# 서버 상태 정보
status = {
"base_model": settings.base_model,
"boost_model": settings.boost_model,
"embedding_model": settings.embedding_model,
"index_loaded": len(index.rows) if index else 0,
}
return templates.TemplateResponse("chat.html", {
"request": request,
"status": status,
"current_time": datetime.now().strftime("%H:%M"),
"api_key": os.getenv("API_KEY", "")
})
# Admin Dashboard Routes
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, api_key: str = Depends(require_api_key)):
"""관리자 대시보드 페이지"""
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": settings.ai_server_port,
"ollama_host": settings.ollama_host,
})
@app.get("/admin/ollama/status")
async def admin_ollama_status(api_key: str = Depends(require_api_key)):
"""Ollama 서버 상태 확인"""
try:
# Ollama 서버에 ping 요청
response = await ollama.client.get(f"{settings.ollama_host}/api/tags")
if response.status_code == 200:
return {"status": "online", "models_count": len(response.json().get("models", []))}
else:
return {"status": "offline", "error": f"HTTP {response.status_code}"}
except Exception as e:
return {"status": "offline", "error": str(e)}
@app.get("/admin/models")
async def admin_get_models(api_key: str = Depends(require_api_key)):
"""설치된 모델 목록 조회"""
try:
models_data = await ollama.list_models()
models = []
for model in models_data.get("models", []):
models.append({
"name": model.get("name", "Unknown"),
"size": model.get("size", 0),
"status": "ready",
"is_active": model.get("name") == settings.base_model,
"last_used": model.get("modified_at"),
})
return {"models": models}
except Exception as e:
return {"models": [], "error": str(e)}
@app.get("/admin/models/active")
async def admin_get_active_model(api_key: str = Depends(require_api_key)):
"""현재 활성 모델 조회"""
return {"model": settings.base_model}
@app.post("/admin/models/test")
async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 테스트"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
try:
# 간단한 테스트 메시지 전송
test_response = await ollama.generate(
model=model_name,
prompt="Hello, this is a test. Please respond with 'Test successful'.",
stream=False
)
return {
"result": f"Test successful. Model responded: {test_response.get('response', 'No response')[:100]}..."
}
except Exception as e:
return {"result": f"Test failed: {str(e)}"}
# API Key Management (Placeholder - 실제 구현은 데이터베이스 필요)
api_keys_store = {} # 임시 저장소
@app.get("/admin/api-keys")
async def admin_get_api_keys(api_key: str = Depends(require_api_key)):
"""API 키 목록 조회"""
keys = []
for key_id, key_data in api_keys_store.items():
keys.append({
"id": key_id,
"name": key_data.get("name", "Unnamed"),
"key": key_data.get("key", ""),
"created_at": key_data.get("created_at", datetime.now().isoformat()),
"usage_count": key_data.get("usage_count", 0),
})
return {"api_keys": keys}
@app.post("/admin/api-keys")
async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)):
"""새 API 키 생성"""
import secrets
import uuid
name = request.get("name", "Unnamed Key")
new_key = secrets.token_urlsafe(32)
key_id = str(uuid.uuid4())
api_keys_store[key_id] = {
"name": name,
"key": new_key,
"created_at": datetime.now().isoformat(),
"usage_count": 0,
}
return {"api_key": new_key, "key_id": key_id}
@app.delete("/admin/api-keys/{key_id}")
async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)):
"""API 키 삭제"""
if key_id in api_keys_store:
del api_keys_store[key_id]
return {"message": "API key deleted successfully"}
else:
raise HTTPException(status_code=404, detail="API key not found")
# Phase 2: Advanced Model Management
@app.post("/admin/models/download")
async def admin_download_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 다운로드"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
try:
# Ollama pull 명령 실행
result = await ollama.pull_model(model_name)
return {
"success": True,
"message": f"Model '{model_name}' download started",
"details": result
}
except Exception as e:
return {
"success": False,
"error": f"Failed to download model: {str(e)}"
}
@app.delete("/admin/models/{model_name}")
async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)):
"""모델 삭제"""
try:
# Ollama 모델 삭제
result = await ollama.delete_model(model_name)
return {
"success": True,
"message": f"Model '{model_name}' deleted successfully",
"details": result
}
except Exception as e:
return {
"success": False,
"error": f"Failed to delete model: {str(e)}"
}
@app.get("/admin/models/available")
async def admin_get_available_models(api_key: str = Depends(require_api_key)):
"""다운로드 가능한 모델 목록"""
# 인기 있는 모델들 목록 (실제로는 Ollama 레지스트리에서 가져와야 함)
available_models = [
{
"name": "llama3.2:1b",
"description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용",
"size": "1.3GB",
"tags": ["chat", "lightweight"]
},
{
"name": "llama3.2:3b",
"description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능",
"size": "2.0GB",
"tags": ["chat", "recommended"]
},
{
"name": "qwen2.5:7b",
"description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원",
"size": "4.1GB",
"tags": ["chat", "multilingual"]
},
{
"name": "gemma2:2b",
"description": "Google의 Gemma 2 2B 모델 - 효율적인 추론",
"size": "1.6GB",
"tags": ["chat", "efficient"]
},
{
"name": "codellama:7b",
"description": "Meta의 Code Llama 7B - 코드 생성 특화",
"size": "3.8GB",
"tags": ["code", "programming"]
},
{
"name": "mistral:7b",
"description": "Mistral AI의 7B 모델 - 고성능 추론",
"size": "4.1GB",
"tags": ["chat", "performance"]
}
]
return {"available_models": available_models}
# Phase 2: System Monitoring
@app.get("/admin/system/stats")
async def admin_get_system_stats(api_key: str = Depends(require_api_key)):
"""시스템 리소스 사용률 조회"""
import psutil
import GPUtil
try:
# CPU 사용률
cpu_percent = psutil.cpu_percent(interval=1)
cpu_count = psutil.cpu_count()
# 메모리 사용률
memory = psutil.virtual_memory()
memory_percent = memory.percent
memory_used = memory.used // (1024**3) # GB
memory_total = memory.total // (1024**3) # GB
# 디스크 사용률
disk = psutil.disk_usage('/')
disk_percent = (disk.used / disk.total) * 100
disk_used = disk.used // (1024**3) # GB
disk_total = disk.total // (1024**3) # GB
# GPU 사용률 (NVIDIA GPU가 있는 경우)
gpu_stats = []
try:
gpus = GPUtil.getGPUs()
for gpu in gpus:
gpu_stats.append({
"name": gpu.name,
"load": gpu.load * 100,
"memory_used": gpu.memoryUsed,
"memory_total": gpu.memoryTotal,
"temperature": gpu.temperature
})
except:
gpu_stats = []
return {
"cpu": {
"usage_percent": cpu_percent,
"core_count": cpu_count
},
"memory": {
"usage_percent": memory_percent,
"used_gb": memory_used,
"total_gb": memory_total
},
"disk": {
"usage_percent": disk_percent,
"used_gb": disk_used,
"total_gb": disk_total
},
"gpu": gpu_stats,
"timestamp": datetime.now().isoformat()
}
except Exception as e:
return {
"error": f"Failed to get system stats: {str(e)}",
"timestamp": datetime.now().isoformat()
}
# Phase 3: JWT Authentication System
from .auth import AuthManager, AuditLogger, get_current_user, require_admin_role, require_system_role
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# Get client IP
client_ip = request.client.host
user_agent = request.headers.get("user-agent", "")
try:
# Authenticate user
user = AuthManager.authenticate_user(username, password)
if user:
# Create JWT token
token = AuthManager.create_jwt_token(user, remember_me)
# Record successful login
AuthManager.record_login_attempt(username, True, client_ip)
AuditLogger.log_login(username, True, client_ip, user_agent)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
# Record failed login
AuthManager.record_login_attempt(username, False, client_ip)
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": "Invalid username or password"}
except HTTPException as e:
# Account locked
AuditLogger.log_login(username, False, client_ip, user_agent)
return {"success": False, "message": e.detail}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(request: Request, current_user: dict = Depends(get_current_user)):
"""로그아웃 (클라이언트에서 토큰 삭제)"""
client_ip = request.client.host
AuditLogger.log_admin_action(
current_user["username"],
"logout",
"User logged out",
client_ip
)
return {"success": True, "message": "Logged out successfully"}
# Update existing admin routes to use JWT authentication
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request, current_user: dict = Depends(require_admin_role)):
"""관리자 대시보드 페이지 (JWT 인증 필요)"""
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": settings.ai_server_port,
"ollama_host": settings.ollama_host,
"current_user": current_user
})

134
server/pipeline.py Normal file
View File

@@ -0,0 +1,134 @@
from __future__ import annotations
from dataclasses import dataclass
from pathlib import Path
from typing import List, Dict, Any
from .utils import chunk_text
from .ollama_client import OllamaClient
from .index_store import IndexRow
@dataclass
class PipelineResult:
doc_id: str
html_path: str | None
added_chunks: int
chunks: int
class DocumentPipeline:
def __init__(self, ollama: OllamaClient, embedding_model: str, boost_model: str, output_dir: str = "outputs") -> None:
self.ollama = ollama
self.embedding_model = embedding_model
self.boost_model = boost_model
self.output_dir = Path(output_dir)
(self.output_dir / "html").mkdir(parents=True, exist_ok=True)
def summarize(self, parts: List[str], target_language: str = "ko", sentences: int = 5) -> List[str]:
summarized: List[str] = []
sys_prompt = (
"당신은 전문 요약가입니다. 핵심 내용만 간결하게 요약하세요."
)
for p in parts:
if not p.strip():
summarized.append("")
continue
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": (
f"다음 텍스트를 {target_language}{sentences}문장 이내로 핵심만 요약하세요. 불필요한 수식어는 제거하고, 중요한 수치/용어는 보존하세요.\n\n{p}"
)},
]
resp = self.ollama.chat(self.boost_model, messages, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content = resp.get("message", {}).get("content") or resp.get("response", "")
summarized.append(content.strip())
# 최종 통합 요약(선택): 각 청크 요약을 다시 결합해 더 짧게
joined = "\n\n".join(s for s in summarized if s)
if not joined.strip():
return summarized
messages2 = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": (
f"아래 부분 요약들을 {target_language}{max(3, sentences)}문장 이내로 다시 한번 통합 요약하세요.\n\n{joined}"
)},
]
resp2 = self.ollama.chat(self.boost_model, messages2, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content2 = resp2.get("message", {}).get("content") or resp2.get("response", "")
return [content2.strip()]
def translate(self, parts: List[str], target_language: str = "ko") -> List[str]:
translated: List[str] = []
sys_prompt = (
"당신은 전문 번역가입니다. 입력 텍스트를 대상 언어로 자연스럽고 충실하게 번역하세요. "
"의미를 임의로 축약하거나 추가하지 마세요. 코드/수식/표기는 가능한 유지하세요."
)
for p in parts:
messages = [
{"role": "system", "content": sys_prompt},
{"role": "user", "content": f"아래 텍스트를 {target_language}로 번역하세요.\n\n{p}"},
]
resp = self.ollama.chat(self.boost_model, messages, stream=False, options={"temperature": 0.2, "num_ctx": 32768})
content = resp.get("message", {}).get("content") or resp.get("response", "")
translated.append(content.strip())
return translated
def build_html(self, basename: str, title: str, ko_text: str) -> str:
# Ensure .html suffix and sanitize basename
safe_base = Path(basename).stem + ".html"
html_path = self.output_dir / "html" / safe_base
html = f"""
<!doctype html>
<html lang=\"ko\">\n<head>\n<meta charset=\"utf-8\"/>\n<title>{title}</title>\n<style>
body{{font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif; line-height:1.6; margin:24px;}}
article{{max-width: 900px; margin: auto;}}
h1{{font-size: 1.6rem; margin-bottom: 1rem;}}
.chunk{{white-space: pre-wrap; margin: 1rem 0;}}
</style>\n</head>\n<body>\n<article>\n<h1>{title}</h1>\n"""
for idx, para in enumerate(ko_text.split("\n\n")):
if para.strip():
html += f"<div class=\"chunk\" id=\"c{idx}\">{para}</div>\n"
html += "</article>\n</body>\n</html>\n"
html_path.write_text(html, encoding="utf-8")
return str(html_path)
def process(
self,
*,
doc_id: str,
text: str,
index,
generate_html: bool = True,
translate: bool = True,
target_language: str = "ko",
summarize: bool = False,
summary_sentences: int = 5,
summary_language: str | None = None,
html_basename: str | None = None,
) -> PipelineResult:
parts = chunk_text(text, max_chars=1200, overlap=200)
if summarize:
# 요약 언어 기본값: 번역 언어와 동일, 번역 off면 ko로 요약(설정 없을 때)
sum_lang = summary_language or (target_language if translate else "ko")
summarized_parts = self.summarize(parts, target_language=sum_lang, sentences=summary_sentences)
working_parts = summarized_parts
else:
working_parts = parts
translated = self.translate(working_parts, target_language=target_language) if translate else working_parts
to_append: List[IndexRow] = []
for i, t in enumerate(translated):
vec = self.ollama.embeddings(self.embedding_model, t)
to_append.append(IndexRow(id=f"pipeline:{doc_id}:{i}", text=t, vector=vec, source=f"pipeline/{doc_id}"))
added = index.append(to_append) if to_append else 0
html_path: str | None = None
if generate_html:
title_suffix = "요약+번역본" if (summarize and translate) else ("요약본" if summarize else ("번역본" if translate else "원문"))
basename = html_basename or f"{doc_id}.html"
html_path = self.build_html(basename, title=f"문서 {doc_id} ({title_suffix})", ko_text="\n\n".join(translated))
return PipelineResult(doc_id=doc_id, html_path=html_path, added_chunks=added, chunks=len(translated))

697
static/admin.css Normal file
View File

@@ -0,0 +1,697 @@
/* AI Server Admin Dashboard CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: #f5f7fa;
color: #2c3e50;
line-height: 1.6;
}
.admin-layout {
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* Header */
.admin-header {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
padding: 1rem 2rem;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.header-content {
display: flex;
justify-content: space-between;
align-items: center;
max-width: 1200px;
margin: 0 auto;
}
.header-content h1 {
font-size: 1.8rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
}
.header-info {
display: flex;
align-items: center;
gap: 1rem;
}
.server-status {
display: flex;
align-items: center;
gap: 0.3rem;
padding: 0.3rem 0.8rem;
border-radius: 20px;
font-size: 0.9rem;
font-weight: 500;
}
.server-status.online {
background: rgba(46, 204, 113, 0.2);
border: 1px solid rgba(46, 204, 113, 0.3);
}
.server-status.offline {
background: rgba(231, 76, 60, 0.2);
border: 1px solid rgba(231, 76, 60, 0.3);
}
.current-time {
font-size: 0.9rem;
opacity: 0.9;
}
.user-menu {
display: flex;
align-items: center;
gap: 1rem;
}
.user-info {
display: flex;
align-items: center;
gap: 0.5rem;
font-size: 0.9rem;
opacity: 0.9;
}
.logout-btn {
background: rgba(255, 255, 255, 0.2);
border: 1px solid rgba(255, 255, 255, 0.3);
color: white;
padding: 0.4rem 0.8rem;
border-radius: 6px;
font-size: 0.8rem;
cursor: pointer;
transition: all 0.2s ease;
display: flex;
align-items: center;
gap: 0.3rem;
}
.logout-btn:hover {
background: rgba(255, 255, 255, 0.3);
border-color: rgba(255, 255, 255, 0.5);
}
/* Main Content */
.admin-main {
flex: 1;
padding: 2rem;
max-width: 1200px;
margin: 0 auto;
width: 100%;
}
.dashboard-section {
margin-bottom: 3rem;
}
.dashboard-section h2 {
color: #2c3e50;
margin-bottom: 1.5rem;
font-size: 1.4rem;
font-weight: 600;
display: flex;
align-items: center;
gap: 0.5rem;
border-bottom: 2px solid #ecf0f1;
padding-bottom: 0.5rem;
}
/* Status Grid */
.status-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
gap: 1.5rem;
margin-bottom: 2rem;
}
.status-card {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
transition: transform 0.2s ease, box-shadow 0.2s ease;
}
.status-card:hover {
transform: translateY(-2px);
box-shadow: 0 8px 25px rgba(0,0,0,0.1);
}
.card-header {
display: flex;
align-items: center;
gap: 0.8rem;
margin-bottom: 1rem;
}
.card-header i {
font-size: 1.5rem;
color: #667eea;
}
.card-header h3 {
font-size: 1.1rem;
font-weight: 600;
color: #2c3e50;
}
.card-content {
text-align: center;
}
.status-value {
font-size: 1.8rem;
font-weight: 700;
color: #27ae60;
margin-bottom: 0.5rem;
}
.status-value.error {
color: #e74c3c;
}
.status-value.warning {
color: #f39c12;
}
.status-detail {
font-size: 0.9rem;
color: #7f8c8d;
}
/* Tables */
.models-container, .api-keys-container {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
}
.models-header, .api-keys-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
}
.models-table table {
width: 100%;
border-collapse: collapse;
}
.models-table th,
.models-table td {
text-align: left;
padding: 1rem;
border-bottom: 1px solid #ecf0f1;
}
.models-table th {
background: #f8f9fa;
font-weight: 600;
color: #2c3e50;
font-size: 0.9rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.models-table tr:hover {
background: #f8f9fa;
}
.loading {
text-align: center;
color: #7f8c8d;
font-style: italic;
padding: 2rem;
}
/* Buttons */
.btn {
display: inline-flex;
align-items: center;
gap: 0.5rem;
padding: 0.6rem 1.2rem;
border: none;
border-radius: 8px;
font-size: 0.9rem;
font-weight: 500;
cursor: pointer;
transition: all 0.2s ease;
text-decoration: none;
}
.btn-primary {
background: #667eea;
color: white;
}
.btn-primary:hover {
background: #5a6fd8;
transform: translateY(-1px);
}
.btn-success {
background: #27ae60;
color: white;
}
.btn-success:hover {
background: #229954;
transform: translateY(-1px);
}
.btn-danger {
background: #e74c3c;
color: white;
}
.btn-danger:hover {
background: #c0392b;
transform: translateY(-1px);
}
.btn-small {
padding: 0.4rem 0.8rem;
font-size: 0.8rem;
}
/* API Keys */
.api-key-item {
background: #f8f9fa;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
}
.api-key-info {
flex: 1;
}
.api-key-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.3rem;
display: flex;
align-items: center;
gap: 0.5rem;
flex-wrap: wrap;
}
.encryption-badge {
display: inline-flex;
align-items: center;
gap: 0.3rem;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
background: #27ae60;
color: white;
}
.encryption-badge.plain {
background: #e74c3c;
}
.encryption-badge i {
font-size: 0.6rem;
}
.api-key-value {
font-family: 'Monaco', 'Menlo', monospace;
font-size: 0.8rem;
color: #7f8c8d;
background: white;
padding: 0.3rem 0.6rem;
border-radius: 4px;
border: 1px solid #ddd;
margin-bottom: 0.3rem;
}
.api-key-meta {
font-size: 0.8rem;
color: #95a5a6;
}
.api-key-actions {
display: flex;
gap: 0.5rem;
}
/* Status Badges */
.status-badge {
display: inline-block;
padding: 0.2rem 0.6rem;
border-radius: 12px;
font-size: 0.8rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.status-badge.active {
background: #d5f4e6;
color: #27ae60;
}
.status-badge.inactive {
background: #fadbd8;
color: #e74c3c;
}
.status-badge.loading {
background: #fef9e7;
color: #f39c12;
}
/* Phase 2: System Monitoring Styles */
.monitoring-container {
background: white;
border-radius: 12px;
padding: 1.5rem;
box-shadow: 0 4px 6px rgba(0,0,0,0.07);
border: 1px solid #e1e8ed;
}
.monitoring-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(200px, 1fr));
gap: 1.5rem;
}
.monitor-card {
background: #f8f9fa;
border-radius: 8px;
padding: 1.5rem;
text-align: center;
border: 1px solid #e9ecef;
}
.monitor-card .card-header {
margin-bottom: 1rem;
}
.progress-circle {
position: relative;
width: 80px;
height: 80px;
margin: 0 auto 1rem;
border-radius: 50%;
background: conic-gradient(#667eea 0deg, #e9ecef 0deg);
display: flex;
align-items: center;
justify-content: center;
}
.progress-circle::before {
content: '';
position: absolute;
width: 60px;
height: 60px;
border-radius: 50%;
background: white;
}
.progress-text {
position: relative;
z-index: 1;
font-size: 0.9rem;
font-weight: 600;
color: #2c3e50;
}
.monitor-details {
font-size: 0.8rem;
color: #7f8c8d;
}
/* Progress circle colors */
.progress-circle.low {
background: conic-gradient(#27ae60 var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
.progress-circle.medium {
background: conic-gradient(#f39c12 var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
.progress-circle.high {
background: conic-gradient(#e74c3c var(--progress, 0deg), #e9ecef var(--progress, 0deg));
}
/* Modal Styles */
.modal {
display: none;
position: fixed;
z-index: 1000;
left: 0;
top: 0;
width: 100%;
height: 100%;
background-color: rgba(0,0,0,0.5);
backdrop-filter: blur(4px);
}
.modal-content {
background-color: white;
margin: 5% auto;
padding: 0;
border-radius: 12px;
width: 90%;
max-width: 600px;
box-shadow: 0 10px 30px rgba(0,0,0,0.3);
animation: modalSlideIn 0.3s ease;
}
@keyframes modalSlideIn {
from {
opacity: 0;
transform: translateY(-50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 1.5rem;
border-bottom: 1px solid #e1e8ed;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border-radius: 12px 12px 0 0;
}
.modal-header h3 {
margin: 0;
display: flex;
align-items: center;
gap: 0.5rem;
}
.close-btn {
background: none;
border: none;
font-size: 1.5rem;
color: white;
cursor: pointer;
padding: 0;
width: 30px;
height: 30px;
display: flex;
align-items: center;
justify-content: center;
border-radius: 50%;
transition: background-color 0.2s ease;
}
.close-btn:hover {
background-color: rgba(255,255,255,0.2);
}
.modal-body {
padding: 1.5rem;
}
.modal-actions {
display: flex;
gap: 1rem;
justify-content: flex-end;
margin-top: 1.5rem;
padding-top: 1rem;
border-top: 1px solid #e1e8ed;
}
/* Available Models List */
.available-model-item {
background: #f8f9fa;
border: 1px solid #e1e8ed;
border-radius: 8px;
padding: 1rem;
margin-bottom: 1rem;
display: flex;
justify-content: space-between;
align-items: center;
transition: border-color 0.2s ease;
}
.available-model-item:hover {
border-color: #667eea;
}
.model-info {
flex: 1;
}
.model-name {
font-weight: 600;
color: #2c3e50;
margin-bottom: 0.3rem;
}
.model-description {
font-size: 0.9rem;
color: #7f8c8d;
margin-bottom: 0.5rem;
}
.model-tags {
display: flex;
gap: 0.5rem;
flex-wrap: wrap;
}
.model-tag {
background: #667eea;
color: white;
padding: 0.2rem 0.5rem;
border-radius: 12px;
font-size: 0.7rem;
text-transform: uppercase;
letter-spacing: 0.5px;
}
.model-tag.code {
background: #e74c3c;
}
.model-tag.lightweight {
background: #27ae60;
}
.model-tag.recommended {
background: #f39c12;
}
.model-size {
font-size: 0.9rem;
color: #95a5a6;
margin-top: 0.5rem;
}
.model-delete-info {
background: #fadbd8;
border: 1px solid #f1948a;
border-radius: 8px;
padding: 1rem;
margin: 1rem 0;
}
.model-delete-info strong {
color: #e74c3c;
}
/* Enhanced Models Table */
.models-header {
display: flex;
justify-content: space-between;
align-items: center;
margin-bottom: 1.5rem;
flex-wrap: wrap;
gap: 1rem;
}
/* Responsive */
@media (max-width: 768px) {
.admin-main {
padding: 1rem;
}
.status-grid {
grid-template-columns: 1fr;
gap: 1rem;
}
.monitoring-grid {
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 1rem;
}
.header-content {
flex-direction: column;
gap: 1rem;
text-align: center;
}
.models-table {
overflow-x: auto;
}
.models-header {
flex-direction: column;
align-items: stretch;
}
.api-key-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.api-key-actions {
width: 100%;
justify-content: flex-end;
}
.available-model-item {
flex-direction: column;
align-items: flex-start;
gap: 1rem;
}
.modal-content {
width: 95%;
margin: 2% auto;
}
.modal-actions {
flex-direction: column;
}
}

508
static/admin.js Normal file
View File

@@ -0,0 +1,508 @@
// AI Server Admin Dashboard JavaScript
class AdminDashboard {
constructor() {
this.apiKey = this.getApiKey();
this.baseUrl = window.location.origin;
this.init();
}
getApiKey() {
// JWT 토큰 사용
const token = localStorage.getItem('ai_admin_token');
console.log('Getting token:', token ? token.substring(0, 20) + '...' : 'No token found');
if (!token) {
// 토큰이 없으면 로그인 페이지로 리다이렉트
console.log('No token, redirecting to login...');
window.location.href = '/login';
return null;
}
return token;
}
async init() {
// 먼저 토큰 검증
if (!this.apiKey) {
return; // getApiKey()에서 이미 리다이렉트됨
}
// 토큰 유효성 검증
try {
await this.apiRequest('/admin/verify-token');
console.log('Token verification successful');
} catch (error) {
console.log('Token verification failed, redirecting to login');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
this.updateCurrentTime();
setInterval(() => this.updateCurrentTime(), 1000);
await this.loadUserInfo(); // Phase 3: Load user info
await this.loadSystemStatus();
await this.loadModels();
await this.loadApiKeys();
await this.loadSystemStats(); // Phase 2
// Auto-refresh every 30 seconds
setInterval(() => {
this.loadSystemStatus();
this.loadModels();
this.loadSystemStats(); // Phase 2
}, 30000);
}
// Phase 3: User Management
async loadUserInfo() {
try {
const userInfo = localStorage.getItem('ai_admin_user');
if (userInfo) {
const user = JSON.parse(userInfo);
document.getElementById('username').textContent = user.username;
} else {
// Verify token and get user info
const response = await this.apiRequest('/admin/verify-token');
if (response.valid) {
document.getElementById('username').textContent = response.user.username;
localStorage.setItem('ai_admin_user', JSON.stringify(response.user));
}
}
} catch (error) {
console.error('Failed to load user info:', error);
// Token might be invalid, redirect to login
window.location.href = '/login';
}
}
updateCurrentTime() {
const now = new Date();
document.getElementById('current-time').textContent =
now.toLocaleString('ko-KR', {
year: 'numeric',
month: '2-digit',
day: '2-digit',
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
}
async apiRequest(endpoint, options = {}) {
const url = `${this.baseUrl}${endpoint}`;
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
'Authorization': `Bearer ${this.apiKey}`
}
};
console.log('API Request:', endpoint, 'with token:', this.apiKey ? this.apiKey.substring(0, 20) + '...' : 'No token');
try {
const response = await fetch(url, { ...defaultOptions, ...options });
console.log('API Response:', response.status, response.statusText);
if (!response.ok) {
if (response.status === 401) {
console.log('401 Unauthorized - clearing tokens and redirecting');
// JWT 토큰이 만료되었거나 유효하지 않음
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
return;
}
const errorText = await response.text();
console.log('Error response:', errorText);
throw new Error(`HTTP ${response.status}: ${response.statusText}`);
}
return await response.json();
} catch (error) {
console.error('API Request failed:', error);
throw error;
}
}
async loadSystemStatus() {
try {
// Check AI Server status
const healthResponse = await this.apiRequest('/health');
document.getElementById('server-status').textContent = 'Online';
document.getElementById('server-status').className = 'status-value';
// Check Ollama status
try {
const ollamaResponse = await this.apiRequest('/admin/ollama/status');
document.getElementById('ollama-status').textContent =
ollamaResponse.status === 'online' ? 'Online' : 'Offline';
document.getElementById('ollama-status').className =
`status-value ${ollamaResponse.status === 'online' ? '' : 'error'}`;
} catch (error) {
document.getElementById('ollama-status').textContent = 'Offline';
document.getElementById('ollama-status').className = 'status-value error';
}
// Load active model
try {
const modelResponse = await this.apiRequest('/admin/models/active');
document.getElementById('active-model').textContent =
modelResponse.model || 'None';
} catch (error) {
document.getElementById('active-model').textContent = 'Unknown';
}
// Load API call stats (placeholder)
document.getElementById('api-calls').textContent = '0';
} catch (error) {
console.error('Failed to load system status:', error);
document.getElementById('server-status').textContent = 'Error';
document.getElementById('server-status').className = 'status-value error';
}
}
async loadModels() {
try {
const response = await this.apiRequest('/admin/models');
const models = response.models || [];
const tbody = document.getElementById('models-tbody');
if (models.length === 0) {
tbody.innerHTML = '<tr><td colspan="5" class="loading">No models found</td></tr>';
return;
}
tbody.innerHTML = models.map(model => `
<tr>
<td>
<strong>${model.name}</strong>
${model.is_active ? '<span class="status-badge active">Active</span>' : ''}
</td>
<td>${this.formatSize(model.size)}</td>
<td>
<span class="status-badge ${model.status === 'ready' ? 'active' : 'inactive'}">
${model.status || 'Unknown'}
</span>
</td>
<td>${model.last_used ? new Date(model.last_used).toLocaleString('ko-KR') : 'Never'}</td>
<td>
<button class="btn btn-small btn-primary" onclick="admin.testModel('${model.name}')">
<i class="fas fa-play"></i> Test
</button>
<button class="btn btn-small btn-danger" onclick="admin.confirmDeleteModel('${model.name}')">
<i class="fas fa-trash"></i> Delete
</button>
</td>
</tr>
`).join('');
} catch (error) {
console.error('Failed to load models:', error);
document.getElementById('models-tbody').innerHTML =
'<tr><td colspan="5" class="loading">Error loading models</td></tr>';
}
}
async loadApiKeys() {
try {
const response = await this.apiRequest('/admin/api-keys');
const apiKeys = response.api_keys || [];
const container = document.getElementById('api-keys-list');
if (apiKeys.length === 0) {
container.innerHTML = '<div class="loading">No API keys found</div>';
return;
}
container.innerHTML = apiKeys.map(key => `
<div class="api-key-item">
<div class="api-key-info">
<div class="api-key-name">
${key.name || 'Unnamed Key'}
${key.encrypted ? '<span class="encryption-badge"><i class="fas fa-lock"></i> Encrypted</span>' : '<span class="encryption-badge plain"><i class="fas fa-unlock"></i> Plain</span>'}
</div>
<div class="api-key-value">${this.maskApiKey(key.key)}</div>
<div class="api-key-meta">
Created: ${new Date(key.created_at).toLocaleString('ko-KR')} |
Uses: ${key.usage_count || 0}
${key.encrypted ? ' | 🔒 AES-256 Encrypted' : ' | ⚠️ Plain Text'}
</div>
</div>
<div class="api-key-actions">
<button class="btn btn-small btn-danger" onclick="admin.deleteApiKey('${key.id}')">
<i class="fas fa-trash"></i> Delete
</button>
</div>
</div>
`).join('');
} catch (error) {
console.error('Failed to load API keys:', error);
document.getElementById('api-keys-list').innerHTML =
'<div class="loading">Error loading API keys</div>';
}
}
formatSize(bytes) {
if (!bytes) return 'Unknown';
const sizes = ['B', 'KB', 'MB', 'GB', 'TB'];
const i = Math.floor(Math.log(bytes) / Math.log(1024));
return Math.round(bytes / Math.pow(1024, i) * 100) / 100 + ' ' + sizes[i];
}
maskApiKey(key) {
if (!key) return 'Unknown';
if (key.length <= 8) return key;
return key.substring(0, 4) + '...' + key.substring(key.length - 4);
}
async refreshModels() {
document.getElementById('models-tbody').innerHTML =
'<tr><td colspan="5" class="loading">Refreshing models...</td></tr>';
await this.loadModels();
}
async testModel(modelName) {
try {
const response = await this.apiRequest('/admin/models/test', {
method: 'POST',
body: JSON.stringify({ model: modelName })
});
alert(`Model test result:\n${response.result || 'Test completed successfully'}`);
} catch (error) {
alert(`Model test failed: ${error.message}`);
}
}
async generateApiKey() {
const name = prompt('Enter a name for the new API key:');
if (!name) return;
try {
const response = await this.apiRequest('/admin/api-keys', {
method: 'POST',
body: JSON.stringify({ name })
});
alert(`New API key created:\n${response.api_key}\n\nPlease save this key securely. It will not be shown again.`);
await this.loadApiKeys();
} catch (error) {
alert(`Failed to generate API key: ${error.message}`);
}
}
async deleteApiKey(keyId) {
if (!confirm('Are you sure you want to delete this API key?')) return;
try {
await this.apiRequest(`/admin/api-keys/${keyId}`, {
method: 'DELETE'
});
await this.loadApiKeys();
} catch (error) {
alert(`Failed to delete API key: ${error.message}`);
}
}
// Phase 2: System Monitoring
async loadSystemStats() {
try {
const response = await this.apiRequest('/admin/system/stats');
// Update CPU
this.updateProgressCircle('cpu-progress', response.cpu.usage_percent);
document.getElementById('cpu-text').textContent = `${response.cpu.usage_percent}%`;
document.getElementById('cpu-cores').textContent = `${response.cpu.core_count} cores`;
// Update Memory
this.updateProgressCircle('memory-progress', response.memory.usage_percent);
document.getElementById('memory-text').textContent = `${response.memory.usage_percent}%`;
document.getElementById('memory-details').textContent =
`${response.memory.used_gb} / ${response.memory.total_gb} GB`;
// Update Disk
this.updateProgressCircle('disk-progress', response.disk.usage_percent);
document.getElementById('disk-text').textContent = `${response.disk.usage_percent}%`;
document.getElementById('disk-details').textContent =
`${response.disk.used_gb} / ${response.disk.total_gb} GB`;
// Update GPU
if (response.gpu && response.gpu.length > 0) {
const gpu = response.gpu[0];
this.updateProgressCircle('gpu-progress', gpu.load);
document.getElementById('gpu-text').textContent = `${gpu.load}%`;
document.getElementById('gpu-details').textContent =
`${gpu.name} - ${gpu.temperature}°C`;
} else {
document.getElementById('gpu-text').textContent = '--';
document.getElementById('gpu-details').textContent = 'No GPU detected';
}
} catch (error) {
console.error('Failed to load system stats:', error);
}
}
updateProgressCircle(elementId, percentage) {
const element = document.getElementById(elementId);
const degrees = (percentage / 100) * 360;
// Remove existing color classes
element.classList.remove('low', 'medium', 'high');
// Add appropriate color class
if (percentage < 50) {
element.classList.add('low');
} else if (percentage < 80) {
element.classList.add('medium');
} else {
element.classList.add('high');
}
// Update CSS custom property for progress
element.style.setProperty('--progress', `${degrees}deg`);
}
// Phase 2: Model Download
async openModelDownload() {
try {
const response = await this.apiRequest('/admin/models/available');
const models = response.available_models || [];
const container = document.getElementById('available-models-list');
if (models.length === 0) {
container.innerHTML = '<div class="loading">No models available</div>';
} else {
container.innerHTML = models.map(model => `
<div class="available-model-item">
<div class="model-info">
<div class="model-name">${model.name}</div>
<div class="model-description">${model.description}</div>
<div class="model-tags">
${model.tags.map(tag => `<span class="model-tag ${tag}">${tag}</span>`).join('')}
</div>
<div class="model-size">Size: ${model.size}</div>
</div>
<button class="btn btn-success" onclick="admin.downloadModel('${model.name}')">
<i class="fas fa-download"></i> Download
</button>
</div>
`).join('');
}
this.openModal('model-download-modal');
} catch (error) {
console.error('Failed to load available models:', error);
alert('Failed to load available models');
}
}
async downloadModel(modelName) {
try {
const response = await this.apiRequest('/admin/models/download', {
method: 'POST',
body: JSON.stringify({ model: modelName })
});
if (response.success) {
alert(`Download started: ${response.message}`);
this.closeModal('model-download-modal');
// Refresh models list after a short delay
setTimeout(() => this.loadModels(), 2000);
} else {
alert(`Download failed: ${response.error}`);
}
} catch (error) {
alert(`Download failed: ${error.message}`);
}
}
// Phase 2: Model Delete
confirmDeleteModel(modelName) {
document.getElementById('delete-model-name').textContent = modelName;
// Set up delete confirmation
const confirmBtn = document.getElementById('confirm-delete-btn');
confirmBtn.onclick = () => this.deleteModel(modelName);
this.openModal('model-delete-modal');
}
async deleteModel(modelName) {
try {
const response = await this.apiRequest(`/admin/models/${modelName}`, {
method: 'DELETE'
});
if (response.success) {
alert(`Model deleted: ${response.message}`);
this.closeModal('model-delete-modal');
await this.loadModels();
} else {
alert(`Delete failed: ${response.error}`);
}
} catch (error) {
alert(`Delete failed: ${error.message}`);
}
}
// Modal management
openModal(modalId) {
document.getElementById(modalId).style.display = 'block';
}
closeModal(modalId) {
document.getElementById(modalId).style.display = 'none';
}
}
// Global functions for HTML onclick handlers
let admin;
function refreshModels() {
admin.refreshModels();
}
function generateApiKey() {
admin.generateApiKey();
}
function openModelDownload() {
admin.openModelDownload();
}
function closeModal(modalId) {
admin.closeModal(modalId);
}
// Phase 3: Logout function
async function logout() {
if (!confirm('Are you sure you want to logout?')) return;
try {
// Call logout API
await admin.apiRequest('/admin/logout', { method: 'POST' });
} catch (error) {
console.error('Logout API call failed:', error);
} finally {
// Clear local storage and redirect
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
window.location.href = '/login';
}
}
// Initialize dashboard when page loads
document.addEventListener('DOMContentLoaded', () => {
admin = new AdminDashboard();
});

394
static/css/style.css Normal file
View File

@@ -0,0 +1,394 @@
/* 기본 스타일 리셋 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Noto Sans KR', 'Apple SD Gothic Neo', Arial, sans-serif;
line-height: 1.6;
color: #333;
background-color: #f8f9fa;
min-height: 100vh;
display: flex;
flex-direction: column;
}
/* 네비게이션 */
.navbar {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
padding: 1rem 0;
box-shadow: 0 2px 10px rgba(0,0,0,0.1);
}
.nav-container {
max-width: 1200px;
margin: 0 auto;
display: flex;
justify-content: space-between;
align-items: center;
padding: 0 2rem;
}
.nav-logo {
color: white;
text-decoration: none;
font-size: 1.5rem;
font-weight: bold;
}
.nav-logo i {
margin-right: 0.5rem;
}
.nav-menu {
display: flex;
list-style: none;
gap: 2rem;
}
.nav-link {
color: white;
text-decoration: none;
padding: 0.5rem 1rem;
border-radius: 5px;
transition: background-color 0.3s;
}
.nav-link:hover {
background-color: rgba(255,255,255,0.2);
}
/* 메인 콘텐츠 */
.main-content {
flex: 1;
max-width: 1200px;
margin: 2rem auto;
padding: 0 2rem;
width: 100%;
}
/* 카드 스타일 */
.card {
background: white;
border-radius: 10px;
padding: 2rem;
margin-bottom: 2rem;
box-shadow: 0 4px 15px rgba(0,0,0,0.1);
border: 1px solid #e9ecef;
}
.card-header {
margin-bottom: 1.5rem;
padding-bottom: 1rem;
border-bottom: 2px solid #f8f9fa;
}
.card-title {
font-size: 1.5rem;
color: #495057;
margin-bottom: 0.5rem;
}
.card-subtitle {
color: #6c757d;
font-size: 0.9rem;
}
/* 그리드 레이아웃 */
.grid {
display: grid;
gap: 2rem;
}
.grid-2 {
grid-template-columns: repeat(auto-fit, minmax(300px, 1fr));
}
.grid-3 {
grid-template-columns: repeat(auto-fit, minmax(250px, 1fr));
}
/* 버튼 */
.btn {
display: inline-block;
padding: 0.75rem 1.5rem;
border: none;
border-radius: 5px;
text-decoration: none;
text-align: center;
cursor: pointer;
transition: all 0.3s;
font-size: 1rem;
font-weight: 500;
}
.btn-primary {
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
}
.btn-primary:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(102, 126, 234, 0.4);
}
.btn-success {
background: linear-gradient(135deg, #56ab2f 0%, #a8e6cf 100%);
color: white;
}
.btn-success:hover {
transform: translateY(-2px);
box-shadow: 0 4px 15px rgba(86, 171, 47, 0.4);
}
.btn-danger {
background: linear-gradient(135deg, #ff6b6b 0%, #ee5a52 100%);
color: white;
}
.btn-secondary {
background: #6c757d;
color: white;
}
/* 폼 요소 */
.form-group {
margin-bottom: 1.5rem;
}
.form-label {
display: block;
margin-bottom: 0.5rem;
font-weight: 500;
color: #495057;
}
.form-control {
width: 100%;
padding: 0.75rem;
border: 1px solid #ced4da;
border-radius: 5px;
font-size: 1rem;
transition: border-color 0.3s, box-shadow 0.3s;
}
.form-control:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
/* 드래그 앤 드롭 영역 */
.drop-zone {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 3rem;
text-align: center;
transition: all 0.3s;
background-color: #f8f9fa;
cursor: pointer;
}
.drop-zone:hover,
.drop-zone.dragover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone i {
font-size: 3rem;
color: #6c757d;
margin-bottom: 1rem;
}
.drop-zone.dragover i {
color: #667eea;
}
/* 상태 표시 */
.status {
padding: 0.5rem 1rem;
border-radius: 20px;
font-size: 0.8rem;
font-weight: 500;
display: inline-block;
}
.status-success {
background-color: #d4edda;
color: #155724;
}
.status-processing {
background-color: #fff3cd;
color: #856404;
}
.status-error {
background-color: #f8d7da;
color: #721c24;
}
/* 테이블 */
.table {
width: 100%;
border-collapse: collapse;
margin-top: 1rem;
}
.table th,
.table td {
padding: 1rem;
text-align: left;
border-bottom: 1px solid #e9ecef;
}
.table th {
background-color: #f8f9fa;
font-weight: 600;
color: #495057;
}
.table tbody tr:hover {
background-color: #f8f9fa;
}
/* 진행률 바 */
.progress {
width: 100%;
height: 8px;
background-color: #e9ecef;
border-radius: 4px;
overflow: hidden;
margin: 1rem 0;
}
.progress-bar {
height: 100%;
background: linear-gradient(90deg, #667eea, #764ba2);
transition: width 0.3s;
border-radius: 4px;
}
/* 알림 */
.alert {
padding: 1rem;
border-radius: 5px;
margin-bottom: 1rem;
border-left: 4px solid;
}
.alert-success {
background-color: #d4edda;
color: #155724;
border-left-color: #28a745;
}
.alert-danger {
background-color: #f8d7da;
color: #721c24;
border-left-color: #dc3545;
}
.alert-info {
background-color: #d1ecf1;
color: #0c5460;
border-left-color: #17a2b8;
}
/* 푸터 */
.footer {
background-color: #343a40;
color: white;
text-align: center;
padding: 1rem 0;
margin-top: auto;
}
/* 반응형 디자인 */
@media (max-width: 768px) {
.nav-container {
flex-direction: column;
gap: 1rem;
}
.nav-menu {
flex-wrap: wrap;
justify-content: center;
gap: 1rem;
}
.main-content {
padding: 0 1rem;
}
.grid-2,
.grid-3 {
grid-template-columns: 1fr;
}
}
/* 로딩 스피너 */
.spinner {
display: inline-block;
width: 20px;
height: 20px;
border: 3px solid rgba(255,255,255,.3);
border-radius: 50%;
border-top-color: #fff;
animation: spin 1s ease-in-out infinite;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 채팅 인터페이스 */
.chat-container {
height: 500px;
display: flex;
flex-direction: column;
border: 1px solid #e9ecef;
border-radius: 10px;
overflow: hidden;
}
.chat-messages {
flex: 1;
padding: 1rem;
overflow-y: auto;
background-color: #f8f9fa;
}
.chat-input-area {
padding: 1rem;
background-color: white;
border-top: 1px solid #e9ecef;
display: flex;
gap: 0.5rem;
}
.chat-input {
flex: 1;
}
.message {
margin-bottom: 1rem;
padding: 0.75rem;
border-radius: 10px;
max-width: 70%;
}
.message.user {
background-color: #667eea;
color: white;
margin-left: auto;
}
.message.assistant {
background-color: white;
border: 1px solid #e9ecef;
}

87
static/js/main.js Normal file
View File

@@ -0,0 +1,87 @@
// 공통 JavaScript 기능들
// API 호출 헬퍼
async function apiCall(url, options = {}) {
const defaultOptions = {
headers: {
'Content-Type': 'application/json',
},
};
const response = await fetch(url, { ...defaultOptions, ...options });
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
return response.json();
}
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 날짜 포맷팅
function formatDate(dateString) {
const date = new Date(dateString);
return date.toLocaleString('ko-KR');
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type} toast`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1000;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
document.body.removeChild(toast);
}, 300);
}, 3000);
}
// 로딩 상태 관리
function setLoading(element, loading = true) {
if (loading) {
element.disabled = true;
element.innerHTML = '<span class="spinner"></span> 처리 중...';
} else {
element.disabled = false;
element.innerHTML = element.getAttribute('data-original-text') || '완료';
}
}
// 애니메이션 CSS 추가
const style = document.createElement('style');
style.textContent = `
@keyframes slideIn {
from { transform: translateX(100%); opacity: 0; }
to { transform: translateX(0); opacity: 1; }
}
@keyframes slideOut {
from { transform: translateX(0); opacity: 1; }
to { transform: translateX(100%); opacity: 0; }
}
.toast {
animation: slideIn 0.3s ease;
}
`;
document.head.appendChild(style);

390
static/login.css Normal file
View File

@@ -0,0 +1,390 @@
/* AI Server Admin Login Page CSS */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
min-height: 100vh;
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
position: relative;
}
/* Background Animation */
.bg-animation {
position: absolute;
top: 0;
left: 0;
width: 100%;
height: 100%;
pointer-events: none;
z-index: 0;
}
.floating-icon {
position: absolute;
font-size: 2rem;
color: rgba(255, 255, 255, 0.1);
animation: float 8s ease-in-out infinite;
animation-delay: var(--delay, 0s);
}
.floating-icon:nth-child(1) {
top: 20%;
left: 10%;
}
.floating-icon:nth-child(2) {
top: 60%;
right: 15%;
}
.floating-icon:nth-child(3) {
bottom: 30%;
left: 20%;
}
.floating-icon:nth-child(4) {
top: 40%;
right: 30%;
}
@keyframes float {
0%, 100% {
transform: translateY(0px) rotate(0deg);
opacity: 0.1;
}
50% {
transform: translateY(-20px) rotate(180deg);
opacity: 0.3;
}
}
/* Login Container */
.login-container {
position: relative;
z-index: 1;
width: 100%;
max-width: 400px;
padding: 2rem;
}
.login-card {
background: rgba(255, 255, 255, 0.95);
backdrop-filter: blur(20px);
border-radius: 20px;
padding: 2.5rem;
box-shadow: 0 20px 40px rgba(0, 0, 0, 0.1);
border: 1px solid rgba(255, 255, 255, 0.2);
animation: slideUp 0.6s ease-out;
}
@keyframes slideUp {
from {
opacity: 0;
transform: translateY(50px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
/* Header */
.login-header {
text-align: center;
margin-bottom: 2rem;
}
.logo {
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
}
.logo i {
font-size: 2rem;
color: #667eea;
}
.logo h1 {
font-size: 1.8rem;
font-weight: 600;
color: #2c3e50;
}
.subtitle {
color: #7f8c8d;
font-size: 0.9rem;
font-weight: 500;
}
/* Form Styles */
.login-form {
margin-bottom: 1.5rem;
}
.form-group {
margin-bottom: 1.5rem;
}
.form-group label {
display: flex;
align-items: center;
gap: 0.5rem;
margin-bottom: 0.5rem;
font-weight: 500;
color: #2c3e50;
font-size: 0.9rem;
}
.form-group label i {
color: #667eea;
width: 16px;
}
.form-group input[type="text"],
.form-group input[type="password"] {
width: 100%;
padding: 0.75rem 1rem;
border: 2px solid #e1e8ed;
border-radius: 10px;
font-size: 1rem;
transition: all 0.3s ease;
background: white;
}
.form-group input:focus {
outline: none;
border-color: #667eea;
box-shadow: 0 0 0 3px rgba(102, 126, 234, 0.1);
}
.password-input {
position: relative;
}
.password-toggle {
position: absolute;
right: 1rem;
top: 50%;
transform: translateY(-50%);
background: none;
border: none;
color: #7f8c8d;
cursor: pointer;
padding: 0;
font-size: 1rem;
transition: color 0.2s ease;
}
.password-toggle:hover {
color: #667eea;
}
/* Checkbox */
.checkbox-label {
display: flex !important;
align-items: center;
gap: 0.75rem;
cursor: pointer;
font-size: 0.9rem;
color: #7f8c8d;
}
.checkbox-label input[type="checkbox"] {
display: none;
}
.checkmark {
width: 18px;
height: 18px;
border: 2px solid #e1e8ed;
border-radius: 4px;
position: relative;
transition: all 0.3s ease;
}
.checkbox-label input:checked + .checkmark {
background: #667eea;
border-color: #667eea;
}
.checkbox-label input:checked + .checkmark::after {
content: '\f00c';
font-family: 'Font Awesome 6 Free';
font-weight: 900;
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: white;
font-size: 0.7rem;
}
/* Login Button */
.login-btn {
width: 100%;
padding: 0.875rem;
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
color: white;
border: none;
border-radius: 10px;
font-size: 1rem;
font-weight: 600;
cursor: pointer;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
gap: 0.5rem;
}
.login-btn:hover {
transform: translateY(-2px);
box-shadow: 0 10px 20px rgba(102, 126, 234, 0.3);
}
.login-btn:active {
transform: translateY(0);
}
.login-btn:disabled {
opacity: 0.6;
cursor: not-allowed;
transform: none;
}
/* Loading State */
.login-btn.loading {
pointer-events: none;
}
.login-btn.loading i {
animation: spin 1s linear infinite;
}
@keyframes spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* Error Message */
.error-message {
background: #fadbd8;
border: 1px solid #f1948a;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #e74c3c;
font-size: 0.9rem;
animation: shake 0.5s ease-in-out;
}
@keyframes shake {
0%, 100% { transform: translateX(0); }
25% { transform: translateX(-5px); }
75% { transform: translateX(5px); }
}
/* Security Info */
.security-info {
display: flex;
justify-content: space-between;
margin-bottom: 1.5rem;
padding: 1rem;
background: rgba(102, 126, 234, 0.05);
border-radius: 8px;
border: 1px solid rgba(102, 126, 234, 0.1);
}
.security-item {
display: flex;
flex-direction: column;
align-items: center;
gap: 0.25rem;
font-size: 0.75rem;
color: #7f8c8d;
text-align: center;
}
.security-item i {
color: #667eea;
font-size: 1rem;
}
/* Footer */
.login-footer {
text-align: center;
color: #95a5a6;
font-size: 0.8rem;
}
.version-info {
margin-top: 0.5rem;
font-size: 0.75rem;
color: #bdc3c7;
}
/* Responsive */
@media (max-width: 480px) {
.login-container {
padding: 1rem;
}
.login-card {
padding: 2rem;
}
.security-info {
flex-direction: column;
gap: 1rem;
}
.security-item {
flex-direction: row;
justify-content: center;
}
.floating-icon {
display: none;
}
}
/* Dark mode support */
@media (prefers-color-scheme: dark) {
.login-card {
background: rgba(44, 62, 80, 0.95);
color: #ecf0f1;
}
.logo h1 {
color: #ecf0f1;
}
.form-group label {
color: #ecf0f1;
}
.form-group input {
background: rgba(52, 73, 94, 0.8);
border-color: #34495e;
color: #ecf0f1;
}
.form-group input::placeholder {
color: #95a5a6;
}
}

235
static/login.js Normal file
View File

@@ -0,0 +1,235 @@
// AI Server Admin Login JavaScript
class LoginManager {
constructor() {
this.baseUrl = window.location.origin;
this.init();
}
init() {
// Check if already logged in
this.checkExistingAuth();
// Setup form submission
document.getElementById('login-form').addEventListener('submit', (e) => {
e.preventDefault();
this.handleLogin();
});
// Setup enter key handling
document.addEventListener('keypress', (e) => {
if (e.key === 'Enter') {
this.handleLogin();
}
});
// Auto-focus username field
document.getElementById('username').focus();
}
async checkExistingAuth() {
const token = localStorage.getItem('ai_admin_token');
if (token) {
try {
console.log('Checking existing token...');
// Verify token is still valid
const response = await fetch(`${this.baseUrl}/admin/verify-token`, {
headers: {
'Authorization': `Bearer ${token}`
}
});
if (response.ok) {
console.log('Token is valid, redirecting to admin...');
// Token is valid, redirect to admin
window.location.href = '/admin';
return;
} else {
console.log('Token verification failed with status:', response.status);
}
} catch (error) {
console.log('Token verification failed:', error);
}
// Token is invalid, remove it
console.log('Removing invalid token...');
localStorage.removeItem('ai_admin_token');
localStorage.removeItem('ai_admin_user');
}
}
async handleLogin() {
const username = document.getElementById('username').value.trim();
const password = document.getElementById('password').value;
const rememberMe = document.getElementById('remember-me').checked;
// Validation
if (!username || !password) {
this.showError('Please enter both username and password');
return;
}
// Show loading state
this.setLoading(true);
this.hideError();
try {
const response = await fetch(`${this.baseUrl}/admin/login`, {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
username,
password,
remember_me: rememberMe
})
});
const data = await response.json();
if (response.ok && data.success) {
// Store JWT token
localStorage.setItem('ai_admin_token', data.token);
// Store user info
localStorage.setItem('ai_admin_user', JSON.stringify(data.user));
console.log('Token stored:', data.token.substring(0, 20) + '...');
console.log('User stored:', data.user);
// Show success message
this.showSuccess('Login successful! Redirecting...');
// Redirect after short delay
setTimeout(() => {
window.location.href = '/admin';
}, 1000);
} else {
this.showError(data.message || 'Login failed. Please check your credentials.');
}
} catch (error) {
console.error('Login error:', error);
this.showError('Connection error. Please try again.');
} finally {
this.setLoading(false);
}
}
setLoading(loading) {
const btn = document.getElementById('login-btn');
const icon = btn.querySelector('i');
if (loading) {
btn.disabled = true;
btn.classList.add('loading');
icon.className = 'fas fa-spinner';
btn.querySelector('span') ?
btn.querySelector('span').textContent = 'Signing In...' :
btn.innerHTML = '<i class="fas fa-spinner"></i> Signing In...';
} else {
btn.disabled = false;
btn.classList.remove('loading');
icon.className = 'fas fa-sign-in-alt';
btn.innerHTML = '<i class="fas fa-sign-in-alt"></i> Sign In';
}
}
showError(message) {
const errorDiv = document.getElementById('error-message');
const errorText = document.getElementById('error-text');
errorText.textContent = message;
errorDiv.style.display = 'flex';
// Auto-hide after 5 seconds
setTimeout(() => {
this.hideError();
}, 5000);
}
hideError() {
document.getElementById('error-message').style.display = 'none';
}
showSuccess(message) {
// Create success message element if it doesn't exist
let successDiv = document.getElementById('success-message');
if (!successDiv) {
successDiv = document.createElement('div');
successDiv.id = 'success-message';
successDiv.className = 'success-message';
successDiv.innerHTML = `
<i class="fas fa-check-circle"></i>
<span id="success-text">${message}</span>
`;
// Add CSS for success message
const style = document.createElement('style');
style.textContent = `
.success-message {
background: #d5f4e6;
border: 1px solid #27ae60;
border-radius: 8px;
padding: 0.75rem;
margin-bottom: 1rem;
display: flex;
align-items: center;
gap: 0.5rem;
color: #27ae60;
font-size: 0.9rem;
animation: slideDown 0.3s ease-out;
}
@keyframes slideDown {
from {
opacity: 0;
transform: translateY(-10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
`;
document.head.appendChild(style);
// Insert before error message
const errorDiv = document.getElementById('error-message');
errorDiv.parentNode.insertBefore(successDiv, errorDiv);
} else {
document.getElementById('success-text').textContent = message;
successDiv.style.display = 'flex';
}
}
}
// Password toggle functionality
function togglePassword() {
const passwordInput = document.getElementById('password');
const passwordEye = document.getElementById('password-eye');
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
passwordEye.className = 'fas fa-eye-slash';
} else {
passwordInput.type = 'password';
passwordEye.className = 'fas fa-eye';
}
}
// Initialize login manager when page loads
document.addEventListener('DOMContentLoaded', () => {
new LoginManager();
});
// Security: Clear sensitive data on page unload
window.addEventListener('beforeunload', () => {
// Clear password field
const passwordField = document.getElementById('password');
if (passwordField) {
passwordField.value = '';
}
});

248
templates/admin.html Normal file
View File

@@ -0,0 +1,248 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin Dashboard</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/admin.css" rel="stylesheet">
</head>
<body>
<div class="admin-layout">
<!-- Header -->
<header class="admin-header">
<div class="header-content">
<h1><i class="fas fa-robot"></i> AI Server Admin</h1>
<div class="header-info">
<span class="server-status online">
<i class="fas fa-circle"></i> Online
</span>
<span class="current-time" id="current-time"></span>
<div class="user-menu">
<span class="user-info" id="user-info">
<i class="fas fa-user"></i>
<span id="username">Loading...</span>
</span>
<button class="logout-btn" onclick="logout()">
<i class="fas fa-sign-out-alt"></i>
Logout
</button>
</div>
</div>
</div>
</header>
<!-- Main Content -->
<main class="admin-main">
<!-- System Status Dashboard -->
<section class="dashboard-section">
<h2><i class="fas fa-tachometer-alt"></i> System Status</h2>
<div class="status-grid">
<div class="status-card">
<div class="card-header">
<i class="fas fa-server"></i>
<h3>AI Server</h3>
</div>
<div class="card-content">
<div class="status-value" id="server-status">Loading...</div>
<div class="status-detail">Port: {{ server_port }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-brain"></i>
<h3>Ollama</h3>
</div>
<div class="card-content">
<div class="status-value" id="ollama-status">Loading...</div>
<div class="status-detail" id="ollama-host">{{ ollama_host }}</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-microchip"></i>
<h3>Active Model</h3>
</div>
<div class="card-content">
<div class="status-value" id="active-model">Loading...</div>
<div class="status-detail">Base Model</div>
</div>
</div>
<div class="status-card">
<div class="card-header">
<i class="fas fa-chart-line"></i>
<h3>API Calls</h3>
</div>
<div class="card-content">
<div class="status-value" id="api-calls">Loading...</div>
<div class="status-detail">Today</div>
</div>
</div>
</div>
</section>
<!-- Model Management -->
<section class="dashboard-section">
<h2><i class="fas fa-cogs"></i> Model Management</h2>
<div class="models-container">
<div class="models-header">
<button class="btn btn-primary" onclick="refreshModels()">
<i class="fas fa-sync"></i> Refresh
</button>
<button class="btn btn-success" onclick="openModelDownload()">
<i class="fas fa-download"></i> Download Model
</button>
</div>
<div class="models-table">
<table>
<thead>
<tr>
<th>Model Name</th>
<th>Size</th>
<th>Status</th>
<th>Last Used</th>
<th>Actions</th>
</tr>
</thead>
<tbody id="models-tbody">
<tr>
<td colspan="5" class="loading">Loading models...</td>
</tr>
</tbody>
</table>
</div>
</div>
</section>
<!-- System Monitoring (Phase 2) -->
<section class="dashboard-section">
<h2><i class="fas fa-chart-line"></i> System Monitoring</h2>
<div class="monitoring-container">
<div class="monitoring-grid">
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-microchip"></i>
<h3>CPU Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="cpu-progress">
<span class="progress-text" id="cpu-text">--</span>
</div>
<div class="monitor-details">
<span id="cpu-cores">-- cores</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-memory"></i>
<h3>Memory Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="memory-progress">
<span class="progress-text" id="memory-text">--</span>
</div>
<div class="monitor-details">
<span id="memory-details">-- / -- GB</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-hdd"></i>
<h3>Disk Usage</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="disk-progress">
<span class="progress-text" id="disk-text">--</span>
</div>
<div class="monitor-details">
<span id="disk-details">-- / -- GB</span>
</div>
</div>
</div>
<div class="monitor-card">
<div class="card-header">
<i class="fas fa-thermometer-half"></i>
<h3>GPU Status</h3>
</div>
<div class="card-content">
<div class="progress-circle" id="gpu-progress">
<span class="progress-text" id="gpu-text">--</span>
</div>
<div class="monitor-details">
<span id="gpu-details">No GPU detected</span>
</div>
</div>
</div>
</div>
</div>
</section>
<!-- API Key Management -->
<section class="dashboard-section">
<h2><i class="fas fa-key"></i> API Key Management</h2>
<div class="api-keys-container">
<div class="api-keys-header">
<button class="btn btn-success" onclick="generateApiKey()">
<i class="fas fa-plus"></i> Generate New Key
</button>
</div>
<div class="api-keys-list" id="api-keys-list">
<div class="loading">Loading API keys...</div>
</div>
</div>
</section>
</main>
</div>
<!-- Model Download Modal -->
<div id="model-download-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-download"></i> Download Model</h3>
<button class="close-btn" onclick="closeModal('model-download-modal')">&times;</button>
</div>
<div class="modal-body">
<div id="available-models-list">
<div class="loading">Loading available models...</div>
</div>
</div>
</div>
</div>
<!-- Model Delete Confirmation Modal -->
<div id="model-delete-modal" class="modal">
<div class="modal-content">
<div class="modal-header">
<h3><i class="fas fa-trash"></i> Delete Model</h3>
<button class="close-btn" onclick="closeModal('model-delete-modal')">&times;</button>
</div>
<div class="modal-body">
<p>Are you sure you want to delete this model?</p>
<div class="model-delete-info">
<strong id="delete-model-name">Model Name</strong>
<p>This action cannot be undone.</p>
</div>
<div class="modal-actions">
<button class="btn btn-danger" id="confirm-delete-btn">
<i class="fas fa-trash"></i> Delete
</button>
<button class="btn btn-secondary" onclick="closeModal('model-delete-modal')">
Cancel
</button>
</div>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/admin.js"></script>
</body>
</html>

39
templates/base.html Normal file
View File

@@ -0,0 +1,39 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>{% block title %}AI 문서 처리 서버{% endblock %}</title>
<link href="/static/css/style.css" rel="stylesheet">
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
</head>
<body>
<nav class="navbar">
<div class="nav-container">
<a href="/" class="nav-logo">
<i class="fas fa-robot"></i> AI 문서 서버
</a>
<ul class="nav-menu">
<li><a href="/" class="nav-link">대시보드</a></li>
<li><a href="/upload" class="nav-link">업로드</a></li>
<li><a href="/documents" class="nav-link">문서관리</a></li>
<li><a href="/chat" class="nav-link">AI 챗봇</a></li>
<li><a href="/docs" class="nav-link" target="_blank">API 문서</a></li>
</ul>
</div>
</nav>
<main class="main-content">
{% block content %}{% endblock %}
</main>
<footer class="footer">
<div class="footer-content">
<p>&copy; 2025 AI 문서 처리 서버 | Mac Mini M4 Pro 64GB</p>
</div>
</footer>
<script src="/static/js/main.js"></script>
{% block scripts %}{% endblock %}
</body>
</html>

631
templates/chat.html Normal file
View File

@@ -0,0 +1,631 @@
{% extends "base.html" %}
{% block title %}AI 챗봇 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="grid grid-2">
<!-- 채팅 영역 -->
<div class="card chat-card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-robot"></i> AI 문서 챗봇
</h2>
<div class="chat-status">
<span class="status status-success">
<i class="fas fa-circle"></i> 온라인
</span>
</div>
</div>
<div class="chat-container">
<div class="chat-messages" id="chatMessages">
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">{{ current_time }}</div>
</div>
</div>
<div class="chat-input-area">
<div class="input-group">
<input type="text" class="form-control chat-input" id="messageInput"
placeholder="메시지를 입력하세요..." maxlength="1000">
<button type="button" class="btn btn-primary" id="sendBtn" onclick="sendMessage()">
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="input-options">
<label class="checkbox-label">
<input type="checkbox" id="useRAG" checked>
<i class="fas fa-search"></i> 문서 검색 사용
</label>
<label class="checkbox-label">
<input type="checkbox" id="forceBoost">
<i class="fas fa-rocket"></i> 고성능 모델 사용
</label>
</div>
</div>
</div>
</div>
<!-- 설정 및 정보 패널 -->
<div class="settings-panel">
<!-- 빠른 질문 -->
<div class="card">
<h3><i class="fas fa-lightning-bolt"></i> 빠른 질문</h3>
<div class="quick-questions">
<button onclick="askQuickQuestion('전체 문서를 요약해주세요')" class="btn btn-sm btn-outline">
문서 요약
</button>
<button onclick="askQuickQuestion('주요 키워드를 추출해주세요')" class="btn btn-sm btn-outline">
키워드 추출
</button>
<button onclick="askQuickQuestion('이 문서의 핵심 내용은 무엇인가요?')" class="btn btn-sm btn-outline">
핵심 내용
</button>
<button onclick="askQuickQuestion('관련된 다른 문서가 있나요?')" class="btn btn-sm btn-outline">
관련 문서
</button>
</div>
</div>
<!-- 검색 결과 -->
<div class="card">
<h3><i class="fas fa-search"></i> 관련 문서 검색</h3>
<input type="text" class="form-control mb-2" id="searchInput"
placeholder="문서 내용 검색..." onkeyup="searchDocuments(event)">
<div class="search-results" id="searchResults">
<p class="text-muted">검색어를 입력하세요</p>
</div>
</div>
<!-- 모델 정보 -->
<div class="card">
<h3><i class="fas fa-cog"></i> 모델 설정</h3>
<div class="model-info">
<div class="info-item">
<strong>기본 모델:</strong> {{ status.base_model }}
</div>
<div class="info-item">
<strong>부스트 모델:</strong> {{ status.boost_model }}
</div>
<div class="info-item">
<strong>임베딩 모델:</strong> {{ status.embedding_model }}
</div>
<div class="info-item">
<strong>인덱스 문서:</strong> {{ status.index_loaded }}개
</div>
</div>
</div>
<!-- 채팅 기록 관리 -->
<div class="card">
<h3><i class="fas fa-history"></i> 대화 관리</h3>
<div class="chat-controls">
<button onclick="clearChat()" class="btn btn-sm btn-secondary w-100 mb-2">
<i class="fas fa-trash"></i> 대화 기록 삭제
</button>
<button onclick="exportChat()" class="btn btn-sm btn-outline w-100">
<i class="fas fa-download"></i> 대화 내용 내보내기
</button>
</div>
</div>
</div>
</div>
<style>
.chat-card {
height: 600px;
display: flex;
flex-direction: column;
}
.chat-card .card-header {
display: flex;
justify-content: space-between;
align-items: center;
flex-shrink: 0;
}
.chat-status {
display: flex;
align-items: center;
gap: 0.5rem;
}
.chat-container {
flex: 1;
display: flex;
flex-direction: column;
min-height: 0;
}
.chat-messages {
flex: 1;
overflow-y: auto;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
margin-bottom: 1rem;
}
.message {
margin-bottom: 1rem;
display: flex;
flex-direction: column;
}
.message.user {
align-items: flex-end;
}
.message.assistant {
align-items: flex-start;
}
.message-content {
display: flex;
align-items: flex-start;
gap: 0.5rem;
max-width: 80%;
}
.message.user .message-content {
flex-direction: row-reverse;
}
.message-icon {
font-size: 1.2rem;
padding: 0.5rem;
border-radius: 50%;
min-width: 40px;
min-height: 40px;
display: flex;
align-items: center;
justify-content: center;
}
.message.user .message-icon {
background-color: #667eea;
color: white;
}
.message.assistant .message-icon {
background-color: #28a745;
color: white;
}
.message-text {
background: white;
padding: 0.75rem 1rem;
border-radius: 10px;
border: 1px solid #e9ecef;
line-height: 1.5;
}
.message.user .message-text {
background: #667eea;
color: white;
border-color: #667eea;
}
.message-time {
font-size: 0.75rem;
color: #6c757d;
margin-top: 0.25rem;
margin-left: 3rem;
}
.message.user .message-time {
margin-left: 0;
margin-right: 3rem;
text-align: right;
}
.chat-input-area {
flex-shrink: 0;
}
.input-group {
display: flex;
gap: 0.5rem;
}
.input-group .form-control {
flex: 1;
}
.input-options {
display: flex;
gap: 1rem;
margin-top: 0.5rem;
font-size: 0.9rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.25rem;
cursor: pointer;
color: #6c757d;
}
.settings-panel {
display: flex;
flex-direction: column;
gap: 1rem;
}
.quick-questions {
display: flex;
flex-direction: column;
gap: 0.5rem;
}
.btn-outline {
background: transparent;
border: 1px solid #667eea;
color: #667eea;
text-align: left;
padding: 0.5rem;
}
.btn-outline:hover {
background: #667eea;
color: white;
}
.search-results {
max-height: 200px;
overflow-y: auto;
}
.search-result-item {
padding: 0.5rem;
border: 1px solid #e9ecef;
border-radius: 5px;
margin-bottom: 0.5rem;
cursor: pointer;
transition: background-color 0.2s;
}
.search-result-item:hover {
background-color: #f8f9fa;
}
.search-result-title {
font-weight: 500;
margin-bottom: 0.25rem;
}
.search-result-snippet {
font-size: 0.85rem;
color: #6c757d;
line-height: 1.4;
}
.info-item {
margin-bottom: 0.5rem;
padding-bottom: 0.5rem;
border-bottom: 1px solid #f8f9fa;
}
.info-item:last-child {
border-bottom: none;
margin-bottom: 0;
}
.chat-controls .btn {
justify-content: center;
display: flex;
align-items: center;
gap: 0.5rem;
}
.w-100 { width: 100%; }
.mb-2 { margin-bottom: 0.5rem; }
.text-muted { color: #6c757d; }
/* 로딩 애니메이션 */
.typing-indicator {
display: flex;
align-items: center;
gap: 0.25rem;
padding: 0.75rem 1rem;
background: white;
border-radius: 10px;
border: 1px solid #e9ecef;
}
.typing-dot {
width: 8px;
height: 8px;
background-color: #6c757d;
border-radius: 50%;
animation: typing 1.5s infinite;
}
.typing-dot:nth-child(2) { animation-delay: 0.2s; }
.typing-dot:nth-child(3) { animation-delay: 0.4s; }
@keyframes typing {
0%, 60%, 100% { opacity: 0.3; }
30% { opacity: 1; }
}
</style>
{% endblock %}
{% block scripts %}
<script>
let chatHistory = [];
let isWaitingForResponse = false;
// DOM 요소들
const chatMessages = document.getElementById('chatMessages');
const messageInput = document.getElementById('messageInput');
const sendBtn = document.getElementById('sendBtn');
const searchInput = document.getElementById('searchInput');
const searchResults = document.getElementById('searchResults');
const useRAGCheckbox = document.getElementById('useRAG');
const forceBoostCheckbox = document.getElementById('forceBoost');
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
messageInput.addEventListener('keypress', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
// URL 파라미터 확인 (특정 문서로 대화하기)
const urlParams = new URLSearchParams(window.location.search);
const doc = urlParams.get('doc');
if (doc) {
addMessage('assistant', `"${doc}" 문서에 대해 질문해주세요. 어떤 내용이 궁금하신가요?`);
}
});
// 메시지 전송
async function sendMessage() {
const message = messageInput.value.trim();
if (!message || isWaitingForResponse) return;
// 사용자 메시지 추가
addMessage('user', message);
messageInput.value = '';
isWaitingForResponse = true;
sendBtn.disabled = true;
// 타이핑 인디케이터 표시
const typingId = addTypingIndicator();
try {
const response = await fetch('/chat', {
method: 'POST',
headers: {
'Content-Type': 'application/json',
'X-API-Key': '{{ api_key }}'
},
body: JSON.stringify({
messages: chatHistory,
use_rag: useRAGCheckbox.checked,
force_boost: forceBoostCheckbox.checked,
top_k: 5
})
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
const data = await response.json();
// 타이핑 인디케이터 제거
removeTypingIndicator(typingId);
// AI 응답 추가
addMessage('assistant', data.response.message?.content || data.response.response || '응답을 받지 못했습니다.');
} catch (error) {
console.error('Chat error:', error);
removeTypingIndicator(typingId);
addMessage('assistant', '죄송합니다. 응답 중 오류가 발생했습니다. 다시 시도해주세요.');
} finally {
isWaitingForResponse = false;
sendBtn.disabled = false;
}
}
// 메시지 추가
function addMessage(sender, content) {
const messageDiv = document.createElement('div');
messageDiv.className = `message ${sender}`;
const now = new Date().toLocaleTimeString('ko-KR', {
hour: '2-digit',
minute: '2-digit'
});
const icon = sender === 'user' ? 'fas fa-user' : 'fas fa-robot';
messageDiv.innerHTML = `
<div class="message-content">
<i class="${icon} message-icon"></i>
<div class="message-text">${content}</div>
</div>
<div class="message-time">${now}</div>
`;
chatMessages.appendChild(messageDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
// 채팅 히스토리에 추가
chatHistory.push({
role: sender === 'user' ? 'user' : 'assistant',
content: content
});
}
// 타이핑 인디케이터 추가
function addTypingIndicator() {
const typingDiv = document.createElement('div');
typingDiv.className = 'message assistant';
typingDiv.id = 'typing-' + Date.now();
typingDiv.innerHTML = `
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="typing-indicator">
<div class="typing-dot"></div>
<div class="typing-dot"></div>
<div class="typing-dot"></div>
</div>
</div>
`;
chatMessages.appendChild(typingDiv);
chatMessages.scrollTop = chatMessages.scrollHeight;
return typingDiv.id;
}
// 타이핑 인디케이터 제거
function removeTypingIndicator(typingId) {
const typingDiv = document.getElementById(typingId);
if (typingDiv) {
typingDiv.remove();
}
}
// 빠른 질문
function askQuickQuestion(question) {
messageInput.value = question;
sendMessage();
}
// 문서 검색
async function searchDocuments(event) {
if (event.key !== 'Enter') return;
const query = searchInput.value.trim();
if (!query) {
searchResults.innerHTML = '<p class="text-muted">검색어를 입력하세요</p>';
return;
}
try {
const response = await fetch('/search', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
query: query,
top_k: 5
})
});
const data = await response.json();
displaySearchResults(data.results);
} catch (error) {
console.error('Search error:', error);
searchResults.innerHTML = '<p class="text-muted">검색 중 오류가 발생했습니다</p>';
}
}
// 검색 결과 표시
function displaySearchResults(results) {
if (!results || results.length === 0) {
searchResults.innerHTML = '<p class="text-muted">검색 결과가 없습니다</p>';
return;
}
const resultsHtml = results.map(result => `
<div class="search-result-item" onclick="useSearchResult('${result.text.replace(/'/g, "\\'")}')">
<div class="search-result-title">문서 ID: ${result.id}</div>
<div class="search-result-snippet">${result.text.substring(0, 100)}...</div>
<small class="text-muted">유사도: ${(result.score * 100).toFixed(1)}%</small>
</div>
`).join('');
searchResults.innerHTML = resultsHtml;
}
// 검색 결과 활용
function useSearchResult(text) {
messageInput.value = `다음 내용에 대해 설명해주세요: "${text.substring(0, 50)}..."`;
}
// 대화 기록 삭제
function clearChat() {
if (!confirm('정말로 대화 기록을 모두 삭제하시겠습니까?')) return;
chatHistory = [];
chatMessages.innerHTML = `
<div class="message assistant">
<div class="message-content">
<i class="fas fa-robot message-icon"></i>
<div class="message-text">
안녕하세요! 저는 문서 기반 AI 어시스턴트입니다.
업로드된 문서들에 대해 질문하시면 답변해드리겠습니다.
</div>
</div>
<div class="message-time">${new Date().toLocaleTimeString('ko-KR', { hour: '2-digit', minute: '2-digit' })}</div>
</div>
`;
}
// 대화 내용 내보내기
function exportChat() {
if (chatHistory.length === 0) {
showToast('내보낼 대화 내용이 없습니다.', 'info');
return;
}
const chatText = chatHistory.map(msg =>
`${msg.role === 'user' ? '사용자' : 'AI'}: ${msg.content}`
).join('\n\n');
const blob = new Blob([chatText], { type: 'text/plain' });
const url = URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = `chat-export-${new Date().toISOString().slice(0, 10)}.txt`;
a.click();
URL.revokeObjectURL(url);
}
// 토스트 알림
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

348
templates/documents.html Normal file
View File

@@ -0,0 +1,348 @@
{% extends "base.html" %}
{% block title %}문서 관리 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-folder-open"></i> 문서 관리
</h1>
<p class="card-subtitle">처리된 문서들을 확인하고 관리하세요</p>
</div>
<!-- 통계 요약 -->
<div class="grid grid-3 mb-4">
<div class="card">
<h3><i class="fas fa-file-alt"></i> 총 문서 수</h3>
<div class="stat-number">{{ documents|length }}</div>
</div>
<div class="card">
<h3><i class="fas fa-database"></i> 총 용량</h3>
<div class="stat-number" id="totalSize">계산 중...</div>
</div>
<div class="card">
<h3><i class="fas fa-calendar"></i> 최근 업로드</h3>
<div class="stat-number">
{% if documents %}
{{ documents[0].created.split(' ')[0] }}
{% else %}
없음
{% endif %}
</div>
</div>
</div>
<!-- 검색 및 필터 -->
<div class="search-controls mb-4">
<div class="grid grid-2">
<div>
<input type="text" class="form-control" id="searchInput"
placeholder="파일명으로 검색..." onkeyup="filterDocuments()">
</div>
<div>
<select class="form-control" id="sortSelect" onchange="sortDocuments()">
<option value="name">이름순</option>
<option value="date" selected>날짜순 (최신)</option>
<option value="size">크기순</option>
</select>
</div>
</div>
</div>
<!-- 문서 목록 -->
{% if documents %}
<div class="table-responsive">
<table class="table" id="documentsTable">
<thead>
<tr>
<th><i class="fas fa-file"></i> 파일명</th>
<th><i class="fas fa-calendar"></i> 생성일시</th>
<th><i class="fas fa-weight"></i> 크기</th>
<th><i class="fas fa-cogs"></i> 액션</th>
</tr>
</thead>
<tbody>
{% for doc in documents %}
<tr class="document-row" data-name="{{ doc.name|lower }}" data-date="{{ doc.created }}" data-size="{{ doc.size }}">
<td>
<div class="file-info">
<i class="fas fa-file-alt text-primary"></i>
<strong>{{ doc.name }}</strong>
<br>
<small class="text-muted">{{ formatFileSize(doc.size) }}</small>
</div>
</td>
<td>{{ doc.created }}</td>
<td>
<span class="file-size" data-bytes="{{ doc.size }}">{{ formatFileSize(doc.size) }}</span>
</td>
<td>
<div class="action-buttons">
<a href="/html/{{ doc.name }}" class="btn btn-sm btn-primary" target="_blank" title="HTML 보기">
<i class="fas fa-eye"></i>
</a>
<button onclick="openChatWithDoc('{{ doc.name }}')" class="btn btn-sm btn-success" title="이 문서로 대화하기">
<i class="fas fa-comments"></i>
</button>
<button onclick="downloadDoc('{{ doc.name }}')" class="btn btn-sm btn-secondary" title="다운로드">
<i class="fas fa-download"></i>
</button>
<button onclick="deleteDoc('{{ doc.name }}')" class="btn btn-sm btn-danger" title="삭제">
<i class="fas fa-trash"></i>
</button>
</div>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="empty-state">
<i class="fas fa-inbox fa-4x text-muted mb-3"></i>
<h3>처리된 문서가 없습니다</h3>
<p class="text-muted mb-4">파일을 업로드하여 AI 처리를 시작하세요.</p>
<a href="/upload" class="btn btn-primary btn-lg">
<i class="fas fa-cloud-upload-alt"></i> 첫 문서 업로드하기
</a>
</div>
{% endif %}
</div>
<!-- 파일 미리보기 모달 -->
<div class="modal" id="previewModal" style="display: none;">
<div class="modal-content">
<div class="modal-header">
<h3 id="previewTitle">문서 미리보기</h3>
<button onclick="closePreview()" class="btn btn-sm btn-secondary">
<i class="fas fa-times"></i> 닫기
</button>
</div>
<div class="modal-body">
<iframe id="previewFrame" style="width: 100%; height: 500px; border: none;"></iframe>
</div>
</div>
</div>
<style>
.search-controls {
background-color: #f8f9fa;
padding: 1.5rem;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-info {
display: flex;
align-items: center;
gap: 0.5rem;
}
.file-info i {
font-size: 1.2rem;
}
.action-buttons {
display: flex;
gap: 0.25rem;
flex-wrap: wrap;
}
.btn-sm {
padding: 0.25rem 0.5rem;
font-size: 0.875rem;
}
.empty-state {
text-align: center;
padding: 4rem 2rem;
}
.modal {
position: fixed;
top: 0;
left: 0;
width: 100%;
height: 100%;
background-color: rgba(0, 0, 0, 0.5);
z-index: 1000;
display: flex;
align-items: center;
justify-content: center;
}
.modal-content {
background: white;
border-radius: 10px;
width: 90%;
max-width: 900px;
max-height: 90%;
overflow: hidden;
}
.modal-header {
padding: 1rem 1.5rem;
border-bottom: 1px solid #e9ecef;
display: flex;
justify-content: space-between;
align-items: center;
}
.modal-body {
padding: 1.5rem;
}
.stat-number {
font-size: 2rem;
font-weight: bold;
color: #667eea;
margin-top: 0.5rem;
}
.mb-4 { margin-bottom: 2rem; }
.text-primary { color: #667eea; }
</style>
{% endblock %}
{% block scripts %}
<script>
let allDocuments = [];
// 페이지 로드 시 초기화
document.addEventListener('DOMContentLoaded', function() {
// 문서 데이터 수집
const rows = document.querySelectorAll('.document-row');
allDocuments = Array.from(rows).map(row => ({
element: row,
name: row.dataset.name,
date: new Date(row.dataset.date),
size: parseInt(row.dataset.size)
}));
// 총 용량 계산
const totalBytes = allDocuments.reduce((sum, doc) => sum + doc.size, 0);
document.getElementById('totalSize').textContent = formatFileSize(totalBytes);
// 기본 정렬 (날짜순)
sortDocuments();
});
// 파일 크기 포맷팅
function formatFileSize(bytes) {
if (bytes === 0) return '0 Bytes';
const k = 1024;
const sizes = ['Bytes', 'KB', 'MB', 'GB'];
const i = Math.floor(Math.log(bytes) / Math.log(k));
return parseFloat((bytes / Math.pow(k, i)).toFixed(2)) + ' ' + sizes[i];
}
// 문서 검색 필터링
function filterDocuments() {
const searchTerm = document.getElementById('searchInput').value.toLowerCase();
const rows = document.querySelectorAll('.document-row');
rows.forEach(row => {
const fileName = row.dataset.name;
if (fileName.includes(searchTerm)) {
row.style.display = '';
} else {
row.style.display = 'none';
}
});
}
// 문서 정렬
function sortDocuments() {
const sortBy = document.getElementById('sortSelect').value;
const tbody = document.querySelector('#documentsTable tbody');
allDocuments.sort((a, b) => {
switch (sortBy) {
case 'name':
return a.name.localeCompare(b.name);
case 'date':
return b.date - a.date; // 최신순
case 'size':
return b.size - a.size; // 큰 것부터
default:
return 0;
}
});
// DOM 재정렬
allDocuments.forEach(doc => {
tbody.appendChild(doc.element);
});
}
// 문서로 채팅 시작
function openChatWithDoc(fileName) {
const docName = fileName.replace('.html', '');
window.location.href = `/chat?doc=${encodeURIComponent(docName)}`;
}
// 문서 다운로드
function downloadDoc(fileName) {
const link = document.createElement('a');
link.href = `/html/${fileName}`;
link.download = fileName;
link.click();
}
// 문서 삭제
async function deleteDoc(fileName) {
if (!confirm(`정말로 "${fileName}" 문서를 삭제하시겠습니까?`)) {
return;
}
try {
// 실제로는 DELETE API를 구현해야 함
showToast('삭제 기능은 아직 구현되지 않았습니다.', 'info');
// TODO: DELETE /api/documents/{fileName} 구현
} catch (error) {
console.error('Delete error:', error);
showToast('삭제 중 오류가 발생했습니다.', 'danger');
}
}
// 미리보기 열기
function openPreview(fileName) {
document.getElementById('previewTitle').textContent = `${fileName} 미리보기`;
document.getElementById('previewFrame').src = `/html/${fileName}`;
document.getElementById('previewModal').style.display = 'flex';
}
// 미리보기 닫기
function closePreview() {
document.getElementById('previewModal').style.display = 'none';
document.getElementById('previewFrame').src = '';
}
// 토스트 알림 (main.js에서 가져옴)
function showToast(message, type = 'info') {
const toast = document.createElement('div');
toast.className = `alert alert-${type}`;
toast.textContent = message;
toast.style.cssText = `
position: fixed;
top: 20px;
right: 20px;
z-index: 1001;
min-width: 300px;
animation: slideIn 0.3s ease;
`;
document.body.appendChild(toast);
setTimeout(() => {
toast.style.animation = 'slideOut 0.3s ease';
setTimeout(() => {
if (document.body.contains(toast)) {
document.body.removeChild(toast);
}
}, 300);
}, 3000);
}
</script>
{% endblock %}

229
templates/index.html Normal file
View File

@@ -0,0 +1,229 @@
{% extends "base.html" %}
{% block title %}AI 문서 처리 서버 - 대시보드{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-tachometer-alt"></i> 대시보드
</h1>
<p class="card-subtitle">AI 문서 처리 서버 현황을 확인하세요</p>
</div>
<!-- 시스템 상태 -->
<div class="grid grid-3">
<div class="card">
<h3><i class="fas fa-server"></i> 서버 상태</h3>
<div class="status status-success">
<i class="fas fa-check-circle"></i> 정상 운영
</div>
<p class="mt-2">
<strong>모델:</strong> {{ status.base_model }}<br>
<strong>임베딩:</strong> {{ status.embedding_model }}<br>
<strong>인덱스:</strong> {{ status.index_loaded }}개 문서
</p>
</div>
<div class="card">
<h3><i class="fas fa-upload"></i> 빠른 업로드</h3>
<div class="drop-zone-mini" onclick="window.location.href='/upload'">
<i class="fas fa-cloud-upload-alt"></i>
<p>파일을 업로드하려면 클릭하세요</p>
</div>
</div>
<div class="card">
<h3><i class="fas fa-search"></i> 빠른 검색</h3>
<form onsubmit="quickSearch(event)">
<input type="text" class="form-control" placeholder="문서 내용 검색..." id="quickSearchInput">
<button type="submit" class="btn btn-primary mt-2 w-100">
<i class="fas fa-search"></i> 검색
</button>
</form>
</div>
</div>
</div>
<!-- 최근 문서 -->
<div class="card">
<div class="card-header">
<h2 class="card-title">
<i class="fas fa-file-alt"></i> 최근 처리된 문서
</h2>
<a href="/documents" class="btn btn-secondary">모든 문서 보기</a>
</div>
{% if recent_documents %}
<div class="table-responsive">
<table class="table">
<thead>
<tr>
<th>문서 ID</th>
<th>처리 시간</th>
<th>청크 수</th>
<th>상태</th>
<th>액션</th>
</tr>
</thead>
<tbody>
{% for doc in recent_documents %}
<tr>
<td>{{ doc.id }}</td>
<td>{{ doc.created_at }}</td>
<td>{{ doc.chunks }}</td>
<td>
<span class="status status-success">
<i class="fas fa-check"></i> 완료
</span>
</td>
<td>
<a href="/html/{{ doc.html_file }}" class="btn btn-sm btn-primary" target="_blank">
<i class="fas fa-eye"></i> 보기
</a>
</td>
</tr>
{% endfor %}
</tbody>
</table>
</div>
{% else %}
<div class="text-center py-4">
<i class="fas fa-inbox fa-3x text-muted mb-3"></i>
<p class="text-muted">아직 처리된 문서가 없습니다.</p>
<a href="/upload" class="btn btn-primary">첫 문서 업로드하기</a>
</div>
{% endif %}
</div>
<!-- 통계 -->
<div class="grid grid-2">
<div class="card">
<h3><i class="fas fa-chart-bar"></i> 처리 통계</h3>
<div class="stats">
<div class="stat-item">
<span class="stat-number">{{ stats.total_documents }}</span>
<span class="stat-label">총 문서 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.total_chunks }}</span>
<span class="stat-label">총 청크 수</span>
</div>
<div class="stat-item">
<span class="stat-number">{{ stats.today_processed }}</span>
<span class="stat-label">오늘 처리</span>
</div>
</div>
</div>
<div class="card">
<h3><i class="fas fa-robot"></i> AI 챗봇</h3>
<p>문서 기반 질의응답을 시작하세요.</p>
<div class="quick-chat">
<input type="text" class="form-control mb-2" placeholder="질문을 입력하세요..." id="quickChatInput">
<button onclick="quickChat()" class="btn btn-success w-100">
<i class="fas fa-paper-plane"></i> 질문하기
</button>
</div>
<a href="/chat" class="btn btn-secondary w-100 mt-2">전체 채팅 화면으로</a>
</div>
</div>
<style>
.drop-zone-mini {
border: 2px dashed #ced4da;
border-radius: 10px;
padding: 2rem 1rem;
text-align: center;
cursor: pointer;
transition: all 0.3s;
background-color: #f8f9fa;
}
.drop-zone-mini:hover {
border-color: #667eea;
background-color: rgba(102, 126, 234, 0.05);
}
.drop-zone-mini i {
font-size: 2rem;
color: #6c757d;
margin-bottom: 0.5rem;
display: block;
}
.stats {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(100px, 1fr));
gap: 1rem;
margin-top: 1rem;
}
.stat-item {
text-align: center;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
}
.stat-number {
display: block;
font-size: 2rem;
font-weight: bold;
color: #667eea;
}
.stat-label {
font-size: 0.85rem;
color: #6c757d;
}
.mt-2 { margin-top: 0.5rem; }
.mb-2 { margin-bottom: 0.5rem; }
.mb-3 { margin-bottom: 1rem; }
.py-4 { padding: 2rem 0; }
.w-100 { width: 100%; }
.text-center { text-align: center; }
.text-muted { color: #6c757d; }
.table-responsive { overflow-x: auto; }
.btn-sm { padding: 0.25rem 0.5rem; font-size: 0.875rem; }
</style>
{% endblock %}
{% block scripts %}
<script>
function quickSearch(event) {
event.preventDefault();
const query = document.getElementById('quickSearchInput').value;
if (query.trim()) {
window.location.href = `/search?q=${encodeURIComponent(query)}`;
}
}
function quickChat() {
const question = document.getElementById('quickChatInput').value;
if (question.trim()) {
// 간단한 챗봇 미리보기 (실제 구현은 /chat 페이지에서)
alert('채팅 기능은 "AI 챗봇" 페이지에서 이용하실 수 있습니다.');
window.location.href = '/chat';
}
}
// 페이지 로드 시 서버 상태 업데이트
document.addEventListener('DOMContentLoaded', function() {
// 실시간 상태 업데이트 (선택사항)
updateStatus();
});
async function updateStatus() {
try {
const response = await fetch('/health');
const data = await response.json();
// 상태 업데이트 로직
console.log('서버 상태:', data);
} catch (error) {
console.error('상태 확인 실패:', error);
}
}
</script>
{% endblock %}

124
templates/login.html Normal file
View File

@@ -0,0 +1,124 @@
<!DOCTYPE html>
<html lang="ko">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI Server Admin - Login</title>
<link href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" rel="stylesheet">
<link href="/static/login.css" rel="stylesheet">
</head>
<body>
<div class="login-container">
<div class="login-card">
<!-- Header -->
<div class="login-header">
<div class="logo">
<i class="fas fa-robot"></i>
<h1>AI Server Admin</h1>
</div>
<p class="subtitle">Secure Access Portal</p>
</div>
<!-- Login Form -->
<form id="login-form" class="login-form">
<div class="form-group">
<label for="username">
<i class="fas fa-user"></i>
Username
</label>
<input
type="text"
id="username"
name="username"
required
autocomplete="username"
placeholder="Enter your username"
>
</div>
<div class="form-group">
<label for="password">
<i class="fas fa-lock"></i>
Password
</label>
<div class="password-input">
<input
type="password"
id="password"
name="password"
required
autocomplete="current-password"
placeholder="Enter your password"
>
<button type="button" class="password-toggle" onclick="togglePassword()">
<i class="fas fa-eye" id="password-eye"></i>
</button>
</div>
</div>
<div class="form-group">
<label class="checkbox-label">
<input type="checkbox" id="remember-me" name="remember">
<span class="checkmark"></span>
Remember me for 30 days
</label>
</div>
<button type="submit" class="login-btn" id="login-btn">
<i class="fas fa-sign-in-alt"></i>
Sign In
</button>
</form>
<!-- Error Message -->
<div id="error-message" class="error-message" style="display: none;">
<i class="fas fa-exclamation-triangle"></i>
<span id="error-text">Invalid credentials</span>
</div>
<!-- Security Info -->
<div class="security-info">
<div class="security-item">
<i class="fas fa-shield-alt"></i>
<span>Secure Connection</span>
</div>
<div class="security-item">
<i class="fas fa-clock"></i>
<span>Session Timeout: 24h</span>
</div>
<div class="security-item">
<i class="fas fa-key"></i>
<span>JWT Authentication</span>
</div>
</div>
<!-- Footer -->
<div class="login-footer">
<p>&copy; 2025 AI Server Admin. All rights reserved.</p>
<div class="version-info">
<span>Version 2.0 (Phase 3)</span>
</div>
</div>
</div>
<!-- Background Animation -->
<div class="bg-animation">
<div class="floating-icon" style="--delay: 0s;">
<i class="fas fa-robot"></i>
</div>
<div class="floating-icon" style="--delay: 2s;">
<i class="fas fa-brain"></i>
</div>
<div class="floating-icon" style="--delay: 4s;">
<i class="fas fa-microchip"></i>
</div>
<div class="floating-icon" style="--delay: 6s;">
<i class="fas fa-network-wired"></i>
</div>
</div>
</div>
<!-- Scripts -->
<script src="/static/login.js"></script>
</body>
</html>

395
templates/upload.html Normal file
View File

@@ -0,0 +1,395 @@
{% extends "base.html" %}
{% block title %}파일 업로드 - AI 문서 처리 서버{% endblock %}
{% block content %}
<div class="card">
<div class="card-header">
<h1 class="card-title">
<i class="fas fa-cloud-upload-alt"></i> 파일 업로드
</h1>
<p class="card-subtitle">PDF 또는 TXT 파일을 업로드하여 AI 처리를 시작하세요</p>
</div>
<form id="uploadForm" enctype="multipart/form-data">
<div class="grid grid-2">
<!-- 파일 선택 영역 -->
<div>
<h3><i class="fas fa-file"></i> 파일 선택</h3>
<div class="drop-zone" id="dropZone">
<i class="fas fa-cloud-upload-alt"></i>
<h4>파일을 드래그하거나 클릭하여 선택</h4>
<p>PDF, TXT 파일 지원 (최대 200MB)</p>
<input type="file" id="fileInput" name="file" accept=".pdf,.txt" style="display: none;">
<button type="button" class="btn btn-primary mt-2" onclick="document.getElementById('fileInput').click()">
<i class="fas fa-folder-open"></i> 파일 선택
</button>
</div>
<div id="fileInfo" class="file-info" style="display: none;">
<h4><i class="fas fa-file-check"></i> 선택된 파일</h4>
<div id="fileDetails"></div>
<button type="button" class="btn btn-secondary btn-sm mt-2" onclick="clearFile()">
<i class="fas fa-times"></i> 다른 파일 선택
</button>
</div>
</div>
<!-- 옵션 설정 -->
<div>
<h3><i class="fas fa-cogs"></i> 처리 옵션</h3>
<div class="form-group">
<label class="form-label" for="docId">문서 ID</label>
<input type="text" class="form-control" id="docId" name="doc_id"
placeholder="예: report-2025-001" required>
<small class="text-muted">고유한 문서 식별자를 입력하세요</small>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="generateHtml" name="generate_html" checked>
<i class="fas fa-code"></i> HTML 파일 생성
</label>
<small class="text-muted">읽기 쉬운 HTML 형태로 변환합니다</small>
</div>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="translate" name="translate">
<i class="fas fa-language"></i> 한국어로 번역
</label>
<small class="text-muted">영어 문서를 한국어로 번역합니다</small>
</div>
</div>
<div class="form-group" id="targetLangGroup" style="display: none;">
<label class="form-label" for="targetLanguage">번역 언어</label>
<select class="form-control" id="targetLanguage" name="target_language">
<option value="ko">한국어</option>
<option value="en">영어</option>
<option value="ja">일본어</option>
<option value="zh">중국어</option>
</select>
</div>
<div class="form-group">
<div class="checkbox-group">
<label class="checkbox-label">
<input type="checkbox" id="summarize" name="summarize">
<i class="fas fa-compress-alt"></i> 요약 생성
</label>
<small class="text-muted">긴 문서를 요약하여 처리합니다</small>
</div>
</div>
<div class="form-group" id="summaryGroup" style="display: none;">
<label class="form-label" for="summarySentences">요약 문장 수</label>
<input type="number" class="form-control" id="summarySentences"
name="summary_sentences" value="5" min="3" max="10">
</div>
</div>
</div>
<div class="upload-actions">
<button type="submit" class="btn btn-success btn-lg" id="uploadBtn" disabled>
<i class="fas fa-rocket"></i> 업로드 및 처리 시작
</button>
<button type="button" class="btn btn-secondary" onclick="resetForm()">
<i class="fas fa-redo"></i> 초기화
</button>
</div>
</form>
<!-- 진행 상황 -->
<div id="progressSection" style="display: none;">
<h3><i class="fas fa-tasks"></i> 처리 진행 상황</h3>
<div class="progress">
<div class="progress-bar" id="progressBar" style="width: 0%"></div>
</div>
<div id="progressText">준비 중...</div>
</div>
<!-- 결과 -->
<div id="resultSection" style="display: none;">
<h3><i class="fas fa-check-circle"></i> 처리 완료</h3>
<div id="resultContent"></div>
</div>
</div>
<style>
.checkbox-group {
margin-bottom: 0.5rem;
}
.checkbox-label {
display: flex;
align-items: center;
gap: 0.5rem;
cursor: pointer;
font-weight: 500;
}
.checkbox-label input[type="checkbox"] {
margin: 0;
}
.file-info {
margin-top: 1rem;
padding: 1rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
.file-details {
display: grid;
gap: 0.5rem;
margin-top: 0.5rem;
}
.upload-actions {
margin-top: 2rem;
padding-top: 2rem;
border-top: 1px solid #e9ecef;
display: flex;
gap: 1rem;
justify-content: center;
}
#progressSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #f8f9fa;
border-radius: 8px;
border: 1px solid #e9ecef;
}
#resultSection {
margin-top: 2rem;
padding: 1.5rem;
background-color: #d4edda;
border-radius: 8px;
border: 1px solid #c3e6cb;
}
.btn-lg {
padding: 1rem 2rem;
font-size: 1.1rem;
}
</style>
{% endblock %}
{% block scripts %}
<script>
let selectedFile = null;
// DOM 요소들
const dropZone = document.getElementById('dropZone');
const fileInput = document.getElementById('fileInput');
const fileInfo = document.getElementById('fileInfo');
const fileDetails = document.getElementById('fileDetails');
const uploadBtn = document.getElementById('uploadBtn');
const uploadForm = document.getElementById('uploadForm');
const progressSection = document.getElementById('progressSection');
const progressBar = document.getElementById('progressBar');
const progressText = document.getElementById('progressText');
const resultSection = document.getElementById('resultSection');
const resultContent = document.getElementById('resultContent');
// 체크박스 이벤트
document.getElementById('translate').addEventListener('change', function() {
const targetLangGroup = document.getElementById('targetLangGroup');
targetLangGroup.style.display = this.checked ? 'block' : 'none';
});
document.getElementById('summarize').addEventListener('change', function() {
const summaryGroup = document.getElementById('summaryGroup');
summaryGroup.style.display = this.checked ? 'block' : 'none';
});
// 드래그 앤 드롭 이벤트
dropZone.addEventListener('dragover', (e) => {
e.preventDefault();
dropZone.classList.add('dragover');
});
dropZone.addEventListener('dragleave', () => {
dropZone.classList.remove('dragover');
});
dropZone.addEventListener('drop', (e) => {
e.preventDefault();
dropZone.classList.remove('dragover');
const files = e.dataTransfer.files;
if (files.length > 0) {
handleFileSelect(files[0]);
}
});
// 파일 선택 이벤트
fileInput.addEventListener('change', (e) => {
if (e.target.files.length > 0) {
handleFileSelect(e.target.files[0]);
}
});
// 파일 처리 함수
function handleFileSelect(file) {
// 파일 타입 검증
const allowedTypes = ['application/pdf', 'text/plain'];
if (!allowedTypes.includes(file.type) &&
!file.name.toLowerCase().endsWith('.pdf') &&
!file.name.toLowerCase().endsWith('.txt')) {
showToast('PDF 또는 TXT 파일만 업로드할 수 있습니다.', 'danger');
return;
}
// 파일 크기 검증 (200MB)
if (file.size > 200 * 1024 * 1024) {
showToast('파일 크기는 200MB를 초과할 수 없습니다.', 'danger');
return;
}
selectedFile = file;
// 파일 정보 표시
fileDetails.innerHTML = `
<div class="file-details">
<div><strong>파일명:</strong> ${file.name}</div>
<div><strong>크기:</strong> ${formatFileSize(file.size)}</div>
<div><strong>타입:</strong> ${file.type || '알 수 없음'}</div>
</div>
`;
dropZone.style.display = 'none';
fileInfo.style.display = 'block';
uploadBtn.disabled = false;
// 문서 ID 자동 생성
const timestamp = new Date().toISOString().slice(0, 10);
const baseName = file.name.replace(/\.[^/.]+$/, "");
document.getElementById('docId').value = `${baseName}-${timestamp}`;
}
// 파일 선택 취소
function clearFile() {
selectedFile = null;
fileInput.value = '';
dropZone.style.display = 'block';
fileInfo.style.display = 'none';
uploadBtn.disabled = true;
progressSection.style.display = 'none';
resultSection.style.display = 'none';
}
// 폼 초기화
function resetForm() {
clearFile();
uploadForm.reset();
document.getElementById('generateHtml').checked = true;
document.getElementById('targetLangGroup').style.display = 'none';
document.getElementById('summaryGroup').style.display = 'none';
}
// 폼 제출
uploadForm.addEventListener('submit', async (e) => {
e.preventDefault();
if (!selectedFile) {
showToast('파일을 선택해주세요.', 'danger');
return;
}
const formData = new FormData();
formData.append('file', selectedFile);
formData.append('doc_id', document.getElementById('docId').value);
formData.append('generate_html', document.getElementById('generateHtml').checked);
formData.append('translate', document.getElementById('translate').checked);
formData.append('target_language', document.getElementById('targetLanguage').value);
// 진행 상황 표시
progressSection.style.display = 'block';
resultSection.style.display = 'none';
setLoading(uploadBtn);
try {
updateProgress(20, '파일 업로드 중...');
const response = await fetch('/pipeline/ingest_file', {
method: 'POST',
headers: {
'X-API-Key': '{{ api_key }}'
},
body: formData
});
if (!response.ok) {
throw new Error(`HTTP error! status: ${response.status}`);
}
updateProgress(80, '처리 중...');
const result = await response.json();
updateProgress(100, '완료!');
// 결과 표시
setTimeout(() => {
showResult(result);
}, 500);
showToast('파일이 성공적으로 처리되었습니다!', 'success');
} catch (error) {
console.error('Upload error:', error);
showToast('업로드 중 오류가 발생했습니다: ' + error.message, 'danger');
progressSection.style.display = 'none';
} finally {
uploadBtn.disabled = false;
uploadBtn.innerHTML = '<i class="fas fa-rocket"></i> 업로드 및 처리 시작';
}
});
function updateProgress(percent, text) {
progressBar.style.width = percent + '%';
progressText.textContent = text;
}
function showResult(result) {
resultContent.innerHTML = `
<div class="result-details">
<h4><i class="fas fa-info-circle"></i> 처리 결과</h4>
<div class="grid grid-2" style="margin-top: 1rem;">
<div>
<strong>문서 ID:</strong> ${result.doc_id}<br>
<strong>처리된 청크:</strong> ${result.added}개<br>
<strong>전체 청크:</strong> ${result.chunks}
</div>
<div class="result-actions">
${result.html_path ? `
<a href="/html/${result.html_path.split('/').pop()}"
class="btn btn-primary" target="_blank">
<i class="fas fa-eye"></i> HTML 보기
</a>
` : ''}
<a href="/documents" class="btn btn-secondary">
<i class="fas fa-list"></i> 문서 목록
</a>
<a href="/chat" class="btn btn-success">
<i class="fas fa-comments"></i> 문서로 대화하기
</a>
</div>
</div>
</div>
`;
resultSection.style.display = 'block';
progressSection.style.display = 'none';
}
</script>
{% endblock %}

536
test_admin.py Normal file
View File

@@ -0,0 +1,536 @@
#!/usr/bin/env python3
"""
AI Server Admin Dashboard Test Server
맥북프로에서 관리 페이지만 테스트하기 위한 간단한 서버
"""
import os
import secrets
import uuid
import jwt
import bcrypt
from datetime import datetime, timedelta
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, Request, HTTPException, Depends, Header
from fastapi.responses import HTMLResponse
from fastapi.staticfiles import StaticFiles
from fastapi.templating import Jinja2Templates
from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
import uvicorn
# Import encryption module
try:
from server.encryption import encrypt_api_key, decrypt_api_key, is_encrypted
ENCRYPTION_AVAILABLE = True
except ImportError:
print("⚠️ Encryption module not available, using plain text storage")
ENCRYPTION_AVAILABLE = False
def encrypt_api_key(key): return key
def decrypt_api_key(key): return key
def is_encrypted(key): return False
# FastAPI 앱 초기화
app = FastAPI(title="AI Server Admin Dashboard (Test Mode)")
# 정적 파일 및 템플릿 설정
app.mount("/static", StaticFiles(directory="static"), name="static")
templates = Jinja2Templates(directory="templates")
# 테스트용 설정
TEST_API_KEY = os.getenv("API_KEY", "test-admin-key-123")
TEST_SERVER_PORT = 28080
TEST_OLLAMA_HOST = "http://localhost:11434"
# JWT 설정
JWT_SECRET_KEY = "test-jwt-secret-key-for-development"
JWT_ALGORITHM = "HS256"
security = HTTPBearer(auto_error=False)
# 테스트용 사용자 데이터
TEST_USERS = {
"admin": {
"username": "admin",
"password_hash": bcrypt.hashpw("admin123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "admin"
},
"hyungi": {
"username": "hyungi",
"password_hash": bcrypt.hashpw("hyungi123".encode('utf-8'), bcrypt.gensalt()).decode('utf-8'),
"role": "system"
}
}
# 임시 데이터 저장소 (암호화된 API 키)
def initialize_api_keys():
"""Initialize API keys with encryption"""
keys = {
"test-key-1": {
"name": "Test Key 1",
"key": "test-api-key-abcd1234",
"created_at": datetime.now().isoformat(),
"usage_count": 42,
},
"test-key-2": {
"name": "Development Key",
"key": "dev-api-key-efgh5678",
"created_at": datetime.now().isoformat(),
"usage_count": 128,
}
}
# Encrypt API keys if encryption is available
if ENCRYPTION_AVAILABLE:
for key_id, key_data in keys.items():
if not is_encrypted(key_data["key"]):
try:
key_data["key"] = encrypt_api_key(key_data["key"])
key_data["encrypted"] = True
print(f"🔒 Encrypted API key: {key_data['name']}")
except Exception as e:
print(f"❌ Failed to encrypt key {key_data['name']}: {e}")
key_data["encrypted"] = False
else:
key_data["encrypted"] = True
else:
for key_data in keys.values():
key_data["encrypted"] = False
return keys
api_keys_store = initialize_api_keys()
# 테스트용 모델 데이터
test_models = [
{
"name": "llama3.2:3b",
"size": 2048000000, # 2GB
"status": "ready",
"is_active": True,
"last_used": datetime.now().isoformat(),
},
{
"name": "qwen2.5:7b",
"size": 4096000000, # 4GB
"status": "ready",
"is_active": False,
"last_used": "2024-12-20T10:30:00",
},
{
"name": "gemma2:2b",
"size": 1536000000, # 1.5GB
"status": "inactive",
"is_active": False,
"last_used": "2024-12-19T15:45:00",
}
]
# JWT 인증 함수들
def create_jwt_token(user_data: dict, remember_me: bool = False) -> str:
"""JWT 토큰 생성"""
expiration = datetime.utcnow() + timedelta(
days=30 if remember_me else 0,
hours=24 if not remember_me else 0
)
payload = {
"username": user_data["username"],
"role": user_data["role"],
"exp": expiration,
"iat": datetime.utcnow()
}
return jwt.encode(payload, JWT_SECRET_KEY, algorithm=JWT_ALGORITHM)
def verify_jwt_token(token: str) -> dict:
"""JWT 토큰 검증"""
try:
payload = jwt.decode(token, JWT_SECRET_KEY, algorithms=[JWT_ALGORITHM])
return payload
except jwt.ExpiredSignatureError:
raise HTTPException(status_code=401, detail="Token has expired")
except jwt.InvalidTokenError:
raise HTTPException(status_code=401, detail="Invalid token")
def verify_password(password: str, password_hash: str) -> bool:
"""비밀번호 검증"""
return bcrypt.checkpw(password.encode('utf-8'), password_hash.encode('utf-8'))
async def get_current_user(credentials: Optional[HTTPAuthorizationCredentials] = Depends(security)):
"""현재 인증된 사용자 가져오기"""
if not credentials:
raise HTTPException(status_code=401, detail="Authentication required")
try:
payload = verify_jwt_token(credentials.credentials)
username = payload.get("username")
user = TEST_USERS.get(username)
if not user:
raise HTTPException(status_code=401, detail="User not found")
return {
"username": user["username"],
"role": user["role"]
}
except HTTPException:
raise
except Exception as e:
print(f"JWT verification error: {e}")
raise HTTPException(status_code=401, detail="Invalid authentication credentials")
def require_api_key(x_api_key: Optional[str] = Header(None), api_key: Optional[str] = None):
"""API 키 검증 (테스트 모드에서는 URL 파라미터도 허용)"""
# URL 파라미터로 API 키가 전달된 경우
if api_key and api_key == TEST_API_KEY:
return api_key
# 헤더로 API 키가 전달된 경우
if x_api_key and x_api_key == TEST_API_KEY:
return x_api_key
# 테스트 모드에서는 기본 허용
return "test-mode"
async def require_admin_role(current_user: dict = Depends(get_current_user)):
"""관리자 권한 필요"""
if current_user["role"] not in ["admin", "system"]:
raise HTTPException(status_code=403, detail="Admin privileges required")
return current_user
# 유연한 인증 (JWT 또는 API 키)
async def flexible_auth(
request: Request,
credentials: Optional[HTTPAuthorizationCredentials] = Depends(security),
x_api_key: Optional[str] = Header(None)
):
"""JWT 또는 API 키 인증"""
# JWT 토큰 시도
if credentials:
try:
return await get_current_user(credentials)
except HTTPException:
pass
# API 키 시도 (테스트 모드)
if x_api_key == TEST_API_KEY:
return {"username": "api_user", "role": "system"}
# 둘 다 실패하면 로그인 페이지로 리다이렉트
raise HTTPException(status_code=401, detail="Authentication required")
@app.get("/", response_class=HTMLResponse)
async def root():
"""루트 페이지 - 관리 페이지로 리다이렉트"""
return HTMLResponse("""
<html>
<head><title>AI Server Test</title></head>
<body>
<h1>AI Server Admin Dashboard (Test Mode)</h1>
<p>이것은 맥북프로에서 관리 페이지를 테스트하기 위한 서버입니다.</p>
<p><a href="/admin">관리 페이지로 이동</a></p>
<p><strong>API Key:</strong> <code>test-admin-key-123</code></p>
</body>
</html>
""")
@app.get("/health")
async def health_check():
"""헬스 체크"""
return {"status": "ok", "mode": "test", "timestamp": datetime.now().isoformat()}
# JWT 인증 엔드포인트들
@app.get("/login", response_class=HTMLResponse)
async def login_page(request: Request):
"""로그인 페이지"""
return templates.TemplateResponse("login.html", {"request": request})
@app.post("/admin/login")
async def admin_login(request: Request):
"""JWT 기반 로그인 (테스트 모드)"""
try:
data = await request.json()
username = data.get("username", "").strip()
password = data.get("password", "")
remember_me = data.get("remember_me", False)
if not username or not password:
return {"success": False, "message": "Username and password are required"}
# 사용자 인증
user = TEST_USERS.get(username)
if user and verify_password(password, user["password_hash"]):
# JWT 토큰 생성
token = create_jwt_token(user, remember_me)
return {
"success": True,
"message": "Login successful",
"token": token,
"user": {
"username": user["username"],
"role": user["role"]
}
}
else:
return {"success": False, "message": "Invalid username or password"}
except Exception as e:
return {"success": False, "message": "Login error occurred"}
@app.get("/admin/verify-token")
async def verify_token(current_user: dict = Depends(get_current_user)):
"""JWT 토큰 검증"""
return {
"valid": True,
"user": current_user
}
@app.post("/admin/logout")
async def admin_logout(current_user: dict = Depends(get_current_user)):
"""로그아웃"""
return {"success": True, "message": "Logged out successfully"}
# Admin Dashboard Routes
@app.get("/admin", response_class=HTMLResponse)
async def admin_dashboard(request: Request):
"""관리자 대시보드 페이지 (클라이언트에서 JWT 검증)"""
# HTML 페이지를 먼저 반환하고, JavaScript에서 토큰 검증
return templates.TemplateResponse("admin.html", {
"request": request,
"server_port": TEST_SERVER_PORT,
"ollama_host": TEST_OLLAMA_HOST,
})
@app.get("/admin/ollama/status")
async def admin_ollama_status(api_key: str = Depends(require_api_key)):
"""Ollama 서버 상태 확인 (테스트 모드)"""
return {"status": "offline", "error": "Test mode - Ollama not available"}
@app.get("/admin/models")
async def admin_get_models(api_key: str = Depends(require_api_key)):
"""설치된 모델 목록 조회 (테스트 데이터)"""
return {"models": test_models}
@app.get("/admin/models/active")
async def admin_get_active_model(api_key: str = Depends(require_api_key)):
"""현재 활성 모델 조회"""
active_model = next((m for m in test_models if m["is_active"]), None)
return {"model": active_model["name"] if active_model else "None"}
@app.post("/admin/models/test")
async def admin_test_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 테스트 (시뮬레이션)"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
# 테스트 모드에서는 시뮬레이션 결과 반환
return {
"result": f"Test mode simulation: Model '{model_name}' would respond with 'Hello! This is a test response from {model_name}.'"
}
@app.get("/admin/api-keys")
async def admin_get_api_keys(api_key: str = Depends(require_api_key)):
"""API 키 목록 조회 (복호화된 키 반환)"""
keys = []
for key_id, key_data in api_keys_store.items():
# Decrypt key for display
display_key = key_data.get("key", "")
if ENCRYPTION_AVAILABLE and key_data.get("encrypted", False):
try:
display_key = decrypt_api_key(display_key)
except Exception as e:
print(f"❌ Failed to decrypt key {key_data.get('name')}: {e}")
display_key = "DECRYPTION_FAILED"
keys.append({
"id": key_id,
"name": key_data.get("name", "Unnamed"),
"key": display_key,
"created_at": key_data.get("created_at", datetime.now().isoformat()),
"usage_count": key_data.get("usage_count", 0),
"encrypted": key_data.get("encrypted", False),
})
return {"api_keys": keys}
@app.post("/admin/api-keys")
async def admin_create_api_key(request: dict, api_key: str = Depends(require_api_key)):
"""새 API 키 생성 (암호화 저장)"""
name = request.get("name", "Unnamed Key")
new_key = secrets.token_urlsafe(32)
key_id = str(uuid.uuid4())
# Encrypt the key before storing
stored_key = new_key
encrypted = False
if ENCRYPTION_AVAILABLE:
try:
stored_key = encrypt_api_key(new_key)
encrypted = True
print(f"🔒 Created encrypted API key: {name}")
except Exception as e:
print(f"❌ Failed to encrypt new key {name}: {e}")
encrypted = False
api_keys_store[key_id] = {
"name": name,
"key": stored_key,
"created_at": datetime.now().isoformat(),
"usage_count": 0,
"encrypted": encrypted,
}
return {
"api_key": new_key, # Return plain key to user (only time they'll see it)
"key_id": key_id,
"encrypted": encrypted
}
@app.delete("/admin/api-keys/{key_id}")
async def admin_delete_api_key(key_id: str, api_key: str = Depends(require_api_key)):
"""API 키 삭제"""
if key_id in api_keys_store:
del api_keys_store[key_id]
return {"message": "API key deleted successfully"}
else:
raise HTTPException(status_code=404, detail="API key not found")
# Phase 2: Advanced Model Management (Test Mode)
@app.post("/admin/models/download")
async def admin_download_model(request: dict, api_key: str = Depends(require_api_key)):
"""모델 다운로드 (테스트 모드)"""
model_name = request.get("model")
if not model_name:
raise HTTPException(status_code=400, detail="Model name is required")
# 테스트 모드에서는 시뮬레이션
return {
"success": True,
"message": f"Test mode: Model '{model_name}' download simulation started",
"details": f"In real mode, this would download {model_name} from Ollama registry"
}
@app.delete("/admin/models/{model_name}")
async def admin_delete_model(model_name: str, api_key: str = Depends(require_api_key)):
"""모델 삭제 (테스트 모드)"""
# 테스트 데이터에서 모델 제거
global test_models
test_models = [m for m in test_models if m["name"] != model_name]
return {
"success": True,
"message": f"Test mode: Model '{model_name}' deleted from test data",
"details": f"In real mode, this would delete {model_name} from Ollama"
}
@app.get("/admin/models/available")
async def admin_get_available_models(api_key: str = Depends(require_api_key)):
"""다운로드 가능한 모델 목록"""
available_models = [
{
"name": "llama3.2:1b",
"description": "Meta의 Llama 3.2 1B 모델 - 가벼운 작업용",
"size": "1.3GB",
"tags": ["chat", "lightweight"]
},
{
"name": "llama3.2:3b",
"description": "Meta의 Llama 3.2 3B 모델 - 균형잡힌 성능",
"size": "2.0GB",
"tags": ["chat", "recommended"]
},
{
"name": "qwen2.5:7b",
"description": "Alibaba의 Qwen 2.5 7B 모델 - 다국어 지원",
"size": "4.1GB",
"tags": ["chat", "multilingual"]
},
{
"name": "gemma2:2b",
"description": "Google의 Gemma 2 2B 모델 - 효율적인 추론",
"size": "1.6GB",
"tags": ["chat", "efficient"]
},
{
"name": "codellama:7b",
"description": "Meta의 Code Llama 7B - 코드 생성 특화",
"size": "3.8GB",
"tags": ["code", "programming"]
},
{
"name": "mistral:7b",
"description": "Mistral AI의 7B 모델 - 고성능 추론",
"size": "4.1GB",
"tags": ["chat", "performance"]
}
]
return {"available_models": available_models}
# Phase 2: System Monitoring (Test Mode)
@app.get("/admin/system/stats")
async def admin_get_system_stats(api_key: str = Depends(require_api_key)):
"""시스템 리소스 사용률 조회 (테스트 데이터)"""
import random
# 테스트용 랜덤 데이터 생성
return {
"cpu": {
"usage_percent": round(random.uniform(10, 80), 1),
"core_count": 8
},
"memory": {
"usage_percent": round(random.uniform(30, 90), 1),
"used_gb": round(random.uniform(4, 12), 1),
"total_gb": 16
},
"disk": {
"usage_percent": round(random.uniform(20, 70), 1),
"used_gb": round(random.uniform(50, 200), 1),
"total_gb": 500
},
"gpu": [
{
"name": "Test GPU (Simulated)",
"load": round(random.uniform(0, 100), 1),
"memory_used": round(random.uniform(1000, 8000)),
"memory_total": 8192,
"temperature": round(random.uniform(45, 75))
}
],
"timestamp": datetime.now().isoformat()
}
if __name__ == "__main__":
print("🚀 AI Server Admin Dashboard (Test Mode)")
print(f"📍 Server: http://localhost:{TEST_SERVER_PORT}")
print(f"🔧 Admin: http://localhost:{TEST_SERVER_PORT}/admin")
print(f"🔑 API Key: {TEST_API_KEY}")
print("=" * 50)
uvicorn.run(
app,
host="0.0.0.0",
port=TEST_SERVER_PORT,
log_level="info"
)