Arslan1997 commited on
Commit
94f4eb8
·
1 Parent(s): 0bf4415

LETS GOOOOOOOOOOOOOOOOOOOOOOOOO

Browse files
backend/app/pipeline/ingest.py CHANGED
@@ -556,7 +556,7 @@ def store_entries(
556
  )
557
  doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
558
  doc.metadata_json = {
559
- **(doc.metadata_json or {}),
560
  "headings": [h for h, _ in sections if h],
561
  "words": len(doc.clean_text.split()),
562
  }
@@ -575,16 +575,60 @@ def store_entries(
575
  return indexed, skipped, changed
576
 
577
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
578
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
579
  source.sync_status = "syncing"
580
  update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
581
- # Fetch one past the plan cap so we can tell the user their archive holds more than was indexed.
582
- fetch_limit = max_posts + 1
583
 
584
  if source.feed_url.startswith(SITE_PREFIX):
585
  update_job(db, job, message="Mapping your site")
586
  entries = crawl_site(
587
- source.feed_url.removeprefix(SITE_PREFIX), fetch_limit,
588
  on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
589
  on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
590
  message=f"Read {done} of {total} pages"),
@@ -604,20 +648,29 @@ def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict
604
  except (httpx.HTTPError, ValueError):
605
  log.warning("substack archive fetch failed for %s", site, exc_info=True)
606
 
607
- capped = len(entries) > max_posts
608
- entries = entries[:max_posts]
609
 
610
  def progress(done: int, total: int, post_title: str) -> None:
611
- update_job(db, job, progress=0.1 + 0.8 * done / total, message=f"{post_title[:80]} is in orbit")
 
 
 
612
 
613
- update_job(db, job, progress=0.1, message=f"Indexing {len(entries)} posts")
614
- indexed, skipped, changed = store_entries(db, source, entries, progress)
 
 
 
 
 
615
 
616
  source.sync_status = "ok"
617
  source.sync_error = None
618
  source.last_synced_at = datetime.now(UTC)
619
  db.commit()
620
- return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts, "capped": capped,
 
621
  "changed": [str(i) for i in changed]}
622
 
623
 
 
556
  )
557
  doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
558
  doc.metadata_json = {
559
+ **{k: v for k, v in (doc.metadata_json or {}).items() if k != "locked"},
560
  "headings": [h for h, _ in sections if h],
561
  "words": len(doc.clean_text.split()),
562
  }
 
575
  return indexed, skipped, changed
576
 
577
 
578
+ # Every plan reads the whole archive (up to this ceiling). Posts past the plan's indexed_posts are stored as locked:
579
+ # listed with their title and text, but with no corpus file, so chat, notebooks and generation never see them.
580
+ # Upgrading re-syncs and unlocks them without asking the writer to reconnect anything.
581
+ ARCHIVE_SCAN_CAP = 5000
582
+ # Sites without a feed are crawled page by page (Firecrawl scrapes when configured), so the ceiling is lower.
583
+ SITE_SCAN_CAP = 500
584
+
585
+
586
+ def _newest_first(entries: list[FeedEntry]) -> list[FeedEntry]:
587
+ """Dated posts newest first; undated ones (site crawls) keep their discovery order after them."""
588
+ dated = sorted((e for e in entries if e.published_at), key=lambda e: e.published_at.timestamp(), reverse=True)
589
+ return dated + [e for e in entries if not e.published_at]
590
+
591
+
592
+ def store_locked(db: Session, source: Source, entries: list[FeedEntry]) -> tuple[int, set[str]]:
593
+ """Index posts beyond the plan limit without making them available. Returns (count, corpus paths to remove):
594
+ a post that was available before (a newer post pushed it out, or the plan went down) loses its corpus file."""
595
+ locked = 0
596
+ remove: set[str] = set()
597
+ for entry in entries:
598
+ sections = entry.sections if entry.sections is not None else html_to_sections(entry.html)
599
+ if not sections:
600
+ continue
601
+ doc = db.scalar(select(Document).where(Document.source_id == source.id, Document.url == entry.url))
602
+ if not doc:
603
+ doc = Document(id=uuid.uuid4(), workspace_id=source.workspace_id, source_id=source.id, url=entry.url)
604
+ db.add(doc)
605
+ if doc.path:
606
+ remove.add(doc.path)
607
+ doc.path = None
608
+ doc.title = entry.title
609
+ doc.published_at = entry.published_at
610
+ doc.content_hash = None # forces a full store (corpus file, raw HTML) once the post unlocks
611
+ doc.clean_text = "\n\n".join(f"## {h}\n{t}" if h else t for h, t in sections)
612
+ doc.metadata_json = {
613
+ **(doc.metadata_json or {}),
614
+ "headings": [h for h, _ in sections if h],
615
+ "words": len(doc.clean_text.split()),
616
+ "locked": True,
617
+ }
618
+ locked += 1
619
+ db.commit()
620
+ return locked, remove
621
+
622
+
623
  def ingest_source(db: Session, source: Source, job: Job, max_posts: int) -> dict:
624
  source.sync_status = "syncing"
625
  update_job(db, job, status="running", progress=0.02, message="Contacting your feed")
626
+ fetch_limit = ARCHIVE_SCAN_CAP
 
627
 
628
  if source.feed_url.startswith(SITE_PREFIX):
629
  update_job(db, job, message="Mapping your site")
630
  entries = crawl_site(
631
+ source.feed_url.removeprefix(SITE_PREFIX), max(SITE_SCAN_CAP, max_posts),
632
  on_found=lambda n: update_job(db, job, progress=0.05, message=f"Found {n} pages, reading them"),
633
  on_post=lambda done, total: update_job(db, job, progress=0.05 + 0.05 * done / total,
634
  message=f"Read {done} of {total} pages"),
 
648
  except (httpx.HTTPError, ValueError):
649
  log.warning("substack archive fetch failed for %s", site, exc_info=True)
650
 
651
+ entries = _newest_first(entries)
652
+ available, beyond = entries[:max_posts], entries[max_posts:]
653
 
654
  def progress(done: int, total: int, post_title: str) -> None:
655
+ update_job(db, job, progress=0.1 + 0.7 * done / total, message=f"{post_title[:80]} is in orbit")
656
+
657
+ update_job(db, job, progress=0.1, message=f"Indexing {len(available)} posts")
658
+ indexed, skipped, changed = store_entries(db, source, available, progress)
659
 
660
+ locked = 0
661
+ if beyond:
662
+ update_job(db, job, progress=0.85,
663
+ message=f"Indexing {len(beyond)} more posts. Your plan makes the latest {max_posts} available")
664
+ locked, remove = store_locked(db, source, beyond)
665
+ if remove:
666
+ Corpus(source.workspace_id).write_files({INDEX: rebuild_index(db, source.workspace_id)}, remove=remove)
667
 
668
  source.sync_status = "ok"
669
  source.sync_error = None
670
  source.last_synced_at = datetime.now(UTC)
671
  db.commit()
672
+ return {"indexed": indexed, "skipped": skipped, "found": len(entries), "limit": max_posts,
673
+ "capped": locked > 0, "locked": locked, "available": len(available),
674
  "changed": [str(i) for i in changed]}
675
 
676
 
backend/app/routers/artifacts.py CHANGED
@@ -11,6 +11,7 @@ from app.services.artifacts import latest_jobs, serialize_artifact, start_artifa
11
  from app.services.jobs import create_job, serialize_job
12
  from app.services.renderer import COMPOSITIONS
13
  from app.services.storage import storage
 
14
  from app.services.usage import check_limit
15
 
16
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
@@ -56,6 +57,11 @@ def _target_title(ctx: Ctx, notebook_id: uuid.UUID | None, document_id: uuid.UUI
56
  Document.workspace_id == ctx.workspace.id))
