577 lines
21 KiB
Rust
577 lines
21 KiB
Rust
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
|
||
}
|