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 +1 -0
- README.md +1 -0
- README_SERVER.md +68 -0
- app/__init__.py +61 -1
- app/database.py +115 -1
- app/routes.py +797 -1
- migrate_db.py +54 -0
- requirements.txt +4 -0
- run.py +7 -1
- start_server.bat +10 -0
- start_server.ps1 +29 -0
- start_server_background.ps1 +87 -0
- templates/admin.html +857 -0
- templates/admin_messages.html +695 -0
- templates/index.html +1365 -34
- templates/login.html +161 -0
.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
|
|
|
|
| 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 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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()">×</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()">×</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, '"')})" 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,
|
| 16 |
-
background
|
| 17 |
-
color:
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
}
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
| 22 |
-
|
| 23 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 26 |
.header {
|
| 27 |
-
background:
|
| 28 |
-
|
| 29 |
-
|
| 30 |
-
|
| 31 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 32 |
text-align: center;
|
|
|
|
|
|
|
| 33 |
}
|
| 34 |
-
|
| 35 |
-
.
|
| 36 |
-
|
| 37 |
-
|
| 38 |
-
|
| 39 |
}
|
| 40 |
-
|
| 41 |
-
.
|
| 42 |
-
|
| 43 |
-
|
| 44 |
-
|
| 45 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 46 |
}
|
| 47 |
</style>
|
| 48 |
</head>
|
| 49 |
<body>
|
| 50 |
-
|
| 51 |
-
|
| 52 |
-
|
| 53 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 54 |
</div>
|
| 55 |
|
| 56 |
-
|
| 57 |
-
|
| 58 |
-
<
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 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 |
+
|