NotebookLM / pages /artifacts.py
internomega-terrablue
source selection
690fe5e
"""Artifacts tab: Summary, Podcast, and Quiz generation."""
import os
import tempfile
from datetime import datetime
from state import UserData, get_active_notebook, get_all_artifacts, get_latest_artifact
def _save_artifact_to_temp_file(artifact) -> str | None:
"""Save artifact content to a temporary file. Returns the file path."""
try:
if artifact.type == "podcast" and artifact.audio_path:
if os.path.exists(artifact.audio_path):
return artifact.audio_path
else:
safe_title = artifact.title.replace(" ", "_")[:50]
suffix = '.html' if artifact.type == "quiz" else '.md'
with tempfile.NamedTemporaryFile(
mode='w', suffix=suffix, prefix=f'{safe_title}_',
delete=False, encoding='utf-8'
) as f:
f.write(artifact.content)
return f.name
except Exception as e:
import logging
logging.error(f"Failed to save artifact: {e}")
return None
def _format_time(iso_str: str) -> str:
try:
dt = datetime.fromisoformat(iso_str)
return dt.strftime("%b %d at %H:%M")
except (ValueError, KeyError):
return ""
def _render_artifact_content(artifact) -> str:
"""Render a single artifact as HTML with metadata."""
time_str = _format_time(artifact.created_at)
html = (
f'<div style="padding:4px 0 8px 0; font-size:0.8rem; color:#707088;">'
f'Generated {time_str}</div>'
)
html += (
f'<div style="max-height:400px; overflow-y:auto; padding:16px; '
f'background:rgba(255,255,255,0.02); border:1px solid rgba(255,255,255,0.08); '
f'border-radius:14px;">'
)
html += f'<div class="artifact-content">{artifact.content}</div>'
html += '</div>'
if artifact.type == "podcast":
if artifact.audio_path:
html += (
f'<div style="margin-top:12px; padding:14px 16px; '
f'background:rgba(102,126,234,0.06); border:1px solid rgba(102,126,234,0.15); '
f'border-radius:10px;">'
f'<div style="font-size:0.82rem; color:#8090d0; margin-bottom:8px;">🎧 Podcast Audio</div>'
f'<audio controls style="width:100%; border-radius:6px;">'
f'<source src="/gradio_api/file={artifact.audio_path}" type="audio/mpeg">'
f'<source src="/file={artifact.audio_path}" type="audio/mpeg">'
f'Your browser does not support the audio element.'
f'</audio>'
f'</div>'
)
else:
html += (
'<div style="display:flex; align-items:center; gap:10px; padding:12px 16px; '
'background:rgba(102,126,234,0.06); border:1px solid rgba(102,126,234,0.15); '
'border-radius:10px; margin-top:8px;">'
'<span style="font-size:1.3rem;">πŸ”‡</span>'
'<span style="font-size:0.85rem; color:#8888aa;">Audio generation failed or TTS is unavailable.</span>'
'</div>'
)
return html
def _render_history(artifacts: list, label: str) -> str:
if len(artifacts) <= 1:
return ""
html = f'<details><summary style="cursor:pointer; color:#a0a0b8; font-size:0.85rem; margin-top:12px;">Previous {label} ({len(artifacts) - 1})</summary>'
for a in artifacts[1:]:
time_str = _format_time(a.created_at)
html += f'<div style="margin-top:12px; padding:12px; border:1px solid rgba(255,255,255,0.06); border-radius:10px;">'
html += f'<strong>{a.title}</strong> β€” {time_str}'
html += f'<div style="max-height:200px; overflow-y:auto; margin-top:8px; font-size:0.85rem;">{a.content}</div>'
html += '</div>'
html += '</details>'
return html
# ── No-sources gate ──────────────────────────────────────────────────────────
def render_no_sources_gate(state: UserData) -> str:
nb = get_active_notebook(state)
if not nb or not nb.sources:
return (
'<div class="empty-state">'
'<div style="font-size:3rem; margin-bottom:16px;">🎯</div>'
'<h3>Add sources first</h3>'
'<p>Upload documents in the <strong>Sources</strong> tab to unlock '
'summary, quiz, and podcast generation.</p>'
'</div>'
)
return ""
def has_sources(state: UserData) -> bool:
nb = get_active_notebook(state)
return nb is not None and len(nb.sources) > 0
# ── Conversation Summary ─────────────────────────────────────────────────────
def render_conv_summary_section(state: UserData) -> str:
nb = get_active_notebook(state)
if not nb:
return ""
if not nb.messages:
return "*No conversation yet. Start chatting in the **Chat** tab first.*"
summaries = get_all_artifacts(nb, "conversation_summary")
if not summaries:
return "*Click Generate to create a conversation summary.*"
latest = summaries[0]
time_str = _format_time(latest.created_at)
md = "*Generated " + time_str + "*\n\n---\n\n" + latest.content
if len(summaries) > 1:
prev = ", ".join(_format_time(a.created_at) for a in summaries[1:])
md += "\n\n---\n\n**Previous summaries:** " + prev
return md
def handle_gen_conv_summary(style: str, state: UserData) -> tuple[UserData, str]:
nb = get_active_notebook(state)
if not nb or not nb.messages:
return state, render_conv_summary_section(state)
from services.summary_service import generate_conversation_summary
artifact = generate_conversation_summary(nb, style or "detailed")
nb.artifacts.append(artifact)
return state, render_conv_summary_section(state)
# ── Document Summary ─────────────────────────────────────────────────────────
def render_doc_summary_section(state: UserData) -> str:
nb = get_active_notebook(state)
if not nb:
return ""
summaries = get_all_artifacts(nb, "document_summary")
if not summaries:
return "*Click Generate to create a document summary.*"
latest = summaries[0]
time_str = _format_time(latest.created_at)
md = "*Generated " + time_str + "*\n\n---\n\n" + latest.content
if len(summaries) > 1:
prev = ", ".join(_format_time(a.created_at) for a in summaries[1:])
md += "\n\n---\n\n**Previous summaries:** " + prev
return md
def handle_gen_doc_summary(style: str, selected_sources: list[str] | None, state: UserData) -> tuple[UserData, str]:
nb = get_active_notebook(state)
if not nb:
return state, render_doc_summary_section(state)
from services.summary_service import generate_document_summary
source_ids = [s.id for s in nb.sources if s.filename in (selected_sources or [])]
artifact = generate_document_summary(nb, style or "detailed", source_ids=source_ids or None)
nb.artifacts.append(artifact)
return state, render_doc_summary_section(state)
# ── Podcast ──────────────────────────────────────────────────────────────────
def has_any_summary(state: UserData) -> bool:
nb = get_active_notebook(state)
if not nb:
return False
return (
get_latest_artifact(nb, "document_summary") is not None
or get_latest_artifact(nb, "conversation_summary") is not None
)
def render_podcast_locked() -> str:
return (
'<div class="locked-state">'
'<div style="font-size:2.5rem; margin-bottom:16px;">πŸ”’</div>'
'<h3 style="color:#a0a0b8; font-weight:600; margin-bottom:8px;">Summary Required</h3>'
'<p style="color:#707088; font-size:0.9rem; line-height:1.6;">'
'Generate a summary first in the <strong>Summary</strong> tab.<br>'
'The podcast is created from your summary to ensure accuracy.</p>'
'<div style="margin-top:20px; display:inline-flex; align-items:center; gap:8px; '
'padding:8px 18px; background:rgba(102,126,234,0.08); '
'border:1px solid rgba(102,126,234,0.15); border-radius:20px; '
'font-size:0.82rem; color:#8090d0;">'
'πŸ“ Summary &nbsp;β†’&nbsp; πŸŽ™οΈ Podcast</div>'
'</div>'
)
def render_podcast_section(state: UserData) -> str:
nb = get_active_notebook(state)
if not nb:
return ""
if not has_any_summary(state):
return render_podcast_locked()
latest_doc = get_latest_artifact(nb, "document_summary")
latest_conv = get_latest_artifact(nb, "conversation_summary")
latest = latest_doc or latest_conv
sum_title = latest.title
sum_time = _format_time(latest.created_at)
html = (
f'<div style="display:flex; align-items:center; gap:12px; padding:12px 18px; '
f'background:rgba(34,197,94,0.06); border:1px solid rgba(34,197,94,0.15); '
f'border-radius:12px; margin-bottom:16px;">'
f'<span style="font-size:1.2rem;">πŸ“</span>'
f'<div><span style="font-size:0.85rem; color:#a0b8a0;">Based on: </span>'
f'<strong style="color:#c0e0c0;">{sum_title}</strong>'
f'<span style="color:#708070; font-size:0.8rem;"> ({sum_time})</span></div>'
f'</div>'
)
podcasts = get_all_artifacts(nb, "podcast")
if podcasts:
html += _render_artifact_content(podcasts[0])
html += _render_history(podcasts, "podcasts")
else:
html += (
'<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; '
'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">'
'<div style="font-size:2rem; margin-bottom:10px;">πŸŽ™οΈ</div>'
'<p>No podcast generated yet.<br>Click <strong>Generate Podcast</strong> to create one.</p>'
'</div>'
)
return html
def handle_gen_podcast(state: UserData) -> tuple[UserData, str]:
"""Generate a podcast using Claude script + HF TTS audio."""
from services.podcast_service import generate_podcast
nb = get_active_notebook(state)
if not nb or not has_any_summary(state):
return state, render_podcast_section(state)
latest_doc = get_latest_artifact(nb, "document_summary")
latest_conv = get_latest_artifact(nb, "conversation_summary")
latest_summary = latest_doc or latest_conv
artifact = generate_podcast(nb, latest_summary.content)
nb.artifacts.append(artifact)
return state, render_podcast_section(state)
# ── Quiz ─────────────────────────────────────────────────────────────────────
def render_quiz_section(state: UserData) -> str:
nb = get_active_notebook(state)
if not nb:
return ""
quizzes = get_all_artifacts(nb, "quiz")
if not quizzes:
return (
'<div style="text-align:center; padding:40px 20px; color:#606078; margin-top:16px; '
'border:1px dashed rgba(255,255,255,0.08); border-radius:14px;">'
'<div style="font-size:2rem; margin-bottom:10px;">❓</div>'
'<p>No quiz generated yet.<br>Choose the number of questions and click <strong>Generate Quiz</strong>.</p>'
'</div>'
)
html = _render_artifact_content(quizzes[0])
html += _render_history(quizzes, "quizzes")
return html
def handle_gen_quiz(num_questions: int, state: UserData) -> tuple[UserData, str]:
"""Generate an interactive quiz using Claude."""
from services.quiz_service import generate_quiz
nb = get_active_notebook(state)
if not nb:
return state, render_quiz_section(state)
num_q = int(num_questions) if num_questions else 5
artifact = generate_quiz(nb, num_q)
nb.artifacts.append(artifact)
return state, render_quiz_section(state)
# ── Download Handlers ────────────────────────────────────────────────────────
def _find_artifact_by_id(state: UserData, artifact_id: str):
"""Find an artifact by ID in the active notebook."""
nb = get_active_notebook(state)
if not nb:
return None
for artifact in nb.artifacts:
if artifact.id == artifact_id:
return artifact
return None
def download_artifact(artifact_id: str, state: UserData) -> str | None:
"""Download an artifact and return the file path for Gradio download."""
artifact = _find_artifact_by_id(state, artifact_id)
if not artifact:
return None
return _save_artifact_to_temp_file(artifact)