├── .gitignore ├── LICENSE ├── README.md ├── panama.epub ├── reader.py ├── requirements.txt └── tts.py /.gitignore: -------------------------------------------------------------------------------- 1 | # Virtual Environment 2 | .venv/ 3 | venv/ 4 | ENV/ 5 | .env 6 | 7 | # Python 8 | __pycache__/ 9 | *.py[cod] 10 | *.class 11 | *.so 12 | .Python 13 | build/ 14 | develop-eggs/ 15 | dist/ 16 | downloads/ 17 | eggs/ 18 | .eggs/ 19 | lib/ 20 | lib64/ 21 | parts/ 22 | sdist/ 23 | var/ 24 | wheels/ 25 | *.egg-info/ 26 | .installed.cfg 27 | *.egg 28 | 29 | # IDE specific files 30 | .idea/ 31 | .vscode/ 32 | *.swp 33 | *.swo 34 | .DS_Store 35 | 36 | # Misc 37 | *.epub 38 | !panama.epub 39 | 40 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2024 Divesh Punjabi 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # weft 🪢 2 | 3 | A vim-like terminal reader to chat with your books 4 | 5 | 6 | 7 | ## Features 8 | 9 | ### Vim-like navigation 10 | 11 | - Flip between chapters: `h`/`l` or `←`/`→` 12 | - Scroll through pages: `j`/`k` or `↑`/`↓` 13 | - Jump to start/end: `g`/`G` 14 | - See table of contents: `t` 15 | 16 | ### Chat with your books 17 | 18 | - `a` - Chat with your current text 19 | - `s` - Generate summary 20 | - `r` - Listen text 21 | - `>` - Listen to the compass 22 | 23 | Uses [LLM](https://github.com/simonw/llm) to interface with OpenAI, Anthropic, and other providers. You can also install [plugins](https://llm.datasette.io/en/stable/other-models.html) to run local models on your machine. 24 | 25 | ## Getting started 26 | 27 | Clone this repo and setup & activate venv using either [uv](https://github.com/astral-sh/uv) (recommended) 28 | 29 | ```bash 30 | uv venv 31 | source .venv/bin/activate 32 | ``` 33 | 34 | Or, standard Python tools: 35 | 36 | ```bash 37 | python3 -m pip install virtualenv 38 | python3 -m virtualenv .venv 39 | source .venv/bin/activate 40 | ``` 41 | 42 | Install dependencies with: 43 | 44 | ```bash 45 | uv pip install -r requirements.txt # if using `uv` - faster! 46 | # or 47 | pip install -r requirements.txt 48 | ``` 49 | 50 | Bring your keys from OpenAI (default): 51 | 52 | ```bash 53 | llm keys set OPENAI_API_KEY 54 | ``` 55 | 56 | Or use Anthropic's Claude: 57 | 58 | ```bash 59 | llm install llm-claude-3 60 | llm keys set ANTHROPIC_API_KEY 61 | llm models default claude-3-5-sonnet-latest 62 | ``` 63 | 64 | Or, install a local model and run it on your machine: 65 | 66 | ```bash 67 | llm install llm-gpt4all 68 | llm models list # shows a list of available models 69 | llm -m orca-mini-3b-gguf2-q4_0 '3 names for a pet cow' # tests the orca model locally (and downloads it first if needed) 70 | ``` 71 | 72 | ## Try it! 73 | 74 | Get a book from [Project Gutenberg](https://www.gutenberg.org/) and try it out: 75 | 76 | ```bash 77 | uv run reader.py path/to/book.epub 78 | ``` 79 | -------------------------------------------------------------------------------- /panama.epub: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/dpunj/weft/5a389201b7c746983fb10ebbdf767462b4e6c7a0/panama.epub -------------------------------------------------------------------------------- /reader.py: -------------------------------------------------------------------------------- 1 | # reader.py 2 | import typer 3 | import llm 4 | import ebooklib 5 | from ebooklib import epub 6 | import html2text 7 | from rich.console import Console 8 | from rich.panel import Panel 9 | from rich.markdown import Markdown 10 | from rich.table import Table 11 | from readchar import readkey 12 | from pathlib import Path 13 | from tts import text_to_speech_stream 14 | from elevenlabs import play 15 | 16 | app = typer.Typer() 17 | console = Console() 18 | 19 | class Reader: 20 | def __init__(self, epub_path: str): 21 | self.book = epub.read_epub(epub_path) 22 | self.model = llm.get_model("gpt-4o-mini") 23 | self.sections = self._process_sections() 24 | self.current_index = self.current_page = 0 25 | self.pages = [] 26 | self.metadata = self._extract_metadata() 27 | 28 | 29 | def _extract_metadata(self): 30 | return { 31 | key: self.book.get_metadata('DC', key) 32 | for key in ['title', 'author', 'language', 'description'] 33 | } 34 | 35 | def _process_sections(self): 36 | # convert epub sections to text 37 | html2tui = html2text.HTML2Text() 38 | html2tui.ignore_links = html2tui.ignore_images = True 39 | sections, prev = [], None 40 | 41 | for item in self.book.get_items(): 42 | if item.get_type() != ebooklib.ITEM_DOCUMENT: 43 | continue 44 | 45 | try: 46 | if content := item.get_content(): 47 | content = html2tui.handle(content.decode('utf-8')) 48 | section = { 49 | 'content': content, 50 | 'title': self._extract_title(item, content), 51 | 'parent': prev['title'] if prev else None, 52 | } 53 | sections.append(section) 54 | prev = section 55 | except Exception as e: 56 | console.print(f"[yellow]Warning: Couldn't process section: {e}[/yellow]") 57 | 58 | return sections 59 | 60 | def _extract_title(self, item, content): 61 | # try to get title from item 62 | if hasattr(item, 'title') and item.title: 63 | return item.title 64 | 65 | # check first 5 lines for markdown heading 66 | lines = content.strip().split('\n') 67 | for line in lines[:5]: # Check first 5 lines 68 | if line.startswith('#'): # Markdown heading 69 | return line.lstrip('#').strip() 70 | 71 | # fallback to filename 72 | return Path(item.file_name).stem.replace('_', ' ').strip() 73 | 74 | def display_current(self): 75 | # show current section with progress indicators 76 | if not self.sections: 77 | console.print("[red]No content available[/red]") 78 | return 79 | 80 | section = self.sections[self.current_index] 81 | content = section['content'] 82 | 83 | console_height = console.height - 10 84 | 85 | # split content into paragraphs and group them into pages 86 | paragraphs = content.split('\n\n') 87 | self.pages = [] 88 | current_page = [] 89 | current_lines = 0 90 | 91 | for para in paragraphs: 92 | # Estimate lines this paragraph will take (including word wrap) 93 | para_lines = len(para) // (console.width - 10) + para.count('\n') + 2 94 | 95 | if current_lines + para_lines > console_height: 96 | if current_page: # Save current page if not empty 97 | self.pages.append('\n\n'.join(current_page)) 98 | current_page = [para] 99 | current_lines = para_lines 100 | else: # If a single paragraph is longer than page height, force split it 101 | self.pages.append(para) 102 | current_page = [] 103 | current_lines = 0 104 | else: 105 | current_page.append(para) 106 | current_lines += para_lines 107 | 108 | # Add the last page if there's content 109 | if current_page: 110 | self.pages.append('\n\n'.join(current_page)) 111 | 112 | # ensure we have at least one page 113 | if not self.pages: 114 | self.pages = ['[No content]'] 115 | 116 | # safety check: ensure current_page is within bounds 117 | if self.current_page >= len(self.pages): 118 | self.current_page = len(self.pages) - 1 119 | elif self.current_page < 0: 120 | self.current_page = 0 121 | 122 | page_content = self.pages[self.current_page] 123 | 124 | # calculate progress 125 | overall_progress = (self.current_index / len(self.sections)) * 100 126 | section_progress = (self.current_page / len(self.pages)) * 100 127 | 128 | # render ui 129 | console.clear() 130 | header = Table.grid(padding=1, expand=True) 131 | header.add_column("title", justify="left", ratio=2) 132 | header.add_column("progress", justify="right", ratio=1) 133 | header.add_row( 134 | f"[bold blue]{section['title']}[/]", 135 | f"[yellow]Section {self.current_index + 1}/{len(self.sections)} " 136 | f"• Page {self.current_page + 1}/{len(self.pages)} ({section_progress:.1f}%) " 137 | f"• Overall {overall_progress:.1f}%[/]" 138 | ) 139 | 140 | # display progress and content 141 | console.print(Panel(header)) 142 | console.print(Panel( 143 | Markdown(page_content), 144 | border_style="blue" 145 | )) 146 | 147 | # show nav help 148 | nav_help = Table.grid(padding=1, expand=True) 149 | nav_help.add_column(style="dim") 150 | nav_help.add_row( 151 | "←(h) →(l) • ↑(k) ↓(j) • TOC(t) • Summarize(s) • Ask AI(a) • Read(r) • Guide(>) • Quit(q)" 152 | 153 | ) 154 | console.print(Panel(nav_help)) 155 | 156 | def show_toc(self): 157 | """Display table of contents.""" 158 | sections_list = "" 159 | for i, section in enumerate(self.sections): 160 | marker = "→" if i == self.current_index else " " 161 | sections_list += f"{marker} {i+1}. {section['title']}\n" 162 | 163 | console.clear() 164 | console.print(Panel( 165 | sections_list, 166 | title="[blue]Table of Contents[/]", 167 | border_style="blue" 168 | )) 169 | console.input("\nPress Enter to continue...") 170 | 171 | def navigate(self, direction: int) -> bool: 172 | match direction: 173 | case -1 | 1: # sections ←/→ 174 | new_index = self.current_index + direction 175 | if 0 <= new_index < len(self.sections): 176 | self.current_index, self.current_page = new_index, 0 177 | return True 178 | case -2 | 2: # pages ↑/↓ 179 | new_page = self.current_page + (1 if direction == 2 else -1) 180 | if 0 <= new_page < len(self.pages): 181 | self.current_page = new_page 182 | return True 183 | # Try next/prev section 184 | new_index = self.current_index + (1 if direction == 2 else -1) 185 | if 0 <= new_index < len(self.sections): 186 | self.current_index = new_index 187 | self.current_page = 0 if direction == 2 else len(self.pages) - 1 188 | return True 189 | case -99: self.current_index = self.current_page = 0; return True # start 190 | case 99: self.current_index = len(self.sections)-1; self.current_page = len(self.pages)-1; return True # end 191 | return False 192 | 193 | # ai 194 | def _get_section_context(self): 195 | # get relevant context for ai from current position in book 196 | current = self.sections[self.current_index] 197 | current_content = self.pages[self.current_page] 198 | 199 | # get book metadata 200 | book_info = [] 201 | if self.metadata.get('title'): 202 | book_info.append(f"Title: {self.metadata['title'][0][0]}") 203 | if self.metadata.get('author'): 204 | book_info.append(f"Author: {self.metadata['author'][0][0]}") 205 | 206 | # get hierarchical context 207 | hierarchy = [] 208 | if current.get('parent'): 209 | hierarchy.append(f"Section: {current['parent']} > {current['title']}") 210 | else: 211 | hierarchy.append(f"Section: {current['title']}") 212 | 213 | context = f"""Book Information: {' | '.join(book_info)} 214 | 215 | Location: {' > '.join(hierarchy)} 216 | Page: {self.current_page + 1} of {len(self.pages)} 217 | 218 | Content: 219 | {current_content}""" 220 | 221 | return context 222 | 223 | def ask_ai(self): 224 | def calculate_layout(): 225 | available_height = console.height - 12 # Extra space for question input 226 | content_height = available_height // 2 227 | response_height = available_height - content_height 228 | return content_height, response_height 229 | 230 | def render_split_view(content, response_text="", question=""): 231 | console.clear() 232 | content_height, response_height = calculate_layout() 233 | 234 | # Format the content to fit the panel 235 | content_lines = content.split('\n') 236 | visible_content = '\n'.join(content_lines[:content_height-4]) # Leave room for panel borders 237 | 238 | # Content panel - show as much as fits in the allocated height 239 | console.print(Panel( 240 | Markdown(visible_content), 241 | title="[blue]Current Text[/]", 242 | border_style="blue", 243 | height=content_height, 244 | expand=True 245 | )) 246 | 247 | # Response panel 248 | console.print(Panel( 249 | Markdown(response_text) if response_text else "[dim]Waiting for response...[/dim]", 250 | title=f"[green]AI Response{f' to: {question}' if question else ''}[/]", 251 | border_style="green", 252 | height=response_height, 253 | expand=True 254 | )) 255 | 256 | def stream_response(conversation, question, content): 257 | text = "" 258 | with console.status("[bold green]Thinking...[/]"): 259 | for chunk in conversation.prompt(f"Based on this text:\n{content}\n\nQuestion: {question}"): 260 | text += chunk 261 | render_split_view(content, text, question) 262 | return text 263 | 264 | conversation = self.model.conversation() 265 | conversation.system = "You are an expert reading assistant analyzing a book. Keep responses clear and concise." 266 | content = self._get_section_context() 267 | 268 | while True: 269 | render_split_view(content) 270 | 271 | question = console.input("\n[bold green]Question (:q to exit):[/] ").strip() 272 | while question: 273 | if question.lower() in (':q', ':quit'): 274 | return 275 | 276 | try: 277 | stream_response(conversation, question, content) 278 | question = console.input("\n[dim]Follow-up? (:q quit):[/] ").strip() 279 | except Exception as e: 280 | console.print(f"[red]Error: {e}[/red]") 281 | console.input("\nPress Enter to continue...") 282 | return 283 | 284 | def summarize_current(self): 285 | if not self.pages: 286 | console.print("[red]No content to summarize[/red]") 287 | return 288 | 289 | def create_prompt(content): 290 | return f"""Please provide a concise summary of this text: 291 | {content} 292 | Focus on the key points and main ideas. Keep the summary brief and clear.""" 293 | 294 | def calculate_layout(): 295 | available_height = console.height - 8 296 | content_height = available_height // 2 297 | summary_height = available_height - content_height 298 | return content_height, summary_height 299 | 300 | def render_split_view(content, summary_text=""): 301 | console.clear() 302 | content_height, summary_height = calculate_layout() 303 | 304 | # Content panel 305 | console.print(Panel( 306 | Markdown(content), 307 | title="[blue]Current Text[/]", 308 | border_style="blue", 309 | height=content_height 310 | )) 311 | 312 | # Summary panel 313 | console.print(Panel( 314 | summary_text or "[dim]Generating summary...[/dim]", 315 | title="[green]Content Summary[/]", 316 | border_style="green", 317 | height=summary_height 318 | )) 319 | 320 | def stream_summary(): 321 | current_content = self.pages[self.current_page] 322 | summary = "" 323 | 324 | with console.status("[bold green]Thinking...[/]"): 325 | response = self.model.prompt(create_prompt(current_content)) 326 | for chunk in response: 327 | summary += chunk 328 | render_split_view(current_content, summary) 329 | 330 | return summary 331 | 332 | try: 333 | final_summary = stream_summary() 334 | console.input("\nPress Enter to continue...") 335 | except Exception as e: 336 | console.print(f"[red]Error generating summary: {e}[/red]") 337 | console.input("\nPress Enter to continue...") 338 | 339 | def read_aloud(self): 340 | """Read the current page aloud using text-to-speech.""" 341 | if not self.pages: 342 | console.print("[red]No content to read[/red]") 343 | return 344 | 345 | try: 346 | content = self.pages[self.current_page] 347 | with console.status("[bold green]Converting text to speech...[/]"): 348 | audio = text_to_speech_stream(content) 349 | 350 | with console.status("[bold green]Reading aloud... (Press Ctrl+C to stop)[/]"): 351 | play(audio) 352 | 353 | except KeyboardInterrupt: 354 | console.print("\n[yellow]Stopped reading.[/yellow]") 355 | except Exception as e: 356 | console.print(f"[red]Error reading aloud: {e}[/red]") 357 | finally: 358 | console.input("\nPress Enter to continue...") 359 | 360 | def read_compass(self): 361 | """Generate and play an AI audio guide for current location.""" 362 | try: 363 | prompt = f"""Provide a brief audio guide for the reader's current location: 364 | - Current story location 365 | - Scene context 366 | - Key characters/themes 367 | 368 | Keep it conversational, like an audiobook companion. 369 | 370 | Context: 371 | {self._get_section_context()}""" 372 | 373 | with console.status("[bold green]Creating guide...[/]"): 374 | response = "".join(chunk for chunk in self.model.prompt(prompt)) 375 | 376 | with console.status("[bold green]Reading guide... (Ctrl+C to stop)[/]"): 377 | play(text_to_speech_stream(response)) 378 | 379 | except KeyboardInterrupt: 380 | console.print("\n[yellow]Stopped reading.[/yellow]") 381 | except Exception as e: 382 | console.print(f"[red]Error: {e}[/red]") 383 | finally: 384 | console.input("\nPress Enter to continue...") 385 | 386 | @app.command() 387 | def read(epub_path: str = typer.Argument(..., help="Path to EPUB file")): 388 | """Interactive ebook reader with AI assistance.""" 389 | if not Path(epub_path).exists() or not epub_path.endswith('.epub'): 390 | console.print("[red]Please provide a valid EPUB file[/red]") 391 | raise typer.Exit(1) 392 | 393 | try: 394 | reader = Reader(epub_path) 395 | while True: 396 | reader.display_current() 397 | match readkey(): 398 | case 'q': break 399 | case 'h' | '\x1b[D': reader.navigate(-1) # h/← for prev section 400 | case 'l' | '\x1b[C': reader.navigate(1) # l/→ for next section 401 | case 'j' | '\x1b[B': reader.navigate(2) # j/↓ for next page 402 | case 'k' | '\x1b[A': reader.navigate(-2) # k/↑ for prev page 403 | case 'g': reader.navigate(-99) # g for start 404 | case 'G': reader.navigate(99) # G for end 405 | case 'a': reader.ask_ai() 406 | case 's': reader.summarize_current() 407 | case 't': reader.show_toc() 408 | case 'r': reader.read_aloud() 409 | case '>': reader.read_compass() 410 | except KeyboardInterrupt: 411 | console.print("\n[yellow]Reader closed.[/yellow]") 412 | 413 | if __name__ == "__main__": 414 | app() 415 | -------------------------------------------------------------------------------- /requirements.txt: -------------------------------------------------------------------------------- 1 | annotated-types==0.7.0 2 | anthropic==0.39.0 3 | anyio==4.6.2.post1 4 | certifi==2024.8.30 5 | charset-normalizer==3.4.0 6 | click==8.1.7 7 | click-default-group==1.2.4 8 | distro==1.9.0 9 | ebooklib==0.18 10 | elevenlabs==1.12.1 11 | h11==0.14.0 12 | html2text==2024.2.26 13 | httpcore==1.0.6 14 | httpx==0.27.2 15 | idna==3.10 16 | jiter==0.7.0 17 | llm==0.17.1 18 | llm-claude-3==0.8 19 | lxml==5.3.0 20 | markdown-it-py==3.0.0 21 | mdurl==0.1.2 22 | openai==1.54.0 23 | pip==24.3.1 24 | pluggy==1.5.0 25 | puremagic==1.28 26 | pydantic==2.9.2 27 | pydantic-core==2.23.4 28 | pygments==2.18.0 29 | python-dateutil==2.9.0.post0 30 | python-ulid==3.0.0 31 | pyyaml==6.0.2 32 | readchar==4.2.1 33 | requests==2.32.3 34 | rich==13.9.4 35 | setuptools==75.3.0 36 | shellingham==1.5.4 37 | simpleaudio==1.0.4 38 | six==1.16.0 39 | sniffio==1.3.1 40 | sqlite-fts4==1.0.3 41 | sqlite-migrate==0.1b0 42 | sqlite-utils==3.37 43 | tabulate==0.9.0 44 | tqdm==4.66.6 45 | typer==0.12.5 46 | typing-extensions==4.12.2 47 | urllib3==2.2.3 48 | websockets==13.1 49 | -------------------------------------------------------------------------------- /tts.py: -------------------------------------------------------------------------------- 1 | import os 2 | import dotenv 3 | from io import BytesIO 4 | from elevenlabs import play, VoiceSettings 5 | from elevenlabs.client import ElevenLabs 6 | 7 | dotenv.load_dotenv() 8 | 9 | client = ElevenLabs(api_key=os.getenv("ELEVENLABS_API_KEY")) 10 | 11 | def text_to_speech_stream(text: str) -> BytesIO: 12 | audio = BytesIO() 13 | # tts conversion 14 | response = client.text_to_speech.convert( 15 | text=text, 16 | voice_id="pNInz6obpgDQGcFmaJgB", # Adam pre-made voice 17 | model_id="eleven_multilingual_v2", 18 | voice_settings=VoiceSettings( 19 | stability=0.0, 20 | similarity_boost=1.0, 21 | style=0.0, 22 | use_speaker_boost=True, 23 | ), 24 | ) 25 | 26 | for chunk in response: 27 | audio.write(chunk) 28 | audio.seek(0) 29 | return audio 30 | 31 | # testing 32 | if __name__ == "__main__": 33 | play(text_to_speech_stream("heya just testing out the elevenlabs api")) --------------------------------------------------------------------------------