PicoBot/src/client/mod.rs
2026-07-15 17:48:14 +08:00

253 lines
9.0 KiB
Rust

pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_outbound};
mod tui;
use crate::client::tui::app::{App, MessageRole};
use crate::client::tui::event::{
handle_key_event, handle_paste, request_history, request_session_list, send,
};
use crate::client::tui::ui::render_ui;
use crossterm::{
event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind},
execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
};
use futures_util::StreamExt;
use ratatui::{Terminal, prelude::CrosstermBackend};
use std::io;
use std::{fs, path::PathBuf};
use tokio_tungstenite::{connect_async, tungstenite::Message};
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let client_id = load_or_create_client_id();
let separator = if gateway_url.contains('?') { '&' } else { '?' };
let connect_url = format!("{gateway_url}{separator}client_id={client_id}");
let (ws_stream, _) = connect_async(&connect_url).await?;
tracing::info!("Connected to gateway");
let (ws_sender, ws_receiver) = ws_stream.split();
let mut app = App::new();
app.ws_sender = Some(ws_sender);
app.ws_receiver = Some(ws_receiver);
enable_raw_mode()?;
let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?;
terminal.clear()?;
let result = run_app(&mut terminal, app).await;
// Cleanup terminal, ignore errors
let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
);
let _ = disable_raw_mode();
let _ = terminal.show_cursor();
result
}
fn load_or_create_client_id() -> String {
let generated = uuid::Uuid::new_v4().simple().to_string();
let Some(home) = dirs::home_dir() else {
return generated;
};
let dir = home.join(".picobot");
let path: PathBuf = dir.join("tui_client_id");
if let Ok(value) = fs::read_to_string(&path) {
let value = value.trim();
if !value.is_empty()
&& value.len() <= 64
&& value
.bytes()
.all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_')
{
return value.to_string();
}
}
if fs::create_dir_all(dir).is_ok() {
let _ = fs::write(path, &generated);
}
generated
}
async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: App,
) -> Result<(), Box<dyn std::error::Error>> {
let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new();
let mut ws_open = true;
send(&mut app, WsInbound::GetSlashCommands).await;
request_session_list(&mut app).await;
loop {
terminal.draw(|f| render_ui(f, &app))?;
tokio::select! {
msg = ws_receiver.next(), if ws_open => {
match msg {
Some(Ok(Message::Text(text))) => {
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
handle_ws_message(&mut app, outbound).await;
}
}
Some(Ok(Message::Close(_))) | None => {
tracing::info!("Gateway disconnected");
app.connected = false;
app.ws_sender = None;
ws_open = false;
app.status_message = Some("Gateway 连接已关闭;按两次 Ctrl+C 退出".to_string());
}
Some(Err(error)) => {
app.connected = false;
app.ws_sender = None;
ws_open = false;
app.status_message = Some(format!("Gateway 连接错误:{error}"));
}
_ => {}
}
}
event_result = event_reader.next() => {
match event_result {
Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => {
handle_key_event(&mut app, key).await;
}
Some(Ok(Event::Paste(text))) => handle_paste(&mut app, &text),
Some(Err(error)) => {
app.status_message = Some(format!("终端输入错误:{error}"));
}
_ => {}
}
}
}
if app.should_quit {
break;
}
}
Ok(())
}
async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound {
WsOutbound::AssistantResponse {
content,
session_id,
..
} => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = None;
if session_id
.as_ref()
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
{
app.add_message(MessageRole::Assistant, content);
} else {
app.status_message = Some("另一个会话已完成响应".to_string());
}
request_session_list(app).await;
}
WsOutbound::Error { message, .. } => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = Some(message.clone());
app.add_message(MessageRole::System, format!("Error: {}", message));
}
WsOutbound::SessionEstablished { session_id } => {
app.connected = true;
app.set_current_session(Some(session_id.clone()));
request_history(app, session_id).await;
request_session_list(app).await;
}
WsOutbound::SessionCreated { session_id, .. } => {
app.set_current_session(Some(session_id.clone()));
app.status_message = None;
request_history(app, session_id).await;
request_session_list(app).await;
}
WsOutbound::SessionList {
sessions,
current_session_id,
} => {
app.set_sessions(sessions);
if let Some(id) = current_session_id {
let changed = app.current_session_id.as_deref() != Some(&id);
app.set_current_session(Some(id.clone()));
if changed {
request_history(app, id).await;
}
}
}
WsOutbound::SessionLoaded { session_id, .. } => {
app.set_current_session(Some(session_id.clone()));
request_history(app, session_id).await;
request_session_list(app).await;
}
WsOutbound::SessionHistory {
session_id,
messages,
} => app.set_history(&session_id, messages),
// The first Todo UI is WebUI-only. CLI keeps receiving ordinary task
// notifications and may inspect plans through /todo.
WsOutbound::SessionPlan { .. } | WsOutbound::PlanUpdated { .. } => {}
WsOutbound::SessionRenamed { session_id, title } => {
if let Some(session) = app
.sessions
.iter_mut()
.find(|session| session.session_id == session_id)
{
session.title = title;
}
request_session_list(app).await;
}
WsOutbound::SessionArchived { session_id } => {
app.sessions
.retain(|session| session.session_id != session_id);
request_session_list(app).await;
}
WsOutbound::SessionDeleted { session_id } => {
if app.current_session_id.as_ref() == Some(&session_id) {
app.set_current_session(None);
}
app.sessions
.retain(|session| session.session_id != session_id);
request_session_list(app).await;
}
WsOutbound::HistoryCleared { session_id } => {
if app.current_session_id.as_deref() == Some(&session_id) {
app.messages.clear();
}
app.status_message = None;
request_session_list(app).await;
}
WsOutbound::SlashCommandsList { commands } => {
app.set_commands(commands);
}
WsOutbound::Pong => {}
WsOutbound::CommandExecuted { message } => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = None;
app.add_message(MessageRole::System, message);
request_session_list(app).await;
}
WsOutbound::SystemNotification {
content,
session_id,
} => {
if session_id
.as_ref()
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
{
app.add_message(MessageRole::System, content);
}
}
}
}