Spaces:
Build error
Build error
Commit ·
ca552c5
1
Parent(s): 932022e
YT support, front end improvements, secondary model option (might remove later)
Browse files- backend/app.py +29 -230
- backend/config.py +5 -0
- backend/ollama.py +88 -0
- backend/schemas.py +13 -0
- backend/youtube.py +37 -0
- frontend/index.html +3 -2
- frontend/src/App.css +61 -8
- frontend/src/App.jsx +62 -253
- frontend/src/assets/logo.svg +527 -0
- frontend/src/assets/react.svg +0 -1
- frontend/src/components/InlineResult.jsx +39 -0
- frontend/src/hooks/useStreaming.js +99 -0
backend/app.py
CHANGED
|
@@ -1,36 +1,22 @@
|
|
| 1 |
-
"""
|
| 2 |
|
| 3 |
-
import
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
-
from fastapi.responses import
|
| 10 |
-
from pydantic import BaseModel
|
| 11 |
|
| 12 |
-
|
| 13 |
-
|
| 14 |
-
|
| 15 |
-
|
| 16 |
-
OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
| 17 |
-
OLLAMA_COMPLETIONS_URL = f"{OLLAMA_BASE_URL}/v1/completions"
|
| 18 |
-
MODEL_NAME = "phi4-mini:3.8b"
|
| 19 |
-
|
| 20 |
-
# Tokens to generate for the summary — keep short for speed
|
| 21 |
-
MAX_SUMMARY_TOKENS = 120
|
| 22 |
-
TEMPERATURE = 0.2
|
| 23 |
-
|
| 24 |
-
logger = logging.getLogger(__name__)
|
| 25 |
-
|
| 26 |
-
# ---------------------------------------------------------------------------
|
| 27 |
-
# App
|
| 28 |
-
# ---------------------------------------------------------------------------
|
| 29 |
|
| 30 |
app = FastAPI(
|
| 31 |
title="Précis API",
|
| 32 |
-
description="Content summarisation service powered by
|
| 33 |
-
version="0.
|
| 34 |
)
|
| 35 |
|
| 36 |
app.add_middleware(
|
|
@@ -42,158 +28,13 @@ app.add_middleware(
|
|
| 42 |
)
|
| 43 |
|
| 44 |
|
| 45 |
-
# ---------------------------------------------------------------------------
|
| 46 |
-
# Schemas
|
| 47 |
-
# ---------------------------------------------------------------------------
|
| 48 |
-
|
| 49 |
-
class YouTubeRequest(BaseModel):
|
| 50 |
-
url: str
|
| 51 |
-
max_length: Optional[int] = 512
|
| 52 |
-
|
| 53 |
-
|
| 54 |
-
class TranscriptRequest(BaseModel):
|
| 55 |
-
text: str
|
| 56 |
-
title: Optional[str] = None
|
| 57 |
-
max_length: Optional[int] = 512
|
| 58 |
-
|
| 59 |
-
|
| 60 |
-
class SummarizeResponse(BaseModel):
|
| 61 |
-
summary: str
|
| 62 |
-
success: bool
|
| 63 |
-
source_type: str
|
| 64 |
-
model: str = MODEL_NAME
|
| 65 |
-
|
| 66 |
-
|
| 67 |
-
# ---------------------------------------------------------------------------
|
| 68 |
-
# Ollama helper
|
| 69 |
-
# ---------------------------------------------------------------------------
|
| 70 |
-
|
| 71 |
-
def _build_prompt(title: Optional[str], text: str) -> str:
|
| 72 |
-
if title:
|
| 73 |
-
instructions = (
|
| 74 |
-
f'The article is titled "{title}". '
|
| 75 |
-
"If the title is a question, answer it directly in one sentence using only facts from the article. "
|
| 76 |
-
"If the title is not a question, write one sentence that gives a concise, high-level overview "
|
| 77 |
-
"of the article, briefly enumerating all key facts."
|
| 78 |
-
)
|
| 79 |
-
else:
|
| 80 |
-
instructions = (
|
| 81 |
-
"Write one sentence that gives a concise, high-level overview of the article, "
|
| 82 |
-
"briefly enumerating all key facts."
|
| 83 |
-
)
|
| 84 |
-
return (
|
| 85 |
-
f"{instructions}\n"
|
| 86 |
-
"Do not add opinions, commentary, or filler phrases like 'The article discusses'.\n"
|
| 87 |
-
"Output the summary sentence only — nothing else.\n\n"
|
| 88 |
-
f"Article:\n{text}\n\n"
|
| 89 |
-
"Summary:"
|
| 90 |
-
)
|
| 91 |
-
|
| 92 |
-
|
| 93 |
-
def _ollama_payload(prompt: str, stream: bool = False) -> dict:
|
| 94 |
-
return {
|
| 95 |
-
"model": MODEL_NAME,
|
| 96 |
-
"prompt": prompt,
|
| 97 |
-
"stream": stream,
|
| 98 |
-
"options": {
|
| 99 |
-
"num_predict": MAX_SUMMARY_TOKENS,
|
| 100 |
-
"temperature": TEMPERATURE,
|
| 101 |
-
"stop": ["\n\n", "Article:", "Title:"],
|
| 102 |
-
},
|
| 103 |
-
}
|
| 104 |
-
|
| 105 |
-
|
| 106 |
-
def _ollama_connect_error() -> HTTPException:
|
| 107 |
-
return HTTPException(
|
| 108 |
-
status_code=503,
|
| 109 |
-
detail="Cannot reach Ollama at 127.0.0.1:11434. Make sure `ollama serve` is running.",
|
| 110 |
-
)
|
| 111 |
-
|
| 112 |
-
|
| 113 |
-
async def call_ollama(prompt: str) -> str:
|
| 114 |
-
"""Non-streaming call — returns the full generated text."""
|
| 115 |
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 116 |
-
try:
|
| 117 |
-
resp = await client.post(
|
| 118 |
-
f"{OLLAMA_BASE_URL}/api/generate",
|
| 119 |
-
json=_ollama_payload(prompt, stream=False),
|
| 120 |
-
)
|
| 121 |
-
resp.raise_for_status()
|
| 122 |
-
except httpx.ConnectError:
|
| 123 |
-
raise _ollama_connect_error()
|
| 124 |
-
except httpx.HTTPStatusError as exc:
|
| 125 |
-
raise HTTPException(status_code=502, detail=f"Ollama error: {exc.response.text}")
|
| 126 |
-
|
| 127 |
-
data = resp.json()
|
| 128 |
-
try:
|
| 129 |
-
return data["response"].strip()
|
| 130 |
-
except KeyError as exc:
|
| 131 |
-
logger.error("Unexpected Ollama response: %s", data)
|
| 132 |
-
raise HTTPException(status_code=502, detail=f"Unexpected response shape: {exc}")
|
| 133 |
-
|
| 134 |
-
|
| 135 |
-
async def stream_ollama(prompt: str):
|
| 136 |
-
"""Async generator that yields raw NDJSON lines from Ollama's streaming endpoint."""
|
| 137 |
-
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 138 |
-
try:
|
| 139 |
-
async with client.stream(
|
| 140 |
-
"POST",
|
| 141 |
-
f"{OLLAMA_BASE_URL}/api/generate",
|
| 142 |
-
json=_ollama_payload(prompt, stream=True),
|
| 143 |
-
) as resp:
|
| 144 |
-
resp.raise_for_status()
|
| 145 |
-
async for line in resp.aiter_lines():
|
| 146 |
-
if line:
|
| 147 |
-
yield line + "\n"
|
| 148 |
-
except httpx.ConnectError:
|
| 149 |
-
raise _ollama_connect_error()
|
| 150 |
-
|
| 151 |
-
|
| 152 |
-
# ---------------------------------------------------------------------------
|
| 153 |
-
# Routes
|
| 154 |
-
# ---------------------------------------------------------------------------
|
| 155 |
-
|
| 156 |
-
@app.get("/", response_class=HTMLResponse)
|
| 157 |
-
async def root():
|
| 158 |
-
"""Root endpoint with basic info."""
|
| 159 |
-
return """
|
| 160 |
-
<!DOCTYPE html>
|
| 161 |
-
<html>
|
| 162 |
-
<head>
|
| 163 |
-
<title>Précis API</title>
|
| 164 |
-
<style>
|
| 165 |
-
body { font-family: system-ui; max-width: 800px; margin: 50px auto; padding: 20px; }
|
| 166 |
-
h1 { color: #333; }
|
| 167 |
-
code { background: #f4f4f4; padding: 2px 6px; border-radius: 3px; }
|
| 168 |
-
.model { color: #6366f1; font-weight: bold; }
|
| 169 |
-
</style>
|
| 170 |
-
</head>
|
| 171 |
-
<body>
|
| 172 |
-
<h1>Précis API</h1>
|
| 173 |
-
<p>Model: <span class="model">phi4-mini:3.8b</span> via Ollama</p>
|
| 174 |
-
<h2>Endpoints</h2>
|
| 175 |
-
<ul>
|
| 176 |
-
<li><code>POST /summarize/transcript</code> — Summarise raw text</li>
|
| 177 |
-
<li><code>POST /summarize/file</code> — Summarise a .txt file</li>
|
| 178 |
-
<li><code>POST /summarize/youtube</code> — Summarise a YouTube video (transcript required)</li>
|
| 179 |
-
<li><code>GET /health</code> — Health check</li>
|
| 180 |
-
<li><code>GET /status</code> — Service status</li>
|
| 181 |
-
<li><code>GET /docs</code> — Interactive API docs</li>
|
| 182 |
-
</ul>
|
| 183 |
-
</body>
|
| 184 |
-
</html>
|
| 185 |
-
"""
|
| 186 |
-
|
| 187 |
-
|
| 188 |
@app.get("/health")
|
| 189 |
async def health():
|
| 190 |
-
"""Health check endpoint."""
|
| 191 |
return {"status": "healthy", "service": "precis"}
|
| 192 |
|
| 193 |
|
| 194 |
@app.get("/status")
|
| 195 |
async def status():
|
| 196 |
-
"""Service status — also pings Ollama to confirm it is reachable."""
|
| 197 |
ollama_ok = False
|
| 198 |
try:
|
| 199 |
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
@@ -204,84 +45,42 @@ async def status():
|
|
| 204 |
|
| 205 |
return {
|
| 206 |
"service": "Précis API",
|
| 207 |
-
"version": "0.
|
| 208 |
-
"
|
|
|
|
| 209 |
"ollama_reachable": ollama_ok,
|
| 210 |
-
"endpoints": ["/", "/health", "/status", "/summarize/transcript",
|
| 211 |
-
"/summarize/file", "/summarize/youtube"],
|
| 212 |
}
|
| 213 |
|
| 214 |
|
| 215 |
-
@app.
|
| 216 |
-
async def
|
| 217 |
-
""
|
| 218 |
-
if not request.text.strip():
|
| 219 |
-
raise HTTPException(status_code=400, detail="text must not be empty")
|
| 220 |
-
|
| 221 |
-
prompt = _build_prompt(request.title, request.text)
|
| 222 |
-
summary = await call_ollama(prompt)
|
| 223 |
-
|
| 224 |
-
return SummarizeResponse(summary=summary, success=True, source_type="transcript")
|
| 225 |
|
| 226 |
|
| 227 |
-
@app.post("/summarize/transcript
|
| 228 |
-
async def
|
| 229 |
-
"""
|
| 230 |
-
Streaming variant — pipes Ollama's NDJSON stream directly to the client.
|
| 231 |
-
Each line is a JSON object: {"response": "<token>", "done": false}.
|
| 232 |
-
"""
|
| 233 |
if not request.text.strip():
|
| 234 |
-
raise HTTPException(status_code=400, detail="
|
|
|
|
| 235 |
|
| 236 |
-
prompt = _build_prompt(request.title, request.text)
|
| 237 |
|
| 238 |
-
|
| 239 |
-
|
| 240 |
-
|
| 241 |
-
|
| 242 |
-
)
|
| 243 |
|
| 244 |
|
| 245 |
-
@app.post("/summarize/file"
|
| 246 |
-
async def summarize_file(file: UploadFile = File(...)):
|
| 247 |
-
"""Summarise content from an uploaded .txt file."""
|
| 248 |
if not file.filename.endswith(".txt"):
|
| 249 |
-
raise HTTPException(status_code=400, detail="Only .txt files are supported")
|
| 250 |
-
|
| 251 |
content = await file.read()
|
| 252 |
text = content.decode("utf-8")
|
| 253 |
-
|
| 254 |
if not text.strip():
|
| 255 |
-
raise HTTPException(status_code=400, detail="Uploaded file is empty")
|
| 256 |
-
|
| 257 |
-
prompt = _build_prompt(file.filename, text)
|
| 258 |
-
summary = await call_ollama(prompt)
|
| 259 |
-
|
| 260 |
-
return SummarizeResponse(summary=summary, success=True, source_type="file")
|
| 261 |
-
|
| 262 |
-
|
| 263 |
-
@app.post("/summarize/youtube", response_model=SummarizeResponse)
|
| 264 |
-
async def summarize_youtube(request: YouTubeRequest):
|
| 265 |
-
"""
|
| 266 |
-
Summarise a YouTube video.
|
| 267 |
-
|
| 268 |
-
NOTE: Automatic transcript fetching is not yet implemented.
|
| 269 |
-
Pass the transcript text in a separate /summarize/transcript call,
|
| 270 |
-
or extend this endpoint with youtube-transcript-api.
|
| 271 |
-
"""
|
| 272 |
-
# Placeholder — returns a clear message rather than silently lying
|
| 273 |
-
raise HTTPException(
|
| 274 |
-
status_code=501,
|
| 275 |
-
detail=(
|
| 276 |
-
"Automatic YouTube transcript fetching is not yet implemented. "
|
| 277 |
-
"Extract the transcript yourself and POST it to /summarize/transcript."
|
| 278 |
-
),
|
| 279 |
-
)
|
| 280 |
-
|
| 281 |
|
| 282 |
-
# ---------------------------------------------------------------------------
|
| 283 |
-
# Entry point
|
| 284 |
-
# ---------------------------------------------------------------------------
|
| 285 |
|
| 286 |
if __name__ == "__main__":
|
| 287 |
import uvicorn
|
|
|
|
| 1 |
+
"""Précis API — routes and app setup."""
|
| 2 |
|
| 3 |
+
import asyncio
|
| 4 |
from typing import Optional
|
| 5 |
|
| 6 |
import httpx
|
| 7 |
from fastapi import FastAPI, HTTPException, UploadFile, File
|
| 8 |
from fastapi.middleware.cors import CORSMiddleware
|
| 9 |
+
from fastapi.responses import StreamingResponse
|
|
|
|
| 10 |
|
| 11 |
+
from config import OLLAMA_BASE_URL, DEFAULT_MODEL, AVAILABLE_MODELS
|
| 12 |
+
from schemas import TranscriptRequest, YouTubeRequest
|
| 13 |
+
from ollama import stream_summary
|
| 14 |
+
from youtube import extract_video_id, fetch_transcript
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 15 |
|
| 16 |
app = FastAPI(
|
| 17 |
title="Précis API",
|
| 18 |
+
description="Content summarisation service powered by Ollama",
|
| 19 |
+
version="0.4.0",
|
| 20 |
)
|
| 21 |
|
| 22 |
app.add_middleware(
|
|
|
|
| 28 |
)
|
| 29 |
|
| 30 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 31 |
@app.get("/health")
|
| 32 |
async def health():
|
|
|
|
| 33 |
return {"status": "healthy", "service": "precis"}
|
| 34 |
|
| 35 |
|
| 36 |
@app.get("/status")
|
| 37 |
async def status():
|
|
|
|
| 38 |
ollama_ok = False
|
| 39 |
try:
|
| 40 |
async with httpx.AsyncClient(timeout=5.0) as client:
|
|
|
|
| 45 |
|
| 46 |
return {
|
| 47 |
"service": "Précis API",
|
| 48 |
+
"version": "0.4.0",
|
| 49 |
+
"default_model": DEFAULT_MODEL,
|
| 50 |
+
"available_models": AVAILABLE_MODELS,
|
| 51 |
"ollama_reachable": ollama_ok,
|
|
|
|
|
|
|
| 52 |
}
|
| 53 |
|
| 54 |
|
| 55 |
+
@app.get("/models")
|
| 56 |
+
async def list_models():
|
| 57 |
+
return {"default": DEFAULT_MODEL, "available": AVAILABLE_MODELS}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 58 |
|
| 59 |
|
| 60 |
+
@app.post("/summarize/transcript")
|
| 61 |
+
async def summarize_transcript(request: TranscriptRequest):
|
|
|
|
|
|
|
|
|
|
|
|
|
| 62 |
if not request.text.strip():
|
| 63 |
+
raise HTTPException(status_code=400, detail="Text must not be empty.")
|
| 64 |
+
return stream_summary(request.text, title=request.title, model=request.model)
|
| 65 |
|
|
|
|
| 66 |
|
| 67 |
+
@app.post("/summarize/youtube")
|
| 68 |
+
async def summarize_youtube(request: YouTubeRequest):
|
| 69 |
+
video_id = extract_video_id(request.url)
|
| 70 |
+
text = await asyncio.to_thread(fetch_transcript, video_id)
|
| 71 |
+
return stream_summary(text, model=request.model)
|
| 72 |
|
| 73 |
|
| 74 |
+
@app.post("/summarize/file")
|
| 75 |
+
async def summarize_file(file: UploadFile = File(...), model: Optional[str] = None):
|
|
|
|
| 76 |
if not file.filename.endswith(".txt"):
|
| 77 |
+
raise HTTPException(status_code=400, detail="Only .txt files are supported.")
|
|
|
|
| 78 |
content = await file.read()
|
| 79 |
text = content.decode("utf-8")
|
|
|
|
| 80 |
if not text.strip():
|
| 81 |
+
raise HTTPException(status_code=400, detail="Uploaded file is empty.")
|
| 82 |
+
return stream_summary(text, title=file.filename, model=model)
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 83 |
|
|
|
|
|
|
|
|
|
|
| 84 |
|
| 85 |
if __name__ == "__main__":
|
| 86 |
import uvicorn
|
backend/config.py
ADDED
|
@@ -0,0 +1,5 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
OLLAMA_BASE_URL = "http://127.0.0.1:11434"
|
| 2 |
+
DEFAULT_MODEL = "phi4-mini:latest"
|
| 3 |
+
AVAILABLE_MODELS = ["phi4-mini:latest", "qwen:4b"]
|
| 4 |
+
MAX_SUMMARY_TOKENS = 120
|
| 5 |
+
TEMPERATURE = 0.2
|
backend/ollama.py
ADDED
|
@@ -0,0 +1,88 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""Ollama integration: prompt building, model validation, and streaming."""
|
| 2 |
+
|
| 3 |
+
from typing import Optional
|
| 4 |
+
|
| 5 |
+
import httpx
|
| 6 |
+
from fastapi import HTTPException
|
| 7 |
+
from fastapi.responses import StreamingResponse
|
| 8 |
+
|
| 9 |
+
from config import (
|
| 10 |
+
OLLAMA_BASE_URL, DEFAULT_MODEL, AVAILABLE_MODELS,
|
| 11 |
+
MAX_SUMMARY_TOKENS, TEMPERATURE,
|
| 12 |
+
)
|
| 13 |
+
|
| 14 |
+
|
| 15 |
+
def build_prompt(title: Optional[str], text: str) -> str:
|
| 16 |
+
if title:
|
| 17 |
+
instructions = (
|
| 18 |
+
f'The article is titled "{title}". '
|
| 19 |
+
"If the title is a question, answer it directly in one sentence using only facts from the article. "
|
| 20 |
+
"If the title is not a question, write one sentence that gives a concise, high-level overview "
|
| 21 |
+
"of the article, briefly enumerating all key facts."
|
| 22 |
+
)
|
| 23 |
+
else:
|
| 24 |
+
instructions = (
|
| 25 |
+
"Write one sentence that gives a concise, high-level overview of the article, "
|
| 26 |
+
"briefly enumerating all key facts."
|
| 27 |
+
)
|
| 28 |
+
return (
|
| 29 |
+
f"{instructions}\n"
|
| 30 |
+
"Do not add opinions, commentary, or filler phrases like 'The article discusses'.\n"
|
| 31 |
+
"Output the summary sentence only — nothing else.\n\n"
|
| 32 |
+
f"Article:\n{text}\n\n"
|
| 33 |
+
"Summary:"
|
| 34 |
+
)
|
| 35 |
+
|
| 36 |
+
|
| 37 |
+
def resolve_model(model: Optional[str]) -> str:
|
| 38 |
+
if not model:
|
| 39 |
+
return DEFAULT_MODEL
|
| 40 |
+
if model not in AVAILABLE_MODELS:
|
| 41 |
+
raise HTTPException(
|
| 42 |
+
status_code=400,
|
| 43 |
+
detail=f"Unknown model '{model}'. Available: {AVAILABLE_MODELS}",
|
| 44 |
+
)
|
| 45 |
+
return model
|
| 46 |
+
|
| 47 |
+
|
| 48 |
+
async def ollama_stream(prompt: str, model: str):
|
| 49 |
+
"""Async generator: yields raw NDJSON lines from Ollama."""
|
| 50 |
+
payload = {
|
| 51 |
+
"model": model,
|
| 52 |
+
"prompt": prompt,
|
| 53 |
+
"stream": True,
|
| 54 |
+
"options": {
|
| 55 |
+
"num_predict": MAX_SUMMARY_TOKENS,
|
| 56 |
+
"temperature": TEMPERATURE,
|
| 57 |
+
"stop": ["\n\n", "Article:", "Title:"],
|
| 58 |
+
},
|
| 59 |
+
}
|
| 60 |
+
async with httpx.AsyncClient(timeout=120.0) as client:
|
| 61 |
+
try:
|
| 62 |
+
async with client.stream(
|
| 63 |
+
"POST", f"{OLLAMA_BASE_URL}/api/generate", json=payload,
|
| 64 |
+
) as resp:
|
| 65 |
+
resp.raise_for_status()
|
| 66 |
+
async for line in resp.aiter_lines():
|
| 67 |
+
if line:
|
| 68 |
+
yield line + "\n"
|
| 69 |
+
except httpx.ConnectError:
|
| 70 |
+
raise HTTPException(
|
| 71 |
+
status_code=503,
|
| 72 |
+
detail="Cannot reach Ollama. Make sure `ollama serve` is running.",
|
| 73 |
+
)
|
| 74 |
+
|
| 75 |
+
|
| 76 |
+
def stream_summary(
|
| 77 |
+
text: str,
|
| 78 |
+
title: Optional[str] = None,
|
| 79 |
+
model: Optional[str] = None,
|
| 80 |
+
) -> StreamingResponse:
|
| 81 |
+
"""Universal funnel: text -> prompt -> Ollama stream -> NDJSON response."""
|
| 82 |
+
resolved = resolve_model(model)
|
| 83 |
+
prompt = build_prompt(title, text)
|
| 84 |
+
return StreamingResponse(
|
| 85 |
+
ollama_stream(prompt, resolved),
|
| 86 |
+
media_type="application/x-ndjson",
|
| 87 |
+
headers={"X-Accel-Buffering": "no"},
|
| 88 |
+
)
|
backend/schemas.py
ADDED
|
@@ -0,0 +1,13 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
from typing import Optional
|
| 2 |
+
from pydantic import BaseModel
|
| 3 |
+
|
| 4 |
+
|
| 5 |
+
class YouTubeRequest(BaseModel):
|
| 6 |
+
url: str
|
| 7 |
+
model: Optional[str] = None
|
| 8 |
+
|
| 9 |
+
|
| 10 |
+
class TranscriptRequest(BaseModel):
|
| 11 |
+
text: str
|
| 12 |
+
title: Optional[str] = None
|
| 13 |
+
model: Optional[str] = None
|
backend/youtube.py
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
"""YouTube transcript extraction."""
|
| 2 |
+
|
| 3 |
+
import re
|
| 4 |
+
|
| 5 |
+
from fastapi import HTTPException
|
| 6 |
+
from youtube_transcript_api import YouTubeTranscriptApi
|
| 7 |
+
from youtube_transcript_api._errors import (
|
| 8 |
+
TranscriptsDisabled,
|
| 9 |
+
NoTranscriptFound,
|
| 10 |
+
VideoUnavailable,
|
| 11 |
+
)
|
| 12 |
+
|
| 13 |
+
YT_ID_RE = re.compile(r"(?:v=|youtu\.be/|embed/|shorts/)([A-Za-z0-9_-]{11})")
|
| 14 |
+
|
| 15 |
+
|
| 16 |
+
def extract_video_id(url: str) -> str:
|
| 17 |
+
match = YT_ID_RE.search(url)
|
| 18 |
+
if not match:
|
| 19 |
+
raise HTTPException(status_code=400, detail="Could not extract a video ID from that URL.")
|
| 20 |
+
return match.group(1)
|
| 21 |
+
|
| 22 |
+
|
| 23 |
+
def fetch_transcript(video_id: str) -> str:
|
| 24 |
+
"""Synchronous transcript fetch — call via asyncio.to_thread."""
|
| 25 |
+
ytt = YouTubeTranscriptApi()
|
| 26 |
+
try:
|
| 27 |
+
transcript = ytt.fetch(video_id, languages=["en", "en-US", "en-GB"])
|
| 28 |
+
except TranscriptsDisabled:
|
| 29 |
+
raise HTTPException(status_code=422, detail="This video has transcripts disabled.")
|
| 30 |
+
except NoTranscriptFound:
|
| 31 |
+
raise HTTPException(status_code=422, detail="No transcript found for this video.")
|
| 32 |
+
except VideoUnavailable:
|
| 33 |
+
raise HTTPException(status_code=404, detail="Video is unavailable or does not exist.")
|
| 34 |
+
except Exception as exc:
|
| 35 |
+
raise HTTPException(status_code=502, detail=f"Transcript fetch failed: {exc}")
|
| 36 |
+
|
| 37 |
+
return " ".join(snippet.text for snippet in transcript)
|
frontend/index.html
CHANGED
|
@@ -2,9 +2,10 @@
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
-
<link rel="icon" type="image/svg+xml" href="/
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
-
<
|
|
|
|
| 8 |
</head>
|
| 9 |
<body>
|
| 10 |
<div id="root"></div>
|
|
|
|
| 2 |
<html lang="en">
|
| 3 |
<head>
|
| 4 |
<meta charset="UTF-8" />
|
| 5 |
+
<link rel="icon" type="image/svg+xml" href="/src/assets/logo.svg" />
|
| 6 |
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
| 7 |
+
<meta name="description" content="Précis — Summarize YouTube videos, articles, and text files with local AI models." />
|
| 8 |
+
<title>Précis</title>
|
| 9 |
</head>
|
| 10 |
<body>
|
| 11 |
<div id="root"></div>
|
frontend/src/App.css
CHANGED
|
@@ -3,24 +3,71 @@
|
|
| 3 |
display: flex;
|
| 4 |
align-items: center;
|
| 5 |
justify-content: space-between;
|
| 6 |
-
padding: var(--spacing-
|
| 7 |
background-color: var(--color-canvas-subtle);
|
| 8 |
border-bottom: 1px solid var(--color-border-default);
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 9 |
}
|
| 10 |
|
| 11 |
.logo {
|
| 12 |
display: flex;
|
| 13 |
align-items: center;
|
| 14 |
-
gap:
|
| 15 |
-
font-size: 20px;
|
| 16 |
-
font-weight: 600;
|
| 17 |
-
color: var(--color-fg-default);
|
| 18 |
text-decoration: none;
|
|
|
|
| 19 |
}
|
| 20 |
|
| 21 |
.logo-icon {
|
| 22 |
-
width:
|
| 23 |
-
height:
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 24 |
}
|
| 25 |
|
| 26 |
/* Main content */
|
|
@@ -52,6 +99,12 @@
|
|
| 52 |
border: 1px solid var(--color-border-default);
|
| 53 |
border-radius: var(--radius-2);
|
| 54 |
overflow: hidden;
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 55 |
}
|
| 56 |
|
| 57 |
.upload-header {
|
|
@@ -264,7 +317,7 @@
|
|
| 264 |
text-decoration: underline;
|
| 265 |
}
|
| 266 |
|
| 267 |
-
/*
|
| 268 |
.inline-result {
|
| 269 |
margin-top: var(--spacing-3);
|
| 270 |
padding: var(--spacing-3) var(--spacing-4);
|
|
|
|
| 3 |
display: flex;
|
| 4 |
align-items: center;
|
| 5 |
justify-content: space-between;
|
| 6 |
+
padding: var(--spacing-2) var(--spacing-4);
|
| 7 |
background-color: var(--color-canvas-subtle);
|
| 8 |
border-bottom: 1px solid var(--color-border-default);
|
| 9 |
+
position: sticky;
|
| 10 |
+
top: 0;
|
| 11 |
+
z-index: 10;
|
| 12 |
+
backdrop-filter: blur(12px);
|
| 13 |
+
background-color: rgba(22, 27, 34, 0.85);
|
| 14 |
}
|
| 15 |
|
| 16 |
.logo {
|
| 17 |
display: flex;
|
| 18 |
align-items: center;
|
| 19 |
+
gap: 10px;
|
|
|
|
|
|
|
|
|
|
| 20 |
text-decoration: none;
|
| 21 |
+
color: var(--color-fg-default);
|
| 22 |
}
|
| 23 |
|
| 24 |
.logo-icon {
|
| 25 |
+
width: 28px;
|
| 26 |
+
height: 28px;
|
| 27 |
+
border-radius: 4px;
|
| 28 |
+
}
|
| 29 |
+
|
| 30 |
+
.logo-text {
|
| 31 |
+
font-size: 18px;
|
| 32 |
+
font-weight: 600;
|
| 33 |
+
letter-spacing: -0.01em;
|
| 34 |
+
}
|
| 35 |
+
|
| 36 |
+
.header-actions {
|
| 37 |
+
display: flex;
|
| 38 |
+
align-items: center;
|
| 39 |
+
gap: var(--spacing-2);
|
| 40 |
+
}
|
| 41 |
+
|
| 42 |
+
.model-select {
|
| 43 |
+
appearance: none;
|
| 44 |
+
background: var(--color-canvas-default);
|
| 45 |
+
border: 1px solid var(--color-border-default);
|
| 46 |
+
border-radius: 6px;
|
| 47 |
+
color: var(--color-fg-default);
|
| 48 |
+
font-size: 13px;
|
| 49 |
+
font-family: inherit;
|
| 50 |
+
padding: 6px 28px 6px 10px;
|
| 51 |
+
cursor: pointer;
|
| 52 |
+
transition: border-color 0.15s;
|
| 53 |
+
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 24 24' fill='none' stroke='%23888' stroke-width='2'%3E%3Cpolyline points='6 9 12 15 18 9'/%3E%3C/svg%3E");
|
| 54 |
+
background-repeat: no-repeat;
|
| 55 |
+
background-position: right 8px center;
|
| 56 |
+
}
|
| 57 |
+
|
| 58 |
+
.model-select:hover {
|
| 59 |
+
border-color: var(--color-accent-fg);
|
| 60 |
+
}
|
| 61 |
+
|
| 62 |
+
.model-select:focus {
|
| 63 |
+
outline: none;
|
| 64 |
+
border-color: var(--color-accent-fg);
|
| 65 |
+
box-shadow: 0 0 0 3px rgba(99, 102, 241, 0.15);
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
.model-select:disabled {
|
| 69 |
+
opacity: 0.5;
|
| 70 |
+
cursor: not-allowed;
|
| 71 |
}
|
| 72 |
|
| 73 |
/* Main content */
|
|
|
|
| 99 |
border: 1px solid var(--color-border-default);
|
| 100 |
border-radius: var(--radius-2);
|
| 101 |
overflow: hidden;
|
| 102 |
+
box-shadow: var(--shadow-md);
|
| 103 |
+
transition: box-shadow 0.2s ease;
|
| 104 |
+
}
|
| 105 |
+
|
| 106 |
+
.upload-card:hover {
|
| 107 |
+
box-shadow: var(--shadow-lg);
|
| 108 |
}
|
| 109 |
|
| 110 |
.upload-header {
|
|
|
|
| 317 |
text-decoration: underline;
|
| 318 |
}
|
| 319 |
|
| 320 |
+
/* Inline result */
|
| 321 |
.inline-result {
|
| 322 |
margin-top: var(--spacing-3);
|
| 323 |
padding: var(--spacing-3) var(--spacing-4);
|
frontend/src/App.jsx
CHANGED
|
@@ -1,125 +1,31 @@
|
|
| 1 |
import { useState, useRef } from 'react'
|
|
|
|
|
|
|
|
|
|
| 2 |
import './App.css'
|
| 3 |
|
| 4 |
const API_BASE = 'http://localhost:8000'
|
|
|
|
| 5 |
|
| 6 |
function App() {
|
| 7 |
const [activeTab, setActiveTab] = useState('youtube')
|
| 8 |
const [youtubeUrl, setYoutubeUrl] = useState('')
|
| 9 |
const [transcript, setTranscript] = useState('')
|
| 10 |
const [selectedFile, setSelectedFile] = useState(null)
|
| 11 |
-
const [
|
| 12 |
-
const [response, setResponse] = useState(null)
|
| 13 |
-
const [error, setError] = useState(null)
|
| 14 |
-
const [streamingText, setStreamingText] = useState('')
|
| 15 |
const fileInputRef = useRef(null)
|
| 16 |
-
const abortRef = useRef(null)
|
| 17 |
|
| 18 |
-
|
| 19 |
-
* Stream tokens from the backend's /summarize/transcript/stream endpoint.
|
| 20 |
-
* The backend owns the prompt — we just send the raw text and title.
|
| 21 |
-
*/
|
| 22 |
-
const streamFromBackend = async (text, title) => {
|
| 23 |
-
abortRef.current = new AbortController()
|
| 24 |
|
| 25 |
-
|
| 26 |
-
|
| 27 |
-
headers: { 'Content-Type': 'application/json' },
|
| 28 |
-
signal: abortRef.current.signal,
|
| 29 |
-
body: JSON.stringify({ text, title: title || null }),
|
| 30 |
-
})
|
| 31 |
-
|
| 32 |
-
if (!res.ok) {
|
| 33 |
-
const body = await res.text()
|
| 34 |
-
throw new Error(`Backend error (${res.status}): ${body}`)
|
| 35 |
-
}
|
| 36 |
-
|
| 37 |
-
const reader = res.body.getReader()
|
| 38 |
-
const decoder = new TextDecoder()
|
| 39 |
-
let accumulated = ''
|
| 40 |
-
let buffer = ''
|
| 41 |
-
|
| 42 |
-
while (true) {
|
| 43 |
-
const { done, value } = await reader.read()
|
| 44 |
-
if (done) break
|
| 45 |
-
|
| 46 |
-
buffer += decoder.decode(value, { stream: true })
|
| 47 |
-
|
| 48 |
-
const lines = buffer.split('\n')
|
| 49 |
-
buffer = lines.pop() // keep incomplete line in buffer
|
| 50 |
-
|
| 51 |
-
for (const line of lines) {
|
| 52 |
-
if (!line.trim()) continue
|
| 53 |
-
try {
|
| 54 |
-
const chunk = JSON.parse(line)
|
| 55 |
-
if (chunk.response) {
|
| 56 |
-
accumulated += chunk.response
|
| 57 |
-
setStreamingText(accumulated)
|
| 58 |
-
}
|
| 59 |
-
} catch {
|
| 60 |
-
// skip malformed lines
|
| 61 |
-
}
|
| 62 |
-
}
|
| 63 |
-
}
|
| 64 |
-
|
| 65 |
-
return accumulated.trim()
|
| 66 |
-
}
|
| 67 |
-
|
| 68 |
-
const handleSubmit = async () => {
|
| 69 |
-
setLoading(true)
|
| 70 |
-
setError(null)
|
| 71 |
-
setResponse(null)
|
| 72 |
-
|
| 73 |
-
try {
|
| 74 |
-
let result
|
| 75 |
-
|
| 76 |
-
if (activeTab === 'youtube') {
|
| 77 |
-
if (!youtubeUrl.trim()) {
|
| 78 |
-
throw new Error('Please enter a YouTube URL')
|
| 79 |
-
}
|
| 80 |
-
const res = await fetch(`${API_BASE}/summarize/youtube`, {
|
| 81 |
-
method: 'POST',
|
| 82 |
-
headers: { 'Content-Type': 'application/json' },
|
| 83 |
-
body: JSON.stringify({ url: youtubeUrl })
|
| 84 |
-
})
|
| 85 |
-
result = await res.json()
|
| 86 |
-
} else if (activeTab === 'transcript') {
|
| 87 |
-
if (!transcript.trim()) {
|
| 88 |
-
throw new Error('Please enter some text')
|
| 89 |
-
}
|
| 90 |
-
setStreamingText('')
|
| 91 |
-
const summary = await streamFromBackend(transcript, null)
|
| 92 |
-
result = { summary, success: true, source_type: 'transcript', model: 'phi4-mini:3.8b' }
|
| 93 |
-
} else if (activeTab === 'file') {
|
| 94 |
-
if (!selectedFile) {
|
| 95 |
-
throw new Error('Please select a file')
|
| 96 |
-
}
|
| 97 |
-
const formData = new FormData()
|
| 98 |
-
formData.append('file', selectedFile)
|
| 99 |
-
const res = await fetch(`${API_BASE}/summarize/file`, {
|
| 100 |
-
method: 'POST',
|
| 101 |
-
body: formData
|
| 102 |
-
})
|
| 103 |
-
result = await res.json()
|
| 104 |
-
}
|
| 105 |
-
|
| 106 |
-
setResponse(result)
|
| 107 |
-
} catch (err) {
|
| 108 |
-
setError(err.message || 'An error occurred')
|
| 109 |
-
} finally {
|
| 110 |
-
setLoading(false)
|
| 111 |
-
}
|
| 112 |
-
}
|
| 113 |
|
| 114 |
const handleFileDrop = (e) => {
|
| 115 |
e.preventDefault()
|
| 116 |
e.stopPropagation()
|
| 117 |
const file = e.dataTransfer?.files[0] || e.target.files?.[0]
|
| 118 |
-
if (file && file.name.endsWith('.txt'))
|
| 119 |
-
|
| 120 |
-
} else if (file) {
|
| 121 |
-
setError('Only .txt files are supported')
|
| 122 |
-
}
|
| 123 |
}
|
| 124 |
|
| 125 |
const formatFileSize = (bytes) => {
|
|
@@ -128,20 +34,32 @@ function App() {
|
|
| 128 |
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
| 129 |
}
|
| 130 |
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 131 |
return (
|
| 132 |
<>
|
| 133 |
<header className="header">
|
| 134 |
<a href="/" className="logo">
|
| 135 |
-
<
|
| 136 |
-
|
| 137 |
-
<path d="M2 17l10 5 10-5" />
|
| 138 |
-
<path d="M2 12l10 5 10-5" />
|
| 139 |
-
</svg>
|
| 140 |
-
Précis
|
| 141 |
-
</a>
|
| 142 |
-
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn">
|
| 143 |
-
API Docs
|
| 144 |
</a>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 145 |
</header>
|
| 146 |
|
| 147 |
<main className="main">
|
|
@@ -166,42 +84,30 @@ function App() {
|
|
| 166 |
|
| 167 |
<div className="upload-body">
|
| 168 |
<div className="tabs">
|
| 169 |
-
|
| 170 |
-
className={`tab ${activeTab ===
|
| 171 |
-
|
| 172 |
-
|
| 173 |
-
|
| 174 |
-
</button>
|
| 175 |
-
<button
|
| 176 |
-
className={`tab ${activeTab === 'transcript' ? 'active' : ''}`}
|
| 177 |
-
onClick={() => setActiveTab('transcript')}
|
| 178 |
-
>
|
| 179 |
-
Article / Transcript
|
| 180 |
-
</button>
|
| 181 |
-
<button
|
| 182 |
-
className={`tab ${activeTab === 'file' ? 'active' : ''}`}
|
| 183 |
-
onClick={() => setActiveTab('file')}
|
| 184 |
-
>
|
| 185 |
-
Text File
|
| 186 |
-
</button>
|
| 187 |
</div>
|
| 188 |
|
| 189 |
-
{/* YouTube
|
| 190 |
<div className={`tab-panel ${activeTab === 'youtube' ? 'active' : ''}`}>
|
| 191 |
<div className="form-group">
|
| 192 |
<label className="form-label">YouTube URL</label>
|
| 193 |
<input
|
| 194 |
-
type="url"
|
| 195 |
-
className="input"
|
| 196 |
placeholder="https://www.youtube.com/watch?v=..."
|
| 197 |
value={youtubeUrl}
|
| 198 |
onChange={(e) => setYoutubeUrl(e.target.value)}
|
|
|
|
| 199 |
/>
|
| 200 |
-
<p className="form-hint">Paste
|
| 201 |
</div>
|
|
|
|
| 202 |
</div>
|
| 203 |
|
| 204 |
-
{/* Transcript
|
| 205 |
<div className={`tab-panel ${activeTab === 'transcript' ? 'active' : ''}`}>
|
| 206 |
<div className="form-group">
|
| 207 |
<label className="form-label">Article or Transcript Text</label>
|
|
@@ -210,70 +116,31 @@ function App() {
|
|
| 210 |
placeholder="Paste your article or transcript here..."
|
| 211 |
value={transcript}
|
| 212 |
onChange={(e) => setTranscript(e.target.value)}
|
| 213 |
-
onKeyDown={
|
| 214 |
-
if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
|
| 215 |
-
}}
|
| 216 |
/>
|
| 217 |
-
<p className="form-hint">
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 218 |
</div>
|
| 219 |
-
|
| 220 |
-
{/* Inline result — only shown when this tab triggered it */}
|
| 221 |
-
{activeTab === 'transcript' && error && (
|
| 222 |
-
<div className="inline-result inline-result--error fade-in">
|
| 223 |
-
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" /></svg>
|
| 224 |
-
{error}
|
| 225 |
-
</div>
|
| 226 |
-
)}
|
| 227 |
-
{activeTab === 'transcript' && loading && (
|
| 228 |
-
<div className="inline-result inline-result--streaming fade-in">
|
| 229 |
-
<div className="inline-result__label">
|
| 230 |
-
<span className="loading-spinner" style={{ width: 12, height: 12 }} />
|
| 231 |
-
Generating…
|
| 232 |
-
<span className="response-badge" style={{ marginLeft: 'auto' }}>phi4-mini:3.8b</span>
|
| 233 |
-
</div>
|
| 234 |
-
<p className="inline-result__text">
|
| 235 |
-
{streamingText || <span className="streaming-placeholder">Waiting for model…</span>}
|
| 236 |
-
<span className="streaming-cursor">▌</span>
|
| 237 |
-
</p>
|
| 238 |
-
</div>
|
| 239 |
-
)}
|
| 240 |
-
{activeTab === 'transcript' && response && !loading && (
|
| 241 |
-
<div className="inline-result inline-result--success fade-in">
|
| 242 |
-
<div className="inline-result__label">
|
| 243 |
-
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2"><polyline points="20 6 9 17 4 12" /></svg>
|
| 244 |
-
Summary
|
| 245 |
-
<span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
|
| 246 |
-
</div>
|
| 247 |
-
<p className="inline-result__text">{response.summary}</p>
|
| 248 |
-
</div>
|
| 249 |
-
)}
|
| 250 |
</div>
|
| 251 |
|
| 252 |
-
{/* File
|
| 253 |
<div className={`tab-panel ${activeTab === 'file' ? 'active' : ''}`}>
|
| 254 |
<div className="form-group">
|
| 255 |
<label className="form-label">Text File (.txt)</label>
|
| 256 |
-
<div
|
| 257 |
-
className={`dropzone ${selectedFile ? '' : ''}`}
|
| 258 |
-
onClick={() => fileInputRef.current?.click()}
|
| 259 |
-
onDrop={handleFileDrop}
|
| 260 |
-
onDragOver={(e) => e.preventDefault()}
|
| 261 |
-
>
|
| 262 |
<svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 263 |
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
| 264 |
</svg>
|
| 265 |
-
<p className="dropzone-text">
|
| 266 |
-
Drag and drop a <strong>.txt</strong> file here, or click to browse
|
| 267 |
-
</p>
|
| 268 |
<p className="dropzone-hint">Maximum file size: 10 MB</p>
|
| 269 |
</div>
|
| 270 |
-
<input
|
| 271 |
-
ref={fileInputRef}
|
| 272 |
-
type="file"
|
| 273 |
-
className="file-input"
|
| 274 |
-
accept=".txt"
|
| 275 |
-
onChange={handleFileDrop}
|
| 276 |
-
/>
|
| 277 |
|
| 278 |
{selectedFile && (
|
| 279 |
<div className="file-selected">
|
|
@@ -289,39 +156,25 @@ function App() {
|
|
| 289 |
<div className="file-name">{selectedFile.name}</div>
|
| 290 |
<div className="file-size">{formatFileSize(selectedFile.size)}</div>
|
| 291 |
</div>
|
| 292 |
-
<button
|
| 293 |
-
className="file-remove"
|
| 294 |
-
onClick={(e) => {
|
| 295 |
-
e.stopPropagation()
|
| 296 |
-
setSelectedFile(null)
|
| 297 |
-
}}
|
| 298 |
-
>
|
| 299 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 300 |
-
<line x1="18" y1="6" x2="6" y2="18" />
|
| 301 |
-
<line x1="6" y1="6" x2="18" y2="18" />
|
| 302 |
</svg>
|
| 303 |
</button>
|
| 304 |
</div>
|
| 305 |
)}
|
| 306 |
</div>
|
|
|
|
| 307 |
</div>
|
| 308 |
|
| 309 |
<div className="submit-section">
|
| 310 |
-
<button
|
| 311 |
-
className="btn btn-primary btn-lg"
|
| 312 |
-
onClick={handleSubmit}
|
| 313 |
-
disabled={loading}
|
| 314 |
-
>
|
| 315 |
{loading ? (
|
| 316 |
-
<>
|
| 317 |
-
<span className="loading-spinner" style={{ width: 16, height: 16 }}></span>
|
| 318 |
-
Processing...
|
| 319 |
-
</>
|
| 320 |
) : (
|
| 321 |
<>
|
| 322 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 323 |
-
<path d="M22 2L11 13" />
|
| 324 |
-
<path d="M22 2L15 22l-4-9-9-4L22 2z" />
|
| 325 |
</svg>
|
| 326 |
Generate Summary
|
| 327 |
</>
|
|
@@ -330,56 +183,12 @@ function App() {
|
|
| 330 |
</div>
|
| 331 |
</div>
|
| 332 |
</div>
|
| 333 |
-
|
| 334 |
-
{/* Error display — for YouTube / File tabs only (transcript shows inline) */}
|
| 335 |
-
{error && activeTab !== 'transcript' && (
|
| 336 |
-
<div className="response-section fade-in">
|
| 337 |
-
<div className="response-card" style={{ borderColor: 'var(--color-danger-fg)' }}>
|
| 338 |
-
<div className="response-header" style={{ borderColor: 'var(--color-danger-fg)' }}>
|
| 339 |
-
<div className="response-title" style={{ color: 'var(--color-danger-fg)' }}>
|
| 340 |
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 341 |
-
<circle cx="12" cy="12" r="10" />
|
| 342 |
-
<line x1="12" y1="8" x2="12" y2="12" />
|
| 343 |
-
<line x1="12" y1="16" x2="12.01" y2="16" />
|
| 344 |
-
</svg>
|
| 345 |
-
Error
|
| 346 |
-
</div>
|
| 347 |
-
</div>
|
| 348 |
-
<div className="response-body">
|
| 349 |
-
<p className="response-text" style={{ color: 'var(--color-danger-fg)' }}>{error}</p>
|
| 350 |
-
</div>
|
| 351 |
-
</div>
|
| 352 |
-
</div>
|
| 353 |
-
)}
|
| 354 |
-
|
| 355 |
-
{/* Response display — for YouTube / File tabs only (transcript shows inline) */}
|
| 356 |
-
{response && activeTab !== 'transcript' && (
|
| 357 |
-
<div className="response-section fade-in">
|
| 358 |
-
<div className="response-card">
|
| 359 |
-
<div className="response-header">
|
| 360 |
-
<div className="response-title">
|
| 361 |
-
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 362 |
-
<path d="M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8z" />
|
| 363 |
-
<polyline points="14 2 14 8 20 8" />
|
| 364 |
-
<line x1="16" y1="13" x2="8" y2="13" />
|
| 365 |
-
<line x1="16" y1="17" x2="8" y2="17" />
|
| 366 |
-
</svg>
|
| 367 |
-
Summary
|
| 368 |
-
</div>
|
| 369 |
-
<span className="response-badge">{response.source_type}</span>
|
| 370 |
-
</div>
|
| 371 |
-
<div className="response-body">
|
| 372 |
-
<p className="response-text">{response.summary}</p>
|
| 373 |
-
</div>
|
| 374 |
-
</div>
|
| 375 |
-
</div>
|
| 376 |
-
)}
|
| 377 |
</div>
|
| 378 |
</div>
|
| 379 |
</main>
|
| 380 |
|
| 381 |
<footer className="footer">
|
| 382 |
-
<p>Précis © 2026 ·
|
| 383 |
</footer>
|
| 384 |
</>
|
| 385 |
)
|
|
|
|
| 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 './App.css'
|
| 6 |
|
| 7 |
const API_BASE = 'http://localhost:8000'
|
| 8 |
+
const MODELS = ['phi4-mini:latest', 'qwen:4b']
|
| 9 |
|
| 10 |
function App() {
|
| 11 |
const [activeTab, setActiveTab] = useState('youtube')
|
| 12 |
const [youtubeUrl, setYoutubeUrl] = useState('')
|
| 13 |
const [transcript, setTranscript] = useState('')
|
| 14 |
const [selectedFile, setSelectedFile] = useState(null)
|
| 15 |
+
const [selectedModel, setSelectedModel] = useState(MODELS[0])
|
|
|
|
|
|
|
|
|
|
| 16 |
const fileInputRef = useRef(null)
|
|
|
|
| 17 |
|
| 18 |
+
const { loading, response, error, streamingText, submit } = useStreaming()
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 19 |
|
| 20 |
+
const handleSubmit = () =>
|
| 21 |
+
submit(activeTab, { youtubeUrl, transcript, selectedFile, selectedModel })
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 22 |
|
| 23 |
const handleFileDrop = (e) => {
|
| 24 |
e.preventDefault()
|
| 25 |
e.stopPropagation()
|
| 26 |
const file = e.dataTransfer?.files[0] || e.target.files?.[0]
|
| 27 |
+
if (file && file.name.endsWith('.txt')) setSelectedFile(file)
|
| 28 |
+
else if (file) alert('Only .txt files are supported')
|
|
|
|
|
|
|
|
|
|
| 29 |
}
|
| 30 |
|
| 31 |
const formatFileSize = (bytes) => {
|
|
|
|
| 34 |
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
|
| 35 |
}
|
| 36 |
|
| 37 |
+
const ctrlEnter = (e) => {
|
| 38 |
+
if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const resultProps = { error, loading, response, streamingText, selectedModel }
|
| 42 |
+
|
| 43 |
return (
|
| 44 |
<>
|
| 45 |
<header className="header">
|
| 46 |
<a href="/" className="logo">
|
| 47 |
+
<img src={logoSvg} alt="Précis" className="logo-icon" />
|
| 48 |
+
<span className="logo-text">Précis</span>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 49 |
</a>
|
| 50 |
+
<div className="header-actions">
|
| 51 |
+
<select
|
| 52 |
+
className="model-select"
|
| 53 |
+
value={selectedModel}
|
| 54 |
+
onChange={(e) => setSelectedModel(e.target.value)}
|
| 55 |
+
disabled={loading}
|
| 56 |
+
>
|
| 57 |
+
{MODELS.map((m) => <option key={m} value={m}>{m}</option>)}
|
| 58 |
+
</select>
|
| 59 |
+
<a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer" className="btn" style={{ textDecoration: 'none' }}>
|
| 60 |
+
API Docs
|
| 61 |
+
</a>
|
| 62 |
+
</div>
|
| 63 |
</header>
|
| 64 |
|
| 65 |
<main className="main">
|
|
|
|
| 84 |
|
| 85 |
<div className="upload-body">
|
| 86 |
<div className="tabs">
|
| 87 |
+
{[['youtube', 'YouTube Video'], ['transcript', 'Article / Transcript'], ['file', 'Text File']].map(([key, label]) => (
|
| 88 |
+
<button key={key} className={`tab ${activeTab === key ? 'active' : ''}`} onClick={() => setActiveTab(key)}>
|
| 89 |
+
{label}
|
| 90 |
+
</button>
|
| 91 |
+
))}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 92 |
</div>
|
| 93 |
|
| 94 |
+
{/* YouTube */}
|
| 95 |
<div className={`tab-panel ${activeTab === 'youtube' ? 'active' : ''}`}>
|
| 96 |
<div className="form-group">
|
| 97 |
<label className="form-label">YouTube URL</label>
|
| 98 |
<input
|
| 99 |
+
type="url" className="input"
|
|
|
|
| 100 |
placeholder="https://www.youtube.com/watch?v=..."
|
| 101 |
value={youtubeUrl}
|
| 102 |
onChange={(e) => setYoutubeUrl(e.target.value)}
|
| 103 |
+
onKeyDown={ctrlEnter}
|
| 104 |
/>
|
| 105 |
+
<p className="form-hint">Paste a YouTube URL. Ctrl+Enter to generate.</p>
|
| 106 |
</div>
|
| 107 |
+
{activeTab === 'youtube' && <InlineResult {...resultProps} loadingLabel="Fetching transcript…" />}
|
| 108 |
</div>
|
| 109 |
|
| 110 |
+
{/* Transcript */}
|
| 111 |
<div className={`tab-panel ${activeTab === 'transcript' ? 'active' : ''}`}>
|
| 112 |
<div className="form-group">
|
| 113 |
<label className="form-label">Article or Transcript Text</label>
|
|
|
|
| 116 |
placeholder="Paste your article or transcript here..."
|
| 117 |
value={transcript}
|
| 118 |
onChange={(e) => setTranscript(e.target.value)}
|
| 119 |
+
onKeyDown={ctrlEnter}
|
|
|
|
|
|
|
| 120 |
/>
|
| 121 |
+
<p className="form-hint">
|
| 122 |
+
Paste any text you want to summarize.{' '}
|
| 123 |
+
<kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Ctrl</kbd>
|
| 124 |
+
{' + '}
|
| 125 |
+
<kbd style={{ fontFamily: 'inherit', background: 'var(--color-canvas-inset)', border: '1px solid var(--color-border-muted)', borderRadius: 3, padding: '0 4px', fontSize: 11 }}>Enter</kbd>
|
| 126 |
+
{' '}to generate.
|
| 127 |
+
</p>
|
| 128 |
</div>
|
| 129 |
+
{activeTab === 'transcript' && <InlineResult {...resultProps} loadingLabel="Generating…" />}
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 130 |
</div>
|
| 131 |
|
| 132 |
+
{/* File upload */}
|
| 133 |
<div className={`tab-panel ${activeTab === 'file' ? 'active' : ''}`}>
|
| 134 |
<div className="form-group">
|
| 135 |
<label className="form-label">Text File (.txt)</label>
|
| 136 |
+
<div className="dropzone" onClick={() => fileInputRef.current?.click()} onDrop={handleFileDrop} onDragOver={(e) => e.preventDefault()}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 137 |
<svg className="dropzone-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.5">
|
| 138 |
<path d="M7 16a4 4 0 01-.88-7.903A5 5 0 1115.9 6L16 6a5 5 0 011 9.9M15 13l-3-3m0 0l-3 3m3-3v12" />
|
| 139 |
</svg>
|
| 140 |
+
<p className="dropzone-text">Drag and drop a <strong>.txt</strong> file here, or click to browse</p>
|
|
|
|
|
|
|
| 141 |
<p className="dropzone-hint">Maximum file size: 10 MB</p>
|
| 142 |
</div>
|
| 143 |
+
<input ref={fileInputRef} type="file" className="file-input" accept=".txt" onChange={handleFileDrop} />
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 144 |
|
| 145 |
{selectedFile && (
|
| 146 |
<div className="file-selected">
|
|
|
|
| 156 |
<div className="file-name">{selectedFile.name}</div>
|
| 157 |
<div className="file-size">{formatFileSize(selectedFile.size)}</div>
|
| 158 |
</div>
|
| 159 |
+
<button className="file-remove" onClick={(e) => { e.stopPropagation(); setSelectedFile(null) }}>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 160 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 161 |
+
<line x1="18" y1="6" x2="6" y2="18" /><line x1="6" y1="6" x2="18" y2="18" />
|
|
|
|
| 162 |
</svg>
|
| 163 |
</button>
|
| 164 |
</div>
|
| 165 |
)}
|
| 166 |
</div>
|
| 167 |
+
{activeTab === 'file' && <InlineResult {...resultProps} loadingLabel="Reading file…" />}
|
| 168 |
</div>
|
| 169 |
|
| 170 |
<div className="submit-section">
|
| 171 |
+
<button className="btn btn-primary btn-lg" onClick={handleSubmit} disabled={loading}>
|
|
|
|
|
|
|
|
|
|
|
|
|
| 172 |
{loading ? (
|
| 173 |
+
<><span className="loading-spinner" style={{ width: 16, height: 16 }} /> Processing...</>
|
|
|
|
|
|
|
|
|
|
| 174 |
) : (
|
| 175 |
<>
|
| 176 |
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 177 |
+
<path d="M22 2L11 13" /><path d="M22 2L15 22l-4-9-9-4L22 2z" />
|
|
|
|
| 178 |
</svg>
|
| 179 |
Generate Summary
|
| 180 |
</>
|
|
|
|
| 183 |
</div>
|
| 184 |
</div>
|
| 185 |
</div>
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 186 |
</div>
|
| 187 |
</div>
|
| 188 |
</main>
|
| 189 |
|
| 190 |
<footer className="footer">
|
| 191 |
+
<p>Précis © 2026 · <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Documentation</a></p>
|
| 192 |
</footer>
|
| 193 |
</>
|
| 194 |
)
|
frontend/src/assets/logo.svg
ADDED
|
|
frontend/src/assets/react.svg
DELETED
frontend/src/components/InlineResult.jsx
ADDED
|
@@ -0,0 +1,39 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
export default function InlineResult({ error, loading, response, streamingText, selectedModel, loadingLabel }) {
|
| 2 |
+
return (
|
| 3 |
+
<>
|
| 4 |
+
{error && (
|
| 5 |
+
<div className="inline-result inline-result--error fade-in">
|
| 6 |
+
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 7 |
+
<circle cx="12" cy="12" r="10" /><line x1="12" y1="8" x2="12" y2="12" /><line x1="12" y1="16" x2="12.01" y2="16" />
|
| 8 |
+
</svg>
|
| 9 |
+
{error}
|
| 10 |
+
</div>
|
| 11 |
+
)}
|
| 12 |
+
{loading && (
|
| 13 |
+
<div className="inline-result inline-result--streaming fade-in">
|
| 14 |
+
<div className="inline-result__label">
|
| 15 |
+
<span className="loading-spinner" style={{ width: 12, height: 12 }} />
|
| 16 |
+
{streamingText ? 'Generating…' : (loadingLabel || 'Processing…')}
|
| 17 |
+
<span className="response-badge" style={{ marginLeft: 'auto' }}>{selectedModel}</span>
|
| 18 |
+
</div>
|
| 19 |
+
<p className="inline-result__text">
|
| 20 |
+
{streamingText || <span className="streaming-placeholder">Waiting for model…</span>}
|
| 21 |
+
<span className="streaming-cursor">▌</span>
|
| 22 |
+
</p>
|
| 23 |
+
</div>
|
| 24 |
+
)}
|
| 25 |
+
{response && !loading && (
|
| 26 |
+
<div className="inline-result inline-result--success fade-in">
|
| 27 |
+
<div className="inline-result__label">
|
| 28 |
+
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
| 29 |
+
<polyline points="20 6 9 17 4 12" />
|
| 30 |
+
</svg>
|
| 31 |
+
Summary
|
| 32 |
+
<span className="response-badge" style={{ marginLeft: 'auto' }}>{response.model ?? 'phi4-mini'}</span>
|
| 33 |
+
</div>
|
| 34 |
+
<p className="inline-result__text">{response.summary}</p>
|
| 35 |
+
</div>
|
| 36 |
+
)}
|
| 37 |
+
</>
|
| 38 |
+
)
|
| 39 |
+
}
|
frontend/src/hooks/useStreaming.js
ADDED
|
@@ -0,0 +1,99 @@
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
| 1 |
+
import { useState, useRef } from 'react'
|
| 2 |
+
|
| 3 |
+
const API_BASE = 'http://localhost:8000'
|
| 4 |
+
|
| 5 |
+
export function useStreaming() {
|
| 6 |
+
const [loading, setLoading] = useState(false)
|
| 7 |
+
const [response, setResponse] = useState(null)
|
| 8 |
+
const [error, setError] = useState(null)
|
| 9 |
+
const [streamingText, setStreamingText] = useState('')
|
| 10 |
+
const abortRef = useRef(null)
|
| 11 |
+
|
| 12 |
+
const readNDJSONStream = async (res) => {
|
| 13 |
+
const reader = res.body.getReader()
|
| 14 |
+
const decoder = new TextDecoder()
|
| 15 |
+
let accumulated = ''
|
| 16 |
+
let buffer = ''
|
| 17 |
+
|
| 18 |
+
while (true) {
|
| 19 |
+
const { done, value } = await reader.read()
|
| 20 |
+
if (done) break
|
| 21 |
+
|
| 22 |
+
buffer += decoder.decode(value, { stream: true })
|
| 23 |
+
const lines = buffer.split('\n')
|
| 24 |
+
buffer = lines.pop()
|
| 25 |
+
|
| 26 |
+
for (const line of lines) {
|
| 27 |
+
if (!line.trim()) continue
|
| 28 |
+
try {
|
| 29 |
+
const chunk = JSON.parse(line)
|
| 30 |
+
if (chunk.response) {
|
| 31 |
+
accumulated += chunk.response
|
| 32 |
+
setStreamingText(accumulated)
|
| 33 |
+
}
|
| 34 |
+
} catch { /* skip malformed */ }
|
| 35 |
+
}
|
| 36 |
+
}
|
| 37 |
+
|
| 38 |
+
return accumulated.trim()
|
| 39 |
+
}
|
| 40 |
+
|
| 41 |
+
const streamFrom = async (endpoint, { json, formData } = {}) => {
|
| 42 |
+
abortRef.current = new AbortController()
|
| 43 |
+
|
| 44 |
+
const fetchOpts = {
|
| 45 |
+
method: 'POST',
|
| 46 |
+
signal: abortRef.current.signal,
|
| 47 |
+
}
|
| 48 |
+
|
| 49 |
+
if (json) {
|
| 50 |
+
fetchOpts.headers = { 'Content-Type': 'application/json' }
|
| 51 |
+
fetchOpts.body = JSON.stringify(json)
|
| 52 |
+
} else if (formData) {
|
| 53 |
+
fetchOpts.body = formData
|
| 54 |
+
}
|
| 55 |
+
|
| 56 |
+
const res = await fetch(`${API_BASE}${endpoint}`, fetchOpts)
|
| 57 |
+
|
| 58 |
+
if (!res.ok) {
|
| 59 |
+
const body = await res.text()
|
| 60 |
+
let detail = `Backend error (${res.status})`
|
| 61 |
+
try { detail = JSON.parse(body).detail } catch { /* use default */ }
|
| 62 |
+
throw new Error(detail)
|
| 63 |
+
}
|
| 64 |
+
|
| 65 |
+
return readNDJSONStream(res)
|
| 66 |
+
}
|
| 67 |
+
|
| 68 |
+
const submit = async (activeTab, { youtubeUrl, transcript, selectedFile, selectedModel }) => {
|
| 69 |
+
setLoading(true)
|
| 70 |
+
setError(null)
|
| 71 |
+
setResponse(null)
|
| 72 |
+
setStreamingText('')
|
| 73 |
+
|
| 74 |
+
try {
|
| 75 |
+
let summary
|
| 76 |
+
|
| 77 |
+
if (activeTab === 'youtube') {
|
| 78 |
+
if (!youtubeUrl.trim()) throw new Error('Please enter a YouTube URL')
|
| 79 |
+
summary = await streamFrom('/summarize/youtube', { json: { url: youtubeUrl, model: selectedModel } })
|
| 80 |
+
} else if (activeTab === 'transcript') {
|
| 81 |
+
if (!transcript.trim()) throw new Error('Please enter some text')
|
| 82 |
+
summary = await streamFrom('/summarize/transcript', { json: { text: transcript, model: selectedModel } })
|
| 83 |
+
} else if (activeTab === 'file') {
|
| 84 |
+
if (!selectedFile) throw new Error('Please select a file')
|
| 85 |
+
const fd = new FormData()
|
| 86 |
+
fd.append('file', selectedFile)
|
| 87 |
+
summary = await streamFrom(`/summarize/file?model=${encodeURIComponent(selectedModel)}`, { formData: fd })
|
| 88 |
+
}
|
| 89 |
+
|
| 90 |
+
setResponse({ summary, success: true, source_type: activeTab, model: selectedModel })
|
| 91 |
+
} catch (err) {
|
| 92 |
+
setError(err.message || 'An error occurred')
|
| 93 |
+
} finally {
|
| 94 |
+
setLoading(false)
|
| 95 |
+
}
|
| 96 |
+
}
|
| 97 |
+
|
| 98 |
+
return { loading, response, error, streamingText, submit }
|
| 99 |
+
}
|