compendious commited on
Commit
ca552c5
·
1 Parent(s): 932022e

YT support, front end improvements, secondary model option (might remove later)

Browse files
backend/app.py CHANGED
@@ -1,36 +1,22 @@
1
- """FastAPI backend for Précis — powered by Ollama (phi4-mini:3.8b)."""
2
 
3
- import logging
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 HTMLResponse, StreamingResponse
10
- from pydantic import BaseModel
11
 
12
- # ---------------------------------------------------------------------------
13
- # Config
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 phi4-mini via Ollama",
33
- version="0.2.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.2.0",
208
- "model": MODEL_NAME,
 
209
  "ollama_reachable": ollama_ok,
210
- "endpoints": ["/", "/health", "/status", "/summarize/transcript",
211
- "/summarize/file", "/summarize/youtube"],
212
  }
213
 
214
 
215
- @app.post("/summarize/transcript", response_model=SummarizeResponse)
216
- async def summarize_transcript(request: TranscriptRequest):
217
- """Summarise a provided article or transcript (non-streaming)."""
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/stream")
228
- async def summarize_transcript_stream(request: TranscriptRequest):
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="text must not be empty")
 
235
 
236
- prompt = _build_prompt(request.title, request.text)
237
 
238
- return StreamingResponse(
239
- stream_ollama(prompt),
240
- media_type="application/x-ndjson",
241
- headers={"X-Accel-Buffering": "no"}, # disable nginx buffering if behind a proxy
242
- )
243
 
244
 
245
- @app.post("/summarize/file", response_model=SummarizeResponse)
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="/vite.svg" />
6
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
7
- <title>frontend</title>
 
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-3) var(--spacing-4);
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: var(--spacing-2);
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: 32px;
23
- height: 32px;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
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
- /* ── Inline result (transcript tab) ─────────────────────────────────────── */
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 [loading, setLoading] = useState(false)
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
- const res = await fetch(`${API_BASE}/summarize/transcript/stream`, {
26
- method: 'POST',
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
- setSelectedFile(file)
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
- <svg className="logo-icon" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
136
- <path d="M12 2L2 7l10 5 10-5-10-5z" />
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
- <button
170
- className={`tab ${activeTab === 'youtube' ? 'active' : ''}`}
171
- onClick={() => setActiveTab('youtube')}
172
- >
173
- YouTube Video
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 Tab */}
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 the full URL of a YouTube video to summarize its content.</p>
201
  </div>
 
202
  </div>
203
 
204
- {/* Transcript Tab */}
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={(e) => {
214
- if (e.key === 'Enter' && e.ctrlKey && !loading) handleSubmit()
215
- }}
216
  />
217
- <p className="form-hint">Paste any text content you want to summarize. Press <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> + <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> to generate.</p>
 
 
 
 
 
 
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 Tab */}
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 · Built with ♥ · <a href={`${API_BASE}/docs`} target="_blank" rel="noopener noreferrer">API Documentation</a></p>
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
+ }