Spaces:
Runtime error
Runtime error
Commit ·
851f234
1
Parent(s): a4d07db
Output rendering, documentation, model readiness UI
Browse files- backend/app.py +49 -1
- frontend/src/App.css +140 -4
- frontend/src/App.jsx +66 -3
- frontend/src/components/InlineResult.jsx +6 -8
- frontend/src/hooks/useStreaming.js +9 -1
- init/README.md +0 -0
- {src → init}/__init__.py +1 -1
- {src → init}/config.py +1 -1
- {src → init}/model.py +0 -0
- {src → init}/tuning/__init__.py +0 -0
- {src → init}/tuning/data.py +0 -0
- {src → init}/tuning/lora.py +0 -0
- {src → init}/tuning/trainer.py +0 -0
- scripts/README.md +15 -0
- scripts/clean.py +2 -1
- scripts/evaluate.py +1 -1
backend/app.py
CHANGED
|
@@ -1,5 +1,7 @@
|
|
| 1 |
from typing import Optional
|
| 2 |
|
|
|
|
|
|
|
| 3 |
import httpx
|
| 4 |
from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Request
|
| 5 |
from fastapi.middleware.cors import CORSMiddleware
|
|
@@ -32,7 +34,6 @@ app.add_middleware(
|
|
| 32 |
)
|
| 33 |
|
| 34 |
# Only mount frontend in production when dist/ exists
|
| 35 |
-
import os
|
| 36 |
if os.path.isdir("frontend/dist"):
|
| 37 |
app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
|
| 38 |
|
|
@@ -101,6 +102,53 @@ async def list_models():
|
|
| 101 |
return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
|
| 102 |
|
| 103 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 104 |
@app.post("/summarize/transcript")
|
| 105 |
async def summarize_transcript(
|
| 106 |
request: TranscriptRequest,
|
|
|
|
| 1 |
from typing import Optional
|
| 2 |
|
| 3 |
+
import os
|
| 4 |
+
|
| 5 |
import httpx
|
| 6 |
from fastapi import FastAPI, HTTPException, UploadFile, File, Header, Request
|
| 7 |
from fastapi.middleware.cors import CORSMiddleware
|
|
|
|
| 34 |
)
|
| 35 |
|
| 36 |
# Only mount frontend in production when dist/ exists
|
|
|
|
| 37 |
if os.path.isdir("frontend/dist"):
|
| 38 |
app.mount("/", StaticFiles(directory="frontend/dist", html=True), name="static")
|
| 39 |
|
|
|
|
| 102 |
return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
|
| 103 |
|
| 104 |
|
| 105 |
+
@app.post("/warmup")
|
| 106 |
+
async def warmup_model(model: Optional[str] = None):
|
| 107 |
+
"""Load a model into Ollama VRAM.
|
| 108 |
+
|
| 109 |
+
Per the Ollama API docs: sending a generate request with an empty
|
| 110 |
+
prompt (no 'prompt' key at all) causes Ollama to load the model into
|
| 111 |
+
memory and return once it is ready. stream=False means this endpoint
|
| 112 |
+
only returns *after* the model is fully loaded — the frontend can await
|
| 113 |
+
it to know exactly when the model is warm.
|
| 114 |
+
"""
|
| 115 |
+
keep_alive = os.getenv("OLLAMA_KEEP_ALIVE", "30m")
|
| 116 |
+
target = model or DEFAULT_MODEL
|
| 117 |
+
try:
|
| 118 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 119 |
+
await client.post(
|
| 120 |
+
f"{OLLAMA_BASE_URL}/api/generate",
|
| 121 |
+
json={"model": target, "keep_alive": keep_alive, "stream": False},
|
| 122 |
+
)
|
| 123 |
+
except Exception:
|
| 124 |
+
pass # Non-fatal
|
| 125 |
+
return {"model": target, "status": "warmed"}
|
| 126 |
+
|
| 127 |
+
|
| 128 |
+
@app.get("/warmup/status")
|
| 129 |
+
async def warmup_status(model: Optional[str] = None):
|
| 130 |
+
"""Check whether a model is currently loaded in Ollama's memory.
|
| 131 |
+
|
| 132 |
+
Hits GET /api/ps (the equivalent of `ollama ps`) and returns
|
| 133 |
+
{"loaded": true/false, "model": name}.
|
| 134 |
+
"""
|
| 135 |
+
target = model or DEFAULT_MODEL
|
| 136 |
+
try:
|
| 137 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 138 |
+
r = await client.get(f"{OLLAMA_BASE_URL}/api/ps")
|
| 139 |
+
r.raise_for_status()
|
| 140 |
+
payload = r.json() if r.content else {}
|
| 141 |
+
running = [m.get("name", "") for m in payload.get("models", [])]
|
| 142 |
+
# Match on exact name or prefix (e.g. "phi4-mini" matches "phi4-mini:latest")
|
| 143 |
+
loaded = any(
|
| 144 |
+
name == target or name.startswith(target + ":")
|
| 145 |
+
for name in running
|
| 146 |
+
)
|
| 147 |
+
return {"model": target, "loaded": loaded}
|
| 148 |
+
except Exception:
|
| 149 |
+
return {"model": target, "loaded": False}
|
| 150 |
+
|
| 151 |
+
|
| 152 |
@app.post("/summarize/transcript")
|
| 153 |
async def summarize_transcript(
|
| 154 |
request: TranscriptRequest,
|
frontend/src/App.css
CHANGED
|
@@ -70,6 +70,19 @@
|
|
| 70 |
cursor: not-allowed;
|
| 71 |
}
|
| 72 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 73 |
/* Main content */
|
| 74 |
.main {
|
| 75 |
flex: 1;
|
|
@@ -220,12 +233,35 @@
|
|
| 220 |
/* Submit section */
|
| 221 |
.submit-section {
|
| 222 |
display: flex;
|
|
|
|
| 223 |
justify-content: flex-end;
|
|
|
|
| 224 |
padding-top: var(--spacing-4);
|
| 225 |
border-top: 1px solid var(--color-border-muted);
|
| 226 |
margin-top: var(--spacing-4);
|
| 227 |
}
|
| 228 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 229 |
/* Response section */
|
| 230 |
.response-section {
|
| 231 |
margin-top: var(--spacing-5);
|
|
@@ -374,29 +410,129 @@
|
|
| 374 |
width: 100%;
|
| 375 |
}
|
| 376 |
|
|
|
|
| 377 |
.inline-result__text {
|
| 378 |
font-size: 14px;
|
| 379 |
line-height: 1.7;
|
| 380 |
color: var(--color-fg-default);
|
| 381 |
margin: 0;
|
|
|
|
| 382 |
}
|
| 383 |
|
| 384 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 385 |
font-weight: 600;
|
|
|
|
|
|
|
| 386 |
color: var(--color-fg-default);
|
| 387 |
}
|
| 388 |
-
|
| 389 |
-
.inline-result__text
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 390 |
font-style: italic;
|
| 391 |
}
|
| 392 |
|
|
|
|
| 393 |
.inline-result__text code {
|
| 394 |
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
| 395 |
font-size: 12px;
|
| 396 |
background-color: var(--color-canvas-inset);
|
| 397 |
border: 1px solid var(--color-border-muted);
|
| 398 |
border-radius: 3px;
|
| 399 |
-
padding: 0
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 400 |
}
|
| 401 |
|
| 402 |
/* Streaming cursor blink */
|
|
|
|
| 70 |
cursor: not-allowed;
|
| 71 |
}
|
| 72 |
|
| 73 |
+
/* Model warm-up border states */
|
| 74 |
+
.model-select--ready {
|
| 75 |
+
border-color: rgba(46, 160, 67, 0.7) !important;
|
| 76 |
+
box-shadow: 0 0 0 2px rgba(46, 160, 67, 0.15);
|
| 77 |
+
transition: border-color 0.4s, box-shadow 0.4s;
|
| 78 |
+
}
|
| 79 |
+
|
| 80 |
+
.model-select--warming {
|
| 81 |
+
border-color: rgba(234, 179, 8, 0.7) !important;
|
| 82 |
+
box-shadow: 0 0 0 2px rgba(234, 179, 8, 0.12);
|
| 83 |
+
transition: border-color 0.4s, box-shadow 0.4s;
|
| 84 |
+
}
|
| 85 |
+
|
| 86 |
/* Main content */
|
| 87 |
.main {
|
| 88 |
flex: 1;
|
|
|
|
| 233 |
/* Submit section */
|
| 234 |
.submit-section {
|
| 235 |
display: flex;
|
| 236 |
+
align-items: center;
|
| 237 |
justify-content: flex-end;
|
| 238 |
+
gap: var(--spacing-2);
|
| 239 |
padding-top: var(--spacing-4);
|
| 240 |
border-top: 1px solid var(--color-border-muted);
|
| 241 |
margin-top: var(--spacing-4);
|
| 242 |
}
|
| 243 |
|
| 244 |
+
/* Cancel / stop button */
|
| 245 |
+
.btn-cancel {
|
| 246 |
+
display: inline-flex;
|
| 247 |
+
align-items: center;
|
| 248 |
+
gap: 6px;
|
| 249 |
+
padding: 8px 16px;
|
| 250 |
+
font-size: 13px;
|
| 251 |
+
font-weight: 500;
|
| 252 |
+
border-radius: 6px;
|
| 253 |
+
border: 1px solid rgba(248, 81, 73, 0.4);
|
| 254 |
+
background-color: rgba(248, 81, 73, 0.08);
|
| 255 |
+
color: var(--color-danger-fg);
|
| 256 |
+
cursor: pointer;
|
| 257 |
+
transition: background-color 0.15s, border-color 0.15s;
|
| 258 |
+
}
|
| 259 |
+
|
| 260 |
+
.btn-cancel:hover {
|
| 261 |
+
background-color: rgba(248, 81, 73, 0.16);
|
| 262 |
+
border-color: rgba(248, 81, 73, 0.6);
|
| 263 |
+
}
|
| 264 |
+
|
| 265 |
/* Response section */
|
| 266 |
.response-section {
|
| 267 |
margin-top: var(--spacing-5);
|
|
|
|
| 410 |
width: 100%;
|
| 411 |
}
|
| 412 |
|
| 413 |
+
/* ── Prose styles for ReactMarkdown output ── */
|
| 414 |
.inline-result__text {
|
| 415 |
font-size: 14px;
|
| 416 |
line-height: 1.7;
|
| 417 |
color: var(--color-fg-default);
|
| 418 |
margin: 0;
|
| 419 |
+
width: 100%;
|
| 420 |
}
|
| 421 |
|
| 422 |
+
/* Paragraphs */
|
| 423 |
+
.inline-result__text p {
|
| 424 |
+
margin: 0 0 0.6em;
|
| 425 |
+
}
|
| 426 |
+
.inline-result__text p:last-child {
|
| 427 |
+
margin-bottom: 0;
|
| 428 |
+
}
|
| 429 |
+
|
| 430 |
+
/* Headings */
|
| 431 |
+
.inline-result__text h1,
|
| 432 |
+
.inline-result__text h2,
|
| 433 |
+
.inline-result__text h3,
|
| 434 |
+
.inline-result__text h4,
|
| 435 |
+
.inline-result__text h5,
|
| 436 |
+
.inline-result__text h6 {
|
| 437 |
font-weight: 600;
|
| 438 |
+
line-height: 1.3;
|
| 439 |
+
margin: 0.9em 0 0.35em;
|
| 440 |
color: var(--color-fg-default);
|
| 441 |
}
|
| 442 |
+
.inline-result__text h1 { font-size: 1.35em; }
|
| 443 |
+
.inline-result__text h2 { font-size: 1.2em; }
|
| 444 |
+
.inline-result__text h3 { font-size: 1.08em; }
|
| 445 |
+
.inline-result__text h4,
|
| 446 |
+
.inline-result__text h5,
|
| 447 |
+
.inline-result__text h6 { font-size: 1em; }
|
| 448 |
+
.inline-result__text h1:first-child,
|
| 449 |
+
.inline-result__text h2:first-child,
|
| 450 |
+
.inline-result__text h3:first-child { margin-top: 0; }
|
| 451 |
+
|
| 452 |
+
/* Lists — top-level */
|
| 453 |
+
.inline-result__text ul,
|
| 454 |
+
.inline-result__text ol {
|
| 455 |
+
margin: 0.4em 0 0.6em;
|
| 456 |
+
padding-left: 1.5em;
|
| 457 |
+
}
|
| 458 |
+
.inline-result__text ul { list-style-type: disc; }
|
| 459 |
+
.inline-result__text ol { list-style-type: decimal; }
|
| 460 |
+
|
| 461 |
+
/* Nested lists — level 2 */
|
| 462 |
+
.inline-result__text ul ul,
|
| 463 |
+
.inline-result__text ol ul { list-style-type: circle; }
|
| 464 |
+
.inline-result__text ul ol,
|
| 465 |
+
.inline-result__text ol ol { list-style-type: lower-alpha; }
|
| 466 |
+
.inline-result__text ul ul,
|
| 467 |
+
.inline-result__text ol ul,
|
| 468 |
+
.inline-result__text ul ol,
|
| 469 |
+
.inline-result__text ol ol {
|
| 470 |
+
margin: 0.15em 0;
|
| 471 |
+
padding-left: 1.4em;
|
| 472 |
+
}
|
| 473 |
+
|
| 474 |
+
/* Nested lists — level 3+ */
|
| 475 |
+
.inline-result__text ul ul ul,
|
| 476 |
+
.inline-result__text ol ul ul { list-style-type: square; }
|
| 477 |
+
.inline-result__text ul ul ul,
|
| 478 |
+
.inline-result__text ol ol ol,
|
| 479 |
+
.inline-result__text ul ol ul,
|
| 480 |
+
.inline-result__text ol ul ol {
|
| 481 |
+
margin: 0.1em 0;
|
| 482 |
+
padding-left: 1.3em;
|
| 483 |
+
}
|
| 484 |
+
|
| 485 |
+
.inline-result__text li {
|
| 486 |
+
margin: 0.2em 0;
|
| 487 |
+
}
|
| 488 |
+
.inline-result__text li > p { margin: 0; }
|
| 489 |
+
|
| 490 |
+
/* Inline emphasis */
|
| 491 |
+
.inline-result__text strong { font-weight: 600; }
|
| 492 |
+
.inline-result__text em { font-style: italic; }
|
| 493 |
+
|
| 494 |
+
/* Blockquote */
|
| 495 |
+
.inline-result__text blockquote {
|
| 496 |
+
margin: 0.6em 0;
|
| 497 |
+
padding: 0.3em 0 0.3em 0.9em;
|
| 498 |
+
border-left: 3px solid var(--color-border-default);
|
| 499 |
+
color: var(--color-fg-muted);
|
| 500 |
font-style: italic;
|
| 501 |
}
|
| 502 |
|
| 503 |
+
/* Inline code */
|
| 504 |
.inline-result__text code {
|
| 505 |
font-family: ui-monospace, 'SFMono-Regular', Consolas, monospace;
|
| 506 |
font-size: 12px;
|
| 507 |
background-color: var(--color-canvas-inset);
|
| 508 |
border: 1px solid var(--color-border-muted);
|
| 509 |
border-radius: 3px;
|
| 510 |
+
padding: 0.1em 0.35em;
|
| 511 |
+
}
|
| 512 |
+
|
| 513 |
+
/* Fenced code blocks */
|
| 514 |
+
.inline-result__text pre {
|
| 515 |
+
background-color: var(--color-canvas-inset);
|
| 516 |
+
border: 1px solid var(--color-border-muted);
|
| 517 |
+
border-radius: 6px;
|
| 518 |
+
padding: 0.75em 1em;
|
| 519 |
+
margin: 0.6em 0;
|
| 520 |
+
overflow-x: auto;
|
| 521 |
+
font-size: 12px;
|
| 522 |
+
line-height: 1.6;
|
| 523 |
+
}
|
| 524 |
+
.inline-result__text pre code {
|
| 525 |
+
background: none;
|
| 526 |
+
border: none;
|
| 527 |
+
padding: 0;
|
| 528 |
+
font-size: inherit;
|
| 529 |
+
}
|
| 530 |
+
|
| 531 |
+
/* Horizontal rule */
|
| 532 |
+
.inline-result__text hr {
|
| 533 |
+
border: none;
|
| 534 |
+
border-top: 1px solid var(--color-border-muted);
|
| 535 |
+
margin: 0.8em 0;
|
| 536 |
}
|
| 537 |
|
| 538 |
/* Streaming cursor blink */
|
frontend/src/App.jsx
CHANGED
|
@@ -12,9 +12,11 @@ function App() {
|
|
| 12 |
const [selectedFile, setSelectedFile] = useState(null)
|
| 13 |
const [models, setModels] = useState([])
|
| 14 |
const [selectedModel, setSelectedModel] = useState('')
|
|
|
|
| 15 |
const fileInputRef = useRef(null)
|
|
|
|
| 16 |
|
| 17 |
-
const { loading, response, error, streamingText, submit } = useStreaming()
|
| 18 |
|
| 19 |
useEffect(() => {
|
| 20 |
let cancelled = false
|
|
@@ -37,6 +39,58 @@ function App() {
|
|
| 37 |
return () => { cancelled = true }
|
| 38 |
}, [])
|
| 39 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
const handleSubmit = () =>
|
| 41 |
submit(activeTab, {
|
| 42 |
youtubeUrl,
|
|
@@ -74,13 +128,14 @@ function App() {
|
|
| 74 |
</a>
|
| 75 |
<div className="header-actions">
|
| 76 |
<select
|
| 77 |
-
className=
|
| 78 |
value={selectedModel}
|
| 79 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 80 |
disabled={loading || models.length === 0}
|
| 81 |
>
|
| 82 |
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
| 83 |
</select>
|
|
|
|
| 84 |
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
|
| 85 |
API Docs
|
| 86 |
</a>
|
|
@@ -132,7 +187,7 @@ function App() {
|
|
| 132 |
{activeTab === 'youtube' && (
|
| 133 |
<InlineResult
|
| 134 |
{...resultProps}
|
| 135 |
-
loadingLabel=
|
| 136 |
placeholderText="Fetching transcript…"
|
| 137 |
/>
|
| 138 |
)}
|
|
@@ -211,6 +266,14 @@ function App() {
|
|
| 211 |
</div>
|
| 212 |
|
| 213 |
<div className="submit-section">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 214 |
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
|
| 215 |
{loading ? (
|
| 216 |
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
|
|
|
|
| 12 |
const [selectedFile, setSelectedFile] = useState(null)
|
| 13 |
const [models, setModels] = useState([])
|
| 14 |
const [selectedModel, setSelectedModel] = useState('')
|
| 15 |
+
const [modelReady, setModelReady] = useState(null) // null=unknown, false=warming, true=ready
|
| 16 |
const fileInputRef = useRef(null)
|
| 17 |
+
const warmupAbortRef = useRef(null)
|
| 18 |
|
| 19 |
+
const { loading, response, error, streamingText, submit, cancel } = useStreaming()
|
| 20 |
|
| 21 |
useEffect(() => {
|
| 22 |
let cancelled = false
|
|
|
|
| 39 |
return () => { cancelled = true }
|
| 40 |
}, [])
|
| 41 |
|
| 42 |
+
// Warm up the selected model whenever the selection changes.
|
| 43 |
+
// 1. POST /warmup — tells Ollama to load the model; only resolves when done.
|
| 44 |
+
// 2. While waiting, poll GET /warmup/status every 2 s to update the indicator sooner.
|
| 45 |
+
useEffect(() => {
|
| 46 |
+
if (!selectedModel) return
|
| 47 |
+
|
| 48 |
+
// Cancel any in-flight warmup for the previous model
|
| 49 |
+
if (warmupAbortRef.current) warmupAbortRef.current.abort()
|
| 50 |
+
const controller = new AbortController()
|
| 51 |
+
warmupAbortRef.current = controller
|
| 52 |
+
|
| 53 |
+
setModelReady(false)
|
| 54 |
+
|
| 55 |
+
// Poll /warmup/status every 2 s until loaded or aborted
|
| 56 |
+
let pollTimer = null
|
| 57 |
+
const poll = async () => {
|
| 58 |
+
if (controller.signal.aborted) return
|
| 59 |
+
try {
|
| 60 |
+
const r = await fetch(
|
| 61 |
+
`${API_BASE}/warmup/status?model=${encodeURIComponent(selectedModel)}`,
|
| 62 |
+
{ signal: controller.signal },
|
| 63 |
+
)
|
| 64 |
+
if (r.ok) {
|
| 65 |
+
const data = await r.json()
|
| 66 |
+
if (data.loaded) {
|
| 67 |
+
setModelReady(true)
|
| 68 |
+
return
|
| 69 |
+
}
|
| 70 |
+
}
|
| 71 |
+
} catch { /* ignore */ }
|
| 72 |
+
if (!controller.signal.aborted) {
|
| 73 |
+
pollTimer = setTimeout(poll, 2000)
|
| 74 |
+
}
|
| 75 |
+
}
|
| 76 |
+
poll()
|
| 77 |
+
|
| 78 |
+
// POST /warmup — blocks until Ollama has the model in VRAM
|
| 79 |
+
fetch(`${API_BASE}/warmup`, {
|
| 80 |
+
method: 'POST',
|
| 81 |
+
headers: { 'Content-Type': 'application/json' },
|
| 82 |
+
body: JSON.stringify({ model: selectedModel }),
|
| 83 |
+
signal: controller.signal,
|
| 84 |
+
})
|
| 85 |
+
.then(() => { setModelReady(true) })
|
| 86 |
+
.catch(() => { /* warmup errors are non-fatal */ })
|
| 87 |
+
|
| 88 |
+
return () => {
|
| 89 |
+
controller.abort()
|
| 90 |
+
clearTimeout(pollTimer)
|
| 91 |
+
}
|
| 92 |
+
}, [selectedModel])
|
| 93 |
+
|
| 94 |
const handleSubmit = () =>
|
| 95 |
submit(activeTab, {
|
| 96 |
youtubeUrl,
|
|
|
|
| 128 |
</a>
|
| 129 |
<div className="header-actions">
|
| 130 |
<select
|
| 131 |
+
className={`model-select${modelReady === true ? ' model-select--ready' : modelReady === false ? ' model-select--warming' : ''}`}
|
| 132 |
value={selectedModel}
|
| 133 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 134 |
disabled={loading || models.length === 0}
|
| 135 |
>
|
| 136 |
{models.map((m) => <option key={m} value={m}>{m}</option>)}
|
| 137 |
</select>
|
| 138 |
+
|
| 139 |
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
|
| 140 |
API Docs
|
| 141 |
</a>
|
|
|
|
| 187 |
{activeTab === 'youtube' && (
|
| 188 |
<InlineResult
|
| 189 |
{...resultProps}
|
| 190 |
+
loadingLabel={streamingText ? 'Generating…' : 'Fetching transcript…'}
|
| 191 |
placeholderText="Fetching transcript…"
|
| 192 |
/>
|
| 193 |
)}
|
|
|
|
| 266 |
</div>
|
| 267 |
|
| 268 |
<div className="submit-section">
|
| 269 |
+
{loading && (
|
| 270 |
+
<button className="btn btn-cancel" onClick={cancel}>
|
| 271 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5">
|
| 272 |
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
| 273 |
+
</svg>
|
| 274 |
+
Cancel
|
| 275 |
+
</button>
|
| 276 |
+
)}
|
| 277 |
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
|
| 278 |
{loading ? (
|
| 279 |
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
|
frontend/src/components/InlineResult.jsx
CHANGED
|
@@ -18,14 +18,16 @@ export default function InlineResult({ error, loading, response, streamingText,
|
|
| 18 |
{streamingText ? 'Generating…' : (loadingLabel || 'Processing…')}
|
| 19 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
|
| 20 |
</div>
|
| 21 |
-
<
|
| 22 |
-
{streamingText
|
|
|
|
|
|
|
| 23 |
<span className="streaming-placeholder">
|
| 24 |
{placeholderText || loadingLabel || 'Waiting for model…'}
|
| 25 |
</span>
|
| 26 |
)}
|
| 27 |
<span className="streaming-cursor">▌</span>
|
| 28 |
-
</
|
| 29 |
</div>
|
| 30 |
)}
|
| 31 |
{response && !loading && (
|
|
@@ -38,11 +40,7 @@ export default function InlineResult({ error, loading, response, streamingText,
|
|
| 38 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
|
| 39 |
</div>
|
| 40 |
<div className="inline-result__text">
|
| 41 |
-
<ReactMarkdown
|
| 42 |
-
components={{ p: ({ children }) => <span>{children}</span> }}
|
| 43 |
-
>
|
| 44 |
-
{response.summary}
|
| 45 |
-
</ReactMarkdown>
|
| 46 |
</div>
|
| 47 |
</div>
|
| 48 |
)}
|
|
|
|
| 18 |
{streamingText ? 'Generating…' : (loadingLabel || 'Processing…')}
|
| 19 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
|
| 20 |
</div>
|
| 21 |
+
<div className="inline-result__text inline-result__text--streaming">
|
| 22 |
+
{streamingText ? (
|
| 23 |
+
<ReactMarkdown>{streamingText}</ReactMarkdown>
|
| 24 |
+
) : (
|
| 25 |
<span className="streaming-placeholder">
|
| 26 |
{placeholderText || loadingLabel || 'Waiting for model…'}
|
| 27 |
</span>
|
| 28 |
)}
|
| 29 |
<span className="streaming-cursor">▌</span>
|
| 30 |
+
</div>
|
| 31 |
</div>
|
| 32 |
)}
|
| 33 |
{response && !loading && (
|
|
|
|
| 40 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
|
| 41 |
</div>
|
| 42 |
<div className="inline-result__text">
|
| 43 |
+
<ReactMarkdown>{response.summary}</ReactMarkdown>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 44 |
</div>
|
| 45 |
</div>
|
| 46 |
)}
|
frontend/src/hooks/useStreaming.js
CHANGED
|
@@ -103,11 +103,19 @@ export function useStreaming() {
|
|
| 103 |
|
| 104 |
setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
|
| 105 |
} catch (err) {
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
setError(err.message || 'An error occurred')
|
| 107 |
} finally {
|
| 108 |
setLoading(false)
|
| 109 |
}
|
| 110 |
}
|
| 111 |
|
| 112 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 113 |
}
|
|
|
|
| 103 |
|
| 104 |
setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
|
| 105 |
} catch (err) {
|
| 106 |
+
if (err.name === 'AbortError') {
|
| 107 |
+
// User cancelled — clear loading silently, keep any partial text
|
| 108 |
+
return
|
| 109 |
+
}
|
| 110 |
setError(err.message || 'An error occurred')
|
| 111 |
} finally {
|
| 112 |
setLoading(false)
|
| 113 |
}
|
| 114 |
}
|
| 115 |
|
| 116 |
+
const cancel = () => {
|
| 117 |
+
abortRef.current?.abort()
|
| 118 |
+
}
|
| 119 |
+
|
| 120 |
+
return { loading, response, error, streamingText, submit, cancel }
|
| 121 |
}
|
init/README.md
ADDED
|
File without changes
|
{src → init}/__init__.py
RENAMED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
"""
|
| 2 |
-
|
| 3 |
"""
|
| 4 |
|
| 5 |
from src.config import ModelConfig, TrainingConfig, DataConfig
|
|
|
|
| 1 |
"""
|
| 2 |
+
Model loading, configuration, and fine-tuning utilities.
|
| 3 |
"""
|
| 4 |
|
| 5 |
from src.config import ModelConfig, TrainingConfig, DataConfig
|
{src → init}/config.py
RENAMED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
"""Configuration management
|
| 2 |
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Optional, List
|
|
|
|
| 1 |
+
"""Configuration management."""
|
| 2 |
|
| 3 |
from dataclasses import dataclass, field
|
| 4 |
from typing import Optional, List
|
{src → init}/model.py
RENAMED
|
File without changes
|
{src → init}/tuning/__init__.py
RENAMED
|
File without changes
|
{src → init}/tuning/data.py
RENAMED
|
File without changes
|
{src → init}/tuning/lora.py
RENAMED
|
File without changes
|
{src → init}/tuning/trainer.py
RENAMED
|
File without changes
|
scripts/README.md
ADDED
|
@@ -0,0 +1,15 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Scripts
|
| 2 |
+
|
| 3 |
+
These are scripts that are run separetly from the app. They are used to collect data and fine-tune the model(s) being run for the actual app's functionality.
|
| 4 |
+
|
| 5 |
+
## Pull.py
|
| 6 |
+
|
| 7 |
+
Pull.py pulls (downloads) different datasets that are known to be exemplary fine-tuners for the task of summarization in the way we're trying to perform it. It'll place the data in the data directory.
|
| 8 |
+
|
| 9 |
+
## Cleaners
|
| 10 |
+
|
| 11 |
+
Cleaners are scripts that are used to clean up the data that is collected. They are used to remove any irrelevant information from the data. The data is then modified by hand to ensure even better performance.
|
| 12 |
+
|
| 13 |
+
## Train.py
|
| 14 |
+
|
| 15 |
+
Train.py simply fine-tunes the model with the cleaned data, and then places the model in the trained_models directory.
|
scripts/clean.py
CHANGED
|
@@ -1,5 +1,6 @@
|
|
| 1 |
"""
|
| 2 |
-
Clean up the raw data files so as to curate specifically-required
|
|
|
|
| 3 |
"""
|
| 4 |
|
| 5 |
import subprocess
|
|
|
|
| 1 |
"""
|
| 2 |
+
Clean up the raw data files so as to curate specifically-required
|
| 3 |
+
* Imports cleaners from the scripts/ folder
|
| 4 |
"""
|
| 5 |
|
| 6 |
import subprocess
|
scripts/evaluate.py
CHANGED
|
@@ -1,5 +1,5 @@
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
-
"""CLI evaluation script for
|
| 3 |
|
| 4 |
import argparse
|
| 5 |
import logging
|
|
|
|
| 1 |
#!/usr/bin/env python3
|
| 2 |
+
"""CLI evaluation script for the models"""
|
| 3 |
|
| 4 |
import argparse
|
| 5 |
import logging
|