"""Interactive REPL for notebook chat.""" import contextlib import re from rich.markdown import Markdown from rich.panel import Panel from notebooklm_tools.cli.utils import get_client, handle_error, make_console from notebooklm_tools.core.alias import get_alias_manager from notebooklm_tools.core.exceptions import NLMError from notebooklm_tools.services import ServiceError from notebooklm_tools.services import notebooks as notebook_service console = make_console() HELP_TEXT = """ [bold]Available Commands:[/bold] /exit, /quit Exit the chat /clear Start new conversation /sources List notebook sources /help Show this help """ def _parse_citations(text: str) -> set[int]: """Extract citation numbers from response text. Handles formats: [1], [1, 2], [11-13], [1, 2, 5-7] """ citations = set() # Find all bracketed citation groups pattern = r"\[(\d+(?:\s*[-,]\s*\d+)*)\]" matches = re.findall(pattern, text) for match in matches: # Split by comma first parts = match.split(",") for part in parts: part = part.strip() if "-" in part: # Handle ranges like "11-13" try: start, end = part.split("-") for num in range(int(start.strip()), int(end.strip()) + 1): citations.add(num) except ValueError: pass else: # Single number with contextlib.suppress(ValueError): citations.add(int(part)) return citations def run_chat_repl(notebook_id: str, profile: str | None = None) -> None: """Run interactive chat session with a notebook.""" notebook_id = get_alias_manager().resolve(notebook_id) try: with get_client(profile) as client: # Use normalized notebook metadata so notebooks with source_count # but no inline sources still display the correct banner. notebook = notebook_service.get_notebook(client, notebook_id) notebook_title = notebook.get("title", "Notebook") source_count = notebook.get("source_count", 0) sources_list = notebook.get("sources", []) # REPL /sources needs typed source metadata, which may not be # present in normalized notebook details. Fetch it separately when # the notebook is not empty, falling back to summary data on error. if source_count > 0: with contextlib.suppress(Exception): sources_list = client.get_notebook_sources_with_types(notebook_id) # Welcome banner console.print( Panel( f"[bold]{notebook_title}[/bold]\n" f"[dim]{source_count} source(s) loaded[/dim]\n\n" f"Type your question and press Enter.\n" f"Use [bold]/help[/bold] for commands, [bold]/exit[/bold] to quit.", title="NotebookLM Chat", border_style="blue", ) ) console.print() conversation_id: str | None = None turn_number = 0 while True: try: # Get user input user_input = console.input("[bold cyan]You:[/bold cyan] ").strip() # Handle empty input if not user_input: continue # Handle slash commands if user_input.startswith("/"): cmd = user_input.lower() if cmd in ("/exit", "/quit"): console.print("\n[dim]Goodbye![/dim]") break elif cmd == "/clear": conversation_id = None turn_number = 0 console.print("[green]✓[/green] Conversation cleared.\n") continue elif cmd == "/sources": if sources_list: console.print("\n[bold]Sources:[/bold]") for i, src in enumerate(sources_list, 1): title = src.get("title", "Untitled") stype = src.get("source_type_name") or src.get( "type", "unknown" ) console.print(f" [{i}] {title} [dim]({stype})[/dim]") console.print() else: console.print("[dim]No sources in this notebook.[/dim]\n") continue elif cmd == "/help": console.print(HELP_TEXT) continue else: console.print(f"[yellow]Unknown command:[/yellow] {user_input}") console.print("[dim]Type /help for available commands.[/dim]\n") continue # Query the notebook turn_number += 1 with console.status("[dim]Thinking...[/dim]", spinner="dots"): result = client.query( notebook_id, query_text=user_input, conversation_id=conversation_id, ) if result: conversation_id = result.get("conversation_id") answer = result.get("answer", "No response.") # Render response with notebook title as label console.print() console.print(f"[bold green]{notebook_title}:[/bold green]") console.print(Markdown(answer)) # Parse and display citation legend cited_nums = _parse_citations(answer) citations_map = result.get("citations", {}) if cited_nums and sources_list: # Build UUID -> title lookup uuid_to_title = { src.get("id"): src.get("title", "Untitled") for src in sources_list } # Collect unique sources cited cited_sources: dict[str, list[int]] = {} for num in sorted(cited_nums): source_uuid = citations_map.get(num) if source_uuid: if source_uuid not in cited_sources: cited_sources[source_uuid] = [] cited_sources[source_uuid].append(num) if cited_sources: console.print() console.print("[dim]" + "─" * 40 + "[/dim]") console.print("[dim]Sources cited:[/dim]") for uuid, nums in cited_sources.items(): title = uuid_to_title.get(uuid, uuid[:8] + "...") nums_str = ", ".join(f"[{n}]" for n in nums) console.print(f"[dim] {nums_str} {title}[/dim]") console.print() else: console.print("[red]No response from AI.[/red]\n") except KeyboardInterrupt: console.print("\n\n[dim]Interrupted. Type /exit to quit.[/dim]\n") continue except (ServiceError, NLMError) as e: handle_error(e, json_output=False) continue except (ServiceError, NLMError) as e: handle_error(e, json_output=False) except KeyboardInterrupt: console.print("\n[dim]Goodbye![/dim]")