Compare commits
10 Commits
b430a27215
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
841178ed7e | ||
|
|
1e098999c1 | ||
|
|
b752e56b94 | ||
|
|
e102ce6db9 | ||
|
|
cb009f7393 | ||
|
|
ef64aaec84 | ||
|
|
8d87b1f46b | ||
|
|
6346635ac1 | ||
|
|
6e7cf8eafa | ||
|
|
a280304adc |
91
README.md
91
README.md
@@ -213,6 +213,42 @@ curl -s -X POST http://localhost:26000/paperless/hook \
|
||||
해당 훅은 문서 도착을 통지받는 용도로 제공됩니다. 실제 본문 텍스트는 Paperless API로 조회해 `/index/upsert`로 추가하세요.
|
||||
|
||||
### 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에서 다수 문서를 일괄 인덱싱합니다.
|
||||
|
||||
@@ -251,6 +287,9 @@ curl -s -X POST http://localhost:26000/paperless/sync \
|
||||
- `PAPERLESS_BASE_URL`, `PAPERLESS_TOKEN`(선택): Paperless API 연동 시 사용
|
||||
- `PAPERLESS_VERIFY_SSL`(기본 `true`): Paperless HTTPS 검증 비활성화는 `false`
|
||||
- `PAPERLESS_CA_BUNDLE`(선택): 신뢰할 CA 번들 경로 지정 시 해당 번들로 검증
|
||||
- `OUTPUT_DIR`(기본 `outputs`): 파이프라인 산출물(HTML) 저장 루트
|
||||
- `EXPORT_HTML_DIR`(선택): HTML 산출물 사본을 내보낼 디렉터리(예: 시놀로지 공유 폴더)
|
||||
- `EXPORT_UPLOAD_DIR`(선택): 업로드 원본 파일 보관 디렉터리
|
||||
- `API_KEY`(선택): 설정 시 모든 민감 엔드포인트 호출에 `X-API-Key` 헤더 필요
|
||||
- `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) 추가
|
||||
2) 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
|
||||
3) 헬스체크/모델 선택/리밋/로깅 옵션 제공
|
||||
1) ✅ Ollama API를 감싸는 경량 서버(FastAPI) 구현 완료
|
||||
2) ✅ 표준화된 엔드포인트(`/v1/chat/completions`, `/v1/completions`) 제공
|
||||
3) ✅ 헬스체크/모델 선택/리밋/로깅 옵션 제공
|
||||
4) 🚧 웹 기반 관리 페이지 구현 중 (Phase 1)
|
||||
|
||||
우선 본 문서로 설치/선택 가이드를 정리했으며, 다음 단계에서 서버 스켈레톤과 샘플 클라이언트를 추가할 예정입니다.
|
||||
우선 본 문서로 설치/선택 가이드를 정리했으며, 현재 관리 페이지와 고급 기능들을 단계적으로 추가하고 있습니다.
|
||||
|
||||
|
||||
@@ -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
|
||||
.com∕permissions.
|
||||
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.4∕08--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 PEL’S, 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
20
outputs/html/test.html
Normal 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
20
outputs/html/ui-test.html
Normal 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>
|
||||
@@ -4,3 +4,5 @@ requests==2.32.4
|
||||
pydantic==2.8.2
|
||||
pypdf==6.0.0
|
||||
tiktoken==0.11.0
|
||||
python-multipart
|
||||
jinja2
|
||||
|
||||
229
server/auth.py
Normal file
229
server/auth.py
Normal 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
|
||||
@@ -13,6 +13,11 @@ class Settings:
|
||||
english_ratio_threshold: float = float(os.getenv("ENGLISH_RATIO_THRESHOLD", "0.65"))
|
||||
embedding_model: str = os.getenv("EMBEDDING_MODEL", "nomic-embed-text")
|
||||
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_base_url: str = os.getenv("PAPERLESS_BASE_URL", "")
|
||||
|
||||
127
server/encryption.py
Normal file
127
server/encryption.py
Normal 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)}")
|
||||
581
server/main.py
581
server/main.py
@@ -1,9 +1,16 @@
|
||||
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.templating import Jinja2Templates
|
||||
from fastapi.staticfiles import StaticFiles
|
||||
from fastapi.responses import HTMLResponse
|
||||
from pydantic import BaseModel
|
||||
from typing import List, Dict, Any
|
||||
import shutil
|
||||
from pathlib import Path
|
||||
import os
|
||||
from datetime import datetime
|
||||
|
||||
from .config import settings
|
||||
from .ollama_client import OllamaClient
|
||||
@@ -11,10 +18,19 @@ from .index_store import JsonlIndex
|
||||
from .security import require_api_key
|
||||
from .paperless_client import PaperlessClient
|
||||
from .utils import chunk_text
|
||||
from .pipeline import DocumentPipeline
|
||||
|
||||
|
||||
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
|
||||
import os
|
||||
cors_origins = os.getenv("CORS_ORIGINS", "*")
|
||||
@@ -28,6 +44,7 @@ app.add_middleware(
|
||||
)
|
||||
ollama = OllamaClient(settings.ollama_host)
|
||||
index = JsonlIndex(settings.index_path)
|
||||
pipeline = DocumentPipeline(ollama, settings.embedding_model, settings.boost_model, output_dir=settings.output_dir)
|
||||
|
||||
|
||||
class ChatRequest(BaseModel):
|
||||
@@ -55,6 +72,18 @@ class UpsertRequest(BaseModel):
|
||||
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")
|
||||
def health() -> Dict[str, Any]:
|
||||
return {
|
||||
@@ -152,6 +181,89 @@ def index_reload() -> Dict[str, Any]:
|
||||
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)
|
||||
class PaperlessHook(BaseModel):
|
||||
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
134
server/pipeline.py
Normal 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
697
static/admin.css
Normal 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
508
static/admin.js
Normal 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
394
static/css/style.css
Normal 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
87
static/js/main.js
Normal 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
390
static/login.css
Normal 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
235
static/login.js
Normal 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
248
templates/admin.html
Normal 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')">×</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')">×</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
39
templates/base.html
Normal 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>© 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
631
templates/chat.html
Normal 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
348
templates/documents.html
Normal 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
229
templates/index.html
Normal 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
124
templates/login.html
Normal 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>© 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
395
templates/upload.html
Normal 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
536
test_admin.py
Normal 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"
|
||||
)
|
||||
Reference in New Issue
Block a user