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