57
  if not doc:
58
  raise HTTPException(404, "Post not found")
 
 
 
 
 
59
  return doc.title
60
  if notebook_id:
61
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
 
11
  from app.services.jobs import create_job, serialize_job
12
  from app.services.renderer import COMPOSITIONS
13
  from app.services.storage import storage
14
+ from app.services.plans import effective_plan, plan_limit_error
15
  from app.services.usage import check_limit
16
 
17
  router = APIRouter(prefix="/api/artifacts", tags=["artifacts"])
 
57
  Document.workspace_id == ctx.workspace.id))
58
  if not doc:
59
  raise HTTPException(404, "Post not found")
60
+ if not doc.path:
61
+ plan = effective_plan(ctx.db, ctx.workspace)
62
+ raise plan_limit_error(plan, "indexed_posts",
63
+ f"This post is locked. Your plan makes your latest {plan.indexed_posts} posts "
64
+ "available; upgrade to use your whole archive.")
65
  return doc.title
66
  if notebook_id:
67
  nb = ctx.db.scalar(select(Notebook).where(Notebook.id == notebook_id,
backend/app/routers/notebooks.py CHANGED
@@ -52,7 +52,9 @@ def _get(ctx: Ctx, notebook_id: uuid.UUID) -> Notebook:
52
 
53
  def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
54
  valid = set(ctx.db.scalars(
55
- select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id)
 
 
56
  ).all())
57
  existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
58
  for doc_id in valid - existing:
 
52
 
53
  def _add_docs(ctx: Ctx, nb: Notebook, ids: list[uuid.UUID]) -> int:
54
  valid = set(ctx.db.scalars(
55
+ # Locked posts (beyond the plan's post limit) have no corpus file and cannot join a notebook.
56
+ select(Document.id).where(Document.id.in_(ids), Document.workspace_id == ctx.workspace.id,
57
+ Document.path.is_not(None))
58
  ).all())
59
  existing = set(ctx.db.scalars(select(NotebookDocument.document_id).where(NotebookDocument.notebook_id == nb.id)))
60
  for doc_id in valid - existing:
backend/app/routers/sources.py CHANGED
@@ -24,7 +24,7 @@ class UploadImportIn(BaseModel):
24
  upload_id: uuid.UUID
25
 
26
 
27
- def serialize_source(source: Source, doc_count: int = 0) -> dict:
28
  return {
29
  "id": str(source.id),
30
  "feed_url": source.feed_url,
@@ -35,6 +35,8 @@ def serialize_source(source: Source, doc_count: int = 0) -> dict:
35
  "sync_error": source.sync_error,
36
  "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
37
  "document_count": doc_count,
 
 
38
  "is_imports": source.feed_url == IMPORTS_FEED,
39
  }
40
 
@@ -91,7 +93,12 @@ def list_sources(ctx: Ctx = Depends(get_ctx)):
91
  .group_by(Source.id)
92
  .order_by(Source.created_at)
93
  ).all()
94
- return [serialize_source(s, n) for s, n in rows]
 
 
 
 
 
95
 
96
 
97
  @router.post("/{source_id}/sync")
@@ -151,6 +158,11 @@ def import_upload(body: UploadImportIn, ctx: Ctx = Depends(get_ctx)):
151
  documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
152
 
153
 
 
 
 
 
 
154
  def serialize_document(d: Document, source: Source | None = None) -> dict:
155
  meta = d.metadata_json or {}
156
  return {
@@ -163,6 +175,7 @@ def serialize_document(d: Document, source: Source | None = None) -> dict:
163
  "published_at": d.published_at.isoformat() if d.published_at else None,
164
  "words": meta.get("words") or len((d.clean_text or "").split()),
165
  "evergreen_score": d.evergreen_score,
 
166
  }
167
 
168
 
@@ -198,6 +211,12 @@ def get_document(document_id: uuid.UUID, ctx: Ctx = Depends(get_ctx)):
198
  if not doc:
199
  raise HTTPException(404, "Post not found")
200
  source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
 
 
 
 
 
 
201
  lines: list[str] = []
202
  if doc.path:
203
  corpus = Corpus(ctx.workspace.id)
 
24
  upload_id: uuid.UUID
25
 
26
 
27
+ def serialize_source(source: Source, doc_count: int = 0, locked_count: int = 0) -> dict:
28
  return {
29
  "id": str(source.id),
30
  "feed_url": source.feed_url,
 
35
  "sync_error": source.sync_error,
36
  "last_synced_at": source.last_synced_at.isoformat() if source.last_synced_at else None,
37
  "document_count": doc_count,
38
+ # Indexed but beyond the plan's post limit: listed, not usable until the workspace upgrades.
39
+ "locked_count": locked_count,
40
  "is_imports": source.feed_url == IMPORTS_FEED,
41
  }
42
 
 
93
  .group_by(Source.id)
94
  .order_by(Source.created_at)
95
  ).all()
96
+ locked = dict(ctx.db.execute(
97
+ select(Document.source_id, func.count(Document.id))
98
+ .where(Document.workspace_id == ctx.workspace.id, Document.path.is_(None))
99
+ .group_by(Document.source_id)
100
+ ).all())
101
+ return [serialize_source(s, n, locked.get(s.id, 0)) for s, n in rows]
102
 
103
 
104
  @router.post("/{source_id}/sync")
 
158
  documents_router = APIRouter(prefix="/api/documents", tags=["documents"])
159
 
160
 
161
+ def is_locked(d: Document) -> bool:
162
+ """Indexed past the plan's post limit (see ingest.store_locked): visible, but not in the corpus."""
163
+ return not d.path and bool((d.metadata_json or {}).get("locked"))
164
+
165
+
166
  def serialize_document(d: Document, source: Source | None = None) -> dict:
167
  meta = d.metadata_json or {}
168
  return {
 
175
  "published_at": d.published_at.isoformat() if d.published_at else None,
176
  "words": meta.get("words") or len((d.clean_text or "").split()),
177
  "evergreen_score": d.evergreen_score,
178
+ "locked": is_locked(d),
179
  }
180
 
181
 
 
211
  if not doc:
212
  raise HTTPException(404, "Post not found")
213
  source = ctx.db.get(Source, doc.source_id) if doc.source_id else None
214
+ if is_locked(doc):
215
+ # A preview only: the full post is part of what upgrading unlocks.
216
+ preview = [ln for ln in (doc.clean_text or "").splitlines() if ln.strip()][:3]
217
+ plan = effective_plan(ctx.db, ctx.workspace)
218
+ return {**serialize_document(doc, source), "lines": [f"# {doc.title}", "", *preview],
219
+ "preview": True, "limit": plan.indexed_posts}
220
  lines: list[str] = []
221
  if doc.path:
222
  corpus = Corpus(ctx.workspace.id)
