SOY NV AI
commited on
Commit
·
04600a5
1
Parent(s):
9f9c12f
feat: 단계별 타임아웃 분리 및 토큰 관리 기능 개선 - 파일 업로드를 단계별로 분리 - 각 단계별 독립적인 타임아웃 설정 - 입력/출력/Parent Chunk 토큰 수 별도 관리 - admin/settings 페이지 개선 - 파일 업로드 진행 상황 메시지 개선 - 가로 스크롤 문제 해결
Browse files- EXAONE_설치_가이드.md +12 -0
- EXAONE_추가_안내.md +12 -0
- add_exaone_model.py +12 -0
- app/__init__.py +18 -0
- app/database.py +6 -2
- app/routes.py +684 -154
- download_exaone_model.py +12 -0
- install_exaone_direct.py +12 -0
- install_exaone_simple.py +12 -0
- templates/admin.html +1 -102
- templates/admin_messages.html +71 -10
- templates/admin_settings.html +596 -0
- templates/admin_webnovels.html +262 -69
- templates/index.html +470 -56
EXAONE_설치_가이드.md
CHANGED
|
@@ -154,4 +154,16 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
|
|
| 154 |
|
| 155 |
|
| 156 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 157 |
|
|
|
|
| 154 |
|
| 155 |
|
| 156 |
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
+
|
| 165 |
+
|
| 166 |
+
|
| 167 |
+
|
| 168 |
+
|
| 169 |
|
EXAONE_추가_안내.md
CHANGED
|
@@ -71,4 +71,16 @@ Ollama를 거치지 않고 Python에서 직접 Hugging Face 모델을 사용할
|
|
| 71 |
|
| 72 |
|
| 73 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 74 |
|
|
|
|
| 71 |
|
| 72 |
|
| 73 |
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
|
| 77 |
+
|
| 78 |
+
|
| 79 |
+
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
|
add_exaone_model.py
CHANGED
|
@@ -149,4 +149,16 @@ if __name__ == "__main__":
|
|
| 149 |
|
| 150 |
|
| 151 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 152 |
|
|
|
|
| 149 |
|
| 150 |
|
| 151 |
|
| 152 |
+
|
| 153 |
+
|
| 154 |
+
|
| 155 |
+
|
| 156 |
+
|
| 157 |
+
|
| 158 |
+
|
| 159 |
+
|
| 160 |
+
|
| 161 |
+
|
| 162 |
+
|
| 163 |
+
|
| 164 |
|
app/__init__.py
CHANGED
|
@@ -150,6 +150,24 @@ def migrate_database(app: Flask) -> None:
|
|
| 150 |
conn.commit()
|
| 151 |
logger.info("document_chunk.chunk_metadata 컬럼 추가 완료")
|
| 152 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 153 |
conn.close()
|
| 154 |
logger.info("데이터베이스 마이그레이션 완료")
|
| 155 |
|
|
|
|
| 150 |
conn.commit()
|
| 151 |
logger.info("document_chunk.chunk_metadata 컬럼 추가 완료")
|
| 152 |
|
| 153 |
+
# chat_session 테이블이 존재하는지 확인
|
| 154 |
+
cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='chat_session'")
|
| 155 |
+
if cursor.fetchone():
|
| 156 |
+
cursor.execute("PRAGMA table_info(chat_session)")
|
| 157 |
+
chat_session_columns = [column[1] for column in cursor.fetchall()]
|
| 158 |
+
|
| 159 |
+
if 'analysis_model' not in chat_session_columns:
|
| 160 |
+
logger.info("chat_session 테이블에 analysis_model 컬럼 추가 중...")
|
| 161 |
+
cursor.execute("ALTER TABLE chat_session ADD COLUMN analysis_model VARCHAR(100)")
|
| 162 |
+
conn.commit()
|
| 163 |
+
logger.info("chat_session.analysis_model 컬럼 추가 완료")
|
| 164 |
+
|
| 165 |
+
if 'answer_model' not in chat_session_columns:
|
| 166 |
+
logger.info("chat_session 테이블에 answer_model 컬럼 추가 중...")
|
| 167 |
+
cursor.execute("ALTER TABLE chat_session ADD COLUMN answer_model VARCHAR(100)")
|
| 168 |
+
conn.commit()
|
| 169 |
+
logger.info("chat_session.answer_model 컬럼 추가 완료")
|
| 170 |
+
|
| 171 |
conn.close()
|
| 172 |
logger.info("데이터베이스 마이그레이션 완료")
|
| 173 |
|
app/database.py
CHANGED
|
@@ -70,7 +70,9 @@ class ChatSession(db.Model):
|
|
| 70 |
id = db.Column(db.Integer, primary_key=True)
|
| 71 |
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
| 72 |
title = db.Column(db.String(255), nullable=True)
|
| 73 |
-
model_name = db.Column(db.String(100), nullable=True)
|
|
|
|
|
|
|
| 74 |
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 75 |
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
| 76 |
|
|
@@ -83,7 +85,9 @@ class ChatSession(db.Model):
|
|
| 83 |
'id': self.id,
|
| 84 |
'user_id': self.user_id,
|
| 85 |
'title': self.title,
|
| 86 |
-
'model_name': self.model_name,
|
|
|
|
|
|
|
| 87 |
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 88 |
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 89 |
'message_count': len(self.messages)
|
|
|
|
| 70 |
id = db.Column(db.Integer, primary_key=True)
|
| 71 |
user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
|
| 72 |
title = db.Column(db.String(255), nullable=True)
|
| 73 |
+
model_name = db.Column(db.String(100), nullable=True) # 하위 호환성을 위해 유지
|
| 74 |
+
analysis_model = db.Column(db.String(100), nullable=True) # 질문 분석용 모델
|
| 75 |
+
answer_model = db.Column(db.String(100), nullable=True) # 최종 답변용 모델
|
| 76 |
created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
|
| 77 |
updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
|
| 78 |
|
|
|
|
| 85 |
'id': self.id,
|
| 86 |
'user_id': self.user_id,
|
| 87 |
'title': self.title,
|
| 88 |
+
'model_name': self.model_name, # 하위 호환성
|
| 89 |
+
'analysis_model': self.analysis_model,
|
| 90 |
+
'answer_model': self.answer_model,
|
| 91 |
'created_at': self.created_at.isoformat() if self.created_at else None,
|
| 92 |
'updated_at': self.updated_at.isoformat() if self.updated_at else None,
|
| 93 |
'message_count': len(self.messages)
|
app/routes.py
CHANGED
|
@@ -31,6 +31,85 @@ def admin_required(f):
|
|
| 31 |
# Ollama 기본 URL (환경 변수로 설정 가능)
|
| 32 |
OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
|
| 33 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 34 |
# 업로드 설정
|
| 35 |
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
|
| 36 |
ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
|
|
@@ -357,7 +436,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
|
|
| 357 |
prompt=prompt,
|
| 358 |
model_name="gemini-1.5-flash",
|
| 359 |
temperature=0.3,
|
| 360 |
-
max_output_tokens=500
|
| 361 |
)
|
| 362 |
if not result['error'] and result.get('response'):
|
| 363 |
response_text = result['response'].strip()
|
|
@@ -385,7 +464,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
|
|
| 385 |
prompt=prompt,
|
| 386 |
model_name=gemini_model_name,
|
| 387 |
temperature=0.3,
|
| 388 |
-
max_output_tokens=500
|
| 389 |
)
|
| 390 |
if not result['error'] and result.get('response'):
|
| 391 |
response_text = result['response'].strip()
|
|
@@ -404,7 +483,7 @@ character_relationships는 이 청크에 등장하는 인물들 간의 현재
|
|
| 404 |
'stream': False,
|
| 405 |
'options': {
|
| 406 |
'temperature': 0.3,
|
| 407 |
-
'num_predict': 500
|
| 408 |
}
|
| 409 |
},
|
| 410 |
timeout=120 # 2분 타임아웃
|
|
@@ -526,6 +605,15 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
|
|
| 526 |
## 인물과 인물간의 관계 변화
|
| 527 |
[이 회차에서 인물들 간의 관계가 어떻게 변화했는지, 새로운 관계가 형성되었는지 등을 분석해주세요]
|
| 528 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 529 |
## 기타
|
| 530 |
[이 회차의 특별한 점, 중요 사건, 떡밥, 복선 등 기타 중요한 내용을 분석해주세요]
|
| 531 |
|
|
@@ -541,7 +629,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
|
|
| 541 |
prompt=prompt,
|
| 542 |
model_name="gemini-1.5-flash",
|
| 543 |
temperature=0.5,
|
| 544 |
-
max_output_tokens=
|
| 545 |
)
|
| 546 |
if not result['error'] and result.get('response'):
|
| 547 |
return result['response'].strip()
|
|
@@ -564,7 +652,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
|
|
| 564 |
prompt=prompt,
|
| 565 |
model_name=gemini_model_name,
|
| 566 |
temperature=0.5,
|
| 567 |
-
max_output_tokens=
|
| 568 |
)
|
| 569 |
if not result['error'] and result.get('response'):
|
| 570 |
return result['response'].strip()
|
|
@@ -579,7 +667,7 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
|
|
| 579 |
'stream': False,
|
| 580 |
'options': {
|
| 581 |
'temperature': 0.5,
|
| 582 |
-
'num_predict':
|
| 583 |
}
|
| 584 |
},
|
| 585 |
timeout=300 # 5분 타임아웃 (회차 분석은 시간이 오래 걸릴 수 있음)
|
|
@@ -813,7 +901,7 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
|
|
| 813 |
db.session.rollback()
|
| 814 |
return False
|
| 815 |
|
| 816 |
-
def create_chunks_for_file(file_id, content):
|
| 817 |
"""파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
|
| 818 |
|
| 819 |
섹션 분할 규칙:
|
|
@@ -823,6 +911,8 @@ def create_chunks_for_file(file_id, content):
|
|
| 823 |
Args:
|
| 824 |
file_id: 파일 ID
|
| 825 |
content: 파일 내용
|
|
|
|
|
|
|
| 826 |
"""
|
| 827 |
try:
|
| 828 |
print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
|
|
@@ -865,7 +955,7 @@ def create_chunks_for_file(file_id, content):
|
|
| 865 |
|
| 866 |
# '#작품설명'을 제외한 각 회차 분석
|
| 867 |
episode_sections = [s for s in sections if s[0] != '작품설명'] # section_type이 '작품설명'이 아닌 것만
|
| 868 |
-
if episode_sections and model_name:
|
| 869 |
print(f"[회차 분석] {len(episode_sections)}개 회차 분석 시작...")
|
| 870 |
|
| 871 |
# Parent Chunk 가져오기
|
|
@@ -915,31 +1005,32 @@ def create_chunks_for_file(file_id, content):
|
|
| 915 |
print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 ���과를 하나의 텍스트로 저장")
|
| 916 |
|
| 917 |
# 회차별 Graph Extraction 실행
|
| 918 |
-
|
| 919 |
-
|
| 920 |
-
|
| 921 |
-
|
| 922 |
-
|
| 923 |
-
|
| 924 |
-
|
| 925 |
-
|
| 926 |
-
|
| 927 |
-
|
| 928 |
-
|
| 929 |
-
|
| 930 |
-
|
| 931 |
-
|
| 932 |
-
|
| 933 |
-
|
| 934 |
-
|
| 935 |
-
|
| 936 |
-
|
| 937 |
-
|
| 938 |
-
|
| 939 |
-
|
| 940 |
-
|
| 941 |
-
|
| 942 |
-
|
|
|
|
| 943 |
else:
|
| 944 |
print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
|
| 945 |
else:
|
|
@@ -1094,7 +1185,7 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
|
|
| 1094 |
prompt=analysis_prompt,
|
| 1095 |
model_name=gemini_model_name,
|
| 1096 |
temperature=0.7,
|
| 1097 |
-
max_output_tokens=8192
|
| 1098 |
)
|
| 1099 |
|
| 1100 |
if result['error']:
|
|
@@ -1666,6 +1757,12 @@ def admin_prompts():
|
|
| 1666 |
"""프롬프트 관리 페이지"""
|
| 1667 |
return render_template('admin_prompts.html')
|
| 1668 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1669 |
@main_bp.route('/admin/files')
|
| 1670 |
@admin_required
|
| 1671 |
def admin_files():
|
|
@@ -1764,6 +1861,7 @@ def get_all_messages():
|
|
| 1764 |
try:
|
| 1765 |
user_id = request.args.get('user_id', type=int)
|
| 1766 |
session_id = request.args.get('session_id', type=int)
|
|
|
|
| 1767 |
page = request.args.get('page', 1, type=int)
|
| 1768 |
per_page = request.args.get('per_page', 50, type=int)
|
| 1769 |
|
|
@@ -1773,6 +1871,8 @@ def get_all_messages():
|
|
| 1773 |
query = query.filter(ChatSession.user_id == user_id)
|
| 1774 |
if session_id:
|
| 1775 |
query = query.filter(ChatMessage.session_id == session_id)
|
|
|
|
|
|
|
| 1776 |
|
| 1777 |
messages = query.order_by(ChatMessage.created_at.desc())\
|
| 1778 |
.paginate(page=page, per_page=per_page, error_out=False)
|
|
@@ -1861,6 +1961,201 @@ def get_gemini_api_key():
|
|
| 1861 |
traceback.print_exc()
|
| 1862 |
return jsonify({'error': f'API 키 조회 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 1863 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1864 |
@main_bp.route('/api/admin/gemini-api-key', methods=['POST'])
|
| 1865 |
@admin_required
|
| 1866 |
def set_gemini_api_key():
|
|
@@ -1911,59 +2206,74 @@ def set_gemini_api_key():
|
|
| 1911 |
def get_ollama_models():
|
| 1912 |
"""Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
|
| 1913 |
try:
|
|
|
|
|
|
|
|
|
|
| 1914 |
all_models = []
|
| 1915 |
|
| 1916 |
-
# 1. Ollama 모델 목록 가져오기
|
| 1917 |
try:
|
| 1918 |
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 1919 |
if response.status_code == 200:
|
| 1920 |
data = response.json()
|
| 1921 |
ollama_models_raw = [model['name'] for model in data.get('models', [])]
|
| 1922 |
|
| 1923 |
-
|
| 1924 |
-
|
| 1925 |
-
|
| 1926 |
-
|
| 1927 |
-
|
| 1928 |
-
|
| 1929 |
-
|
| 1930 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1931 |
|
| 1932 |
-
|
| 1933 |
-
|
| 1934 |
-
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
|
| 1935 |
-
else:
|
| 1936 |
-
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 1937 |
-
|
| 1938 |
-
all_models.extend(filtered_ollama_models)
|
| 1939 |
-
print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
|
| 1940 |
except Exception as e:
|
| 1941 |
print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
|
| 1942 |
|
| 1943 |
-
# 2. Gemini 모델 목록 가져오기
|
| 1944 |
try:
|
| 1945 |
gemini_client = get_gemini_client()
|
| 1946 |
if gemini_client.is_configured():
|
| 1947 |
gemini_models = gemini_client.get_available_models()
|
| 1948 |
|
| 1949 |
-
|
| 1950 |
-
|
| 1951 |
-
|
| 1952 |
-
|
| 1953 |
-
|
| 1954 |
-
|
| 1955 |
-
|
| 1956 |
-
|
| 1957 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1958 |
|
| 1959 |
-
|
| 1960 |
-
|
| 1961 |
-
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
|
| 1962 |
-
else:
|
| 1963 |
-
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 1964 |
-
|
| 1965 |
-
all_models.extend(filtered_gemini_models)
|
| 1966 |
-
print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
|
| 1967 |
else:
|
| 1968 |
print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
|
| 1969 |
except Exception as e:
|
|
@@ -2085,22 +2395,32 @@ def chat():
|
|
| 2085 |
try:
|
| 2086 |
data = request.json
|
| 2087 |
message = data.get('message', '')
|
| 2088 |
-
model
|
|
|
|
|
|
|
| 2089 |
file_ids = [int(fid) for fid in data.get('file_ids', []) if fid] # 선택한 웹소설 파일 ID 목록
|
| 2090 |
session_id = data.get('session_id', None) # 대화 세션 ID (정수로 변환)
|
| 2091 |
|
| 2092 |
if not message:
|
| 2093 |
return jsonify({'error': '메시지가 필요합니다.'}), 400
|
| 2094 |
|
| 2095 |
-
# 모델이
|
| 2096 |
-
if
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2097 |
try:
|
| 2098 |
# RAG: 질문과 관련된 청크 검색
|
| 2099 |
context = ""
|
| 2100 |
use_rag = True # RAG 사용 여부
|
| 2101 |
|
| 2102 |
if use_rag:
|
| 2103 |
-
print(f"\n[RAG 검색] 모델: {
|
| 2104 |
print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
|
| 2105 |
|
| 2106 |
# 1단계: 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)
|
|
@@ -2120,12 +2440,12 @@ def chat():
|
|
| 2120 |
)
|
| 2121 |
print(f"[RAG 검색 2단계] GraphRAG 데이터 조회 완료: 엔티티 {len(graph_data['entities'])}개, 관계 {len(graph_data['relationships'])}개, 사건 {len(graph_data['events'])}개")
|
| 2122 |
|
| 2123 |
-
# 3단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색
|
| 2124 |
-
print(f"[RAG 검색 3단계] 벡터 검색 + 리랭킹
|
| 2125 |
relevant_chunks = search_relevant_chunks(
|
| 2126 |
query=message,
|
| 2127 |
file_ids=file_ids if file_ids else None,
|
| 2128 |
-
model_name=
|
| 2129 |
top_k=5, # 리랭킹 후 상위 5개만 선택
|
| 2130 |
min_score=0.5 # 최소 점수 임계값
|
| 2131 |
)
|
|
@@ -2421,13 +2741,13 @@ def chat():
|
|
| 2421 |
|
| 2422 |
uploaded_files = UploadedFile.query.filter(
|
| 2423 |
UploadedFile.id.in_(expanded_file_ids),
|
| 2424 |
-
UploadedFile.model_name ==
|
| 2425 |
).all()
|
| 2426 |
print(f"[파일 사용] 선택된 파일 ID로 조회 (이어서 업로드 포함): {len(uploaded_files)}개 파일")
|
| 2427 |
else:
|
| 2428 |
# 파일 ID가 없으면 해당 모델의 모든 파일 사용 (원본 및 이어서 업로드 포함)
|
| 2429 |
-
uploaded_files = UploadedFile.query.filter_by(model_name=
|
| 2430 |
-
print(f"[파일 사용] 모델 '{
|
| 2431 |
|
| 2432 |
if uploaded_files:
|
| 2433 |
print(f"[파일 사용] 사용되는 파일 목록:")
|
|
@@ -2490,12 +2810,18 @@ def chat():
|
|
| 2490 |
if system_prompt:
|
| 2491 |
print(f"[프롬프트] 시스템 프롬프트 적용: {len(system_prompt)}자")
|
| 2492 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2493 |
# 모델 타입 확인 (Gemini 또는 Ollama)
|
| 2494 |
-
is_gemini =
|
|
|
|
|
|
|
| 2495 |
|
| 2496 |
if is_gemini:
|
| 2497 |
# Gemini API 호출
|
| 2498 |
-
gemini_model_name =
|
| 2499 |
print(f"[Gemini] 모델: {gemini_model_name}, 질문: {message[:50]}...")
|
| 2500 |
|
| 2501 |
gemini_client = get_gemini_client()
|
|
@@ -2506,19 +2832,22 @@ def chat():
|
|
| 2506 |
prompt=full_prompt,
|
| 2507 |
model_name=gemini_model_name,
|
| 2508 |
temperature=0.7,
|
| 2509 |
-
max_output_tokens=8192
|
| 2510 |
)
|
| 2511 |
|
| 2512 |
if result['error']:
|
| 2513 |
return jsonify({'error': result['error']}), 500
|
| 2514 |
|
| 2515 |
-
response_text = result
|
|
|
|
|
|
|
|
|
|
| 2516 |
else:
|
| 2517 |
# Ollama API 호출
|
| 2518 |
ollama_response = requests.post(
|
| 2519 |
f'{OLLAMA_BASE_URL}/api/generate',
|
| 2520 |
json={
|
| 2521 |
-
'model':
|
| 2522 |
'prompt': full_prompt,
|
| 2523 |
'stream': False
|
| 2524 |
},
|
|
@@ -2533,13 +2862,16 @@ def chat():
|
|
| 2533 |
error_detail = ollama_response.text[:200] if ollama_response.text else '상세 정보 없음'
|
| 2534 |
|
| 2535 |
if ollama_response.status_code == 404:
|
| 2536 |
-
error_msg = f'모델 "{
|
| 2537 |
else:
|
| 2538 |
error_msg = f'Ollama 서버 오류: {ollama_response.status_code} (오류: {error_detail})'
|
| 2539 |
return jsonify({'error': error_msg}), ollama_response.status_code
|
| 2540 |
|
| 2541 |
ollama_data = ollama_response.json()
|
| 2542 |
-
response_text = ollama_data.get('response', '
|
|
|
|
|
|
|
|
|
|
| 2543 |
|
| 2544 |
# 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
|
| 2545 |
session_id = data.get('session_id')
|
|
@@ -2603,6 +2935,15 @@ def chat():
|
|
| 2603 |
)
|
| 2604 |
db.session.add(ai_msg)
|
| 2605 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2606 |
session.updated_at = datetime.utcnow()
|
| 2607 |
db.session.commit()
|
| 2608 |
|
|
@@ -2613,6 +2954,13 @@ def chat():
|
|
| 2613 |
db.session.rollback()
|
| 2614 |
session_dict = None
|
| 2615 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2616 |
response_data = {'response': response_text, 'session_id': session_id}
|
| 2617 |
if session_dict:
|
| 2618 |
response_data['session'] = session_dict
|
|
@@ -2827,88 +3175,45 @@ def upload_file():
|
|
| 2827 |
db.session.flush() # ID를 얻기 위해 flush
|
| 2828 |
log_print(f"[7/8] 데이터베이스 flush 완료, 파일 ID: {uploaded_file.id}")
|
| 2829 |
|
| 2830 |
-
#
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2831 |
if original_filename.lower().endswith(('.txt', '.md')):
|
| 2832 |
try:
|
| 2833 |
-
log_print(f"[7/8] 청크 생성 시작: {original_filename}")
|
| 2834 |
-
log_print(f"[7/8] 파일 ID: {uploaded_file.id}")
|
| 2835 |
-
|
| 2836 |
-
# 파일 내용 읽기
|
| 2837 |
encoding = 'utf-8'
|
| 2838 |
try:
|
| 2839 |
with open(file_path, 'r', encoding=encoding) as f:
|
| 2840 |
content = f.read()
|
| 2841 |
-
log_print(f"[7/8] UTF-8 인코딩으로 파일 읽기 성공: {len(content)}자")
|
| 2842 |
except UnicodeDecodeError:
|
| 2843 |
-
log_print(f"[7/8] UTF-8 인코딩 실패, CP949 시도: {original_filename}")
|
| 2844 |
with open(file_path, 'r', encoding='cp949') as f:
|
| 2845 |
content = f.read()
|
| 2846 |
-
log_print(f"[7/8] CP949 인코딩으로 파일 읽기 성공: {len(content)}자")
|
| 2847 |
-
|
| 2848 |
-
# 1. Parent Chunk 생성 (AI 분석) - 먼저 생성
|
| 2849 |
-
try:
|
| 2850 |
-
log_print(f"[7/8] Parent Chunk 생성 시작 (AI 분석)...")
|
| 2851 |
-
parent_chunk = create_parent_chunk_with_ai(uploaded_file.id, content, model_name)
|
| 2852 |
-
if parent_chunk:
|
| 2853 |
-
log_print(f"[7/8] ✅ Parent Chunk 생성 완료: {original_filename}")
|
| 2854 |
-
print(f"Parent Chunk가 생성되었습니다: {original_filename}")
|
| 2855 |
-
else:
|
| 2856 |
-
log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 실패: {original_filename}")
|
| 2857 |
-
print(f"경고: Parent Chunk 생성에 실패했습니다: {original_filename}")
|
| 2858 |
-
except Exception as parent_chunk_error:
|
| 2859 |
-
# Parent Chunk 생성 실패해도 업로드는 계속 진행
|
| 2860 |
-
log_print(f"[7/8] ⚠️ 경고: Parent Chunk 생성 중 예외 발생: {str(parent_chunk_error)}")
|
| 2861 |
-
print(f"경고: Parent Chunk 생성 중 오류가 발생했습니다: {original_filename}")
|
| 2862 |
-
import traceback
|
| 2863 |
-
traceback.print_exc()
|
| 2864 |
-
|
| 2865 |
-
# 2. Child Chunk 생성 및 저장 (섹션별 분할)
|
| 2866 |
-
log_print(f"[8/8] Child Chunk 생성 함수 호출 중...")
|
| 2867 |
-
chunk_count = create_chunks_for_file(uploaded_file.id, content)
|
| 2868 |
-
|
| 2869 |
-
if chunk_count > 0:
|
| 2870 |
-
log_print(f"[8/8] ✅ 성공: 파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
|
| 2871 |
-
print(f"파일 {original_filename}을 {chunk_count}개의 청크로 분할했습니다.")
|
| 2872 |
-
else:
|
| 2873 |
-
log_print(f"[8/8] ⚠️ 경고: 청크가 생성되지 않았습니다. (파일이 너무 짧거나 비어있을 수 있습니다.)")
|
| 2874 |
-
print(f"경고: 파일 {original_filename}에 대한 청크가 생성되지 않았습니다.")
|
| 2875 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2876 |
except Exception as e:
|
| 2877 |
-
|
| 2878 |
-
|
| 2879 |
-
print(error_msg)
|
| 2880 |
-
import traceback
|
| 2881 |
-
traceback.print_exc()
|
| 2882 |
-
# 청크 생성 실패해도 파일 업로드는 계속 진행 (경고만 표시)
|
| 2883 |
-
log_print(f"[7/8] ⚠️ 경고: 청크 생성 실패했지만 파일 업로드는 계속 진행합니다.")
|
| 2884 |
-
|
| 2885 |
-
# 최종 청크 개수 확인 및 저장
|
| 2886 |
-
chunk_count = 0
|
| 2887 |
-
if original_filename.lower().endswith(('.txt', '.md')):
|
| 2888 |
-
chunk_count = DocumentChunk.query.filter_by(file_id=uploaded_file.id).count()
|
| 2889 |
-
log_print(f"[8/8] 최종 청크 개수 확인: {chunk_count}개")
|
| 2890 |
-
|
| 2891 |
-
db.session.commit()
|
| 2892 |
-
log_print(f"[8/8] 데이터베이스 커밋 완료: {original_filename}")
|
| 2893 |
-
log_print(f"[8/8] 연결된 모델: {model_name}")
|
| 2894 |
-
log_print(f"[8/8] 생성된 청크 수: {chunk_count}")
|
| 2895 |
-
|
| 2896 |
-
# 학습 상태 요약
|
| 2897 |
-
if chunk_count > 0:
|
| 2898 |
-
log_print(f"[8/8] ✅ AI 학습 준비 완료: {chunk_count}개 청크가 저장되어 RAG 검색에 사용 가능합니다.")
|
| 2899 |
-
else:
|
| 2900 |
-
log_print(f"[8/8] ⚠️ 경고: 청크가 생성되지 않아 RAG 검색이 불가능합니다.")
|
| 2901 |
-
log_print(f"{'='*60}")
|
| 2902 |
-
log_print(f"=== 파일 업로드 성공 ===")
|
| 2903 |
-
log_print(f"{'='*60}\n")
|
| 2904 |
-
|
| 2905 |
-
log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
|
| 2906 |
|
| 2907 |
return jsonify({
|
| 2908 |
'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
|
| 2909 |
'file': uploaded_file.to_dict(),
|
| 2910 |
'model_name': model_name,
|
| 2911 |
-
'
|
|
|
|
|
|
|
| 2912 |
}), 200
|
| 2913 |
|
| 2914 |
except Exception as e:
|
|
@@ -3303,6 +3608,227 @@ def create_file_parent_chunk(file_id):
|
|
| 3303 |
except Exception as e:
|
| 3304 |
return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 3305 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3306 |
@main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
|
| 3307 |
@login_required
|
| 3308 |
def create_file_metadata(file_id):
|
|
@@ -3574,12 +4100,16 @@ def create_chat_session():
|
|
| 3574 |
try:
|
| 3575 |
data = request.json
|
| 3576 |
title = data.get('title', '새 대화')
|
| 3577 |
-
model_name = data.get('model_name', None)
|
|
|
|
|
|
|
| 3578 |
|
| 3579 |
session = ChatSession(
|
| 3580 |
user_id=current_user.id,
|
| 3581 |
title=title,
|
| 3582 |
-
model_name=model_name
|
|
|
|
|
|
|
| 3583 |
)
|
| 3584 |
db.session.add(session)
|
| 3585 |
db.session.commit()
|
|
|
|
| 31 |
# Ollama 기본 URL (환경 변수로 설정 가능)
|
| 32 |
OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
|
| 33 |
|
| 34 |
+
def get_model_token_limit(model_name, default_tokens=2000, token_type='output'):
|
| 35 |
+
"""모델별 토큰 수 제한 가져오기 (하위 호환성을 위해 기본값은 출력 토큰)
|
| 36 |
+
|
| 37 |
+
Args:
|
| 38 |
+
model_name: AI 모델명 (예: "gemini-2.0-flash-exp", "gemini:gemini-2.0-flash-exp", "gemma2:9b")
|
| 39 |
+
default_tokens: 기본 토큰 수 (설정이 없을 때 사용)
|
| 40 |
+
token_type: 'input' 또는 'output' (기본값: 'output')
|
| 41 |
+
|
| 42 |
+
Returns:
|
| 43 |
+
토큰 수 (정수)
|
| 44 |
+
"""
|
| 45 |
+
return get_model_token_limit_by_type(model_name, default_tokens, token_type)
|
| 46 |
+
|
| 47 |
+
def get_model_token_limit_by_type(model_name, default_tokens=2000, token_type='output'):
|
| 48 |
+
"""모델별 토큰 수 제한 가져오기 (입력/출력/Parent Chunk 구분)
|
| 49 |
+
|
| 50 |
+
Args:
|
| 51 |
+
model_name: AI 모델명 (예: "gemini-2.0-flash-exp", "gemini:gemini-2.0-flash-exp", "gemma2:9b")
|
| 52 |
+
default_tokens: 기본 토큰 수 (설정이 없을 때 사용)
|
| 53 |
+
token_type: 'input', 'output', 또는 'parent_chunk'
|
| 54 |
+
|
| 55 |
+
Returns:
|
| 56 |
+
토큰 수 (정수)
|
| 57 |
+
"""
|
| 58 |
+
if not model_name:
|
| 59 |
+
return default_tokens
|
| 60 |
+
|
| 61 |
+
try:
|
| 62 |
+
from app.database import SystemConfig
|
| 63 |
+
|
| 64 |
+
# 여러 형식의 모델명을 시도
|
| 65 |
+
# 1. 원본 모델명 그대로
|
| 66 |
+
# 2. Gemini 모델의 경우 "gemini:" 접두사 추가/제거 버전
|
| 67 |
+
# 3. Ollama 모델의 경우 그대로
|
| 68 |
+
|
| 69 |
+
model_name_clean = model_name.strip()
|
| 70 |
+
possible_keys = [model_name_clean]
|
| 71 |
+
|
| 72 |
+
# Gemini 모델 처리
|
| 73 |
+
if model_name_clean.startswith('gemini:'):
|
| 74 |
+
# "gemini:gemini-2.0-flash-exp" -> "gemini:gemini-2.0-flash-exp" (그대로)
|
| 75 |
+
# 또는 "gemini-2.0-flash-exp" (접두사 제거)
|
| 76 |
+
possible_keys.append(model_name_clean.replace('gemini:', '', 1))
|
| 77 |
+
elif model_name_clean.startswith('gemini-'):
|
| 78 |
+
# "gemini-2.0-flash-exp" -> "gemini:gemini-2.0-flash-exp" (접두사 추가)
|
| 79 |
+
possible_keys.append(f'gemini:{model_name_clean}')
|
| 80 |
+
|
| 81 |
+
# 각 가능한 키를 시도
|
| 82 |
+
for key in possible_keys:
|
| 83 |
+
# 새로운 형식: model_token_input_{model_name}, model_token_output_{model_name}, model_token_parent_chunk_{model_name}
|
| 84 |
+
config_key = f"model_token_{token_type}_{key}"
|
| 85 |
+
token_value = SystemConfig.get_config(config_key)
|
| 86 |
+
if token_value:
|
| 87 |
+
try:
|
| 88 |
+
token_int = int(token_value)
|
| 89 |
+
print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 {token_type} 토큰 수 {token_int} 사용 (키: {config_key})")
|
| 90 |
+
return token_int
|
| 91 |
+
except (ValueError, TypeError):
|
| 92 |
+
continue
|
| 93 |
+
|
| 94 |
+
# 하위 호환성: 기존 형식 model_token_{model_name}도 확인 (출력 토큰으로 간주)
|
| 95 |
+
if token_type == 'output':
|
| 96 |
+
old_config_key = f"model_token_{key}"
|
| 97 |
+
token_value = SystemConfig.get_config(old_config_key)
|
| 98 |
+
if token_value:
|
| 99 |
+
try:
|
| 100 |
+
token_int = int(token_value)
|
| 101 |
+
print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 출력 토큰 수 {token_int} 사용 (기존 키: {old_config_key})")
|
| 102 |
+
return token_int
|
| 103 |
+
except (ValueError, TypeError):
|
| 104 |
+
continue
|
| 105 |
+
|
| 106 |
+
# 설정이 없으면 기본값 사용
|
| 107 |
+
print(f"[get_model_token_limit_by_type] 모델 '{model_name}'의 {token_type} 토큰 수 설정이 없어 기본값 {default_tokens} 사용")
|
| 108 |
+
except Exception as e:
|
| 109 |
+
print(f"[get_model_token_limit_by_type] 오류: {e}")
|
| 110 |
+
|
| 111 |
+
return default_tokens
|
| 112 |
+
|
| 113 |
# 업로드 설정
|
| 114 |
UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
|
| 115 |
ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
|
|
|
|
| 436 |
prompt=prompt,
|
| 437 |
model_name="gemini-1.5-flash",
|
| 438 |
temperature=0.3,
|
| 439 |
+
max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 500) # 저장된 토큰 수 사용
|
| 440 |
)
|
| 441 |
if not result['error'] and result.get('response'):
|
| 442 |
response_text = result['response'].strip()
|
|
|
|
| 464 |
prompt=prompt,
|
| 465 |
model_name=gemini_model_name,
|
| 466 |
temperature=0.3,
|
| 467 |
+
max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 500) # 저장된 토큰 수 사용
|
| 468 |
)
|
| 469 |
if not result['error'] and result.get('response'):
|
| 470 |
response_text = result['response'].strip()
|
|
|
|
| 483 |
'stream': False,
|
| 484 |
'options': {
|
| 485 |
'temperature': 0.3,
|
| 486 |
+
'num_predict': get_model_token_limit(model_name, 500) # 저장된 토큰 수 사용
|
| 487 |
}
|
| 488 |
},
|
| 489 |
timeout=120 # 2분 타임아웃
|
|
|
|
| 605 |
## 인물과 인물간의 관계 변화
|
| 606 |
[이 회차에서 인물들 간의 관계가 어떻게 변화했는지, 새로운 관계가 형성되었는지 등을 분석해주세요]
|
| 607 |
|
| 608 |
+
## {episode_title} 인물 외모 분석
|
| 609 |
+
[이 회차에 등장한 인물들의 외모, 체형, 얼굴 특징, 신체적 특징 등을 상세히 분석해주세요. 특히 새로 등장한 인물이나 외모가 변경된 인물에 대해 자세히 설명해주세요]
|
| 610 |
+
|
| 611 |
+
## {episode_title} 인물 의복 분석
|
| 612 |
+
[이 회차에 등장한 인물들이 착용한 의복, 복장, 액세서리 등을 상세히 분석해주세요. 의복의 스타일, 색상, 특징, 상황에 맞는 복장인지 등을 분석해주세요]
|
| 613 |
+
|
| 614 |
+
## {episode_title} 배경 분석
|
| 615 |
+
[이 회차의 배경이 되는 장소, 환경, 시간대, 분위기 등을 상세히 분석해주세요. 장소의 특징, 분위기, 시간적 배경, 날씨, 계절 등을 포함하여 분석해주세요]
|
| 616 |
+
|
| 617 |
## 기타
|
| 618 |
[이 회차의 특별한 점, 중요 사건, 떡밥, 복선 등 기타 중요한 내용을 분석해주세요]
|
| 619 |
|
|
|
|
| 629 |
prompt=prompt,
|
| 630 |
model_name="gemini-1.5-flash",
|
| 631 |
temperature=0.5,
|
| 632 |
+
max_output_tokens=get_model_token_limit("gemini-1.5-flash", 3000) # 저장된 토큰 수 사용
|
| 633 |
)
|
| 634 |
if not result['error'] and result.get('response'):
|
| 635 |
return result['response'].strip()
|
|
|
|
| 652 |
prompt=prompt,
|
| 653 |
model_name=gemini_model_name,
|
| 654 |
temperature=0.5,
|
| 655 |
+
max_output_tokens=get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
|
| 656 |
)
|
| 657 |
if not result['error'] and result.get('response'):
|
| 658 |
return result['response'].strip()
|
|
|
|
| 667 |
'stream': False,
|
| 668 |
'options': {
|
| 669 |
'temperature': 0.5,
|
| 670 |
+
'num_predict': get_model_token_limit(model_name, 3000) # 저장된 토큰 수 사용
|
| 671 |
}
|
| 672 |
},
|
| 673 |
timeout=300 # 5분 타임아웃 (회차 분석은 시간이 오래 걸릴 수 있음)
|
|
|
|
| 901 |
db.session.rollback()
|
| 902 |
return False
|
| 903 |
|
| 904 |
+
def create_chunks_for_file(file_id, content, skip_episode_analysis=False, skip_graph_extraction=False):
|
| 905 |
"""파일 내용을 섹션별로 분할하여 의미 기반 청크로 저장 (벡터 DB 포함)
|
| 906 |
|
| 907 |
섹션 분할 규칙:
|
|
|
|
| 911 |
Args:
|
| 912 |
file_id: 파일 ID
|
| 913 |
content: 파일 내용
|
| 914 |
+
skip_episode_analysis: 회차 분석 건너뛰기 (기본값: False)
|
| 915 |
+
skip_graph_extraction: Graph Extraction 건너뛰기 (기본값: False)
|
| 916 |
"""
|
| 917 |
try:
|
| 918 |
print(f"[청크 생성] 파일 ID {file_id}에 대한 청크 생성 시작")
|
|
|
|
| 955 |
|
| 956 |
# '#작품설명'을 제외한 각 회차 분석
|
| 957 |
episode_sections = [s for s in sections if s[0] != '작품설명'] # section_type이 '작품설명'이 아닌 것만
|
| 958 |
+
if episode_sections and model_name and not skip_episode_analysis:
|
| 959 |
print(f"[회차 분석] {len(episode_sections)}개 회차 분석 시작...")
|
| 960 |
|
| 961 |
# Parent Chunk 가져오기
|
|
|
|
| 1005 |
print(f"[회차 분석] 완료: {len(episode_sections)}개 회차 분석 ���과를 하나의 텍스트로 저장")
|
| 1006 |
|
| 1007 |
# 회차별 Graph Extraction 실행
|
| 1008 |
+
if not skip_graph_extraction:
|
| 1009 |
+
print(f"[Graph Extraction] 회차별 Graph Extraction 시작...")
|
| 1010 |
+
graph_extraction_success_count = 0
|
| 1011 |
+
for section_type, section_title, section_content, section_metadata in episode_sections:
|
| 1012 |
+
try:
|
| 1013 |
+
print(f"[Graph Extraction] '{section_title}' Graph Extraction 중...")
|
| 1014 |
+
success = extract_graph_from_episode(
|
| 1015 |
+
episode_content=section_content,
|
| 1016 |
+
episode_title=section_title,
|
| 1017 |
+
file_id=file_id,
|
| 1018 |
+
full_content=content,
|
| 1019 |
+
parent_chunk=parent_chunk,
|
| 1020 |
+
model_name=model_name
|
| 1021 |
+
)
|
| 1022 |
+
if success:
|
| 1023 |
+
graph_extraction_success_count += 1
|
| 1024 |
+
print(f"[Graph Extraction] '{section_title}' Graph Extraction 완료")
|
| 1025 |
+
else:
|
| 1026 |
+
print(f"[Graph Extraction] '{section_title}' Graph Extraction 실패")
|
| 1027 |
+
except Exception as e:
|
| 1028 |
+
print(f"[Graph Extraction] '{section_title}' Graph Extraction 중 오류: {str(e)}")
|
| 1029 |
+
import traceback
|
| 1030 |
+
traceback.print_exc()
|
| 1031 |
+
continue
|
| 1032 |
+
|
| 1033 |
+
print(f"[Graph Extraction] 완료: {graph_extraction_success_count}/{len(episode_sections)}개 회차 Graph Extraction 성공")
|
| 1034 |
else:
|
| 1035 |
print(f"[회차 분석] 경고: 분석 결과가 없습니다.")
|
| 1036 |
else:
|
|
|
|
| 1185 |
prompt=analysis_prompt,
|
| 1186 |
model_name=gemini_model_name,
|
| 1187 |
temperature=0.7,
|
| 1188 |
+
max_output_tokens=get_model_token_limit_by_type(model_name or "gemini-1.5-flash", 8192, 'parent_chunk') # Parent Chunk 전용 토큰 수 사용
|
| 1189 |
)
|
| 1190 |
|
| 1191 |
if result['error']:
|
|
|
|
| 1757 |
"""프롬프트 관리 페이지"""
|
| 1758 |
return render_template('admin_prompts.html')
|
| 1759 |
|
| 1760 |
+
@main_bp.route('/admin/settings')
|
| 1761 |
+
@admin_required
|
| 1762 |
+
def admin_settings():
|
| 1763 |
+
"""AI 설정 관리 페이지 (API 키, 토큰 수)"""
|
| 1764 |
+
return render_template('admin_settings.html')
|
| 1765 |
+
|
| 1766 |
@main_bp.route('/admin/files')
|
| 1767 |
@admin_required
|
| 1768 |
def admin_files():
|
|
|
|
| 1861 |
try:
|
| 1862 |
user_id = request.args.get('user_id', type=int)
|
| 1863 |
session_id = request.args.get('session_id', type=int)
|
| 1864 |
+
message_id = request.args.get('message_id', type=int)
|
| 1865 |
page = request.args.get('page', 1, type=int)
|
| 1866 |
per_page = request.args.get('per_page', 50, type=int)
|
| 1867 |
|
|
|
|
| 1871 |
query = query.filter(ChatSession.user_id == user_id)
|
| 1872 |
if session_id:
|
| 1873 |
query = query.filter(ChatMessage.session_id == session_id)
|
| 1874 |
+
if message_id:
|
| 1875 |
+
query = query.filter(ChatMessage.id == message_id)
|
| 1876 |
|
| 1877 |
messages = query.order_by(ChatMessage.created_at.desc())\
|
| 1878 |
.paginate(page=page, per_page=per_page, error_out=False)
|
|
|
|
| 1961 |
traceback.print_exc()
|
| 1962 |
return jsonify({'error': f'API 키 조회 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 1963 |
|
| 1964 |
+
@main_bp.route('/api/admin/model-tokens', methods=['GET'])
|
| 1965 |
+
@admin_required
|
| 1966 |
+
def get_model_tokens():
|
| 1967 |
+
"""모든 모델의 토큰 수 설정 조회 (입력/출력 분리)"""
|
| 1968 |
+
try:
|
| 1969 |
+
# Ollama 모델 목록 가져오기
|
| 1970 |
+
ollama_models = []
|
| 1971 |
+
try:
|
| 1972 |
+
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 1973 |
+
if response.status_code == 200:
|
| 1974 |
+
data = response.json()
|
| 1975 |
+
ollama_models = [model['name'] for model in data.get('models', [])]
|
| 1976 |
+
except:
|
| 1977 |
+
pass
|
| 1978 |
+
|
| 1979 |
+
# Gemini 모델 목록 가져오기
|
| 1980 |
+
gemini_models = []
|
| 1981 |
+
try:
|
| 1982 |
+
gemini_client = get_gemini_client()
|
| 1983 |
+
if gemini_client.is_configured():
|
| 1984 |
+
gemini_models = gemini_client.get_available_models()
|
| 1985 |
+
gemini_models = [f'gemini:{m}' for m in gemini_models]
|
| 1986 |
+
except:
|
| 1987 |
+
pass
|
| 1988 |
+
|
| 1989 |
+
# 모든 모델 목록
|
| 1990 |
+
all_models = ollama_models + gemini_models
|
| 1991 |
+
|
| 1992 |
+
# 각 모델의 토큰 수 설정 가져오기 (입력/출력/Parent Chunk 분리)
|
| 1993 |
+
model_input_tokens = {}
|
| 1994 |
+
model_output_tokens = {}
|
| 1995 |
+
model_parent_chunk_tokens = {}
|
| 1996 |
+
default_input_tokens = {}
|
| 1997 |
+
default_output_tokens = {}
|
| 1998 |
+
default_parent_chunk_tokens = {}
|
| 1999 |
+
|
| 2000 |
+
# 모델별 기본값 결정
|
| 2001 |
+
def get_default_token_for_model(model_name, token_type='output'):
|
| 2002 |
+
"""모델별 기본 토큰 수 결정"""
|
| 2003 |
+
if not model_name:
|
| 2004 |
+
if token_type == 'parent_chunk':
|
| 2005 |
+
return 8192
|
| 2006 |
+
return 2000 if token_type == 'output' else 100000
|
| 2007 |
+
|
| 2008 |
+
# Gemini 모델의 경우
|
| 2009 |
+
if model_name.startswith('gemini:'):
|
| 2010 |
+
if token_type == 'parent_chunk':
|
| 2011 |
+
return 8192 # Parent Chunk 기본값
|
| 2012 |
+
return 2000 if token_type == 'output' else 100000 # Gemini 입력 기본값은 더 큼
|
| 2013 |
+
|
| 2014 |
+
# Ollama 모델의 경우
|
| 2015 |
+
if token_type == 'parent_chunk':
|
| 2016 |
+
return 8192 # Parent Chunk 기본값
|
| 2017 |
+
return 2000 if token_type == 'output' else 100000 # Ollama 입력 기본값도 더 큼
|
| 2018 |
+
|
| 2019 |
+
for model_name in all_models:
|
| 2020 |
+
# 입력 토큰 설정 가져오기
|
| 2021 |
+
input_config_key = f"model_token_input_{model_name}"
|
| 2022 |
+
input_token_value = SystemConfig.get_config(input_config_key)
|
| 2023 |
+
default_input_token = get_default_token_for_model(model_name, 'input')
|
| 2024 |
+
default_input_tokens[model_name] = default_input_token
|
| 2025 |
+
|
| 2026 |
+
if input_token_value:
|
| 2027 |
+
try:
|
| 2028 |
+
model_input_tokens[model_name] = int(input_token_value)
|
| 2029 |
+
except (ValueError, TypeError):
|
| 2030 |
+
model_input_tokens[model_name] = None
|
| 2031 |
+
else:
|
| 2032 |
+
model_input_tokens[model_name] = None
|
| 2033 |
+
|
| 2034 |
+
# 출력 토큰 설정 가져오기
|
| 2035 |
+
output_config_key = f"model_token_output_{model_name}"
|
| 2036 |
+
output_token_value = SystemConfig.get_config(output_config_key)
|
| 2037 |
+
# 하위 호환성: 기존 형식도 확인
|
| 2038 |
+
if not output_token_value:
|
| 2039 |
+
old_config_key = f"model_token_{model_name}"
|
| 2040 |
+
output_token_value = SystemConfig.get_config(old_config_key)
|
| 2041 |
+
|
| 2042 |
+
default_output_token = get_default_token_for_model(model_name, 'output')
|
| 2043 |
+
default_output_tokens[model_name] = default_output_token
|
| 2044 |
+
|
| 2045 |
+
if output_token_value:
|
| 2046 |
+
try:
|
| 2047 |
+
model_output_tokens[model_name] = int(output_token_value)
|
| 2048 |
+
except (ValueError, TypeError):
|
| 2049 |
+
model_output_tokens[model_name] = None
|
| 2050 |
+
else:
|
| 2051 |
+
model_output_tokens[model_name] = None
|
| 2052 |
+
|
| 2053 |
+
# Parent Chunk 토큰 설정 가져오기
|
| 2054 |
+
parent_chunk_config_key = f"model_token_parent_chunk_{model_name}"
|
| 2055 |
+
parent_chunk_token_value = SystemConfig.get_config(parent_chunk_config_key)
|
| 2056 |
+
default_parent_chunk_token = get_default_token_for_model(model_name, 'parent_chunk')
|
| 2057 |
+
default_parent_chunk_tokens[model_name] = default_parent_chunk_token
|
| 2058 |
+
|
| 2059 |
+
if parent_chunk_token_value:
|
| 2060 |
+
try:
|
| 2061 |
+
model_parent_chunk_tokens[model_name] = int(parent_chunk_token_value)
|
| 2062 |
+
except (ValueError, TypeError):
|
| 2063 |
+
model_parent_chunk_tokens[model_name] = None
|
| 2064 |
+
else:
|
| 2065 |
+
model_parent_chunk_tokens[model_name] = None
|
| 2066 |
+
|
| 2067 |
+
return jsonify({
|
| 2068 |
+
'models': all_models,
|
| 2069 |
+
'input_tokens': model_input_tokens,
|
| 2070 |
+
'output_tokens': model_output_tokens,
|
| 2071 |
+
'parent_chunk_tokens': model_parent_chunk_tokens,
|
| 2072 |
+
'default_input_tokens': default_input_tokens,
|
| 2073 |
+
'default_output_tokens': default_output_tokens,
|
| 2074 |
+
'default_parent_chunk_tokens': default_parent_chunk_tokens
|
| 2075 |
+
}), 200
|
| 2076 |
+
|
| 2077 |
+
except Exception as e:
|
| 2078 |
+
return jsonify({'error': f'토큰 수 설정 조회 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 2079 |
+
|
| 2080 |
+
@main_bp.route('/api/admin/model-tokens', methods=['POST'])
|
| 2081 |
+
@admin_required
|
| 2082 |
+
def save_model_tokens():
|
| 2083 |
+
"""모델별 토큰 수 설정 저장 (입력/출력 분리, 또는 삭제)"""
|
| 2084 |
+
try:
|
| 2085 |
+
data = request.json
|
| 2086 |
+
model_name = data.get('model_name', '').strip()
|
| 2087 |
+
token_type = data.get('token_type', 'output').strip() # 'input' 또는 'output'
|
| 2088 |
+
tokens = data.get('tokens', None)
|
| 2089 |
+
|
| 2090 |
+
if not model_name:
|
| 2091 |
+
return jsonify({'error': '모델명을 입력해주세요.'}), 400
|
| 2092 |
+
|
| 2093 |
+
if token_type not in ['input', 'output', 'parent_chunk']:
|
| 2094 |
+
return jsonify({'error': '토큰 타입은 "input", "output", 또는 "parent_chunk"이어야 합니다.'}), 400
|
| 2095 |
+
|
| 2096 |
+
# tokens가 None이면 설정 삭제 (기본값 사용)
|
| 2097 |
+
if tokens is None:
|
| 2098 |
+
try:
|
| 2099 |
+
config_key = f"model_token_{token_type}_{model_name}"
|
| 2100 |
+
config = SystemConfig.query.filter_by(key=config_key).first()
|
| 2101 |
+
if config:
|
| 2102 |
+
db.session.delete(config)
|
| 2103 |
+
db.session.commit()
|
| 2104 |
+
return jsonify({
|
| 2105 |
+
'message': f'{model_name} 모델의 {token_type} 토큰 수 설정이 삭제되었습니다. 기본값을 사용합니다.',
|
| 2106 |
+
'model_name': model_name,
|
| 2107 |
+
'token_type': token_type,
|
| 2108 |
+
'tokens': None
|
| 2109 |
+
}), 200
|
| 2110 |
+
else:
|
| 2111 |
+
# 하위 호환성: 기존 형식도 삭제 시도 (출력 토큰인 경우)
|
| 2112 |
+
if token_type == 'output':
|
| 2113 |
+
old_config_key = f"model_token_{model_name}"
|
| 2114 |
+
old_config = SystemConfig.query.filter_by(key=old_config_key).first()
|
| 2115 |
+
if old_config:
|
| 2116 |
+
db.session.delete(old_config)
|
| 2117 |
+
db.session.commit()
|
| 2118 |
+
return jsonify({
|
| 2119 |
+
'message': f'{model_name} 모델의 출력 토큰 수 설정이 삭제되었습니다. 기본값을 사용합니다.',
|
| 2120 |
+
'model_name': model_name,
|
| 2121 |
+
'token_type': token_type,
|
| 2122 |
+
'tokens': None
|
| 2123 |
+
}), 200
|
| 2124 |
+
return jsonify({
|
| 2125 |
+
'message': f'{model_name} 모델은 이미 기본값을 사용하고 있습니다.',
|
| 2126 |
+
'model_name': model_name,
|
| 2127 |
+
'token_type': token_type,
|
| 2128 |
+
'tokens': None
|
| 2129 |
+
}), 200
|
| 2130 |
+
except Exception as e:
|
| 2131 |
+
db.session.rollback()
|
| 2132 |
+
return jsonify({'error': f'설정 삭제 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 2133 |
+
|
| 2134 |
+
try:
|
| 2135 |
+
tokens = int(tokens)
|
| 2136 |
+
if tokens < 1:
|
| 2137 |
+
return jsonify({'error': '토큰 수는 1 이상이어야 합니다.'}), 400
|
| 2138 |
+
except (ValueError, TypeError):
|
| 2139 |
+
return jsonify({'error': '토큰 수는 정수여야 합니다.'}), 400
|
| 2140 |
+
|
| 2141 |
+
# SystemConfig에 저장
|
| 2142 |
+
config_key = f"model_token_{token_type}_{model_name}"
|
| 2143 |
+
SystemConfig.set_config(config_key, str(tokens), f'{model_name} 모델 {token_type} 토큰 수 제한')
|
| 2144 |
+
|
| 2145 |
+
return jsonify({
|
| 2146 |
+
'message': f'{model_name} 모델의 {token_type} 토큰 수가 {tokens}로 설정되었습니다.',
|
| 2147 |
+
'model_name': model_name,
|
| 2148 |
+
'token_type': token_type,
|
| 2149 |
+
'tokens': tokens
|
| 2150 |
+
}), 200
|
| 2151 |
+
|
| 2152 |
+
except Exception as e:
|
| 2153 |
+
db.session.rollback()
|
| 2154 |
+
print(f"[토큰 수 저장] 오류: {e}")
|
| 2155 |
+
import traceback
|
| 2156 |
+
traceback.print_exc()
|
| 2157 |
+
return jsonify({'error': f'토큰 수 저장 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 2158 |
+
|
| 2159 |
@main_bp.route('/api/admin/gemini-api-key', methods=['POST'])
|
| 2160 |
@admin_required
|
| 2161 |
def set_gemini_api_key():
|
|
|
|
| 2206 |
def get_ollama_models():
|
| 2207 |
"""Ollama 및 Gemini에서 사용 가능한 모델 목록 가져오기 (로컬 AI 모델은 학습된 웹소설이 있는 모델만 표시)"""
|
| 2208 |
try:
|
| 2209 |
+
# 쿼리 파라미터로 all=true가 전달되면 모든 모델 반환
|
| 2210 |
+
show_all = request.args.get('all', 'false').lower() == 'true'
|
| 2211 |
+
|
| 2212 |
all_models = []
|
| 2213 |
|
| 2214 |
+
# 1. Ollama 모델 목록 가져오기
|
| 2215 |
try:
|
| 2216 |
response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
|
| 2217 |
if response.status_code == 200:
|
| 2218 |
data = response.json()
|
| 2219 |
ollama_models_raw = [model['name'] for model in data.get('models', [])]
|
| 2220 |
|
| 2221 |
+
if show_all:
|
| 2222 |
+
# 모든 Ollama 모델 반환
|
| 2223 |
+
ollama_models = [{'name': model_name, 'type': 'ollama'} for model_name in ollama_models_raw]
|
| 2224 |
+
all_models.extend(ollama_models)
|
| 2225 |
+
print(f"[모델 목록] Ollama 모델 {len(ollama_models)}개 추가 (전체 목록)")
|
| 2226 |
+
else:
|
| 2227 |
+
# 학습된 웹소설이 있는 모델만 필터링
|
| 2228 |
+
filtered_ollama_models = []
|
| 2229 |
+
for model_name in ollama_models_raw:
|
| 2230 |
+
# 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
|
| 2231 |
+
file_count = UploadedFile.query.filter_by(
|
| 2232 |
+
model_name=model_name,
|
| 2233 |
+
parent_file_id=None
|
| 2234 |
+
).count()
|
| 2235 |
+
|
| 2236 |
+
if file_count > 0:
|
| 2237 |
+
filtered_ollama_models.append({'name': model_name, 'type': 'ollama'})
|
| 2238 |
+
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 {file_count}개")
|
| 2239 |
+
else:
|
| 2240 |
+
print(f"[모델 목록] Ollama 모델 '{model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 2241 |
|
| 2242 |
+
all_models.extend(filtered_ollama_models)
|
| 2243 |
+
print(f"[모델 목록] Ollama 모델 {len(filtered_ollama_models)}개 추가 (전체 {len(ollama_models_raw)}개 중 {len(filtered_ollama_models)}개 필터링됨)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2244 |
except Exception as e:
|
| 2245 |
print(f"[모델 목록] Ollama 모델 목록 조회 실패: {e}")
|
| 2246 |
|
| 2247 |
+
# 2. Gemini 모델 목록 가져오기
|
| 2248 |
try:
|
| 2249 |
gemini_client = get_gemini_client()
|
| 2250 |
if gemini_client.is_configured():
|
| 2251 |
gemini_models = gemini_client.get_available_models()
|
| 2252 |
|
| 2253 |
+
if show_all:
|
| 2254 |
+
# 모든 Gemini 모델 반환
|
| 2255 |
+
gemini_models_list = [{'name': f'gemini:{model_name}', 'type': 'gemini'} for model_name in gemini_models]
|
| 2256 |
+
all_models.extend(gemini_models_list)
|
| 2257 |
+
print(f"[모델 목록] Gemini 모델 {len(gemini_models_list)}개 추가 (전체 목록)")
|
| 2258 |
+
else:
|
| 2259 |
+
# 학습된 웹소설이 있는 모델만 필터링
|
| 2260 |
+
filtered_gemini_models = []
|
| 2261 |
+
for model_name in gemini_models:
|
| 2262 |
+
full_model_name = f'gemini:{model_name}'
|
| 2263 |
+
# 해당 모델로 학습된 원본 파일이 있는지 확인 (parent_file_id가 None인 파일만)
|
| 2264 |
+
file_count = UploadedFile.query.filter_by(
|
| 2265 |
+
model_name=full_model_name,
|
| 2266 |
+
parent_file_id=None
|
| 2267 |
+
).count()
|
| 2268 |
+
|
| 2269 |
+
if file_count > 0:
|
| 2270 |
+
filtered_gemini_models.append({'name': full_model_name, 'type': 'gemini'})
|
| 2271 |
+
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 {file_count}개")
|
| 2272 |
+
else:
|
| 2273 |
+
print(f"[모델 목록] Gemini 모델 '{full_model_name}' - 학습된 웹소설 없음, 목록에서 제외")
|
| 2274 |
|
| 2275 |
+
all_models.extend(filtered_gemini_models)
|
| 2276 |
+
print(f"[모델 목록] Gemini 모델 {len(filtered_gemini_models)}개 추가 (전체 {len(gemini_models)}개 중 {len(filtered_gemini_models)}개 필터링됨)")
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2277 |
else:
|
| 2278 |
print(f"[모델 목록] Gemini API 키가 설정되지 않아 Gemini 모델을 불러올 수 없습니다.")
|
| 2279 |
except Exception as e:
|
|
|
|
| 2395 |
try:
|
| 2396 |
data = request.json
|
| 2397 |
message = data.get('message', '')
|
| 2398 |
+
# 하위 호환성을 위해 model도 확인 (기존 코드)
|
| 2399 |
+
analysis_model = data.get('analysis_model', data.get('model', '')) # 질문 분석용 모델
|
| 2400 |
+
answer_model = data.get('answer_model', '') # 최종 답변용 모델
|
| 2401 |
file_ids = [int(fid) for fid in data.get('file_ids', []) if fid] # 선택한 웹소설 파일 ID 목록
|
| 2402 |
session_id = data.get('session_id', None) # 대화 세션 ID (정수로 변환)
|
| 2403 |
|
| 2404 |
if not message:
|
| 2405 |
return jsonify({'error': '메시지가 필요합니다.'}), 400
|
| 2406 |
|
| 2407 |
+
# 답변용 모델이 없으면 분석용 모델 사용 (하위 호환성)
|
| 2408 |
+
if not answer_model:
|
| 2409 |
+
answer_model = analysis_model
|
| 2410 |
+
|
| 2411 |
+
# 답변용 모델이 여전히 없으면 에러 반환
|
| 2412 |
+
if not answer_model:
|
| 2413 |
+
return jsonify({'error': '답변을 생성할 AI 모델이 선택되지 않았습니다. "사용 가능한 AI 목록"에서 답변을 생성할 AI 모델을 선택해주세요.'}), 400
|
| 2414 |
+
|
| 2415 |
+
# 분석용 모델이 선택된 경우 RAG 검색 진행
|
| 2416 |
+
if analysis_model:
|
| 2417 |
try:
|
| 2418 |
# RAG: 질문과 관련된 청크 검색
|
| 2419 |
context = ""
|
| 2420 |
use_rag = True # RAG 사용 여부
|
| 2421 |
|
| 2422 |
if use_rag:
|
| 2423 |
+
print(f"\n[RAG 검색] 분석 모델: {analysis_model}, 답변 모델: {answer_model}, 질문: {message[:50]}...")
|
| 2424 |
print(f"[RAG 검색] 선택된 파일 ID: {file_ids if file_ids else '없음 (모든 파일 검색)'}")
|
| 2425 |
|
| 2426 |
# 1단계: 회차별 분석(EpisodeAnalysis) 조회 (회차별 요약 참조용)
|
|
|
|
| 2440 |
)
|
| 2441 |
print(f"[RAG 검색 2단계] GraphRAG 데이터 조회 완료: 엔티티 {len(graph_data['entities'])}개, 관계 {len(graph_data['relationships'])}개, 사건 {len(graph_data['events'])}개")
|
| 2442 |
|
| 2443 |
+
# 3단계: 벡터 검색 + 리랭킹으로 Child Chunk 정밀 검색 (분석 모델 사용)
|
| 2444 |
+
print(f"[RAG 검색 3단계] 벡터 검색 + 리랭킹 시작 (분석 모델: {analysis_model})...")
|
| 2445 |
relevant_chunks = search_relevant_chunks(
|
| 2446 |
query=message,
|
| 2447 |
file_ids=file_ids if file_ids else None,
|
| 2448 |
+
model_name=analysis_model, # 질문 분석은 analysis_model 사용
|
| 2449 |
top_k=5, # 리랭킹 후 상위 5개만 선택
|
| 2450 |
min_score=0.5 # 최소 점수 임계값
|
| 2451 |
)
|
|
|
|
| 2741 |
|
| 2742 |
uploaded_files = UploadedFile.query.filter(
|
| 2743 |
UploadedFile.id.in_(expanded_file_ids),
|
| 2744 |
+
UploadedFile.model_name == analysis_model
|
| 2745 |
).all()
|
| 2746 |
print(f"[파일 사용] 선택된 파일 ID로 조회 (이어서 업로드 포함): {len(uploaded_files)}개 파일")
|
| 2747 |
else:
|
| 2748 |
# 파일 ID가 없으면 해당 모델의 모든 파일 사용 (원본 및 이어서 업로드 포함)
|
| 2749 |
+
uploaded_files = UploadedFile.query.filter_by(model_name=analysis_model).all()
|
| 2750 |
+
print(f"[파일 사용] 모델 '{analysis_model}'의 모든 파일 사용: {len(uploaded_files)}개 파일")
|
| 2751 |
|
| 2752 |
if uploaded_files:
|
| 2753 |
print(f"[파일 사용] 사용되는 파일 목록:")
|
|
|
|
| 2810 |
if system_prompt:
|
| 2811 |
print(f"[프롬프트] 시스템 프롬프트 적용: {len(system_prompt)}자")
|
| 2812 |
|
| 2813 |
+
# 최종 답변 생성은 answer_model 사용
|
| 2814 |
+
if not answer_model:
|
| 2815 |
+
return jsonify({'error': '답변용 모델이 선택되지 않았습니다.'}), 400
|
| 2816 |
+
|
| 2817 |
# 모델 타입 확인 (Gemini 또는 Ollama)
|
| 2818 |
+
is_gemini = answer_model.startswith('gemini:')
|
| 2819 |
+
|
| 2820 |
+
print(f"[최종 답변 생성] 답변 모델: {answer_model}, 프롬프트 길이: {len(full_prompt)}자")
|
| 2821 |
|
| 2822 |
if is_gemini:
|
| 2823 |
# Gemini API 호출
|
| 2824 |
+
gemini_model_name = answer_model.replace('gemini:', '')
|
| 2825 |
print(f"[Gemini] 모델: {gemini_model_name}, 질문: {message[:50]}...")
|
| 2826 |
|
| 2827 |
gemini_client = get_gemini_client()
|
|
|
|
| 2832 |
prompt=full_prompt,
|
| 2833 |
model_name=gemini_model_name,
|
| 2834 |
temperature=0.7,
|
| 2835 |
+
max_output_tokens=get_model_token_limit(model_name or "gemini-1.5-flash", 8192) # 저장된 토큰 수 사용
|
| 2836 |
)
|
| 2837 |
|
| 2838 |
if result['error']:
|
| 2839 |
return jsonify({'error': result['error']}), 500
|
| 2840 |
|
| 2841 |
+
response_text = result.get('response', '').strip()
|
| 2842 |
+
if not response_text:
|
| 2843 |
+
print(f"[채팅] Gemini 응답이 비어있습니다. result: {result}")
|
| 2844 |
+
response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
|
| 2845 |
else:
|
| 2846 |
# Ollama API 호출
|
| 2847 |
ollama_response = requests.post(
|
| 2848 |
f'{OLLAMA_BASE_URL}/api/generate',
|
| 2849 |
json={
|
| 2850 |
+
'model': answer_model, # 답변 모델 사용
|
| 2851 |
'prompt': full_prompt,
|
| 2852 |
'stream': False
|
| 2853 |
},
|
|
|
|
| 2862 |
error_detail = ollama_response.text[:200] if ollama_response.text else '상세 정보 없음'
|
| 2863 |
|
| 2864 |
if ollama_response.status_code == 404:
|
| 2865 |
+
error_msg = f'모델 "{answer_model}"을(를) 찾을 수 없습니다. 모델이 Ollama에 설치되어 있는지 확인하세요. (오류: {error_detail})'
|
| 2866 |
else:
|
| 2867 |
error_msg = f'Ollama 서버 오류: {ollama_response.status_code} (오류: {error_detail})'
|
| 2868 |
return jsonify({'error': error_msg}), ollama_response.status_code
|
| 2869 |
|
| 2870 |
ollama_data = ollama_response.json()
|
| 2871 |
+
response_text = ollama_data.get('response', '').strip()
|
| 2872 |
+
if not response_text:
|
| 2873 |
+
print(f"[채팅] Ollama 응답이 비어있습니다. ollama_data: {ollama_data}")
|
| 2874 |
+
response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
|
| 2875 |
|
| 2876 |
# 대화 세션에 메시지 저장 (Gemini와 Ollama 공통)
|
| 2877 |
session_id = data.get('session_id')
|
|
|
|
| 2935 |
)
|
| 2936 |
db.session.add(ai_msg)
|
| 2937 |
|
| 2938 |
+
# 세션 모델 정보 업데이트 (첫 메시지인 경우 또는 변경된 경우)
|
| 2939 |
+
if not session.analysis_model or session.analysis_model != analysis_model:
|
| 2940 |
+
session.analysis_model = analysis_model
|
| 2941 |
+
if not session.answer_model or session.answer_model != answer_model:
|
| 2942 |
+
session.answer_model = answer_model
|
| 2943 |
+
# 하위 호환성을 위해 model_name도 업데이트
|
| 2944 |
+
if not session.model_name:
|
| 2945 |
+
session.model_name = answer_model or analysis_model
|
| 2946 |
+
|
| 2947 |
session.updated_at = datetime.utcnow()
|
| 2948 |
db.session.commit()
|
| 2949 |
|
|
|
|
| 2954 |
db.session.rollback()
|
| 2955 |
session_dict = None
|
| 2956 |
|
| 2957 |
+
# 응답이 비어있으면 기본 메시지 사용
|
| 2958 |
+
if not response_text or not response_text.strip():
|
| 2959 |
+
print(f"[채팅] 최종 응답이 비어있습니다. 기본 메시지를 사용합니다.")
|
| 2960 |
+
response_text = '응답을 생성할 수 없었습니다. 다시 시도해주세요.'
|
| 2961 |
+
|
| 2962 |
+
print(f"[채팅] 최종 응답 길이: {len(response_text)}자, 미리보기: {response_text[:100]}...")
|
| 2963 |
+
|
| 2964 |
response_data = {'response': response_text, 'session_id': session_id}
|
| 2965 |
if session_dict:
|
| 2966 |
response_data['session'] = session_dict
|
|
|
|
| 3175 |
db.session.flush() # ID를 얻기 위해 flush
|
| 3176 |
log_print(f"[7/8] 데이터베이스 flush 완료, 파일 ID: {uploaded_file.id}")
|
| 3177 |
|
| 3178 |
+
# 파일 저장만 완료 (청크 생성은 별도 API로 처리)
|
| 3179 |
+
db.session.commit()
|
| 3180 |
+
log_print(f"[8/8] 데이터베이스 커밋 완료: {original_filename}")
|
| 3181 |
+
log_print(f"[8/8] 연결된 모델: {model_name}")
|
| 3182 |
+
log_print(f"{'='*60}")
|
| 3183 |
+
log_print(f"=== 파일 업로드 완료 (처리 대기 중) ===")
|
| 3184 |
+
log_print(f"{'='*60}\n")
|
| 3185 |
+
|
| 3186 |
+
log_print(f"[8/8] 업로드 완료 - 파일: {original_filename}, 모델: {model_name}, 크기: {saved_file_size} bytes")
|
| 3187 |
+
log_print(f"[8/8] 다음 단계: Parent Chunk 생성, Chunk 생성, 회차 분석, Graph Extraction을 별도로 진행합니다.")
|
| 3188 |
+
|
| 3189 |
+
# 회차 수 계산 (섹션 분할 결과 기반) - 파일 읽기 필요
|
| 3190 |
+
episode_count = 0
|
| 3191 |
if original_filename.lower().endswith(('.txt', '.md')):
|
| 3192 |
try:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3193 |
encoding = 'utf-8'
|
| 3194 |
try:
|
| 3195 |
with open(file_path, 'r', encoding=encoding) as f:
|
| 3196 |
content = f.read()
|
|
|
|
| 3197 |
except UnicodeDecodeError:
|
|
|
|
| 3198 |
with open(file_path, 'r', encoding='cp949') as f:
|
| 3199 |
content = f.read()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3200 |
|
| 3201 |
+
sections = split_content_by_episodes(content)
|
| 3202 |
+
# '#작품설명'을 제외한 회차 수
|
| 3203 |
+
episode_sections = [s for s in sections if s[0] != '작품설명']
|
| 3204 |
+
episode_count = len(episode_sections)
|
| 3205 |
+
log_print(f"[8/8] 회차 수 계산: {episode_count}개 회차")
|
| 3206 |
except Exception as e:
|
| 3207 |
+
log_print(f"[8/8] 회차 수 계산 오류: {str(e)}")
|
| 3208 |
+
episode_count = 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 3209 |
|
| 3210 |
return jsonify({
|
| 3211 |
'message': f'파일이 성공적으로 업로드되었습니다. (모델: {model_name})',
|
| 3212 |
'file': uploaded_file.to_dict(),
|
| 3213 |
'model_name': model_name,
|
| 3214 |
+
'file_id': uploaded_file.id,
|
| 3215 |
+
'episode_count': episode_count, # 회차 수 추가
|
| 3216 |
+
'needs_processing': original_filename.lower().endswith(('.txt', '.md')) # 처리 필요 여부
|
| 3217 |
}), 200
|
| 3218 |
|
| 3219 |
except Exception as e:
|
|
|
|
| 3608 |
except Exception as e:
|
| 3609 |
return jsonify({'error': f'Parent Chunk 생성 중 오류가 발생했습니다: {str(e)}'}), 500
|
| 3610 |
|
| 3611 |
+
@main_bp.route('/api/files/<int:file_id>/process/parent-chunk', methods=['POST'])
|
| 3612 |
+
@login_required
|
| 3613 |
+
def process_parent_chunk(file_id):
|
| 3614 |
+
"""단계 1: Parent Chunk 생성"""
|
| 3615 |
+
return create_file_parent_chunk(file_id)
|
| 3616 |
+
|
| 3617 |
+
@main_bp.route('/api/files/<int:file_id>/process/chunks', methods=['POST'])
|
| 3618 |
+
@login_required
|
| 3619 |
+
def process_chunks(file_id):
|
| 3620 |
+
"""단계 2: Chunk 생성 (회차 분석, Graph Extraction 제외)"""
|
| 3621 |
+
try:
|
| 3622 |
+
file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
|
| 3623 |
+
if not file:
|
| 3624 |
+
return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
|
| 3625 |
+
|
| 3626 |
+
if not file.original_filename.lower().endswith(('.txt', '.md')):
|
| 3627 |
+
return jsonify({'error': 'Chunk는 텍스트 파일(.txt, .md)에만 생성할 수 있습니다.'}), 400
|
| 3628 |
+
|
| 3629 |
+
# 파일 내용 읽기
|
| 3630 |
+
try:
|
| 3631 |
+
encoding = 'utf-8'
|
| 3632 |
+
try:
|
| 3633 |
+
with open(file.file_path, 'r', encoding=encoding) as f:
|
| 3634 |
+
content = f.read()
|
| 3635 |
+
except UnicodeDecodeError:
|
| 3636 |
+
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 3637 |
+
content = f.read()
|
| 3638 |
+
except Exception as e:
|
| 3639 |
+
return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
|
| 3640 |
+
|
| 3641 |
+
print(f"[단계 2: Chunk 생성] 파일 ID {file_id}에 대한 Chunk 생성 시작")
|
| 3642 |
+
chunk_count = create_chunks_for_file(file_id, content, skip_episode_analysis=True, skip_graph_extraction=True)
|
| 3643 |
+
|
| 3644 |
+
return jsonify({
|
| 3645 |
+
'file_id': file_id,
|
| 3646 |
+
'filename': file.original_filename,
|
| 3647 |
+
'chunk_count': chunk_count,
|
| 3648 |
+
'message': f'Chunk {chunk_count}개가 성공적으로 생성되었습니다.',
|
| 3649 |
+
'step': 'chunks',
|
| 3650 |
+
'completed': True
|
| 3651 |
+
}), 200
|
| 3652 |
+
|
| 3653 |
+
except Exception as e:
|
| 3654 |
+
return jsonify({'error': f'Chunk 생성 중 오류가 발생했습니다: {str(e)}', 'step': 'chunks'}), 500
|
| 3655 |
+
|
| 3656 |
+
@main_bp.route('/api/files/<int:file_id>/process/episode-analysis', methods=['POST'])
|
| 3657 |
+
@login_required
|
| 3658 |
+
def process_episode_analysis(file_id):
|
| 3659 |
+
"""단계 3: 회차 분석"""
|
| 3660 |
+
try:
|
| 3661 |
+
file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
|
| 3662 |
+
if not file:
|
| 3663 |
+
return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
|
| 3664 |
+
|
| 3665 |
+
if not file.model_name:
|
| 3666 |
+
return jsonify({'error': '파일에 연결된 AI 모델이 없습니다.'}), 400
|
| 3667 |
+
|
| 3668 |
+
if not file.original_filename.lower().endswith(('.txt', '.md')):
|
| 3669 |
+
return jsonify({'error': '회차 분석은 텍스트 파일(.txt, .md)에만 가능합니다.'}), 400
|
| 3670 |
+
|
| 3671 |
+
# 파일 내용 읽기
|
| 3672 |
+
try:
|
| 3673 |
+
encoding = 'utf-8'
|
| 3674 |
+
try:
|
| 3675 |
+
with open(file.file_path, 'r', encoding=encoding) as f:
|
| 3676 |
+
content = f.read()
|
| 3677 |
+
except UnicodeDecodeError:
|
| 3678 |
+
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 3679 |
+
content = f.read()
|
| 3680 |
+
except Exception as e:
|
| 3681 |
+
return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
|
| 3682 |
+
|
| 3683 |
+
# 섹션 분할
|
| 3684 |
+
sections = split_content_by_episodes(content)
|
| 3685 |
+
episode_sections = [s for s in sections if s[0] != '작품설명']
|
| 3686 |
+
|
| 3687 |
+
if not episode_sections:
|
| 3688 |
+
return jsonify({'error': '분석할 회차가 없습니다.'}), 400
|
| 3689 |
+
|
| 3690 |
+
# Parent Chunk 가져오기
|
| 3691 |
+
parent_chunk = None
|
| 3692 |
+
try:
|
| 3693 |
+
parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
|
| 3694 |
+
except:
|
| 3695 |
+
pass
|
| 3696 |
+
|
| 3697 |
+
# 기존 회차 분석 삭제
|
| 3698 |
+
existing_analyses = EpisodeAnalysis.query.filter_by(file_id=file_id).all()
|
| 3699 |
+
if existing_analyses:
|
| 3700 |
+
for analysis in existing_analyses:
|
| 3701 |
+
db.session.delete(analysis)
|
| 3702 |
+
db.session.commit()
|
| 3703 |
+
|
| 3704 |
+
print(f"[단계 3: 회차 분석] 파일 ID {file_id}에 대한 회차 분석 시작 ({len(episode_sections)}개 회차)")
|
| 3705 |
+
|
| 3706 |
+
# 각 회차 분석
|
| 3707 |
+
all_analyses = []
|
| 3708 |
+
for section_type, section_title, section_content, section_metadata in episode_sections:
|
| 3709 |
+
try:
|
| 3710 |
+
print(f"[단계 3: 회차 분석] '{section_title}' 분석 중...")
|
| 3711 |
+
analysis_result = analyze_episode(
|
| 3712 |
+
episode_content=section_content,
|
| 3713 |
+
episode_title=section_title,
|
| 3714 |
+
full_content=content,
|
| 3715 |
+
parent_chunk=parent_chunk,
|
| 3716 |
+
model_name=file.model_name
|
| 3717 |
+
)
|
| 3718 |
+
|
| 3719 |
+
if analysis_result:
|
| 3720 |
+
all_analyses.append(f"\n\n{analysis_result}")
|
| 3721 |
+
print(f"[단계 3: 회차 분석] '{section_title}' 분석 완료")
|
| 3722 |
+
except Exception as e:
|
| 3723 |
+
print(f"[단계 3: 회차 분석] '{section_title}' 분석 중 오류: {str(e)}")
|
| 3724 |
+
continue
|
| 3725 |
+
|
| 3726 |
+
# 모든 회차 분석 결과를 하나의 텍스트로 저장
|
| 3727 |
+
if all_analyses:
|
| 3728 |
+
combined_analysis = "\n".join(all_analyses).strip()
|
| 3729 |
+
episode_analysis = EpisodeAnalysis(
|
| 3730 |
+
file_id=file_id,
|
| 3731 |
+
episode_title="전체 회차 통합 분석",
|
| 3732 |
+
analysis_content=combined_analysis
|
| 3733 |
+
)
|
| 3734 |
+
db.session.add(episode_analysis)
|
| 3735 |
+
db.session.commit()
|
| 3736 |
+
|
| 3737 |
+
return jsonify({
|
| 3738 |
+
'file_id': file_id,
|
| 3739 |
+
'filename': file.original_filename,
|
| 3740 |
+
'episode_count': len(episode_sections),
|
| 3741 |
+
'message': f'{len(episode_sections)}개 회차 분석이 완료되었습니다.',
|
| 3742 |
+
'step': 'episode-analysis',
|
| 3743 |
+
'completed': True
|
| 3744 |
+
}), 200
|
| 3745 |
+
else:
|
| 3746 |
+
return jsonify({
|
| 3747 |
+
'error': '회차 분석 결과가 없습니다.',
|
| 3748 |
+
'step': 'episode-analysis',
|
| 3749 |
+
'completed': False
|
| 3750 |
+
}), 500
|
| 3751 |
+
|
| 3752 |
+
except Exception as e:
|
| 3753 |
+
db.session.rollback()
|
| 3754 |
+
return jsonify({'error': f'회차 분석 ��� 오류가 발생했습니다: {str(e)}', 'step': 'episode-analysis'}), 500
|
| 3755 |
+
|
| 3756 |
+
@main_bp.route('/api/files/<int:file_id>/process/graph', methods=['POST'])
|
| 3757 |
+
@login_required
|
| 3758 |
+
def process_graph(file_id):
|
| 3759 |
+
"""단계 4: Graph Extraction"""
|
| 3760 |
+
try:
|
| 3761 |
+
file = UploadedFile.query.filter_by(id=file_id, uploaded_by=current_user.id).first()
|
| 3762 |
+
if not file:
|
| 3763 |
+
return jsonify({'error': '파일을 찾을 수 없습니다.'}), 404
|
| 3764 |
+
|
| 3765 |
+
if not file.model_name:
|
| 3766 |
+
return jsonify({'error': '파일에 연결된 AI 모델이 없습니다.'}), 400
|
| 3767 |
+
|
| 3768 |
+
if not file.original_filename.lower().endswith(('.txt', '.md')):
|
| 3769 |
+
return jsonify({'error': 'Graph Extraction은 텍스트 파일(.txt, .md)에만 가능합니다.'}), 400
|
| 3770 |
+
|
| 3771 |
+
# 파일 내용 읽기
|
| 3772 |
+
try:
|
| 3773 |
+
encoding = 'utf-8'
|
| 3774 |
+
try:
|
| 3775 |
+
with open(file.file_path, 'r', encoding=encoding) as f:
|
| 3776 |
+
content = f.read()
|
| 3777 |
+
except UnicodeDecodeError:
|
| 3778 |
+
with open(file.file_path, 'r', encoding='cp949') as f:
|
| 3779 |
+
content = f.read()
|
| 3780 |
+
except Exception as e:
|
| 3781 |
+
return jsonify({'error': f'파일을 읽을 수 없습니다: {str(e)}'}), 500
|
| 3782 |
+
|
| 3783 |
+
# 섹션 분할
|
| 3784 |
+
sections = split_content_by_episodes(content)
|
| 3785 |
+
episode_sections = [s for s in sections if s[0] != '작품설명']
|
| 3786 |
+
|
| 3787 |
+
if not episode_sections:
|
| 3788 |
+
return jsonify({'error': 'Graph Extraction할 회차가 없습니다.'}), 400
|
| 3789 |
+
|
| 3790 |
+
# Parent Chunk 가져오기
|
| 3791 |
+
parent_chunk = None
|
| 3792 |
+
try:
|
| 3793 |
+
parent_chunk = ParentChunk.query.filter_by(file_id=file_id).first()
|
| 3794 |
+
except:
|
| 3795 |
+
pass
|
| 3796 |
+
|
| 3797 |
+
print(f"[단계 4: Graph Extraction] 파일 ID {file_id}에 대한 Graph Extraction 시작 ({len(episode_sections)}개 회차)")
|
| 3798 |
+
|
| 3799 |
+
# 각 회차 Graph Extraction
|
| 3800 |
+
success_count = 0
|
| 3801 |
+
for section_type, section_title, section_content, section_metadata in episode_sections:
|
| 3802 |
+
try:
|
| 3803 |
+
print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 중...")
|
| 3804 |
+
success = extract_graph_from_episode(
|
| 3805 |
+
episode_content=section_content,
|
| 3806 |
+
episode_title=section_title,
|
| 3807 |
+
file_id=file_id,
|
| 3808 |
+
full_content=content,
|
| 3809 |
+
parent_chunk=parent_chunk,
|
| 3810 |
+
model_name=file.model_name
|
| 3811 |
+
)
|
| 3812 |
+
if success:
|
| 3813 |
+
success_count += 1
|
| 3814 |
+
print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 완료")
|
| 3815 |
+
except Exception as e:
|
| 3816 |
+
print(f"[단계 4: Graph Extraction] '{section_title}' Graph Extraction 중 오류: {str(e)}")
|
| 3817 |
+
continue
|
| 3818 |
+
|
| 3819 |
+
return jsonify({
|
| 3820 |
+
'file_id': file_id,
|
| 3821 |
+
'filename': file.original_filename,
|
| 3822 |
+
'episode_count': len(episode_sections),
|
| 3823 |
+
'success_count': success_count,
|
| 3824 |
+
'message': f'{success_count}/{len(episode_sections)}개 회차 Graph Extraction이 완료되었습니다.',
|
| 3825 |
+
'step': 'graph',
|
| 3826 |
+
'completed': True
|
| 3827 |
+
}), 200
|
| 3828 |
+
|
| 3829 |
+
except Exception as e:
|
| 3830 |
+
return jsonify({'error': f'Graph Extraction 중 오류가 발생했습니다: {str(e)}', 'step': 'graph'}), 500
|
| 3831 |
+
|
| 3832 |
@main_bp.route('/api/files/<int:file_id>/metadata', methods=['POST'])
|
| 3833 |
@login_required
|
| 3834 |
def create_file_metadata(file_id):
|
|
|
|
| 4100 |
try:
|
| 4101 |
data = request.json
|
| 4102 |
title = data.get('title', '새 대화')
|
| 4103 |
+
model_name = data.get('model_name', None) # 하위 호환성
|
| 4104 |
+
analysis_model = data.get('analysis_model', None)
|
| 4105 |
+
answer_model = data.get('answer_model', None)
|
| 4106 |
|
| 4107 |
session = ChatSession(
|
| 4108 |
user_id=current_user.id,
|
| 4109 |
title=title,
|
| 4110 |
+
model_name=model_name, # 하위 호환성
|
| 4111 |
+
analysis_model=analysis_model,
|
| 4112 |
+
answer_model=answer_model
|
| 4113 |
)
|
| 4114 |
db.session.add(session)
|
| 4115 |
db.session.commit()
|
download_exaone_model.py
CHANGED
|
@@ -77,4 +77,16 @@ if __name__ == "__main__":
|
|
| 77 |
|
| 78 |
|
| 79 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 80 |
|
|
|
|
| 77 |
|
| 78 |
|
| 79 |
|
| 80 |
+
|
| 81 |
+
|
| 82 |
+
|
| 83 |
+
|
| 84 |
+
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
|
| 92 |
|
install_exaone_direct.py
CHANGED
|
@@ -82,4 +82,16 @@ if __name__ == "__main__":
|
|
| 82 |
|
| 83 |
|
| 84 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 85 |
|
|
|
|
| 82 |
|
| 83 |
|
| 84 |
|
| 85 |
+
|
| 86 |
+
|
| 87 |
+
|
| 88 |
+
|
| 89 |
+
|
| 90 |
+
|
| 91 |
+
|
| 92 |
+
|
| 93 |
+
|
| 94 |
+
|
| 95 |
+
|
| 96 |
+
|
| 97 |
|
install_exaone_simple.py
CHANGED
|
@@ -59,4 +59,16 @@ if __name__ == "__main__":
|
|
| 59 |
|
| 60 |
|
| 61 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
|
|
|
|
| 59 |
|
| 60 |
|
| 61 |
|
| 62 |
+
|
| 63 |
+
|
| 64 |
+
|
| 65 |
+
|
| 66 |
+
|
| 67 |
+
|
| 68 |
+
|
| 69 |
+
|
| 70 |
+
|
| 71 |
+
|
| 72 |
+
|
| 73 |
+
|
| 74 |
|
templates/admin.html
CHANGED
|
@@ -449,6 +449,7 @@
|
|
| 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>
|
| 453 |
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
|
| 454 |
</div>
|
|
@@ -462,27 +463,6 @@
|
|
| 462 |
|
| 463 |
<div id="alertContainer"></div>
|
| 464 |
|
| 465 |
-
<!-- Gemini API 키 설정 섹션 -->
|
| 466 |
-
<div class="card">
|
| 467 |
-
<div class="card-header">
|
| 468 |
-
<div class="card-title">Gemini API 키 설정</div>
|
| 469 |
-
</div>
|
| 470 |
-
<div style="padding: 16px 0;">
|
| 471 |
-
<div class="form-group">
|
| 472 |
-
<label for="geminiApiKey">Gemini API 키</label>
|
| 473 |
-
<div style="display: flex; gap: 8px;">
|
| 474 |
-
<input type="password" id="geminiApiKey" placeholder="Gemini API 키를 입력하세요" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
|
| 475 |
-
<button class="btn btn-primary" onclick="saveGeminiApiKey()">저장</button>
|
| 476 |
-
<button class="btn btn-secondary" onclick="loadGeminiApiKey()">현재 상태 확인</button>
|
| 477 |
-
</div>
|
| 478 |
-
<small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
|
| 479 |
-
Google AI Studio(<a href="https://aistudio.google.com/app/apikey" target="_blank">https://aistudio.google.com/app/apikey</a>)에서 API 키를 발급받을 수 있습니다.
|
| 480 |
-
</small>
|
| 481 |
-
<div id="geminiApiKeyStatus" style="margin-top: 8px; font-size: 13px;"></div>
|
| 482 |
-
</div>
|
| 483 |
-
</div>
|
| 484 |
-
</div>
|
| 485 |
-
|
| 486 |
<div class="card">
|
| 487 |
<div class="card-header">
|
| 488 |
<div class="card-title">사용자 목록</div>
|
|
@@ -691,87 +671,6 @@
|
|
| 691 |
}
|
| 692 |
});
|
| 693 |
|
| 694 |
-
// Gemini API 키 관련 함수
|
| 695 |
-
async function loadGeminiApiKey() {
|
| 696 |
-
try {
|
| 697 |
-
const response = await fetch('/api/admin/gemini-api-key');
|
| 698 |
-
|
| 699 |
-
// 응답이 JSON인지 확인
|
| 700 |
-
const contentType = response.headers.get('content-type');
|
| 701 |
-
if (!contentType || !contentType.includes('application/json')) {
|
| 702 |
-
const text = await response.text();
|
| 703 |
-
console.error('Non-JSON response:', text.substring(0, 200));
|
| 704 |
-
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 705 |
-
statusDiv.innerHTML = `<span style="color: #ea4335;">서버 오류: 응답 형식이 올바르지 않습니다.</span>`;
|
| 706 |
-
return;
|
| 707 |
-
}
|
| 708 |
-
|
| 709 |
-
const data = await response.json();
|
| 710 |
-
|
| 711 |
-
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 712 |
-
if (!response.ok) {
|
| 713 |
-
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${data.error || '알 수 없는 오류'}</span>`;
|
| 714 |
-
return;
|
| 715 |
-
}
|
| 716 |
-
|
| 717 |
-
if (data.has_api_key) {
|
| 718 |
-
statusDiv.innerHTML = `<span style="color: #137333;">✓ API 키가 설정되어 있습니다 (${data.masked_key})</span>`;
|
| 719 |
-
} else {
|
| 720 |
-
statusDiv.innerHTML = `<span style="color: #ea4335;">⚠ API 키가 설정되지 않았습니다</span>`;
|
| 721 |
-
}
|
| 722 |
-
} catch (error) {
|
| 723 |
-
console.error('Gemini API 키 로드 오류:', error);
|
| 724 |
-
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 725 |
-
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
|
| 726 |
-
}
|
| 727 |
-
}
|
| 728 |
-
|
| 729 |
-
async function saveGeminiApiKey() {
|
| 730 |
-
const apiKeyInput = document.getElementById('geminiApiKey');
|
| 731 |
-
const apiKey = apiKeyInput.value.trim();
|
| 732 |
-
|
| 733 |
-
if (!apiKey) {
|
| 734 |
-
showAlert('API 키를 입력해주세요.', 'error');
|
| 735 |
-
return;
|
| 736 |
-
}
|
| 737 |
-
|
| 738 |
-
try {
|
| 739 |
-
const response = await fetch('/api/admin/gemini-api-key', {
|
| 740 |
-
method: 'POST',
|
| 741 |
-
headers: {
|
| 742 |
-
'Content-Type': 'application/json',
|
| 743 |
-
},
|
| 744 |
-
body: JSON.stringify({ api_key: apiKey })
|
| 745 |
-
});
|
| 746 |
-
|
| 747 |
-
// 응답이 JSON인지 확인
|
| 748 |
-
const contentType = response.headers.get('content-type');
|
| 749 |
-
if (!contentType || !contentType.includes('application/json')) {
|
| 750 |
-
const text = await response.text();
|
| 751 |
-
console.error('Non-JSON response:', text.substring(0, 200));
|
| 752 |
-
showAlert('서버 오류: 응답 형식이 올바르지 않습니다.', 'error');
|
| 753 |
-
return;
|
| 754 |
-
}
|
| 755 |
-
|
| 756 |
-
const data = await response.json();
|
| 757 |
-
|
| 758 |
-
if (response.ok) {
|
| 759 |
-
showAlert(data.message, 'success');
|
| 760 |
-
apiKeyInput.value = ''; // 보안을 위해 입력 필드 초기화
|
| 761 |
-
loadGeminiApiKey(); // 상태 업데이트
|
| 762 |
-
} else {
|
| 763 |
-
showAlert(data.error || 'API 키 저장 중 오류가 발생했습니다.', 'error');
|
| 764 |
-
}
|
| 765 |
-
} catch (error) {
|
| 766 |
-
console.error('Gemini API 키 저장 오류:', error);
|
| 767 |
-
showAlert(`오류: ${error.message}`, 'error');
|
| 768 |
-
}
|
| 769 |
-
}
|
| 770 |
-
|
| 771 |
-
// 페이지 로드 시 API 키 상태 확인
|
| 772 |
-
window.addEventListener('load', () => {
|
| 773 |
-
loadGeminiApiKey();
|
| 774 |
-
});
|
| 775 |
|
| 776 |
</script>
|
| 777 |
</body>
|
|
|
|
| 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.admin_settings') }}" class="btn btn-secondary">AI 설정</a>
|
| 453 |
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
|
| 454 |
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
|
| 455 |
</div>
|
|
|
|
| 463 |
|
| 464 |
<div id="alertContainer"></div>
|
| 465 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 466 |
<div class="card">
|
| 467 |
<div class="card-header">
|
| 468 |
<div class="card-title">사용자 목록</div>
|
|
|
|
| 671 |
}
|
| 672 |
});
|
| 673 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 674 |
|
| 675 |
</script>
|
| 676 |
</body>
|
templates/admin_messages.html
CHANGED
|
@@ -392,7 +392,8 @@
|
|
| 392 |
<th>ID</th>
|
| 393 |
<th>사용자</th>
|
| 394 |
<th>제목</th>
|
| 395 |
-
<th
|
|
|
|
| 396 |
<th>메시지 수</th>
|
| 397 |
<th>생성일</th>
|
| 398 |
<th>수정일</th>
|
|
@@ -521,11 +522,15 @@
|
|
| 521 |
return;
|
| 522 |
}
|
| 523 |
|
|
|
|
|
|
|
|
|
|
| 524 |
row.innerHTML = `
|
| 525 |
<td>${session.id}</td>
|
| 526 |
<td>${session.nickname || session.username || 'Unknown'}</td>
|
| 527 |
<td>${session.title || '-'}</td>
|
| 528 |
-
<td>${
|
|
|
|
| 529 |
<td>${session.message_count || 0}</td>
|
| 530 |
<td>${createdDate}</td>
|
| 531 |
<td>${updatedDate}</td>
|
|
@@ -536,7 +541,7 @@
|
|
| 536 |
tbody.appendChild(row);
|
| 537 |
});
|
| 538 |
} else {
|
| 539 |
-
tbody.innerHTML = '<tr><td colspan="
|
| 540 |
}
|
| 541 |
|
| 542 |
// 페이지네이션
|
|
@@ -551,6 +556,9 @@
|
|
| 551 |
|
| 552 |
// 메시지 목록 로드
|
| 553 |
async function loadMessages(page = 1) {
|
|
|
|
|
|
|
|
|
|
| 554 |
try {
|
| 555 |
const sessionId = document.getElementById('sessionIdFilter').value;
|
| 556 |
|
|
@@ -561,17 +569,25 @@
|
|
| 561 |
url += `&session_id=${selectedSessionId}`;
|
| 562 |
}
|
| 563 |
|
|
|
|
| 564 |
const response = await fetch(url);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 565 |
const data = await response.json();
|
|
|
|
| 566 |
|
| 567 |
-
const tbody = document.getElementById('messagesTableBody');
|
| 568 |
tbody.innerHTML = '';
|
| 569 |
|
| 570 |
if (data.messages && data.messages.length > 0) {
|
|
|
|
| 571 |
data.messages.forEach(msg => {
|
| 572 |
const row = document.createElement('tr');
|
| 573 |
const date = new Date(msg.created_at).toLocaleString('ko-KR');
|
| 574 |
-
const contentPreview = msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : msg.content;
|
| 575 |
|
| 576 |
row.innerHTML = `
|
| 577 |
<td>${msg.id}</td>
|
|
@@ -580,22 +596,33 @@
|
|
| 580 |
<td class="message-content">${escapeHtml(contentPreview)}</td>
|
| 581 |
<td>${date}</td>
|
| 582 |
<td>
|
| 583 |
-
<button class="btn btn-secondary" onclick="
|
| 584 |
</td>
|
| 585 |
`;
|
|
|
|
|
|
|
|
|
|
|
|
|
| 586 |
tbody.appendChild(row);
|
| 587 |
});
|
| 588 |
} else {
|
|
|
|
| 589 |
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
|
| 590 |
}
|
| 591 |
|
| 592 |
// 페이지네이션
|
| 593 |
-
|
| 594 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 595 |
|
| 596 |
} catch (error) {
|
|
|
|
| 597 |
showAlert(`메시지 조회 오류: ${error.message}`, 'error');
|
| 598 |
-
|
| 599 |
}
|
| 600 |
}
|
| 601 |
|
|
@@ -606,6 +633,40 @@
|
|
| 606 |
loadMessages(1);
|
| 607 |
}
|
| 608 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 609 |
// 메시지 상세 보기
|
| 610 |
function viewMessage(messageId, role, content) {
|
| 611 |
const modal = document.getElementById('messageModal');
|
|
@@ -618,7 +679,7 @@
|
|
| 618 |
<span class="message-item-role role-badge role-${role}">${role === 'user' ? '사용자' : 'AI'}</span>
|
| 619 |
<span class="message-item-time">메시지 ID: ${messageId}</span>
|
| 620 |
</div>
|
| 621 |
-
<div class="message-item-content">${
|
| 622 |
</div>
|
| 623 |
</div>
|
| 624 |
`;
|
|
|
|
| 392 |
<th>ID</th>
|
| 393 |
<th>사용자</th>
|
| 394 |
<th>제목</th>
|
| 395 |
+
<th>학습 모델</th>
|
| 396 |
+
<th>답변 모델</th>
|
| 397 |
<th>메시지 수</th>
|
| 398 |
<th>생성일</th>
|
| 399 |
<th>수정일</th>
|
|
|
|
| 522 |
return;
|
| 523 |
}
|
| 524 |
|
| 525 |
+
const analysisModel = session.analysis_model || session.model_name || '-';
|
| 526 |
+
const answerModel = session.answer_model || session.model_name || '-';
|
| 527 |
+
|
| 528 |
row.innerHTML = `
|
| 529 |
<td>${session.id}</td>
|
| 530 |
<td>${session.nickname || session.username || 'Unknown'}</td>
|
| 531 |
<td>${session.title || '-'}</td>
|
| 532 |
+
<td>${analysisModel}</td>
|
| 533 |
+
<td>${answerModel}</td>
|
| 534 |
<td>${session.message_count || 0}</td>
|
| 535 |
<td>${createdDate}</td>
|
| 536 |
<td>${updatedDate}</td>
|
|
|
|
| 541 |
tbody.appendChild(row);
|
| 542 |
});
|
| 543 |
} else {
|
| 544 |
+
tbody.innerHTML = '<tr><td colspan="9" style="text-align: center; padding: 20px; color: #5f6368;">대화 세션이 없습니다.</td></tr>';
|
| 545 |
}
|
| 546 |
|
| 547 |
// 페이지네이션
|
|
|
|
| 556 |
|
| 557 |
// 메시지 목록 로드
|
| 558 |
async function loadMessages(page = 1) {
|
| 559 |
+
const tbody = document.getElementById('messagesTableBody');
|
| 560 |
+
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</td></tr>';
|
| 561 |
+
|
| 562 |
try {
|
| 563 |
const sessionId = document.getElementById('sessionIdFilter').value;
|
| 564 |
|
|
|
|
| 569 |
url += `&session_id=${selectedSessionId}`;
|
| 570 |
}
|
| 571 |
|
| 572 |
+
console.log('[메시지 목록] 요청 URL:', url);
|
| 573 |
const response = await fetch(url);
|
| 574 |
+
|
| 575 |
+
if (!response.ok) {
|
| 576 |
+
const errorData = await response.json().catch(() => ({ error: `HTTP ${response.status}` }));
|
| 577 |
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
| 578 |
+
}
|
| 579 |
+
|
| 580 |
const data = await response.json();
|
| 581 |
+
console.log('[메시지 목록] 응답 데이터:', data);
|
| 582 |
|
|
|
|
| 583 |
tbody.innerHTML = '';
|
| 584 |
|
| 585 |
if (data.messages && data.messages.length > 0) {
|
| 586 |
+
console.log('[메시지 목록] 메시지 개수:', data.messages.length);
|
| 587 |
data.messages.forEach(msg => {
|
| 588 |
const row = document.createElement('tr');
|
| 589 |
const date = new Date(msg.created_at).toLocaleString('ko-KR');
|
| 590 |
+
const contentPreview = msg.content && msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : (msg.content || '');
|
| 591 |
|
| 592 |
row.innerHTML = `
|
| 593 |
<td>${msg.id}</td>
|
|
|
|
| 596 |
<td class="message-content">${escapeHtml(contentPreview)}</td>
|
| 597 |
<td>${date}</td>
|
| 598 |
<td>
|
| 599 |
+
<button class="btn btn-secondary" onclick="viewMessageById(${msg.id})" style="padding: 4px 8px; font-size: 12px;">상세 보기</button>
|
| 600 |
</td>
|
| 601 |
`;
|
| 602 |
+
// 메시지 데이터를 data 속성에 저장
|
| 603 |
+
row.setAttribute('data-message-id', msg.id);
|
| 604 |
+
row.setAttribute('data-message-role', msg.role);
|
| 605 |
+
row.setAttribute('data-message-content', escapeHtml(msg.content || ''));
|
| 606 |
tbody.appendChild(row);
|
| 607 |
});
|
| 608 |
} else {
|
| 609 |
+
console.log('[메시지 목록] 메시지가 없습니다');
|
| 610 |
tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">메시지가 없습니다.</td></tr>';
|
| 611 |
}
|
| 612 |
|
| 613 |
// 페이지네이션
|
| 614 |
+
if (data.current_page !== undefined && data.pages !== undefined) {
|
| 615 |
+
updateMessagesPagination(data.current_page, data.pages);
|
| 616 |
+
currentMessagesPage = data.current_page;
|
| 617 |
+
} else {
|
| 618 |
+
// 페이지네이션 정보가 없으면 숨김
|
| 619 |
+
document.getElementById('messagesPagination').innerHTML = '';
|
| 620 |
+
}
|
| 621 |
|
| 622 |
} catch (error) {
|
| 623 |
+
console.error('[메시지 목록] 조회 오류:', error);
|
| 624 |
showAlert(`메시지 조회 오류: ${error.message}`, 'error');
|
| 625 |
+
tbody.innerHTML = `<tr><td colspan="6" style="text-align: center; padding: 20px; color: #ea4335;">메시지 조회 중 오류가 발생했습니다.<br><small>${error.message || '알 수 없는 오류'}</small></td></tr>`;
|
| 626 |
}
|
| 627 |
}
|
| 628 |
|
|
|
|
| 633 |
loadMessages(1);
|
| 634 |
}
|
| 635 |
|
| 636 |
+
// 메시지 상세 보기 (ID로 조회)
|
| 637 |
+
async function viewMessageById(messageId) {
|
| 638 |
+
try {
|
| 639 |
+
const response = await fetch(`/api/admin/messages?page=1&per_page=1&message_id=${messageId}`);
|
| 640 |
+
const data = await response.json();
|
| 641 |
+
|
| 642 |
+
if (data.messages && data.messages.length > 0) {
|
| 643 |
+
const msg = data.messages[0];
|
| 644 |
+
viewMessage(msg.id, msg.role, msg.content);
|
| 645 |
+
} else {
|
| 646 |
+
// 데이터 속성에서 가져오기 (fallback)
|
| 647 |
+
const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
|
| 648 |
+
if (row) {
|
| 649 |
+
const role = row.getAttribute('data-message-role');
|
| 650 |
+
const content = row.getAttribute('data-message-content');
|
| 651 |
+
viewMessage(messageId, role, content);
|
| 652 |
+
} else {
|
| 653 |
+
showAlert('메시지를 찾을 수 없습니다.', 'error');
|
| 654 |
+
}
|
| 655 |
+
}
|
| 656 |
+
} catch (error) {
|
| 657 |
+
// 데이터 속성에서 가져오기 (fallback)
|
| 658 |
+
const row = document.querySelector(`tr[data-message-id="${messageId}"]`);
|
| 659 |
+
if (row) {
|
| 660 |
+
const role = row.getAttribute('data-message-role');
|
| 661 |
+
const content = row.getAttribute('data-message-content');
|
| 662 |
+
viewMessage(messageId, role, content);
|
| 663 |
+
} else {
|
| 664 |
+
showAlert(`메시지 조회 오류: ${error.message}`, 'error');
|
| 665 |
+
console.error('메시지 조회 오류:', error);
|
| 666 |
+
}
|
| 667 |
+
}
|
| 668 |
+
}
|
| 669 |
+
|
| 670 |
// 메시지 상세 보기
|
| 671 |
function viewMessage(messageId, role, content) {
|
| 672 |
const modal = document.getElementById('messageModal');
|
|
|
|
| 679 |
<span class="message-item-role role-badge role-${role}">${role === 'user' ? '사용자' : 'AI'}</span>
|
| 680 |
<span class="message-item-time">메시지 ID: ${messageId}</span>
|
| 681 |
</div>
|
| 682 |
+
<div class="message-item-content">${content}</div>
|
| 683 |
</div>
|
| 684 |
</div>
|
| 685 |
`;
|
templates/admin_settings.html
ADDED
|
@@ -0,0 +1,596 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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>AI 설정 관리 - 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 |
+
.container {
|
| 77 |
+
max-width: 1200px;
|
| 78 |
+
margin: 0 auto;
|
| 79 |
+
padding: 24px;
|
| 80 |
+
}
|
| 81 |
+
|
| 82 |
+
.page-header {
|
| 83 |
+
margin-bottom: 24px;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
+
.page-header h1 {
|
| 87 |
+
font-size: 28px;
|
| 88 |
+
font-weight: 600;
|
| 89 |
+
margin-bottom: 8px;
|
| 90 |
+
}
|
| 91 |
+
|
| 92 |
+
.page-header p {
|
| 93 |
+
color: #5f6368;
|
| 94 |
+
}
|
| 95 |
+
|
| 96 |
+
.card {
|
| 97 |
+
background: white;
|
| 98 |
+
border-radius: 8px;
|
| 99 |
+
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 100 |
+
padding: 24px;
|
| 101 |
+
margin-bottom: 24px;
|
| 102 |
+
}
|
| 103 |
+
|
| 104 |
+
.card-header {
|
| 105 |
+
display: flex;
|
| 106 |
+
justify-content: space-between;
|
| 107 |
+
align-items: center;
|
| 108 |
+
margin-bottom: 20px;
|
| 109 |
+
}
|
| 110 |
+
|
| 111 |
+
.card-title {
|
| 112 |
+
font-size: 18px;
|
| 113 |
+
font-weight: 500;
|
| 114 |
+
}
|
| 115 |
+
|
| 116 |
+
.form-group {
|
| 117 |
+
margin-bottom: 16px;
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
.form-group label {
|
| 121 |
+
display: block;
|
| 122 |
+
font-size: 14px;
|
| 123 |
+
font-weight: 500;
|
| 124 |
+
margin-bottom: 8px;
|
| 125 |
+
}
|
| 126 |
+
|
| 127 |
+
.form-group input {
|
| 128 |
+
width: 100%;
|
| 129 |
+
padding: 10px 12px;
|
| 130 |
+
border: 1px solid #dadce0;
|
| 131 |
+
border-radius: 6px;
|
| 132 |
+
font-size: 14px;
|
| 133 |
+
font-family: inherit;
|
| 134 |
+
}
|
| 135 |
+
|
| 136 |
+
.form-group input:focus {
|
| 137 |
+
outline: none;
|
| 138 |
+
border-color: #1a73e8;
|
| 139 |
+
box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
|
| 140 |
+
}
|
| 141 |
+
|
| 142 |
+
.alert {
|
| 143 |
+
padding: 12px 16px;
|
| 144 |
+
border-radius: 6px;
|
| 145 |
+
margin-bottom: 16px;
|
| 146 |
+
font-size: 14px;
|
| 147 |
+
}
|
| 148 |
+
|
| 149 |
+
.alert.error {
|
| 150 |
+
background: #fce8e6;
|
| 151 |
+
color: #c5221f;
|
| 152 |
+
}
|
| 153 |
+
|
| 154 |
+
.alert.success {
|
| 155 |
+
background: #e8f5e9;
|
| 156 |
+
color: #137333;
|
| 157 |
+
}
|
| 158 |
+
</style>
|
| 159 |
+
</head>
|
| 160 |
+
<body>
|
| 161 |
+
<div class="header">
|
| 162 |
+
<div class="header-title">
|
| 163 |
+
<span>⚙️</span>
|
| 164 |
+
<span>AI 설정 관리</span>
|
| 165 |
+
</div>
|
| 166 |
+
<div class="header-actions">
|
| 167 |
+
<span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
|
| 168 |
+
<a href="{{ url_for('main.admin') }}" class="btn btn-secondary">사용자 관리</a>
|
| 169 |
+
<a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">웹소설 관리</a>
|
| 170 |
+
<a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 목록</a>
|
| 171 |
+
<a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">메시지 확인</a>
|
| 172 |
+
<a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">프롬프트 관리</a>
|
| 173 |
+
<a href="{{ url_for('main.index') }}" class="btn btn-secondary">메인으로</a>
|
| 174 |
+
<a href="{{ url_for('main.logout') }}" class="btn btn-secondary">로그아웃</a>
|
| 175 |
+
</div>
|
| 176 |
+
</div>
|
| 177 |
+
|
| 178 |
+
<div class="container">
|
| 179 |
+
<div class="page-header">
|
| 180 |
+
<h1>AI 설정 관리</h1>
|
| 181 |
+
<p>Gemini API 키와 AI 모델별 토큰 수를 관리할 수 있습니다.</p>
|
| 182 |
+
</div>
|
| 183 |
+
|
| 184 |
+
<div id="alertContainer"></div>
|
| 185 |
+
|
| 186 |
+
<!-- Gemini API 키 설정 섹션 -->
|
| 187 |
+
<div class="card">
|
| 188 |
+
<div class="card-header">
|
| 189 |
+
<div class="card-title">Gemini API 키 설정</div>
|
| 190 |
+
</div>
|
| 191 |
+
<div style="padding: 16px 0;">
|
| 192 |
+
<div class="form-group">
|
| 193 |
+
<label for="geminiApiKey">Gemini API 키</label>
|
| 194 |
+
<div style="display: flex; gap: 8px;">
|
| 195 |
+
<input type="password" id="geminiApiKey" placeholder="Gemini API 키를 입력하세요" style="flex: 1; padding: 8px 12px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;">
|
| 196 |
+
<button class="btn btn-primary" onclick="saveGeminiApiKey()">저장</button>
|
| 197 |
+
<button class="btn btn-secondary" onclick="loadGeminiApiKey()">현재 상태 확인</button>
|
| 198 |
+
</div>
|
| 199 |
+
<small style="color: #5f6368; font-size: 12px; display: block; margin-top: 4px;">
|
| 200 |
+
Google AI Studio(<a href="https://aistudio.google.com/app/apikey" target="_blank">https://aistudio.google.com/app/apikey</a>)에서 API 키를 발급받을 수 있습니다.
|
| 201 |
+
</small>
|
| 202 |
+
<div id="geminiApiKeyStatus" style="margin-top: 8px; font-size: 13px;"></div>
|
| 203 |
+
</div>
|
| 204 |
+
</div>
|
| 205 |
+
</div>
|
| 206 |
+
|
| 207 |
+
<!-- AI 모델별 토큰 수 관리 섹션 -->
|
| 208 |
+
<div class="card">
|
| 209 |
+
<div class="card-header">
|
| 210 |
+
<div class="card-title">AI 모델별 토큰 수 관리</div>
|
| 211 |
+
<button class="btn btn-secondary" onclick="loadModelTokens()">새로고침</button>
|
| 212 |
+
</div>
|
| 213 |
+
<div style="padding: 16px 0;">
|
| 214 |
+
<div id="modelTokensStatus" style="margin-bottom: 16px; font-size: 13px;"></div>
|
| 215 |
+
<div id="modelTokensList" style="display: grid; gap: 12px;">
|
| 216 |
+
<div style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</div>
|
| 217 |
+
</div>
|
| 218 |
+
</div>
|
| 219 |
+
</div>
|
| 220 |
+
</div>
|
| 221 |
+
|
| 222 |
+
<script>
|
| 223 |
+
function showAlert(message, type = 'success') {
|
| 224 |
+
const container = document.getElementById('alertContainer');
|
| 225 |
+
container.innerHTML = `<div class="alert ${type}">${message}</div>`;
|
| 226 |
+
setTimeout(() => {
|
| 227 |
+
container.innerHTML = '';
|
| 228 |
+
}, 5000);
|
| 229 |
+
}
|
| 230 |
+
|
| 231 |
+
// Gemini API 키 관련 함수
|
| 232 |
+
async function loadGeminiApiKey() {
|
| 233 |
+
try {
|
| 234 |
+
const response = await fetch('/api/admin/gemini-api-key', {
|
| 235 |
+
credentials: 'include'
|
| 236 |
+
});
|
| 237 |
+
|
| 238 |
+
// 응답이 JSON인지 확인
|
| 239 |
+
const contentType = response.headers.get('content-type');
|
| 240 |
+
if (!contentType || !contentType.includes('application/json')) {
|
| 241 |
+
const text = await response.text();
|
| 242 |
+
console.error('Non-JSON response:', text.substring(0, 200));
|
| 243 |
+
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 244 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">서버 오류: 응답 형식이 올바르지 않습니다.</span>`;
|
| 245 |
+
return;
|
| 246 |
+
}
|
| 247 |
+
|
| 248 |
+
const data = await response.json();
|
| 249 |
+
|
| 250 |
+
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 251 |
+
if (!response.ok) {
|
| 252 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${data.error || '알 수 없는 오류'}</span>`;
|
| 253 |
+
return;
|
| 254 |
+
}
|
| 255 |
+
|
| 256 |
+
if (data.has_api_key) {
|
| 257 |
+
statusDiv.innerHTML = `<span style="color: #137333;">✓ API 키가 설정되어 있습니다 (${data.masked_key})</span>`;
|
| 258 |
+
} else {
|
| 259 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">⚠ API 키가 설정되지 않았습니다</span>`;
|
| 260 |
+
}
|
| 261 |
+
} catch (error) {
|
| 262 |
+
console.error('Gemini API 키 로드 오류:', error);
|
| 263 |
+
const statusDiv = document.getElementById('geminiApiKeyStatus');
|
| 264 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
|
| 265 |
+
}
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
async function saveGeminiApiKey() {
|
| 269 |
+
const apiKeyInput = document.getElementById('geminiApiKey');
|
| 270 |
+
const apiKey = apiKeyInput.value.trim();
|
| 271 |
+
|
| 272 |
+
if (!apiKey) {
|
| 273 |
+
showAlert('API 키를 입력해주세요.', 'error');
|
| 274 |
+
return;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
+
try {
|
| 278 |
+
const response = await fetch('/api/admin/gemini-api-key', {
|
| 279 |
+
method: 'POST',
|
| 280 |
+
headers: {
|
| 281 |
+
'Content-Type': 'application/json',
|
| 282 |
+
},
|
| 283 |
+
credentials: 'include',
|
| 284 |
+
body: JSON.stringify({ api_key: apiKey })
|
| 285 |
+
});
|
| 286 |
+
|
| 287 |
+
// 응답이 JSON인지 확인
|
| 288 |
+
const contentType = response.headers.get('content-type');
|
| 289 |
+
if (!contentType || !contentType.includes('application/json')) {
|
| 290 |
+
const text = await response.text();
|
| 291 |
+
console.error('Non-JSON response:', text.substring(0, 200));
|
| 292 |
+
showAlert('서버 오류: 응답 형식이 올바르지 않습니다.', 'error');
|
| 293 |
+
return;
|
| 294 |
+
}
|
| 295 |
+
|
| 296 |
+
const data = await response.json();
|
| 297 |
+
|
| 298 |
+
if (response.ok) {
|
| 299 |
+
showAlert(data.message, 'success');
|
| 300 |
+
apiKeyInput.value = ''; // 보안을 위해 입력 필드 초기화
|
| 301 |
+
loadGeminiApiKey(); // 상태 업데이트
|
| 302 |
+
} else {
|
| 303 |
+
showAlert(data.error || 'API 키 저장 중 오류가 발생했습니다.', 'error');
|
| 304 |
+
}
|
| 305 |
+
} catch (error) {
|
| 306 |
+
console.error('Gemini API 키 저장 오류:', error);
|
| 307 |
+
showAlert(`오류: ${error.message}`, 'error');
|
| 308 |
+
}
|
| 309 |
+
}
|
| 310 |
+
|
| 311 |
+
// AI 모델별 토큰 수 관리 (입력/출력 분리)
|
| 312 |
+
async function loadModelTokens() {
|
| 313 |
+
const statusDiv = document.getElementById('modelTokensStatus');
|
| 314 |
+
const listDiv = document.getElementById('modelTokensList');
|
| 315 |
+
|
| 316 |
+
try {
|
| 317 |
+
statusDiv.innerHTML = '<span style="color: #1a73e8;">토큰 수 설정을 불러오는 중...</span>';
|
| 318 |
+
listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #5f6368;">로딩 중...</div>';
|
| 319 |
+
|
| 320 |
+
const response = await fetch('/api/admin/model-tokens', {
|
| 321 |
+
credentials: 'include'
|
| 322 |
+
});
|
| 323 |
+
|
| 324 |
+
if (!response.ok) {
|
| 325 |
+
const error = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 326 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.error || '토큰 수 설정을 불러올 수 없습니다'}</span>`;
|
| 327 |
+
listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #ea4335;">토큰 수 설정을 불러올 수 없습니다.</div>';
|
| 328 |
+
return;
|
| 329 |
+
}
|
| 330 |
+
|
| 331 |
+
const data = await response.json();
|
| 332 |
+
|
| 333 |
+
if (!data.models || data.models.length === 0) {
|
| 334 |
+
statusDiv.innerHTML = '<span style="color: #5f6368;">사용 가능한 AI 모델이 없습니다.</span>';
|
| 335 |
+
listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #5f6368;">사용 가능한 AI 모델이 없습니다.</div>';
|
| 336 |
+
return;
|
| 337 |
+
}
|
| 338 |
+
|
| 339 |
+
statusDiv.innerHTML = `<span style="color: #137333;">✓ ${data.models.length}개 모델 발견</span>`;
|
| 340 |
+
|
| 341 |
+
let html = '';
|
| 342 |
+
data.models.forEach(modelName => {
|
| 343 |
+
// 안전하게 토큰 데이터 접근 (undefined 체크)
|
| 344 |
+
const inputTokens = data.input_tokens || {};
|
| 345 |
+
const outputTokens = data.output_tokens || {};
|
| 346 |
+
const parentChunkTokens = data.parent_chunk_tokens || {};
|
| 347 |
+
const defaultInputTokens = data.default_input_tokens || {};
|
| 348 |
+
const defaultOutputTokens = data.default_output_tokens || {};
|
| 349 |
+
const defaultParentChunkTokens = data.default_parent_chunk_tokens || {};
|
| 350 |
+
|
| 351 |
+
const currentInputTokens = inputTokens[modelName] || '';
|
| 352 |
+
const currentOutputTokens = outputTokens[modelName] || '';
|
| 353 |
+
const currentParentChunkTokens = parentChunkTokens[modelName] || '';
|
| 354 |
+
const defaultInputToken = defaultInputTokens[modelName] || 100000;
|
| 355 |
+
const defaultOutputToken = defaultOutputTokens[modelName] || 2000;
|
| 356 |
+
const defaultParentChunkToken = defaultParentChunkTokens[modelName] || 8192;
|
| 357 |
+
const modelType = modelName.startsWith('gemini:') ? 'Gemini' : 'Ollama';
|
| 358 |
+
const displayName = modelName.startsWith('gemini:') ? modelName.replace('gemini:', '') : modelName;
|
| 359 |
+
|
| 360 |
+
// 실제 사용 중인 값 계산
|
| 361 |
+
const actualInputToken = currentInputTokens ? parseInt(currentInputTokens) : defaultInputToken;
|
| 362 |
+
const actualOutputToken = currentOutputTokens ? parseInt(currentOutputTokens) : defaultOutputToken;
|
| 363 |
+
const actualParentChunkToken = currentParentChunkTokens ? parseInt(currentParentChunkTokens) : defaultParentChunkToken;
|
| 364 |
+
|
| 365 |
+
// 사용 중인 값 표시 텍스트 생성
|
| 366 |
+
const getTokenStatusText = (actual, defaultVal, isUsingDefault) => {
|
| 367 |
+
if (isUsingDefault) {
|
| 368 |
+
return `실제 사용: ${actual.toLocaleString()} (기본값)`;
|
| 369 |
+
} else {
|
| 370 |
+
return `실제 사용: ${actual.toLocaleString()} (설정값)`;
|
| 371 |
+
}
|
| 372 |
+
};
|
| 373 |
+
|
| 374 |
+
const inputStatusText = getTokenStatusText(actualInputToken, defaultInputToken, !currentInputTokens);
|
| 375 |
+
const outputStatusText = getTokenStatusText(actualOutputToken, defaultOutputToken, !currentOutputTokens);
|
| 376 |
+
const parentChunkStatusText = getTokenStatusText(actualParentChunkToken, defaultParentChunkToken, !currentParentChunkTokens);
|
| 377 |
+
|
| 378 |
+
// 안전한 ID 생성 (특수문자 처리)
|
| 379 |
+
const safeId = modelNameToId(modelName);
|
| 380 |
+
|
| 381 |
+
html += `
|
| 382 |
+
<div style="padding: 16px; background: #f8f9fa; border-radius: 6px; border: 1px solid #dadce0; margin-bottom: 12px;" data-model-name="${escapeHtml(modelName)}">
|
| 383 |
+
<div style="margin-bottom: 12px;">
|
| 384 |
+
<div style="font-weight: 500; margin-bottom: 4px; font-size: 15px;">${escapeHtml(displayName)}</div>
|
| 385 |
+
<div style="font-size: 12px; color: #5f6368; line-height: 1.6;">
|
| 386 |
+
<div><strong>타입:</strong> ${modelType}</div>
|
| 387 |
+
<div><strong>입력 토큰:</strong> ${inputStatusText}</div>
|
| 388 |
+
<div><strong>출력 토큰:</strong> ${outputStatusText}</div>
|
| 389 |
+
<div><strong>Parent Chunk 토큰:</strong> ${parentChunkStatusText}</div>
|
| 390 |
+
</div>
|
| 391 |
+
</div>
|
| 392 |
+
<div style="display: grid; grid-template-columns: 1fr 1fr 1fr; gap: 12px;">
|
| 393 |
+
<div>
|
| 394 |
+
<label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">입력 토큰</label>
|
| 395 |
+
<div style="display: flex; gap: 8px;">
|
| 396 |
+
<input
|
| 397 |
+
type="number"
|
| 398 |
+
id="input_tokens_${safeId}"
|
| 399 |
+
data-model-name="${escapeHtml(modelName)}"
|
| 400 |
+
value="${currentInputTokens || ''}"
|
| 401 |
+
placeholder="기본값: ${defaultInputToken.toLocaleString()}"
|
| 402 |
+
min="1"
|
| 403 |
+
style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
|
| 404 |
+
>
|
| 405 |
+
<button
|
| 406 |
+
class="btn btn-primary"
|
| 407 |
+
onclick="saveModelTokens('${escapeHtml(modelName)}', 'input')"
|
| 408 |
+
style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
|
| 409 |
+
>
|
| 410 |
+
저장
|
| 411 |
+
</button>
|
| 412 |
+
</div>
|
| 413 |
+
</div>
|
| 414 |
+
<div>
|
| 415 |
+
<label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">출력 토큰</label>
|
| 416 |
+
<div style="display: flex; gap: 8px;">
|
| 417 |
+
<input
|
| 418 |
+
type="number"
|
| 419 |
+
id="output_tokens_${safeId}"
|
| 420 |
+
data-model-name="${escapeHtml(modelName)}"
|
| 421 |
+
value="${currentOutputTokens || ''}"
|
| 422 |
+
placeholder="기본값: ${defaultOutputToken.toLocaleString()}"
|
| 423 |
+
min="1"
|
| 424 |
+
style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
|
| 425 |
+
>
|
| 426 |
+
<button
|
| 427 |
+
class="btn btn-primary"
|
| 428 |
+
onclick="saveModelTokens('${escapeHtml(modelName)}', 'output')"
|
| 429 |
+
style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
|
| 430 |
+
>
|
| 431 |
+
저장
|
| 432 |
+
</button>
|
| 433 |
+
</div>
|
| 434 |
+
</div>
|
| 435 |
+
<div>
|
| 436 |
+
<label style="display: block; font-size: 13px; font-weight: 500; margin-bottom: 6px; color: #202124;">Parent Chunk 토큰</label>
|
| 437 |
+
<div style="display: flex; gap: 8px;">
|
| 438 |
+
<input
|
| 439 |
+
type="number"
|
| 440 |
+
id="parent_chunk_tokens_${safeId}"
|
| 441 |
+
data-model-name="${escapeHtml(modelName)}"
|
| 442 |
+
value="${currentParentChunkTokens || ''}"
|
| 443 |
+
placeholder="기본값: ${defaultParentChunkToken.toLocaleString()}"
|
| 444 |
+
min="1"
|
| 445 |
+
style="flex: 1; padding: 6px 10px; border: 1px solid #dadce0; border-radius: 4px; font-size: 14px;"
|
| 446 |
+
>
|
| 447 |
+
<button
|
| 448 |
+
class="btn btn-primary"
|
| 449 |
+
onclick="saveModelTokens('${escapeHtml(modelName)}', 'parent_chunk')"
|
| 450 |
+
style="padding: 6px 12px; font-size: 13px; white-space: nowrap;"
|
| 451 |
+
>
|
| 452 |
+
저장
|
| 453 |
+
</button>
|
| 454 |
+
</div>
|
| 455 |
+
</div>
|
| 456 |
+
</div>
|
| 457 |
+
</div>
|
| 458 |
+
`;
|
| 459 |
+
});
|
| 460 |
+
|
| 461 |
+
listDiv.innerHTML = html;
|
| 462 |
+
|
| 463 |
+
} catch (error) {
|
| 464 |
+
console.error('토큰 수 설정 로드 오류:', error);
|
| 465 |
+
statusDiv.innerHTML = `<span style="color: #ea4335;">오류: ${error.message}</span>`;
|
| 466 |
+
listDiv.innerHTML = '<div style="text-align: center; padding: 20px; color: #ea4335;">토큰 수 설정을 불러오는 중 오류가 발생했습니다.</div>';
|
| 467 |
+
}
|
| 468 |
+
}
|
| 469 |
+
|
| 470 |
+
async function saveModelTokens(modelName, tokenType) {
|
| 471 |
+
// tokenType에 따라 input ID 결정 (안전한 ID 사용)
|
| 472 |
+
const safeId = modelNameToId(modelName);
|
| 473 |
+
let inputId;
|
| 474 |
+
if (tokenType === 'input') {
|
| 475 |
+
inputId = `input_tokens_${safeId}`;
|
| 476 |
+
} else if (tokenType === 'output') {
|
| 477 |
+
inputId = `output_tokens_${safeId}`;
|
| 478 |
+
} else if (tokenType === 'parent_chunk') {
|
| 479 |
+
inputId = `parent_chunk_tokens_${safeId}`;
|
| 480 |
+
} else {
|
| 481 |
+
showAlert('잘못된 토큰 타입입니다.', 'error');
|
| 482 |
+
return;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
const input = document.getElementById(inputId);
|
| 486 |
+
if (!input) {
|
| 487 |
+
console.error(`Input element not found: ${inputId}`);
|
| 488 |
+
showAlert('입력 필드를 찾을 수 없습니다. 페이지를 새로고침해주세요.', 'error');
|
| 489 |
+
return;
|
| 490 |
+
}
|
| 491 |
+
|
| 492 |
+
const tokens = input.value.trim();
|
| 493 |
+
console.log(`[토큰 저장] 모델: ${modelName}, 타입: ${tokenType}, 값: ${tokens}`);
|
| 494 |
+
|
| 495 |
+
// 빈 값이면 기본값으로 되돌리기 (설정 삭제)
|
| 496 |
+
if (!tokens) {
|
| 497 |
+
if (!confirm('입력값이 비어있습니다. 기본값으로 되돌리시겠습니까?')) {
|
| 498 |
+
return;
|
| 499 |
+
}
|
| 500 |
+
|
| 501 |
+
try {
|
| 502 |
+
const response = await fetch('/api/admin/model-tokens', {
|
| 503 |
+
method: 'POST',
|
| 504 |
+
headers: {
|
| 505 |
+
'Content-Type': 'application/json'
|
| 506 |
+
},
|
| 507 |
+
credentials: 'include',
|
| 508 |
+
body: JSON.stringify({
|
| 509 |
+
model_name: modelName,
|
| 510 |
+
token_type: tokenType,
|
| 511 |
+
tokens: null // null을 보내면 삭제
|
| 512 |
+
})
|
| 513 |
+
});
|
| 514 |
+
|
| 515 |
+
const data = await response.json();
|
| 516 |
+
|
| 517 |
+
if (response.ok) {
|
| 518 |
+
showAlert('기본값으로 되돌렸습니다.', 'success');
|
| 519 |
+
loadModelTokens(); // 목록 새로고침
|
| 520 |
+
} else {
|
| 521 |
+
showAlert(data.error || '설정 삭제 중 오류가 발생했습니다.', 'error');
|
| 522 |
+
}
|
| 523 |
+
} catch (error) {
|
| 524 |
+
console.error('설정 삭제 오류:', error);
|
| 525 |
+
showAlert(`오류: ${error.message}`, 'error');
|
| 526 |
+
}
|
| 527 |
+
return;
|
| 528 |
+
}
|
| 529 |
+
|
| 530 |
+
try {
|
| 531 |
+
const tokensNum = parseInt(tokens);
|
| 532 |
+
if (isNaN(tokensNum) || tokensNum < 1) {
|
| 533 |
+
showAlert('토큰 수는 1 이상의 정수여야 합니다.', 'error');
|
| 534 |
+
return;
|
| 535 |
+
}
|
| 536 |
+
|
| 537 |
+
const response = await fetch('/api/admin/model-tokens', {
|
| 538 |
+
method: 'POST',
|
| 539 |
+
headers: {
|
| 540 |
+
'Content-Type': 'application/json'
|
| 541 |
+
},
|
| 542 |
+
credentials: 'include',
|
| 543 |
+
body: JSON.stringify({
|
| 544 |
+
model_name: modelName,
|
| 545 |
+
token_type: tokenType,
|
| 546 |
+
tokens: tokensNum
|
| 547 |
+
})
|
| 548 |
+
});
|
| 549 |
+
|
| 550 |
+
const data = await response.json();
|
| 551 |
+
console.log(`[토큰 저장 응답]`, data);
|
| 552 |
+
|
| 553 |
+
if (response.ok) {
|
| 554 |
+
showAlert(data.message || '토큰 수가 저장되었습니다.', 'success');
|
| 555 |
+
// 약간의 지연 후 목록 새로고침 (서버 반영 시간 고려)
|
| 556 |
+
setTimeout(() => {
|
| 557 |
+
loadModelTokens(); // 목록 새로고침
|
| 558 |
+
}, 300);
|
| 559 |
+
} else {
|
| 560 |
+
console.error(`[토큰 저장 실패]`, data);
|
| 561 |
+
showAlert(data.error || '토큰 수 저장 중 오류가 발생했습니다.', 'error');
|
| 562 |
+
}
|
| 563 |
+
} catch (error) {
|
| 564 |
+
console.error('토큰 수 저장 오류:', error);
|
| 565 |
+
showAlert(`오류: ${error.message}`, 'error');
|
| 566 |
+
}
|
| 567 |
+
}
|
| 568 |
+
|
| 569 |
+
function escapeHtml(text) {
|
| 570 |
+
const div = document.createElement('div');
|
| 571 |
+
div.textContent = text;
|
| 572 |
+
return div.innerHTML;
|
| 573 |
+
}
|
| 574 |
+
|
| 575 |
+
// 모델명을 안전한 ID로 변환 (특수문자 처리)
|
| 576 |
+
function modelNameToId(modelName) {
|
| 577 |
+
// 특수문자를 언더스코어로 변환
|
| 578 |
+
return modelName.replace(/[^a-zA-Z0-9]/g, '_');
|
| 579 |
+
}
|
| 580 |
+
|
| 581 |
+
// ID를 모델명으로 역변환 (언더스코어를 원래 특수문자로 복원)
|
| 582 |
+
// 주의: 이 방법은 완벽하지 않으므로, 대신 data 속성을 사용하는 것이 더 안전합니다
|
| 583 |
+
function idToModelName(id) {
|
| 584 |
+
// 이 함수는 사용하지 않음 - data 속성 사용
|
| 585 |
+
return id;
|
| 586 |
+
}
|
| 587 |
+
|
| 588 |
+
// 페이지 로드 시 API 키 상태 확인 및 토큰 수 설정 로드
|
| 589 |
+
window.addEventListener('load', () => {
|
| 590 |
+
loadGeminiApiKey();
|
| 591 |
+
loadModelTokens();
|
| 592 |
+
});
|
| 593 |
+
</script>
|
| 594 |
+
</body>
|
| 595 |
+
</html>
|
| 596 |
+
|
templates/admin_webnovels.html
CHANGED
|
@@ -17,6 +17,7 @@
|
|
| 17 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 18 |
background: #f8f9fa;
|
| 19 |
color: #202124;
|
|
|
|
| 20 |
}
|
| 21 |
|
| 22 |
.header {
|
|
@@ -27,6 +28,7 @@
|
|
| 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 {
|
|
@@ -41,6 +43,7 @@
|
|
| 41 |
display: flex;
|
| 42 |
gap: 12px;
|
| 43 |
align-items: center;
|
|
|
|
| 44 |
}
|
| 45 |
|
| 46 |
.btn {
|
|
@@ -86,6 +89,7 @@
|
|
| 86 |
max-width: 1200px;
|
| 87 |
margin: 0 auto;
|
| 88 |
padding: 24px;
|
|
|
|
| 89 |
}
|
| 90 |
|
| 91 |
.page-header {
|
|
@@ -108,6 +112,7 @@
|
|
| 108 |
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 109 |
padding: 24px;
|
| 110 |
margin-bottom: 24px;
|
|
|
|
| 111 |
}
|
| 112 |
|
| 113 |
.card-header {
|
|
@@ -125,6 +130,7 @@
|
|
| 125 |
table {
|
| 126 |
width: 100%;
|
| 127 |
border-collapse: collapse;
|
|
|
|
| 128 |
}
|
| 129 |
|
| 130 |
thead {
|
|
@@ -135,6 +141,8 @@
|
|
| 135 |
padding: 12px;
|
| 136 |
text-align: left;
|
| 137 |
border-bottom: 1px solid #e8eaed;
|
|
|
|
|
|
|
| 138 |
}
|
| 139 |
|
| 140 |
th {
|
|
@@ -217,6 +225,8 @@
|
|
| 217 |
font-size: 12px;
|
| 218 |
margin-top: 8px;
|
| 219 |
min-height: 16px;
|
|
|
|
|
|
|
| 220 |
}
|
| 221 |
|
| 222 |
.file-upload-status.success {
|
|
@@ -250,6 +260,7 @@
|
|
| 250 |
justify-content: space-between;
|
| 251 |
padding: 8px 0;
|
| 252 |
border-bottom: 1px solid #e8eaed;
|
|
|
|
| 253 |
}
|
| 254 |
|
| 255 |
.progress-item:last-child {
|
|
@@ -264,6 +275,8 @@
|
|
| 264 |
text-overflow: ellipsis;
|
| 265 |
white-space: nowrap;
|
| 266 |
margin-right: 12px;
|
|
|
|
|
|
|
| 267 |
}
|
| 268 |
|
| 269 |
.progress-item-status {
|
|
@@ -597,13 +610,42 @@
|
|
| 597 |
|
| 598 |
// 모델 목록 로드 (관리자용: 모든 모델 표시)
|
| 599 |
async function loadModelsForFiles() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 600 |
try {
|
| 601 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 602 |
const data = await response.json();
|
|
|
|
| 603 |
|
| 604 |
fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
| 605 |
|
| 606 |
if (data.models && data.models.length > 0) {
|
|
|
|
| 607 |
data.models.forEach(model => {
|
| 608 |
const option = document.createElement('option');
|
| 609 |
option.value = model.name;
|
|
@@ -614,22 +656,55 @@
|
|
| 614 |
: model.name;
|
| 615 |
option.textContent = displayText;
|
| 616 |
fileModelSelect.appendChild(option);
|
|
|
|
| 617 |
});
|
|
|
|
| 618 |
} else if (data.error) {
|
| 619 |
-
console.error('모델
|
| 620 |
-
fileModelSelect.innerHTML =
|
|
|
|
|
|
|
|
|
|
| 621 |
}
|
| 622 |
} catch (error) {
|
| 623 |
-
console.error('모델 로드 오류:', error);
|
| 624 |
-
fileModelSelect.innerHTML =
|
| 625 |
}
|
| 626 |
}
|
| 627 |
|
| 628 |
// 파일 목록 로드
|
| 629 |
async function loadFiles() {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 630 |
try {
|
| 631 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 632 |
const data = await response.json();
|
|
|
|
| 633 |
|
| 634 |
filesTableBody.innerHTML = '';
|
| 635 |
|
|
@@ -715,11 +790,12 @@
|
|
| 715 |
});
|
| 716 |
});
|
| 717 |
} else {
|
|
|
|
| 718 |
filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
|
| 719 |
}
|
| 720 |
} catch (error) {
|
| 721 |
-
|
| 722 |
-
|
| 723 |
}
|
| 724 |
}
|
| 725 |
|
|
@@ -813,32 +889,41 @@
|
|
| 813 |
progressMap.set(index, { file, item, status: 'waiting', step: 0 });
|
| 814 |
});
|
| 815 |
|
| 816 |
-
// 업로드 단계 정의
|
| 817 |
const uploadSteps = [
|
| 818 |
{ name: '업로드 폴더 확인', step: 1 },
|
| 819 |
{ name: '파일 수신', step: 2 },
|
| 820 |
{ name: '파일 검증', step: 3 },
|
| 821 |
{ name: '파일 저장', step: 4 },
|
| 822 |
{ name: '데이터베이스 저장', step: 5 },
|
| 823 |
-
{ name: '
|
| 824 |
-
{ name: '
|
|
|
|
|
|
|
|
|
|
| 825 |
];
|
| 826 |
|
| 827 |
-
function updateProgressStatus(fileIndex, stepIndex, stepName) {
|
| 828 |
const statusElement = document.getElementById(`progress-status-${fileIndex}`);
|
| 829 |
if (statusElement) {
|
| 830 |
const step = uploadSteps[stepIndex] || { name: stepName || '처리 중', step: stepIndex + 1 };
|
| 831 |
-
|
|
|
|
|
|
|
|
|
|
| 832 |
}
|
| 833 |
}
|
| 834 |
|
| 835 |
-
function updateOverallStatus(currentFile, totalFiles, stepIndex) {
|
| 836 |
const step = uploadSteps[stepIndex] || { name: '처리 중', step: stepIndex + 1 };
|
| 837 |
-
|
|
|
|
|
|
|
|
|
|
| 838 |
}
|
| 839 |
|
| 840 |
if (fileUploadStatus) {
|
| 841 |
-
fileUploadStatus.textContent = `[0/${files.length}] 업로드 준비
|
| 842 |
fileUploadStatus.className = 'file-upload-status progress';
|
| 843 |
}
|
| 844 |
|
|
@@ -884,14 +969,14 @@
|
|
| 884 |
|
| 885 |
// 단계 1: 업로드 폴더 확인
|
| 886 |
console.log(`[단계 1] 업로드 폴더 확인 시작`);
|
| 887 |
-
updateProgressStatus(i, 0, '업로드 폴더 확인');
|
| 888 |
updateOverallStatus(i + 1, files.length, 0);
|
| 889 |
|
| 890 |
console.log(`[단계 1] fetch 호출 시작: /api/upload`);
|
| 891 |
console.log(`[단계 1] FormData 항목:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
|
| 892 |
|
| 893 |
-
// 타임아웃이 있는 fetch 래퍼 (
|
| 894 |
-
const fetchWithTimeout = (url, options, timeout =
|
| 895 |
return Promise.race([
|
| 896 |
fetch(url, options),
|
| 897 |
new Promise((_, reject) =>
|
|
@@ -904,7 +989,7 @@
|
|
| 904 |
method: 'POST',
|
| 905 |
body: formData,
|
| 906 |
credentials: 'include' // 쿠키 포함 (세션 인증)
|
| 907 |
-
},
|
| 908 |
|
| 909 |
console.log(`[단계 1] fetch 응답 수신: ${response.status} ${response.statusText}`);
|
| 910 |
|
|
@@ -922,13 +1007,13 @@
|
|
| 922 |
}
|
| 923 |
|
| 924 |
// 단계 2: 파일 수신
|
| 925 |
-
updateProgressStatus(i, 1, '파일 수신');
|
| 926 |
updateOverallStatus(i + 1, files.length, 1);
|
| 927 |
|
| 928 |
console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
|
| 929 |
|
| 930 |
// 단계 3: 파일 검증
|
| 931 |
-
updateProgressStatus(i, 2, '파일 검증');
|
| 932 |
updateOverallStatus(i + 1, files.length, 2);
|
| 933 |
|
| 934 |
let data;
|
|
@@ -944,36 +1029,94 @@
|
|
| 944 |
}
|
| 945 |
|
| 946 |
// 단계 4: 파일 저장
|
| 947 |
-
updateProgressStatus(i, 3, '파일 저장');
|
| 948 |
updateOverallStatus(i + 1, files.length, 3);
|
| 949 |
|
| 950 |
if (response.ok) {
|
| 951 |
-
|
| 952 |
-
|
| 953 |
-
|
| 954 |
-
|
| 955 |
-
// 단계 6: 청크 생성
|
| 956 |
-
updateProgressStatus(i, 5, '청크 생성');
|
| 957 |
-
updateOverallStatus(i + 1, files.length, 5);
|
| 958 |
-
|
| 959 |
-
successCount++;
|
| 960 |
-
const modelName = data.model_name || '알 수 없음';
|
| 961 |
-
const chunkCount = data.chunk_count || 0;
|
| 962 |
|
| 963 |
-
// 단계
|
| 964 |
-
|
| 965 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 966 |
|
| 967 |
-
|
| 968 |
-
|
| 969 |
-
|
| 970 |
-
|
| 971 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 972 |
} else {
|
| 973 |
failCount++;
|
| 974 |
const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
|
| 975 |
statusElement.className = 'progress-item-status error';
|
| 976 |
-
statusElement.innerHTML =
|
| 977 |
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 978 |
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 979 |
errors.push(`${file.name}: ${errorMsg}`);
|
|
@@ -984,26 +1127,41 @@
|
|
| 984 |
data: data,
|
| 985 |
responseText: responseText
|
| 986 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 987 |
}
|
| 988 |
} catch (error) {
|
| 989 |
failCount++;
|
| 990 |
const errorMsg = error.message || '네트워크 오류';
|
|
|
|
|
|
|
| 991 |
if (statusElement) {
|
| 992 |
statusElement.className = 'progress-item-status error';
|
| 993 |
-
statusElement.innerHTML =
|
| 994 |
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 995 |
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 996 |
}
|
|
|
|
| 997 |
errors.push(`${file.name}: ${errorMsg}`);
|
| 998 |
console.error(`[업로드 예외] 파일: ${file.name}`, error);
|
| 999 |
console.error(`[업로드 예외 스택]`, error.stack);
|
| 1000 |
|
| 1001 |
// 타임아웃 오류인 경우 사용자에게 명확한 메시지 표시
|
| 1002 |
if (error.message && error.message.includes('타임아웃')) {
|
| 1003 |
-
const timeoutMsg = `파일 업로드 타임아웃: ${file.name}\n파일이
|
| 1004 |
console.error(`[타임아웃 오류] ${timeoutMsg}`);
|
| 1005 |
if (fileUploadStatus) {
|
| 1006 |
-
fileUploadStatus.textContent = `[${i + 1}/${files.length}] 타임아웃: ${file.name}
|
| 1007 |
fileUploadStatus.className = 'file-upload-status error';
|
| 1008 |
}
|
| 1009 |
// 사용자에게 알림 표시
|
|
@@ -1023,15 +1181,18 @@
|
|
| 1023 |
showAlert(`파일 업로드 오류: ${file.name}\n${errorMsg}`, 'error');
|
| 1024 |
}
|
| 1025 |
console.error(`[스택 트레이스]`, error.stack);
|
| 1026 |
-
|
| 1027 |
-
|
| 1028 |
-
|
| 1029 |
-
if (
|
| 1030 |
-
|
| 1031 |
-
|
| 1032 |
-
|
|
|
|
|
|
|
| 1033 |
}
|
| 1034 |
}
|
|
|
|
| 1035 |
} catch (uploadLoopError) {
|
| 1036 |
console.error('[업로드 루프 오류]', uploadLoopError);
|
| 1037 |
fileUploadStatus.textContent = `업로드 처리 중 오류 발생: ${uploadLoopError.message}`;
|
|
@@ -1044,12 +1205,12 @@
|
|
| 1044 |
if (fileUploadStatus) {
|
| 1045 |
fileUploadStatus.className = 'file-upload-status';
|
| 1046 |
if (successCount > 0) {
|
| 1047 |
-
fileUploadStatus.textContent =
|
| 1048 |
fileUploadStatus.className = 'file-upload-status success';
|
| 1049 |
showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
|
| 1050 |
loadFiles();
|
| 1051 |
} else {
|
| 1052 |
-
fileUploadStatus.textContent =
|
| 1053 |
fileUploadStatus.className = 'file-upload-status error';
|
| 1054 |
const errorDetails = errors.length > 0 ? '\n\n오류 상세:\n' + errors.slice(0, 5).map((e, idx) => `${idx + 1}. ${e}`).join('\n') + (errors.length > 5 ? `\n... 외 ${errors.length - 5}개 오류` : '') : '';
|
| 1055 |
showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
|
|
@@ -1067,15 +1228,9 @@
|
|
| 1067 |
console.error('[handleFileUpload] 완료 처리 오류:', finalError);
|
| 1068 |
}
|
| 1069 |
|
| 1070 |
-
//
|
| 1071 |
-
|
| 1072 |
-
|
| 1073 |
-
progressContainer.classList.remove('active');
|
| 1074 |
-
}
|
| 1075 |
-
if (fileUploadStatus) {
|
| 1076 |
-
fileUploadStatus.textContent = '';
|
| 1077 |
-
}
|
| 1078 |
-
}, 3000);
|
| 1079 |
}
|
| 1080 |
|
| 1081 |
// 파일 삭제
|
|
@@ -1397,11 +1552,49 @@
|
|
| 1397 |
}
|
| 1398 |
});
|
| 1399 |
|
| 1400 |
-
// 페이지 로드 시 초기화
|
| 1401 |
-
|
| 1402 |
-
|
| 1403 |
-
|
| 1404 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1405 |
</script>
|
| 1406 |
</body>
|
| 1407 |
</html>
|
|
|
|
| 17 |
font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
|
| 18 |
background: #f8f9fa;
|
| 19 |
color: #202124;
|
| 20 |
+
overflow-x: hidden;
|
| 21 |
}
|
| 22 |
|
| 23 |
.header {
|
|
|
|
| 28 |
align-items: center;
|
| 29 |
justify-content: space-between;
|
| 30 |
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 31 |
+
overflow-x: hidden;
|
| 32 |
}
|
| 33 |
|
| 34 |
.header-title {
|
|
|
|
| 43 |
display: flex;
|
| 44 |
gap: 12px;
|
| 45 |
align-items: center;
|
| 46 |
+
flex-wrap: wrap;
|
| 47 |
}
|
| 48 |
|
| 49 |
.btn {
|
|
|
|
| 89 |
max-width: 1200px;
|
| 90 |
margin: 0 auto;
|
| 91 |
padding: 24px;
|
| 92 |
+
overflow-x: hidden;
|
| 93 |
}
|
| 94 |
|
| 95 |
.page-header {
|
|
|
|
| 112 |
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
|
| 113 |
padding: 24px;
|
| 114 |
margin-bottom: 24px;
|
| 115 |
+
overflow-x: hidden;
|
| 116 |
}
|
| 117 |
|
| 118 |
.card-header {
|
|
|
|
| 130 |
table {
|
| 131 |
width: 100%;
|
| 132 |
border-collapse: collapse;
|
| 133 |
+
table-layout: auto;
|
| 134 |
}
|
| 135 |
|
| 136 |
thead {
|
|
|
|
| 141 |
padding: 12px;
|
| 142 |
text-align: left;
|
| 143 |
border-bottom: 1px solid #e8eaed;
|
| 144 |
+
word-break: break-word;
|
| 145 |
+
overflow-wrap: break-word;
|
| 146 |
}
|
| 147 |
|
| 148 |
th {
|
|
|
|
| 225 |
font-size: 12px;
|
| 226 |
margin-top: 8px;
|
| 227 |
min-height: 16px;
|
| 228 |
+
word-break: break-word;
|
| 229 |
+
overflow-wrap: break-word;
|
| 230 |
}
|
| 231 |
|
| 232 |
.file-upload-status.success {
|
|
|
|
| 260 |
justify-content: space-between;
|
| 261 |
padding: 8px 0;
|
| 262 |
border-bottom: 1px solid #e8eaed;
|
| 263 |
+
min-width: 0;
|
| 264 |
}
|
| 265 |
|
| 266 |
.progress-item:last-child {
|
|
|
|
| 275 |
text-overflow: ellipsis;
|
| 276 |
white-space: nowrap;
|
| 277 |
margin-right: 12px;
|
| 278 |
+
min-width: 0;
|
| 279 |
+
max-width: 100%;
|
| 280 |
}
|
| 281 |
|
| 282 |
.progress-item-status {
|
|
|
|
| 610 |
|
| 611 |
// 모델 목록 로드 (관리자용: 모든 모델 표시)
|
| 612 |
async function loadModelsForFiles() {
|
| 613 |
+
if (!fileModelSelect) {
|
| 614 |
+
console.error('[모델 목록] fileModelSelect 요소를 찾을 수 없습니다.');
|
| 615 |
+
return;
|
| 616 |
+
}
|
| 617 |
+
|
| 618 |
try {
|
| 619 |
+
console.log('[모델 목록] API 요청 시작: /api/admin/ollama/models');
|
| 620 |
+
const response = await fetch('/api/admin/ollama/models', {
|
| 621 |
+
credentials: 'include', // 쿠키 포함 (세션 인증)
|
| 622 |
+
headers: {
|
| 623 |
+
'Accept': 'application/json'
|
| 624 |
+
}
|
| 625 |
+
});
|
| 626 |
+
|
| 627 |
+
console.log('[모델 목록] 응답 상태:', response.status, response.statusText);
|
| 628 |
+
console.log('[모델 목록] 응답 헤더:', Object.fromEntries(response.headers.entries()));
|
| 629 |
+
|
| 630 |
+
if (!response.ok) {
|
| 631 |
+
const errorText = await response.text();
|
| 632 |
+
console.error('[모델 목록] 응답 오류:', errorText);
|
| 633 |
+
try {
|
| 634 |
+
const errorData = JSON.parse(errorText);
|
| 635 |
+
fileModelSelect.innerHTML = `<option value="">모델을 불러올 수 없습니다: ${errorData.error || '서버 오류'}</option>`;
|
| 636 |
+
} catch {
|
| 637 |
+
fileModelSelect.innerHTML = `<option value="">모델을 불러올 수 없습니다 (${response.status} ${response.statusText})</option>`;
|
| 638 |
+
}
|
| 639 |
+
return;
|
| 640 |
+
}
|
| 641 |
+
|
| 642 |
const data = await response.json();
|
| 643 |
+
console.log('[모델 목록] 응답 데이터:', data);
|
| 644 |
|
| 645 |
fileModelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
| 646 |
|
| 647 |
if (data.models && data.models.length > 0) {
|
| 648 |
+
console.log(`[모델 목록] ${data.models.length}개 모델 발견`);
|
| 649 |
data.models.forEach(model => {
|
| 650 |
const option = document.createElement('option');
|
| 651 |
option.value = model.name;
|
|
|
|
| 656 |
: model.name;
|
| 657 |
option.textContent = displayText;
|
| 658 |
fileModelSelect.appendChild(option);
|
| 659 |
+
console.log(`[모델 목록] 모델 추가: ${model.name} (타입: ${model.type || 'unknown'})`);
|
| 660 |
});
|
| 661 |
+
console.log(`[모델 목록] 총 ${data.models.length}개 모델 로드 완료`);
|
| 662 |
} else if (data.error) {
|
| 663 |
+
console.error('[모델 목록] 오류:', data.error);
|
| 664 |
+
fileModelSelect.innerHTML = `<option value="">${escapeHtml(data.error) || '모델을 불러올 수 없습니다'}</option>`;
|
| 665 |
+
} else {
|
| 666 |
+
console.warn('[모델 목록] 모델이 없습니다');
|
| 667 |
+
fileModelSelect.innerHTML = '<option value="">사용 가능한 모델이 없습니다</option>';
|
| 668 |
}
|
| 669 |
} catch (error) {
|
| 670 |
+
console.error('[모델 목록] 로드 오류:', error);
|
| 671 |
+
fileModelSelect.innerHTML = `<option value="">모델 로드 실패: ${escapeHtml(error.message)}</option>`;
|
| 672 |
}
|
| 673 |
}
|
| 674 |
|
| 675 |
// 파일 목록 로드
|
| 676 |
async function loadFiles() {
|
| 677 |
+
if (!filesTableBody) {
|
| 678 |
+
console.error('[파일 목록] filesTableBody 요소를 찾을 수 없습니다.');
|
| 679 |
+
return;
|
| 680 |
+
}
|
| 681 |
+
|
| 682 |
try {
|
| 683 |
+
console.log('[파일 목록] API 요청 시작: /api/files');
|
| 684 |
+
const response = await fetch('/api/files', {
|
| 685 |
+
credentials: 'include', // 쿠키 포함 (세션 인증)
|
| 686 |
+
headers: {
|
| 687 |
+
'Accept': 'application/json'
|
| 688 |
+
}
|
| 689 |
+
});
|
| 690 |
+
|
| 691 |
+
console.log('[파일 목록] 응답 상태:', response.status, response.statusText);
|
| 692 |
+
console.log('[파일 목록] 응답 헤더:', Object.fromEntries(response.headers.entries()));
|
| 693 |
+
|
| 694 |
+
if (!response.ok) {
|
| 695 |
+
const errorText = await response.text();
|
| 696 |
+
console.error('[파일 목록] 응답 오류:', errorText);
|
| 697 |
+
try {
|
| 698 |
+
const errorData = JSON.parse(errorText);
|
| 699 |
+
filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러올 수 없습니다: ${escapeHtml(errorData.error || '서버 오류')}</td></tr>`;
|
| 700 |
+
} catch {
|
| 701 |
+
filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러올 수 없습니다 (${response.status} ${response.statusText})</td></tr>`;
|
| 702 |
+
}
|
| 703 |
+
return;
|
| 704 |
+
}
|
| 705 |
+
|
| 706 |
const data = await response.json();
|
| 707 |
+
console.log('[파일 목록] 응답 데이터:', data);
|
| 708 |
|
| 709 |
filesTableBody.innerHTML = '';
|
| 710 |
|
|
|
|
| 790 |
});
|
| 791 |
});
|
| 792 |
} else {
|
| 793 |
+
console.log('[파일 목록] 업로드된 파일이 없습니다');
|
| 794 |
filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">업로드된 파일이 없습니다.</td></tr>';
|
| 795 |
}
|
| 796 |
} catch (error) {
|
| 797 |
+
console.error('[파일 목록] 로드 오류:', error);
|
| 798 |
+
filesTableBody.innerHTML = `<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">파일 목록을 불러오는 중 오류가 발생했습니다.<br><small>${escapeHtml(error.message)}</small></td></tr>`;
|
| 799 |
}
|
| 800 |
}
|
| 801 |
|
|
|
|
| 889 |
progressMap.set(index, { file, item, status: 'waiting', step: 0 });
|
| 890 |
});
|
| 891 |
|
| 892 |
+
// 업로드 단계 정의 (더 세분화)
|
| 893 |
const uploadSteps = [
|
| 894 |
{ name: '업로드 폴더 확인', step: 1 },
|
| 895 |
{ name: '파일 수신', step: 2 },
|
| 896 |
{ name: '파일 검증', step: 3 },
|
| 897 |
{ name: '파일 저장', step: 4 },
|
| 898 |
{ name: '데이터베이스 저장', step: 5 },
|
| 899 |
+
{ name: 'Parent Chunk 생성', step: 6, detail: 'AI 분석 중...' },
|
| 900 |
+
{ name: 'Child Chunk 생성', step: 7, detail: '섹션 분할 중...' },
|
| 901 |
+
{ name: '회차 분석', step: 8, detail: '회차 분석 준비 중...' },
|
| 902 |
+
{ name: 'Graph Extraction', step: 9, detail: 'Graph Extraction 준비 중...' },
|
| 903 |
+
{ name: '완료', step: 10 }
|
| 904 |
];
|
| 905 |
|
| 906 |
+
function updateProgressStatus(fileIndex, stepIndex, stepName, totalFiles, detail = null) {
|
| 907 |
const statusElement = document.getElementById(`progress-status-${fileIndex}`);
|
| 908 |
if (statusElement) {
|
| 909 |
const step = uploadSteps[stepIndex] || { name: stepName || '처리 중', step: stepIndex + 1 };
|
| 910 |
+
const currentFile = fileIndex + 1;
|
| 911 |
+
const detailText = detail || step.detail || '';
|
| 912 |
+
const detailDisplay = detailText ? ` - ${detailText}` : '';
|
| 913 |
+
statusElement.innerHTML = `<span class="spinner"></span>파일 ${currentFile}/${totalFiles}: ${step.name} (${step.step}/10)${detailDisplay}`;
|
| 914 |
}
|
| 915 |
}
|
| 916 |
|
| 917 |
+
function updateOverallStatus(currentFile, totalFiles, stepIndex, detail = null) {
|
| 918 |
const step = uploadSteps[stepIndex] || { name: '처리 중', step: stepIndex + 1 };
|
| 919 |
+
const progressPercent = Math.round((currentFile / totalFiles) * 100);
|
| 920 |
+
const detailText = detail || step.detail || '';
|
| 921 |
+
const detailDisplay = detailText ? ` - ${detailText}` : '';
|
| 922 |
+
fileUploadStatus.textContent = `[파일 ${currentFile}/${totalFiles}] ${step.name} 중... (${step.step}/10)${detailDisplay} - 전체 진행률: ${progressPercent}%`;
|
| 923 |
}
|
| 924 |
|
| 925 |
if (fileUploadStatus) {
|
| 926 |
+
fileUploadStatus.textContent = `[파일 0/${files.length}] 업로드 준비 중... - 전체 진행률: 0%`;
|
| 927 |
fileUploadStatus.className = 'file-upload-status progress';
|
| 928 |
}
|
| 929 |
|
|
|
|
| 969 |
|
| 970 |
// 단계 1: 업로드 폴더 확인
|
| 971 |
console.log(`[단계 1] 업로드 폴더 확인 시작`);
|
| 972 |
+
updateProgressStatus(i, 0, '업로드 폴더 확인', files.length);
|
| 973 |
updateOverallStatus(i + 1, files.length, 0);
|
| 974 |
|
| 975 |
console.log(`[단계 1] fetch 호출 시작: /api/upload`);
|
| 976 |
console.log(`[단계 1] FormData 항목:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
|
| 977 |
|
| 978 |
+
// 타임아웃이 있는 fetch 래퍼 (30분 타임아웃 - 큰 파일 처리 시간 고려)
|
| 979 |
+
const fetchWithTimeout = (url, options, timeout = 1800000) => { // 30분 타임아웃
|
| 980 |
return Promise.race([
|
| 981 |
fetch(url, options),
|
| 982 |
new Promise((_, reject) =>
|
|
|
|
| 989 |
method: 'POST',
|
| 990 |
body: formData,
|
| 991 |
credentials: 'include' // 쿠키 포함 (세션 인증)
|
| 992 |
+
}, 1800000); // 30분 타임아웃
|
| 993 |
|
| 994 |
console.log(`[단계 1] fetch 응답 수신: ${response.status} ${response.statusText}`);
|
| 995 |
|
|
|
|
| 1007 |
}
|
| 1008 |
|
| 1009 |
// 단계 2: 파일 수신
|
| 1010 |
+
updateProgressStatus(i, 1, '파일 수신', files.length);
|
| 1011 |
updateOverallStatus(i + 1, files.length, 1);
|
| 1012 |
|
| 1013 |
console.log(`[응답 수신] 상태: ${response.status} ${response.statusText}, Content-Type: ${response.headers.get('Content-Type')}`);
|
| 1014 |
|
| 1015 |
// 단계 3: 파일 검증
|
| 1016 |
+
updateProgressStatus(i, 2, '파일 검증', files.length);
|
| 1017 |
updateOverallStatus(i + 1, files.length, 2);
|
| 1018 |
|
| 1019 |
let data;
|
|
|
|
| 1029 |
}
|
| 1030 |
|
| 1031 |
// 단계 4: 파일 저장
|
| 1032 |
+
updateProgressStatus(i, 3, '파일 저장', files.length);
|
| 1033 |
updateOverallStatus(i + 1, files.length, 3);
|
| 1034 |
|
| 1035 |
if (response.ok) {
|
| 1036 |
+
const fileId = data.file_id;
|
| 1037 |
+
const episodeCount = data.episode_count || 0;
|
| 1038 |
+
const needsProcessing = data.needs_processing !== false; // 기본값 true
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1039 |
|
| 1040 |
+
// 텍스트 파일이 아니면 처리 단계 건너뛰기
|
| 1041 |
+
if (!needsProcessing) {
|
| 1042 |
+
successCount++;
|
| 1043 |
+
const modelName = data.model_name || '알 수 없음';
|
| 1044 |
+
updateProgressStatus(i, 9, '완료', files.length);
|
| 1045 |
+
updateOverallStatus(i + 1, files.length, 9);
|
| 1046 |
+
statusElement.className = 'progress-item-status success';
|
| 1047 |
+
statusElement.innerHTML = `✓ 완료 (파일 ${i + 1}/${files.length})`;
|
| 1048 |
+
statusElement.title = `모델: ${modelName}`;
|
| 1049 |
+
itemElement.style.opacity = '0.7';
|
| 1050 |
+
console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}`);
|
| 1051 |
+
} else {
|
| 1052 |
+
// 단계별 처리 함수
|
| 1053 |
+
const processStep = async (stepName, stepNumber, url, timeout, detailText = '') => {
|
| 1054 |
+
updateProgressStatus(i, stepNumber, stepName, files.length, detailText);
|
| 1055 |
+
updateOverallStatus(i + 1, files.length, stepNumber, detailText);
|
| 1056 |
+
|
| 1057 |
+
const stepResponse = await fetchWithTimeout(url, {
|
| 1058 |
+
method: 'POST',
|
| 1059 |
+
credentials: 'include'
|
| 1060 |
+
}, timeout);
|
| 1061 |
+
|
| 1062 |
+
if (!stepResponse.ok) {
|
| 1063 |
+
const stepError = await stepResponse.json().catch(() => ({ error: `HTTP ${stepResponse.status}` }));
|
| 1064 |
+
throw new Error(`${stepName} 실패: ${stepError.error || '알 수 없는 오류'}`);
|
| 1065 |
+
}
|
| 1066 |
+
|
| 1067 |
+
return await stepResponse.json();
|
| 1068 |
+
};
|
| 1069 |
+
|
| 1070 |
+
try {
|
| 1071 |
+
// 단계 1: Parent Chunk 생성 (10분 타임아웃)
|
| 1072 |
+
await processStep('Parent Chunk 생성', 5, `/api/files/${fileId}/process/parent-chunk`, 600000, 'AI 분석 중...');
|
| 1073 |
+
|
| 1074 |
+
// 단계 2: Chunk 생성 (5분 타임아웃)
|
| 1075 |
+
const chunksData = await processStep('Child Chunk 생성', 6, `/api/files/${fileId}/process/chunks`, 300000, '섹션 분할 중...');
|
| 1076 |
+
const chunkCount = chunksData.chunk_count || 0;
|
| 1077 |
+
|
| 1078 |
+
// 단계 3: 회차 분석 (회차당 2분, 최대 30분)
|
| 1079 |
+
const episodeTimeout = episodeCount > 0 ? Math.min(episodeCount * 120000, 1800000) : 600000;
|
| 1080 |
+
const episodeDetail = episodeCount > 0 ? `총 ${episodeCount}회차 중 분석 중...` : '회차 분석 중...';
|
| 1081 |
+
await processStep('회차 분석', 7, `/api/files/${fileId}/process/episode-analysis`, episodeTimeout, episodeDetail);
|
| 1082 |
+
|
| 1083 |
+
// 단계 4: Graph Extraction (회차당 1분, 최대 20분)
|
| 1084 |
+
const graphTimeout = episodeCount > 0 ? Math.min(episodeCount * 60000, 1200000) : 300000;
|
| 1085 |
+
const graphDetail = episodeCount > 0 ? `총 ${episodeCount}회차 중 추출 중...` : 'Graph Extraction 중...';
|
| 1086 |
+
await processStep('Graph Extraction', 8, `/api/files/${fileId}/process/graph`, graphTimeout, graphDetail);
|
| 1087 |
+
|
| 1088 |
+
successCount++;
|
| 1089 |
+
const modelName = data.model_name || '알 수 없음';
|
| 1090 |
+
|
| 1091 |
+
// 단계 10: 완료
|
| 1092 |
+
updateProgressStatus(i, 9, '완료', files.length);
|
| 1093 |
+
updateOverallStatus(i + 1, files.length, 9);
|
| 1094 |
+
|
| 1095 |
+
statusElement.className = 'progress-item-status success';
|
| 1096 |
+
statusElement.innerHTML = `✓ 완료 (파일 ${i + 1}/${files.length})`;
|
| 1097 |
+
statusElement.title = `모델: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
|
| 1098 |
+
itemElement.style.opacity = '0.7';
|
| 1099 |
+
console.log(`[업로드 성공] ${file.name} → 모델: ${modelName}, 청크: ${chunkCount}개`);
|
| 1100 |
+
} catch (stepError) {
|
| 1101 |
+
// 단계별 처리 실패
|
| 1102 |
+
throw stepError;
|
| 1103 |
+
}
|
| 1104 |
+
}
|
| 1105 |
|
| 1106 |
+
// 성공한 경우 진행 상태 업데이트
|
| 1107 |
+
const progressPercent = Math.round(((i + 1) / files.length) * 100);
|
| 1108 |
+
if (fileUploadStatus) {
|
| 1109 |
+
if (i < files.length - 1) {
|
| 1110 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 완료, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
|
| 1111 |
+
} else {
|
| 1112 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 - 전체 진행률: 100%`;
|
| 1113 |
+
}
|
| 1114 |
+
}
|
| 1115 |
} else {
|
| 1116 |
failCount++;
|
| 1117 |
const errorMsg = data.error || data.message || `HTTP ${response.status} 오류`;
|
| 1118 |
statusElement.className = 'progress-item-status error';
|
| 1119 |
+
statusElement.innerHTML = `✗ 실패 (파일 ${i + 1}/${files.length})`;
|
| 1120 |
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 1121 |
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 1122 |
errors.push(`${file.name}: ${errorMsg}`);
|
|
|
|
| 1127 |
data: data,
|
| 1128 |
responseText: responseText
|
| 1129 |
});
|
| 1130 |
+
|
| 1131 |
+
// 실패한 경우 진행 상태 업데이트
|
| 1132 |
+
const progressPercent = Math.round(((i + 1) / files.length) * 100);
|
| 1133 |
+
if (fileUploadStatus) {
|
| 1134 |
+
if (i < files.length - 1) {
|
| 1135 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 실패, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
|
| 1136 |
+
} else {
|
| 1137 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 (일부 실패) - 전체 진행률: ${progressPercent}%`;
|
| 1138 |
+
}
|
| 1139 |
+
fileUploadStatus.className = 'file-upload-status error';
|
| 1140 |
+
}
|
| 1141 |
+
showAlert(`파일 업로드 실패: ${file.name}\n${errorMsg}`, 'error');
|
| 1142 |
}
|
| 1143 |
} catch (error) {
|
| 1144 |
failCount++;
|
| 1145 |
const errorMsg = error.message || '네트워크 오류';
|
| 1146 |
+
|
| 1147 |
+
// statusElement가 있으면 실패 상태로 표시
|
| 1148 |
if (statusElement) {
|
| 1149 |
statusElement.className = 'progress-item-status error';
|
| 1150 |
+
statusElement.innerHTML = `✗ 실패 (파일 ${i + 1}/${files.length})`;
|
| 1151 |
statusElement.title = errorMsg; // 툴팁으로 상세 에러 표시
|
| 1152 |
statusElement.style.cursor = 'help'; // 툴팁 표시를 위한 커서 변경
|
| 1153 |
}
|
| 1154 |
+
|
| 1155 |
errors.push(`${file.name}: ${errorMsg}`);
|
| 1156 |
console.error(`[업로드 예외] 파일: ${file.name}`, error);
|
| 1157 |
console.error(`[업로드 예외 스택]`, error.stack);
|
| 1158 |
|
| 1159 |
// 타임아웃 오류인 경우 사용자에게 명확한 메시지 표시
|
| 1160 |
if (error.message && error.message.includes('타임아웃')) {
|
| 1161 |
+
const timeoutMsg = `파일 업로드 타임아웃: ${file.name}\n\n파일이 크거나 처리 시간이 오래 걸려 타임아웃이 발생했습니다.\n서버에서는 파일 처리가 계속 진행 중일 수 있습니다.\n서버 로그를 확인하시거나 잠시 후 파일 목록을 새로고침해보세요.`;
|
| 1162 |
console.error(`[타임아웃 오류] ${timeoutMsg}`);
|
| 1163 |
if (fileUploadStatus) {
|
| 1164 |
+
fileUploadStatus.textContent = `[${i + 1}/${files.length}] 타임아웃: ${file.name} - 서버 로그 확인 필요`;
|
| 1165 |
fileUploadStatus.className = 'file-upload-status error';
|
| 1166 |
}
|
| 1167 |
// 사용자에게 알림 표시
|
|
|
|
| 1181 |
showAlert(`파일 업로드 오류: ${file.name}\n${errorMsg}`, 'error');
|
| 1182 |
}
|
| 1183 |
console.error(`[스택 트레이스]`, error.stack);
|
| 1184 |
+
|
| 1185 |
+
// 진행 상태 업데이트 (에러 발생 시에도)
|
| 1186 |
+
const progressPercent = Math.round(((i + 1) / files.length) * 100);
|
| 1187 |
+
if (fileUploadStatus) {
|
| 1188 |
+
if (i < files.length - 1) {
|
| 1189 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 오류 발생, 다음 파일 처리 중... - 전체 진행률: ${progressPercent}%`;
|
| 1190 |
+
} else {
|
| 1191 |
+
fileUploadStatus.textContent = `[파일 ${i + 1}/${files.length}] 모든 파일 처리 완료 (일부 실패) - 전체 진행률: ${progressPercent}%`;
|
| 1192 |
+
}
|
| 1193 |
}
|
| 1194 |
}
|
| 1195 |
+
} // for 루프 닫기
|
| 1196 |
} catch (uploadLoopError) {
|
| 1197 |
console.error('[업로드 루프 오류]', uploadLoopError);
|
| 1198 |
fileUploadStatus.textContent = `업로드 처리 중 오류 발생: ${uploadLoopError.message}`;
|
|
|
|
| 1205 |
if (fileUploadStatus) {
|
| 1206 |
fileUploadStatus.className = 'file-upload-status';
|
| 1207 |
if (successCount > 0) {
|
| 1208 |
+
fileUploadStatus.textContent = `✅ ${successCount}개 파일 업로드 완료${failCount > 0 ? ` (${failCount}개 실패)` : ''} - 전체 진행률: 100%`;
|
| 1209 |
fileUploadStatus.className = 'file-upload-status success';
|
| 1210 |
showAlert(`${successCount}개 파일이 성공적으로 업로드되었습니다.${failCount > 0 ? ` (${failCount}개 실패)` : ''}`, 'success');
|
| 1211 |
loadFiles();
|
| 1212 |
} else {
|
| 1213 |
+
fileUploadStatus.textContent = `❌ 모든 파일 업로드 실패 - 전체 진행률: 100%`;
|
| 1214 |
fileUploadStatus.className = 'file-upload-status error';
|
| 1215 |
const errorDetails = errors.length > 0 ? '\n\n오류 상세:\n' + errors.slice(0, 5).map((e, idx) => `${idx + 1}. ${e}`).join('\n') + (errors.length > 5 ? `\n... 외 ${errors.length - 5}개 오류` : '') : '';
|
| 1216 |
showAlert(`파일 업로드에 실패했습니다.${errorDetails}`, 'error');
|
|
|
|
| 1228 |
console.error('[handleFileUpload] 완료 처리 오류:', finalError);
|
| 1229 |
}
|
| 1230 |
|
| 1231 |
+
// 업로드가 완전히 끝날 때까지 진행 상태 유지 (자동 숨김 제거)
|
| 1232 |
+
// 사용자가 수동으로 닫을 수 있도록 버튼 추가는 선택사항
|
| 1233 |
+
// 진행 상태는 업로드 완료 후에도 계속 표시됨
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1234 |
}
|
| 1235 |
|
| 1236 |
// 파일 삭제
|
|
|
|
| 1552 |
}
|
| 1553 |
});
|
| 1554 |
|
| 1555 |
+
// 페이지 로드 시 초기화 (한 번만 실행)
|
| 1556 |
+
let initialized = false;
|
| 1557 |
+
function initializePage() {
|
| 1558 |
+
if (initialized) {
|
| 1559 |
+
console.log('[초기화] 이미 초기화되었습니다.');
|
| 1560 |
+
return;
|
| 1561 |
+
}
|
| 1562 |
+
|
| 1563 |
+
// DOM 요소 존재 확인
|
| 1564 |
+
if (!fileModelSelect || !filesTableBody) {
|
| 1565 |
+
console.error('[초기화] 필수 DOM 요소가 없습니다:', {
|
| 1566 |
+
fileModelSelect: !!fileModelSelect,
|
| 1567 |
+
filesTableBody: !!filesTableBody
|
| 1568 |
+
});
|
| 1569 |
+
return;
|
| 1570 |
+
}
|
| 1571 |
+
|
| 1572 |
+
initialized = true;
|
| 1573 |
+
console.log('[초기화] 페이지 초기화 시작');
|
| 1574 |
+
|
| 1575 |
+
// 모델 목록 로드
|
| 1576 |
+
loadModelsForFiles().catch(error => {
|
| 1577 |
+
console.error('[초기화] 모델 목록 로드 실패:', error);
|
| 1578 |
+
initialized = false; // 실패 시 재시도 가능하도록
|
| 1579 |
+
});
|
| 1580 |
+
|
| 1581 |
+
// 파일 목록 로드
|
| 1582 |
+
loadFiles().catch(error => {
|
| 1583 |
+
console.error('[초기화] 파일 목록 로드 실패:', error);
|
| 1584 |
+
initialized = false; // 실패 시 재시도 가능하도록
|
| 1585 |
+
});
|
| 1586 |
+
}
|
| 1587 |
+
|
| 1588 |
+
// DOMContentLoaded 또는 load 이벤트에서 초기화
|
| 1589 |
+
if (document.readyState === 'loading') {
|
| 1590 |
+
document.addEventListener('DOMContentLoaded', initializePage);
|
| 1591 |
+
} else {
|
| 1592 |
+
// 이미 로드된 경우 즉시 실행
|
| 1593 |
+
initializePage();
|
| 1594 |
+
}
|
| 1595 |
+
|
| 1596 |
+
// 추가 안전장치: load 이벤트에서도 시도 (이미 초기화되었으면 무시됨)
|
| 1597 |
+
window.addEventListener('load', initializePage);
|
| 1598 |
</script>
|
| 1599 |
</body>
|
| 1600 |
</html>
|
templates/index.html
CHANGED
|
@@ -88,6 +88,7 @@
|
|
| 88 |
padding: 12px;
|
| 89 |
}
|
| 90 |
|
|
|
|
| 91 |
.sidebar.collapsed .model-selector-label,
|
| 92 |
.sidebar.collapsed .model-select,
|
| 93 |
.sidebar.collapsed .model-status,
|
|
@@ -258,6 +259,21 @@
|
|
| 258 |
}
|
| 259 |
|
| 260 |
/* AI 모델 선택 영역 */
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 261 |
.model-selector {
|
| 262 |
border-top: 1px solid var(--border);
|
| 263 |
padding: 16px;
|
|
@@ -733,6 +749,11 @@
|
|
| 733 |
flex-direction: row-reverse;
|
| 734 |
}
|
| 735 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 736 |
.message-avatar {
|
| 737 |
width: 32px;
|
| 738 |
height: 32px;
|
|
@@ -744,6 +765,13 @@
|
|
| 744 |
flex-shrink: 0;
|
| 745 |
}
|
| 746 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 747 |
.message.user .message-avatar {
|
| 748 |
background: var(--accent);
|
| 749 |
color: white;
|
|
@@ -771,7 +799,7 @@
|
|
| 771 |
}
|
| 772 |
|
| 773 |
.message.ai .message-bubble {
|
| 774 |
-
white-space:
|
| 775 |
}
|
| 776 |
|
| 777 |
.message.ai .message-bubble br {
|
|
@@ -1216,13 +1244,26 @@
|
|
| 1216 |
<!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
|
| 1217 |
</div>
|
| 1218 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1219 |
<!-- AI 모델 선택 영역 -->
|
| 1220 |
<div class="model-selector">
|
| 1221 |
<div class="model-selector-label">
|
| 1222 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1223 |
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 1224 |
</svg>
|
| 1225 |
-
|
| 1226 |
</div>
|
| 1227 |
<select class="model-select" id="modelSelect">
|
| 1228 |
<option value="">모델을 선택하세요...</option>
|
|
@@ -1419,20 +1460,32 @@
|
|
| 1419 |
let currentChatId = null;
|
| 1420 |
let currentSessionId = null;
|
| 1421 |
let chatSessions = [];
|
| 1422 |
-
let selectedModel = localStorage.getItem('selectedModel') || '';
|
|
|
|
| 1423 |
let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]');
|
| 1424 |
|
| 1425 |
const novelList = document.getElementById('novelList');
|
| 1426 |
const selectedNovelsInfo = document.getElementById('selectedNovelsInfo');
|
| 1427 |
|
| 1428 |
-
// 모델 선택 이벤트
|
| 1429 |
modelSelect.addEventListener('change', function() {
|
| 1430 |
selectedModel = this.value;
|
| 1431 |
localStorage.setItem('selectedModel', selectedModel);
|
| 1432 |
-
updateModelStatus();
|
| 1433 |
loadNovels(); // 모델 변경 시 웹소설 목록 로드
|
| 1434 |
});
|
| 1435 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1436 |
// 웹소설 목록 로드
|
| 1437 |
async function loadNovels() {
|
| 1438 |
try {
|
|
@@ -1545,7 +1598,78 @@
|
|
| 1545 |
}
|
| 1546 |
}
|
| 1547 |
|
| 1548 |
-
//
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1549 |
async function loadModels() {
|
| 1550 |
refreshModelsBtn.disabled = true;
|
| 1551 |
refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 로딩 중...';
|
|
@@ -1557,21 +1681,61 @@
|
|
| 1557 |
modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
| 1558 |
|
| 1559 |
if (data.models && data.models.length > 0) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1560 |
data.models.forEach(model => {
|
| 1561 |
-
|
| 1562 |
-
|
| 1563 |
-
|
| 1564 |
-
|
| 1565 |
-
option.selected = true;
|
| 1566 |
}
|
| 1567 |
-
modelSelect.appendChild(option);
|
| 1568 |
});
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1569 |
updateModelStatus('connected');
|
| 1570 |
} else {
|
| 1571 |
updateModelStatus('error', '사용 가능한 모델이 없습니다');
|
| 1572 |
}
|
| 1573 |
} catch (error) {
|
| 1574 |
-
updateModelStatus('error', '
|
| 1575 |
console.error('모델 로드 오류:', error);
|
| 1576 |
} finally {
|
| 1577 |
refreshModelsBtn.disabled = false;
|
|
@@ -1584,12 +1748,18 @@
|
|
| 1584 |
modelStatus.className = 'model-status';
|
| 1585 |
if (status === 'connected') {
|
| 1586 |
modelStatus.classList.add('connected');
|
| 1587 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1588 |
} else if (status === 'error') {
|
| 1589 |
modelStatus.classList.add('error');
|
| 1590 |
modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || '오류'}</span>`;
|
| 1591 |
} else {
|
| 1592 |
-
modelStatus.innerHTML = '<span class="model-status-dot"></span><span
|
| 1593 |
}
|
| 1594 |
}
|
| 1595 |
|
|
@@ -1639,8 +1809,18 @@
|
|
| 1639 |
|
| 1640 |
try {
|
| 1641 |
const response = await fetch('/api/chat/sessions');
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1642 |
const data = await response.json();
|
| 1643 |
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1644 |
chatHistory.innerHTML = '';
|
| 1645 |
chatSessions = data.sessions || [];
|
| 1646 |
|
|
@@ -1666,7 +1846,7 @@
|
|
| 1666 |
<svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1667 |
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
| 1668 |
</svg>
|
| 1669 |
-
<div class="chat-item-title">${session.title || '새 대화'}</div>
|
| 1670 |
<div class="chat-item-time">${formatTime(session.updated_at)}</div>
|
| 1671 |
`;
|
| 1672 |
|
|
@@ -1675,7 +1855,7 @@
|
|
| 1675 |
});
|
| 1676 |
} catch (error) {
|
| 1677 |
console.error('대화 히스토리 로드 오류:', error);
|
| 1678 |
-
chatHistory.innerHTML =
|
| 1679 |
}
|
| 1680 |
}
|
| 1681 |
|
|
@@ -1699,19 +1879,54 @@
|
|
| 1699 |
async function loadChat(sessionId) {
|
| 1700 |
try {
|
| 1701 |
const response = await fetch(`/api/chat/sessions/${sessionId}`);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1702 |
const data = await response.json();
|
| 1703 |
|
| 1704 |
-
if (!data.session)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1705 |
|
| 1706 |
currentSessionId = sessionId;
|
| 1707 |
currentChatId = sessionId;
|
| 1708 |
chatContainer.innerHTML = '';
|
| 1709 |
|
|
|
|
|
|
|
|
|
|
| 1710 |
if (data.session.messages && data.session.messages.length > 0) {
|
| 1711 |
-
data.session.messages.
|
| 1712 |
-
|
| 1713 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1714 |
} else {
|
|
|
|
| 1715 |
if (emptyState) {
|
| 1716 |
emptyState.style.display = 'flex';
|
| 1717 |
}
|
|
@@ -1723,7 +1938,11 @@
|
|
| 1723 |
}
|
| 1724 |
} catch (error) {
|
| 1725 |
console.error('대화 로드 오류:', error);
|
| 1726 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1727 |
}
|
| 1728 |
}
|
| 1729 |
|
|
@@ -1737,7 +1956,9 @@
|
|
| 1737 |
},
|
| 1738 |
body: JSON.stringify({
|
| 1739 |
title: '새 대화',
|
| 1740 |
-
model_name: selectedModel || null
|
|
|
|
|
|
|
| 1741 |
})
|
| 1742 |
});
|
| 1743 |
|
|
@@ -1879,8 +2100,27 @@
|
|
| 1879 |
function formatContentWithFootnotes(content) {
|
| 1880 |
if (!content) return { formattedContent: '', footnotes: [] };
|
| 1881 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1882 |
// 먼저 마크다운을 HTML로 변환
|
| 1883 |
-
let htmlContent = markdownToHtml(
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1884 |
|
| 1885 |
// [근거: 내용] 패턴 찾기 (마크다운 변환 후에 처리)
|
| 1886 |
const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
|
|
@@ -1889,8 +2129,12 @@
|
|
| 1889 |
|
| 1890 |
// HTML에서 [근거: ] 패턴을 찾아서 각주로 변환
|
| 1891 |
htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
|
| 1892 |
-
footnoteIndex++;
|
| 1893 |
const cleanText = footnoteText.trim();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1894 |
footnotes.push(cleanText);
|
| 1895 |
// 직접 HTML로 변환 (플레이스홀더 사용하지 않음)
|
| 1896 |
return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
|
|
@@ -1908,6 +2152,18 @@
|
|
| 1908 |
|
| 1909 |
// 메시지 추가
|
| 1910 |
function addMessage(role, content, save = true) {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1911 |
// 빈 상태 숨기기
|
| 1912 |
if (emptyState) {
|
| 1913 |
emptyState.style.display = 'none';
|
|
@@ -1923,11 +2179,30 @@
|
|
| 1923 |
const contentDiv = document.createElement('div');
|
| 1924 |
contentDiv.className = 'message-content';
|
| 1925 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1926 |
const bubble = document.createElement('div');
|
| 1927 |
bubble.className = 'message-bubble';
|
| 1928 |
|
| 1929 |
// AI 응답인 경우 [근거: ] 형식을 각주로 변환
|
| 1930 |
if (role === 'ai') {
|
|
|
|
|
|
|
|
|
|
| 1931 |
const { formattedContent, footnotes } = formatContentWithFootnotes(content);
|
| 1932 |
bubble.innerHTML = formattedContent;
|
| 1933 |
|
|
@@ -1965,33 +2240,109 @@
|
|
| 1965 |
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 1966 |
}
|
| 1967 |
|
| 1968 |
-
//
|
| 1969 |
-
function
|
| 1970 |
-
const
|
| 1971 |
-
|
| 1972 |
-
|
| 1973 |
-
|
| 1974 |
-
const
|
| 1975 |
-
|
| 1976 |
-
|
| 1977 |
-
|
| 1978 |
-
|
| 1979 |
-
|
| 1980 |
-
|
| 1981 |
-
|
| 1982 |
-
|
| 1983 |
-
|
| 1984 |
-
|
| 1985 |
-
|
| 1986 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1987 |
}
|
| 1988 |
-
|
| 1989 |
-
|
| 1990 |
-
|
| 1991 |
-
|
| 1992 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1993 |
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 1994 |
}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1995 |
|
| 1996 |
// 타이핑 인디케이터 제거
|
| 1997 |
function removeTypingIndicator() {
|
|
@@ -2006,6 +2357,12 @@
|
|
| 2006 |
const message = messageInput.value.trim();
|
| 2007 |
if (!message) return;
|
| 2008 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2009 |
// 세션이 없으면 새로 생성
|
| 2010 |
if (!currentSessionId) {
|
| 2011 |
currentSessionId = await createNewSession();
|
|
@@ -2021,11 +2378,42 @@
|
|
| 2021 |
messageInput.disabled = true;
|
| 2022 |
sendButton.disabled = true;
|
| 2023 |
|
| 2024 |
-
//
|
| 2025 |
-
showTypingIndicator();
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2026 |
|
| 2027 |
try {
|
| 2028 |
// API 호출
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2029 |
const response = await fetch('/api/chat', {
|
| 2030 |
method: 'POST',
|
| 2031 |
headers: {
|
|
@@ -2033,18 +2421,38 @@
|
|
| 2033 |
},
|
| 2034 |
body: JSON.stringify({
|
| 2035 |
message: message,
|
| 2036 |
-
|
|
|
|
| 2037 |
file_ids: selectedFileIds.length > 0 ? selectedFileIds : [],
|
| 2038 |
session_id: currentSessionId
|
| 2039 |
})
|
| 2040 |
});
|
|
|
|
|
|
|
| 2041 |
|
| 2042 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2043 |
|
| 2044 |
if (response.ok) {
|
| 2045 |
const data = await response.json();
|
| 2046 |
-
|
| 2047 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2048 |
|
| 2049 |
// DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
|
| 2050 |
// 세션 목록 새로고침 (제목 업데이트 반영)
|
|
@@ -2057,6 +2465,7 @@
|
|
| 2057 |
}
|
| 2058 |
} else {
|
| 2059 |
const error = await response.json().catch(() => ({ error: '서버 오류' }));
|
|
|
|
| 2060 |
addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
|
| 2061 |
}
|
| 2062 |
} catch (error) {
|
|
@@ -2391,9 +2800,14 @@
|
|
| 2391 |
// 페이지 로드 시 초기화
|
| 2392 |
window.addEventListener('load', async () => {
|
| 2393 |
await loadChatHistory();
|
| 2394 |
-
await
|
|
|
|
| 2395 |
// 모델 선택 여부와 관계없이 웹소설 목록 로드 (모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일)
|
| 2396 |
loadNovels();
|
|
|
|
|
|
|
|
|
|
|
|
|
| 2397 |
messageInput.focus();
|
| 2398 |
});
|
| 2399 |
</script>
|
|
|
|
| 88 |
padding: 12px;
|
| 89 |
}
|
| 90 |
|
| 91 |
+
.sidebar.collapsed .available-models-list,
|
| 92 |
.sidebar.collapsed .model-selector-label,
|
| 93 |
.sidebar.collapsed .model-select,
|
| 94 |
.sidebar.collapsed .model-status,
|
|
|
|
| 259 |
}
|
| 260 |
|
| 261 |
/* AI 모델 선택 영역 */
|
| 262 |
+
.available-models-list {
|
| 263 |
+
border-top: 1px solid var(--border);
|
| 264 |
+
padding: 16px;
|
| 265 |
+
background: var(--bg-primary);
|
| 266 |
+
}
|
| 267 |
+
|
| 268 |
+
.available-models-list .model-select {
|
| 269 |
+
margin-top: 8px;
|
| 270 |
+
}
|
| 271 |
+
|
| 272 |
+
.available-models-list .model-select:disabled {
|
| 273 |
+
opacity: 0.6;
|
| 274 |
+
cursor: not-allowed;
|
| 275 |
+
}
|
| 276 |
+
|
| 277 |
.model-selector {
|
| 278 |
border-top: 1px solid var(--border);
|
| 279 |
padding: 16px;
|
|
|
|
| 749 |
flex-direction: row-reverse;
|
| 750 |
}
|
| 751 |
|
| 752 |
+
.message.ai {
|
| 753 |
+
flex-direction: column;
|
| 754 |
+
align-items: flex-start;
|
| 755 |
+
}
|
| 756 |
+
|
| 757 |
.message-avatar {
|
| 758 |
width: 32px;
|
| 759 |
height: 32px;
|
|
|
|
| 765 |
flex-shrink: 0;
|
| 766 |
}
|
| 767 |
|
| 768 |
+
.message.ai .message-avatar {
|
| 769 |
+
width: 24px;
|
| 770 |
+
height: 24px;
|
| 771 |
+
font-size: 14px;
|
| 772 |
+
margin-bottom: 8px;
|
| 773 |
+
}
|
| 774 |
+
|
| 775 |
.message.user .message-avatar {
|
| 776 |
background: var(--accent);
|
| 777 |
color: white;
|
|
|
|
| 799 |
}
|
| 800 |
|
| 801 |
.message.ai .message-bubble {
|
| 802 |
+
white-space: pre-wrap; /* 줄바꿈 보존 */
|
| 803 |
}
|
| 804 |
|
| 805 |
.message.ai .message-bubble br {
|
|
|
|
| 1244 |
<!-- 대화 히스토리 항목들이 여기에 동적으로 추가됩니다 -->
|
| 1245 |
</div>
|
| 1246 |
|
| 1247 |
+
<!-- 사용 가능한 AI 목록 -->
|
| 1248 |
+
<div class="available-models-list">
|
| 1249 |
+
<div class="model-selector-label">
|
| 1250 |
+
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1251 |
+
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 1252 |
+
</svg>
|
| 1253 |
+
사용 가능한 AI 목록
|
| 1254 |
+
</div>
|
| 1255 |
+
<select class="model-select" id="availableModelsSelect">
|
| 1256 |
+
<option value="">AI 목록을 불러오는 중...</option>
|
| 1257 |
+
</select>
|
| 1258 |
+
</div>
|
| 1259 |
+
|
| 1260 |
<!-- AI 모델 선택 영역 -->
|
| 1261 |
<div class="model-selector">
|
| 1262 |
<div class="model-selector-label">
|
| 1263 |
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1264 |
<path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
|
| 1265 |
</svg>
|
| 1266 |
+
AI 모델 선택
|
| 1267 |
</div>
|
| 1268 |
<select class="model-select" id="modelSelect">
|
| 1269 |
<option value="">모델을 선택하세요...</option>
|
|
|
|
| 1460 |
let currentChatId = null;
|
| 1461 |
let currentSessionId = null;
|
| 1462 |
let chatSessions = [];
|
| 1463 |
+
let selectedModel = localStorage.getItem('selectedModel') || ''; // 질문 분석용 모델
|
| 1464 |
+
let answerModel = localStorage.getItem('answerModel') || ''; // 최종 답변용 모델
|
| 1465 |
let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]');
|
| 1466 |
|
| 1467 |
const novelList = document.getElementById('novelList');
|
| 1468 |
const selectedNovelsInfo = document.getElementById('selectedNovelsInfo');
|
| 1469 |
|
| 1470 |
+
// 모델 선택 이벤트 (질문 분석용)
|
| 1471 |
modelSelect.addEventListener('change', function() {
|
| 1472 |
selectedModel = this.value;
|
| 1473 |
localStorage.setItem('selectedModel', selectedModel);
|
| 1474 |
+
updateModelStatus('connected');
|
| 1475 |
loadNovels(); // 모델 변경 시 웹소설 목록 로드
|
| 1476 |
});
|
| 1477 |
|
| 1478 |
+
// 사용 가능한 AI 목록 선택 이벤트 (최종 답변용)
|
| 1479 |
+
const availableModelsSelect = document.getElementById('availableModelsSelect');
|
| 1480 |
+
availableModelsSelect.addEventListener('change', function() {
|
| 1481 |
+
answerModel = this.value;
|
| 1482 |
+
localStorage.setItem('answerModel', answerModel);
|
| 1483 |
+
// 선택된 모델이 있으면 콘솔에 로그 출력 (디버깅용)
|
| 1484 |
+
if (answerModel) {
|
| 1485 |
+
console.log('[답변 모델 선택]', answerModel);
|
| 1486 |
+
}
|
| 1487 |
+
});
|
| 1488 |
+
|
| 1489 |
// 웹소설 목록 로드
|
| 1490 |
async function loadNovels() {
|
| 1491 |
try {
|
|
|
|
| 1598 |
}
|
| 1599 |
}
|
| 1600 |
|
| 1601 |
+
// 사용 가능한 AI 목록 로드 (모든 모델)
|
| 1602 |
+
async function loadAvailableModels() {
|
| 1603 |
+
const availableModelsSelect = document.getElementById('availableModelsSelect');
|
| 1604 |
+
availableModelsSelect.disabled = true;
|
| 1605 |
+
availableModelsSelect.innerHTML = '<option value="">AI 목록을 불러오는 중...</option>';
|
| 1606 |
+
|
| 1607 |
+
try {
|
| 1608 |
+
const response = await fetch('/api/ollama/models?all=true');
|
| 1609 |
+
const data = await response.json();
|
| 1610 |
+
|
| 1611 |
+
availableModelsSelect.innerHTML = '<option value="">답변 생성할 AI 모델을 선택하세요...</option>';
|
| 1612 |
+
|
| 1613 |
+
if (data.models && data.models.length > 0) {
|
| 1614 |
+
// 모델을 타입별로 그룹화
|
| 1615 |
+
const ollamaModels = [];
|
| 1616 |
+
const geminiModels = [];
|
| 1617 |
+
|
| 1618 |
+
data.models.forEach(model => {
|
| 1619 |
+
if (model.type === 'gemini') {
|
| 1620 |
+
geminiModels.push(model);
|
| 1621 |
+
} else {
|
| 1622 |
+
ollamaModels.push(model);
|
| 1623 |
+
}
|
| 1624 |
+
});
|
| 1625 |
+
|
| 1626 |
+
// Ollama 모델 그룹
|
| 1627 |
+
if (ollamaModels.length > 0) {
|
| 1628 |
+
const optgroup = document.createElement('optgroup');
|
| 1629 |
+
optgroup.label = '🤖 Ollama 모델';
|
| 1630 |
+
ollamaModels.forEach(model => {
|
| 1631 |
+
const option = document.createElement('option');
|
| 1632 |
+
option.value = model.name;
|
| 1633 |
+
option.textContent = model.name;
|
| 1634 |
+
if (model.name === answerModel) {
|
| 1635 |
+
option.selected = true;
|
| 1636 |
+
}
|
| 1637 |
+
optgroup.appendChild(option);
|
| 1638 |
+
});
|
| 1639 |
+
availableModelsSelect.appendChild(optgroup);
|
| 1640 |
+
}
|
| 1641 |
+
|
| 1642 |
+
// Gemini 모델 그룹
|
| 1643 |
+
if (geminiModels.length > 0) {
|
| 1644 |
+
const optgroup = document.createElement('optgroup');
|
| 1645 |
+
optgroup.label = '✨ Gemini 모델';
|
| 1646 |
+
geminiModels.forEach(model => {
|
| 1647 |
+
const option = document.createElement('option');
|
| 1648 |
+
option.value = model.name;
|
| 1649 |
+
// "gemini:" 접두사 제거하여 표시
|
| 1650 |
+
const displayName = model.name.startsWith('gemini:')
|
| 1651 |
+
? model.name.substring(7)
|
| 1652 |
+
: model.name;
|
| 1653 |
+
option.textContent = displayName;
|
| 1654 |
+
if (model.name === answerModel) {
|
| 1655 |
+
option.selected = true;
|
| 1656 |
+
}
|
| 1657 |
+
optgroup.appendChild(option);
|
| 1658 |
+
});
|
| 1659 |
+
availableModelsSelect.appendChild(optgroup);
|
| 1660 |
+
}
|
| 1661 |
+
|
| 1662 |
+
availableModelsSelect.disabled = false;
|
| 1663 |
+
} else {
|
| 1664 |
+
availableModelsSelect.innerHTML = '<option value="">등록된 AI 모델이 없습니다</option>';
|
| 1665 |
+
}
|
| 1666 |
+
} catch (error) {
|
| 1667 |
+
availableModelsSelect.innerHTML = '<option value="">모델 목록 로드 실패</option>';
|
| 1668 |
+
console.error('사용 가능한 모델 로드 오류:', error);
|
| 1669 |
+
}
|
| 1670 |
+
}
|
| 1671 |
+
|
| 1672 |
+
// 모델 목록 로드 (학습된 웹소설이 있는 모델만)
|
| 1673 |
async function loadModels() {
|
| 1674 |
refreshModelsBtn.disabled = true;
|
| 1675 |
refreshModelsBtn.innerHTML = '<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M23 4v6h-6M1 20v-6h6M3.51 9a9 9 0 0 1 14.85-3.36L23 10M1 14l4.64 4.36A9 9 0 0 0 20.49 15"/></svg> 로딩 중...';
|
|
|
|
| 1681 |
modelSelect.innerHTML = '<option value="">모델을 선택하세요...</option>';
|
| 1682 |
|
| 1683 |
if (data.models && data.models.length > 0) {
|
| 1684 |
+
// 모델을 타입별로 그룹화
|
| 1685 |
+
const ollamaModels = [];
|
| 1686 |
+
const geminiModels = [];
|
| 1687 |
+
|
| 1688 |
data.models.forEach(model => {
|
| 1689 |
+
if (model.type === 'gemini') {
|
| 1690 |
+
geminiModels.push(model);
|
| 1691 |
+
} else {
|
| 1692 |
+
ollamaModels.push(model);
|
|
|
|
| 1693 |
}
|
|
|
|
| 1694 |
});
|
| 1695 |
+
|
| 1696 |
+
// 드롭다운에 추가
|
| 1697 |
+
// Ollama 모델 그룹
|
| 1698 |
+
if (ollamaModels.length > 0) {
|
| 1699 |
+
const optgroup = document.createElement('optgroup');
|
| 1700 |
+
optgroup.label = '🤖 Ollama 모델';
|
| 1701 |
+
ollamaModels.forEach(model => {
|
| 1702 |
+
const option = document.createElement('option');
|
| 1703 |
+
option.value = model.name;
|
| 1704 |
+
option.textContent = model.name;
|
| 1705 |
+
if (model.name === selectedModel) {
|
| 1706 |
+
option.selected = true;
|
| 1707 |
+
}
|
| 1708 |
+
optgroup.appendChild(option);
|
| 1709 |
+
});
|
| 1710 |
+
modelSelect.appendChild(optgroup);
|
| 1711 |
+
}
|
| 1712 |
+
|
| 1713 |
+
// Gemini 모델 그룹
|
| 1714 |
+
if (geminiModels.length > 0) {
|
| 1715 |
+
const optgroup = document.createElement('optgroup');
|
| 1716 |
+
optgroup.label = '✨ Gemini 모델';
|
| 1717 |
+
geminiModels.forEach(model => {
|
| 1718 |
+
const option = document.createElement('option');
|
| 1719 |
+
option.value = model.name;
|
| 1720 |
+
// "gemini:" 접두사 제거하여 표시
|
| 1721 |
+
const displayName = model.name.startsWith('gemini:')
|
| 1722 |
+
? model.name.substring(7)
|
| 1723 |
+
: model.name;
|
| 1724 |
+
option.textContent = displayName;
|
| 1725 |
+
if (model.name === selectedModel) {
|
| 1726 |
+
option.selected = true;
|
| 1727 |
+
}
|
| 1728 |
+
optgroup.appendChild(option);
|
| 1729 |
+
});
|
| 1730 |
+
modelSelect.appendChild(optgroup);
|
| 1731 |
+
}
|
| 1732 |
+
|
| 1733 |
updateModelStatus('connected');
|
| 1734 |
} else {
|
| 1735 |
updateModelStatus('error', '사용 가능한 모델이 없습니다');
|
| 1736 |
}
|
| 1737 |
} catch (error) {
|
| 1738 |
+
updateModelStatus('error', '모델 목록 로드 실패');
|
| 1739 |
console.error('모델 로드 오류:', error);
|
| 1740 |
} finally {
|
| 1741 |
refreshModelsBtn.disabled = false;
|
|
|
|
| 1748 |
modelStatus.className = 'model-status';
|
| 1749 |
if (status === 'connected') {
|
| 1750 |
modelStatus.classList.add('connected');
|
| 1751 |
+
// 선택된 모델 타입에 따라 메시지 변경
|
| 1752 |
+
if (selectedModel) {
|
| 1753 |
+
const modelType = selectedModel.toLowerCase().startsWith('gemini') ? 'Gemini' : 'Ollama';
|
| 1754 |
+
modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${modelType} 모델 선택됨</span>`;
|
| 1755 |
+
} else {
|
| 1756 |
+
modelStatus.innerHTML = '<span class="model-status-dot"></span><span>모델 선택 가능</span>';
|
| 1757 |
+
}
|
| 1758 |
} else if (status === 'error') {
|
| 1759 |
modelStatus.classList.add('error');
|
| 1760 |
modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || '오류'}</span>`;
|
| 1761 |
} else {
|
| 1762 |
+
modelStatus.innerHTML = '<span class="model-status-dot"></span><span>모델을 선택하세요</span>';
|
| 1763 |
}
|
| 1764 |
}
|
| 1765 |
|
|
|
|
| 1809 |
|
| 1810 |
try {
|
| 1811 |
const response = await fetch('/api/chat/sessions');
|
| 1812 |
+
|
| 1813 |
+
if (!response.ok) {
|
| 1814 |
+
const errorData = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 1815 |
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
| 1816 |
+
}
|
| 1817 |
+
|
| 1818 |
const data = await response.json();
|
| 1819 |
|
| 1820 |
+
if (data.error) {
|
| 1821 |
+
throw new Error(data.error);
|
| 1822 |
+
}
|
| 1823 |
+
|
| 1824 |
chatHistory.innerHTML = '';
|
| 1825 |
chatSessions = data.sessions || [];
|
| 1826 |
|
|
|
|
| 1846 |
<svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
| 1847 |
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
|
| 1848 |
</svg>
|
| 1849 |
+
<div class="chat-item-title">${escapeHtml(session.title || '새 대화')}</div>
|
| 1850 |
<div class="chat-item-time">${formatTime(session.updated_at)}</div>
|
| 1851 |
`;
|
| 1852 |
|
|
|
|
| 1855 |
});
|
| 1856 |
} catch (error) {
|
| 1857 |
console.error('대화 히스토리 로드 오류:', error);
|
| 1858 |
+
chatHistory.innerHTML = `<div style="padding: 16px; text-align: center; color: #ea4335; font-size: 14px;">대화 기록을 불러올 수 없습니다<br><small>${error.message || '알 수 없는 오류'}</small></div>`;
|
| 1859 |
}
|
| 1860 |
}
|
| 1861 |
|
|
|
|
| 1879 |
async function loadChat(sessionId) {
|
| 1880 |
try {
|
| 1881 |
const response = await fetch(`/api/chat/sessions/${sessionId}`);
|
| 1882 |
+
|
| 1883 |
+
if (!response.ok) {
|
| 1884 |
+
const errorData = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 1885 |
+
if (response.status === 404) {
|
| 1886 |
+
console.warn('대화 세션을 찾을 수 없습니다:', sessionId);
|
| 1887 |
+
// 세션이 삭제되었을 수 있으므로 히스토리만 새로고침
|
| 1888 |
+
await loadChatHistory();
|
| 1889 |
+
return;
|
| 1890 |
+
}
|
| 1891 |
+
throw new Error(errorData.error || `HTTP ${response.status}`);
|
| 1892 |
+
}
|
| 1893 |
+
|
| 1894 |
const data = await response.json();
|
| 1895 |
|
| 1896 |
+
if (!data.session) {
|
| 1897 |
+
console.warn('세션 데이터가 없습니다:', sessionId);
|
| 1898 |
+
await loadChatHistory();
|
| 1899 |
+
return;
|
| 1900 |
+
}
|
| 1901 |
|
| 1902 |
currentSessionId = sessionId;
|
| 1903 |
currentChatId = sessionId;
|
| 1904 |
chatContainer.innerHTML = '';
|
| 1905 |
|
| 1906 |
+
console.log('[대화 로드] 세션 ID:', sessionId);
|
| 1907 |
+
console.log('[대화 로드] 메시지 개수:', data.session.messages ? data.session.messages.length : 0);
|
| 1908 |
+
|
| 1909 |
if (data.session.messages && data.session.messages.length > 0) {
|
| 1910 |
+
console.log('[대화 로드] 메시지 목록:', data.session.messages.map(m => ({ id: m.id, role: m.role, content_preview: m.content ? m.content.substring(0, 50) : '' })));
|
| 1911 |
+
|
| 1912 |
+
// 메시지를 순차적으로 추가
|
| 1913 |
+
for (let index = 0; index < data.session.messages.length; index++) {
|
| 1914 |
+
const msg = data.session.messages[index];
|
| 1915 |
+
console.log(`[대화 로드] 메시지 ${index + 1}/${data.session.messages.length} 추가 중:`, msg.id, msg.role, msg.content ? msg.content.substring(0, 50) : '');
|
| 1916 |
+
if (msg.content && msg.content.trim() !== '') {
|
| 1917 |
+
addMessage(msg.role, msg.content, false);
|
| 1918 |
+
} else {
|
| 1919 |
+
console.warn(`[대화 로드] 메시지 ${msg.id}의 내용이 비어있습니다.`);
|
| 1920 |
+
}
|
| 1921 |
+
}
|
| 1922 |
+
|
| 1923 |
+
// 모든 메시지 추가 후 스크롤을 맨 아래로
|
| 1924 |
+
setTimeout(() => {
|
| 1925 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 1926 |
+
console.log('[대화 로드] 모든 메시지 추가 완료, 스크롤 위치:', chatContainer.scrollTop);
|
| 1927 |
+
}, 100);
|
| 1928 |
} else {
|
| 1929 |
+
console.log('[대화 로드] 메시지가 없습니다.');
|
| 1930 |
if (emptyState) {
|
| 1931 |
emptyState.style.display = 'flex';
|
| 1932 |
}
|
|
|
|
| 1938 |
}
|
| 1939 |
} catch (error) {
|
| 1940 |
console.error('대화 로드 오류:', error);
|
| 1941 |
+
// 더 자세한 에러 메시지 표시
|
| 1942 |
+
const errorMessage = error.message || '알 수 없는 오류';
|
| 1943 |
+
console.error('에러 상세:', errorMessage);
|
| 1944 |
+
// alert 대신 콘솔에만 표시하고 히스토리 새로고침
|
| 1945 |
+
await loadChatHistory();
|
| 1946 |
}
|
| 1947 |
}
|
| 1948 |
|
|
|
|
| 1956 |
},
|
| 1957 |
body: JSON.stringify({
|
| 1958 |
title: '새 대화',
|
| 1959 |
+
model_name: selectedModel || null, // 하위 호환성
|
| 1960 |
+
analysis_model: selectedModel || null, // 질문 분석용 모델
|
| 1961 |
+
answer_model: answerModel || null // 최종 답변용 모델
|
| 1962 |
})
|
| 1963 |
});
|
| 1964 |
|
|
|
|
| 2100 |
function formatContentWithFootnotes(content) {
|
| 2101 |
if (!content) return { formattedContent: '', footnotes: [] };
|
| 2102 |
|
| 2103 |
+
// "[내용을 찾을 수 없습니다]" 관련 텍스트 제거
|
| 2104 |
+
let cleanedContent = content;
|
| 2105 |
+
// 줄바꿈은 보존하면서 제거 (줄바꿈 앞뒤 공백도 ���께 제거)
|
| 2106 |
+
cleanedContent = cleanedContent.replace(/\s*\[내용을 찾을 수 없습니다\]\s*/g, ' ');
|
| 2107 |
+
cleanedContent = cleanedContent.replace(/\s*내용을 찾을 수 없습니다\s*/g, ' ');
|
| 2108 |
+
cleanedContent = cleanedContent.replace(/\s*\[근거:\s*내용을 찾을 수 없습니다\]\s*/g, ' ');
|
| 2109 |
+
cleanedContent = cleanedContent.replace(/\s*\[근거:\s*"내용을 찾을 수 없습니다"\]\s*/g, ' ');
|
| 2110 |
+
cleanedContent = cleanedContent.replace(/\s*\[근거:\s*'내용을 찾을 수 없습니다'\]\s*/g, ' ');
|
| 2111 |
+
// 연속된 공백만 정리 (줄바꿈은 보존)
|
| 2112 |
+
cleanedContent = cleanedContent.replace(/[ \t]{2,}/g, ' '); // 탭과 공백만 정리
|
| 2113 |
+
// 줄바꿈 앞뒤의 불필요한 공백 제거 (줄바꿈은 유지)
|
| 2114 |
+
cleanedContent = cleanedContent.replace(/[ \t]+\n/g, '\n');
|
| 2115 |
+
cleanedContent = cleanedContent.replace(/\n[ \t]+/g, '\n');
|
| 2116 |
+
|
| 2117 |
// 먼저 마크다운을 HTML로 변환
|
| 2118 |
+
let htmlContent = markdownToHtml(cleanedContent);
|
| 2119 |
+
|
| 2120 |
+
// HTML에서도 "[내용을 찾을 수 없습니다]" 제거
|
| 2121 |
+
htmlContent = htmlContent.replace(/\[내용을 찾을 수 없습니다\]/g, '');
|
| 2122 |
+
htmlContent = htmlContent.replace(/내용을 찾을 수 없습니다/g, '');
|
| 2123 |
+
htmlContent = htmlContent.replace(/<[^>]*>내용을 찾을 수 없습니다<\/[^>]*>/g, '');
|
| 2124 |
|
| 2125 |
// [근거: 내용] 패턴 찾기 (마크다운 변환 후에 처리)
|
| 2126 |
const footnotePattern = /\[근거:\s*([^\]]+)\]/g;
|
|
|
|
| 2129 |
|
| 2130 |
// HTML에서 [근거: ] 패턴을 찾아서 각주로 변환
|
| 2131 |
htmlContent = htmlContent.replace(footnotePattern, (match, footnoteText) => {
|
|
|
|
| 2132 |
const cleanText = footnoteText.trim();
|
| 2133 |
+
// "내용을 찾을 수 없습니다"는 각주에 추가하지 않음
|
| 2134 |
+
if (cleanText.includes('내용을 찾을 수 없습니다')) {
|
| 2135 |
+
return '';
|
| 2136 |
+
}
|
| 2137 |
+
footnoteIndex++;
|
| 2138 |
footnotes.push(cleanText);
|
| 2139 |
// 직접 HTML로 변환 (플레이스홀더 사용하지 않음)
|
| 2140 |
return `<sup class="footnote-ref" data-footnote="${footnoteIndex}">[${footnoteIndex}]<span class="footnote-tooltip">${escapeHtml(cleanText)}</span></sup>`;
|
|
|
|
| 2152 |
|
| 2153 |
// 메시지 추가
|
| 2154 |
function addMessage(role, content, save = true) {
|
| 2155 |
+
// 내용이 없으면 추가하지 않음 (단, AI 응답인 경우 기본 메시지 표시)
|
| 2156 |
+
if (!content || (typeof content === 'string' && content.trim() === '')) {
|
| 2157 |
+
if (role === 'ai') {
|
| 2158 |
+
// AI 응답이 비어있으면 기본 메시지 표시
|
| 2159 |
+
content = '응답을 생성할 수 없었습니다. 다시 시도해주세요.';
|
| 2160 |
+
console.warn('[addMessage] AI 응답이 비어있어 기본 메시지를 표시합니다.');
|
| 2161 |
+
} else {
|
| 2162 |
+
console.warn('[addMessage] 내용이 비어있어 메시지를 추가하지 않습니다. role:', role);
|
| 2163 |
+
return;
|
| 2164 |
+
}
|
| 2165 |
+
}
|
| 2166 |
+
|
| 2167 |
// 빈 상태 숨기기
|
| 2168 |
if (emptyState) {
|
| 2169 |
emptyState.style.display = 'none';
|
|
|
|
| 2179 |
const contentDiv = document.createElement('div');
|
| 2180 |
contentDiv.className = 'message-content';
|
| 2181 |
|
| 2182 |
+
// AI 응답인 경우 AI 정보 표시
|
| 2183 |
+
if (role === 'ai') {
|
| 2184 |
+
const aiInfoDiv = document.createElement('div');
|
| 2185 |
+
aiInfoDiv.className = 'ai-info';
|
| 2186 |
+
aiInfoDiv.style.cssText = 'font-size: 12px; color: var(--text-secondary); margin-bottom: 8px; display: flex; align-items: center; gap: 6px;';
|
| 2187 |
+
|
| 2188 |
+
const modelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : 'AI';
|
| 2189 |
+
const modelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : '모델 미선택';
|
| 2190 |
+
|
| 2191 |
+
aiInfoDiv.innerHTML = `
|
| 2192 |
+
<span style="font-weight: 500;">${modelType} 모델:</span>
|
| 2193 |
+
<span>${escapeHtml(modelName)}</span>
|
| 2194 |
+
`;
|
| 2195 |
+
contentDiv.appendChild(aiInfoDiv);
|
| 2196 |
+
}
|
| 2197 |
+
|
| 2198 |
const bubble = document.createElement('div');
|
| 2199 |
bubble.className = 'message-bubble';
|
| 2200 |
|
| 2201 |
// AI 응답인 경우 [근거: ] 형식을 각주로 변환
|
| 2202 |
if (role === 'ai') {
|
| 2203 |
+
// "[내용을 찾을 수 없습니다]" 텍스트 제거는 formatContentWithFootnotes에서 처리
|
| 2204 |
+
// 여기서는 원본 내용을 그대로 전달
|
| 2205 |
+
|
| 2206 |
const { formattedContent, footnotes } = formatContentWithFootnotes(content);
|
| 2207 |
bubble.innerHTML = formattedContent;
|
| 2208 |
|
|
|
|
| 2240 |
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 2241 |
}
|
| 2242 |
|
| 2243 |
+
// AI 모델 정보 가져오기
|
| 2244 |
+
function getAIModelInfo() {
|
| 2245 |
+
const analysisModelType = selectedModel ? (selectedModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null;
|
| 2246 |
+
const analysisModelName = selectedModel ? (selectedModel.startsWith('gemini:') ? selectedModel.replace('gemini:', '') : selectedModel) : null;
|
| 2247 |
+
|
| 2248 |
+
const answerModelType = answerModel ? (answerModel.startsWith('gemini:') ? 'Gemini' : 'Ollama') : null;
|
| 2249 |
+
const answerModelName = answerModel ? (answerModel.startsWith('gemini:') ? answerModel.replace('gemini:', '') : answerModel) : null;
|
| 2250 |
+
|
| 2251 |
+
let aiInfo = '';
|
| 2252 |
+
if (analysisModelName && answerModelName) {
|
| 2253 |
+
if (analysisModelName === answerModelName) {
|
| 2254 |
+
// 같은 모델인 경우
|
| 2255 |
+
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
|
| 2256 |
+
<span style="font-weight: 500;">${analysisModelType} 모델:</span>
|
| 2257 |
+
<span>${escapeHtml(analysisModelName)}</span>
|
| 2258 |
+
</div>`;
|
| 2259 |
+
} else {
|
| 2260 |
+
// 다른 모델인 경우
|
| 2261 |
+
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; flex-direction: column; gap: 2px;">
|
| 2262 |
+
<div style="display: flex; align-items: center; gap: 6px;">
|
| 2263 |
+
<span style="font-weight: 500;">분석 모델:</span>
|
| 2264 |
+
<span>${escapeHtml(analysisModelName)} (${analysisModelType})</span>
|
| 2265 |
+
</div>
|
| 2266 |
+
<div style="display: flex; align-items: center; gap: 6px;">
|
| 2267 |
+
<span style="font-weight: 500;">답변 모델:</span>
|
| 2268 |
+
<span>${escapeHtml(answerModelName)} (${answerModelType})</span>
|
| 2269 |
+
</div>
|
| 2270 |
+
</div>`;
|
| 2271 |
+
}
|
| 2272 |
+
} else if (answerModelName) {
|
| 2273 |
+
aiInfo = `<div style="font-size: 11px; color: var(--text-secondary); margin-bottom: 6px; display: flex; align-items: center; gap: 6px;">
|
| 2274 |
+
<span style="font-weight: 500;">${answerModelType} 모델:</span>
|
| 2275 |
+
<span>${escapeHtml(answerModelName)}</span>
|
| 2276 |
+
</div>`;
|
| 2277 |
}
|
| 2278 |
+
|
| 2279 |
+
return aiInfo;
|
| 2280 |
+
}
|
| 2281 |
+
|
| 2282 |
+
// 진행 상황 표시
|
| 2283 |
+
function showTypingIndicator(step = '질문 분석 중...') {
|
| 2284 |
+
let messageDiv = document.getElementById('typingIndicator');
|
| 2285 |
+
|
| 2286 |
+
if (!messageDiv) {
|
| 2287 |
+
messageDiv = document.createElement('div');
|
| 2288 |
+
messageDiv.className = 'message ai';
|
| 2289 |
+
messageDiv.id = 'typingIndicator';
|
| 2290 |
+
|
| 2291 |
+
const avatar = document.createElement('div');
|
| 2292 |
+
avatar.className = 'message-avatar';
|
| 2293 |
+
avatar.textContent = '🤖';
|
| 2294 |
+
|
| 2295 |
+
const contentDiv = document.createElement('div');
|
| 2296 |
+
contentDiv.className = 'message-content';
|
| 2297 |
+
contentDiv.id = 'typingIndicatorContent';
|
| 2298 |
+
|
| 2299 |
+
messageDiv.appendChild(avatar);
|
| 2300 |
+
messageDiv.appendChild(contentDiv);
|
| 2301 |
+
chatContainer.appendChild(messageDiv);
|
| 2302 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 2303 |
+
}
|
| 2304 |
+
|
| 2305 |
+
const aiInfo = getAIModelInfo();
|
| 2306 |
+
const contentDiv = document.getElementById('typingIndicatorContent');
|
| 2307 |
+
contentDiv.innerHTML = `
|
| 2308 |
+
${aiInfo}
|
| 2309 |
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 2310 |
+
<div style="display: flex; align-items: center; gap: 8px;">
|
| 2311 |
+
<div class="typing-indicator" style="display: flex; gap: 4px;">
|
| 2312 |
+
<div class="typing-dot"></div>
|
| 2313 |
+
<div class="typing-dot"></div>
|
| 2314 |
+
<div class="typing-dot"></div>
|
| 2315 |
+
</div>
|
| 2316 |
+
<span style="color: var(--text-secondary); font-size: 13px;">${step}</span>
|
| 2317 |
+
</div>
|
| 2318 |
+
</div>
|
| 2319 |
+
`;
|
| 2320 |
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 2321 |
}
|
| 2322 |
+
|
| 2323 |
+
// 진행 상황 업데이트
|
| 2324 |
+
function updateTypingIndicator(step, details = '') {
|
| 2325 |
+
const contentDiv = document.getElementById('typingIndicatorContent');
|
| 2326 |
+
if (contentDiv) {
|
| 2327 |
+
const aiInfo = getAIModelInfo();
|
| 2328 |
+
const detailsHtml = details ? `<div style="font-size: 12px; color: var(--text-secondary); margin-left: 24px; margin-top: 4px;">${details}</div>` : '';
|
| 2329 |
+
contentDiv.innerHTML = `
|
| 2330 |
+
${aiInfo}
|
| 2331 |
+
<div style="display: flex; flex-direction: column; gap: 8px;">
|
| 2332 |
+
<div style="display: flex; align-items: center; gap: 8px;">
|
| 2333 |
+
<div class="typing-indicator" style="display: flex; gap: 4px;">
|
| 2334 |
+
<div class="typing-dot"></div>
|
| 2335 |
+
<div class="typing-dot"></div>
|
| 2336 |
+
<div class="typing-dot"></div>
|
| 2337 |
+
</div>
|
| 2338 |
+
<span style="color: var(--text-secondary); font-size: 13px;">${step}</span>
|
| 2339 |
+
</div>
|
| 2340 |
+
${detailsHtml}
|
| 2341 |
+
</div>
|
| 2342 |
+
`;
|
| 2343 |
+
chatContainer.scrollTop = chatContainer.scrollHeight;
|
| 2344 |
+
}
|
| 2345 |
+
}
|
| 2346 |
|
| 2347 |
// 타이핑 인디케이터 제거
|
| 2348 |
function removeTypingIndicator() {
|
|
|
|
| 2357 |
const message = messageInput.value.trim();
|
| 2358 |
if (!message) return;
|
| 2359 |
|
| 2360 |
+
// 답변용 모델이 선택되지 않았으면 경고
|
| 2361 |
+
if (!answerModel) {
|
| 2362 |
+
alert('답변을 생성할 AI 모델을 선택해주세요.\n\n"사용 가능한 AI 목록"에서 답변을 생성할 AI 모델을 선택하세요.');
|
| 2363 |
+
return;
|
| 2364 |
+
}
|
| 2365 |
+
|
| 2366 |
// 세션이 없으면 새로 생성
|
| 2367 |
if (!currentSessionId) {
|
| 2368 |
currentSessionId = await createNewSession();
|
|
|
|
| 2378 |
messageInput.disabled = true;
|
| 2379 |
sendButton.disabled = true;
|
| 2380 |
|
| 2381 |
+
// 진행 상황 표시 시작
|
| 2382 |
+
showTypingIndicator('질문 분석 중...');
|
| 2383 |
+
|
| 2384 |
+
// 진행 상황 단계별 표시
|
| 2385 |
+
const progressSteps = [
|
| 2386 |
+
{ step: '질문 분석 중...', details: '질문 내용을 분석하고 있습니다' },
|
| 2387 |
+
{ step: '관련 정보 검색 중...', details: '웹소설 데이터베이스에서 관련 정보를 찾고 있습니다' },
|
| 2388 |
+
{ step: '회차별 분석 조회 중...', details: '회차별 요약 정보를 불러오고 있습니다' },
|
| 2389 |
+
{ step: 'GraphRAG 데이터 조회 중...', details: '캐릭터 관계 및 사건 정보를 조회하고 있습니다' },
|
| 2390 |
+
{ step: '벡터 검색 및 리랭킹 중...', details: '관련된 구체적인 내용을 검색하고 있습니다' },
|
| 2391 |
+
{ step: '컨텍스트 구성 중...', details: '참고할 정보를 정리하고 있습니다' },
|
| 2392 |
+
{ step: 'AI 답변 생성 중...', details: '답변을 생성하고 있습니다' }
|
| 2393 |
+
];
|
| 2394 |
+
|
| 2395 |
+
let currentStepIndex = 0;
|
| 2396 |
+
const startTime = Date.now();
|
| 2397 |
+
|
| 2398 |
+
// 진행 상황 자동 업데이트 (백엔드 응답 대기 중)
|
| 2399 |
+
const progressInterval = setInterval(() => {
|
| 2400 |
+
if (currentStepIndex < progressSteps.length) {
|
| 2401 |
+
const currentStep = progressSteps[currentStepIndex];
|
| 2402 |
+
updateTypingIndicator(currentStep.step, currentStep.details);
|
| 2403 |
+
currentStepIndex++;
|
| 2404 |
+
}
|
| 2405 |
+
}, 1500); // 1.5초마다 다음 단계로
|
| 2406 |
|
| 2407 |
try {
|
| 2408 |
// API 호출
|
| 2409 |
+
console.log('[sendMessage] 요청 전송:', {
|
| 2410 |
+
message: message.substring(0, 50) + '...',
|
| 2411 |
+
analysis_model: selectedModel,
|
| 2412 |
+
answer_model: answerModel,
|
| 2413 |
+
file_ids: selectedFileIds,
|
| 2414 |
+
session_id: currentSessionId
|
| 2415 |
+
});
|
| 2416 |
+
|
| 2417 |
const response = await fetch('/api/chat', {
|
| 2418 |
method: 'POST',
|
| 2419 |
headers: {
|
|
|
|
| 2421 |
},
|
| 2422 |
body: JSON.stringify({
|
| 2423 |
message: message,
|
| 2424 |
+
analysis_model: selectedModel || null, // 질문 분석용 모델
|
| 2425 |
+
answer_model: answerModel || null, // 최종 답변용 모델
|
| 2426 |
file_ids: selectedFileIds.length > 0 ? selectedFileIds : [],
|
| 2427 |
session_id: currentSessionId
|
| 2428 |
})
|
| 2429 |
});
|
| 2430 |
+
|
| 2431 |
+
console.log('[sendMessage] 응답 상태:', response.status, response.statusText);
|
| 2432 |
|
| 2433 |
+
clearInterval(progressInterval);
|
| 2434 |
+
const elapsedTime = ((Date.now() - startTime) / 1000).toFixed(1);
|
| 2435 |
+
updateTypingIndicator('답변 수신 완료', `총 ${elapsedTime}초 소요`);
|
| 2436 |
+
|
| 2437 |
+
// 잠시 후 인디케이터 제거
|
| 2438 |
+
setTimeout(() => {
|
| 2439 |
+
removeTypingIndicator();
|
| 2440 |
+
}, 300);
|
| 2441 |
|
| 2442 |
if (response.ok) {
|
| 2443 |
const data = await response.json();
|
| 2444 |
+
console.log('[sendMessage] 응답 데이터:', data);
|
| 2445 |
+
|
| 2446 |
+
const aiResponse = data.response || data.message || '응답을 생성하는 중 오류가 발생했습니다.';
|
| 2447 |
+
console.log('[sendMessage] AI 응답:', aiResponse ? aiResponse.substring(0, 100) + '...' : '(비어있음)');
|
| 2448 |
+
|
| 2449 |
+
// 응답이 비어있거나 공백만 있어도 메시지 표시
|
| 2450 |
+
if (!aiResponse || aiResponse.trim() === '') {
|
| 2451 |
+
console.warn('[sendMessage] 응답이 비어있습니다. 기본 메시지를 표시합니다.');
|
| 2452 |
+
addMessage('ai', '응답을 생성할 수 없었습니다. 다시 시도해주세요.', false);
|
| 2453 |
+
} else {
|
| 2454 |
+
addMessage('ai', aiResponse, false);
|
| 2455 |
+
}
|
| 2456 |
|
| 2457 |
// DB에 AI 응답 저장 (이미 백엔드에서 저장됨)
|
| 2458 |
// 세션 목록 새로고침 (제목 업데이트 반영)
|
|
|
|
| 2465 |
}
|
| 2466 |
} else {
|
| 2467 |
const error = await response.json().catch(() => ({ error: '서버 오류' }));
|
| 2468 |
+
console.error('[sendMessage] 서버 오류:', error);
|
| 2469 |
addMessage('ai', `오류: ${error.error || '알 수 없는 오류가 발생했습니다.'}`, false);
|
| 2470 |
}
|
| 2471 |
} catch (error) {
|
|
|
|
| 2800 |
// 페이지 로드 시 초기화
|
| 2801 |
window.addEventListener('load', async () => {
|
| 2802 |
await loadChatHistory();
|
| 2803 |
+
await loadAvailableModels(); // 사용 가능한 AI 목록 로드 (모든 모델)
|
| 2804 |
+
await loadModels(); // AI 모델 선택 로드 (학습된 웹소설이 있는 모델만)
|
| 2805 |
// 모델 선택 여부와 관계없이 웹소설 목록 로드 (모델이 선택되어 있으면 해당 모델의 파일만, 없으면 모든 파일)
|
| 2806 |
loadNovels();
|
| 2807 |
+
// 초기 모델 상태 업데이트
|
| 2808 |
+
if (selectedModel) {
|
| 2809 |
+
updateModelStatus('connected');
|
| 2810 |
+
}
|
| 2811 |
messageInput.focus();
|
| 2812 |
});
|
| 2813 |
</script>
|