diff --git a/.env.example b/.env.example
new file mode 100644
index 00000000..8981c7e1
--- /dev/null
+++ b/.env.example
@@ -0,0 +1,5 @@
+# Reader3 AI 配置
+# 填入你的 API Key 后保存,重启应用生效
+# 免费获取 Gemini Key: https://aistudio.google.com/apikey
+
+GEMINI_API_KEY=""
diff --git a/.gitignore b/.gitignore
index 9e1d25d4..e3415cad 100644
--- a/.gitignore
+++ b/.gitignore
@@ -1,14 +1,50 @@
-# Python-generated files
-__pycache__/
-*.py[oc]
-build/
-dist/
-wheels/
-*.egg-info
+# Book data (extracted images, pkl, etc.)
+*_data/
-# Virtual environments
-.venv
+# TTS cache
+.tts_cache/
-# Custom
-*_data/
+# Library metadata cache
+.library_index.json
+
+# EPUB source files
*.epub
+!assets/Meditations by Emperor of Rome Marcus Aurelius.epub
+
+# Dictionary data
+dict/
+
+# Test files
+test_*.py
+
+# Python
+__pycache__/
+*.pyc
+.venv/
+
+# OS
+.DS_Store
+
+# AI config (contains API keys)
+ai_config.json
+
+# --- bkit / PDCA metadata ---
+docs/.pdca-snapshots/
+docs/.pdca-status.json
+docs/.bkit-memory.json
+
+# 敏感环境配置文件(截获 .env)
+.env
+.env.*
+!.env.example
+
+# 插件及工具生成的本地配置(截获 settings.local.json)
+settings.local.json
+.claude/
+.gemini/
+
+# 日志文件(截获 server.log)
+*.log
+
+# PDCA 状态文件(补全根目录下的路径,截获 .pdca-status.json)
+.pdca-status.json
\ No newline at end of file
diff --git a/CLAUDE.md b/CLAUDE.md
new file mode 100644
index 00000000..0a380310
--- /dev/null
+++ b/CLAUDE.md
@@ -0,0 +1,52 @@
+# Reader3 — AI 智能电子书阅读器
+
+## 项目简介
+
+本地部署的 AI EPUB 阅读器,集成划词查词、AI 翻译/对话、TTS 朗读、高亮笔记系统。灵感源自 Karpathy 的同名项目,在其基础上深度重构。
+
+## 架构
+
+| 文件 | 职责 |
+|------|------|
+| `server.py` | FastAPI 后端 — 图书加载、AI 路由(20+ 提供商)、TTS (edge-tts)、Google Translate、ECDICT 词典、EPUB 上传 |
+| `reader3.py` | EPUB 解析模块 |
+| `templates/reader.html` | 阅读器页面(CSS + HTML + JS 单文件) |
+| `templates/library.html` | 图书馆页面(封面墙、上传、Apple Books 扫描) |
+| `tools/_md2pdf.py` | 文档转 PDF(Playwright/Chromium),源文件在 `docs/` |
+
+## 常用命令
+
+```bash
+# 启动开发服务
+uvicorn server:app --host 0.0.0.0 --port 8123 --reload
+
+# 生成文档 PDF
+python tools/_md2pdf.py
+```
+
+## 开发规范
+
+- **包管理**:始终使用 `uv pip install`,不用 pip
+- **语言**:始终用中文回复
+- **前端**:reader.html 是单文件架构(CSS + HTML + JS),不拆分
+- **数据存储**:图书数据 `{name}_data/book.pkl`(pickle),高亮笔记在浏览器 localStorage
+- **AI 配置**:`ai_config.json` 存储提供商设置,`.env` 存储 API Key
+
+## 目录说明
+
+```
+reader3/
+├── server.py / reader3.py # 后端
+├── templates/ # 前端页面
+├── docs/ # 源文档 (md)
+├── tools/ # 开发工具脚本
+├── assets/ # 截图、预置电子书
+├── dict/ # 词典文件(应用内下载)
+└── books/ # 导入的电子书数据
+```
+
+## 隐私约束
+
+- `books/` 下的电子书数据、`.env`、`ai_config.json` 禁止提交
+- 词典文件 (`dict/*.db`) 不提交
+- 服务器日志 `server.log` 不提交
diff --git a/GEMINI.md b/GEMINI.md
new file mode 100644
index 00000000..23ba4a7d
--- /dev/null
+++ b/GEMINI.md
@@ -0,0 +1,16 @@
+# Project Intelligence: Reader3
+
+一个现代化的、支持 AI 交互的 EPUB 阅读器。
+
+## 🚀 运行环境 (Runtime)
+- **后端**: FastAPI
+- **音频引擎**: Edge-TTS
+- **环境管理**: `uv` (强制)
+
+## 🧠 AI 协作规范 (AI Patterns)
+- **模型选型**: 已固定为 **gemini-1.5-flash** (追求极速响应)。
+- **核心功能**: 负责全文翻译、内容摘要及 TTS 文本预处理。
+
+## 📁 隔离规范 (Isolation)
+- **数据缓存**: `books/` 目录下的解析结果和 `cache/` 音频严禁提交。
+- **字典**: `dict/` 下的本地词库不被跟踪。
\ No newline at end of file
diff --git a/LICENSE b/LICENSE
new file mode 100644
index 00000000..64b0f018
--- /dev/null
+++ b/LICENSE
@@ -0,0 +1,21 @@
+MIT License
+
+Copyright (c) 2026 Haining Yu
+
+Permission is hereby granted, free of charge, to any person obtaining a copy
+of this software and associated documentation files (the "Software"), to deal
+in the Software without restriction, including without limitation the rights
+to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
+copies of the Software, and to permit persons to whom the Software is
+furnished to do so, subject to the following conditions:
+
+The above copyright notice and this permission notice shall be included in all
+copies or substantial portions of the Software.
+
+THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
+IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
+FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
+AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
+LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
+OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
+SOFTWARE.
diff --git a/README.en.md b/README.en.md
new file mode 100644
index 00000000..7d114891
--- /dev/null
+++ b/README.en.md
@@ -0,0 +1,78 @@
+English | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> "When technology is democratized by AI, aesthetics and human-centric design become the ultimate differentiators."
+
+Inspired by Andrej Karpathy's [minimalist reader prototype](https://x.com/karpathy/status/1990577951671509438), Smoothie Reader is a locally deployed AI-powered e-book reader with built-in word lookup, AI translation & chat, TTS reading, and highlight notes — designed for deep reading.
+
+📖 **Built-in sample book**: The repository includes "Meditations" by Marcus Aurelius (via Project Gutenberg), ready to explore after cloning.
+
+
+
+
Your personal library at a glance
+
+
+## ✨ Key Features
+
+- 🔍 **Intuitive Discovery**: Highlight any text to reveal the action bar. Built-in support for **ECDICT** (English) and Chinese offline dictionaries.
+- 🤖 **AI-Powered Reading**:
+ - **Inline Translation**: High-quality, contextual translations elegantly embedded below the original text.
+ - **AI Companion**: A sidebar AI assistant supporting streaming dialogue and multi-turn memory for deep engagement.
+ - **Broad Compatibility**: Built-in support for OpenAI, Anthropic, Gemini, DeepSeek, Grok, Alibaba Cloud Bailian, Volcengine, Tencent Hunyuan, MiniMax, Moonshot, SiliconFlow, Cerebras, SambaNova, Groq, Mistral, DeepInfra, Together AI, OpenRouter, Zhipu AI, and ModelScope — 20 AI providers in total, plus custom OpenAI-compatible endpoints.
+
+
+
+
Selection toolbar · Inline translation · AI companion sidebar
+
+
+- 🔊 **TTS Reading**: Powered by Edge-TTS with multiple high-quality Chinese and English voices.
+- ✏️ **Highlights & Notes**: 5-color highlighting, inline annotations, and bookmarks — all stored in your browser's **localStorage**.
+
+
+
+
Three-column reading: TOC navigation · Immersive text · Multi-color highlights
+
+
+- 🎨 **Minimalist Aesthetics**: 6 curated themes and a flexible 3-column layout (TOC / Content / AI), optimized for all devices.
+
+
+
+
Themes, typography, dictionaries, AI models — all in one panel
+
+
+## 🚀 Quick Start
+
+This project uses [uv](https://docs.astral.sh/uv/) to manage the Python environment and dependencies.
+
+### 1. Install uv
+Ensure Python 3.10+ is available, then install `uv`:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. Import a Book & Launch
+```bash
+# Import an EPUB e-book
+uv run reader3.py your_book.epub
+
+# Start the server
+uv run server.py
+```
+Open your browser at: 👉 **http://localhost:8123**
+
+### 3. Configure AI
+Enter the reading interface, click **Settings** in the top-left corner, and configure your **AI Provider** and API Key (e.g., [get a free Gemini Key](https://aistudio.google.com/apikey)).
+
+> [!TIP]
+> **🚀 Easter Egg**: Anywhere on the page, enter **`↑ ↑ ↓ ↓ ← → ← → B A`** (Konami Code) to unlock the hidden **Advanced AI Routing Panel**.
+
+## 🛡️ Privacy
+- **Local First**: No data leaves your device unless you explicitly trigger an AI or TTS request.
+- **No Account Required**: Your data is stored only in your browser's `localStorage`.
+
+## 📚 User Guide
+For detailed configuration (offline dictionaries, multi-device access, port settings), see the [User Guide](docs/GUIDE.md).
+
+## 📄 License
+[MIT License](LICENSE)
diff --git a/README.es.md b/README.es.md
new file mode 100644
index 00000000..a24cd5d6
--- /dev/null
+++ b/README.es.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | Español | [Français](README.fr.md) | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> "Cuando la tecnología es democratizada por la IA, la estética y el diseño centrado en el ser humano se convierten en los diferenciadores definitivos."
+
+Inspirado por el [prototipo de lector minimalista](https://x.com/karpathy/status/1990577951671509438) de Andrej Karpathy, Smoothie Reader es un lector de libros electrónicos con IA que se ejecuta localmente. Integra búsqueda de palabras, traducción y diálogo con IA, lectura TTS y notas de resaltado, diseñado para una lectura profunda.
+
+📖 **Libro de ejemplo incluido**: El repositorio incluye "Meditaciones" de Marco Aurelio (vía Project Gutenberg), listo para explorar tras clonar.
+
+
+
+
Tu biblioteca personal de un vistazo
+
+
+## ✨ Características Principales
+
+- 🔍 **Descubrimiento Intuitivo**: Resalte cualquier texto para revelar la barra de acciones. Soporte integrado para **ECDICT** (Inglés) y diccionarios chinos offline.
+- 🤖 **Lectura Potenciada por IA**:
+ - **Traducción en Línea**: Traducciones contextuales de alta calidad elegantemente incrustadas bajo el texto original.
+ - **Compañero IA**: Un asistente de IA en la barra lateral que admite diálogos en streaming y memoria de múltiples turnos.
+ - **Amplia Compatibilidad**: Soporte integrado para OpenAI, Anthropic, Gemini, DeepSeek, Grok, Alibaba Cloud Bailian, Volcengine, Tencent Hunyuan, MiniMax, Moonshot, SiliconFlow, Cerebras, SambaNova, Groq, Mistral, DeepInfra, Together AI, OpenRouter, Zhipu AI y ModelScope — 20 proveedores de IA en total, más endpoints personalizados compatibles con OpenAI.
+
+
+
+
Barra de selección · Traducción en línea · Panel lateral del compañero IA
+
+
+- 🔊 **Lectura TTS**: Potenciado por Edge-TTS con múltiples voces de alta calidad en chino e inglés.
+- ✏️ **Resaltados y Notas**: Resaltado de 5 colores, anotaciones en línea y marcadores — todo almacenado en el **localStorage** de tu navegador.
+
+
+
+
Lectura en 3 columnas: navegación ToC · Texto inmersivo · Resaltados multicolor
+
+
+- 🎨 **Estética Minimalista**: 6 temas curados y un diseño flexible de 3 columnas (ToC/Contenido/IA), optimizado para todos los dispositivos.
+
+
+
+
Temas, tipografía, diccionarios, modelos IA — configuración todo en uno
+
+
+## 🚀 Inicio Rápido
+
+Este proyecto utiliza [uv](https://docs.astral.sh/uv/) para gestionar el entorno Python y las dependencias.
+
+### 1. Instalar uv
+Asegúrese de tener Python 3.10+ disponible. Instale `uv`:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. Importar un Libro e Iniciar
+```bash
+# Importar un libro electrónico EPUB
+uv run reader3.py your_book.epub
+
+# Iniciar el servidor
+uv run server.py
+```
+Acceda al lector en: 👉 **http://localhost:8123**
+
+### 3. Configurar IA
+Entre en la interfaz de lectura, haga clic en **Configuración (Settings)** en la esquina superior izquierda y configure su **Proveedor de IA** y clave API (ej., [obtenga una Gemini Key gratis](https://aistudio.google.com/apikey)).
+
+> [!TIP]
+> **🚀 Huevo de Pascua**: En cualquier lugar de la página, ingrese **`↑ ↑ ↓ ↓ ← → ← → B A`** (Código Konami) para desbloquear el **Panel Avanzado de Enrutamiento de IA** oculto.
+
+## 🛡️ Privacidad
+- **Local Primero**: Ningún dato sale de su dispositivo a menos que active explícitamente una solicitud de IA o TTS.
+- **Sin Cuenta**: Sus datos se almacenan solo en el `localStorage` de su navegador.
+
+## 📚 Guía de Usuario
+Para configuraciones detalladas (diccionarios offline, acceso multidispositivo, configuración de puertos), consulte la [Guía de Usuario](docs/GUIDE.md).
+
+## 📄 Licencia
+[MIT License](LICENSE)
diff --git a/README.fr.md b/README.fr.md
new file mode 100644
index 00000000..e30d5e0b
--- /dev/null
+++ b/README.fr.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | Français | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> "Lorsque la technologie est démocratisée par l'IA, l'esthétique et le design centré sur l'humain deviennent les ultimes différenciateurs."
+
+Inspiré par le [prototype de lecteur minimaliste](https://x.com/karpathy/status/1990577951671509438) d'Andrej Karpathy, Smoothie Reader est un lecteur de livres numériques alimenté par l'IA qui fonctionne localement. Il intègre la recherche de mots, la traduction et le dialogue IA, la lecture TTS et les notes surlignées, conçu pour une lecture approfondie.
+
+📖 **Livre exemple inclus** : Le dépôt contient les « Méditations » de Marc Aurèle (via Project Gutenberg), prêt à explorer après le clonage.
+
+
+
+
Votre bibliothèque personnelle en un coup d'œil
+
+
+## ✨ Fonctionnalités Principales
+
+- 🔍 **Découverte Intuitive** : Surlignez n'importe quel texte pour révéler la barre d'action. Prise en charge intégrée d'**ECDICT** (Anglais) et de dictionnaires chinois hors ligne.
+- 🤖 **Lecture Augmentée par l'IA** :
+ - **Traduction en Ligne** : Traductions contextuelles de haute qualité élégamment intégrées sous le texte original.
+ - **Compagnon IA** : Un assistant IA en barre latérale prenant en charge le dialogue en streaming et la mémoire multi-tours.
+ - **Large Compatibilité** : Prise en charge intégrée d'OpenAI, Anthropic, Gemini, DeepSeek, Grok, Alibaba Cloud Bailian, Volcengine, Tencent Hunyuan, MiniMax, Moonshot, SiliconFlow, Cerebras, SambaNova, Groq, Mistral, DeepInfra, Together AI, OpenRouter, Zhipu AI et ModelScope — 20 fournisseurs d'IA au total, plus des endpoints personnalisés compatibles OpenAI.
+
+
+
+
Barre de sélection · Traduction en ligne · Panneau latéral du compagnon IA
+
+
+- 🔊 **Lecture TTS** : Propulsé par Edge-TTS avec plusieurs voix de haute qualité en chinois et en anglais.
+- ✏️ **Surlignages et Notes** : Surlignage en 5 couleurs, annotations en ligne et signets — le tout stocké dans le **localStorage** de votre navigateur.
+
+
+
+
Lecture en 3 colonnes : navigation ToC · Texte immersif · Surlignages multicolores
+
+
+- 🎨 **Esthétique Minimaliste** : 6 thèmes sélectionnés et une mise en page flexible à 3 colonnes (ToC/Contenu/IA), optimisée pour tous les appareils.
+
+
+
+
Thèmes, typographie, dictionnaires, modèles IA — configuration tout-en-un
+
+
+## 🚀 Démarrage Rapide
+
+Ce projet utilise [uv](https://docs.astral.sh/uv/) pour gérer l'environnement Python et les dépendances.
+
+### 1. Installer uv
+Assurez-vous que Python 3.10+ est disponible. Installez `uv` :
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. Importer un Livre et Lancer
+```bash
+# Importer un livre EPUB
+uv run reader3.py your_book.epub
+
+# Démarrer le serveur
+uv run server.py
+```
+Accédez au lecteur sur : 👉 **http://localhost:8123**
+
+### 3. Configurer l'IA
+Entrez dans l'interface de lecture, cliquez sur **Paramètres (Settings)** en haut à gauche et configurez votre **Fournisseur d'IA** et clé API (ex. [obtenez une clé Gemini gratuite](https://aistudio.google.com/apikey)).
+
+> [!TIP]
+> **🚀 Œuf de Pâques** : N'importe où sur la page, entrez **`↑ ↑ ↓ ↓ ← → ← → B A`** (Code Konami) pour déverrouiller le **Panneau de Routage IA Avancé** caché.
+
+## 🛡️ Confidentialité
+- **Local d'Abord** : Aucune donnée ne quitte votre appareil sauf si vous déclenchez explicitement une requête IA ou TTS.
+- **Sans Compte** : Vos données restent exclusivement dans le `localStorage` de votre navigateur.
+
+## 📚 Guide Utilisateur
+Pour des configurations détaillées (dictionnaires hors ligne, accès multi-appareils, paramètres de port), consultez le [Guide Utilisateur](docs/GUIDE.md).
+
+## 📄 Licence
+[MIT License](LICENSE)
diff --git a/README.it.md b/README.it.md
new file mode 100644
index 00000000..08d1f904
--- /dev/null
+++ b/README.it.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | Italiano
+
+# 🧊 Smoothie Reader (Reader3)
+
+> "Quando la tecnologia viene democratizzata dall'IA, l'estetica e il design incentrato sull'uomo diventano i differenziatori finali."
+
+Ispirato al [prototipo di lettore minimalista](https://x.com/karpathy/status/1990577951671509438) di Andrej Karpathy, Smoothie Reader è un lettore di e-book alimentato dall'IA che funziona localmente. Integra ricerca di parole, traduzione e dialogo IA, lettura TTS e note evidenziate, progettato per la lettura profonda.
+
+📖 **Libro di esempio incluso**: Il repository include "Meditazioni" di Marco Aurelio (via Project Gutenberg), pronto da esplorare dopo il clone.
+
+
+
+
La tua biblioteca personale a colpo d'occhio
+
+
+## ✨ Funzionalità Principali
+
+- 🔍 **Scoperta Intuitiva**: Evidenzia qualsiasi testo per rivelare la barra delle azioni. Supporto integrato per **ECDICT** (Inglese) e dizionari cinesi offline.
+- 🤖 **Lettura Potenziata dall'IA**:
+ - **Traduzione in Linea**: Traduzioni contestuali di alta qualità elegantemente incorporate sotto il testo originale.
+ - **Compagno IA**: Un assistente IA nella barra laterale che supporta il dialogo in streaming e la memoria multi-turno.
+ - **Ampia Compatibilità**: Supporto integrato per OpenAI, Anthropic, Gemini, DeepSeek, Grok, Alibaba Cloud Bailian, Volcengine, Tencent Hunyuan, MiniMax, Moonshot, SiliconFlow, Cerebras, SambaNova, Groq, Mistral, DeepInfra, Together AI, OpenRouter, Zhipu AI e ModelScope — 20 provider IA in totale, più endpoint personalizzati compatibili con OpenAI.
+
+
+
+
Barra di selezione · Traduzione in linea · Pannello laterale del compagno IA
+
+
+- 🔊 **Lettura TTS**: Alimentato da Edge-TTS con molteplici voci di alta qualità in cinese e inglese.
+- ✏️ **Evidenziazioni e Note**: Evidenziazione in 5 colori, annotazioni in linea e segnalibri — tutto conservato nel **localStorage** del tuo browser.
+
+
+
+
Lettura a 3 colonne: navigazione ToC · Testo immersivo · Evidenziazioni multicolore
+
+
+- 🎨 **Estetica Minimalista**: 6 temi curati e un layout flessibile a 3 colonne (ToC/Contenuto/IA), ottimizzato per tutti i dispositivi.
+
+
+
+
Temi, tipografia, dizionari, modelli IA — configurazione tutto in uno
+
+
+## 🚀 Avvio Rapido
+
+Questo progetto utilizza [uv](https://docs.astral.sh/uv/) per gestire l'ambiente Python e le dipendenze.
+
+### 1. Installare uv
+Assicurati che Python 3.10+ sia disponibile. Installa `uv`:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. Importare un Libro e Avviare
+```bash
+# Importa un e-book EPUB
+uv run reader3.py your_book.epub
+
+# Avvia il server
+uv run server.py
+```
+Accedi al lettore su: 👉 **http://localhost:8123**
+
+### 3. Configurare l'IA
+Entra nell'interfaccia di lettura, clicca su **Impostazioni (Settings)** in alto a sinistra e configura il tuo **Provider IA** e chiave API (es. [ottieni una Gemini Key gratuita](https://aistudio.google.com/apikey)).
+
+> [!TIP]
+> **🚀 Easter Egg**: In qualsiasi punto della pagina, inserisci **`↑ ↑ ↓ ↓ ← → ← → B A`** (Codice Konami) per sbloccare il **Pannello Avanzato di Routing IA** nascosto.
+
+## 🛡️ Privacy
+- **Locale Prima di Tutto**: Nessun dato lascia il tuo dispositivo a meno che tu non attivi esplicitamente una richiesta IA o TTS.
+- **Senza Account**: I tuoi dati rimangono esclusivamente nel `localStorage` del tuo browser.
+
+## 📚 Guida Utente
+Per configurazioni dettagliate (dizionari offline, accesso multi-dispositivo, impostazioni porta), consulta la [Guida Utente](docs/GUIDE.md).
+
+## 📄 Licenza
+[MIT License](LICENSE)
diff --git a/README.ja.md b/README.ja.md
new file mode 100644
index 00000000..d816bdfa
--- /dev/null
+++ b/README.ja.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | 日本語 | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> 「AIによって技術が民主化されるとき、美学と人間中心のデザインこそが究極の差別化要因となる。」
+
+Andrej Karpathyの[ミニマリスト・リーダー・プロトタイプ](https://x.com/karpathy/status/1990577951671509438)に触発されたSmoothie Readerは、ローカルで動作するAI搭載電子書籍リーダーです。ワンタップ辞書検索、AI翻訳・対話、TTS読み上げ、ハイライトメモなどの機能を統合し、深い読書のために設計されています。
+
+📖 **内蔵サンプル書籍**:リポジトリにはマルクス・アウレリウスの『自省録』(Project Gutenberg提供)が含まれており、クローン後すぐに体験できます。
+
+
+
+
パーソナルライブラリを一望
+
+
+## ✨ 主な機能
+
+- 🔍 **直感的な検索**:テキストを選択するだけでアクションバーが表示されます。**ECDICT(英語)** および中国語のオフライン辞書を内蔵。
+- 🤖 **AI強化リーディング**:
+ - **インライン翻訳**:文脈に応じた高品質な翻訳を、原文の下にエレガントに埋め込みます。
+ - **AIコンパニオン**:ストリーミング対話とマルチターン記憶をサポートするサイドバーAIアシスタント。
+ - **幅広い互換性**:OpenAI、Anthropic、Gemini、DeepSeek、Grok、阿里雲百煉、火山引擎、騰訊混元、MiniMax、月之暗面、硅基流動、Cerebras、SambaNova、Groq、Mistral、DeepInfra、Together AI、OpenRouter、智譜AI、ModelScope の計20社のAIプロバイダーを内蔵し、任意のOpenAI互換エンドポイントのカスタム接続もサポート。
+
+
+
+
選択ツールバー · インライン翻訳 · AIコンパニオンサイドバー
+
+
+- 🔊 **TTS読み上げ**:Edge-TTSを搭載し、中国語・英語の高品質な音声を複数提供。
+- ✏️ **ハイライト&メモ**:5色ハイライト、インライン注釈、ブックマーク。データはすべてブラウザの**localStorage**に保存されます。
+
+
+
+
3カラム閲覧:目次ナビ · 没入型テキスト · マルチカラーハイライト
+
+
+- 🎨 **ミニマルな美学**:厳選された6つのテーマと柔軟な3カラムレイアウト(目次/本文/AI)、あらゆるデバイスに対応。
+
+
+
+
テーマ、タイポグラフィ、辞書、AIモデル — ワンストップ設定
+
+
+## 🚀 クイックスタート
+
+本プロジェクトは [uv](https://docs.astral.sh/uv/) でPython環境と依存関係を管理しています。
+
+### 1. uvのインストール
+Python 3.10以上がインストールされていることを確認し、`uv`をインストールします:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. 電子書籍のインポートと起動
+```bash
+# EPUB電子書籍をインポート
+uv run reader3.py your_book.epub
+
+# サーバーを起動
+uv run server.py
+```
+ブラウザでアクセス:👉 **http://localhost:8123**
+
+### 3. AIの設定
+読書インターフェースに入り、左上の**設定 (Settings)** から**AIプロバイダー**とAPIキーを構成します(例:[Gemini Keyを無料で取得](https://aistudio.google.com/apikey))。
+
+> [!TIP]
+> **🚀 イースターエッグ**:ページ上のどこでも **`↑ ↑ ↓ ↓ ← → ← → B A`**(コナミコマンド)を入力すると、隠された**高度なAIルーティングパネル**がアンロックされます。
+
+## 🛡️ プライバシー
+- **ローカル優先**:AIやTTSを明示的にトリガーしない限り、データがデバイスを離れることはありません。
+- **アカウント不要**:データはブラウザの`localStorage`のみに保持されます。
+
+## 📚 ユーザーガイド
+詳細な設定手順(オフライン辞書のダウンロード、マルチデバイスアクセス、ポート設定)については、[ユーザーガイド](docs/GUIDE.md)を参照してください。
+
+## 📄 ライセンス
+[MIT License](LICENSE)
diff --git a/README.ko.md b/README.ko.md
new file mode 100644
index 00000000..ff079c59
--- /dev/null
+++ b/README.ko.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | 한국어 | [Español](README.es.md) | [Français](README.fr.md) | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> "기술이 AI에 의해 민주화될 때, 미학과 인간 중심의 디자인이 궁극적인 차별점이 됩니다."
+
+Andrej Karpathy의 [미니멀리스트 리더 프로토타입](https://x.com/karpathy/status/1990577951671509438)에서 영감을 받은 Smoothie Reader는 로컬에서 실행되는 AI 기반 전자책 리더기입니다. 원터치 사전 검색, AI 번역 및 대화, TTS 읽기, 하이라이트 노트 등의 기능을 통합하여 깊은 독서를 위해 설계되었습니다.
+
+📖 **내장 샘플 도서**: 저장소에는 마르쿠스 아우렐리우스의 "명상록" (Project Gutenberg 제공)이 포함되어 있어, 클론 후 바로 체험할 수 있습니다.
+
+
+
+
개인 서재를 한눈에
+
+
+## ✨ 핵심 기능
+
+- 🔍 **직관적 탐색**: 텍스트를 선택하기만 하면 액션 바가 나타납니다. **ECDICT (영어)** 및 중국어 오프라인 사전 내장.
+- 🤖 **AI 강화 독서**:
+ - **인라인 번역**: 문맥을 반영한 고품질 번역이 원문 아래에 우아하게 삽입됩니다.
+ - **AI 동반자**: 스트리밍 대화와 다중 턴 기억을 지원하는 사이드바 AI 어시스턴트.
+ - **폭넓은 호환성**: OpenAI, Anthropic, Gemini, DeepSeek, Grok, 阿里云百炼, 火山引擎, 腾讯混元, MiniMax, Moonshot, SiliconFlow, Cerebras, SambaNova, Groq, Mistral, DeepInfra, Together AI, OpenRouter, 智谱AI, ModelScope 총 20개 AI 서비스 제공업체를 내장하고, 임의의 OpenAI 호환 엔드포인트 커스텀 연결도 지원.
+
+
+
+
선택 도구 모음 · 인라인 번역 · AI 동반자 사이드바
+
+
+- 🔊 **TTS 읽기**: Edge-TTS 기반으로 중국어·영어 고품질 음성을 다수 제공.
+- ✏️ **하이라이트 및 노트**: 5가지 색상 하이라이트, 인라인 주석, 북마크. 모든 데이터는 브라우저의 **localStorage**에 저장됩니다.
+
+
+
+
3단 독서: 목차 탐색 · 몰입형 텍스트 · 멀티 컬러 하이라이트
+
+
+- 🎨 **미니멀리즘 미학**: 엄선된 6가지 테마와 유연한 3단 레이아웃 (목차/본문/AI), 모든 기기에 최적화.
+
+
+
+
테마, 타이포그래피, 사전, AI 모델 — 올인원 설정
+
+
+## 🚀 빠른 시작
+
+이 프로젝트는 [uv](https://docs.astral.sh/uv/)를 사용하여 Python 환경과 의존성을 관리합니다.
+
+### 1. uv 설치
+Python 3.10 이상이 필요합니다. `uv`를 설치하세요:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. 전자책 가져오기 및 실행
+```bash
+# EPUB 전자책 가져오기
+uv run reader3.py your_book.epub
+
+# 서버 시작
+uv run server.py
+```
+브라우저에서 접속: 👉 **http://localhost:8123**
+
+### 3. AI 설정
+독서 인터페이스에서 왼쪽 상단의 **설정 (Settings)**을 클릭하여 **AI 제공업체**와 API 키를 구성하세요 (예: [무료 Gemini 키 받기](https://aistudio.google.com/apikey)).
+
+> [!TIP]
+> **🚀 이스터 에그**: 페이지 어디에서나 **`↑ ↑ ↓ ↓ ← → ← → B A`** (코나미 커맨드)를 입력하면 숨겨진 **고급 AI 라우팅 패널**이 잠금 해제됩니다.
+
+## 🛡️ 개인정보 보호
+- **로컬 우선**: AI나 TTS를 명시적으로 호출하지 않는 한 데이터가 기기를 떠나지 않습니다.
+- **계정 불필요**: 데이터는 브라우저의 `localStorage`에만 보관됩니다.
+
+## 📚 사용 가이드
+상세한 구성 지침 (오프라인 사전 다운로드, 다중 기기 접속, 포트 설정)은 [사용 가이드](docs/GUIDE.md)를 참조하세요.
+
+## 📄 라이선스
+[MIT License](LICENSE)
diff --git a/README.md b/README.md
index 5d868d7b..62e4fe0a 100644
--- a/README.md
+++ b/README.md
@@ -1,27 +1,78 @@
-# reader 3
+[English](README.en.md) | 简体中文 | [繁體中文](README.zh-Hant.md) | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Italiano](README.it.md)
-
+# 🧊 Smoothie Reader (Reader3)
-A lightweight, self-hosted EPUB reader that lets you read through EPUB books one chapter at a time. This makes it very easy to copy paste the contents of a chapter to an LLM, to read along. Basically - get epub books (e.g. [Project Gutenberg](https://www.gutenberg.org/) has many), open them up in this reader, copy paste text around to your favorite LLM, and read together and along.
+> "当技术被 AI 民主化,审美与以人为本的设计便成了最终的护城河。"
-This project was 90% vibe coded just to illustrate how one can very easily [read books together with LLMs](https://x.com/karpathy/status/1990577951671509438). I'm not going to support it in any way, it's provided here as is for other people's inspiration and I don't intend to improve it. Code is ephemeral now and libraries are over, ask your LLM to change it in whatever way you like.
+灵感源自 Andrej Karpathy 的 [极简阅读器原型](https://x.com/karpathy/status/1990577951671509438),Smoothie Reader 是一个本地部署的 AI 智能电子书阅读器,集成划词查词、AI 翻译与对话、TTS 朗读、高亮笔记等功能,为深度阅读而生。
-## Usage
+📖 **内置示例书籍**:仓库预置了马可·奥勒留的《沉思录》(来源于 Project Gutenberg),克隆后即可体验。
-The project uses [uv](https://docs.astral.sh/uv/). So for example, download [Dracula EPUB3](https://www.gutenberg.org/ebooks/345) to this directory as `dracula.epub`, then:
+
+
+
个人藏书馆:封面墙一览无余
+
+## ✨ 核心功能
+
+- 🔍 **直觉探索**:选中文字秒开工具栏。内置支持 **ECDICT 英文词典** 和 **中文离线词典**。
+- 🤖 **增强对话**:
+ - **叙事级翻译**:一键获取高质量上下文翻译,优雅地内嵌于原文下方。
+ - **数字伴读**:侧边栏 AI 助手支持流式输出和多轮记忆,随时进行深度的智力碰撞。
+ - **广泛兼容**:内置 OpenAI、Anthropic、Gemini、DeepSeek、Grok、阿里云百炼、火山引擎、腾讯混元、MiniMax、月之暗面、硅基流动、Cerebras、SambaNova、Groq、Mistral、DeepInfra、Together AI、OpenRouter、智谱AI、ModelScope 共 20 家 AI 服务商,并支持任意 OpenAI 兼容接口自定义接入。
+
+
+
+
划词工具栏 · 行内翻译 · AI 伴读侧栏
+
+
+- 🔊 **TTS 朗读**:基于 Edge-TTS,提供中英文多种高质量语音,解放双眼。
+- ✏️ **高亮笔记**:5 色高亮、行内笔记、书签,所有数据存储在浏览器**本地 localStorage** 中。
+
+
+
+
三栏阅读:目录导航 · 沉浸正文 · 多色高亮
+
+
+- 🎨 **极简审美**:6 种精选主题、灵活的三栏布局(目录/正文/AI),适配所有设备的极致视觉。
+
+
+
+
主题、排版、词典、AI 模型 —— 一站式配置
+
+
+## 🚀 快速开始
+
+本项目使用 [uv](https://docs.astral.sh/uv/) 管理 Python 环境和依赖。
+
+### 1. 安装 uv
+确保系统已安装 Python 3.10+,然后安装 `uv`:
```bash
-uv run reader3.py dracula.epub
+curl -LsSf https://astral.sh/uv/install.sh | sh
```
-This creates the directory `dracula_data`, which registers the book to your local library. We can then run the server:
-
+### 2. 导入电子书并启动
```bash
+# 导入一本 EPUB 电子书
+uv run reader3.py your_book.epub
+
+# 启动服务
uv run server.py
```
+然后打开浏览器访问:👉 **http://localhost:8123**
+
+### 3. 配置 AI
+进入阅读页面,点击左上角 **设置 (Settings)**,配置您的 **AI 提供商**和 API Key(例如,[免费获取 Gemini Key](https://aistudio.google.com/apikey))。
+
+> [!TIP]
+> **🚀 秘密通道 (秘籍)**:在页面任何地方,依次按下 **`↑ ↑ ↓ ↓ ← → ← → B A`** (Konami Code),即可解锁隐藏的 **高级 AI 路由管理面板**。
-And visit [localhost:8123](http://localhost:8123/) to see your current Library. You can easily add more books, or delete them from your library by deleting the folder. It's not supposed to be complicated or complex.
+## 🛡️ 主权与隐私
+- **本地优先**:除了您主动触发的 AI 或 TTS 请求外,没有任何数据会离开您的设备。
+- **无需账号**:您的档案仅保存在浏览器 `localStorage` 中。
-## License
+## 📚 进阶向导
+详细的配置说明(如离线词典下载、多设备访问、端口修改),请参阅 [使用指南](docs/GUIDE.md)。
-MIT
\ No newline at end of file
+## 📄 许可证
+[MIT License](LICENSE)
diff --git a/README.zh-Hant.md b/README.zh-Hant.md
new file mode 100644
index 00000000..a6acfe57
--- /dev/null
+++ b/README.zh-Hant.md
@@ -0,0 +1,78 @@
+[English](README.en.md) | [简体中文](README.md) | 繁體中文 | [日本語](README.ja.md) | [한국어](README.ko.md) | [Español](README.es.md) | [Français](README.fr.md) | [Italiano](README.it.md)
+
+# 🧊 Smoothie Reader (Reader3)
+
+> 「當技術被 AI 民主化,審美與以人為本的設計便成了最終的護城河。」
+
+靈感源自 Andrej Karpathy 的 [極簡閱讀器原型](https://x.com/karpathy/status/1990577951671509438),Smoothie Reader 是一個本地部署的 AI 智能電子書閱讀器,集成劃詞查詞、AI 翻譯與對話、TTS 朗讀、高亮筆記等功能,為深度閱讀而生。
+
+📖 **內置示例書籍**:倉庫預置了馬可·奧勒留的《沉思錄》(來源於 Project Gutenberg),克隆後即可體驗。
+
+
+
+
個人藏書館:封面牆一覽無餘
+
+
+## ✨ 核心功能
+
+- 🔍 **直覺探索**:選中文字秒開工具欄。內置支持 **ECDICT 英文詞典** 和 **中文離線詞典**。
+- 🤖 **AI 增強閱讀**:
+ - **行內翻譯**:一鍵獲取高質量上下文翻譯,優雅地內嵌於原文下方。
+ - **AI 伴讀**:側邊欄 AI 助手支持流式輸出和多輪記憶,隨時進行深度對話。
+ - **廣泛兼容**:內置 OpenAI、Anthropic、Gemini、DeepSeek、Grok、阿里雲百煉、火山引擎、騰訊混元、MiniMax、月之暗面、矽基流動、Cerebras、SambaNova、Groq、Mistral、DeepInfra、Together AI、OpenRouter、智譜AI、ModelScope 共 20 家 AI 服務商,並支持任意 OpenAI 兼容接口自定義接入。
+
+
+
+
劃詞工具欄 · 行內翻譯 · AI 伴讀側欄
+
+
+- 🔊 **TTS 朗讀**:基於 Edge-TTS,提供中英文多種高質量語音,解放雙眼。
+- ✏️ **高亮筆記**:5 色高亮、行內筆記、書籤,所有數據存儲在瀏覽器**本地 localStorage** 中。
+
+
+
+
三欄閱讀:目錄導航 · 沉浸正文 · 多色高亮
+
+
+- 🎨 **極簡審美**:6 種精選主題、靈活的三欄佈局(目錄/正文/AI),適配所有設備。
+
+
+
+
主題、排版、詞典、AI 模型 —— 一站式配置
+
+
+## 🚀 快速開始
+
+本項目使用 [uv](https://docs.astral.sh/uv/) 管理 Python 環境和依賴。
+
+### 1. 安裝 uv
+確保系統已安裝 Python 3.10+,然後安裝 `uv`:
+```bash
+curl -LsSf https://astral.sh/uv/install.sh | sh
+```
+
+### 2. 導入電子書並啟動
+```bash
+# 導入一本 EPUB 電子書
+uv run reader3.py your_book.epub
+
+# 啟動服務
+uv run server.py
+```
+然後打開瀏覽器訪問:👉 **http://localhost:8123**
+
+### 3. 配置 AI
+進入閱讀頁面,點擊左上角 **設置 (Settings)**,配置您的 **AI 提供商**和 API Key(例如,[免費獲取 Gemini Key](https://aistudio.google.com/apikey))。
+
+> [!TIP]
+> **🚀 秘密通道 (秘籍)**:在頁面任何地方,依次按下 **`↑ ↑ ↓ ↓ ← → ← → B A`** (Konami Code),即可解鎖隱藏的 **高級 AI 路由管理面板**。
+
+## 🛡️ 隱私保護
+- **本地優先**:除了您主動觸發的 AI 或 TTS 請求外,沒有任何數據會離開您的設備。
+- **無需帳號**:您的數據僅保存在瀏覽器 `localStorage` 中。
+
+## 📚 使用指南
+詳細的配置說明(如離線詞典下載、多設備訪問、端口修改),請參閱 [使用指南](docs/GUIDE.md)。
+
+## 📄 許可證
+[MIT License](LICENSE)
diff --git a/assets/Meditations by Emperor of Rome Marcus Aurelius.epub b/assets/Meditations by Emperor of Rome Marcus Aurelius.epub
new file mode 100644
index 00000000..ffa27199
Binary files /dev/null and b/assets/Meditations by Emperor of Rome Marcus Aurelius.epub differ
diff --git a/assets/library.jpg b/assets/library.jpg
new file mode 100644
index 00000000..c80a4d23
Binary files /dev/null and b/assets/library.jpg differ
diff --git a/assets/reader_AItools.jpg b/assets/reader_AItools.jpg
new file mode 100644
index 00000000..b505cd8b
Binary files /dev/null and b/assets/reader_AItools.jpg differ
diff --git a/assets/reader_catelog.jpg b/assets/reader_catelog.jpg
new file mode 100644
index 00000000..c8722230
Binary files /dev/null and b/assets/reader_catelog.jpg differ
diff --git a/assets/reader_setting.jpg b/assets/reader_setting.jpg
new file mode 100644
index 00000000..2c2fbfd8
Binary files /dev/null and b/assets/reader_setting.jpg differ
diff --git a/docs/GUIDE.md b/docs/GUIDE.md
new file mode 100644
index 00000000..abe49182
--- /dev/null
+++ b/docs/GUIDE.md
@@ -0,0 +1,59 @@
+# Reader3 进阶配置指南
+
+本指南涵盖了 Reader3 的高级功能配置。如果你还没有安装 Reader3,请先查看项目主页的 [快速开始](../README.md)。
+
+## 1. 安装离线词典
+
+离线词典用于「划词查词」功能,可以在阅读时选中单词快速查看释义。
+
+1. 进入阅读页面,点击左上角菜单按钮(三横线图标)
+2. 在弹出的子菜单中点击齿轮图标打开「设置」面板
+3. 在设置面板底部找到「词典管理」区域
+4. 点击 `下载` 按钮安装词典:
+ - **ECDICT 英文词典**(~134MB 下载,307MB 解压)
+ - **中文词典**(~25MB 下载,48MB 解压)
+
+下载完成后即可使用划词查词功能。
+
+## 2. 高级 AI 提供商路由 (AI Routing)
+
+除了在普通的「设置」面板中添加一个主 API Key,Reader3 还支持极其强大的多提供商路由功能。
+
+### 🚀 解锁隐藏面板 (游戏秘籍)
+> [!IMPORTANT]
+> 在阅读页面任何地方,连续快速按下键盘上的 **`↑ ↑ ↓ ↓ ← → ← → B A`**(经典的 Konami Code 彩蛋)即可立即呼出隐藏的 **高级 AI 提供商管理面板**。
+
+### 路由策略
+你可以配置 20+ AI 服务商,并支持:
+- **按任务分流**:例如,设置「翻译」任务使用成本较低的 Gemini 1.5 Flash,而「聊天分析」任务使用推理能力更强的 Claude 3.5 Sonnet 或 OpenAI GPT-4o。
+- **高可用回退**:拖拽提供商进行优先级排序。如果首选模型报错或限流,系统会自动尝试列表中下一个提供商。
+
+## 3. 局域网内多设备访问
+
+Reader3 默认监听 `0.0.0.0` 接口。如果你想在同一 Wi-Fi 网络下的平板或手机上阅读:
+
+1. 确保电脑上的 `uv run server.py` 正在运行。
+2. 在电脑终端输入 `ifconfig | grep "inet "` (macOS/Linux) 或 `ipconfig` (Windows) 查找本机 IP 地址(例如 `192.168.1.100`)。
+3. 在平板浏览器中访问 `http://你的IP地址:8123`。
+
+*注:移动端访问会自动隐藏侧边栏并启用抽屉式交互。*
+
+## 4. 修改默认端口
+
+如果端口 8123 被其他应用占用,你可以通过环境变量修改启动端口:
+
+```bash
+PORT=8080 uv run server.py
+```
+
+## 5. 疑难解答
+
+### 导入书籍后页面空白
+部分非标准的 EPUB 文件可能在解析时出现问题:
+1. 在图书馆页面点击右上角的 `Manage` 进入管理模式
+2. 选中有问题的书籍
+3. 点击 `Reprocess` 强制重新解析该书
+
+### 语音朗读 (TTS) 无声
+- 请确保当前网络可以访问 Edge-TTS 服务。
+- 尝试在工具栏选择不同的朗读声音,系统会自动根据文字检测语言,但如果检测失败,你可以手动选择强制语音。
diff --git a/pyproject.toml b/pyproject.toml
index 31e61793..35172edc 100644
--- a/pyproject.toml
+++ b/pyproject.toml
@@ -6,8 +6,14 @@ readme = "README.md"
requires-python = ">=3.10"
dependencies = [
"beautifulsoup4>=4.14.2",
+ "dashscope>=1.25.12",
"ebooklib>=0.20",
+ "edge-tts>=7.2.7",
"fastapi>=0.121.2",
+ "google-genai>=1.0.0",
+ "httpx[socks]>=0.28.1",
"jinja2>=3.1.6",
+ "python-dotenv>=1.2.1",
+ "python-multipart>=0.0.22",
"uvicorn>=0.38.0",
]
diff --git a/reader3.png b/reader3.png
deleted file mode 100644
index 45aac09f..00000000
Binary files a/reader3.png and /dev/null differ
diff --git a/reader3.py b/reader3.py
index d0b9d3f9..21657657 100644
--- a/reader3.py
+++ b/reader3.py
@@ -100,48 +100,59 @@ def parse_toc_recursive(toc_list, depth=0) -> List[TOCEntry]:
result = []
for item in toc_list:
- # ebooklib TOC items are either `Link` objects or tuples (Section, [Children])
- if isinstance(item, tuple):
- section, children = item
- entry = TOCEntry(
- title=section.title,
- href=section.href,
- file_href=section.href.split('#')[0],
- anchor=section.href.split('#')[1] if '#' in section.href else "",
- children=parse_toc_recursive(children, depth + 1)
- )
- result.append(entry)
- elif isinstance(item, epub.Link):
- entry = TOCEntry(
- title=item.title,
- href=item.href,
- file_href=item.href.split('#')[0],
- anchor=item.href.split('#')[1] if '#' in item.href else ""
- )
- result.append(entry)
- # Note: ebooklib sometimes returns direct Section objects without children
- elif isinstance(item, epub.Section):
- entry = TOCEntry(
- title=item.title,
- href=item.href,
- file_href=item.href.split('#')[0],
- anchor=item.href.split('#')[1] if '#' in item.href else ""
- )
- result.append(entry)
+ try:
+ # ebooklib TOC items are either `Link` objects or tuples (Section, [Children])
+ if isinstance(item, tuple):
+ section, children = item
+ child_entries = parse_toc_recursive(children, depth + 1)
+
+ # Some magazines have sections without an href (just a label)
+ href = getattr(section, 'href', "") or ""
+ if not href and child_entries:
+ # Use the first child's href as a fallback for the parent container
+ href = child_entries[0].href
+
+ entry = TOCEntry(
+ title=getattr(section, 'title', "Untitled Section"),
+ href=href,
+ file_href=href.split('#')[0] if href else "",
+ anchor=href.split('#')[1] if href and '#' in href else "",
+ children=child_entries
+ )
+ result.append(entry)
+ elif isinstance(item, epub.Link):
+ href = item.href or ""
+ entry = TOCEntry(
+ title=item.title or "Untitled",
+ href=href,
+ file_href=href.split('#')[0] if href else "",
+ anchor=href.split('#')[1] if href and '#' in href else ""
+ )
+ result.append(entry)
+ elif isinstance(item, epub.Section):
+ href = item.href or ""
+ entry = TOCEntry(
+ title=item.title or "Untitled",
+ href=href,
+ file_href=href.split('#')[0] if href else "",
+ anchor=href.split('#')[1] if href and '#' in href else ""
+ )
+ result.append(entry)
+ except Exception as e:
+ print(f"Warning: Skipping TOC item due to error: {e}")
return result
def get_fallback_toc(book_obj) -> List[TOCEntry]:
"""
- If TOC is missing, build a flat one from the Spine.
+ If TOC is missing, build a flat one from all documents.
"""
toc = []
for item in book_obj.get_items():
if item.get_type() == ebooklib.ITEM_DOCUMENT:
name = item.get_name()
- # Try to guess a title from the content or ID
- title = item.get_name().replace('.html', '').replace('.xhtml', '').replace('_', ' ').title()
+ title = name.replace('.html', '').replace('.xhtml', '').replace('_', ' ').title()
toc.append(TOCEntry(title=title, href=name, file_href=name, anchor=""))
return toc
@@ -152,11 +163,11 @@ def extract_metadata_robust(book_obj) -> BookMetadata:
"""
def get_list(key):
data = book_obj.get_metadata('DC', key)
- return [x[0] for x in data] if data else []
+ return [str(x[0]) for x in data] if data else []
def get_one(key):
data = book_obj.get_metadata('DC', key)
- return data[0][0] if data else None
+ return str(data[0][0]) if data else None
return BookMetadata(
title=get_one('title') or "Untitled",
@@ -192,7 +203,7 @@ def process_epub(epub_path: str, output_dir: str) -> Book:
image_map = {} # Key: internal_path, Value: local_relative_path
for item in book.get_items():
- if item.get_type() == ebooklib.ITEM_IMAGE:
+ if item.get_type() in (ebooklib.ITEM_IMAGE, ebooklib.ITEM_COVER):
# Normalize filename
original_fname = os.path.basename(item.get_name())
# Sanitize filename for OS
@@ -200,49 +211,115 @@ def process_epub(epub_path: str, output_dir: str) -> Book:
# Save to disk
local_path = os.path.join(images_dir, safe_fname)
- with open(local_path, 'wb') as f:
- f.write(item.get_content())
-
- # Map keys: We try both the full internal path and just the basename
- # to be robust against messy HTML src attributes
- rel_path = f"images/{safe_fname}"
- image_map[item.get_name()] = rel_path
- image_map[original_fname] = rel_path
+ try:
+ with open(local_path, 'wb') as f:
+ f.write(item.get_content())
+ # Map keys: We try both the full internal path and just the basename
+ # to be robust against messy HTML src attributes
+ rel_path = f"images/{safe_fname}"
+ image_map[item.get_name()] = rel_path
+ image_map[original_fname] = rel_path
+ except Exception as e:
+ print(f"Warning: Failed to extract image {original_fname}: {e}")
+
+ # 4b. Extract cover image from epub metadata and write marker file
+ cover_fname = None
+ try:
+ # Method A: OPF
+ cover_id = None
+ for meta in book.get_metadata('OPF', 'cover') or []:
+ if meta and meta[1] and 'content' in meta[1]:
+ cover_id = meta[1]['content']
+ break
+ if cover_id:
+ item = book.get_item_with_id(cover_id)
+ if item:
+ fname = os.path.basename(item.get_name())
+ cover_fname = "".join([c for c in fname if c.isalpha() or c.isdigit() or c in '._-']).strip()
+ # Method B: item with properties="cover-image" (EPUB3)
+ if not cover_fname:
+ for item in book.get_items():
+ if item.get_type() == ebooklib.ITEM_COVER:
+ fname = os.path.basename(item.get_name())
+ cover_fname = "".join([c for c in fname if c.isalpha() or c.isdigit() or c in '._-']).strip()
+ break
+ except Exception:
+ pass
+ if cover_fname and os.path.exists(os.path.join(images_dir, cover_fname)):
+ with open(os.path.join(output_dir, "cover_image.txt"), "w") as f:
+ f.write(cover_fname)
+ print(f"Cover image: {cover_fname}")
# 5. Process TOC
print("Parsing Table of Contents...")
toc_structure = parse_toc_recursive(book.toc)
if not toc_structure:
- print("Warning: Empty TOC, building fallback from Spine...")
+ print("Warning: Empty TOC, building fallback from content...")
toc_structure = get_fallback_toc(book)
- # 6. Process Content (Spine-based to preserve HTML validity)
+ # 6. Process Content (Collect all Document Items)
print("Processing chapters...")
- spine_chapters = []
-
- # We iterate over the spine (linear reading order)
- for i, spine_item in enumerate(book.spine):
- item_id, linear = spine_item
+
+ # Aggressive document collection: Any file ending in .html or .xhtml
+ # This is more robust than relying on the EPUB's internal type metadata
+ all_docs = {
+ item.get_name(): item
+ for item in book.get_items()
+ if item.get_name().lower().endswith(('.html', '.xhtml', '.htm'))
+ }
+
+ spine_names = []
+ for spine_item in book.spine:
+ item_id = spine_item[0]
item = book.get_item_with_id(item_id)
-
- if not item:
+ if item and item.get_name().lower().endswith(('.html', '.xhtml', '.htm')):
+ spine_names.append(item.get_name())
+
+ # Add any document that isn't in the spine
+ all_names = list(all_docs.keys())
+ # Sort them to keep some semblance of order for non-spine items
+ all_names.sort()
+
+ final_names_ordered = []
+ seen = set()
+
+ # 1. Spine first
+ for name in spine_names:
+ if name not in seen:
+ final_names_ordered.append(name)
+ seen.add(name)
+
+ # 2. Everything else
+ for name in all_names:
+ # Skip common non-content items if they aren't in spine
+ if name.lower() in ('nav.xhtml', 'toc.xhtml', 'navigation.xhtml'):
continue
+ if name not in seen:
+ final_names_ordered.append(name)
+ seen.add(name)
- if item.get_type() == ebooklib.ITEM_DOCUMENT:
+ spine_chapters = []
+ for i, name in enumerate(final_names_ordered):
+ item = all_docs[name]
+ item_id = item.get_id()
+
+ try:
# Raw content
- raw_content = item.get_content().decode('utf-8', errors='ignore')
+ content_bytes = item.get_content()
+ raw_content = content_bytes.decode('utf-8', errors='ignore')
+
+ # Skip very short or empty documents (often placeholders)
+ if len(raw_content) < 50 and i > 0: # keep first one if it's a cover
+ continue
+
soup = BeautifulSoup(raw_content, 'html.parser')
# A. Fix Images
for img in soup.find_all('img'):
src = img.get('src', '')
if not src: continue
-
- # Decode URL (part01/image%201.jpg -> part01/image 1.jpg)
src_decoded = unquote(src)
filename = os.path.basename(src_decoded)
-
- # Try to find in map
if src_decoded in image_map:
img['src'] = image_map[src_decoded]
elif filename in image_map:
@@ -254,7 +331,6 @@ def process_epub(epub_path: str, output_dir: str) -> Book:
# C. Extract Body Content only
body = soup.find('body')
if body:
- # Extract inner HTML of body
final_html = "".join([str(x) for x in body.contents])
else:
final_html = str(soup)
@@ -262,13 +338,15 @@ def process_epub(epub_path: str, output_dir: str) -> Book:
# D. Create Object
chapter = ChapterContent(
id=item_id,
- href=item.get_name(), # Important: This links TOC to Content
- title=f"Section {i+1}", # Fallback, real titles come from TOC
+ href=name,
+ title=f"Section {i+1}",
content=final_html,
text=extract_plain_text(soup),
order=i
)
spine_chapters.append(chapter)
+ except Exception as e:
+ print(f"Error processing chapter {name}: {e}")
# 7. Final Assembly
final_book = Book(
diff --git a/requirements.txt b/requirements.txt
new file mode 100644
index 00000000..7fc55920
--- /dev/null
+++ b/requirements.txt
@@ -0,0 +1,55 @@
+aiohappyeyeballs==2.6.1
+aiohttp==3.13.3
+aiosignal==1.4.0
+annotated-doc==0.0.4
+annotated-types==0.7.0
+anyio==4.11.0
+async-timeout==5.0.1
+attrs==25.4.0
+beautifulsoup4==4.14.2
+certifi==2026.1.4
+cffi==2.0.0
+charset-normalizer==3.4.4
+click==8.3.1
+cryptography==46.0.5
+dashscope==1.25.12
+distro==1.9.0
+ebooklib==0.20
+edge-tts==7.2.7
+exceptiongroup==1.3.0
+fastapi==0.121.2
+frozenlist==1.8.0
+google-auth==2.48.0
+google-genai==1.66.0
+h11==0.16.0
+httpcore==1.0.9
+httpx==0.28.1
+idna==2.10
+jinja2==3.1.6
+lxml==6.0.2
+markupsafe==3.0.3
+multidict==6.7.1
+propcache==0.4.1
+pyasn1==0.6.2
+pyasn1-modules==0.4.2
+pycparser==3.0
+pydantic==2.12.4
+pydantic-core==2.41.5
+python-dotenv==1.2.1
+python-multipart==0.0.22
+requests==2.32.5
+rsa==4.9.1
+six==1.17.0
+sniffio==1.3.1
+socksio==1.0.0
+soupsieve==2.8
+starlette==0.49.3
+tabulate==0.9.0
+tenacity==9.1.4
+typing-extensions==4.15.0
+typing-inspection==0.4.2
+urllib3==2.6.3
+uvicorn==0.38.0
+websocket-client==1.9.0
+websockets==16.0
+yarl==1.22.0
diff --git a/server.py b/server.py
index 9c870dc6..e91a2b39 100644
--- a/server.py
+++ b/server.py
@@ -1,110 +1,2011 @@
import os
+import json
+import hashlib
import pickle
+import asyncio
+import sqlite3
+import tempfile
+import zlib
from functools import lru_cache
from typing import Optional
-from fastapi import FastAPI, Request, HTTPException
-from fastapi.responses import HTMLResponse, FileResponse
-from fastapi.staticfiles import StaticFiles
+from fastapi import FastAPI, Request, HTTPException, UploadFile, File
+from fastapi.responses import HTMLResponse, FileResponse, StreamingResponse, Response
+from fastapi.middleware.gzip import GZipMiddleware
from fastapi.templating import Jinja2Templates
+from pydantic import BaseModel
+import edge_tts
+import httpx
+
+# AI Imports
+from google import genai as google_genai
+from dotenv import load_dotenv
+
+from reader3 import Book, BookMetadata, ChapterContent, TOCEntry, process_epub, save_to_pickle
+
+# Load .env file automatically
+load_dotenv()
+
+# --- AI Provider System ---
+AI_CONFIG_PATH = os.path.join(os.path.dirname(os.path.abspath(__file__)), 'ai_config.json')
+
+_PROVIDER_DEFS = {
+ 'openai': {'name': 'OpenAI', 'base_url': 'https://api.openai.com/v1/', 'default_model': 'gpt-4o-mini', 'format': 'openai'},
+ 'anthropic': {'name': 'Anthropic', 'base_url': 'https://api.anthropic.com/v1/', 'default_model': 'claude-sonnet-4-20250514', 'format': 'anthropic'},
+ 'gemini': {'name': 'Google Gemini', 'base_url': '', 'default_model': 'gemini-3-flash-preview', 'format': 'gemini'},
+ 'deepseek': {'name': 'DeepSeek', 'base_url': 'https://api.deepseek.com/v1/', 'default_model': 'deepseek-chat', 'format': 'openai'},
+ 'grok': {'name': 'Grok (xAI)', 'base_url': 'https://api.x.ai/v1/', 'default_model': 'grok-3-mini-fast', 'format': 'openai'},
+ 'dashscope': {'name': '阿里云百炼', 'base_url': 'https://dashscope.aliyuncs.com/compatible-mode/v1/', 'default_model': 'qwen-plus', 'format': 'openai'},
+ 'volcengine': {'name': '火山引擎', 'base_url': 'https://ark.cn-beijing.volces.com/api/v3/', 'default_model': 'doubao-1-5-pro-32k-250115', 'format': 'openai'},
+ 'hunyuan': {'name': '腾讯混元', 'base_url': 'https://api.hunyuan.cloud.tencent.com/v1/', 'default_model': 'hunyuan-turbos-latest', 'format': 'openai'},
+ 'minimax': {'name': 'MiniMax', 'base_url': 'https://api.minimax.io/v1/', 'default_model': 'MiniMax-M2.5', 'format': 'openai'},
+ 'moonshot': {'name': '月之暗面', 'base_url': 'https://api.moonshot.cn/v1/', 'default_model': 'moonshot-v1-8k', 'format': 'openai'},
+ 'siliconflow': {'name': '硅基流动', 'base_url': 'https://api.siliconflow.cn/v1/', 'default_model': 'Qwen/Qwen2.5-7B-Instruct', 'format': 'openai'},
+ 'cerebras': {'name': 'Cerebras', 'base_url': 'https://api.cerebras.ai/v1/', 'default_model': 'llama-3.3-70b', 'format': 'openai'},
+ 'sambanova': {'name': 'SambaNova', 'base_url': 'https://api.sambanova.ai/v1/', 'default_model': 'Meta-Llama-3.3-70B-Instruct', 'format': 'openai'},
+ 'groq': {'name': 'Groq', 'base_url': 'https://api.groq.com/openai/v1/', 'default_model': 'llama-3.3-70b-versatile', 'format': 'openai'},
+ 'mistral': {'name': 'Mistral', 'base_url': 'https://api.mistral.ai/v1/', 'default_model': 'mistral-small-latest', 'format': 'openai'},
+ 'deepinfra': {'name': 'DeepInfra', 'base_url': 'https://api.deepinfra.com/v1/openai/', 'default_model': 'meta-llama/Llama-3.3-70B-Instruct', 'format': 'openai'},
+ 'together': {'name': 'Together AI', 'base_url': 'https://api.together.xyz/v1/', 'default_model': 'meta-llama/Llama-3.3-70B-Instruct-Turbo', 'format': 'openai'},
+ 'openrouter': {'name': 'OpenRouter', 'base_url': 'https://openrouter.ai/api/v1/', 'default_model': 'openai/gpt-4o-mini', 'format': 'openai'},
+ 'zhipuai': {'name': '智谱AI', 'base_url': 'https://open.bigmodel.cn/api/paas/v4/', 'default_model': 'glm-4.7-flash', 'format': 'openai'},
+ 'modelscope': {'name': 'ModelScope', 'base_url': 'https://api-inference.modelscope.cn/v1/', 'default_model': 'Qwen/Qwen2.5-72B-Instruct', 'format': 'openai'},
+ 'custom': {'name': '自定义 (OpenAI 兼容)', 'base_url': '', 'default_model': '', 'format': 'openai'},
+}
+
+_ai_config = {'providers': {}, 'order': []}
+
+# --- Dictionary Management ---
+_DICT_DIR = os.path.join(os.path.dirname(__file__), 'dict')
+_DICT_FILES = {
+ 'ecdict': {'filename': 'stardict.db', 'label': 'ECDICT英文词典', 'label_en': 'ECDICT English', 'size_mb': 307, 'gz_mb': 134},
+ 'cn_dict': {'filename': 'cn_dict.db', 'label': '中文词典', 'label_en': 'Chinese Dict', 'size_mb': 48, 'gz_mb': 25},
+}
+_DEFAULT_DICT_URL = 'https://github.com/Golden0Voyager/reader3-dict/releases/download/dict-v1'
+
+
+def _load_ai_config():
+ """Load AI provider config from JSON file."""
+ global _ai_config
+ if os.path.exists(AI_CONFIG_PATH):
+ try:
+ with open(AI_CONFIG_PATH, 'r') as f:
+ _ai_config = json.load(f)
+ except (json.JSONDecodeError, IOError):
+ pass
+ _ai_config.setdefault('providers', {})
+ _ai_config.setdefault('order', [])
+
+
+def _save_ai_config():
+ """Save AI provider config to JSON file."""
+ with open(AI_CONFIG_PATH, 'w') as f:
+ json.dump(_ai_config, f, indent=2, ensure_ascii=False)
+
+
+def _get_builtin_providers():
+ """Return builtin provider configs from .env (in-memory only, never shown in UI)."""
+ builtins = []
+ gemini_key = os.getenv("GEMINI_API_KEY") or os.getenv("GOOGLE_API_KEY")
+ if gemini_key:
+ builtins.append(('gemini', gemini_key, 'gemini-3-flash-preview'))
+ zhipu_key = os.getenv("ZHIPUAI_API_KEY")
+ if zhipu_key:
+ builtins.append(('zhipuai', zhipu_key, 'glm-4.7-flash'))
+ return builtins
+
+
+# Set of provider IDs that have builtin .env keys
+_BUILTIN_IDS = {pid for pid, _, _ in _get_builtin_providers()}
+
+
+def _get_enabled_providers():
+ """Return list of enabled provider configs in priority order.
+ User-configured providers first, then builtin (.env) providers as fallback."""
+ result = []
+ seen = set()
+ def _make_entry(pid, p):
+ defn = _PROVIDER_DEFS.get(pid, {})
+ if not defn and pid.startswith('custom'):
+ defn = _PROVIDER_DEFS.get('custom', {})
+ name = p.get('custom_name') or defn.get('name', pid)
+ entry = {
+ 'id': pid, 'name': name,
+ 'api_key': p['api_key'],
+ 'model': p.get('model') or defn.get('default_model', ''),
+ 'base_url': p.get('base_url') or defn.get('base_url', ''),
+ 'format': defn.get('format', 'openai'),
+ }
+ if p.get('temperature') is not None:
+ entry['temperature'] = p['temperature']
+ if p.get('max_tokens') is not None:
+ entry['max_tokens'] = p['max_tokens']
+ return entry
+ # 1) User-configured providers (from ai_config.json)
+ for pid in _ai_config.get('order', []):
+ p = _ai_config['providers'].get(pid)
+ if p and p.get('enabled') and p.get('api_key'):
+ result.append(_make_entry(pid, p))
+ seen.add(pid)
+ for pid, p in _ai_config['providers'].items():
+ if pid not in seen and p.get('enabled') and p.get('api_key'):
+ result.append(_make_entry(pid, p))
+ seen.add(pid)
+ # 2) Builtin providers from .env as fallback (skip if user already configured same provider)
+ for pid, bkey, bmodel in _get_builtin_providers():
+ if pid not in seen:
+ defn = _PROVIDER_DEFS.get(pid, {})
+ result.append({
+ 'id': pid, 'name': defn.get('name', pid) + ' (内置)',
+ 'api_key': bkey,
+ 'model': bmodel,
+ 'base_url': defn.get('base_url', ''),
+ 'format': defn.get('format', 'openai'),
+ })
+ return result
+
+
+# Initialize provider config on module load
+_load_ai_config()
+_enabled = _get_enabled_providers()
+if _enabled:
+ print(f"AI providers: {', '.join(p['name'] + ' (' + p['model'] + ')' for p in _enabled)}")
+else:
+ print("Warning: No AI providers configured. Add one in Settings or set API keys in .env")
+
+# Google Translate direct API (fast, connection-pooled, independent of AI providers)
+_gt_client = httpx.AsyncClient(timeout=5, http2=False, headers={"User-Agent": "Reader3/1.0"})
+
+# Shared httpx pool for AI provider calls (reuse TCP/TLS connections)
+_ai_client = httpx.AsyncClient(timeout=60, trust_env=True, headers={"User-Agent": "Reader3/1.0"})
+
+
+# --- Unified AI Dispatch ---
+
+async def _call_openai_compat(base_url, api_key, model, prompt, temperature, max_tokens, extra_body=None):
+ """Non-streaming call to OpenAI-compatible chat/completions endpoint."""
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ body = {
+ "model": model,
+ "messages": [{"role": "user", "content": prompt}],
+ "temperature": temperature,
+ "max_tokens": max_tokens,
+ }
+ if extra_body:
+ body.update(extra_body)
+ resp = await _ai_client.post(f"{base_url.rstrip('/')}/chat/completions", headers=headers, json=body)
+ if resp.status_code != 200:
+ try:
+ err = resp.json().get('error', {})
+ detail = err.get('message', '') if isinstance(err, dict) else str(err)
+ except Exception:
+ detail = resp.text[:200]
+ raise Exception(f"HTTP {resp.status_code}: {detail}")
+ return resp.json()["choices"][0]["message"]["content"]
+
+
+async def _call_anthropic(base_url, api_key, model, prompt, temperature, max_tokens):
+ """Non-streaming call to Anthropic Messages API."""
+ resp = await _ai_client.post(
+ f"{base_url.rstrip('/')}/messages",
+ headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
+ json={"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], "temperature": temperature},
+ )
+ if resp.status_code != 200:
+ try:
+ err = resp.json().get('error', {})
+ detail = err.get('message', '') if isinstance(err, dict) else str(err)
+ except Exception:
+ detail = resp.text[:200]
+ raise Exception(f"HTTP {resp.status_code}: {detail}")
+ return resp.json()["content"][0]["text"]
+
+
+async def _call_gemini(api_key, model_name, prompt, temperature, max_tokens):
+ """Non-streaming call to Gemini via SDK."""
+ client = google_genai.Client(api_key=api_key)
+ config = google_genai.types.GenerateContentConfig(temperature=temperature, max_output_tokens=max_tokens)
+ response = await asyncio.to_thread(lambda: client.models.generate_content(model=model_name, contents=prompt, config=config))
+ return response.text.strip()
+
+
+async def _ai_complete(prompt, temperature=0.7, max_tokens=4096, task=None):
+ """Unified non-streaming AI call. Returns (text, display_name). Tries providers in order with fallback."""
+ providers = _get_enabled_providers()
+ if not providers:
+ raise HTTPException(status_code=500, detail="AI not configured — please add a provider in Settings")
+ # Task-specific provider routing
+ routing = _ai_config.get('task_routing', {})
+ routed_pid = routing.get(task) if task else None
+ if routed_pid:
+ routed = [p for p in providers if p['id'] == routed_pid]
+ others = [p for p in providers if p['id'] != routed_pid]
+ providers = routed + others
+ last_error = None
+ for p in providers:
+ try:
+ t = p.get('temperature', temperature)
+ mt = p.get('max_tokens', max_tokens)
+ fmt = p['format']
+ if fmt == 'gemini':
+ text = await _call_gemini(p['api_key'], p['model'], prompt, t, mt)
+ elif fmt == 'anthropic':
+ text = await _call_anthropic(p['base_url'], p['api_key'], p['model'], prompt, t, mt)
+ else:
+ extra = {"thinking": {"type": "disabled"}} if p['id'] == 'zhipuai' else None
+ text = await _call_openai_compat(p['base_url'], p['api_key'], p['model'], prompt, t, mt, extra_body=extra)
+ return text.strip(), f"{p['name']} {p['model']}"
+ except Exception as e:
+ last_error = e
+ print(f"[AI] {p['name']} ({p['model']}) failed: {e}")
+ continue
+ raise HTTPException(status_code=500, detail=f"All AI providers failed. Last error: {last_error}")
+
+
+async def _stream_openai_compat(base_url, api_key, model, prompt, temperature, max_tokens, extra_body=None):
+ """Streaming async generator for OpenAI-compatible endpoint."""
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ body = {"model": model, "messages": [{"role": "user", "content": prompt}], "stream": True, "temperature": temperature, "max_tokens": max_tokens}
+ if extra_body:
+ body.update(extra_body)
+ async with httpx.AsyncClient(timeout=60, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ async with client.stream("POST", f"{base_url.rstrip('/')}/chat/completions", headers=headers, json=body) as resp:
+ if resp.status_code != 200:
+ err = await resp.aread()
+ raise Exception(f"HTTP {resp.status_code}: {err[:300].decode(errors='replace')}")
+ buf = b''
+ async for raw in resp.aiter_bytes():
+ buf += raw
+ while b'\n' in buf:
+ line_bytes, buf = buf.split(b'\n', 1)
+ line = line_bytes.decode('utf-8', errors='replace').strip()
+ if not line.startswith("data: "):
+ continue
+ data = line[6:]
+ if data.strip() == "[DONE]":
+ return
+ try:
+ chunk = json.loads(data)
+ content = chunk["choices"][0]["delta"].get("content", "")
+ if content:
+ yield content
+ except (json.JSONDecodeError, KeyError, IndexError):
+ continue
+
+
+async def _stream_anthropic(base_url, api_key, model, prompt, temperature, max_tokens):
+ """Streaming async generator for Anthropic Messages API."""
+ async with httpx.AsyncClient(timeout=60, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ async with client.stream("POST", f"{base_url.rstrip('/')}/messages",
+ headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
+ json={"model": model, "max_tokens": max_tokens, "messages": [{"role": "user", "content": prompt}], "temperature": temperature, "stream": True},
+ ) as resp:
+ if resp.status_code != 200:
+ err = await resp.aread()
+ raise Exception(f"HTTP {resp.status_code}: {err[:300].decode(errors='replace')}")
+ buf = b''
+ async for raw in resp.aiter_bytes():
+ buf += raw
+ while b'\n' in buf:
+ line_bytes, buf = buf.split(b'\n', 1)
+ line = line_bytes.decode('utf-8', errors='replace').strip()
+ if not line.startswith("data: "):
+ continue
+ try:
+ event = json.loads(line[6:])
+ if event.get("type") == "content_block_delta":
+ text = event.get("delta", {}).get("text", "")
+ if text:
+ yield text
+ except (json.JSONDecodeError, KeyError):
+ continue
+
+
+async def _ai_stream(prompt, temperature=0.7, max_tokens=4096, task=None):
+ """Unified streaming AI. Returns async generator with auto-fallback between providers."""
+ providers = _get_enabled_providers()
+ # Task-specific provider routing
+ routing = _ai_config.get('task_routing', {})
+ routed_pid = routing.get(task) if task else None
+ if routed_pid:
+ routed = [p for p in providers if p['id'] == routed_pid]
+ others = [p for p in providers if p['id'] != routed_pid]
+ providers = routed + others
+
+ async def generate():
+ if not providers:
+ yield "[Error: AI not configured — please add a provider in Settings]"
+ return
+ for i, p in enumerate(providers):
+ try:
+ t = p.get('temperature', temperature)
+ mt = p.get('max_tokens', max_tokens)
+ fmt = p['format']
+ if fmt == 'gemini':
+ client = google_genai.Client(api_key=p['api_key'])
+ config = google_genai.types.GenerateContentConfig(temperature=t)
+ response = await asyncio.to_thread(
+ lambda: client.models.generate_content_stream(model=p['model'], contents=prompt, config=config)
+ )
+ for chunk in response:
+ try:
+ if chunk.text:
+ yield chunk.text
+ except (ValueError, AttributeError):
+ continue
+ elif fmt == 'anthropic':
+ async for chunk in _stream_anthropic(p['base_url'], p['api_key'], p['model'], prompt, t, mt):
+ yield chunk
+ else:
+ extra = {"thinking": {"type": "disabled"}} if p['id'] == 'zhipuai' else None
+ async for chunk in _stream_openai_compat(p['base_url'], p['api_key'], p['model'], prompt, t, mt, extra_body=extra):
+ yield chunk
+ yield f"\n"
+ return # Success
+ except asyncio.CancelledError:
+ return
+ except Exception as e:
+ print(f"[AI stream] {p['name']} ({p['model']}) failed: {e}")
+ if i == len(providers) - 1:
+ yield f"\n[Error: {e}]"
+ continue
+
+ return generate()
+
+def _detect_cjk_ratio(text):
+ cjk = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' or '\uac00' <= c <= '\ud7af')
+ return cjk / max(len(text), 1)
+
+_cn_dict_cache: dict[str, str] = {}
+
+async def _chinese_define(word: str) -> str | None:
+ """AI-powered Chinese word definition, with in-memory cache."""
+ if word in _cn_dict_cache:
+ return _cn_dict_cache[word]
+ prompt = f'请用一句话简明解释"{word}"的含义,像词典释义一样简短。只输出释义,不要引号不要前缀。'
+ try:
+ result, _ = await _ai_complete(prompt, temperature=0.1, max_tokens=200, task='dict')
+ _cn_dict_cache[word] = result
+ return result
+ except Exception:
+ return None
+
+async def _google_translate(text, dest='zh-CN'):
+ """Direct Google Translate API call, ~100ms with connection reuse."""
+ resp = await _gt_client.get('https://translate.googleapis.com/translate_a/single', params={
+ 'client': 'gtx', 'sl': 'auto', 'tl': dest, 'dt': 't', 'q': text
+ })
+ data = resp.json()
+ return ''.join(s[0] for s in data[0] if s[0])
+
+def _open_dict_db(path):
+ """Open a dict SQLite DB with read-optimized settings."""
+ conn = sqlite3.connect(path, check_same_thread=False)
+ conn.row_factory = sqlite3.Row
+ conn.execute('PRAGMA journal_mode=WAL')
+ conn.execute('PRAGMA mmap_size=67108864') # 64MB mmap for faster reads
+ conn.execute('PRAGMA cache_size=-8000') # 8MB page cache
+ return conn
+
+# ECDICT offline dictionary (~3.4M entries, <1ms lookup)
+_dict_db_path = os.path.join(os.path.dirname(__file__), 'dict', 'stardict.db')
+_dict_conn = None
+if os.path.exists(_dict_db_path):
+ _dict_conn = _open_dict_db(_dict_db_path)
+
+# Chinese dictionary (457K entries: xinhua + moedict, <1ms lookup)
+_cn_dict_path = os.path.join(os.path.dirname(__file__), 'dict', 'cn_dict.db')
+_cn_dict_conn = None
+if os.path.exists(_cn_dict_path):
+ _cn_dict_conn = _open_dict_db(_cn_dict_path)
+
+def _reload_dict():
+ """Hot-reload dictionary connections after download."""
+ global _dict_conn, _cn_dict_conn
+ for path, conn_name in [(_dict_db_path, '_dict_conn'), (_cn_dict_path, '_cn_dict_conn')]:
+ if os.path.exists(path) and globals()[conn_name] is None:
+ globals()[conn_name] = _open_dict_db(path)
+
+def _dict_lookup(word: str) -> dict | None:
+ """Look up a word in the local ECDICT dictionary. Returns dict or None."""
+ if not _dict_conn:
+ return None
+ row = _dict_conn.execute(
+ 'SELECT word, phonetic, translation, definition FROM dict WHERE word = ? COLLATE NOCASE',
+ (word.strip(),)
+ ).fetchone()
+ if not row or not (row['translation'] or row['definition']):
+ return None
+ return {
+ 'word': row['word'],
+ 'phonetic': row['phonetic'] or '',
+ 'translation': (row['translation'] or '').strip(),
+ 'definition': (row['definition'] or '').strip(),
+ }
+
+def _cn_dict_lookup(word: str) -> dict | None:
+ """Look up a Chinese word in local cn_dict. Returns dict or None."""
+ if not _cn_dict_conn:
+ return None
+ row = _cn_dict_conn.execute(
+ 'SELECT word, pinyin, definition, source FROM cn_dict WHERE word = ?',
+ (word.strip(),)
+ ).fetchone()
+ if not row or not row['definition']:
+ return None
+ return {
+ 'word': row['word'],
+ 'pinyin': row['pinyin'] or '',
+ 'definition': row['definition'].strip(),
+ 'source': row['source'],
+ }
+
+async def _wiki_summary(term: str) -> dict:
+ """Fetch Wikipedia summary for a term. Auto-detect language."""
+ import urllib.request, urllib.parse
+ is_cjk = _detect_cjk_ratio(term) > 0.3
+ lang = 'zh' if is_cjk else 'en'
+ try:
+ encoded = urllib.parse.quote(term)
+ url = f'https://{lang}.wikipedia.org/api/rest_v1/page/summary/{encoded}'
+ req = urllib.request.Request(url, headers={'User-Agent': 'Reader3/1.0'})
+ def _fetch():
+ with urllib.request.urlopen(req, timeout=5) as resp:
+ return json.loads(resp.read())
+ data = await asyncio.to_thread(_fetch)
+ return {
+ 'title': data.get('title', ''),
+ 'extract': data.get('extract', ''),
+ 'url': data.get('content_urls', {}).get('desktop', {}).get('page', ''),
+ }
+ except Exception:
+ return {}
+
+import re as _re
+
+def _safe_dirname(title: str, authors: list[str] = None) -> str:
+ """Sanitize book title + author for use as directory name."""
+ name = _re.sub(r'[\\/:*?"<>|]', '', title).strip()
+ name = _re.sub(r'\s+', ' ', name)
+ if authors and authors[0]:
+ author = _re.sub(r'[\\/:*?"<>|]', '', authors[0]).strip()
+ if author:
+ name = f"{name} - {author}"
+ if len(name) > 80:
+ name = name[:80].rstrip()
+ return name or 'untitled'
+
+
+def _process_pdf(pdf_path: str, out_dir: str) -> dict:
+ """Process a PDF file: extract metadata, render cover from first page, copy PDF."""
+ import fitz # PyMuPDF
+ import shutil
+
+ os.makedirs(out_dir, exist_ok=True)
+ images_dir = os.path.join(out_dir, "images")
+ os.makedirs(images_dir, exist_ok=True)
+
+ doc = fitz.open(pdf_path)
+ meta = doc.metadata or {}
+ title = meta.get("title", "").strip() or os.path.splitext(os.path.basename(pdf_path))[0]
+ author = meta.get("author", "").strip()
+ page_count = len(doc)
+
+ # Render first page as cover
+ if page_count > 0:
+ page = doc[0]
+ pix = page.get_pixmap(matrix=fitz.Matrix(2, 2)) # 2x zoom for quality
+ cover_path = os.path.join(images_dir, "cover.png")
+ pix.save(cover_path)
+ with open(os.path.join(out_dir, "cover_image.txt"), "w") as f:
+ f.write("cover.png")
+
+ # 提取 PDF 目录(outline/bookmarks)
+ toc = doc.get_toc() # PyMuPDF 返回 [[level, title, page], ...]
+ outline = [{"level": item[0], "title": item[1], "page": item[2]} for item in toc]
+
+ doc.close()
+
+ # Copy PDF to output dir
+ dest_pdf = os.path.join(out_dir, "book.pdf")
+ if os.path.abspath(pdf_path) != os.path.abspath(dest_pdf):
+ shutil.copy2(pdf_path, dest_pdf)
+
+ # Write meta.json
+ meta_info = {
+ "title": title,
+ "author": author,
+ "pages": page_count,
+ "format": "pdf",
+ "outline": outline,
+ }
+ with open(os.path.join(out_dir, "meta.json"), "w", encoding="utf-8") as f:
+ json.dump(meta_info, f, ensure_ascii=False)
+
+ return meta_info
-from reader3 import Book, BookMetadata, ChapterContent, TOCEntry
app = FastAPI()
+app.add_middleware(GZipMiddleware, minimum_size=1000) # gzip responses > 1KB
templates = Jinja2Templates(directory="templates")
# Where are the book folders located?
-BOOKS_DIR = "."
+BOOKS_DIR = os.path.join(os.path.dirname(__file__), "books")
+
+# TTS audio cache directory
+TTS_CACHE_DIR = os.path.join(BOOKS_DIR, ".tts_cache")
+os.makedirs(TTS_CACHE_DIR, exist_ok=True)
-@lru_cache(maxsize=10)
+# server-cache-lru: Multi-level cache for AI Analysis
+# We use both in-memory and could easily extend to disk.
+_analysis_cache = {}
+
+def _get_text_hash(text: str) -> str:
+ return hashlib.md5(text.encode()).hexdigest()
+
+@lru_cache(maxsize=20)
def load_book_cached(folder_name: str) -> Optional[Book]:
- """
- Loads the book from the pickle file.
- Cached so we don't re-read the disk on every click.
- """
+ """Load a Book object from its pickle file, with LRU caching."""
file_path = os.path.join(BOOKS_DIR, folder_name, "book.pkl")
if not os.path.exists(file_path):
return None
-
try:
with open(file_path, "rb") as f:
- book = pickle.load(f)
- return book
- except Exception as e:
- print(f"Error loading book {folder_name}: {e}")
+ return pickle.load(f)
+ except Exception:
return None
+# --- Library metadata index (avoid full pickle load for listing) ---
+_LIBRARY_INDEX = os.path.join(BOOKS_DIR, ".library_index.json")
+
+def _build_library_index():
+ """Scan books dir, build/update lightweight metadata index."""
+ index = {}
+ if os.path.exists(_LIBRARY_INDEX):
+ try:
+ with open(_LIBRARY_INDEX, 'r') as f:
+ index = json.load(f)
+ except (json.JSONDecodeError, IOError):
+ pass
+ changed = False
+ current_dirs = set()
+ if not os.path.exists(BOOKS_DIR):
+ return index
+ for item in os.listdir(BOOKS_DIR):
+ if not item.endswith("_data") or not os.path.isdir(os.path.join(BOOKS_DIR, item)):
+ continue
+ current_dirs.add(item)
+ # Check for PDF (meta.json) or EPUB (book.pkl)
+ meta_json_path = os.path.join(BOOKS_DIR, item, "meta.json")
+ pkl_path = os.path.join(BOOKS_DIR, item, "book.pkl")
+ if os.path.exists(meta_json_path):
+ # PDF book
+ meta_mtime = os.path.getmtime(meta_json_path)
+ if item in index and index[item].get('_mtime') == meta_mtime:
+ continue
+ try:
+ with open(meta_json_path, 'r', encoding='utf-8') as f:
+ pdf_meta = json.load(f)
+ old_display_title = index.get(item, {}).get('display_title')
+ index[item] = {
+ 'title': pdf_meta.get('title', 'Untitled'),
+ 'author': pdf_meta.get('author', ''),
+ 'chapters': pdf_meta.get('pages', 0),
+ 'language': 'en',
+ 'format': 'pdf',
+ '_mtime': meta_mtime,
+ }
+ if old_display_title:
+ index[item]['display_title'] = old_display_title
+ changed = True
+ except (json.JSONDecodeError, IOError):
+ pass
+ elif os.path.exists(pkl_path):
+ # EPUB book
+ pkl_mtime = os.path.getmtime(pkl_path)
+ if item in index and index[item].get('_mtime') == pkl_mtime:
+ continue
+ book = load_book_cached(item)
+ if book:
+ old_display_title = index.get(item, {}).get('display_title')
+ index[item] = {
+ 'title': book.metadata.title,
+ 'author': ', '.join(book.metadata.authors) if book.metadata.authors else '',
+ 'chapters': len(book.spine),
+ 'language': book.metadata.language or 'en',
+ 'format': 'epub',
+ '_mtime': pkl_mtime,
+ }
+ if old_display_title:
+ index[item]['display_title'] = old_display_title
+ changed = True
+ # Remove deleted books from index
+ for key in list(index.keys()):
+ if key not in current_dirs:
+ del index[key]
+ changed = True
+ if changed:
+ with open(_LIBRARY_INDEX, 'w') as f:
+ json.dump(index, f, ensure_ascii=False)
+ return index
+
@app.get("/", response_class=HTMLResponse)
async def library_view(request: Request):
- """Lists all available processed books."""
+ index = await asyncio.to_thread(_build_library_index)
books = []
+ for item in sorted(index.keys()):
+ meta = index[item]
+ books.append({
+ "id": item,
+ "title": meta.get('display_title') or meta['title'],
+ "original_title": meta['title'],
+ "author": meta['author'],
+ "chapters": meta['chapters'],
+ "language": meta['language'],
+ "format": meta.get('format', 'epub'),
+ "mtime": meta.get('_mtime', 0),
+ })
+ return templates.TemplateResponse("library.html", {"request": request, "books": books})
- # Scan directory for folders ending in '_data' that have a book.pkl
- if os.path.exists(BOOKS_DIR):
- for item in os.listdir(BOOKS_DIR):
- if item.endswith("_data") and os.path.isdir(item):
- # Try to load it to get the title
- book = load_book_cached(item)
- if book:
- books.append({
- "id": item,
- "title": book.metadata.title,
- "author": ", ".join(book.metadata.authors),
- "chapters": len(book.spine)
- })
- return templates.TemplateResponse("library.html", {"request": request, "books": books})
+def _find_cover_image(book_id: str) -> str | None:
+ """Find cover image path for a book. Returns absolute path or None."""
+ import re
+ images_dir = os.path.join(BOOKS_DIR, book_id, "images")
+ if not os.path.isdir(images_dir):
+ return None
+
+ img_exts = ('.jpg', '.jpeg', '.png', '.gif', '.webp')
+ images = [f for f in os.listdir(images_dir) if f.lower().endswith(img_exts)]
+
+ # 1. Marker file left by process_epub (most reliable)
+ marker = os.path.join(BOOKS_DIR, book_id, "cover_image.txt")
+ if os.path.exists(marker):
+ fname = open(marker).read().strip()
+ path = os.path.join(images_dir, fname)
+ if os.path.exists(path):
+ return path
-@app.get("/read/{book_id}", response_class=HTMLResponse)
-async def redirect_to_first_chapter(book_id: str):
- """Helper to just go to chapter 0."""
- return await read_chapter(book_id=book_id, chapter_index=0)
+ # 2. File named cover* (most explicit naming convention)
+ for f in images:
+ if re.match(r'cover', f, re.I):
+ return os.path.join(images_dir, f)
+ # 3. File containing *cover* anywhere in name
+ for f in images:
+ if 'cover' in f.lower():
+ return os.path.join(images_dir, f)
+
+ # 4. Parse first chapter HTML for image reference (often the cover page)
+ book = load_book_cached(book_id)
+ if book and book.spine:
+ content = book.spine[0].content[:2000]
+ m = re.search(r'(?:src|href)=["\']([^"\']+\.(?:jpe?g|png|gif|webp))', content, re.I)
+ if m:
+ src = m.group(1)
+ fname = os.path.basename(src)
+ path = os.path.join(images_dir, fname)
+ if os.path.exists(path):
+ return path
+
+ # 5. Fallback: pick the largest image file (covers are usually the biggest)
+ if images:
+ largest = max(images, key=lambda f: os.path.getsize(os.path.join(images_dir, f)))
+ return os.path.join(images_dir, largest)
+
+ return None
+
+
+@app.get("/api/book-cover/{book_id}")
+async def serve_book_cover(book_id: str):
+ """Serve cover image for an imported book."""
+ safe_id = os.path.basename(book_id)
+ cover = _find_cover_image(safe_id)
+ if cover and os.path.exists(cover):
+ return FileResponse(cover, headers={"Cache-Control": "no-cache"})
+ raise HTTPException(status_code=404, detail="No cover found")
@app.get("/read/{book_id}/{chapter_index}", response_class=HTMLResponse)
-async def read_chapter(request: Request, book_id: str, chapter_index: int):
- """The main reader interface."""
+async def read_chapter(request: Request, book_id: str, chapter_index: str):
+ """Render a single chapter, or serve an image if chapter_index is a filename."""
+ # Handle ../images/ or ../Images/ relative paths (book_id would be "images" or "Images")
+ if book_id.lower() == 'images':
+ referer = request.headers.get("referer", "")
+ import re as _re
+ from urllib.parse import unquote
+ m = _re.search(r'/read/([^/]+)/', referer)
+ if m:
+ real_book_id = unquote(m.group(1))
+ safe_name = os.path.basename(chapter_index)
+ image_path = os.path.join(BOOKS_DIR, real_book_id, "images", safe_name)
+ if os.path.exists(image_path):
+ return FileResponse(image_path)
+ raise HTTPException(status_code=404, detail="Image not found")
+
+ # If it looks like a file (has extension), serve as image fallback
+ if '.' in chapter_index:
+ safe_name = os.path.basename(chapter_index)
+ image_path = os.path.join(BOOKS_DIR, book_id, "images", safe_name)
+ if os.path.exists(image_path):
+ return FileResponse(image_path)
+ raise HTTPException(status_code=404, detail="Not found")
+
+ try:
+ idx = int(chapter_index)
+ except ValueError:
+ raise HTTPException(status_code=404, detail="Not found")
+
book = load_book_cached(book_id)
- if not book:
- raise HTTPException(status_code=404, detail="Book not found")
+ if not book or idx < 0 or idx >= len(book.spine):
+ raise HTTPException(status_code=404, detail="Not found")
- if chapter_index < 0 or chapter_index >= len(book.spine):
- raise HTTPException(status_code=404, detail="Chapter not found")
+ current_chapter = book.spine[idx]
+ prev_idx = idx - 1 if idx > 0 else None
+ next_idx = idx + 1 if idx < len(book.spine) - 1 else None
- current_chapter = book.spine[chapter_index]
+ # Fix SVG cover distortion: replace preserveAspectRatio="none" and width/height="100%"
+ import re as _re
+ content = current_chapter.content
- # Calculate Prev/Next links
- prev_idx = chapter_index - 1 if chapter_index > 0 else None
- next_idx = chapter_index + 1 if chapter_index < len(book.spine) - 1 else None
+ # Normalize image paths: EPUB/images/x.jpg, OEBPS/Images/x.jpg, ../images/x.jpg → images/x.jpg
+ content = _re.sub(r'(?:(?:\.\.\/)*(?:EPUB|OEBPS|OPS)\/|(?:\.\.\/)+)[Ii]mages/', 'images/', content)
+
+ if ']*)\s+width="100%"', r'\1', content, flags=_re.I)
+ content = _re.sub(r'(]*)\s+height="100%"', r'\1', content, flags=_re.I)
return templates.TemplateResponse("reader.html", {
- "request": request,
- "book": book,
- "current_chapter": current_chapter,
- "chapter_index": chapter_index,
- "book_id": book_id,
- "prev_idx": prev_idx,
- "next_idx": next_idx
+ "request": request, "book": book, "current_chapter": current_chapter,
+ "chapter_index": idx, "book_id": book_id,
+ "prev_idx": prev_idx, "next_idx": next_idx,
+ "chapter_content": content
})
+
@app.get("/read/{book_id}/images/{image_name}")
-async def serve_image(book_id: str, image_name: str):
- """
- Serves images specifically for a book.
- The HTML contains .
- The browser resolves this to /read/{book_id}/images/pic.jpg.
- """
- # Security check: ensure book_id is clean
- safe_book_id = os.path.basename(book_id)
- safe_image_name = os.path.basename(image_name)
-
- img_path = os.path.join(BOOKS_DIR, safe_book_id, "images", safe_image_name)
-
- if not os.path.exists(img_path):
+async def serve_book_image(book_id: str, image_name: str):
+ """Serve an image file from a book's extracted images directory."""
+ safe_name = os.path.basename(image_name)
+ image_path = os.path.join(BOOKS_DIR, book_id, "images", safe_name)
+ if not os.path.exists(image_path):
raise HTTPException(status_code=404, detail="Image not found")
+ return FileResponse(image_path)
+
+
+# --- Rename book ---
+@app.post("/api/rename-book/{book_id}")
+async def rename_book(book_id: str, request: Request):
+ """Set display_title for a book. Empty string removes custom title."""
+ req = await request.json()
+ title = req.get("title", "").strip()
+ index = {}
+ if os.path.exists(_LIBRARY_INDEX):
+ with open(_LIBRARY_INDEX, 'r') as f:
+ index = json.load(f)
+ if book_id not in index:
+ raise HTTPException(status_code=404, detail="Book not found")
+ if title:
+ index[book_id]['display_title'] = title
+ else:
+ index[book_id].pop('display_title', None)
+ with open(_LIBRARY_INDEX, 'w') as f:
+ json.dump(index, f, ensure_ascii=False)
+ return {"ok": True, "display_title": title or None}
+
+
+# --- Delete books ---
+@app.post("/api/delete-books")
+async def delete_books(req: dict):
+ """Delete one or more books by their IDs."""
+ import shutil
+ book_ids = req.get("book_ids", [])
+ if not book_ids:
+ raise HTTPException(status_code=400, detail="No books specified")
+ deleted = []
+ for bid in book_ids:
+ safe_id = os.path.basename(bid)
+ book_dir = os.path.join(BOOKS_DIR, safe_id)
+ if os.path.isdir(book_dir) and os.path.exists(os.path.join(book_dir, "book.pkl")):
+ await asyncio.to_thread(shutil.rmtree, book_dir)
+ deleted.append(safe_id)
+ load_book_cached.cache_clear()
+ return {"deleted": deleted, "count": len(deleted)}
+
+# --- AI MODULE REFACTORED (Gemini 3 Pro standards) ---
+
+class AIAnalyzeRequest(BaseModel):
+ book_id: str
+ chapter_index: int
+
+@app.post("/api/ai/analyze")
+async def analyze_chapter(req: AIAnalyzeRequest):
+ """Analyze a chapter and return structured insights."""
+ book = load_book_cached(req.book_id)
+ if not book or req.chapter_index < 0 or req.chapter_index >= len(book.spine):
+ raise HTTPException(status_code=404, detail="Chapter not found")
+ chapter = book.spine[req.chapter_index]
+
+ # Use text field, fallback to stripping HTML from content
+ chapter_text = chapter.text.strip()
+ if not chapter_text and chapter.content:
+ from html.parser import HTMLParser
+ class _Strip(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.parts = []
+ def handle_data(self, d):
+ self.parts.append(d)
+ s = _Strip()
+ s.feed(chapter.content)
+ chapter_text = ' '.join(s.parts).strip()
+
+ if len(chapter_text) < 20:
+ return {
+ "summary": "本章内容过短,无法进行有效分析。",
+ "key_points": [],
+ "difficulties": "",
+ "insight": ""
+ }
+
+ # server-cache-lru: Fingerprint based on text content hash
+ content_hash = _get_text_hash(chapter_text)
+ cache_key = f"{req.book_id}:{req.chapter_index}:{content_hash}"
+
+ if cache_key in _analysis_cache:
+ return _analysis_cache[cache_key]
+
+ prompt = f"""你是一位经验丰富的读书会领读者,同时具备深厚的文学素养和跨学科知识。
+你正在带领一群认真的成年读者讨论这本书。你的风格:有见地但不卖弄,善于发现文字背后的深意。
+
+书名:{book.metadata.title}
+作者:{', '.join(book.metadata.authors) if book.metadata.authors else '未知'}
+章节:{chapter.title}
+
+【本章内容】:
+{chapter_text[:15000]}
+
+请阅读后,以领读者的身份进行分析。严格按以下 JSON 返回(不要包含 ```json 标记):
+
+{{
+ "summary": "用 150-250 字概述本章核心内容。不要罗列事件,而是说清楚:这一章到底在讲什么、推进了什么、改变了什么。如果是非虚构类,提炼核心论点和关键论据。",
+ "key_points": [
+ "提炼 3-5 个本章最值得关注的要点。每个要点用一句话点明是什么,再用一句话说明为什么重要。不要泛泛而谈。"
+ ],
+ "difficulties": "找出本章中读者可能卡住的地方:专业术语、文化背景、隐晦的表达、复杂的逻辑链等,用大白话解释清楚。如果没有难点就坦诚说明。",
+ "insight": "分享一个有启发性的深层解读:可以是与其他作品的对比、一个反直觉的发现、当下社会的映射、或者作者没有明说但暗含的立场。要言之有物,避免空洞的感悟。"
+}}"""
+
+ try:
+ text, used_model = await _ai_complete(prompt, temperature=0.3, max_tokens=8192, task='analyze')
+
+ # Robust JSON cleaning
+ if "{" in text and "}" in text:
+ text = text[text.find("{"):text.rfind("}")+1]
+
+ result = json.loads(text)
+ result["_model"] = used_model
+ _analysis_cache[cache_key] = result # Cache the processed object
+ return result
+ except json.JSONDecodeError as e:
+ raise HTTPException(status_code=500, detail=f"Analysis failed: invalid JSON from AI")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Analysis failed: {str(e)}")
+
+@app.post("/api/ai/translate")
+async def translate_text(req: dict):
+ """Translate text using unified AI dispatch."""
+ text = req.get("text", "")
+ if not text:
+ return {"translation": ""}
+
+ # Auto-detect: if mostly CJK → translate to English, otherwise → translate to Chinese
+ cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' or '\uac00' <= c <= '\ud7af')
+ target = "英文" if cjk_count > len(text) * 0.3 else "中文"
+
+ prompt = f"""请将以下文本翻译成{target}。
+
+翻译要求:
+- 准确传达原意,语句通顺自然,符合{target}的表达习惯
+- 专有名词(人名、地名、术语)首次出现时附注原文
+- 保留原文的语气和风格(正式/口语/文学/技术)
+- 只返回译文,不要解释
+
+原文:
+{text}"""
+
+ try:
+ result, _ = await _ai_complete(prompt, temperature=0.1, max_tokens=4096, task='translate')
+ return {"translation": result.strip()}
+ except Exception as e:
+ return {"translation": f"[Translation Error: {str(e)}]"}
+
+
+@app.post("/api/ai/translate-stream")
+async def translate_text_stream(req: dict):
+ """Translate text with streaming output using unified AI dispatch."""
+ text = req.get("text", "")
+ if not text:
+ return StreamingResponse(iter([""]), media_type="text/plain")
+
+ # Auto-detect: if mostly CJK → translate to English, otherwise → translate to Chinese
+ cjk_count = sum(1 for c in text if '\u4e00' <= c <= '\u9fff' or '\u3040' <= c <= '\u30ff' or '\uac00' <= c <= '\ud7af')
+ target = "英文" if cjk_count > len(text) * 0.3 else "中文"
+
+ # Build context from book metadata
+ context = ""
+ book_id = req.get("book_id")
+ chapter_index = req.get("chapter_index")
+ if book_id:
+ book = load_book_cached(book_id)
+ if book:
+ meta = book.metadata
+ parts = [f"书名《{meta.title}》"]
+ if meta.authors:
+ parts.append(f"作者{', '.join(meta.authors)}")
+ if chapter_index is not None and 0 <= chapter_index < len(book.spine):
+ ch_title = book.spine[chapter_index].title
+ if ch_title:
+ parts.append(f"当前章节「{ch_title}」")
+ context = f"[{', '.join(parts)}] "
+
+ prompt = f"{context}将以下内容完整翻译成{target},所有词汇都必须翻译,不得保留原文(专有名词首次出现时括号附注原文除外),只返回译文:\n{text}"
+
+ gen = await _ai_stream(prompt, temperature=0.1, max_tokens=4096, task='translate')
+ return StreamingResponse(gen, media_type="text/plain; charset=utf-8")
+
+
+@app.post("/api/quick-translate")
+async def quick_translate(req: dict):
+ """Dict lookup (instant) → Google Translate fallback (~100ms). Chinese words get AI definitions."""
+ text = (req.get("text") or "").strip()
+ if not text:
+ return {"translation": ""}
+
+ # Step 1: Try local dictionary for single words/short phrases
+ dict_result = _dict_lookup(text)
+ if dict_result:
+ return {
+ "source": "dict",
+ "word": dict_result['word'],
+ "phonetic": dict_result['phonetic'],
+ "translation": dict_result['translation'],
+ "definition": dict_result['definition'],
+ }
+
+ # Step 2: Chinese text → local Chinese dictionary (457K entries, <1ms)
+ is_cjk = _detect_cjk_ratio(text) > 0.3
+ if is_cjk:
+ cn_result = _cn_dict_lookup(text)
+ if cn_result:
+ return {
+ "source": "cn-dict",
+ "word": cn_result['word'],
+ "pinyin": cn_result['pinyin'],
+ "translation": cn_result['definition'],
+ }
+
+ # Step 3: Chinese text → AI definition (fallback for words not in local dict)
+ if is_cjk and len(text) <= 20:
+ defn = await _chinese_define(text)
+ if defn:
+ return {"source": "ai-dict", "word": text, "translation": defn}
+
+ # Step 4: Fallback to Google Translate
+ dest = 'en' if is_cjk else 'zh-CN'
+ try:
+ translation = await _google_translate(text, dest)
+ return {"source": "google", "translation": translation}
+ except Exception as e:
+ return {"source": "error", "translation": "", "error": str(e)}
+
+
+@app.post("/api/wiki-lookup")
+async def wiki_lookup(req: dict):
+ """Wikipedia summary for a term."""
+ text = (req.get("text") or "").strip()
+ if not text:
+ return {"extract": ""}
+ result = await _wiki_summary(text)
+ return result
+
+
+@app.post("/api/search")
+async def search_book(req: dict):
+ """Full-text search across all chapters of a book."""
+ book_id = (req.get("book_id") or "").strip()
+ query = (req.get("query") or "").strip()
+ if not book_id or not query:
+ return {"results": []}
+ book = load_book_cached(book_id)
+ if not book:
+ return {"results": []}
+ lower_q = query.lower()
+ results = []
+ for ch in book.spine:
+ text = ch.text or ""
+ lower_text = text.lower()
+ pos = 0
+ while (pos := lower_text.find(lower_q, pos)) != -1:
+ start = max(0, pos - 40)
+ end = min(len(text), pos + len(query) + 40)
+ snippet = ("..." if start > 0 else "") + text[start:end] + ("..." if end < len(text) else "")
+ results.append({
+ "chapterIndex": ch.order,
+ "chapterTitle": ch.title,
+ "snippet": snippet,
+ "matchStart": pos,
+ })
+ pos += len(query)
+ if len(results) >= 200:
+ break
+ if len(results) >= 200:
+ break
+ return {"results": results}
+
+
+class AIChatRequest(BaseModel):
+ book_id: str
+ chapter_index: int
+ question: str
+
+
+@app.post("/api/ai/chat")
+async def chat_about_chapter(req: AIChatRequest):
+ """Answer a free-form question about the current chapter."""
+ book = load_book_cached(req.book_id)
+ if not book or req.chapter_index < 0 or req.chapter_index >= len(book.spine):
+ raise HTTPException(status_code=404, detail="Chapter not found")
+ chapter = book.spine[req.chapter_index]
+
+ # Use text field, fallback to stripping HTML from content
+ chapter_text = chapter.text.strip()
+ if not chapter_text and chapter.content:
+ from html.parser import HTMLParser
+ class _Strip(HTMLParser):
+ def __init__(self):
+ super().__init__()
+ self.parts = []
+ def handle_data(self, d):
+ self.parts.append(d)
+ s = _Strip()
+ s.feed(chapter.content)
+ chapter_text = ' '.join(s.parts).strip()
+
+ prompt = f"""你是这本书的深度阅读伙伴。基于章节内容回答问题时:
+- 尽量引用原文中的具体段落或细节来支撑回答
+- 如果问题涉及更广的背景知识,可以拓展,但要标注哪些是原文内容、哪些是补充
+- 用与提问者相同的语言回答
+- 回答要有条理,必要时使用小标题分段
+
+书名:{book.metadata.title}
+作者:{', '.join(book.metadata.authors) if book.metadata.authors else '未知'}
+章节:{chapter.title}
+
+【章节内容】:
+{chapter_text[:12000]}
+
+【读者提问】:
+{req.question}"""
+
+ try:
+ gen = await _ai_stream(prompt, temperature=0.7, max_tokens=4096, task='chat')
+ return StreamingResponse(gen, media_type="text/plain; charset=utf-8")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}")
+
+
+@app.post("/api/ai/chat-context")
+async def chat_with_context(req: dict):
+ """AI chat with arbitrary text context (for PDF reader etc.)."""
+ question = (req.get("question") or "").strip()
+ context = (req.get("context") or "").strip()
+ title = (req.get("title") or "").strip()
+ if not question:
+ raise HTTPException(status_code=400, detail="No question provided")
+
+ parts = []
+ if title:
+ parts.append(f"当前阅读:《{title}》")
+ if context:
+ parts.append(f"【选中文本】:\n{context[:6000]}")
+ parts.append(f"【提问】:{question}")
+
+ prompt = "你是一位知识渊博的阅读助手。用与提问者相同的语言回答。回答要有条理。" \
+ "如果提供了选中文本,请结合该文本来回答。\n\n" + "\n\n".join(parts)
+
+ try:
+ gen = await _ai_stream(prompt, temperature=0.7, max_tokens=4096, task='chat')
+ return StreamingResponse(gen, media_type="text/plain; charset=utf-8")
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Chat failed: {str(e)}")
+
+
+# --- AI Provider Management API ---
+
+@app.get("/api/ai/providers")
+async def get_providers():
+ """Return provider list with status (key masked)."""
+ _load_ai_config()
+ result = []
+ seen = set()
+ def _entry(pid, p):
+ defn = _PROVIDER_DEFS.get(pid, {})
+ if not defn and pid.startswith('custom'):
+ defn = _PROVIDER_DEFS.get('custom', {})
+ key = p.get('api_key', '')
+ name = p.get('custom_name') or defn.get('name', pid)
+ entry = {
+ 'id': pid, 'name': name,
+ 'has_key': bool(key), 'key_preview': key[:3] + '******' + key[-3:] if len(key) > 6 else ('******' if key else ''),
+ 'enabled': p.get('enabled', False),
+ 'model': p.get('model') or defn.get('default_model', ''),
+ 'base_url': p.get('base_url', ''),
+ 'default_model': defn.get('default_model', ''),
+ 'default_base_url': defn.get('base_url', ''),
+ }
+ if p.get('temperature') is not None:
+ entry['temperature'] = p['temperature']
+ if p.get('max_tokens') is not None:
+ entry['max_tokens'] = p['max_tokens']
+ if pid.startswith('custom'):
+ entry['custom_name'] = p.get('custom_name', '')
+ return entry
+ # Build map of builtin keys for filtering migrated entries
+ builtin_keys = {pid: bkey for pid, bkey, _ in _get_builtin_providers()}
+ for pid in _ai_config.get('order', []):
+ if pid in _ai_config.get('providers', {}):
+ p = _ai_config['providers'][pid]
+ # Skip entries whose key matches a builtin .env key (legacy migration artifacts)
+ if pid in builtin_keys and p.get('api_key') == builtin_keys[pid]:
+ continue
+ seen.add(pid)
+ result.append(_entry(pid, p))
+ for pid, p in _ai_config.get('providers', {}).items():
+ if pid not in seen:
+ if pid in builtin_keys and p.get('api_key') == builtin_keys[pid]:
+ continue
+ result.append(_entry(pid, p))
+ return {"providers": result, "available": list(_PROVIDER_DEFS.keys()), "task_routing": _ai_config.get('task_routing', {})}
+
+
+@app.post("/api/ai/providers")
+async def save_providers(req: dict):
+ """Save provider configuration. Empty api_key = keep existing key."""
+ providers = req.get('providers', [])
+ old_providers = _ai_config.get('providers', {})
+ _ai_config['providers'] = {}
+ _ai_config['order'] = []
+ for p in providers:
+ pid = p.get('id', '')
+ if not pid:
+ continue
+ _ai_config['order'].append(pid)
+ new_key = p.get('api_key', '')
+ # If no new key provided, keep the old one
+ if not new_key and pid in old_providers:
+ new_key = old_providers[pid].get('api_key', '')
+ _ai_config['providers'][pid] = {
+ 'api_key': new_key,
+ 'enabled': p.get('enabled', False),
+ 'model': p.get('model', ''),
+ 'base_url': p.get('base_url', ''),
+ }
+ if pid.startswith('custom') and p.get('custom_name'):
+ _ai_config['providers'][pid]['custom_name'] = p['custom_name']
+ if p.get('temperature') is not None:
+ _ai_config['providers'][pid]['temperature'] = p['temperature']
+ if p.get('max_tokens') is not None:
+ _ai_config['providers'][pid]['max_tokens'] = p['max_tokens']
+ # Save task routing if provided
+ if 'task_routing' in req:
+ _ai_config['task_routing'] = req['task_routing']
+ _save_ai_config()
+ _load_ai_config()
+ return {"ok": True}
+
+
+@app.post("/api/ai/test-provider")
+async def test_provider(req: dict):
+ """Test a provider's API key by making a minimal request."""
+ pid = req.get('id', '')
+ api_key = req.get('api_key', '')
+ model_name = req.get('model', '')
+ base_url = req.get('base_url', '')
+ defn = _PROVIDER_DEFS.get(pid, {})
+ if not defn and pid.startswith('custom'):
+ defn = _PROVIDER_DEFS.get('custom', {})
+ fmt = defn.get('format', 'openai')
+ # Fallback to stored key if not provided
+ if not api_key and pid in _ai_config.get('providers', {}):
+ api_key = _ai_config['providers'][pid].get('api_key', '')
+ if not api_key:
+ return {"ok": False, "message": "No API key provided"}
+ if not model_name:
+ model_name = defn.get('default_model', '')
+
+ if fmt == 'gemini':
+ try:
+ client = google_genai.Client(api_key=api_key)
+ resp = await asyncio.to_thread(lambda: client.models.generate_content(model=model_name, contents="Say 'ok'"))
+ return {"ok": True, "message": f"Connected: {model_name}"}
+ except Exception as e:
+ return {"ok": False, "message": str(e)}
+
+ if not base_url:
+ base_url = defn.get('base_url', '')
+ if not base_url:
+ return {"ok": False, "message": "No base URL configured"}
+ def _extract_error(resp):
+ """Extract human-readable error from API response."""
+ try:
+ body = resp.json()
+ # OpenAI / DashScope / most providers: {"error": {"message": "..."}}
+ err = body.get('error') or body.get('errors') or {}
+ if isinstance(err, dict):
+ msg = err.get('message', '')
+ code = err.get('code', '')
+ return f"{code}: {msg}" if code else msg
+ if isinstance(err, str):
+ return err
+ return str(body)[:200]
+ except Exception:
+ return resp.text[:200] if hasattr(resp, 'text') else f"HTTP {resp.status_code}"
+
+ try:
+ if fmt == 'anthropic':
+ async with httpx.AsyncClient(timeout=15, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ resp = await client.post(
+ f"{base_url.rstrip('/')}/messages",
+ headers={"x-api-key": api_key, "anthropic-version": "2023-06-01", "Content-Type": "application/json"},
+ json={"model": model_name, "max_tokens": 10, "messages": [{"role": "user", "content": "Say ok"}]},
+ )
+ if resp.status_code != 200:
+ return {"ok": False, "message": _extract_error(resp)}
+ return {"ok": True, "message": f"Connected: {model_name}"}
+ else:
+ headers = {"Authorization": f"Bearer {api_key}", "Content-Type": "application/json"}
+ body = {"model": model_name, "messages": [{"role": "user", "content": "Say ok"}], "max_tokens": 10}
+ if pid == 'zhipuai':
+ body["thinking"] = {"type": "disabled"}
+ async with httpx.AsyncClient(timeout=15, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ resp = await client.post(f"{base_url.rstrip('/')}/chat/completions", headers=headers, json=body)
+ if resp.status_code != 200:
+ return {"ok": False, "message": _extract_error(resp)}
+ return {"ok": True, "message": f"Connected: {model_name}"}
+ except Exception as e:
+ return {"ok": False, "message": str(e)}
+
+
+@app.get("/api/ai/export-config")
+async def export_config():
+ """Export AI provider config as downloadable JSON file."""
+ _load_ai_config()
+ from fastapi.responses import Response
+ content = json.dumps(_ai_config, indent=2, ensure_ascii=False)
+ return Response(content=content, media_type="application/json",
+ headers={"Content-Disposition": "attachment; filename=ai_config.json"})
+
+
+@app.post("/api/ai/import-config")
+async def import_config(req: dict):
+ """Import AI provider config from uploaded JSON."""
+ if 'providers' not in req or not isinstance(req['providers'], dict):
+ raise HTTPException(status_code=400, detail="Invalid config: missing 'providers' object")
+ global _ai_config
+ _ai_config = req
+ _ai_config.setdefault('order', list(req['providers'].keys()))
+ _ai_config.setdefault('task_routing', {})
+ _save_ai_config()
+ _load_ai_config()
+ return {"ok": True, "count": len(_ai_config['providers'])}
+
+
+@app.post("/api/ai/fetch-models")
+async def fetch_models(req: dict):
+ """Fetch available models from a provider."""
+ pid = req.get('id', '')
+ api_key = req.get('api_key', '')
+ base_url = req.get('base_url', '')
+ defn = _PROVIDER_DEFS.get(pid, {})
+ if not defn and pid.startswith('custom'):
+ defn = _PROVIDER_DEFS.get('custom', {})
+ fmt = defn.get('format', 'openai')
+ # Fallback to stored key if not provided
+ if not api_key and pid in _ai_config.get('providers', {}):
+ api_key = _ai_config['providers'][pid].get('api_key', '')
+ if not api_key:
+ return {"models": [], "error": "No API key provided"}
+
+ try:
+ if fmt == 'gemini':
+ client = google_genai.Client(api_key=api_key)
+ models = await asyncio.to_thread(lambda: [m.name.replace('models/', '') for m in client.models.list() if 'generateContent' in (m.supported_actions or [])])
+ return {"models": models}
+ except Exception as e:
+ return {"models": [], "error": str(e)}
+
+ if not base_url:
+ base_url = defn.get('base_url', '')
+ if not base_url:
+ return {"models": [], "error": "No base URL configured"}
+
+ try:
+ if fmt == 'anthropic':
+ async with httpx.AsyncClient(timeout=15, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ resp = await client.get(f"{base_url.rstrip('/')}/models", headers={"x-api-key": api_key, "anthropic-version": "2023-06-01"})
+ resp.raise_for_status()
+ data = resp.json()
+ models = [m['id'] for m in data.get('data', [])]
+ return {"models": models}
+ else:
+ async with httpx.AsyncClient(timeout=15, trust_env=True, headers={"User-Agent": "Reader3/1.0"}) as client:
+ resp = await client.get(f"{base_url.rstrip('/')}/models", headers={"Authorization": f"Bearer {api_key}"})
+ resp.raise_for_status()
+ data = resp.json()
+ # Handle both {"data": [...]} (OpenAI) and [...] (Together AI) formats
+ items = data.get('data', data) if isinstance(data, dict) else data
+ models = [m['id'] for m in items if isinstance(m, dict) and 'id' in m]
+ return {"models": sorted(models)}
+ except Exception as e:
+ return {"models": [], "error": str(e)}
+
+
+# --- Dictionary Management API ---
+
+@app.get("/api/dict/status")
+async def dict_status():
+ """Return dictionary install status and download URL availability."""
+ dict_url = _ai_config.get('dict_url', '').strip() or _DEFAULT_DICT_URL
+ items = {}
+ for did, info in _DICT_FILES.items():
+ path = os.path.join(_DICT_DIR, info['filename'])
+ exists = os.path.exists(path)
+ items[did] = {
+ 'label': info['label'], 'label_en': info['label_en'],
+ 'installed': exists,
+ 'size_mb': round(os.path.getsize(path) / 1048576) if exists else info['size_mb'],
+ 'gz_mb': info['gz_mb'],
+ }
+ return {'dicts': items, 'has_url': bool(dict_url)}
+
+
+_dict_downloading = set() # prevent concurrent downloads
+
+@app.post("/api/dict/download")
+async def download_dict(req: dict):
+ """Download and decompress a dictionary file. Streams SSE progress."""
+ dict_id = req.get('id', '')
+ info = _DICT_FILES.get(dict_id)
+ if not info:
+ raise HTTPException(status_code=400, detail="Unknown dictionary id")
+ if dict_id in _dict_downloading:
+ raise HTTPException(status_code=409, detail="Already downloading")
+ dict_url = _ai_config.get('dict_url', '').strip() or _DEFAULT_DICT_URL
+ gz_url = f"{dict_url.rstrip('/')}/{info['filename']}.gz"
+ dest = os.path.join(_DICT_DIR, info['filename'])
+ tmp = dest + '.tmp'
+ expected_gz = info['gz_mb'] * 1048576 # fallback total size
+
+ async def stream():
+ _dict_downloading.add(dict_id)
+ try:
+ import urllib.request
+ os.makedirs(_DICT_DIR, exist_ok=True)
+ decomp = zlib.decompressobj(16 + zlib.MAX_WBITS)
+
+ def _download():
+ req = urllib.request.Request(gz_url, headers={'User-Agent': 'Reader3/1.0'})
+ proxy_url = _ai_config.get('proxy', '').strip()
+ if proxy_url:
+ handler = urllib.request.ProxyHandler({
+ 'http': proxy_url, 'https': proxy_url,
+ })
+ opener = urllib.request.build_opener(handler)
+ else:
+ opener = urllib.request.build_opener() # respects env http_proxy
+ return opener.open(req, timeout=300)
+
+ resp = await asyncio.to_thread(_download)
+ total = int(resp.headers.get('Content-Length', 0)) or expected_gz
+ downloaded = 0
+ last_pct = -1
+ with open(tmp, 'wb') as f:
+ while True:
+ chunk = await asyncio.to_thread(resp.read, 65536)
+ if not chunk:
+ break
+ decompressed = decomp.decompress(chunk)
+ f.write(decompressed)
+ downloaded += len(chunk)
+ pct = min(int(downloaded * 100 / total), 99) if total else 0
+ if pct > last_pct:
+ last_pct = pct
+ yield f"data: {json.dumps({'progress': pct})}\n\n"
+ remaining = decomp.flush()
+ if remaining:
+ f.write(remaining)
+ if os.path.exists(tmp):
+ os.replace(tmp, dest)
+ _reload_dict()
+ yield f"data: {json.dumps({'done': True})}\n\n"
+ else:
+ yield f"data: {json.dumps({'error': 'Download failed: temp file missing'})}\n\n"
+ except Exception as e:
+ if os.path.exists(tmp):
+ os.unlink(tmp)
+ yield f"data: {json.dumps({'error': str(e)})}\n\n"
+ finally:
+ _dict_downloading.discard(dict_id)
+
+ return StreamingResponse(stream(), media_type='text/event-stream')
+
+
+# --- TTS MODULE: edge-tts (local, free, low latency) ---
+
+@app.get("/api/tts")
+async def stream_tts(text: str, voice: str = "zh-CN-XiaoxiaoNeural", rate: str = "+0%"):
+ """Stream TTS audio via edge-tts."""
+ communicate = edge_tts.Communicate(text, voice, rate=rate)
+ async def generate():
+ async for chunk in communicate.stream():
+ if chunk["type"] == "audio":
+ yield chunk["data"]
+ return StreamingResponse(generate(), media_type="audio/mpeg")
+
+# --- Apple Books Integration ---
+
+APPLE_BOOKS_DB = os.path.expanduser(
+ "~/Library/Containers/com.apple.iBooksX/Data/Documents/BKLibrary/BKLibrary-1-091020131601.sqlite"
+)
+APPLE_BOOKS_COVER_DIR = os.path.expanduser(
+ "~/Library/Containers/com.apple.iBooksX/Data/Library/Caches/BCCoverCache-1/BICDiskDataStore"
+)
+
+@app.get("/api/apple-books")
+async def list_apple_books():
+ """List books from Apple Books library."""
+ if not os.path.exists(APPLE_BOOKS_DB):
+ return {"books": [], "error": "Apple Books database not found"}
+
+ def _query():
+ conn = sqlite3.connect(APPLE_BOOKS_DB)
+ conn.row_factory = sqlite3.Row
+ rows = conn.execute(
+ "SELECT ZTITLE, ZAUTHOR, ZPATH, ZFILESIZE, ZASSETID FROM ZBKLIBRARYASSET "
+ "WHERE ZTITLE IS NOT NULL AND ZPATH IS NOT NULL AND ZCONTENTTYPE = 1 "
+ "ORDER BY ZTITLE"
+ ).fetchall()
+ conn.close()
+ return [dict(r) for r in rows]
+
+ rows = await asyncio.to_thread(_query)
+
+ # Build a set of imported book titles (extracted from directory names) for fuzzy matching
+ imported_titles = set()
+ if os.path.isdir(BOOKS_DIR):
+ for d in os.listdir(BOOKS_DIR):
+ if d.endswith("_data") and os.path.exists(os.path.join(BOOKS_DIR, d, "book.pkl")):
+ # "失衡的免疫 - 【法】蒙蒂·莱曼_data" → "失衡的免疫"
+ name = d[:-5] # strip "_data"
+ title_part = name.split(" - ")[0].strip()
+ imported_titles.add(title_part.lower())
+
+ def _normalize(s):
+ """Strip punctuation and whitespace for fuzzy title comparison."""
+ return _re.sub(r'[\s\W]+', '', s).lower()
+
+ imported_normalized = {_normalize(t): t for t in imported_titles}
+
+ def _is_imported(r):
+ # 1. Check by epub filename
+ base_name = os.path.splitext(os.path.basename(r['ZPATH']))[0]
+ if os.path.exists(os.path.join(BOOKS_DIR, base_name + "_data", "book.pkl")):
+ return True
+ # 2. Check by Apple Books title + author
+ title_name = _safe_dirname(r['ZTITLE'], [r['ZAUTHOR']] if r['ZAUTHOR'] else None)
+ if os.path.exists(os.path.join(BOOKS_DIR, title_name + "_data", "book.pkl")):
+ return True
+ # 3. Fuzzy: require full normalized equality (prefix match too loose for serials)
+ ab_norm = _normalize(r['ZTITLE'])
+ if ab_norm in imported_normalized:
+ return True
+ return False
+
+ books = []
+ for r in rows:
+ path = r['ZPATH']
+ if not path or not os.path.exists(path):
+ continue
+ books.append({
+ "title": r['ZTITLE'],
+ "author": r['ZAUTHOR'] or '',
+ "path": path,
+ "size_mb": round((r['ZFILESIZE'] or 0) / 1048576, 1),
+ "imported": _is_imported(r),
+ "asset_id": r['ZASSETID'] or '',
+ })
+ return {"books": books}
+
+
+@app.get("/api/apple-books/cover/{asset_id}")
+async def serve_apple_books_cover(asset_id: str):
+ """Serve a cover image from Apple Books cache."""
+ import subprocess
+ safe_id = os.path.basename(asset_id)
+ cover_dir = os.path.join(APPLE_BOOKS_COVER_DIR, safe_id)
+ if not os.path.isdir(cover_dir):
+ raise HTTPException(status_code=404, detail="Cover not found")
+ # Check for cached JPEG first
+ jpeg_path = os.path.join(cover_dir, f"{safe_id}.jpg")
+ if os.path.exists(jpeg_path) and os.path.getsize(jpeg_path) > 0:
+ return FileResponse(jpeg_path, media_type="image/jpeg")
+ # Find largest HEIC file (best quality)
+ import glob as _glob
+ heics = sorted(_glob.glob(os.path.join(cover_dir, "*.heic")), key=os.path.getsize, reverse=True)
+ if not heics:
+ raise HTTPException(status_code=404, detail="No cover image")
+ # Convert HEIC to JPEG synchronously (awaited)
+ result = await asyncio.to_thread(
+ subprocess.run,
+ ["sips", "-s", "format", "jpeg", heics[0], "--out", jpeg_path],
+ capture_output=True, timeout=10
+ )
+ if os.path.exists(jpeg_path) and os.path.getsize(jpeg_path) > 0:
+ return FileResponse(jpeg_path, media_type="image/jpeg")
+ raise HTTPException(status_code=500, detail="Cover conversion failed")
+
+
+@app.post("/api/import-local")
+async def import_local_epub(req: dict):
+ """Import an EPUB or PDF from a local file path (e.g. Apple Books)."""
+ path = req.get("path", "")
+ if not path or not os.path.exists(path):
+ raise HTTPException(status_code=400, detail="File not found")
+ path_lower = path.lower()
+ if not (path_lower.endswith('.epub') or path_lower.endswith('.pdf')):
+ raise HTTPException(status_code=400, detail="Only .epub and .pdf files are supported")
+
+ is_pdf = path_lower.endswith('.pdf')
+ base_name = os.path.splitext(os.path.basename(path))[0]
+ out_dir = os.path.join(BOOKS_DIR, base_name + "_data")
+
+ if is_pdf:
+ meta_info = await asyncio.to_thread(_process_pdf, path, out_dir)
+ title_name = _safe_dirname(meta_info['title'], [meta_info['author']] if meta_info['author'] else None)
+ title_dir = os.path.join(BOOKS_DIR, title_name + "_data")
+ if title_name and title_dir != out_dir and not os.path.exists(title_dir):
+ os.rename(out_dir, title_dir)
+ out_dir = title_dir
+ book_id = os.path.basename(out_dir)
+ return {
+ "success": True,
+ "book_id": book_id,
+ "title": meta_info['title'],
+ "chapters": meta_info['pages'],
+ "has_cover": True,
+ }
+
+ book_obj = await asyncio.to_thread(process_epub, path, out_dir)
+ await asyncio.to_thread(save_to_pickle, book_obj, out_dir)
+
+ # Rename directory to book title if different from filename
+ import shutil
+ title_name = _safe_dirname(book_obj.metadata.title, book_obj.metadata.authors)
+ title_dir = os.path.join(BOOKS_DIR, title_name + "_data")
+ if title_name and title_dir != out_dir and not os.path.exists(title_dir):
+ os.rename(out_dir, title_dir)
+ out_dir = title_dir
+ book_id = os.path.basename(out_dir)
+
+ # Keep a copy of the epub for future reprocessing
+ epub_copy = os.path.join(out_dir, "source.epub")
+ if not os.path.exists(epub_copy):
+ try:
+ shutil.copy2(path, epub_copy)
+ except (OSError, PermissionError):
+ # Apple Books sandbox may block metadata copy; fallback to content-only copy
+ try:
+ shutil.copy(path, epub_copy)
+ except Exception:
+ pass # Non-critical: source.epub is only for reprocessing
+
+ load_book_cached.cache_clear()
+
+ return {
+ "success": True,
+ "book_id": book_id,
+ "title": book_obj.metadata.title,
+ "chapters": len(book_obj.spine),
+ "has_cover": _find_cover_image(book_id) is not None,
+ }
+
+
+@app.post("/api/upload")
+async def upload_epub(file: UploadFile = File(...)):
+ """Upload and process an EPUB or PDF file."""
+ if not file.filename:
+ raise HTTPException(status_code=400, detail="No file provided")
+ fname_lower = file.filename.lower()
+ if not (fname_lower.endswith('.epub') or fname_lower.endswith('.pdf')):
+ raise HTTPException(status_code=400, detail="Only .epub and .pdf files are supported")
+
+ is_pdf = fname_lower.endswith('.pdf')
+ suffix = '.pdf' if is_pdf else '.epub'
+
+ # Save to temp file
+ with tempfile.NamedTemporaryFile(suffix=suffix, delete=False) as tmp:
+ content = await file.read()
+ tmp.write(content)
+ tmp_path = tmp.name
+
+ try:
+ # Determine output dir name
+ base_name = os.path.splitext(file.filename)[0]
+ out_dir = os.path.join(BOOKS_DIR, base_name + "_data")
+
+ if is_pdf:
+ meta_info = await asyncio.to_thread(_process_pdf, tmp_path, out_dir)
+ # Rename directory to title if different from filename
+ title_name = _safe_dirname(meta_info['title'], [meta_info['author']] if meta_info['author'] else None)
+ title_dir = os.path.join(BOOKS_DIR, title_name + "_data")
+ if title_name and title_dir != out_dir and not os.path.exists(title_dir):
+ os.rename(out_dir, title_dir)
+ out_dir = title_dir
+ book_id = os.path.basename(out_dir)
+ return {
+ "success": True,
+ "book_id": book_id,
+ "title": meta_info['title'],
+ "chapters": meta_info['pages'],
+ "has_cover": True,
+ }
+ else:
+ # Process in thread to avoid blocking
+ book_obj = await asyncio.to_thread(process_epub, tmp_path, out_dir)
+ await asyncio.to_thread(save_to_pickle, book_obj, out_dir)
+
+ # Rename directory to book title if different from filename
+ title_name = _safe_dirname(book_obj.metadata.title, book_obj.metadata.authors)
+ title_dir = os.path.join(BOOKS_DIR, title_name + "_data")
+ if title_name and title_dir != out_dir and not os.path.exists(title_dir):
+ os.rename(out_dir, title_dir)
+ out_dir = title_dir
+ book_id = os.path.basename(out_dir)
+
+ # Keep a copy of the epub for future reprocessing
+ import shutil
+ shutil.copy2(tmp_path, os.path.join(out_dir, "source.epub"))
+
+ # Clear LRU cache so new book appears
+ load_book_cached.cache_clear()
+
+ return {
+ "success": True,
+ "book_id": book_id,
+ "title": book_obj.metadata.title,
+ "chapters": len(book_obj.spine),
+ "has_cover": _find_cover_image(book_id) is not None,
+ }
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to process file: {str(e)}")
+ finally:
+ os.unlink(tmp_path)
+
+
+@app.post("/api/reprocess/{book_id}")
+async def reprocess_book(book_id: str):
+ """Reprocess a book from its saved source.epub."""
+ safe_id = os.path.basename(book_id)
+ book_dir = os.path.join(BOOKS_DIR, safe_id)
+ source_epub = os.path.join(book_dir, "source.epub")
+ if not os.path.exists(source_epub):
+ raise HTTPException(status_code=400, detail="No source epub found. Please re-upload the book.")
+
+ # Copy source.epub to a temp file OUTSIDE book_dir,
+ # because process_epub will rmtree the entire book_dir
+ import shutil
+ tmp_fd, tmp_epub = tempfile.mkstemp(suffix='.epub')
+ os.close(tmp_fd)
+ shutil.copy2(source_epub, tmp_epub)
+
+ try:
+ book_obj = await asyncio.to_thread(process_epub, tmp_epub, book_dir)
+ await asyncio.to_thread(save_to_pickle, book_obj, book_dir)
+ # Restore source.epub into the fresh output dir
+ shutil.copy2(tmp_epub, os.path.join(book_dir, "source.epub"))
+ load_book_cached.cache_clear()
+ # Clear AI analysis cache for this book so stale results aren't served
+ stale_keys = [k for k in _analysis_cache if k.startswith(f"{safe_id}:")]
+ for k in stale_keys:
+ del _analysis_cache[k]
+ return {"success": True, "title": book_obj.metadata.title}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Reprocess failed: {str(e)}")
+ finally:
+ if os.path.exists(tmp_epub):
+ os.unlink(tmp_epub)
+
+
+# --- PDF Reader Routes ---
+
+@app.get("/read-pdf/{book_id}", response_class=HTMLResponse)
+async def read_pdf(request: Request, book_id: str):
+ """Render PDF reader page."""
+ safe_id = os.path.basename(book_id)
+ meta_path = os.path.join(BOOKS_DIR, safe_id, "meta.json")
+ if not os.path.exists(meta_path):
+ raise HTTPException(status_code=404, detail="PDF book not found")
+ with open(meta_path, 'r', encoding='utf-8') as f:
+ meta = json.load(f)
+ # Check for display_title in library index
+ index = _build_library_index()
+ display_title = index.get(safe_id, {}).get('display_title')
+ return templates.TemplateResponse("pdf_reader.html", {
+ "request": request,
+ "book_id": safe_id,
+ "title": display_title or meta.get('title', 'Untitled'),
+ "pages": meta.get('pages', 0),
+ "outline_json": json.dumps(meta.get('outline', []), ensure_ascii=False),
+ })
+
+
+@app.get("/api/pdf-file/{book_id}")
+async def serve_pdf_file(book_id: str):
+ """Serve the PDF file for the reader."""
+ safe_id = os.path.basename(book_id)
+ pdf_path = os.path.join(BOOKS_DIR, safe_id, "book.pdf")
+ if not os.path.exists(pdf_path):
+ raise HTTPException(status_code=404, detail="PDF file not found")
+ return FileResponse(pdf_path, media_type="application/pdf")
+
+
+@app.post("/api/pdf-search/{book_id}")
+async def search_pdf(book_id: str, req: dict):
+ """Search text in PDF file using PyMuPDF."""
+ import fitz
+ safe_id = os.path.basename(book_id)
+ pdf_path = os.path.join(BOOKS_DIR, safe_id, "book.pdf")
+ if not os.path.exists(pdf_path):
+ raise HTTPException(status_code=404, detail="PDF file not found")
+
+ query = (req.get("query") or "").strip()
+ if not query:
+ return {"results": []}
+
+ results = []
+ doc = fitz.open(pdf_path)
+ try:
+ for page_num in range(len(doc)):
+ page = doc[page_num]
+ text_instances = page.search_for(query)
+ if text_instances:
+ # Get page text for snippet extraction
+ page_text = page.get_text("text")
+ for rect in text_instances:
+ # Extract snippet around match
+ idx = page_text.lower().find(query.lower())
+ if idx >= 0:
+ start = max(0, idx - 40)
+ end = min(len(page_text), idx + len(query) + 40)
+ snippet = page_text[start:end].replace('\n', ' ').strip()
+ if start > 0:
+ snippet = '...' + snippet
+ if end < len(page_text):
+ snippet = snippet + '...'
+ else:
+ snippet = query
+ results.append({
+ "page": page_num + 1,
+ "snippet": snippet,
+ "rect": [rect.x0, rect.y0, rect.x1, rect.y1],
+ })
+ if len(results) >= 200:
+ break
+ finally:
+ doc.close()
+
+ return {"results": results}
+
+
+@app.post("/api/search-cover/{book_id}")
+async def search_cover_online(book_id: str, req: dict = None):
+ """Search for book cover online using Google Books + Douban."""
+ safe_id = os.path.basename(book_id)
+
+ # Try EPUB first, then PDF meta.json
+ book = load_book_cached(safe_id)
+ meta_path = os.path.join(BOOKS_DIR, safe_id, "meta.json")
+ if not book and not os.path.exists(meta_path):
+ raise HTTPException(status_code=404, detail="Book not found")
+
+ # Allow custom query from user
+ custom_query = (req or {}).get("query", "").strip()
+
+ if custom_query:
+ query = custom_query
+ elif book:
+ title = book.metadata.title
+ authors = ', '.join(book.metadata.authors) if book.metadata.authors else ''
+ query = f"{title} {authors}".strip()
+ else:
+ with open(meta_path, 'r', encoding='utf-8') as f:
+ pdf_meta = json.load(f)
+ query = f"{pdf_meta.get('title', '')} {pdf_meta.get('author', '')}".strip()
+
+ import urllib.parse, urllib.request
+
+ # Detect if query is likely Chinese
+ has_cjk = any('\u4e00' <= ch <= '\u9fff' for ch in query)
+ douban_covers = []
+ google_covers = []
+
+ # --- Douban (better for Chinese books) ---
+ try:
+ dquery = custom_query if custom_query else _re.sub(r'[\\/:*?"<>|]', '', book.metadata.title).strip()
+ durl = f"https://book.douban.com/j/subject_suggest?q={urllib.parse.quote(dquery)}"
+ def _fetch_douban():
+ req = urllib.request.Request(durl, headers={
+ "User-Agent": "Mozilla/5.0",
+ "Referer": "https://book.douban.com/",
+ })
+ with urllib.request.urlopen(req, timeout=8) as resp:
+ return json.loads(resp.read())
+ items = await asyncio.to_thread(_fetch_douban)
+ for item in items[:6]:
+ pic = item.get("pic", "")
+ if pic:
+ img_url = pic.replace("/s/", "/l/")
+ douban_covers.append({
+ "title": item.get("title", ""),
+ "authors": [item["author_name"]] if item.get("author_name") else [],
+ "image_url": img_url,
+ "source": "douban",
+ })
+ except Exception:
+ pass
+
+ # --- Google Books ---
+ try:
+ gurl = f"https://www.googleapis.com/books/v1/volumes?q={urllib.parse.quote(query)}&maxResults=12"
+ def _fetch_google():
+ req = urllib.request.Request(gurl, headers={"User-Agent": "Reader3/1.0"})
+ with urllib.request.urlopen(req, timeout=8) as resp:
+ return json.loads(resp.read())
+ data = await asyncio.to_thread(_fetch_google)
+ for item in data.get("items", []):
+ info = item.get("volumeInfo", {})
+ images = info.get("imageLinks", {})
+ # Prefer highest resolution available
+ img_url = (images.get("extraLarge") or images.get("large") or
+ images.get("medium") or images.get("thumbnail"))
+ if img_url:
+ img_url = img_url.replace("http://", "https://").replace("&edge=curl", "")
+ # Request higher zoom level for better quality
+ img_url = _re.sub(r'zoom=\d', 'zoom=3', img_url)
+ google_covers.append({
+ "title": info.get("title", ""),
+ "authors": info.get("authors", []),
+ "image_url": img_url,
+ })
+ except Exception:
+ pass
+
+ # CJK queries: Douban first; otherwise Google first
+ if has_cjk:
+ covers = douban_covers + google_covers
+ else:
+ covers = google_covers + douban_covers
+
+ return {"covers": covers, "query": query}
+
+
+@app.get("/api/proxy-image")
+async def proxy_image(url: str):
+ """Proxy external images that block direct browser access (e.g. Douban)."""
+ import urllib.request
+ if "doubanio.com" not in url and "douban.com" not in url:
+ raise HTTPException(status_code=400, detail="Only douban images supported")
+ def _fetch():
+ req = urllib.request.Request(url, headers={
+ "User-Agent": "Mozilla/5.0",
+ "Referer": "https://book.douban.com/",
+ })
+ with urllib.request.urlopen(req, timeout=10) as resp:
+ return resp.read(), resp.headers.get("Content-Type", "image/jpeg")
+ try:
+ data, ctype = await asyncio.to_thread(_fetch)
+ return Response(content=data, media_type=ctype)
+ except Exception:
+ raise HTTPException(status_code=502, detail="Failed to fetch image")
+
+
+@app.post("/api/set-cover/{book_id}")
+async def set_cover_from_url(book_id: str, req: dict):
+ """Download an image URL and set it as the book cover."""
+ safe_id = os.path.basename(book_id)
+ images_dir = os.path.join(BOOKS_DIR, safe_id, "images")
+ os.makedirs(images_dir, exist_ok=True)
+
+ image_url = req.get("image_url", "")
+ if not image_url:
+ raise HTTPException(status_code=400, detail="No image URL provided")
+
+ import urllib.request
+ try:
+ def _download():
+ headers = {"User-Agent": "Mozilla/5.0"}
+ # Douban images require Referer header
+ if "doubanio.com" in image_url:
+ headers["Referer"] = "https://book.douban.com/"
+ req_obj = urllib.request.Request(image_url, headers=headers)
+ with urllib.request.urlopen(req_obj, timeout=10) as resp:
+ return resp.read()
+ img_data = await asyncio.to_thread(_download)
+
+ # Auto-trim white borders
+ from PIL import Image, ImageChops
+ import io
+ def _trim_and_save():
+ img = Image.open(io.BytesIO(img_data)).convert("RGB")
+ # Create a white background image, diff to find content area
+ bg = Image.new("RGB", img.size, (255, 255, 255))
+ diff = ImageChops.difference(img, bg)
+ # Tolerance: treat near-white (>240) as white
+ thresh = diff.point(lambda x: 0 if x < 15 else 255)
+ bbox = thresh.getbbox()
+ if bbox:
+ # Only trim if it removes meaningful border (>2% per side)
+ w, h = img.size
+ margin = 0.02
+ if (bbox[0] > w * margin or bbox[1] > h * margin
+ or bbox[2] < w * (1 - margin) or bbox[3] < h * (1 - margin)):
+ img = img.crop(bbox)
+ img.save(cover_path, "JPEG", quality=92)
+
+ cover_path = os.path.join(images_dir, "cover.jpg")
+ await asyncio.to_thread(_trim_and_save)
+
+ # Also write marker
+ marker_path = os.path.join(BOOKS_DIR, safe_id, "cover_image.txt")
+ with open(marker_path, "w") as f:
+ f.write("cover.jpg")
+
+ return {"success": True}
+ except Exception as e:
+ raise HTTPException(status_code=500, detail=f"Failed to download cover: {str(e)}")
+
+
+def auto_import_default_books():
+ """Scan assets/ for specific default EPUBs and import them if not already in library."""
+ default_book = os.path.join("assets", "Meditations by Emperor of Rome Marcus Aurelius.epub")
+ if not os.path.exists(default_book):
+ return
+
+ # Check if already imported (basic folder name check)
+ book_filename = os.path.basename(default_book)
+ book_id = os.path.splitext(book_filename)[0].replace(" ", "_")
+ book_data_path = os.path.join(BOOKS_DIR, book_id)
+
+ if not os.path.exists(book_data_path):
+ print(f"📦 First run detected: Auto-importing '{book_filename}'...")
+ try:
+ # Silence internal prints during auto-import
+ import sys
+ import io
+ old_stdout = sys.stdout
+ sys.stdout = io.StringIO()
+
+ process_epub(default_book, BOOKS_DIR)
+
+ sys.stdout = old_stdout
+ print(f"✅ Successfully imported '{book_filename}'.")
+ except Exception as e:
+ print(f"❌ Failed to auto-import '{book_filename}': {e}")
- return FileResponse(img_path)
if __name__ == "__main__":
import uvicorn
- print("Starting server at http://127.0.0.1:8123")
+ # Perform auto-import before starting server
+ auto_import_default_books()
uvicorn.run(app, host="127.0.0.1", port=8123)
+
diff --git a/templates/library.html b/templates/library.html
index e7d094d3..cf0e8ec3 100644
--- a/templates/library.html
+++ b/templates/library.html
@@ -3,39 +3,1304 @@
- My Library
+
+ Library — Reader 3
-
Library
+
+
+
+
+ 中
+ EN
+
+
+
Library
+
+
+
+
+
+ {% if books %}
+
+ {% endif %}
{% if not books %}
-
No processed books found. Run reader3.py on an epub first.
+
+
📖
+
No books yet
+
Drop an EPUB or PDF here, or import from Apple Books
+
{% endif %}
{% for book in books %}
-
-
{{ book.title }}
-
+
+
{% endfor %}
+
+
+
+
+
+
+
+
Select Cover
+
+
+ 搜索
+
+
+
+ Skip
+ Use This Cover
+
+
+
+
+
+
+
+
+
+
+
+
+
+
确认操作
+
您确定要执行此操作吗?
+
+ 取消
+ 确定
+
+
+
+
+
+
+
+
+
Select books to import
+
+
+
+
+
+