backend/app/routers/voice.py CHANGED
@@ -57,6 +57,23 @@ class PreviewIn(BaseModel):
57
  delivery: DeliveryIn | None = None
58
 
59
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
60
  class LibraryAddIn(BaseModel):
61
  public_owner_id: str = Field(min_length=5, max_length=100)
62
  voice_id: str = Field(min_length=5, max_length=60)
@@ -116,6 +133,8 @@ def _serialize(ctx: Ctx) -> dict:
116
  "error": job.error if job and job.status == "failed" else None,
117
  "preview_url": storage.presign_get(preview_key) if preview_key else None,
118
  },
 
 
119
  "tts_configured": bool(settings.elevenlabs_api_key),
120
  "model": tts.model_id(),
121
  "models": tts.MODELS,
@@ -211,6 +230,61 @@ async def preview(body: PreviewIn, ctx: Ctx = Depends(get_ctx)):
211
  return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
212
 
213
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
214
  @router.get("/reading-script")
215
  def reading_script(ctx: Ctx = Depends(get_ctx)):
216
  """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
 
57
  delivery: DeliveryIn | None = None
58
 
59
 
60
+ class DesignIn(BaseModel):
61
+ """Either picked options (Build from options) or a free description (Describe in your own words)."""
62
+ prompt: str | None = Field(None, max_length=1000)
63
+ gender: str = Field("", max_length=20)
64
+ age: str = Field("", max_length=20)
65
+ persona: str = Field("", max_length=30)
66
+ pace: str = Field("", max_length=20)
67
+ accent: str = Field("", max_length=40)
68
+
69
+
70
+ class DesignSaveIn(BaseModel):
71
+ generated_voice_id: str = Field(min_length=5, max_length=100)
72
+ name: str = Field(min_length=1, max_length=60)
73
+ description: str = Field("", max_length=1000)
74
+ use_as: str | None = None # "host_a" | "host_b"
75
+
76
+
77
  class LibraryAddIn(BaseModel):
78
  public_owner_id: str = Field(min_length=5, max_length=100)
79
  voice_id: str = Field(min_length=5, max_length=60)
 
133
  "error": job.error if job and job.status == "failed" else None,
134
  "preview_url": storage.presign_get(preview_key) if preview_key else None,
135
  },
136
+ # Voices this workspace generated with Voice Design (the ElevenLabs account is shared across workspaces).
137
+ "custom_voices": voices.get("custom") or [],
138
  "tts_configured": bool(settings.elevenlabs_api_key),
139
  "model": tts.model_id(),
140
  "models": tts.MODELS,
 
230
  return {"url": storage.presign_get(key), "seconds": round(tts.mp3_seconds(clip), 1)}
231
 
232
 
233
+ def _design_description(body: DesignIn) -> str:
234
+ if body.prompt and body.prompt.strip():
235
+ text = body.prompt.strip()
236
+ if len(text) < 20:
237
+ raise HTTPException(400, "Describe the voice in at least 20 characters.")
238
+ return text
239
+ parts = [f"A {body.age} {body.gender} voice".replace(" ", " ") if body.gender or body.age else "A voice"]
240
+ if body.accent:
241
+ parts.append(f"with a {body.accent} accent")
242
+ sentence = " ".join(parts) + "."
243
+ if body.persona:
244
+ sentence += f" Sounds {body.persona}."
245
+ if body.pace:
246
+ sentence += f" Speaks at a {body.pace} pace."
247
+ return sentence + " Clear studio quality, suited to podcasts and narration."
248
+
249
+
250
+ @router.post("/design")
251
+ async def design(body: DesignIn, ctx: Ctx = Depends(get_ctx)):
252
+ """Three previews of a new voice. Nothing is saved until the writer picks one."""
253
+ if not settings.elevenlabs_api_key:
254
+ raise HTTPException(503, "Set ELEVENLABS_API_KEY in .env to generate voices.")
255
+ description = _design_description(body)
256
+ try:
257
+ previews = await run_in_threadpool(tts.design_voice, description)
258
+ except tts.TTSError as exc:
259
+ raise HTTPException(502, str(exc)) from exc
260
+ out = []
261
+ for p in previews:
262
+ key = f"ws/{ctx.workspace.id}/voice-previews/{uuid.uuid4().hex}.mp3"
263
+ storage.put_bytes(key, p["audio"], "audio/mpeg")
264
+ out.append({"generated_voice_id": p["generated_voice_id"], "url": storage.presign_get(key), "seconds": p["seconds"]})
265
+ record_usage(ctx.db, workspace_id=ctx.workspace.id, kind="voice_design", provider="elevenlabs",
266
+ quantity=len(out), unit="previews")
267
+ return {"description": description, "previews": out}
268
+
269
+
270
+ @router.post("/design/save")
271
+ async def save_design(body: DesignSaveIn, ctx: Ctx = Depends(get_ctx)):
272
+ try:
273
+ voice_id = await run_in_threadpool(tts.create_designed_voice, body.name, body.description,
274
+ body.generated_voice_id)
275
+ except tts.TTSError as exc:
276
+ raise HTTPException(502, str(exc)) from exc
277
+ vp = _ensure_profile(ctx)
278
+ voices = dict(vp.host_voices or {})
279
+ voices["custom"] = [*(voices.get("custom") or []),
280
+ {"voice_id": voice_id, "name": body.name, "description": body.description[:300]}]
281
+ if body.use_as in ("host_a", "host_b"):
282
+ voices[body.use_as] = voice_id
283
+ vp.host_voices = voices
284
+ ctx.db.commit()
285
+ return {"voice_id": voice_id, **_serialize(ctx)}
286
+
287
+
288
  @router.get("/reading-script")
289
  def reading_script(ctx: Ctx = Depends(get_ctx)):
290
  """About two minutes of the writer's own prose to read aloud for the clone (their words, their rhythm)."""
backend/app/worker.py CHANGED
@@ -91,8 +91,9 @@ def handle_ingest(db: Session, job: Job):
91
  raise PermanentJobError(str(exc)) from exc
92
  raise
93
  _after_import(db, job, source.workspace_id, result.get("changed", []))
94
- if result.get("capped"):
95
- return Done(result, f"First {result['limit']} posts in orbit: your plan's limit. Upgrade to index the rest")
 
96
  return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
97
 
98
 
 
91
  raise PermanentJobError(str(exc)) from exc
92
  raise
93
  _after_import(db, job, source.workspace_id, result.get("changed", []))
94
+ if result.get("locked"):
95
+ return Done(result, f"All {result['found']} posts indexed. Your latest {result['available']} are available; "
96
+ f"upgrade to unlock the other {result['locked']}")
97
  return Done(result, f"All posts in orbit: {result['indexed']} new or updated")
98
 
99
 
frontend/src/api/endpoints.ts CHANGED
@@ -24,6 +24,8 @@ import type {
24
  Usage,
25
  Voice,
26
  Delivery,
 
 
27
  VoiceProfileData,
28
  VoiceState,
29
  } from "./types";
@@ -129,6 +131,9 @@ export const voiceApi = {
129
  readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
130
  quota: () => api<Quota>("/api/voice/quota"),
131
  revoke: () => del<VoiceState>("/api/voice/consent"),
 
 
 
132
  };
133
 
