use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal}; use crate::protocol::{MessageAttachment, UploadDescriptor, WsInbound, serialize_inbound}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use futures_util::SinkExt; use tokio::io::AsyncWriteExt; use tokio_tungstenite::tungstenite::Message; use tokio_util::io::ReaderStream; pub async fn handle_key_event(app: &mut App, key: KeyEvent) { if app.show_help { if matches!(key.code, KeyCode::Esc | KeyCode::F(1)) || (key.code == KeyCode::Char('q') && key.modifiers.is_empty()) { app.show_help = false; } return; } if app.modal.is_some() { handle_modal_key(app, key).await; return; } let ctrl = key.modifiers.contains(KeyModifiers::CONTROL); if ctrl && key.code == KeyCode::Char('c') { app.handle_ctrl_c_for_quit(); return; } app.cancel_pending_quit(); if matches!(key.code, KeyCode::F(1)) || (ctrl && key.code == KeyCode::Char('h')) { app.show_help = true; return; } if app.show_command_menu { match key.code { KeyCode::Esc => close_command_menu(app), KeyCode::Up => app.select_previous_command(), KeyCode::Down => app.select_next_command(), KeyCode::Tab => { app.insert_selected_command(); close_command_menu(app); } KeyCode::Enter if key.modifiers.is_empty() => { app.insert_selected_command(); close_command_menu(app); } _ => handle_input_key(app, key).await, } return; } if ctrl { match key.code { KeyCode::Char('n') => { send(app, WsInbound::CreateSession { title: None }).await; app.status_message = Some("正在创建会话…".to_string()); } KeyCode::Char('s') => app.focus = Focus::Sessions, KeyCode::Char('r') => open_rename(app), KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)), KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)), KeyCode::Char('l') => app.modal = Some(Modal::Confirm(ConfirmAction::ClearHistory)), KeyCode::Char('o') => { app.show_archived = !app.show_archived; request_session_list(app).await; } KeyCode::Char('f') => { if app.file_transfer_supported { app.modal = Some(Modal::AttachPath { input: String::new(), cursor: 0, }); } else { app.status_message = Some("当前 Gateway 不支持文件传输".to_string()); } } KeyCode::Char('u') if app.focus == Focus::Input => { app.input.clear(); app.input_cursor_pos = 0; } KeyCode::Char('x') if app.focus == Focus::Input => { app.pending_uploads.clear(); app.status_message = Some("已移除待发送附件".to_string()); } KeyCode::Up => app.scroll_chat_up(3), KeyCode::Down => app.scroll_chat_down(3), _ => {} } return; } match key.code { KeyCode::Tab => { app.focus = match app.focus { Focus::Input => Focus::Sessions, Focus::Sessions => Focus::Input, }; } KeyCode::Esc => app.focus = Focus::Input, KeyCode::PageUp => app.scroll_chat_up(10), KeyCode::PageDown => app.scroll_chat_down(10), KeyCode::F(2) => download_latest_attachment(app).await, KeyCode::Home if app.focus == Focus::Sessions => app.selected_session = 0, KeyCode::End if app.focus == Focus::Sessions => { app.selected_session = app.sessions.len().saturating_sub(1); } _ if app.focus == Focus::Sessions => handle_session_key(app, key).await, _ => handle_input_key(app, key).await, } } pub fn handle_paste(app: &mut App, text: &str) { if let Some(Modal::Rename { input, cursor } | Modal::AttachPath { input, cursor }) = &mut app.modal { let remaining = 256_usize.saturating_sub(input.len()); let mut end = text.len().min(remaining); while !text.is_char_boundary(end) { end -= 1; } input.insert_str(*cursor, &text[..end]); *cursor += end; } else if app.focus == Focus::Input { app.input_insert_str(text); update_command_menu(app); } } async fn handle_session_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Up | KeyCode::Char('k') => app.select_previous_session(), KeyCode::Down | KeyCode::Char('j') => app.select_next_session(), KeyCode::Enter => { if let Some(session_id) = app.selected_session_id() && app.current_session_id.as_deref() != Some(&session_id) { app.status_message = Some("正在载入会话…".to_string()); send(app, WsInbound::LoadSession { session_id }).await; } } KeyCode::Char('n') => { send(app, WsInbound::CreateSession { title: None }).await; } KeyCode::Char('r') => open_rename(app), KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)), KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)), _ => {} } } async fn handle_input_key(app: &mut App, key: KeyEvent) { match key.code { KeyCode::Char(c) => { app.input_insert_char(c); update_command_menu(app); } KeyCode::Backspace => { app.input_delete_char(); update_command_menu(app); } KeyCode::Delete => app.input_delete_forward(), KeyCode::Left => app.input_move_cursor_left(), KeyCode::Right => app.input_move_cursor_right(), KeyCode::Home => app.input_move_cursor_to_start(), KeyCode::End => app.input_move_cursor_to_end(), KeyCode::Up => app.scroll_chat_up(1), KeyCode::Down => app.scroll_chat_down(1), KeyCode::Enter if key.modifiers.contains(KeyModifiers::SHIFT) || key.modifiers.contains(KeyModifiers::ALT) => { app.input_insert_char('\n'); } KeyCode::Enter => { let input = app.take_input(); close_command_menu(app); if !input.trim().is_empty() || !app.pending_uploads.is_empty() { let upload_ids = app .pending_uploads .iter() .map(|upload| upload.upload_id.clone()) .collect::>(); let attachments = app .pending_uploads .iter() .enumerate() .map(|(index, upload)| MessageAttachment { index: u32::try_from(index).unwrap_or(u32::MAX), name: upload.name.clone(), media_type: upload.media_type.clone(), mime_type: upload.mime_type.clone(), }) .collect(); app.add_message_with_attachments( crate::util::short_id(), MessageRole::User, input.clone(), attachments, ); app.pending_responses = app.pending_responses.saturating_add(1); app.status_message = Some("PicoBot 正在处理…".to_string()); let sent = send( app, WsInbound::UserInput { content: input, upload_ids, channel: None, // Session routing is owned by the server. A full session // id is not a chat id and must never be sent here. chat_id: None, sender_id: None, }, ) .await; if !sent { app.pending_responses = app.pending_responses.saturating_sub(1); } else { app.pending_uploads.clear(); } } } _ => {} } } async fn handle_modal_key(app: &mut App, key: KeyEvent) { match app.modal.take() { Some(Modal::Confirm(action)) => match key.code { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => { let target = target_session_id(app); let message = match action { ConfirmAction::Archive => target.map(|session_id| WsInbound::ArchiveSession { session_id: Some(session_id), }), ConfirmAction::Delete => target.map(|session_id| WsInbound::DeleteSession { session_id: Some(session_id), }), ConfirmAction::ClearHistory => { target.map(|session_id| WsInbound::ClearHistory { chat_id: None, session_id: Some(session_id), }) } }; if let Some(message) = message { send(app, message).await; app.status_message = Some("正在更新会话…".to_string()); } } KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {} _ => app.modal = Some(Modal::Confirm(action)), }, Some(Modal::Rename { mut input, mut cursor, }) => match key.code { KeyCode::Esc => {} KeyCode::Enter => { let title = input.trim().to_string(); if !title.is_empty() { send( app, WsInbound::RenameSession { session_id: target_session_id(app), title, }, ) .await; } } KeyCode::Char(character) => { input.insert(cursor, character); cursor += character.len_utf8(); app.modal = Some(Modal::Rename { input, cursor }); } KeyCode::Backspace => { if let Some((index, _)) = input[..cursor].char_indices().next_back() { input.drain(index..cursor); cursor = index; } app.modal = Some(Modal::Rename { input, cursor }); } KeyCode::Delete => { if let Some(character) = input[cursor..].chars().next() { input.drain(cursor..cursor + character.len_utf8()); } app.modal = Some(Modal::Rename { input, cursor }); } KeyCode::Left => { if let Some((index, _)) = input[..cursor].char_indices().next_back() { cursor = index; } app.modal = Some(Modal::Rename { input, cursor }); } KeyCode::Right => { if let Some(character) = input[cursor..].chars().next() { cursor += character.len_utf8(); } app.modal = Some(Modal::Rename { input, cursor }); } _ => app.modal = Some(Modal::Rename { input, cursor }), }, Some(Modal::AttachPath { mut input, mut cursor, }) => match key.code { KeyCode::Esc => {} KeyCode::Enter => { let path = input.trim().to_string(); if !path.is_empty() { upload_tui_file(app, &path).await; } } KeyCode::Char(character) => { input.insert(cursor, character); cursor += character.len_utf8(); app.modal = Some(Modal::AttachPath { input, cursor }); } KeyCode::Backspace => { if let Some((index, _)) = input[..cursor].char_indices().next_back() { input.drain(index..cursor); cursor = index; } app.modal = Some(Modal::AttachPath { input, cursor }); } KeyCode::Delete => { if let Some(character) = input[cursor..].chars().next() { input.drain(cursor..cursor + character.len_utf8()); } app.modal = Some(Modal::AttachPath { input, cursor }); } KeyCode::Left => { if let Some((index, _)) = input[..cursor].char_indices().next_back() { cursor = index; } app.modal = Some(Modal::AttachPath { input, cursor }); } KeyCode::Right => { if let Some(character) = input[cursor..].chars().next() { cursor += character.len_utf8(); } app.modal = Some(Modal::AttachPath { input, cursor }); } _ => app.modal = Some(Modal::AttachPath { input, cursor }), }, None => {} } } async fn upload_tui_file(app: &mut App, raw_path: &str) { let path = std::path::PathBuf::from(raw_path); let metadata = match tokio::fs::metadata(&path).await { Ok(metadata) if metadata.is_file() => metadata, _ => { app.status_message = Some("附件路径不是可读普通文件".to_string()); return; } }; let file_name = path .file_name() .and_then(|value| value.to_str()) .unwrap_or("attachment") .to_string(); let file = match tokio::fs::File::open(&path).await { Ok(file) => file, Err(error) => { app.status_message = Some(format!("无法打开附件:{error}")); return; } }; app.status_message = Some(format!("正在上传 {file_name}…")); let body = reqwest::Body::wrap_stream(ReaderStream::new(file)); let part = reqwest::multipart::Part::stream_with_length(body, metadata.len()) .file_name(file_name.clone()); let form = reqwest::multipart::Form::new().part("file", part); let url = format!("{}/api/chat/{}/uploads", app.http_base_url, app.client_id); let client = reqwest::Client::new(); let mut request = client.post(url).multipart(form); if let Some(token) = &app.auth_token { request = request.bearer_auth(token); } match request.send().await { Ok(response) if response.status().is_success() => { match response.json::().await { Ok(upload) => { app.status_message = Some(format!("已添加附件:{}", upload.name)); app.pending_uploads.push(upload); } Err(error) => app.status_message = Some(format!("上传响应无效:{error}")), } } Ok(response) => { let status = response.status(); let detail = response.text().await.unwrap_or_default(); app.status_message = Some(format!("上传失败 ({status}):{detail}")); } Err(error) => app.status_message = Some(format!("上传失败:{error}")), } } async fn download_latest_attachment(app: &mut App) { let Some(session_id) = app.current_session_id.clone() else { return; }; let Some((message_id, attachment)) = app.messages.iter().rev().find_map(|message| { message .attachments .first() .map(|attachment| (message.id.clone(), attachment.clone())) }) else { app.status_message = Some("当前历史中没有附件".to_string()); return; }; let mut url = match reqwest::Url::parse(&app.http_base_url) { Ok(url) => url, Err(error) => { app.status_message = Some(format!("下载地址无效:{error}")); return; } }; if let Ok(mut segments) = url.path_segments_mut() { segments.extend([ "api", "chat", &app.client_id, "sessions", &session_id, "messages", &message_id, "attachments", &attachment.index.to_string(), ]); } let client = reqwest::Client::new(); let mut request = client.get(url); if let Some(token) = &app.auth_token { request = request.bearer_auth(token); } let response = match request.send().await { Ok(response) if response.status().is_success() => response, Ok(response) => { app.status_message = Some(format!("附件不可用 ({})", response.status())); return; } Err(error) => { app.status_message = Some(format!("下载失败:{error}")); return; } }; let target = unique_download_path(&attachment.name); let temporary = target.with_extension("picobot.part"); let mut output = match tokio::fs::File::create(&temporary).await { Ok(output) => output, Err(error) => { app.status_message = Some(format!("无法创建下载文件:{error}")); return; } }; let mut stream = response.bytes_stream(); while let Some(chunk) = futures_util::StreamExt::next(&mut stream).await { match chunk { Ok(chunk) => { if let Err(error) = output.write_all(&chunk).await { let _ = tokio::fs::remove_file(&temporary).await; app.status_message = Some(format!("写入下载文件失败:{error}")); return; } } Err(error) => { let _ = tokio::fs::remove_file(&temporary).await; app.status_message = Some(format!("下载中断:{error}")); return; } } } drop(output); if let Err(error) = tokio::fs::rename(&temporary, &target).await { let _ = tokio::fs::remove_file(&temporary).await; app.status_message = Some(format!("保存附件失败:{error}")); return; } app.status_message = Some(format!("附件已保存到 {}", target.display())); } fn unique_download_path(name: &str) -> std::path::PathBuf { let safe = std::path::Path::new(name) .file_name() .and_then(|value| value.to_str()) .unwrap_or("attachment"); let mut candidate = std::path::PathBuf::from(safe); let mut counter = 1_u32; while candidate.exists() { candidate = std::path::PathBuf::from(format!("{safe}.{counter}")); counter += 1; } candidate } fn open_rename(app: &mut App) { if target_session_id(app).is_some() { let input = if app.focus == Focus::Sessions { app.sessions .get(app.selected_session) .map(|session| session.title.clone()) .unwrap_or_default() } else { app.current_title().to_string() }; let cursor = input.len(); app.modal = Some(Modal::Rename { input, cursor }); } } fn target_session_id(app: &App) -> Option { if app.focus == Focus::Sessions { app.selected_session_id() } else { app.current_session_id.clone() } } fn update_command_menu(app: &mut App) { app.show_command_menu = app.input.starts_with('/') && !app.input.contains('\n'); app.selected_command_idx = app .selected_command_idx .min(app.get_filtered_commands().len().saturating_sub(1)); } fn close_command_menu(app: &mut App) { app.show_command_menu = false; app.selected_command_idx = 0; } pub async fn request_session_list(app: &mut App) { send( app, WsInbound::ListSessions { include_archived: app.show_archived, }, ) .await; } pub async fn request_history(app: &mut App, session_id: String) { send( app, WsInbound::GetSessionHistory { session_id, limit: Some(1_000), }, ) .await; } pub async fn send(app: &mut App, inbound: WsInbound) -> bool { let serialized = match serialize_inbound(&inbound) { Ok(serialized) => serialized, Err(error) => { app.status_message = Some(format!("请求编码失败:{error}")); return false; } }; let Some(sender) = &mut app.ws_sender else { app.connected = false; app.status_message = Some("Gateway 已断开".to_string()); return false; }; if let Err(error) = sender.send(Message::Text(serialized.into())).await { app.connected = false; app.status_message = Some(format!("发送失败:{error}")); return false; } true }