xtom0 commited on
Commit
07b94cd
·
1 Parent(s): f72291c

Replace simulated download with real ollama pull subprocess, add Ollama status check, installed models endpoint, WS wss support

Browse files
Files changed (4) hide show
  1. Dockerfile +7 -1
  2. app.py +147 -22
  3. templates/dashboard.html +45 -2
  4. templates/progress.html +20 -5
Dockerfile CHANGED
@@ -1,5 +1,9 @@
1
  FROM python:3.11-slim
2
 
 
 
 
 
3
  WORKDIR /app
4
 
5
  COPY requirements.txt .
@@ -9,4 +13,6 @@ COPY . .
9
 
10
  EXPOSE 7860
11
 
12
- CMD ["uvicorn", "app:app", "--host", "0.0.0.0", "--port", "7860"]
 
 
 
1
  FROM python:3.11-slim
2
 
3
+ RUN apt-get update && apt-get install -y curl && \
4
+ curl -fsSL https://ollama.com/install.sh | sh && \
5
+ rm -rf /var/lib/apt/lists/*
6
+
7
  WORKDIR /app
8
 
9
  COPY requirements.txt .
 
13
 
14
  EXPOSE 7860
15
 
16
+ CMD ollama serve > /tmp/ollama.log 2>&1 & \
17
+ sleep 3 && \
18
+ uvicorn app:app --host 0.0.0.0 --port 7860
app.py CHANGED
@@ -1,6 +1,8 @@
1
  import json
2
  import asyncio
3
  import uuid
 
 
4
  from datetime import datetime, timedelta
5
  from typing import Optional
6
 
@@ -151,6 +153,24 @@ async def me(user: dict = Depends(get_current_user)):
151
  async def get_models():
152
  return {"models": settings.recommended_models}
153
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
154
 
155
  # --- API Key Management ---
156
 
@@ -171,11 +191,32 @@ async def delete_api_key(key_id: int, user: dict = Depends(get_current_user)):
171
 
172
  # --- Download Sessions ---
173
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
174
  @app.post("/api/download-sessions")
175
  async def start_download_session(req: CreateSessionRequest, user: dict = Depends(get_current_user)):
 
 
 
176
  session_id = str(uuid.uuid4())
177
  create_download_session(user["id"], session_id, req.model_name)
178
- asyncio.create_task(simulate_download(session_id, req.model_name))
179
  return {"session_id": session_id, "model_name": req.model_name, "status": "started"}
180
 
181
  @app.get("/api/download-sessions")
@@ -204,28 +245,112 @@ async def websocket_endpoint(websocket: WebSocket, session_id: str):
204
  manager.disconnect(session_id, websocket)
205
 
206
 
207
- # --- Simulated Download (mocked until Ollama works) ---
208
-
209
- async def simulate_download(session_id: str, model_name: str):
210
- total_mb = 4500
211
- downloaded = 0
212
- speed = 5000
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
213
 
214
- update_download_progress(session_id, status="downloading", progress=0, total_size_mb=total_mb, downloaded_mb=0, speed_kbps=speed, current_step="Connecting...", eta_seconds=total_mb * 8 // speed)
215
- await manager.broadcast(session_id, {"status": "downloading", "progress": 0, "current_step": "Connecting...", "speed_kbps": speed, "eta_seconds": total_mb * 8 // speed})
216
-
217
- await asyncio.sleep(2)
218
-
219
- for i in range(1, 101):
220
- await asyncio.sleep(0.5)
221
- downloaded = int(total_mb * i / 100)
222
- eta = int((total_mb - downloaded) * 8 / speed)
223
- step = "Downloading manifest" if i < 10 else ("Downloading layers" if i < 90 else "Verifying")
224
- update_download_progress(session_id, progress=i, downloaded_mb=downloaded, current_step=step, speed_kbps=speed, eta_seconds=eta)
225
- await manager.broadcast(session_id, {"status": "downloading", "progress": i, "downloaded_mb": downloaded, "total_size_mb": total_mb, "current_step": step, "speed_kbps": speed, "eta_seconds": eta})
226
-
227
- update_download_progress(session_id, status="completed", progress=100, downloaded_mb=total_mb, current_step="Done", speed_kbps=0, eta_seconds=0, completed_at=datetime.utcnow().isoformat())
228
- await manager.broadcast(session_id, {"status": "completed", "progress": 100, "current_step": "Done"})
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
229
 
230
 
231
  # --- Startup ---
 
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
 
 
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 ---
176
 
 
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")
 
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 ---
templates/dashboard.html CHANGED
@@ -10,9 +10,18 @@
10
  <div class="container">
11
  <header>
12
  <h1>Dashboard</h1>
13
- <button class="btn btn-secondary" onclick="logout()">Logout</button>
 
 
 
14
  </header>
15
 
 
 
 
 
 
 
16
  <div class="dashboard-grid">
17
  <div class="card">
18
  <h2>Your Profile</h2>
@@ -29,11 +38,17 @@
29
  </div>
30
 
31
  <div class="card">
 
 
 
 
 
32
  <h2>Start Download</h2>
33
  <form onsubmit="startDownload(event)">
34
  <select id="model-select" required></select>
35
  <button type="submit" class="btn btn-primary">Download</button>
36
  </form>
 
37
  </div>
38
 
39
  <div class="card full-width">
@@ -72,7 +87,31 @@
72
  `;
73
  });
74
 
75
- // Load models
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
76
  fetch('/api/models').then(r => r.json()).then(data => {
77
  const select = document.getElementById('model-select');
78
  data.models.filter(m => !m.name.includes('70b')).forEach(m => {
@@ -118,6 +157,10 @@
118
  const model = document.getElementById('model-select').value;
119
  const res = await api('/api/download-sessions', {method: 'POST', body: {model_name: model}});
120
  const data = await res.json();
 
 
 
 
121
  window.location.href = `/progress/${data.session_id}`;
122
  }
123
 
 
10
  <div class="container">
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
  </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">
 
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 => {
 
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
 
templates/progress.html CHANGED
@@ -44,7 +44,8 @@
44
  });
45
 
46
  // WebSocket
47
- const ws = new WebSocket(`ws://${window.location.host}/ws/${sessionId}`);
 
48
 
49
  ws.onmessage = function(event) {
50
  const data = JSON.parse(event.data);
@@ -55,9 +56,9 @@
55
 
56
  if (data.status) document.getElementById('status-text').textContent = data.status;
57
  if (data.current_step) document.getElementById('step-text').textContent = data.current_step;
58
- if (data.downloaded_mb) document.getElementById('downloaded-text').textContent = data.downloaded_mb + ' MB';
59
- if (data.total_size_mb) document.getElementById('total-text').textContent = data.total_size_mb + ' MB';
60
- if (data.speed_kbps) document.getElementById('speed-text').textContent = data.speed_kbps;
61
  if (data.eta_seconds !== undefined) {
62
  const eta = data.eta_seconds;
63
  if (eta <= 0) document.getElementById('eta-text').textContent = 'Done';
@@ -68,11 +69,25 @@
68
  if (data.status === 'completed') {
69
  document.getElementById('status-text').textContent = 'Completed';
70
  document.getElementById('step-text').textContent = 'Done';
 
 
 
 
 
 
 
71
  }
72
  };
73
 
74
  ws.onerror = function() {
75
- document.getElementById('status-text').textContent = 'Connection lost';
 
 
 
 
 
 
 
76
  };
77
  </script>
78
  </body>
 
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);
 
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';
 
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>