134
  export type ItemBody = {
 
24
  Usage,
25
  Voice,
26
  Delivery,
27
+ VoiceDesignInput,
28
+ VoiceDesignPreview,
29
  VoiceProfileData,
30
  VoiceState,
31
  } from "./types";
 
131
  readingScript: () => api<{ title: string; text: string; words: number }>("/api/voice/reading-script"),
132
  quota: () => api<Quota>("/api/voice/quota"),
133
  revoke: () => del<VoiceState>("/api/voice/consent"),
134
+ design: (input: VoiceDesignInput) => post<{ description: string; previews: VoiceDesignPreview[] }>("/api/voice/design", input),
135
+ saveDesign: (body: { generated_voice_id: string; name: string; description: string; use_as?: "host_a" | "host_b" }) =>
136
+ post<VoiceState & { voice_id: string }>("/api/voice/design/save", body),
137
  };
138
 
139
  export type ItemBody = {
frontend/src/api/types.ts CHANGED
@@ -195,9 +195,14 @@ export type VoiceState = {
195
  error: string | null;
196
  preview_url: string | null;
197
  };
 
 
198
  tts_configured: boolean;
199
  };
200
 
 
 
 
201
  export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
202
 
203
  export type Voice = {
 
195
  error: string | null;
196
  preview_url: string | null;
197
  };
198
+ /** Voices this workspace generated with Voice Design. */
199
+ custom_voices: { voice_id: string; name: string; description?: string }[];
200
  tts_configured: boolean;
201
  };
202
 
203
+ export type VoiceDesignInput = { prompt?: string; gender?: string; age?: string; persona?: string; pace?: string; accent?: string };
204
+ export type VoiceDesignPreview = { generated_voice_id: string; url: string; seconds: number | null };
205
+
206
  export type Delivery = { stability: number; similarity_boost: number; style: number; speed: number; use_speaker_boost?: boolean };
207
 
