SOY NV AI commited on
Commit
ae31891
Β·
1 Parent(s): 59f2d9e

Add Hugging Face Spaces deployment support and file public/private feature

Browse files

- Add Hugging Face Spaces deployment files (app.py, Dockerfile, README_HF.md)
- Add GitHub Actions workflows for auto-deployment
- Add file public/private feature (is_public field in database)
- Improve Gemini API error handling (429 quota exceeded)
- Update admin pages with responsive design
- Add file upload timeout unlimited support

.github/workflows/README.md ADDED
@@ -0,0 +1,83 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # GitHub Actions μ›Œν¬ν”Œλ‘œμš°
2
+
3
+ 이 λ””λ ‰ν† λ¦¬μ—λŠ” GitHub Actions μ›Œν¬ν”Œλ‘œμš° 파일이 ν¬ν•¨λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.
4
+
5
+ ## μ›Œν¬ν”Œλ‘œμš° λͺ©λ‘
6
+
7
+ ### deploy-to-hf.yml
8
+
9
+ Hugging Face Spaces에 μžλ™ λ°°ν¬ν•˜λŠ” μ›Œν¬ν”Œλ‘œμš°μž…λ‹ˆλ‹€.
10
+
11
+ #### 트리거 쑰건
12
+ - `main` λ˜λŠ” `master` λΈŒλžœμΉ˜μ— ν‘Έμ‹œλ  λ•Œ
13
+ - λ‹€μŒ 파일/디렉토리가 변경될 λ•Œ:
14
+ - `app/**`
15
+ - `templates/**`
16
+ - `static/**`
17
+ - `app.py`
18
+ - `Dockerfile`
19
+ - `requirements.txt`
20
+ - `README_HF.md`
21
+ - μˆ˜λ™ μ‹€ν–‰ (workflow_dispatch)
22
+
23
+ #### ν•„μš”ν•œ Secrets μ„€μ •
24
+
25
+ GitHub μ €μž₯μ†Œμ˜ Settings > Secrets and variables > Actionsμ—μ„œ λ‹€μŒ secretsλ₯Ό μ„€μ •ν•΄μ•Ό ν•©λ‹ˆλ‹€:
26
+
27
+ 1. **HF_USERNAME**: Hugging Face μ‚¬μš©μžλͺ…
28
+ 2. **HF_SPACE_NAME**: Hugging Face Space 이름
29
+ 3. **HF_TOKEN**: Hugging Face Access Token
30
+ - [Hugging Face Settings > Access Tokens](https://huggingface.co/settings/tokens)μ—μ„œ 생성
31
+ - `write` κΆŒν•œμ΄ ν•„μš”ν•©λ‹ˆλ‹€
32
+
33
+ #### μ‚¬μš© 방법
34
+
35
+ 1. GitHub μ €μž₯μ†Œμ— secrets μ„€μ •
36
+ 2. μ½”λ“œλ₯Ό `main` λ˜λŠ” `master` λΈŒλžœμΉ˜μ— ν‘Έμ‹œ
37
+ 3. μžλ™μœΌλ‘œ Hugging Face Spaces에 배포됨
38
+
39
+ λ˜λŠ” GitHub Actions νƒ­μ—μ„œ μˆ˜λ™μœΌλ‘œ μ‹€ν–‰ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
40
+
41
+ ## Secrets μ„€μ • 방법
42
+
43
+ ### 1. Hugging Face Access Token 생성
44
+
45
+ 1. [Hugging Face Settings](https://huggingface.co/settings/tokens) 접속
46
+ 2. "New token" 클릭
47
+ 3. Token 이름 μž…λ ₯ (예: `github-actions-deploy`)
48
+ 4. Token type: **Write** 선택
49
+ 5. "Generate a token" 클릭
50
+ 6. μƒμ„±λœ 토큰을 볡사 (ν•œ 번만 ν‘œμ‹œλ¨)
51
+
52
+ ### 2. GitHub Secrets μ„€μ •
53
+
54
+ 1. GitHub μ €μž₯μ†Œμ˜ Settings > Secrets and variables > Actions 접속
55
+ 2. "New repository secret" 클릭
56
+ 3. λ‹€μŒ secrets μΆ”κ°€:
57
+
58
+ - **Name**: `HF_USERNAME`
59
+ **Value**: Hugging Face μ‚¬μš©μžλͺ… (예: `your-username`)
60
+
61
+ - **Name**: `HF_SPACE_NAME`
62
+ **Value**: Space 이름 (예: `soy-nv-ai`)
63
+
64
+ - **Name**: `HF_TOKEN`
65
+ **Value**: μœ„μ—μ„œ μƒμ„±ν•œ Hugging Face Access Token
66
+
67
+ ### 3. μ›Œν¬ν”Œλ‘œμš° ν…ŒμŠ€νŠΈ
68
+
69
+ 1. μ½”λ“œ λ³€κ²½ ν›„ `main` λΈŒλžœμΉ˜μ— ν‘Έμ‹œ
70
+ 2. GitHub Actions νƒ­μ—μ„œ μ›Œν¬ν”Œλ‘œμš° μ‹€ν–‰ 확인
71
+ 3. Hugging Face Spacesμ—μ„œ 배포 확인
72
+
73
+ ## 문제 ν•΄κ²°
74
+
75
+ ### 배포 μ‹€νŒ¨
76
+ - Secretsκ°€ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인
77
+ - Hugging Face Token에 `write` κΆŒν•œμ΄ μžˆλŠ”μ§€ 확인
78
+ - Space 이름과 μ‚¬μš©μžλͺ…이 μ •ν™•ν•œμ§€ 확인
79
+
80
+ ### 파일이 μ—…λ‘œλ“œλ˜μ§€ μ•ŠμŒ
81
+ - `.gitignore`μ—μ„œ ν•΄λ‹Ή 파일이 μ œμ™Έλ˜μ§€ μ•Šμ•˜λŠ”μ§€ 확인
82
+ - μ›Œν¬ν”Œλ‘œμš°μ˜ `paths` 필터에 ν•΄λ‹Ή 파일이 ν¬ν•¨λ˜μ–΄ μžˆλŠ”μ§€ 확인
83
+
.github/workflows/deploy-to-hf.yml ADDED
@@ -0,0 +1,71 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Deploy to Hugging Face Spaces
2
+
3
+ on:
4
+ push:
5
+ branches:
6
+ - main
7
+ - master
8
+ paths:
9
+ - 'app/**'
10
+ - 'templates/**'
11
+ - 'static/**'
12
+ - 'app.py'
13
+ - 'Dockerfile'
14
+ - 'requirements.txt'
15
+ - 'README_HF.md'
16
+ workflow_dispatch: # μˆ˜λ™ μ‹€ν–‰ κ°€λŠ₯
17
+
18
+ jobs:
19
+ deploy:
20
+ runs-on: ubuntu-latest
21
+
22
+ steps:
23
+ - name: Checkout repository
24
+ uses: actions/checkout@v3
25
+ with:
26
+ fetch-depth: 0
27
+
28
+ - name: Setup Git
29
+ run: |
30
+ git config --global user.name "GitHub Actions"
31
+ git config --global user.email "actions@github.com"
32
+
33
+ - name: Checkout Hugging Face Space
34
+ uses: actions/checkout@v3
35
+ with:
36
+ repository: ${{ secrets.HF_USERNAME }}/${{ secrets.HF_SPACE_NAME }}
37
+ path: hf-space
38
+ token: ${{ secrets.HF_TOKEN }}
39
+
40
+ - name: Copy files to Hugging Face Space
41
+ run: |
42
+ # ν•„μˆ˜ 파일 볡사
43
+ cp app.py hf-space/
44
+ cp Dockerfile hf-space/
45
+ cp requirements.txt hf-space/
46
+ cp README_HF.md hf-space/README.md
47
+
48
+ # 디렉토리 볡사
49
+ cp -r app hf-space/
50
+ cp -r templates hf-space/
51
+ cp -r static hf-space/
52
+
53
+ # .gitkeep 파일이 ν•„μš”ν•œ 디렉토리 확인
54
+ mkdir -p hf-space/instance
55
+ mkdir -p hf-space/uploads
56
+ mkdir -p hf-space/vector_db
57
+ mkdir -p hf-space/knowledge_graphs
58
+ mkdir -p hf-space/logs
59
+
60
+ - name: Commit and push to Hugging Face
61
+ working-directory: hf-space
62
+ run: |
63
+ git add .
64
+ git diff --staged --quiet || git commit -m "Auto-deploy from GitHub Actions - $(date +'%Y-%m-%d %H:%M:%S')"
65
+ git push
66
+
67
+ - name: Deployment Status
68
+ run: |
69
+ echo "βœ… Deployment completed successfully!"
70
+ echo "Space URL: https://huggingface.co/spaces/${{ secrets.HF_USERNAME }}/${{ secrets.HF_SPACE_NAME }}"
71
+
.github/workflows/sync_to_hub.yml ADDED
@@ -0,0 +1,13 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ jobs:
2
+ sync-to-hub:
3
+ runs-on: ubuntu-latest
4
+ steps:
5
+ - uses: actions/checkout@v3
6
+ with:
7
+ fetch-depth: 0
8
+ lfs: true
9
+ - name: Push to hub
10
+ env:
11
+ HF_TOKEN: ${{ secrets.HF_TOKEN }}
12
+ # μ•„λž˜ μ€„μ—μ„œ [아이디/μŠ€νŽ˜μ΄μŠ€μ΄λ¦„] 뢀뢄을 μ΄μ„ΈμΈλ‹˜ μ •λ³΄λ‘œ κΌ­ μˆ˜μ •ν•΄μ£Όμ„Έμš”!
13
+ run: git push https://wiizm:$HF_TOKEN@huggingface.co/spaces/wiizm/
.github/workflows/test.yml ADDED
@@ -0,0 +1,59 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ name: Test Application
2
+
3
+ on:
4
+ pull_request:
5
+ branches:
6
+ - main
7
+ - master
8
+ push:
9
+ branches:
10
+ - main
11
+ - master
12
+
13
+ jobs:
14
+ test:
15
+ runs-on: ubuntu-latest
16
+
17
+ steps:
18
+ - name: Checkout repository
19
+ uses: actions/checkout@v3
20
+
21
+ - name: Set up Python
22
+ uses: actions/setup-python@v4
23
+ with:
24
+ python-version: '3.11'
25
+
26
+ - name: Install dependencies
27
+ run: |
28
+ python -m pip install --upgrade pip
29
+ pip install -r requirements.txt
30
+ pip install pytest pytest-cov flake8
31
+
32
+ - name: Lint with flake8
33
+ run: |
34
+ # flake8 μ„€μΉ˜ 및 μ‹€ν–‰ (선택사항)
35
+ # flake8 app --count --select=E9,F63,F7,F82 --show-source --statistics
36
+ # flake8 app --count --exit-zero --max-complexity=10 --max-line-length=127 --statistics
37
+ echo "Linting skipped (flake8 not configured)"
38
+
39
+ - name: Check file structure
40
+ run: |
41
+ echo "Checking required files..."
42
+ test -f app.py && echo "βœ“ app.py exists" || echo "βœ— app.py missing"
43
+ test -f Dockerfile && echo "βœ“ Dockerfile exists" || echo "βœ— Dockerfile missing"
44
+ test -f requirements.txt && echo "βœ“ requirements.txt exists" || echo "βœ— requirements.txt missing"
45
+ test -d app && echo "βœ“ app/ directory exists" || echo "βœ— app/ directory missing"
46
+ test -d templates && echo "βœ“ templates/ directory exists" || echo "βœ— templates/ directory missing"
47
+ test -d static && echo "βœ“ static/ directory exists" || echo "βœ— static/ directory missing"
48
+
49
+ - name: Validate Python syntax
50
+ run: |
51
+ echo "Validating Python syntax..."
52
+ python -m py_compile app.py
53
+ find app -name "*.py" -exec python -m py_compile {} \;
54
+ echo "βœ“ All Python files are syntactically valid"
55
+
56
+ - name: Test complete
57
+ run: |
58
+ echo "βœ… All tests passed!"
59
+
Dockerfile ADDED
@@ -0,0 +1,30 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ FROM python:3.11-slim
2
+
3
+ WORKDIR /app
4
+
5
+ # μ‹œμŠ€ν…œ νŒ¨ν‚€μ§€ μ„€μΉ˜
6
+ RUN apt-get update && apt-get install -y \
7
+ build-essential \
8
+ git \
9
+ && rm -rf /var/lib/apt/lists/*
10
+
11
+ # Python μ˜μ‘΄μ„± μ„€μΉ˜
12
+ COPY requirements.txt .
13
+ RUN pip install --no-cache-dir -r requirements.txt
14
+
15
+ # μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ½”λ“œ 볡사
16
+ COPY . .
17
+
18
+ # ν•„μš”ν•œ 디렉토리 생성
19
+ RUN mkdir -p instance uploads vector_db knowledge_graphs logs static templates
20
+
21
+ # 포트 λ…ΈμΆœ (Hugging Face SpacesλŠ” 7860 포트 μ‚¬μš©)
22
+ EXPOSE 7860
23
+
24
+ # ν™˜κ²½ λ³€μˆ˜ μ„€μ •
25
+ ENV PORT=7860
26
+ ENV HOST=0.0.0.0
27
+
28
+ # μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ‹€ν–‰
29
+ CMD ["python", "app.py"]
30
+
EXAONE_μ„€μΉ˜_κ°€μ΄λ“œ.md CHANGED
@@ -167,5 +167,6 @@ tokenizer = AutoTokenizer.from_pretrained("LGAI-EXAONE/EXAONE-3.0-7.8B-Instruct"
167
 
168
 
169
 
 
170
 
171
 
 
167
 
168
 
169
 
170
+
171
 
172
 
EXAONE_μΆ”κ°€_μ•ˆλ‚΄.md CHANGED
@@ -84,5 +84,6 @@ Ollamaλ₯Ό κ±°μΉ˜μ§€ μ•Šκ³  Pythonμ—μ„œ 직접 Hugging Face λͺ¨λΈμ„ μ‚¬μš©ν• 
84
 
85
 
86
 
 
87
 
88
 
 
84
 
85
 
86
 
87
+
88
 
89
 
HF_UPLOAD_GUIDE.md ADDED
@@ -0,0 +1,144 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces μ—…λ‘œλ“œ κ°€μ΄λ“œ
2
+
3
+ 이 κ°€μ΄λ“œλŠ” Hugging Face Spaces에 νŒŒμΌμ„ μ—…λ‘œλ“œν•˜λŠ” 방법을 μ„€λͺ…ν•©λ‹ˆλ‹€.
4
+
5
+ ## 방법 1: Git을 μ‚¬μš©ν•œ μ—…λ‘œλ“œ (ꢌμž₯)
6
+
7
+ ### 1. Hugging Face Spaces 생성
8
+
9
+ 1. [Hugging Face Spaces](https://huggingface.co/spaces) 접속
10
+ 2. "Create new Space" 클릭
11
+ 3. μ„€μ •:
12
+ - **Space name**: μ›ν•˜λŠ” 이름
13
+ - **SDK**: Docker
14
+ - **Docker template**: Blank
15
+ - **Hardware**: CPU Basic (λ˜λŠ” ν•„μš”μ— 따라)
16
+ - **Visibility**: Public λ˜λŠ” Private
17
+
18
+ ### 2. Git μ €μž₯μ†Œ 클둠
19
+
20
+ Spaces 생성 ν›„, Hugging Faceμ—μ„œ μ œκ³΅ν•˜λŠ” Git URL을 μ‚¬μš©ν•˜μ—¬ 클둠:
21
+
22
+ ```bash
23
+ # Hugging Faceμ—μ„œ μ œκ³΅ν•˜λŠ” Git URL μ‚¬μš©
24
+ git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME
25
+ cd YOUR_SPACE_NAME
26
+ ```
27
+
28
+ ### 3. ν•„μš”ν•œ 파일 볡사
29
+
30
+ 둜컬 ν”„λ‘œμ νŠΈμ—μ„œ ν•„μš”ν•œ νŒŒμΌλ“€μ„ 볡사:
31
+
32
+ ```bash
33
+ # Windows PowerShellμ—μ„œ μ‹€ν–‰
34
+ # ν”„λ‘œμ νŠΈ 루트 λ””λ ‰ν† λ¦¬μ—μ„œ μ‹€ν–‰
35
+
36
+ # ν•„μˆ˜ 파일 볡사
37
+ Copy-Item ..\app.py .
38
+ Copy-Item ..\Dockerfile .
39
+ Copy-Item ..\requirements.txt .
40
+ Copy-Item ..\README_HF.md README.md
41
+
42
+ # 디렉토리 볡사
43
+ Copy-Item -Recurse ..\app .
44
+ Copy-Item -Recurse ..\templates .
45
+ Copy-Item -Recurse ..\static .
46
+ ```
47
+
48
+ ### 4. Git 컀밋 및 ν‘Έμ‹œ
49
+
50
+ ```bash
51
+ git add .
52
+ git commit -m "Initial deployment"
53
+ git push
54
+ ```
55
+
56
+ ## 방법 2: μ›Ή μΈν„°νŽ˜μ΄μŠ€λ₯Ό μ‚¬μš©ν•œ μ—…λ‘œλ“œ
57
+
58
+ 1. Hugging Face Spaces νŽ˜μ΄μ§€ 접속
59
+ 2. "Files and versions" νƒ­ 클릭
60
+ 3. "Add file" > "Upload files" 클릭
61
+ 4. λ‹€μŒ νŒŒμΌλ“€μ„ λ“œλž˜κ·Έ μ•€ λ“œλ‘­μœΌλ‘œ μ—…λ‘œλ“œ:
62
+
63
+ ### ν•„μˆ˜ 파일 λͺ©λ‘
64
+
65
+ #### 루트 디렉토리 파일
66
+ - `app.py`
67
+ - `Dockerfile`
68
+ - `requirements.txt`
69
+ - `README.md` (README_HF.md의 λ‚΄μš© μ‚¬μš©)
70
+
71
+ #### 디렉토리 (폴더 전체 μ—…λ‘œλ“œ)
72
+ - `app/` (전체 디렉토리)
73
+ - `templates/` (전체 디렉토리)
74
+ - `static/` (전체 디렉토리)
75
+
76
+ ## μ—…λ‘œλ“œν•˜μ§€ 말아야 ν•  파일/폴더
77
+
78
+ λ‹€μŒ ν•­λͺ©μ€ `.gitignore`에 ν¬ν•¨λ˜μ–΄ μžˆμœΌλ―€λ‘œ μ—…λ‘œλ“œν•˜μ§€ λ§ˆμ„Έμš”:
79
+
80
+ - `venv/` - 가상 ν™˜κ²½
81
+ - `instance/` - λ°μ΄ν„°λ² μ΄μŠ€ 파일
82
+ - `uploads/` - μ—…λ‘œλ“œλœ 파일
83
+ - `vector_db/` - 벑터 λ°μ΄ν„°λ² μ΄μŠ€
84
+ - `logs/` - 둜그 파일
85
+ - `*.pyc`, `__pycache__/` - Python μΊμ‹œ
86
+ - `.env` - ν™˜κ²½ λ³€μˆ˜ 파일 (민감 정보)
87
+ - `.git/` - Git μ €μž₯μ†Œ
88
+
89
+ ## ν™˜κ²½ λ³€μˆ˜ μ„€μ •
90
+
91
+ 파일 μ—…λ‘œλ“œ ν›„, Settings > Repository secretsμ—μ„œ ν™˜κ²½ λ³€μˆ˜ μ„€μ •:
92
+
93
+ ### ν•„μˆ˜
94
+ ```
95
+ SECRET_KEY=your-random-secret-key-here
96
+ ```
97
+
98
+ ### 선택사항
99
+ ```
100
+ GEMINI_API_KEY=your-gemini-api-key
101
+ DATABASE_URL=sqlite:///instance/finance_analysis.db
102
+ ```
103
+
104
+ ## 배포 확인
105
+
106
+ 1. Spaces νŽ˜μ΄μ§€μ—μ„œ "Logs" νƒ­ 확인
107
+ 2. λΉŒλ“œκ°€ μ™„λ£Œλ˜λ©΄ μ• ν”Œλ¦¬μΌ€μ΄μ…˜ 접속 ν…ŒμŠ€νŠΈ
108
+ 3. 였λ₯˜κ°€ 있으면 둜그 확인
109
+
110
+ ## λΉ λ₯Έ μ—…λ‘œλ“œ 슀크립트 (Windows PowerShell)
111
+
112
+ ν”„λ‘œμ νŠΈ 루트 λ””λ ‰ν† λ¦¬μ—μ„œ μ‹€ν–‰:
113
+
114
+ ```powershell
115
+ # Hugging Face Spaces 디렉토리 경둜 μ„€μ •
116
+ $HF_SPACE_DIR = "C:\path\to\huggingface\space"
117
+
118
+ # ν•„μˆ˜ 파일 볡사
119
+ Copy-Item app.py $HF_SPACE_DIR\
120
+ Copy-Item Dockerfile $HF_SPACE_DIR\
121
+ Copy-Item requirements.txt $HF_SPACE_DIR\
122
+ Copy-Item README_HF.md "$HF_SPACE_DIR\README.md"
123
+
124
+ # 디렉토리 볡사
125
+ Copy-Item -Recurse app "$HF_SPACE_DIR\app"
126
+ Copy-Item -Recurse templates "$HF_SPACE_DIR\templates"
127
+ Copy-Item -Recurse static "$HF_SPACE_DIR\static"
128
+
129
+ Write-Host "파일 볡사 μ™„λ£Œ!"
130
+ ```
131
+
132
+ ## 문제 ν•΄κ²°
133
+
134
+ ### λΉŒλ“œ μ‹€νŒ¨
135
+ - `requirements.txt`의 νŒ¨ν‚€μ§€ 버전 확인
136
+ - λ‘œκ·Έμ—μ„œ 였λ₯˜ λ©”μ‹œμ§€ 확인
137
+
138
+ ### λŸ°νƒ€μž„ 였λ₯˜
139
+ - ν™˜κ²½ λ³€μˆ˜κ°€ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인
140
+ - `app.py`κ°€ μ˜¬λ°”λ₯Έ 포트λ₯Ό μ‚¬μš©ν•˜λŠ”μ§€ 확인 (7860)
141
+
142
+ ### 파일 λˆ„λ½
143
+ - λͺ¨λ“  ν•„μˆ˜ 디렉토리(`app/`, `templates/`, `static/`)κ°€ μ—…λ‘œλ“œλ˜μ—ˆλŠ”μ§€ 확인
144
+
HUGGINGFACE_DEPLOY.md ADDED
@@ -0,0 +1,146 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces 배포 κ°€μ΄λ“œ
2
+
3
+ 이 κ°€μ΄λ“œλŠ” SOY NV AI μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ„ Hugging Face Spaces에 λ°°ν¬ν•˜λŠ” 방법을 μ„€λͺ…ν•©λ‹ˆλ‹€.
4
+
5
+ ## 사전 μ€€λΉ„
6
+
7
+ 1. Hugging Face 계정 생성 및 둜그인
8
+ 2. Hugging Face Spaces 생성
9
+
10
+ ## 배포 단계
11
+
12
+ ### 1. Hugging Face Spaces 생성
13
+
14
+ 1. [Hugging Face Spaces](https://huggingface.co/spaces)에 접속
15
+ 2. "Create new Space" 클릭
16
+ 3. λ‹€μŒ 정보 μž…λ ₯:
17
+ - **Space name**: μ›ν•˜λŠ” 이름 (예: `soy-nv-ai`)
18
+ - **SDK**: `Docker` 선택
19
+ - **Docker template**: **Blank** 선택 (λ˜λŠ” λΉ„μ›Œλ‘κΈ°) βœ…
20
+ - 이미 `Dockerfile`을 μƒμ„±ν–ˆμœΌλ―€λ‘œ blank ν…œν”Œλ¦Ώμ„ μ„ νƒν•˜μ„Έμš”
21
+ - **Hardware**: ν•„μš”μ— 따라 선택 (CPU κΈ°λ³Έ, GPU ν•„μš” μ‹œ 선택)
22
+ - **Visibility**: Public λ˜λŠ” Private 선택
23
+
24
+ ### 2. 파일 μ—…λ‘œλ“œ
25
+
26
+ λ‹€μŒ νŒŒμΌλ“€μ„ Hugging Face Spaces에 μ—…λ‘œλ“œν•΄μ•Ό ν•©λ‹ˆλ‹€:
27
+
28
+ #### ν•„μˆ˜ 파일
29
+ - `app.py` - Hugging Face Spaces μ§„μž…μ 
30
+ - `requirements.txt` - Python νŒ¨ν‚€μ§€ μ˜μ‘΄μ„±
31
+ - `README.md` - ν”„λ‘œμ νŠΈ μ„€λͺ…
32
+ - `app/` - μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ½”λ“œ 디렉토리
33
+ - `templates/` - HTML ν…œν”Œλ¦Ώ 디렉토리
34
+ - `static/` - 정적 파일 디렉토리
35
+
36
+ #### 선택적 파일
37
+ - `.gitignore` - Git λ¬΄μ‹œ 파일
38
+ - `.env.example` - ν™˜κ²½ λ³€μˆ˜ 예제 (μ‹€μ œ `.env`λŠ” μ—…λ‘œλ“œν•˜μ§€ μ•ŠμŒ)
39
+
40
+ ### 3. ν™˜κ²½ λ³€μˆ˜ μ„€μ •
41
+
42
+ Hugging Face Spaces의 Settings > Repository secretsμ—μ„œ λ‹€μŒ ν™˜κ²½ λ³€μˆ˜λ₯Ό μ„€μ •ν•˜μ„Έμš”:
43
+
44
+ #### ν•„μˆ˜ ν™˜κ²½ λ³€μˆ˜
45
+ - `SECRET_KEY`: Flask μ‹œν¬λ¦Ώ ν‚€ (랜덀 λ¬Έμžμ—΄ 생성)
46
+ - `GEMINI_API_KEY`: Google Gemini API ν‚€ (Gemini μ‚¬μš© μ‹œ)
47
+
48
+ #### 선택적 ν™˜κ²½ λ³€μˆ˜
49
+ - `DATABASE_URL`: λ°μ΄ν„°λ² μ΄μŠ€ URL (κΈ°λ³Έκ°’: SQLite μ‚¬μš©)
50
+ - `OLLAMA_BASE_URL`: Ollama μ„œλ²„ URL (κΈ°λ³Έκ°’: http://localhost:11434)
51
+ - `EMBEDDING_MODEL_NAME`: μž„λ² λ”© λͺ¨λΈ 이름
52
+ - `RERANKER_MODEL_NAME`: 리랭컀 λͺ¨λΈ 이름
53
+
54
+ ### 4. Dockerfile 확인
55
+
56
+ βœ… **이미 생성됨**: ν”„λ‘œμ νŠΈ λ£¨νŠΈμ— `Dockerfile`이 이미 μƒμ„±λ˜μ–΄ μžˆμŠ΅λ‹ˆλ‹€.
57
+
58
+ ν•„μš”ν•œ 경우 `Dockerfile`을 ν™•μΈν•˜κ±°λ‚˜ μˆ˜μ •ν•  수 μžˆμŠ΅λ‹ˆλ‹€.
59
+
60
+ ### 5. README.md μž‘μ„±
61
+
62
+ Hugging Face Spaces용 README.mdλ₯Ό μž‘μ„±ν•˜μ„Έμš”:
63
+
64
+ ```markdown
65
+ ---
66
+ title: SOY NV AI
67
+ emoji: πŸ“š
68
+ colorFrom: blue
69
+ colorTo: purple
70
+ sdk: docker
71
+ pinned: false
72
+ ---
73
+
74
+ # SOY NV AI - μ›Ήμ†Œμ„€ μž‘ν’ˆ 개발 μ–΄μ‹œμŠ€ν„΄νŠΈ
75
+
76
+ AIλ₯Ό ν™œμš©ν•œ μ›Ήμ†Œμ„€ μž‘ν’ˆ κ°œλ°œμ„ μ§€μ›ν•˜λŠ” μ–΄μ‹œμŠ€ν„΄νŠΈμž…λ‹ˆλ‹€.
77
+
78
+ ## κΈ°λŠ₯
79
+
80
+ - μ›Ήμ†Œμ„€ 파일 μ—…λ‘œλ“œ 및 뢄석
81
+ - AI 기반 μž‘ν’ˆ 뢄석 및 μš”μ•½
82
+ - 캐릭터 관계 κ·Έλž˜ν”„ μΆ”μΆœ
83
+ - νšŒμ°¨λ³„ 뢄석
84
+
85
+ ## μ‚¬μš© 방법
86
+
87
+ 1. 둜그인 λ˜λŠ” νšŒμ›κ°€μž…
88
+ 2. μ›Ήμ†Œμ„€ 파일 μ—…λ‘œλ“œ
89
+ 3. AI 뢄석 μ‹€ν–‰
90
+ 4. κ²°κ³Ό 확인
91
+
92
+ ## ν™˜κ²½ λ³€μˆ˜
93
+
94
+ λ‹€μŒ ν™˜κ²½ λ³€μˆ˜λ₯Ό μ„€μ •ν•΄μ•Ό ν•©λ‹ˆλ‹€:
95
+
96
+ - `SECRET_KEY`: Flask μ‹œν¬λ¦Ώ ν‚€
97
+ - `GEMINI_API_KEY`: Google Gemini API ν‚€ (선택사항)
98
+ ```
99
+
100
+ ## μ£Όμ˜μ‚¬ν•­
101
+
102
+ ### 1. λ°μ΄ν„°λ² μ΄μŠ€
103
+
104
+ - Hugging Face SpacesλŠ” μž„μ‹œ μ €μž₯μ†Œμ΄λ―€λ‘œ, λ°μ΄ν„°λ² μ΄μŠ€λŠ” 영ꡬ μ €μž₯μ†Œκ°€ μ•„λ‹™λ‹ˆλ‹€.
105
+ - μ€‘μš”ν•œ λ°μ΄ν„°λŠ” μ™ΈλΆ€ λ°μ΄ν„°λ² μ΄μŠ€(PostgreSQL, MySQL λ“±)λ₯Ό μ‚¬μš©ν•˜κ±°λ‚˜ μ •κΈ°μ μœΌλ‘œ λ°±μ—…ν•˜μ„Έμš”.
106
+
107
+ ### 2. 파일 μ—…λ‘œλ“œ
108
+
109
+ - μ—…λ‘œλ“œλœ νŒŒμΌμ€ Spaces μž¬μ‹œμž‘ μ‹œ μ‚­μ œλ  수 μžˆμŠ΅λ‹ˆλ‹€.
110
+ - 영ꡬ μ €μž₯이 ν•„μš”ν•œ 경우 μ™ΈλΆ€ μŠ€ν† λ¦¬μ§€(S3, Google Cloud Storage λ“±)λ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
111
+
112
+ ### 3. λ¦¬μ†ŒμŠ€ μ œν•œ
113
+
114
+ - Hugging Face SpacesλŠ” 무료 ν‹°μ–΄μ—μ„œ λ¦¬μ†ŒμŠ€ μ œν•œμ΄ μžˆμŠ΅λ‹ˆλ‹€.
115
+ - λŒ€μš©λŸ‰ 파일 μ²˜λ¦¬λ‚˜ κΈ΄ μž‘μ—…μ€ νƒ€μž„μ•„μ›ƒλ  수 μžˆμŠ΅λ‹ˆλ‹€.
116
+
117
+ ### 4. Ollama μ‚¬μš©
118
+
119
+ - OllamaλŠ” 둜컬 μ„œλ²„μ΄λ―€λ‘œ Hugging Face Spacesμ—μ„œ 직접 μ‚¬μš©ν•  수 μ—†μŠ΅λ‹ˆλ‹€.
120
+ - μ™ΈλΆ€ Ollama μ„œλ²„λ₯Ό μ‚¬μš©ν•˜κ±°λ‚˜, Hugging Face의 λͺ¨λΈ APIλ₯Ό μ‚¬μš©ν•˜μ„Έμš”.
121
+
122
+ ## 배포 ν›„ 확인
123
+
124
+ 1. Spaces νŽ˜μ΄μ§€μ—μ„œ "Logs" 탭을 ν™•μΈν•˜μ—¬ 였λ₯˜κ°€ μ—†λŠ”μ§€ 확인
125
+ 2. μ• ν”Œλ¦¬μΌ€μ΄μ…˜μ΄ μ •μƒμ μœΌλ‘œ λ‘œλ“œλ˜λŠ”μ§€ 확인
126
+ 3. 둜그인 및 κΈ°λ³Έ κΈ°λŠ₯ ν…ŒμŠ€νŠΈ
127
+
128
+ ## 문제 ν•΄κ²°
129
+
130
+ ### λΉŒλ“œ μ‹€νŒ¨
131
+ - `requirements.txt`의 νŒ¨ν‚€μ§€ 버전 확인
132
+ - λ‘œκ·Έμ—μ„œ 였λ₯˜ λ©”μ‹œμ§€ 확인
133
+
134
+ ### λŸ°νƒ€μž„ 였λ₯˜
135
+ - ν™˜κ²½ λ³€μˆ˜κ°€ μ˜¬λ°”λ₯΄κ²Œ μ„€μ •λ˜μ—ˆλŠ”μ§€ 확인
136
+ - λ°μ΄ν„°λ² μ΄μŠ€ 경둜 및 κΆŒν•œ 확인
137
+
138
+ ### μ„±λŠ₯ 문제
139
+ - 더 높은 ν•˜λ“œμ›¨μ–΄ ν‹°μ–΄λ‘œ μ—…κ·Έλ ˆμ΄λ“œ κ³ λ €
140
+ - λΆˆν•„μš”ν•œ μ˜μ‘΄μ„± 제거
141
+
142
+ ## μΆ”κ°€ λ¦¬μ†ŒμŠ€
143
+
144
+ - [Hugging Face Spaces λ¬Έμ„œ](https://huggingface.co/docs/hub/spaces)
145
+ - [Docker 배포 κ°€μ΄λ“œ](https://huggingface.co/docs/hub/spaces-sdks-docker)
146
+
README_HF.md ADDED
@@ -0,0 +1,56 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ ---
2
+ title: SOY NV AI
3
+ emoji: πŸ“š
4
+ colorFrom: blue
5
+ colorTo: purple
6
+ sdk: docker
7
+ pinned: false
8
+ license: mit
9
+ ---
10
+
11
+ # SOY NV AI - μ›Ήμ†Œμ„€ μž‘ν’ˆ 개발 μ–΄μ‹œμŠ€ν„΄νŠΈ
12
+
13
+ AIλ₯Ό ν™œμš©ν•œ μ›Ήμ†Œμ„€ μž‘ν’ˆ κ°œλ°œμ„ μ§€μ›ν•˜λŠ” μ–΄μ‹œμŠ€ν„΄νŠΈμž…λ‹ˆλ‹€.
14
+
15
+ ## μ£Όμš” κΈ°λŠ₯
16
+
17
+ - πŸ“ μ›Ήμ†Œμ„€ 파일 μ—…λ‘œλ“œ 및 뢄석
18
+ - πŸ€– AI 기반 μž‘ν’ˆ 뢄석 및 μš”μ•½
19
+ - πŸ”— 캐릭터 관계 κ·Έλž˜ν”„ μΆ”μΆœ
20
+ - πŸ“Š νšŒμ°¨λ³„ 상세 뢄석
21
+ - πŸ’¬ AI μ±„νŒ… μ–΄μ‹œμŠ€ν„΄νŠΈ
22
+
23
+ ## μ‚¬μš© 방법
24
+
25
+ 1. **둜그인/νšŒμ›κ°€μž…**: 처음 μ‚¬μš© μ‹œ 계정을 μƒμ„±ν•˜μ„Έμš”
26
+ 2. **파일 μ—…λ‘œλ“œ**: μ›Ήμ†Œμ„€ 파일(.txt, .md)을 μ—…λ‘œλ“œν•˜μ„Έμš”
27
+ 3. **AI 뢄석**: Parent Chunk, Episode Analysis, Graph Extraction을 μ‹€ν–‰ν•˜μ„Έμš”
28
+ 4. **κ²°κ³Ό 확인**: 뢄석 κ²°κ³Όλ₯Ό ν™•μΈν•˜κ³  AI와 λŒ€ν™”ν•˜μ„Έμš”
29
+
30
+ ## ν™˜κ²½ λ³€μˆ˜ μ„€μ •
31
+
32
+ Settings > Repository secretsμ—μ„œ λ‹€μŒ ν™˜κ²½ λ³€μˆ˜λ₯Ό μ„€μ •ν•˜μ„Έμš”:
33
+
34
+ ### ν•„μˆ˜
35
+ - `SECRET_KEY`: Flask μ‹œν¬λ¦Ώ ν‚€ (랜덀 λ¬Έμžμ—΄)
36
+
37
+ ### 선택사항
38
+ - `GEMINI_API_KEY`: Google Gemini API ν‚€
39
+ - `DATABASE_URL`: λ°μ΄ν„°λ² μ΄μŠ€ URL (κΈ°λ³Έ: SQLite)
40
+ - `OLLAMA_BASE_URL`: Ollama μ„œλ²„ URL
41
+
42
+ ## 기술 μŠ€νƒ
43
+
44
+ - **Backend**: Flask, SQLAlchemy
45
+ - **AI**: Google Gemini, Ollama
46
+ - **Vector DB**: ChromaDB
47
+ - **Embedding**: Sentence Transformers
48
+
49
+ ## μ£Όμ˜μ‚¬ν•­
50
+
51
+ ⚠️ Hugging Face SpacesλŠ” μž„μ‹œ μ €μž₯μ†Œμž…λ‹ˆλ‹€. μ€‘μš”ν•œ λ°μ΄ν„°λŠ” μ •κΈ°μ μœΌλ‘œ λ°±μ—…ν•˜μ„Έμš”.
52
+
53
+ ## λΌμ΄μ„ μŠ€
54
+
55
+ MIT License
56
+
add_exaone_model.py CHANGED
@@ -162,5 +162,6 @@ if __name__ == "__main__":
162
 
163
 
164
 
 
165
 
166
 
 
162
 
163
 
164
 
165
+
166
 
167
 
app.py ADDED
@@ -0,0 +1,72 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ Hugging Face Spaces용 Flask μ• ν”Œλ¦¬μΌ€μ΄μ…˜ μ§„μž…μ 
3
+ """
4
+ import sys
5
+ import os
6
+ import logging
7
+ from logging.handlers import RotatingFileHandler
8
+
9
+ # UTF-8 인코딩 κ°•μ œ μ„€μ •
10
+ if sys.platform == 'win32':
11
+ sys.stdout.reconfigure(encoding='utf-8')
12
+ sys.stderr.reconfigure(encoding='utf-8')
13
+
14
+ from app import create_app
15
+
16
+ app = create_app()
17
+
18
+ # Hugging Face Spaces ν™˜κ²½ λ³€μˆ˜ μ„€μ •
19
+ # SpacesλŠ” μžλ™μœΌλ‘œ 포트λ₯Ό ν• λ‹Ήν•˜λ―€λ‘œ ν™˜κ²½ λ³€μˆ˜μ—μ„œ κ°€μ Έμ˜΄
20
+ port = int(os.environ.get('PORT', 7860))
21
+ host = os.environ.get('HOST', '0.0.0.0')
22
+
23
+ # λ‘œκΉ… μ„€μ •
24
+ if not os.path.exists('logs'):
25
+ os.mkdir('logs')
26
+
27
+ # 파일 ν•Έλ“€λŸ¬ μ„€μ •
28
+ file_handler = RotatingFileHandler('logs/server.log', maxBytes=10240000, backupCount=10)
29
+ file_handler.setFormatter(logging.Formatter(
30
+ '%(asctime)s %(levelname)s: %(message)s [in %(pathname)s:%(lineno)d]'
31
+ ))
32
+ file_handler.setLevel(logging.INFO)
33
+
34
+ # μ½˜μ†” ν•Έλ“€λŸ¬ μ„€μ •
35
+ console_handler = logging.StreamHandler(sys.stdout)
36
+ console_handler.setFormatter(logging.Formatter(
37
+ '%(asctime)s - %(name)s - %(levelname)s - %(message)s'
38
+ ))
39
+ console_handler.setLevel(logging.INFO)
40
+
41
+ # Flask μ•± 둜거 μ„€μ •
42
+ app.logger.setLevel(logging.INFO)
43
+ app.logger.addHandler(file_handler)
44
+ app.logger.addHandler(console_handler)
45
+
46
+ # 루트 둜거 μ„€μ •
47
+ root_logger = logging.getLogger()
48
+ root_logger.setLevel(logging.INFO)
49
+ root_logger.addHandler(console_handler)
50
+
51
+ # Werkzeug 둜거 μ„€μ •
52
+ werkzeug_logger = logging.getLogger('werkzeug')
53
+ werkzeug_logger.setLevel(logging.INFO)
54
+ werkzeug_logger.handlers.clear()
55
+ werkzeug_handler = logging.StreamHandler(sys.stdout)
56
+ werkzeug_handler.setFormatter(logging.Formatter(
57
+ '%(asctime)s - %(levelname)s - %(message)s'
58
+ ))
59
+ werkzeug_logger.addHandler(werkzeug_handler)
60
+
61
+ app.logger.info(f'μ„œλ²„ μ‹œμž‘ - Host: {host}, Port: {port}')
62
+
63
+ if __name__ == '__main__':
64
+ try:
65
+ print(f"[{__name__}] μ„œλ²„ μ‹œμž‘: http://{host}:{port}")
66
+ print(f"[{__name__}] λ‘œκ·ΈλŠ” μ½˜μ†”κ³Ό logs/server.log νŒŒμΌμ— κΈ°λ‘λ©λ‹ˆλ‹€.")
67
+ app.run(host=host, port=port, debug=False, use_reloader=False)
68
+ except Exception as e:
69
+ print(f"μ„œλ²„ μ‹œμž‘ 였λ₯˜: {e}")
70
+ import traceback
71
+ traceback.print_exc()
72
+
app/database.py CHANGED
@@ -41,6 +41,7 @@ class UploadedFile(db.Model):
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
  parent_file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=True) # μ΄μ–΄μ„œ μ—…λ‘œλ“œν•œ 경우 원본 파일 ID
@@ -58,6 +59,7 @@ class UploadedFile(db.Model):
58
  'original_filename': self.original_filename,
59
  'file_size': self.file_size,
60
  'model_name': self.model_name,
 
61
  'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
62
  'uploaded_by': self.uploaded_by,
63
  'parent_file_id': self.parent_file_id,
 
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
+ is_public = db.Column(db.Boolean, default=False, nullable=False) # 곡개 μ—¬λΆ€ (κΈ°λ³Έκ°’: 미곡개)
45
  uploaded_at = db.Column(db.DateTime, default=datetime.utcnow, nullable=False)
46
  uploaded_by = db.Column(db.Integer, db.ForeignKey('user.id'), nullable=True)
47
  parent_file_id = db.Column(db.Integer, db.ForeignKey('uploaded_file.id'), nullable=True) # μ΄μ–΄μ„œ μ—…λ‘œλ“œν•œ 경우 원본 파일 ID
 
59
  'original_filename': self.original_filename,
60
  'file_size': self.file_size,
61
  'model_name': self.model_name,
62
+ 'is_public': self.is_public,
63
  'uploaded_at': self.uploaded_at.isoformat() if self.uploaded_at else None,
64
  'uploaded_by': self.uploaded_by,
65
  'parent_file_id': self.parent_file_id,
app/gemini_client.py CHANGED
@@ -414,8 +414,38 @@ class GeminiClient:
414
  elif rest_response.status_code != 200:
415
  error_text_v1beta = rest_response.text[:1000] if rest_response.text else '상세 정보 μ—†μŒ'
416
  raise Exception(f"REST API 였λ₯˜ {rest_response.status_code}: {error_text_v1beta}")
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
417
  else:
418
- # 404κ°€ μ•„λ‹Œ λ‹€λ₯Έ μ—λŸ¬
419
  error_text = json.dumps(error_info)
420
  raise Exception(f"REST API 였λ₯˜ {error_code}: {error_text}")
421
  except ValueError:
@@ -497,8 +527,12 @@ class GeminiClient:
497
  error_str = str(e).lower()
498
 
499
  # μž¬μ‹œλ„ κ°€λŠ₯ν•œ 였λ₯˜μΈμ§€ 확인 (νƒ€μž„μ•„μ›ƒ, λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±)
 
 
 
 
500
  retryable_errors = ['timeout', '503', '502', '504', 'connection', 'network', 'illegal metadata']
501
- is_retryable = any(err in error_str for err in retryable_errors)
502
 
503
  # deadline 확인
504
  if time.time() >= deadline_time:
@@ -512,7 +546,10 @@ class GeminiClient:
512
  wait_time = min(wait_time * multiplier, max_wait) # 배수둜 증가, μ΅œλŒ€κ°’ μ œν•œ
513
  else:
514
  # μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜
515
- print(f"[Gemini] μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜: {str(e)[:100]}")
 
 
 
516
  raise
517
 
518
  # 응닡 μ‹œκ°„ 확인
@@ -604,8 +641,12 @@ class GeminiClient:
604
  error_str = str(e).lower()
605
 
606
  # μž¬μ‹œλ„ κ°€λŠ₯ν•œ 였λ₯˜μΈμ§€ 확인 (νƒ€μž„μ•„μ›ƒ, λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±)
 
 
 
 
607
  retryable_errors = ['timeout', '503', '502', '504', 'connection', 'network', 'illegal metadata']
608
- is_retryable = any(err in error_str for err in retryable_errors)
609
 
610
  # deadline 확인
611
  if time.time() >= deadline_time:
@@ -619,7 +660,10 @@ class GeminiClient:
619
  wait_time = min(wait_time * multiplier, max_wait) # 배수둜 증가, μ΅œλŒ€κ°’ μ œν•œ
620
  else:
621
  # μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜
622
- print(f"[Gemini] μ±„νŒ… μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜: {str(e)[:100]}")
 
 
 
623
  raise
624
 
625
  elapsed_time = time.time() - start_time
 
414
  elif rest_response.status_code != 200:
415
  error_text_v1beta = rest_response.text[:1000] if rest_response.text else '상세 정보 μ—†μŒ'
416
  raise Exception(f"REST API 였λ₯˜ {rest_response.status_code}: {error_text_v1beta}")
417
+ elif error_code == 429:
418
+ # 429 였λ₯˜: ν• λ‹ΉλŸ‰ 초과 (μž¬μ‹œλ„ λΆˆκ°€λŠ₯)
419
+ print(f"[Gemini] ❌ ν• λ‹ΉλŸ‰ 초과 였λ₯˜ (429) 감지")
420
+ print(f"[Gemini] μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜: REST API 였λ₯˜ {error_code}: {json.dumps(error_info)}")
421
+
422
+ # μ—λŸ¬ λ©”μ‹œμ§€μ—μ„œ ꢌμž₯ λͺ¨λΈ μΆ”μΆœ
423
+ recommended_model = None
424
+ if 'gemini-2.0-flash' in error_message.lower() or 'gemini 2.0' in error_message.lower():
425
+ recommended_model = "gemini-2.0-flash-exp"
426
+
427
+ # μ‚¬μš©μž μΉœν™”μ μΈ μ—λŸ¬ λ©”μ‹œμ§€ 생성
428
+ quota_error_msg = f"""Gemini API ν• λ‹ΉλŸ‰ 초과 (429)
429
+
430
+ ν˜„μž¬ μ‚¬μš© 쀑인 λͺ¨λΈ '{model_name_clean}'의 일일 μš”μ²­ ν•œλ„λ₯Ό μ΄ˆκ³Όν–ˆμŠ΅λ‹ˆλ‹€.
431
+
432
+ ν•΄κ²° 방법:
433
+ 1. λ‚΄μΌκΉŒμ§€ λŒ€κΈ° (ν• λ‹ΉλŸ‰μ΄ μžλ™μœΌλ‘œ μž¬μ„€μ •λ©λ‹ˆλ‹€)
434
+ 2. λ‹€λ₯Έ Gemini λͺ¨λΈλ‘œ λ³€κ²½:
435
+ - gemini-2.0-flash-exp (더 높은 ν• λ‹ΉλŸ‰ 제곡)
436
+ - gemini-1.5-pro
437
+ - gemini-1.5-flash
438
+ 3. Google AI Studioμ—μ„œ ν• λ‹ΉλŸ‰ 확인:
439
+ https://aistudio.google.com/app/apikey
440
+
441
+ 상세 였λ₯˜: {error_message[:200]}"""
442
+
443
+ if recommended_model:
444
+ quota_error_msg += f"\n\nꢌμž₯: {recommended_model} λͺ¨λΈλ‘œ 변경을 κ³ λ €ν•΄λ³΄μ„Έμš”."
445
+
446
+ raise Exception(quota_error_msg)
447
  else:
448
+ # 404, 429κ°€ μ•„λ‹Œ λ‹€λ₯Έ μ—λŸ¬
449
  error_text = json.dumps(error_info)
450
  raise Exception(f"REST API 였λ₯˜ {error_code}: {error_text}")
451
  except ValueError:
 
527
  error_str = str(e).lower()
528
 
529
  # μž¬μ‹œλ„ κ°€λŠ₯ν•œ 였λ₯˜μΈμ§€ 확인 (νƒ€μž„μ•„μ›ƒ, λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±)
530
+ # 429(ν• λ‹ΉλŸ‰ 초과), 400(잘λͺ»λœ μš”μ²­), 401(인증 μ‹€νŒ¨), 403(κΆŒν•œ μ—†μŒ)은 μž¬μ‹œλ„ λΆˆκ°€
531
+ non_retryable_errors = ['429', 'quota', 'exceeded', '400', '401', '403', 'api key', 'invalid', 'unauthorized', 'forbidden']
532
+ is_non_retryable = any(err in error_str for err in non_retryable_errors)
533
+
534
  retryable_errors = ['timeout', '503', '502', '504', 'connection', 'network', 'illegal metadata']
535
+ is_retryable = any(err in error_str for err in retryable_errors) and not is_non_retryable
536
 
537
  # deadline 확인
538
  if time.time() >= deadline_time:
 
546
  wait_time = min(wait_time * multiplier, max_wait) # 배수둜 증가, μ΅œλŒ€κ°’ μ œν•œ
547
  else:
548
  # μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜
549
+ if is_non_retryable:
550
+ print(f"[Gemini] μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜ (ν• λ‹ΉλŸ‰/인증 였λ₯˜): {str(e)[:200]}")
551
+ else:
552
+ print(f"[Gemini] μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜: {str(e)[:100]}")
553
  raise
554
 
555
  # 응닡 μ‹œκ°„ 확인
 
641
  error_str = str(e).lower()
642
 
643
  # μž¬μ‹œλ„ κ°€λŠ₯ν•œ 였λ₯˜μΈμ§€ 확인 (νƒ€μž„μ•„μ›ƒ, λ„€νŠΈμ›Œν¬ 였λ₯˜ λ“±)
644
+ # 429(ν• λ‹ΉλŸ‰ 초과), 400(잘λͺ»λœ μš”μ²­), 401(인증 μ‹€νŒ¨), 403(κΆŒν•œ μ—†μŒ)은 μž¬μ‹œλ„ λΆˆκ°€
645
+ non_retryable_errors = ['429', 'quota', 'exceeded', '400', '401', '403', 'api key', 'invalid', 'unauthorized', 'forbidden']
646
+ is_non_retryable = any(err in error_str for err in non_retryable_errors)
647
+
648
  retryable_errors = ['timeout', '503', '502', '504', 'connection', 'network', 'illegal metadata']
649
+ is_retryable = any(err in error_str for err in retryable_errors) and not is_non_retryable
650
 
651
  # deadline 확인
652
  if time.time() >= deadline_time:
 
660
  wait_time = min(wait_time * multiplier, max_wait) # 배수둜 증가, μ΅œλŒ€κ°’ μ œν•œ
661
  else:
662
  # μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜
663
+ if is_non_retryable:
664
+ print(f"[Gemini] μ±„νŒ… μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜ (ν• λ‹ΉλŸ‰/인증 였λ₯˜): {str(e)[:200]}")
665
+ else:
666
+ print(f"[Gemini] μ±„νŒ… μž¬μ‹œλ„ λΆˆκ°€λŠ₯ν•œ 였λ₯˜: {str(e)[:100]}")
667
  raise
668
 
669
  elapsed_time = time.time() - start_time
app/routes.py CHANGED
@@ -475,6 +475,8 @@ character_relationshipsλŠ” 이 청크에 λ“±μž₯ν•˜λŠ” 인물듀 κ°„μ˜ ν˜„μž¬
475
  else:
476
  # Ollama API 호좜
477
  try:
 
 
478
  ollama_response = requests.post(
479
  f'{OLLAMA_BASE_URL}/api/generate',
480
  json={
@@ -483,7 +485,8 @@ character_relationshipsλŠ” 이 청크에 λ“±μž₯ν•˜λŠ” 인물듀 κ°„μ˜ ν˜„μž¬
483
  'stream': False,
484
  'options': {
485
  'temperature': 0.3,
486
- 'num_predict': get_model_token_limit(model_name, 500) # μ €μž₯된 토큰 수 μ‚¬μš©
 
487
  }
488
  },
489
  timeout=120 # 2λΆ„ νƒ€μž„μ•„μ›ƒ
@@ -659,6 +662,8 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
659
  else:
660
  # Ollama API 호좜
661
  try:
 
 
662
  ollama_response = requests.post(
663
  f'{OLLAMA_BASE_URL}/api/generate',
664
  json={
@@ -667,7 +672,8 @@ def analyze_episode(episode_content, episode_title, full_content=None, parent_ch
667
  'stream': False,
668
  'options': {
669
  'temperature': 0.5,
670
- 'num_predict': get_model_token_limit(model_name, 3000) # μ €μž₯된 토큰 수 μ‚¬μš©
 
671
  }
672
  },
673
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ (회차 뢄석은 μ‹œκ°„μ΄ 였래 걸릴 수 있음)
@@ -770,6 +776,8 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
770
  else:
771
  # Ollama API 호좜
772
  try:
 
 
773
  ollama_response = requests.post(
774
  f'{OLLAMA_BASE_URL}/api/generate',
775
  json={
@@ -778,7 +786,8 @@ def extract_graph_from_episode(episode_content, episode_title, file_id, full_con
778
  'stream': False,
779
  'options': {
780
  'temperature': 0.3,
781
- 'num_predict': 3000
 
782
  }
783
  },
784
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ
@@ -1213,6 +1222,8 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
1213
  print(f"[Parent Chunk 생성] Ollama API에 뢄석 μš”μ²­ 전솑 쀑... (λͺ¨λΈ: {model_name})")
1214
 
1215
  try:
 
 
1216
  ollama_response = requests.post(
1217
  f'{OLLAMA_BASE_URL}/api/chat',
1218
  json={
@@ -1223,7 +1234,10 @@ def create_parent_chunk_with_ai(file_id, content, model_name):
1223
  'content': analysis_prompt
1224
  }
1225
  ],
1226
- 'stream': False
 
 
 
1227
  },
1228
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ
1229
  )
@@ -2852,12 +2866,19 @@ def chat():
2852
  response_text = '응닡을 생성할 수 μ—†μ—ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.'
2853
  else:
2854
  # Ollama API 호좜
 
 
 
2855
  ollama_response = requests.post(
2856
  f'{OLLAMA_BASE_URL}/api/generate',
2857
  json={
2858
  'model': answer_model, # λ‹΅λ³€ λͺ¨λΈ μ‚¬μš©
2859
  'prompt': full_prompt,
2860
- 'stream': False
 
 
 
 
2861
  },
2862
  timeout=120 # 파일이 λ§Žμ„ 수 μžˆμœΌλ―€λ‘œ νƒ€μž„μ•„μ›ƒ 증가
2863
  )
@@ -3176,6 +3197,7 @@ def upload_file():
3176
  file_path=file_path,
3177
  file_size=saved_file_size,
3178
  model_name=model_name, # 이미 검증됨
 
3179
  uploaded_by=current_user.id,
3180
  parent_file_id=parent_file_id if parent_file else None # μ΄μ–΄μ„œ μ—…λ‘œλ“œμΈ 경우
3181
  )
@@ -3259,11 +3281,16 @@ def get_files():
3259
  """μ—…λ‘œλ“œλœ 파일 λͺ©λ‘ 쑰회"""
3260
  try:
3261
  model_name = request.args.get('model_name', None)
 
3262
 
3263
  # 원본 파일만 쑰회 (parent_file_idκ°€ None인 파일)
3264
- # λͺ¨λ“  μ‚¬μš©μžκ°€ μ—…λ‘œλ“œλœ λͺ¨λ“  νŒŒμΌμ„ λ³Ό 수 있음
3265
- query = UploadedFile.query.filter_by(parent_file_id=None)
3266
- print(f"[파일 쑰회] λͺ¨λ“  파일 쑰회 (μ‚¬μš©μž: {current_user.username})")
 
 
 
 
3267
 
3268
  # λͺ¨λΈ 필터링 μ „ 전체 파일 수 확인
3269
  total_before_filter = query.count()
@@ -3311,12 +3338,14 @@ def get_files():
3311
  file_dict['child_files'] = child_files_dict
3312
  files_with_children.append(file_dict)
3313
 
3314
- # λͺ¨λΈλ³„ 톡계 정보 μΆ”κ°€ (원본 파일만 카운트)
3315
  model_stats = {}
3316
  if not model_name:
3317
- # λͺ¨λ“  λͺ¨λΈμ˜ 톡계 (원본 파일만)
3318
- # λͺ¨λ“  μ‚¬μš©μžκ°€ λͺ¨λ“  νŒŒμΌμ„ λ³Ό 수 있음
3319
- all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
 
 
3320
  for file in all_files:
3321
  model = file.model_name or 'λ―Έμ§€μ •'
3322
  if model not in model_stats:
@@ -4081,6 +4110,29 @@ def delete_file(file_id):
4081
  db.session.rollback()
4082
  return jsonify({'error': f'파일 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {str(e)}'}), 500
4083
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
4084
  @main_bp.route('/api/files/<int:file_id>/content', methods=['GET'])
4085
  @login_required
4086
  def get_file_content(file_id):
 
475
  else:
476
  # Ollama API 호좜
477
  try:
478
+ # μž…λ ₯ 토큰 수λ₯Ό num_ctx둜 μ‚¬μš©
479
+ num_ctx = get_model_token_limit_by_type(model_name, 100000, 'input')
480
  ollama_response = requests.post(
481
  f'{OLLAMA_BASE_URL}/api/generate',
482
  json={
 
485
  'stream': False,
486
  'options': {
487
  'temperature': 0.3,
488
+ 'num_predict': get_model_token_limit(model_name, 500), # μ €μž₯된 토큰 수 μ‚¬μš©
489
+ 'num_ctx': num_ctx # μž…λ ₯ 토큰 수λ₯Ό μ»¨ν…μŠ€νŠΈ μœˆλ„μš°λ‘œ μ‚¬μš©
490
  }
491
  },
492
  timeout=120 # 2λΆ„ νƒ€μž„μ•„μ›ƒ
 
662
  else:
663
  # Ollama API 호좜
664
  try:
665
+ # μž…λ ₯ 토큰 수λ₯Ό num_ctx둜 μ‚¬μš©
666
+ num_ctx = get_model_token_limit_by_type(model_name, 100000, 'input')
667
  ollama_response = requests.post(
668
  f'{OLLAMA_BASE_URL}/api/generate',
669
  json={
 
672
  'stream': False,
673
  'options': {
674
  'temperature': 0.5,
675
+ 'num_predict': get_model_token_limit(model_name, 3000), # μ €μž₯된 토큰 수 μ‚¬μš©
676
+ 'num_ctx': num_ctx # μž…λ ₯ 토큰 수λ₯Ό μ»¨ν…μŠ€νŠΈ μœˆλ„μš°λ‘œ μ‚¬μš©
677
  }
678
  },
679
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ (회차 뢄석은 μ‹œκ°„μ΄ 였래 걸릴 수 있음)
 
776
  else:
777
  # Ollama API 호좜
778
  try:
779
+ # μž…λ ₯ 토큰 수λ₯Ό num_ctx둜 μ‚¬μš©
780
+ num_ctx = get_model_token_limit_by_type(model_name, 100000, 'input')
781
  ollama_response = requests.post(
782
  f'{OLLAMA_BASE_URL}/api/generate',
783
  json={
 
786
  'stream': False,
787
  'options': {
788
  'temperature': 0.3,
789
+ 'num_predict': 3000,
790
+ 'num_ctx': num_ctx # μž…λ ₯ 토큰 수λ₯Ό μ»¨ν…μŠ€νŠΈ μœˆλ„μš°λ‘œ μ‚¬μš©
791
  }
792
  },
793
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ
 
1222
  print(f"[Parent Chunk 생성] Ollama API에 뢄석 μš”μ²­ 전솑 쀑... (λͺ¨λΈ: {model_name})")
1223
 
1224
  try:
1225
+ # μž…λ ₯ 토큰 수λ₯Ό num_ctx둜 μ‚¬μš©
1226
+ num_ctx = get_model_token_limit_by_type(model_name, 100000, 'input')
1227
  ollama_response = requests.post(
1228
  f'{OLLAMA_BASE_URL}/api/chat',
1229
  json={
 
1234
  'content': analysis_prompt
1235
  }
1236
  ],
1237
+ 'stream': False,
1238
+ 'options': {
1239
+ 'num_ctx': num_ctx # μž…λ ₯ 토큰 수λ₯Ό μ»¨ν…μŠ€νŠΈ μœˆλ„μš°λ‘œ μ‚¬μš©
1240
+ }
1241
  },
1242
  timeout=300 # 5λΆ„ νƒ€μž„μ•„μ›ƒ
1243
  )
 
2866
  response_text = '응닡을 생성할 수 μ—†μ—ˆμŠ΅λ‹ˆλ‹€. λ‹€μ‹œ μ‹œλ„ν•΄μ£Όμ„Έμš”.'
2867
  else:
2868
  # Ollama API 호좜
2869
+ # μž…λ ₯ 토큰 수λ₯Ό num_ctx둜 μ‚¬μš©
2870
+ num_ctx = get_model_token_limit_by_type(answer_model, 100000, 'input')
2871
+ num_predict = get_model_token_limit_by_type(answer_model, 8192, 'output')
2872
  ollama_response = requests.post(
2873
  f'{OLLAMA_BASE_URL}/api/generate',
2874
  json={
2875
  'model': answer_model, # λ‹΅λ³€ λͺ¨λΈ μ‚¬μš©
2876
  'prompt': full_prompt,
2877
+ 'stream': False,
2878
+ 'options': {
2879
+ 'num_ctx': num_ctx, # μž…λ ₯ 토큰 수λ₯Ό μ»¨ν…μŠ€νŠΈ μœˆλ„μš°λ‘œ μ‚¬μš©
2880
+ 'num_predict': num_predict # 좜λ ₯ 토큰 수
2881
+ }
2882
  },
2883
  timeout=120 # 파일이 λ§Žμ„ 수 μžˆμœΌλ―€λ‘œ νƒ€μž„μ•„μ›ƒ 증가
2884
  )
 
3197
  file_path=file_path,
3198
  file_size=saved_file_size,
3199
  model_name=model_name, # 이미 검증됨
3200
+ is_public=False, # κΈ°λ³Έκ°’: 미곡개
3201
  uploaded_by=current_user.id,
3202
  parent_file_id=parent_file_id if parent_file else None # μ΄μ–΄μ„œ μ—…λ‘œλ“œμΈ 경우
3203
  )
 
3281
  """μ—…λ‘œλ“œλœ 파일 λͺ©λ‘ 쑰회"""
3282
  try:
3283
  model_name = request.args.get('model_name', None)
3284
+ public_only = request.args.get('public_only', 'false').lower() == 'true' # 곡개 파일만 쑰회 μ˜΅μ…˜
3285
 
3286
  # 원본 파일만 쑰회 (parent_file_idκ°€ None인 파일)
3287
+ # κ΄€λ¦¬μžκ°€ μ•„λ‹Œ 경우 곡개 파일만 쑰회, κ΄€λ¦¬μžλŠ” λͺ¨λ“  파일 쑰회 κ°€λŠ₯
3288
+ if public_only or (not current_user.is_admin):
3289
+ query = UploadedFile.query.filter_by(parent_file_id=None, is_public=True)
3290
+ print(f"[파일 쑰회] 곡개 파일만 쑰회 (μ‚¬μš©μž: {current_user.username}, κ΄€λ¦¬μž: {current_user.is_admin})")
3291
+ else:
3292
+ query = UploadedFile.query.filter_by(parent_file_id=None)
3293
+ print(f"[파일 쑰회] λͺ¨λ“  파일 쑰회 (μ‚¬μš©μž: {current_user.username}, κ΄€λ¦¬μž: {current_user.is_admin})")
3294
 
3295
  # λͺ¨λΈ 필터링 μ „ 전체 파일 수 확인
3296
  total_before_filter = query.count()
 
3338
  file_dict['child_files'] = child_files_dict
3339
  files_with_children.append(file_dict)
3340
 
3341
+ # λͺ¨λΈλ³„ 톡계 정보 μΆ”κ°€ (원본 파일만 카운트, 곡개 파일만)
3342
  model_stats = {}
3343
  if not model_name:
3344
+ # λͺ¨λ“  λͺ¨λΈμ˜ 톡계 (원본 파일만, 곡개 파일만)
3345
+ if public_only or (not current_user.is_admin):
3346
+ all_files = UploadedFile.query.filter_by(parent_file_id=None, is_public=True).all()
3347
+ else:
3348
+ all_files = UploadedFile.query.filter_by(parent_file_id=None).all()
3349
  for file in all_files:
3350
  model = file.model_name or 'λ―Έμ§€μ •'
3351
  if model not in model_stats:
 
4110
  db.session.rollback()
4111
  return jsonify({'error': f'파일 μ‚­μ œ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {str(e)}'}), 500
4112
 
4113
+ @main_bp.route('/api/files/<int:file_id>/public', methods=['PUT'])
4114
+ @login_required
4115
+ @admin_required
4116
+ def toggle_file_public(file_id):
4117
+ """파일 곡개 μ—¬λΆ€ λ³€κ²½ (κ΄€λ¦¬μžλ§Œ κ°€λŠ₯)"""
4118
+ try:
4119
+ file = UploadedFile.query.get_or_404(file_id)
4120
+
4121
+ data = request.get_json()
4122
+ is_public = data.get('is_public', False)
4123
+
4124
+ file.is_public = is_public
4125
+ db.session.commit()
4126
+
4127
+ return jsonify({
4128
+ 'message': f'파일이 {"곡개" if is_public else "λΉ„κ³΅κ°œ"}둜 μ„€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.',
4129
+ 'file': file.to_dict()
4130
+ }), 200
4131
+
4132
+ except Exception as e:
4133
+ db.session.rollback()
4134
+ return jsonify({'error': f'파일 곡개 μ—¬λΆ€ λ³€κ²½ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€: {str(e)}'}), 500
4135
+
4136
  @main_bp.route('/api/files/<int:file_id>/content', methods=['GET'])
4137
  @login_required
4138
  def get_file_content(file_id):
download_exaone_model.py CHANGED
@@ -90,5 +90,6 @@ if __name__ == "__main__":
90
 
91
 
92
 
 
93
 
94
 
 
90
 
91
 
92
 
93
+
94
 
95
 
install_exaone_direct.py CHANGED
@@ -95,5 +95,6 @@ if __name__ == "__main__":
95
 
96
 
97
 
 
98
 
99
 
 
95
 
96
 
97
 
98
+
99
 
100
 
install_exaone_simple.py CHANGED
@@ -72,5 +72,6 @@ if __name__ == "__main__":
72
 
73
 
74
 
 
75
 
76
 
 
72
 
73
 
74
 
75
+
76
 
77
 
migrate_add_is_public.py ADDED
@@ -0,0 +1,61 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ """
2
+ λ°μ΄ν„°λ² μ΄μŠ€ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜ 슀크립트
3
+ uploaded_file ν…Œμ΄λΈ”μ— is_public μ»¬λŸΌμ„ μΆ”κ°€ν•©λ‹ˆλ‹€.
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
+ """λ°μ΄ν„°λ² μ΄μŠ€μ— is_public 컬럼 μΆ”κ°€"""
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
+ # uploaded_file ν…Œμ΄λΈ”μ— is_public 컬럼이 μžˆλŠ”μ§€ 확인
24
+ cursor.execute("PRAGMA table_info(uploaded_file)")
25
+ columns = [column[1] for column in cursor.fetchall()]
26
+
27
+ if 'is_public' in columns:
28
+ print("is_public 컬럼이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€.")
29
+ conn.close()
30
+ return
31
+
32
+ # is_public 컬럼 μΆ”κ°€ (κΈ°λ³Έκ°’: 0 = False)
33
+ print("is_public μ»¬λŸΌμ„ μΆ”κ°€ν•˜λŠ” 쀑...")
34
+ cursor.execute("ALTER TABLE uploaded_file ADD COLUMN is_public BOOLEAN DEFAULT 0")
35
+ conn.commit()
36
+ print("is_public 컬럼이 μ„±κ³΅μ μœΌλ‘œ μΆ”κ°€λ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
37
+
38
+ # κΈ°μ‘΄ λ°μ΄ν„°μ˜ is_public 값을 0(False)으둜 μ„€μ •
39
+ cursor.execute("UPDATE uploaded_file SET is_public = 0 WHERE is_public IS NULL")
40
+ conn.commit()
41
+ print("κΈ°μ‘΄ λ°μ΄ν„°μ˜ is_public 값을 0(False)으둜 μ„€μ •ν–ˆμŠ΅λ‹ˆλ‹€.")
42
+
43
+ conn.close()
44
+ print("λ§ˆμ΄κ·Έλ ˆμ΄μ…˜μ΄ μ™„λ£Œλ˜μ—ˆμŠ΅λ‹ˆλ‹€.")
45
+
46
+ except sqlite3.OperationalError as e:
47
+ print(f"였λ₯˜ λ°œμƒ: {e}")
48
+ if "duplicate column name" in str(e).lower() or "already exists" in str(e).lower():
49
+ print("is_public 컬럼이 이미 μ‘΄μž¬ν•©λ‹ˆλ‹€.")
50
+ else:
51
+ raise
52
+ except Exception as e:
53
+ print(f"μ˜ˆμƒμΉ˜ λͺ»ν•œ 였λ₯˜: {e}")
54
+ raise
55
+
56
+ if __name__ == '__main__':
57
+ print("=" * 60)
58
+ print("λ°μ΄ν„°λ² μ΄μŠ€ λ§ˆμ΄κ·Έλ ˆμ΄μ…˜: is_public 컬럼 μΆ”κ°€")
59
+ print("=" * 60)
60
+ migrate_database()
61
+
templates/admin.html CHANGED
@@ -43,6 +43,126 @@
43
  align-items: center;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .btn {
47
  padding: 8px 16px;
48
  border: none;
@@ -443,6 +563,7 @@
443
  <span>πŸ€–</span>
444
  <span>SOY NV AI κ΄€λ¦¬μž νŽ˜μ΄μ§€</span>
445
  </div>
 
446
  <div class="header-actions">
447
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
448
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
@@ -455,6 +576,26 @@
455
  </div>
456
  </div>
457
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
458
  <div class="container">
459
  <div class="page-header">
460
  <h1>μ‚¬μš©μž 관리</h1>
@@ -558,6 +699,24 @@
558
  <script>
559
  let currentEditUserId = null;
560
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
561
  function showAlert(message, type = 'success') {
562
  const container = document.getElementById('alertContainer');
563
  container.innerHTML = `<div class="alert ${type}">${message}</div>`;
 
43
  align-items: center;
44
  }
45
 
46
+ .menu-toggle {
47
+ display: none;
48
+ background: none;
49
+ border: none;
50
+ font-size: 24px;
51
+ cursor: pointer;
52
+ padding: 8px;
53
+ color: #202124;
54
+ }
55
+
56
+ .mobile-menu {
57
+ display: none;
58
+ position: fixed;
59
+ top: 0;
60
+ left: 0;
61
+ right: 0;
62
+ bottom: 0;
63
+ background: rgba(0, 0, 0, 0.5);
64
+ z-index: 1000;
65
+ }
66
+
67
+ .mobile-menu.active {
68
+ display: block;
69
+ }
70
+
71
+ .mobile-menu-content {
72
+ position: fixed;
73
+ top: 0;
74
+ right: -100%;
75
+ width: 280px;
76
+ max-width: 80%;
77
+ height: 100%;
78
+ background: white;
79
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
80
+ transition: right 0.3s ease;
81
+ overflow-y: auto;
82
+ z-index: 1001;
83
+ }
84
+
85
+ .mobile-menu.active .mobile-menu-content {
86
+ right: 0;
87
+ }
88
+
89
+ .mobile-menu-header {
90
+ padding: 16px 20px;
91
+ border-bottom: 1px solid #dadce0;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ background: white;
96
+ position: sticky;
97
+ top: 0;
98
+ z-index: 10;
99
+ }
100
+
101
+ .mobile-menu-title {
102
+ font-size: 18px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ .mobile-menu-close {
107
+ background: none;
108
+ border: none;
109
+ font-size: 24px;
110
+ cursor: pointer;
111
+ color: #202124;
112
+ padding: 0;
113
+ width: 32px;
114
+ height: 32px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .mobile-menu-items {
121
+ padding: 8px 0;
122
+ }
123
+
124
+ .mobile-menu-item {
125
+ display: block;
126
+ padding: 12px 20px;
127
+ color: #202124;
128
+ text-decoration: none;
129
+ border-bottom: 1px solid #f1f3f4;
130
+ transition: background 0.2s;
131
+ }
132
+
133
+ .mobile-menu-item:hover {
134
+ background: #f8f9fa;
135
+ }
136
+
137
+ .mobile-menu-user {
138
+ padding: 16px 20px;
139
+ border-bottom: 1px solid #dadce0;
140
+ color: #5f6368;
141
+ font-size: 14px;
142
+ }
143
+
144
+ @media (max-width: 768px) {
145
+ .header {
146
+ padding: 12px 16px;
147
+ }
148
+
149
+ .header-title {
150
+ font-size: 18px;
151
+ }
152
+
153
+ .header-title span:first-child {
154
+ display: none;
155
+ }
156
+
157
+ .menu-toggle {
158
+ display: block;
159
+ }
160
+
161
+ .header-actions {
162
+ display: none;
163
+ }
164
+ }
165
+
166
  .btn {
167
  padding: 8px 16px;
168
  border: none;
 
563
  <span>πŸ€–</span>
564
  <span>SOY NV AI κ΄€λ¦¬μž νŽ˜μ΄μ§€</span>
565
  </div>
566
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
567
  <div class="header-actions">
568
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
569
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
 
576
  </div>
577
  </div>
578
 
579
+ <!-- λͺ¨λ°”일 메뉴 -->
580
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
581
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
582
+ <div class="mobile-menu-header">
583
+ <div class="mobile-menu-title">메뉴</div>
584
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
585
+ </div>
586
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
587
+ <div class="mobile-menu-items">
588
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›Ήμ†Œμ„€ 관리</a>
589
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 λͺ©λ‘</a>
590
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μ‹œμ§€ 확인</a>
591
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ν”„λ‘¬ν”„νŠΈ 관리</a>
592
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI μ„€μ •</a>
593
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
594
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">��그아웃</a>
595
+ </div>
596
+ </div>
597
+ </div>
598
+
599
  <div class="container">
600
  <div class="page-header">
601
  <h1>μ‚¬μš©μž 관리</h1>
 
699
  <script>
700
  let currentEditUserId = null;
701
 
702
+ function toggleMobileMenu() {
703
+ const menu = document.getElementById('mobileMenu');
704
+ menu.classList.toggle('active');
705
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
706
+ }
707
+
708
+ function closeMobileMenu() {
709
+ const menu = document.getElementById('mobileMenu');
710
+ menu.classList.remove('active');
711
+ document.body.style.overflow = '';
712
+ }
713
+
714
+ function closeMobileMenuOnBackdrop(event) {
715
+ if (event.target.id === 'mobileMenu') {
716
+ closeMobileMenu();
717
+ }
718
+ }
719
+
720
  function showAlert(message, type = 'success') {
721
  const container = document.getElementById('alertContainer');
722
  container.innerHTML = `<div class="alert ${type}">${message}</div>`;
templates/admin_files.html CHANGED
@@ -43,6 +43,126 @@
43
  align-items: center;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .btn {
47
  padding: 8px 16px;
48
  border: none;
@@ -345,17 +465,39 @@
345
  <span>πŸ€–</span>
346
  <span>SOY NV AI κ΄€λ¦¬μž νŽ˜μ΄μ§€</span>
347
  </div>
 
348
  <div class="header-actions">
349
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
350
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
351
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
352
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
353
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
 
354
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
355
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
356
  </div>
357
  </div>
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  <div class="container">
360
  <div class="page-header">
361
  <h1>파일 λͺ©λ‘</h1>
@@ -523,6 +665,24 @@
523
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
524
 
525
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
526
  function escapeHtml(text) {
527
  const div = document.createElement('div');
528
  div.textContent = text;
 
43
  align-items: center;
44
  }
45
 
46
+ .menu-toggle {
47
+ display: none;
48
+ background: none;
49
+ border: none;
50
+ font-size: 24px;
51
+ cursor: pointer;
52
+ padding: 8px;
53
+ color: #202124;
54
+ }
55
+
56
+ .mobile-menu {
57
+ display: none;
58
+ position: fixed;
59
+ top: 0;
60
+ left: 0;
61
+ right: 0;
62
+ bottom: 0;
63
+ background: rgba(0, 0, 0, 0.5);
64
+ z-index: 1000;
65
+ }
66
+
67
+ .mobile-menu.active {
68
+ display: block;
69
+ }
70
+
71
+ .mobile-menu-content {
72
+ position: fixed;
73
+ top: 0;
74
+ right: -100%;
75
+ width: 280px;
76
+ max-width: 80%;
77
+ height: 100%;
78
+ background: white;
79
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
80
+ transition: right 0.3s ease;
81
+ overflow-y: auto;
82
+ z-index: 1001;
83
+ }
84
+
85
+ .mobile-menu.active .mobile-menu-content {
86
+ right: 0;
87
+ }
88
+
89
+ .mobile-menu-header {
90
+ padding: 16px 20px;
91
+ border-bottom: 1px solid #dadce0;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ background: white;
96
+ position: sticky;
97
+ top: 0;
98
+ z-index: 10;
99
+ }
100
+
101
+ .mobile-menu-title {
102
+ font-size: 18px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ .mobile-menu-close {
107
+ background: none;
108
+ border: none;
109
+ font-size: 24px;
110
+ cursor: pointer;
111
+ color: #202124;
112
+ padding: 0;
113
+ width: 32px;
114
+ height: 32px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .mobile-menu-items {
121
+ padding: 8px 0;
122
+ }
123
+
124
+ .mobile-menu-item {
125
+ display: block;
126
+ padding: 12px 20px;
127
+ color: #202124;
128
+ text-decoration: none;
129
+ border-bottom: 1px solid #f1f3f4;
130
+ transition: background 0.2s;
131
+ }
132
+
133
+ .mobile-menu-item:hover {
134
+ background: #f8f9fa;
135
+ }
136
+
137
+ .mobile-menu-user {
138
+ padding: 16px 20px;
139
+ border-bottom: 1px solid #dadce0;
140
+ color: #5f6368;
141
+ font-size: 14px;
142
+ }
143
+
144
+ @media (max-width: 768px) {
145
+ .header {
146
+ padding: 12px 16px;
147
+ }
148
+
149
+ .header-title {
150
+ font-size: 18px;
151
+ }
152
+
153
+ .header-title span:first-child {
154
+ display: none;
155
+ }
156
+
157
+ .menu-toggle {
158
+ display: block;
159
+ }
160
+
161
+ .header-actions {
162
+ display: none;
163
+ }
164
+ }
165
+
166
  .btn {
167
  padding: 8px 16px;
168
  border: none;
 
465
  <span>πŸ€–</span>
466
  <span>SOY NV AI κ΄€λ¦¬μž νŽ˜μ΄μ§€</span>
467
  </div>
468
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
469
  <div class="header-actions">
470
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
471
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
472
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
473
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
474
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
475
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI μ„€μ •</a>
476
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
477
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
478
  </div>
479
  </div>
480
 
481
+ <!-- λͺ¨λ°”일 메뉴 -->
482
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
483
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
484
+ <div class="mobile-menu-header">
485
+ <div class="mobile-menu-title">메뉴</div>
486
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
487
+ </div>
488
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
489
+ <div class="mobile-menu-items">
490
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ‚¬μš©μž 관리</a>
491
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›Ήμ†Œμ„€ 관리</a>
492
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μ‹œμ§€ 확인</a>
493
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ν”„λ‘¬ν”„νŠΈ 관리</a>
494
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI μ„€μ •</a>
495
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
496
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
497
+ </div>
498
+ </div>
499
+ </div>
500
+
501
  <div class="container">
502
  <div class="page-header">
503
  <h1>파일 λͺ©λ‘</h1>
 
665
  <script type="text/javascript" src="https://unpkg.com/vis-network@latest/standalone/umd/vis-network.min.js"></script>
666
 
667
  <script>
668
+ function toggleMobileMenu() {
669
+ const menu = document.getElementById('mobileMenu');
670
+ menu.classList.toggle('active');
671
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
672
+ }
673
+
674
+ function closeMobileMenu() {
675
+ const menu = document.getElementById('mobileMenu');
676
+ menu.classList.remove('active');
677
+ document.body.style.overflow = '';
678
+ }
679
+
680
+ function closeMobileMenuOnBackdrop(event) {
681
+ if (event.target.id === 'mobileMenu') {
682
+ closeMobileMenu();
683
+ }
684
+ }
685
+
686
  function escapeHtml(text) {
687
  const div = document.createElement('div');
688
  div.textContent = text;
templates/admin_messages.html CHANGED
@@ -43,6 +43,126 @@
43
  align-items: center;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .btn {
47
  padding: 8px 16px;
48
  border: none;
@@ -346,16 +466,39 @@
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.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
 
353
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
 
354
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
355
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
356
  </div>
357
  </div>
358
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
359
  <div class="container">
360
  <div class="page-header">
361
  <h1>전체 λ©”μ‹œμ§€ 확인</h1>
@@ -460,6 +603,24 @@
460
  </div>
461
 
462
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
463
  let currentSessionsPage = 1;
464
  let currentMessagesPage = 1;
465
  let selectedSessionId = null;
 
43
  align-items: center;
44
  }
45
 
46
+ .menu-toggle {
47
+ display: none;
48
+ background: none;
49
+ border: none;
50
+ font-size: 24px;
51
+ cursor: pointer;
52
+ padding: 8px;
53
+ color: #202124;
54
+ }
55
+
56
+ .mobile-menu {
57
+ display: none;
58
+ position: fixed;
59
+ top: 0;
60
+ left: 0;
61
+ right: 0;
62
+ bottom: 0;
63
+ background: rgba(0, 0, 0, 0.5);
64
+ z-index: 1000;
65
+ }
66
+
67
+ .mobile-menu.active {
68
+ display: block;
69
+ }
70
+
71
+ .mobile-menu-content {
72
+ position: fixed;
73
+ top: 0;
74
+ right: -100%;
75
+ width: 280px;
76
+ max-width: 80%;
77
+ height: 100%;
78
+ background: white;
79
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
80
+ transition: right 0.3s ease;
81
+ overflow-y: auto;
82
+ z-index: 1001;
83
+ }
84
+
85
+ .mobile-menu.active .mobile-menu-content {
86
+ right: 0;
87
+ }
88
+
89
+ .mobile-menu-header {
90
+ padding: 16px 20px;
91
+ border-bottom: 1px solid #dadce0;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ background: white;
96
+ position: sticky;
97
+ top: 0;
98
+ z-index: 10;
99
+ }
100
+
101
+ .mobile-menu-title {
102
+ font-size: 18px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ .mobile-menu-close {
107
+ background: none;
108
+ border: none;
109
+ font-size: 24px;
110
+ cursor: pointer;
111
+ color: #202124;
112
+ padding: 0;
113
+ width: 32px;
114
+ height: 32px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .mobile-menu-items {
121
+ padding: 8px 0;
122
+ }
123
+
124
+ .mobile-menu-item {
125
+ display: block;
126
+ padding: 12px 20px;
127
+ color: #202124;
128
+ text-decoration: none;
129
+ border-bottom: 1px solid #f1f3f4;
130
+ transition: background 0.2s;
131
+ }
132
+
133
+ .mobile-menu-item:hover {
134
+ background: #f8f9fa;
135
+ }
136
+
137
+ .mobile-menu-user {
138
+ padding: 16px 20px;
139
+ border-bottom: 1px solid #dadce0;
140
+ color: #5f6368;
141
+ font-size: 14px;
142
+ }
143
+
144
+ @media (max-width: 768px) {
145
+ .header {
146
+ padding: 12px 16px;
147
+ }
148
+
149
+ .header-title {
150
+ font-size: 18px;
151
+ }
152
+
153
+ .header-title span:first-child {
154
+ display: none;
155
+ }
156
+
157
+ .menu-toggle {
158
+ display: block;
159
+ }
160
+
161
+ .header-actions {
162
+ display: none;
163
+ }
164
+ }
165
+
166
  .btn {
167
  padding: 8px 16px;
168
  border: none;
 
466
  <span>πŸ€–</span>
467
  <span>SOY NV AI λ©”μ‹œμ§€ 확인</span>
468
  </div>
469
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
470
  <div class="header-actions">
471
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
472
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
473
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
474
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 λͺ©λ‘</a>
475
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
476
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI μ„€μ •</a>
477
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
478
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
479
  </div>
480
  </div>
481
 
482
+ <!-- λͺ¨λ°”일 메뉴 -->
483
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
484
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
485
+ <div class="mobile-menu-header">
486
+ <div class="mobile-menu-title">메뉴</div>
487
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
488
+ </div>
489
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
490
+ <div class="mobile-menu-items">
491
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ‚¬μš©μž 관리</a>
492
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›Ήμ†Œμ„€ 관리</a>
493
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 λͺ©λ‘</a>
494
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ν”„λ‘¬ν”„νŠΈ 관리</a>
495
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI μ„€μ •</a>
496
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
497
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
498
+ </div>
499
+ </div>
500
+ </div>
501
+
502
  <div class="container">
503
  <div class="page-header">
504
  <h1>전체 λ©”μ‹œμ§€ 확인</h1>
 
603
  </div>
604
 
605
  <script>
606
+ function toggleMobileMenu() {
607
+ const menu = document.getElementById('mobileMenu');
608
+ menu.classList.toggle('active');
609
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
610
+ }
611
+
612
+ function closeMobileMenu() {
613
+ const menu = document.getElementById('mobileMenu');
614
+ menu.classList.remove('active');
615
+ document.body.style.overflow = '';
616
+ }
617
+
618
+ function closeMobileMenuOnBackdrop(event) {
619
+ if (event.target.id === 'mobileMenu') {
620
+ closeMobileMenu();
621
+ }
622
+ }
623
+
624
  let currentSessionsPage = 1;
625
  let currentMessagesPage = 1;
626
  let selectedSessionId = null;
templates/admin_prompts.html CHANGED
@@ -45,6 +45,126 @@
45
  gap: 8px;
46
  }
47
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
48
  .btn {
49
  padding: 8px 16px;
50
  border: none;
@@ -200,16 +320,39 @@
200
  <span>πŸ“</span>
201
  <span>ν”„λ‘¬ν”„νŠΈ 관리</span>
202
  </div>
 
203
  <div class="header-actions">
204
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
205
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
206
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
 
207
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
 
208
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
209
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
210
  </div>
211
  </div>
212
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
  <div class="container">
214
  <div class="page-header">
215
  <h1>μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ 관리</h1>
@@ -243,6 +386,24 @@
243
  </div>
244
 
245
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
246
  // νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ ν”„λ‘¬ν”„νŠΈ 뢈러였기
247
  window.addEventListener('DOMContentLoaded', () => {
248
  loadPrompt();
 
45
  gap: 8px;
46
  }
47
 
48
+ .menu-toggle {
49
+ display: none;
50
+ background: none;
51
+ border: none;
52
+ font-size: 24px;
53
+ cursor: pointer;
54
+ padding: 8px;
55
+ color: #202124;
56
+ }
57
+
58
+ .mobile-menu {
59
+ display: none;
60
+ position: fixed;
61
+ top: 0;
62
+ left: 0;
63
+ right: 0;
64
+ bottom: 0;
65
+ background: rgba(0, 0, 0, 0.5);
66
+ z-index: 1000;
67
+ }
68
+
69
+ .mobile-menu.active {
70
+ display: block;
71
+ }
72
+
73
+ .mobile-menu-content {
74
+ position: fixed;
75
+ top: 0;
76
+ right: -100%;
77
+ width: 280px;
78
+ max-width: 80%;
79
+ height: 100%;
80
+ background: white;
81
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
82
+ transition: right 0.3s ease;
83
+ overflow-y: auto;
84
+ z-index: 1001;
85
+ }
86
+
87
+ .mobile-menu.active .mobile-menu-content {
88
+ right: 0;
89
+ }
90
+
91
+ .mobile-menu-header {
92
+ padding: 16px 20px;
93
+ border-bottom: 1px solid #dadce0;
94
+ display: flex;
95
+ justify-content: space-between;
96
+ align-items: center;
97
+ background: white;
98
+ position: sticky;
99
+ top: 0;
100
+ z-index: 10;
101
+ }
102
+
103
+ .mobile-menu-title {
104
+ font-size: 18px;
105
+ font-weight: 500;
106
+ }
107
+
108
+ .mobile-menu-close {
109
+ background: none;
110
+ border: none;
111
+ font-size: 24px;
112
+ cursor: pointer;
113
+ color: #202124;
114
+ padding: 0;
115
+ width: 32px;
116
+ height: 32px;
117
+ display: flex;
118
+ align-items: center;
119
+ justify-content: center;
120
+ }
121
+
122
+ .mobile-menu-items {
123
+ padding: 8px 0;
124
+ }
125
+
126
+ .mobile-menu-item {
127
+ display: block;
128
+ padding: 12px 20px;
129
+ color: #202124;
130
+ text-decoration: none;
131
+ border-bottom: 1px solid #f1f3f4;
132
+ transition: background 0.2s;
133
+ }
134
+
135
+ .mobile-menu-item:hover {
136
+ background: #f8f9fa;
137
+ }
138
+
139
+ .mobile-menu-user {
140
+ padding: 16px 20px;
141
+ border-bottom: 1px solid #dadce0;
142
+ color: #5f6368;
143
+ font-size: 14px;
144
+ }
145
+
146
+ @media (max-width: 768px) {
147
+ .header {
148
+ padding: 12px 16px;
149
+ }
150
+
151
+ .header-title {
152
+ font-size: 18px;
153
+ }
154
+
155
+ .header-title span:first-child {
156
+ display: none;
157
+ }
158
+
159
+ .menu-toggle {
160
+ display: block;
161
+ }
162
+
163
+ .header-actions {
164
+ display: none;
165
+ }
166
+ }
167
+
168
  .btn {
169
  padding: 8px 16px;
170
  border: none;
 
320
  <span>πŸ“</span>
321
  <span>ν”„λ‘¬ν”„νŠΈ 관리</span>
322
  </div>
323
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
324
  <div class="header-actions">
325
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
326
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
327
  <a href="{{ url_for('main.admin_webnovels') }}" class="btn btn-secondary">μ›Ήμ†Œμ„€ 관리</a>
328
+ <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 λͺ©λ‘</a>
329
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
330
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI μ„€μ •</a>
331
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
332
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
333
  </div>
334
  </div>
335
 
336
+ <!-- λͺ¨λ°”일 메뉴 -->
337
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
338
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
339
+ <div class="mobile-menu-header">
340
+ <div class="mobile-menu-title">메뉴</div>
341
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
342
+ </div>
343
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
344
+ <div class="mobile-menu-items">
345
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ‚¬μš©μž 관리</a>
346
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›Ήμ†Œμ„€ 관리</a>
347
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 λͺ©λ‘</a>
348
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μ‹œμ§€ 확인</a>
349
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI μ„€μ •</a>
350
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
351
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
352
+ </div>
353
+ </div>
354
+ </div>
355
+
356
  <div class="container">
357
  <div class="page-header">
358
  <h1>μ‹œμŠ€ν…œ ν”„λ‘¬ν”„νŠΈ 관리</h1>
 
386
  </div>
387
 
388
  <script>
389
+ function toggleMobileMenu() {
390
+ const menu = document.getElementById('mobileMenu');
391
+ menu.classList.toggle('active');
392
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
393
+ }
394
+
395
+ function closeMobileMenu() {
396
+ const menu = document.getElementById('mobileMenu');
397
+ menu.classList.remove('active');
398
+ document.body.style.overflow = '';
399
+ }
400
+
401
+ function closeMobileMenuOnBackdrop(event) {
402
+ if (event.target.id === 'mobileMenu') {
403
+ closeMobileMenu();
404
+ }
405
+ }
406
+
407
  // νŽ˜μ΄μ§€ λ‘œλ“œ μ‹œ ν”„λ‘¬ν”„νŠΈ 뢈러였기
408
  window.addEventListener('DOMContentLoaded', () => {
409
  loadPrompt();
templates/admin_settings.html CHANGED
@@ -43,6 +43,126 @@
43
  align-items: center;
44
  }
45
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
46
  .btn {
47
  padding: 8px 16px;
48
  border: none;
@@ -163,6 +283,7 @@
163
  <span>βš™οΈ</span>
164
  <span>AI μ„€μ • 관리</span>
165
  </div>
 
166
  <div class="header-actions">
167
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
168
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
@@ -175,6 +296,26 @@
175
  </div>
176
  </div>
177
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
178
  <div class="container">
179
  <div class="page-header">
180
  <h1>AI μ„€μ • 관리</h1>
@@ -220,6 +361,24 @@
220
  </div>
221
 
222
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
223
  function showAlert(message, type = 'success') {
224
  const container = document.getElementById('alertContainer');
225
  container.innerHTML = `<div class="alert ${type}">${message}</div>`;
 
43
  align-items: center;
44
  }
45
 
46
+ .menu-toggle {
47
+ display: none;
48
+ background: none;
49
+ border: none;
50
+ font-size: 24px;
51
+ cursor: pointer;
52
+ padding: 8px;
53
+ color: #202124;
54
+ }
55
+
56
+ .mobile-menu {
57
+ display: none;
58
+ position: fixed;
59
+ top: 0;
60
+ left: 0;
61
+ right: 0;
62
+ bottom: 0;
63
+ background: rgba(0, 0, 0, 0.5);
64
+ z-index: 1000;
65
+ }
66
+
67
+ .mobile-menu.active {
68
+ display: block;
69
+ }
70
+
71
+ .mobile-menu-content {
72
+ position: fixed;
73
+ top: 0;
74
+ right: -100%;
75
+ width: 280px;
76
+ max-width: 80%;
77
+ height: 100%;
78
+ background: white;
79
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
80
+ transition: right 0.3s ease;
81
+ overflow-y: auto;
82
+ z-index: 1001;
83
+ }
84
+
85
+ .mobile-menu.active .mobile-menu-content {
86
+ right: 0;
87
+ }
88
+
89
+ .mobile-menu-header {
90
+ padding: 16px 20px;
91
+ border-bottom: 1px solid #dadce0;
92
+ display: flex;
93
+ justify-content: space-between;
94
+ align-items: center;
95
+ background: white;
96
+ position: sticky;
97
+ top: 0;
98
+ z-index: 10;
99
+ }
100
+
101
+ .mobile-menu-title {
102
+ font-size: 18px;
103
+ font-weight: 500;
104
+ }
105
+
106
+ .mobile-menu-close {
107
+ background: none;
108
+ border: none;
109
+ font-size: 24px;
110
+ cursor: pointer;
111
+ color: #202124;
112
+ padding: 0;
113
+ width: 32px;
114
+ height: 32px;
115
+ display: flex;
116
+ align-items: center;
117
+ justify-content: center;
118
+ }
119
+
120
+ .mobile-menu-items {
121
+ padding: 8px 0;
122
+ }
123
+
124
+ .mobile-menu-item {
125
+ display: block;
126
+ padding: 12px 20px;
127
+ color: #202124;
128
+ text-decoration: none;
129
+ border-bottom: 1px solid #f1f3f4;
130
+ transition: background 0.2s;
131
+ }
132
+
133
+ .mobile-menu-item:hover {
134
+ background: #f8f9fa;
135
+ }
136
+
137
+ .mobile-menu-user {
138
+ padding: 16px 20px;
139
+ border-bottom: 1px solid #dadce0;
140
+ color: #5f6368;
141
+ font-size: 14px;
142
+ }
143
+
144
+ @media (max-width: 768px) {
145
+ .header {
146
+ padding: 12px 16px;
147
+ }
148
+
149
+ .header-title {
150
+ font-size: 18px;
151
+ }
152
+
153
+ .header-title span:first-child {
154
+ display: none;
155
+ }
156
+
157
+ .menu-toggle {
158
+ display: block;
159
+ }
160
+
161
+ .header-actions {
162
+ display: none;
163
+ }
164
+ }
165
+
166
  .btn {
167
  padding: 8px 16px;
168
  border: none;
 
283
  <span>βš™οΈ</span>
284
  <span>AI μ„€μ • 관리</span>
285
  </div>
286
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
287
  <div class="header-actions">
288
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
289
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
 
296
  </div>
297
  </div>
298
 
299
+ <!-- λͺ¨λ°”일 메뉴 -->
300
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
301
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
302
+ <div class="mobile-menu-header">
303
+ <div class="mobile-menu-title">메뉴</div>
304
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
305
+ </div>
306
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
307
+ <div class="mobile-menu-items">
308
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ‚¬μš©μž 관리</a>
309
+ <a href="{{ url_for('main.admin_webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›Ήμ†Œμ„€ 관리</a>
310
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 λͺ©λ‘</a>
311
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μ‹œμ§€ 확인</a>
312
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ν”„λ‘¬ν”„νŠΈ 관리</a>
313
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
314
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
315
+ </div>
316
+ </div>
317
+ </div>
318
+
319
  <div class="container">
320
  <div class="page-header">
321
  <h1>AI μ„€μ • 관리</h1>
 
361
  </div>
362
 
363
  <script>
364
+ function toggleMobileMenu() {
365
+ const menu = document.getElementById('mobileMenu');
366
+ menu.classList.toggle('active');
367
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
368
+ }
369
+
370
+ function closeMobileMenu() {
371
+ const menu = document.getElementById('mobileMenu');
372
+ menu.classList.remove('active');
373
+ document.body.style.overflow = '';
374
+ }
375
+
376
+ function closeMobileMenuOnBackdrop(event) {
377
+ if (event.target.id === 'mobileMenu') {
378
+ closeMobileMenu();
379
+ }
380
+ }
381
+
382
  function showAlert(message, type = 'success') {
383
  const container = document.getElementById('alertContainer');
384
  container.innerHTML = `<div class="alert ${type}">${message}</div>`;
templates/admin_webnovels.html CHANGED
@@ -46,6 +46,126 @@
46
  flex-wrap: wrap;
47
  }
48
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
49
  .btn {
50
  padding: 8px 16px;
51
  border: none;
@@ -160,6 +280,7 @@
160
  border-radius: 6px;
161
  margin-bottom: 16px;
162
  font-size: 14px;
 
163
  }
164
 
165
  .alert.error {
@@ -172,6 +293,40 @@
172
  color: #137333;
173
  }
174
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
175
  /* 파일 μ—…λ‘œλ“œ μ˜μ—­ */
176
  .file-upload-section {
177
  margin-top: 24px;
@@ -454,17 +609,39 @@
454
  <span>πŸ“š</span>
455
  <span>μ›Ήμ†Œμ„€ 관리</span>
456
  </div>
 
457
  <div class="header-actions">
458
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
459
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
460
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 λͺ©λ‘</a>
461
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
462
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
 
463
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
464
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
465
  </div>
466
  </div>
467
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
468
  <div class="container">
469
  <div class="page-header">
470
  <h1>μ›Ήμ†Œμ„€ ν•™μŠ΅ 파일 관리</h1>
@@ -557,12 +734,13 @@
557
  <th>μ—°κ²°λœ λͺ¨λΈ</th>
558
  <th>크기</th>
559
  <th>μ—…λ‘œλ“œμΌ</th>
 
560
  <th>μž‘μ—…</th>
561
  </tr>
562
  </thead>
563
  <tbody id="filesTableBody">
564
  <tr>
565
- <td colspan="5" style="text-align: center; padding: 20px; color: #5f6368;">파일 λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ” 쀑...</td>
566
  </tr>
567
  </tbody>
568
  </table>
@@ -584,12 +762,60 @@
584
  </div>
585
 
586
  <script>
587
- function showAlert(message, type = 'success') {
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
588
  const container = document.getElementById('alertContainer');
589
- container.innerHTML = `<div class="alert ${type}">${message}</div>`;
590
- setTimeout(() => {
591
- container.innerHTML = '';
592
- }, 5000);
 
 
 
 
 
 
593
  }
594
 
595
  // 파일 μ—…λ‘œλ“œ κ΄€λ ¨
@@ -748,11 +974,22 @@
748
  fileNameDisplay += ` <span style="color: #1a73e8; font-size: 12px;">(${childCount}개 회차)</span>`;
749
  }
750
 
 
 
 
 
 
751
  row.innerHTML = `
752
  <td>${fileNameDisplay}</td>
753
  <td>${modelName}</td>
754
  <td class="file-size">${fileSize}</td>
755
  <td>${uploadDate}</td>
 
 
 
 
 
 
756
  <td>
757
  <div class="file-actions">
758
  ${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
@@ -773,6 +1010,11 @@
773
  const childFileSize = formatFileSize(childFile.file_size);
774
  const childUploadDate = new Date(childFile.uploaded_at).toLocaleString('ko-KR');
775
 
 
 
 
 
 
776
  childRow.innerHTML = `
777
  <td style="padding-left: 32px; color: #5f6368;">
778
  <span style="margin-right: 8px;">└─</span>${escapeHtml(childFile.original_filename)}
@@ -780,6 +1022,12 @@
780
  <td>${modelName}</td>
781
  <td class="file-size">${childFileSize}</td>
782
  <td>${childUploadDate}</td>
 
 
 
 
 
 
783
  <td>
784
  <div class="file-actions">
785
  <button class="btn btn-secondary" onclick="deleteFile(${childFile.id})" style="padding: 4px 8px; font-size: 12px;">μ‚­μ œ</button>
@@ -975,8 +1223,13 @@
975
  console.log(`[단계 1] fetch 호좜 μ‹œμž‘: /api/upload`);
976
  console.log(`[단계 1] FormData ν•­λͺ©:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
977
 
978
- // νƒ€μž„μ•„μ›ƒμ΄ μžˆλŠ” fetch 래퍼 (30λΆ„ νƒ€μž„μ•„μ›ƒ - 큰 파일 처리 μ‹œκ°„ κ³ λ €)
979
- const fetchWithTimeout = (url, options, timeout = 1800000) => { // 30λΆ„ νƒ€μž„μ•„μ›ƒ
 
 
 
 
 
980
  return Promise.race([
981
  fetch(url, options),
982
  new Promise((_, reject) =>
@@ -989,7 +1242,7 @@
989
  method: 'POST',
990
  body: formData,
991
  credentials: 'include' // μΏ ν‚€ 포함 (μ„Έμ…˜ 인증)
992
- }, 1800000); // 30λΆ„ νƒ€μž„μ•„μ›ƒ
993
 
994
  console.log(`[단계 1] fetch 응닡 μˆ˜μ‹ : ${response.status} ${response.statusText}`);
995
 
@@ -1049,42 +1302,101 @@
1049
  itemElement.style.opacity = '0.7';
1050
  console.log(`[μ—…λ‘œλ“œ 성곡] ${file.name} β†’ λͺ¨λΈ: ${modelName}`);
1051
  } else {
1052
- // 단계별 처리 ν•¨μˆ˜
1053
- const processStep = async (stepName, stepNumber, url, timeout, detailText = '') => {
1054
  updateProgressStatus(i, stepNumber, stepName, files.length, detailText);
1055
  updateOverallStatus(i + 1, files.length, stepNumber, detailText);
1056
 
1057
- const stepResponse = await fetchWithTimeout(url, {
1058
- method: 'POST',
1059
- credentials: 'include'
1060
- }, timeout);
1061
-
1062
- if (!stepResponse.ok) {
1063
- const stepError = await stepResponse.json().catch(() => ({ error: `HTTP ${stepResponse.status}` }));
1064
- throw new Error(`${stepName} μ‹€νŒ¨: ${stepError.error || 'μ•Œ 수 μ—†λŠ” 였λ₯˜'}`);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1065
  }
1066
-
1067
- return await stepResponse.json();
1068
  };
1069
 
 
 
 
1070
  try {
1071
- // 단계 1: Parent Chunk 생성 (10λΆ„ νƒ€μž„μ•„μ›ƒ)
1072
- await processStep('Parent Chunk 생성', 5, `/api/files/${fileId}/process/parent-chunk`, 600000, 'AI 뢄석 쀑...');
 
 
 
 
 
 
 
1073
 
1074
- // 단계 2: Chunk 생성 (5λΆ„ νƒ€μž„μ•„μ›ƒ)
1075
- const chunksData = await processStep('Child Chunk 생성', 6, `/api/files/${fileId}/process/chunks`, 300000, 'μ„Ήμ…˜ λΆ„ν•  쀑...');
1076
- const chunkCount = chunksData.chunk_count || 0;
 
 
 
 
 
 
 
 
 
1077
 
1078
- // 단계 3: 회차 뢄석 (νšŒμ°¨λ‹Ή 2λΆ„, μ΅œλŒ€ 30λΆ„)
1079
- const episodeTimeout = episodeCount > 0 ? Math.min(episodeCount * 120000, 1800000) : 600000;
1080
- const episodeDetail = episodeCount > 0 ? `총 ${episodeCount}회차 쀑 뢄석 쀑...` : '회차 뢄석 쀑...';
1081
- await processStep('회차 뢄석', 7, `/api/files/${fileId}/process/episode-analysis`, episodeTimeout, episodeDetail);
 
 
 
 
 
 
1082
 
1083
- // 단계 4: Graph Extraction (νšŒμ°¨λ‹Ή 1λΆ„, μ΅œλŒ€ 20λΆ„)
1084
- const graphTimeout = episodeCount > 0 ? Math.min(episodeCount * 60000, 1200000) : 300000;
1085
- const graphDetail = episodeCount > 0 ? `총 ${episodeCount}회차 쀑 μΆ”μΆœ 쀑...` : 'Graph Extraction 쀑...';
1086
- await processStep('Graph Extraction', 8, `/api/files/${fileId}/process/graph`, graphTimeout, graphDetail);
 
 
 
 
 
 
1087
 
 
1088
  successCount++;
1089
  const modelName = data.model_name || 'μ•Œ 수 μ—†μŒ';
1090
 
@@ -1093,12 +1405,21 @@
1093
  updateOverallStatus(i + 1, files.length, 9);
1094
 
1095
  statusElement.className = 'progress-item-status success';
 
 
 
1096
  statusElement.innerHTML = `βœ“ μ™„λ£Œ (파일 ${i + 1}/${files.length})`;
1097
- statusElement.title = `λͺ¨λΈ: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
1098
  itemElement.style.opacity = '0.7';
1099
- console.log(`[μ—…λ‘œλ“œ 성곡] ${file.name} β†’ λͺ¨λΈ: ${modelName}, 청크: ${chunkCount}개`);
 
 
 
 
 
1100
  } catch (stepError) {
1101
- // 단계별 처리 μ‹€νŒ¨
 
1102
  throw stepError;
1103
  }
1104
  }
@@ -1234,6 +1555,31 @@
1234
  }
