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