diff --git a/AGENTS.md b/AGENTS.md index 9b809bb..8f92cbd 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -13,6 +13,7 @@ This file is the operational contract for coding agents working in this reposito - Config load order: `~/.picobot/config.json` then fallback to `./config.json` (`Config::load_default` in `src/config/mod.rs`) - `.env` (cwd) is loaded with a custom parser, not via dotenv crate; env var placeholders `` in config JSON are substituted - Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run) +- CLI TUI identity is stored in `~/.picobot/tui_client_id`; it is a non-secret stable chat scope used to restore dialogs across reconnects ## Tests @@ -61,7 +62,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message | `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` | | `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` | | `observability` | Observer pattern for agent/tool telemetry events | `Observer` trait, `ObserverEvent`, `MultiObserver` | -| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary` | +| `protocol` | WebSocket protocol message types | `WsInbound`, `WsOutbound`, `SessionSummary`, `HistoryMessage` | | `config` | Config loading, env substitution, path resolution | `Config`, `LLMProviderConfig` | | `logging` | Tracing initialization with file rotation | `init_logging()`, `init_logging_console_only()` | | `task_supervisor` | Owns, cancels, and boundedly joins gateway background tasks | `TaskSupervisor` | diff --git a/Cargo.toml b/Cargo.toml index bffb424..14196b0 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -33,6 +33,7 @@ ratatui = "0.30" crossterm = { version = "0.29", features = ["event-stream"] } termimad = "0.34" textwrap = "0.16" +unicode-width = "0.2" chrono = "0.4" sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] } jieba-rs = "0.9" diff --git a/README.md b/README.md index 6d39a92..aef5511 100644 --- a/README.md +++ b/README.md @@ -91,6 +91,21 @@ cargo run -- chat CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`。 +TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。 + +常用快捷键: + +| 快捷键 | 操作 | +|--------|------| +| `F1` / `Ctrl+H` | 打开帮助 | +| `Tab` / `Ctrl+S` | 切换焦点 / 聚焦会话列表 | +| `Ctrl+N` | 新建会话 | +| `Ctrl+R` / `Ctrl+A` / `Ctrl+D` | 重命名 / 归档 / 删除所选会话 | +| `Ctrl+L` / `Ctrl+O` | 清空历史 / 显示归档会话 | +| `Enter` / `Shift+Enter` | 发送 / 换行 | +| `PageUp` / `PageDown` | 滚动对话历史 | +| 连按两次 `Ctrl+C` | 退出客户端 | + ## 运行时数据流 用户消息进入 PicoBot 后,会被转换为统一的 inbound message,经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文、调用 AgentLoop;AgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。 @@ -242,13 +257,14 @@ Inbound 消息类型: | `create_session` | 可选 `title` | | `list_sessions` | `include_archived` | | `load_session` | `session_id` | +| `get_session_history` | `session_id`,可选 `limit`(服务端限制为 1–2000) | | `rename_session` | 可选 `session_id`,`title` | | `archive_session` | 可选 `session_id` | | `delete_session` | 可选 `session_id` | | `get_slash_commands` | 无 | | `ping` | 无 | -Outbound 消息类型包括 `assistant_response`、`error`、`session_established`、`session_created`、`session_list`、`session_loaded`、`session_renamed`、`session_archived`、`session_deleted`、`history_cleared`、`slash_commands_list`、`pong`、`command_executed` 和 `system_notification`。 +Outbound 消息类型包括 `assistant_response`、`error`、`session_established`、`session_created`、`session_list`、`session_loaded`、`session_history`、`session_renamed`、`session_archived`、`session_deleted`、`history_cleared`、`slash_commands_list`、`pong`、`command_executed` 和 `system_notification`。其中异步 `assistant_response` / `system_notification` 可携带 `session_id`,客户端应避免把迟到结果显示到其他 dialog。 ## 测试 diff --git a/docs/ARCHITECTURE.md b/docs/ARCHITECTURE.md index 1076650..9487fd2 100644 --- a/docs/ARCHITECTURE.md +++ b/docs/ARCHITECTURE.md @@ -23,6 +23,8 @@ PicoBot 只有一个二进制,提供两种模式: | Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 | | CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 | +CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。 + Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。 ## 3. 组件关系 @@ -118,6 +120,10 @@ sequenceDiagram WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。 +TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope,再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 1–2000 条,TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。 + +Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session,`cli_chat` 将其映射为 WebSocket `session_id`。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。 + ## 5. 会话模型与并发不变量 Session ID 格式为: diff --git a/src/channels/cli_chat.rs b/src/channels/cli_chat.rs index b55fcda..46c5c44 100644 --- a/src/channels/cli_chat.rs +++ b/src/channels/cli_chat.rs @@ -4,7 +4,7 @@ use std::sync::Arc; use tokio::sync::{Mutex, mpsc}; use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage}; -use crate::protocol::{SlashCommandInfo, WsInbound, WsOutbound, parse_inbound}; +use crate::protocol::{HistoryMessage, SlashCommandInfo, WsInbound, WsOutbound, parse_inbound}; use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId}; use super::base::{Channel, ChannelError}; @@ -52,11 +52,12 @@ impl CliChatChannel { pub(crate) async fn register_client( &self, sender: mpsc::Sender, + requested_chat_id: Option, ) -> (String, Arc) { // Each WebSocket connection gets a stable chat scope. All user input and // dialog controls for this client stay inside that scope unless the // protocol explicitly carries a full session id. - let chat_id = crate::util::short_id(); + let chat_id = requested_chat_id.unwrap_or_else(crate::util::short_id); let client = Arc::new(Client { sender, @@ -68,11 +69,12 @@ impl CliChatChannel { .await .insert(chat_id.clone(), client.clone()); - // Create initial session via control message - let session_id = match self.create_session_via_control(&chat_id, None).await { - Ok((id, _title)) => id, + // Resume the current/most-recent dialog for a stable TUI identity. Only + // create a dialog when this client has never connected before. + let session_id = match self.resume_session_via_control(&chat_id).await { + Ok(id) => id, Err(e) => { - tracing::error!(error = %e, "Failed to create initial session"); + tracing::error!(error = %e, "Failed to resume initial session"); UnifiedSessionId::new("cli_chat", &chat_id, crate::util::short_id()).to_string() } }; @@ -86,8 +88,14 @@ impl CliChatChannel { (session_id, client) } - pub(crate) async fn unregister_client(&self, chat_id: &str) { - self.clients.lock().await.remove(chat_id); + pub(crate) async fn unregister_client(&self, client: &Arc) { + let mut clients = self.clients.lock().await; + if clients + .get(client.chat_id()) + .is_some_and(|registered| Arc::ptr_eq(registered, client)) + { + clients.remove(client.chat_id()); + } } /// Handle an inbound message from a client @@ -157,10 +165,13 @@ impl CliChatChannel { } => { let (reply_tx, mut reply_rx) = mpsc::channel(1); let session_id = if let Some(session_id) = session_id { - UnifiedSessionId::parse(&session_id).ok_or_else(|| { - ChannelError::Other("Invalid session ID format".to_string()) - })? + Self::parse_client_session(&client, &session_id)? } else if let Some(chat_id) = chat_id { + if chat_id != client.chat_id { + return Err(ChannelError::Other( + "Chat does not belong to this client".to_string(), + )); + } let (current_tx, mut current_rx) = mpsc::channel(1); bus.publish_control(ControlMessage { op: SessionCommand::GetCurrentDialog { @@ -191,9 +202,7 @@ impl CliChatChannel { let target = current_session_guard .clone() .ok_or_else(|| ChannelError::Other("No active session".to_string()))?; - UnifiedSessionId::parse(&target).ok_or_else(|| { - ChannelError::Other("Invalid session ID format".to_string()) - })? + Self::parse_client_session(&client, &target)? }; let target = session_id.to_string(); bus.publish_control(ControlMessage { @@ -338,14 +347,56 @@ impl CliChatChannel { } } } + WsInbound::GetSessionHistory { session_id, limit } => { + let unified_id = Self::parse_client_session(&client, &session_id)?; + let (reply_tx, mut reply_rx) = mpsc::channel(1); + bus.publish_control(ControlMessage { + op: SessionCommand::GetDialogHistory { + session_id: unified_id, + limit: limit.unwrap_or(1_000).clamp(1, 2_000), + }, + reply_tx, + }) + .await?; + + match reply_rx.recv().await { + Some(Ok(SessionEvent::DialogHistory { + session_id, + messages, + })) => { + let messages = messages + .into_iter() + .filter(|message| !message.content.is_empty()) + .map(|message| HistoryMessage { + id: message.id, + seq: message.seq, + role: message.role, + content: message.content, + created_at: message.created_at, + }) + .collect(); + let _ = client + .sender + .send(WsOutbound::SessionHistory { + session_id: session_id.to_string(), + messages, + }) + .await; + } + Some(Ok(_)) => {} + Some(Err(e)) => return Err(e), + None => { + return Err(ChannelError::Other("Control channel closed".to_string())); + } + } + } WsInbound::RenameSession { session_id, title } => { let target = session_id .or(current_session_guard.clone()) .ok_or_else(|| ChannelError::Other("No active session".to_string()))?; let (reply_tx, mut reply_rx) = mpsc::channel(1); - let unified_id = UnifiedSessionId::parse(&target) - .ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?; + let unified_id = Self::parse_client_session(&client, &target)?; bus.publish_control(ControlMessage { op: SessionCommand::RenameDialog { session_id: unified_id, @@ -383,8 +434,7 @@ impl CliChatChannel { let was_current = current_session_guard.as_deref() == Some(&target); let (reply_tx, mut reply_rx) = mpsc::channel(1); - let unified_id = UnifiedSessionId::parse(&target) - .ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?; + let unified_id = Self::parse_client_session(&client, &target)?; bus.publish_control(ControlMessage { op: SessionCommand::ArchiveDialog { session_id: unified_id, @@ -432,8 +482,7 @@ impl CliChatChannel { .ok_or_else(|| ChannelError::Other("No active session".to_string()))?; let (reply_tx, mut reply_rx) = mpsc::channel(1); - let unified_id = UnifiedSessionId::parse(&target) - .ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?; + let unified_id = Self::parse_client_session(&client, &target)?; bus.publish_control(ControlMessage { op: SessionCommand::DeleteDialog { session_id: unified_id, @@ -569,6 +618,78 @@ impl CliChatChannel { None => Err(ChannelError::Other("Control channel closed".to_string())), } } + + async fn resume_session_via_control(&self, chat_id: &str) -> Result { + let bus = { + let guard = self.bus.lock().unwrap(); + guard + .clone() + .ok_or_else(|| ChannelError::Other("Channel not started".to_string()))? + }; + + let (reply_tx, mut reply_rx) = mpsc::channel(1); + bus.publish_control(ControlMessage { + op: SessionCommand::GetCurrentDialog { + channel: "cli_chat".to_string(), + chat_id: chat_id.to_string(), + }, + reply_tx, + }) + .await?; + if let Some(Ok(SessionEvent::CurrentDialog { + session_id: Some(session_id), + })) = reply_rx.recv().await + { + return Ok(session_id.to_string()); + } + + let (reply_tx, mut reply_rx) = mpsc::channel(1); + bus.publish_control(ControlMessage { + op: SessionCommand::ListDialogs { + channel: "cli_chat".to_string(), + chat_id: chat_id.to_string(), + include_archived: false, + }, + reply_tx, + }) + .await?; + if let Some(Ok(SessionEvent::DialogList { dialogs, .. })) = reply_rx.recv().await + && let Some(dialog) = dialogs.first() + { + let session_id = dialog.session_id.clone(); + let (reply_tx, mut reply_rx) = mpsc::channel(1); + bus.publish_control(ControlMessage { + op: SessionCommand::SwitchDialog { + channel: session_id.channel.clone(), + chat_id: session_id.chat_id.clone(), + dialog_id: session_id.dialog_id.clone(), + }, + reply_tx, + }) + .await?; + if let Some(Ok(SessionEvent::DialogSwitched { session_id })) = reply_rx.recv().await { + return Ok(session_id.to_string()); + } + } + + self.create_session_via_control(chat_id, None) + .await + .map(|(session_id, _)| session_id) + } + + fn parse_client_session( + client: &Client, + session_id: &str, + ) -> Result { + let unified_id = UnifiedSessionId::parse(session_id) + .ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?; + if unified_id.channel != "cli_chat" || unified_id.chat_id != client.chat_id { + return Err(ChannelError::Other( + "Session does not belong to this client".to_string(), + )); + } + Ok(unified_id) + } } #[async_trait] @@ -598,15 +719,23 @@ impl Channel for CliChatChannel { tracing::debug!(chat_id = %msg.chat_id, "No active CLI client for outbound message"); return Ok(()); }; - let outbound = if msg.metadata.get("_type").map(|v| v.as_str()) == Some("notification") { + let message_type = msg.metadata.get("_type").map(String::as_str); + let session_id = msg.metadata.get("_session_id").cloned(); + let outbound = if message_type == Some("notification") { WsOutbound::SystemNotification { content: msg.content, + session_id, + } + } else if message_type == Some("command") { + WsOutbound::CommandExecuted { + message: msg.content, } } else { WsOutbound::AssistantResponse { id: crate::util::short_id(), content: msg.content, role: "assistant".to_string(), + session_id, } }; if client.sender.send(outbound).await.is_err() { @@ -657,4 +786,35 @@ mod tests { assert!(channel.clients.lock().await.is_empty()); } + + #[tokio::test] + async fn stale_connection_cannot_unregister_replacement() { + let channel = CliChatChannel::new(); + let (old_sender, _old_receiver) = mpsc::channel(1); + let (new_sender, _new_receiver) = mpsc::channel(1); + let old = Arc::new(Client { + sender: old_sender, + chat_id: "stable-client".to_string(), + current_session_id: Mutex::new(None), + }); + let replacement = Arc::new(Client { + sender: new_sender, + chat_id: "stable-client".to_string(), + current_session_id: Mutex::new(None), + }); + channel + .clients + .lock() + .await + .insert("stable-client".to_string(), replacement.clone()); + + channel.unregister_client(&old).await; + + let registered = channel.clients.lock().await; + assert!( + registered + .get("stable-client") + .is_some_and(|client| Arc::ptr_eq(client, &replacement)) + ); + } } diff --git a/src/client/mod.rs b/src/client/mod.rs index 685b791..ffef1cb 100644 --- a/src/client/mod.rs +++ b/src/client/mod.rs @@ -3,21 +3,27 @@ pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_ou mod tui; use crate::client::tui::app::{App, MessageRole}; -use crate::client::tui::event::handle_key_event; +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, Event}, + event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind}, execute, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, }; -use futures_util::{SinkExt, StreamExt}; +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> { - let (ws_stream, _) = connect_async(gateway_url).await?; - tracing::info!(url = %gateway_url, "Connected to gateway"); + 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(); @@ -27,7 +33,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { enable_raw_mode()?; let mut stdout = io::stdout(); - execute!(stdout, EnterAlternateScreen)?; + execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?; let backend = CrosstermBackend::new(stdout); let mut terminal = Terminal::new(backend)?; terminal.clear()?; @@ -35,33 +41,57 @@ pub async fn run(gateway_url: &str) -> Result<(), Box> { let result = run_app(&mut terminal, app).await; // Cleanup terminal, ignore errors - let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); + 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>, mut app: App, ) -> Result<(), Box> { let mut ws_receiver = app.ws_receiver.take().unwrap(); let mut event_reader = event::EventStream::new(); + let mut ws_open = true; - // Request command list on startup - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::GetSlashCommands; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender.send(Message::Text(text.into())).await; - } - } + 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() => { + msg = ws_receiver.next(), if ws_open => { match msg { Some(Ok(Message::Text(text))) => { if let Ok(outbound) = serde_json::from_str::(&text) { @@ -70,14 +100,30 @@ async fn run_app( } Some(Ok(Message::Close(_))) | None => { tracing::info!("Gateway disconnected"); - app.quit(); + 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() => { - if let Some(Ok(Event::Key(key))) = event_result { - handle_key_event(&mut app, key).await; + 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}")); + } + _ => {} } } } @@ -92,17 +138,39 @@ async fn run_app( async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { match outbound { - WsOutbound::AssistantResponse { content, .. } => { - app.add_message(MessageRole::Assistant, content); + 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.set_current_session(Some(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)); + 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, @@ -110,31 +178,72 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) { } => { app.set_sessions(sessions); if let Some(id) = current_session_id { - app.set_current_session(Some(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)); + 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), + 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::SessionRenamed { .. } => {} - WsOutbound::SessionArchived { .. } => {} 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 { .. } => { - app.messages.clear(); + 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 } => { - app.add_message(MessageRole::System, content); + 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); + } } } } diff --git a/src/client/tui/app.rs b/src/client/tui/app.rs index 7999e81..051d343 100644 --- a/src/client/tui/app.rs +++ b/src/client/tui/app.rs @@ -1,8 +1,11 @@ -use crate::protocol::{SessionSummary, SlashCommandInfo}; +use crate::protocol::{HistoryMessage, SessionSummary, SlashCommandInfo}; use std::collections::VecDeque; use tokio_tungstenite::tungstenite::Message; -#[derive(Debug, Clone)] +const MAX_MESSAGES: usize = 2_000; +const MAX_INPUT_BYTES: usize = 16 * 1024; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] pub enum MessageRole { User, Assistant, @@ -15,6 +18,25 @@ pub struct ChatMessage { pub content: String, } +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Focus { + Input, + Sessions, +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ConfirmAction { + Archive, + Delete, + ClearHistory, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum Modal { + Rename { input: String, cursor: usize }, + Confirm(ConfirmAction), +} + pub struct App { pub ws_sender: Option< futures_util::stream::SplitSink< @@ -31,26 +53,26 @@ pub struct App { >, >, >, - pub current_session_id: Option, pub sessions: Vec, - + pub selected_session: usize, + pub show_archived: bool, pub messages: VecDeque, - pub input: String, + /// UTF-8 byte offset. It is always maintained at a character boundary. pub input_cursor_pos: usize, + pub focus: Focus, + pub modal: Option, pub show_help: bool, - pub chat_scroll_offset: u16, + pub chat_scroll_from_bottom: u16, pub should_quit: bool, - - // Quit confirmation state (double Ctrl+C to exit) - pub ctrl_c_count: u8, pub pending_quit: bool, - - // Command menu state + pub connected: bool, + pub pending_responses: usize, + pub status_message: Option, pub commands: Vec, pub show_command_menu: bool, - pub selected_command_idx: u16, + pub selected_command_idx: usize, } impl App { @@ -60,14 +82,20 @@ impl App { ws_receiver: None, current_session_id: None, sessions: Vec::new(), + selected_session: 0, + show_archived: false, messages: VecDeque::new(), input: String::new(), input_cursor_pos: 0, + focus: Focus::Input, + modal: None, show_help: false, - chat_scroll_offset: 0, + chat_scroll_from_bottom: 0, should_quit: false, - ctrl_c_count: 0, pending_quit: false, + connected: true, + pending_responses: 0, + status_message: Some("正在加载会话…".to_string()), commands: Vec::new(), show_command_menu: false, selected_command_idx: 0, @@ -76,54 +104,161 @@ impl App { pub fn add_message(&mut self, role: MessageRole, content: String) { self.messages.push_back(ChatMessage { role, content }); - self.chat_scroll_offset = 0; + while self.messages.len() > MAX_MESSAGES { + self.messages.pop_front(); + } + self.chat_scroll_from_bottom = 0; + } + + pub fn set_history(&mut self, session_id: &str, messages: Vec) { + if self.current_session_id.as_deref() != Some(session_id) { + return; + } + self.messages = messages + .into_iter() + .filter_map(|message| { + let role = match message.role.as_str() { + "user" => MessageRole::User, + "assistant" => MessageRole::Assistant, + "system" | "tool" => MessageRole::System, + _ => return None, + }; + Some(ChatMessage { + role, + content: message.content, + }) + }) + .collect(); + while self.messages.len() > MAX_MESSAGES { + self.messages.pop_front(); + } + self.chat_scroll_from_bottom = 0; + self.status_message = None; } pub fn set_sessions(&mut self, sessions: Vec) { self.sessions = sessions; + if let Some(current) = &self.current_session_id + && let Some(index) = self + .sessions + .iter() + .position(|session| &session.session_id == current) + { + self.selected_session = index; + } + self.clamp_session_selection(); } pub fn set_current_session(&mut self, session_id: Option) { - self.current_session_id = session_id; - self.messages.clear(); + if self.current_session_id != session_id { + self.current_session_id = session_id; + self.messages.clear(); + self.chat_scroll_from_bottom = 0; + } + if let Some(current) = &self.current_session_id + && let Some(index) = self + .sessions + .iter() + .position(|session| &session.session_id == current) + { + self.selected_session = index; + } } - pub fn scroll_chat_up(&mut self) { - self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1); + pub fn current_title(&self) -> &str { + self.current_session_id + .as_ref() + .and_then(|id| { + self.sessions + .iter() + .find(|session| &session.session_id == id) + }) + .map(|session| session.title.as_str()) + .unwrap_or("新对话") } - pub fn scroll_chat_down(&mut self) { - self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1); + pub fn selected_session_id(&self) -> Option { + self.sessions + .get(self.selected_session) + .map(|session| session.session_id.clone()) + } + + pub fn select_next_session(&mut self) { + if !self.sessions.is_empty() { + self.selected_session = (self.selected_session + 1).min(self.sessions.len() - 1); + } + } + + pub fn select_previous_session(&mut self) { + self.selected_session = self.selected_session.saturating_sub(1); + } + + fn clamp_session_selection(&mut self) { + self.selected_session = self + .selected_session + .min(self.sessions.len().saturating_sub(1)); + } + + pub fn scroll_chat_up(&mut self, lines: u16) { + self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_add(lines); + } + + pub fn scroll_chat_down(&mut self, lines: u16) { + self.chat_scroll_from_bottom = self.chat_scroll_from_bottom.saturating_sub(lines); } pub fn input_insert_char(&mut self, c: char) { - self.input.insert(self.input_cursor_pos, c); - self.input_cursor_pos += 1; + if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES { + self.input.insert(self.input_cursor_pos, c); + self.input_cursor_pos += c.len_utf8(); + } + } + + pub fn input_insert_str(&mut self, text: &str) { + let remaining = MAX_INPUT_BYTES.saturating_sub(self.input.len()); + let mut end = text.len().min(remaining); + while !text.is_char_boundary(end) { + end -= 1; + } + self.input.insert_str(self.input_cursor_pos, &text[..end]); + self.input_cursor_pos += end; } pub fn input_delete_char(&mut self) { - if self.input_cursor_pos > 0 { - self.input.remove(self.input_cursor_pos - 1); - self.input_cursor_pos -= 1; + if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) { + self.input.drain(previous..self.input_cursor_pos); + self.input_cursor_pos = previous; + } + } + + pub fn input_delete_forward(&mut self) { + if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) { + self.input.drain(self.input_cursor_pos..next); } } pub fn input_move_cursor_left(&mut self) { - self.input_cursor_pos = self.input_cursor_pos.saturating_sub(1); + if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) { + self.input_cursor_pos = previous; + } } pub fn input_move_cursor_right(&mut self) { - if self.input_cursor_pos < self.input.len() { - self.input_cursor_pos += 1; + if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) { + self.input_cursor_pos = next; } } pub fn input_move_cursor_to_start(&mut self) { - self.input_cursor_pos = 0; + let line_start = self.input[..self.input_cursor_pos] + .rfind('\n') + .map_or(0, |index| index + 1); + self.input_cursor_pos = line_start; } pub fn input_move_cursor_to_end(&mut self) { - self.input_cursor_pos = self.input.len(); + let tail = &self.input[self.input_cursor_pos..]; + self.input_cursor_pos += tail.find('\n').unwrap_or(tail.len()); } pub fn take_input(&mut self) -> String { @@ -132,86 +267,108 @@ impl App { input } - pub fn toggle_help(&mut self) { - self.show_help = !self.show_help; - } - - pub fn quit(&mut self) { - self.should_quit = true; - } - - /// Handle Ctrl+C for quit confirmation (requires double press) - pub fn handle_ctrl_c_for_quit(&mut self) -> bool { + pub fn handle_ctrl_c_for_quit(&mut self) { if self.pending_quit { - self.ctrl_c_count += 1; - if self.ctrl_c_count >= 2 { - self.should_quit = true; - return true; - } - false + self.should_quit = true; } else { self.pending_quit = true; - self.ctrl_c_count = 1; - false + self.status_message = Some("再次按 Ctrl+C 退出".to_string()); } } - /// Cancel pending quit if user presses any other key pub fn cancel_pending_quit(&mut self) { - self.pending_quit = false; - self.ctrl_c_count = 0; + if self.pending_quit { + self.pending_quit = false; + self.status_message = None; + } } - // Command menu methods pub fn set_commands(&mut self, commands: Vec) { self.commands = commands; } pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> { - let input_lower = self.input.to_lowercase(); + let query = self + .input + .split_whitespace() + .next() + .unwrap_or("") + .to_lowercase(); self.commands .iter() - .filter(|cmd| { - cmd.name.to_lowercase().contains(&input_lower) - || cmd.description.to_lowercase().contains(&input_lower) - || cmd + .filter(|command| { + command.name.to_lowercase().contains(&query) + || command.description.to_lowercase().contains(&query) + || command .aliases .iter() - .any(|a| a.to_lowercase().contains(&input_lower)) + .any(|alias| alias.to_lowercase().starts_with(&query)) }) .collect() } pub fn select_next_command(&mut self) { - let filtered = self.get_filtered_commands(); - if !filtered.is_empty() { - self.selected_command_idx = (self.selected_command_idx + 1) % filtered.len() as u16; + let len = self.get_filtered_commands().len(); + if len > 0 { + self.selected_command_idx = (self.selected_command_idx + 1) % len; } } - pub fn select_prev_command(&mut self) { - let filtered = self.get_filtered_commands(); - if !filtered.is_empty() { - self.selected_command_idx = if self.selected_command_idx == 0 { - filtered.len() as u16 - 1 - } else { - self.selected_command_idx - 1 - }; + pub fn select_previous_command(&mut self) { + let len = self.get_filtered_commands().len(); + if len > 0 { + self.selected_command_idx = (self.selected_command_idx + len - 1) % len; } } - pub fn get_selected_command(&self) -> Option<&SlashCommandInfo> { - let filtered = self.get_filtered_commands(); - filtered.get(self.selected_command_idx as usize).copied() - } - - pub fn insert_command(&mut self) { - if let Some(cmd) = self.get_selected_command() { - // Use the first alias as the command to insert - if let Some(alias) = cmd.aliases.first() { - self.input = alias.clone(); - self.input_cursor_pos = self.input.len(); - } + pub fn insert_selected_command(&mut self) { + let command = self + .get_filtered_commands() + .get(self.selected_command_idx) + .and_then(|command| command.aliases.first().cloned()); + if let Some(command) = command { + self.input = format!("{command} "); + self.input_cursor_pos = self.input.len(); } } } + +fn previous_boundary(value: &str, offset: usize) -> Option { + value[..offset] + .char_indices() + .next_back() + .map(|(index, _)| index) +} + +fn next_boundary(value: &str, offset: usize) -> Option { + value[offset..] + .chars() + .next() + .map(|character| offset + character.len_utf8()) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn unicode_cursor_edits_only_at_character_boundaries() { + let mut app = App::new(); + app.input_insert_str("你a🙂"); + app.input_move_cursor_left(); + app.input_delete_char(); + assert_eq!(app.input, "你🙂"); + assert!(app.input.is_char_boundary(app.input_cursor_pos)); + app.input_delete_forward(); + assert_eq!(app.input, "你"); + } + + #[test] + fn stale_history_does_not_replace_the_active_dialog() { + let mut app = App::new(); + app.set_current_session(Some("new".to_string())); + app.add_message(MessageRole::User, "keep".to_string()); + app.set_history("old", Vec::new()); + assert_eq!(app.messages.len(), 1); + } +} diff --git a/src/client/tui/components/chat_history.rs b/src/client/tui/components/chat_history.rs index 4efd5aa..60be6d0 100644 --- a/src/client/tui/components/chat_history.rs +++ b/src/client/tui/components/chat_history.rs @@ -3,35 +3,60 @@ use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, - text::Line, - widgets::{Block, Borders, List, ListItem}, + text::{Line, Span}, + widgets::{Block, Borders, Paragraph}, }; -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let items: Vec = app - .messages - .iter() - .map(|msg| { - let (prefix, color) = match msg.role { - MessageRole::User => ("[User] ", Color::Blue), - MessageRole::Assistant => ("[Assistant] ", Color::Green), - MessageRole::System => ("[System] ", Color::Red), - }; +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let content_width = area.width.saturating_sub(4).max(1) as usize; + let mut lines = Vec::new(); + if app.messages.is_empty() { + lines.push(Line::from("开始一段对话,或从左侧选择已有会话。")); + } + for message in &app.messages { + let (label, color) = match message.role { + MessageRole::User => ("你", Color::Blue), + MessageRole::Assistant => ("PicoBot", Color::Green), + MessageRole::System => ("系统", Color::Yellow), + }; + lines.push(Line::from(Span::styled( + label, + Style::default().fg(color).add_modifier(Modifier::BOLD), + ))); + for source_line in message.content.lines() { + let wrapped = textwrap::wrap(source_line, content_width); + if wrapped.is_empty() { + lines.push(Line::from("")); + } else { + lines.extend( + wrapped + .into_iter() + .map(|line| Line::from(line.into_owned())), + ); + } + } + lines.push(Line::from("")); + } + if app.pending_responses > 0 { + lines.push(Line::from(Span::styled( + "● 正在思考…", + Style::default().fg(Color::Cyan), + ))); + } - let content = vec![ - Line::from(vec![ratatui::text::Span::styled( - prefix, - Style::default().fg(color).add_modifier(Modifier::BOLD), - )]), - Line::from(msg.content.as_str()), - Line::from(""), - ]; - - ListItem::new(content) - }) - .collect(); - - let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL)); - - f.render_widget(list, area); + let visible_height = area.height.saturating_sub(2); + let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX); + let max_scroll = line_count.saturating_sub(visible_height); + let scroll = max_scroll.saturating_sub(app.chat_scroll_from_bottom.min(max_scroll)); + let title = if app.chat_scroll_from_bottom > 0 { + " 对话 · 已暂停自动滚动 " + } else { + " 对话 " + }; + frame.render_widget( + Paragraph::new(lines) + .scroll((scroll, 0)) + .block(Block::default().title(title).borders(Borders::ALL)), + area, + ); } diff --git a/src/client/tui/components/command_menu.rs b/src/client/tui/components/command_menu.rs index 555c14b..121036f 100644 --- a/src/client/tui/components/command_menu.rs +++ b/src/client/tui/components/command_menu.rs @@ -4,7 +4,7 @@ use ratatui::{ layout::Rect, style::{Color, Modifier, Style}, text::{Line, Span}, - widgets::{Block, Borders, List, ListItem}, + widgets::{Block, Borders, List, ListItem, ListState}, }; pub fn render(f: &mut Frame, area: Rect, app: &App) { @@ -18,7 +18,7 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { .iter() .enumerate() .map(|(i, cmd)| { - let is_selected = i == app.selected_command_idx as usize; + let is_selected = i == app.selected_command_idx; let style = if is_selected { Style::default() .fg(Color::White) @@ -48,5 +48,6 @@ pub fn render(f: &mut Frame, area: Rect, app: &App) { ) .highlight_style(Style::default().add_modifier(Modifier::BOLD)); - f.render_widget(list, area); + let mut state = ListState::default().with_selected(Some(app.selected_command_idx)); + f.render_stateful_widget(list, area, &mut state); } diff --git a/src/client/tui/components/help_popup.rs b/src/client/tui/components/help_popup.rs index 111f56c..a14c156 100644 --- a/src/client/tui/components/help_popup.rs +++ b/src/client/tui/components/help_popup.rs @@ -2,41 +2,52 @@ use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders, Clear, List, ListItem}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; -pub fn render(f: &mut Frame, area: Rect) { - f.render_widget(Clear, area); - - let help_text = vec![ - ListItem::new("Commands:"), - ListItem::new(" /new [title] - Archive current, start new"), - ListItem::new(" /sessions - List all conversations"), - ListItem::new(" /switch - Switch to conversation"), - ListItem::new(" /rename - Rename current conversation"), - ListItem::new(" /archive - Archive current conversation"), - ListItem::new(" /delete - Delete current conversation"), - ListItem::new(" /compact - Trigger context compression"), - ListItem::new(" /info - Show session information"), - ListItem::new(""), - ListItem::new("Keyboard:"), - ListItem::new(" Enter - Send message"), - ListItem::new(" Ctrl+C ×2 - Quit"), - ListItem::new(" ? - Show help"), - ListItem::new(" Arrow keys - Navigate"), - ListItem::new(" / - Show command menu"), +pub fn render(frame: &mut Frame, area: Rect) { + frame.render_widget(Clear, area); + let lines = vec![ + Line::from(Span::styled( + "全局", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" F1 / Ctrl+H 帮助 Tab 切换输入/会话焦点"), + Line::from(" Ctrl+N 新会话 Ctrl+S 聚焦会话列表"), + Line::from(" Ctrl+R 重命名 Ctrl+O 显示/隐藏归档"), + Line::from(" Ctrl+A 归档 Ctrl+D 删除"), + Line::from(" Ctrl+L 清空历史 Ctrl+C 两次 退出"), + Line::from(""), + Line::from(Span::styled( + "输入", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" Enter 发送 Shift/Alt+Enter 换行"), + Line::from(" / 打开命令菜单 Tab 补全命令"), + Line::from(" PageUp/PageDown 滚动对话,Ctrl+↑/↓ 微调"), + Line::from(""), + Line::from(Span::styled( + "会话列表", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(" ↑/↓ 或 j/k 选择 Enter 切换"), + Line::from(" n 新建 · r 重命名 · a 归档 · d 删除"), + Line::from(""), + Line::from(Span::styled( + "Esc / F1 关闭帮助", + Style::default().fg(Color::DarkGray), + )), ]; - - let list = List::new(help_text).block( - Block::default() - .title("Help") - .title_style( - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), + frame.render_widget( + Paragraph::new(lines) + .block( + Block::default() + .title(" 帮助 ") + .title_style(Style::default().fg(Color::Cyan)) + .borders(Borders::ALL), ) - .borders(Borders::ALL), + .wrap(Wrap { trim: false }), + area, ); - - f.render_widget(list, area); } diff --git a/src/client/tui/components/input_area.rs b/src/client/tui/components/input_area.rs index 427e062..c29dbcc 100644 --- a/src/client/tui/components/input_area.rs +++ b/src/client/tui/components/input_area.rs @@ -1,21 +1,81 @@ -use crate::client::tui::app::App; +use crate::client::tui::app::{App, Focus}; use ratatui::{ Frame, layout::Rect, style::{Color, Style}, - widgets::{Block, Borders, Paragraph}, + widgets::{Block, Borders, Paragraph, Wrap}, }; +use unicode_width::UnicodeWidthChar; -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let input = Paragraph::new(app.input.as_str()) - .style(Style::default().fg(Color::White)) - .block(Block::default().title("Input").borders(Borders::ALL)); +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let active = app.focus == Focus::Input && app.modal.is_none() && !app.show_help; + let border_style = if active { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let title = if app.connected { + " 输入 · Enter 发送 / Shift+Enter 换行 " + } else { + " 输入 · Gateway 已断开 " + }; + let inner_width = area.width.saturating_sub(2).max(1); + let inner_height = area.height.saturating_sub(2).max(1); + let (cursor_row, cursor_col) = cursor_position(&app.input[..app.input_cursor_pos], inner_width); + let vertical_scroll = cursor_row.saturating_sub(inner_height.saturating_sub(1)); + frame.render_widget( + Paragraph::new(app.input.as_str()) + .scroll((vertical_scroll, 0)) + .wrap(Wrap { trim: false }) + .style(Style::default().fg(Color::White)) + .block( + Block::default() + .title(title) + .borders(Borders::ALL) + .border_style(border_style), + ), + area, + ); - f.render_widget(input, area); - - let cursor_x = area.x + 1 + app.input_cursor_pos as u16; - let cursor_y = area.y + 1; - if cursor_x < area.right() && cursor_y < area.bottom() { - f.set_cursor_position((cursor_x, cursor_y)); + if active { + let x = area.x + 1 + cursor_col.min(inner_width.saturating_sub(1)); + let y = area.y + 1 + cursor_row.saturating_sub(vertical_scroll); + if x < area.right() && y < area.bottom() { + frame.set_cursor_position((x, y)); + } + } +} + +fn cursor_position(value: &str, width: u16) -> (u16, u16) { + let mut row = 0_u16; + let mut column = 0_u16; + for character in value.chars() { + if character == '\n' { + row = row.saturating_add(1); + column = 0; + continue; + } + let character_width = character.width().unwrap_or(0) as u16; + if column > 0 && column.saturating_add(character_width) > width { + row = row.saturating_add(1); + column = 0; + } + column = column.saturating_add(character_width); + if column >= width { + row = row.saturating_add(column / width); + column %= width; + } + } + (row, column) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn cursor_accounts_for_wide_characters_and_wrapping() { + assert_eq!(cursor_position("你a", 10), (0, 3)); + assert_eq!(cursor_position("1234你", 5), (1, 2)); } } diff --git a/src/client/tui/components/session_list.rs b/src/client/tui/components/session_list.rs index c626f2f..5d2a34e 100644 --- a/src/client/tui/components/session_list.rs +++ b/src/client/tui/components/session_list.rs @@ -1,42 +1,60 @@ -use crate::client::tui::app::App; +use crate::client::tui::app::{App, Focus}; use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, - widgets::{Block, Borders, List, ListItem}, + text::{Line, Span}, + widgets::{Block, Borders, List, ListItem, ListState}, }; -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let items: Vec = app - .sessions - .iter() - .map(|session| { - let is_current = app.current_session_id.as_ref() == Some(&session.session_id); - let archived = session.archived_at.is_some(); - - let mut content = if is_current { - format!("• {}", session.title) - } else { - format!(" {}", session.title) - }; - - if archived { - content.push_str(" [archived]"); - } - - let style = if is_current { - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD) - } else { - Style::default().fg(Color::White) - }; - - ListItem::new(content).style(style) - }) - .collect(); - - let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL)); - - f.render_widget(list, area); +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let items = app.sessions.iter().enumerate().map(|(index, session)| { + let selected = app.focus == Focus::Sessions && index == app.selected_session; + let current = app.current_session_id.as_ref() == Some(&session.session_id); + let marker = if current { "●" } else { " " }; + let archived = if session.archived_at.is_some() { + " 归档" + } else { + "" + }; + let style = if selected { + Style::default().fg(Color::Black).bg(Color::Cyan) + } else if current { + Style::default() + .fg(Color::Yellow) + .add_modifier(Modifier::BOLD) + } else { + Style::default() + }; + ListItem::new(vec![ + Line::from(vec![ + Span::raw(format!("{marker} ")), + Span::styled(session.title.clone(), style), + ]), + Line::from(Span::styled( + format!(" {} 条{archived}", session.message_count), + Style::default().fg(Color::DarkGray), + )), + ]) + .style(style) + }); + let mode = if app.show_archived { + "全部" + } else { + "活跃" + }; + let border = if app.focus == Focus::Sessions { + Style::default().fg(Color::Cyan) + } else { + Style::default() + }; + let list = List::new(items).block( + Block::default() + .title(format!(" 会话 · {mode} ")) + .borders(Borders::ALL) + .border_style(border), + ); + let selected = (!app.sessions.is_empty()).then_some(app.selected_session); + let mut state = ListState::default().with_selected(selected); + frame.render_stateful_widget(list, area, &mut state); } diff --git a/src/client/tui/components/title_bar.rs b/src/client/tui/components/title_bar.rs index be06a98..636c78b 100644 --- a/src/client/tui/components/title_bar.rs +++ b/src/client/tui/components/title_bar.rs @@ -3,44 +3,34 @@ use ratatui::{ Frame, layout::Rect, style::{Color, Modifier, Style}, + text::{Line, Span}, widgets::{Block, Borders, Paragraph}, }; -pub fn render(f: &mut Frame, area: Rect, app: &App) { - let (title, style) = if app.pending_quit { - let msg = if let Some(session_id) = &app.current_session_id { - format!( - "PicoBot | Session: {} | Press Ctrl+C again to quit", - session_id - ) - } else { - "PicoBot | Press Ctrl+C again to quit".to_string() - }; - ( - msg, - Style::default() - .fg(Color::Yellow) - .add_modifier(Modifier::BOLD), - ) - } else if let Some(session_id) = &app.current_session_id { - ( - format!("PicoBot | Session: {}", session_id), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) +pub fn render(frame: &mut Frame, area: Rect, app: &App) { + let connection = if app.connected { + Span::styled("● 已连接", Style::default().fg(Color::Green)) } else { - ( - "PicoBot".to_string(), - Style::default() - .fg(Color::Cyan) - .add_modifier(Modifier::BOLD), - ) + Span::styled("● 已断开", Style::default().fg(Color::Red)) }; - - let paragraph = Paragraph::new(title) - .style(style) - .block(Block::default().borders(Borders::ALL)); - - f.render_widget(paragraph, area); + let pending = if app.pending_responses > 0 { + format!(" · {} 个请求处理中", app.pending_responses) + } else { + String::new() + }; + frame.render_widget( + Paragraph::new(Line::from(vec![ + Span::styled( + " PicoBot ", + Style::default() + .fg(Color::Cyan) + .add_modifier(Modifier::BOLD), + ), + Span::raw(format!("{} ", app.current_title())), + connection, + Span::styled(pending, Style::default().fg(Color::DarkGray)), + ])) + .block(Block::default().borders(Borders::ALL)), + area, + ); } diff --git a/src/client/tui/event.rs b/src/client/tui/event.rs index 4ac4ff6..ddd8cbc 100644 --- a/src/client/tui/event.rs +++ b/src/client/tui/event.rs @@ -1,134 +1,341 @@ -use crate::client::tui::app::{App, MessageRole}; -use crate::protocol::WsInbound; -use crate::protocol::serialize_inbound; -use crossterm::event::{KeyCode, KeyEvent}; +use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal}; +use crate::protocol::{WsInbound, serialize_inbound}; +use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use futures_util::SinkExt; +use tokio_tungstenite::tungstenite::Message; pub async fn handle_key_event(app: &mut App, key: KeyEvent) { if app.show_help { - match key.code { - KeyCode::Esc | KeyCode::Char('q') => { - app.toggle_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 => { - app.show_command_menu = false; - app.selected_command_idx = 0; - } - KeyCode::Up => { - app.select_prev_command(); - } - KeyCode::Down => { - app.select_next_command(); - } - KeyCode::Enter => { - app.insert_command(); - app.show_command_menu = false; - app.selected_command_idx = 0; - } + KeyCode::Esc => close_command_menu(app), + KeyCode::Up => app.select_previous_command(), + KeyCode::Down => app.select_next_command(), KeyCode::Tab => { - app.insert_command(); + app.insert_selected_command(); + close_command_menu(app); } - _ => { - // Handle normal input and check if menu should stay open - handle_normal_input(app, key).await; + KeyCode::Enter if key.modifiers.is_empty() => { + app.insert_selected_command(); + close_command_menu(app); } + _ => handle_input_key(app, key).await, } return; } - handle_normal_input(app, key).await; -} - -async fn handle_normal_input(app: &mut App, key: KeyEvent) { - // Handle Ctrl+C for quit (double press to exit) - let is_ctrl_c = key.code == KeyCode::Char('c') - && key - .modifiers - .contains(crossterm::event::KeyModifiers::CONTROL); - if is_ctrl_c { - if app.handle_ctrl_c_for_quit() { - 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('u') if app.focus == Focus::Input => { + app.input.clear(); + app.input_cursor_pos = 0; + } + KeyCode::Up => app.scroll_chat_up(3), + KeyCode::Down => app.scroll_chat_down(3), + _ => {} } - } else { - app.cancel_pending_quit(); + return; } match key.code { - KeyCode::Char('?') => { - app.toggle_help(); + 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::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 }) = &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); - - // Show command menu when input starts with / - if !app.show_command_menu - && (app.input == "/" || (app.input.len() > 1 && app.input.starts_with('/'))) - { - app.show_command_menu = true; - app.selected_command_idx = 0; - } else if app.show_command_menu && !app.input.starts_with('/') { - app.show_command_menu = false; - } + update_command_menu(app); } KeyCode::Backspace => { app.input_delete_char(); - - // Hide menu if input no longer starts with / - if app.show_command_menu && !app.input.starts_with('/') { - app.show_command_menu = false; - app.selected_command_idx = 0; - } + update_command_menu(app); } - 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(); - } - KeyCode::Down => { - app.scroll_chat_down(); + 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(); - app.show_command_menu = false; - app.selected_command_idx = 0; - if !input.is_empty() { - process_input(app, input).await; + close_command_menu(app); + if !input.trim().is_empty() { + app.add_message(MessageRole::User, input.clone()); + app.pending_responses = app.pending_responses.saturating_add(1); + app.status_message = Some("PicoBot 正在处理…".to_string()); + let sent = send( + app, + WsInbound::UserInput { + content: input, + 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); + } } } _ => {} } } -async fn process_input(app: &mut App, input: String) { - app.add_message(MessageRole::User, input.clone()); - if let Some(sender) = &mut app.ws_sender { - let inbound = WsInbound::UserInput { - content: input, - chat_id: app.current_session_id.clone(), - channel: None, - sender_id: None, - }; - if let Ok(text) = serialize_inbound(&inbound) { - let _ = sender - .send(tokio_tungstenite::tungstenite::Message::Text(text.into())) - .await; - } +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 }), + }, + None => {} } } + +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 +} diff --git a/src/client/tui/ui.rs b/src/client/tui/ui.rs index 02b7fd7..cef9b9d 100644 --- a/src/client/tui/ui.rs +++ b/src/client/tui/ui.rs @@ -1,73 +1,165 @@ -use crate::client::tui::app::App; +use crate::client::tui::app::{App, ConfirmAction, Focus, Modal}; use crate::client::tui::components::*; use ratatui::{ Frame, layout::{Constraint, Direction, Layout, Rect}, + style::{Color, Modifier, Style}, + text::{Line, Span}, + widgets::{Block, Borders, Clear, Paragraph, Wrap}, }; +use unicode_width::UnicodeWidthStr; -pub fn render_ui(f: &mut Frame, app: &App) { - let size = f.area(); - let chunks = Layout::default() +pub fn render_ui(frame: &mut Frame, app: &App) { + let area = frame.area(); + let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2; + let rows = Layout::default() .direction(Direction::Vertical) .constraints([ Constraint::Length(3), - Constraint::Min(0), - Constraint::Length(5), + Constraint::Min(3), + Constraint::Length(input_height), + Constraint::Length(1), ]) - .split(size); + .split(area); - title_bar::render(f, chunks[0], app); + title_bar::render(frame, rows[0], app); + if area.width >= 80 { + let columns = Layout::default() + .direction(Direction::Horizontal) + .constraints([Constraint::Length(28), Constraint::Min(30)]) + .split(rows[1]); + session_list::render(frame, columns[0], app); + chat_history::render(frame, columns[1], app); + } else { + chat_history::render(frame, rows[1], app); + } + input_area::render(frame, rows[2], app); + render_footer(frame, rows[3], app); - let middle_chunks = Layout::default() - .direction(Direction::Horizontal) - .constraints([Constraint::Percentage(25), Constraint::Percentage(75)]) - .split(chunks[1]); - - session_list::render(f, middle_chunks[0], app); - chat_history::render(f, middle_chunks[1], app); - - input_area::render(f, chunks[2], app); - - // Render command menu if needed - position above input area if app.show_command_menu && !app.get_filtered_commands().is_empty() { - let menu_area = menu_above_input(chunks[2]); - command_menu::render(f, menu_area, app); + let height = (app.get_filtered_commands().len().min(6) + 2) as u16; + let menu_area = Rect::new( + rows[2].x.saturating_add(1), + rows[2].y.saturating_sub(height), + rows[2].width.saturating_sub(2), + height, + ); + command_menu::render(frame, menu_area, app); } - if app.show_help { - let help_area = centered_rect(60, 60, size); - help_popup::render(f, help_area); + help_popup::render(frame, centered_rect(72, 26, area)); + } + if let Some(modal) = &app.modal { + render_modal(frame, centered_rect(64, 7, area), modal); } } -fn menu_above_input(input_area: Rect) -> Rect { - let max_commands = 6; // Show up to 6 commands - let menu_height = max_commands + 2; // +2 for borders +fn render_footer(frame: &mut Frame, area: Rect, app: &App) { + let focus = match app.focus { + Focus::Input => "输入", + Focus::Sessions => "会话", + }; + let status = + app.status_message + .as_deref() + .unwrap_or(if app.connected { "就绪" } else { "已断开" }); + let line = Line::from(vec![ + Span::styled( + format!(" {focus} "), + Style::default().fg(Color::Black).bg(Color::Cyan), + ), + Span::raw(format!(" {status}")), + Span::styled( + " F1 帮助 Tab 切换焦点 Ctrl+N 新会话 ", + Style::default().fg(Color::DarkGray), + ), + ]); + frame.render_widget(Paragraph::new(line), area); +} - Rect { - x: input_area.x + 1, - y: input_area.y.saturating_sub(menu_height), - width: input_area.width.saturating_sub(2), - height: menu_height, +fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) { + frame.render_widget(Clear, area); + let block = Block::default() + .borders(Borders::ALL) + .border_style(Style::default().fg(Color::Yellow)); + match modal { + Modal::Rename { input, cursor } => { + frame.render_widget( + Paragraph::new(vec![ + Line::from(Span::styled( + "重命名会话", + Style::default().add_modifier(Modifier::BOLD), + )), + Line::from(""), + Line::from(input.as_str()), + Line::from(Span::styled( + "Enter 保存 · Esc 取消", + Style::default().fg(Color::DarkGray), + )), + ]) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); + let cursor_x = area.x + + 1 + + UnicodeWidthStr::width(&input[..*cursor]) + .min(area.width.saturating_sub(3) as usize) as u16; + let cursor_y = area.y.saturating_add(3); + if cursor_x < area.right() && cursor_y < area.bottom() { + frame.set_cursor_position((cursor_x, cursor_y)); + } + } + Modal::Confirm(action) => { + let prompt = match action { + ConfirmAction::Archive => "归档所选会话?", + ConfirmAction::Delete => "永久删除所选会话?此操作不可撤销。", + ConfirmAction::ClearHistory => "清空所选会话的全部历史?", + }; + frame.render_widget( + Paragraph::new(vec![ + Line::from(""), + Line::from(prompt), + Line::from(""), + Line::from(Span::styled( + "Y / Enter 确认 · N / Esc 取消", + Style::default().fg(Color::DarkGray), + )), + ]) + .block(block) + .wrap(Wrap { trim: false }), + area, + ); + } } } -fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect { - let popup_layout = Layout::default() - .direction(Direction::Vertical) - .constraints([ - Constraint::Percentage((100 - percent_y) / 2), - Constraint::Percentage(percent_y), - Constraint::Percentage((100 - percent_y) / 2), - ]) - .split(r); - - Layout::default() - .direction(Direction::Horizontal) - .constraints([ - Constraint::Percentage((100 - percent_x) / 2), - Constraint::Percentage(percent_x), - Constraint::Percentage((100 - percent_x) / 2), - ]) - .split(popup_layout[1])[1] +fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect { + let width = max_width.min(area.width.saturating_sub(2)).max(1); + let height = max_height.min(area.height.saturating_sub(2)).max(1); + Rect::new( + area.x + area.width.saturating_sub(width) / 2, + area.y + area.height.saturating_sub(height) / 2, + width, + height, + ) +} + +#[cfg(test)] +mod tests { + use super::*; + use ratatui::{Terminal, backend::TestBackend}; + + #[test] + fn narrow_terminal_renders_without_sidebar_or_panic() { + let backend = TestBackend::new(48, 14); + let mut terminal = Terminal::new(backend).unwrap(); + let mut app = App::new(); + app.input_insert_str("中文 input"); + app.add_message( + crate::client::tui::app::MessageRole::Assistant, + "一条很长的响应,用于验证窄终端换行。".repeat(4), + ); + terminal.draw(|frame| render_ui(frame, &app)).unwrap(); + } } diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index 6587926..9c07f5c 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -233,13 +233,15 @@ impl GatewayState { } } Ok(crate::session::session::HandleResult::CommandOutput(content)) => { + let mut metadata = inbound.forwarded_metadata; + metadata.insert("_type".to_string(), "command".to_string()); let outbound = crate::bus::OutboundMessage { channel: inbound.channel.clone(), chat_id: inbound.chat_id.clone(), content, reply_to: None, media: vec![], - metadata: inbound.forwarded_metadata, + metadata, delivery: None, }; if let Err(e) = bus.publish_outbound(outbound).await { @@ -339,6 +341,14 @@ impl GatewayState { .await .map(|session_id| SessionEvent::DialogSwitched { session_id }) .map_err(|e| ChannelError::Other(e.to_string())), + GetDialogHistory { session_id, limit } => session_manager + .get_dialog_history(&session_id, limit) + .await + .map(|messages| SessionEvent::DialogHistory { + session_id, + messages, + }) + .map_err(|e| ChannelError::Other(e.to_string())), RenameDialog { session_id, title } => session_manager .rename_dialog(&session_id, &title) .await diff --git a/src/gateway/ws.rs b/src/gateway/ws.rs index 1a320c6..cb0e871 100644 --- a/src/gateway/ws.rs +++ b/src/gateway/ws.rs @@ -1,21 +1,41 @@ use super::GatewayState; use crate::protocol::WsOutbound; use crate::protocol::serialize_outbound; -use axum::extract::State; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; +use axum::extract::{Query, State}; use axum::response::Response; use futures_util::{SinkExt, StreamExt}; +use serde::Deserialize; use std::sync::Arc; use tokio::sync::mpsc; use tokio::time::{Duration, timeout}; -pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State>) -> Response { +#[derive(Debug, Default, Deserialize)] +pub struct WsQuery { + client_id: Option, +} + +pub async fn ws_handler( + ws: WebSocketUpgrade, + Query(query): Query, + State(state): State>, +) -> Response { ws.on_upgrade(|socket| async move { - handle_socket(socket, state).await; + handle_socket(socket, state, valid_client_id(query.client_id)).await; }) } -async fn handle_socket(ws: WebSocket, state: Arc) { +fn valid_client_id(client_id: Option) -> Option { + client_id.filter(|value| { + !value.is_empty() + && value.len() <= 64 + && value + .bytes() + .all(|byte| byte.is_ascii_alphanumeric() || byte == b'-' || byte == b'_') + }) +} + +async fn handle_socket(ws: WebSocket, state: Arc, client_id: Option) { // Create channel for sending outbound messages to this client let (sender, mut receiver) = mpsc::channel::(100); @@ -23,9 +43,9 @@ async fn handle_socket(ws: WebSocket, state: Arc) { let cli_chat_channel = state.cli_chat_channel(); // Register client with CliChatChannel and get initial session id - let (session_id, client) = cli_chat_channel.register_client(sender.clone()).await; - let chat_id = client.chat_id().to_string(); - + let (session_id, client) = cli_chat_channel + .register_client(sender.clone(), client_id) + .await; // Send session established message let _ = sender .send(WsOutbound::SessionEstablished { @@ -76,7 +96,7 @@ async fn handle_socket(ws: WebSocket, state: Arc) { } } - cli_chat_channel.unregister_client(&chat_id).await; + cli_chat_channel.unregister_client(&client).await; drop(client); drop(sender); if !writer_finished @@ -89,3 +109,19 @@ async fn handle_socket(ws: WebSocket, state: Arc) { } tracing::info!(session_id = %session_id, "CLI session ended"); } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn client_id_is_strictly_bounded() { + assert_eq!( + valid_client_id(Some("client_123-abc".to_string())).as_deref(), + Some("client_123-abc") + ); + assert!(valid_client_id(Some("bad/query".to_string())).is_none()); + assert!(valid_client_id(Some("x".repeat(65))).is_none()); + assert!(valid_client_id(Some(String::new())).is_none()); + } +} diff --git a/src/protocol.rs b/src/protocol.rs index 6b1c5b5..c666f3f 100644 --- a/src/protocol.rs +++ b/src/protocol.rs @@ -19,6 +19,15 @@ pub struct SlashCommandInfo { pub aliases: Vec, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct HistoryMessage { + pub id: String, + pub seq: i64, + pub role: String, + pub content: String, + pub created_at: i64, +} + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum WsInbound { @@ -51,6 +60,12 @@ pub enum WsInbound { }, #[serde(rename = "load_session")] LoadSession { session_id: String }, + #[serde(rename = "get_session_history")] + GetSessionHistory { + session_id: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + limit: Option, + }, #[serde(rename = "rename_session")] RenameSession { #[serde(default, skip_serializing_if = "Option::is_none")] @@ -81,6 +96,8 @@ pub enum WsOutbound { id: String, content: String, role: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, }, #[serde(rename = "error")] Error { code: String, message: String }, @@ -100,6 +117,11 @@ pub enum WsOutbound { title: String, message_count: i64, }, + #[serde(rename = "session_history")] + SessionHistory { + session_id: String, + messages: Vec, + }, #[serde(rename = "session_renamed")] SessionRenamed { session_id: String, title: String }, #[serde(rename = "session_archived")] @@ -115,7 +137,11 @@ pub enum WsOutbound { #[serde(rename = "command_executed")] CommandExecuted { message: String }, #[serde(rename = "system_notification")] - SystemNotification { content: String }, + SystemNotification { + content: String, + #[serde(default, skip_serializing_if = "Option::is_none")] + session_id: Option, + }, } pub fn parse_inbound(raw: &str) -> Result { diff --git a/src/session/commands.rs b/src/session/commands.rs index bc16e4a..001062c 100644 --- a/src/session/commands.rs +++ b/src/session/commands.rs @@ -21,6 +21,11 @@ pub enum SessionCommand { chat_id: String, dialog_id: String, }, + /// Load persisted messages for a dialog. + GetDialogHistory { + session_id: UnifiedSessionId, + limit: u32, + }, /// Get the current dialog for a chat GetCurrentDialog { channel: String, chat_id: String }, /// Rename a dialog diff --git a/src/session/events.rs b/src/session/events.rs index 8fdf6ce..77046c4 100644 --- a/src/session/events.rs +++ b/src/session/events.rs @@ -31,6 +31,11 @@ pub enum SessionEvent { }, /// Dialog switched successfully DialogSwitched { session_id: UnifiedSessionId }, + /// Persisted dialog messages, ordered by sequence. + DialogHistory { + session_id: UnifiedSessionId, + messages: Vec, + }, /// Dialog renamed DialogRenamed { session_id: UnifiedSessionId, diff --git a/src/session/session.rs b/src/session/session.rs index 3fb5c13..7e6a7ab 100644 --- a/src/session/session.rs +++ b/src/session/session.rs @@ -18,6 +18,10 @@ pub(super) type MessagePersistSnapshot = ( const SESSION_QUEUE_CAPACITY: usize = 32; +fn outbound_session_metadata(session_id: &str) -> HashMap { + HashMap::from([("_session_id".to_string(), session_id.to_string())]) +} + tokio::task_local! { pub(super) static CURRENT_SOURCE_SESSION: Option; } @@ -1622,6 +1626,22 @@ impl SessionManager { Ok(unified_id) } + pub async fn get_dialog_history( + &self, + session_id: &UnifiedSessionId, + limit: u32, + ) -> Result, AgentError> { + let session_id = session_id.to_string(); + self.storage + .get_session(&session_id) + .await + .map_err(|e| AgentError::Other(format!("failed to load dialog: {e}")))?; + self.storage + .load_recent_session_messages(&session_id, limit) + .await + .map_err(|e| AgentError::Other(format!("failed to load dialog history: {e}"))) + } + pub async fn list_dialogs( &self, channel: &str, @@ -1630,7 +1650,7 @@ impl SessionManager { ) -> Result<(Vec, Option), AgentError> { let metas = self .storage - .list_sessions(channel, chat_id, 10, include_archived) + .list_sessions(channel, chat_id, 100, include_archived) .await .map_err(|e| AgentError::Other(format!("failed to list dialogs: {}", e)))?; let current_dialog_id = self @@ -1851,7 +1871,7 @@ impl SessionManager { content: content.to_string(), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&unified_id.to_string()), delivery: None, }; self.bus @@ -2042,6 +2062,7 @@ fn spawn_agent_worker( 'tasks: while let Some(task) = task_rx.recv().await { let task_chan = task.channel.clone(); let task_cid = task.chat_id.clone(); + let notification_session_id = unified_str.clone(); let (notify_tx, mut notify_rx) = mpsc::unbounded_channel(); @@ -2056,6 +2077,10 @@ fn spawn_agent_worker( while let Some(notif) = notify_rx.recv().await { let mut metadata = HashMap::new(); metadata.insert("_type".to_string(), "notification".to_string()); + metadata.insert( + "_session_id".to_string(), + notification_session_id.clone(), + ); let outbound = OutboundMessage { channel: ch.clone(), chat_id: cid.clone(), @@ -2093,7 +2118,7 @@ fn spawn_agent_worker( content: "Failed to save your message, please try again.".to_string(), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&unified_str), delivery: None, }; let _ = bus.publish_outbound(err_outbound).await; @@ -2120,7 +2145,7 @@ fn spawn_agent_worker( .to_string(), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&unified_str), delivery: None, }; let _ = bus.publish_outbound(err_outbound).await; @@ -2230,6 +2255,7 @@ fn spawn_agent_worker( let cid2 = task_cid.clone(); let unified_str2 = unified_str.clone(); let process_future = async move { + let response_session_id = unified_str2.clone(); let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope( crate::agent::DelegateContext { session_id: unified_str2, @@ -2273,7 +2299,9 @@ fn spawn_agent_worker( .to_string(), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata( + &response_session_id, + ), delivery: None, }; let _ = bus2.publish_outbound(err_outbound).await; @@ -2332,7 +2360,7 @@ fn spawn_agent_worker( content: format!("Processing error: {}", e), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&response_session_id), delivery: None, }; let _ = bus2.publish_outbound(err_outbound).await; @@ -2348,7 +2376,7 @@ fn spawn_agent_worker( content: format!("Processing error: {}", e), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&response_session_id), delivery: None, }; let _ = bus2.publish_outbound(err_outbound).await; @@ -2377,7 +2405,7 @@ fn spawn_agent_worker( .to_string(), reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&response_session_id), delivery: None, }; let _ = bus2.publish_outbound(err_outbound).await; @@ -2394,7 +2422,7 @@ fn spawn_agent_worker( content: response, reply_to: None, media: vec![], - metadata: HashMap::new(), + metadata: outbound_session_metadata(&response_session_id), delivery: None, }; let _ = bus2.publish_outbound(outbound).await; diff --git a/src/storage/mod.rs b/src/storage/mod.rs index bc1d6bf..541425c 100644 --- a/src/storage/mod.rs +++ b/src/storage/mod.rs @@ -793,6 +793,52 @@ impl Storage { Ok(row.get::("max_seq")) } + /// Load a bounded tail of one session while preserving chronological order. + pub async fn load_recent_session_messages( + &self, + session_id: &str, + limit: u32, + ) -> Result, StorageError> { + let limit = limit.clamp(1, 2_000); + let rows = sqlx::query( + r#" + SELECT id, session_id, seq, role, content, reasoning_content, media_refs, + tool_call_id, tool_name, tool_calls, source, created_at + FROM ( + SELECT id, session_id, seq, role, content, reasoning_content, media_refs, + tool_call_id, tool_name, tool_calls, source, created_at + FROM messages + WHERE session_id = ? + ORDER BY seq DESC + LIMIT ? + ) + ORDER BY seq ASC + "#, + ) + .bind(session_id) + .bind(i64::from(limit)) + .fetch_all(self.pool()) + .await?; + + Ok(rows + .into_iter() + .map(|row| crate::storage::message::MessageMeta { + id: row.get("id"), + session_id: row.get("session_id"), + seq: row.get("seq"), + role: row.get("role"), + content: row.get("content"), + reasoning_content: row.get("reasoning_content"), + media_refs: row.get("media_refs"), + tool_call_id: row.get("tool_call_id"), + tool_name: row.get("tool_name"), + tool_calls: row.get("tool_calls"), + source: row.get("source"), + created_at: row.get("created_at"), + }) + .collect()) + } + pub async fn load_messages_after_timestamp( &self, session_id: &str, @@ -1515,6 +1561,24 @@ mod tests { let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap(); assert_eq!(loaded.len(), 1); assert_eq!(loaded[0].content, "你好"); + + for seq in 2..=5 { + let mut message = msg.clone(); + message.id = format!("msg{seq}"); + message.seq = seq; + message.content = format!("message {seq}"); + storage + .append_message(&session_meta.id, &message) + .await + .unwrap(); + } + let recent = storage + .load_recent_session_messages(&session_meta.id, 2) + .await + .unwrap(); + assert_eq!(recent.len(), 2); + assert_eq!(recent[0].seq, 4); + assert_eq!(recent[1].seq, 5); } #[tokio::test] diff --git a/tests/test_request_format.rs b/tests/test_request_format.rs index dd327af..0963223 100644 --- a/tests/test_request_format.rs +++ b/tests/test_request_format.rs @@ -1,4 +1,4 @@ -use picobot::protocol::{SessionSummary, WsInbound, WsOutbound}; +use picobot::protocol::{HistoryMessage, SessionSummary, WsInbound, WsOutbound}; use picobot::providers::{ChatCompletionRequest, Message}; /// Test that message with special characters is properly escaped @@ -116,3 +116,34 @@ fn test_clear_history_with_session_id_serialization() { assert!(json.contains(r#""type":"clear_history""#)); assert!(json.contains(r#""session_id":"session-1""#)); } + +#[test] +fn test_bounded_session_history_protocol() { + let inbound = WsInbound::GetSessionHistory { + session_id: "cli_chat:client:dialog".to_string(), + limit: Some(1000), + }; + let json = serde_json::to_string(&inbound).unwrap(); + assert!(json.contains(r#""type":"get_session_history""#)); + assert!(json.contains(r#""limit":1000"#)); + + let outbound = WsOutbound::SessionHistory { + session_id: "cli_chat:client:dialog".to_string(), + messages: vec![HistoryMessage { + id: "m1".to_string(), + seq: 1, + role: "user".to_string(), + content: "你好".to_string(), + created_at: 123, + }], + }; + let decoded: WsOutbound = + serde_json::from_str(&serde_json::to_string(&outbound).unwrap()).unwrap(); + match decoded { + WsOutbound::SessionHistory { messages, .. } => { + assert_eq!(messages.len(), 1); + assert_eq!(messages[0].content, "你好"); + } + other => panic!("unexpected decoded variant: {other:?}"), + } +}