208
  export type Voice = {
frontend/src/pages/VoiceProfile.tsx CHANGED
@@ -1,8 +1,8 @@
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
- import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
- import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
@@ -23,8 +23,6 @@ const SLIDERS: { key: keyof Delivery; label: string; min: number; max: number; s
23
  { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
24
  ];
25
 
26
- const HOST_LABEL: Record<Host, string> = { host_a: "Host A, leads and narrates videos", host_b: "Host B, the co-host" };
27
-
28
  /** Plays one URL at a time across the page. */
29
  function usePlayer() {
30
  const audio = useRef<HTMLAudioElement | null>(null);
@@ -51,83 +49,159 @@ function fmtSeconds(s: number) {
51
 
52
  // Speaking voices
53
 
54
- function HostCard({
55
- host,
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
56
  state,
57
  voices,
58
  onSaved,
59
  onBrowse,
 
60
  }: {
61
- host: Host;
62
  state: VoiceState;
63
  voices: Voice[];
64
  onSaved: (s: VoiceState) => void;
65
  onBrowse: (host: Host) => void;
 
66
  }) {
 
67
  const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
68
- const [busy, setBusy] = useState(false);
69
- const [error, setError] = useState<string | null>(null);
70
  const [dirty, setDirty] = useState(false);
 
 
71
  const { play, playing } = usePlayer();
72
  const current = state.host_voices[host];
73
- const groups = voices.reduce<Record<string, Voice[]>>((acc, v) => {
74
- const k = v.category ?? "other";
75
- (acc[k] ??= []).push(v);
76
- return acc;
77
- }, {});
78
 
79
  useEffect(() => {
80
  setDelivery(state.delivery[host]);
81
  setDirty(false);
82
  }, [state.delivery, host]);
83
 
84
- const preview = async () => {
85
- setBusy(true);
 
 
 
 
 
 
 
 
 
 
 
 
 
 
86
  setError(null);
87
  try {
88
- const { url } = await voiceApi.preview(current, { delivery });
89
- play(url, "preview");
90
  } catch (e) {
91
  setError(errorMessage(e));
92
  } finally {
93
- setBusy(false);
 
 
 
 
 
 
 
 
 
 
94
  }
95
  };
96
 
97
  return (
98
- <div className="host-card">
99
- <div className="row between">
100
- <strong>{HOST_LABEL[host]}</strong>
101
- <button className="link-btn small" onClick={() => onBrowse(host)}>
102
- Browse voice library
103
- </button>
104
- </div>
105
- <div className="row">
106
- <select
107
- className="input input-sm"
108
- value={current}
109
- onChange={async (e) => onSaved(await voiceApi.update({ host_voices: { [host]: e.target.value } }))}
110
- aria-label={`${HOST_LABEL[host]} voice`}
111
- >
112
- {Object.entries(groups).map(([cat, list]) => (
113
- <optgroup key={cat} label={cat}>
114
- {list.map((v) => (
115
- <option key={v.voice_id} value={v.voice_id}>
116
- {v.name}
117
- {v.labels?.accent ? `, ${v.labels.accent}` : ""}
118
- {v.labels?.gender ? `, ${v.labels.gender}` : ""}
119
- </option>
120
- ))}
121
- </optgroup>
122
- ))}
123
- {!voices.some((v) => v.voice_id === current) && <option value={current}>Current voice</option>}
124
- </select>
125
- <button className="btn btn-small" onClick={preview} disabled={busy || !state.tts_configured}>
126
- {busy ? "Rendering..." : playing ? "Stop" : "Preview"}
127
- </button>
128
- </div>
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
129
  <details className="delivery">
130
- <summary className="mono muted small">Delivery settings</summary>
131
  {SLIDERS.map((s) => (
132
  <label key={s.key} className="slider" title={s.hint}>
133
  <span className="row between">
@@ -164,11 +238,210 @@ function HostCard({
164
  </div>
165
  )}
166
  </details>
167
- {error && <p className="error-text">{error}</p>}
 
 
 
 
 
168
  </div>
169
  );
170
  }
171
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
172
  // Voice library (commercially licensed voices from ElevenLabs)
173
 
174
  function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
@@ -483,6 +756,7 @@ export default function VoiceProfile() {
483
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
484
  const [cloning, setCloning] = useState(false);
485
  const [browsing, setBrowsing] = useState<Host | null>(null);
 
486
  const [saved, setSaved] = useState(false);
487
  const [error, setError] = useState<string | null>(null);
488
  const { play, playing } = usePlayer();
@@ -583,7 +857,7 @@ export default function VoiceProfile() {
583
  <>
584
  <label className="field">
585
  <span>In a sentence, you sound like this</span>
586
- <textarea className="textarea" rows={3} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
587
  </label>
588
  <details className="delivery">
589
  <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
@@ -631,14 +905,10 @@ export default function VoiceProfile() {
631
  <p className="eyebrow">Step 2 of 3</p>
632
  <h2>Pick who reads your work aloud</h2>
633
  <p className="muted">
634
- Audio overviews are a conversation between two hosts. Videos are narrated by Host A. Press Preview to hear each one, or browse the library for more voices.
635
  </p>
636
  {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
637
- <div className="vp-hosts">
638
- {(["host_a", "host_b"] as Host[]).map((h) => (
639
- <HostCard key={h} host={h} state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} />
640
- ))}
641
- </div>
642
  <div className="row between">
643
  <button className="btn btn-small" onClick={() => setStep("writing")}>
644
  Back
@@ -746,6 +1016,18 @@ export default function VoiceProfile() {
746
  </Modal>
747
  )}
748
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
 
 
 
 
 
 
 
 
 
 
 
 
749
  {browsing && (
750
  <LibraryModal
751
  host={browsing}
 
1
  import { useCallback, useEffect, useRef, useState } from "react";
2
  import { voiceApi } from "../api/endpoints";
3
+ import type { Delivery, Job, LibraryVoice, Quota, Voice, VoiceDesignInput, VoiceDesignPreview, VoiceProfileData, VoiceState } from "../api/types";
4
  import { DocPicker } from "../components/DocPicker";
5
+ import { ConfirmButton, errorMessage, formatDate, JobProgress, Loading, Modal, PageHeader, Tabs } from "../components/ui";
6
  import { useJob } from "../hooks/useJob";
7
  import { useUpgrade } from "../hooks/useUpgrade";
8
 
 
23
  { key: "speed", label: "Speed", min: 0.7, max: 1.2, step: 0.05, hint: "1.0 is natural pace" },
24
  ];
25
 
 
 
26
  /** Plays one URL at a time across the page. */
27
  function usePlayer() {
28
  const audio = useRef<HTMLAudioElement | null>(null);
 
49
 
50
  // Speaking voices
51
 
52
+ type Icon = "sun" | "book" | "mic" | "spark" | "wave" | "person";
53
+
54
+ /** The four ElevenLabs premade voices offered up front. Each has a glyph so they read at a glance. */
55
+ const PREBUILT: { voice_id: string; name: string; tag: string; icon: Icon }[] = [
56
+ { voice_id: "EXAVITQu4vr4xnSDxMaL", name: "Sarah", tag: "Warm and reassuring", icon: "sun" },
57
+ { voice_id: "JBFqnCBsd6RMkjVDRZzb", name: "George", tag: "British storyteller", icon: "book" },
58
+ { voice_id: "nPczCjzI2devNBz1zQrb", name: "Brian", tag: "Deep narrator", icon: "mic" },
59
+ { voice_id: "cgSgspJ2msm6clMCkdW9", name: "Jessica", tag: "Bright and lively", icon: "spark" },
60
+ ];
61
+
62
+ const ICON_PATHS: Record<Icon, string> = {
63
+ sun: "M12 4V2m0 20v-2m8-8h2M2 12h2m13.66-5.66 1.41-1.41M4.93 19.07l1.41-1.41m0-11.32L4.93 4.93m14.14 14.14-1.41-1.41M16 12a4 4 0 1 1-8 0 4 4 0 0 1 8 0Z",
64
+ book: "M4 5.5A2.5 2.5 0 0 1 6.5 3H20v15H6.5A2.5 2.5 0 0 0 4 20.5v-15Zm0 15A2.5 2.5 0 0 0 6.5 23H20v-5",
65
+ mic: "M12 2a3 3 0 0 0-3 3v6a3 3 0 0 0 6 0V5a3 3 0 0 0-3-3Zm7 9a7 7 0 0 1-14 0m7 7v4m-4 0h8",
66
+ spark: "M12 3l1.9 5.1L19 10l-5.1 1.9L12 17l-1.9-5.1L5 10l5.1-1.9L12 3Zm7 11 .8 2.2L22 17l-2.2.8L19 20l-.8-2.2L16 17l2.2-.8L19 14Z",
67
+ wave: "M3 12h2m3-5v10m4-13v16m4-11v6m4-3h1",
68
+ person: "M12 12a4 4 0 1 0 0-8 4 4 0 0 0 0 8Zm-7 9a7 7 0 0 1 14 0",
69
+ };
70
+
71
+ function VoiceIcon({ icon }: { icon: Icon }) {
72
+ return (
73
+ <span className="voice-icon" aria-hidden="true">
74
+ <svg width="34" height="34" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinecap="round" strokeLinejoin="round">
75
+ <path d={ICON_PATHS[icon]} />
76
+ </svg>
77
+ </span>
78
+ );
79
+ }
80
+
81
+ type Tile = { voice_id: string; name: string; tag: string; icon: Icon; preview_url?: string | null };
82
+
83
+ function VoicePicker({
84
  state,
85
  voices,
86
  onSaved,
87
  onBrowse,
88
+ onGenerate,
89
  }: {
 
90
  state: VoiceState;
91
  voices: Voice[];
92
  onSaved: (s: VoiceState) => void;
93
  onBrowse: (host: Host) => void;
94
+ onGenerate: (host: Host) => void;
95
  }) {
96
+ const [host, setHost] = useState<Host>("host_a");
97
  const [delivery, setDelivery] = useState<Delivery>(state.delivery[host]);
 
 
98
  const [dirty, setDirty] = useState(false);
99
+ const [loading, setLoading] = useState<string | null>(null);
100
+ const [error, setError] = useState<string | null>(null);
101
  const { play, playing } = usePlayer();
102
  const current = state.host_voices[host];
103
+ const other: Host = host === "host_a" ? "host_b" : "host_a";
104
+ const byId = new Map(voices.map((v) => [v.voice_id, v]));
 
 
 
105
 
106
  useEffect(() => {
107
  setDelivery(state.delivery[host]);
108
  setDirty(false);
109
  }, [state.delivery, host]);
110
 
111
+ // Prebuilt first, then the writer's own voices, then whatever else a host is set to (e.g. a library voice).
112
+ const tiles: Tile[] = PREBUILT.map((p) => ({ ...p, preview_url: byId.get(p.voice_id)?.preview_url }));
113
+ if (state.clone.status === "ready" && state.clone.voice_id) {
114
+ tiles.push({ voice_id: state.clone.voice_id, name: "My voice", tag: "Your clone", icon: "person", preview_url: state.clone.preview_url });
115
+ }
116
+ for (const c of state.custom_voices) tiles.push({ voice_id: c.voice_id, name: c.name, tag: "Generated", icon: "wave" });
117
+ for (const id of [state.host_voices.host_a, state.host_voices.host_b]) {
118
+ if (!tiles.some((t) => t.voice_id === id)) {
119
+ const v = byId.get(id);
120
+ tiles.push({ voice_id: id, name: v?.name ?? "Current voice", tag: v?.labels?.accent ?? "Library voice", icon: "wave", preview_url: v?.preview_url });
121
+ }
122
+ }
123
+
124
+ const listen = async (t: Tile) => {
125
+ if (playing === t.voice_id || t.preview_url) return play(t.preview_url ?? "", t.voice_id);
126
+ setLoading(t.voice_id);
127
  setError(null);
128
  try {
129
+ const { url } = await voiceApi.preview(t.voice_id, { host });
130
+ play(url, t.voice_id);
131
  } catch (e) {
132
  setError(errorMessage(e));
133
  } finally {
134
+ setLoading(null);
135
+ }
136
+ };
137
+
138
+ const choose = async (voice_id: string) => {
139
+ if (voice_id === current) return;
140
+ setError(null);
141
+ try {
142
+ onSaved(await voiceApi.update({ host_voices: { [host]: voice_id } }));
143
+ } catch (e) {
144
+ setError(errorMessage(e));
145
  }
146
  };
147
 
148
  return (
149
+ <div className="stack">
150
+ <Tabs<Host>
151
+ tabs={[
152
+ { id: "host_a", label: "Host A · leads" },
153
+ { id: "host_b", label: "Host B · co-host" },
154
+ ]}
155
+ value={host}
156
+ onChange={setHost}
157
+ />
158
+ <ul className="voice-grid" aria-label={`Voices for ${host === "host_a" ? "Host A" : "Host B"}`}>
159
+ {tiles.map((t) => {
160
+ const selected = t.voice_id === current;
161
+ const usedByOther = t.voice_id === state.host_voices[other];
162
+ return (
163
+ <li key={t.voice_id} className={`voice-tile${selected ? " on" : ""}`}>
164
+ <button type="button" className="voice-pick" onClick={() => choose(t.voice_id)} aria-pressed={selected}>
165
+ <VoiceIcon icon={t.icon} />
166
+ <strong>{t.name}</strong>
167
+ <span className="muted small">{t.tag}</span>
168
+ </button>
169
+ {selected && <span className="voice-badge mono">{host === "host_a" ? "Host A" : "Host B"}</span>}
170
+ {!selected && usedByOther && <span className="voice-badge dim mono">{other === "host_a" ? "Host A" : "Host B"}</span>}
171
+ <button
172
+ type="button"
173
+ className="icon-btn voice-play"
174
+ onClick={() => listen(t)}
175
+ disabled={loading !== null || (!t.preview_url && !state.tts_configured)}
176
+ aria-label={playing === t.voice_id ? `Stop ${t.name}` : `Play ${t.name}`}
177
+ title={playing === t.voice_id ? "Stop" : "Listen"}
178
+ >
179
+ {loading === t.voice_id ? (
180
+ "…"
181
+ ) : (
182
+ <svg width="12" height="12" viewBox="0 0 24 24" fill="currentColor" aria-hidden="true">
183
+ {playing === t.voice_id ? <path d="M6 5h4v14H6zM14 5h4v14h-4z" /> : <path d="M7 4.5v15l13-7.5z" />}
184
+ </svg>
185
+ )}
186
+ </button>
187
+ </li>
188
+ );
189
+ })}
190
+ <li className="voice-tile voice-new">
191
+ <button type="button" className="voice-pick" onClick={() => onGenerate(host)} aria-label="Add a voice">
192
+ <span className="voice-icon plus" aria-hidden="true">
193
+ <svg width="30" height="30" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round">
194
+ <path d="M12 5v14M5 12h14" />
195
+ </svg>
196
+ </span>
197
+ <strong>Add a voice</strong>
198
+ <span className="muted small">Generate or clone your own</span>
199
+ </button>
200
+ </li>
201
+ </ul>
202
+ {error && <p className="error-text">{error}</p>}
203
  <details className="delivery">
204
+ <summary className="mono muted small">Delivery settings for {host === "host_a" ? "Host A" : "Host B"}</summary>
205
  {SLIDERS.map((s) => (
206
  <label key={s.key} className="slider" title={s.hint}>
207
  <span className="row between">
 
238
  </div>
239
  )}
240
  </details>
241
+ <p className="muted small">
242
+ Want something else?{" "}
243
+ <button className="link-btn small" onClick={() => onBrowse(host)}>
244
+ Browse the full voice library
245
+ </button>
246
+ </p>
247
  </div>
248
  );
249
  }
250
 
251
+ // Generating a new voice: build from options, describe it, or clone your own
252
+
253
+ type GenMode = "options" | "prompt" | "clone";
254
+
255
+ const GEN_OPTIONS: { key: keyof Omit<VoiceDesignInput, "prompt">; label: string; values: string[] }[] = [
256
+ { key: "gender", label: "Gender", values: ["female", "male", "neutral"] },
257
+ { key: "age", label: "Age", values: ["young", "middle-aged", "older"] },
258
+ { key: "persona", label: "Personality", values: ["warm", "professional", "calm", "friendly", "confident", "energetic"] },
259
+ { key: "pace", label: "Pace", values: ["slow", "measured", "brisk"] },
260
+ { key: "accent", label: "Accent", values: ["American", "British", "Australian", "Irish", "Scottish", "Indian", "Canadian", "South African"] },
261
+ ];
262
+
263
+ const PROMPT_IDEAS = [
264
+ "A calm, low British voice for late night essays",
265
+ "An upbeat young American podcaster who smiles while talking",
266
+ "A thoughtful older narrator with a soft Irish lilt",
267
+ ];
268
+
269
+ function GenerateVoiceModal({
270
+ host,
271
+ state,
272
+ onClose,
273
+ onSaved,
274
+ onClone,
275
+ }: {
276
+ host: Host;
277
+ state: VoiceState;
278
+ onClose: () => void;
279
+ onSaved: (s: VoiceState) => void;
280
+ onClone: () => void;
281
+ }) {
282
+ const [mode, setMode] = useState<GenMode>("options");
283
+ const [opts, setOpts] = useState<VoiceDesignInput>({ gender: "female", age: "middle-aged", persona: "warm", pace: "measured", accent: "American" });
284
+ const [prompt, setPrompt] = useState("");
285
+ const [result, setResult] = useState<{ description: string; previews: VoiceDesignPreview[] } | null>(null);
286
+ const [picked, setPicked] = useState<string | null>(null);
287
+ const [name, setName] = useState("");
288
+ const [busy, setBusy] = useState<"generate" | "save" | null>(null);
289
+ const [error, setError] = useState<string | null>(null);
290
+ const { play, playing } = usePlayer();
291
+ const { openUpgrade } = useUpgrade();
292
+ const hostLabel = host === "host_a" ? "Host A" : "Host B";
293
+
294
+ const generate = async () => {
295
+ setBusy("generate");
296
+ setError(null);
297
+ setResult(null);
298
+ setPicked(null);
299
+ try {
300
+ const res = await voiceApi.design(mode === "prompt" ? { prompt } : opts);
301
+ setResult(res);
302
+ setPicked(res.previews[0]?.generated_voice_id ?? null);
303
+ } catch (e) {
304
+ setError(errorMessage(e));
305
+ } finally {
306
+ setBusy(null);
307
+ }
308
+ };
309
+
310
+ const save = async () => {
311
+ if (!result || !picked) return;
312
+ setBusy("save");
313
+ setError(null);
314
+ try {
315
+ onSaved(await voiceApi.saveDesign({ generated_voice_id: picked, name: name.trim(), description: result.description, use_as: host }));
316
+ onClose();
317
+ } catch (e) {
318
+ setError(errorMessage(e));
319
+ } finally {
320
+ setBusy(null);
321
+ }
322
+ };
323
+
324
+ return (
325
+ <Modal title="Generate a new voice" onClose={onClose} wide>
326
+ <div className="stack">
327
+ <Tabs<GenMode>
328
+ tabs={[
329
+ { id: "options", label: "Build from options" },
330
+ { id: "prompt", label: "Describe it" },
331
+ { id: "clone", label: "Clone my voice" },
332
+ ]}
333
+ value={mode}
334
+ onChange={(m) => {
335
+ setMode(m);
336
+ setResult(null);
337
+ setError(null);
338
+ }}
339
+ />
340
+
341
+ {mode === "options" && (
342
+ <div className="gen-options">
343
+ {GEN_OPTIONS.map((g) => (
344
+ <div key={g.key} className="gen-row">
345
+ <span className="mono muted small">{g.label}</span>
346
+ <div className="gen-chips">
347
+ {g.values.map((v) => (
348
+ <button key={v} type="button" className={`chip${opts[g.key] === v ? " on" : ""}`} aria-pressed={opts[g.key] === v} onClick={() => setOpts({ ...opts, [g.key]: v })}>
349
+ {v}
350
+ </button>
351
+ ))}
352
+ </div>
353
+ </div>
354
+ ))}
355
+ </div>
356
+ )}
357
+
358
+ {mode === "prompt" && (
359
+ <>
360
+ <label className="field">
361
+ <span>Describe the voice you want</span>
362
+ <textarea className="textarea" rows={3} maxLength={1000} placeholder="A calm, low British voice for late night essays" value={prompt} onChange={(e) => setPrompt(e.target.value)} />
363
+ </label>
364
+ <div className="row between">
365
+ <div className="gen-chips">
366
+ {PROMPT_IDEAS.map((p) => (
367
+ <button key={p} type="button" className="chip" onClick={() => setPrompt(p)}>
368
+ {p}
369
+ </button>
370
+ ))}
371
+ </div>
372
+ <span className="mono muted small">{prompt.trim().length} / 1000</span>
373
+ </div>
374
+ </>
375
+ )}
376
+
377
+ {mode === "clone" &&
378
+ (state.clone.allowed ? (
379
+ <>
380
+ <p className="muted">Read a passage from your own writing for one to three minutes. Your voice becomes Host A, so every overview and video sounds like you.</p>
381
+ <div className="row">
382
+ <button
383
+ className="btn btn-primary"
384
+ disabled={!state.tts_configured}
385
+ onClick={() => {
386
+ onClose();
387
+ onClone();
388
+ }}
389
+ >
390
+ {state.clone.status === "ready" ? "Record my voice again" : "Start recording"}
391
+ </button>
392
+ </div>
393
+ </>
394
+ ) : (
395
+ <div className="vp-upgrade">
396
+ <div>
397
+ <strong>Voice cloning is on the Writer and Studio plans</strong>
398
+ <p className="muted small">Record a couple of minutes and every audio overview and video is narrated by you.</p>
399
+ </div>
400
+ <button className="btn btn-primary" onClick={() => openUpgrade("writer")}>
401
+ Upgrade to clone my voice
402
+ </button>
403
+ </div>
404
+ ))}
405
+
406
+ {mode !== "clone" && (
407
+ <div className="row end">
408
+ <button className="btn btn-primary" onClick={generate} disabled={busy !== null || !state.tts_configured || (mode === "prompt" && prompt.trim().length < 20)}>
409
+ {busy === "generate" ? "Generating previews..." : result ? "Generate again" : "Generate previews"}
410
+ </button>
411
+ </div>
412
+ )}
413
+
414
+ {result && mode !== "clone" && (
415
+ <div className="stack">
416
+ <p className="eyebrow">Pick the one you like</p>
417
+ <ul className="gen-previews">
418
+ {result.previews.map((p, i) => (
419
+ <li key={p.generated_voice_id} className={picked === p.generated_voice_id ? "on" : ""}>
420
+ <label className="check">
421
+ <input type="radio" name="gen-preview" checked={picked === p.generated_voice_id} onChange={() => setPicked(p.generated_voice_id)} />
422
+ Option {i + 1}
423
+ </label>
424
+ <button type="button" className="btn btn-small" onClick={() => play(p.url, p.generated_voice_id)}>
425
+ {playing === p.generated_voice_id ? "Stop" : "Play"}
426
+ </button>
427
+ </li>
428
+ ))}
429
+ </ul>
430
+ <div className="row">
431
+ <input className="input input-sm" placeholder="Name it, e.g. Late night narrator" maxLength={60} value={name} onChange={(e) => setName(e.target.value)} aria-label="Voice name" />
432
+ <button className="btn btn-primary" onClick={save} disabled={busy !== null || !picked || !name.trim()}>
433
+ {busy === "save" ? "Saving..." : `Save and use as ${hostLabel}`}
434
+ </button>
435
+ </div>
436
+ </div>
437
+ )}
438
+ {!state.tts_configured && <p className="muted small">Set ELEVENLABS_API_KEY in .env to generate voices.</p>}
439
+ {error && <p className="error-text">{error}</p>}
440
+ </div>
441
+ </Modal>
442
+ );
443
+ }
444
+
445
  // Voice library (commercially licensed voices from ElevenLabs)
446
 
447
  function LibraryModal({ host, onClose, onAdded }: { host: Host; onClose: () => void; onAdded: (s: VoiceState) => void }) {
 
756
  const [cloneJob, setCloneJob] = useState<Job | null>(null);
757
  const [cloning, setCloning] = useState(false);
758
  const [browsing, setBrowsing] = useState<Host | null>(null);
759
+ const [generating, setGenerating] = useState<Host | null>(null);
760
  const [saved, setSaved] = useState(false);
761
  const [error, setError] = useState<string | null>(null);
762
  const { play, playing } = usePlayer();
 
857
  <>
858
  <label className="field">
859
  <span>In a sentence, you sound like this</span>
860
+ <textarea className="textarea" rows={7} value={draft.summary} onChange={(e) => setDraft({ ...draft, summary: e.target.value })} />
861
  </label>
862
  <details className="delivery">
863
  <summary className="mono muted small">Fine-tune tone, phrases and habits</summary>
 
905
  <p className="eyebrow">Step 2 of 3</p>
906
  <h2>Pick who reads your work aloud</h2>
907
  <p className="muted">
908
+ Audio overviews are a conversation between two hosts, and videos are narrated by Host A. Tap a voice to use it, press play to hear it, or generate your own.
909
  </p>
910
  {!state.tts_configured && <p className="muted">Set ELEVENLABS_API_KEY in .env to generate audio.</p>}
911
+ <VoicePicker state={state} voices={voices} onSaved={setState} onBrowse={setBrowsing} onGenerate={setGenerating} />
 
 
 
 
912
  <div className="row between">
913
  <button className="btn btn-small" onClick={() => setStep("writing")}>
914
  Back
 
1016
  </Modal>
1017
  )}
1018
  {cloning && <CloneModal state={state} onClose={() => setCloning(false)} onDone={setCloneJob} />}
1019
+ {generating && (
1020
+ <GenerateVoiceModal
1021
+ host={generating}
1022
+ state={state}
1023
+ onClose={() => setGenerating(null)}
1024
+ onSaved={(s) => {
1025
+ setState(s);
1026
+ loadVoices();
1027
+ }}
1028
+ onClone={() => setCloning(true)}
1029
+ />
1030
+ )}
1031
  {browsing && (
1032
  <LibraryModal
1033
  host={browsing}
frontend/src/styles/pages.css CHANGED
@@ -2030,12 +2030,189 @@ select.input option {
2030
  box-shadow: var(--glow-sm);
2031
  }
2032
 
2033
- .vp-hosts {
 
 
 
2034
  display: grid;
2035
- grid-template-columns: repeat(auto-fit, minmax(280px, 1fr));
2036
  gap: 12px;
2037
  }
2038
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
2039
  .vp-how {
2040
  list-style: none;
2041
  margin: 0;
 
2030
  box-shadow: var(--glow-sm);
2031
  }
2032
 
2033
+ .voice-grid {
2034
+ list-style: none;
2035
+ margin: 0;
2036
+ padding: 0;
2037
  display: grid;
2038
+ grid-template-columns: repeat(4, minmax(0, 1fr));
2039
  gap: 12px;
2040
  }
2041
 
2042
+ @media (max-width: 720px) {
2043
+ .voice-grid {
2044
+ grid-template-columns: repeat(2, minmax(0, 1fr));
2045
+ }
2046
+ }
2047
+
2048
+ .voice-tile {
2049
+ position: relative;
2050
+ border: 1px solid var(--line);
2051
+ border-radius: var(--radius);
2052
+ transition: border-color 0.2s, box-shadow 0.2s, background 0.2s, transform 0.2s;
2053
+ }
2054
+
2055
+ .voice-tile:hover {
2056
+ border-color: var(--line-strong);
2057
+ transform: translateY(-2px);
2058
+ }
2059
+
2060
+ .voice-tile.on {
2061
+ border-color: var(--blue);
2062
+ background: var(--blue-05);
2063
+ box-shadow: var(--glow-sm);
2064
+ }
2065
+
2066
+ .voice-pick {
2067
+ width: 100%;
2068
+ display: grid;
2069
+ justify-items: center;
2070
+ gap: 4px;
2071
+ padding: 26px 12px 18px;
2072
+ border: 0;
2073
+ background: none;
2074
+ color: var(--white);
2075
+ font: inherit;
2076
+ text-align: center;
2077
+ cursor: pointer;
2078
+ }
2079
+
2080
+ .voice-pick strong {
2081
+ margin-top: 10px;
2082
+ font-size: 1rem;
2083
+ }
2084
+
2085
+ .voice-pick strong,
2086
+ .voice-pick span.small {
2087
+ max-width: 100%;
2088
+ overflow: hidden;
2089
+ text-overflow: ellipsis;
2090
+ white-space: nowrap;
2091
+ }
2092
+
2093
+ .voice-icon {
2094
+ display: grid;
2095
+ place-items: center;
2096
+ width: 76px;
2097
+ height: 76px;
2098
+ border-radius: 999px;
2099
+ border: 1px solid var(--blue-35);
2100
+ color: var(--blue);
2101
+ background: var(--blue-05);
2102
+ transition: background 0.2s, color 0.2s;
2103
+ }
2104
+
2105
+ .voice-tile.on .voice-icon {
2106
+ background: var(--blue);
2107
+ border-color: var(--blue);
2108
+ color: var(--white);
2109
+ box-shadow: var(--glow-sm);
2110
+ }
2111
+
2112
+ .voice-badge {
2113
+ position: absolute;
2114
+ top: 10px;
2115
+ left: 10px;
2116
+ padding: 1px 7px;
2117
+ border-radius: 999px;
2118
+ background: var(--blue);
2119
+ color: var(--white);
2120
+ font-size: 0.625rem;
2121
+ letter-spacing: 0.04em;
2122
+ pointer-events: none;
2123
+ }
2124
+
2125
+ .voice-badge.dim {
2126
+ background: var(--line-strong);
2127
+ color: var(--text-2);
2128
+ }
2129
+
2130
+ .voice-play {
2131
+ position: absolute;
2132
+ top: 8px;
2133
+ right: 8px;
2134
+ padding: 0;
2135
+ border-color: var(--line-strong);
2136
+ }
2137
+
2138
+ .voice-new {
2139
+ border-style: dashed;
2140
+ border-color: var(--line-strong);
2141
+ }
2142
+
2143
+ .voice-new:hover {
2144
+ border-color: var(--blue-60);
2145
+ }
2146
+
2147
+ .voice-icon.plus {
2148
+ border-style: dashed;
2149
+ background: transparent;
2150
+ }
2151
+
2152
+ .voice-new:hover .voice-icon.plus {
2153
+ color: var(--white);
2154
+ border-color: var(--blue);
2155
+ }
2156
+
2157
+ .gen-options {
2158
+ display: grid;
2159
+ gap: 12px;
2160
+ }
2161
+
2162
+ .gen-row {
2163
+ display: grid;
2164
+ gap: 6px;
2165
+ }
2166
+
2167
+ .gen-chips {
2168
+ display: flex;
2169
+ flex-wrap: wrap;
2170
+ gap: 6px;
2171
+ }
2172
+
2173
+ .gen-chips .chip {
2174
+ padding: 5px 12px;
2175
+ background: transparent;
2176
+ color: var(--text-2);
2177
+ font: inherit;
2178
+ font-size: 0.8125rem;
2179
+ cursor: pointer;
2180
+ text-transform: capitalize;
2181
+ }
2182
+
2183
+ .gen-chips .chip:hover {
2184
+ color: var(--white);
2185
+ }
2186
+
2187
+ .gen-chips .chip.on {
2188
+ background: var(--blue);
2189
+ border-color: var(--blue);
2190
+ color: var(--white);
2191
+ }
2192
+
2193
+ .gen-previews {
2194
+ list-style: none;
2195
+ margin: 0;
2196
+ padding: 0;
2197
+ display: grid;
2198
+ gap: 8px;
2199
+ }
2200
+
2201
+ .gen-previews li {
2202
+ display: flex;
2203
+ align-items: center;
2204
+ justify-content: space-between;
2205
+ gap: 12px;
2206
+ padding: 10px 12px;
2207
+ border: 1px solid var(--line);
2208
+ border-radius: var(--radius-sm);
2209
+ }
2210
+
2211
+ .gen-previews li.on {
2212
+ border-color: var(--blue);
2213
+ background: var(--blue-05);
2214
+ }
2215
+
2216
  .vp-how {
2217
  list-style: none;
2218
  margin: 0;
frontend/src/styles/tools.css CHANGED
@@ -23,12 +23,34 @@
23
  gap: 1rem;
24
  }
25
 
 
 
26
  .tool-input {
 
27
  width: 100%;
 
28
  min-height: 14rem;
 
29
  resize: vertical;
30
- font: inherit;
31
- line-height: 1.6;
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
 
32
  }
33
 
34
  .tool-output {
 
23
  gap: 1rem;
24
  }
25
 
26
+ /* Flat, opaque writing surface: overrides the pill shaped, see-through .input so the starfield does not show
27
+ through and the box edge is obvious. */
28
  .tool-input {
29
+ display: block;
30
  width: 100%;
31
+ height: auto;
32
  min-height: 14rem;
33
+ padding: 14px 16px;
34
  resize: vertical;
35
+ border-radius: 8px;
36
+ border: 1px solid rgba(255, 255, 255, 0.28);
37
+ background: #0d0f14;
38
+ box-shadow: none;
39
+ color: var(--text);
40
+ font: 400 1rem/1.6 var(--font-body);
41
+ }
42
+
43
+ .tool-input:hover {
44
+ border-color: rgba(255, 255, 255, 0.4);
45
+ }
46
+
47
+ .tool-input:focus {
48
+ border-color: var(--blue);
49
+ box-shadow: none;
50
+ }
51
+
52
+ .tool-input::placeholder {
53
+ color: var(--text-2);
54
  }
55
 
56
  .tool-output {