diff --git a/Cargo.toml b/Cargo.toml index 6d68971..8061fb4 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -57,6 +57,8 @@ tui-textarea = "0.7" # ansi-to-tui = "7.0.0" vt100 = "0.15" codepage-437 = "0.1.0" +reqwest = { version = "0.11", features = ["json"] } +tokio = { version = "1", features = ["full"] } # Vendored ratatui-image dependencies icy_sixel = "0.1.1" base64 = "0.21.2" diff --git a/src/chatgpt_client.rs b/src/chatgpt_client.rs new file mode 100644 index 0000000..4297b98 --- /dev/null +++ b/src/chatgpt_client.rs @@ -0,0 +1,90 @@ +use anyhow::{Result, anyhow}; +use serde::{Deserialize, Serialize}; + +#[derive(Serialize)] +pub struct ChatGPTRequest { + pub model: String, + pub messages: Vec, + pub max_tokens: usize, + pub temperature: f32, +} + +#[derive(Serialize, Deserialize, Debug)] +pub struct Message { + pub role: String, + pub content: String, +} + +#[derive(Deserialize, Debug)] +pub struct ChatGPTResponse { + pub choices: Vec, +} + +#[derive(Deserialize, Debug)] +pub struct Choice { + pub message: Message, +} + +pub struct ChatGPTClient { + api_key: String, + client: reqwest::Client, +} + +impl ChatGPTClient { + pub fn new(api_key: String) -> Result { + if api_key.is_empty() { + return Err(anyhow!( + "ChatGPT API key not found. Please set OPENAI_API_KEY environment variable." + )); + } + + Ok(ChatGPTClient { + api_key, + client: reqwest::Client::new(), + }) + } + + pub async fn summarize(&self, text: &str, language_instruction: &str) -> Result { + let prompt = format!("{}{}", language_instruction, text); + + let request = ChatGPTRequest { + model: "gpt-3.5-turbo".to_string(), + messages: vec![Message { + role: "user".to_string(), + content: prompt, + }], + max_tokens: 500, + temperature: 0.7, + }; + + let response = self + .client + .post("https://api.openai.com/v1/chat/completions") + .header("Authorization", format!("Bearer {}", self.api_key)) + .header("Content-Type", "application/json") + .json(&request) + .send() + .await + .map_err(|e| anyhow!("Failed to send request to ChatGPT: {}", e))?; + + if !response.status().is_success() { + let status = response.status(); + let body = response + .text() + .await + .unwrap_or_else(|_| "Unknown error".to_string()); + return Err(anyhow!("ChatGPT API error ({}): {}", status, body)); + } + + let gpt_response: ChatGPTResponse = response + .json() + .await + .map_err(|e| anyhow!("Failed to parse ChatGPT response: {}", e))?; + + if let Some(choice) = gpt_response.choices.first() { + Ok(choice.message.content.clone()) + } else { + Err(anyhow!("No response from ChatGPT")) + } + } +} diff --git a/src/lib.rs b/src/lib.rs index e5e71f1..9d2deef 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,11 +1,13 @@ // Export modules for use in tests pub mod book_manager; pub mod bookmarks; +pub mod chatgpt_client; pub mod color_mode; pub mod comments; pub use inputs::event_source; pub mod components; pub mod images; +pub mod preferences; // Vendored ratatui-image pub mod vendored; pub use vendored::ratatui_image; diff --git a/src/main_app.rs b/src/main_app.rs index 9a6e440..9c63b11 100644 --- a/src/main_app.rs +++ b/src/main_app.rs @@ -14,18 +14,21 @@ use crate::navigation_panel::{CurrentBookInfo, NavigationPanel, TableOfContents} use crate::notification::NotificationManager; use crate::parsing::text_generator::TextGenerator; use crate::parsing::toc_parser::TocParser; +use crate::preferences::Preferences; use crate::reading_history::ReadingHistory; use crate::search::{SearchMode, SearchablePanel}; use crate::search_engine::SearchEngine; use crate::settings; use crate::system_command::{RealSystemCommandExecutor, SystemCommandExecutor}; use crate::table_of_contents::TocItem; -use crate::theme::{current_theme, current_theme_name}; +use crate::theme::{OCEANIC_NEXT, current_theme, current_theme_name}; use crate::types::LinkInfo; use crate::widget::help_popup::{HelpPopup, HelpPopupAction}; +use crate::widget::language_select_popup::Language; use crate::widget::theme_selector::{ThemeSelector, ThemeSelectorAction}; use image::GenericImageView; use log::warn; +use std::sync::mpsc; #[derive(Debug, Clone, Copy, PartialEq)] pub enum ChapterDirection { @@ -124,6 +127,12 @@ pub struct App { theme_selector: Option, notifications: NotificationManager, help_bar_area: Rect, + preferences: Preferences, + chatgpt_popup: Option, + language_select_popup: Option, + summary_language: Language, + summary_sender: mpsc::Sender>, + summary_receiver: mpsc::Receiver>, zen_mode: bool, comments_dir: Option, } @@ -159,6 +168,8 @@ pub enum PopupWindow { BookSearch, Help, CommentsViewer, + ChatGPT, + LanguageSelect, ThemeSelector, } @@ -337,6 +348,16 @@ impl App { Rect::new(0, 0, 80, 24) }; + let preferences = Preferences::load_or_ephemeral(Some("preferences.json")); + + let (summary_sender, summary_receiver) = mpsc::channel(); + + let summary_language = match preferences.summary_language.as_str() { + "中文" => Language::Chinese, + "Chinese" => Language::Chinese, + _ => Language::English, + }; + let mut app = Self { book_manager, navigation_panel, @@ -362,6 +383,12 @@ impl App { theme_selector: None, notifications: NotificationManager::new(), help_bar_area: Rect::default(), + preferences, + chatgpt_popup: None, + language_select_popup: None, + summary_language, + summary_sender, + summary_receiver, zen_mode: false, comments_dir: comments_dir.map(|p| p.to_path_buf()), }; @@ -1910,6 +1937,40 @@ impl App { } } + if matches!( + self.focused_panel, + FocusedPanel::Popup(PopupWindow::ChatGPT) + ) { + let dim_block = Block::default().style( + Style::default() + .bg(Color::Rgb(10, 10, 10)) + .add_modifier(Modifier::DIM), + ); + f.render_widget(dim_block, f.area()); + + if let Some(ref mut popup) = self.chatgpt_popup { + let mut popup = popup.clone(); + popup.render(f, f.area(), &OCEANIC_NEXT); + } + } + + if matches!( + self.focused_panel, + FocusedPanel::Popup(PopupWindow::LanguageSelect) + ) { + let dim_block = Block::default().style( + Style::default() + .bg(Color::Rgb(10, 10, 10)) + .add_modifier(Modifier::DIM), + ); + f.render_widget(dim_block, f.area()); + + if let Some(ref mut popup) = self.language_select_popup { + let mut popup = popup.clone(); + popup.render(f, f.area(), &OCEANIC_NEXT); + } + } + if matches!( self.focused_panel, FocusedPanel::Popup(PopupWindow::ThemeSelector) @@ -2133,6 +2194,12 @@ impl App { FocusedPanel::Popup(PopupWindow::CommentsViewer) => { "j/k/Ctrl+d/u: Scroll | /: Search | Enter/DblClick: Jump | ESC: Close" } + FocusedPanel::Popup(PopupWindow::ChatGPT) => { + "ESC: Close | Waiting for ChatGPT summary..." + } + FocusedPanel::Popup(PopupWindow::LanguageSelect) => { + "j/k: Navigate | Enter: Select | ESC: Cancel" + } FocusedPanel::Popup(PopupWindow::ThemeSelector) => { "j/k: Navigate | Enter: Apply | ESC: Close" } @@ -2634,7 +2701,46 @@ impl App { return None; } - // If theme selector popup is shown, handle keys for it + if self.focused_panel == FocusedPanel::Popup(PopupWindow::ChatGPT) { + let action = if let Some(ref mut popup) = self.chatgpt_popup { + popup.handle_key(key) + } else { + None + }; + if let Some(crate::widget::chatgpt_popup::ChatGPTPopupAction::Close) = action { + self.focused_panel = FocusedPanel::Main(self.previous_main_panel); + self.chatgpt_popup = None; + } + return None; + } + + if self.focused_panel == FocusedPanel::Popup(PopupWindow::LanguageSelect) { + let action = if let Some(ref mut popup) = self.language_select_popup { + popup.handle_key(key) + } else { + None + }; + if let Some(action) = action { + use crate::widget::language_select_popup::LanguageSelectAction; + match action { + LanguageSelectAction::Selected(lang) => { + self.summary_language = lang; + self.preferences.summary_language = lang.as_str().to_string(); + if let Err(e) = self.preferences.save() { + error!("Failed to save preferences: {e}"); + } + self.focused_panel = FocusedPanel::Main(self.previous_main_panel); + self.language_select_popup = None; + } + LanguageSelectAction::Close => { + self.focused_panel = FocusedPanel::Main(self.previous_main_panel); + self.language_select_popup = None; + } + } + } + return None; + } + if self.focused_panel == FocusedPanel::Popup(PopupWindow::ThemeSelector) { let action = if let Some(ref mut selector) = self.theme_selector { selector.handle_key(key, &mut self.key_sequence) @@ -2879,14 +2985,24 @@ impl App { } KeyCode::Char('c') => { if !self.handle_key_sequence('c') { - if let Err(e) = self.text_reader.copy_selection_to_clipboard() { - error!("Copy failed: {e}"); + if self.text_reader.has_text_selection() { + if let Err(e) = self.text_reader.copy_selection_to_clipboard() { + error!("Copy failed: {e}"); + } + } else if self.current_book.is_some() { + self.open_chatgpt_summarization(); } } } + KeyCode::Char('u') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.open_language_select(); + } KeyCode::Char('t') => { self.handle_key_sequence('t'); } + KeyCode::Char('u') if !key.modifiers.contains(KeyModifiers::CONTROL) => { + self.open_language_select(); + } KeyCode::Char('?') => { self.help_popup = Some(HelpPopup::new()); self.focused_panel = FocusedPanel::Popup(PopupWindow::Help); @@ -3081,6 +3197,80 @@ impl App { } self.text_reader.rebuild_chapter_comments(); } + + fn open_chatgpt_summarization(&mut self) { + if let FocusedPanel::Main(panel) = self.focused_panel { + self.previous_main_panel = panel; + } + + let popup = crate::widget::chatgpt_popup::ChatGPTPopup::new(); + + let api_key = std::env::var("OPENAI_API_KEY").ok(); + + if api_key.is_none() { + let mut error_popup = popup; + error_popup.set_error( + "ChatGPT API key not found. Please set OPENAI_API_KEY environment variable." + .to_string(), + ); + self.chatgpt_popup = Some(error_popup); + self.focused_panel = FocusedPanel::Popup(PopupWindow::ChatGPT); + return; + } + + if let Some(screen_text) = self.text_reader.get_screen_text() { + let chapter_text = screen_text.clone(); + let api_key = api_key.unwrap(); + + self.chatgpt_popup = Some(popup); + self.focused_panel = FocusedPanel::Popup(PopupWindow::ChatGPT); + + let sender = self.summary_sender.clone(); + let language_instruction = self.summary_language.prompt_instruction().to_string(); + + std::thread::spawn(move || { + let rt = match tokio::runtime::Runtime::new() { + Ok(rt) => rt, + Err(e) => { + error!("Failed to create tokio runtime: {e}"); + let _ = sender.send(Err(format!("Failed to create async runtime: {e}"))); + return; + } + }; + + let result = rt.block_on(async { + let client = match crate::chatgpt_client::ChatGPTClient::new(api_key) { + Ok(c) => c, + Err(e) => { + return Err(e); + } + }; + + client.summarize(&chapter_text, &language_instruction).await + }); + + let _ = sender.send(result.map_err(|e| e.to_string())); + }); + } else { + let mut error_popup = popup; + error_popup.set_error("No screen text available to summarize.".to_string()); + self.chatgpt_popup = Some(error_popup); + self.focused_panel = FocusedPanel::Popup(PopupWindow::ChatGPT); + } + } + + fn open_language_select(&mut self) { + if let FocusedPanel::Main(panel) = self.focused_panel { + self.previous_main_panel = panel; + } + + self.language_select_popup = Some( + crate::widget::language_select_popup::LanguageSelectPopup::with_selected( + self.summary_language, + ), + ); + self.focused_panel = FocusedPanel::Popup(PopupWindow::LanguageSelect); + } } pub struct FPSCounter { @@ -3187,6 +3377,20 @@ pub fn run_app_with_event_source( last_tick = std::time::Instant::now(); } + if let Ok(result) = app.summary_receiver.try_recv() { + if let Some(ref mut popup) = app.chatgpt_popup { + match result { + Ok(summary) => { + popup.set_summary(summary); + } + Err(e) => { + popup.set_error(format!("Failed to get ChatGPT summary: {e}")); + } + } + needs_redraw = true; + } + } + if needs_redraw { let draw_start = std::time::Instant::now(); terminal.draw(|f| app.draw(f, &fps_counter))?; diff --git a/src/preferences.rs b/src/preferences.rs new file mode 100644 index 0000000..af04bdf --- /dev/null +++ b/src/preferences.rs @@ -0,0 +1,68 @@ +use serde::{Deserialize, Serialize}; +use std::fs; +use std::path::Path; + +#[derive(Debug, Serialize, Deserialize, Clone)] +pub struct Preferences { + pub summary_language: String, + + #[serde(skip)] + file_path: Option, +} + +impl Preferences { + pub fn ephemeral() -> Self { + Self { + summary_language: "English".to_string(), + file_path: None, + } + } + + pub fn with_file(file_path: &str) -> Self { + Self { + summary_language: "English".to_string(), + file_path: Some(file_path.to_string()), + } + } + + pub fn load_or_ephemeral(file_path: Option<&str>) -> Self { + match file_path { + Some(path) => Self::load_from_file(path).unwrap_or_else(|e| { + log::error!("Failed to load preferences from {path}: {e}"); + Self::with_file(path) + }), + None => Self::ephemeral(), + } + } + + pub fn load_from_file(file_path: &str) -> anyhow::Result { + let path = Path::new(file_path); + if path.exists() { + let content = fs::read_to_string(path)?; + + match serde_json::from_str::(&content) { + Ok(mut prefs) => { + prefs.file_path = Some(file_path.to_string()); + Ok(prefs) + } + Err(e) => { + log::error!("Failed to parse preferences file: {e}"); + Err(anyhow::anyhow!("Failed to parse preferences: {}", e)) + } + } + } else { + Ok(Self::with_file(file_path)) + } + } + + pub fn save(&self) -> anyhow::Result<()> { + match &self.file_path { + Some(path) => { + let content = serde_json::to_string_pretty(self)?; + fs::write(path, content)?; + Ok(()) + } + None => Ok(()), + } + } +} diff --git a/src/widget/chatgpt_popup.rs b/src/widget/chatgpt_popup.rs new file mode 100644 index 0000000..88db24e --- /dev/null +++ b/src/widget/chatgpt_popup.rs @@ -0,0 +1,117 @@ +use ratatui::{ + Frame, + layout::{Alignment, Constraint, Direction, Layout, Rect}, + style::{Color, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, +}; + +use crate::theme::Base16Palette; + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum ChatGPTPopupAction { + Close, +} + +#[derive(Debug, Clone)] +pub enum SummaryState { + Loading, + Success(String), + Error(String), +} + +#[derive(Clone)] +pub struct ChatGPTPopup { + state: SummaryState, + last_popup_area: Option, +} + +impl ChatGPTPopup { + pub fn new() -> Self { + ChatGPTPopup { + state: SummaryState::Loading, + last_popup_area: None, + } + } + + pub fn set_summary(&mut self, summary: String) { + self.state = SummaryState::Success(summary); + } + + pub fn set_error(&mut self, error: String) { + self.state = SummaryState::Error(error); + } + + pub fn render(&mut self, f: &mut Frame, area: Rect, _palette: &Base16Palette) { + let popup_area = self.centered_rect(60, 80, area); + self.last_popup_area = Some(popup_area); + + f.render_widget(Clear, popup_area); + + let (title, content) = match &self.state { + SummaryState::Loading => ( + " ChatGPT Summary - Loading... ", + vec![Line::from(Span::styled( + "Sending to ChatGPT for summarization...", + Style::default().fg(Color::White), + ))], + ), + SummaryState::Success(summary) => ( + " ChatGPT Summary ", + summary + .lines() + .map(|line| Line::from(Span::styled(line, Style::default().fg(Color::White)))) + .collect(), + ), + SummaryState::Error(error) => ( + " ChatGPT Summary - Error ", + vec![Line::from(Span::styled( + format!("Error: {}", error), + Style::default().fg(Color::White), + ))], + ), + }; + + let paragraph = Paragraph::new(content) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::White)) + .style(Style::default().bg(Color::Rgb(64, 64, 64))), + ) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + f.render_widget(paragraph, popup_area); + } + + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Option { + use crossterm::event::KeyCode; + + match key.code { + KeyCode::Esc => Some(ChatGPTPopupAction::Close), + _ => None, + } + } + + fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] + } +} diff --git a/src/widget/language_select_popup.rs b/src/widget/language_select_popup.rs new file mode 100644 index 0000000..68537d7 --- /dev/null +++ b/src/widget/language_select_popup.rs @@ -0,0 +1,187 @@ +use ratatui::{ + Frame, + layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, List, ListItem, ListState}, +}; + +use crate::theme::Base16Palette; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum Language { + English, + Chinese, +} + +impl Language { + pub fn as_str(&self) -> &'static str { + match self { + Language::English => "English", + Language::Chinese => "中文", + } + } + + pub fn prompt_instruction(&self) -> &'static str { + match self { + Language::English => { + "Please summarize the following text in English in a concise manner (around 3-5 bullet points):\n\n" + } + Language::Chinese => "请用中文简洁地总结以下文本(大约3-5个要点):\n\n", + } + } +} + +#[derive(Debug, Clone, Copy, PartialEq)] +pub enum LanguageSelectAction { + Selected(Language), + Close, +} + +#[derive(Clone)] +pub struct LanguageSelectPopup { + items: Vec, + state: ListState, + last_popup_area: Option, +} + +impl LanguageSelectPopup { + pub fn new() -> Self { + let items = vec![Language::English, Language::Chinese]; + let mut state = ListState::default(); + state.select(Some(0)); + + LanguageSelectPopup { + items, + state, + last_popup_area: None, + } + } + + pub fn with_selected(selected: Language) -> Self { + let items = vec![Language::English, Language::Chinese]; + let index = items.iter().position(|&l| l == selected).unwrap_or(0); + let mut state = ListState::default(); + state.select(Some(index)); + + LanguageSelectPopup { + items, + state, + last_popup_area: None, + } + } + + pub fn render(&mut self, f: &mut Frame, area: Rect, _palette: &Base16Palette) { + let popup_area = self.centered_rect(40, 30, area); + self.last_popup_area = Some(popup_area); + + f.render_widget(Clear, popup_area); + + let items: Vec = self + .items + .iter() + .map(|lang| { + ListItem::new(Line::from(Span::styled( + lang.as_str(), + Style::default().fg(Color::White), + ))) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .title(" Select Language ") + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::White)) + .style(Style::default().bg(Color::Rgb(64, 64, 64))), + ) + .highlight_style( + Style::default() + .fg(Color::White) + .bg(Color::Rgb(64, 64, 64)) + .add_modifier(Modifier::BOLD), + ) + .highlight_symbol("» "); + + f.render_stateful_widget(list, popup_area, &mut self.state); + } + + pub fn handle_key(&mut self, key: crossterm::event::KeyEvent) -> Option { + use crossterm::event::KeyCode; + + match key.code { + KeyCode::Char('j') | KeyCode::Down => { + self.next(); + None + } + KeyCode::Char('k') | KeyCode::Up => { + self.previous(); + None + } + KeyCode::Enter => { + if let Some(selected) = self.state.selected() { + Some(LanguageSelectAction::Selected(self.items[selected])) + } else { + None + } + } + KeyCode::Esc => Some(LanguageSelectAction::Close), + _ => None, + } + } + + fn next(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i >= self.items.len() - 1 { + 0 + } else { + i + 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn previous(&mut self) { + let i = match self.state.selected() { + Some(i) => { + if i == 0 { + self.items.len() - 1 + } else { + i - 1 + } + } + None => 0, + }; + self.state.select(Some(i)); + } + + fn centered_rect(&self, percent_x: u16, percent_y: u16, r: Rect) -> Rect { + let popup_layout = Layout::default() + .direction(Direction::Vertical) + .constraints([ + Constraint::Percentage((100 - percent_y) / 2), + Constraint::Percentage(percent_y), + Constraint::Percentage((100 - percent_y) / 2), + ]) + .split(r); + + Layout::default() + .direction(Direction::Horizontal) + .constraints([ + Constraint::Percentage((100 - percent_x) / 2), + Constraint::Percentage(percent_x), + Constraint::Percentage((100 - percent_x) / 2), + ]) + .split(popup_layout[1])[1] + } +} + +impl Default for LanguageSelectPopup { + fn default() -> Self { + Self::new() + } +} diff --git a/src/widget/mod.rs b/src/widget/mod.rs index e8923b1..4b75323 100644 --- a/src/widget/mod.rs +++ b/src/widget/mod.rs @@ -1,7 +1,9 @@ pub mod book_search; pub mod book_stat; +pub mod chatgpt_popup; pub mod comments_viewer; pub mod help_popup; +pub mod language_select_popup; pub mod navigation_panel; pub mod reading_history; pub mod text_reader; diff --git a/src/widget/text_reader/mod.rs b/src/widget/text_reader/mod.rs index c0c6a05..8177d7c 100644 --- a/src/widget/text_reader/mod.rs +++ b/src/widget/text_reader/mod.rs @@ -678,6 +678,26 @@ impl MarkdownTextReader { self.cache_generation += 1; } + pub fn get_screen_text(&self) -> Option { + let visible_start = self.scroll_offset; + let visible_end = + (self.scroll_offset + self.visible_height).min(self.rendered_content.lines.len()); + + if visible_start >= visible_end { + return None; + } + + let mut text = String::new(); + for line in &self.rendered_content.lines[visible_start..visible_end] { + if !text.is_empty() { + text.push('\n'); + } + text.push_str(&line.raw_text); + } + + if text.is_empty() { None } else { Some(text) } + } + pub fn increase_margin(&mut self) { self.content_margin = self.content_margin.saturating_add(1).min(20); self.cache_generation += 1;