Skip to main content

j_cli/command/chat/
app.rs

1use super::api::{create_openai_client, to_openai_messages};
2use super::model::{
3    AgentConfig, ChatMessage, ChatSession, ModelProvider, load_agent_config, load_chat_session,
4    save_agent_config, save_chat_session,
5};
6use super::theme::Theme;
7use crate::util::log::write_error_log;
8use async_openai::types::chat::CreateChatCompletionRequestArgs;
9use futures::StreamExt;
10use ratatui::text::Line;
11use ratatui::widgets::ListState;
12use std::sync::{Arc, Mutex, mpsc};
13
14// ========== TUI 界面 ==========
15
16/// 后台线程发送给 TUI 的消息类型
17pub enum StreamMsg {
18    /// 收到一个流式文本块
19    Chunk,
20    /// 流式响应完成
21    Done,
22    /// 发生错误
23    Error(String),
24}
25
26/// TUI 应用状态
27pub struct ChatApp {
28    /// Agent 配置
29    pub agent_config: AgentConfig,
30    /// 当前对话会话
31    pub session: ChatSession,
32    /// 输入缓冲区
33    pub input: String,
34    /// 光标位置(字符索引)
35    pub cursor_pos: usize,
36    /// 当前模式
37    pub mode: ChatMode,
38    /// 消息列表滚动偏移
39    pub scroll_offset: u16,
40    /// 是否正在等待 AI 回复
41    pub is_loading: bool,
42    /// 模型选择列表状态
43    pub model_list_state: ListState,
44    /// Toast 通知消息 (内容, 是否错误, 创建时间)
45    pub toast: Option<(String, bool, std::time::Instant)>,
46    /// 用于接收后台流式回复的 channel
47    pub stream_rx: Option<mpsc::Receiver<StreamMsg>>,
48    /// 当前正在流式接收的 AI 回复内容(实时更新)
49    pub streaming_content: Arc<Mutex<String>>,
50    /// 消息渲染行缓存:(消息数, 最后一条消息内容hash, 气泡宽度) → 渲染好的行
51    /// 避免每帧都重新解析 Markdown
52    pub msg_lines_cache: Option<MsgLinesCache>,
53    /// 消息浏览模式中选中的消息索引
54    pub browse_msg_index: usize,
55    /// 浏览模式下当前消息内部的滚动偏移(相对于消息起始行,A/D 控制)
56    pub browse_scroll_offset: u16,
57    /// 流式节流:上次实际渲染流式内容时的长度
58    pub last_rendered_streaming_len: usize,
59    /// 流式节流:上次实际渲染流式内容的时间
60    pub last_stream_render_time: std::time::Instant,
61    /// 配置界面:当前选中的 provider 索引
62    pub config_provider_idx: usize,
63    /// 配置界面:当前选中的字段索引
64    pub config_field_idx: usize,
65    /// 配置界面:是否正在编辑某个字段
66    pub config_editing: bool,
67    /// 配置界面:编辑缓冲区
68    pub config_edit_buf: String,
69    /// 配置界面:编辑光标位置
70    pub config_edit_cursor: usize,
71    /// 流式输出时是否自动滚动到底部(用户手动上滚后关闭,发送新消息或滚到底部时恢复)
72    pub auto_scroll: bool,
73    /// 当前主题
74    pub theme: Theme,
75    /// 归档列表(缓存)
76    pub archives: Vec<super::archive::ChatArchive>,
77    /// 归档列表选中索引
78    pub archive_list_index: usize,
79    /// 归档确认模式的默认名称
80    pub archive_default_name: String,
81    /// 归档确认模式的用户自定义名称
82    pub archive_custom_name: String,
83    /// 归档确认模式是否正在编辑名称
84    pub archive_editing_name: bool,
85    /// 归档确认模式的光标位置
86    pub archive_edit_cursor: usize,
87    /// 还原确认模式:是否需要确认当前会话有消息
88    pub restore_confirm_needed: bool,
89}
90
91/// 消息渲染行缓存
92pub struct MsgLinesCache {
93    /// 会话消息数量
94    pub msg_count: usize,
95    /// 最后一条消息的内容长度(用于检测流式更新)
96    pub last_msg_len: usize,
97    /// 流式内容长度
98    pub streaming_len: usize,
99    /// 是否正在加载
100    pub is_loading: bool,
101    /// 气泡最大宽度(窗口变化时需要重算)
102    pub bubble_max_width: usize,
103    /// 浏览模式选中索引(None 表示非浏览模式)
104    pub browse_index: Option<usize>,
105    /// 缓存的渲染行
106    pub lines: Vec<Line<'static>>,
107    /// 每条消息(按 msg_index)的起始行号(用于浏览模式自动滚动)
108    pub msg_start_lines: Vec<(usize, usize)>, // (msg_index, start_line)
109    /// 按消息粒度缓存:每条历史消息的渲染行(key: 消息索引)
110    pub per_msg_lines: Vec<PerMsgCache>,
111    /// 流式增量渲染缓存:已完成段落的渲染行
112    pub streaming_stable_lines: Vec<Line<'static>>,
113    /// 流式增量渲染缓存:已缓存到 streaming_content 的字节偏移
114    pub streaming_stable_offset: usize,
115}
116
117/// 单条消息的渲染缓存
118pub struct PerMsgCache {
119    /// 消息内容长度(用于检测变化)
120    pub content_len: usize,
121    /// 渲染好的行
122    pub lines: Vec<Line<'static>>,
123    /// 对应的 msg_start_line(此消息在全局行列表中的起始行号,需在拼装时更新)
124    pub msg_index: usize,
125}
126
127/// Toast 通知显示时长(秒)
128pub const TOAST_DURATION_SECS: u64 = 4;
129
130#[derive(PartialEq)]
131pub enum ChatMode {
132    /// 正常对话模式(焦点在输入框)
133    Chat,
134    /// 模型选择模式
135    SelectModel,
136    /// 消息浏览模式(可选中消息并复制)
137    Browse,
138    /// 帮助
139    Help,
140    /// 配置编辑模式
141    Config,
142    /// 归档确认模式(确认归档名称)
143    ArchiveConfirm,
144    /// 归档列表模式(查看和还原归档)
145    ArchiveList,
146}
147
148/// 配置编辑界面的字段列表
149pub const CONFIG_FIELDS: &[&str] = &["name", "api_base", "api_key", "model"];
150/// 全局配置字段
151pub const CONFIG_GLOBAL_FIELDS: &[&str] = &[
152    "system_prompt",
153    "stream_mode",
154    "max_history_messages",
155    "theme",
156];
157/// 所有字段数 = provider 字段 + 全局字段
158pub fn config_total_fields() -> usize {
159    CONFIG_FIELDS.len() + CONFIG_GLOBAL_FIELDS.len()
160}
161
162impl ChatApp {
163    pub fn new() -> Self {
164        let agent_config = load_agent_config();
165        let session = load_chat_session();
166        let mut model_list_state = ListState::default();
167        if !agent_config.providers.is_empty() {
168            model_list_state.select(Some(agent_config.active_index));
169        }
170        let theme = Theme::from_name(&agent_config.theme);
171        Self {
172            agent_config,
173            session,
174            input: String::new(),
175            cursor_pos: 0,
176            mode: ChatMode::Chat,
177            scroll_offset: u16::MAX, // 默认滚动到底部
178            is_loading: false,
179            model_list_state,
180            toast: None,
181            stream_rx: None,
182            streaming_content: Arc::new(Mutex::new(String::new())),
183            msg_lines_cache: None,
184            browse_msg_index: 0,
185            browse_scroll_offset: 0,
186            last_rendered_streaming_len: 0,
187            last_stream_render_time: std::time::Instant::now(),
188            config_provider_idx: 0,
189            config_field_idx: 0,
190            config_editing: false,
191            config_edit_buf: String::new(),
192            config_edit_cursor: 0,
193            auto_scroll: true,
194            theme,
195            archives: Vec::new(),
196            archive_list_index: 0,
197            archive_default_name: String::new(),
198            archive_custom_name: String::new(),
199            archive_editing_name: false,
200            archive_edit_cursor: 0,
201            restore_confirm_needed: false,
202        }
203    }
204
205    /// 切换到下一个主题
206    pub fn switch_theme(&mut self) {
207        self.agent_config.theme = self.agent_config.theme.next();
208        self.theme = Theme::from_name(&self.agent_config.theme);
209        self.msg_lines_cache = None; // 清除缓存以触发重绘
210    }
211
212    /// 显示一条 toast 通知
213    pub fn show_toast(&mut self, msg: impl Into<String>, is_error: bool) {
214        self.toast = Some((msg.into(), is_error, std::time::Instant::now()));
215    }
216
217    /// 清理过期的 toast
218    pub fn tick_toast(&mut self) {
219        if let Some((_, _, created)) = &self.toast {
220            if created.elapsed().as_secs() >= TOAST_DURATION_SECS {
221                self.toast = None;
222            }
223        }
224    }
225
226    /// 获取当前活跃的 provider
227    pub fn active_provider(&self) -> Option<&ModelProvider> {
228        if self.agent_config.providers.is_empty() {
229            return None;
230        }
231        let idx = self
232            .agent_config
233            .active_index
234            .min(self.agent_config.providers.len() - 1);
235        Some(&self.agent_config.providers[idx])
236    }
237
238    /// 获取当前模型名称
239    pub fn active_model_name(&self) -> String {
240        self.active_provider()
241            .map(|p| p.name.clone())
242            .unwrap_or_else(|| "未配置".to_string())
243    }
244
245    /// 构建发送给 API 的消息列表
246    pub fn build_api_messages(&self) -> Vec<ChatMessage> {
247        let mut messages = Vec::new();
248        if let Some(sys) = &self.agent_config.system_prompt {
249            messages.push(ChatMessage {
250                role: "system".to_string(),
251                content: sys.clone(),
252            });
253        }
254
255        // 只取最近的 N 条历史消息,避免 token 消耗过大
256        let max_history = self.agent_config.max_history_messages;
257        let history_messages: Vec<_> = if self.session.messages.len() > max_history {
258            self.session.messages[self.session.messages.len() - max_history..].to_vec()
259        } else {
260            self.session.messages.clone()
261        };
262
263        for msg in history_messages {
264            messages.push(msg);
265        }
266        messages
267    }
268
269    /// 发送消息(非阻塞,启动后台线程流式接收)
270    pub fn send_message(&mut self) {
271        let text = self.input.trim().to_string();
272        if text.is_empty() {
273            return;
274        }
275
276        // 添加用户消息
277        self.session.messages.push(ChatMessage {
278            role: "user".to_string(),
279            content: text,
280        });
281        self.input.clear();
282        self.cursor_pos = 0;
283        // 发送新消息时恢复自动滚动并滚到底部
284        self.auto_scroll = true;
285        self.scroll_offset = u16::MAX;
286
287        // 调用 API
288        let provider = match self.active_provider() {
289            Some(p) => p.clone(),
290            None => {
291                self.show_toast("未配置模型提供方,请先编辑配置文件", true);
292                return;
293            }
294        };
295
296        self.is_loading = true;
297        // 重置流式节流状态和缓存
298        self.last_rendered_streaming_len = 0;
299        self.last_stream_render_time = std::time::Instant::now();
300        self.msg_lines_cache = None;
301
302        let api_messages = self.build_api_messages();
303
304        // 清空流式内容缓冲
305        {
306            let mut sc = self.streaming_content.lock().unwrap();
307            sc.clear();
308        }
309
310        // 创建 channel 用于后台线程 -> TUI 通信
311        let (tx, rx) = mpsc::channel::<StreamMsg>();
312        self.stream_rx = Some(rx);
313
314        let streaming_content = Arc::clone(&self.streaming_content);
315
316        let use_stream = self.agent_config.stream_mode;
317
318        // 启动后台线程执行 API 调用
319        std::thread::spawn(move || {
320            let rt = match tokio::runtime::Runtime::new() {
321                Ok(rt) => rt,
322                Err(e) => {
323                    let _ = tx.send(StreamMsg::Error(format!("创建异步运行时失败: {}", e)));
324                    return;
325                }
326            };
327
328            rt.block_on(async {
329                let client = create_openai_client(&provider);
330                let openai_messages = to_openai_messages(&api_messages);
331
332                let request = match CreateChatCompletionRequestArgs::default()
333                    .model(&provider.model)
334                    .messages(openai_messages)
335                    .build()
336                {
337                    Ok(req) => req,
338                    Err(e) => {
339                        let _ = tx.send(StreamMsg::Error(format!("构建请求失败: {}", e)));
340                        return;
341                    }
342                };
343
344                if use_stream {
345                    // 流式输出模式
346                    let mut stream = match client.chat().create_stream(request).await {
347                        Ok(s) => s,
348                        Err(e) => {
349                            let error_msg = format!("API 请求失败: {}", e);
350                            write_error_log("Chat API 流式请求创建", &error_msg);
351                            let _ = tx.send(StreamMsg::Error(error_msg));
352                            return;
353                        }
354                    };
355
356                    while let Some(result) = stream.next().await {
357                        match result {
358                            Ok(response) => {
359                                for choice in &response.choices {
360                                    if let Some(ref content) = choice.delta.content {
361                                        // 更新共享缓冲
362                                        {
363                                            let mut sc = streaming_content.lock().unwrap();
364                                            sc.push_str(content);
365                                        }
366                                        let _ = tx.send(StreamMsg::Chunk);
367                                    }
368                                }
369                            }
370                            Err(e) => {
371                                let error_str = format!("{}", e);
372                                write_error_log("Chat API 流式响应", &error_str);
373                                let _ = tx.send(StreamMsg::Error(error_str));
374                                return;
375                            }
376                        }
377                    }
378                } else {
379                    // 非流式输出模式:等待完整响应后一次性返回
380                    match client.chat().create(request).await {
381                        Ok(response) => {
382                            if let Some(choice) = response.choices.first() {
383                                if let Some(ref content) = choice.message.content {
384                                    {
385                                        let mut sc = streaming_content.lock().unwrap();
386                                        sc.push_str(content);
387                                    }
388                                    let _ = tx.send(StreamMsg::Chunk);
389                                }
390                            }
391                        }
392                        Err(e) => {
393                            let error_msg = format!("API 请求失败: {}", e);
394                            write_error_log("Chat API 非流式请求", &error_msg);
395                            let _ = tx.send(StreamMsg::Error(error_msg));
396                            return;
397                        }
398                    }
399                }
400
401                let _ = tx.send(StreamMsg::Done);
402
403                let _ = tx.send(StreamMsg::Done);
404            });
405        });
406    }
407
408    /// 处理后台流式消息(在主循环中每帧调用)
409    pub fn poll_stream(&mut self) {
410        if self.stream_rx.is_none() {
411            return;
412        }
413
414        let mut finished = false;
415        let mut had_error = false;
416
417        // 非阻塞地取出所有可用的消息
418        if let Some(ref rx) = self.stream_rx {
419            loop {
420                match rx.try_recv() {
421                    Ok(StreamMsg::Chunk) => {
422                        // 内容已经通过 Arc<Mutex<String>> 更新
423                        // 只有在用户没有手动滚动的情况下才自动滚到底部
424                        if self.auto_scroll {
425                            self.scroll_offset = u16::MAX;
426                        }
427                    }
428                    Ok(StreamMsg::Done) => {
429                        finished = true;
430                        break;
431                    }
432                    Ok(StreamMsg::Error(e)) => {
433                        self.show_toast(format!("请求失败: {}", e), true);
434                        had_error = true;
435                        finished = true;
436                        break;
437                    }
438                    Err(mpsc::TryRecvError::Empty) => break,
439                    Err(mpsc::TryRecvError::Disconnected) => {
440                        finished = true;
441                        break;
442                    }
443                }
444            }
445        }
446
447        if finished {
448            self.stream_rx = None;
449            self.is_loading = false;
450            // 重置流式节流状态
451            self.last_rendered_streaming_len = 0;
452            // 清除缓存,流式结束后需要完整重建(新消息已加入 session)
453            self.msg_lines_cache = None;
454
455            if !had_error {
456                // 将流式内容作为完整回复添加到会话
457                let content = {
458                    let sc = self.streaming_content.lock().unwrap();
459                    sc.clone()
460                };
461                if !content.is_empty() {
462                    self.session.messages.push(ChatMessage {
463                        role: "assistant".to_string(),
464                        content,
465                    });
466                    // 清空流式缓冲
467                    self.streaming_content.lock().unwrap().clear();
468                    self.show_toast("回复完成 ✓", false);
469                }
470                if self.auto_scroll {
471                    self.scroll_offset = u16::MAX;
472                }
473            } else {
474                // 错误时也清空流式缓冲
475                self.streaming_content.lock().unwrap().clear();
476            }
477
478            // 自动保存对话历史
479            let _ = save_chat_session(&self.session);
480        }
481    }
482
483    /// 清空对话
484    pub fn clear_session(&mut self) {
485        self.session.messages.clear();
486        self.scroll_offset = 0;
487        self.msg_lines_cache = None; // 清除缓存
488        let _ = save_chat_session(&self.session);
489        self.show_toast("对话已清空", false);
490    }
491
492    /// 切换模型
493    pub fn switch_model(&mut self) {
494        if let Some(sel) = self.model_list_state.selected() {
495            self.agent_config.active_index = sel;
496            let _ = save_agent_config(&self.agent_config);
497            let name = self.active_model_name();
498            self.show_toast(format!("已切换到: {}", name), false);
499        }
500        self.mode = ChatMode::Chat;
501    }
502
503    /// 向上滚动消息
504    pub fn scroll_up(&mut self) {
505        self.scroll_offset = self.scroll_offset.saturating_sub(3);
506        // 用户手动上滚,关闭自动滚动
507        self.auto_scroll = false;
508    }
509
510    /// 向下滚动消息
511    pub fn scroll_down(&mut self) {
512        self.scroll_offset = self.scroll_offset.saturating_add(3);
513        // 注意:scroll_offset 可能超过 max_scroll,绘制时会校正。
514        // 如果用户滚到了底部(offset >= max_scroll),在绘制时会恢复 auto_scroll。
515    }
516
517    // ========== 归档相关方法 ==========
518
519    /// 开始归档确认流程
520    pub fn start_archive_confirm(&mut self) {
521        use super::archive::generate_default_archive_name;
522        self.archive_default_name = generate_default_archive_name();
523        self.archive_custom_name = String::new();
524        self.archive_editing_name = false;
525        self.archive_edit_cursor = 0;
526        self.mode = ChatMode::ArchiveConfirm;
527    }
528
529    /// 开始还原流程(加载归档列表)
530    pub fn start_archive_list(&mut self) {
531        use super::archive::list_archives;
532        self.archives = list_archives();
533        self.archive_list_index = 0;
534        self.restore_confirm_needed = false;
535        self.mode = ChatMode::ArchiveList;
536    }
537
538    /// 执行归档
539    pub fn do_archive(&mut self, name: &str) {
540        use super::archive::create_archive;
541
542        match create_archive(name, self.session.messages.clone()) {
543            Ok(_) => {
544                // 归档成功后清空当前会话
545                self.clear_session();
546                self.show_toast(format!("对话已归档: {}", name), false);
547            }
548            Err(e) => {
549                self.show_toast(e, true);
550            }
551        }
552        self.mode = ChatMode::Chat;
553    }
554
555    /// 执行还原归档
556    pub fn do_restore(&mut self) {
557        use super::archive::restore_archive;
558
559        if let Some(archive) = self.archives.get(self.archive_list_index) {
560            match restore_archive(&archive.name) {
561                Ok(messages) => {
562                    // 清空当前会话
563                    self.session.messages = messages;
564                    self.scroll_offset = u16::MAX;
565                    self.msg_lines_cache = None;
566                    self.input.clear();
567                    self.cursor_pos = 0;
568                    let _ = save_chat_session(&self.session);
569                    self.show_toast(format!("已还原归档: {}", archive.name), false);
570                }
571                Err(e) => {
572                    self.show_toast(e, true);
573                }
574            }
575        }
576        self.mode = ChatMode::Chat;
577    }
578
579    /// 删除选中的归档
580    pub fn do_delete_archive(&mut self) {
581        use super::archive::delete_archive;
582
583        if let Some(archive) = self.archives.get(self.archive_list_index) {
584            match delete_archive(&archive.name) {
585                Ok(_) => {
586                    self.show_toast(format!("归档已删除: {}", archive.name), false);
587                    // 刷新归档列表
588                    self.archives = super::archive::list_archives();
589                    if self.archive_list_index >= self.archives.len() && self.archive_list_index > 0
590                    {
591                        self.archive_list_index -= 1;
592                    }
593                }
594                Err(e) => {
595                    self.show_toast(e, true);
596                }
597            }
598        }
599    }
600}