SOY NV AI commited on
Commit
d234e06
·
1 Parent(s): 479b257

메타데이터 생성 기능 개선: 기존 메타데이터 병합 및 회차 정보 유지

Browse files
DEPLOYMENT_GUIDE.md ADDED
@@ -0,0 +1,773 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # DigitalOcean 배포 가이드
2
+
3
+ ## 목차
4
+ 1. [사전 준비사항](#사전-준비사항)
5
+ 2. [Droplet 생성](#droplet-생성)
6
+ 3. [서버 초기 설정](#서버-초기-설정)
7
+ 4. [Ollama 설치 및 설정](#ollama-설치-및-설정)
8
+ 5. [프로젝트 배포](#프로젝트-배포)
9
+ 6. [Nginx 설정](#nginx-설정)
10
+ 7. [SSL 인증서 설정](#ssl-인증서-설정)
11
+ 8. [systemd 서비스 설정](#systemd-서비스-설정)
12
+ 9. [백업 설정](#백업-설정)
13
+ 10. [모니터링 설정](#모니터링-설정)
14
+ 11. [문제 해결](#문제-해결)
15
+
16
+ ---
17
+
18
+ ## 사전 준비사항
19
+
20
+ ### 필요한 것들:
21
+ - DigitalOcean 계정 (https://www.digitalocean.com/)
22
+ - 도메인 (선택사항, 있으면 좋음)
23
+ - GitHub/GitLab 저장소 (또는 프로젝트 파일)
24
+ - Gemini API 키 (사용하는 경우)
25
+
26
+ ### 예상 비용:
27
+ - Droplet (8GB RAM): $48/월
28
+ - 도메인: $10-15/년 (선택사항)
29
+ - 총: 약 $48-50/월
30
+
31
+ ---
32
+
33
+ ## Droplet 생성
34
+
35
+ ### 1. DigitalOcean 로그인 및 Droplet 생성
36
+
37
+ 1. https://www.digitalocean.com/ 접속 및 로그인
38
+ 2. "Create" → "Droplets" 클릭
39
+ 3. 다음 설정 선택:
40
+
41
+ **이미지:**
42
+ - Ubuntu 22.04 (LTS) x64
43
+
44
+ **플랜:**
45
+ - Regular (일반)
46
+ - 8GB RAM / 4 vCPUs / 160GB SSD ($48/월) **추천**
47
+ - 또는 4GB RAM / 2 vCPUs / 80GB SSD ($24/월) - 최소 사양
48
+
49
+ **데이터센터 지역:**
50
+ - Singapore (싱가포르) - 한국에서 가장 가까움
51
+ - 또는 Seoul (서울) - 가능한 경우
52
+
53
+ **인증:**
54
+ - SSH 키 추가 (권장)
55
+ - 또는 비밀번호 설정
56
+
57
+ **추가 옵션:**
58
+ - Monitoring 활성화
59
+ - Backups 활성화 (선택사항, 추가 비용)
60
+
61
+ 4. "Create Droplet" 클릭
62
+
63
+ ### 2. 서버 접속 정보 확인
64
+
65
+ 생성 완료 후 이메일로 IP 주소와 접속 정보를 받게 됩니다.
66
+ - IP 주소: 예) `123.45.67.89`
67
+ - Root 비밀번호 (비밀번호 방식인 경우)
68
+
69
+ ---
70
+
71
+ ## 서버 초기 설정
72
+
73
+ ### 1. SSH로 서버 접속
74
+
75
+ **Windows (PowerShell):**
76
+ ```powershell
77
+ ssh root@YOUR_SERVER_IP
78
+ ```
79
+
80
+ **Mac/Linux:**
81
+ ```bash
82
+ ssh root@YOUR_SERVER_IP
83
+ ```
84
+
85
+ ### 2. 시스템 업데이트
86
+
87
+ ```bash
88
+ # 시스템 업데이트
89
+ apt update && apt upgrade -y
90
+
91
+ # 필수 패키지 설치
92
+ apt install -y \
93
+ python3 \
94
+ python3-pip \
95
+ python3-venv \
96
+ git \
97
+ curl \
98
+ wget \
99
+ nginx \
100
+ certbot \
101
+ python3-certbot-nginx \
102
+ supervisor \
103
+ ufw \
104
+ htop \
105
+ nano \
106
+ build-essential \
107
+ libssl-dev \
108
+ libffi-dev \
109
+ python3-dev
110
+ ```
111
+
112
+ ### 3. 방화벽 설정
113
+
114
+ ```bash
115
+ # UFW 방화벽 활성화
116
+ ufw allow OpenSSH
117
+ ufw allow 'Nginx Full'
118
+ ufw allow 11434/tcp # Ollama 포트
119
+ ufw enable
120
+ ufw status
121
+ ```
122
+
123
+ ### 4. 사용자 생성 (선택사항, 보안을 위해 권장)
124
+
125
+ ```bash
126
+ # 새 사용자 생성
127
+ adduser deploy
128
+ usermod -aG sudo deploy
129
+
130
+ # SSH 키 복사 (로컬에서)
131
+ # Windows: type C:\Users\YourName\.ssh\id_rsa.pub | ssh root@YOUR_SERVER_IP "cat >> /home/deploy/.ssh/authorized_keys"
132
+ # Mac/Linux: ssh-copy-id deploy@YOUR_SERVER_IP
133
+
134
+ # deploy 사용자로 전환
135
+ su - deploy
136
+ ```
137
+
138
+ ---
139
+
140
+ ## Ollama 설치 및 설정
141
+
142
+ ### 1. Ollama 설치
143
+
144
+ ```bash
145
+ # Ollama 설치 스크립트 실행
146
+ curl -fsSL https://ollama.com/install.sh | sh
147
+
148
+ # Ollama 서비스 확인
149
+ systemctl status ollama
150
+
151
+ # Ollama 자동 시작 설정
152
+ systemctl enable ollama
153
+ ```
154
+
155
+ ### 2. Ollama 모델 다운로드
156
+
157
+ ```bash
158
+ # 사용할 모델 다운로드 (예: llama2)
159
+ ollama pull llama2
160
+
161
+ # 또는 다른 모델
162
+ # ollama pull mistral
163
+ # ollama pull qwen
164
+
165
+ # 설치된 모델 확인
166
+ ollama list
167
+ ```
168
+
169
+ ### 3. Ollama 서비스 확인
170
+
171
+ ```bash
172
+ # Ollama가 정상 작동하는지 확인
173
+ curl http://localhost:11434/api/tags
174
+
175
+ # 서비스 재시작
176
+ systemctl restart ollama
177
+ ```
178
+
179
+ ---
180
+
181
+ ## 프로젝트 배포
182
+
183
+ ### 1. 프로젝트 디렉토리 생성
184
+
185
+ ```bash
186
+ # 홈 디렉토리로 이동
187
+ cd ~
188
+
189
+ # 프로젝트 디렉토리 생성
190
+ mkdir -p /var/www
191
+ cd /var/www
192
+
193
+ # 소유권 설정 (deploy 사용자 사용 시)
194
+ # chown -R deploy:deploy /var/www
195
+ ```
196
+
197
+ ### 2. 프로젝트 파일 업로드
198
+
199
+ **방법 1: Git 사용 (권장)**
200
+
201
+ ```bash
202
+ # Git 저장소 클론
203
+ git clone YOUR_REPOSITORY_URL "soy-nv-ai"
204
+ cd soy-nv-ai
205
+
206
+ # 또는 직접 파일 업로드
207
+ # scp -r "D:\SOY NV AI\*" deploy@YOUR_SERVER_IP:/var/www/soy-nv-ai/
208
+ ```
209
+
210
+ **방법 2: SCP로 파일 전송 (Windows PowerShell)**
211
+
212
+ ```powershell
213
+ # 프로젝트 폴더 전체 업로드
214
+ scp -r "D:\SOY NV AI\*" deploy@YOUR_SERVER_IP:/var/www/soy-nv-ai/
215
+ ```
216
+
217
+ ### 3. 가상환경 설정
218
+
219
+ ```bash
220
+ cd /var/www/soy-nv-ai
221
+
222
+ # 가상환경 생성
223
+ python3 -m venv venv
224
+
225
+ # 가상환경 활성화
226
+ source venv/bin/activate
227
+
228
+ # pip 업그레이드
229
+ pip install --upgrade pip
230
+
231
+ # 의존성 설치
232
+ pip install -r requirements.txt
233
+
234
+ # 설치 확인
235
+ python --version
236
+ pip list
237
+ ```
238
+
239
+ ### 4. 환경 변수 설정
240
+
241
+ ```bash
242
+ # .env 파일 생성
243
+ nano .env
244
+ ```
245
+
246
+ **.env 파일 내용:**
247
+ ```env
248
+ # Flask 설정
249
+ SECRET_KEY=your-super-secret-key-change-this-in-production
250
+ FLASK_ENV=production
251
+ FLASK_DEBUG=False
252
+
253
+ # 데이터베이스
254
+ DATABASE_URL=sqlite:///var/www/soy-nv-ai/instance/finance_analysis.db
255
+
256
+ # Ollama 설정
257
+ OLLAMA_BASE_URL=http://localhost:11434
258
+
259
+ # Gemini API (사용하는 경우)
260
+ GEMINI_API_KEY=your-gemini-api-key-here
261
+
262
+ # 업로드 폴더
263
+ UPLOAD_FOLDER=/var/www/soy-nv-ai/uploads
264
+ VECTOR_DB_PATH=/var/www/soy-nv-ai/vector_db
265
+ KNOWLEDGE_GRAPH_PATH=/var/www/soy-nv-ai/knowledge_graphs
266
+
267
+ # 임베딩 모델
268
+ EMBEDDING_MODEL_NAME=sentence-transformers/all-MiniLM-L6-v2
269
+ RERANKER_MODEL_NAME=BAAI/bge-reranker-base
270
+ ```
271
+
272
+ **SECRET_KEY 생성 방법:**
273
+ ```bash
274
+ python3 -c "import secrets; print(secrets.token_hex(32))"
275
+ ```
276
+
277
+ ### 5. 디렉토리 권한 설정
278
+
279
+ ```bash
280
+ # 필요한 디렉토리 생성
281
+ mkdir -p instance uploads vector_db knowledge_graphs logs
282
+
283
+ # 권한 설정
284
+ chmod -R 755 /var/www/soy-nv-ai
285
+ chown -R deploy:deploy /var/www/soy-nv-ai # deploy 사용자 사용 시
286
+
287
+ # 업로드 폴더 쓰기 권한
288
+ chmod -R 775 uploads
289
+ chmod -R 775 instance
290
+ chmod -R 775 vector_db
291
+ ```
292
+
293
+ ### 6. 애플리케이션 테스트
294
+
295
+ ```bash
296
+ # 가상환경 활성화
297
+ source venv/bin/activate
298
+
299
+ # 데이터베이스 초기화
300
+ python -c "from app import create_app; app = create_app(); app.app_context().push(); from app.database import db; db.create_all()"
301
+
302
+ # 서버 테스트 실행
303
+ python run.py
304
+ ```
305
+
306
+ **다른 터미널에서 테스트:**
307
+ ```bash
308
+ curl http://localhost:5001
309
+ ```
310
+
311
+ 정상 작동하면 `Ctrl+C`로 중지합니다.
312
+
313
+ ---
314
+
315
+ ## Nginx 설정
316
+
317
+ ### 1. Nginx 설정 파일 생성
318
+
319
+ ```bash
320
+ sudo nano /etc/nginx/sites-available/soy-nv-ai
321
+ ```
322
+
323
+ **설정 파일 내용:**
324
+ ```nginx
325
+ server {
326
+ listen 80;
327
+ server_name YOUR_DOMAIN.com www.YOUR_DOMAIN.com;
328
+ # 또는 IP 주소만 사용: server_name YOUR_SERVER_IP;
329
+
330
+ # 로그 설정
331
+ access_log /var/log/nginx/soy-nv-ai-access.log;
332
+ error_log /var/log/nginx/soy-nv-ai-error.log;
333
+
334
+ # 클라이언트 최대 업로드 크기 (100MB)
335
+ client_max_body_size 100M;
336
+
337
+ # 프록시 설정
338
+ location / {
339
+ proxy_pass http://127.0.0.1:5001;
340
+ proxy_set_header Host $host;
341
+ proxy_set_header X-Real-IP $remote_addr;
342
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
343
+ proxy_set_header X-Forwarded-Proto $scheme;
344
+
345
+ # WebSocket 지원 (필요한 경우)
346
+ proxy_http_version 1.1;
347
+ proxy_set_header Upgrade $http_upgrade;
348
+ proxy_set_header Connection "upgrade";
349
+
350
+ # 타임아웃 설정 (AI 응답이 오래 걸릴 수 있음)
351
+ proxy_connect_timeout 600s;
352
+ proxy_send_timeout 600s;
353
+ proxy_read_timeout 600s;
354
+ }
355
+
356
+ # 정적 파일 직접 제공 (선택사항)
357
+ location /static {
358
+ alias /var/www/soy-nv-ai/static;
359
+ expires 30d;
360
+ add_header Cache-Control "public, immutable";
361
+ }
362
+ }
363
+ ```
364
+
365
+ ### 2. 설정 파일 활성화
366
+
367
+ ```bash
368
+ # 심볼릭 링크 생성
369
+ sudo ln -s /etc/nginx/sites-available/soy-nv-ai /etc/nginx/sites-enabled/
370
+
371
+ # 기본 설정 비활성화 (선택사항)
372
+ sudo rm /etc/nginx/sites-enabled/default
373
+
374
+ # Nginx 설정 테스트
375
+ sudo nginx -t
376
+
377
+ # Nginx 재시작
378
+ sudo systemctl restart nginx
379
+ sudo systemctl enable nginx
380
+ ```
381
+
382
+ ### 3. 방화벽 확인
383
+
384
+ ```bash
385
+ # Nginx 포트 확인
386
+ sudo ufw status
387
+ ```
388
+
389
+ ---
390
+
391
+ ## SSL 인증서 설정
392
+
393
+ ### 1. Let's Encrypt SSL 인증서 설치
394
+
395
+ **도메인이 있는 경우:**
396
+
397
+ ```bash
398
+ # Certbot으로 SSL 인증서 발급
399
+ sudo certbot --nginx -d YOUR_DOMAIN.com -d www.YOUR_DOMAIN.com
400
+
401
+ # 자동 갱신 테스트
402
+ sudo certbot renew --dry-run
403
+ ```
404
+
405
+ **도메인이 없는 경우 (IP만 사용):**
406
+ - SSL 인증서는 발급할 수 없습니다
407
+ - HTTP로만 접속 가능 (보안상 권장하지 않음)
408
+ - 내부 네트워크나 VPN 사용 고려
409
+
410
+ ### 2. 자동 갱신 설정
411
+
412
+ ```bash
413
+ # Certbot 자동 갱신은 이미 systemd 타이머로 설정됨
414
+ sudo systemctl status certbot.timer
415
+ ```
416
+
417
+ ---
418
+
419
+ ## systemd 서비스 설정
420
+
421
+ ### 1. 서비스 파일 생성
422
+
423
+ ```bash
424
+ sudo nano /etc/systemd/system/soy-nv-ai.service
425
+ ```
426
+
427
+ **서비스 파일 내용:**
428
+ ```ini
429
+ [Unit]
430
+ Description=SOY NV AI Flask Application
431
+ After=network.target ollama.service
432
+
433
+ [Service]
434
+ Type=simple
435
+ User=deploy
436
+ Group=deploy
437
+ WorkingDirectory=/var/www/soy-nv-ai
438
+ Environment="PATH=/var/www/soy-nv-ai/venv/bin"
439
+ Environment="FLASK_ENV=production"
440
+ ExecStart=/var/www/soy-nv-ai/venv/bin/python /var/www/soy-nv-ai/run.py
441
+ Restart=always
442
+ RestartSec=10
443
+ StandardOutput=journal
444
+ StandardError=journal
445
+ SyslogIdentifier=soy-nv-ai
446
+
447
+ # 리소스 제한 (선택사항)
448
+ LimitNOFILE=65535
449
+ MemoryLimit=6G
450
+
451
+ [Install]
452
+ WantedBy=multi-user.target
453
+ ```
454
+
455
+ ### 2. 서비스 활성화 및 시작
456
+
457
+ ```bash
458
+ # systemd 재로드
459
+ sudo systemctl daemon-reload
460
+
461
+ # 서비스 활성화 (부팅 시 자동 시작)
462
+ sudo systemctl enable soy-nv-ai
463
+
464
+ # 서비스 시작
465
+ sudo systemctl start soy-nv-ai
466
+
467
+ # 서비스 상태 확인
468
+ sudo systemctl status soy-nv-ai
469
+
470
+ # 로그 확인
471
+ sudo journalctl -u soy-nv-ai -f
472
+ ```
473
+
474
+ ### 3. 서비스 관리 명령어
475
+
476
+ ```bash
477
+ # 서비스 시작
478
+ sudo systemctl start soy-nv-ai
479
+
480
+ # 서비��� 중지
481
+ sudo systemctl stop soy-nv-ai
482
+
483
+ # 서비스 재시작
484
+ sudo systemctl restart soy-nv-ai
485
+
486
+ # 서비스 상태 확인
487
+ sudo systemctl status soy-nv-ai
488
+
489
+ # 로그 확인
490
+ sudo journalctl -u soy-nv-ai -n 50
491
+ sudo journalctl -u soy-nv-ai -f # 실시간 로그
492
+ ```
493
+
494
+ ---
495
+
496
+ ## 백업 설정
497
+
498
+ ### 1. 백업 스크립트 생성
499
+
500
+ ```bash
501
+ nano /var/www/soy-nv-ai/backup.sh
502
+ ```
503
+
504
+ **백업 스크립트 내용:**
505
+ ```bash
506
+ #!/bin/bash
507
+
508
+ # 백업 디렉토리
509
+ BACKUP_DIR="/var/backups/soy-nv-ai"
510
+ DATE=$(date +%Y%m%d_%H%M%S)
511
+
512
+ # 백업 디렉토리 생성
513
+ mkdir -p $BACKUP_DIR
514
+
515
+ # 데이터베이스 백업
516
+ cp /var/www/soy-nv-ai/instance/finance_analysis.db $BACKUP_DIR/db_$DATE.db
517
+
518
+ # 업로드 파일 백업
519
+ tar -czf $BACKUP_DIR/uploads_$DATE.tar.gz /var/www/soy-nv-ai/uploads
520
+
521
+ # 벡터 DB 백업
522
+ tar -czf $BACKUP_DIR/vector_db_$DATE.tar.gz /var/www/soy-nv-ai/vector_db
523
+
524
+ # 오래된 백업 삭제 (30일 이상)
525
+ find $BACKUP_DIR -type f -mtime +30 -delete
526
+
527
+ echo "Backup completed: $DATE"
528
+ ```
529
+
530
+ ### 2. 백업 스크립트 실행 권한 부여
531
+
532
+ ```bash
533
+ chmod +x /var/www/soy-nv-ai/backup.sh
534
+ ```
535
+
536
+ ### 3. Cron으로 자동 백업 설정
537
+
538
+ ```bash
539
+ # Crontab 편집
540
+ crontab -e
541
+
542
+ # 매일 새벽 2시에 백업 실행
543
+ 0 2 * * * /var/www/soy-nv-ai/backup.sh >> /var/log/soy-nv-ai-backup.log 2>&1
544
+ ```
545
+
546
+ ---
547
+
548
+ ## 모니터링 설정
549
+
550
+ ### 1. 서버 리소스 모니터링
551
+
552
+ ```bash
553
+ # htop 설치 (이미 설치됨)
554
+ htop
555
+
556
+ # 또는 기본 top
557
+ top
558
+ ```
559
+
560
+ ### 2. 디스크 사용량 확인
561
+
562
+ ```bash
563
+ df -h
564
+ du -sh /var/www/soy-nv-ai/*
565
+ ```
566
+
567
+ ### 3. 외부 모니터링 서비스 (선택사항)
568
+
569
+ **Uptime Robot (무료):**
570
+ - https://uptimerobot.com/
571
+ - 5분마다 서버 상태 확인
572
+ - 다운타임 알림
573
+
574
+ **Pingdom:**
575
+ - https://www.pingdom.com/
576
+ - 더 상세한 모니터링
577
+
578
+ ---
579
+
580
+ ## 문제 해결
581
+
582
+ ### 1. 서비스가 시작되지 않는 경우
583
+
584
+ ```bash
585
+ # 서비스 상태 확인
586
+ sudo systemctl status soy-nv-ai
587
+
588
+ # 로그 확인
589
+ sudo journalctl -u soy-nv-ai -n 100
590
+
591
+ # 수동 실행으로 오류 확인
592
+ cd /var/www/soy-nv-ai
593
+ source venv/bin/activate
594
+ python run.py
595
+ ```
596
+
597
+ ### 2. Ollama 연결 오류
598
+
599
+ ```bash
600
+ # Ollama 서비스 상태 확인
601
+ sudo systemctl status ollama
602
+
603
+ # Ollama 재시작
604
+ sudo systemctl restart ollama
605
+
606
+ # Ollama 로그 확인
607
+ sudo journalctl -u ollama -n 50
608
+
609
+ # Ollama 포트 확인
610
+ netstat -tlnp | grep 11434
611
+ ```
612
+
613
+ ### 3. Nginx 오류
614
+
615
+ ```bash
616
+ # Nginx 설정 테스트
617
+ sudo nginx -t
618
+
619
+ # Nginx 로그 확인
620
+ sudo tail -f /var/log/nginx/error.log
621
+ sudo tail -f /var/log/nginx/soy-nv-ai-error.log
622
+ ```
623
+
624
+ ### 4. 메모리 부족
625
+
626
+ ```bash
627
+ # 메모리 사용량 확인
628
+ free -h
629
+
630
+ # 프로세스별 메모리 사용량
631
+ ps aux --sort=-%mem | head
632
+
633
+ # 필요시 서버 재시작
634
+ sudo reboot
635
+ ```
636
+
637
+ ### 5. 디스크 공간 부족
638
+
639
+ ```bash
640
+ # 디스크 사용량 확인
641
+ df -h
642
+
643
+ # 큰 파일 찾기
644
+ du -h /var/www/soy-nv-ai | sort -rh | head -20
645
+
646
+ # 로그 파일 정리
647
+ sudo journalctl --vacuum-time=7d # 7일 이상 된 로그 삭제
648
+ ```
649
+
650
+ ### 6. 포트 충돌
651
+
652
+ ```bash
653
+ # 포트 사용 확인
654
+ sudo netstat -tlnp | grep 5001
655
+ sudo lsof -i :5001
656
+
657
+ # 프로세스 종료
658
+ sudo kill -9 PID
659
+ ```
660
+
661
+ ---
662
+
663
+ ## 배포 후 체크리스트
664
+
665
+ - [ ] 서버 접속 확인
666
+ - [ ] Ollama 설치 및 모델 다운로드 확인
667
+ - [ ] 프로젝트 파일 업로드 완료
668
+ - [ ] 가상환경 설정 및 의존성 설치 완료
669
+ - [ ] 환경 변수 설정 완료
670
+ - [ ] 데이터베이스 초기화 완료
671
+ - [ ] Nginx 설정 및 테스트 완료
672
+ - [ ] SSL 인증서 설치 완료 (도메인 있는 경우)
673
+ - [ ] systemd 서비스 설정 및 자동 시작 확인
674
+ - [ ] 백업 스크립트 설정 완료
675
+ - [ ] 방화벽 설정 확인
676
+ - [ ] 웹사이트 접속 테스트
677
+ - [ ] 파일 업로드 기능 테스트
678
+ - [ ] AI 채팅 기능 테스트
679
+ - [ ] 모니터링 설정 완료
680
+
681
+ ---
682
+
683
+ ## 유용한 명령어 모음
684
+
685
+ ```bash
686
+ # 서비스 관리
687
+ sudo systemctl start/stop/restart/status soy-nv-ai
688
+ sudo systemctl start/stop/restart/status ollama
689
+ sudo systemctl start/stop/restart/status nginx
690
+
691
+ # 로그 확인
692
+ sudo journalctl -u soy-nv-ai -f
693
+ sudo journalctl -u ollama -f
694
+ sudo tail -f /var/log/nginx/soy-nv-ai-error.log
695
+
696
+ # 프로세스 확인
697
+ ps aux | grep python
698
+ ps aux | grep ollama
699
+
700
+ # 포트 확인
701
+ netstat -tlnp | grep 5001
702
+ netstat -tlnp | grep 11434
703
+
704
+ # 디스크/메모리 확인
705
+ df -h
706
+ free -h
707
+ htop
708
+
709
+ # 백업 실행
710
+ /var/www/soy-nv-ai/backup.sh
711
+ ```
712
+
713
+ ---
714
+
715
+ ## 추가 최적화
716
+
717
+ ### 1. Gunicorn 사용 (선택사항)
718
+
719
+ Flask 개발 서버 대신 Gunicorn 사용 권장:
720
+
721
+ ```bash
722
+ # Gunicorn 설치
723
+ pip install gunicorn
724
+
725
+ # systemd 서비스 파일 수정
726
+ ExecStart=/var/www/soy-nv-ai/venv/bin/gunicorn \
727
+ --bind 127.0.0.1:5001 \
728
+ --workers 4 \
729
+ --timeout 600 \
730
+ --access-logfile - \
731
+ --error-logfile - \
732
+ run:app
733
+ ```
734
+
735
+ ### 2. 데이터베이스 마이그레이션
736
+
737
+ SQLite 대신 PostgreSQL 사용 고려 (대규모 사용자):
738
+
739
+ ```bash
740
+ # PostgreSQL 설치
741
+ sudo apt install postgresql postgresql-contrib
742
+
743
+ # requirements.txt에 추가
744
+ # psycopg2-binary==2.9.9
745
+ ```
746
+
747
+ ### 3. CDN 설정
748
+
749
+ Cloudflare 사용 (무료):
750
+ - https://www.cloudflare.com/
751
+ - 도메인 DNS를 Cloudflare로 변경
752
+ - 캐싱 및 DDoS 보호
753
+
754
+ ---
755
+
756
+ ## 지원 및 도움말
757
+
758
+ 문제가 발생하면:
759
+ 1. 서버 로그 확인
760
+ 2. Nginx 로그 확인
761
+ 3. systemd 서비스 로그 확인
762
+ 4. DigitalOcean 문서 참조: https://docs.digitalocean.com/
763
+
764
+ ---
765
+
766
+ **배포 완료 후 웹사이트 접속:**
767
+ - HTTP: http://YOUR_SERVER_IP 또는 http://YOUR_DOMAIN.com
768
+ - HTTPS: https://YOUR_DOMAIN.com (SSL 설정 후)
769
+
770
+ 축하합니다! 배포가 완료되었습니다! 🎉
771
+
772
+
773
+
EXAONE_설치_가이드.md CHANGED
@@ -153,3 +153,5 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
153
 
154
 
155
 
 
 
 
153
 
154
 
155
 
156
+
157
+
EXAONE_추가_안내.md CHANGED
@@ -70,3 +70,5 @@ Ollama를 거치지 않고 Python에서 직접 Hugging Face 모델을 사용할
70
 
71
 
72
 
 
 
 
70
 
71
 
72
 
73
+
74
+
GIT_SETUP.md CHANGED
@@ -73,3 +73,5 @@ gh repo create soy-nv-ai --public --source=. --remote=origin --push
73
 
74
 
75
 
 
 
 
73
 
74
 
75
 
76
+
77
+
HOSTING_RECOMMENDATIONS.md ADDED
@@ -0,0 +1,259 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 웹 호스팅 서비스 추천 가이드
2
+
3
+ ## 프로젝트 특성 분석
4
+
5
+ 현재 프로젝트는 다음과 같은 특징을 가지고 있습니다:
6
+ - Flask 웹 애플리케이션
7
+ - SQLite 데이터베이스
8
+ - Ollama (로컬 AI 서버) 사용 - 포트 11434
9
+ - ChromaDB (벡터 DB) - 로컬 파일 시스템
10
+ - 파일 업로드 기능 (최대 100MB)
11
+ - ML 라이브러리 (sentence-transformers, numpy, chromadb)
12
+ - Gemini API도 사용 가능
13
+
14
+ ## 호스팅 옵션 비교
15
+
16
+ ### 1. VPS (Virtual Private Server) - 추천 ⭐⭐⭐⭐⭐
17
+
18
+ **왜 추천하는가:**
19
+ - Ollama와 같은 로컬 AI 서버를 실행할 수 있음
20
+ - 전체 시스템 접근 권한
21
+ - 파일 시스템 접근 (벡터 DB, 업로드 파일 등)
22
+ - 커스텀 포트 설정 가능
23
+
24
+ **추천 서비스:**
25
+
26
+ #### A. AWS Lightsail
27
+ - **가격**: $5/월 (512MB RAM) ~ $40/월 (8GB RAM)
28
+ - **장점**:
29
+ - AWS 생태계와 통합
30
+ - 자동 백업 기능
31
+ - 쉬운 스케일링
32
+ - **단점**: 초기 설정이 약간 복잡
33
+ - **추천 사양**: 4GB RAM 이상 (Ollama + ML 모델을 위해)
34
+
35
+ #### B. DigitalOcean Droplets
36
+ - **가격**: $6/월 (1GB RAM) ~ $48/월 (8GB RAM)
37
+ - **장점**:
38
+ - 매우 직관적인 UI
39
+ - 우수한 문서화
40
+ - 예측 가능한 가격
41
+ - **단점**: AWS보다 기능이 적음
42
+ - **추천 사양**: 8GB RAM 이상
43
+ - **링크**: https://www.digitalocean.com/
44
+
45
+ #### C. Vultr
46
+ - **가격**: $6/월 (1GB RAM) ~ $40/월 (8GB RAM)
47
+ - **장점**:
48
+ - 빠른 서버 생성
49
+ - 전 세계 데이터센터
50
+ - GPU 서버 옵션 (AI 작업에 유용)
51
+ - **단점**: 일부 지역은 속도가 느릴 수 있음
52
+ - **링크**: https://www.vultr.com/
53
+
54
+ #### D. Linode (Akamai)
55
+ - **가격**: $5/월 (1GB RAM) ~ $48/월 (8GB RAM)
56
+ - **장점**:
57
+ - 우수한 성능
58
+ - 24/7 고객 지원
59
+ - 백업 서비스 포함
60
+ - **링크**: https://www.linode.com/
61
+
62
+ ### 2. 클라우드 플랫폼 - 중급 추천 ⭐⭐⭐
63
+
64
+ #### A. Google Cloud Platform (GCP) Compute Engine
65
+ - **가격**: 사용한 만큼 지불 (예상 $20-50/월)
66
+ - **장점**:
67
+ - Gemini API와의 통합 용이
68
+ - 강력한 머신러닝 서비스
69
+ - 유연한 설정
70
+ - **단점**: 가격 예측이 어려움, 설정 복잡
71
+ - **추천 이유**: Gemini API를 사용하고 있어 통합이 용이
72
+
73
+ #### B. AWS EC2
74
+ - **가격**: 사용한 만큼 지불 또는 예약 인스턴스
75
+ - **장점**:
76
+ - 가장 많은 기능과 서비스
77
+ - 우수한 확장성
78
+ - **단점**: 가격 구조가 복잡, 초기 학습 곡선이 높음
79
+
80
+ ### 3. Platform-as-a-Service (PaaS) - 제한적 ⭐⭐
81
+
82
+ **주의사항:**
83
+ - Ollama를 직접 실행하기 어려움 (컨테이너 제약)
84
+ - 파일 시스템 접근 제한 (벡터 DB 문제)
85
+ - 일부 서비스는 ML 라이브러리 설치 제한
86
+
87
+ #### A. Railway
88
+ - **가격**: $5/월 + 사용량
89
+ - **장점**: 매우 쉬운 배포
90
+ - **단점**: Ollama 실행 어려움, 저장 공간 제한
91
+
92
+ #### B. Render
93
+ - **가격**: 무료 티어 있음, $7/월부터
94
+ - **장점**: 무료 시작 가능
95
+ - **단점**: 무료 티어는 제약이 많음, Ollama 실행 어려움
96
+
97
+ #### C. Heroku
98
+ - **가격**: $7/월부터
99
+ - **단점**: Ollama 실행 어려움, 벡터 DB 저장 공간 제한
100
+
101
+ ## 최종 추천 순위
102
+
103
+ ### 1순위: DigitalOcean Droplets (8GB RAM)
104
+ **이유:**
105
+ - 설정이 간단하고 직관적
106
+ - Ollama와 모든 의존성 설치 가능
107
+ - 예측 가능한 가격
108
+ - 우수한 성능
109
+
110
+ **예상 비용**: $48/월
111
+
112
+ ### 2순위: Vultr (8GB RAM)
113
+ **이유:**
114
+ - 빠른 서버 생성
115
+ - GPU 옵션 (향후 확장 가능)
116
+ - 경쟁력 있는 가격
117
+
118
+ **예상 비용**: $40/월
119
+
120
+ ### 3순위: AWS Lightsail (4GB RAM)
121
+ **이유:**
122
+ - AWS 생태계와의 통합
123
+ - 자동 백업
124
+ - 확장성
125
+
126
+ **예상 비용**: $20/월 (시작), 확장 시 추가 비용
127
+
128
+ ## 배포 시 고려사항
129
+
130
+ ### 필수 변경사항:
131
+
132
+ 1. **Ollama URL 변경**
133
+ ```python
134
+ # 프로덕션 환경에서는 내부 네트워크나 같은 서버에서 실행
135
+ OLLAMA_BASE_URL = 'http://localhost:11434' # 동일 서버
136
+ # 또는
137
+ OLLAMA_BASE_URL = 'http://ollama-server:11434' # 별도 서버
138
+ ```
139
+
140
+ 2. **데이터베이스 백업**
141
+ - SQLite는 주기적 백업 필요
142
+ - PostgreSQL 또는 MySQL로 마이그레이션 고려 (선택사항)
143
+
144
+ 3. **환경 변수 설정**
145
+ - `.env` 파일 또는 환경 변수로 설정
146
+ - SECRET_KEY, GEMINI_API_KEY 등
147
+
148
+ 4. **파일 업로드 경로**
149
+ - 영구 스토리지 사용
150
+ - 백업 전략 수립
151
+
152
+ 5. **프록시 서버 설정**
153
+ - Nginx 또는 Apache로 리버스 프록시
154
+ - HTTPS 설정 (Let's Encrypt 무료 SSL)
155
+
156
+ 6. **프로세스 관리**
157
+ - systemd, supervisor, 또는 PM2 사용
158
+ - 서버 재시작 시 자동 실행
159
+
160
+ 7. **로그 관리**
161
+ - 로그 파일 로테이션
162
+ - 외부 로그 서비스 연동 (선택사항)
163
+
164
+ ## 빠른 시작 가이드 (DigitalOcean 예시)
165
+
166
+ ### 1. Droplet 생성
167
+ - Ubuntu 22.04 LTS
168
+ - 8GB RAM
169
+ - 160GB SSD
170
+ - 지역: 서울 (Seoul) 또는 가까운 지역
171
+
172
+ ### 2. 초기 설정
173
+ ```bash
174
+ # 시스템 업데이트
175
+ sudo apt update && sudo apt upgrade -y
176
+
177
+ # 필수 패키지 설치
178
+ sudo apt install -y python3-pip python3-venv git nginx certbot python3-certbot-nginx
179
+
180
+ # Ollama 설치
181
+ curl -fsSL https://ollama.com/install.sh | sh
182
+
183
+ # 프로젝트 클론
184
+ git clone <your-repo-url>
185
+ cd "SOY NV AI"
186
+
187
+ # 가상환경 설정
188
+ python3 -m venv venv
189
+ source venv/bin/activate
190
+ pip install -r requirements.txt
191
+ ```
192
+
193
+ ### 3. 환경 변수 설정
194
+ ```bash
195
+ # .env 파일 생성
196
+ nano .env
197
+ ```
198
+
199
+ ### 4. Nginx 설정
200
+ ```nginx
201
+ server {
202
+ listen 80;
203
+ server_name your-domain.com;
204
+
205
+ location / {
206
+ proxy_pass http://127.0.0.1:5001;
207
+ proxy_set_header Host $host;
208
+ proxy_set_header X-Real-IP $remote_addr;
209
+ }
210
+ }
211
+ ```
212
+
213
+ ### 5. systemd 서비스 생성
214
+ ```bash
215
+ # /etc/systemd/system/soy-nv-ai.service
216
+ [Unit]
217
+ Description=SOY NV AI Flask App
218
+ After=network.target
219
+
220
+ [Service]
221
+ User=www-data
222
+ WorkingDirectory=/path/to/SOY NV AI
223
+ Environment="PATH=/path/to/SOY NV AI/venv/bin"
224
+ ExecStart=/path/to/SOY NV AI/venv/bin/python run.py
225
+
226
+ [Install]
227
+ WantedBy=multi-user.target
228
+ ```
229
+
230
+ ## 비용 비교표
231
+
232
+ | 서비스 | 최소 사양 | 월 비용 | 추천 사양 | 월 비용 |
233
+ |--------|----------|---------|----------|---------|
234
+ | DigitalOcean | 1GB RAM | $6 | 8GB RAM | $48 |
235
+ | Vultr | 1GB RAM | $6 | 8GB RAM | $40 |
236
+ | AWS Lightsail | 512MB RAM | $5 | 4GB RAM | $20 |
237
+ | Linode | 1GB RAM | $5 | 8GB RAM | $48 |
238
+ | GCP Compute Engine | - | 사용량 기반 | n1-standard-2 | ~$50 |
239
+
240
+ ## 추가 권장사항
241
+
242
+ 1. **도메인 구매**: Namecheap, GoDaddy 등에서 도메인 구매
243
+ 2. **SSL 인증서**: Let's Encrypt 무료 SSL 사용
244
+ 3. **모니터링**: Uptime Robot (무료) 또는 Pingdom
245
+ 4. **백업**: 자동 백업 스크립트 설정
246
+ 5. **CDN**: Cloudflare (무료) 사용 고려
247
+
248
+ ## 다음 단계
249
+
250
+ 1. 호스팅 서비스 선택
251
+ 2. 서버 생성 및 초기 설정
252
+ 3. 프로젝트 배포
253
+ 4. 도메인 연결 및 SSL 설정
254
+ 5. 모니터링 및 백업 설정
255
+
256
+ 결정하신 호스팅 서비스를 알려주시면 상세한 배포 가이드를 작성해드리겠습니다!
257
+
258
+
259
+
QUICK_DEPLOY.md ADDED
@@ -0,0 +1,153 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # 빠른 배포 가이드 (요약)
2
+
3
+ ## 1단계: DigitalOcean Droplet 생성
4
+
5
+ 1. https://www.digitalocean.com/ 접속
6
+ 2. Create → Droplets
7
+ 3. 설정:
8
+ - Ubuntu 22.04 LTS
9
+ - 8GB RAM / 4 vCPUs ($48/월)
10
+ - Singapore 또는 Seoul
11
+ 4. SSH 키 또는 비밀번호 설정
12
+ 5. Create Droplet
13
+
14
+ ## 2단계: 서버 접속 및 초기 설정
15
+
16
+ ```bash
17
+ # 서버 접속
18
+ ssh root@YOUR_SERVER_IP
19
+
20
+ # 시스템 업데이트 및 필수 패키지 설치
21
+ apt update && apt upgrade -y
22
+ apt install -y python3 python3-pip python3-venv git curl nginx certbot python3-certbot-nginx supervisor ufw build-essential
23
+
24
+ # 방화벽 설정
25
+ ufw allow OpenSSH
26
+ ufw allow 'Nginx Full'
27
+ ufw allow 11434/tcp
28
+ ufw enable
29
+ ```
30
+
31
+ ## 3단계: Ollama 설치
32
+
33
+ ```bash
34
+ curl -fsSL https://ollama.com/install.sh | sh
35
+ systemctl enable ollama
36
+ ollama pull llama2 # 또는 사용할 모델
37
+ ```
38
+
39
+ ## 4단계: 프로젝트 배포
40
+
41
+ ```bash
42
+ # 프로젝트 디렉토리 생성
43
+ mkdir -p /var/www
44
+ cd /var/www
45
+
46
+ # 프로젝트 파일 업로드 (Git 또는 SCP)
47
+ git clone YOUR_REPO_URL soy-nv-ai
48
+ # 또는
49
+ # scp -r "D:\SOY NV AI\*" root@YOUR_SERVER_IP:/var/www/soy-nv-ai/
50
+
51
+ cd soy-nv-ai
52
+
53
+ # 가상환경 설정
54
+ python3 -m venv venv
55
+ source venv/bin/activate
56
+ pip install --upgrade pip
57
+ pip install -r requirements.txt
58
+
59
+ # 환경 변수 설정
60
+ nano .env
61
+ # SECRET_KEY, OLLAMA_BASE_URL, GEMINI_API_KEY 등 설정
62
+
63
+ # 디렉토리 생성
64
+ mkdir -p instance uploads vector_db knowledge_graphs logs
65
+ chmod -R 775 uploads instance vector_db
66
+ ```
67
+
68
+ ## 5단계: systemd 서비스 설정
69
+
70
+ ```bash
71
+ # 서비스 파일 복사
72
+ cp systemd/soy-nv-ai.service /etc/systemd/system/
73
+
74
+ # 파일 수정 (경로 확인)
75
+ nano /etc/systemd/system/soy-nv-ai.service
76
+
77
+ # 서비스 활성화
78
+ systemctl daemon-reload
79
+ systemctl enable soy-nv-ai
80
+ systemctl start soy-nv-ai
81
+ systemctl status soy-nv-ai
82
+ ```
83
+
84
+ ## 6단계: Nginx 설정
85
+
86
+ ```bash
87
+ # Nginx 설정 파일 복사
88
+ cp nginx/soy-nv-ai.conf /etc/nginx/sites-available/soy-nv-ai
89
+
90
+ # 도메인/IP 수정
91
+ nano /etc/nginx/sites-available/soy-nv-ai
92
+
93
+ # 활성화
94
+ ln -s /etc/nginx/sites-available/soy-nv-ai /etc/nginx/sites-enabled/
95
+ rm /etc/nginx/sites-enabled/default # 선택사항
96
+
97
+ # 테스트 및 재시작
98
+ nginx -t
99
+ systemctl restart nginx
100
+ ```
101
+
102
+ ## 7단계: SSL 설정 (도메인 있는 경우)
103
+
104
+ ```bash
105
+ certbot --nginx -d YOUR_DOMAIN.com -d www.YOUR_DOMAIN.com
106
+ ```
107
+
108
+ ## 8단계: 배포 스크립트 실행 (선택사항)
109
+
110
+ ```bash
111
+ chmod +x deploy.sh
112
+ ./deploy.sh
113
+ ```
114
+
115
+ ## 완료!
116
+
117
+ 웹사이트 접속:
118
+ - HTTP: http://YOUR_SERVER_IP
119
+ - HTTPS: https://YOUR_DOMAIN.com (SSL 설정 후)
120
+
121
+ ## 문제 해결
122
+
123
+ ```bash
124
+ # 서비스 로그
125
+ journalctl -u soy-nv-ai -f
126
+
127
+ # Nginx 로그
128
+ tail -f /var/log/nginx/soy-nv-ai-error.log
129
+
130
+ # 서비스 재시작
131
+ systemctl restart soy-nv-ai
132
+ systemctl restart nginx
133
+ ```
134
+
135
+ ## 유용한 명령어
136
+
137
+ ```bash
138
+ # 서비스 관리
139
+ systemctl start/stop/restart/status soy-nv-ai
140
+
141
+ # 로그 확인
142
+ journalctl -u soy-nv-ai -n 50 -f
143
+
144
+ # 프로세스 확인
145
+ ps aux | grep python
146
+ htop
147
+
148
+ # 디스크 확인
149
+ df -h
150
+ ```
151
+
152
+
153
+
README_SERVER.md CHANGED
@@ -73,3 +73,5 @@ Start-Process powershell -ArgumentList "-File", "start_server_background.ps1" -W
73
 
74
 
75
 
 
 
 
73
 
74
 
75
 
76
+
77
+
add_exaone_model.py CHANGED
@@ -148,3 +148,5 @@ if __name__ == "__main__":
148
 
149
 
150
 
 
 
 
148
 
149
 
150
 
151
+
152
+
app/__init__.py CHANGED
@@ -49,10 +49,11 @@ def create_app() -> Flask:
49
  # 필수 디렉토리 생성
50
  config.ensure_directories()
51
 
52
- # 템플릿 폴더 경로 설정
53
  template_folder = str(config.TEMPLATES_FOLDER)
 
54
 
55
- app = Flask(__name__, template_folder=template_folder)
56
 
57
  # Flask 설정 적용
58
  app.config['SECRET_KEY'] = config.SECRET_KEY
 
49
  # 필수 디렉토리 생성
50
  config.ensure_directories()
51
 
52
+ # 템플릿 폴더 및 static 폴더 경로 설정
53
  template_folder = str(config.TEMPLATES_FOLDER)
54
+ static_folder = str(config.STATIC_FOLDER)
55
 
56
+ app = Flask(__name__, template_folder=template_folder, static_folder=static_folder)
57
 
58
  # Flask 설정 적용
59
  app.config['SECRET_KEY'] = config.SECRET_KEY
app/core/__init__.py CHANGED
@@ -7,3 +7,5 @@ from app.core.logger import get_logger
7
 
8
  __all__ = ['get_config', 'get_logger']
9
 
 
 
 
7
 
8
  __all__ = ['get_config', 'get_logger']
9
 
10
+
11
+
app/core/config.py CHANGED
@@ -34,6 +34,7 @@ class Config:
34
  VECTOR_DB_PATH: Path = PROJECT_ROOT / 'vector_db'
35
  KNOWLEDGE_GRAPH_PATH: Path = PROJECT_ROOT / 'knowledge_graphs'
36
  TEMPLATES_FOLDER: Path = PROJECT_ROOT / 'templates'
 
37
  INSTANCE_FOLDER: Path = PROJECT_ROOT / 'instance'
38
 
39
  # 파일 확장자 설정
@@ -52,6 +53,7 @@ class Config:
52
  cls.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
53
  cls.VECTOR_DB_PATH.mkdir(parents=True, exist_ok=True)
54
  cls.KNOWLEDGE_GRAPH_PATH.mkdir(parents=True, exist_ok=True)
 
55
  cls.INSTANCE_FOLDER.mkdir(parents=True, exist_ok=True)
56
 
57
 
@@ -59,3 +61,4 @@ def get_config() -> Config:
59
  """설정 인스턴스 반환"""
60
  return Config
61
 
 
 
34
  VECTOR_DB_PATH: Path = PROJECT_ROOT / 'vector_db'
35
  KNOWLEDGE_GRAPH_PATH: Path = PROJECT_ROOT / 'knowledge_graphs'
36
  TEMPLATES_FOLDER: Path = PROJECT_ROOT / 'templates'
37
+ STATIC_FOLDER: Path = PROJECT_ROOT / 'static'
38
  INSTANCE_FOLDER: Path = PROJECT_ROOT / 'instance'
39
 
40
  # 파일 확장자 설정
 
53
  cls.UPLOAD_FOLDER.mkdir(parents=True, exist_ok=True)
54
  cls.VECTOR_DB_PATH.mkdir(parents=True, exist_ok=True)
55
  cls.KNOWLEDGE_GRAPH_PATH.mkdir(parents=True, exist_ok=True)
56
+ cls.STATIC_FOLDER.mkdir(parents=True, exist_ok=True)
57
  cls.INSTANCE_FOLDER.mkdir(parents=True, exist_ok=True)
58
 
59
 
 
61
  """설정 인스턴스 반환"""
62
  return Config
63
 
64
+
app/core/logger.py CHANGED
@@ -63,3 +63,5 @@ def get_logger(name: str, level: int = logging.INFO) -> logging.Logger:
63
 
64
  return logger
65
 
 
 
 
63
 
64
  return logger
65
 
66
+
67
+
app/models/__init__.py CHANGED
@@ -21,3 +21,5 @@ __all__ = [
21
  'FileResponse',
22
  ]
23
 
 
 
 
21
  'FileResponse',
22
  ]
23
 
24
+
25
+
app/models/chunk.py CHANGED
@@ -48,3 +48,5 @@ class ChunkResponse(BaseModel):
48
  """Pydantic 설정"""
49
  from_attributes = True
50
 
 
 
 
48
  """Pydantic 설정"""
49
  from_attributes = True
50
 
51
+
52
+
app/models/file.py CHANGED
@@ -37,3 +37,5 @@ class FileResponse(BaseModel):
37
  datetime: lambda v: v.isoformat()
38
  }
39
 
 
 
 
37
  datetime: lambda v: v.isoformat()
38
  }
39
 
40
+
41
+
app/prompts/__init__.py CHANGED
@@ -11,3 +11,5 @@ __all__ = [
11
  'get_parent_chunk_analysis_prompt',
12
  ]
13
 
 
 
 
11
  'get_parent_chunk_analysis_prompt',
12
  ]
13
 
14
+
15
+
app/prompts/metadata.py CHANGED
@@ -37,3 +37,5 @@ def get_metadata_extraction_prompt(
37
 
38
  return prompt
39
 
 
 
 
37
 
38
  return prompt
39
 
40
+
41
+
app/prompts/parent_chunk.py CHANGED
@@ -50,3 +50,5 @@ def get_parent_chunk_analysis_prompt(
50
 
51
  return prompt
52
 
 
 
 
50
 
51
  return prompt
52
 
53
+
54
+
app/routes.py CHANGED
@@ -212,6 +212,96 @@ def extract_chapter_number(text):
212
 
213
  return None
214
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
215
  def extract_metadata_with_ai(chunk_content, full_content=None, parent_chunk=None, model_name=None):
216
  """AI를 사용하여 청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
217
 
@@ -346,7 +436,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
346
  }
347
 
348
  def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, file_id=None, model_name=None):
349
- """청크의 메타데이터 추출 (챕터, 화자, 등장인물, 시간적 배경, 인물 관계)
350
 
351
  Args:
352
  chunk_content: 분석할 청크 내용
@@ -356,27 +446,13 @@ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, f
356
  model_name: 사용할 AI 모델명
357
  """
358
  metadata = {
359
- "chapter": None,
360
  "pov": None,
361
  "characters": [],
362
  "time_background": None,
363
  "character_relationships": []
364
  }
365
 
366
- # 1. 챕터 번호 추출 (정규식 기반)
367
- chapter_num = extract_chapter_number(chunk_content)
368
- if chapter_num:
369
- metadata["chapter"] = chapter_num
370
- elif full_content and chunk_index is not None:
371
- # 청크 앞부분 컨텍스트에서 챕터 찾기
372
- context_start = max(0, sum(len(c) for c in chunk_content.split('\n')[:10]))
373
- if full_content and len(full_content) > context_start:
374
- context = full_content[:context_start + 1000]
375
- chapter_num = extract_chapter_number(context)
376
- if chapter_num:
377
- metadata["chapter"] = chapter_num
378
-
379
- # 2. AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
380
  # Parent Chunk가 있으면 참조
381
  parent_chunk = None
382
  if file_id:
@@ -396,7 +472,11 @@ def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, f
396
  return metadata
397
 
398
  def create_chunks_for_file(file_id, content):
399
- """파일 내용을 의미 기반 청크로 분할하여 저장 (벡터 DB 포함)
 
 
 
 
400
 
401
  Args:
402
  file_id: 파일 ID
@@ -423,50 +503,65 @@ def create_chunks_for_file(file_id, content):
423
  DocumentChunk.query.filter_by(file_id=file_id).delete()
424
  db.session.commit()
425
 
426
- # 의미 기반 청킹 (문장과 문단 경계를 고려하여 분할)
427
- # min_chunk_size: 최소 200자, max_chunk_size: 최대 1000자, overlap: 150자
428
- chunks = split_text_into_chunks(content, min_chunk_size=200, max_chunk_size=1000, overlap=150)
429
- print(f"[청크 생성] 분할된 청크 수: {len(chunks)}개")
 
430
 
431
- if len(chunks) == 0:
432
- print(f"[청크 생성] 경고: 청크가 생성되지 않았습니다. 텍스트가 너무 짧거나 비어있을 수 있습니다.")
433
  return 0
434
 
435
- # 각 청크를 데이터베이스와 벡터 DB에 저장
436
  saved_count = 0
437
  vector_saved_count = 0
 
438
 
439
- for idx, chunk_content in enumerate(chunks):
440
- try:
441
- # DB에 청크 저장 (메타데이터는 나중에 별도로 생성)
442
- chunk = DocumentChunk(
443
- file_id=file_id,
444
- chunk_index=idx,
445
- content=chunk_content,
446
- chunk_metadata=None # 메타데이터는 별도 API로 생성
447
- )
448
- db.session.add(chunk)
449
- db.session.flush() # ID 생성
450
-
451
- # 벡터 DB에 청크 추가
452
- if vector_db.add_chunk(
453
- chunk_id=chunk.id,
454
- chunk_content=chunk_content,
455
- file_id=file_id,
456
- chunk_index=idx
457
- ):
458
- vector_saved_count += 1
459
-
460
- saved_count += 1
461
-
462
- # 진행 상황 출력 (10개마다)
463
- if (idx + 1) % 10 == 0:
464
- print(f"[청크 생성] 진행 중: {idx + 1}/{len(chunks)}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count})")
465
- except Exception as e:
466
- print(f"[청크 생성] 경고: 청크 {idx} 저장 중 오류: {str(e)}")
467
- import traceback
468
- traceback.print_exc()
469
- continue
 
 
 
 
 
 
 
 
 
 
 
 
 
470
 
471
  db.session.commit()
472
  print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개)")
@@ -957,6 +1052,12 @@ def logout():
957
  def index():
958
  return render_template('index.html')
959
 
 
 
 
 
 
 
960
  @main_bp.route('/admin')
961
  @admin_required
962
  def admin():
@@ -982,6 +1083,12 @@ def admin_prompts():
982
  """프롬프트 관리 페이지"""
983
  return render_template('admin_prompts.html')
984
 
 
 
 
 
 
 
985
  @main_bp.route('/api/admin/users', methods=['GET'])
986
  @admin_required
987
  def get_users():
@@ -1987,34 +2094,34 @@ def upload_file():
1987
  content = f.read()
1988
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
1989
 
1990
- # 청크 생성 저장
1991
- log_print(f"[7/8] 청크 생성 함수 호출 중...")
1992
- chunk_count = create_chunks_for_file(uploaded_file.id, content)
1993
-
1994
- if chunk_count > 0:
1995
- log_print(f"[7/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
1996
- print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
1997
- else:
1998
- log_print(f"[7/8] ⚠️ 경고: 청크가 생성되지 않았습니다. (파일이 너무 짧거나 비어있을 수 있습니다.)")
1999
- print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
2000
-
2001
- # Parent Chunk 생성 (AI 분석)
2002
  try:
2003
- log_print(f"[8/8] Parent Chunk 생성 시작 (AI 분석)...")
2004
  parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
2005
  if parent_chunk:
2006
- log_print(f"[8/8] ✅ Parent Chunk 생성 완료: {original_filename}")
2007
  print(f"Parent Chunk가 생성되었습니다: {original_filename}")
2008
  else:
2009
- log_print(f"[8/8] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
2010
  print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
2011
  except Exception as parent_chunk_error:
2012
  # Parent Chunk 생성 실패해도 업로드는 계속 진행
2013
- log_print(f"[8/8] ⚠️ 경고: Parent Chunk 생성 중 예외 발생: {str(parent_chunk_error)}")
2014
  print(f"경고: Parent Chunk 생성 중 오류가 발생했습니다: {original_filename}")
2015
  import traceback
2016
  traceback.print_exc()
2017
 
 
 
 
 
 
 
 
 
 
 
 
2018
  except Exception as e:
2019
  error_msg = f"청크 생성 중 오류: {str(e)}"
2020
  log_print(f"[7/8] ❌ 오류: {error_msg}")
@@ -2090,13 +2197,30 @@ def get_files():
2090
  model_name = request.args.get('model_name', None)
2091
 
2092
  # 원본 파일만 조회 (parent_file_id가 None인 파일)
 
2093
  query = UploadedFile.query.filter_by(parent_file_id=None)
 
 
 
 
 
 
2094
  if model_name:
2095
  query = query.filter_by(model_name=model_name)
2096
  print(f"[파일 조회] 모델 '{model_name}' 필터링")
2097
 
2098
  files = query.order_by(UploadedFile.uploaded_at.desc()).all()
2099
 
 
 
 
 
 
 
 
 
 
 
2100
  # 각 원본 파일에 대해 이어서 업로드된 파일도 포함
2101
  files_with_children = []
2102
  for file in files:
@@ -2127,6 +2251,7 @@ def get_files():
2127
  model_stats = {}
2128
  if not model_name:
2129
  # 모든 모델의 통계 (원본 파일만)
 
2130
  all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
2131
  for file in all_files:
2132
  model = file.model_name or '미지정'
@@ -2186,6 +2311,55 @@ def get_file_chunks(file_id):
2186
  except Exception as e:
2187
  return jsonify({'error': f'청크 정보 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2188
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2189
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
2190
  @login_required
2191
  def get_file_parent_chunk(file_id):
@@ -2318,8 +2492,16 @@ def create_file_metadata(file_id):
2318
 
2319
  for chunk in chunks:
2320
  try:
2321
- # 메타데이터 추출
2322
- metadata = extract_chunk_metadata(
 
 
 
 
 
 
 
 
2323
  chunk_content=chunk.content,
2324
  full_content=content, # 원본 웹소설 전체 내용 참조
2325
  chunk_index=chunk.chunk_index,
@@ -2327,8 +2509,25 @@ def create_file_metadata(file_id):
2327
  model_name=file.model_name
2328
  )
2329
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2330
  # 메타데이터를 JSON 문자열로 변환
2331
- metadata_json = json.dumps(metadata, ensure_ascii=False) if metadata else None
2332
 
2333
  # 청크에 메타데이터 저장
2334
  chunk.chunk_metadata = metadata_json
 
212
 
213
  return None
214
 
215
+ def split_content_by_episodes(content):
216
+ """원본 웹소설을 #작품설명, #1화, #2화 등으로 분할
217
+
218
+ Returns:
219
+ list: [(section_type, section_title, section_content, metadata), ...]
220
+ section_type: '작품설명' or '화'
221
+ section_title: '작품설명' or '1화', '2화', ...
222
+ metadata: {'chapter': '#작품설명'} or {'chapter': '1화'}
223
+ """
224
+ if not content or len(content.strip()) == 0:
225
+ return []
226
+
227
+ sections = []
228
+
229
+ # #작품설명, #1화, #2화 등의 패턴 찾기
230
+ # 패턴: #작품설명, #1화, #2화, #10화 등
231
+ episode_pattern = r'^#\s*(작품설명|\d+화)'
232
+
233
+ lines = content.split('\n')
234
+ current_section_type = None
235
+ current_section_title = None
236
+ current_section_content = []
237
+ current_section_start_line = 0
238
+
239
+ for i, line in enumerate(lines):
240
+ # 줄 시작 부분에서 #작품설명 또는 #n화 패턴 찾기
241
+ match = re.match(episode_pattern, line.strip())
242
+
243
+ if match:
244
+ # 이전 섹션 저장
245
+ if current_section_type and current_section_content:
246
+ section_content = '\n'.join(current_section_content).strip()
247
+ if section_content:
248
+ # 메타데이터 생성
249
+ if current_section_type == '작품설명':
250
+ metadata = {'chapter': '#작품설명'}
251
+ else:
252
+ metadata = {'chapter': current_section_title}
253
+
254
+ sections.append((
255
+ current_section_type,
256
+ current_section_title,
257
+ section_content,
258
+ metadata
259
+ ))
260
+
261
+ # 새 섹션 시작
262
+ section_title = match.group(1)
263
+ if section_title == '작품설명':
264
+ current_section_type = '작품설명'
265
+ current_section_title = '작품설명'
266
+ else:
267
+ current_section_type = '화'
268
+ current_section_title = section_title # '1화', '2화' 등
269
+
270
+ current_section_content = [line] # 헤더 라인 포함
271
+ current_section_start_line = i
272
+ else:
273
+ # 현재 섹션에 내용 추가
274
+ if current_section_content is not None:
275
+ current_section_content.append(line)
276
+
277
+ # 마지막 섹션 저장
278
+ if current_section_type and current_section_content:
279
+ section_content = '\n'.join(current_section_content).strip()
280
+ if section_content:
281
+ # 메타데이터 생성
282
+ if current_section_type == '작품설명':
283
+ metadata = {'chapter': '#작품설명'}
284
+ else:
285
+ metadata = {'chapter': current_section_title}
286
+
287
+ sections.append((
288
+ current_section_type,
289
+ current_section_title,
290
+ section_content,
291
+ metadata
292
+ ))
293
+
294
+ # 섹션이 하나도 없으면 전체를 하나의 섹션으로 처리
295
+ if not sections:
296
+ sections.append((
297
+ '기타',
298
+ '전체',
299
+ content.strip(),
300
+ {'chapter': None}
301
+ ))
302
+
303
+ return sections
304
+
305
  def extract_metadata_with_ai(chunk_content, full_content=None, parent_chunk=None, model_name=None):
306
  """AI를 사용하여 청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
307
 
 
436
  }
437
 
438
  def extract_chunk_metadata(chunk_content, full_content=None, chunk_index=None, file_id=None, model_name=None):
439
+ """청크의 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
440
 
441
  Args:
442
  chunk_content: 분석할 청크 내용
 
446
  model_name: 사용할 AI 모델명
447
  """
448
  metadata = {
 
449
  "pov": None,
450
  "characters": [],
451
  "time_background": None,
452
  "character_relationships": []
453
  }
454
 
455
+ # AI를 사용한 메타데이터 추출 (화자, 등장인물, 시간적 배경, 인물 관계)
 
 
 
 
 
 
 
 
 
 
 
 
 
456
  # Parent Chunk가 있으면 참조
457
  parent_chunk = None
458
  if file_id:
 
472
  return metadata
473
 
474
  def create_chunks_for_file(file_id, content):
475
+ """파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
476
+
477
+ 섹션 분할 규칙:
478
+ - #작품설명부터 #1화까지: '작품설명' 섹션, 메타데이터에 #작품설명 추가
479
+ - #n화부터 #n+1화까지: 'n화' 섹션, 메타데이터에 회차 정보(n화) 추가
480
 
481
  Args:
482
  file_id: 파일 ID
 
503
  DocumentChunk.query.filter_by(file_id=file_id).delete()
504
  db.session.commit()
505
 
506
+ # 원본 웹소설을 섹션별로 분할 (#작품설명, #1화, #2화 )
507
+ sections = split_content_by_episodes(content)
508
+ print(f"[청크 생성] 섹션 분할 완료: {len(sections)}개 섹션")
509
+ for i, (section_type, section_title, section_content, section_metadata) in enumerate(sections):
510
+ print(f"[청크 생성] 섹션 {i+1}: {section_title} ({len(section_content)}자)")
511
 
512
+ if len(sections) == 0:
513
+ print(f"[청크 생성] 경고: 섹션이 생성되지 않았습니다.")
514
  return 0
515
 
516
+ # 각 섹션별로 청크 생성 저장
517
  saved_count = 0
518
  vector_saved_count = 0
519
+ global_chunk_index = 0 # 전체 청크 인덱스
520
 
521
+ for section_idx, (section_type, section_title, section_content, section_metadata) in enumerate(sections):
522
+ print(f"[청크 생성] 섹션 '{section_title}' 처리 중... ({len(section_content)}자)")
523
+
524
+ # 섹션을 의미 기반 청킹 (문장과 문단 경계를 고려하여 분할)
525
+ # min_chunk_size: 최소 200자, max_chunk_size: 최대 1000자, overlap: 150자
526
+ section_chunks = split_text_into_chunks(section_content, min_chunk_size=200, max_chunk_size=1000, overlap=150)
527
+ print(f"[청크 생성] 섹션 '{section_title}' 분할된 청크 수: {len(section_chunks)}개")
528
+
529
+ # 각 청크를 데이터베이스와 벡터 DB에 저장
530
+ for chunk_idx, chunk_content in enumerate(section_chunks):
531
+ try:
532
+ # 섹션 메타데이터를 기본으로 사용 (chapter 정보 포함)
533
+ chunk_metadata = section_metadata.copy()
534
+
535
+ # DB에 청크 저장 (섹션 메타데이터 포함)
536
+ chunk = DocumentChunk(
537
+ file_id=file_id,
538
+ chunk_index=global_chunk_index,
539
+ content=chunk_content,
540
+ chunk_metadata=json.dumps(chunk_metadata, ensure_ascii=False) # 섹션 메타데이터 저장
541
+ )
542
+ db.session.add(chunk)
543
+ db.session.flush() # ID 생성
544
+
545
+ # 벡터 DB에 청크 추가
546
+ if vector_db.add_chunk(
547
+ chunk_id=chunk.id,
548
+ chunk_content=chunk_content,
549
+ file_id=file_id,
550
+ chunk_index=global_chunk_index
551
+ ):
552
+ vector_saved_count += 1
553
+
554
+ saved_count += 1
555
+ global_chunk_index += 1
556
+
557
+ # 진행 상황 출력 (10개마다)
558
+ if saved_count % 10 == 0:
559
+ print(f"[청크 생성] 진행 중: {saved_count}개 청크 저장 중... (DB: {saved_count}, 벡터 DB: {vector_saved_count})")
560
+ except Exception as e:
561
+ print(f"[청크 생성] 경고: 청크 {global_chunk_index} 저장 중 오류: {str(e)}")
562
+ import traceback
563
+ traceback.print_exc()
564
+ continue
565
 
566
  db.session.commit()
567
  print(f"[청크 생성] 완료: {saved_count}개 청크가 데이터베이스에 저장되었습니다. (벡터 DB: {vector_saved_count}개)")
 
1052
  def index():
1053
  return render_template('index.html')
1054
 
1055
+ @main_bp.route('/webnovels')
1056
+ @login_required
1057
+ def webnovels():
1058
+ """업로드된 웹소설 목록 페이지"""
1059
+ return render_template('webnovels.html')
1060
+
1061
  @main_bp.route('/admin')
1062
  @admin_required
1063
  def admin():
 
1083
  """프롬프트 관리 페이지"""
1084
  return render_template('admin_prompts.html')
1085
 
1086
+ @main_bp.route('/admin/files')
1087
+ @admin_required
1088
+ def admin_files():
1089
+ """파일 목록 관리 페이지"""
1090
+ return render_template('admin_files.html')
1091
+
1092
  @main_bp.route('/api/admin/users', methods=['GET'])
1093
  @admin_required
1094
  def get_users():
 
2094
  content = f.read()
2095
  log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
2096
 
2097
+ # 1. Parent Chunk 생성 (AI 분석) - 먼저 생성
 
 
 
 
 
 
 
 
 
 
 
2098
  try:
2099
+ log_print(f"[7/8] Parent Chunk 생성 시작 (AI 분석)...")
2100
  parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
2101
  if parent_chunk:
2102
+ log_print(f"[7/8] ✅ Parent Chunk 생성 완료: {original_filename}")
2103
  print(f"Parent Chunk가 생성되었습니다: {original_filename}")
2104
  else:
2105
+ log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
2106
  print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
2107
  except Exception as parent_chunk_error:
2108
  # Parent Chunk 생성 실패해도 업로드는 계속 진행
2109
+ log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 중 예외 발생: {str(parent_chunk_error)}")
2110
  print(f"경고: Parent Chunk 생성 중 오류가 발생했습니다: {original_filename}")
2111
  import traceback
2112
  traceback.print_exc()
2113
 
2114
+ # 2. Child Chunk 생성 및 ��장 (섹션별 분할)
2115
+ log_print(f"[8/8] Child Chunk 생성 함수 호출 중...")
2116
+ chunk_count = create_chunks_for_file(uploaded_file.id, content)
2117
+
2118
+ if chunk_count > 0:
2119
+ log_print(f"[8/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
2120
+ print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
2121
+ else:
2122
+ log_print(f"[8/8] ⚠️ 경고: 청크가 생성되지 않았습니다. (파일이 너무 짧거나 비어있을 수 있습니다.)")
2123
+ print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
2124
+
2125
  except Exception as e:
2126
  error_msg = f"청크 생성 중 오류: {str(e)}"
2127
  log_print(f"[7/8] ❌ 오류: {error_msg}")
 
2197
  model_name = request.args.get('model_name', None)
2198
 
2199
  # 원본 파일만 조회 (parent_file_id가 None인 파일)
2200
+ # 모든 사용자가 업로드된 모든 파일을 볼 수 있음
2201
  query = UploadedFile.query.filter_by(parent_file_id=None)
2202
+ print(f"[파일 조회] 모든 파일 조회 (사용자: {current_user.username})")
2203
+
2204
+ # 모델 필터링 전 전체 파일 수 확인
2205
+ total_before_filter = query.count()
2206
+ print(f"[파일 조회] 필터링 전 파일 수: {total_before_filter}개")
2207
+
2208
  if model_name:
2209
  query = query.filter_by(model_name=model_name)
2210
  print(f"[파일 조회] 모델 '{model_name}' 필터링")
2211
 
2212
  files = query.order_by(UploadedFile.uploaded_at.desc()).all()
2213
 
2214
+ # 필터링 후 파일 수와 모델명 확인
2215
+ print(f"[파일 조회] 필터링 후 파일 수: {len(files)}개")
2216
+ if len(files) > 0:
2217
+ print(f"[파일 조회] 첫 번째 파일 모델명: {files[0].model_name}")
2218
+ else:
2219
+ # 필터링 결과가 없을 때 실제 존재하는 모델명 확인
2220
+ all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
2221
+ unique_models = set(f.model_name for f in all_files if f.model_name)
2222
+ print(f"[파일 조회] 데이터베이스에 존재하는 모델명 목록: {list(unique_models)}")
2223
+
2224
  # 각 원본 파일에 대해 이어서 업로드된 파일도 포함
2225
  files_with_children = []
2226
  for file in files:
 
2251
  model_stats = {}
2252
  if not model_name:
2253
  # 모든 모델의 통계 (원본 파일만)
2254
+ # 모든 사용자가 모든 파일을 볼 수 있음
2255
  all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
2256
  for file in all_files:
2257
  model = file.model_name or '미지정'
 
2311
  except Exception as e:
2312
  return jsonify({'error': f'청크 정보 조회 중 오류가 발생했습니다: {str(e)}'}), 500
2313
 
2314
+ @main_bp.route('/api/files/<int:file_id>/chunks/all', methods=['GET'])
2315
+ @login_required
2316
+ def get_all_file_chunks(file_id):
2317
+ """파일의 모든 청크 목록과 내용 조회 (관리자용)"""
2318
+ try:
2319
+ # 관리자는 모든 파일 조회 가능
2320
+ if current_user.is_admin:
2321
+ file = UploadedFile.query.get(file_id)
2322
+ else:
2323
+ file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
2324
+
2325
+ if not file:
2326
+ return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
2327
+
2328
+ chunks = DocumentChunk.query.filter_by(file_id=file_id).order_by(DocumentChunk.chunk_index.asc()).all()
2329
+
2330
+ chunks_data = []
2331
+ for chunk in chunks:
2332
+ chunk_dict = {
2333
+ 'id': chunk.id,
2334
+ 'chunk_index': chunk.chunk_index,
2335
+ 'content': chunk.content,
2336
+ 'content_length': len(chunk.content),
2337
+ 'created_at': chunk.created_at.isoformat() if chunk.created_at else None
2338
+ }
2339
+
2340
+ # 메타데이터 파싱
2341
+ if chunk.chunk_metadata:
2342
+ try:
2343
+ metadata = json.loads(chunk.chunk_metadata)
2344
+ chunk_dict['metadata'] = metadata
2345
+ except:
2346
+ chunk_dict['metadata'] = None
2347
+ else:
2348
+ chunk_dict['metadata'] = None
2349
+
2350
+ chunks_data.append(chunk_dict)
2351
+
2352
+ return jsonify({
2353
+ 'file_id': file_id,
2354
+ 'filename': file.original_filename,
2355
+ 'model_name': file.model_name,
2356
+ 'total_chunks': len(chunks_data),
2357
+ 'chunks': chunks_data
2358
+ }), 200
2359
+
2360
+ except Exception as e:
2361
+ return jsonify({'error': f'청크 목록 조회 중 오류가 ���생했습니다: {str(e)}'}), 500
2362
+
2363
  @main_bp.route('/api/files/<int:file_id>/parent-chunk', methods=['GET'])
2364
  @login_required
2365
  def get_file_parent_chunk(file_id):
 
2492
 
2493
  for chunk in chunks:
2494
  try:
2495
+ # 기존 메타데이터 읽기
2496
+ existing_metadata = {}
2497
+ if chunk.chunk_metadata:
2498
+ try:
2499
+ existing_metadata = json.loads(chunk.chunk_metadata)
2500
+ except:
2501
+ existing_metadata = {}
2502
+
2503
+ # 새 메타데이터 추출
2504
+ new_metadata = extract_chunk_metadata(
2505
  chunk_content=chunk.content,
2506
  full_content=content, # 원본 웹소설 전체 내용 참조
2507
  chunk_index=chunk.chunk_index,
 
2509
  model_name=file.model_name
2510
  )
2511
 
2512
+ # 기존 메타데이터와 새 메타데이터 병합 (새 메타데이터가 우선)
2513
+ # 기존 메타데이터의 모든 필드를 유지하되, 새로 추출한 필드로 업데이트
2514
+ # chapter 필드는 파일 업로드 시 추가된 회차 정보이므로 유지
2515
+ merged_metadata = existing_metadata.copy()
2516
+
2517
+ for key, value in new_metadata.items():
2518
+ if value is not None and value != []:
2519
+ # 리스트인 경우 중복 제거 후 병합
2520
+ if isinstance(value, list) and isinstance(merged_metadata.get(key), list):
2521
+ merged_list = merged_metadata.get(key, []).copy()
2522
+ for item in value:
2523
+ if item not in merged_list:
2524
+ merged_list.append(item)
2525
+ merged_metadata[key] = merged_list
2526
+ else:
2527
+ merged_metadata[key] = value
2528
+
2529
  # 메타데이터를 JSON 문자열로 변환
2530
+ metadata_json = json.dumps(merged_metadata, ensure_ascii=False) if merged_metadata else None
2531
 
2532
  # 청크에 메타데이터 저장
2533
  chunk.chunk_metadata = metadata_json
app/utils/__init__.py CHANGED
@@ -23,3 +23,5 @@ __all__ = [
23
  'clean_text',
24
  ]
25
 
 
 
 
23
  'clean_text',
24
  ]
25
 
26
+
27
+
app/utils/file_utils.py CHANGED
@@ -77,3 +77,5 @@ def ensure_upload_folder() -> Path:
77
  logger.error(f"업로드 폴더 생성 오류: {e}", exc_info=True)
78
  raise
79
 
 
 
 
77
  logger.error(f"업로드 폴더 생성 오류: {e}", exc_info=True)
78
  raise
79
 
80
+
81
+
app/utils/text_utils.py CHANGED
@@ -193,3 +193,5 @@ def extract_chapter_number(text: str) -> Optional[int]:
193
 
194
  return None
195
 
 
 
 
193
 
194
  return None
195
 
196
+
197
+
deploy.sh ADDED
@@ -0,0 +1,156 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ #!/bin/bash
2
+ # SOY NV AI 배포 스크립트
3
+ # 사용법: bash deploy.sh
4
+
5
+ set -e # 오류 발생 시 스크립트 중단
6
+
7
+ echo "=========================================="
8
+ echo "SOY NV AI 배포 스크립트"
9
+ echo "=========================================="
10
+
11
+ # 색상 정의
12
+ RED='\033[0;31m'
13
+ GREEN='\033[0;32m'
14
+ YELLOW='\033[1;33m'
15
+ NC='\033[0m' # No Color
16
+
17
+ # 변수 설정
18
+ PROJECT_DIR="/var/www/soy-nv-ai"
19
+ VENV_DIR="$PROJECT_DIR/venv"
20
+ SERVICE_NAME="soy-nv-ai"
21
+
22
+ # 함수 정의
23
+ print_success() {
24
+ echo -e "${GREEN}✓ $1${NC}"
25
+ }
26
+
27
+ print_error() {
28
+ echo -e "${RED}✗ $1${NC}"
29
+ }
30
+
31
+ print_info() {
32
+ echo -e "${YELLOW}ℹ $1${NC}"
33
+ }
34
+
35
+ # 1. 프로젝트 디렉토리 확인
36
+ echo ""
37
+ print_info "1. 프로젝트 디렉토리 확인..."
38
+ if [ ! -d "$PROJECT_DIR" ]; then
39
+ print_error "프로젝트 디렉토리를 찾을 수 없습니다: $PROJECT_DIR"
40
+ exit 1
41
+ fi
42
+ print_success "프로젝트 디렉토리 확인 완료"
43
+
44
+ # 2. 가상환경 확인 및 생성
45
+ echo ""
46
+ print_info "2. 가상환경 확인..."
47
+ if [ ! -d "$VENV_DIR" ]; then
48
+ print_info "가상환경이 없습니다. 생성 중..."
49
+ python3 -m venv "$VENV_DIR"
50
+ print_success "가상환경 생성 완료"
51
+ else
52
+ print_success "가상환경 확인 완료"
53
+ fi
54
+
55
+ # 3. 가상환경 활성화 및 의존성 설치
56
+ echo ""
57
+ print_info "3. 의존성 설치..."
58
+ source "$VENV_DIR/bin/activate"
59
+ pip install --upgrade pip --quiet
60
+ pip install -r "$PROJECT_DIR/requirements.txt" --quiet
61
+ print_success "의존성 설치 완료"
62
+
63
+ # 4. 필요한 디렉토리 생성
64
+ echo ""
65
+ print_info "4. 필요한 디렉토리 생성..."
66
+ mkdir -p "$PROJECT_DIR/instance"
67
+ mkdir -p "$PROJECT_DIR/uploads"
68
+ mkdir -p "$PROJECT_DIR/vector_db"
69
+ mkdir -p "$PROJECT_DIR/knowledge_graphs"
70
+ mkdir -p "$PROJECT_DIR/logs"
71
+ print_success "디렉토리 생성 완료"
72
+
73
+ # 5. 데이터베이스 초기화
74
+ echo ""
75
+ print_info "5. 데이터베이스 초기화..."
76
+ cd "$PROJECT_DIR"
77
+ python -c "from app import create_app; app = create_app(); app.app_context().push(); from app.database import db; db.create_all(); print('Database initialized')" 2>/dev/null || print_info "데이터베이스가 이미 존재하거나 초기화 중 오류 발생 (무시 가능)"
78
+ print_success "데이터베이스 확인 완료"
79
+
80
+ # 6. 권한 설정
81
+ echo ""
82
+ print_info "6. 권한 설정..."
83
+ chmod -R 755 "$PROJECT_DIR"
84
+ chmod -R 775 "$PROJECT_DIR/uploads" 2>/dev/null || true
85
+ chmod -R 775 "$PROJECT_DIR/instance" 2>/dev/null || true
86
+ chmod -R 775 "$PROJECT_DIR/vector_db" 2>/dev/null || true
87
+ print_success "권한 설정 완료"
88
+
89
+ # 7. .env 파일 확인
90
+ echo ""
91
+ print_info "7. 환경 변수 파일 확인..."
92
+ if [ ! -f "$PROJECT_DIR/.env" ]; then
93
+ print_error ".env 파일이 없습니다!"
94
+ print_info ".env 파일을 생성해주세요."
95
+ print_info "예시:"
96
+ echo "SECRET_KEY=your-secret-key"
97
+ echo "OLLAMA_BASE_URL=http://localhost:11434"
98
+ echo "FLASK_ENV=production"
99
+ else
100
+ print_success ".env 파일 확인 완료"
101
+ fi
102
+
103
+ # 8. systemd 서비스 확인
104
+ echo ""
105
+ print_info "8. systemd 서비스 확인..."
106
+ if systemctl is-active --quiet "$SERVICE_NAME"; then
107
+ print_info "서비스가 실행 중입니다. 재시작합니다..."
108
+ sudo systemctl restart "$SERVICE_NAME"
109
+ print_success "서비스 재시작 완료"
110
+ elif systemctl is-enabled --quiet "$SERVICE_NAME" 2>/dev/null; then
111
+ print_info "서비스를 시작합니다..."
112
+ sudo systemctl start "$SERVICE_NAME"
113
+ print_success "서비스 시작 완료"
114
+ else
115
+ print_info "서비스가 설정되지 않았습니다."
116
+ print_info "다음 명령어로 서비스를 설정하세요:"
117
+ echo "sudo systemctl enable $SERVICE_NAME"
118
+ echo "sudo systemctl start $SERVICE_NAME"
119
+ fi
120
+
121
+ # 9. 서비스 상태 확인
122
+ echo ""
123
+ print_info "9. 서비스 상태 확인..."
124
+ sleep 2
125
+ if systemctl is-active --quiet "$SERVICE_NAME"; then
126
+ print_success "서비스가 정상적으로 실행 중입니다!"
127
+ sudo systemctl status "$SERVICE_NAME" --no-pager -l
128
+ else
129
+ print_error "서비스가 실행되지 않았습니다!"
130
+ print_info "로그를 확인하세요:"
131
+ echo "sudo journalctl -u $SERVICE_NAME -n 50"
132
+ fi
133
+
134
+ # 10. Nginx 확인
135
+ echo ""
136
+ print_info "10. Nginx 상태 확인..."
137
+ if systemctl is-active --quiet nginx; then
138
+ print_success "Nginx가 실행 중입니다"
139
+ sudo nginx -t
140
+ else
141
+ print_info "Nginx가 실행되지 않았습니다"
142
+ fi
143
+
144
+ echo ""
145
+ echo "=========================================="
146
+ print_success "배포 스크립트 실행 완료!"
147
+ echo "=========================================="
148
+ echo ""
149
+ print_info "다음 단계:"
150
+ echo "1. 서비스 로그 확인: sudo journalctl -u $SERVICE_NAME -f"
151
+ echo "2. 웹사이트 접속 테스트: http://YOUR_SERVER_IP"
152
+ echo "3. Nginx 로그 확인: sudo tail -f /var/log/nginx/soy-nv-ai-error.log"
153
+ echo ""
154
+
155
+
156
+
download_exaone_model.py CHANGED
@@ -76,3 +76,5 @@ if __name__ == "__main__":
76
 
77
 
78
 
 
 
 
76
 
77
 
78
 
79
+
80
+
install_exaone_direct.py CHANGED
@@ -81,3 +81,5 @@ if __name__ == "__main__":
81
 
82
 
83
 
 
 
 
81
 
82
 
83
 
84
+
85
+
install_exaone_simple.py CHANGED
@@ -58,3 +58,5 @@ if __name__ == "__main__":
58
 
59
 
60
 
 
 
 
58
 
59
 
60
 
61
+
62
+
nginx/soy-nv-ai.conf ADDED
@@ -0,0 +1,57 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ server {
2
+ listen 80;
3
+ server_name YOUR_DOMAIN.com www.YOUR_DOMAIN.com;
4
+ # 또는 IP만 사용: server_name YOUR_SERVER_IP;
5
+
6
+ # 로그 설정
7
+ access_log /var/log/nginx/soy-nv-ai-access.log;
8
+ error_log /var/log/nginx/soy-nv-ai-error.log;
9
+
10
+ # 클라이언트 최대 업로드 크기 (100MB)
11
+ client_max_body_size 100M;
12
+ client_body_timeout 300s;
13
+ client_header_timeout 300s;
14
+
15
+ # 프록시 버퍼 설정
16
+ proxy_buffering off;
17
+ proxy_request_buffering off;
18
+
19
+ # 프록시 설정
20
+ location / {
21
+ proxy_pass http://127.0.0.1:5001;
22
+ proxy_set_header Host $host;
23
+ proxy_set_header X-Real-IP $remote_addr;
24
+ proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
25
+ proxy_set_header X-Forwarded-Proto $scheme;
26
+
27
+ # WebSocket 지원
28
+ proxy_http_version 1.1;
29
+ proxy_set_header Upgrade $http_upgrade;
30
+ proxy_set_header Connection "upgrade";
31
+
32
+ # 타임아웃 설정 (AI 응답이 오래 걸릴 수 있음)
33
+ proxy_connect_timeout 600s;
34
+ proxy_send_timeout 600s;
35
+ proxy_read_timeout 600s;
36
+
37
+ # 버퍼 설정
38
+ proxy_buffer_size 128k;
39
+ proxy_buffers 4 256k;
40
+ proxy_busy_buffers_size 256k;
41
+ }
42
+
43
+ # 정적 파일 직접 제공 (선택사항)
44
+ location /static {
45
+ alias /var/www/soy-nv-ai/static;
46
+ expires 30d;
47
+ add_header Cache-Control "public, immutable";
48
+ }
49
+
50
+ # 보안 헤더
51
+ add_header X-Frame-Options "SAMEORIGIN" always;
52
+ add_header X-Content-Type-Options "nosniff" always;
53
+ add_header X-XSS-Protection "1; mode=block" always;
54
+ }
55
+
56
+
57
+
setup_remote.ps1 CHANGED
@@ -66,3 +66,5 @@ Write-Host "=== 완료 ===" -ForegroundColor Green
66
 
67
 
68
 
 
 
 
66
 
67
 
68
 
69
+
70
+
static/logo.svg ADDED
static/logo.webp ADDED
systemd/soy-nv-ai.service ADDED
@@ -0,0 +1,34 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ [Unit]
2
+ Description=SOY NV AI Flask Application
3
+ After=network.target ollama.service
4
+ Requires=network.target
5
+
6
+ [Service]
7
+ Type=simple
8
+ User=deploy
9
+ Group=deploy
10
+ WorkingDirectory=/var/www/soy-nv-ai
11
+ Environment="PATH=/var/www/soy-nv-ai/venv/bin"
12
+ Environment="FLASK_ENV=production"
13
+ Environment="PYTHONUNBUFFERED=1"
14
+ ExecStart=/var/www/soy-nv-ai/venv/bin/python /var/www/soy-nv-ai/run.py
15
+ Restart=always
16
+ RestartSec=10
17
+ StandardOutput=journal
18
+ StandardError=journal
19
+ SyslogIdentifier=soy-nv-ai
20
+
21
+ # 리소스 제한
22
+ LimitNOFILE=65535
23
+ MemoryLimit=6G
24
+ CPUQuota=400%
25
+
26
+ # 보안 설정
27
+ NoNewPrivileges=true
28
+ PrivateTmp=true
29
+
30
+ [Install]
31
+ WantedBy=multi-user.target
32
+
33
+
34
+
templates/admin.html CHANGED
@@ -446,6 +446,7 @@
446
  <div class="header-actions">
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
 
449
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
450
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
451
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
 
446
  <div class="header-actions">
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
449
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
450
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
451
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
452
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
templates/admin_files.html ADDED
@@ -0,0 +1,629 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>파일 목록 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .btn {
47
+ padding: 8px 16px;
48
+ border: none;
49
+ border-radius: 6px;
50
+ font-size: 14px;
51
+ font-weight: 500;
52
+ cursor: pointer;
53
+ transition: all 0.2s;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ }
57
+
58
+ .btn-primary {
59
+ background: #1a73e8;
60
+ color: white;
61
+ }
62
+
63
+ .btn-primary:hover {
64
+ background: #1557b0;
65
+ }
66
+
67
+ .btn-secondary {
68
+ background: #f1f3f4;
69
+ color: #202124;
70
+ }
71
+
72
+ .btn-secondary:hover {
73
+ background: #e8eaed;
74
+ }
75
+
76
+ .btn-info {
77
+ background: #17a2b8;
78
+ color: white;
79
+ }
80
+
81
+ .btn-info:hover {
82
+ background: #138496;
83
+ }
84
+
85
+ .container {
86
+ max-width: 1400px;
87
+ margin: 0 auto;
88
+ padding: 24px;
89
+ }
90
+
91
+ .page-header {
92
+ margin-bottom: 24px;
93
+ }
94
+
95
+ .page-header h1 {
96
+ font-size: 28px;
97
+ font-weight: 600;
98
+ margin-bottom: 8px;
99
+ }
100
+
101
+ .page-header p {
102
+ color: #5f6368;
103
+ }
104
+
105
+ .card {
106
+ background: white;
107
+ border-radius: 8px;
108
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
109
+ padding: 24px;
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .card-header {
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: center;
117
+ margin-bottom: 20px;
118
+ }
119
+
120
+ .card-title {
121
+ font-size: 18px;
122
+ font-weight: 500;
123
+ }
124
+
125
+ table {
126
+ width: 100%;
127
+ border-collapse: collapse;
128
+ }
129
+
130
+ thead {
131
+ background: #f8f9fa;
132
+ }
133
+
134
+ th, td {
135
+ padding: 12px;
136
+ text-align: left;
137
+ border-bottom: 1px solid #e8eaed;
138
+ }
139
+
140
+ th {
141
+ font-weight: 500;
142
+ font-size: 14px;
143
+ color: #5f6368;
144
+ }
145
+
146
+ td {
147
+ font-size: 14px;
148
+ }
149
+
150
+ .file-actions {
151
+ display: flex;
152
+ gap: 4px;
153
+ }
154
+
155
+ .badge {
156
+ display: inline-block;
157
+ padding: 4px 8px;
158
+ border-radius: 4px;
159
+ font-size: 12px;
160
+ font-weight: 500;
161
+ }
162
+
163
+ .badge-success {
164
+ background: #e8f5e9;
165
+ color: #137333;
166
+ }
167
+
168
+ .badge-info {
169
+ background: #e8f0fe;
170
+ color: #1967d2;
171
+ }
172
+
173
+ .modal {
174
+ display: none;
175
+ position: fixed;
176
+ top: 0;
177
+ left: 0;
178
+ width: 100%;
179
+ height: 100%;
180
+ background: rgba(0, 0, 0, 0.5);
181
+ z-index: 1000;
182
+ align-items: center;
183
+ justify-content: center;
184
+ }
185
+
186
+ .modal.active {
187
+ display: flex;
188
+ }
189
+
190
+ .modal-content {
191
+ background: white;
192
+ border-radius: 8px;
193
+ padding: 24px;
194
+ width: 90%;
195
+ max-width: 1200px;
196
+ max-height: 90vh;
197
+ overflow-y: auto;
198
+ }
199
+
200
+ .modal-header {
201
+ display: flex;
202
+ justify-content: space-between;
203
+ align-items: center;
204
+ margin-bottom: 20px;
205
+ border-bottom: 1px solid #e8eaed;
206
+ padding-bottom: 16px;
207
+ }
208
+
209
+ .modal-title {
210
+ font-size: 20px;
211
+ font-weight: 500;
212
+ }
213
+
214
+ .modal-close {
215
+ background: none;
216
+ border: none;
217
+ font-size: 24px;
218
+ cursor: pointer;
219
+ color: #5f6368;
220
+ }
221
+
222
+ .chunk-list {
223
+ max-height: 60vh;
224
+ overflow-y: auto;
225
+ border: 1px solid #e8eaed;
226
+ border-radius: 6px;
227
+ padding: 12px;
228
+ }
229
+
230
+ .chunk-item {
231
+ padding: 12px;
232
+ margin-bottom: 12px;
233
+ border: 1px solid #e8eaed;
234
+ border-radius: 6px;
235
+ background: #f8f9fa;
236
+ cursor: pointer;
237
+ transition: all 0.2s;
238
+ }
239
+
240
+ .chunk-item:hover {
241
+ background: #e8f0fe;
242
+ border-color: #1a73e8;
243
+ }
244
+
245
+ .chunk-item-header {
246
+ display: flex;
247
+ justify-content: space-between;
248
+ align-items: center;
249
+ margin-bottom: 8px;
250
+ }
251
+
252
+ .chunk-item-index {
253
+ font-weight: 600;
254
+ color: #1a73e8;
255
+ }
256
+
257
+ .chunk-item-preview {
258
+ color: #5f6368;
259
+ font-size: 13px;
260
+ overflow: hidden;
261
+ text-overflow: ellipsis;
262
+ white-space: nowrap;
263
+ }
264
+
265
+ .chunk-content-modal {
266
+ max-width: 1000px;
267
+ }
268
+
269
+ .chunk-content {
270
+ white-space: pre-wrap;
271
+ font-family: inherit;
272
+ line-height: 1.6;
273
+ color: #202124;
274
+ padding: 16px;
275
+ background: #f8f9fa;
276
+ border-radius: 6px;
277
+ max-height: 70vh;
278
+ overflow-y: auto;
279
+ }
280
+
281
+ .chunk-metadata {
282
+ margin-top: 16px;
283
+ padding: 12px;
284
+ background: #e8f0fe;
285
+ border-radius: 6px;
286
+ font-size: 13px;
287
+ }
288
+
289
+ .chunk-metadata-title {
290
+ font-weight: 600;
291
+ margin-bottom: 8px;
292
+ color: #1967d2;
293
+ }
294
+
295
+ .chunk-metadata-item {
296
+ margin-bottom: 4px;
297
+ }
298
+
299
+ .filter-section {
300
+ margin-bottom: 16px;
301
+ display: flex;
302
+ gap: 12px;
303
+ align-items: center;
304
+ }
305
+
306
+ .filter-section select {
307
+ padding: 8px 12px;
308
+ border: 1px solid #dadce0;
309
+ border-radius: 6px;
310
+ font-size: 14px;
311
+ background: white;
312
+ }
313
+ </style>
314
+ </head>
315
+ <body>
316
+ <div class="header">
317
+ <div class="header-title">
318
+ <span>🤖</span>
319
+ <span>SOY NV AI 관리자 페이지</span>
320
+ </div>
321
+ <div class="header-actions">
322
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
323
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
324
+ <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
325
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
326
+ <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
327
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
328
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
329
+ </div>
330
+ </div>
331
+
332
+ <div class="container">
333
+ <div class="page-header">
334
+ <h1>파일 목록</h1>
335
+ <p>업로드된 파일과 생성된 청크를 확인할 수 있습니다.</p>
336
+ </div>
337
+
338
+ <div id="alertContainer"></div>
339
+
340
+ <div class="card">
341
+ <div class="card-header">
342
+ <div class="card-title">파일 목록</div>
343
+ <div class="filter-section">
344
+ <select id="modelFilter" onchange="loadFiles()">
345
+ <option value="">모든 모델</option>
346
+ </select>
347
+ </div>
348
+ </div>
349
+
350
+ <div id="filesTableContainer">
351
+ <table>
352
+ <thead>
353
+ <tr>
354
+ <th>ID</th>
355
+ <th>파일명</th>
356
+ <th>모델</th>
357
+ <th>청크 수</th>
358
+ <th>파일 크기</th>
359
+ <th>업로드일</th>
360
+ <th>작업</th>
361
+ </tr>
362
+ </thead>
363
+ <tbody id="filesTableBody">
364
+ <tr>
365
+ <td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">
366
+ 파일 목록을 불러오는 중...
367
+ </td>
368
+ </tr>
369
+ </tbody>
370
+ </table>
371
+ </div>
372
+ </div>
373
+ </div>
374
+
375
+ <!-- 청크 목록 모달 -->
376
+ <div id="chunksModal" class="modal">
377
+ <div class="modal-content">
378
+ <div class="modal-header">
379
+ <div class="modal-title" id="chunksModalTitle">청크 목록</div>
380
+ <button class="modal-close" onclick="closeChunksModal()">&times;</button>
381
+ </div>
382
+ <div id="chunksList" class="chunk-list">
383
+ <div style="text-align: center; padding: 24px; color: #5f6368;">
384
+ 청크 목록을 불러오는 중...
385
+ </div>
386
+ </div>
387
+ </div>
388
+ </div>
389
+
390
+ <!-- 청크 내용 모달 -->
391
+ <div id="chunkContentModal" class="modal">
392
+ <div class="modal-content chunk-content-modal">
393
+ <div class="modal-header">
394
+ <div class="modal-title" id="chunkContentModalTitle">청크 내용</div>
395
+ <button class="modal-close" onclick="closeChunkContentModal()">&times;</button>
396
+ </div>
397
+ <div id="chunkContent" class="chunk-content">
398
+ 내용을 불러오는 중...
399
+ </div>
400
+ </div>
401
+ </div>
402
+
403
+ <script>
404
+ function escapeHtml(text) {
405
+ const div = document.createElement('div');
406
+ div.textContent = text;
407
+ return div.innerHTML;
408
+ }
409
+
410
+ function formatFileSize(bytes) {
411
+ if (bytes === 0) return '0 Bytes';
412
+ const k = 1024;
413
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
414
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
415
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
416
+ }
417
+
418
+ function formatDate(dateString) {
419
+ const date = new Date(dateString);
420
+ return date.toLocaleDateString('ko-KR', {
421
+ year: 'numeric',
422
+ month: 'long',
423
+ day: 'numeric',
424
+ hour: '2-digit',
425
+ minute: '2-digit'
426
+ });
427
+ }
428
+
429
+ function showAlert(message, type = 'success') {
430
+ const container = document.getElementById('alertContainer');
431
+ container.innerHTML = `<div class="alert ${type}" style="padding: 12px 16px; border-radius: 6px; margin-bottom: 16px; font-size: 14px; background: ${type === 'success' ? '#e8f5e9' : '#fce8e6'}; color: ${type === 'success' ? '#137333' : '#c5221f'};">${message}</div>`;
432
+ setTimeout(() => {
433
+ container.innerHTML = '';
434
+ }, 5000);
435
+ }
436
+
437
+ async function loadModelFilter() {
438
+ try {
439
+ const response = await fetch('/api/ollama/models');
440
+ if (!response.ok) throw new Error('모델 목록을 불러올 수 없습니다.');
441
+ const data = await response.json();
442
+ const models = data.models || [];
443
+
444
+ const filter = document.getElementById('modelFilter');
445
+ filter.innerHTML = '<option value="">모든 모델</option>';
446
+ models.forEach(model => {
447
+ const option = document.createElement('option');
448
+ option.value = model.name;
449
+ option.textContent = model.name;
450
+ filter.appendChild(option);
451
+ });
452
+ } catch (error) {
453
+ console.error('모델 필터 로드 오류:', error);
454
+ }
455
+ }
456
+
457
+ async function loadFiles() {
458
+ const tbody = document.getElementById('filesTableBody');
459
+ const modelFilter = document.getElementById('modelFilter');
460
+ const modelName = modelFilter ? modelFilter.value : '';
461
+
462
+ tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">파일 목록을 불러오는 중...</td></tr>';
463
+
464
+ try {
465
+ const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files';
466
+ console.log('[파일 목록] API 요청:', url);
467
+ const response = await fetch(url, {
468
+ credentials: 'include'
469
+ });
470
+
471
+ console.log('[파일 목록] 응답 상태:', response.status, response.statusText);
472
+
473
+ if (!response.ok) {
474
+ const errorText = await response.text();
475
+ console.error('[파일 목록] 응답 오류:', errorText);
476
+ throw new Error(`파일 목록을 불러올 수 없습니다. (${response.status})`);
477
+ }
478
+
479
+ const data = await response.json();
480
+ console.log('[파일 목록] 응답 데이터:', data);
481
+ console.log('[파일 목록] files 배열:', data.files);
482
+
483
+ const files = data.files || [];
484
+ console.log('[파일 목록] 파일 개수:', files.length);
485
+
486
+ if (files.length === 0) {
487
+ tbody.innerHTML = '<tr><td colspan="7" style="text-align: center; padding: 24px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
488
+ return;
489
+ }
490
+
491
+ tbody.innerHTML = '';
492
+ files.forEach(file => {
493
+ console.log('[파일 목록] 파일 처리:', file);
494
+ const row = document.createElement('tr');
495
+ row.innerHTML = `
496
+ <td>${file.id}</td>
497
+ <td>${escapeHtml(file.original_filename)}</td>
498
+ <td>${escapeHtml(file.model_name || '미지정')}</td>
499
+ <td><span class="badge badge-info">${file.chunk_count || 0}개</span></td>
500
+ <td>${formatFileSize(file.file_size || 0)}</td>
501
+ <td>${formatDate(file.uploaded_at)}</td>
502
+ <td>
503
+ <div class="file-actions">
504
+ <button class="btn btn-info" onclick="viewChunks(${file.id}, '${escapeHtml(file.original_filename)}')" style="padding: 4px 8px; font-size: 12px;">청크 보기</button>
505
+ </div>
506
+ </td>
507
+ `;
508
+ tbody.appendChild(row);
509
+ });
510
+ } catch (error) {
511
+ console.error('[파일 목록] 로드 오류:', error);
512
+ tbody.innerHTML = `<tr><td colspan="7" style="text-align: center; padding: 24px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.<br><small>${escapeHtml(error.message)}</small></td></tr>`;
513
+ showAlert(`파일 목록을 불러오는 중 오류가 발생했습니다: ${error.message}`, 'error');
514
+ }
515
+ }
516
+
517
+ async function viewChunks(fileId, fileName) {
518
+ const modal = document.getElementById('chunksModal');
519
+ const title = document.getElementById('chunksModalTitle');
520
+ const list = document.getElementById('chunksList');
521
+
522
+ title.textContent = `청크 목록 - ${fileName}`;
523
+ list.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">청크 목록을 불러오는 중...</div>';
524
+ modal.classList.add('active');
525
+
526
+ try {
527
+ const response = await fetch(`/api/files/${fileId}/chunks/all`);
528
+ if (!response.ok) throw new Error('청크 목록을 불러올 수 없습니다.');
529
+
530
+ const data = await response.json();
531
+ const chunks = data.chunks || [];
532
+
533
+ if (chunks.length === 0) {
534
+ list.innerHTML = '<div style="text-align: center; padding: 24px; color: #5f6368;">생성된 청크가 없습니다.</div>';
535
+ return;
536
+ }
537
+
538
+ list.innerHTML = '';
539
+ chunks.forEach(chunk => {
540
+ const chunkItem = document.createElement('div');
541
+ chunkItem.className = 'chunk-item';
542
+ chunkItem.onclick = () => viewChunkContent(chunk, fileName);
543
+
544
+ const preview = chunk.content.length > 150
545
+ ? chunk.content.substring(0, 150) + '...'
546
+ : chunk.content;
547
+
548
+ chunkItem.innerHTML = `
549
+ <div class="chunk-item-header">
550
+ <span class="chunk-item-index">청크 #${chunk.chunk_index}</span>
551
+ <span style="font-size: 12px; color: #5f6368;">${chunk.content_length}자</span>
552
+ </div>
553
+ <div class="chunk-item-preview">${escapeHtml(preview)}</div>
554
+ `;
555
+ list.appendChild(chunkItem);
556
+ });
557
+ } catch (error) {
558
+ console.error('청크 목록 로드 오류:', error);
559
+ list.innerHTML = '<div style="text-align: center; padding: 24px; color: #c5221f;">청크 목록을 불러오는 중 오류가 발생했습니다.</div>';
560
+ showAlert('청크 목록을 불러오는 중 오류가 발생했습니다.', 'error');
561
+ }
562
+ }
563
+
564
+ function closeChunksModal() {
565
+ document.getElementById('chunksModal').classList.remove('active');
566
+ }
567
+
568
+ function viewChunkContent(chunk, fileName) {
569
+ const modal = document.getElementById('chunkContentModal');
570
+ const title = document.getElementById('chunkContentModalTitle');
571
+ const content = document.getElementById('chunkContent');
572
+
573
+ title.textContent = `${fileName} - 청크 #${chunk.chunk_index}`;
574
+
575
+ let contentHtml = `<div style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: #202124; margin-bottom: 16px;">${escapeHtml(chunk.content)}</div>`;
576
+
577
+ if (chunk.metadata) {
578
+ contentHtml += '<div class="chunk-metadata">';
579
+ contentHtml += '<div class="chunk-metadata-title">메타데이터</div>';
580
+
581
+ if (chunk.metadata.chapter) {
582
+ contentHtml += `<div class="chunk-metadata-item"><strong>챕터:</strong> ${escapeHtml(chunk.metadata.chapter)}</div>`;
583
+ }
584
+ if (chunk.metadata.pov) {
585
+ contentHtml += `<div class="chunk-metadata-item"><strong>시점:</strong> ${escapeHtml(chunk.metadata.pov)}</div>`;
586
+ }
587
+ if (chunk.metadata.characters && chunk.metadata.characters.length > 0) {
588
+ contentHtml += `<div class="chunk-metadata-item"><strong>등장 인물:</strong> ${escapeHtml(chunk.metadata.characters.join(', '))}</div>`;
589
+ }
590
+ if (chunk.metadata.time_background) {
591
+ contentHtml += `<div class="chunk-metadata-item"><strong>시간 배경:</strong> ${escapeHtml(chunk.metadata.time_background)}</div>`;
592
+ }
593
+ if (chunk.metadata.character_relationships && chunk.metadata.character_relationships.length > 0) {
594
+ contentHtml += `<div class="chunk-metadata-item"><strong>인물 관계:</strong> ${escapeHtml(JSON.stringify(chunk.metadata.character_relationships, null, 2))}</div>`;
595
+ }
596
+
597
+ contentHtml += '</div>';
598
+ }
599
+
600
+ content.innerHTML = contentHtml;
601
+ modal.classList.add('active');
602
+ }
603
+
604
+ function closeChunkContentModal() {
605
+ document.getElementById('chunkContentModal').classList.remove('active');
606
+ }
607
+
608
+ // 모달 외부 클릭 시 닫기
609
+ document.getElementById('chunksModal').addEventListener('click', function(e) {
610
+ if (e.target === this) {
611
+ closeChunksModal();
612
+ }
613
+ });
614
+
615
+ document.getElementById('chunkContentModal').addEventListener('click', function(e) {
616
+ if (e.target === this) {
617
+ closeChunkContentModal();
618
+ }
619
+ });
620
+
621
+ // 페이지 로드 시 초기화
622
+ window.addEventListener('load', () => {
623
+ loadModelFilter();
624
+ loadFiles();
625
+ });
626
+ </script>
627
+ </body>
628
+ </html>
629
+
templates/admin_prompts.html CHANGED
@@ -317,3 +317,5 @@
317
  </body>
318
  </html>
319
 
 
 
 
317
  </body>
318
  </html>
319
 
320
+
321
+
templates/admin_webnovels.html CHANGED
@@ -444,6 +444,7 @@
444
  <div class="header-actions">
445
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
446
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
 
447
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
448
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
449
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
 
444
  <div class="header-actions">
445
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
446
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
447
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
448
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
449
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
450
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
templates/index.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>SOY NV AI</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
  <style>
@@ -50,9 +50,76 @@
50
  }
51
 
52
  .sidebar.collapsed {
53
- width: 0;
54
- overflow: hidden;
55
- border-right: none;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  }
57
 
58
  .sidebar-header {
@@ -73,6 +140,12 @@
73
  gap: 8px;
74
  }
75
 
 
 
 
 
 
 
76
  .sidebar-toggle {
77
  background: none;
78
  border: none;
@@ -370,6 +443,131 @@
370
  color: var(--accent);
371
  }
372
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
373
  /* 로그아웃 버튼 */
374
  .sidebar-footer {
375
  border-top: 1px solid var(--border);
@@ -436,9 +634,10 @@
436
  gap: 12px;
437
  }
438
 
439
- .header-title::before {
440
- content: '🤖';
441
- font-size: 24px;
 
442
  }
443
 
444
  .header-actions {
@@ -861,8 +1060,8 @@
861
  <div class="sidebar" id="sidebar">
862
  <div class="sidebar-header">
863
  <div class="sidebar-title">
864
- <span>🤖</span>
865
- <span>SOY NV AI</span>
866
  </div>
867
  <button class="sidebar-toggle" onclick="toggleSidebar()" title="사이드바 접기">
868
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -874,7 +1073,7 @@
874
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
875
  <path d="M12 5v14M5 12h14"/>
876
  </svg>
877
- 대화
878
  </button>
879
  <div class="chat-history" id="chatHistory">
880
  <!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
@@ -912,7 +1111,7 @@
912
  학습할 웹소설 선택
913
  </div>
914
  <div class="novel-list" id="novelList">
915
- <div class="novel-item-empty">모델을 선택하면 웹소설 목록이 표시됩니다</div>
916
  </div>
917
  <div class="selected-novels-info" id="selectedNovelsInfo"></div>
918
  </div>
@@ -938,9 +1137,14 @@
938
  <path d="M3 12h18M3 6h18M3 18h18"/>
939
  </svg>
940
  </button>
941
- <span>SOY NV AI</span>
942
  </div>
943
  <div class="header-actions">
 
 
 
 
 
944
  {% if current_user.is_admin %}
945
  <a href="{{ url_for('main.admin') }}" class="btn-icon" title="관리자 페이지" style="text-decoration: none; color: var(--text-secondary);">
946
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
@@ -966,7 +1170,7 @@
966
  <div class="chat-container" id="chatContainer">
967
  <div class="empty-state" id="emptyState">
968
  <div class="empty-state-icon">💬</div>
969
- <div class="empty-state-title">SOY NV AI에 오신 것을 환영합니다</div>
970
  <div class="empty-state-description">
971
  무엇이든 물어보세요. AI가 도와드리겠습니다.
972
  </div>
@@ -990,6 +1194,78 @@
990
  </div>
991
  </div>
992
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
993
  <script>
994
  const chatContainer = document.getElementById('chatContainer');
995
  const messageInput = document.getElementById('messageInput');
@@ -1021,20 +1297,56 @@
1021
 
1022
  // 웹소설 목록 로드
1023
  async function loadNovels() {
1024
- if (!selectedModel) {
1025
- novelList.innerHTML = '<div class="novel-item-empty">모델을 선택하면 웹소설 목록이 표시됩니다</div>';
1026
- selectedNovelsInfo.textContent = '';
1027
- return;
1028
- }
1029
-
1030
  try {
1031
- const response = await fetch(`/api/files?model_name=${encodeURIComponent(selectedModel)}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1032
  const data = await response.json();
 
 
1033
 
1034
  novelList.innerHTML = '';
1035
 
1036
- if (data.files && data.files.length > 0) {
1037
- data.files.forEach(file => {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1038
  const novelItem = document.createElement('div');
1039
  novelItem.className = 'novel-item';
1040
 
@@ -1048,8 +1360,12 @@
1048
  const label = document.createElement('label');
1049
  label.className = 'novel-item-name';
1050
  label.htmlFor = `novel-${file.id}`;
1051
- label.textContent = file.original_filename;
1052
- label.title = file.original_filename;
 
 
 
 
1053
 
1054
  novelItem.appendChild(checkbox);
1055
  novelItem.appendChild(label);
@@ -1057,12 +1373,14 @@
1057
  });
1058
  updateSelectedNovelsInfo();
1059
  } else {
 
1060
  novelList.innerHTML = '<div class="novel-item-empty">업로드된 웹소설이 없습니다</div>';
1061
  selectedNovelsInfo.textContent = '';
1062
  }
1063
  } catch (error) {
1064
- console.error('웹소설 목록 로드 오류:', error);
1065
- novelList.innerHTML = '<div class="novel-item-empty">웹소설 목록을 불러올 수 없습니다</div>';
 
1066
  }
1067
  }
1068
 
@@ -1518,13 +1836,318 @@
1518
  }
1519
  }
1520
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1521
  // 페이지 로드 시 초기화
1522
  window.addEventListener('load', async () => {
1523
  await loadChatHistory();
1524
  await loadModels();
1525
- if (selectedModel) {
1526
- loadNovels();
1527
- }
1528
  messageInput.focus();
1529
  });
1530
  </script>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>SOY AI 작품 개발 어시스턴트</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
  <style>
 
50
  }
51
 
52
  .sidebar.collapsed {
53
+ width: 64px;
54
+ }
55
+
56
+ .sidebar.collapsed .sidebar-title-text {
57
+ display: none;
58
+ }
59
+
60
+ .sidebar.collapsed .sidebar-title {
61
+ justify-content: center;
62
+ }
63
+
64
+ .sidebar.collapsed .sidebar-logo {
65
+ display: none;
66
+ }
67
+
68
+ .sidebar.collapsed .new-chat-button span {
69
+ display: none;
70
+ }
71
+
72
+ .sidebar.collapsed .new-chat-button {
73
+ justify-content: center;
74
+ padding: 12px;
75
+ border-radius: 50%;
76
+ width: 40px;
77
+ height: 40px;
78
+ margin: 12px auto;
79
+ }
80
+
81
+ .sidebar.collapsed .chat-item-title,
82
+ .sidebar.collapsed .chat-item-time {
83
+ display: none;
84
+ }
85
+
86
+ .sidebar.collapsed .chat-item {
87
+ justify-content: center;
88
+ padding: 12px;
89
+ }
90
+
91
+ .sidebar.collapsed .model-selector-label,
92
+ .sidebar.collapsed .model-select,
93
+ .sidebar.collapsed .model-status,
94
+ .sidebar.collapsed .refresh-models-btn {
95
+ display: none;
96
+ }
97
+
98
+ .sidebar.collapsed .novel-selector-label,
99
+ .sidebar.collapsed .novel-list,
100
+ .sidebar.collapsed .selected-novels-info {
101
+ display: none;
102
+ }
103
+
104
+ .sidebar.collapsed .novel-selector {
105
+ padding: 8px;
106
+ }
107
+
108
+ .sidebar.collapsed .logout-button span {
109
+ display: none;
110
+ }
111
+
112
+ .sidebar.collapsed .logout-button {
113
+ justify-content: center;
114
+ padding: 12px;
115
+ border-radius: 50%;
116
+ width: 40px;
117
+ height: 40px;
118
+ margin: 0 auto;
119
+ }
120
+
121
+ .sidebar.collapsed .chat-history {
122
+ padding: 4px;
123
  }
124
 
125
  .sidebar-header {
 
140
  gap: 8px;
141
  }
142
 
143
+ .sidebar-title img {
144
+ width: 24px;
145
+ height: 24px;
146
+ object-fit: contain;
147
+ }
148
+
149
  .sidebar-toggle {
150
  background: none;
151
  border: none;
 
443
  color: var(--accent);
444
  }
445
 
446
+ /* 모달 스타일 */
447
+ .modal {
448
+ display: none;
449
+ position: fixed;
450
+ z-index: 1000;
451
+ left: 0;
452
+ top: 0;
453
+ width: 100%;
454
+ height: 100%;
455
+ background-color: rgba(0, 0, 0, 0.5);
456
+ backdrop-filter: blur(4px);
457
+ }
458
+
459
+ .modal-content {
460
+ background-color: var(--bg-primary);
461
+ margin: 5% auto;
462
+ border-radius: 12px;
463
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
464
+ width: 90%;
465
+ max-width: 800px;
466
+ overflow: hidden;
467
+ display: flex;
468
+ flex-direction: column;
469
+ }
470
+
471
+ .modal-header {
472
+ padding: 20px 24px;
473
+ border-bottom: 1px solid var(--border);
474
+ display: flex;
475
+ justify-content: space-between;
476
+ align-items: center;
477
+ background: var(--bg-secondary);
478
+ }
479
+
480
+ .modal-header h2 {
481
+ margin: 0;
482
+ font-size: 20px;
483
+ font-weight: 500;
484
+ color: var(--text-primary);
485
+ }
486
+
487
+ .modal-close {
488
+ background: none;
489
+ border: none;
490
+ font-size: 28px;
491
+ color: var(--text-secondary);
492
+ cursor: pointer;
493
+ padding: 0;
494
+ width: 32px;
495
+ height: 32px;
496
+ display: flex;
497
+ align-items: center;
498
+ justify-content: center;
499
+ border-radius: 50%;
500
+ transition: background 0.2s;
501
+ }
502
+
503
+ .modal-close:hover {
504
+ background: var(--bg-tertiary);
505
+ }
506
+
507
+ .modal-body {
508
+ padding: 24px;
509
+ overflow-y: auto;
510
+ }
511
+
512
+ .webnovel-item {
513
+ padding: 16px;
514
+ margin-bottom: 12px;
515
+ border: 1px solid var(--border);
516
+ border-radius: 8px;
517
+ background: var(--bg-secondary);
518
+ cursor: pointer;
519
+ transition: all 0.2s;
520
+ }
521
+
522
+ .webnovel-item:hover {
523
+ background: var(--bg-tertiary);
524
+ border-color: var(--accent);
525
+ }
526
+
527
+ .webnovel-item-header {
528
+ display: flex;
529
+ justify-content: space-between;
530
+ align-items: center;
531
+ margin-bottom: 8px;
532
+ }
533
+
534
+ .webnovel-item-title {
535
+ font-size: 16px;
536
+ font-weight: 500;
537
+ color: var(--text-primary);
538
+ }
539
+
540
+ .webnovel-item-meta {
541
+ font-size: 12px;
542
+ color: var(--text-secondary);
543
+ display: flex;
544
+ gap: 12px;
545
+ flex-wrap: wrap;
546
+ }
547
+
548
+ .webnovel-item-actions {
549
+ display: flex;
550
+ gap: 8px;
551
+ margin-top: 12px;
552
+ }
553
+
554
+ .webnovel-item-btn {
555
+ padding: 6px 12px;
556
+ border: 1px solid var(--border);
557
+ border-radius: 6px;
558
+ background: var(--bg-primary);
559
+ color: var(--text-primary);
560
+ font-size: 12px;
561
+ cursor: pointer;
562
+ transition: all 0.2s;
563
+ }
564
+
565
+ .webnovel-item-btn:hover {
566
+ background: var(--accent);
567
+ color: white;
568
+ border-color: var(--accent);
569
+ }
570
+
571
  /* 로그아웃 버튼 */
572
  .sidebar-footer {
573
  border-top: 1px solid var(--border);
 
634
  gap: 12px;
635
  }
636
 
637
+ .header-title img {
638
+ width: 24px;
639
+ height: 24px;
640
+ object-fit: contain;
641
  }
642
 
643
  .header-actions {
 
1060
  <div class="sidebar" id="sidebar">
1061
  <div class="sidebar-header">
1062
  <div class="sidebar-title">
1063
+ <img src="{{ url_for('static', filename='logo.webp') }}" alt="SOY AI 로고" class="sidebar-logo">
1064
+ <span class="sidebar-title-text">작품 개발 어시스턴트</span>
1065
  </div>
1066
  <button class="sidebar-toggle" onclick="toggleSidebar()" title="사이드바 접기">
1067
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
1073
  <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1074
  <path d="M12 5v14M5 12h14"/>
1075
  </svg>
1076
+ <span>새 대화</span>
1077
  </button>
1078
  <div class="chat-history" id="chatHistory">
1079
  <!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
 
1111
  학습할 웹소설 선택
1112
  </div>
1113
  <div class="novel-list" id="novelList">
1114
+ <div class="novel-item-empty">웹소설 목록을 불러오는 중...</div>
1115
  </div>
1116
  <div class="selected-novels-info" id="selectedNovelsInfo"></div>
1117
  </div>
 
1137
  <path d="M3 12h18M3 6h18M3 18h18"/>
1138
  </svg>
1139
  </button>
1140
+ <span></span>
1141
  </div>
1142
  <div class="header-actions">
1143
+ <a href="{{ url_for('main.webnovels') }}" class="btn-icon" title="웹소설 보기" style="text-decoration: none; color: var(--text-secondary);">
1144
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1145
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
1146
+ </svg>
1147
+ </a>
1148
  {% if current_user.is_admin %}
1149
  <a href="{{ url_for('main.admin') }}" class="btn-icon" title="관리자 페이지" style="text-decoration: none; color: var(--text-secondary);">
1150
  <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
 
1170
  <div class="chat-container" id="chatContainer">
1171
  <div class="empty-state" id="emptyState">
1172
  <div class="empty-state-icon">💬</div>
1173
+ <div class="empty-state-title">SOYMEDIA 작품 세계에 오신 것을 환영합니다</div>
1174
  <div class="empty-state-description">
1175
  무엇이든 물어보세요. AI가 도와드리겠습니다.
1176
  </div>
 
1194
  </div>
1195
  </div>
1196
 
1197
+ <!-- 웹소설 보기 모달 -->
1198
+ <div id="webnovelsModal" class="modal" style="display: none;">
1199
+ <div class="modal-content" style="max-width: 900px; max-height: 90vh;">
1200
+ <div class="modal-header">
1201
+ <h2>업로드된 웹소설</h2>
1202
+ <button class="modal-close" onclick="closeWebnovelsModal()">&times;</button>
1203
+ </div>
1204
+ <div class="modal-body" style="display: flex; flex-direction: column; height: calc(90vh - 120px);">
1205
+ <div style="margin-bottom: 16px;">
1206
+ <select id="webnovelModelFilter" onchange="loadWebnovels()" style="width: 100%; padding: 8px; border: 1px solid var(--border); border-radius: 8px; background: var(--bg-primary); color: var(--text-primary);">
1207
+ <option value="">모든 모델</option>
1208
+ </select>
1209
+ </div>
1210
+ <div id="webnovelsList" style="flex: 1; overflow-y: auto; border: 1px solid var(--border); border-radius: 8px; padding: 12px;">
1211
+ <div style="text-align: center; color: var(--text-secondary); padding: 24px;">
1212
+ 웹소설 목록을 불러오는 중...
1213
+ </div>
1214
+ </div>
1215
+ </div>
1216
+ </div>
1217
+ </div>
1218
+
1219
+ <!-- 웹소설 내용 보기 모달 -->
1220
+ <div id="webnovelContentModal" class="modal" style="display: none;">
1221
+ <div class="modal-content" style="max-width: 1000px; max-height: 90vh;">
1222
+ <div class="modal-header">
1223
+ <h2 id="webnovelContentTitle">웹소설 내용</h2>
1224
+ <button class="modal-close" onclick="closeWebnovelContentModal()">&times;</button>
1225
+ </div>
1226
+ <!-- 검색 영역 -->
1227
+ <div style="padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
1228
+ <div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;">
1229
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);">
1230
+ <circle cx="11" cy="11" r="8"></circle>
1231
+ <path d="m21 21-4.35-4.35"></path>
1232
+ </svg>
1233
+ <input type="text" id="webnovelSearchInput" placeholder="검색어를 입력하세요..."
1234
+ style="flex: 1; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;"
1235
+ onkeydown="if(event.key === 'Enter') performWebnovelSearch()">
1236
+ </div>
1237
+ <button onclick="performWebnovelSearch()"
1238
+ style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;">
1239
+ 검색
1240
+ </button>
1241
+ <button onclick="clearWebnovelSearch()"
1242
+ style="padding: 6px 16px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 14px;">
1243
+ 초기화
1244
+ </button>
1245
+ <div id="webnovelSearchInfo" style="font-size: 12px; color: var(--text-secondary); min-width: 120px; text-align: right;">
1246
+ <!-- 검색 결과 정보 표시 -->
1247
+ </div>
1248
+ </div>
1249
+ <div style="padding: 8px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; justify-content: center;">
1250
+ <button onclick="scrollToPreviousMatch()" id="prevMatchBtn"
1251
+ style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;"
1252
+ disabled>
1253
+ ← 이전
1254
+ </button>
1255
+ <button onclick="scrollToNextMatch()" id="nextMatchBtn"
1256
+ style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;"
1257
+ disabled>
1258
+ 다음 →
1259
+ </button>
1260
+ </div>
1261
+ <div class="modal-body" style="height: calc(90vh - 200px); overflow-y: auto; position: relative;" id="webnovelContentContainer">
1262
+ <div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;">
1263
+ 내용을 불러오는 중...
1264
+ </div>
1265
+ </div>
1266
+ </div>
1267
+ </div>
1268
+
1269
  <script>
1270
  const chatContainer = document.getElementById('chatContainer');
1271
  const messageInput = document.getElementById('messageInput');
 
1297
 
1298
  // 웹소설 목록 로드
1299
  async function loadNovels() {
 
 
 
 
 
 
1300
  try {
1301
+ // 모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일
1302
+ const url = selectedModel
1303
+ ? `/api/files?model_name=${encodeURIComponent(selectedModel)}`
1304
+ : '/api/files';
1305
+
1306
+ console.log('[웹소설 목록] API 요청:', url, '선택된 모델:', selectedModel || '없음 (전체)');
1307
+
1308
+ const response = await fetch(url, {
1309
+ credentials: 'include'
1310
+ });
1311
+
1312
+ console.log('[웹소설 목록] 응답 상태:', response.status, response.statusText);
1313
+
1314
+ if (!response.ok) {
1315
+ const errorText = await response.text();
1316
+ console.error('[웹소설 목록] 응답 오류:', errorText);
1317
+ throw new Error(`웹소설 목록을 불러올 수 없습니다. (${response.status})`);
1318
+ }
1319
+
1320
  const data = await response.json();
1321
+ console.log('[웹소설 목록] 응답 데이터:', data);
1322
+ console.log('[웹소설 목록] files 배열:', data.files);
1323
 
1324
  novelList.innerHTML = '';
1325
 
1326
+ // API 응답은 { files: [...], model_stats: {...} } 형태
1327
+ let files = data.files || [];
1328
+ console.log('[웹소설 목록] 파일 개수:', files.length);
1329
+
1330
+ // 모델이 선택되어 있는데 해당 모델의 파일이 없으면, 모든 파일 다시 조회
1331
+ if (selectedModel && files.length === 0) {
1332
+ console.log('[웹소설 목록] 선택한 모델에 파일이 없어 전체 파일 조회 중...');
1333
+ try {
1334
+ const allFilesResponse = await fetch('/api/files', {
1335
+ credentials: 'include'
1336
+ });
1337
+ if (allFilesResponse.ok) {
1338
+ const allFilesData = await allFilesResponse.json();
1339
+ files = allFilesData.files || [];
1340
+ console.log('[웹소설 목록] 전체 파일 개수:', files.length);
1341
+ }
1342
+ } catch (e) {
1343
+ console.error('[웹소설 목록] 전체 파일 조회 오류:', e);
1344
+ }
1345
+ }
1346
+
1347
+ if (files.length > 0) {
1348
+ files.forEach(file => {
1349
+ console.log('[웹소설 목록] 파일 처리:', file);
1350
  const novelItem = document.createElement('div');
1351
  novelItem.className = 'novel-item';
1352
 
 
1360
  const label = document.createElement('label');
1361
  label.className = 'novel-item-name';
1362
  label.htmlFor = `novel-${file.id}`;
1363
+ // 모델 정보도 함께 표시
1364
+ const displayText = files.some(f => f.model_name && f.model_name !== selectedModel)
1365
+ ? `${file.original_filename} (${file.model_name || '미지정'})`
1366
+ : file.original_filename;
1367
+ label.textContent = displayText;
1368
+ label.title = `${file.original_filename} (${file.model_name || '미지정'})`;
1369
 
1370
  novelItem.appendChild(checkbox);
1371
  novelItem.appendChild(label);
 
1373
  });
1374
  updateSelectedNovelsInfo();
1375
  } else {
1376
+ // 모든 파일을 조회했는데도 없으면 진짜 없는 것
1377
  novelList.innerHTML = '<div class="novel-item-empty">업로드된 웹소설이 없습니다</div>';
1378
  selectedNovelsInfo.textContent = '';
1379
  }
1380
  } catch (error) {
1381
+ console.error('[웹소설 목록] 로드 오류:', error);
1382
+ novelList.innerHTML = `<div class="novel-item-empty">웹소설 목록을 불러올 수 없습니다<br><small style="color: #ea4335;">${error.message}</small></div>`;
1383
+ selectedNovelsInfo.textContent = '';
1384
  }
1385
  }
1386
 
 
1836
  }
1837
  }
1838
 
1839
+ // 웹소설 모달 관련 함수
1840
+ async function showWebnovelsModal() {
1841
+ const modal = document.getElementById('webnovelsModal');
1842
+ modal.style.display = 'block';
1843
+ await loadWebnovels();
1844
+ await loadWebnovelModelFilter();
1845
+ }
1846
+
1847
+ function closeWebnovelsModal() {
1848
+ document.getElementById('webnovelsModal').style.display = 'none';
1849
+ }
1850
+
1851
+ async function loadWebnovelModelFilter() {
1852
+ try {
1853
+ const response = await fetch('/api/ollama/models');
1854
+ if (!response.ok) throw new Error('모델 목록을 불러올 수 없습니다.');
1855
+ const data = await response.json();
1856
+ const models = data.models || [];
1857
+
1858
+ const filter = document.getElementById('webnovelModelFilter');
1859
+ filter.innerHTML = '<option value="">모든 모델</option>';
1860
+ models.forEach(model => {
1861
+ const option = document.createElement('option');
1862
+ option.value = model.name;
1863
+ option.textContent = model.name;
1864
+ filter.appendChild(option);
1865
+ });
1866
+ } catch (error) {
1867
+ console.error('모델 필터 로드 오류:', error);
1868
+ }
1869
+ }
1870
+
1871
+ async function loadWebnovels() {
1872
+ const listContainer = document.getElementById('webnovelsList');
1873
+ const modelFilter = document.getElementById('webnovelModelFilter');
1874
+ const modelName = modelFilter ? modelFilter.value : '';
1875
+
1876
+ listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">웹소설 목록을 불러오는 중...</div>';
1877
+
1878
+ try {
1879
+ const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files';
1880
+ const response = await fetch(url);
1881
+ if (!response.ok) throw new Error('웹소설 목록을 불러올 수 없습니다.');
1882
+
1883
+ const data = await response.json();
1884
+
1885
+ // API 응답은 { files: [...], model_stats: {...} } 형태
1886
+ const files = data.files || [];
1887
+
1888
+ if (!Array.isArray(files)) {
1889
+ console.error('예상치 못한 응답 형식:', data);
1890
+ throw new Error('웹소설 목록 형식이 올바르지 않습니다.');
1891
+ }
1892
+
1893
+ if (files.length === 0) {
1894
+ listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">업로드된 웹소설이 없습니다.</div>';
1895
+ return;
1896
+ }
1897
+
1898
+ listContainer.innerHTML = '';
1899
+ files.forEach(file => {
1900
+ const fileItem = document.createElement('div');
1901
+ fileItem.className = 'webnovel-item';
1902
+
1903
+ const uploadedDate = new Date(file.uploaded_at);
1904
+ const formattedDate = uploadedDate.toLocaleDateString('ko-KR', {
1905
+ year: 'numeric',
1906
+ month: 'long',
1907
+ day: 'numeric'
1908
+ });
1909
+
1910
+ fileItem.innerHTML = `
1911
+ <div class="webnovel-item-header">
1912
+ <div class="webnovel-item-title">${escapeHtml(file.original_filename)}</div>
1913
+ </div>
1914
+ <div class="webnovel-item-meta">
1915
+ <span>📅 ${formattedDate}</span>
1916
+ <span>📦 ${formatFileSize(file.file_size)}</span>
1917
+ <span>🧩 청크: ${file.chunk_count || 0}개</span>
1918
+ ${file.model_name ? `<span>🤖 ${escapeHtml(file.model_name)}</span>` : ''}
1919
+ </div>
1920
+ <div class="webnovel-item-actions">
1921
+ <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')">내용 보기</button>
1922
+ </div>
1923
+ `;
1924
+ listContainer.appendChild(fileItem);
1925
+ });
1926
+ } catch (error) {
1927
+ console.error('웹소설 목록 로드 오류:', error);
1928
+ listContainer.innerHTML = `<div style="text-align: center; color: #ea4335; padding: 24px;">오류: ${error.message}</div>`;
1929
+ }
1930
+ }
1931
+
1932
+ let webnovelOriginalContent = '';
1933
+ let webnovelSearchMatches = [];
1934
+ let webnovelCurrentMatchIndex = -1;
1935
+
1936
+ async function viewWebnovelContent(fileId, filename) {
1937
+ const modal = document.getElementById('webnovelContentModal');
1938
+ const title = document.getElementById('webnovelContentTitle');
1939
+ const content = document.getElementById('webnovelContent');
1940
+ const searchInput = document.getElementById('webnovelSearchInput');
1941
+
1942
+ modal.style.display = 'block';
1943
+ title.textContent = filename;
1944
+ content.textContent = '내용을 불러오는 중...';
1945
+ searchInput.value = '';
1946
+ webnovelOriginalContent = '';
1947
+ webnovelSearchMatches = [];
1948
+ webnovelCurrentMatchIndex = -1;
1949
+ updateWebnovelSearchInfo();
1950
+
1951
+ try {
1952
+ const response = await fetch(`/api/files/${fileId}/content`);
1953
+ if (!response.ok) throw new Error('웹소설 내용을 불러올 수 없습니다.');
1954
+
1955
+ const data = await response.json();
1956
+ webnovelOriginalContent = data.content;
1957
+ content.textContent = data.content;
1958
+
1959
+ // 검색 입력 필드 포커스
1960
+ searchInput.focus();
1961
+ } catch (error) {
1962
+ console.error('웹소설 내용 로드 오류:', error);
1963
+ content.textContent = `오류: ${error.message}`;
1964
+ }
1965
+ }
1966
+
1967
+ function performWebnovelSearch() {
1968
+ const searchInput = document.getElementById('webnovelSearchInput');
1969
+ const searchTerm = searchInput.value.trim();
1970
+ const content = document.getElementById('webnovelContent');
1971
+
1972
+ if (!searchTerm) {
1973
+ clearWebnovelSearch();
1974
+ return;
1975
+ }
1976
+
1977
+ if (!webnovelOriginalContent) {
1978
+ webnovelOriginalContent = content.textContent;
1979
+ }
1980
+
1981
+ // 검색어로 하이라이트 처리
1982
+ const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
1983
+ const highlightedContent = webnovelOriginalContent.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>');
1984
+ content.innerHTML = highlightedContent;
1985
+
1986
+ // 검색 결과 위치 찾기
1987
+ webnovelSearchMatches = [];
1988
+ const matches = [...webnovelOriginalContent.matchAll(new RegExp(escapeRegex(searchTerm), 'gi'))];
1989
+ matches.forEach(match => {
1990
+ webnovelSearchMatches.push(match.index);
1991
+ });
1992
+
1993
+ webnovelCurrentMatchIndex = webnovelSearchMatches.length > 0 ? 0 : -1;
1994
+ updateWebnovelSearchInfo();
1995
+ updateWebnovelNavigationButtons();
1996
+
1997
+ if (webnovelCurrentMatchIndex >= 0) {
1998
+ scrollToMatch(webnovelCurrentMatchIndex);
1999
+ }
2000
+ }
2001
+
2002
+ function clearWebnovelSearch() {
2003
+ const searchInput = document.getElementById('webnovelSearchInput');
2004
+ const content = document.getElementById('webnovelContent');
2005
+
2006
+ searchInput.value = '';
2007
+ if (webnovelOriginalContent) {
2008
+ content.textContent = webnovelOriginalContent;
2009
+ }
2010
+ webnovelSearchMatches = [];
2011
+ webnovelCurrentMatchIndex = -1;
2012
+ updateWebnovelSearchInfo();
2013
+ updateWebnovelNavigationButtons();
2014
+ }
2015
+
2016
+ function scrollToMatch(index) {
2017
+ if (index < 0 || index >= webnovelSearchMatches.length) return;
2018
+
2019
+ const content = document.getElementById('webnovelContent');
2020
+ const container = document.getElementById('webnovelContentContainer');
2021
+ const searchInput = document.getElementById('webnovelSearchInput');
2022
+ const searchTerm = searchInput.value.trim();
2023
+
2024
+ if (!searchTerm) return;
2025
+
2026
+ // HTML이 있는 경우 (하이라이트된 경우)
2027
+ const marks = content.querySelectorAll('mark');
2028
+ if (marks.length > 0 && marks[index]) {
2029
+ // 이전 하이라이트 제거
2030
+ marks.forEach((mark, i) => {
2031
+ if (i === index) {
2032
+ mark.style.background = '#ff9800';
2033
+ mark.style.fontWeight = 'bold';
2034
+ mark.style.boxShadow = '0 0 4px rgba(255, 152, 0, 0.5)';
2035
+ } else {
2036
+ mark.style.background = '#ffeb3b';
2037
+ mark.style.fontWeight = 'normal';
2038
+ mark.style.boxShadow = 'none';
2039
+ }
2040
+ });
2041
+
2042
+ // 해당 매치로 스크롤
2043
+ marks[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
2044
+ } else {
2045
+ // 텍스트만 있는 경우 Range API 사용
2046
+ const textNode = content.firstChild;
2047
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
2048
+ const range = document.createRange();
2049
+ const matchPos = webnovelSearchMatches[index];
2050
+ const matchLength = searchTerm.length;
2051
+
2052
+ try {
2053
+ range.setStart(textNode, matchPos);
2054
+ range.setEnd(textNode, matchPos + matchLength);
2055
+
2056
+ // Range의 위치 정보 가져오기
2057
+ const rect = range.getBoundingClientRect();
2058
+ const containerRect = container.getBoundingClientRect();
2059
+
2060
+ // 스크롤 계산
2061
+ const scrollTop = container.scrollTop + (rect.top - containerRect.top) - (containerRect.height / 2);
2062
+ container.scrollTo({ top: scrollTop, behavior: 'smooth' });
2063
+ } catch (e) {
2064
+ console.error('스크롤 오류:', e);
2065
+ }
2066
+ }
2067
+ }
2068
+ }
2069
+
2070
+ function scrollToNextMatch() {
2071
+ if (webnovelSearchMatches.length === 0) return;
2072
+ webnovelCurrentMatchIndex = (webnovelCurrentMatchIndex + 1) % webnovelSearchMatches.length;
2073
+ scrollToMatch(webnovelCurrentMatchIndex);
2074
+ updateWebnovelSearchInfo();
2075
+ }
2076
+
2077
+ function scrollToPreviousMatch() {
2078
+ if (webnovelSearchMatches.length === 0) return;
2079
+ webnovelCurrentMatchIndex = webnovelCurrentMatchIndex <= 0
2080
+ ? webnovelSearchMatches.length - 1
2081
+ : webnovelCurrentMatchIndex - 1;
2082
+ scrollToMatch(webnovelCurrentMatchIndex);
2083
+ updateWebnovelSearchInfo();
2084
+ }
2085
+
2086
+ function updateWebnovelSearchInfo() {
2087
+ const info = document.getElementById('webnovelSearchInfo');
2088
+ const searchInput = document.getElementById('webnovelSearchInput');
2089
+ const searchTerm = searchInput.value.trim();
2090
+
2091
+ if (!searchTerm || webnovelSearchMatches.length === 0) {
2092
+ info.textContent = '';
2093
+ return;
2094
+ }
2095
+
2096
+ if (webnovelCurrentMatchIndex >= 0) {
2097
+ info.textContent = `${webnovelCurrentMatchIndex + 1} / ${webnovelSearchMatches.length}`;
2098
+ } else {
2099
+ info.textContent = `총 ${webnovelSearchMatches.length}개`;
2100
+ }
2101
+ }
2102
+
2103
+ function updateWebnovelNavigationButtons() {
2104
+ const prevBtn = document.getElementById('prevMatchBtn');
2105
+ const nextBtn = document.getElementById('nextMatchBtn');
2106
+ const hasMatches = webnovelSearchMatches.length > 0;
2107
+
2108
+ prevBtn.disabled = !hasMatches;
2109
+ nextBtn.disabled = !hasMatches;
2110
+ }
2111
+
2112
+ function escapeRegex(str) {
2113
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
2114
+ }
2115
+
2116
+ function closeWebnovelContentModal() {
2117
+ document.getElementById('webnovelContentModal').style.display = 'none';
2118
+ clearWebnovelSearch();
2119
+ }
2120
+
2121
+ function formatFileSize(bytes) {
2122
+ if (bytes < 1024) return bytes + ' B';
2123
+ if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB';
2124
+ return (bytes / (1024 * 1024)).toFixed(1) + ' MB';
2125
+ }
2126
+
2127
+ function escapeHtml(text) {
2128
+ const div = document.createElement('div');
2129
+ div.textContent = text;
2130
+ return div.innerHTML;
2131
+ }
2132
+
2133
+ // 모달 외부 클릭 시 닫기
2134
+ window.onclick = function(event) {
2135
+ const webnovelsModal = document.getElementById('webnovelsModal');
2136
+ const contentModal = document.getElementById('webnovelContentModal');
2137
+ if (event.target === webnovelsModal) {
2138
+ closeWebnovelsModal();
2139
+ }
2140
+ if (event.target === contentModal) {
2141
+ closeWebnovelContentModal();
2142
+ }
2143
+ }
2144
+
2145
  // 페이지 로드 시 초기화
2146
  window.addEventListener('load', async () => {
2147
  await loadChatHistory();
2148
  await loadModels();
2149
+ // 모델 선택 여부와 관계없이 웹소설 목록 로드 (모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일)
2150
+ loadNovels();
 
2151
  messageInput.focus();
2152
  });
2153
  </script>
templates/login.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>로그인 - SOY NV AI</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
  <style>
@@ -50,8 +50,16 @@
50
  }
51
 
52
  .login-icon {
53
- font-size: 48px;
54
  margin-bottom: 16px;
 
 
 
 
 
 
 
 
 
55
  }
56
 
57
  .form-group {
@@ -127,8 +135,10 @@
127
  <body>
128
  <div class="login-container">
129
  <div class="login-header">
130
- <div class="login-icon">🤖</div>
131
- <h1>SOY NV AI</h1>
 
 
132
  <p>로그인이 필요합니다</p>
133
  </div>
134
 
@@ -166,3 +176,4 @@
166
 
167
 
168
 
 
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>로그인 - SOY AI 웹소설 어시스턴트</title>
7
  <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
  <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
  <style>
 
50
  }
51
 
52
  .login-icon {
 
53
  margin-bottom: 16px;
54
+ display: flex;
55
+ justify-content: center;
56
+ align-items: center;
57
+ }
58
+
59
+ .login-icon img {
60
+ width: 64px;
61
+ height: 64px;
62
+ object-fit: contain;
63
  }
64
 
65
  .form-group {
 
135
  <body>
136
  <div class="login-container">
137
  <div class="login-header">
138
+ <div class="login-icon">
139
+ <img src="{{ url_for('static', filename='logo.webp') }}" alt="SOY AI 로고">
140
+ </div>
141
+ <h1>SOY AI<br/>작품 개발 어시스턴트</h1>
142
  <p>로그인이 필요합니다</p>
143
  </div>
144
 
 
176
 
177
 
178
 
179
+
templates/webnovels.html ADDED
@@ -0,0 +1,697 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>업로드된 웹소설 - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ :root {
17
+ --bg-primary: #ffffff;
18
+ --bg-secondary: #f8f9fa;
19
+ --bg-tertiary: #f1f3f4;
20
+ --text-primary: #202124;
21
+ --text-secondary: #5f6368;
22
+ --accent: #1a73e8;
23
+ --accent-hover: #1557b0;
24
+ --border: #dadce0;
25
+ --ai-bg: #e8f0fe;
26
+ }
27
+
28
+ body {
29
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
30
+ background: var(--bg-secondary);
31
+ color: var(--text-primary);
32
+ }
33
+
34
+ .header {
35
+ background: var(--bg-primary);
36
+ border-bottom: 1px solid var(--border);
37
+ padding: 16px 24px;
38
+ display: flex;
39
+ align-items: center;
40
+ justify-content: space-between;
41
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
42
+ }
43
+
44
+ .header-title {
45
+ font-size: 20px;
46
+ font-weight: 500;
47
+ display: flex;
48
+ align-items: center;
49
+ gap: 12px;
50
+ }
51
+
52
+ .header-actions {
53
+ display: flex;
54
+ gap: 12px;
55
+ align-items: center;
56
+ }
57
+
58
+ .btn {
59
+ padding: 8px 16px;
60
+ border: none;
61
+ border-radius: 6px;
62
+ font-size: 14px;
63
+ font-weight: 500;
64
+ cursor: pointer;
65
+ transition: all 0.2s;
66
+ text-decoration: none;
67
+ display: inline-block;
68
+ }
69
+
70
+ .btn-primary {
71
+ background: var(--accent);
72
+ color: white;
73
+ }
74
+
75
+ .btn-primary:hover {
76
+ background: var(--accent-hover);
77
+ }
78
+
79
+ .btn-secondary {
80
+ background: var(--bg-tertiary);
81
+ color: var(--text-primary);
82
+ }
83
+
84
+ .btn-secondary:hover {
85
+ background: var(--border);
86
+ }
87
+
88
+ .container {
89
+ max-width: 1200px;
90
+ margin: 0 auto;
91
+ padding: 24px;
92
+ }
93
+
94
+ .page-header {
95
+ margin-bottom: 24px;
96
+ }
97
+
98
+ .page-header h1 {
99
+ font-size: 28px;
100
+ font-weight: 600;
101
+ margin-bottom: 8px;
102
+ }
103
+
104
+ .page-header p {
105
+ color: var(--text-secondary);
106
+ }
107
+
108
+ .card {
109
+ background: var(--bg-primary);
110
+ border-radius: 8px;
111
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
112
+ padding: 24px;
113
+ margin-bottom: 24px;
114
+ }
115
+
116
+ .filter-section {
117
+ margin-bottom: 16px;
118
+ display: flex;
119
+ gap: 12px;
120
+ align-items: center;
121
+ }
122
+
123
+ .filter-section select {
124
+ padding: 8px 12px;
125
+ border: 1px solid var(--border);
126
+ border-radius: 6px;
127
+ font-size: 14px;
128
+ background: var(--bg-primary);
129
+ color: var(--text-primary);
130
+ flex: 1;
131
+ max-width: 300px;
132
+ }
133
+
134
+ .webnovel-item {
135
+ padding: 16px;
136
+ margin-bottom: 12px;
137
+ border: 1px solid var(--border);
138
+ border-radius: 8px;
139
+ background: var(--bg-secondary);
140
+ transition: all 0.2s;
141
+ }
142
+
143
+ .webnovel-item:hover {
144
+ background: var(--bg-tertiary);
145
+ border-color: var(--accent);
146
+ }
147
+
148
+ .webnovel-item-header {
149
+ display: flex;
150
+ justify-content: space-between;
151
+ align-items: center;
152
+ margin-bottom: 8px;
153
+ }
154
+
155
+ .webnovel-item-title {
156
+ font-size: 16px;
157
+ font-weight: 500;
158
+ color: var(--text-primary);
159
+ }
160
+
161
+ .webnovel-item-meta {
162
+ font-size: 12px;
163
+ color: var(--text-secondary);
164
+ display: flex;
165
+ gap: 12px;
166
+ flex-wrap: wrap;
167
+ margin-bottom: 12px;
168
+ }
169
+
170
+ .webnovel-item-actions {
171
+ display: flex;
172
+ gap: 8px;
173
+ }
174
+
175
+ .webnovel-item-btn {
176
+ padding: 6px 16px;
177
+ background: var(--accent);
178
+ color: white;
179
+ border: none;
180
+ border-radius: 6px;
181
+ font-size: 13px;
182
+ font-weight: 500;
183
+ cursor: pointer;
184
+ transition: background 0.2s;
185
+ }
186
+
187
+ .webnovel-item-btn:hover {
188
+ background: var(--accent-hover);
189
+ }
190
+
191
+ /* 모달 스타일 */
192
+ .modal {
193
+ display: none;
194
+ position: fixed;
195
+ top: 0;
196
+ left: 0;
197
+ width: 100%;
198
+ height: 100%;
199
+ background: rgba(0, 0, 0, 0.5);
200
+ z-index: 1000;
201
+ align-items: center;
202
+ justify-content: center;
203
+ }
204
+
205
+ .modal.active {
206
+ display: flex;
207
+ }
208
+
209
+ .modal-content {
210
+ background: var(--bg-primary);
211
+ border-radius: 8px;
212
+ padding: 0;
213
+ width: 90%;
214
+ max-width: 1000px;
215
+ max-height: 90vh;
216
+ overflow: hidden;
217
+ display: flex;
218
+ flex-direction: column;
219
+ }
220
+
221
+ .modal-header {
222
+ display: flex;
223
+ justify-content: space-between;
224
+ align-items: center;
225
+ padding: 20px 24px;
226
+ border-bottom: 1px solid var(--border);
227
+ }
228
+
229
+ .modal-title {
230
+ font-size: 20px;
231
+ font-weight: 500;
232
+ }
233
+
234
+ .modal-close {
235
+ background: none;
236
+ border: none;
237
+ font-size: 24px;
238
+ cursor: pointer;
239
+ color: var(--text-secondary);
240
+ width: 32px;
241
+ height: 32px;
242
+ display: flex;
243
+ align-items: center;
244
+ justify-content: center;
245
+ border-radius: 50%;
246
+ transition: background 0.2s;
247
+ }
248
+
249
+ .modal-close:hover {
250
+ background: var(--bg-tertiary);
251
+ }
252
+
253
+ .modal-body {
254
+ padding: 24px;
255
+ overflow-y: auto;
256
+ flex: 1;
257
+ }
258
+
259
+ mark {
260
+ background: #ffeb3b;
261
+ padding: 2px 0;
262
+ border-radius: 2px;
263
+ }
264
+ </style>
265
+ </head>
266
+ <body>
267
+ <div class="header">
268
+ <div class="header-title">
269
+ <span>📚</span>
270
+ <span>업로드된 웹소설</span>
271
+ </div>
272
+ <div class="header-actions">
273
+ <span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
274
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
275
+ {% if current_user.is_admin %}
276
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">관리자 페이지</a>
277
+ {% endif %}
278
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
279
+ </div>
280
+ </div>
281
+
282
+ <div class="container">
283
+ <div class="page-header">
284
+ <h1>업로드된 웹소설</h1>
285
+ <p>업로드된 웹소설 목록을 확인하고 내용을 볼 수 있습니다.</p>
286
+ </div>
287
+
288
+ <div class="card">
289
+ <div class="filter-section" style="display: flex; gap: 16px; align-items: center; flex-wrap: wrap;">
290
+ <div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 250px;">
291
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);">
292
+ <circle cx="11" cy="11" r="8"></circle>
293
+ <path d="m21 21-4.35-4.35"></path>
294
+ </svg>
295
+ <input type="text" id="webnovelTitleSearch" placeholder="제목으로 검색..."
296
+ style="flex: 1; padding: 8px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;"
297
+ oninput="filterWebnovelsByTitle()"
298
+ onkeydown="if(event.key === 'Escape') { document.getElementById('webnovelTitleSearch').value = ''; filterWebnovelsByTitle(); }">
299
+ </div>
300
+ <div style="display: flex; align-items: center; gap: 8px;">
301
+ <label for="webnovelModelFilter" style="font-size: 14px; font-weight: 500; color: var(--text-primary);">모델 필터:</label>
302
+ <select id="webnovelModelFilter" onchange="loadWebnovels()" style="min-width: 200px;">
303
+ <option value="">모든 모델</option>
304
+ </select>
305
+ </div>
306
+ </div>
307
+ <div id="webnovelsList" style="min-height: 400px;">
308
+ <div style="text-align: center; color: var(--text-secondary); padding: 24px;">
309
+ 웹소설 목록을 불러오는 중...
310
+ </div>
311
+ </div>
312
+ </div>
313
+ </div>
314
+
315
+ <!-- 웹소설 내용 보기 모달 -->
316
+ <div id="webnovelContentModal" class="modal">
317
+ <div class="modal-content">
318
+ <div class="modal-header">
319
+ <h2 class="modal-title" id="webnovelContentTitle">웹소설 내용</h2>
320
+ <button class="modal-close" onclick="closeWebnovelContentModal()">&times;</button>
321
+ </div>
322
+ <!-- 검색 영역 -->
323
+ <div style="padding: 12px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; flex-wrap: wrap;">
324
+ <div style="display: flex; align-items: center; gap: 8px; flex: 1; min-width: 200px;">
325
+ <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" style="color: var(--text-secondary);">
326
+ <circle cx="11" cy="11" r="8"></circle>
327
+ <path d="m21 21-4.35-4.35"></path>
328
+ </svg>
329
+ <input type="text" id="webnovelSearchInput" placeholder="검색어를 입력하세요..."
330
+ style="flex: 1; padding: 6px 12px; border: 1px solid var(--border); border-radius: 6px; background: var(--bg-primary); color: var(--text-primary); font-size: 14px; outline: none;"
331
+ onkeydown="if(event.key === 'Enter') performWebnovelSearch()">
332
+ </div>
333
+ <button onclick="performWebnovelSearch()"
334
+ style="padding: 6px 16px; background: var(--accent); color: white; border: none; border-radius: 6px; cursor: pointer; font-size: 14px; font-weight: 500;">
335
+ 검색
336
+ </button>
337
+ <button onclick="clearWebnovelSearch()"
338
+ style="padding: 6px 16px; background: var(--bg-tertiary); color: var(--text-secondary); border: 1px solid var(--border); border-radius: 6px; cursor: pointer; font-size: 14px;">
339
+ 초기화
340
+ </button>
341
+ <div id="webnovelSearchInfo" style="font-size: 12px; color: var(--text-secondary); min-width: 120px; text-align: right;">
342
+ <!-- 검색 결과 정보 표시 -->
343
+ </div>
344
+ </div>
345
+ <div style="padding: 8px 24px; border-bottom: 1px solid var(--border); background: var(--bg-secondary); display: flex; gap: 8px; align-items: center; justify-content: center;">
346
+ <button onclick="scrollToPreviousMatch()" id="prevMatchBtn"
347
+ style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;"
348
+ disabled>
349
+ ← 이전
350
+ </button>
351
+ <button onclick="scrollToNextMatch()" id="nextMatchBtn"
352
+ style="padding: 4px 12px; background: var(--bg-tertiary); color: var(--text-primary); border: 1px solid var(--border); border-radius: 4px; cursor: pointer; font-size: 12px;"
353
+ disabled>
354
+ 다음 →
355
+ </button>
356
+ </div>
357
+ <div class="modal-body" style="height: calc(90vh - 200px); overflow-y: auto; position: relative;" id="webnovelContentContainer">
358
+ <div id="webnovelContent" style="white-space: pre-wrap; font-family: inherit; line-height: 1.6; color: var(--text-primary); padding: 16px;">
359
+ 내용을 불러오는 중...
360
+ </div>
361
+ </div>
362
+ </div>
363
+ </div>
364
+
365
+ <script>
366
+ function escapeHtml(text) {
367
+ const div = document.createElement('div');
368
+ div.textContent = text;
369
+ return div.innerHTML;
370
+ }
371
+
372
+ function formatFileSize(bytes) {
373
+ if (bytes === 0) return '0 Bytes';
374
+ const k = 1024;
375
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
376
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
377
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
378
+ }
379
+
380
+ async function loadWebnovelModelFilter() {
381
+ try {
382
+ const response = await fetch('/api/ollama/models');
383
+ if (!response.ok) throw new Error('모델 목록을 불러올 수 없습니다.');
384
+ const data = await response.json();
385
+ const models = data.models || [];
386
+
387
+ const filter = document.getElementById('webnovelModelFilter');
388
+ filter.innerHTML = '<option value="">모든 모델</option>';
389
+ models.forEach(model => {
390
+ const option = document.createElement('option');
391
+ option.value = model.name;
392
+ option.textContent = model.name;
393
+ filter.appendChild(option);
394
+ });
395
+ } catch (error) {
396
+ console.error('모델 필터 로드 오류:', error);
397
+ }
398
+ }
399
+
400
+ let allWebnovels = []; // 전체 웹소설 목록 저장
401
+
402
+ async function loadWebnovels() {
403
+ const listContainer = document.getElementById('webnovelsList');
404
+ const modelFilter = document.getElementById('webnovelModelFilter');
405
+ const modelName = modelFilter ? modelFilter.value : '';
406
+
407
+ listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">웹소설 목록을 불러오는 중...</div>';
408
+
409
+ try {
410
+ const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files';
411
+ const response = await fetch(url, {
412
+ credentials: 'include'
413
+ });
414
+ if (!response.ok) throw new Error('웹소설 목록을 불러올 수 없습니다.');
415
+
416
+ const data = await response.json();
417
+
418
+ // API 응답은 { files: [...], model_stats: {...} } 형태
419
+ allWebnovels = data.files || [];
420
+
421
+ if (!Array.isArray(allWebnovels)) {
422
+ console.error('예상치 못한 응답 형식:', data);
423
+ throw new Error('웹소설 목록 형식이 올바르지 않습니다.');
424
+ }
425
+
426
+ // 제목 검색 필터 적용
427
+ filterWebnovelsByTitle();
428
+ } catch (error) {
429
+ console.error('웹소설 목록 로드 오류:', error);
430
+ listContainer.innerHTML = `<div style="text-align: center; color: #ea4335; padding: 24px;">오류: ${error.message}</div>`;
431
+ }
432
+ }
433
+
434
+ function filterWebnovelsByTitle() {
435
+ const listContainer = document.getElementById('webnovelsList');
436
+ const searchInput = document.getElementById('webnovelTitleSearch');
437
+ const searchTerm = searchInput ? searchInput.value.trim().toLowerCase() : '';
438
+
439
+ if (allWebnovels.length === 0) {
440
+ listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">업로드된 웹소설이 없습니다.</div>';
441
+ return;
442
+ }
443
+
444
+ // 제목으로 필터링
445
+ const filteredFiles = searchTerm
446
+ ? allWebnovels.filter(file => file.original_filename.toLowerCase().includes(searchTerm))
447
+ : allWebnovels;
448
+
449
+ if (filteredFiles.length === 0) {
450
+ listContainer.innerHTML = `<div style="text-align: center; color: var(--text-secondary); padding: 24px;">검색 결과가 없습니다. (검색어: "${escapeHtml(searchTerm)}")</div>`;
451
+ return;
452
+ }
453
+
454
+ listContainer.innerHTML = '';
455
+ filteredFiles.forEach(file => {
456
+ const fileItem = document.createElement('div');
457
+ fileItem.className = 'webnovel-item';
458
+
459
+ const uploadedDate = new Date(file.uploaded_at);
460
+ const formattedDate = uploadedDate.toLocaleDateString('ko-KR', {
461
+ year: 'numeric',
462
+ month: 'long',
463
+ day: 'numeric'
464
+ });
465
+
466
+ // 검색어 하이라이트
467
+ let displayTitle = escapeHtml(file.original_filename);
468
+ if (searchTerm) {
469
+ const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
470
+ displayTitle = displayTitle.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 4px; border-radius: 2px;">$1</mark>');
471
+ }
472
+
473
+ fileItem.innerHTML = `
474
+ <div class="webnovel-item-header">
475
+ <div class="webnovel-item-title">${displayTitle}</div>
476
+ </div>
477
+ <div class="webnovel-item-meta">
478
+ <span>📅 ${formattedDate}</span>
479
+ <span>📦 ${formatFileSize(file.file_size)}</span>
480
+ <span>🧩 청크: ${file.chunk_count || 0}개</span>
481
+ ${file.model_name ? `<span>🤖 ${escapeHtml(file.model_name)}</span>` : ''}
482
+ </div>
483
+ <div class="webnovel-item-actions">
484
+ <button class="webnovel-item-btn" onclick="viewWebnovelContent(${file.id}, '${escapeHtml(file.original_filename)}')">내용 보기</button>
485
+ </div>
486
+ `;
487
+ listContainer.appendChild(fileItem);
488
+ });
489
+ }
490
+
491
+ let webnovelOriginalContent = '';
492
+ let webnovelSearchMatches = [];
493
+ let webnovelCurrentMatchIndex = -1;
494
+
495
+ async function viewWebnovelContent(fileId, filename) {
496
+ const modal = document.getElementById('webnovelContentModal');
497
+ const title = document.getElementById('webnovelContentTitle');
498
+ const content = document.getElementById('webnovelContent');
499
+ const searchInput = document.getElementById('webnovelSearchInput');
500
+
501
+ modal.classList.add('active');
502
+ title.textContent = filename;
503
+ content.textContent = '내용을 불러오는 중...';
504
+ searchInput.value = '';
505
+ webnovelOriginalContent = '';
506
+ webnovelSearchMatches = [];
507
+ webnovelCurrentMatchIndex = -1;
508
+ updateWebnovelSearchInfo();
509
+
510
+ try {
511
+ const response = await fetch(`/api/files/${fileId}/content`, {
512
+ credentials: 'include'
513
+ });
514
+ if (!response.ok) throw new Error('웹소설 내용을 불러올 수 없습니다.');
515
+
516
+ const data = await response.json();
517
+ webnovelOriginalContent = data.content;
518
+ content.textContent = data.content;
519
+
520
+ // 검색 입력 필드 포커스
521
+ searchInput.focus();
522
+ } catch (error) {
523
+ console.error('웹소설 내용 로드 오류:', error);
524
+ content.textContent = `오류: ${error.message}`;
525
+ }
526
+ }
527
+
528
+ function performWebnovelSearch() {
529
+ const searchInput = document.getElementById('webnovelSearchInput');
530
+ const searchTerm = searchInput.value.trim();
531
+ const content = document.getElementById('webnovelContent');
532
+
533
+ if (!searchTerm) {
534
+ clearWebnovelSearch();
535
+ return;
536
+ }
537
+
538
+ if (!webnovelOriginalContent) {
539
+ webnovelOriginalContent = content.textContent;
540
+ }
541
+
542
+ // 검색어로 하이라이트 처리
543
+ const regex = new RegExp(`(${escapeRegex(searchTerm)})`, 'gi');
544
+ const highlightedContent = webnovelOriginalContent.replace(regex, '<mark style="background: #ffeb3b; padding: 2px 0; border-radius: 2px;">$1</mark>');
545
+ content.innerHTML = highlightedContent;
546
+
547
+ // 검색 결과 위치 찾기
548
+ webnovelSearchMatches = [];
549
+ const matches = [...webnovelOriginalContent.matchAll(new RegExp(escapeRegex(searchTerm), 'gi'))];
550
+ matches.forEach(match => {
551
+ webnovelSearchMatches.push(match.index);
552
+ });
553
+
554
+ webnovelCurrentMatchIndex = webnovelSearchMatches.length > 0 ? 0 : -1;
555
+ updateWebnovelSearchInfo();
556
+ updateWebnovelNavigationButtons();
557
+
558
+ if (webnovelCurrentMatchIndex >= 0) {
559
+ scrollToMatch(webnovelCurrentMatchIndex);
560
+ }
561
+ }
562
+
563
+ function clearWebnovelSearch() {
564
+ const searchInput = document.getElementById('webnovelSearchInput');
565
+ const content = document.getElementById('webnovelContent');
566
+
567
+ searchInput.value = '';
568
+ if (webnovelOriginalContent) {
569
+ content.textContent = webnovelOriginalContent;
570
+ }
571
+ webnovelSearchMatches = [];
572
+ webnovelCurrentMatchIndex = -1;
573
+ updateWebnovelSearchInfo();
574
+ updateWebnovelNavigationButtons();
575
+ }
576
+
577
+ function scrollToMatch(index) {
578
+ if (index < 0 || index >= webnovelSearchMatches.length) return;
579
+
580
+ const content = document.getElementById('webnovelContent');
581
+ const container = document.getElementById('webnovelContentContainer');
582
+ const searchInput = document.getElementById('webnovelSearchInput');
583
+ const searchTerm = searchInput.value.trim();
584
+
585
+ if (!searchTerm) return;
586
+
587
+ // HTML이 있는 경우 (하이라이트된 경우)
588
+ const marks = content.querySelectorAll('mark');
589
+ if (marks.length > 0 && marks[index]) {
590
+ // 이전 하이라이트 제거
591
+ marks.forEach((mark, i) => {
592
+ if (i === index) {
593
+ mark.style.background = '#ff9800';
594
+ mark.style.fontWeight = 'bold';
595
+ mark.style.boxShadow = '0 0 4px rgba(255, 152, 0, 0.5)';
596
+ } else {
597
+ mark.style.background = '#ffeb3b';
598
+ mark.style.fontWeight = 'normal';
599
+ mark.style.boxShadow = 'none';
600
+ }
601
+ });
602
+
603
+ // 해당 매치로 스크롤
604
+ marks[index].scrollIntoView({ behavior: 'smooth', block: 'center' });
605
+ } else {
606
+ // 텍스트만 있는 경우 Range API 사용
607
+ const textNode = content.firstChild;
608
+ if (textNode && textNode.nodeType === Node.TEXT_NODE) {
609
+ const range = document.createRange();
610
+ const matchPos = webnovelSearchMatches[index];
611
+ const matchLength = searchTerm.length;
612
+
613
+ try {
614
+ range.setStart(textNode, matchPos);
615
+ range.setEnd(textNode, matchPos + matchLength);
616
+
617
+ // Range의 위치 정보 가져오기
618
+ const rect = range.getBoundingClientRect();
619
+ const containerRect = container.getBoundingClientRect();
620
+
621
+ // 스크롤 계산
622
+ const scrollTop = container.scrollTop + (rect.top - containerRect.top) - (containerRect.height / 2);
623
+ container.scrollTo({ top: scrollTop, behavior: 'smooth' });
624
+ } catch (e) {
625
+ console.error('스크롤 오류:', e);
626
+ }
627
+ }
628
+ }
629
+ }
630
+
631
+ function scrollToNextMatch() {
632
+ if (webnovelSearchMatches.length === 0) return;
633
+ webnovelCurrentMatchIndex = (webnovelCurrentMatchIndex + 1) % webnovelSearchMatches.length;
634
+ scrollToMatch(webnovelCurrentMatchIndex);
635
+ updateWebnovelSearchInfo();
636
+ }
637
+
638
+ function scrollToPreviousMatch() {
639
+ if (webnovelSearchMatches.length === 0) return;
640
+ webnovelCurrentMatchIndex = webnovelCurrentMatchIndex <= 0
641
+ ? webnovelSearchMatches.length - 1
642
+ : webnovelCurrentMatchIndex - 1;
643
+ scrollToMatch(webnovelCurrentMatchIndex);
644
+ updateWebnovelSearchInfo();
645
+ }
646
+
647
+ function updateWebnovelSearchInfo() {
648
+ const info = document.getElementById('webnovelSearchInfo');
649
+ const searchInput = document.getElementById('webnovelSearchInput');
650
+ const searchTerm = searchInput.value.trim();
651
+
652
+ if (!searchTerm || webnovelSearchMatches.length === 0) {
653
+ info.textContent = '';
654
+ return;
655
+ }
656
+
657
+ if (webnovelCurrentMatchIndex >= 0) {
658
+ info.textContent = `${webnovelCurrentMatchIndex + 1} / ${webnovelSearchMatches.length}`;
659
+ } else {
660
+ info.textContent = `총 ${webnovelSearchMatches.length}개`;
661
+ }
662
+ }
663
+
664
+ function updateWebnovelNavigationButtons() {
665
+ const prevBtn = document.getElementById('prevMatchBtn');
666
+ const nextBtn = document.getElementById('nextMatchBtn');
667
+ const hasMatches = webnovelSearchMatches.length > 0;
668
+
669
+ prevBtn.disabled = !hasMatches;
670
+ nextBtn.disabled = !hasMatches;
671
+ }
672
+
673
+ function escapeRegex(str) {
674
+ return str.replace(/[.*+?^${}()|[\]\\]/g, '\\$&');
675
+ }
676
+
677
+ function closeWebnovelContentModal() {
678
+ document.getElementById('webnovelContentModal').classList.remove('active');
679
+ clearWebnovelSearch();
680
+ }
681
+
682
+ // 모달 외부 클릭 시 닫기
683
+ document.getElementById('webnovelContentModal').addEventListener('click', function(e) {
684
+ if (e.target === this) {
685
+ closeWebnovelContentModal();
686
+ }
687
+ });
688
+
689
+ // 페이지 로드 시 초기화
690
+ window.addEventListener('load', async () => {
691
+ await loadWebnovelModelFilter();
692
+ await loadWebnovels();
693
+ });
694
+ </script>
695
+ </body>
696
+ </html>
697
+