PicoBot/src/client/tui/event.rs

577 lines
21 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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::<Vec<_>>();
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::<UploadDescriptor>().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<String> {
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
}