SOY NV AI commited on
Commit
9d377df
ยท
1 Parent(s): b55a977

Initial commit: SOY NV AI - Web novel training system with RAG functionality

Browse files
.gitignore CHANGED
@@ -35,3 +35,4 @@ Thumbs.db
35
  uploads/*
36
  !uploads/.gitkeep
37
 
 
 
35
  uploads/*
36
  !uploads/.gitkeep
37
 
38
+
README.md CHANGED
@@ -37,3 +37,4 @@ SOY NV AI/
37
  โ””โ”€โ”€ README.md
38
  ```
39
 
 
 
37
  โ””โ”€โ”€ README.md
38
  ```
39
 
40
+
README_SERVER.md ADDED
@@ -0,0 +1,68 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ์„œ๋ฒ„ ์‹คํ–‰ ๊ฐ€์ด๋“œ
2
+
3
+ ## ์„œ๋ฒ„ ์‹œ์ž‘ ๋ฐฉ๋ฒ•
4
+
5
+ ### ๋ฐฉ๋ฒ• 1: ์ž๋™ ์žฌ์‹œ์ž‘ ์Šคํฌ๋ฆฝํŠธ (๊ถŒ์žฅ)
6
+
7
+ **PowerShell ์‚ฌ์šฉ:**
8
+ ```powershell
9
+ .\start_server_background.ps1
10
+ ```
11
+
12
+ **๋ฐฐ์น˜ ํŒŒ์ผ ์‚ฌ์šฉ:**
13
+ ```cmd
14
+ start_server.bat
15
+ ```
16
+
17
+ ์ด ์Šคํฌ๋ฆฝํŠธ๋“ค์€ ์„œ๋ฒ„๊ฐ€ ์ข…๋ฃŒ๋˜๋ฉด ์ž๋™์œผ๋กœ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค.
18
+
19
+ ### ๋ฐฉ๋ฒ• 2: ์ง์ ‘ ์‹คํ–‰
20
+
21
+ ```bash
22
+ python run.py
23
+ ```
24
+
25
+ ## ์„œ๋ฒ„ ์ƒํƒœ ํ™•์ธ
26
+
27
+ ํฌํŠธ 5001์ด ์—ด๋ ค์žˆ๋Š”์ง€ ํ™•์ธ:
28
+ ```powershell
29
+ netstat -ano | Select-String ":5001" | Select-String "LISTENING"
30
+ ```
31
+
32
+ ## ๋ฌธ์ œ ํ•ด๊ฒฐ
33
+
34
+ ### ์„œ๋ฒ„๊ฐ€ ์‹œ์ž‘๋˜์ง€ ์•Š๋Š” ๊ฒฝ์šฐ
35
+
36
+ 1. Python์ด ์„ค์น˜๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ:
37
+ ```bash
38
+ python --version
39
+ ```
40
+
41
+ 2. ํ•„์š”ํ•œ ๋ผ์ด๋ธŒ๋Ÿฌ๋ฆฌ๊ฐ€ ์„ค์น˜๋˜์–ด ์žˆ๋Š”์ง€ ํ™•์ธ:
42
+ ```bash
43
+ pip install -r requirements.txt
44
+ ```
45
+
46
+ 3. ํฌํŠธ 5001์ด ๋‹ค๋ฅธ ํ”„๋กœ๊ทธ๋žจ์—์„œ ์‚ฌ์šฉ ์ค‘์ธ์ง€ ํ™•์ธ:
47
+ ```powershell
48
+ netstat -ano | Select-String ":5001"
49
+ ```
50
+
51
+ ### ์„œ๋ฒ„๊ฐ€ ์ž์ฃผ ์ข…๋ฃŒ๋˜๋Š” ๊ฒฝ์šฐ
52
+
53
+ - `start_server_background.ps1` ์Šคํฌ๋ฆฝํŠธ๋ฅผ ์‚ฌ์šฉํ•˜๋ฉด ์ž๋™์œผ๋กœ ์žฌ์‹œ์ž‘๋ฉ๋‹ˆ๋‹ค.
54
+ - ๋กœ๊ทธ๋Š” `server.log` ํŒŒ์ผ์— ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.
55
+
56
+ ## ๋ฐฑ๊ทธ๋ผ์šด๋“œ ์‹คํ–‰
57
+
58
+ PowerShell์—์„œ ๋ฐฑ๊ทธ๋ผ์šด๋“œ๋กœ ์‹คํ–‰ํ•˜๋ ค๋ฉด:
59
+ ```powershell
60
+ Start-Process powershell -ArgumentList "-File", "start_server_background.ps1" -WindowStyle Hidden
61
+ ```
62
+
63
+ ## ์„œ๋ฒ„ ์ค‘์ง€
64
+
65
+ ์„œ๋ฒ„๋ฅผ ์ค‘์ง€ํ•˜๋ ค๋ฉด:
66
+ 1. PowerShell ์ฐฝ์—์„œ `Ctrl+C` ๋ˆ„๋ฅด๊ธฐ
67
+ 2. ๋˜๋Š” ์ž‘์—… ๊ด€๋ฆฌ์ž์—์„œ Python ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ
68
+
app/__init__.py CHANGED
@@ -1,6 +1,18 @@
1
  from flask import Flask
2
- from app.database import db
 
3
  import os
 
 
 
 
 
 
 
 
 
 
 
4
 
5
  def create_app():
6
  # ํ…œํ”Œ๋ฆฟ ํด๋” ๊ฒฝ๋กœ ๋ช…์‹œ์  ์„ค์ •
@@ -11,12 +23,60 @@ def create_app():
11
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
12
 
13
  db.init_app(app)
 
14
 
15
  from app.routes import main_bp
16
  app.register_blueprint(main_bp)
17
 
18
  with app.app_context():
19
  db.create_all()
 
 
 
 
20
 
21
  return app
22
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
  from flask import Flask
2
+ from flask_login import LoginManager
3
+ from app.database import db, User
4
  import os
5
+ import sqlite3
6
+ from pathlib import Path
7
+
8
+ login_manager = LoginManager()
9
+ login_manager.login_view = 'main.login'
10
+ login_manager.login_message = '๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'
11
+ login_manager.login_message_category = 'info'
12
+
13
+ @login_manager.user_loader
14
+ def load_user(user_id):
15
+ return User.query.get(int(user_id))
16
 
17
  def create_app():
18
  # ํ…œํ”Œ๋ฆฟ ํด๋” ๊ฒฝ๋กœ ๋ช…์‹œ์  ์„ค์ •
 
23
  app.config['SQLALCHEMY_TRACK_MODIFICATIONS'] = False
24
 
25
  db.init_app(app)
26
+ login_manager.init_app(app)
27
 
28
  from app.routes import main_bp
29
  app.register_blueprint(main_bp)
30
 
31
  with app.app_context():
32
  db.create_all()
33
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ (nickname ์ปฌ๋Ÿผ ์ถ”๊ฐ€)
34
+ migrate_database(app)
35
+ # ์ดˆ๊ธฐ ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ
36
+ create_admin_user()
37
 
38
  return app
39
 
40
+ def migrate_database(app):
41
+ """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ - nickname ์ปฌ๋Ÿผ ์ถ”๊ฐ€"""
42
+ try:
43
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค URI์—์„œ ๊ฒฝ๋กœ ์ถ”์ถœ
44
+ db_uri = app.config['SQLALCHEMY_DATABASE_URI']
45
+ if db_uri.startswith('sqlite:///'):
46
+ db_path = db_uri.replace('sqlite:///', '')
47
+ # ์ƒ๋Œ€ ๊ฒฝ๋กœ์ธ ๊ฒฝ์šฐ instance ํด๋” ๊ธฐ์ค€์œผ๋กœ ์ฒ˜๋ฆฌ
48
+ if not os.path.isabs(db_path):
49
+ db_path = os.path.join(app.instance_path, db_path)
50
+
51
+ if not os.path.exists(db_path):
52
+ return
53
+
54
+ conn = sqlite3.connect(db_path)
55
+ cursor = conn.cursor()
56
+
57
+ # user ํ…Œ์ด๋ธ”์— nickname ์ปฌ๋Ÿผ์ด ์žˆ๋Š”์ง€ ํ™•์ธ
58
+ cursor.execute("PRAGMA table_info(user)")
59
+ columns = [column[1] for column in cursor.fetchall()]
60
+
61
+ if 'nickname' not in columns:
62
+ cursor.execute("ALTER TABLE user ADD COLUMN nickname VARCHAR(80)")
63
+ conn.commit()
64
+
65
+ conn.close()
66
+ except Exception as e:
67
+ print(f"๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์˜ค๋ฅ˜ (๋ฌด์‹œ ๊ฐ€๋Šฅ): {e}")
68
+
69
+ def create_admin_user():
70
+ """์ดˆ๊ธฐ ๊ด€๋ฆฌ์ž ๊ณ„์ • ์ƒ์„ฑ"""
71
+ admin_username = 'soymedia'
72
+ admin_password = 's0ymedi@1@34'
73
+
74
+ admin = User.query.filter_by(username=admin_username).first()
75
+ if not admin:
76
+ admin = User(username=admin_username, is_admin=True, is_active=True)
77
+ admin.set_password(admin_password)
78
+ db.session.add(admin)
79
+ db.session.commit()
80
+ print(f'๊ด€๋ฆฌ์ž ๊ณ„์ •์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค: {admin_username}')
81
+
82
+
app/database.py CHANGED
@@ -1,6 +1,120 @@
1
  from flask_sqlalchemy import SQLAlchemy
 
 
 
2
 
3
  db = SQLAlchemy()
4
 
5
- # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋ชจ๋ธ์€ ์—ฌ๊ธฐ์— ์ถ”๊ฐ€
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
6
 
 
1
  from flask_sqlalchemy import SQLAlchemy
2
+ from flask_login import UserMixin
3
+ from datetime import datetime
4
+ from werkzeug.security import generate_password_hash, check_password_hash
5
 
6
  db = SQLAlchemy()
7
 
8
+ # ์‚ฌ์šฉ์ž ๋ชจ๋ธ
9
+ class User(UserMixin, db.Model):
10
+ id = db.Column(db.Integer, primary_key=True)
11
+ username = db.Column(db.String(80), unique=True, nullable=False)
12
+ nickname = db.Column(db.String(80), nullable=True) # ๋‹‰๋„ค์ž„ ํ•„๋“œ ์ถ”๊ฐ€
13
+ password_hash = db.Column(db.String(255), nullable=False)
14
+ is_admin = db.Column(db.Boolean, default=False, nullable=False)
15
+ is_active = db.Column(db.Boolean, default=True, nullable=False)
16
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
17
+ last_login = db.Column(db.DateTime, nullable=True)
18
+
19
+ def set_password(self, password):
20
+ self.password_hash = generate_password_hash(password)
21
+
22
+ def check_password(self, password):
23
+ return check_password_hash(self.password_hash, password)
24
+
25
+ def to_dict(self):
26
+ return {
27
+ 'id': self.id,
28
+ 'username': self.username,
29
+ 'nickname': self.nickname,
30
+ 'is_admin': self.is_admin,
31
+ 'is_active': self.is_active,
32
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
33
+ 'last_login': self.last_login.isoformat() if self.last_login else None
34
+ }
35
+
36
+ # ์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์ •๋ณด ๋ชจ๋ธ
37
+ class UploadedFile(db.Model):
38
+ id = db.Column(db.Integer, primary_key=True)
39
+ filename = db.Column(db.String(255), nullable=False)
40
+ original_filename = db.Column(db.String(255), nullable=False)
41
+ file_path = db.Column(db.String(500), nullable=False)
42
+ file_size = db.Column(db.Integer, nullable=False)
43
+ model_name = db.Column(db.String(100), nullable=True) # ์—ฐ๊ฒฐ๋œ ๋ชจ๋ธ ์ด๋ฆ„
44
+ uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
45
+ uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
46
+
47
+ def to_dict(self):
48
+ return {
49
+ 'id': self.id,
50
+ 'filename': self.filename,
51
+ 'original_filename': self.original_filename,
52
+ 'file_size': self.file_size,
53
+ 'model_name': self.model_name,
54
+ 'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
55
+ 'uploaded_by': self.uploaded_by
56
+ }
57
+
58
+ # ๋Œ€ํ™” ์„ธ์…˜ ๋ชจ๋ธ
59
+ class ChatSession(db.Model):
60
+ id = db.Column(db.Integer, primary_key=True)
61
+ user_id = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=False)
62
+ title = db.Column(db.String(255), nullable=True)
63
+ model_name = db.Column(db.String(100), nullable=True)
64
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
65
+ updated_at = db.Column(db.DateTime, default=datetime.utcnow, onupdate=datetime.utcnow, nullable=False)
66
+
67
+ # ๊ด€๊ณ„
68
+ user = db.relationship('User', backref='chat_sessions')
69
+ messages = db.relationship('ChatMessage', backref='session', lazy=True, cascade='all, delete-orphan', order_by='ChatMessage.created_at')
70
+
71
+ def to_dict(self):
72
+ return {
73
+ 'id': self.id,
74
+ 'user_id': self.user_id,
75
+ 'title': self.title,
76
+ 'model_name': self.model_name,
77
+ 'created_at': self.created_at.isoformat() if self.created_at else None,
78
+ 'updated_at': self.updated_at.isoformat() if self.updated_at else None,
79
+ 'message_count': len(self.messages)
80
+ }
81
+
82
+ # ๋Œ€ํ™” ๋ฉ”์‹œ์ง€ ๋ชจ๋ธ
83
+ class ChatMessage(db.Model):
84
+ id = db.Column(db.Integer, primary_key=True)
85
+ session_id = db.Column(db.Integer, db.ForeignKey('chat_session.id'), nullable=False)
86
+ role = db.Column(db.String(20), nullable=False) # 'user' or 'ai'
87
+ content = db.Column(db.Text, nullable=False)
88
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
89
+
90
+ def to_dict(self):
91
+ return {
92
+ 'id': self.id,
93
+ 'session_id': self.session_id,
94
+ 'role': self.role,
95
+ 'content': self.content,
96
+ 'created_at': self.created_at.isoformat() if self.created_at else None
97
+ }
98
+
99
+ # ๋ฌธ์„œ ์ฒญํฌ ๋ชจ๋ธ (RAG์šฉ)
100
+ class DocumentChunk(db.Model):
101
+ id = db.Column(db.Integer, primary_key=True)
102
+ file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=False)
103
+ chunk_index = db.Column(db.Integer, nullable=False) # ์ฒญํฌ ์ˆœ์„œ
104
+ content = db.Column(db.Text, nullable=False) # ์ฒญํฌ ๋‚ด์šฉ
105
+ embedding = db.Column(db.Text, nullable=True) # ์ž„๋ฒ ๋”ฉ ๋ฒกํ„ฐ (JSON ๋ฌธ์ž์—ด๋กœ ์ €์žฅ)
106
+ created_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
107
+
108
+ # ๊ด€๊ณ„
109
+ file = db.relationship('UploadedFile', backref='chunks')
110
+
111
+ def to_dict(self):
112
+ return {
113
+ 'id': self.id,
114
+ 'file_id': self.file_id,
115
+ 'chunk_index': self.chunk_index,
116
+ 'content': self.content,
117
+ 'created_at': self.created_at.isoformat() if self.created_at else None
118
+ }
119
+
120
 
app/routes.py CHANGED
@@ -1,8 +1,804 @@
1
- from flask import Blueprint, render_template
 
 
 
 
 
 
 
 
 
2
 
3
  main_bp = Blueprint('main', __name__)
4
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
5
  @main_bp.route('/')
 
6
  def index():
7
  return render_template('index.html')
8
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ from flask import Blueprint, render_template, request, jsonify, send_from_directory, redirect, url_for, flash
2
+ from flask_login import login_user, logout_user, login_required, current_user
3
+ from werkzeug.utils import secure_filename
4
+ from app.database import db, UploadedFile, User, ChatSession, ChatMessage, DocumentChunk
5
+ import requests
6
+ import os
7
+ from datetime import datetime
8
+ import uuid
9
+ import re
10
+ import json
11
 
12
  main_bp = Blueprint('main', __name__)
13
 
14
+ def admin_required(f):
15
+ """๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•œ ๋ฐ์ฝ”๋ ˆ์ดํ„ฐ"""
16
+ from functools import wraps
17
+ @wraps(f)
18
+ @login_required
19
+ def decorated_function(*args, **kwargs):
20
+ if not current_user.is_admin:
21
+ flash('๊ด€๋ฆฌ์ž ๊ถŒํ•œ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.', 'error')
22
+ return redirect(url_for('main.index'))
23
+ return f(*args, **kwargs)
24
+ return decorated_function
25
+
26
+ # Ollama ๊ธฐ๋ณธ URL (ํ™˜๊ฒฝ ๋ณ€์ˆ˜๋กœ ์„ค์ • ๊ฐ€๋Šฅ)
27
+ OLLAMA_BASE_URL = os.getenv('OLLAMA_BASE_URL', 'http://localhost:11434')
28
+
29
+ # ์—…๋กœ๋“œ ์„ค์ •
30
+ UPLOAD_FOLDER = os.path.join(os.path.dirname(os.path.dirname(__file__)), 'uploads')
31
+ ALLOWED_EXTENSIONS = {'txt', 'md', 'pdf', 'docx', 'epub'}
32
+
33
+ def allowed_file(filename):
34
+ return '.' in filename and filename.rsplit('.', 1)[1].lower() in ALLOWED_EXTENSIONS
35
+
36
+ def ensure_upload_folder():
37
+ """์—…๋กœ๋“œ ํด๋”๊ฐ€ ์—†์œผ๋ฉด ์ƒ์„ฑ"""
38
+ if not os.path.exists(UPLOAD_FOLDER):
39
+ os.makedirs(UPLOAD_FOLDER)
40
+
41
+ def split_text_into_chunks(text, chunk_size=500, overlap=50):
42
+ """ํ…์ŠคํŠธ๋ฅผ ์ฒญํฌ๋กœ ๋ถ„ํ• """
43
+ chunks = []
44
+ start = 0
45
+ while start < len(text):
46
+ end = start + chunk_size
47
+ chunk = text[start:end]
48
+ chunks.append(chunk)
49
+ start = end - overlap # ์˜ค๋ฒ„๋žฉ์œผ๋กœ ๋ฌธ๋งฅ ์œ ์ง€
50
+ return chunks
51
+
52
+ def create_chunks_for_file(file_id, content):
53
+ """ํŒŒ์ผ ๋‚ด์šฉ์„ ์ฒญํฌ๋กœ ๋ถ„ํ• ํ•˜์—ฌ ์ €์žฅ"""
54
+ try:
55
+ # ๊ธฐ์กด ์ฒญํฌ ์‚ญ์ œ
56
+ DocumentChunk.query.filter_by(file_id=file_id).delete()
57
+
58
+ # ํ…์ŠคํŠธ๋ฅผ ์ฒญํฌ๋กœ ๋ถ„ํ• 
59
+ chunks = split_text_into_chunks(content, chunk_size=500, overlap=50)
60
+
61
+ # ๊ฐ ์ฒญํฌ๋ฅผ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ
62
+ for idx, chunk_content in enumerate(chunks):
63
+ chunk = DocumentChunk(
64
+ file_id=file_id,
65
+ chunk_index=idx,
66
+ content=chunk_content
67
+ )
68
+ db.session.add(chunk)
69
+
70
+ db.session.commit()
71
+ return len(chunks)
72
+ except Exception as e:
73
+ db.session.rollback()
74
+ print(f"์ฒญํฌ ์ƒ์„ฑ ์˜ค๋ฅ˜: {str(e)}")
75
+ return 0
76
+
77
+ def search_relevant_chunks(query, file_ids=None, model_name=None, top_k=5):
78
+ """์งˆ๋ฌธ๊ณผ ๊ด€๋ จ๋œ ์ฒญํฌ ๊ฒ€์ƒ‰ (ํ‚ค์›Œ๋“œ ๊ธฐ๋ฐ˜)"""
79
+ try:
80
+ # ๊ฒ€์ƒ‰ ์ฟผ๋ฆฌ ์ค€๋น„
81
+ query_words = set(re.findall(r'\w+', query.lower()))
82
+
83
+ # ์ฒญํฌ ์กฐํšŒ
84
+ query_obj = DocumentChunk.query.join(UploadedFile)
85
+
86
+ if file_ids:
87
+ query_obj = query_obj.filter(UploadedFile.id.in_(file_ids))
88
+
89
+ if model_name:
90
+ query_obj = query_obj.filter(UploadedFile.model_name == model_name)
91
+
92
+ all_chunks = query_obj.all()
93
+
94
+ if not all_chunks:
95
+ return []
96
+
97
+ # ๊ฐ ์ฒญํฌ์˜ ๊ด€๋ จ๋„ ์ ์ˆ˜ ๊ณ„์‚ฐ (ํ‚ค์›Œ๋“œ ๋งค์นญ)
98
+ scored_chunks = []
99
+ for chunk in all_chunks:
100
+ chunk_words = set(re.findall(r'\w+', chunk.content.lower()))
101
+ # ๊ณตํ†ต ๋‹จ์–ด ์ˆ˜๋กœ ์ ์ˆ˜ ๊ณ„์‚ฐ
102
+ score = len(query_words & chunk_words)
103
+ if score > 0:
104
+ scored_chunks.append((score, chunk))
105
+
106
+ # ์ ์ˆ˜ ์ˆœ์œผ๋กœ ์ •๋ ฌํ•˜๊ณ  ์ƒ์œ„ k๊ฐœ ์„ ํƒ
107
+ scored_chunks.sort(key=lambda x: x[0], reverse=True)
108
+ top_chunks = [chunk for _, chunk in scored_chunks[:top_k]]
109
+
110
+ return top_chunks
111
+ except Exception as e:
112
+ print(f"์ฒญํฌ ๊ฒ€์ƒ‰ ์˜ค๋ฅ˜: {str(e)}")
113
+ return []
114
+
115
+ @main_bp.route('/login', methods=['GET', 'POST'])
116
+ def login():
117
+ """๋กœ๊ทธ์ธ ํŽ˜์ด์ง€"""
118
+ if current_user.is_authenticated:
119
+ # ๊ด€๋ฆฌ์ž์ธ ๊ฒฝ์šฐ ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
120
+ if current_user.is_admin:
121
+ return redirect(url_for('main.admin'))
122
+ return redirect(url_for('main.index'))
123
+
124
+ if request.method == 'POST':
125
+ username = request.form.get('username', '').strip()
126
+ password = request.form.get('password', '')
127
+
128
+ if not username or not password:
129
+ flash('์‚ฌ์šฉ์ž๋ช…๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error')
130
+ return render_template('login.html')
131
+
132
+ user = User.query.filter_by(username=username).first()
133
+
134
+ if user and user.check_password(password) and user.is_active:
135
+ login_user(user)
136
+ user.last_login = datetime.utcnow()
137
+ db.session.commit()
138
+ next_page = request.args.get('next')
139
+ # ๊ด€๋ฆฌ์ž์ธ ๊ฒฝ์šฐ ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€๋กœ ๋ฆฌ๋‹ค์ด๋ ‰ํŠธ
140
+ if user.is_admin:
141
+ return redirect(next_page) if next_page else redirect(url_for('main.admin'))
142
+ return redirect(next_page) if next_page else redirect(url_for('main.index'))
143
+ else:
144
+ flash('์‚ฌ์šฉ์ž๋ช… ๋˜๋Š” ๋น„๋ฐ€๋ฒˆํ˜ธ๊ฐ€ ์˜ฌ๋ฐ”๋ฅด์ง€ ์•Š์Šต๋‹ˆ๋‹ค.', 'error')
145
+
146
+ return render_template('login.html')
147
+
148
+ @main_bp.route('/logout')
149
+ @login_required
150
+ def logout():
151
+ """๋กœ๊ทธ์•„์›ƒ"""
152
+ logout_user()
153
+ flash('๋กœ๊ทธ์•„์›ƒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'info')
154
+ return redirect(url_for('main.login'))
155
+
156
  @main_bp.route('/')
157
+ @login_required
158
  def index():
159
  return render_template('index.html')
160
 
161
+ @main_bp.route('/admin')
162
+ @admin_required
163
+ def admin():
164
+ """๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€"""
165
+ users = User.query.order_by(User.created_at.desc()).all()
166
+ return render_template('admin.html', users=users)
167
+
168
+ @main_bp.route('/admin/messages')
169
+ @admin_required
170
+ def admin_messages():
171
+ """๊ด€๋ฆฌ์ž ๋ฉ”์‹œ์ง€ ํ™•์ธ ํŽ˜์ด์ง€"""
172
+ return render_template('admin_messages.html')
173
+
174
+ @main_bp.route('/api/admin/users', methods=['GET'])
175
+ @admin_required
176
+ def get_users():
177
+ """์‚ฌ์šฉ์ž ๋ชฉ๋ก API"""
178
+ try:
179
+ users = User.query.order_by(User.created_at.desc()).all()
180
+ return jsonify({
181
+ 'users': [user.to_dict() for user in users]
182
+ }), 200
183
+ except Exception as e:
184
+ return jsonify({'error': f'์‚ฌ์šฉ์ž ๋ชฉ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
185
+
186
+ @main_bp.route('/api/admin/users', methods=['POST'])
187
+ @admin_required
188
+ def create_user():
189
+ """์‚ฌ์šฉ์ž ์ƒ์„ฑ API"""
190
+ try:
191
+ data = request.json
192
+ username = data.get('username', '').strip()
193
+ nickname = data.get('nickname', '').strip()
194
+ password = data.get('password', '')
195
+ is_admin = data.get('is_admin', False)
196
+
197
+ if not username or not password:
198
+ return jsonify({'error': '์‚ฌ์šฉ์ž๋ช…๊ณผ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.'}), 400
199
+
200
+ if User.query.filter_by(username=username).first():
201
+ return jsonify({'error': '์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž๋ช…์ž…๋‹ˆ๋‹ค.'}), 400
202
+
203
+ user = User(username=username, nickname=nickname if nickname else None, is_admin=is_admin, is_active=True)
204
+ user.set_password(password)
205
+ db.session.add(user)
206
+ db.session.commit()
207
+
208
+ return jsonify({
209
+ 'message': '์‚ฌ์šฉ์ž๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
210
+ 'user': user.to_dict()
211
+ }), 200
212
+
213
+ except Exception as e:
214
+ db.session.rollback()
215
+ return jsonify({'error': f'์‚ฌ์šฉ์ž ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
216
+
217
+ @main_bp.route('/api/admin/users/<int:user_id>', methods=['PUT'])
218
+ @admin_required
219
+ def update_user(user_id):
220
+ """์‚ฌ์šฉ์ž ์ •๋ณด ์ˆ˜์ • API"""
221
+ try:
222
+ user = User.query.get_or_404(user_id)
223
+ data = request.json
224
+
225
+ # ์ž๊ธฐ ์ž์‹ ์˜ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์„ ์ œ๊ฑฐํ•˜๋Š” ๊ฒƒ์€ ๋ฐฉ์ง€
226
+ if user_id == current_user.id and data.get('is_admin') == False:
227
+ return jsonify({'error': '์ž๊ธฐ ์ž์‹ ์˜ ๊ด€๋ฆฌ์ž ๊ถŒํ•œ์„ ์ œ๊ฑฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
228
+
229
+ if 'username' in data:
230
+ new_username = data['username'].strip()
231
+ if new_username != user.username:
232
+ if User.query.filter_by(username=new_username).first():
233
+ return jsonify({'error': '์ด๋ฏธ ์กด์žฌํ•˜๋Š” ์‚ฌ์šฉ์ž๋ช…์ž…๋‹ˆ๋‹ค.'}), 400
234
+ user.username = new_username
235
+
236
+ if 'nickname' in data:
237
+ user.nickname = data['nickname'].strip() if data['nickname'] else None
238
+
239
+ if 'password' in data and data['password']:
240
+ user.set_password(data['password'])
241
+
242
+ if 'is_admin' in data:
243
+ user.is_admin = data['is_admin']
244
+
245
+ if 'is_active' in data:
246
+ user.is_active = data['is_active']
247
+
248
+ db.session.commit()
249
+
250
+ return jsonify({
251
+ 'message': '์‚ฌ์šฉ์ž ์ •๋ณด๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
252
+ 'user': user.to_dict()
253
+ }), 200
254
+
255
+ except Exception as e:
256
+ db.session.rollback()
257
+ return jsonify({'error': f'์‚ฌ์šฉ์ž ์ •๋ณด ์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
258
+
259
+ @main_bp.route('/api/admin/messages', methods=['GET'])
260
+ @admin_required
261
+ def get_all_messages():
262
+ """์ „์ฒด ๋ฉ”์‹œ์ง€ ์กฐํšŒ (๊ด€๋ฆฌ์ž์šฉ)"""
263
+ try:
264
+ user_id = request.args.get('user_id', type=int)
265
+ session_id = request.args.get('session_id', type=int)
266
+ page = request.args.get('page', 1, type=int)
267
+ per_page = request.args.get('per_page', 50, type=int)
268
+
269
+ query = ChatMessage.query.join(ChatSession)
270
+
271
+ if user_id:
272
+ query = query.filter(ChatSession.user_id == user_id)
273
+ if session_id:
274
+ query = query.filter(ChatMessage.session_id == session_id)
275
+
276
+ messages = query.order_by(ChatMessage.created_at.desc())\
277
+ .paginate(page=page, per_page=per_page, error_out=False)
278
+
279
+ return jsonify({
280
+ 'messages': [msg.to_dict() for msg in messages.items],
281
+ 'total': messages.total,
282
+ 'pages': messages.pages,
283
+ 'current_page': page
284
+ }), 200
285
+
286
+ except Exception as e:
287
+ return jsonify({'error': f'๋ฉ”์‹œ์ง€ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
288
+
289
+ @main_bp.route('/api/admin/sessions', methods=['GET'])
290
+ @admin_required
291
+ def get_all_sessions():
292
+ """์ „์ฒด ๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ (๊ด€๋ฆฌ์ž์šฉ)"""
293
+ try:
294
+ user_id = request.args.get('user_id', type=int)
295
+ page = request.args.get('page', 1, type=int)
296
+ per_page = request.args.get('per_page', 50, type=int)
297
+
298
+ query = ChatSession.query
299
+
300
+ if user_id:
301
+ query = query.filter(ChatSession.user_id == user_id)
302
+
303
+ sessions = query.order_by(ChatSession.updated_at.desc())\
304
+ .paginate(page=page, per_page=per_page, error_out=False)
305
+
306
+ sessions_data = []
307
+ for session in sessions.items:
308
+ session_dict = session.to_dict()
309
+ session_dict['username'] = session.user.username if session.user else 'Unknown'
310
+ session_dict['nickname'] = session.user.nickname if session.user else None
311
+ sessions_data.append(session_dict)
312
+
313
+ return jsonify({
314
+ 'sessions': sessions_data,
315
+ 'total': sessions.total,
316
+ 'pages': sessions.pages,
317
+ 'current_page': page
318
+ }), 200
319
+
320
+ except Exception as e:
321
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
322
+
323
+ @main_bp.route('/api/admin/users/<int:user_id>', methods=['DELETE'])
324
+ @admin_required
325
+ def delete_user(user_id):
326
+ """์‚ฌ์šฉ์ž ์‚ญ์ œ API"""
327
+ try:
328
+ user = User.query.get_or_404(user_id)
329
+
330
+ # ์ž๊ธฐ ์ž์‹ ์„ ์‚ญ์ œํ•˜๋Š” ๊ฒƒ์€ ๋ฐฉ์ง€
331
+ if user_id == current_user.id:
332
+ return jsonify({'error': '์ž๊ธฐ ์ž์‹ ์„ ์‚ญ์ œํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 400
333
+
334
+ db.session.delete(user)
335
+ db.session.commit()
336
+
337
+ return jsonify({'message': '์‚ฌ์šฉ์ž๊ฐ€ ์„ฑ๊ณต์ ์œผ๋กœ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}), 200
338
+
339
+ except Exception as e:
340
+ db.session.rollback()
341
+ return jsonify({'error': f'์‚ฌ์šฉ์ž ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
342
+
343
+ @main_bp.route('/api/ollama/models', methods=['GET'])
344
+ @login_required
345
+ def get_ollama_models():
346
+ """Ollama์—์„œ ์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ ๋ชฉ๋ก ๊ฐ€์ ธ์˜ค๊ธฐ"""
347
+ try:
348
+ response = requests.get(f'{OLLAMA_BASE_URL}/api/tags', timeout=5)
349
+ if response.status_code == 200:
350
+ data = response.json()
351
+ models = [{'name': model['name']} for model in data.get('models', [])]
352
+ return jsonify({'models': models})
353
+ else:
354
+ return jsonify({'error': 'Ollama ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.', 'models': []}), 500
355
+ except requests.exceptions.ConnectionError:
356
+ return jsonify({'error': 'Ollama ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. Ollama๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”.', 'models': []}), 503
357
+ except requests.exceptions.Timeout:
358
+ return jsonify({'error': 'Ollama ์„œ๋ฒ„ ์‘๋‹ต ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.', 'models': []}), 504
359
+ except Exception as e:
360
+ return jsonify({'error': f'๋ชจ๋ธ ๋ชฉ๋ก์„ ๊ฐ€์ ธ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}', 'models': []}), 500
361
+
362
+ @main_bp.route('/api/chat', methods=['POST'])
363
+ @login_required
364
+ def chat():
365
+ """์ฑ„ํŒ… API ์—”๋“œํฌ์ธํŠธ"""
366
+ try:
367
+ data = request.json
368
+ message = data.get('message', '')
369
+ model = data.get('model', '')
370
+ file_ids = [int(fid) for fid in data.get('file_ids', []) if fid] # ์„ ํƒํ•œ ์›น์†Œ์„ค ํŒŒ์ผ ID ๋ชฉ๋ก
371
+ session_id = data.get('session_id', None) # ๋Œ€ํ™” ์„ธ์…˜ ID (์ •์ˆ˜๋กœ ๋ณ€ํ™˜)
372
+
373
+ if not message:
374
+ return jsonify({'error': '๋ฉ”์‹œ์ง€๊ฐ€ ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
375
+
376
+ # ๋ชจ๋ธ์ด ์„ ํƒ๋œ ๊ฒฝ์šฐ Ollama ์‚ฌ์šฉ
377
+ if model:
378
+ try:
379
+ # RAG: ์งˆ๋ฌธ๊ณผ ๊ด€๋ จ๋œ ์ฒญํฌ ๊ฒ€์ƒ‰
380
+ context = ""
381
+ use_rag = True # RAG ์‚ฌ์šฉ ์—ฌ๋ถ€
382
+
383
+ if use_rag:
384
+ # ๊ด€๋ จ ์ฒญํฌ ๊ฒ€์ƒ‰
385
+ relevant_chunks = search_relevant_chunks(
386
+ query=message,
387
+ file_ids=file_ids if file_ids else None,
388
+ model_name=model,
389
+ top_k=5
390
+ )
391
+
392
+ if relevant_chunks:
393
+ context_parts = []
394
+ seen_files = set()
395
+
396
+ for chunk in relevant_chunks:
397
+ file = chunk.file
398
+ if file.original_filename not in seen_files:
399
+ seen_files.add(file.original_filename)
400
+
401
+ context_parts.append(f"[{file.original_filename} - ์ฒญํฌ {chunk.chunk_index + 1}]\n{chunk.content}")
402
+
403
+ if context_parts:
404
+ context = "\n\n".join(context_parts)
405
+ context = f"๋‹ค์Œ์€ ์งˆ๋ฌธ๊ณผ ๊ด€๋ จ๋œ ์›น์†Œ์„ค ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค (RAG ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ):\n\n{context}\n\n์œ„ ๋‚ด์šฉ์„ ์ฐธ๊ณ ํ•˜์—ฌ ๋‹ค์Œ ์งˆ๋ฌธ์— ์ •ํ™•ํ•˜๊ฒŒ ๋‹ต๋ณ€ํ•ด์ฃผ์„ธ์š”:\n\n"
406
+ else:
407
+ # RAG ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†์œผ๋ฉด ๊ธฐ์กด ๋ฐฉ์‹ ์‚ฌ์šฉ
408
+ use_rag = False
409
+
410
+ # RAG ๊ฒ€์ƒ‰ ๊ฒฐ๊ณผ๊ฐ€ ์—†๊ฑฐ๋‚˜ ๋น„ํ™œ์„ฑํ™”๋œ ๊ฒฝ์šฐ ๊ธฐ์กด ๋ฐฉ์‹ ์‚ฌ์šฉ
411
+ if not context and not use_rag:
412
+ if file_ids:
413
+ # ์„ ํƒํ•œ ํŒŒ์ผ ID๋“ค๋กœ ํŒŒ์ผ ์กฐํšŒ
414
+ uploaded_files = UploadedFile.query.filter(
415
+ UploadedFile.id.in_(file_ids),
416
+ UploadedFile.model_name == model
417
+ ).all()
418
+ else:
419
+ # ํŒŒ์ผ ID๊ฐ€ ์—†์œผ๋ฉด ํ•ด๋‹น ๋ชจ๋ธ์˜ ๋ชจ๋“  ํŒŒ์ผ ์‚ฌ์šฉ (๊ธฐ์กด ๋™์ž‘)
420
+ uploaded_files = UploadedFile.query.filter_by(model_name=model).all()
421
+
422
+ if uploaded_files:
423
+ context_parts = []
424
+ for file in uploaded_files:
425
+ try:
426
+ if os.path.exists(file.file_path):
427
+ encoding = 'utf-8'
428
+ try:
429
+ with open(file.file_path, 'r', encoding=encoding) as f:
430
+ file_content = f.read()
431
+ except UnicodeDecodeError:
432
+ with open(file.file_path, 'r', encoding='cp949') as f:
433
+ file_content = f.read()
434
+
435
+ # ํŒŒ์ผ ๋‚ด์šฉ์ด ๋„ˆ๋ฌด ๊ธธ๋ฉด ์ผ๋ถ€๋งŒ ์‚ฌ์šฉ (์ตœ๋Œ€ 10000์ž๋กœ ์ฆ๊ฐ€)
436
+ if len(file_content) > 10000:
437
+ file_content = file_content[:10000] + "..."
438
+
439
+ context_parts.append(f"[{file.original_filename}]\n{file_content}")
440
+ except Exception as e:
441
+ print(f"ํŒŒ์ผ ์ฝ๊ธฐ ์˜ค๋ฅ˜ ({file.original_filename}): {str(e)}")
442
+ continue
443
+
444
+ if context_parts:
445
+ context = "\n\n".join(context_parts)
446
+ context = f"๋‹ค์Œ์€ ํ•™์Šต๋œ ์›น์†Œ์„ค ๋‚ด์šฉ์ž…๋‹ˆ๋‹ค:\n\n{context}\n\n์œ„ ๋‚ด์šฉ์„ ์ฐธ๊ณ ํ•˜์—ฌ ๋‹ค์Œ ์งˆ๋ฌธ์— ๋‹ต๋ณ€ํ•ด์ฃผ์„ธ์š”:\n\n"
447
+
448
+ # ํ”„๋กฌํ”„ํŠธ ๊ตฌ์„ฑ
449
+ full_prompt = context + message if context else message
450
+
451
+ # Ollama API ํ˜ธ์ถœ
452
+ ollama_response = requests.post(
453
+ f'{OLLAMA_BASE_URL}/api/generate',
454
+ json={
455
+ 'model': model,
456
+ 'prompt': full_prompt,
457
+ 'stream': False
458
+ },
459
+ timeout=120 # ํŒŒ์ผ์ด ๋งŽ์„ ์ˆ˜ ์žˆ์œผ๋ฏ€๋กœ ํƒ€์ž„์•„์›ƒ ์ฆ๊ฐ€
460
+ )
461
+
462
+ if ollama_response.status_code == 200:
463
+ ollama_data = ollama_response.json()
464
+ response_text = ollama_data.get('response', '์‘๋‹ต์„ ์ƒ์„ฑํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.')
465
+
466
+ # ๋Œ€ํ™” ์„ธ์…˜์— ๋ฉ”์‹œ์ง€ ์ €์žฅ
467
+ session_id = data.get('session_id')
468
+ if session_id:
469
+ try:
470
+ session = ChatSession.query.filter_by(
471
+ id=session_id,
472
+ user_id=current_user.id
473
+ ).first()
474
+
475
+ if session:
476
+ # ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ์ €์žฅ
477
+ user_msg = ChatMessage(
478
+ session_id=session_id,
479
+ role='user',
480
+ content=message
481
+ )
482
+ db.session.add(user_msg)
483
+
484
+ # AI ์‘๋‹ต ์ €์žฅ
485
+ ai_msg = ChatMessage(
486
+ session_id=session_id,
487
+ role='ai',
488
+ content=response_text
489
+ )
490
+ db.session.add(ai_msg)
491
+
492
+ session.updated_at = datetime.utcnow()
493
+ db.session.commit()
494
+ except Exception as e:
495
+ print(f"๋ฉ”์‹œ์ง€ ์ €์žฅ ์˜ค๋ฅ˜: {str(e)}")
496
+ db.session.rollback()
497
+
498
+ return jsonify({'response': response_text, 'session_id': session_id})
499
+ else:
500
+ error_msg = f'Ollama ์„œ๋ฒ„ ์˜ค๋ฅ˜: {ollama_response.status_code}'
501
+ return jsonify({'error': error_msg}), ollama_response.status_code
502
+
503
+ except requests.exceptions.ConnectionError:
504
+ return jsonify({'error': 'Ollama ์„œ๋ฒ„์— ์—ฐ๊ฒฐํ•  ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค. Ollama๊ฐ€ ์‹คํ–‰ ์ค‘์ธ์ง€ ํ™•์ธํ•˜์„ธ์š”.'}), 503
505
+ except requests.exceptions.Timeout:
506
+ return jsonify({'error': '์‘๋‹ต ์‹œ๊ฐ„์ด ์ดˆ๊ณผ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. ๋” ์งง์€ ๋ฉ”์‹œ์ง€๋ฅผ ์‹œ๋„ํ•ด๋ณด์„ธ์š”.'}), 504
507
+ except Exception as e:
508
+ return jsonify({'error': f'Ollama ํ†ต์‹  ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
509
+ else:
510
+ # ๋ชจ๋ธ์ด ์„ ํƒ๋˜์ง€ ์•Š์€ ๊ฒฝ์šฐ ๊ธฐ๋ณธ ์‘๋‹ต
511
+ response_text = f"์•ˆ๋…•ํ•˜์„ธ์š”! '{message}'์— ๋Œ€ํ•œ ๋‹ต๋ณ€์„ ์ค€๋น„ ์ค‘์ž…๋‹ˆ๋‹ค.\n\n์ขŒ์ธก ํ•˜๋‹จ์—์„œ ๋กœ์ปฌ AI ๋ชจ๋ธ์„ ์„ ํƒํ•˜๋ฉด ๋” ์ •ํ™•ํ•œ ๋‹ต๋ณ€์„ ์ œ๊ณตํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค."
512
+ return jsonify({'response': response_text})
513
+
514
+ except Exception as e:
515
+ return jsonify({'error': f'์ฑ„ํŒ… ์ฒ˜๋ฆฌ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
516
+
517
+ @main_bp.route('/api/upload', methods=['POST'])
518
+ @login_required
519
+ def upload_file():
520
+ """์›น์†Œ์„ค ํŒŒ์ผ ์—…๋กœ๋“œ"""
521
+ try:
522
+ ensure_upload_folder()
523
+
524
+ if 'file' not in request.files:
525
+ return jsonify({'error': 'ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 400
526
+
527
+ file = request.files['file']
528
+ model_name = request.form.get('model_name', '')
529
+
530
+ if file.filename == '':
531
+ return jsonify({'error': 'ํŒŒ์ผ๋ช…์ด ์—†์Šต๋‹ˆ๋‹ค.'}), 400
532
+
533
+ if not allowed_file(file.filename):
534
+ return jsonify({'error': f'ํ—ˆ์šฉ๋˜์ง€ ์•Š์€ ํŒŒ์ผ ํ˜•์‹์ž…๋‹ˆ๋‹ค. ํ—ˆ์šฉ ํ˜•์‹: {", ".join(ALLOWED_EXTENSIONS)}'}), 400
535
+
536
+ # ์•ˆ์ „ํ•œ ํŒŒ์ผ๋ช… ์ƒ์„ฑ
537
+ original_filename = file.filename
538
+ filename = secure_filename(original_filename)
539
+ unique_filename = f"{uuid.uuid4().hex}_{filename}"
540
+ file_path = os.path.join(UPLOAD_FOLDER, unique_filename)
541
+
542
+ # ํŒŒ์ผ ์ €์žฅ
543
+ file.save(file_path)
544
+ file_size = os.path.getsize(file_path)
545
+
546
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— ์ €์žฅ
547
+ uploaded_file = UploadedFile(
548
+ filename=unique_filename,
549
+ original_filename=original_filename,
550
+ file_path=file_path,
551
+ file_size=file_size,
552
+ model_name=model_name if model_name else None,
553
+ uploaded_by=current_user.id
554
+ )
555
+ db.session.add(uploaded_file)
556
+ db.session.flush() # ID๋ฅผ ์–ป๊ธฐ ์œ„ํ•ด flush
557
+
558
+ # ํ…์ŠคํŠธ ํŒŒ์ผ์ธ ๊ฒฝ์šฐ ์ฒญํฌ๋กœ ๋ถ„ํ• ํ•˜์—ฌ ์ €์žฅ (RAG์šฉ)
559
+ if original_filename.lower().endswith(('.txt', '.md')):
560
+ try:
561
+ encoding = 'utf-8'
562
+ try:
563
+ with open(file_path, 'r', encoding=encoding) as f:
564
+ content = f.read()
565
+ except UnicodeDecodeError:
566
+ with open(file_path, 'r', encoding='cp949') as f:
567
+ content = f.read()
568
+
569
+ chunk_count = create_chunks_for_file(uploaded_file.id, content)
570
+ if chunk_count > 0:
571
+ print(f"ํŒŒ์ผ {original_filename}์„ {chunk_count}๊ฐœ์˜ ์ฒญํฌ๋กœ ๋ถ„ํ• ํ–ˆ์Šต๋‹ˆ๋‹ค.")
572
+ except Exception as e:
573
+ print(f"์ฒญํฌ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜ (๋ฌด์‹œ): {str(e)}")
574
+
575
+ db.session.commit()
576
+
577
+ return jsonify({
578
+ 'message': 'ํŒŒ์ผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
579
+ 'file': uploaded_file.to_dict()
580
+ }), 200
581
+
582
+ except Exception as e:
583
+ db.session.rollback()
584
+ return jsonify({'error': f'ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
585
+
586
+ @main_bp.route('/api/files', methods=['GET'])
587
+ @login_required
588
+ def get_files():
589
+ """์—…๋กœ๋“œ๋œ ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ"""
590
+ try:
591
+ model_name = request.args.get('model_name', None)
592
+
593
+ query = UploadedFile.query
594
+ if model_name:
595
+ query = query.filter_by(model_name=model_name)
596
+
597
+ files = query.order_by(UploadedFile.uploaded_at.desc()).all()
598
+
599
+ return jsonify({
600
+ 'files': [file.to_dict() for file in files]
601
+ }), 200
602
+
603
+ except Exception as e:
604
+ return jsonify({'error': f'ํŒŒ์ผ ๋ชฉ๋ก ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
605
+
606
+ @main_bp.route('/api/files/<int:file_id>', methods=['DELETE'])
607
+ @login_required
608
+ def delete_file(file_id):
609
+ """์—…๋กœ๋“œ๋œ ํŒŒ์ผ ์‚ญ์ œ"""
610
+ try:
611
+ file = UploadedFile.query.get_or_404(file_id)
612
+
613
+ # ํŒŒ์ผ ์‹œ์Šคํ…œ์—์„œ ์‚ญ์ œ
614
+ if os.path.exists(file.file_path):
615
+ os.remove(file.file_path)
616
+
617
+ # ๊ด€๋ จ ์ฒญํฌ๋„ ์‚ญ์ œ (CASCADE๋กœ ์ž๋™ ์‚ญ์ œ๋˜์ง€๋งŒ ๋ช…์‹œ์ ์œผ๋กœ ์ฒ˜๋ฆฌ)
618
+ DocumentChunk.query.filter_by(file_id=file_id).delete()
619
+
620
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์—์„œ ์‚ญ์ œ
621
+ db.session.delete(file)
622
+ db.session.commit()
623
+
624
+ return jsonify({'message': 'ํŒŒ์ผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}), 200
625
+
626
+ except Exception as e:
627
+ db.session.rollback()
628
+ return jsonify({'error': f'ํŒŒ์ผ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
629
+
630
+ @main_bp.route('/api/files/<int:file_id>/content', methods=['GET'])
631
+ @login_required
632
+ def get_file_content(file_id):
633
+ """์—…๋กœ๋“œ๋œ ํŒŒ์ผ ๋‚ด์šฉ ์กฐํšŒ"""
634
+ try:
635
+ file = UploadedFile.query.get_or_404(file_id)
636
+
637
+ if not os.path.exists(file.file_path):
638
+ return jsonify({'error': 'ํŒŒ์ผ์„ ์ฐพ์„ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.'}), 404
639
+
640
+ # ํ…์ŠคํŠธ ํŒŒ์ผ ์ฝ๊ธฐ
641
+ encoding = 'utf-8'
642
+ try:
643
+ with open(file.file_path, 'r', encoding=encoding) as f:
644
+ content = f.read()
645
+ except UnicodeDecodeError:
646
+ # UTF-8๋กœ ์ฝ์„ ์ˆ˜ ์—†์œผ๋ฉด ๋‹ค๋ฅธ ์ธ์ฝ”๋”ฉ ์‹œ๋„
647
+ with open(file.file_path, 'r', encoding='cp949') as f:
648
+ content = f.read()
649
+
650
+ return jsonify({
651
+ 'content': content,
652
+ 'filename': file.original_filename
653
+ }), 200
654
+
655
+ except Exception as e:
656
+ return jsonify({'error': f'ํŒŒ์ผ ๋‚ด์šฉ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
657
+
658
+ @main_bp.route('/api/chat/sessions', methods=['GET'])
659
+ @login_required
660
+ def get_chat_sessions():
661
+ """์‚ฌ์šฉ์ž์˜ ๋Œ€ํ™” ์„ธ์…˜ ๋ชฉ๋ก ์กฐํšŒ (์ตœ๊ทผ 20๊ฐœ๋งŒ ํ‘œ์‹œ)"""
662
+ try:
663
+ sessions = ChatSession.query.filter_by(user_id=current_user.id)\
664
+ .order_by(ChatSession.updated_at.desc())\
665
+ .limit(20).all()
666
+
667
+ return jsonify({
668
+ 'sessions': [session.to_dict() for session in sessions]
669
+ }), 200
670
+
671
+ except Exception as e:
672
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
673
+
674
+ @main_bp.route('/api/chat/sessions', methods=['POST'])
675
+ @login_required
676
+ def create_chat_session():
677
+ """์ƒˆ ๋Œ€ํ™” ์„ธ์…˜ ์ƒ์„ฑ"""
678
+ try:
679
+ data = request.json
680
+ title = data.get('title', '์ƒˆ ๋Œ€ํ™”')
681
+ model_name = data.get('model_name', None)
682
+
683
+ session = ChatSession(
684
+ user_id=current_user.id,
685
+ title=title,
686
+ model_name=model_name
687
+ )
688
+ db.session.add(session)
689
+ db.session.commit()
690
+
691
+ return jsonify({
692
+ 'message': '๋Œ€ํ™” ์„ธ์…˜์ด ์ƒ์„ฑ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
693
+ 'session': session.to_dict()
694
+ }), 200
695
+
696
+ except Exception as e:
697
+ db.session.rollback()
698
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์ƒ์„ฑ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
699
+
700
+ @main_bp.route('/api/chat/sessions/<int:session_id>', methods=['GET'])
701
+ @login_required
702
+ def get_chat_session(session_id):
703
+ """๋Œ€ํ™” ์„ธ์…˜ ์ƒ์„ธ ์กฐํšŒ (๋ฉ”์‹œ์ง€ ํฌํ•จ)"""
704
+ try:
705
+ session = ChatSession.query.filter_by(
706
+ id=session_id,
707
+ user_id=current_user.id
708
+ ).first_or_404()
709
+
710
+ session_dict = session.to_dict()
711
+ session_dict['messages'] = [msg.to_dict() for msg in session.messages]
712
+
713
+ return jsonify({'session': session_dict}), 200
714
+
715
+ except Exception as e:
716
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
717
+
718
+ @main_bp.route('/api/chat/sessions/<int:session_id>', methods=['PUT'])
719
+ @login_required
720
+ def update_chat_session(session_id):
721
+ """๋Œ€ํ™” ์„ธ์…˜ ์ˆ˜์ • (์ œ๋ชฉ ๋“ฑ)"""
722
+ try:
723
+ session = ChatSession.query.filter_by(
724
+ id=session_id,
725
+ user_id=current_user.id
726
+ ).first_or_404()
727
+
728
+ data = request.json
729
+ if 'title' in data:
730
+ session.title = data['title']
731
+
732
+ session.updated_at = datetime.utcnow()
733
+ db.session.commit()
734
+
735
+ return jsonify({
736
+ 'message': '๋Œ€ํ™” ์„ธ์…˜์ด ์ˆ˜์ •๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
737
+ 'session': session.to_dict()
738
+ }), 200
739
+
740
+ except Exception as e:
741
+ db.session.rollback()
742
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์ˆ˜์ • ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
743
+
744
+ @main_bp.route('/api/chat/sessions/<int:session_id>', methods=['DELETE'])
745
+ @login_required
746
+ def delete_chat_session(session_id):
747
+ """๋Œ€ํ™” ์„ธ์…˜ ์‚ญ์ œ"""
748
+ try:
749
+ session = ChatSession.query.filter_by(
750
+ id=session_id,
751
+ user_id=current_user.id
752
+ ).first_or_404()
753
+
754
+ db.session.delete(session)
755
+ db.session.commit()
756
+
757
+ return jsonify({'message': '๋Œ€ํ™” ์„ธ์…˜์ด ์‚ญ์ œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.'}), 200
758
+
759
+ except Exception as e:
760
+ db.session.rollback()
761
+ return jsonify({'error': f'๋Œ€ํ™” ์„ธ์…˜ ์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
762
+
763
+ @main_bp.route('/api/chat/sessions/<int:session_id>/messages', methods=['POST'])
764
+ @login_required
765
+ def add_chat_message(session_id):
766
+ """๋Œ€ํ™” ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€"""
767
+ try:
768
+ session = ChatSession.query.filter_by(
769
+ id=session_id,
770
+ user_id=current_user.id
771
+ ).first_or_404()
772
+
773
+ data = request.json
774
+ role = data.get('role', 'user')
775
+ content = data.get('content', '')
776
+
777
+ if not content:
778
+ return jsonify({'error': '๋ฉ”์‹œ์ง€ ๋‚ด์šฉ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค.'}), 400
779
+
780
+ message = ChatMessage(
781
+ session_id=session_id,
782
+ role=role,
783
+ content=content
784
+ )
785
+ db.session.add(message)
786
+
787
+ # ์„ธ์…˜ ์ œ๋ชฉ ์—…๋ฐ์ดํŠธ (์ฒซ ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€์ธ ๊ฒฝ์šฐ)
788
+ if not session.title or session.title == '์ƒˆ ๋Œ€ํ™”':
789
+ if role == 'user':
790
+ title = content[:30] + '...' if len(content) > 30 else content
791
+ session.title = title
792
+
793
+ session.updated_at = datetime.utcnow()
794
+ db.session.commit()
795
+
796
+ return jsonify({
797
+ 'message': '๋ฉ”์‹œ์ง€๊ฐ€ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.',
798
+ 'chat_message': message.to_dict()
799
+ }), 200
800
+
801
+ except Exception as e:
802
+ db.session.rollback()
803
+ return jsonify({'error': f'๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค: {str(e)}'}), 500
804
+
migrate_db.py ADDED
@@ -0,0 +1,54 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์Šคํฌ๋ฆฝํŠธ
3
+ nickname ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•ฉ๋‹ˆ๋‹ค.
4
+ """
5
+ import sqlite3
6
+ import os
7
+ from pathlib import Path
8
+
9
+ # ๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๊ฒฝ๋กœ
10
+ db_path = Path(__file__).parent / 'instance' / 'finance_analysis.db'
11
+
12
+ def migrate_database():
13
+ """๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค์— nickname ์ปฌ๋Ÿผ ์ถ”๊ฐ€"""
14
+ if not db_path.exists():
15
+ print(f"๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค: {db_path}")
16
+ print("์•ฑ์„ ์‹คํ–‰ํ•˜๋ฉด ์ž๋™์œผ๋กœ ์ƒ์„ฑ๋ฉ๋‹ˆ๋‹ค.")
17
+ return
18
+
19
+ try:
20
+ conn = sqlite3.connect(str(db_path))
21
+ cursor = conn.cursor()
22
+
23
+ # user ํ…Œ์ด๋ธ”์— nickname ์ปฌ๋Ÿผ์ด ์žˆ๋Š”์ง€ ํ™•์ธ
24
+ cursor.execute("PRAGMA table_info(user)")
25
+ columns = [column[1] for column in cursor.fetchall()]
26
+
27
+ if 'nickname' in columns:
28
+ print("nickname ์ปฌ๋Ÿผ์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.")
29
+ conn.close()
30
+ return
31
+
32
+ # nickname ์ปฌ๋Ÿผ ์ถ”๊ฐ€
33
+ print("nickname ์ปฌ๋Ÿผ์„ ์ถ”๊ฐ€ํ•˜๋Š” ์ค‘...")
34
+ cursor.execute("ALTER TABLE user ADD COLUMN nickname VARCHAR(80)")
35
+ conn.commit()
36
+ print("nickname ์ปฌ๋Ÿผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์ถ”๊ฐ€๋˜์—ˆ์Šต๋‹ˆ๋‹ค.")
37
+
38
+ conn.close()
39
+
40
+ except sqlite3.OperationalError as e:
41
+ print(f"์˜ค๋ฅ˜ ๋ฐœ์ƒ: {e}")
42
+ if "duplicate column name" in str(e).lower():
43
+ print("nickname ์ปฌ๋Ÿผ์ด ์ด๋ฏธ ์กด์žฌํ•ฉ๋‹ˆ๋‹ค.")
44
+ else:
45
+ raise
46
+ except Exception as e:
47
+ print(f"์˜ˆ์ƒ์น˜ ๋ชปํ•œ ์˜ค๋ฅ˜: {e}")
48
+ raise
49
+
50
+ if __name__ == '__main__':
51
+ print("๋ฐ์ดํ„ฐ๋ฒ ์ด์Šค ๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์‹œ์ž‘...")
52
+ migrate_database()
53
+ print("๋งˆ์ด๊ทธ๋ ˆ์ด์…˜ ์™„๋ฃŒ!")
54
+
requirements.txt CHANGED
@@ -1,4 +1,8 @@
1
  Flask==3.0.0
2
  flask-sqlalchemy==3.1.1
 
3
  python-dotenv==1.0.0
 
 
 
4
 
 
1
  Flask==3.0.0
2
  flask-sqlalchemy==3.1.1
3
+ flask-login==0.6.3
4
  python-dotenv==1.0.0
5
+ requests==2.31.0
6
+ werkzeug==3.0.1
7
+
8
 
run.py CHANGED
@@ -11,5 +11,11 @@ from app import create_app
11
  app = create_app()
12
 
13
  if __name__ == '__main__':
14
- app.run(host='0.0.0.0', port=5000, debug=True)
 
 
 
 
 
 
15
 
 
11
  app = create_app()
12
 
13
  if __name__ == '__main__':
14
+ try:
15
+ print(f"[{__name__}] ์„œ๋ฒ„ ์‹œ์ž‘: http://0.0.0.0:5001")
16
+ app.run(host='0.0.0.0', port=5001, debug=True, use_reloader=False)
17
+ except Exception as e:
18
+ print(f"์„œ๋ฒ„ ์‹œ์ž‘ ์˜ค๋ฅ˜: {e}")
19
+ import traceback
20
+ traceback.print_exc()
21
 
start_server.bat ADDED
@@ -0,0 +1,10 @@
 
 
 
 
 
 
 
 
 
 
 
1
+ @echo off
2
+ chcp 65001 >nul
3
+ cd /d "%~dp0"
4
+ :start
5
+ echo [%date% %time%] ์„œ๋ฒ„ ์‹œ์ž‘ ์ค‘...
6
+ python run.py
7
+ echo [%date% %time%] ์„œ๋ฒ„๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. 5์ดˆ ํ›„ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค...
8
+ timeout /t 5 /nobreak >nul
9
+ goto start
10
+
start_server.ps1 ADDED
@@ -0,0 +1,29 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ์„œ๋ฒ„ ์ž๋™ ์žฌ์‹œ์ž‘ ์Šคํฌ๋ฆฝํŠธ
2
+ $ErrorActionPreference = "Continue"
3
+ $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
4
+ Set-Location $scriptPath
5
+
6
+ function Start-Server {
7
+ Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ์„œ๋ฒ„ ์‹œ์ž‘ ์ค‘..." -ForegroundColor Green
8
+
9
+ $process = Start-Process -FilePath "python" -ArgumentList "run.py" -PassThru -NoNewWindow -Wait
10
+
11
+ if ($process.ExitCode -ne 0) {
12
+ Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ์„œ๋ฒ„๊ฐ€ ์ข…๋ฃŒ๋˜์—ˆ์Šต๋‹ˆ๋‹ค. 5์ดˆ ํ›„ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." -ForegroundColor Yellow
13
+ Start-Sleep -Seconds 5
14
+ Start-Server
15
+ }
16
+ }
17
+
18
+ # ๋ฌดํ•œ ๋ฃจํ”„๋กœ ์„œ๋ฒ„ ์‹คํ–‰
19
+ while ($true) {
20
+ try {
21
+ Start-Server
22
+ }
23
+ catch {
24
+ Write-Host "[$(Get-Date -Format 'yyyy-MM-dd HH:mm:ss')] ์˜ค๋ฅ˜ ๋ฐœ์ƒ: $_" -ForegroundColor Red
25
+ Write-Host "5์ดˆ ํ›„ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..." -ForegroundColor Yellow
26
+ Start-Sleep -Seconds 5
27
+ }
28
+ }
29
+
start_server_background.ps1 ADDED
@@ -0,0 +1,87 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # ๋ฐฑ๊ทธ๋ผ์šด๋“œ์—์„œ ์„œ๋ฒ„๋ฅผ ๊ณ„์† ์‹คํ–‰ํ•˜๋Š” ์Šคํฌ๋ฆฝํŠธ
2
+ $ErrorActionPreference = "Continue"
3
+ $scriptPath = Split-Path -Parent $MyInvocation.MyCommand.Path
4
+ Set-Location $scriptPath
5
+
6
+ # ๋กœ๊ทธ ํŒŒ์ผ ๊ฒฝ๋กœ
7
+ $logFile = Join-Path $scriptPath "server.log"
8
+
9
+ function Write-Log {
10
+ param([string]$Message)
11
+ $timestamp = Get-Date -Format 'yyyy-MM-dd HH:mm:ss'
12
+ $logMessage = "[$timestamp] $Message"
13
+ Write-Host $logMessage
14
+ Add-Content -Path $logFile -Value $logMessage
15
+ }
16
+
17
+ function Start-ServerProcess {
18
+ Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘ ์ค‘..."
19
+
20
+ $processInfo = New-Object System.Diagnostics.ProcessStartInfo
21
+ $processInfo.FileName = "python"
22
+ $processInfo.Arguments = "run.py"
23
+ $processInfo.WorkingDirectory = $scriptPath
24
+ $processInfo.UseShellExecute = $false
25
+ $processInfo.RedirectStandardOutput = $true
26
+ $processInfo.RedirectStandardError = $true
27
+ $processInfo.CreateNoWindow = $true
28
+
29
+ $process = New-Object System.Diagnostics.Process
30
+ $process.StartInfo = $processInfo
31
+
32
+ # ์ถœ๋ ฅ ๋ฆฌ๋‹ค์ด๋ ‰์…˜
33
+ $process.add_OutputDataReceived({
34
+ param($sender, $e)
35
+ if ($e.Data) {
36
+ Write-Log $e.Data
37
+ }
38
+ })
39
+
40
+ $process.add_ErrorDataReceived({
41
+ param($sender, $e)
42
+ if ($e.Data) {
43
+ Write-Log "ERROR: $($e.Data)"
44
+ }
45
+ })
46
+
47
+ $process.Start() | Out-Null
48
+ $process.BeginOutputReadLine()
49
+ $process.BeginErrorReadLine()
50
+
51
+ return $process
52
+ }
53
+
54
+ # ๋ฉ”์ธ ๋ฃจํ”„
55
+ Write-Log "=== ์„œ๋ฒ„ ์ž๋™ ์žฌ์‹œ์ž‘ ์Šคํฌ๋ฆฝํŠธ ์‹œ์ž‘ ==="
56
+
57
+ while ($true) {
58
+ $process = $null
59
+ try {
60
+ $process = Start-ServerProcess
61
+ Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์‹œ์ž‘๋จ (PID: $($process.Id))"
62
+
63
+ # ํ”„๋กœ์„ธ์Šค๊ฐ€ ์ข…๋ฃŒ๋  ๋•Œ๊นŒ์ง€ ๋Œ€๊ธฐ
64
+ $process.WaitForExit()
65
+
66
+ $exitCode = $process.ExitCode
67
+ Write-Log "์„œ๋ฒ„ ํ”„๋กœ์„ธ์Šค ์ข…๋ฃŒ๋จ (Exit Code: $exitCode)"
68
+
69
+ # ํ”„๋กœ์„ธ์Šค ์ •๋ฆฌ
70
+ if (!$process.HasExited) {
71
+ $process.Kill()
72
+ }
73
+ $process.Dispose()
74
+ }
75
+ catch {
76
+ Write-Log "์˜ค๋ฅ˜ ๋ฐœ์ƒ: $_"
77
+ if ($process -and !$process.HasExited) {
78
+ try {
79
+ $process.Kill()
80
+ } catch {}
81
+ }
82
+ }
83
+
84
+ Write-Log "5์ดˆ ํ›„ ์„œ๋ฒ„๋ฅผ ์žฌ์‹œ์ž‘ํ•ฉ๋‹ˆ๋‹ค..."
85
+ Start-Sleep -Seconds 5
86
+ }
87
+
templates/admin.html ADDED
@@ -0,0 +1,857 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€ - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .btn {
47
+ padding: 8px 16px;
48
+ border: none;
49
+ border-radius: 6px;
50
+ font-size: 14px;
51
+ font-weight: 500;
52
+ cursor: pointer;
53
+ transition: all 0.2s;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ }
57
+
58
+ .btn-primary {
59
+ background: #1a73e8;
60
+ color: white;
61
+ }
62
+
63
+ .btn-primary:hover {
64
+ background: #1557b0;
65
+ }
66
+
67
+ .btn-secondary {
68
+ background: #f1f3f4;
69
+ color: #202124;
70
+ }
71
+
72
+ .btn-secondary:hover {
73
+ background: #e8eaed;
74
+ }
75
+
76
+ .btn-danger {
77
+ background: #ea4335;
78
+ color: white;
79
+ }
80
+
81
+ .btn-danger:hover {
82
+ background: #c5221f;
83
+ }
84
+
85
+ .container {
86
+ max-width: 1200px;
87
+ margin: 0 auto;
88
+ padding: 24px;
89
+ }
90
+
91
+ .page-header {
92
+ margin-bottom: 24px;
93
+ }
94
+
95
+ .page-header h1 {
96
+ font-size: 28px;
97
+ font-weight: 600;
98
+ margin-bottom: 8px;
99
+ }
100
+
101
+ .page-header p {
102
+ color: #5f6368;
103
+ }
104
+
105
+ .card {
106
+ background: white;
107
+ border-radius: 8px;
108
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
109
+ padding: 24px;
110
+ margin-bottom: 24px;
111
+ }
112
+
113
+ .card-header {
114
+ display: flex;
115
+ justify-content: space-between;
116
+ align-items: center;
117
+ margin-bottom: 20px;
118
+ }
119
+
120
+ .card-title {
121
+ font-size: 18px;
122
+ font-weight: 500;
123
+ }
124
+
125
+ table {
126
+ width: 100%;
127
+ border-collapse: collapse;
128
+ }
129
+
130
+ thead {
131
+ background: #f8f9fa;
132
+ }
133
+
134
+ th, td {
135
+ padding: 12px;
136
+ text-align: left;
137
+ border-bottom: 1px solid #e8eaed;
138
+ }
139
+
140
+ th {
141
+ font-weight: 500;
142
+ font-size: 14px;
143
+ color: #5f6368;
144
+ }
145
+
146
+ td {
147
+ font-size: 14px;
148
+ }
149
+
150
+ .badge {
151
+ display: inline-block;
152
+ padding: 4px 8px;
153
+ border-radius: 4px;
154
+ font-size: 12px;
155
+ font-weight: 500;
156
+ }
157
+
158
+ .badge-admin {
159
+ background: #e8f0fe;
160
+ color: #1967d2;
161
+ }
162
+
163
+ .badge-user {
164
+ background: #e8f5e9;
165
+ color: #137333;
166
+ }
167
+
168
+ .badge-inactive {
169
+ background: #fce8e6;
170
+ color: #c5221f;
171
+ }
172
+
173
+ .modal {
174
+ display: none;
175
+ position: fixed;
176
+ top: 0;
177
+ left: 0;
178
+ width: 100%;
179
+ height: 100%;
180
+ background: rgba(0, 0, 0, 0.5);
181
+ z-index: 1000;
182
+ align-items: center;
183
+ justify-content: center;
184
+ }
185
+
186
+ .modal.active {
187
+ display: flex;
188
+ }
189
+
190
+ .modal-content {
191
+ background: white;
192
+ border-radius: 8px;
193
+ padding: 24px;
194
+ width: 90%;
195
+ max-width: 500px;
196
+ max-height: 90vh;
197
+ overflow-y: auto;
198
+ }
199
+
200
+ .modal-header {
201
+ display: flex;
202
+ justify-content: space-between;
203
+ align-items: center;
204
+ margin-bottom: 20px;
205
+ }
206
+
207
+ .modal-title {
208
+ font-size: 20px;
209
+ font-weight: 500;
210
+ }
211
+
212
+ .modal-close {
213
+ background: none;
214
+ border: none;
215
+ font-size: 24px;
216
+ cursor: pointer;
217
+ color: #5f6368;
218
+ }
219
+
220
+ .form-group {
221
+ margin-bottom: 16px;
222
+ }
223
+
224
+ .form-group label {
225
+ display: block;
226
+ font-size: 14px;
227
+ font-weight: 500;
228
+ margin-bottom: 8px;
229
+ }
230
+
231
+ .form-group input,
232
+ .form-group select {
233
+ width: 100%;
234
+ padding: 10px 12px;
235
+ border: 1px solid #dadce0;
236
+ border-radius: 6px;
237
+ font-size: 14px;
238
+ font-family: inherit;
239
+ }
240
+
241
+ .form-group input:focus,
242
+ .form-group select:focus {
243
+ outline: none;
244
+ border-color: #1a73e8;
245
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
246
+ }
247
+
248
+ .form-group-checkbox {
249
+ display: flex;
250
+ align-items: center;
251
+ gap: 8px;
252
+ }
253
+
254
+ .form-group-checkbox input {
255
+ width: auto;
256
+ }
257
+
258
+ .modal-actions {
259
+ display: flex;
260
+ gap: 8px;
261
+ justify-content: flex-end;
262
+ margin-top: 24px;
263
+ }
264
+
265
+ .alert {
266
+ padding: 12px 16px;
267
+ border-radius: 6px;
268
+ margin-bottom: 16px;
269
+ font-size: 14px;
270
+ }
271
+
272
+ .alert.error {
273
+ background: #fce8e6;
274
+ color: #c5221f;
275
+ }
276
+
277
+ .alert.success {
278
+ background: #e8f5e9;
279
+ color: #137333;
280
+ }
281
+
282
+ /* ํŒŒ์ผ ์—…๋กœ๋“œ ์˜์—ญ */
283
+ .file-upload-section {
284
+ margin-top: 24px;
285
+ }
286
+
287
+ .file-upload-input-wrapper {
288
+ position: relative;
289
+ margin-bottom: 12px;
290
+ border: 2px dashed #dadce0;
291
+ border-radius: 8px;
292
+ padding: 20px;
293
+ text-align: center;
294
+ background: #f8f9fa;
295
+ cursor: pointer;
296
+ transition: all 0.2s;
297
+ }
298
+
299
+ .file-upload-input-wrapper:hover {
300
+ border-color: #1a73e8;
301
+ background: #e8f0fe;
302
+ }
303
+
304
+ .file-upload-input-wrapper.dragover {
305
+ border-color: #1a73e8;
306
+ background: #e8f0fe;
307
+ }
308
+
309
+ .file-upload-input-wrapper input[type="file"] {
310
+ position: absolute;
311
+ opacity: 0;
312
+ width: 100%;
313
+ height: 100%;
314
+ cursor: pointer;
315
+ }
316
+
317
+ .file-upload-label {
318
+ display: flex;
319
+ flex-direction: column;
320
+ align-items: center;
321
+ gap: 8px;
322
+ color: #5f6368;
323
+ font-size: 14px;
324
+ }
325
+
326
+ .file-upload-label svg {
327
+ width: 32px;
328
+ height: 32px;
329
+ }
330
+
331
+ .file-upload-status {
332
+ font-size: 12px;
333
+ margin-top: 8px;
334
+ min-height: 16px;
335
+ }
336
+
337
+ .file-upload-status.success {
338
+ color: #137333;
339
+ }
340
+
341
+ .file-upload-status.error {
342
+ color: #c5221f;
343
+ }
344
+
345
+ .files-table {
346
+ margin-top: 16px;
347
+ }
348
+
349
+ .file-size {
350
+ color: #5f6368;
351
+ font-size: 12px;
352
+ }
353
+
354
+ .file-actions {
355
+ display: flex;
356
+ gap: 4px;
357
+ }
358
+ </style>
359
+ </head>
360
+ <body>
361
+ <div class="header">
362
+ <div class="header-title">
363
+ <span>๐Ÿค–</span>
364
+ <span>SOY NV AI ๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€</span>
365
+ </div>
366
+ <div class="header-actions">
367
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
368
+ <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">๋ฉ”์‹œ์ง€ ํ™•์ธ</a>
369
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
370
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
371
+ </div>
372
+ </div>
373
+
374
+ <div class="container">
375
+ <div class="page-header">
376
+ <h1>์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</h1>
377
+ <p>์‹œ์Šคํ…œ ์‚ฌ์šฉ์ž๋ฅผ ๊ด€๋ฆฌํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
378
+ </div>
379
+
380
+ <div id="alertContainer"></div>
381
+
382
+ <div class="card">
383
+ <div class="card-header">
384
+ <div class="card-title">์‚ฌ์šฉ์ž ๋ชฉ๋ก</div>
385
+ <button class="btn btn-primary" onclick="openCreateModal()">์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€</button>
386
+ </div>
387
+
388
+ <table>
389
+ <thead>
390
+ <tr>
391
+ <th>ID</th>
392
+ <th>์‚ฌ์šฉ์ž๋ช…</th>
393
+ <th>๋‹‰๋„ค์ž„</th>
394
+ <th>๊ถŒํ•œ</th>
395
+ <th>์ƒํƒœ</th>
396
+ <th>์ƒ์„ฑ์ผ</th>
397
+ <th>๋งˆ์ง€๋ง‰ ๋กœ๊ทธ์ธ</th>
398
+ <th>์ž‘์—…</th>
399
+ </tr>
400
+ </thead>
401
+ <tbody id="usersTableBody">
402
+ {% for user in users %}
403
+ <tr data-user-id="{{ user.id }}">
404
+ <td>{{ user.id }}</td>
405
+ <td>{{ user.username }}</td>
406
+ <td>{{ user.nickname or '-' }}</td>
407
+ <td>
408
+ {% if user.is_admin %}
409
+ <span class="badge badge-admin">๊ด€๋ฆฌ์ž</span>
410
+ {% else %}
411
+ <span class="badge badge-user">์ผ๋ฐ˜ ์‚ฌ์šฉ์ž</span>
412
+ {% endif %}
413
+ </td>
414
+ <td>
415
+ {% if user.is_active %}
416
+ <span class="badge badge-user">ํ™œ์„ฑ</span>
417
+ {% else %}
418
+ <span class="badge badge-inactive">๋น„ํ™œ์„ฑ</span>
419
+ {% endif %}
420
+ </td>
421
+ <td>{{ user.created_at.strftime('%Y-%m-%d %H:%M') if user.created_at else '-' }}</td>
422
+ <td>{{ user.last_login.strftime('%Y-%m-%d %H:%M') if user.last_login else '-' }}</td>
423
+ <td>
424
+ <button class="btn btn-secondary" onclick="openEditModal({{ user.id }}, '{{ user.username }}', '{{ user.nickname or '' }}', {{ user.is_admin|lower }}, {{ user.is_active|lower }})" style="padding: 4px 8px; font-size: 12px;">์ˆ˜์ •</button>
425
+ {% if user.id != current_user.id %}
426
+ <button class="btn btn-danger" onclick="deleteUser({{ user.id }})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
427
+ {% endif %}
428
+ </td>
429
+ </tr>
430
+ {% endfor %}
431
+ </tbody>
432
+ </table>
433
+ </div>
434
+
435
+ <!-- ํŒŒ์ผ ๊ด€๋ฆฌ ์„น์…˜ -->
436
+ <div class="card file-upload-section">
437
+ <div class="card-header">
438
+ <div class="card-title">์›น์†Œ์„ค ํ•™์Šต ํŒŒ์ผ ๊ด€๋ฆฌ</div>
439
+ </div>
440
+
441
+ <!-- ํŒŒ์ผ ์—…๋กœ๋“œ -->
442
+ <div class="file-upload-input-wrapper" id="fileUploadWrapper">
443
+ <input type="file" id="fileInput" accept=".txt,.md,.pdf,.docx,.epub" multiple>
444
+ <label class="file-upload-label">
445
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
446
+ <path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4M17 8l-5-5-5 5M12 3v12"/>
447
+ </svg>
448
+ <span>ํŒŒ์ผ์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ๋“œ๋ž˜๊ทธํ•˜์„ธ์š”</span>
449
+ <small style="color: #5f6368;">(์—ฌ๋Ÿฌ ํŒŒ์ผ ์„ ํƒ ๊ฐ€๋Šฅ)</small>
450
+ </label>
451
+ </div>
452
+ <div class="file-upload-status" id="fileUploadStatus"></div>
453
+
454
+ <!-- ๋ชจ๋ธ ์„ ํƒ -->
455
+ <div style="margin-top: 16px;">
456
+ <label style="display: block; font-size: 14px; font-weight: 500; margin-bottom: 8px;">AI ๋ชจ๋ธ ์„ ํƒ</label>
457
+ <select id="fileModelSelect" style="width: 100%; padding: 10px 12px; border: 1px solid #dadce0; border-radius: 6px; font-size: 14px;">
458
+ <option value="">๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”...</option>
459
+ </select>
460
+ </div>
461
+
462
+ <!-- ์—…๋กœ๋“œ ๊ทœ์น™ ์•ˆ๋‚ด -->
463
+ <div style="margin-top: 20px; padding: 16px; background: #f8f9fa; border-radius: 8px; border-left: 4px solid #1a73e8;">
464
+ <div style="font-size: 14px; font-weight: 500; margin-bottom: 12px; color: #202124;">
465
+ ๐Ÿ“‹ ํŒŒ์ผ ์—…๋กœ๋“œ ๊ทœ์น™
466
+ </div>
467
+ <div style="font-size: 13px; color: #5f6368; line-height: 1.8;">
468
+ <div style="margin-bottom: 8px;">
469
+ <strong>ํ—ˆ์šฉ ํŒŒ์ผ ํ˜•์‹:</strong> .txt, .md, .pdf, .docx, .epub
470
+ </div>
471
+ <div style="margin-bottom: 8px;">
472
+ <strong>ํŒŒ์ผ ์ธ์ฝ”๋”ฉ:</strong> UTF-8 ๋˜๋Š” CP949 (ํ•œ๊ธ€ ํŒŒ์ผ์˜ ๊ฒฝ์šฐ)
473
+ </div>
474
+ <div style="margin-bottom: 8px;">
475
+ <strong>๊ถŒ์žฅ ์‚ฌํ•ญ:</strong>
476
+ <ul style="margin: 8px 0 0 20px; padding: 0;">
477
+ <li>ํ…์ŠคํŠธ ํŒŒ์ผ(.txt, .md)์€ UTF-8 ์ธ์ฝ”๋”ฉ์„ ๊ถŒ์žฅํ•ฉ๋‹ˆ๋‹ค</li>
478
+ <li>ํŒŒ์ผ๋ช…์— ํŠน์ˆ˜๋ฌธ์ž ์‚ฌ์šฉ์„ ์ž์ œํ•ด์ฃผ์„ธ์š”</li>
479
+ <li>์—…๋กœ๋“œ ์ „์— AI ๋ชจ๋ธ์„ ๋จผ์ € ์„ ํƒํ•ด์ฃผ์„ธ์š”</li>
480
+ <li>์—ฌ๋Ÿฌ ํŒŒ์ผ์„ ํ•œ ๋ฒˆ์— ์—…๋กœ๋“œํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค</li>
481
+ </ul>
482
+ </div>
483
+ <div style="margin-top: 12px; padding-top: 12px; border-top: 1px solid #dadce0; font-size: 12px; color: #80868b;">
484
+ โš ๏ธ ์—…๋กœ๋“œ๋œ ํŒŒ์ผ์€ ์„ ํƒํ•œ AI ๋ชจ๋ธ๊ณผ ์—ฐ๊ฒฐ๋˜์–ด ํ•™์Šต ๋ฐ์ดํ„ฐ๋กœ ์‚ฌ์šฉ๋ฉ๋‹ˆ๋‹ค.
485
+ </div>
486
+ </div>
487
+ </div>
488
+
489
+ <!-- ํŒŒ์ผ ๋ชฉ๋ก -->
490
+ <div class="files-table">
491
+ <table style="margin-top: 16px;">
492
+ <thead>
493
+ <tr>
494
+ <th>ํŒŒ์ผ๋ช…</th>
495
+ <th>๋ชจ๋ธ</th>
496
+ <th>ํฌ๊ธฐ</th>
497
+ <th>์—…๋กœ๋“œ์ผ</th>
498
+ <th>์ž‘์—…</th>
499
+ </tr>
500
+ </thead>
501
+ <tbody id="filesTableBody">
502
+ <tr>
503
+ <td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘...</td>
504
+ </tr>
505
+ </tbody>
506
+ </table>
507
+ </div>
508
+ </div>
509
+ </div>
510
+
511
+ <!-- ์‚ฌ์šฉ์ž ์ƒ์„ฑ/์ˆ˜์ • ๋ชจ๋‹ฌ -->
512
+ <div id="userModal" class="modal">
513
+ <div class="modal-content">
514
+ <div class="modal-header">
515
+ <div class="modal-title" id="modalTitle">์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€</div>
516
+ <button class="modal-close" onclick="closeModal()">&times;</button>
517
+ </div>
518
+ <form id="userForm" onsubmit="saveUser(event)">
519
+ <input type="hidden" id="userId" name="user_id">
520
+ <div class="form-group">
521
+ <label for="username">์‚ฌ์šฉ์ž๋ช…</label>
522
+ <input type="text" id="username" name="username" required>
523
+ </div>
524
+ <div class="form-group">
525
+ <label for="nickname">๋‹‰๋„ค์ž„</label>
526
+ <input type="text" id="nickname" name="nickname" placeholder="์„ ํƒ์‚ฌํ•ญ">
527
+ </div>
528
+ <div class="form-group">
529
+ <label for="password">๋น„๋ฐ€๋ฒˆํ˜ธ</label>
530
+ <input type="password" id="password" name="password" id="passwordInput">
531
+ <small style="color: #5f6368; font-size: 12px;">์ˆ˜์ • ์‹œ ๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ๋ณ€๊ฒฝํ•˜์ง€ ์•Š์œผ๋ ค๋ฉด ๋น„์›Œ๋‘์„ธ์š”.</small>
532
+ </div>
533
+ <div class="form-group-checkbox">
534
+ <input type="checkbox" id="isAdmin" name="is_admin">
535
+ <label for="isAdmin">๊ด€๋ฆฌ์ž ๊ถŒํ•œ</label>
536
+ </div>
537
+ <div class="form-group-checkbox">
538
+ <input type="checkbox" id="isActive" name="is_active" checked>
539
+ <label for="isActive">ํ™œ์„ฑ ์ƒํƒœ</label>
540
+ </div>
541
+ <div class="modal-actions">
542
+ <button type="button" class="btn btn-secondary" onclick="closeModal()">์ทจ์†Œ</button>
543
+ <button type="submit" class="btn btn-primary">์ €์žฅ</button>
544
+ </div>
545
+ </form>
546
+ </div>
547
+ </div>
548
+
549
+ <script>
550
+ let currentEditUserId = null;
551
+
552
+ function showAlert(message, type = 'success') {
553
+ const container = document.getElementById('alertContainer');
554
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
555
+ setTimeout(() => {
556
+ container.innerHTML = '';
557
+ }, 5000);
558
+ }
559
+
560
+ function openCreateModal() {
561
+ currentEditUserId = null;
562
+ document.getElementById('modalTitle').textContent = '์ƒˆ ์‚ฌ์šฉ์ž ์ถ”๊ฐ€';
563
+ document.getElementById('userForm').reset();
564
+ document.getElementById('userId').value = '';
565
+ document.getElementById('password').required = true;
566
+ document.getElementById('isActive').checked = true;
567
+ document.getElementById('userModal').classList.add('active');
568
+ }
569
+
570
+ function openEditModal(userId, username, nickname, isAdmin, isActive) {
571
+ currentEditUserId = userId;
572
+ document.getElementById('modalTitle').textContent = '์‚ฌ์šฉ์ž ์ˆ˜์ •';
573
+ document.getElementById('userId').value = userId;
574
+ document.getElementById('username').value = username;
575
+ document.getElementById('nickname').value = nickname || '';
576
+ document.getElementById('password').value = '';
577
+ document.getElementById('password').required = false;
578
+ document.getElementById('isAdmin').checked = isAdmin;
579
+ document.getElementById('isActive').checked = isActive;
580
+ document.getElementById('userModal').classList.add('active');
581
+ }
582
+
583
+ function closeModal() {
584
+ document.getElementById('userModal').classList.remove('active');
585
+ currentEditUserId = null;
586
+ }
587
+
588
+ async function saveUser(event) {
589
+ event.preventDefault();
590
+
591
+ const formData = {
592
+ username: document.getElementById('username').value.trim(),
593
+ nickname: document.getElementById('nickname').value.trim(),
594
+ password: document.getElementById('password').value,
595
+ is_admin: document.getElementById('isAdmin').checked,
596
+ is_active: document.getElementById('isActive').checked
597
+ };
598
+
599
+ const userId = document.getElementById('userId').value;
600
+ const url = userId ? `/api/admin/users/${userId}` : '/api/admin/users';
601
+ const method = userId ? 'PUT' : 'POST';
602
+
603
+ if (!userId && !formData.password) {
604
+ showAlert('๋น„๋ฐ€๋ฒˆํ˜ธ๋ฅผ ์ž…๋ ฅํ•ด์ฃผ์„ธ์š”.', 'error');
605
+ return;
606
+ }
607
+
608
+ try {
609
+ const response = await fetch(url, {
610
+ method: method,
611
+ headers: {
612
+ 'Content-Type': 'application/json',
613
+ },
614
+ body: JSON.stringify(formData)
615
+ });
616
+
617
+ const data = await response.json();
618
+
619
+ if (response.ok) {
620
+ showAlert(data.message, 'success');
621
+ closeModal();
622
+ setTimeout(() => {
623
+ location.reload();
624
+ }, 1000);
625
+ } else {
626
+ showAlert(data.error || '์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
627
+ }
628
+ } catch (error) {
629
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
630
+ }
631
+ }
632
+
633
+ async function deleteUser(userId) {
634
+ if (!confirm('์ •๋ง ์ด ์‚ฌ์šฉ์ž๋ฅผ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) {
635
+ return;
636
+ }
637
+
638
+ try {
639
+ const response = await fetch(`/api/admin/users/${userId}`, {
640
+ method: 'DELETE'
641
+ });
642
+
643
+ const data = await response.json();
644
+
645
+ if (response.ok) {
646
+ showAlert(data.message, 'success');
647
+ setTimeout(() => {
648
+ location.reload();
649
+ }, 1000);
650
+ } else {
651
+ showAlert(data.error || '์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
652
+ }
653
+ } catch (error) {
654
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
655
+ }
656
+ }
657
+
658
+ // ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
659
+ document.getElementById('userModal').addEventListener('click', function(e) {
660
+ if (e.target === this) {
661
+ closeModal();
662
+ }
663
+ });
664
+
665
+ // ํŒŒ์ผ ์—…๋กœ๋“œ ๊ด€๋ จ
666
+ const fileInput = document.getElementById('fileInput');
667
+ const fileUploadWrapper = document.getElementById('fileUploadWrapper');
668
+ const fileUploadStatus = document.getElementById('fileUploadStatus');
669
+ const fileModelSelect = document.getElementById('fileModelSelect');
670
+ const filesTableBody = document.getElementById('filesTableBody');
671
+
672
+ // ๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ
673
+ async function loadModelsForFiles() {
674
+ try {
675
+ const response = await fetch('/api/ollama/models');
676
+ const data = await response.json();
677
+
678
+ fileModelSelect.innerHTML = '<option value="">๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”...</option>';
679
+
680
+ if (data.models && data.models.length > 0) {
681
+ data.models.forEach(model => {
682
+ const option = document.createElement('option');
683
+ option.value = model.name;
684
+ option.textContent = model.name;
685
+ fileModelSelect.appendChild(option);
686
+ });
687
+ }
688
+ } catch (error) {
689
+ console.error('๋ชจ๋ธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
690
+ }
691
+ }
692
+
693
+ // ํŒŒ์ผ ๋ชฉ๋ก ๋กœ๋“œ
694
+ async function loadFiles() {
695
+ try {
696
+ const response = await fetch('/api/files');
697
+ const data = await response.json();
698
+
699
+ filesTableBody.innerHTML = '';
700
+
701
+ if (data.files && data.files.length > 0) {
702
+ data.files.forEach(file => {
703
+ const row = document.createElement('tr');
704
+ const fileSize = formatFileSize(file.file_size);
705
+ const uploadDate = new Date(file.uploaded_at).toLocaleString('ko-KR');
706
+
707
+ row.innerHTML = `
708
+ <td>${escapeHtml(file.original_filename)}</td>
709
+ <td>${file.model_name || '-'}</td>
710
+ <td class="file-size">${fileSize}</td>
711
+ <td>${uploadDate}</td>
712
+ <td>
713
+ <div class="file-actions">
714
+ <button class="btn btn-secondary" onclick="deleteFile(${file.id})" style="padding: 4px 8px; font-size: 12px;">์‚ญ์ œ</button>
715
+ </div>
716
+ </td>
717
+ `;
718
+ filesTableBody.appendChild(row);
719
+ });
720
+ } else {
721
+ filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">์—…๋กœ๋“œ๋œ ํŒŒ์ผ์ด ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
722
+ }
723
+ } catch (error) {
724
+ filesTableBody.innerHTML = '<tr><td colspan="5" style="text-align: center; padding: 20px; color: #c5221f;">ํŒŒ์ผ ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ค๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.</td></tr>';
725
+ console.error('ํŒŒ์ผ ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
726
+ }
727
+ }
728
+
729
+ // ํŒŒ์ผ ํฌ๊ธฐ ํฌ๋งท
730
+ function formatFileSize(bytes) {
731
+ if (bytes === 0) return '0 Bytes';
732
+ const k = 1024;
733
+ const sizes = ['Bytes', 'KB', 'MB', 'GB'];
734
+ const i = Math.floor(Math.log(bytes) / Math.log(k));
735
+ return Math.round(bytes / Math.pow(k, i) * 100) / 100 + ' ' + sizes[i];
736
+ }
737
+
738
+ // HTML ์ด์Šค์ผ€์ดํ”„
739
+ function escapeHtml(text) {
740
+ const div = document.createElement('div');
741
+ div.textContent = text;
742
+ return div.innerHTML;
743
+ }
744
+
745
+ // ํŒŒ์ผ ์—…๋กœ๋“œ ์ฒ˜๋ฆฌ
746
+ async function handleFileUpload(files) {
747
+ if (!files || files.length === 0) return;
748
+
749
+ const modelName = fileModelSelect.value;
750
+ if (!modelName) {
751
+ showAlert('๋จผ์ € AI ๋ชจ๋ธ์„ ์„ ํƒํ•ด์ฃผ์„ธ์š”.', 'error');
752
+ return;
753
+ }
754
+
755
+ fileUploadStatus.textContent = `${files.length}๊ฐœ ํŒŒ์ผ ์—…๋กœ๋“œ ์ค‘...`;
756
+ fileUploadStatus.className = 'file-upload-status';
757
+
758
+ let successCount = 0;
759
+ let failCount = 0;
760
+
761
+ for (let i = 0; i < files.length; i++) {
762
+ const file = files[i];
763
+ const formData = new FormData();
764
+ formData.append('file', file);
765
+ formData.append('model_name', modelName);
766
+
767
+ try {
768
+ const response = await fetch('/api/upload', {
769
+ method: 'POST',
770
+ body: formData
771
+ });
772
+
773
+ const data = await response.json();
774
+
775
+ if (response.ok) {
776
+ successCount++;
777
+ } else {
778
+ failCount++;
779
+ console.error(`ํŒŒ์ผ ์—…๋กœ๋“œ ์‹คํŒจ (${file.name}):`, data.error);
780
+ }
781
+ } catch (error) {
782
+ failCount++;
783
+ console.error(`ํŒŒ์ผ ์—…๋กœ๋“œ ์˜ค๋ฅ˜ (${file.name}):`, error);
784
+ }
785
+ }
786
+
787
+ if (successCount > 0) {
788
+ showAlert(`${successCount}๊ฐœ ํŒŒ์ผ์ด ์„ฑ๊ณต์ ์œผ๋กœ ์—…๋กœ๋“œ๋˜์—ˆ์Šต๋‹ˆ๋‹ค.${failCount > 0 ? ` (${failCount}๊ฐœ ์‹คํŒจ)` : ''}`, 'success');
789
+ loadFiles();
790
+ } else {
791
+ showAlert('ํŒŒ์ผ ์—…๋กœ๋“œ์— ์‹คํŒจํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
792
+ }
793
+
794
+ fileUploadStatus.textContent = '';
795
+ fileInput.value = '';
796
+ }
797
+
798
+ // ํŒŒ์ผ ์‚ญ์ œ
799
+ async function deleteFile(fileId) {
800
+ if (!confirm('์ด ํŒŒ์ผ์„ ์‚ญ์ œํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ?')) {
801
+ return;
802
+ }
803
+
804
+ try {
805
+ const response = await fetch(`/api/files/${fileId}`, {
806
+ method: 'DELETE'
807
+ });
808
+
809
+ const data = await response.json();
810
+
811
+ if (response.ok) {
812
+ showAlert(data.message, 'success');
813
+ loadFiles();
814
+ } else {
815
+ showAlert(data.error || '์‚ญ์ œ ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.', 'error');
816
+ }
817
+ } catch (error) {
818
+ showAlert(`์˜ค๋ฅ˜: ${error.message}`, 'error');
819
+ }
820
+ }
821
+
822
+ // ํŒŒ์ผ ์ž…๋ ฅ ์ด๋ฒคํŠธ
823
+ fileInput.addEventListener('change', function(e) {
824
+ if (e.target.files.length > 0) {
825
+ handleFileUpload(Array.from(e.target.files));
826
+ }
827
+ });
828
+
829
+ // ๋“œ๋ž˜๊ทธ ์•ค ๋“œ๋กญ
830
+ fileUploadWrapper.addEventListener('dragover', (e) => {
831
+ e.preventDefault();
832
+ fileUploadWrapper.classList.add('dragover');
833
+ });
834
+
835
+ fileUploadWrapper.addEventListener('dragleave', () => {
836
+ fileUploadWrapper.classList.remove('dragover');
837
+ });
838
+
839
+ fileUploadWrapper.addEventListener('drop', (e) => {
840
+ e.preventDefault();
841
+ fileUploadWrapper.classList.remove('dragover');
842
+
843
+ const files = Array.from(e.dataTransfer.files);
844
+ if (files.length > 0) {
845
+ handleFileUpload(files);
846
+ }
847
+ });
848
+
849
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
850
+ window.addEventListener('load', () => {
851
+ loadModelsForFiles();
852
+ loadFiles();
853
+ });
854
+ </script>
855
+ </body>
856
+ </html>
857
+
templates/admin_messages.html ADDED
@@ -0,0 +1,695 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>๋ฉ”์‹œ์ง€ ํ™•์ธ - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: #f8f9fa;
19
+ color: #202124;
20
+ }
21
+
22
+ .header {
23
+ background: white;
24
+ border-bottom: 1px solid #dadce0;
25
+ padding: 16px 24px;
26
+ display: flex;
27
+ align-items: center;
28
+ justify-content: space-between;
29
+ box-shadow: 0 1px 2px rgba(0, 0, 0, 0.1);
30
+ }
31
+
32
+ .header-title {
33
+ font-size: 20px;
34
+ font-weight: 500;
35
+ display: flex;
36
+ align-items: center;
37
+ gap: 12px;
38
+ }
39
+
40
+ .header-actions {
41
+ display: flex;
42
+ gap: 12px;
43
+ align-items: center;
44
+ }
45
+
46
+ .btn {
47
+ padding: 8px 16px;
48
+ border: none;
49
+ border-radius: 6px;
50
+ font-size: 14px;
51
+ font-weight: 500;
52
+ cursor: pointer;
53
+ transition: all 0.2s;
54
+ text-decoration: none;
55
+ display: inline-block;
56
+ }
57
+
58
+ .btn-primary {
59
+ background: #1a73e8;
60
+ color: white;
61
+ }
62
+
63
+ .btn-primary:hover {
64
+ background: #1557b0;
65
+ }
66
+
67
+ .btn-secondary {
68
+ background: #f1f3f4;
69
+ color: #202124;
70
+ }
71
+
72
+ .btn-secondary:hover {
73
+ background: #e8eaed;
74
+ }
75
+
76
+ .container {
77
+ max-width: 1400px;
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
+ .filters {
117
+ display: flex;
118
+ gap: 12px;
119
+ margin-bottom: 20px;
120
+ flex-wrap: wrap;
121
+ }
122
+
123
+ .filter-group {
124
+ display: flex;
125
+ flex-direction: column;
126
+ gap: 4px;
127
+ }
128
+
129
+ .filter-group label {
130
+ font-size: 12px;
131
+ font-weight: 500;
132
+ color: #5f6368;
133
+ }
134
+
135
+ .filter-group select,
136
+ .filter-group input {
137
+ padding: 8px 12px;
138
+ border: 1px solid #dadce0;
139
+ border-radius: 6px;
140
+ font-size: 14px;
141
+ font-family: inherit;
142
+ }
143
+
144
+ .filter-group select:focus,
145
+ .filter-group input:focus {
146
+ outline: none;
147
+ border-color: #1a73e8;
148
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
149
+ }
150
+
151
+ table {
152
+ width: 100%;
153
+ border-collapse: collapse;
154
+ }
155
+
156
+ thead {
157
+ background: #f8f9fa;
158
+ }
159
+
160
+ th, td {
161
+ padding: 12px;
162
+ text-align: left;
163
+ border-bottom: 1px solid #e8eaed;
164
+ }
165
+
166
+ th {
167
+ font-weight: 500;
168
+ font-size: 14px;
169
+ color: #5f6368;
170
+ }
171
+
172
+ td {
173
+ font-size: 14px;
174
+ }
175
+
176
+ .message-content {
177
+ max-width: 500px;
178
+ overflow: hidden;
179
+ text-overflow: ellipsis;
180
+ white-space: nowrap;
181
+ }
182
+
183
+ .message-content-full {
184
+ max-width: none;
185
+ white-space: normal;
186
+ word-wrap: break-word;
187
+ }
188
+
189
+ .role-badge {
190
+ display: inline-block;
191
+ padding: 4px 8px;
192
+ border-radius: 4px;
193
+ font-size: 12px;
194
+ font-weight: 500;
195
+ }
196
+
197
+ .role-user {
198
+ background: #e8f0fe;
199
+ color: #1967d2;
200
+ }
201
+
202
+ .role-ai {
203
+ background: #e8f5e9;
204
+ color: #137333;
205
+ }
206
+
207
+ .pagination {
208
+ display: flex;
209
+ justify-content: center;
210
+ align-items: center;
211
+ gap: 8px;
212
+ margin-top: 20px;
213
+ }
214
+
215
+ .pagination button {
216
+ padding: 8px 12px;
217
+ border: 1px solid #dadce0;
218
+ background: white;
219
+ border-radius: 6px;
220
+ cursor: pointer;
221
+ font-size: 14px;
222
+ }
223
+
224
+ .pagination button:hover:not(:disabled) {
225
+ background: #f1f3f4;
226
+ }
227
+
228
+ .pagination button:disabled {
229
+ opacity: 0.5;
230
+ cursor: not-allowed;
231
+ }
232
+
233
+ .pagination .page-info {
234
+ padding: 8px 12px;
235
+ color: #5f6368;
236
+ }
237
+
238
+ .modal {
239
+ display: none;
240
+ position: fixed;
241
+ top: 0;
242
+ left: 0;
243
+ width: 100%;
244
+ height: 100%;
245
+ background: rgba(0, 0, 0, 0.5);
246
+ z-index: 1000;
247
+ align-items: center;
248
+ justify-content: center;
249
+ }
250
+
251
+ .modal.active {
252
+ display: flex;
253
+ }
254
+
255
+ .modal-content {
256
+ background: white;
257
+ border-radius: 8px;
258
+ padding: 24px;
259
+ width: 90%;
260
+ max-width: 800px;
261
+ max-height: 90vh;
262
+ overflow-y: auto;
263
+ }
264
+
265
+ .modal-header {
266
+ display: flex;
267
+ justify-content: space-between;
268
+ align-items: center;
269
+ margin-bottom: 20px;
270
+ }
271
+
272
+ .modal-title {
273
+ font-size: 20px;
274
+ font-weight: 500;
275
+ }
276
+
277
+ .modal-close {
278
+ background: none;
279
+ border: none;
280
+ font-size: 24px;
281
+ cursor: pointer;
282
+ color: #5f6368;
283
+ }
284
+
285
+ .message-view {
286
+ display: flex;
287
+ flex-direction: column;
288
+ gap: 16px;
289
+ }
290
+
291
+ .message-item {
292
+ padding: 12px;
293
+ border-radius: 8px;
294
+ border-left: 4px solid;
295
+ }
296
+
297
+ .message-item.user {
298
+ background: #e8f0fe;
299
+ border-color: #1a73e8;
300
+ }
301
+
302
+ .message-item.ai {
303
+ background: #e8f5e9;
304
+ border-color: #34a853;
305
+ }
306
+
307
+ .message-item-header {
308
+ display: flex;
309
+ justify-content: space-between;
310
+ align-items: center;
311
+ margin-bottom: 8px;
312
+ }
313
+
314
+ .message-item-role {
315
+ font-weight: 500;
316
+ font-size: 14px;
317
+ }
318
+
319
+ .message-item-time {
320
+ font-size: 12px;
321
+ color: #5f6368;
322
+ }
323
+
324
+ .message-item-content {
325
+ white-space: pre-wrap;
326
+ word-wrap: break-word;
327
+ line-height: 1.6;
328
+ }
329
+
330
+ .alert {
331
+ padding: 12px 16px;
332
+ border-radius: 6px;
333
+ margin-bottom: 16px;
334
+ font-size: 14px;
335
+ }
336
+
337
+ .alert.error {
338
+ background: #fce8e6;
339
+ color: #c5221f;
340
+ }
341
+ </style>
342
+ </head>
343
+ <body>
344
+ <div class="header">
345
+ <div class="header-title">
346
+ <span>๐Ÿค–</span>
347
+ <span>SOY NV AI ๋ฉ”์‹œ์ง€ ํ™•์ธ</span>
348
+ </div>
349
+ <div class="header-actions">
350
+ <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
351
+ <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">์‚ฌ์šฉ์ž ๊ด€๋ฆฌ</a>
352
+ <a href="{{ url_for('main.index') }}" class="btn btn-secondary">๋ฉ”์ธ์œผ๋กœ</a>
353
+ <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">๋กœ๊ทธ์•„์›ƒ</a>
354
+ </div>
355
+ </div>
356
+
357
+ <div class="container">
358
+ <div class="page-header">
359
+ <h1>์ „์ฒด ๋ฉ”์‹œ์ง€ ํ™•์ธ</h1>
360
+ <p>๋ชจ๋“  ์‚ฌ์šฉ์ž์˜ ๋Œ€ํ™” ๋ฉ”์‹œ์ง€๋ฅผ ํ™•์ธํ•  ์ˆ˜ ์žˆ์Šต๋‹ˆ๋‹ค.</p>
361
+ </div>
362
+
363
+ <div id="alertContainer"></div>
364
+
365
+ <!-- ๋Œ€ํ™” ์„ธ์…˜ ๋ชฉ๋ก -->
366
+ <div class="card">
367
+ <div class="card-header">
368
+ <div class="card-title">๋Œ€ํ™” ์„ธ์…˜ ๋ชฉ๋ก</div>
369
+ </div>
370
+
371
+ <div class="filters">
372
+ <div class="filter-group">
373
+ <label>์‚ฌ์šฉ์ž ํ•„ํ„ฐ</label>
374
+ <select id="userFilter">
375
+ <option value="">์ „์ฒด ์‚ฌ์šฉ์ž</option>
376
+ </select>
377
+ </div>
378
+ <div class="filter-group">
379
+ <label>๊ฒ€์ƒ‰</label>
380
+ <input type="text" id="searchInput" placeholder="์„ธ์…˜ ์ œ๋ชฉ ๊ฒ€์ƒ‰...">
381
+ </div>
382
+ <div class="filter-group" style="align-items: flex-end;">
383
+ <button class="btn btn-primary" onclick="loadSessions()">์กฐํšŒ</button>
384
+ </div>
385
+ </div>
386
+
387
+ <table>
388
+ <thead>
389
+ <tr>
390
+ <th>ID</th>
391
+ <th>์‚ฌ์šฉ์ž</th>
392
+ <th>์ œ๋ชฉ</th>
393
+ <th>๋ชจ๋ธ</th>
394
+ <th>๋ฉ”์‹œ์ง€ ์ˆ˜</th>
395
+ <th>์ƒ์„ฑ์ผ</th>
396
+ <th>์ˆ˜์ •์ผ</th>
397
+ <th>์ž‘์—…</th>
398
+ </tr>
399
+ </thead>
400
+ <tbody id="sessionsTableBody">
401
+ <tr>
402
+ <td colspan="8" style="text-align: center; padding: 20px; color: #5f6368;">๋กœ๋”ฉ ์ค‘...</td>
403
+ </tr>
404
+ </tbody>
405
+ </table>
406
+
407
+ <div class="pagination" id="sessionsPagination"></div>
408
+ </div>
409
+
410
+ <!-- ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก -->
411
+ <div class="card">
412
+ <div class="card-header">
413
+ <div class="card-title">๋ฉ”์‹œ์ง€ ๋ชฉ๋ก</div>
414
+ </div>
415
+
416
+ <div class="filters">
417
+ <div class="filter-group">
418
+ <label>์„ธ์…˜ ID</label>
419
+ <input type="number" id="sessionIdFilter" placeholder="์„ธ์…˜ ID">
420
+ </div>
421
+ <div class="filter-group" style="align-items: flex-end;">
422
+ <button class="btn btn-primary" onclick="loadMessages()">์กฐํšŒ</button>
423
+ </div>
424
+ </div>
425
+
426
+ <table>
427
+ <thead>
428
+ <tr>
429
+ <th>ID</th>
430
+ <th>์„ธ์…˜ ID</th>
431
+ <th>์—ญํ• </th>
432
+ <th>๋‚ด์šฉ</th>
433
+ <th>์‹œ๊ฐ„</th>
434
+ <th>์ž‘์—…</th>
435
+ </tr>
436
+ </thead>
437
+ <tbody id="messagesTableBody">
438
+ <tr>
439
+ <td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">์„ธ์…˜์„ ์„ ํƒํ•˜๊ฑฐ๋‚˜ ํ•„ํ„ฐ๋ฅผ ์‚ฌ์šฉํ•˜์„ธ์š”</td>
440
+ </tr>
441
+ </tbody>
442
+ </table>
443
+
444
+ <div class="pagination" id="messagesPagination"></div>
445
+ </div>
446
+ </div>
447
+
448
+ <!-- ๋ฉ”์‹œ์ง€ ์ƒ์„ธ ๋ณด๊ธฐ ๋ชจ๋‹ฌ -->
449
+ <div id="messageModal" class="modal">
450
+ <div class="modal-content">
451
+ <div class="modal-header">
452
+ <div class="modal-title">๋ฉ”์‹œ์ง€ ์ƒ์„ธ</div>
453
+ <button class="modal-close" onclick="closeMessageModal()">&times;</button>
454
+ </div>
455
+ <div id="messageModalContent"></div>
456
+ </div>
457
+ </div>
458
+
459
+ <script>
460
+ let currentSessionsPage = 1;
461
+ let currentMessagesPage = 1;
462
+ let selectedSessionId = null;
463
+
464
+ function showAlert(message, type = 'error') {
465
+ const container = document.getElementById('alertContainer');
466
+ container.innerHTML = `<div class="alert ${type}">${message}</div>`;
467
+ setTimeout(() => {
468
+ container.innerHTML = '';
469
+ }, 5000);
470
+ }
471
+
472
+ // ์‚ฌ์šฉ์ž ๋ชฉ๋ก ๋กœ๋“œ
473
+ async function loadUsers() {
474
+ try {
475
+ const response = await fetch('/api/admin/users');
476
+ const data = await response.json();
477
+
478
+ const userFilter = document.getElementById('userFilter');
479
+ userFilter.innerHTML = '<option value="">์ „์ฒด ์‚ฌ์šฉ์ž</option>';
480
+
481
+ if (data.users) {
482
+ data.users.forEach(user => {
483
+ const option = document.createElement('option');
484
+ option.value = user.id;
485
+ option.textContent = `${user.nickname || user.username} (${user.username})`;
486
+ userFilter.appendChild(option);
487
+ });
488
+ }
489
+ } catch (error) {
490
+ console.error('์‚ฌ์šฉ์ž ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
491
+ }
492
+ }
493
+
494
+ // ๋Œ€ํ™” ์„ธ์…˜ ๋ชฉ๋ก ๋กœ๋“œ
495
+ async function loadSessions(page = 1) {
496
+ try {
497
+ const userId = document.getElementById('userFilter').value;
498
+ const search = document.getElementById('searchInput').value;
499
+
500
+ let url = `/api/admin/sessions?page=${page}&per_page=20`;
501
+ if (userId) {
502
+ url += `&user_id=${userId}`;
503
+ }
504
+
505
+ const response = await fetch(url);
506
+ const data = await response.json();
507
+
508
+ const tbody = document.getElementById('sessionsTableBody');
509
+ tbody.innerHTML = '';
510
+
511
+ if (data.sessions && data.sessions.length > 0) {
512
+ data.sessions.forEach(session => {
513
+ const row = document.createElement('tr');
514
+ const createdDate = new Date(session.created_at).toLocaleString('ko-KR');
515
+ const updatedDate = new Date(session.updated_at).toLocaleString('ko-KR');
516
+
517
+ // ๊ฒ€์ƒ‰ ํ•„ํ„ฐ ์ ์šฉ
518
+ if (search && !session.title.toLowerCase().includes(search.toLowerCase())) {
519
+ return;
520
+ }
521
+
522
+ row.innerHTML = `
523
+ <td>${session.id}</td>
524
+ <td>${session.nickname || session.username || 'Unknown'}</td>
525
+ <td>${session.title || '-'}</td>
526
+ <td>${session.model_name || '-'}</td>
527
+ <td>${session.message_count || 0}</td>
528
+ <td>${createdDate}</td>
529
+ <td>${updatedDate}</td>
530
+ <td>
531
+ <button class="btn btn-secondary" onclick="viewSessionMessages(${session.id})" style="padding: 4px 8px; font-size: 12px;">๋ฉ”์‹œ์ง€ ๋ณด๊ธฐ</button>
532
+ </td>
533
+ `;
534
+ tbody.appendChild(row);
535
+ });
536
+ } else {
537
+ tbody.innerHTML = '<tr><td colspan="8" style="text-align: center; padding: 20px; color: #5f6368;">๋Œ€ํ™” ์„ธ์…˜์ด ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
538
+ }
539
+
540
+ // ํŽ˜์ด์ง€๋„ค์ด์…˜
541
+ updateSessionsPagination(data.current_page, data.pages);
542
+ currentSessionsPage = data.current_page;
543
+
544
+ } catch (error) {
545
+ showAlert(`๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์˜ค๋ฅ˜: ${error.message}`, 'error');
546
+ console.error('๋Œ€ํ™” ์„ธ์…˜ ์กฐํšŒ ์˜ค๋ฅ˜:', error);
547
+ }
548
+ }
549
+
550
+ // ๋ฉ”์‹œ์ง€ ๋ชฉ๋ก ๋กœ๋“œ
551
+ async function loadMessages(page = 1) {
552
+ try {
553
+ const sessionId = document.getElementById('sessionIdFilter').value;
554
+
555
+ let url = `/api/admin/messages?page=${page}&per_page=50`;
556
+ if (sessionId) {
557
+ url += `&session_id=${sessionId}`;
558
+ } else if (selectedSessionId) {
559
+ url += `&session_id=${selectedSessionId}`;
560
+ }
561
+
562
+ const response = await fetch(url);
563
+ const data = await response.json();
564
+
565
+ const tbody = document.getElementById('messagesTableBody');
566
+ tbody.innerHTML = '';
567
+
568
+ if (data.messages && data.messages.length > 0) {
569
+ data.messages.forEach(msg => {
570
+ const row = document.createElement('tr');
571
+ const date = new Date(msg.created_at).toLocaleString('ko-KR');
572
+ const contentPreview = msg.content.length > 100 ? msg.content.substring(0, 100) + '...' : msg.content;
573
+
574
+ row.innerHTML = `
575
+ <td>${msg.id}</td>
576
+ <td>${msg.session_id}</td>
577
+ <td><span class="role-badge role-${msg.role}">${msg.role === 'user' ? '์‚ฌ์šฉ์ž' : 'AI'}</span></td>
578
+ <td class="message-content">${escapeHtml(contentPreview)}</td>
579
+ <td>${date}</td>
580
+ <td>
581
+ <button class="btn btn-secondary" onclick="viewMessage(${msg.id}, '${msg.role}', ${JSON.stringify(msg.content).replace(/"/g, '&quot;')})" style="padding: 4px 8px; font-size: 12px;">์ƒ์„ธ ๋ณด๊ธฐ</button>
582
+ </td>
583
+ `;
584
+ tbody.appendChild(row);
585
+ });
586
+ } else {
587
+ tbody.innerHTML = '<tr><td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">๋ฉ”์‹œ์ง€๊ฐ€ ์—†์Šต๋‹ˆ๋‹ค.</td></tr>';
588
+ }
589
+
590
+ // ํŽ˜์ด์ง€๋„ค์ด์…˜
591
+ updateMessagesPagination(data.current_page, data.pages);
592
+ currentMessagesPage = data.current_page;
593
+
594
+ } catch (error) {
595
+ showAlert(`๋ฉ”์‹œ์ง€ ์กฐํšŒ ์˜ค๋ฅ˜: ${error.message}`, 'error');
596
+ console.error('๋ฉ”์‹œ์ง€ ์กฐํšŒ ์˜ค๋ฅ˜:', error);
597
+ }
598
+ }
599
+
600
+ // ์„ธ์…˜ ๋ฉ”์‹œ์ง€ ๋ณด๊ธฐ
601
+ function viewSessionMessages(sessionId) {
602
+ selectedSessionId = sessionId;
603
+ document.getElementById('sessionIdFilter').value = sessionId;
604
+ loadMessages(1);
605
+ }
606
+
607
+ // ๋ฉ”์‹œ์ง€ ์ƒ์„ธ ๋ณด๊ธฐ
608
+ function viewMessage(messageId, role, content) {
609
+ const modal = document.getElementById('messageModal');
610
+ const modalContent = document.getElementById('messageModalContent');
611
+
612
+ modalContent.innerHTML = `
613
+ <div class="message-view">
614
+ <div class="message-item ${role}">
615
+ <div class="message-item-header">
616
+ <span class="message-item-role role-badge role-${role}">${role === 'user' ? '์‚ฌ์šฉ์ž' : 'AI'}</span>
617
+ <span class="message-item-time">๋ฉ”์‹œ์ง€ ID: ${messageId}</span>
618
+ </div>
619
+ <div class="message-item-content">${escapeHtml(content)}</div>
620
+ </div>
621
+ </div>
622
+ `;
623
+
624
+ modal.classList.add('active');
625
+ }
626
+
627
+ function closeMessageModal() {
628
+ document.getElementById('messageModal').classList.remove('active');
629
+ }
630
+
631
+ // ํŽ˜์ด์ง€๋„ค์ด์…˜ ์—…๋ฐ์ดํŠธ
632
+ function updateSessionsPagination(currentPage, totalPages) {
633
+ const pagination = document.getElementById('sessionsPagination');
634
+ pagination.innerHTML = '';
635
+
636
+ pagination.appendChild(createPaginationButton('์ด์ „', currentPage > 1, () => loadSessions(currentPage - 1)));
637
+ pagination.appendChild(createPaginationInfo(`${currentPage} / ${totalPages}`));
638
+ pagination.appendChild(createPaginationButton('๋‹ค์Œ', currentPage < totalPages, () => loadSessions(currentPage + 1)));
639
+ }
640
+
641
+ function updateMessagesPagination(currentPage, totalPages) {
642
+ const pagination = document.getElementById('messagesPagination');
643
+ pagination.innerHTML = '';
644
+
645
+ pagination.appendChild(createPaginationButton('์ด์ „', currentPage > 1, () => loadMessages(currentPage - 1)));
646
+ pagination.appendChild(createPaginationInfo(`${currentPage} / ${totalPages}`));
647
+ pagination.appendChild(createPaginationButton('๋‹ค์Œ', currentPage < totalPages, () => loadMessages(currentPage + 1)));
648
+ }
649
+
650
+ function createPaginationButton(text, enabled, onClick) {
651
+ const button = document.createElement('button');
652
+ button.textContent = text;
653
+ button.disabled = !enabled;
654
+ if (enabled) {
655
+ button.onclick = onClick;
656
+ }
657
+ return button;
658
+ }
659
+
660
+ function createPaginationInfo(text) {
661
+ const span = document.createElement('span');
662
+ span.className = 'page-info';
663
+ span.textContent = text;
664
+ return span;
665
+ }
666
+
667
+ function escapeHtml(text) {
668
+ const div = document.createElement('div');
669
+ div.textContent = text;
670
+ return div.innerHTML;
671
+ }
672
+
673
+ // ๋ชจ๋‹ฌ ์™ธ๋ถ€ ํด๋ฆญ ์‹œ ๋‹ซ๊ธฐ
674
+ document.getElementById('messageModal').addEventListener('click', function(e) {
675
+ if (e.target === this) {
676
+ closeMessageModal();
677
+ }
678
+ });
679
+
680
+ // ๊ฒ€์ƒ‰ ์—”ํ„ฐ ํ‚ค
681
+ document.getElementById('searchInput').addEventListener('keypress', function(e) {
682
+ if (e.key === 'Enter') {
683
+ loadSessions(1);
684
+ }
685
+ });
686
+
687
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
688
+ window.addEventListener('load', () => {
689
+ loadUsers();
690
+ loadSessions();
691
+ });
692
+ </script>
693
+ </body>
694
+ </html>
695
+
templates/index.html CHANGED
@@ -4,60 +4,1391 @@
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SOY NV AI</title>
 
 
7
  <style>
8
  * {
9
  margin: 0;
10
  padding: 0;
11
  box-sizing: border-box;
12
  }
13
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
14
  body {
15
- font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, 'Helvetica Neue', Arial, sans-serif;
16
- background-color: #f5f5f5;
17
- color: #333;
 
 
 
 
18
  }
19
-
20
- .container {
21
- max-width: 1200px;
22
- margin: 0 auto;
23
- padding: 20px;
 
 
 
 
 
 
24
  }
25
-
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
26
  .header {
27
- background: white;
28
- padding: 30px;
29
- border-radius: 10px;
30
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
31
- margin-bottom: 30px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  text-align: center;
 
 
33
  }
34
-
35
- .header h1 {
36
- color: #2563eb;
37
- font-size: 2.5em;
38
- margin-bottom: 10px;
39
  }
40
-
41
- .content {
42
- background: white;
43
- padding: 40px;
44
- border-radius: 10px;
45
- box-shadow: 0 2px 10px rgba(0,0,0,0.1);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  }
47
  </style>
48
  </head>
49
  <body>
50
- <div class="container">
51
- <div class="header">
52
- <h1>SOY NV AI</h1>
53
- <p>์‹ ๊ทœ ํ”„๋กœ์ ํŠธ</p>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
54
  </div>
55
 
56
- <div class="content">
57
- <h2>ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค!</h2>
58
- <p>์ด ํ”„๋กœ์ ํŠธ๋Š” SOY NV AI์ž…๋‹ˆ๋‹ค.</p>
 
 
 
 
 
59
  </div>
60
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
61
  </body>
62
  </html>
63
-
 
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
  <title>SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
  <style>
10
  * {
11
  margin: 0;
12
  padding: 0;
13
  box-sizing: border-box;
14
  }
15
+
16
+ :root {
17
+ --bg-primary: #ffffff;
18
+ --bg-secondary: #f8f9fa;
19
+ --bg-tertiary: #f1f3f4;
20
+ --text-primary: #202124;
21
+ --text-secondary: #5f6368;
22
+ --accent: #1a73e8;
23
+ --accent-hover: #1557b0;
24
+ --border: #dadce0;
25
+ --user-bg: #e8f0fe;
26
+ --ai-bg: #f1f3f4;
27
+ --shadow: rgba(0, 0, 0, 0.1);
28
+ }
29
+
30
  body {
31
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
32
+ background: var(--bg-primary);
33
+ color: var(--text-primary);
34
+ height: 100vh;
35
+ overflow: hidden;
36
+ display: flex;
37
+ flex-direction: row;
38
  }
39
+
40
+ /* ์‚ฌ์ด๋“œ๋ฐ” */
41
+ .sidebar {
42
+ width: 280px;
43
+ background: var(--bg-secondary);
44
+ border-right: 1px solid var(--border);
45
+ display: flex;
46
+ flex-direction: column;
47
+ height: 100vh;
48
+ transition: width 0.3s ease;
49
+ flex-shrink: 0;
50
  }
51
+
52
+ .sidebar.collapsed {
53
+ width: 0;
54
+ overflow: hidden;
55
+ border-right: none;
56
+ }
57
+
58
+ .sidebar-header {
59
+ padding: 16px;
60
+ border-bottom: 1px solid var(--border);
61
+ display: flex;
62
+ align-items: center;
63
+ justify-content: space-between;
64
+ min-height: 64px;
65
+ }
66
+
67
+ .sidebar-title {
68
+ font-size: 18px;
69
+ font-weight: 500;
70
+ color: var(--text-primary);
71
+ display: flex;
72
+ align-items: center;
73
+ gap: 8px;
74
+ }
75
+
76
+ .sidebar-toggle {
77
+ background: none;
78
+ border: none;
79
+ padding: 8px;
80
+ cursor: pointer;
81
+ border-radius: 50%;
82
+ color: var(--text-secondary);
83
+ transition: background 0.2s;
84
+ display: flex;
85
+ align-items: center;
86
+ justify-content: center;
87
+ }
88
+
89
+ .sidebar-toggle:hover {
90
+ background: var(--bg-tertiary);
91
+ }
92
+
93
+ .new-chat-button {
94
+ margin: 12px 16px;
95
+ padding: 12px 16px;
96
+ background: var(--accent);
97
+ color: white;
98
+ border: none;
99
+ border-radius: 24px;
100
+ font-size: 14px;
101
+ font-weight: 500;
102
+ cursor: pointer;
103
+ display: flex;
104
+ align-items: center;
105
+ gap: 8px;
106
+ transition: background 0.2s;
107
+ }
108
+
109
+ .new-chat-button:hover {
110
+ background: var(--accent-hover);
111
+ }
112
+
113
+ .new-chat-button svg {
114
+ width: 18px;
115
+ height: 18px;
116
+ }
117
+
118
+ .chat-history {
119
+ flex: 1;
120
+ overflow-y: auto;
121
+ padding: 8px;
122
+ }
123
+
124
+ .chat-history::-webkit-scrollbar {
125
+ width: 6px;
126
+ }
127
+
128
+ .chat-history::-webkit-scrollbar-track {
129
+ background: transparent;
130
+ }
131
+
132
+ .chat-history::-webkit-scrollbar-thumb {
133
+ background: var(--border);
134
+ border-radius: 3px;
135
+ }
136
+
137
+ .chat-history::-webkit-scrollbar-thumb:hover {
138
+ background: var(--text-secondary);
139
+ }
140
+
141
+ .chat-item {
142
+ padding: 12px 16px;
143
+ margin: 4px 0;
144
+ border-radius: 12px;
145
+ cursor: pointer;
146
+ transition: background 0.2s;
147
+ display: flex;
148
+ align-items: center;
149
+ gap: 12px;
150
+ color: var(--text-primary);
151
+ text-decoration: none;
152
+ }
153
+
154
+ .chat-item:hover {
155
+ background: var(--bg-tertiary);
156
+ }
157
+
158
+ .chat-item.active {
159
+ background: var(--accent);
160
+ color: white;
161
+ }
162
+
163
+ .chat-item-icon {
164
+ width: 20px;
165
+ height: 20px;
166
+ flex-shrink: 0;
167
+ }
168
+
169
+ .chat-item-title {
170
+ flex: 1;
171
+ font-size: 14px;
172
+ overflow: hidden;
173
+ text-overflow: ellipsis;
174
+ white-space: nowrap;
175
+ }
176
+
177
+ .chat-item-time {
178
+ font-size: 12px;
179
+ opacity: 0.7;
180
+ flex-shrink: 0;
181
+ }
182
+
183
+ .chat-item.active .chat-item-time {
184
+ opacity: 0.9;
185
+ }
186
+
187
+ /* AI ๋ชจ๋ธ ์„ ํƒ ์˜์—ญ */
188
+ .model-selector {
189
+ border-top: 1px solid var(--border);
190
+ padding: 16px;
191
+ background: var(--bg-primary);
192
+ }
193
+
194
+ .model-selector-label {
195
+ font-size: 12px;
196
+ font-weight: 500;
197
+ color: var(--text-secondary);
198
+ margin-bottom: 8px;
199
+ display: flex;
200
+ align-items: center;
201
+ gap: 6px;
202
+ }
203
+
204
+ .model-selector-label svg {
205
+ width: 16px;
206
+ height: 16px;
207
+ }
208
+
209
+ .model-select {
210
+ width: 100%;
211
+ padding: 10px 12px;
212
+ border: 1px solid var(--border);
213
+ border-radius: 8px;
214
+ background: var(--bg-primary);
215
+ color: var(--text-primary);
216
+ font-size: 14px;
217
+ font-family: inherit;
218
+ cursor: pointer;
219
+ transition: border-color 0.2s, box-shadow 0.2s;
220
+ }
221
+
222
+ .model-select:focus {
223
+ outline: none;
224
+ border-color: var(--accent);
225
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
226
+ }
227
+
228
+ .model-status {
229
+ margin-top: 8px;
230
+ font-size: 11px;
231
+ color: var(--text-secondary);
232
+ display: flex;
233
+ align-items: center;
234
+ gap: 4px;
235
+ }
236
+
237
+ .model-status.connected {
238
+ color: #34a853;
239
+ }
240
+
241
+ .model-status.error {
242
+ color: #ea4335;
243
+ }
244
+
245
+ .model-status-dot {
246
+ width: 6px;
247
+ height: 6px;
248
+ border-radius: 50%;
249
+ background: currentColor;
250
+ }
251
+
252
+ .refresh-models-btn {
253
+ margin-top: 8px;
254
+ width: 100%;
255
+ padding: 8px;
256
+ background: var(--bg-tertiary);
257
+ border: 1px solid var(--border);
258
+ border-radius: 6px;
259
+ color: var(--text-primary);
260
+ font-size: 12px;
261
+ cursor: pointer;
262
+ transition: background 0.2s;
263
+ display: flex;
264
+ align-items: center;
265
+ justify-content: center;
266
+ gap: 6px;
267
+ }
268
+
269
+ .refresh-models-btn:hover {
270
+ background: var(--border);
271
+ }
272
+
273
+ .refresh-models-btn:disabled {
274
+ opacity: 0.5;
275
+ cursor: not-allowed;
276
+ }
277
+
278
+ .refresh-models-btn svg {
279
+ width: 14px;
280
+ height: 14px;
281
+ }
282
+
283
+ /* ์›น์†Œ์„ค ์„ ํƒ ์˜์—ญ */
284
+ .novel-selector {
285
+ border-top: 1px solid var(--border);
286
+ padding: 16px;
287
+ background: var(--bg-primary);
288
+ max-height: 300px;
289
+ display: flex;
290
+ flex-direction: column;
291
+ }
292
+
293
+ .novel-selector-label {
294
+ font-size: 12px;
295
+ font-weight: 500;
296
+ color: var(--text-secondary);
297
+ margin-bottom: 12px;
298
+ display: flex;
299
+ align-items: center;
300
+ gap: 6px;
301
+ }
302
+
303
+ .novel-selector-label svg {
304
+ width: 16px;
305
+ height: 16px;
306
+ }
307
+
308
+ .novel-list {
309
+ max-height: 200px;
310
+ overflow-y: auto;
311
+ display: flex;
312
+ flex-direction: column;
313
+ gap: 8px;
314
+ }
315
+
316
+ .novel-list::-webkit-scrollbar {
317
+ width: 4px;
318
+ }
319
+
320
+ .novel-list::-webkit-scrollbar-thumb {
321
+ background: var(--border);
322
+ border-radius: 2px;
323
+ }
324
+
325
+ .novel-item {
326
+ display: flex;
327
+ align-items: center;
328
+ gap: 8px;
329
+ padding: 8px;
330
+ background: var(--bg-secondary);
331
+ border-radius: 6px;
332
+ cursor: pointer;
333
+ transition: background 0.2s;
334
+ }
335
+
336
+ .novel-item:hover {
337
+ background: var(--bg-tertiary);
338
+ }
339
+
340
+ .novel-item input[type="checkbox"] {
341
+ width: 16px;
342
+ height: 16px;
343
+ cursor: pointer;
344
+ flex-shrink: 0;
345
+ }
346
+
347
+ .novel-item-name {
348
+ flex: 1;
349
+ font-size: 12px;
350
+ color: var(--text-primary);
351
+ overflow: hidden;
352
+ text-overflow: ellipsis;
353
+ white-space: nowrap;
354
+ }
355
+
356
+ .novel-item-empty {
357
+ padding: 16px;
358
+ text-align: center;
359
+ color: var(--text-secondary);
360
+ font-size: 12px;
361
+ }
362
+
363
+ .selected-novels-info {
364
+ margin-top: 8px;
365
+ font-size: 11px;
366
+ color: var(--text-secondary);
367
+ }
368
+
369
+ .selected-novels-info.has-selection {
370
+ color: var(--accent);
371
+ }
372
+
373
+ /* ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ */
374
+ .sidebar-footer {
375
+ border-top: 1px solid var(--border);
376
+ padding: 16px;
377
+ background: var(--bg-primary);
378
+ margin-top: auto;
379
+ }
380
+
381
+ .logout-button {
382
+ width: 100%;
383
+ padding: 12px 16px;
384
+ background: var(--bg-secondary);
385
+ border: 1px solid var(--border);
386
+ border-radius: 8px;
387
+ color: var(--text-primary);
388
+ font-size: 14px;
389
+ font-weight: 500;
390
+ cursor: pointer;
391
+ transition: background 0.2s, border-color 0.2s;
392
+ display: flex;
393
+ align-items: center;
394
+ justify-content: center;
395
+ gap: 8px;
396
+ text-decoration: none;
397
+ }
398
+
399
+ .logout-button:hover {
400
+ background: var(--bg-tertiary);
401
+ border-color: var(--accent);
402
+ }
403
+
404
+ .logout-button svg {
405
+ width: 18px;
406
+ height: 18px;
407
+ }
408
+
409
+ /* ๋ฉ”์ธ ์ฝ˜ํ…์ธ  ์˜์—ญ */
410
+ .main-content {
411
+ flex: 1;
412
+ display: flex;
413
+ flex-direction: column;
414
+ height: 100vh;
415
+ overflow: hidden;
416
+ }
417
+
418
+ /* ํ—ค๋” */
419
  .header {
420
+ background: var(--bg-primary);
421
+ border-bottom: 1px solid var(--border);
422
+ padding: 16px 24px;
423
+ display: flex;
424
+ align-items: center;
425
+ justify-content: space-between;
426
+ box-shadow: 0 1px 2px var(--shadow);
427
+ z-index: 10;
428
+ }
429
+
430
+ .header-title {
431
+ font-size: 20px;
432
+ font-weight: 500;
433
+ color: var(--text-primary);
434
+ display: flex;
435
+ align-items: center;
436
+ gap: 12px;
437
+ }
438
+
439
+ .header-title::before {
440
+ content: '๐Ÿค–';
441
+ font-size: 24px;
442
+ }
443
+
444
+ .header-actions {
445
+ display: flex;
446
+ gap: 12px;
447
+ align-items: center;
448
+ }
449
+
450
+ .btn-icon {
451
+ background: none;
452
+ border: none;
453
+ padding: 8px;
454
+ cursor: pointer;
455
+ border-radius: 50%;
456
+ color: var(--text-secondary);
457
+ transition: background 0.2s;
458
+ display: flex;
459
+ align-items: center;
460
+ justify-content: center;
461
+ }
462
+
463
+ .btn-icon:hover {
464
+ background: var(--bg-tertiary);
465
+ }
466
+
467
+ /* ์ฑ„ํŒ… ์˜์—ญ */
468
+ .chat-container {
469
+ flex: 1;
470
+ overflow-y: auto;
471
+ padding: 24px;
472
+ display: flex;
473
+ flex-direction: column;
474
+ gap: 16px;
475
+ background: var(--bg-primary);
476
+ }
477
+
478
+ .chat-container::-webkit-scrollbar {
479
+ width: 8px;
480
+ }
481
+
482
+ .chat-container::-webkit-scrollbar-track {
483
+ background: transparent;
484
+ }
485
+
486
+ .chat-container::-webkit-scrollbar-thumb {
487
+ background: var(--border);
488
+ border-radius: 4px;
489
+ }
490
+
491
+ .chat-container::-webkit-scrollbar-thumb:hover {
492
+ background: var(--text-secondary);
493
+ }
494
+
495
+ /* ๋ฉ”์‹œ์ง€ */
496
+ .message {
497
+ display: flex;
498
+ gap: 12px;
499
+ max-width: 800px;
500
+ animation: fadeIn 0.3s ease-in;
501
+ }
502
+
503
+ @keyframes fadeIn {
504
+ from {
505
+ opacity: 0;
506
+ transform: translateY(10px);
507
+ }
508
+ to {
509
+ opacity: 1;
510
+ transform: translateY(0);
511
+ }
512
+ }
513
+
514
+ .message.user {
515
+ align-self: flex-end;
516
+ flex-direction: row-reverse;
517
+ }
518
+
519
+ .message-avatar {
520
+ width: 32px;
521
+ height: 32px;
522
+ border-radius: 50%;
523
+ display: flex;
524
+ align-items: center;
525
+ justify-content: center;
526
+ font-size: 16px;
527
+ flex-shrink: 0;
528
+ }
529
+
530
+ .message.user .message-avatar {
531
+ background: var(--accent);
532
+ color: white;
533
+ }
534
+
535
+ .message.ai .message-avatar {
536
+ background: var(--bg-tertiary);
537
+ color: var(--text-primary);
538
+ }
539
+
540
+ .message-content {
541
+ flex: 1;
542
+ display: flex;
543
+ flex-direction: column;
544
+ gap: 4px;
545
+ }
546
+
547
+ .message-bubble {
548
+ padding: 12px 16px;
549
+ border-radius: 18px;
550
+ line-height: 1.5;
551
+ font-size: 15px;
552
+ word-wrap: break-word;
553
+ white-space: pre-wrap;
554
+ }
555
+
556
+ .message.user .message-bubble {
557
+ background: var(--accent);
558
+ color: white;
559
+ border-bottom-right-radius: 4px;
560
+ }
561
+
562
+ .message.ai .message-bubble {
563
+ background: var(--ai-bg);
564
+ color: var(--text-primary);
565
+ border-bottom-left-radius: 4px;
566
+ }
567
+
568
+ .message-time {
569
+ font-size: 12px;
570
+ color: var(--text-secondary);
571
+ padding: 0 4px;
572
+ }
573
+
574
+ .message.user .message-time {
575
+ text-align: right;
576
+ }
577
+
578
+ /* ์ž…๋ ฅ ์˜์—ญ */
579
+ .input-container {
580
+ background: var(--bg-primary);
581
+ border-top: 1px solid var(--border);
582
+ padding: 16px 24px;
583
+ display: flex;
584
+ align-items: flex-end;
585
+ gap: 12px;
586
+ }
587
+
588
+ .input-wrapper {
589
+ flex: 1;
590
+ position: relative;
591
+ background: var(--bg-secondary);
592
+ border: 1px solid var(--border);
593
+ border-radius: 24px;
594
+ padding: 12px 16px;
595
+ display: flex;
596
+ align-items: center;
597
+ gap: 8px;
598
+ transition: border-color 0.2s, box-shadow 0.2s;
599
+ }
600
+
601
+ .input-wrapper:focus-within {
602
+ border-color: var(--accent);
603
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
604
+ }
605
+
606
+ #messageInput {
607
+ flex: 1;
608
+ border: none;
609
+ outline: none;
610
+ background: transparent;
611
+ font-size: 15px;
612
+ font-family: inherit;
613
+ color: var(--text-primary);
614
+ resize: none;
615
+ max-height: 200px;
616
+ min-height: 24px;
617
+ line-height: 1.5;
618
+ }
619
+
620
+ #messageInput::placeholder {
621
+ color: var(--text-secondary);
622
+ }
623
+
624
+ .send-button {
625
+ background: var(--accent);
626
+ color: white;
627
+ border: none;
628
+ border-radius: 50%;
629
+ width: 40px;
630
+ height: 40px;
631
+ display: flex;
632
+ align-items: center;
633
+ justify-content: center;
634
+ cursor: pointer;
635
+ transition: background 0.2s, transform 0.1s;
636
+ flex-shrink: 0;
637
+ }
638
+
639
+ .send-button:hover:not(:disabled) {
640
+ background: var(--accent-hover);
641
+ transform: scale(1.05);
642
+ }
643
+
644
+ .send-button:disabled {
645
+ opacity: 0.5;
646
+ cursor: not-allowed;
647
+ }
648
+
649
+ .send-button svg {
650
+ width: 20px;
651
+ height: 20px;
652
+ }
653
+
654
+ /* ๋กœ๋”ฉ ์ธ๋””์ผ€์ดํ„ฐ */
655
+ .typing-indicator {
656
+ display: flex;
657
+ gap: 4px;
658
+ padding: 12px 16px;
659
+ background: var(--ai-bg);
660
+ border-radius: 18px;
661
+ border-bottom-left-radius: 4px;
662
+ width: fit-content;
663
+ }
664
+
665
+ .typing-dot {
666
+ width: 8px;
667
+ height: 8px;
668
+ border-radius: 50%;
669
+ background: var(--text-secondary);
670
+ animation: typing 1.4s infinite;
671
+ }
672
+
673
+ .typing-dot:nth-child(2) {
674
+ animation-delay: 0.2s;
675
+ }
676
+
677
+ .typing-dot:nth-child(3) {
678
+ animation-delay: 0.4s;
679
+ }
680
+
681
+ @keyframes typing {
682
+ 0%, 60%, 100% {
683
+ transform: translateY(0);
684
+ opacity: 0.7;
685
+ }
686
+ 30% {
687
+ transform: translateY(-10px);
688
+ opacity: 1;
689
+ }
690
+ }
691
+
692
+ /* ๋นˆ ์ƒํƒœ */
693
+ .empty-state {
694
+ flex: 1;
695
+ display: flex;
696
+ flex-direction: column;
697
+ align-items: center;
698
+ justify-content: center;
699
  text-align: center;
700
+ padding: 48px 24px;
701
+ color: var(--text-secondary);
702
  }
703
+
704
+ .empty-state-icon {
705
+ font-size: 64px;
706
+ margin-bottom: 16px;
707
+ opacity: 0.5;
708
  }
709
+
710
+ .empty-state-title {
711
+ font-size: 24px;
712
+ font-weight: 500;
713
+ color: var(--text-primary);
714
+ margin-bottom: 8px;
715
+ }
716
+
717
+ .empty-state-description {
718
+ font-size: 15px;
719
+ max-width: 500px;
720
+ }
721
+
722
+ /* ๋ฐ˜์‘ํ˜• */
723
+ @media (max-width: 768px) {
724
+ .sidebar {
725
+ position: fixed;
726
+ left: 0;
727
+ top: 0;
728
+ z-index: 1000;
729
+ box-shadow: 2px 0 8px var(--shadow);
730
+ }
731
+
732
+ .sidebar.collapsed {
733
+ width: 0;
734
+ }
735
+
736
+ .main-content {
737
+ width: 100%;
738
+ }
739
+
740
+ .header {
741
+ padding: 12px 16px;
742
+ }
743
+
744
+ .header-title {
745
+ font-size: 18px;
746
+ }
747
+
748
+ .chat-container {
749
+ padding: 16px;
750
+ }
751
+
752
+ .message {
753
+ max-width: 100%;
754
+ }
755
+
756
+ .input-container {
757
+ padding: 12px 16px;
758
+ }
759
  }
760
  </style>
761
  </head>
762
  <body>
763
+ <!-- ์‚ฌ์ด๋“œ๋ฐ” -->
764
+ <div class="sidebar" id="sidebar">
765
+ <div class="sidebar-header">
766
+ <div class="sidebar-title">
767
+ <span>๐Ÿค–</span>
768
+ <span>SOY NV AI</span>
769
+ </div>
770
+ <button class="sidebar-toggle" onclick="toggleSidebar()" title="์‚ฌ์ด๋“œ๋ฐ” ์ ‘๊ธฐ">
771
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
772
+ <path d="M9 18l-6-6 6-6M21 18l-6-6 6-6"/>
773
+ </svg>
774
+ </button>
775
+ </div>
776
+ <button class="new-chat-button" onclick="startNewChat()">
777
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
778
+ <path d="M12 5v14M5 12h14"/>
779
+ </svg>
780
+ ์ƒˆ ๋Œ€ํ™”
781
+ </button>
782
+ <div class="chat-history" id="chatHistory">
783
+ <!-- ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ํ•ญ๋ชฉ๋“ค์ด ์—ฌ๊ธฐ์— ๋™์ ์œผ๋กœ ์ถ”๊ฐ€๋ฉ๋‹ˆ๋‹ค -->
784
+ </div>
785
+
786
+ <!-- AI ๋ชจ๋ธ ์„ ํƒ ์˜์—ญ -->
787
+ <div class="model-selector">
788
+ <div class="model-selector-label">
789
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
790
+ <path d="M12 2L2 7l10 5 10-5-10-5zM2 17l10 5 10-5M2 12l10 5 10-5"/>
791
+ </svg>
792
+ ๋กœ์ปฌ AI ๋ชจ๋ธ
793
+ </div>
794
+ <select class="model-select" id="modelSelect">
795
+ <option value="">๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”...</option>
796
+ </select>
797
+ <div class="model-status" id="modelStatus">
798
+ <span class="model-status-dot"></span>
799
+ <span>์—ฐ๊ฒฐ ์•ˆ ๋จ</span>
800
+ </div>
801
+ <button class="refresh-models-btn" id="refreshModelsBtn" onclick="loadModels()">
802
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
803
+ <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"/>
804
+ </svg>
805
+ ๋ชจ๋ธ ์ƒˆ๋กœ๊ณ ์นจ
806
+ </button>
807
+ </div>
808
+
809
+ <!-- ์›น์†Œ์„ค ์„ ํƒ ์˜์—ญ -->
810
+ <div class="novel-selector">
811
+ <div class="novel-selector-label">
812
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
813
+ <path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8"/>
814
+ </svg>
815
+ ํ•™์Šตํ•  ์›น์†Œ์„ค ์„ ํƒ
816
+ </div>
817
+ <div class="novel-list" id="novelList">
818
+ <div class="novel-item-empty">๋ชจ๋ธ์„ ์„ ํƒํ•˜๋ฉด ์›น์†Œ์„ค ๋ชฉ๋ก์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค</div>
819
+ </div>
820
+ <div class="selected-novels-info" id="selectedNovelsInfo"></div>
821
  </div>
822
 
823
+ <!-- ๋กœ๊ทธ์•„์›ƒ ๋ฒ„ํŠผ -->
824
+ <div class="sidebar-footer">
825
+ <a href="{{ url_for('main.logout') }}" class="logout-button" title="๋กœ๊ทธ์•„์›ƒ">
826
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
827
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
828
+ </svg>
829
+ <span>๋กœ๊ทธ์•„์›ƒ</span>
830
+ </a>
831
  </div>
832
  </div>
833
+
834
+ <!-- ๋ฉ”์ธ ์ฝ˜ํ…์ธ  -->
835
+ <div class="main-content">
836
+ <!-- ํ—ค๋” -->
837
+ <div class="header">
838
+ <div class="header-title">
839
+ <button class="btn-icon" onclick="toggleSidebar()" title="์‚ฌ์ด๋“œ๋ฐ” ์—ด๊ธฐ" id="sidebarToggleBtn" style="display: none;">
840
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
841
+ <path d="M3 12h18M3 6h18M3 18h18"/>
842
+ </svg>
843
+ </button>
844
+ <span>SOY NV AI</span>
845
+ </div>
846
+ <div class="header-actions">
847
+ {% if current_user.is_admin %}
848
+ <a href="{{ url_for('main.admin') }}" class="btn-icon" title="๊ด€๋ฆฌ์ž ํŽ˜์ด์ง€" style="text-decoration: none; color: var(--text-secondary);">
849
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
850
+ <path d="M12 15v2m-6 4h12a2 2 0 0 0 2-2v-6a2 2 0 0 0-2-2H6a2 2 0 0 0-2 2v6a2 2 0 0 0 2 2zm10-10V7a4 4 0 0 0-8 0v4h8z"/>
851
+ </svg>
852
+ </a>
853
+ {% endif %}
854
+ <span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
855
+ <a href="{{ url_for('main.logout') }}" class="btn-icon" title="๋กœ๊ทธ์•„์›ƒ" style="text-decoration: none; color: var(--text-secondary);">
856
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
857
+ <path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4M16 17l5-5-5-5M21 12H9"/>
858
+ </svg>
859
+ </a>
860
+ <button class="btn-icon" onclick="clearChat()" title="๋Œ€ํ™” ์ดˆ๊ธฐํ™”">
861
+ <svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
862
+ <path d="M3 6h18M8 6V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2m3 0v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6h14z"/>
863
+ </svg>
864
+ </button>
865
+ </div>
866
+ </div>
867
+
868
+ <!-- ์ฑ„ํŒ… ์˜์—ญ -->
869
+ <div class="chat-container" id="chatContainer">
870
+ <div class="empty-state" id="emptyState">
871
+ <div class="empty-state-icon">๐Ÿ’ฌ</div>
872
+ <div class="empty-state-title">SOY NV AI์— ์˜ค์‹  ๊ฒƒ์„ ํ™˜์˜ํ•ฉ๋‹ˆ๋‹ค</div>
873
+ <div class="empty-state-description">
874
+ ๋ฌด์—‡์ด๋“  ๋ฌผ์–ด๋ณด์„ธ์š”. AI๊ฐ€ ๋„์™€๋“œ๋ฆฌ๊ฒ ์Šต๋‹ˆ๋‹ค.
875
+ </div>
876
+ </div>
877
+ </div>
878
+
879
+ <!-- ์ž…๋ ฅ ์˜์—ญ -->
880
+ <div class="input-container">
881
+ <div class="input-wrapper">
882
+ <textarea
883
+ id="messageInput"
884
+ placeholder="๋ฉ”์‹œ์ง€๋ฅผ ์ž…๋ ฅํ•˜์„ธ์š”..."
885
+ rows="1"
886
+ ></textarea>
887
+ <button class="send-button" id="sendButton" onclick="sendMessage()">
888
+ <svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
889
+ <path d="M22 2L11 13M22 2l-7 20-4-9-9-4 20-7z"/>
890
+ </svg>
891
+ </button>
892
+ </div>
893
+ </div>
894
+ </div>
895
+
896
+ <script>
897
+ const chatContainer = document.getElementById('chatContainer');
898
+ const messageInput = document.getElementById('messageInput');
899
+ const sendButton = document.getElementById('sendButton');
900
+ const emptyState = document.getElementById('emptyState');
901
+ const sidebar = document.getElementById('sidebar');
902
+ const chatHistory = document.getElementById('chatHistory');
903
+ const sidebarToggleBtn = document.getElementById('sidebarToggleBtn');
904
+ const modelSelect = document.getElementById('modelSelect');
905
+ const modelStatus = document.getElementById('modelStatus');
906
+ const refreshModelsBtn = document.getElementById('refreshModelsBtn');
907
+
908
+ let currentChatId = null;
909
+ let currentSessionId = null;
910
+ let chatSessions = [];
911
+ let selectedModel = localStorage.getItem('selectedModel') || '';
912
+ let selectedFileIds = JSON.parse(localStorage.getItem('selectedFileIds') || '[]');
913
+
914
+ const novelList = document.getElementById('novelList');
915
+ const selectedNovelsInfo = document.getElementById('selectedNovelsInfo');
916
+
917
+ // ๋ชจ๋ธ ์„ ํƒ ์ด๋ฒคํŠธ
918
+ modelSelect.addEventListener('change', function() {
919
+ selectedModel = this.value;
920
+ localStorage.setItem('selectedModel', selectedModel);
921
+ updateModelStatus();
922
+ loadNovels(); // ๋ชจ๋ธ ๋ณ€๊ฒฝ ์‹œ ์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ
923
+ });
924
+
925
+ // ์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ
926
+ async function loadNovels() {
927
+ if (!selectedModel) {
928
+ novelList.innerHTML = '<div class="novel-item-empty">๋ชจ๋ธ์„ ์„ ํƒํ•˜๋ฉด ์›น์†Œ์„ค ๋ชฉ๋ก์ด ํ‘œ์‹œ๋ฉ๋‹ˆ๋‹ค</div>';
929
+ selectedNovelsInfo.textContent = '';
930
+ return;
931
+ }
932
+
933
+ try {
934
+ const response = await fetch(`/api/files?model_name=${encodeURIComponent(selectedModel)}`);
935
+ const data = await response.json();
936
+
937
+ novelList.innerHTML = '';
938
+
939
+ if (data.files && data.files.length > 0) {
940
+ data.files.forEach(file => {
941
+ const novelItem = document.createElement('div');
942
+ novelItem.className = 'novel-item';
943
+
944
+ const checkbox = document.createElement('input');
945
+ checkbox.type = 'checkbox';
946
+ checkbox.id = `novel-${file.id}`;
947
+ checkbox.value = file.id;
948
+ checkbox.checked = selectedFileIds.includes(file.id);
949
+ checkbox.addEventListener('change', updateSelectedNovels);
950
+
951
+ const label = document.createElement('label');
952
+ label.className = 'novel-item-name';
953
+ label.htmlFor = `novel-${file.id}`;
954
+ label.textContent = file.original_filename;
955
+ label.title = file.original_filename;
956
+
957
+ novelItem.appendChild(checkbox);
958
+ novelItem.appendChild(label);
959
+ novelList.appendChild(novelItem);
960
+ });
961
+ updateSelectedNovelsInfo();
962
+ } else {
963
+ novelList.innerHTML = '<div class="novel-item-empty">์—…๋กœ๋“œ๋œ ์›น์†Œ์„ค์ด ์—†์Šต๋‹ˆ๋‹ค</div>';
964
+ selectedNovelsInfo.textContent = '';
965
+ }
966
+ } catch (error) {
967
+ console.error('์›น์†Œ์„ค ๋ชฉ๋ก ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
968
+ novelList.innerHTML = '<div class="novel-item-empty">์›น์†Œ์„ค ๋ชฉ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค</div>';
969
+ }
970
+ }
971
+
972
+ // ์„ ํƒ๋œ ์›น์†Œ์„ค ์—…๋ฐ์ดํŠธ
973
+ function updateSelectedNovels() {
974
+ const checkboxes = novelList.querySelectorAll('input[type="checkbox"]');
975
+ selectedFileIds = Array.from(checkboxes)
976
+ .filter(cb => cb.checked)
977
+ .map(cb => parseInt(cb.value));
978
+
979
+ localStorage.setItem('selectedFileIds', JSON.stringify(selectedFileIds));
980
+ updateSelectedNovelsInfo();
981
+ }
982
+
983
+ // ์„ ํƒ๋œ ์›น์†Œ์„ค ์ •๋ณด ํ‘œ์‹œ
984
+ function updateSelectedNovelsInfo() {
985
+ if (selectedFileIds.length === 0) {
986
+ selectedNovelsInfo.textContent = '์„ ํƒ๋œ ์›น์†Œ์„ค ์—†์Œ (๋ชจ๋“  ์›น์†Œ์„ค ์‚ฌ์šฉ)';
987
+ selectedNovelsInfo.className = 'selected-novels-info';
988
+ } else {
989
+ const count = selectedFileIds.length;
990
+ selectedNovelsInfo.textContent = `${count}๊ฐœ ์›น์†Œ์„ค ์„ ํƒ๋จ`;
991
+ selectedNovelsInfo.className = 'selected-novels-info has-selection';
992
+ }
993
+ }
994
+
995
+ // ๋ชจ๋ธ ๋ชฉ๋ก ๋กœ๋“œ
996
+ async function loadModels() {
997
+ refreshModelsBtn.disabled = true;
998
+ 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> ๋กœ๋”ฉ ์ค‘...';
999
+
1000
+ try {
1001
+ const response = await fetch('/api/ollama/models');
1002
+ const data = await response.json();
1003
+
1004
+ modelSelect.innerHTML = '<option value="">๋ชจ๋ธ์„ ์„ ํƒํ•˜์„ธ์š”...</option>';
1005
+
1006
+ if (data.models && data.models.length > 0) {
1007
+ data.models.forEach(model => {
1008
+ const option = document.createElement('option');
1009
+ option.value = model.name;
1010
+ option.textContent = model.name;
1011
+ if (model.name === selectedModel) {
1012
+ option.selected = true;
1013
+ }
1014
+ modelSelect.appendChild(option);
1015
+ });
1016
+ updateModelStatus('connected');
1017
+ } else {
1018
+ updateModelStatus('error', '์‚ฌ์šฉ ๊ฐ€๋Šฅํ•œ ๋ชจ๋ธ์ด ์—†์Šต๋‹ˆ๋‹ค');
1019
+ }
1020
+ } catch (error) {
1021
+ updateModelStatus('error', 'Ollama ์—ฐ๊ฒฐ ์‹คํŒจ');
1022
+ console.error('๋ชจ๋ธ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
1023
+ } finally {
1024
+ refreshModelsBtn.disabled = false;
1025
+ 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> ๋ชจ๋ธ ์ƒˆ๋กœ๊ณ ์นจ';
1026
+ }
1027
+ }
1028
+
1029
+ // ๋ชจ๋ธ ์ƒํƒœ ์—…๋ฐ์ดํŠธ
1030
+ function updateModelStatus(status = 'disconnected', message = '') {
1031
+ modelStatus.className = 'model-status';
1032
+ if (status === 'connected') {
1033
+ modelStatus.classList.add('connected');
1034
+ modelStatus.innerHTML = '<span class="model-status-dot"></span><span>์—ฐ๊ฒฐ๋จ</span>';
1035
+ } else if (status === 'error') {
1036
+ modelStatus.classList.add('error');
1037
+ modelStatus.innerHTML = `<span class="model-status-dot"></span><span>${message || '์˜ค๋ฅ˜'}</span>`;
1038
+ } else {
1039
+ modelStatus.innerHTML = '<span class="model-status-dot"></span><span>์—ฐ๊ฒฐ ์•ˆ ๋จ</span>';
1040
+ }
1041
+ }
1042
+
1043
+ // ์‚ฌ์ด๋“œ๋ฐ” ํ† ๊ธ€
1044
+ function toggleSidebar() {
1045
+ sidebar.classList.toggle('collapsed');
1046
+ const isCollapsed = sidebar.classList.contains('collapsed');
1047
+ if (window.innerWidth <= 768) {
1048
+ sidebarToggleBtn.style.display = isCollapsed ? 'flex' : 'none';
1049
+ }
1050
+ // ์‚ฌ์ด๋“œ๋ฐ” ๋‚ด๋ถ€ ํ† ๊ธ€ ๋ฒ„ํŠผ ์•„์ด์ฝ˜ ์—…๋ฐ์ดํŠธ
1051
+ const sidebarToggle = sidebar.querySelector('.sidebar-toggle');
1052
+ if (sidebarToggle) {
1053
+ sidebarToggle.innerHTML = isCollapsed ?
1054
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M3 12h18M3 6h18M3 18h18"/></svg>' :
1055
+ '<svg width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M9 18l-6-6 6-6M21 18l-6-6 6-6"/></svg>';
1056
+ }
1057
+ }
1058
+
1059
+ // ๋ฐ˜์‘ํ˜• ์‚ฌ์ด๋“œ๋ฐ” ์ฒ˜๋ฆฌ
1060
+ function handleResize() {
1061
+ if (window.innerWidth <= 768) {
1062
+ sidebar.classList.add('collapsed');
1063
+ sidebarToggleBtn.style.display = 'flex';
1064
+ } else {
1065
+ sidebar.classList.remove('collapsed');
1066
+ sidebarToggleBtn.style.display = 'none';
1067
+ }
1068
+ }
1069
+
1070
+ window.addEventListener('resize', handleResize);
1071
+ handleResize();
1072
+
1073
+ // ์ƒˆ ๋Œ€ํ™” ์‹œ์ž‘
1074
+ async function startNewChat() {
1075
+ if (confirm('์ƒˆ ๋Œ€ํ™”๋ฅผ ์‹œ์ž‘ํ•˜์‹œ๊ฒ ์Šต๋‹ˆ๊นŒ? ํ˜„์žฌ ๋Œ€ํ™”๋Š” ์ €์žฅ๋ฉ๋‹ˆ๋‹ค.')) {
1076
+ clearChat();
1077
+ currentChatId = null;
1078
+ currentSessionId = null;
1079
+ await loadChatHistory();
1080
+ }
1081
+ }
1082
+
1083
+ // ๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๋กœ๋“œ (DB์—์„œ ์ตœ๊ทผ 20๊ฐœ๋งŒ)
1084
+ async function loadChatHistory() {
1085
+ chatHistory.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-secondary); font-size: 14px;">๋กœ๋”ฉ ์ค‘...</div>';
1086
+
1087
+ try {
1088
+ const response = await fetch('/api/chat/sessions');
1089
+ const data = await response.json();
1090
+
1091
+ chatHistory.innerHTML = '';
1092
+ chatSessions = data.sessions || [];
1093
+
1094
+ if (chatSessions.length === 0) {
1095
+ const emptyMsg = document.createElement('div');
1096
+ emptyMsg.style.padding = '16px';
1097
+ emptyMsg.style.textAlign = 'center';
1098
+ emptyMsg.style.color = 'var(--text-secondary)';
1099
+ emptyMsg.style.fontSize = '14px';
1100
+ emptyMsg.textContent = '๋Œ€ํ™” ๊ธฐ๋ก์ด ์—†์Šต๋‹ˆ๋‹ค';
1101
+ chatHistory.appendChild(emptyMsg);
1102
+ return;
1103
+ }
1104
+
1105
+ chatSessions.forEach((session) => {
1106
+ const chatItem = document.createElement('div');
1107
+ chatItem.className = 'chat-item';
1108
+ if (session.id === currentSessionId) {
1109
+ chatItem.classList.add('active');
1110
+ }
1111
+
1112
+ chatItem.innerHTML = `
1113
+ <svg class="chat-item-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
1114
+ <path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z"/>
1115
+ </svg>
1116
+ <div class="chat-item-title">${session.title || '์ƒˆ ๋Œ€ํ™”'}</div>
1117
+ <div class="chat-item-time">${formatTime(session.updated_at)}</div>
1118
+ `;
1119
+
1120
+ chatItem.onclick = () => loadChat(session.id);
1121
+ chatHistory.appendChild(chatItem);
1122
+ });
1123
+ } catch (error) {
1124
+ console.error('๋Œ€ํ™” ํžˆ์Šคํ† ๋ฆฌ ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
1125
+ chatHistory.innerHTML = '<div style="padding: 16px; text-align: center; color: var(--text-secondary); font-size: 14px;">๋Œ€ํ™” ๊ธฐ๋ก์„ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค</div>';
1126
+ }
1127
+ }
1128
+
1129
+ // ์‹œ๊ฐ„ ํฌ๋งท
1130
+ function formatTime(timestamp) {
1131
+ const date = new Date(timestamp);
1132
+ const now = new Date();
1133
+ const diff = now - date;
1134
+ const minutes = Math.floor(diff / 60000);
1135
+ const hours = Math.floor(diff / 3600000);
1136
+ const days = Math.floor(diff / 86400000);
1137
+
1138
+ if (minutes < 1) return '๋ฐฉ๊ธˆ';
1139
+ if (minutes < 60) return `${minutes}๋ถ„ ์ „`;
1140
+ if (hours < 24) return `${hours}์‹œ๊ฐ„ ์ „`;
1141
+ if (days < 7) return `${days}์ผ ์ „`;
1142
+ return date.toLocaleDateString('ko-KR', { month: 'short', day: 'numeric' });
1143
+ }
1144
+
1145
+ // ๋Œ€ํ™” ๋กœ๋“œ
1146
+ async function loadChat(sessionId) {
1147
+ try {
1148
+ const response = await fetch(`/api/chat/sessions/${sessionId}`);
1149
+ const data = await response.json();
1150
+
1151
+ if (!data.session) return;
1152
+
1153
+ currentSessionId = sessionId;
1154
+ currentChatId = sessionId;
1155
+ chatContainer.innerHTML = '';
1156
+
1157
+ if (data.session.messages && data.session.messages.length > 0) {
1158
+ data.session.messages.forEach(msg => {
1159
+ addMessage(msg.role, msg.content, false);
1160
+ });
1161
+ } else {
1162
+ if (emptyState) {
1163
+ emptyState.style.display = 'flex';
1164
+ }
1165
+ }
1166
+
1167
+ await loadChatHistory();
1168
+ if (window.innerWidth <= 768) {
1169
+ sidebar.classList.add('collapsed');
1170
+ }
1171
+ } catch (error) {
1172
+ console.error('๋Œ€ํ™” ๋กœ๋“œ ์˜ค๋ฅ˜:', error);
1173
+ alert('๋Œ€ํ™”๋ฅผ ๋ถˆ๋Ÿฌ์˜ฌ ์ˆ˜ ์—†์Šต๋‹ˆ๋‹ค.');
1174
+ }
1175
+ }
1176
+
1177
+ // ์ƒˆ ๋Œ€ํ™” ์„ธ์…˜ ์ƒ์„ฑ
1178
+ async function createNewSession() {
1179
+ try {
1180
+ const response = await fetch('/api/chat/sessions', {
1181
+ method: 'POST',
1182
+ headers: {
1183
+ 'Content-Type': 'application/json',
1184
+ },
1185
+ body: JSON.stringify({
1186
+ title: '์ƒˆ ๋Œ€ํ™”',
1187
+ model_name: selectedModel || null
1188
+ })
1189
+ });
1190
+
1191
+ const data = await response.json();
1192
+ if (response.ok && data.session) {
1193
+ currentSessionId = data.session.id;
1194
+ currentChatId = data.session.id;
1195
+ await loadChatHistory();
1196
+ return data.session.id;
1197
+ }
1198
+ } catch (error) {
1199
+ console.error('์„ธ์…˜ ์ƒ์„ฑ ์˜ค๋ฅ˜:', error);
1200
+ }
1201
+ return null;
1202
+ }
1203
+
1204
+ // ์ž๋™ ๋†’์ด ์กฐ์ ˆ
1205
+ messageInput.addEventListener('input', function() {
1206
+ this.style.height = 'auto';
1207
+ this.style.height = Math.min(this.scrollHeight, 200) + 'px';
1208
+ });
1209
+
1210
+ // Enter ํ‚ค ์ฒ˜๋ฆฌ
1211
+ messageInput.addEventListener('keydown', function(e) {
1212
+ if (e.key === 'Enter' && !e.shiftKey) {
1213
+ e.preventDefault();
1214
+ sendMessage();
1215
+ }
1216
+ });
1217
+
1218
+ // ๋ฉ”์‹œ์ง€ ์ถ”๊ฐ€
1219
+ function addMessage(role, content, save = true) {
1220
+ // ๋นˆ ์ƒํƒœ ์ˆจ๊ธฐ๊ธฐ
1221
+ if (emptyState) {
1222
+ emptyState.style.display = 'none';
1223
+ }
1224
+
1225
+ const messageDiv = document.createElement('div');
1226
+ messageDiv.className = `message ${role}`;
1227
+
1228
+ const avatar = document.createElement('div');
1229
+ avatar.className = 'message-avatar';
1230
+ avatar.textContent = role === 'user' ? '๐Ÿ‘ค' : '๐Ÿค–';
1231
+
1232
+ const contentDiv = document.createElement('div');
1233
+ contentDiv.className = 'message-content';
1234
+
1235
+ const bubble = document.createElement('div');
1236
+ bubble.className = 'message-bubble';
1237
+ bubble.textContent = content;
1238
+
1239
+ const time = document.createElement('div');
1240
+ time.className = 'message-time';
1241
+ time.textContent = new Date().toLocaleTimeString('ko-KR', {
1242
+ hour: '2-digit',
1243
+ minute: '2-digit'
1244
+ });
1245
+
1246
+ contentDiv.appendChild(bubble);
1247
+ contentDiv.appendChild(time);
1248
+ messageDiv.appendChild(avatar);
1249
+ messageDiv.appendChild(contentDiv);
1250
+ chatContainer.appendChild(messageDiv);
1251
+
1252
+ // ์Šคํฌ๋กค์„ ๋งจ ์•„๋ž˜๋กœ
1253
+ chatContainer.scrollTop = chatContainer.scrollHeight;
1254
+ }
1255
+
1256
+ // ํƒ€์ดํ•‘ ์ธ๋””์ผ€์ดํ„ฐ ํ‘œ์‹œ
1257
+ function showTypingIndicator() {
1258
+ const messageDiv = document.createElement('div');
1259
+ messageDiv.className = 'message ai';
1260
+ messageDiv.id = 'typingIndicator';
1261
+
1262
+ const avatar = document.createElement('div');
1263
+ avatar.className = 'message-avatar';
1264
+ avatar.textContent = '๐Ÿค–';
1265
+
1266
+ const contentDiv = document.createElement('div');
1267
+ contentDiv.className = 'message-content';
1268
+
1269
+ const typingDiv = document.createElement('div');
1270
+ typingDiv.className = 'typing-indicator';
1271
+ for (let i = 0; i < 3; i++) {
1272
+ const dot = document.createElement('div');
1273
+ dot.className = 'typing-dot';
1274
+ typingDiv.appendChild(dot);
1275
+ }
1276
+
1277
+ contentDiv.appendChild(typingDiv);
1278
+ messageDiv.appendChild(avatar);
1279
+ messageDiv.appendChild(contentDiv);
1280
+ chatContainer.appendChild(messageDiv);
1281
+ chatContainer.scrollTop = chatContainer.scrollHeight;
1282
+ }
1283
+
1284
+ // ํƒ€์ดํ•‘ ์ธ๋””์ผ€์ดํ„ฐ ์ œ๊ฑฐ
1285
+ function removeTypingIndicator() {
1286
+ const indicator = document.getElementById('typingIndicator');
1287
+ if (indicator) {
1288
+ indicator.remove();
1289
+ }
1290
+ }
1291
+
1292
+ // ๋ฉ”์‹œ์ง€ ์ „์†ก
1293
+ async function sendMessage() {
1294
+ const message = messageInput.value.trim();
1295
+ if (!message) return;
1296
+
1297
+ // ์„ธ์…˜์ด ์—†์œผ๋ฉด ์ƒˆ๋กœ ์ƒ์„ฑ
1298
+ if (!currentSessionId) {
1299
+ currentSessionId = await createNewSession();
1300
+ }
1301
+
1302
+ // ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ํ‘œ๏ฟฝ๏ฟฝ๏ฟฝ
1303
+ addMessage('user', message, false);
1304
+
1305
+ // DB์— ์‚ฌ์šฉ์ž ๋ฉ”์‹œ์ง€ ์ €์žฅ
1306
+ if (currentSessionId) {
1307
+ try {
1308
+ await fetch(`/api/chat/sessions/${currentSessionId}/messages`, {
1309
+ method: 'POST',
1310
+ headers: {
1311
+ 'Content-Type': 'application/json',
1312
+ },
1313
+ body: JSON.stringify({
1314
+ role: 'user',
1315
+ content: message
1316
+ })
1317
+ });
1318
+ } catch (error) {
1319
+ console.error('๋ฉ”์‹œ์ง€ ์ €์žฅ ์˜ค๋ฅ˜:', error);
1320
+ }
1321
+ }
1322
+
1323
+ messageInput.value = '';
1324
+ messageInput.style.height = 'auto';
1325
+
1326
+ // ์ž…๋ ฅ ๋น„ํ™œ์„ฑํ™”
1327
+ messageInput.disabled = true;
1328
+ sendButton.disabled = true;
1329
+
1330
+ // ํƒ€์ดํ•‘ ์ธ๋””์ผ€์ดํ„ฐ ํ‘œ์‹œ
1331
+ showTypingIndicator();
1332
+
1333
+ try {
1334
+ // API ํ˜ธ์ถœ
1335
+ const response = await fetch('/api/chat', {
1336
+ method: 'POST',
1337
+ headers: {
1338
+ 'Content-Type': 'application/json',
1339
+ },
1340
+ body: JSON.stringify({
1341
+ message: message,
1342
+ model: selectedModel || null,
1343
+ file_ids: selectedFileIds.length > 0 ? selectedFileIds : [],
1344
+ session_id: currentSessionId
1345
+ })
1346
+ });
1347
+
1348
+ removeTypingIndicator();
1349
+
1350
+ if (response.ok) {
1351
+ const data = await response.json();
1352
+ const aiResponse = data.response || '์‘๋‹ต์„ ์ƒ์„ฑํ•˜๋Š” ์ค‘ ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.';
1353
+ addMessage('ai', aiResponse, false);
1354
+
1355
+ // DB์— AI ์‘๋‹ต ์ €์žฅ (์ด๋ฏธ ๋ฐฑ์—”๋“œ์—์„œ ์ €์žฅ๋จ)
1356
+ // ์„ธ์…˜ ๋ชฉ๋ก ์ƒˆ๋กœ๊ณ ์นจ
1357
+ await loadChatHistory();
1358
+ } else {
1359
+ const error = await response.json().catch(() => ({ error: '์„œ๋ฒ„ ์˜ค๋ฅ˜' }));
1360
+ addMessage('ai', `์˜ค๋ฅ˜: ${error.error || '์•Œ ์ˆ˜ ์—†๋Š” ์˜ค๋ฅ˜๊ฐ€ ๋ฐœ์ƒํ–ˆ์Šต๋‹ˆ๋‹ค.'}`, false);
1361
+ }
1362
+ } catch (error) {
1363
+ removeTypingIndicator();
1364
+ addMessage('ai', `์—ฐ๊ฒฐ ์˜ค๋ฅ˜: ${error.message}`, false);
1365
+ } finally {
1366
+ // ์ž…๋ ฅ ํ™œ์„ฑํ™”
1367
+ messageInput.disabled = false;
1368
+ sendButton.disabled = false;
1369
+ messageInput.focus();
1370
+ }
1371
+ }
1372
+
1373
+ // ๋Œ€ํ™” ์ดˆ๊ธฐํ™”
1374
+ function clearChat() {
1375
+ chatContainer.innerHTML = '';
1376
+ currentChatId = null;
1377
+ currentSessionId = null;
1378
+ if (emptyState) {
1379
+ emptyState.style.display = 'flex';
1380
+ }
1381
+ }
1382
+
1383
+ // ํŽ˜์ด์ง€ ๋กœ๋“œ ์‹œ ์ดˆ๊ธฐํ™”
1384
+ window.addEventListener('load', async () => {
1385
+ await loadChatHistory();
1386
+ await loadModels();
1387
+ if (selectedModel) {
1388
+ loadNovels();
1389
+ }
1390
+ messageInput.focus();
1391
+ });
1392
+ </script>
1393
  </body>
1394
  </html>
 
templates/login.html ADDED
@@ -0,0 +1,161 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="ko">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>๋กœ๊ทธ์ธ - SOY NV AI</title>
7
+ <link rel="preconnect" href="https://fonts.googleapis.com" crossorigin>
8
+ <link href="https://fonts.googleapis.com/css2?family=Inter:wght@400;500;600&display=swap" rel="stylesheet">
9
+ <style>
10
+ * {
11
+ margin: 0;
12
+ padding: 0;
13
+ box-sizing: border-box;
14
+ }
15
+
16
+ body {
17
+ font-family: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
18
+ background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
19
+ min-height: 100vh;
20
+ display: flex;
21
+ align-items: center;
22
+ justify-content: center;
23
+ padding: 20px;
24
+ }
25
+
26
+ .login-container {
27
+ background: white;
28
+ border-radius: 16px;
29
+ box-shadow: 0 20px 60px rgba(0, 0, 0, 0.3);
30
+ padding: 40px;
31
+ width: 100%;
32
+ max-width: 400px;
33
+ }
34
+
35
+ .login-header {
36
+ text-align: center;
37
+ margin-bottom: 32px;
38
+ }
39
+
40
+ .login-header h1 {
41
+ font-size: 28px;
42
+ font-weight: 600;
43
+ color: #202124;
44
+ margin-bottom: 8px;
45
+ }
46
+
47
+ .login-header p {
48
+ font-size: 14px;
49
+ color: #5f6368;
50
+ }
51
+
52
+ .login-icon {
53
+ font-size: 48px;
54
+ margin-bottom: 16px;
55
+ }
56
+
57
+ .form-group {
58
+ margin-bottom: 20px;
59
+ }
60
+
61
+ .form-group label {
62
+ display: block;
63
+ font-size: 14px;
64
+ font-weight: 500;
65
+ color: #202124;
66
+ margin-bottom: 8px;
67
+ }
68
+
69
+ .form-group input {
70
+ width: 100%;
71
+ padding: 12px 16px;
72
+ border: 1px solid #dadce0;
73
+ border-radius: 8px;
74
+ font-size: 15px;
75
+ font-family: inherit;
76
+ transition: border-color 0.2s, box-shadow 0.2s;
77
+ }
78
+
79
+ .form-group input:focus {
80
+ outline: none;
81
+ border-color: #1a73e8;
82
+ box-shadow: 0 0 0 3px rgba(26, 115, 232, 0.1);
83
+ }
84
+
85
+ .login-button {
86
+ width: 100%;
87
+ padding: 12px;
88
+ background: #1a73e8;
89
+ color: white;
90
+ border: none;
91
+ border-radius: 8px;
92
+ font-size: 15px;
93
+ font-weight: 500;
94
+ cursor: pointer;
95
+ transition: background 0.2s;
96
+ margin-top: 8px;
97
+ }
98
+
99
+ .login-button:hover {
100
+ background: #1557b0;
101
+ }
102
+
103
+ .login-button:active {
104
+ transform: scale(0.98);
105
+ }
106
+
107
+ .alert {
108
+ padding: 12px 16px;
109
+ border-radius: 8px;
110
+ margin-bottom: 20px;
111
+ font-size: 14px;
112
+ }
113
+
114
+ .alert.error {
115
+ background: #fce8e6;
116
+ color: #c5221f;
117
+ border: 1px solid #f28b82;
118
+ }
119
+
120
+ .alert.info {
121
+ background: #e8f0fe;
122
+ color: #1967d2;
123
+ border: 1px solid #aecbfa;
124
+ }
125
+ </style>
126
+ </head>
127
+ <body>
128
+ <div class="login-container">
129
+ <div class="login-header">
130
+ <div class="login-icon">๐Ÿค–</div>
131
+ <h1>SOY NV AI</h1>
132
+ <p>๋กœ๊ทธ์ธ์ด ํ•„์š”ํ•ฉ๋‹ˆ๋‹ค</p>
133
+ </div>
134
+
135
+ {% with messages = get_flashed_messages(with_categories=true) %}
136
+ {% if messages %}
137
+ {% for category, message in messages %}
138
+ <div class="alert {{ category }}">
139
+ {{ message }}
140
+ </div>
141
+ {% endfor %}
142
+ {% endif %}
143
+ {% endwith %}
144
+
145
+ <form method="POST" action="{{ url_for('main.login') }}">
146
+ <div class="form-group">
147
+ <label for="username">์‚ฌ์šฉ์ž๋ช…</label>
148
+ <input type="text" id="username" name="username" required autofocus>
149
+ </div>
150
+
151
+ <div class="form-group">
152
+ <label for="password">๋น„๋ฐ€๋ฒˆํ˜ธ</label>
153
+ <input type="password" id="password" name="password" required>
154
+ </div>
155
+
156
+ <button type="submit" class="login-button">๋กœ๊ทธ์ธ</button>
157
+ </form>
158
+ </div>
159
+ </body>
160
+ </html>
161
+