Spaces:
Build error
Build error
Commit ·
2f317f9
1
Parent(s): d30214c
Such slow progress...
Browse files- .env.example +12 -3
- .github/workflows/hf.yml +1 -1
- .gitignore +8 -0
- README.md +8 -8
- backend/app.py +22 -0
- backend/config.py +9 -5
- backend/ollama.py +83 -15
- frontend/src/App.jsx +54 -9
- frontend/src/components/InlineResult.jsx +6 -2
- frontend/src/config.js +15 -18
- frontend/src/hooks/useStreaming.js +15 -1
- frontend/vite.config.js +1 -1
- requirements.txt +7 -5
.env.example
CHANGED
|
@@ -1,10 +1,19 @@
|
|
|
|
|
|
|
|
| 1 |
API_BASE_URL=http://localhost:8000 # Temporary btw
|
| 2 |
OLLAMA_BASE_URL=http://127.0.0.1:11434 # Ollama's default
|
| 3 |
PRECIS_ALLOWED_ORIGINS=http://localhost:5173 # Just front end, might make it more/less strict but I just need something consistent rn
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 4 |
PRECIS_API_KEY=replace-with-a-long-random-secret # Once the API is actually up, this'll be needed
|
| 5 |
-
PRECIS_DEFAULT_MODEL=phi4-mini:latest
|
| 6 |
-
PRECIS_AVAILABLE_MODELS=phi4-mini:latest,qwen:4b # Only here so both front and backend have it
|
| 7 |
MAX_SUMMARY_TOKENS=120
|
| 8 |
-
TEMPERATURE=0.2 #
|
| 9 |
PRECIS_MAX_UPLOAD_BYTES=10485760
|
| 10 |
PRECIS_MAX_TRANSCRIPT_CHARS=250000
|
|
|
|
| 1 |
+
|
| 2 |
+
# Frontend/backend stuff
|
| 3 |
API_BASE_URL=http://localhost:8000 # Temporary btw
|
| 4 |
OLLAMA_BASE_URL=http://127.0.0.1:11434 # Ollama's default
|
| 5 |
PRECIS_ALLOWED_ORIGINS=http://localhost:5173 # Just front end, might make it more/less strict but I just need something consistent rn
|
| 6 |
+
|
| 7 |
+
# Model addresses
|
| 8 |
+
DEFAULT_MODEL=phi4-mini:latest
|
| 9 |
+
|
| 10 |
+
# Update this if you add/remove models
|
| 11 |
+
# Or if you custom-name any of them
|
| 12 |
+
AVAILABLE_MODELS=phi4-mini:latest,qwen3:4b # Only here so both front and backend have it
|
| 13 |
+
|
| 14 |
+
# API stuff
|
| 15 |
PRECIS_API_KEY=replace-with-a-long-random-secret # Once the API is actually up, this'll be needed
|
|
|
|
|
|
|
| 16 |
MAX_SUMMARY_TOKENS=120
|
| 17 |
+
TEMPERATURE=0.2 # Randomly selected, will probably tweak later
|
| 18 |
PRECIS_MAX_UPLOAD_BYTES=10485760
|
| 19 |
PRECIS_MAX_TRANSCRIPT_CHARS=250000
|
.github/workflows/hf.yml
CHANGED
|
@@ -12,4 +12,4 @@ jobs:
|
|
| 12 |
- name: Push to hub
|
| 13 |
env:
|
| 14 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 15 |
-
run: git push
|
|
|
|
| 12 |
- name: Push to hub
|
| 13 |
env:
|
| 14 |
HF_TOKEN: ${{ secrets.HF_TOKEN }}
|
| 15 |
+
run: git push https://compendious:${HF_TOKEN}@huggingface.co/spaces/compendious/precis main
|
.gitignore
CHANGED
|
@@ -3,10 +3,18 @@
|
|
| 3 |
**cache**
|
| 4 |
*.ipynb
|
| 5 |
*.venv
|
|
|
|
|
|
|
|
|
|
| 6 |
.env
|
| 7 |
.env.*
|
| 8 |
!.env.example
|
| 9 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 10 |
# Front end
|
| 11 |
node_modules
|
| 12 |
package-lock.json
|
|
|
|
| 3 |
**cache**
|
| 4 |
*.ipynb
|
| 5 |
*.venv
|
| 6 |
+
temp.py
|
| 7 |
+
|
| 8 |
+
# Secrets
|
| 9 |
.env
|
| 10 |
.env.*
|
| 11 |
!.env.example
|
| 12 |
|
| 13 |
+
# Leftovers
|
| 14 |
+
*.csv
|
| 15 |
+
*.json*
|
| 16 |
+
/*data*/
|
| 17 |
+
|
| 18 |
# Front end
|
| 19 |
node_modules
|
| 20 |
package-lock.json
|
README.md
CHANGED
|
@@ -27,27 +27,27 @@ All `/summarize/*` endpoints accept an optional `model` field to override the de
|
|
| 27 |
|
| 28 |
### Prerequisites
|
| 29 |
|
| 30 |
-
- Python 3.11+
|
| 31 |
-
- Node.js 18+ (or [Bun](https://bun.sh))
|
| 32 |
-
- [Ollama](https://ollama.com) installed and running (`ollama serve`)
|
| 33 |
-
- At least one model pulled: `ollama pull phi4-mini:latest`
|
| 34 |
|
| 35 |
### Run the Fine-Tuning
|
| 36 |
|
| 37 |
-
Follow the scripts in `scripts/`, using any model you prefer. This project has been primarily tested with phi4-mini (from Microsoft) and Qwen 3-
|
| 38 |
|
| 39 |
-
### Backend
|
| 40 |
|
| 41 |
```bash
|
| 42 |
-
cd backend
|
| 43 |
# Create a venv or conda environment or whatever else you may want
|
| 44 |
pip install -r ../requirements.txt
|
|
|
|
| 45 |
uvicorn app:app --reload
|
| 46 |
```
|
| 47 |
|
| 48 |
Runs on `http://localhost:8000`. Interactive docs at `/docs`.
|
| 49 |
|
| 50 |
-
### Frontend
|
| 51 |
|
| 52 |
```bash
|
| 53 |
cd frontend
|
|
|
|
| 27 |
|
| 28 |
### Prerequisites
|
| 29 |
|
| 30 |
+
- Python 3.11+,
|
| 31 |
+
- Node.js 18+ (or an alternative like [Bun](https://bun.sh)),
|
| 32 |
+
- [Ollama](https://ollama.com) installed and running (`ollama serve` is the command, although it may be on auto-start).
|
| 33 |
+
- At least one model pulled: `ollama pull phi4-mini:latest` (for example)
|
| 34 |
|
| 35 |
### Run the Fine-Tuning
|
| 36 |
|
| 37 |
+
Follow the scripts in `scripts/`, using any model you prefer. This project has been primarily tested with phi4-mini (from Microsoft) and Qwen 3-4b (from Alibaba) (`ollama pull qwen3:4b` to pull it).
|
| 38 |
|
| 39 |
+
### Start the Backend
|
| 40 |
|
| 41 |
```bash
|
|
|
|
| 42 |
# Create a venv or conda environment or whatever else you may want
|
| 43 |
pip install -r ../requirements.txt
|
| 44 |
+
cd backend
|
| 45 |
uvicorn app:app --reload
|
| 46 |
```
|
| 47 |
|
| 48 |
Runs on `http://localhost:8000`. Interactive docs at `/docs`.
|
| 49 |
|
| 50 |
+
### Run the Frontend
|
| 51 |
|
| 52 |
```bash
|
| 53 |
cd frontend
|
backend/app.py
CHANGED
|
@@ -42,6 +42,16 @@ def verify_api_key(x_api_key: Optional[str] = Header(default=None, alias="X-API-
|
|
| 42 |
raise HTTPException(status_code=401, detail="Invalid API key.")
|
| 43 |
|
| 44 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 45 |
@app.get("/health")
|
| 46 |
async def health():
|
| 47 |
return {"status": "healthy", "service": "precis"}
|
|
@@ -68,6 +78,18 @@ async def status():
|
|
| 68 |
|
| 69 |
@app.get("/models")
|
| 70 |
async def list_models():
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 71 |
return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
|
| 72 |
|
| 73 |
|
|
|
|
| 42 |
raise HTTPException(status_code=401, detail="Invalid API key.")
|
| 43 |
|
| 44 |
|
| 45 |
+
@app.get("/")
|
| 46 |
+
async def root():
|
| 47 |
+
return {
|
| 48 |
+
"service": "Précis API",
|
| 49 |
+
"docs": "/docs",
|
| 50 |
+
"health": "/health",
|
| 51 |
+
"status": "/status",
|
| 52 |
+
}
|
| 53 |
+
|
| 54 |
+
|
| 55 |
@app.get("/health")
|
| 56 |
async def health():
|
| 57 |
return {"status": "healthy", "service": "precis"}
|
|
|
|
| 78 |
|
| 79 |
@app.get("/models")
|
| 80 |
async def list_models():
|
| 81 |
+
try:
|
| 82 |
+
async with httpx.AsyncClient(timeout=5.0) as client:
|
| 83 |
+
r = await client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 84 |
+
r.raise_for_status()
|
| 85 |
+
payload = r.json() if r.content else {}
|
| 86 |
+
installed = [m.get("name") for m in payload.get("models", []) if m.get("name")]
|
| 87 |
+
if installed:
|
| 88 |
+
default = DEFAULT_MODEL if DEFAULT_MODEL in installed else installed[0]
|
| 89 |
+
return {"default": default, "available": installed}
|
| 90 |
+
except Exception:
|
| 91 |
+
pass
|
| 92 |
+
|
| 93 |
return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
|
| 94 |
|
| 95 |
|
backend/config.py
CHANGED
|
@@ -23,9 +23,9 @@ def _required_env(name: str) -> str:
|
|
| 23 |
return value
|
| 24 |
|
| 25 |
|
| 26 |
-
OLLAMA_BASE_URL = _required_env("
|
| 27 |
-
DEFAULT_MODEL = _required_env("
|
| 28 |
-
AVAILABLE_MODELS = _csv_env("
|
| 29 |
if DEFAULT_MODEL not in AVAILABLE_MODELS:
|
| 30 |
AVAILABLE_MODELS = [DEFAULT_MODEL, *AVAILABLE_MODELS]
|
| 31 |
|
|
@@ -35,7 +35,11 @@ if not ALLOWED_ORIGINS:
|
|
| 35 |
|
| 36 |
API_KEY = _required_env("PRECIS_API_KEY")
|
| 37 |
|
| 38 |
-
MAX_SUMMARY_TOKENS = int(
|
| 39 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
| 40 |
MAX_UPLOAD_BYTES = int(os.getenv("PRECIS_MAX_UPLOAD_BYTES", "10485760"))
|
| 41 |
MAX_TRANSCRIPT_CHARS = int(os.getenv("PRECIS_MAX_TRANSCRIPT_CHARS", "120000"))
|
|
|
|
| 23 |
return value
|
| 24 |
|
| 25 |
|
| 26 |
+
OLLAMA_BASE_URL = _required_env("OLLAMA_BASE_URL")
|
| 27 |
+
DEFAULT_MODEL = _required_env("DEFAULT_MODEL")
|
| 28 |
+
AVAILABLE_MODELS = _csv_env("AVAILABLE_MODELS", [DEFAULT_MODEL])
|
| 29 |
if DEFAULT_MODEL not in AVAILABLE_MODELS:
|
| 30 |
AVAILABLE_MODELS = [DEFAULT_MODEL, *AVAILABLE_MODELS]
|
| 31 |
|
|
|
|
| 35 |
|
| 36 |
API_KEY = _required_env("PRECIS_API_KEY")
|
| 37 |
|
| 38 |
+
MAX_SUMMARY_TOKENS = int(
|
| 39 |
+
os.getenv("MAX_SUMMARY_TOKENS", os.getenv("PRECIS_MAX_SUMMARY_TOKENS", "120"))
|
| 40 |
+
)
|
| 41 |
+
TEMPERATURE = float(
|
| 42 |
+
os.getenv("TEMPERATURE", os.getenv("PRECIS_TEMPERATURE", "0.2"))
|
| 43 |
+
)
|
| 44 |
MAX_UPLOAD_BYTES = int(os.getenv("PRECIS_MAX_UPLOAD_BYTES", "10485760"))
|
| 45 |
MAX_TRANSCRIPT_CHARS = int(os.getenv("PRECIS_MAX_TRANSCRIPT_CHARS", "120000"))
|
backend/ollama.py
CHANGED
|
@@ -1,4 +1,6 @@
|
|
| 1 |
from typing import Optional
|
|
|
|
|
|
|
| 2 |
|
| 3 |
import httpx
|
| 4 |
from fastapi import HTTPException
|
|
@@ -25,50 +27,115 @@ def build_prompt(title: Optional[str], text: str) -> str:
|
|
| 25 |
)
|
| 26 |
return (
|
| 27 |
f"{instructions}\n"
|
| 28 |
-
"Do not add opinions, commentary, or filler phrases like 'The article discusses'.\n"
|
| 29 |
-
"
|
|
|
|
| 30 |
f"Article:\n{text}\n\n"
|
| 31 |
"Summary:"
|
| 32 |
)
|
| 33 |
|
| 34 |
|
| 35 |
def resolve_model(model: Optional[str]) -> str:
|
| 36 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 37 |
return DEFAULT_MODEL
|
| 38 |
-
if
|
| 39 |
raise HTTPException(
|
| 40 |
status_code=400,
|
| 41 |
-
detail=f"Unknown model '{
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 42 |
)
|
| 43 |
-
return model
|
| 44 |
|
| 45 |
|
| 46 |
async def ollama_stream(prompt: str, model: str):
|
| 47 |
-
"""Async generator: yields
|
|
|
|
|
|
|
|
|
|
| 48 |
payload = {
|
| 49 |
"model": model,
|
| 50 |
"prompt": prompt,
|
| 51 |
"stream": True,
|
|
|
|
| 52 |
"options": {
|
| 53 |
-
"num_predict":
|
| 54 |
"temperature": TEMPERATURE,
|
| 55 |
-
"stop": ["
|
| 56 |
},
|
| 57 |
}
|
| 58 |
-
async with httpx.AsyncClient(timeout=
|
| 59 |
try:
|
| 60 |
async with client.stream(
|
| 61 |
"POST", f"{OLLAMA_BASE_URL}/api/generate", json=payload,
|
| 62 |
) as resp:
|
| 63 |
resp.raise_for_status()
|
| 64 |
async for line in resp.aiter_lines():
|
| 65 |
-
if line:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 66 |
yield line + "\n"
|
| 67 |
except httpx.ConnectError:
|
| 68 |
-
|
| 69 |
-
|
| 70 |
-
|
| 71 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 72 |
|
| 73 |
|
| 74 |
def stream_summary(
|
|
@@ -77,6 +144,7 @@ def stream_summary(
|
|
| 77 |
model: Optional[str] = None,
|
| 78 |
) -> StreamingResponse:
|
| 79 |
"""Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
|
|
|
|
| 80 |
resolved = resolve_model(model)
|
| 81 |
prompt = build_prompt(title, text)
|
| 82 |
return StreamingResponse(
|
|
|
|
| 1 |
from typing import Optional
|
| 2 |
+
import json
|
| 3 |
+
import os
|
| 4 |
|
| 5 |
import httpx
|
| 6 |
from fastapi import HTTPException
|
|
|
|
| 27 |
)
|
| 28 |
return (
|
| 29 |
f"{instructions}\n"
|
| 30 |
+
"Do not add opinions, commentary, or filler phrases like 'The article discusses' or 'This document provides'.\n"
|
| 31 |
+
"or ANYTHING along those lines whether it be in meaning or phrasing. "
|
| 32 |
+
"Output the summary sentence only. The sentence should be no longer than 200 characetrs long. Nothing else should be included.\n\n"
|
| 33 |
f"Article:\n{text}\n\n"
|
| 34 |
"Summary:"
|
| 35 |
)
|
| 36 |
|
| 37 |
|
| 38 |
def resolve_model(model: Optional[str]) -> str:
|
| 39 |
+
requested = model or ""
|
| 40 |
+
|
| 41 |
+
# Prefer what Ollama actually has installed.
|
| 42 |
+
try:
|
| 43 |
+
with httpx.Client(timeout=5.0) as client:
|
| 44 |
+
r = client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 45 |
+
r.raise_for_status()
|
| 46 |
+
payload = r.json() if r.content else {}
|
| 47 |
+
installed = [m.get("name") for m in payload.get("models", []) if m.get("name")]
|
| 48 |
+
except Exception:
|
| 49 |
+
installed = []
|
| 50 |
+
|
| 51 |
+
if installed:
|
| 52 |
+
if not requested:
|
| 53 |
+
return DEFAULT_MODEL if DEFAULT_MODEL in installed else installed[0]
|
| 54 |
+
if requested not in installed:
|
| 55 |
+
raise HTTPException(
|
| 56 |
+
status_code=400,
|
| 57 |
+
detail=(
|
| 58 |
+
f"Model '{requested}' is not installed in Ollama. "
|
| 59 |
+
f"Installed: {installed}. Run `ollama pull {requested}`."
|
| 60 |
+
),
|
| 61 |
+
)
|
| 62 |
+
return requested
|
| 63 |
+
|
| 64 |
+
# Fallback: use configured allowlist when Ollama isn't reachable.
|
| 65 |
+
if not requested:
|
| 66 |
return DEFAULT_MODEL
|
| 67 |
+
if requested not in AVAILABLE_MODELS:
|
| 68 |
raise HTTPException(
|
| 69 |
status_code=400,
|
| 70 |
+
detail=f"Unknown model '{requested}'. Available: {AVAILABLE_MODELS}",
|
| 71 |
+
)
|
| 72 |
+
return requested
|
| 73 |
+
|
| 74 |
+
|
| 75 |
+
def ensure_ollama_reachable() -> None:
|
| 76 |
+
try:
|
| 77 |
+
with httpx.Client(timeout=10.0) as client:
|
| 78 |
+
response = client.get(f"{OLLAMA_BASE_URL}/api/tags")
|
| 79 |
+
response.raise_for_status()
|
| 80 |
+
except httpx.ConnectError:
|
| 81 |
+
raise HTTPException(
|
| 82 |
+
status_code=503,
|
| 83 |
+
detail="Cannot reach Ollama. Make sure `ollama serve` is running.",
|
| 84 |
+
)
|
| 85 |
+
except httpx.HTTPError as exc:
|
| 86 |
+
raise HTTPException(
|
| 87 |
+
status_code=503,
|
| 88 |
+
detail=f"Ollama responded with an error: {exc}",
|
| 89 |
)
|
|
|
|
| 90 |
|
| 91 |
|
| 92 |
async def ollama_stream(prompt: str, model: str):
|
| 93 |
+
"""Async generator: yields NDJSON lines from Ollama, filtering out thinking-only chunks."""
|
| 94 |
+
keep_alive = os.getenv("OLLAMA_KEEP_ALIVE", "30m")
|
| 95 |
+
# Set num_predict high so thinking tokens don't limit output.
|
| 96 |
+
num_predict = MAX_SUMMARY_TOKENS * 3
|
| 97 |
payload = {
|
| 98 |
"model": model,
|
| 99 |
"prompt": prompt,
|
| 100 |
"stream": True,
|
| 101 |
+
"keep_alive": keep_alive,
|
| 102 |
"options": {
|
| 103 |
+
"num_predict": num_predict,
|
| 104 |
"temperature": TEMPERATURE,
|
| 105 |
+
"stop": ["Article:", "Title:"],
|
| 106 |
},
|
| 107 |
}
|
| 108 |
+
async with httpx.AsyncClient(timeout=300.0) as client:
|
| 109 |
try:
|
| 110 |
async with client.stream(
|
| 111 |
"POST", f"{OLLAMA_BASE_URL}/api/generate", json=payload,
|
| 112 |
) as resp:
|
| 113 |
resp.raise_for_status()
|
| 114 |
async for line in resp.aiter_lines():
|
| 115 |
+
if not line:
|
| 116 |
+
continue
|
| 117 |
+
try:
|
| 118 |
+
chunk = json.loads(line)
|
| 119 |
+
# Skips thinking-only chunks.
|
| 120 |
+
if chunk.get("response"):
|
| 121 |
+
yield line + "\n"
|
| 122 |
+
except json.JSONDecodeError:
|
| 123 |
yield line + "\n"
|
| 124 |
except httpx.ConnectError:
|
| 125 |
+
error_line = json.dumps({
|
| 126 |
+
"error": "Cannot reach Ollama. Make sure `ollama serve` is running.",
|
| 127 |
+
})
|
| 128 |
+
yield error_line + "\n"
|
| 129 |
+
except httpx.TimeoutException:
|
| 130 |
+
error_line = json.dumps({
|
| 131 |
+
"error": "Ollama timed out. The model may still be loading — try again in a moment.",
|
| 132 |
+
})
|
| 133 |
+
yield error_line + "\n"
|
| 134 |
+
except httpx.HTTPError as exc:
|
| 135 |
+
error_line = json.dumps({
|
| 136 |
+
"error": f"Ollama error: {exc}",
|
| 137 |
+
})
|
| 138 |
+
yield error_line + "\n"
|
| 139 |
|
| 140 |
|
| 141 |
def stream_summary(
|
|
|
|
| 144 |
model: Optional[str] = None,
|
| 145 |
) -> StreamingResponse:
|
| 146 |
"""Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
|
| 147 |
+
ensure_ollama_reachable()
|
| 148 |
resolved = resolve_model(model)
|
| 149 |
prompt = build_prompt(title, text)
|
| 150 |
return StreamingResponse(
|
frontend/src/App.jsx
CHANGED
|
@@ -1,8 +1,8 @@
|
|
| 1 |
-
import { useState, useRef } from 'react'
|
| 2 |
import InlineResult from './components/InlineResult'
|
| 3 |
import { useStreaming } from './hooks/useStreaming'
|
| 4 |
import logoSvg from './assets/logo.svg'
|
| 5 |
-
import { API_BASE
|
| 6 |
import './App.css'
|
| 7 |
|
| 8 |
function App() {
|
|
@@ -10,13 +10,40 @@ function App() {
|
|
| 10 |
const [youtubeUrl, setYoutubeUrl] = useState('')
|
| 11 |
const [transcript, setTranscript] = useState('')
|
| 12 |
const [selectedFile, setSelectedFile] = useState(null)
|
| 13 |
-
const [
|
|
|
|
| 14 |
const fileInputRef = useRef(null)
|
| 15 |
|
| 16 |
const { loading, response, error, streamingText, submit } = useStreaming()
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 18 |
const handleSubmit = () =>
|
| 19 |
-
submit(activeTab, {
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 20 |
|
| 21 |
const handleFileDrop = (e) => {
|
| 22 |
e.preventDefault()
|
|
@@ -50,9 +77,9 @@ function App() {
|
|
| 50 |
className="model-select"
|
| 51 |
value={selectedModel}
|
| 52 |
onChange={(e) => setSelectedModel(e.target.value)}
|
| 53 |
-
disabled={loading}
|
| 54 |
>
|
| 55 |
-
{
|
| 56 |
</select>
|
| 57 |
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
|
| 58 |
API Docs
|
|
@@ -102,7 +129,13 @@ function App() {
|
|
| 102 |
/>
|
| 103 |
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
|
| 104 |
</div>
|
| 105 |
-
{activeTab === 'youtube' &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 106 |
</div>
|
| 107 |
|
| 108 |
{/* Transcript */}
|
|
@@ -124,7 +157,13 @@ function App() {
|
|
| 124 |
{' '}to generate.
|
| 125 |
</p>
|
| 126 |
</div>
|
| 127 |
-
{activeTab === 'transcript' &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 128 |
</div>
|
| 129 |
|
| 130 |
{/* File upload */}
|
|
@@ -162,7 +201,13 @@ function App() {
|
|
| 162 |
</div>
|
| 163 |
)}
|
| 164 |
</div>
|
| 165 |
-
{activeTab === 'file' &&
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 166 |
</div>
|
| 167 |
|
| 168 |
<div className="submit-section">
|
|
|
|
| 1 |
+
import { useEffect, useState, useRef } from 'react'
|
| 2 |
import InlineResult from './components/InlineResult'
|
| 3 |
import { useStreaming } from './hooks/useStreaming'
|
| 4 |
import logoSvg from './assets/logo.svg'
|
| 5 |
+
import { API_BASE } from './config'
|
| 6 |
import './App.css'
|
| 7 |
|
| 8 |
function App() {
|
|
|
|
| 10 |
const [youtubeUrl, setYoutubeUrl] = useState('')
|
| 11 |
const [transcript, setTranscript] = useState('')
|
| 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
|
| 21 |
+
;(async () => {
|
| 22 |
+
try {
|
| 23 |
+
const res = await fetch(`${API_BASE}/models`)
|
| 24 |
+
if (!res.ok) return
|
| 25 |
+
const data = await res.json()
|
| 26 |
+
if (cancelled) return
|
| 27 |
+
|
| 28 |
+
const available = Array.isArray(data.available) ? data.available : []
|
| 29 |
+
setModels(available)
|
| 30 |
+
|
| 31 |
+
const serverDefault = typeof data.default === 'string' ? data.default : ''
|
| 32 |
+
setSelectedModel((prev) => prev || serverDefault || available[0] || '')
|
| 33 |
+
} catch {
|
| 34 |
+
// Non-fatal: model list stays empty; backend will still pick default if model omitted.
|
| 35 |
+
}
|
| 36 |
+
})()
|
| 37 |
+
return () => { cancelled = true }
|
| 38 |
+
}, [])
|
| 39 |
+
|
| 40 |
const handleSubmit = () =>
|
| 41 |
+
submit(activeTab, {
|
| 42 |
+
youtubeUrl,
|
| 43 |
+
transcript,
|
| 44 |
+
selectedFile,
|
| 45 |
+
selectedModel: selectedModel || undefined,
|
| 46 |
+
})
|
| 47 |
|
| 48 |
const handleFileDrop = (e) => {
|
| 49 |
e.preventDefault()
|
|
|
|
| 77 |
className="model-select"
|
| 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
|
|
|
|
| 129 |
/>
|
| 130 |
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
|
| 131 |
</div>
|
| 132 |
+
{activeTab === 'youtube' && (
|
| 133 |
+
<InlineResult
|
| 134 |
+
{...resultProps}
|
| 135 |
+
loadingLabel="Fetching transcript…"
|
| 136 |
+
placeholderText="Fetching transcript…"
|
| 137 |
+
/>
|
| 138 |
+
)}
|
| 139 |
</div>
|
| 140 |
|
| 141 |
{/* Transcript */}
|
|
|
|
| 157 |
{' '}to generate.
|
| 158 |
</p>
|
| 159 |
</div>
|
| 160 |
+
{activeTab === 'transcript' && (
|
| 161 |
+
<InlineResult
|
| 162 |
+
{...resultProps}
|
| 163 |
+
loadingLabel="Generating…"
|
| 164 |
+
placeholderText="Waiting for model…"
|
| 165 |
+
/>
|
| 166 |
+
)}
|
| 167 |
</div>
|
| 168 |
|
| 169 |
{/* File upload */}
|
|
|
|
| 201 |
</div>
|
| 202 |
)}
|
| 203 |
</div>
|
| 204 |
+
{activeTab === 'file' && (
|
| 205 |
+
<InlineResult
|
| 206 |
+
{...resultProps}
|
| 207 |
+
loadingLabel="Reading file…"
|
| 208 |
+
placeholderText="Reading file…"
|
| 209 |
+
/>
|
| 210 |
+
)}
|
| 211 |
</div>
|
| 212 |
|
| 213 |
<div className="submit-section">
|
frontend/src/components/InlineResult.jsx
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
export default function InlineResult({ error, loading, response, streamingText, selectedModel, loadingLabel }) {
|
| 2 |
return (
|
| 3 |
<>
|
| 4 |
{error && (
|
|
@@ -17,7 +17,11 @@ export default function InlineResult({ error, loading, response, streamingText,
|
|
| 17 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
|
| 18 |
</div>
|
| 19 |
<p className="inline-result__text">
|
| 20 |
-
{streamingText ||
|
|
|
|
|
|
|
|
|
|
|
|
|
| 21 |
<span className="streaming-cursor">▌</span>
|
| 22 |
</p>
|
| 23 |
</div>
|
|
|
|
| 1 |
+
export default function InlineResult({ error, loading, response, streamingText, selectedModel, loadingLabel, placeholderText }) {
|
| 2 |
return (
|
| 3 |
<>
|
| 4 |
{error && (
|
|
|
|
| 17 |
<span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
|
| 18 |
</div>
|
| 19 |
<p className="inline-result__text">
|
| 20 |
+
{streamingText || (
|
| 21 |
+
<span className="streaming-placeholder">
|
| 22 |
+
{placeholderText || loadingLabel || 'Waiting for model…'}
|
| 23 |
+
</span>
|
| 24 |
+
)}
|
| 25 |
<span className="streaming-cursor">▌</span>
|
| 26 |
</p>
|
| 27 |
</div>
|
frontend/src/config.js
CHANGED
|
@@ -1,24 +1,21 @@
|
|
| 1 |
-
const
|
| 2 |
-
|
| 3 |
-
|
| 4 |
-
|
| 5 |
-
|
| 6 |
-
const requiredEnv = (name) => {
|
| 7 |
-
const value = import.meta.env[name]
|
| 8 |
-
if (!value || !String(value).trim()) {
|
| 9 |
-
throw new Error(`Missing required environment variable: ${name}`)
|
| 10 |
}
|
| 11 |
-
|
| 12 |
}
|
| 13 |
|
| 14 |
-
export const API_BASE = requiredEnv(
|
| 15 |
-
|
| 16 |
-
|
| 17 |
-
|
| 18 |
-
|
| 19 |
-
|
| 20 |
-
|
| 21 |
-
|
|
|
|
| 22 |
|
| 23 |
export const authHeaders = (headers = {}) => (
|
| 24 |
API_KEY ? { ...headers, 'X-API-Key': API_KEY } : headers
|
|
|
|
| 1 |
+
const requiredEnv = (names) => {
|
| 2 |
+
const list = Array.isArray(names) ? names : [names]
|
| 3 |
+
for (const name of list) {
|
| 4 |
+
const value = import.meta.env[name]
|
| 5 |
+
if (value && String(value).trim()) return String(value).trim()
|
|
|
|
|
|
|
|
|
|
|
|
|
| 6 |
}
|
| 7 |
+
throw new Error(`Missing required environment variable. Tried: ${list.join(', ')}`)
|
| 8 |
}
|
| 9 |
|
| 10 |
+
export const API_BASE = requiredEnv([
|
| 11 |
+
'API_BASE_URL',
|
| 12 |
+
'VITE_API_BASE_URL',
|
| 13 |
+
'PRECIS_API_BASE_URL',
|
| 14 |
+
])
|
| 15 |
+
export const API_KEY = requiredEnv([
|
| 16 |
+
'PRECIS_API_KEY',
|
| 17 |
+
'VITE_API_KEY',
|
| 18 |
+
])
|
| 19 |
|
| 20 |
export const authHeaders = (headers = {}) => (
|
| 21 |
API_KEY ? { ...headers, 'X-API-Key': API_KEY } : headers
|
frontend/src/hooks/useStreaming.js
CHANGED
|
@@ -13,6 +13,7 @@ export function useStreaming() {
|
|
| 13 |
const decoder = new TextDecoder()
|
| 14 |
let accumulated = ''
|
| 15 |
let buffer = ''
|
|
|
|
| 16 |
|
| 17 |
while (true) {
|
| 18 |
const { done, value } = await reader.read()
|
|
@@ -26,6 +27,10 @@ export function useStreaming() {
|
|
| 26 |
if (!line.trim()) continue
|
| 27 |
try {
|
| 28 |
const chunk = JSON.parse(line)
|
|
|
|
|
|
|
|
|
|
|
|
|
| 29 |
if (chunk.response) {
|
| 30 |
accumulated += chunk.response
|
| 31 |
setStreamingText(accumulated)
|
|
@@ -34,7 +39,16 @@ export function useStreaming() {
|
|
| 34 |
}
|
| 35 |
}
|
| 36 |
|
| 37 |
-
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 38 |
}
|
| 39 |
|
| 40 |
const streamFrom = async (endpoint, { json, formData } = {}) => {
|
|
|
|
| 13 |
const decoder = new TextDecoder()
|
| 14 |
let accumulated = ''
|
| 15 |
let buffer = ''
|
| 16 |
+
let streamError = null
|
| 17 |
|
| 18 |
while (true) {
|
| 19 |
const { done, value } = await reader.read()
|
|
|
|
| 27 |
if (!line.trim()) continue
|
| 28 |
try {
|
| 29 |
const chunk = JSON.parse(line)
|
| 30 |
+
if (chunk.error) {
|
| 31 |
+
streamError = String(chunk.error)
|
| 32 |
+
continue
|
| 33 |
+
}
|
| 34 |
if (chunk.response) {
|
| 35 |
accumulated += chunk.response
|
| 36 |
setStreamingText(accumulated)
|
|
|
|
| 39 |
}
|
| 40 |
}
|
| 41 |
|
| 42 |
+
if (streamError) {
|
| 43 |
+
throw new Error(streamError)
|
| 44 |
+
}
|
| 45 |
+
|
| 46 |
+
const finalText = accumulated.trim()
|
| 47 |
+
if (!finalText) {
|
| 48 |
+
throw new Error('Model returned an empty response. Try again or pick a different model.')
|
| 49 |
+
}
|
| 50 |
+
|
| 51 |
+
return finalText
|
| 52 |
}
|
| 53 |
|
| 54 |
const streamFrom = async (endpoint, { json, formData } = {}) => {
|
frontend/vite.config.js
CHANGED
|
@@ -4,6 +4,6 @@ import react from '@vitejs/plugin-react'
|
|
| 4 |
// https://vite.dev/config/
|
| 5 |
export default defineConfig({
|
| 6 |
envDir: '..',
|
| 7 |
-
envPrefix: ['VITE_', 'PRECIS_'],
|
| 8 |
plugins: [react()],
|
| 9 |
})
|
|
|
|
| 4 |
// https://vite.dev/config/
|
| 5 |
export default defineConfig({
|
| 6 |
envDir: '..',
|
| 7 |
+
envPrefix: ['VITE_', 'PRECIS_', 'API_', 'DEFAULT_', 'AVAILABLE_'],
|
| 8 |
plugins: [react()],
|
| 9 |
})
|
requirements.txt
CHANGED
|
@@ -1,4 +1,4 @@
|
|
| 1 |
-
#
|
| 2 |
torch
|
| 3 |
transformers
|
| 4 |
accelerate
|
|
@@ -10,8 +10,10 @@ sentencepiece
|
|
| 10 |
# API
|
| 11 |
fastapi
|
| 12 |
uvicorn
|
| 13 |
-
httpx
|
| 14 |
-
python-multipart
|
| 15 |
-
youtube-transcript-api # YouTube transcript fetching
|
| 16 |
-
python-dotenv # .env loading for backend/frontend config
|
| 17 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
# Model Trainerz
|
| 2 |
torch
|
| 3 |
transformers
|
| 4 |
accelerate
|
|
|
|
| 10 |
# API
|
| 11 |
fastapi
|
| 12 |
uvicorn
|
| 13 |
+
httpx # async HTTP client for Ollama calls
|
| 14 |
+
python-multipart # faster file uploads
|
|
|
|
|
|
|
| 15 |
|
| 16 |
+
python-dotenv
|
| 17 |
+
|
| 18 |
+
# Prop tools
|
| 19 |
+
youtube-transcript-api
|