1235
 
1236
  // 파일 μ‚­μ œ
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1237
  async function deleteFile(fileId) {
1238
  if (!confirm('이 νŒŒμΌμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?\n원본 파일인 경우 μ΄μ–΄μ„œ μ—…λ‘œλ“œν•œ λͺ¨λ“  νšŒμ°¨λ„ ν•¨κ»˜ μ‚­μ œλ©λ‹ˆλ‹€.')) {
1239
  return;
 
46
  flex-wrap: wrap;
47
  }
48
 
49
+ .menu-toggle {
50
+ display: none;
51
+ background: none;
52
+ border: none;
53
+ font-size: 24px;
54
+ cursor: pointer;
55
+ padding: 8px;
56
+ color: #202124;
57
+ }
58
+
59
+ .mobile-menu {
60
+ display: none;
61
+ position: fixed;
62
+ top: 0;
63
+ left: 0;
64
+ right: 0;
65
+ bottom: 0;
66
+ background: rgba(0, 0, 0, 0.5);
67
+ z-index: 1000;
68
+ }
69
+
70
+ .mobile-menu.active {
71
+ display: block;
72
+ }
73
+
74
+ .mobile-menu-content {
75
+ position: fixed;
76
+ top: 0;
77
+ right: -100%;
78
+ width: 280px;
79
+ max-width: 80%;
80
+ height: 100%;
81
+ background: white;
82
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
83
+ transition: right 0.3s ease;
84
+ overflow-y: auto;
85
+ z-index: 1001;
86
+ }
87
+
88
+ .mobile-menu.active .mobile-menu-content {
89
+ right: 0;
90
+ }
91
+
92
+ .mobile-menu-header {
93
+ padding: 16px 20px;
94
+ border-bottom: 1px solid #dadce0;
95
+ display: flex;
96
+ justify-content: space-between;
97
+ align-items: center;
98
+ background: white;
99
+ position: sticky;
100
+ top: 0;
101
+ z-index: 10;
102
+ }
103
+
104
+ .mobile-menu-title {
105
+ font-size: 18px;
106
+ font-weight: 500;
107
+ }
108
+
109
+ .mobile-menu-close {
110
+ background: none;
111
+ border: none;
112
+ font-size: 24px;
113
+ cursor: pointer;
114
+ color: #202124;
115
+ padding: 0;
116
+ width: 32px;
117
+ height: 32px;
118
+ display: flex;
119
+ align-items: center;
120
+ justify-content: center;
121
+ }
122
+
123
+ .mobile-menu-items {
124
+ padding: 8px 0;
125
+ }
126
+
127
+ .mobile-menu-item {
128
+ display: block;
129
+ padding: 12px 20px;
130
+ color: #202124;
131
+ text-decoration: none;
132
+ border-bottom: 1px solid #f1f3f4;
133
+ transition: background 0.2s;
134
+ }
135
+
136
+ .mobile-menu-item:hover {
137
+ background: #f8f9fa;
138
+ }
139
+
140
+ .mobile-menu-user {
141
+ padding: 16px 20px;
142
+ border-bottom: 1px solid #dadce0;
143
+ color: #5f6368;
144
+ font-size: 14px;
145
+ }
146
+
147
+ @media (max-width: 768px) {
148
+ .header {
149
+ padding: 12px 16px;
150
+ }
151
+
152
+ .header-title {
153
+ font-size: 18px;
154
+ }
155
+
156
+ .header-title span:first-child {
157
+ display: none;
158
+ }
159
+
160
+ .menu-toggle {
161
+ display: block;
162
+ }
163
+
164
+ .header-actions {
165
+ display: none;
166
+ }
167
+ }
168
+
169
  .btn {
170
  padding: 8px 16px;
171
  border: none;
 
280
  border-radius: 6px;
281
  margin-bottom: 16px;
282
  font-size: 14px;
283
+ position: relative;
284
  }
285
 
286
  .alert.error {
 
293
  color: #137333;
294
  }
295
 
296
+ .alert.warning {
297
+ background: #fef7e0;
298
+ color: #b06000;
299
+ }
300
+
301
+ .alert-close {
302
+ position: absolute;
303
+ top: 8px;
304
+ right: 8px;
305
+ background: none;
306
+ border: none;
307
+ font-size: 18px;
308
+ cursor: pointer;
309
+ color: inherit;
310
+ opacity: 0.7;
311
+ padding: 0;
312
+ width: 24px;
313
+ height: 24px;
314
+ display: flex;
315
+ align-items: center;
316
+ justify-content: center;
317
+ border-radius: 4px;
318
+ transition: opacity 0.2s, background-color 0.2s;
319
+ }
320
+
321
+ .alert-close:hover {
322
+ opacity: 1;
323
+ background-color: rgba(0, 0, 0, 0.1);
324
+ }
325
+
326
+ .alert-persistent {
327
+ padding-right: 40px;
328
+ }
329
+
330
  /* 파일 μ—…λ‘œλ“œ μ˜μ—­ */
331
  .file-upload-section {
332
  margin-top: 24px;
 
609
  <span>πŸ“š</span>
610
  <span>μ›Ήμ†Œμ„€ 관리</span>
611
  </div>
612
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
613
  <div class="header-actions">
614
  <span style="margin-right: 12px; color: #5f6368;">{{ current_user.nickname or current_user.username }}</span>
615
  <a href="{{ url_for('main.admin') }}" class="btn btn-secondary">μ‚¬μš©μž 관리</a>
616
  <a href="{{ url_for('main.admin_files') }}" class="btn btn-secondary">파일 λͺ©λ‘</a>
617
  <a href="{{ url_for('main.admin_messages') }}" class="btn btn-secondary">λ©”μ‹œμ§€ 확인</a>
618
  <a href="{{ url_for('main.admin_prompts') }}" class="btn btn-secondary">ν”„λ‘¬ν”„νŠΈ 관리</a>
619
+ <a href="{{ url_for('main.admin_settings') }}" class="btn btn-secondary">AI μ„€μ •</a>
620
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">λ©”μΈμœΌλ‘œ</a>
621
  <a href="{{ url_for('main.logout') }}" class="btn btn-secondary">λ‘œκ·Έμ•„μ›ƒ</a>
622
  </div>
623
  </div>
624
 
625
+ <!-- λͺ¨λ°”일 메뉴 -->
626
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
627
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
628
+ <div class="mobile-menu-header">
629
+ <div class="mobile-menu-title">메뉴</div>
630
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
631
+ </div>
632
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
633
+ <div class="mobile-menu-items">
634
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ‚¬μš©μž 관리</a>
635
+ <a href="{{ url_for('main.admin_files') }}" class="mobile-menu-item" onclick="closeMobileMenu()">파일 λͺ©λ‘</a>
636
+ <a href="{{ url_for('main.admin_messages') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μ‹œμ§€ 확인</a>
637
+ <a href="{{ url_for('main.admin_prompts') }}" class="mobile-menu-item" onclick="closeMobileMenu()">ν”„λ‘¬ν”„νŠΈ 관리</a>
638
+ <a href="{{ url_for('main.admin_settings') }}" class="mobile-menu-item" onclick="closeMobileMenu()">AI μ„€μ •</a>
639
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ©”μΈμœΌλ‘œ</a>
640
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
641
+ </div>
642
+ </div>
643
+ </div>
644
+
645
  <div class="container">
646
  <div class="page-header">
647
  <h1>μ›Ήμ†Œμ„€ ν•™μŠ΅ 파일 관리</h1>
 
734
  <th>μ—°κ²°λœ λͺ¨λΈ</th>
735
  <th>크기</th>
736
  <th>μ—…λ‘œλ“œμΌ</th>
737
+ <th>곡개 μ—¬λΆ€</th>
738
  <th>μž‘μ—…</th>
739
  </tr>
740
  </thead>
741
  <tbody id="filesTableBody">
742
  <tr>
743
+ <td colspan="6" style="text-align: center; padding: 20px; color: #5f6368;">파일 λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ” 쀑...</td>
744
  </tr>
745
  </tbody>
746
  </table>
 
762
  </div>
763
 
764
  <script>
765
+ function toggleMobileMenu() {
766
+ const menu = document.getElementById('mobileMenu');
767
+ menu.classList.toggle('active');
768
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
769
+ }
770
+
771
+ function closeMobileMenu() {
772
+ const menu = document.getElementById('mobileMenu');
773
+ menu.classList.remove('active');
774
+ document.body.style.overflow = '';
775
+ }
776
+
777
+ function closeMobileMenuOnBackdrop(event) {
778
+ if (event.target.id === 'mobileMenu') {
779
+ closeMobileMenu();
780
+ }
781
+ }
782
+
783
+ function showAlert(message, type = 'success', autoClose = true) {
784
+ const container = document.getElementById('alertContainer');
785
+ const alertId = `alert-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
786
+ const closeButton = autoClose ? '' : '<button class="alert-close" onclick="closeAlert(\'' + alertId + '\')" title="λ‹«κΈ°">&times;</button>';
787
+ const persistentClass = autoClose ? '' : ' alert-persistent';
788
+ container.innerHTML = `<div id="${alertId}" class="alert ${type}${persistentClass}">${closeButton}${message}</div>`;
789
+
790
+ if (autoClose) {
791
+ setTimeout(() => {
792
+ const alertElement = document.getElementById(alertId);
793
+ if (alertElement) {
794
+ alertElement.remove();
795
+ }
796
+ }, 5000);
797
+ }
798
+ }
799
+
800
+ function closeAlert(alertId) {
801
+ const alertElement = document.getElementById(alertId);
802
+ if (alertElement) {
803
+ alertElement.remove();
804
+ }
805
+ }
806
+
807
+ function showPersistentError(message, stepName = '') {
808
  const container = document.getElementById('alertContainer');
809
+ const alertId = `alert-error-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`;
810
+ const title = stepName ? `<strong>${stepName}:</strong> ` : '';
811
+ const alertElement = document.createElement('div');
812
+ alertElement.id = alertId;
813
+ alertElement.className = 'alert error alert-persistent';
814
+ alertElement.innerHTML = `
815
+ <button class="alert-close" onclick="closeAlert('${alertId}')" title="λ‹«κΈ°">&times;</button>
816
+ ${title}${message}
817
+ `;
818
+ container.appendChild(alertElement);
819
  }
820
 
821
  // 파일 μ—…λ‘œλ“œ κ΄€λ ¨
 
974
  fileNameDisplay += ` <span style="color: #1a73e8; font-size: 12px;">(${childCount}개 회차)</span>`;
975
  }
976
 
977
+ const isPublic = file.is_public !== undefined ? file.is_public : false;
978
+ const publicStatus = isPublic ? '<span style="color: #137333; font-weight: 500;">곡개</span>' : '<span style="color: #5f6368;">λΉ„κ³΅κ°œ</span>';
979
+ const toggleButtonText = isPublic ? 'λΉ„κ³΅κ°œλ‘œ' : '곡개둜';
980
+ const toggleButtonClass = isPublic ? 'btn-warning' : 'btn-success';
981
+
982
  row.innerHTML = `
983
  <td>${fileNameDisplay}</td>
984
  <td>${modelName}</td>
985
  <td class="file-size">${fileSize}</td>
986
  <td>${uploadDate}</td>
987
+ <td>
988
+ ${publicStatus}
989
+ <button class="btn ${toggleButtonClass}" onclick="toggleFilePublic(${file.id}, ${!isPublic})" style="padding: 4px 8px; font-size: 12px; margin-left: 8px;">
990
+ ${toggleButtonText}
991
+ </button>
992
+ </td>
993
  <td>
994
  <div class="file-actions">
995
  ${(file.has_parent_chunk !== undefined && file.has_parent_chunk) ?
 
1010
  const childFileSize = formatFileSize(childFile.file_size);
1011
  const childUploadDate = new Date(childFile.uploaded_at).toLocaleString('ko-KR');
1012
 
1013
+ const childIsPublic = childFile.is_public !== undefined ? childFile.is_public : false;
1014
+ const childPublicStatus = childIsPublic ? '<span style="color: #137333; font-weight: 500;">곡개</span>' : '<span style="color: #5f6368;">λΉ„κ³΅κ°œ</span>';
1015
+ const childToggleButtonText = childIsPublic ? 'λΉ„κ³΅κ°œλ‘œ' : '곡개둜';
1016
+ const childToggleButtonClass = childIsPublic ? 'btn-warning' : 'btn-success';
1017
+
1018
  childRow.innerHTML = `
1019
  <td style="padding-left: 32px; color: #5f6368;">
1020
  <span style="margin-right: 8px;">└─</span>${escapeHtml(childFile.original_filename)}
 
1022
  <td>${modelName}</td>
1023
  <td class="file-size">${childFileSize}</td>
1024
  <td>${childUploadDate}</td>
1025
+ <td>
1026
+ ${childPublicStatus}
1027
+ <button class="btn ${childToggleButtonClass}" onclick="toggleFilePublic(${childFile.id}, ${!childIsPublic})" style="padding: 4px 8px; font-size: 12px; margin-left: 8px;">
1028
+ ${childToggleButtonText}
1029
+ </button>
1030
+ </td>
1031
  <td>
1032
  <div class="file-actions">
1033
  <button class="btn btn-secondary" onclick="deleteFile(${childFile.id})" style="padding: 4px 8px; font-size: 12px;">μ‚­μ œ</button>
 
1223
  console.log(`[단계 1] fetch 호좜 μ‹œμž‘: /api/upload`);
1224
  console.log(`[단계 1] FormData ν•­λͺ©:`, Array.from(formData.entries()).map(([k, v]) => [k, v instanceof File ? v.name : v]));
1225
 
1226
+ // νƒ€μž„μ•„μ›ƒμ΄ μžˆλŠ” fetch 래퍼 (νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ)
1227
+ const fetchWithTimeout = (url, options, timeout = null) => {
1228
+ // timeout이 0μ΄κ±°λ‚˜ null/undefined이면 νƒ€μž„μ•„μ›ƒ 없이 μ‹€ν–‰
1229
+ if (!timeout || timeout === 0) {
1230
+ return fetch(url, options);
1231
+ }
1232
+ // νƒ€μž„μ•„μ›ƒμ΄ μ„€μ •λœ κ²½μš°μ—λ§Œ Promise.race μ‚¬μš©
1233
  return Promise.race([
1234
  fetch(url, options),
1235
  new Promise((_, reject) =>
 
1242
  method: 'POST',
1243
  body: formData,
1244
  credentials: 'include' // μΏ ν‚€ 포함 (μ„Έμ…˜ 인증)
1245
+ }, null); // νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ
1246
 
1247
  console.log(`[단계 1] fetch 응닡 μˆ˜μ‹ : ${response.status} ${response.statusText}`);
1248
 
 
1302
  itemElement.style.opacity = '0.7';
1303
  console.log(`[μ—…λ‘œλ“œ 성곡] ${file.name} β†’ λͺ¨λΈ: ${modelName}`);
1304
  } else {
1305
+ // 단계별 처리 ν•¨μˆ˜ (μ—λŸ¬κ°€ λ°œμƒν•΄λ„ 계속 μ§„ν–‰)
1306
+ const processStep = async (stepName, stepNumber, url, timeout, detailText = '', allowSkip = false) => {
1307
  updateProgressStatus(i, stepNumber, stepName, files.length, detailText);
1308
  updateOverallStatus(i + 1, files.length, stepNumber, detailText);
1309
 
1310
+ try {
1311
+ const stepResponse = await fetchWithTimeout(url, {
1312
+ method: 'POST',
1313
+ credentials: 'include'
1314
+ }, timeout);
1315
+
1316
+ if (!stepResponse.ok) {
1317
+ const stepError = await stepResponse.json().catch(() => ({ error: `HTTP ${stepResponse.status}` }));
1318
+ const errorMsg = stepError.error || 'μ•Œ 수 μ—†λŠ” 였λ₯˜';
1319
+
1320
+ // "이미 μ™„λ£Œ" λ˜λŠ” "이미 쑴재" 같은 λ©”μ‹œμ§€λŠ” μ„±κ³΅μœΌλ‘œ 처리
1321
+ if (allowSkip && (
1322
+ errorMsg.includes('이미') ||
1323
+ errorMsg.includes('쑴재') ||
1324
+ errorMsg.includes('μ™„λ£Œ') ||
1325
+ errorMsg.toLowerCase().includes('already') ||
1326
+ errorMsg.toLowerCase().includes('exists')
1327
+ )) {
1328
+ console.log(`[${stepName}] 이미 μ™„λ£Œλœ μž‘μ—…μœΌλ‘œ κ±΄λ„ˆλœ€: ${errorMsg}`);
1329
+ return { skipped: true, message: errorMsg };
1330
+ }
1331
+
1332
+ throw new Error(`${stepName} μ‹€νŒ¨: ${errorMsg}`);
1333
+ }
1334
+
1335
+ return await stepResponse.json();
1336
+ } catch (error) {
1337
+ // νƒ€μž„μ•„μ›ƒμ΄λ‚˜ λ„€νŠΈμ›Œν¬ 였λ₯˜κ°€ μ•„λ‹Œ κ²½μš°μ—λ§Œ μ—λŸ¬λ‘œ 처리
1338
+ if (allowSkip && error.message && (
1339
+ error.message.includes('이미') ||
1340
+ error.message.includes('쑴재') ||
1341
+ error.message.includes('μ™„λ£Œ')
1342
+ )) {
1343
+ console.log(`[${stepName}] 이미 μ™„λ£Œλœ μž‘μ—…μœΌλ‘œ κ±΄λ„ˆλœ€: ${error.message}`);
1344
+ return { skipped: true, message: error.message };
1345
+ }
1346
+ throw error;
1347
  }
 
 
1348
  };
1349
 
1350
+ let chunkCount = 0;
1351
+ const stepErrors = [];
1352
+
1353
  try {
1354
+ // 단계 1: Parent Chunk 생성 (νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ, μ‹€νŒ¨ν•΄λ„ 계속 μ§„ν–‰)
1355
+ try {
1356
+ await processStep('Parent Chunk 생성', 5, `/api/files/${fileId}/process/parent-chunk`, null, 'AI 뢄석 쀑...', true);
1357
+ } catch (stepError) {
1358
+ const errorMsg = stepError.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜';
1359
+ console.warn(`[Parent Chunk 생성] κ²½κ³ : ${errorMsg}`);
1360
+ stepErrors.push(`Parent Chunk: ${errorMsg}`);
1361
+ showPersistentError(`${errorMsg}`, `[${file.name}] Parent Chunk 생성`);
1362
+ }
1363
 
1364
+ // 단계 2: Chunk 생성 (νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ, μ‹€νŒ¨ν•΄λ„ 계속 μ§„ν–‰)
1365
+ try {
1366
+ const chunksData = await processStep('Child Chunk 생성', 6, `/api/files/${fileId}/process/chunks`, null, 'μ„Ήμ…˜ λΆ„ν•  쀑...', true);
1367
+ if (chunksData && !chunksData.skipped) {
1368
+ chunkCount = chunksData.chunk_count || 0;
1369
+ }
1370
+ } catch (stepError) {
1371
+ const errorMsg = stepError.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜';
1372
+ console.warn(`[Child Chunk 생성] κ²½κ³ : ${errorMsg}`);
1373
+ stepErrors.push(`Child Chunk: ${errorMsg}`);
1374
+ showPersistentError(`${errorMsg}`, `[${file.name}] Child Chunk 생성`);
1375
+ }
1376
 
1377
+ // 단계 3: 회차 뢄석 (νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ, μ‹€νŒ¨ν•΄λ„ 계속 μ§„ν–‰)
1378
+ try {
1379
+ const episodeDetail = episodeCount > 0 ? `총 ${episodeCount}회차 쀑 뢄석 쀑...` : '회차 뢄석 쀑...';
1380
+ await processStep('회차 뢄석', 7, `/api/files/${fileId}/process/episode-analysis`, null, episodeDetail, true);
1381
+ } catch (stepError) {
1382
+ const errorMsg = stepError.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜';
1383
+ console.warn(`[회차 뢄석] κ²½κ³ : ${errorMsg}`);
1384
+ stepErrors.push(`회차 뢄석: ${errorMsg}`);
1385
+ showPersistentError(`${errorMsg}`, `[${file.name}] 회차 뢄석`);
1386
+ }
1387
 
1388
+ // 단계 4: Graph Extraction (νƒ€μž„μ•„μ›ƒ λ¬΄μ œν•œ, μ‹€νŒ¨ν•΄λ„ 계속 μ§„ν–‰)
1389
+ try {
1390
+ const graphDetail = episodeCount > 0 ? `총 ${episodeCount}회차 쀑 μΆ”μΆœ 쀑...` : 'Graph Extraction 쀑...';
1391
+ await processStep('Graph Extraction', 8, `/api/files/${fileId}/process/graph`, null, graphDetail, true);
1392
+ } catch (stepError) {
1393
+ const errorMsg = stepError.message || 'μ•Œ 수 μ—†λŠ” 였λ₯˜';
1394
+ console.warn(`[Graph Extraction] κ²½κ³ : ${errorMsg}`);
1395
+ stepErrors.push(`Graph Extraction: ${errorMsg}`);
1396
+ showPersistentError(`${errorMsg}`, `[${file.name}] Graph Extraction`);
1397
+ }
1398
 
1399
+ // λͺ¨λ“  단계가 μ™„λ£Œλ˜μ—ˆκ±°λ‚˜ μΌλΆ€λ§Œ μ‹€νŒ¨ν•œ 경우 μ„±κ³΅μœΌλ‘œ 처리
1400
  successCount++;
1401
  const modelName = data.model_name || 'μ•Œ 수 μ—†μŒ';
1402
 
 
1405
  updateOverallStatus(i + 1, files.length, 9);
1406
 
1407
  statusElement.className = 'progress-item-status success';
1408
+ const statusTitle = stepErrors.length > 0
1409
+ ? `λͺ¨λΈ: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''} (일뢀 단계 κ²½κ³ : ${stepErrors.length}개)`
1410
+ : `λͺ¨λΈ: ${modelName}${chunkCount > 0 ? `, 청크: ${chunkCount}개` : ''}`;
1411
  statusElement.innerHTML = `βœ“ μ™„λ£Œ (파일 ${i + 1}/${files.length})`;
1412
+ statusElement.title = statusTitle;
1413
  itemElement.style.opacity = '0.7';
1414
+
1415
+ if (stepErrors.length > 0) {
1416
+ console.warn(`[μ—…λ‘œλ“œ μ™„λ£Œ (일뢀 κ²½κ³ )] ${file.name} β†’ λͺ¨λΈ: ${modelName}, 청크: ${chunkCount}개, κ²½κ³ : ${stepErrors.join('; ')}`);
1417
+ } else {
1418
+ console.log(`[μ—…λ‘œλ“œ 성곡] ${file.name} β†’ λͺ¨λΈ: ${modelName}, 청크: ${chunkCount}개`);
1419
+ }
1420
  } catch (stepError) {
1421
+ // μ˜ˆμƒμΉ˜ λͺ»ν•œ 치λͺ…적 였λ₯˜λ§Œ μ‹€νŒ¨λ‘œ 처리
1422
+ console.error(`[μ—…λ‘œλ“œ 처리 쀑 치λͺ…적 였λ₯˜] ${file.name}:`, stepError);
1423
  throw stepError;
1424
  }
1425
  }
 
1555
  }
1556
 
1557
  // 파일 μ‚­μ œ
1558
+ // 파일 곡개 μ—¬λΆ€ ν† κΈ€
1559
+ async function toggleFilePublic(fileId, isPublic) {
1560
+ try {
1561
+ const response = await fetch(`/api/files/${fileId}/public`, {
1562
+ method: 'PUT',
1563
+ headers: {
1564
+ 'Content-Type': 'application/json'
1565
+ },
1566
+ body: JSON.stringify({ is_public: isPublic })
1567
+ });
1568
+
1569
+ const data = await response.json();
1570
+
1571
+ if (response.ok) {
1572
+ alert(data.message || `파일이 ${isPublic ? '곡개' : 'λΉ„κ³΅κ°œ'}둜 μ„€μ •λ˜μ—ˆμŠ΅λ‹ˆλ‹€.`);
1573
+ loadFiles(); // 파일 λͺ©λ‘ μƒˆλ‘œκ³ μΉ¨
1574
+ } else {
1575
+ alert(`였λ₯˜: ${data.error || '파일 곡개 μ—¬λΆ€ 변경에 μ‹€νŒ¨ν–ˆμŠ΅λ‹ˆλ‹€.'}`);
1576
+ }
1577
+ } catch (error) {
1578
+ console.error('파일 곡개 μ—¬λΆ€ λ³€κ²½ 였λ₯˜:', error);
1579
+ alert(`였λ₯˜: ${error.message || '파일 곡개 μ—¬λΆ€ λ³€κ²½ 쀑 였λ₯˜κ°€ λ°œμƒν–ˆμŠ΅λ‹ˆλ‹€.'}`);
1580
+ }
1581
+ }
1582
+
1583
  async function deleteFile(fileId) {
1584
  if (!confirm('이 νŒŒμΌμ„ μ‚­μ œν•˜μ‹œκ² μŠ΅λ‹ˆκΉŒ?\n원본 파일인 경우 μ΄μ–΄μ„œ μ—…λ‘œλ“œν•œ λͺ¨λ“  νšŒμ°¨λ„ ν•¨κ»˜ μ‚­μ œλ©λ‹ˆλ‹€.')) {
1585
  return;
templates/index.html CHANGED
@@ -662,6 +662,122 @@
662
  align-items: center;
663
  }
664
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
665
  .btn-icon {
666
  background: none;
667
  border: none;
@@ -1180,6 +1296,22 @@
1180
  max-width: 500px;
1181
  }
1182
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1183
  /* λ°˜μ‘ν˜• */
1184
  @media (max-width: 768px) {
1185
  .sidebar {
@@ -1188,6 +1320,13 @@
1188
  top: 0;
1189
  z-index: 1000;
1190
  box-shadow: 2px 0 8px var(--shadow);
 
 
 
 
 
 
 
1191
  }
1192
 
1193
  .sidebar.collapsed {
@@ -1206,6 +1345,10 @@
1206
  font-size: 18px;
1207
  }
1208
 
 
 
 
 
1209
  .chat-container {
1210
  padding: 16px;
1211
  }
@@ -1305,6 +1448,9 @@
1305
  </div>
1306
  </div>
1307
 
 
 
 
1308
  <!-- 메인 μ½˜ν…μΈ  -->
1309
  <div class="main-content">
1310
  <!-- 헀더 -->
@@ -1317,6 +1463,7 @@
1317
  </button>
1318
  <span></span>
1319
  </div>
 
1320
  <div class="header-actions">
1321
  <span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
1322
  <a href="{{ url_for('main.webnovels') }}" class="btn-text-icon" title="μ›μž‘ 정보">
@@ -1345,6 +1492,24 @@
1345
  </div>
1346
  </div>
1347
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1348
  <!-- μ±„νŒ… μ˜μ—­ -->
1349
  <div class="chat-container" id="chatContainer">
1350
  <div class="empty-state" id="emptyState">
@@ -1446,6 +1611,24 @@
1446
  </div>
1447
 
1448
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1449
  const chatContainer = document.getElementById('chatContainer');
1450
  const messageInput = document.getElementById('messageInput');
1451
  const sendButton = document.getElementById('sendButton');
@@ -1490,9 +1673,10 @@
1490
  async function loadNovels() {
1491
  try {
1492
  // λͺ¨λΈμ΄ μ„ νƒλ˜μ–΄ 있으면 ν•΄λ‹Ή λͺ¨λΈμ˜ 파일만, μ—†μœΌλ©΄ λͺ¨λ“  파일
 
1493
  const url = selectedModel
1494
- ? `/api/files?model_name=${encodeURIComponent(selectedModel)}`
1495
- : '/api/files';
1496
 
1497
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] API μš”μ²­:', url, 'μ„ νƒλœ λͺ¨λΈ:', selectedModel || 'μ—†μŒ (전체)');
1498
 
@@ -1518,11 +1702,11 @@
1518
  let files = data.files || [];
1519
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] 파일 개수:', files.length);
1520
 
1521
- // λͺ¨λΈμ΄ μ„ νƒλ˜μ–΄ μžˆλŠ”λ° ν•΄λ‹Ή λͺ¨λΈμ˜ 파일이 μ—†μœΌλ©΄, λͺ¨λ“  파일 λ‹€μ‹œ 쑰회
1522
  if (selectedModel && files.length === 0) {
1523
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] μ„ νƒν•œ λͺ¨λΈμ— 파일이 μ—†μ–΄ 전체 파일 쑰회 쀑...');
1524
  try {
1525
- const allFilesResponse = await fetch('/api/files', {
1526
  credentials: 'include'
1527
  });
1528
  if (allFilesResponse.ok) {
@@ -1767,9 +1951,22 @@
1767
  function toggleSidebar() {
1768
  sidebar.classList.toggle('collapsed');
1769
  const isCollapsed = sidebar.classList.contains('collapsed');
 
 
1770
  if (window.innerWidth <= 768) {
1771
  sidebarToggleBtn.style.display = isCollapsed ? 'flex' : 'none';
 
 
 
 
 
 
 
 
 
 
1772
  }
 
1773
  // μ‚¬μ΄λ“œλ°” λ‚΄λΆ€ ν† κΈ€ λ²„νŠΌ μ•„μ΄μ½˜ μ—…λ°μ΄νŠΈ
1774
  const sidebarToggle = sidebar.querySelector('.sidebar-toggle');
1775
  if (sidebarToggle) {
@@ -1778,15 +1975,29 @@
1778
  '<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>';
1779
  }
1780
  }
 
 
 
 
 
 
 
 
 
 
1781
 
1782
  // λ°˜μ‘ν˜• μ‚¬μ΄λ“œλ°” 처리
1783
  function handleResize() {
 
1784
  if (window.innerWidth <= 768) {
1785
  sidebar.classList.add('collapsed');
1786
  sidebarToggleBtn.style.display = 'flex';
 
 
1787
  } else {
1788
  sidebar.classList.remove('collapsed');
1789
  sidebarToggleBtn.style.display = 'none';
 
1790
  }
1791
  }
1792
 
 
662
  align-items: center;
663
  }
664
 
665
+ .menu-toggle {
666
+ display: none;
667
+ background: none;
668
+ border: none;
669
+ font-size: 24px;
670
+ cursor: pointer;
671
+ padding: 8px;
672
+ color: var(--text-primary);
673
+ }
674
+
675
+ .mobile-menu {
676
+ display: none;
677
+ position: fixed;
678
+ top: 0;
679
+ left: 0;
680
+ right: 0;
681
+ bottom: 0;
682
+ background: rgba(0, 0, 0, 0.5);
683
+ z-index: 1000;
684
+ }
685
+
686
+ .mobile-menu.active {
687
+ display: block;
688
+ }
689
+
690
+ .mobile-menu-content {
691
+ position: fixed;
692
+ top: 0;
693
+ right: -100%;
694
+ width: 280px;
695
+ max-width: 80%;
696
+ height: 100%;
697
+ background: var(--bg-primary);
698
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
699
+ transition: right 0.3s ease;
700
+ overflow-y: auto;
701
+ z-index: 1001;
702
+ }
703
+
704
+ .mobile-menu.active .mobile-menu-content {
705
+ right: 0;
706
+ }
707
+
708
+ .mobile-menu-header {
709
+ padding: 16px 20px;
710
+ border-bottom: 1px solid var(--border);
711
+ display: flex;
712
+ justify-content: space-between;
713
+ align-items: center;
714
+ background: var(--bg-primary);
715
+ position: sticky;
716
+ top: 0;
717
+ z-index: 10;
718
+ }
719
+
720
+ .mobile-menu-title {
721
+ font-size: 18px;
722
+ font-weight: 500;
723
+ }
724
+
725
+ .mobile-menu-close {
726
+ background: none;
727
+ border: none;
728
+ font-size: 24px;
729
+ cursor: pointer;
730
+ color: var(--text-primary);
731
+ padding: 0;
732
+ width: 32px;
733
+ height: 32px;
734
+ display: flex;
735
+ align-items: center;
736
+ justify-content: center;
737
+ }
738
+
739
+ .mobile-menu-items {
740
+ padding: 8px 0;
741
+ }
742
+
743
+ .mobile-menu-item {
744
+ display: block;
745
+ padding: 12px 20px;
746
+ color: var(--text-primary);
747
+ text-decoration: none;
748
+ border-bottom: 1px solid var(--bg-tertiary);
749
+ transition: background 0.2s;
750
+ }
751
+
752
+ .mobile-menu-item:hover {
753
+ background: var(--bg-secondary);
754
+ }
755
+
756
+ .mobile-menu-user {
757
+ padding: 16px 20px;
758
+ border-bottom: 1px solid var(--border);
759
+ color: var(--text-secondary);
760
+ font-size: 14px;
761
+ }
762
+
763
+ @media (max-width: 768px) {
764
+ .header {
765
+ padding: 12px 16px;
766
+ }
767
+
768
+ .header-title {
769
+ font-size: 18px;
770
+ }
771
+
772
+ .menu-toggle {
773
+ display: block;
774
+ }
775
+
776
+ .header-actions {
777
+ display: none;
778
+ }
779
+ }
780
+
781
  .btn-icon {
782
  background: none;
783
  border: none;
 
1296
  max-width: 500px;
1297
  }
1298
 
1299
+ /* μ‚¬μ΄λ“œλ°” μ˜€λ²„λ ˆμ΄ (λͺ¨λ°”일) */
1300
+ .sidebar-overlay {
1301
+ display: none;
1302
+ position: fixed;
1303
+ top: 0;
1304
+ left: 0;
1305
+ right: 0;
1306
+ bottom: 0;
1307
+ background: rgba(0, 0, 0, 0.5);
1308
+ z-index: 999;
1309
+ }
1310
+
1311
+ .sidebar-overlay.active {
1312
+ display: block;
1313
+ }
1314
+
1315
  /* λ°˜μ‘ν˜• */
1316
  @media (max-width: 768px) {
1317
  .sidebar {
 
1320
  top: 0;
1321
  z-index: 1000;
1322
  box-shadow: 2px 0 8px var(--shadow);
1323
+ width: 0;
1324
+ overflow: hidden;
1325
+ transition: width 0.3s ease;
1326
+ }
1327
+
1328
+ .sidebar:not(.collapsed) {
1329
+ width: 280px;
1330
  }
1331
 
1332
  .sidebar.collapsed {
 
1345
  font-size: 18px;
1346
  }
1347
 
1348
+ #sidebarToggleBtn {
1349
+ display: flex !important;
1350
+ }
1351
+
1352
  .chat-container {
1353
  padding: 16px;
1354
  }
 
1448
  </div>
1449
  </div>
1450
 
1451
+ <!-- μ‚¬μ΄λ“œλ°” μ˜€λ²„λ ˆμ΄ (λͺ¨λ°”일) -->
1452
+ <div class="sidebar-overlay" id="sidebarOverlay" onclick="closeSidebarOnMobile()"></div>
1453
+
1454
  <!-- 메인 μ½˜ν…μΈ  -->
1455
  <div class="main-content">
1456
  <!-- 헀더 -->
 
1463
  </button>
1464
  <span></span>
1465
  </div>
1466
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
1467
  <div class="header-actions">
1468
  <span style="margin-right: 8px; font-size: 14px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
1469
  <a href="{{ url_for('main.webnovels') }}" class="btn-text-icon" title="μ›μž‘ 정보">
 
1492
  </div>
1493
  </div>
1494
 
1495
+ <!-- λͺ¨λ°”일 메뉴 -->
1496
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
1497
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
1498
+ <div class="mobile-menu-header">
1499
+ <div class="mobile-menu-title">메뉴</div>
1500
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
1501
+ </div>
1502
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
1503
+ <div class="mobile-menu-items">
1504
+ <a href="{{ url_for('main.webnovels') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ›μž‘ 정보</a>
1505
+ {% if current_user.is_admin %}
1506
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">κ΄€λ¦¬μž νŽ˜μ΄μ§€</a>
1507
+ {% endif %}
1508
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
1509
+ </div>
1510
+ </div>
1511
+ </div>
1512
+
1513
  <!-- μ±„νŒ… μ˜μ—­ -->
1514
  <div class="chat-container" id="chatContainer">
1515
  <div class="empty-state" id="emptyState">
 
1611
  </div>
1612
 
1613
  <script>
1614
+ function toggleMobileMenu() {
1615
+ const menu = document.getElementById('mobileMenu');
1616
+ menu.classList.toggle('active');
1617
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
1618
+ }
1619
+
1620
+ function closeMobileMenu() {
1621
+ const menu = document.getElementById('mobileMenu');
1622
+ menu.classList.remove('active');
1623
+ document.body.style.overflow = '';
1624
+ }
1625
+
1626
+ function closeMobileMenuOnBackdrop(event) {
1627
+ if (event.target.id === 'mobileMenu') {
1628
+ closeMobileMenu();
1629
+ }
1630
+ }
1631
+
1632
  const chatContainer = document.getElementById('chatContainer');
1633
  const messageInput = document.getElementById('messageInput');
1634
  const sendButton = document.getElementById('sendButton');
 
1673
  async function loadNovels() {
1674
  try {
1675
  // λͺ¨λΈμ΄ μ„ νƒλ˜μ–΄ 있으면 ν•΄λ‹Ή λͺ¨λΈμ˜ 파일만, μ—†μœΌλ©΄ λͺ¨λ“  파일
1676
+ // 곡개 파일만 쑰회 (public_only=true)
1677
  const url = selectedModel
1678
+ ? `/api/files?model_name=${encodeURIComponent(selectedModel)}&public_only=true`
1679
+ : '/api/files?public_only=true';
1680
 
1681
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] API μš”μ²­:', url, 'μ„ νƒλœ λͺ¨λΈ:', selectedModel || 'μ—†μŒ (전체)');
1682
 
 
1702
  let files = data.files || [];
1703
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] 파일 개수:', files.length);
1704
 
1705
+ // λͺ¨λΈμ΄ μ„ νƒλ˜μ–΄ μžˆλŠ”λ° ν•΄λ‹Ή λͺ¨λΈμ˜ 파일이 μ—†μœΌλ©΄, λͺ¨λ“  파일 λ‹€μ‹œ 쑰회 (곡개 파일만)
1706
  if (selectedModel && files.length === 0) {
1707
  console.log('[μ›Ήμ†Œμ„€ λͺ©λ‘] μ„ νƒν•œ λͺ¨λΈμ— 파일이 μ—†μ–΄ 전체 파일 쑰회 쀑...');
1708
  try {
1709
+ const allFilesResponse = await fetch('/api/files?public_only=true', {
1710
  credentials: 'include'
1711
  });
1712
  if (allFilesResponse.ok) {
 
1951
  function toggleSidebar() {
1952
  sidebar.classList.toggle('collapsed');
1953
  const isCollapsed = sidebar.classList.contains('collapsed');
1954
+ const overlay = document.getElementById('sidebarOverlay');
1955
+
1956
  if (window.innerWidth <= 768) {
1957
  sidebarToggleBtn.style.display = isCollapsed ? 'flex' : 'none';
1958
+ // λͺ¨λ°”μΌμ—μ„œ μ‚¬μ΄λ“œλ°”κ°€ 열릴 λ•Œ λ°°κ²½ μ˜€λ²„λ ˆμ΄ μΆ”κ°€
1959
+ if (!isCollapsed) {
1960
+ if (overlay) overlay.classList.add('active');
1961
+ document.body.style.overflow = 'hidden';
1962
+ } else {
1963
+ if (overlay) overlay.classList.remove('active');
1964
+ document.body.style.overflow = '';
1965
+ }
1966
+ } else {
1967
+ if (overlay) overlay.classList.remove('active');
1968
  }
1969
+
1970
  // μ‚¬μ΄λ“œλ°” λ‚΄λΆ€ ν† κΈ€ λ²„νŠΌ μ•„μ΄μ½˜ μ—…λ°μ΄νŠΈ
1971
  const sidebarToggle = sidebar.querySelector('.sidebar-toggle');
1972
  if (sidebarToggle) {
 
1975
  '<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>';
1976
  }
1977
  }
1978
+
1979
+ function closeSidebarOnMobile() {
1980
+ if (window.innerWidth <= 768) {
1981
+ sidebar.classList.add('collapsed');
1982
+ const overlay = document.getElementById('sidebarOverlay');
1983
+ if (overlay) overlay.classList.remove('active');
1984
+ document.body.style.overflow = '';
1985
+ sidebarToggleBtn.style.display = 'flex';
1986
+ }
1987
+ }
1988
 
1989
  // λ°˜μ‘ν˜• μ‚¬μ΄λ“œλ°” 처리
1990
  function handleResize() {
1991
+ const overlay = document.getElementById('sidebarOverlay');
1992
  if (window.innerWidth <= 768) {
1993
  sidebar.classList.add('collapsed');
1994
  sidebarToggleBtn.style.display = 'flex';
1995
+ if (overlay) overlay.classList.remove('active');
1996
+ document.body.style.overflow = '';
1997
  } else {
1998
  sidebar.classList.remove('collapsed');
1999
  sidebarToggleBtn.style.display = 'none';
2000
+ if (overlay) overlay.classList.remove('active');
2001
  }
2002
  }
2003
 
templates/webnovels.html CHANGED
@@ -56,6 +56,126 @@
56
  align-items: center;
57
  }
58
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
59
  .btn {
60
  padding: 8px 16px;
61
  border: none;
@@ -477,6 +597,7 @@
477
  <span>πŸ“š</span>
478
  <span>μ—…λ‘œλ“œλœ μ›Ήμ†Œμ„€</span>
479
  </div>
 
480
  <div class="header-actions">
481
  <span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
482
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">μ°½μž‘ μ–΄μ‹œμŠ€ν„΄νŠΈ</a>
@@ -487,6 +608,24 @@
487
  </div>
488
  </div>
489
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
490
  <div class="container">
491
  <div class="page-header">
492
  <h1>μ—…λ‘œλ“œλœ μ›Ήμ†Œμ„€</h1>
@@ -694,6 +833,24 @@
694
  </div>
695
 
696
  <script>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
697
  function escapeHtml(text) {
698
  const div = document.createElement('div');
699
  div.textContent = text;
@@ -754,7 +911,10 @@
754
  listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">μ›Ήμ†Œμ„€ λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ” 쀑...</div>';
755
 
756
  try {
757
- const url = modelName ? `/api/files?model_name=${encodeURIComponent(modelName)}` : '/api/files';
 
 
 
758
  const response = await fetch(url, {
759
  credentials: 'include'
760
  });
 
56
  align-items: center;
57
  }
58
 
59
+ .menu-toggle {
60
+ display: none;
61
+ background: none;
62
+ border: none;
63
+ font-size: 24px;
64
+ cursor: pointer;
65
+ padding: 8px;
66
+ color: var(--text-primary);
67
+ }
68
+
69
+ .mobile-menu {
70
+ display: none;
71
+ position: fixed;
72
+ top: 0;
73
+ left: 0;
74
+ right: 0;
75
+ bottom: 0;
76
+ background: rgba(0, 0, 0, 0.5);
77
+ z-index: 1000;
78
+ }
79
+
80
+ .mobile-menu.active {
81
+ display: block;
82
+ }
83
+
84
+ .mobile-menu-content {
85
+ position: fixed;
86
+ top: 0;
87
+ right: -100%;
88
+ width: 280px;
89
+ max-width: 80%;
90
+ height: 100%;
91
+ background: var(--bg-primary);
92
+ box-shadow: -2px 0 8px rgba(0, 0, 0, 0.1);
93
+ transition: right 0.3s ease;
94
+ overflow-y: auto;
95
+ z-index: 1001;
96
+ }
97
+
98
+ .mobile-menu.active .mobile-menu-content {
99
+ right: 0;
100
+ }
101
+
102
+ .mobile-menu-header {
103
+ padding: 16px 20px;
104
+ border-bottom: 1px solid var(--border);
105
+ display: flex;
106
+ justify-content: space-between;
107
+ align-items: center;
108
+ background: var(--bg-primary);
109
+ position: sticky;
110
+ top: 0;
111
+ z-index: 10;
112
+ }
113
+
114
+ .mobile-menu-title {
115
+ font-size: 18px;
116
+ font-weight: 500;
117
+ }
118
+
119
+ .mobile-menu-close {
120
+ background: none;
121
+ border: none;
122
+ font-size: 24px;
123
+ cursor: pointer;
124
+ color: var(--text-primary);
125
+ padding: 0;
126
+ width: 32px;
127
+ height: 32px;
128
+ display: flex;
129
+ align-items: center;
130
+ justify-content: center;
131
+ }
132
+
133
+ .mobile-menu-items {
134
+ padding: 8px 0;
135
+ }
136
+
137
+ .mobile-menu-item {
138
+ display: block;
139
+ padding: 12px 20px;
140
+ color: var(--text-primary);
141
+ text-decoration: none;
142
+ border-bottom: 1px solid var(--bg-tertiary);
143
+ transition: background 0.2s;
144
+ }
145
+
146
+ .mobile-menu-item:hover {
147
+ background: var(--bg-secondary);
148
+ }
149
+
150
+ .mobile-menu-user {
151
+ padding: 16px 20px;
152
+ border-bottom: 1px solid var(--border);
153
+ color: var(--text-secondary);
154
+ font-size: 14px;
155
+ }
156
+
157
+ @media (max-width: 768px) {
158
+ .header {
159
+ padding: 12px 16px;
160
+ }
161
+
162
+ .header-title {
163
+ font-size: 18px;
164
+ }
165
+
166
+ .header-title span:first-child {
167
+ display: none;
168
+ }
169
+
170
+ .menu-toggle {
171
+ display: block;
172
+ }
173
+
174
+ .header-actions {
175
+ display: none;
176
+ }
177
+ }
178
+
179
  .btn {
180
  padding: 8px 16px;
181
  border: none;
 
597
  <span>πŸ“š</span>
598
  <span>μ—…λ‘œλ“œλœ μ›Ήμ†Œμ„€</span>
599
  </div>
600
+ <button class="menu-toggle" onclick="toggleMobileMenu()" aria-label="메뉴 μ—΄κΈ°">☰</button>
601
  <div class="header-actions">
602
  <span style="margin-right: 12px; color: var(--text-secondary);">{{ current_user.nickname or current_user.username }}</span>
603
  <a href="{{ url_for('main.index') }}" class="btn btn-secondary">μ°½μž‘ μ–΄μ‹œμŠ€ν„΄νŠΈ</a>
 
608
  </div>
609
  </div>
610
 
611
+ <!-- λͺ¨λ°”일 메뉴 -->
612
+ <div class="mobile-menu" id="mobileMenu" onclick="closeMobileMenuOnBackdrop(event)">
613
+ <div class="mobile-menu-content" onclick="event.stopPropagation()">
614
+ <div class="mobile-menu-header">
615
+ <div class="mobile-menu-title">메뉴</div>
616
+ <button class="mobile-menu-close" onclick="toggleMobileMenu()" aria-label="메뉴 λ‹«κΈ°">&times;</button>
617
+ </div>
618
+ <div class="mobile-menu-user">{{ current_user.nickname or current_user.username }}</div>
619
+ <div class="mobile-menu-items">
620
+ <a href="{{ url_for('main.index') }}" class="mobile-menu-item" onclick="closeMobileMenu()">μ°½μž‘ μ–΄μ‹œμŠ€ν„΄νŠΈ</a>
621
+ {% if current_user.is_admin %}
622
+ <a href="{{ url_for('main.admin') }}" class="mobile-menu-item" onclick="closeMobileMenu()">κ΄€λ¦¬μž νŽ˜μ΄μ§€</a>
623
+ {% endif %}
624
+ <a href="{{ url_for('main.logout') }}" class="mobile-menu-item" onclick="closeMobileMenu()">λ‘œκ·Έμ•„μ›ƒ</a>
625
+ </div>
626
+ </div>
627
+ </div>
628
+
629
  <div class="container">
630
  <div class="page-header">
631
  <h1>μ—…λ‘œλ“œλœ μ›Ήμ†Œμ„€</h1>
 
833
  </div>
834
 
835
  <script>
836
+ function toggleMobileMenu() {
837
+ const menu = document.getElementById('mobileMenu');
838
+ menu.classList.toggle('active');
839
+ document.body.style.overflow = menu.classList.contains('active') ? 'hidden' : '';
840
+ }
841
+
842
+ function closeMobileMenu() {
843
+ const menu = document.getElementById('mobileMenu');
844
+ menu.classList.remove('active');
845
+ document.body.style.overflow = '';
846
+ }
847
+
848
+ function closeMobileMenuOnBackdrop(event) {
849
+ if (event.target.id === 'mobileMenu') {
850
+ closeMobileMenu();
851
+ }
852
+ }
853
+
854
  function escapeHtml(text) {
855
  const div = document.createElement('div');
856
  div.textContent = text;
 
911
  listContainer.innerHTML = '<div style="text-align: center; color: var(--text-secondary); padding: 24px;">μ›Ήμ†Œμ„€ λͺ©λ‘μ„ λΆˆλŸ¬μ˜€λŠ” 쀑...</div>';
912
 
913
  try {
914
+ // 곡개 파일만 쑰회 (public_only=true)
915
+ const url = modelName
916
+ ? `/api/files?model_name=${encodeURIComponent(modelName)}&public_only=true`
917
+ : '/api/files?public_only=true';
918
  const response = await fetch(url, {
919
  credentials: 'include'
920
  });
upload_to_hf.ps1 ADDED
@@ -0,0 +1,114 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ # Hugging Face Spaces μ—…λ‘œλ“œ 슀크립트
2
+ # μ‚¬μš©λ²•: .\upload_to_hf.ps1 -HFSpacePath "C:\path\to\huggingface\space"
3
+
4
+ param(
5
+ [Parameter(Mandatory=$true)]
6
+ [string]$HFSpacePath
7
+ )
8
+
9
+ Write-Host "========================================" -ForegroundColor Cyan
10
+ Write-Host "Hugging Face Spaces μ—…λ‘œλ“œ μ€€λΉ„" -ForegroundColor Cyan
11
+ Write-Host "========================================" -ForegroundColor Cyan
12
+
13
+ # 경둜 확인
14
+ if (-not (Test-Path $HFSpacePath)) {
15
+ Write-Host "였λ₯˜: Hugging Face Spaces 디렉토리λ₯Ό 찾을 수 μ—†μŠ΅λ‹ˆλ‹€: $HFSpacePath" -ForegroundColor Red
16
+ Write-Host "λ¨Όμ € 'git clone https://huggingface.co/spaces/YOUR_USERNAME/YOUR_SPACE_NAME' λͺ…λ ΉμœΌλ‘œ ν΄λ‘ ν•˜μ„Έμš”." -ForegroundColor Yellow
17
+ exit 1
18
+ }
19
+
20
+ Write-Host "`nλŒ€μƒ 디렉토리: $HFSpacePath" -ForegroundColor Green
21
+
22
+ # ν˜„μž¬ 디렉토리 확인
23
+ $currentDir = Get-Location
24
+ Write-Host "ν˜„μž¬ 디렉토리: $currentDir" -ForegroundColor Green
25
+
26
+ # ν•„μˆ˜ 파일 확인
27
+ $requiredFiles = @("app.py", "Dockerfile", "requirements.txt", "README_HF.md")
28
+ $missingFiles = @()
29
+
30
+ foreach ($file in $requiredFiles) {
31
+ if (-not (Test-Path $file)) {
32
+ $missingFiles += $file
33
+ }
34
+ }
35
+
36
+ if ($missingFiles.Count -gt 0) {
37
+ Write-Host "`n였λ₯˜: λ‹€μŒ ν•„μˆ˜ 파일이 μ—†μŠ΅λ‹ˆλ‹€:" -ForegroundColor Red
38
+ foreach ($file in $missingFiles) {
39
+ Write-Host " - $file" -ForegroundColor Red
40
+ }
41
+ exit 1
42
+ }
43
+
44
+ # ν•„μˆ˜ 디렉토리 확인
45
+ $requiredDirs = @("app", "templates", "static")
46
+ $missingDirs = @()
47
+
48
+ foreach ($dir in $requiredDirs) {
49
+ if (-not (Test-Path $dir)) {
50
+ $missingDirs += $dir
51
+ }
52
+ }
53
+
54
+ if ($missingDirs.Count -gt 0) {
55
+ Write-Host "`n였λ₯˜: λ‹€μŒ ν•„μˆ˜ 디렉토리가 μ—†μŠ΅λ‹ˆλ‹€:" -ForegroundColor Red
56
+ foreach ($dir in $missingDirs) {
57
+ Write-Host " - $dir" -ForegroundColor Red
58
+ }
59
+ exit 1
60
+ }
61
+
62
+ Write-Host "`nν•„μˆ˜ 파일 및 디렉토리 확인 μ™„λ£Œ!" -ForegroundColor Green
63
+
64
+ # 파일 볡사 μ‹œμž‘
65
+ Write-Host "`n파일 볡사 μ‹œμž‘..." -ForegroundColor Yellow
66
+
67
+ try {
68
+ # ν•„μˆ˜ 파일 볡사
69
+ Write-Host "`n[1/4] ν•„μˆ˜ 파일 볡사 쀑..." -ForegroundColor Cyan
70
+ Copy-Item "app.py" "$HFSpacePath\" -Force
71
+ Copy-Item "Dockerfile" "$HFSpacePath\" -Force
72
+ Copy-Item "requirements.txt" "$HFSpacePath\" -Force
73
+ Copy-Item "README_HF.md" "$HFSpacePath\README.md" -Force
74
+ Write-Host " βœ“ ν•„μˆ˜ 파일 볡사 μ™„λ£Œ" -ForegroundColor Green
75
+
76
+ # app 디렉토리 볡사
77
+ Write-Host "`n[2/4] app 디렉토리 볡사 쀑..." -ForegroundColor Cyan
78
+ if (Test-Path "$HFSpacePath\app") {
79
+ Remove-Item "$HFSpacePath\app" -Recurse -Force
80
+ }
81
+ Copy-Item -Recurse "app" "$HFSpacePath\app" -Force
82
+ Write-Host " βœ“ app 디렉토리 볡사 μ™„λ£Œ" -ForegroundColor Green
83
+
84
+ # templates 디렉토리 볡사
85
+ Write-Host "`n[3/4] templates 디렉토리 볡사 쀑..." -ForegroundColor Cyan
86
+ if (Test-Path "$HFSpacePath\templates") {
87
+ Remove-Item "$HFSpacePath\templates" -Recurse -Force
88
+ }
89
+ Copy-Item -Recurse "templates" "$HFSpacePath\templates" -Force
90
+ Write-Host " βœ“ templates 디렉토리 볡사 μ™„λ£Œ" -ForegroundColor Green
91
+
92
+ # static 디렉토리 볡사
93
+ Write-Host "`n[4/4] static 디렉토리 볡사 쀑..." -ForegroundColor Cyan
94
+ if (Test-Path "$HFSpacePath\static") {
95
+ Remove-Item "$HFSpacePath\static" -Recurse -Force
96
+ }
97
+ Copy-Item -Recurse "static" "$HFSpacePath\static" -Force
98
+ Write-Host " βœ“ static 디렉토리 볡사 μ™„λ£Œ" -ForegroundColor Green
99
+
100
+ Write-Host "`n========================================" -ForegroundColor Cyan
101
+ Write-Host "파일 볡사 μ™„λ£Œ!" -ForegroundColor Green
102
+ Write-Host "========================================" -ForegroundColor Cyan
103
+ Write-Host "`nλ‹€μŒ 단계:" -ForegroundColor Yellow
104
+ Write-Host "1. Hugging Face Spaces λ””λ ‰ν† λ¦¬λ‘œ 이동: cd $HFSpacePath" -ForegroundColor White
105
+ Write-Host "2. Git 컀밋: git add ." -ForegroundColor White
106
+ Write-Host "3. Git 컀밋: git commit -m 'Initial deployment'" -ForegroundColor White
107
+ Write-Host "4. Git ν‘Έμ‹œ: git push" -ForegroundColor White
108
+ Write-Host "`nλ˜λŠ” Hugging Face μ›Ή μΈν„°νŽ˜μ΄μŠ€μ—μ„œ νŒŒμΌμ„ ν™•μΈν•˜κ³  μ»€λ°‹ν•˜μ„Έμš”." -ForegroundColor White
109
+
110
+ } catch {
111
+ Write-Host "`n였λ₯˜ λ°œμƒ: $_" -ForegroundColor Red
112
+ exit 1
113
+ }
114
+