xtom0 commited on
Commit
946d512
·
1 Parent(s): 9f9f6ef

Simplify to single-model chat app: llama3.1:8b only. Add /api/chat SSE streaming, chatbot UI, remove model selection/download UI, build-time model pull

Browse files
Files changed (6) hide show
  1. Dockerfile +10 -3
  2. app.py +73 -214
  3. templates/chat.html +199 -0
  4. templates/dashboard.html +44 -83
  5. templates/index.html +11 -18
  6. templates/progress.html +0 -94
Dockerfile CHANGED
@@ -11,11 +11,18 @@ RUN pip install --no-cache-dir -r requirements.txt
11
 
12
  COPY . .
13
 
 
 
 
 
 
14
  EXPOSE 7860
15
 
16
  CMD ollama serve > /tmp/ollama.log 2>&1 & \
17
  sleep 3 && \
18
- echo "Starting model pull in background..." && \
19
- nohup ollama pull llama3.1:8b > /tmp/ollama-pull.log 2>&1 & \
20
- echo "Model pull started, launching app..." && \
 
 
21
  uvicorn app:app --host 0.0.0.0 --port 7860
 
11
 
12
  COPY . .
13
 
14
+ RUN ollama serve > /tmp/ollama-build.log 2>&1 & \
15
+ sleep 5 && \
16
+ echo "Pulling llama3.1:8b..." && \
17
+ ollama pull llama3.1:8b 2>&1 || echo "WARNING: Build-time pull failed, will retry at runtime"
18
+
19
  EXPOSE 7860
20
 
21
  CMD ollama serve > /tmp/ollama.log 2>&1 & \
22
  sleep 3 && \
23
+ if ! ollama list 2>/dev/null | grep -q llama3.1; then \
24
+ echo "llama3.1:8b not found, pulling in background..." && \
25
+ nohup ollama pull llama3.1:8b > /tmp/ollama-pull.log 2>&1 & \
26
+ fi && \
27
+ echo "Starting app..." && \
28
  uvicorn app:app --host 0.0.0.0 --port 7860
app.py CHANGED
@@ -1,15 +1,14 @@
1
  import json
2
  import asyncio
3
- import uuid
4
- import re
5
  import shutil
6
  from datetime import datetime, timedelta
7
  from typing import Optional
8
 
9
- from fastapi import FastAPI, HTTPException, Depends, status, WebSocket, WebSocketDisconnect, Request, Form
 
10
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
11
  from fastapi.staticfiles import StaticFiles
12
- from fastapi.responses import HTMLResponse, RedirectResponse, JSONResponse
13
  from fastapi.templating import Jinja2Templates
14
  from jose import JWTError, jwt
15
  from pydantic import BaseModel
@@ -17,8 +16,7 @@ from pydantic import BaseModel
17
  from config import settings
18
  from database import (
19
  init_db, create_user, get_user_by_username, verify_password,
20
- get_user_by_api_key, create_download_session, update_download_progress,
21
- get_download_session, get_user_sessions, create_api_key,
22
  get_user_api_keys, revoke_api_key, generate_api_key
23
  )
24
 
@@ -30,6 +28,9 @@ app.mount("/static", StaticFiles(directory="static"), name="static")
30
 
31
  init_db()
32
 
 
 
 
33
 
34
  # --- Models ---
35
 
@@ -45,8 +46,9 @@ class LoginRequest(BaseModel):
45
  class CreateApiKeyRequest(BaseModel):
46
  key_name: str
47
 
48
- class CreateSessionRequest(BaseModel):
49
- model_name: str
 
50
 
51
 
52
  # --- Auth Helpers ---
