diff --git a/Cargo.lock b/Cargo.lock index 21d624a..d704de0 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -701,6 +701,7 @@ dependencies = [ "async-stream", "async-trait", "chrono", + "clap", "clipboard", "cortex-mem-config", "cortex-mem-core", diff --git a/examples/cortex-mem-tars/Cargo.toml b/examples/cortex-mem-tars/Cargo.toml index 19d70e7..1cdc736 100644 --- a/examples/cortex-mem-tars/Cargo.toml +++ b/examples/cortex-mem-tars/Cargo.toml @@ -53,3 +53,6 @@ reqwest = { version = "0.12", features = ["json"] } # Utilities uuid = { version = "1.10", features = ["v4"] } clipboard = "0.5" + +# CLI +clap = { version = "4.5", features = ["derive"] } diff --git a/examples/cortex-mem-tars/src/app.rs b/examples/cortex-mem-tars/src/app.rs index 1046ca8..f0c5b82 100644 --- a/examples/cortex-mem-tars/src/app.rs +++ b/examples/cortex-mem-tars/src/app.rs @@ -248,6 +248,16 @@ impl App { self.show_help(); } } + crate::ui::KeyAction::ShowThemes => { + log::info!("收到 ShowThemes 动作,当前状态: {:?}", self.ui.state); + if self.ui.state == AppState::Chat { + log::info!("调用 show_themes()"); + self.show_themes(); + log::info!("show_themes() 调用完成,theme_modal_visible: {}", self.ui.theme_modal_visible); + } else { + log::warn!("不在 Chat 状态,无法显示主题"); + } + } crate::ui::KeyAction::DumpChats => { if self.ui.state == AppState::Chat { self.dump_chats(); @@ -322,6 +332,9 @@ impl App { crate::ui::KeyAction::ShowHelp => { self.show_help(); } + crate::ui::KeyAction::ShowThemes => { + self.show_themes(); + } crate::ui::KeyAction::DumpChats => { self.dump_chats(); } @@ -462,9 +475,15 @@ impl App { /// 显示帮助信息 fn show_help(&mut self) { log::info!("显示帮助信息"); - let help_message = ChatMessage::assistant(AppUi::get_help_message()); - self.ui.messages.push(help_message); - self.ui.auto_scroll = true; + self.ui.help_modal_visible = true; + self.ui.help_scroll_offset = 0; + } + + /// 显示主题选择 + fn show_themes(&mut self) { + log::info!("显示主题选择"); + self.ui.theme_modal_visible = true; + log::info!("主题弹窗可见性已设置为: {}", self.ui.theme_modal_visible); } /// 导出会话到剪贴板 diff --git a/examples/cortex-mem-tars/src/main.rs b/examples/cortex-mem-tars/src/main.rs index b42b4c8..2dd46f4 100644 --- a/examples/cortex-mem-tars/src/main.rs +++ b/examples/cortex-mem-tars/src/main.rs @@ -7,18 +7,29 @@ mod ui; use anyhow::{Context, Result}; use app::{create_default_bots, App}; +use clap::Parser; use config::ConfigManager; use infrastructure::Infrastructure; use logger::init_logger; use std::sync::Arc; +#[derive(Parser, Debug)] +#[command(name = "cortex-mem-tars")] +#[command(about = "TARS, An Interactive Demonstration Program Based on Cortex Memory")] +#[command(author = "Sopaco")] +#[command(version)] +struct Args { + /// 启用增强记忆保存功能,退出时自动保存对话到记忆系统 + #[arg(long, action)] + enhance_memory_saver: bool, +} + #[tokio::main] async fn main() -> Result<()> { // 解析命令行参数 - let args: Vec = std::env::args().collect(); - let enhance_memory_saver = args.contains(&"--enhance-memory-saver".to_string()); + let args = Args::parse(); - if enhance_memory_saver { + if args.enhance_memory_saver { log::info!("已启用增强记忆保存功能"); } @@ -63,7 +74,7 @@ async fn main() -> Result<()> { app.run().await.context("应用运行失败")?; // 退出时保存对话到记忆系统(仅在启用增强记忆保存功能时) - if enhance_memory_saver { + if args.enhance_memory_saver { if let Some(_inf) = infrastructure { println!("\n╔══════════════════════════════════════════════════════════════════════════════╗"); println!("║ 🧠 Cortex Memory - 退出流程 ║"); diff --git a/examples/cortex-mem-tars/src/ui.rs b/examples/cortex-mem-tars/src/ui.rs index ef2d02f..6ff13ee 100644 --- a/examples/cortex-mem-tars/src/ui.rs +++ b/examples/cortex-mem-tars/src/ui.rs @@ -37,6 +37,75 @@ pub enum ChatState { Selection, } +/// 主题定义 +#[derive(Debug, Clone, Copy)] +pub struct Theme { + pub name: &'static str, + pub primary_color: Color, + pub secondary_color: Color, + pub accent_color: Color, + pub background_color: Color, + pub text_color: Color, + pub border_color: Color, +} + +/// 预设主题 +impl Theme { + pub const DEFAULT: Theme = Theme { + name: "默认", + primary_color: Color::Cyan, + secondary_color: Color::Blue, + accent_color: Color::Green, + background_color: Color::Rgb(20, 30, 40), + text_color: Color::White, + border_color: Color::Cyan, + }; + + pub const DARK: Theme = Theme { + name: "暗黑", + primary_color: Color::Gray, + secondary_color: Color::DarkGray, + accent_color: Color::LightCyan, + background_color: Color::Rgb(10, 10, 15), + text_color: Color::Rgb(220, 220, 220), + border_color: Color::Gray, + }; + + pub const FOREST: Theme = Theme { + name: "森林", + primary_color: Color::Green, + secondary_color: Color::Rgb(0, 100, 0), + accent_color: Color::LightGreen, + background_color: Color::Rgb(20, 40, 20), + text_color: Color::Rgb(200, 255, 200), + border_color: Color::Green, + }; + + pub const OCEAN: Theme = Theme { + name: "海洋", + primary_color: Color::Blue, + secondary_color: Color::Rgb(0, 0, 100), + accent_color: Color::LightBlue, + background_color: Color::Rgb(20, 30, 50), + text_color: Color::Rgb(200, 220, 255), + border_color: Color::Blue, + }; + + pub const SUNSET: Theme = Theme { + name: "日落", + primary_color: Color::Rgb(255, 165, 0), + secondary_color: Color::Rgb(200, 100, 0), + accent_color: Color::Rgb(255, 200, 100), + background_color: Color::Rgb(40, 20, 10), + text_color: Color::Rgb(255, 240, 200), + border_color: Color::Rgb(255, 165, 0), + }; + + pub fn all() -> &'static [Theme; 5] { + &[Self::DEFAULT, Self::DARK, Self::FOREST, Self::OCEAN, Self::SUNSET] + } +} + /// 应用 UI 状态 pub struct AppUi { pub state: AppState, @@ -62,6 +131,14 @@ pub struct AppUi { pub cursor_position: (usize, usize), // 当前光标位置 (line_index, char_index) // 消息显示区域位置 pub messages_area: Option, + // 帮助弹窗相关字段 + pub help_modal_visible: bool, + pub help_content: Vec>, + pub help_scroll_offset: usize, + // 主题相关字段 + pub current_theme: Theme, + pub theme_modal_visible: bool, + pub theme_list_state: ListState, } /// 键盘事件处理结果 @@ -72,6 +149,7 @@ pub enum KeyAction { SendMessage, // 发送消息 ClearChat, // 清空会话 ShowHelp, // 显示帮助 + ShowThemes, // 显示主题选择 DumpChats, // 导出会话到剪贴板 } @@ -83,9 +161,15 @@ impl AppUi { let mut input_textarea = TextArea::default(); let _ = input_textarea.set_block(Block::default() .borders(Borders::ALL) + .border_style(Style::default().fg(Theme::DEFAULT.border_color)) .title("输入消息或命令 (Enter 发送, 输入 /help 查看命令)")); let _ = input_textarea.set_cursor_line_style(Style::default()); + let help_content = Self::parse_help_content(); + + let mut theme_list_state = ListState::default(); + theme_list_state.select(Some(0)); + Self { state: AppState::BotSelection, service_status: ServiceStatus::Initing, @@ -106,6 +190,12 @@ impl AppUi { selection_end: None, cursor_position: (0, 0), messages_area: None, + help_modal_visible: false, + help_content, + help_scroll_offset: 0, + current_theme: Theme::DEFAULT, + theme_modal_visible: false, + theme_list_state, } } @@ -198,6 +288,16 @@ impl AppUi { fn handle_chat_key(&mut self, key: KeyEvent) -> KeyAction { use ratatui::crossterm::event::{KeyCode, KeyModifiers}; + // 如果帮助弹窗打开,只处理弹窗相关的按键 + if self.help_modal_visible { + return self.handle_help_modal_key(key); + } + + // 如果主题弹窗打开,只处理主题弹窗相关的按键 + if self.theme_modal_visible { + return self.handle_theme_modal_key(key); + } + if self.log_panel_visible { log::debug!("日志面板打开,处理日志面板键盘事件"); if self.handle_log_panel_key(key) { @@ -219,7 +319,6 @@ impl AppUi { } } else { // Shift+Enter 换行 - log::debug!("Shift+Enter: 换行"); self.input_textarea.input(key); KeyAction::Continue } @@ -229,12 +328,10 @@ impl AppUi { KeyAction::Quit } KeyCode::Char('l') if key.modifiers.contains(KeyModifiers::CONTROL) => { - log::debug!("Ctrl-L: 切换日志面板"); self.log_panel_visible = !self.log_panel_visible; KeyAction::Continue } KeyCode::Esc => { - log::debug!("关闭日志面板"); self.log_panel_visible = false; // 清除选择 self.selection_active = false; @@ -421,6 +518,98 @@ impl AppUi { } } + /// 处理帮助弹窗的键盘事件 + fn handle_help_modal_key(&mut self, key: KeyEvent) -> KeyAction { + use ratatui::crossterm::event::KeyCode; + match key.code { + KeyCode::Esc => { + log::debug!("关闭帮助弹窗"); + self.help_modal_visible = false; + KeyAction::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + if self.help_scroll_offset > 0 { + self.help_scroll_offset -= 1; + } + KeyAction::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + let visible_lines = 20; // 弹窗可见行数 + if self.help_scroll_offset < self.help_content.len().saturating_sub(visible_lines) { + self.help_scroll_offset += 1; + } + KeyAction::Continue + } + KeyCode::PageUp => { + self.help_scroll_offset = self.help_scroll_offset.saturating_sub(10); + KeyAction::Continue + } + KeyCode::PageDown => { + let visible_lines = 20; // 弹窗可见行数 + self.help_scroll_offset = self.help_scroll_offset + .saturating_add(10) + .min(self.help_content.len().saturating_sub(visible_lines)); + KeyAction::Continue + } + KeyCode::Home => { + self.help_scroll_offset = 0; + KeyAction::Continue + } + KeyCode::End => { + let visible_lines = 20; // 弹窗可见行数 + self.help_scroll_offset = self.help_content.len().saturating_sub(visible_lines); + KeyAction::Continue + } + _ => KeyAction::Continue, + } + } + + /// 处理主题弹窗的键盘事件 + fn handle_theme_modal_key(&mut self, key: KeyEvent) -> KeyAction { + use ratatui::crossterm::event::KeyCode; + match key.code { + KeyCode::Esc => { + log::debug!("关闭主题弹窗"); + self.theme_modal_visible = false; + KeyAction::Continue + } + KeyCode::Up | KeyCode::Char('k') => { + if let Some(selected) = self.theme_list_state.selected() { + if selected > 0 { + self.theme_list_state.select(Some(selected - 1)); + } + } + KeyAction::Continue + } + KeyCode::Down | KeyCode::Char('j') => { + if let Some(selected) = self.theme_list_state.selected() { + if selected < Theme::all().len().saturating_sub(1) { + self.theme_list_state.select(Some(selected + 1)); + } + } + KeyAction::Continue + } + KeyCode::Enter => { + if let Some(index) = self.theme_list_state.selected() { + if let Some(theme) = Theme::all().get(index) { + self.current_theme = *theme; + log::info!("切换主题: {}", theme.name); + + // 更新输入框样式 + let _ = self.input_textarea.set_block(Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.current_theme.border_color)) + .title("输入消息或命令 (Enter 发送, 输入 /help 查看命令)")); + + self.theme_modal_visible = false; + } + } + KeyAction::Continue + } + _ => KeyAction::Continue, + } + } + /// 复制选中的内容到剪贴板 fn copy_selection(&mut self) { if let (Some(start), Some(end)) = (self.selection_start, self.selection_end) { @@ -525,6 +714,29 @@ impl AppUi { return true; } + // 如果帮助弹窗打开,处理帮助弹窗的滚轮事件 + if self.help_modal_visible { + // 动态计算弹窗高度和可见行数(与 render_help_modal 保持一致) + let modal_height = _area.height.saturating_sub(10).min(25); + let visible_lines = modal_height.saturating_sub(4) as usize; + let max_scroll = self.help_content.len().saturating_sub(visible_lines); + + match event.kind { + MouseEventKind::ScrollUp => { + if self.help_scroll_offset > 0 { + self.help_scroll_offset = self.help_scroll_offset.saturating_sub(3); + } + } + MouseEventKind::ScrollDown => { + if self.help_scroll_offset < max_scroll { + self.help_scroll_offset = self.help_scroll_offset.saturating_add(3).min(max_scroll); + } + } + _ => {} + } + return true; + } + // 使用保存的消息区域 let messages_area = match self.messages_area { Some(area) => area, @@ -690,6 +902,16 @@ impl AppUi { } else { self.render_chat_normal(frame, area); } + + // 如果帮助弹窗可见,渲染弹窗 + if self.help_modal_visible { + self.render_help_modal(frame); + } + + // 如果主题弹窗可见,渲染弹窗 + if self.theme_modal_visible { + self.render_theme_modal(frame); + } } /// 渲染普通聊天界面 @@ -709,7 +931,7 @@ impl AppUi { Span::styled( "Cortex TARS AI Program", Style::default() - .fg(Color::Cyan) + .fg(self.current_theme.primary_color) .add_modifier(Modifier::BOLD), ), ]); @@ -720,7 +942,7 @@ impl AppUi { .borders(Borders::ALL) .border_style( Style::default() - .fg(Color::Cyan) + .fg(self.current_theme.primary_color) .add_modifier(Modifier::BOLD) ) .border_type(ratatui::widgets::BorderType::Double) @@ -742,8 +964,8 @@ impl AppUi { .alignment(Alignment::Center) .style( Style::default() - .fg(Color::White) - .bg(Color::Rgb(20, 30, 40)) + .fg(self.current_theme.text_color) + .bg(self.current_theme.background_color) ); frame.render_widget(title, chunks[0]); @@ -774,7 +996,7 @@ impl AppUi { Span::styled( "Cortex TARS AI Program", Style::default() - .fg(Color::Cyan) + .fg(self.current_theme.primary_color) .add_modifier(Modifier::BOLD), ), ]); @@ -785,7 +1007,7 @@ impl AppUi { .borders(Borders::ALL) .border_style( Style::default() - .fg(Color::Cyan) + .fg(self.current_theme.primary_color) .add_modifier(Modifier::BOLD) ) .border_type(ratatui::widgets::BorderType::Double) @@ -807,8 +1029,8 @@ impl AppUi { .alignment(Alignment::Center) .style( Style::default() - .fg(Color::White) - .bg(Color::Rgb(20, 30, 40)) + .fg(self.current_theme.text_color) + .bg(self.current_theme.background_color) ); frame.render_widget(title, chunks[0]); @@ -842,8 +1064,8 @@ impl AppUi { let role_color = match message.role { crate::agent::MessageRole::System => Color::Yellow, - crate::agent::MessageRole::User => Color::Green, - crate::agent::MessageRole::Assistant => Color::Cyan, + crate::agent::MessageRole::User => self.current_theme.accent_color, + crate::agent::MessageRole::Assistant => self.current_theme.primary_color, }; // 格式化时间戳 @@ -999,9 +1221,6 @@ impl AppUi { let selected: String = chars[safe_start_col..safe_end_col].iter().collect(); let after: String = chars[safe_end_col..].iter().collect(); - log::debug!("单行高亮: original_idx={}, before_len={}, selected_len={}, after_len={}", - original_idx, before.len(), selected.len(), after.len()); - Line::from(vec![ Span::raw(before), Span::styled(selected, highlight_style), @@ -1112,6 +1331,137 @@ impl AppUi { ); } + /// 渲染帮助弹窗 + fn render_help_modal(&mut self, frame: &mut Frame) { + // 计算弹窗大小(居中显示) + let area = frame.area(); + let modal_width = area.width.saturating_sub(20).min(80); + let modal_height = area.height.saturating_sub(10).min(25); + + let x = (area.width - modal_width) / 2; + let y = (area.height - modal_height) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + // 创建半透明背景遮罩(使用深灰色) + let overlay_area = area; + let overlay_block = Block::default() + .style(Style::default().bg(Color::Rgb(20, 20, 20))); + + frame.render_widget(overlay_block, overlay_area); + + // 渲染弹窗内容 + let visible_lines = modal_height.saturating_sub(4) as usize; // 减去边框和标题 + let max_scroll = self.help_content.len().saturating_sub(visible_lines); + self.help_scroll_offset = self.help_scroll_offset.min(max_scroll); + + let display_lines: Vec = self + .help_content + .iter() + .skip(self.help_scroll_offset) + .take(visible_lines) + .cloned() + .collect(); + + let paragraph = Paragraph::new(display_lines) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.current_theme.border_color)) + .border_type(ratatui::widgets::BorderType::Double) + .title_style(Style::default().fg(self.current_theme.primary_color).add_modifier(Modifier::BOLD)) + .title(" 帮助信息 (Esc 关闭) ") + .style(Style::default().bg(self.current_theme.background_color)) + ) + .wrap(Wrap { trim: false }) + .alignment(Alignment::Left); + + frame.render_widget(paragraph, modal_area); + + // 渲染滚动条 + if self.help_content.len() > visible_lines { + let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight) + .begin_symbol(Some("↑")) + .end_symbol(Some("↓")); + + let mut scrollbar_state = ScrollbarState::new(self.help_content.len()) + .position(self.help_scroll_offset); + + let scrollbar_area = modal_area.inner(Margin { + vertical: 1, + horizontal: 0, + }); + + frame.render_stateful_widget( + scrollbar, + scrollbar_area, + &mut scrollbar_state, + ); + } + } + + /// 渲染主题选择弹窗 + fn render_theme_modal(&mut self, frame: &mut Frame) { + // 计算弹窗大小(居中显示) + let area = frame.area(); + let modal_width = 50; + let modal_height = 15; + + let x = (area.width - modal_width) / 2; + let y = (area.height - modal_height) / 2; + + let modal_area = Rect::new(x, y, modal_width, modal_height); + + // 创建半透明背景遮罩(使用深灰色) + let overlay_area = area; + let overlay_block = Block::default() + .style(Style::default().bg(Color::Rgb(20, 20, 20))); + + frame.render_widget(overlay_block, overlay_area); + + // 创建主题列表项 + let items: Vec = Theme::all() + .iter() + .map(|theme| { + ListItem::new(Line::from(vec![ + Span::styled( + theme.name, + Style::default() + .fg(theme.primary_color) + .add_modifier(Modifier::BOLD), + ), + Span::raw(" - "), + Span::styled( + "●", + Style::default().fg(theme.accent_color), + ), + ])) + }) + .collect(); + + let list = List::new(items) + .block( + Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(self.current_theme.primary_color)) + .border_type(ratatui::widgets::BorderType::Double) + .title_style( + Style::default() + .fg(self.current_theme.primary_color) + .add_modifier(Modifier::BOLD) + ) + .title(" 选择主题 (Esc 关闭, Enter 确认) ") + .style(Style::default().bg(self.current_theme.background_color)) + ) + .highlight_style( + Style::default() + .bg(self.current_theme.secondary_color) + .add_modifier(Modifier::REVERSED) + ); + + frame.render_stateful_widget(list, modal_area, &mut self.theme_list_state); + } + /// Get input text, filtering out auto-wrap newlines /// Heuristic: next line starts with whitespace = user newline (Shift+Enter) /// next line starts without whitespace = auto-wrap continuation @@ -1153,6 +1503,7 @@ impl AppUi { self.input_textarea = TextArea::default(); let _ = self.input_textarea.set_block(Block::default() .borders(Borders::ALL) + .border_style(Style::default().fg(self.current_theme.border_color)) .title("输入消息或命令 (Enter 发送, 输入 /help 查看命令)")); let _ = self.input_textarea.set_cursor_line_style(Style::default()); } @@ -1182,6 +1533,10 @@ impl AppUi { log::info!("执行命令: /help"); Some(KeyAction::ShowHelp) } + "/themes" => { + log::info!("执行命令: /themes"); + Some(KeyAction::ShowThemes) + } "/dump-chats" => { log::info!("执行命令: /dump-chats"); Some(KeyAction::DumpChats) @@ -1193,9 +1548,40 @@ impl AppUi { } } - /// 获取帮助信息 - pub fn get_help_message() -> String { - "# Cortex TARS AI Program - 帮助信息\n\n欢迎使用TARS演示程序,我是由Cortex Memory技术驱动的人工智能程序,作为你的第二大脑,我能够作为你的外脑与你的记忆深度链接。\n\n## 可用命令\n\n| 命令 | 说明 |\n|------|------|\n| `/quit` | 退出程序 |\n| `/cls` 或 `/clear` | 清空会话区域 |\n| `/help` | 显示此帮助信息 |\n| `/dump-chats` | 复制会话区域的所有内容到剪贴板 |\n\n## 快捷键\n\n- **Enter**: 发送消息\n- **Shift+Enter**: 换行\n- **Ctrl+L**: 打开/关闭日志面板\n- **Esc**: 关闭日志面板\n\n---\n\n*Powered by TARS AI*".to_string() + /// 解析帮助内容为 Line 列表 + fn parse_help_content() -> Vec> { + let help_text = "# Cortex TARS AI Program - 帮助信息 + +欢迎使用TARS演示程序,我是由Cortex Memory技术驱动的人工智能程序,作为你的第二大脑,我能够作为你的外脑与你的记忆深度链接。 + +## 可用命令 + + - /quit 退出程序 + - /cls /clear 清空会话区域 + - /help 显示此帮助信息 + - /themes 切换主题 + - /dump-chats 复制会话区域的所有内容到剪贴板 + +## 快捷键 + + - Enter 发送消息 + - Shift+Enter 换行 + - Ctrl+L 打开/关闭日志面板 + - Esc 关闭弹窗 + +--- + +Powered by Cortex Memory"; + + // 使用 tui-markdown 渲染帮助文本 + let markdown_text = from_str(help_text); + + // 转换为 Line 列表 + markdown_text.lines.into_iter().map(|line| { + Line::from(line.spans.iter().map(|s| { + Span::raw(s.content.clone()) + }).collect::>()) + }).collect() } /// 导出所有会话内容到剪贴板