@@ -71,34 +73,14 @@ async def get_current_user(credentials: HTTPAuthorizationCredentials = Depends(s
71
  raise HTTPException(status_code=401, detail="User not found")
72
  return user
73
 
74
-
75
- # --- WebSocket Progress Manager ---
76
-
77
- class ConnectionManager:
78
- def __init__(self):
79
- self.active_connections: dict[str, list[WebSocket]] = {}
80
-
81
- async def connect(self, session_id: str, websocket: WebSocket):
82
- await websocket.accept()
83
- if session_id not in self.active_connections:
84
- self.active_connections[session_id] = []
85
- self.active_connections[session_id].append(websocket)
86
-
87
- def disconnect(self, session_id: str, websocket: WebSocket):
88
- if session_id in self.active_connections:
89
- self.active_connections[session_id].remove(websocket)
90
- if not self.active_connections[session_id]:
91
- del self.active_connections[session_id]
92
-
93
- async def broadcast(self, session_id: str, data: dict):
94
- if session_id in self.active_connections:
95
- for ws in self.active_connections[session_id]:
96
- try:
97
- await ws.send_text(json.dumps(data))
98
- except Exception:
99
- pass
100
-
101
- manager = ConnectionManager()
102
 
103
 
104
  # --- Web Pages ---
@@ -115,9 +97,9 @@ async def login_page(request: Request):
115
  async def dashboard_page(request: Request):
116
  return templates.TemplateResponse("dashboard.html", {"request": request})
117
 
118
- @app.get("/progress/{session_id}", response_class=HTMLResponse)
119
- async def progress_page(request: Request, session_id: str):
120
- return templates.TemplateResponse("progress.html", {"request": request, "session_id": session_id})
121
 
122
 
123
  # --- Auth API ---
@@ -147,29 +129,26 @@ async def me(user: dict = Depends(get_current_user)):
147
  }
148
 
149
 
150
- # --- Model Recommendations ---
151
 
152
- @app.get("/api/models")
153
- async def get_models():
154
- return {"models": settings.recommended_models}
155
-
156
- @app.get("/api/installed-models")
157
- async def installed_models():
158
  ollama_path = shutil.which("ollama")
159
  if not ollama_path:
160
- return {"models": []}
161
  try:
162
  proc = await asyncio.create_subprocess_exec(ollama_path, "list", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
163
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
164
- models = []
165
- for line in stdout.decode().strip().split("\n")[1:]:
166
- if line.strip():
167
- parts = line.split()
168
- if len(parts) >= 1:
169
- models.append({"name": parts[0], "size": parts[2] if len(parts) > 2 else "?"})
170
- return {"models": models}
171
- except:
172
- return {"models": []}
 
173
 
174
 
175
  # --- API Key Management ---
@@ -189,168 +168,48 @@ async def delete_api_key(key_id: int, user: dict = Depends(get_current_user)):
189
  return {"message": "API key revoked"}
190
 
191
 
192
- # --- Download Sessions ---
193
-
194
- @app.get("/api/ollama-status")
195
- async def ollama_status():
196
- ollama_path = shutil.which("ollama")
197
- if not ollama_path:
198
- return {"available": False, "message": "Ollama not found on this server"}
199
- try:
200
- proc = await asyncio.create_subprocess_exec(ollama_path, "list", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
201
- stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
202
- models = []
203
- for line in stdout.decode().strip().split("\n")[1:]:
204
- if line.strip():
205
- parts = line.split()
206
- if len(parts) >= 1:
207
- models.append(parts[0])
208
- return {"available": True, "message": "Ollama is running", "models": models}
209
- except Exception as e:
210
- return {"available": False, "message": f"Ollama server not reachable: {str(e)}"}
211
-
212
- @app.post("/api/download-sessions")
213
- async def start_download_session(req: CreateSessionRequest, user: dict = Depends(get_current_user)):
214
- ollama_path = shutil.which("ollama")
215
- if not ollama_path:
216
- raise HTTPException(status_code=400, detail="Ollama is not installed on this server. Run locally with Ollama installed.")
217
- session_id = str(uuid.uuid4())
218
- create_download_session(user["id"], session_id, req.model_name)
219
- asyncio.create_task(ollama_download(session_id, req.model_name, ollama_path))
220
- return {"session_id": session_id, "model_name": req.model_name, "status": "started"}
221
-
222
- @app.get("/api/download-sessions")
223
- async def list_sessions(user: dict = Depends(get_current_user)):
224
- return {"sessions": get_user_sessions(user["id"])}
225
-
226
- @app.get("/api/download-sessions/{session_id}")
227
- async def get_session(session_id: str, user: dict = Depends(get_current_user)):
228
- session = get_download_session(session_id)
229
- if not session:
230
- raise HTTPException(status_code=404, detail="Session not found")
231
- if session["user_id"] != user["id"]:
232
- raise HTTPException(status_code=403, detail="Not your session")
233
- return session
234
-
235
-
236
- # --- WebSocket ---
237
-
238
- @app.websocket("/ws/{session_id}")
239
- async def websocket_endpoint(websocket: WebSocket, session_id: str):
240
- await manager.connect(session_id, websocket)
241
- try:
242
- while True:
243
- await websocket.receive_text()
244
- except WebSocketDisconnect:
245
- manager.disconnect(session_id, websocket)
246
-
247
-
248
- # --- Real Ollama Download ---
249
-
250
- def parse_ollama_progress(line: str) -> Optional[dict]:
251
- m = re.search(r'pulling\s+\S+\s*:\s*(\d+)%\s*.*?(\d+\.?\d*)\s*(KB|MB|GB)\s*/\s*(\d+\.?\d*)\s*(KB|MB|GB)\s*(\d+\.?\d*)\s*(KB|MB)/s\s*(.+)?', line)
252
- if m:
253
- pct = int(m.group(1))
254
- dl_val = float(m.group(2))
255
- dl_unit = m.group(3)
256
- total_val = float(m.group(4))
257
- total_unit = m.group(5)
258
- speed_val = float(m.group(6))
259
- speed_unit = m.group(7)
260
- eta_str = m.group(8).strip() if m.group(8) else ""
261
-
262
- def to_mb(val, unit):
263
- if unit == "GB": return int(val * 1024)
264
- if unit == "KB": return int(val / 1024)
265
- return int(val)
266
-
267
- downloaded_mb = to_mb(dl_val, dl_unit)
268
- total_mb = to_mb(total_val, total_unit)
269
- speed_kbps = int(speed_val * 1024 if speed_unit == "MB" else speed_val)
270
-
271
- eta_seconds = 0
272
- if eta_str:
273
- eta_parts = eta_str.split(":")
274
- if len(eta_parts) == 2:
275
- eta_seconds = int(eta_parts[0]) * 60 + int(eta_parts[1])
276
- elif "m" in eta_str and "s" in eta_str:
277
- parts = eta_str.replace("m", " ").replace("s", "").split()
278
- eta_seconds = int(parts[0]) * 60 + (int(parts[1]) if len(parts) > 1 else 0)
279
- elif "h" in eta_str:
280
- parts = eta_str.replace("h", " ").replace("m", "").split()
281
- eta_seconds = int(parts[0]) * 3600 + (int(parts[1]) * 60 if len(parts) > 1 else 0)
282
-
283
- return {
284
- "progress": pct,
285
- "downloaded_mb": downloaded_mb,
286
- "total_size_mb": total_mb,
287
- "speed_kbps": speed_kbps,
288
- "eta_seconds": eta_seconds,
289
- "current_step": f"Downloading layer ({pct}%)"
290
- }
291
- if "pulling manifest" in line:
292
- return {"current_step": "Pulling manifest...", "progress": 0}
293
- if "verifying sha256" in line:
294
- return {"current_step": "Verifying...", "progress": 95}
295
- if "writing manifest" in line:
296
- return {"current_step": "Writing manifest...", "progress": 98}
297
- if "success" in line:
298
- return {"current_step": "Done", "progress": 100, "status": "completed"}
299
- return None
300
-
301
- async def ollama_download(session_id: str, model_name: str, ollama_path: str):
302
- update_download_progress(session_id, status="starting", progress=0, current_step="Starting download...")
303
- await manager.broadcast(session_id, {"status": "starting", "progress": 0, "current_step": "Starting download..."})
304
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
305
  try:
306
  proc = await asyncio.create_subprocess_exec(
307
- ollama_path, "pull", model_name,
308
- stdout=asyncio.subprocess.PIPE,
309
- stderr=asyncio.subprocess.PIPE
310
  )
311
-
312
- last_progress = {}
313
-
314
- async def read_stderr():
315
- nonlocal last_progress
316
- while True:
317
- line = await proc.stderr.readline()
318
- if not line:
319
- break
320
- decoded = line.decode(errors="replace").strip()
321
- if not decoded:
322
- continue
323
- parsed = parse_ollama_progress(decoded)
324
- if parsed:
325
- last_progress.update({k: v for k, v in parsed.items() if v is not None})
326
- data = dict(last_progress)
327
- data["status"] = "downloading"
328
- update_download_progress(session_id, **data)
329
- await manager.broadcast(session_id, data)
330
-
331
- async def read_stdout():
332
- async for line in proc.stdout:
333
- decoded = line.decode(errors="replace").strip()
334
- if decoded:
335
- last_progress["current_step"] = decoded
336
- data = dict(last_progress)
337
- data["status"] = "downloading"
338
- update_download_progress(session_id, **data)
339
- await manager.broadcast(session_id, data)
340
-
341
- await asyncio.gather(read_stderr(), read_stdout())
342
- await proc.wait()
343
-
344
- if proc.returncode == 0:
345
- update_download_progress(session_id, status="completed", progress=100, current_step="Done", speed_kbps=0, eta_seconds=0, completed_at=datetime.utcnow().isoformat())
346
- await manager.broadcast(session_id, {"status": "completed", "progress": 100, "current_step": "Done"})
347
- else:
348
- update_download_progress(session_id, status="error", current_step="Download failed", error_message=f"Process exited with code {proc.returncode}")
349
- await manager.broadcast(session_id, {"status": "error", "current_step": "Download failed"})
350
-
351
- except Exception as e:
352
- update_download_progress(session_id, status="error", current_step="Error", error_message=str(e))
353
- await manager.broadcast(session_id, {"status": "error", "current_step": f"Error: {str(e)}"})
354
 
355
 
356
  # --- Startup ---
 
1
  import json
2
  import asyncio
 
 
3
  import shutil
4
  from datetime import datetime, timedelta
5
  from typing import Optional
6
 
7
+ import httpx
8
+ from fastapi import FastAPI, HTTPException, Depends, Request
9
  from fastapi.security import HTTPBearer, HTTPAuthorizationCredentials
10
  from fastapi.staticfiles import StaticFiles
11
+ from fastapi.responses import HTMLResponse, StreamingResponse
12
  from fastapi.templating import Jinja2Templates
13
  from jose import JWTError, jwt
14
  from pydantic import BaseModel
 
16
  from config import settings
17
  from database import (
18
  init_db, create_user, get_user_by_username, verify_password,
19
+ get_user_by_api_key, create_api_key,
 
20
  get_user_api_keys, revoke_api_key, generate_api_key
21
  )
22
 
 
28
 
29
  init_db()
30
 
31
+ OLLAMA_MODEL = "llama3.1:8b"
32
+ OLLAMA_BASE = "http://localhost:11434"
33
+
34
 
35
  # --- Models ---
36
 
 
46
  class CreateApiKeyRequest(BaseModel):
47
  key_name: str
48
 
49
+ class ChatRequest(BaseModel):
50
+ message: str
51
+ stream: bool = True
52
 
53
 
54
  # --- Auth Helpers ---
 
73
  raise HTTPException(status_code=401, detail="User not found")
74
  return user
75
 
76
+ async def get_user_or_none(request: Request):
77
+ auth = request.headers.get("Authorization", "")
78
+ if not auth.startswith("Bearer "):
79
+ return None
80
+ try:
81
+ return await get_current_user(HTTPAuthorizationCredentials(scheme="Bearer", credentials=auth[7:]))
82
+ except:
83
+ return None
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
84
 
85
 
86
  # --- Web Pages ---
 
97
  async def dashboard_page(request: Request):
98
  return templates.TemplateResponse("dashboard.html", {"request": request})
99
 
100
+ @app.get("/chat", response_class=HTMLResponse)
101
+ async def chat_page(request: Request):
102
+ return templates.TemplateResponse("chat.html", {"request": request})
103
 
104
 
105
  # --- Auth API ---
 
129
  }
130
 
131
 
132
+ # --- Model / Status ---
133
 
134
+ @app.get("/api/status")
135
+ async def status():
 
 
 
 
136
  ollama_path = shutil.which("ollama")
137
  if not ollama_path:
138
+ return {"ollama": False, "model": False, "message": "Ollama not installed"}
139
  try:
140
  proc = await asyncio.create_subprocess_exec(ollama_path, "list", stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE)
141
  stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
142
+ installed = [line.split()[0] for line in stdout.decode().strip().split("\n")[1:] if line.strip()]
143
+ model_ready = OLLAMA_MODEL in installed
144
+ return {
145
+ "ollama": True,
146
+ "model": model_ready,
147
+ "installed": installed,
148
+ "message": "Ready" if model_ready else f"Model {OLLAMA_MODEL} not yet installed (pulling in background)"
149
+ }
150
+ except Exception as e:
151
+ return {"ollama": False, "model": False, "message": str(e)}
152
 
153
 
154
  # --- API Key Management ---
 
168
  return {"message": "API key revoked"}
169
 
170
 
171
+ # --- Chat API (streaming via SSE) ---
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
 
173
+ @app.post("/api/chat")
174
+ async def chat(req: ChatRequest, user: Optional[dict] = Depends(get_current_user)):
175
+ if not user:
176
+ raise HTTPException(status_code=401, detail="Authentication required")
177
+ model_ready = await check_model_ready()
178
+ if not model_ready:
179
+ raise HTTPException(status_code=503, detail=f"{OLLAMA_MODEL} is still downloading. Please wait and try again.")
180
+
181
+ async def generate():
182
+ async with httpx.AsyncClient(timeout=None) as client:
183
+ async with client.stream(
184
+ "POST",
185
+ f"{OLLAMA_BASE}/api/chat",
186
+ json={"model": OLLAMA_MODEL, "messages": [{"role": "user", "content": req.message}], "stream": True},
187
+ ) as resp:
188
+ async for line in resp.aiter_lines():
189
+ if not line.strip():
190
+ continue
191
+ try:
192
+ chunk = json.loads(line)
193
+ content = chunk.get("message", {}).get("content", "")
194
+ if content:
195
+ yield f"data: {json.dumps({'content': content})}\n\n"
196
+ if chunk.get("done"):
197
+ yield f"data: {json.dumps({'done': True})}\n\n"
198
+ except json.JSONDecodeError:
199
+ continue
200
+
201
+ return StreamingResponse(generate(), media_type="text/event-stream")
202
+
203
+ async def check_model_ready() -> bool:
204
  try:
205
  proc = await asyncio.create_subprocess_exec(
206
+ shutil.which("ollama"), "list",
207
+ stdout=asyncio.subprocess.PIPE, stderr=asyncio.subprocess.PIPE
 
208
  )
209
+ stdout, _ = await asyncio.wait_for(proc.communicate(), timeout=5)
210
+ return any(OLLAMA_MODEL in line for line in stdout.decode().strip().split("\n")[1:])
211
+ except:
212
+ return False
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
 
215
  # --- Startup ---
templates/chat.html ADDED
@@ -0,0 +1,199 @@
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
1
+ <!DOCTYPE html>
2
+ <html lang="en">
3
+ <head>
4
+ <meta charset="UTF-8">
5
+ <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Chat - Ollama</title>
7
+ <link rel="stylesheet" href="/static/style.css">
8
+ <style>
9
+ .chat-layout { display: flex; gap: 20px; min-height: 70vh; }
10
+ .chat-sidebar { width: 260px; flex-shrink: 0; }
11
+ .chat-main { flex: 1; display: flex; flex-direction: column; }
12
+ .chat-box { flex: 1; overflow-y: auto; padding: 16px; background: #f8f9fa; border-radius: 12px; margin-bottom: 12px; max-height: 60vh; }
13
+ .message { margin-bottom: 16px; display: flex; }
14
+ .message.user { justify-content: flex-end; }
15
+ .message.assistant { justify-content: flex-start; }
16
+ .message .bubble { max-width: 80%; padding: 10px 16px; border-radius: 16px; line-height: 1.5; white-space: pre-wrap; }
17
+ .message.user .bubble { background: #4361ee; color: white; border-bottom-right-radius: 4px; }
18
+ .message.assistant .bubble { background: white; color: #333; border: 1px solid #e0e0e0; border-bottom-left-radius: 4px; }
19
+ .chat-input-row { display: flex; gap: 8px; }
20
+ .chat-input-row input { flex: 1; padding: 12px 16px; border: 1px solid #ddd; border-radius: 24px; font-size: 1rem; }
21
+ .chat-input-row button { padding: 12px 24px; border-radius: 24px; }
22
+ .model-badge { display: inline-block; padding: 4px 12px; border-radius: 12px; font-size: 0.8rem; font-weight: 600; margin-bottom: 12px; }
23
+ .model-badge.ready { background: #d4edda; color: #155724; }
24
+ .model-badge.loading { background: #fff3cd; color: #856404; }
25
+ .model-badge.error { background: #f8d7da; color: #721c24; }
26
+ #status-msg { font-size: 0.9rem; color: #666; margin-top: 8px; }
27
+ .typing-indicator { display: none; padding: 10px 16px; background: white; border: 1px solid #e0e0e0; border-radius: 16px; border-bottom-left-radius: 4px; max-width: 80px; }
28
+ .typing-indicator.active { display: flex; gap: 4px; }
29
+ .typing-indicator span { width: 8px; height: 8px; background: #999; border-radius: 50%; animation: bounce 1.4s infinite; }
30
+ .typing-indicator span:nth-child(2) { animation-delay: 0.2s; }
31
+ .typing-indicator span:nth-child(3) { animation-delay: 0.4s; }
32
+ @keyframes bounce { 0%, 80%, 100% { transform: translateY(0); } 40% { transform: translateY(-8px); } }
33
+ @media (max-width: 768px) { .chat-layout { flex-direction: column; } .chat-sidebar { width: 100%; } }
34
+ </style>
35
+ </head>
36
+ <body>
37
+ <div class="container">
38
+ <header>
39
+ <h1>Llama 3.1 Chat</h1>
40
+ <div>
41
+ <span id="model-status" class="model-badge loading">Checking...</span>
42
+ <a href="/dashboard" class="btn btn-secondary">Dashboard</a>
43
+ <button class="btn btn-secondary" onclick="logout()">Logout</button>
44
+ </div>
45
+ </header>
46
+
47
+ <div class="chat-layout">
48
+ <div class="chat-sidebar">
49
+ <div class="card">
50
+ <h2>Info</h2>
51
+ <p><strong>Model:</strong> llama3.1:8b</p>
52
+ <p id="status-msg">Checking model status...</p>
53
+ <button class="btn btn-primary" style="width:100%;margin-top:12px" onclick="newChat()">New Chat</button>
54
+ </div>
55
+ </div>
56
+
57
+ <div class="chat-main">
58
+ <div class="chat-box" id="chat-box">
59
+ <div class="message assistant">
60
+ <div class="bubble">Hello! I'm Llama 3.1 (8B). Ask me anything.</div>
61
+ </div>
62
+ <div class="typing-indicator" id="typing"><span></span><span></span><span></span></div>
63
+ </div>
64
+
65
+ <form class="chat-input-row" onsubmit="sendMessage(event)">
66
+ <input type="text" id="message-input" placeholder="Type your message..." required autofocus>
67
+ <button type="submit" class="btn btn-primary">Send</button>
68
+ </form>
69
+ </div>
70
+ </div>
71
+ </div>
72
+
73
+ <script>
74
+ const token = localStorage.getItem('token');
75
+ if (!token) window.location.href = '/login';
76
+
77
+ let reading = false;
78
+
79
+ function logout() { localStorage.removeItem('token'); window.location.href = '/login'; }
80
+
81
+ function newChat() {
82
+ document.getElementById('chat-box').innerHTML = `
83
+ <div class="message assistant">
84
+ <div class="bubble">Hello! I'm Llama 3.1 (8B). Ask me anything.</div>
85
+ </div>
86
+ <div class="typing-indicator" id="typing"><span></span><span></span><span></span></div>
87
+ `;
88
+ }
89
+
90
+ function addMessage(role, content) {
91
+ const box = document.getElementById('chat-box');
92
+ const typing = document.getElementById('typing');
93
+ const div = document.createElement('div');
94
+ div.className = `message ${role}`;
95
+ div.innerHTML = `<div class="bubble">${content}</div>`;
96
+ box.insertBefore(div, typing);
97
+ box.scrollTop = box.scrollHeight;
98
+ }
99
+
100
+ // Check model status
101
+ async function checkStatus() {
102
+ const res = await fetch('/api/status', {headers: {'Authorization': `Bearer ${token}`}});
103
+ const data = await res.json();
104
+ const badge = document.getElementById('model-status');
105
+ const msg = document.getElementById('status-msg');
106
+ if (data.model) {
107
+ badge.className = 'model-badge ready';
108
+ badge.textContent = 'Model Ready';
109
+ msg.textContent = 'llama3.1:8b is ready to use';
110
+ } else if (data.ollama) {
111
+ badge.className = 'model-badge loading';
112
+ badge.textContent = 'Downloading...';
113
+ msg.textContent = data.message || 'Model is being downloaded in background. Please wait...';
114
+ setTimeout(checkStatus, 10000);
115
+ } else {
116
+ badge.className = 'model-badge error';
117
+ badge.textContent = 'Unavailable';
118
+ msg.textContent = data.message || 'Ollama not available';
119
+ }
120
+ }
121
+ checkStatus();
122
+
123
+ async function sendMessage(e) {
124
+ e.preventDefault();
125
+ if (reading) return;
126
+
127
+ const input = document.getElementById('message-input');
128
+ const text = input.value.trim();
129
+ if (!text) return;
130
+ input.value = '';
131
+
132
+ addMessage('user', text);
133
+ reading = true;
134
+
135
+ const typing = document.getElementById('typing');
136
+ typing.classList.add('active');
137
+
138
+ let assistantContent = '';
139
+ const bubbleDiv = document.createElement('div');
140
+ bubbleDiv.className = 'message assistant';
141
+ const bubble = document.createElement('div');
142
+ bubble.className = 'bubble';
143
+ bubbleDiv.appendChild(bubble);
144
+ const box = document.getElementById('chat-box');
145
+ box.insertBefore(bubbleDiv, typing);
146
+
147
+ try {
148
+ const res = await fetch('/api/chat', {
149
+ method: 'POST',
150
+ headers: {'Authorization': `Bearer ${token}`, 'Content-Type': 'application/json'},
151
+ body: JSON.stringify({message: text, stream: true})
152
+ });
153
+
154
+ if (!res.ok) {
155
+ const err = await res.json();
156
+ bubble.textContent = err.detail || 'Error: Model not ready yet';
157
+ reading = false;
158
+ typing.classList.remove('active');
159
+ return;
160
+ }
161
+
162
+ const reader = res.body.getReader();
163
+ const decoder = new TextDecoder();
164
+ let buffer = '';
165
+
166
+ while (true) {
167
+ const {done, value} = await reader.read();
168
+ if (done) break;
169
+
170
+ buffer += decoder.decode(value, {stream: true});
171
+ const lines = buffer.split('\n');
172
+ buffer = lines.pop() || '';
173
+
174
+ for (const line of lines) {
175
+ if (!line.startsWith('data: ')) continue;
176
+ try {
177
+ const data = JSON.parse(line.slice(6));
178
+ if (data.done) {
179
+ reading = false;
180
+ typing.classList.remove('active');
181
+ break;
182
+ }
183
+ if (data.content) {
184
+ assistantContent += data.content;
185
+ bubble.textContent = assistantContent;
186
+ box.scrollTop = box.scrollHeight;
187
+ }
188
+ } catch(e) {}
189
+ }
190
+ }
191
+ } catch(e) {
192
+ bubble.textContent = 'Error: Connection failed';
193
+ reading = false;
194
+ typing.classList.remove('active');
195
+ }
196
+ }
197
+ </script>
198
+ </body>
199
+ </html>
templates/dashboard.html CHANGED
@@ -3,7 +3,7 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Dashboard - Ollama Manager</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
  </head>
9
  <body>
@@ -11,17 +11,12 @@
11
  <header>
12
  <h1>Dashboard</h1>
13
  <div>
14
- <span id="ollama-badge" class="status status-pending">Checking Ollama...</span>
 
15
  <button class="btn btn-secondary" onclick="logout()">Logout</button>
16
  </div>
17
  </header>
18
 
19
- <div id="ollama-warning" class="card hidden" style="border: 2px solid #e63946;">
20
- <h2>Ollama Not Available</h2>
21
- <p id="ollama-msg">Ollama is not installed or not running on this server.</p>
22
- <p>To use this app locally: <code>ollama serve</code> then <code>python app.py</code></p>
23
- </div>
24
-
25
  <div class="dashboard-grid">
26
  <div class="card">
27
  <h2>Your Profile</h2>
@@ -38,22 +33,23 @@
38
  </div>
39
 
40
  <div class="card">
41
- <h2>Installed Models</h2>
42
- <ul id="installed-models" class="model-list"><li>Loading...</li></ul>
43
- </div>
44
-
45
- <div class="card full-width">
46
- <h2>Start Download</h2>
47
- <form onsubmit="startDownload(event)">
48
- <select id="model-select" required></select>
49
- <button type="submit" class="btn btn-primary">Download</button>
50
- </form>
51
- <p id="download-error" class="error"></p>
52
  </div>
53
 
54
  <div class="card full-width">
55
- <h2>Download Sessions</h2>
56
- <div id="sessions-list"></div>
 
 
 
 
57
  </div>
58
  </div>
59
  </div>
@@ -73,10 +69,7 @@
73
  return res;
74
  }
75
 
76
- function logout() {
77
- localStorage.removeItem('token');
78
- window.location.href = '/login';
79
- }
80
 
81
  // Load profile
82
  api('/api/me').then(r => r.json()).then(data => {
@@ -87,40 +80,41 @@
87
  `;
88
  });
89
 
90
- // Check Ollama status
91
- fetch('/api/ollama-status').then(r => r.json()).then(data => {
 
 
92
  const badge = document.getElementById('ollama-badge');
93
- if (data.available) {
 
 
 
94
  badge.className = 'status status-completed';
95
- badge.textContent = 'Ollama Ready';
 
 
 
 
 
 
 
 
96
  } else {
97
  badge.className = 'status status-error';
98
- badge.textContent = 'Ollama Unavailable';
99
- document.getElementById('ollama-warning').classList.remove('hidden');
100
- document.getElementById('ollama-msg').textContent = data.message;
101
  }
102
- });
103
 
104
- // Load installed models
105
- fetch('/api/installed-models').then(r => r.json()).then(data => {
106
  const ul = document.getElementById('installed-models');
107
- if (data.models.length === 0) {
108
- ul.innerHTML = '<li>No models installed yet. Download one below.</li>';
109
  } else {
110
- ul.innerHTML = data.models.map(m => `<li><strong>${m.name}</strong> (${m.size})</li>`).join('');
111
  }
112
- });
113
-
114
- // Load recommended models
115
- fetch('/api/models').then(r => r.json()).then(data => {
116
- const select = document.getElementById('model-select');
117
- data.models.filter(m => !m.name.includes('70b')).forEach(m => {
118
- const opt = document.createElement('option');
119
- opt.value = m.name;
120
- opt.textContent = `${m.name} (${m.size})`;
121
- select.appendChild(opt);
122
- });
123
- });
124
 
125
  // Load API keys
126
  async function loadApiKeys() {
@@ -150,39 +144,6 @@
150
  loadApiKeys();
151
  }
152
  }
153
-
154
- // Start download
155
- async function startDownload(e) {
156
- e.preventDefault();
157
- const model = document.getElementById('model-select').value;
158
- const res = await api('/api/download-sessions', {method: 'POST', body: {model_name: model}});
159
- const data = await res.json();
160
- if (!res.ok) {
161
- document.getElementById('download-error').textContent = data.detail || 'Download failed';
162
- return;
163
- }
164
- window.location.href = `/progress/${data.session_id}`;
165
- }
166
-
167
- // Load sessions
168
- async function loadSessions() {
169
- const res = await api('/api/download-sessions');
170
- const data = await res.json();
171
- const div = document.getElementById('sessions-list');
172
- if (data.sessions.length === 0) {
173
- div.innerHTML = '<p>No download sessions yet.</p>';
174
- return;
175
- }
176
- div.innerHTML = data.sessions.map(s => `
177
- <div class="session-item">
178
- <a href="/progress/${s.session_id}"><strong>${s.model_name}</strong></a>
179
- <span class="status status-${s.status}">${s.status}</span>
180
- <span>${s.progress}%</span>
181
- <small>${s.started_at}</small>
182
- </div>
183
- `).join('');
184
- }
185
- loadSessions();
186
  </script>
187
  </body>
188
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>Dashboard - Ollama</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
  </head>
9
  <body>
 
11
  <header>
12
  <h1>Dashboard</h1>
13
  <div>
14
+ <span id="ollama-badge" class="status status-pending">Checking...</span>
15
+ <a href="/chat" class="btn btn-primary">Chat</a>
16
  <button class="btn btn-secondary" onclick="logout()">Logout</button>
17
  </div>
18
  </header>
19
 
 
 
 
 
 
 
20
  <div class="dashboard-grid">
21
  <div class="card">
22
  <h2>Your Profile</h2>
 
33
  </div>
34
 
35
  <div class="card">
36
+ <h2>Model Status</h2>
37
+ <p><strong>Model:</strong> llama3.1:8b</p>
38
+ <p><strong>Status:</strong> <span id="model-status-text">Checking...</span></p>
39
+ <div id="installed-section">
40
+ <p><strong>Installed Models:</strong></p>
41
+ <ul id="installed-models" class="model-list"><li>Loading...</li></ul>
42
+ </div>
43
+ <a href="/chat" class="btn btn-primary" style="width:100%;margin-top:12px;text-align:center">Open Chat</a>
 
 
 
44
  </div>
45
 
46
  <div class="card full-width">
47
+ <h2>Quick Start</h2>
48
+ <p>Use the chat interface to interact with <strong>llama3.1:8b</strong>.</p>
49
+ <p>Generate API keys above to access the model programmatically via <code>POST /api/chat</code>.</p>
50
+ <p style="margin-top:8px;font-size:0.9rem;color:#666;">
51
+ Model status: <span id="status-sub">checking...</span>
52
+ </p>
53
  </div>
54
  </div>
55
  </div>
 
69
  return res;
70
  }
71
 
72
+ function logout() { localStorage.removeItem('token'); window.location.href = '/login'; }
 
 
 
73
 
74
  // Load profile
75
  api('/api/me').then(r => r.json()).then(data => {
 
80
  `;
81
  });
82
 
83
+ // Check status
84
+ async function loadStatus() {
85
+ const res = await fetch('/api/status', {headers: {'Authorization': `Bearer ${token}`}});
86
+ const data = await res.json();
87
  const badge = document.getElementById('ollama-badge');
88
+ const statusText = document.getElementById('model-status-text');
89
+ const subText = document.getElementById('status-sub');
90
+
91
+ if (data.model) {
92
  badge.className = 'status status-completed';
93
+ badge.textContent = 'Ready';
94
+ statusText.textContent = 'Ready';
95
+ subText.textContent = 'llama3.1:8b is ready';
96
+ } else if (data.ollama) {
97
+ badge.className = 'status status-downloading';
98
+ badge.textContent = 'Downloading...';
99
+ statusText.textContent = 'Downloading model...';
100
+ subText.textContent = data.message || 'Model is being downloaded';
101
+ setTimeout(loadStatus, 10000);
102
  } else {
103
  badge.className = 'status status-error';
104
+ badge.textContent = 'Unavailable';
105
+ statusText.textContent = 'Unavailable';
106
+ subText.textContent = data.message || 'Ollama not reachable';
107
  }
 
108
 
109
+ // Installed models
 
110
  const ul = document.getElementById('installed-models');
111
+ if (data.installed && data.installed.length > 0) {
112
+ ul.innerHTML = data.installed.map(m => `<li>${m}</li>`).join('');
113
  } else {
114
+ ul.innerHTML = '<li>None yet</li>';
115
  }
116
+ }
117
+ loadStatus();
 
 
 
 
 
 
 
 
 
 
118
 
119
  // Load API keys
120
  async function loadApiKeys() {
 
144
  loadApiKeys();
145
  }
146
  }
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
147
  </script>
148
  </body>
149
  </html>
templates/index.html CHANGED
@@ -3,40 +3,33 @@
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Ollama Model Manager</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
  </head>
9
  <body>
10
  <div class="container">
11
  <header>
12
- <h1>Ollama Model Manager</h1>
13
- <p>Manage Ollama model downloads for 16GB systems</p>
14
  </header>
15
  <div class="hero">
16
  <div class="card">
17
  <h2>Get Started</h2>
18
- <p>Register to generate API keys and track model download progress.</p>
19
  <div class="buttons">
20
  <a href="/login" class="btn btn-primary">Login / Register</a>
 
21
  </div>
22
  </div>
23
  <div class="card">
24
- <h2>Recommended Models</h2>
25
- <ul id="model-list" class="model-list"></ul>
 
 
 
 
26
  </div>
27
  </div>
28
  </div>
29
- <script>
30
- fetch('/api/models')
31
- .then(r => r.json())
32
- .then(data => {
33
- const ul = document.getElementById('model-list');
34
- data.models.filter(m => !m.name.includes('70b')).forEach(m => {
35
- const li = document.createElement('li');
36
- li.innerHTML = `<strong>${m.name}</strong> (${m.size}, ${m.ram} RAM) - ${m.description}`;
37
- ul.appendChild(li);
38
- });
39
- });
40
- </script>
41
  </body>
42
  </html>
 
3
  <head>
4
  <meta charset="UTF-8">
5
  <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
+ <title>FreeAPI - Llama 3.1</title>
7
  <link rel="stylesheet" href="/static/style.css">
8
  </head>
9
  <body>
10
  <div class="container">
11
  <header>
12
+ <h1>FreeAPI</h1>
13
+ <p>Llama 3.1 8B API & Chat</p>
14
  </header>
15
  <div class="hero">
16
  <div class="card">
17
  <h2>Get Started</h2>
18
+ <p>Register to get API keys and chat with Llama 3.1 8B.</p>
19
  <div class="buttons">
20
  <a href="/login" class="btn btn-primary">Login / Register</a>
21
+ <a href="/chat" class="btn btn-secondary">Try Chat</a>
22
  </div>
23
  </div>
24
  <div class="card">
25
+ <h2>API Access</h2>
26
+ <p><code>POST /api/chat</code> with your API key</p>
27
+ <p>Model: <strong>llama3.1:8b</strong></p>
28
+ <p style="font-size:0.85rem;color:#666;margin-top:8px">
29
+ Register → Generate API key → Chat via UI or API
30
+ </p>
31
  </div>
32
  </div>
33
  </div>
 
 
 
 
 
 
 
 
 
 
 
 
34
  </body>
35
  </html>
templates/progress.html DELETED
@@ -1,94 +0,0 @@
1
- <!DOCTYPE html>
2
- <html lang="en">
3
- <head>
4
- <meta charset="UTF-8">
5
- <meta name="viewport" content="width=device-width, initial-scale=1.0">
6
- <title>Download Progress - Ollama Manager</title>
7
- <link rel="stylesheet" href="/static/style.css">
8
- </head>
9
- <body>
10
- <div class="container">
11
- <header>
12
- <h1>Download Progress</h1>
13
- <a href="/dashboard" class="btn btn-secondary">Back to Dashboard</a>
14
- </header>
15
-
16
- <div class="progress-container">
17
- <div class="card">
18
- <h2 id="model-name">Model: <span id="model-label"></span></h2>
19
- <div class="progress-bar-wrapper">
20
- <div class="progress-bar" id="progress-bar"></div>
21
- </div>
22
- <p id="progress-percent" class="progress-text">0%</p>
23
- <div class="progress-details">
24
- <p><strong>Status:</strong> <span id="status-text">Pending</span></p>
25
- <p><strong>Step:</strong> <span id="step-text">Waiting...</span></p>
26
- <p><strong>Downloaded:</strong> <span id="downloaded-text">0 MB</span> / <span id="total-text">0 MB</span></p>
27
- <p><strong>Speed:</strong> <span id="speed-text">0</span> KB/s</p>
28
- <p><strong>ETA:</strong> <span id="eta-text">--</span></p>
29
- </div>
30
- </div>
31
- </div>
32
- </div>
33
-
34
- <script>
35
- const sessionId = '{{ session_id }}';
36
- const token = localStorage.getItem('token');
37
- if (!token) window.location.href = '/login';
38
-
39
- // Load session info
40
- fetch(`/api/download-sessions/${sessionId}`, {
41
- headers: {'Authorization': `Bearer ${token}`}
42
- }).then(r => r.json()).then(data => {
43
- document.getElementById('model-label').textContent = data.model_name || 'Unknown';
44
- });
45
-
46
- // WebSocket
47
- const protocol = window.location.protocol === 'https:' ? 'wss:' : 'ws:';
48
- const ws = new WebSocket(`${protocol}//${window.location.host}/ws/${sessionId}`);
49
-
50
- ws.onmessage = function(event) {
51
- const data = JSON.parse(event.data);
52
-
53
- const progress = data.progress || 0;
54
- document.getElementById('progress-bar').style.width = progress + '%';
55
- document.getElementById('progress-percent').textContent = progress + '%';
56
-
57
- if (data.status) document.getElementById('status-text').textContent = data.status;
58
- if (data.current_step) document.getElementById('step-text').textContent = data.current_step;
59
- if (data.downloaded_mb !== undefined) document.getElementById('downloaded-text').textContent = data.downloaded_mb + ' MB';
60
- if (data.total_size_mb !== undefined) document.getElementById('total-text').textContent = data.total_size_mb + ' MB';
61
- if (data.speed_kbps !== undefined) document.getElementById('speed-text').textContent = data.speed_kbps;
62
- if (data.eta_seconds !== undefined) {
63
- const eta = data.eta_seconds;
64
- if (eta <= 0) document.getElementById('eta-text').textContent = 'Done';
65
- else if (eta < 60) document.getElementById('eta-text').textContent = eta + 's';
66
- else document.getElementById('eta-text').textContent = Math.floor(eta / 60) + 'm ' + (eta % 60) + 's';
67
- }
68
-
69
- if (data.status === 'completed') {
70
- document.getElementById('status-text').textContent = 'Completed';
71
- document.getElementById('step-text').textContent = 'Done';
72
- document.getElementById('progress-bar').style.width = '100%';
73
- document.getElementById('progress-percent').textContent = '100%';
74
- }
75
-
76
- if (data.status === 'error') {
77
- document.getElementById('status-text').textContent = 'Error';
78
- document.getElementById('progress-bar').style.background = 'linear-gradient(90deg, #e63946, #c1121f)';
79
- }
80
- };
81
-
82
- ws.onerror = function() {
83
- document.getElementById('status-text').textContent = 'Connecting...';
84
- };
85
-
86
- ws.onclose = function() {
87
- if (document.getElementById('status-text').textContent !== 'Completed' &&
88
- document.getElementById('status-text').textContent !== 'Error') {
89
- document.getElementById('status-text').textContent = 'Disconnected';
90
- }
91
- };
92
- </script>
93
- </body>
94
- </html>