tui客户端增强。

This commit is contained in:
xiaoxixi 2026-07-14 14:31:16 +08:00
parent 901b622b40
commit 27d126cf73
23 changed files with 1504 additions and 445 deletions

View File

@ -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`) - 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 `<VAR_NAME>` in config JSON are substituted - `.env` (cwd) is loaded with a custom parser, not via dotenv crate; env var placeholders `<VAR_NAME>` in config JSON are substituted
- Config example: `resources/templates/config.example.json` (released to `~/.picobot/` on first run) - 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 ## Tests
@ -61,7 +62,7 @@ Scheduler → SessionManager.handle_cron_message → AgentLoop → send_message
| `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` | | `storage` | SQLite persistence for sessions and messages | `Storage`, `SessionMeta`, `MessageMeta` |
| `scheduler` | Cron-based job scheduling, next-run computation | `Scheduler`, `Schedule`, `next_run_for_schedule()` | | `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` | | `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` | | `config` | Config loading, env substitution, path resolution | `Config`, `LLMProviderConfig` |
| `logging` | Tracing initialization with file rotation | `init_logging()`, `init_logging_console_only()` | | `logging` | Tracing initialization with file rotation | `init_logging()`, `init_logging_console_only()` |
| `task_supervisor` | Owns, cancels, and boundedly joins gateway background tasks | `TaskSupervisor` | | `task_supervisor` | Owns, cancels, and boundedly joins gateway background tasks | `TaskSupervisor` |

View File

@ -33,6 +33,7 @@ ratatui = "0.30"
crossterm = { version = "0.29", features = ["event-stream"] } crossterm = { version = "0.29", features = ["event-stream"] }
termimad = "0.34" termimad = "0.34"
textwrap = "0.16" textwrap = "0.16"
unicode-width = "0.2"
chrono = "0.4" chrono = "0.4"
sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] } sqlx = { version = "0.8", features = ["sqlite", "macros", "chrono", "runtime-tokio"] }
jieba-rs = "0.9" jieba-rs = "0.9"

View File

@ -91,6 +91,21 @@ cargo run -- chat
CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url` 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、组装上下文、调用 AgentLoopAgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。 用户消息进入 PicoBot 后,会被转换为统一的 inbound message经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文、调用 AgentLoopAgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。
@ -242,13 +257,14 @@ Inbound 消息类型:
| `create_session` | 可选 `title` | | `create_session` | 可选 `title` |
| `list_sessions` | `include_archived` | | `list_sessions` | `include_archived` |
| `load_session` | `session_id` | | `load_session` | `session_id` |
| `get_session_history` | `session_id`,可选 `limit`(服务端限制为 12000 |
| `rename_session` | 可选 `session_id``title` | | `rename_session` | 可选 `session_id``title` |
| `archive_session` | 可选 `session_id` | | `archive_session` | 可选 `session_id` |
| `delete_session` | 可选 `session_id` | | `delete_session` | 可选 `session_id` |
| `get_slash_commands` | 无 | | `get_slash_commands` | 无 |
| `ping` | 无 | | `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。
## 测试 ## 测试

View File

@ -23,6 +23,8 @@ PicoBot 只有一个二进制,提供两种模式:
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 | | Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 |
| CLI client | `cargo run -- chat` | 运行 Ratatui UI通过 WebSocket 使用 Gateway不持有业务状态 | | CLI client | `cargo run -- chat` | 运行 Ratatui UI通过 WebSocket 使用 Gateway不持有业务状态 |
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope重连时恢复内存中的当前 dialogGateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。 Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
## 3. 组件关系 ## 3. 组件关系
@ -118,6 +120,10 @@ sequenceDiagram
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。 WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 12000 条TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session`cli_chat` 将其映射为 WebSocket `session_id`。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。
## 5. 会话模型与并发不变量 ## 5. 会话模型与并发不变量
Session ID 格式为: Session ID 格式为:

View File

@ -4,7 +4,7 @@ use std::sync::Arc;
use tokio::sync::{Mutex, mpsc}; use tokio::sync::{Mutex, mpsc};
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage}; 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 crate::session::{SessionCommand, SessionEvent, UnifiedSessionId};
use super::base::{Channel, ChannelError}; use super::base::{Channel, ChannelError};
@ -52,11 +52,12 @@ impl CliChatChannel {
pub(crate) async fn register_client( pub(crate) async fn register_client(
&self, &self,
sender: mpsc::Sender<WsOutbound>, sender: mpsc::Sender<WsOutbound>,
requested_chat_id: Option<String>,
) -> (String, Arc<Client>) { ) -> (String, Arc<Client>) {
// Each WebSocket connection gets a stable chat scope. All user input and // Each WebSocket connection gets a stable chat scope. All user input and
// dialog controls for this client stay inside that scope unless the // dialog controls for this client stay inside that scope unless the
// protocol explicitly carries a full session id. // 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 { let client = Arc::new(Client {
sender, sender,
@ -68,11 +69,12 @@ impl CliChatChannel {
.await .await
.insert(chat_id.clone(), client.clone()); .insert(chat_id.clone(), client.clone());
// Create initial session via control message // Resume the current/most-recent dialog for a stable TUI identity. Only
let session_id = match self.create_session_via_control(&chat_id, None).await { // create a dialog when this client has never connected before.
Ok((id, _title)) => id, let session_id = match self.resume_session_via_control(&chat_id).await {
Ok(id) => id,
Err(e) => { 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() UnifiedSessionId::new("cli_chat", &chat_id, crate::util::short_id()).to_string()
} }
}; };
@ -86,8 +88,14 @@ impl CliChatChannel {
(session_id, client) (session_id, client)
} }
pub(crate) async fn unregister_client(&self, chat_id: &str) { pub(crate) async fn unregister_client(&self, client: &Arc<Client>) {
self.clients.lock().await.remove(chat_id); 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 /// Handle an inbound message from a client
@ -157,10 +165,13 @@ impl CliChatChannel {
} => { } => {
let (reply_tx, mut reply_rx) = mpsc::channel(1); let (reply_tx, mut reply_rx) = mpsc::channel(1);
let session_id = if let Some(session_id) = session_id { let session_id = if let Some(session_id) = session_id {
UnifiedSessionId::parse(&session_id).ok_or_else(|| { Self::parse_client_session(&client, &session_id)?
ChannelError::Other("Invalid session ID format".to_string())
})?
} else if let Some(chat_id) = chat_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); let (current_tx, mut current_rx) = mpsc::channel(1);
bus.publish_control(ControlMessage { bus.publish_control(ControlMessage {
op: SessionCommand::GetCurrentDialog { op: SessionCommand::GetCurrentDialog {
@ -191,9 +202,7 @@ impl CliChatChannel {
let target = current_session_guard let target = current_session_guard
.clone() .clone()
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?; .ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
UnifiedSessionId::parse(&target).ok_or_else(|| { Self::parse_client_session(&client, &target)?
ChannelError::Other("Invalid session ID format".to_string())
})?
}; };
let target = session_id.to_string(); let target = session_id.to_string();
bus.publish_control(ControlMessage { 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 } => { WsInbound::RenameSession { session_id, title } => {
let target = session_id let target = session_id
.or(current_session_guard.clone()) .or(current_session_guard.clone())
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?; .ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
let (reply_tx, mut reply_rx) = mpsc::channel(1); let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = UnifiedSessionId::parse(&target) let unified_id = Self::parse_client_session(&client, &target)?;
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage { bus.publish_control(ControlMessage {
op: SessionCommand::RenameDialog { op: SessionCommand::RenameDialog {
session_id: unified_id, session_id: unified_id,
@ -383,8 +434,7 @@ impl CliChatChannel {
let was_current = current_session_guard.as_deref() == Some(&target); let was_current = current_session_guard.as_deref() == Some(&target);
let (reply_tx, mut reply_rx) = mpsc::channel(1); let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = UnifiedSessionId::parse(&target) let unified_id = Self::parse_client_session(&client, &target)?;
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage { bus.publish_control(ControlMessage {
op: SessionCommand::ArchiveDialog { op: SessionCommand::ArchiveDialog {
session_id: unified_id, session_id: unified_id,
@ -432,8 +482,7 @@ impl CliChatChannel {
.ok_or_else(|| ChannelError::Other("No active session".to_string()))?; .ok_or_else(|| ChannelError::Other("No active session".to_string()))?;
let (reply_tx, mut reply_rx) = mpsc::channel(1); let (reply_tx, mut reply_rx) = mpsc::channel(1);
let unified_id = UnifiedSessionId::parse(&target) let unified_id = Self::parse_client_session(&client, &target)?;
.ok_or_else(|| ChannelError::Other("Invalid session ID format".to_string()))?;
bus.publish_control(ControlMessage { bus.publish_control(ControlMessage {
op: SessionCommand::DeleteDialog { op: SessionCommand::DeleteDialog {
session_id: unified_id, session_id: unified_id,
@ -569,6 +618,78 @@ impl CliChatChannel {
None => Err(ChannelError::Other("Control channel closed".to_string())), None => Err(ChannelError::Other("Control channel closed".to_string())),
} }
} }
async fn resume_session_via_control(&self, chat_id: &str) -> Result<String, ChannelError> {
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<UnifiedSessionId, ChannelError> {
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] #[async_trait]
@ -598,15 +719,23 @@ impl Channel for CliChatChannel {
tracing::debug!(chat_id = %msg.chat_id, "No active CLI client for outbound message"); tracing::debug!(chat_id = %msg.chat_id, "No active CLI client for outbound message");
return Ok(()); 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 { WsOutbound::SystemNotification {
content: msg.content, content: msg.content,
session_id,
}
} else if message_type == Some("command") {
WsOutbound::CommandExecuted {
message: msg.content,
} }
} else { } else {
WsOutbound::AssistantResponse { WsOutbound::AssistantResponse {
id: crate::util::short_id(), id: crate::util::short_id(),
content: msg.content, content: msg.content,
role: "assistant".to_string(), role: "assistant".to_string(),
session_id,
} }
}; };
if client.sender.send(outbound).await.is_err() { if client.sender.send(outbound).await.is_err() {
@ -657,4 +786,35 @@ mod tests {
assert!(channel.clients.lock().await.is_empty()); 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))
);
}
} }

View File

@ -3,21 +3,27 @@ pub use crate::protocol::{WsInbound, WsOutbound, serialize_inbound, serialize_ou
mod tui; mod tui;
use crate::client::tui::app::{App, MessageRole}; 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 crate::client::tui::ui::render_ui;
use crossterm::{ use crossterm::{
event::{self, Event}, event::{self, DisableBracketedPaste, EnableBracketedPaste, Event, KeyEventKind},
execute, execute,
terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode}, terminal::{EnterAlternateScreen, LeaveAlternateScreen, disable_raw_mode, enable_raw_mode},
}; };
use futures_util::{SinkExt, StreamExt}; use futures_util::StreamExt;
use ratatui::{Terminal, prelude::CrosstermBackend}; use ratatui::{Terminal, prelude::CrosstermBackend};
use std::io; use std::io;
use std::{fs, path::PathBuf};
use tokio_tungstenite::{connect_async, tungstenite::Message}; use tokio_tungstenite::{connect_async, tungstenite::Message};
pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> { pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let (ws_stream, _) = connect_async(gateway_url).await?; let client_id = load_or_create_client_id();
tracing::info!(url = %gateway_url, "Connected to gateway"); 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 (ws_sender, ws_receiver) = ws_stream.split();
@ -27,7 +33,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
enable_raw_mode()?; enable_raw_mode()?;
let mut stdout = io::stdout(); let mut stdout = io::stdout();
execute!(stdout, EnterAlternateScreen)?; execute!(stdout, EnterAlternateScreen, EnableBracketedPaste)?;
let backend = CrosstermBackend::new(stdout); let backend = CrosstermBackend::new(stdout);
let mut terminal = Terminal::new(backend)?; let mut terminal = Terminal::new(backend)?;
terminal.clear()?; terminal.clear()?;
@ -35,33 +41,57 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
let result = run_app(&mut terminal, app).await; let result = run_app(&mut terminal, app).await;
// Cleanup terminal, ignore errors // Cleanup terminal, ignore errors
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen); let _ = execute!(
terminal.backend_mut(),
DisableBracketedPaste,
LeaveAlternateScreen
);
let _ = disable_raw_mode(); let _ = disable_raw_mode();
let _ = terminal.show_cursor(); let _ = terminal.show_cursor();
result 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( async fn run_app(
terminal: &mut Terminal<CrosstermBackend<io::Stdout>>, terminal: &mut Terminal<CrosstermBackend<io::Stdout>>,
mut app: App, mut app: App,
) -> Result<(), Box<dyn std::error::Error>> { ) -> Result<(), Box<dyn std::error::Error>> {
let mut ws_receiver = app.ws_receiver.take().unwrap(); let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new(); let mut event_reader = event::EventStream::new();
let mut ws_open = true;
// Request command list on startup send(&mut app, WsInbound::GetSlashCommands).await;
if let Some(sender) = &mut app.ws_sender { request_session_list(&mut app).await;
let inbound = WsInbound::GetSlashCommands;
if let Ok(text) = serialize_inbound(&inbound) {
let _ = sender.send(Message::Text(text.into())).await;
}
}
loop { loop {
terminal.draw(|f| render_ui(f, &app))?; terminal.draw(|f| render_ui(f, &app))?;
tokio::select! { tokio::select! {
msg = ws_receiver.next() => { msg = ws_receiver.next(), if ws_open => {
match msg { match msg {
Some(Ok(Message::Text(text))) => { Some(Ok(Message::Text(text))) => {
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) { if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
@ -70,14 +100,30 @@ async fn run_app(
} }
Some(Ok(Message::Close(_))) | None => { Some(Ok(Message::Close(_))) | None => {
tracing::info!("Gateway disconnected"); 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() => { event_result = event_reader.next() => {
if let Some(Ok(Event::Key(key))) = event_result { match event_result {
handle_key_event(&mut app, key).await; 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) { async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
match outbound { match outbound {
WsOutbound::AssistantResponse { content, .. } => { WsOutbound::AssistantResponse {
app.add_message(MessageRole::Assistant, content); 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, .. } => { 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)); app.add_message(MessageRole::System, format!("Error: {}", message));
} }
WsOutbound::SessionEstablished { session_id } => { 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, .. } => { 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 { WsOutbound::SessionList {
sessions, sessions,
@ -110,31 +178,72 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
} => { } => {
app.set_sessions(sessions); app.set_sessions(sessions);
if let Some(id) = current_session_id { 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, .. } => { 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 } => { WsOutbound::SessionDeleted { session_id } => {
if app.current_session_id.as_ref() == Some(&session_id) { if app.current_session_id.as_ref() == Some(&session_id) {
app.set_current_session(None); app.set_current_session(None);
} }
app.sessions
.retain(|session| session.session_id != session_id);
request_session_list(app).await;
} }
WsOutbound::HistoryCleared { .. } => { WsOutbound::HistoryCleared { session_id } => {
app.messages.clear(); 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 } => { WsOutbound::SlashCommandsList { commands } => {
app.set_commands(commands); app.set_commands(commands);
} }
WsOutbound::Pong => {} WsOutbound::Pong => {}
WsOutbound::CommandExecuted { message } => { WsOutbound::CommandExecuted { message } => {
app.pending_responses = app.pending_responses.saturating_sub(1);
app.status_message = None;
app.add_message(MessageRole::System, message); app.add_message(MessageRole::System, message);
request_session_list(app).await;
} }
WsOutbound::SystemNotification { content } => { WsOutbound::SystemNotification {
app.add_message(MessageRole::System, content); 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);
}
} }
} }
} }

View File

@ -1,8 +1,11 @@
use crate::protocol::{SessionSummary, SlashCommandInfo}; use crate::protocol::{HistoryMessage, SessionSummary, SlashCommandInfo};
use std::collections::VecDeque; use std::collections::VecDeque;
use tokio_tungstenite::tungstenite::Message; 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 { pub enum MessageRole {
User, User,
Assistant, Assistant,
@ -15,6 +18,25 @@ pub struct ChatMessage {
pub content: String, 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 struct App {
pub ws_sender: Option< pub ws_sender: Option<
futures_util::stream::SplitSink< futures_util::stream::SplitSink<
@ -31,26 +53,26 @@ pub struct App {
>, >,
>, >,
>, >,
pub current_session_id: Option<String>, pub current_session_id: Option<String>,
pub sessions: Vec<SessionSummary>, pub sessions: Vec<SessionSummary>,
pub selected_session: usize,
pub show_archived: bool,
pub messages: VecDeque<ChatMessage>, pub messages: VecDeque<ChatMessage>,
pub input: String, pub input: String,
/// UTF-8 byte offset. It is always maintained at a character boundary.
pub input_cursor_pos: usize, pub input_cursor_pos: usize,
pub focus: Focus,
pub modal: Option<Modal>,
pub show_help: bool, pub show_help: bool,
pub chat_scroll_offset: u16, pub chat_scroll_from_bottom: u16,
pub should_quit: bool, pub should_quit: bool,
// Quit confirmation state (double Ctrl+C to exit)
pub ctrl_c_count: u8,
pub pending_quit: bool, pub pending_quit: bool,
pub connected: bool,
// Command menu state pub pending_responses: usize,
pub status_message: Option<String>,
pub commands: Vec<SlashCommandInfo>, pub commands: Vec<SlashCommandInfo>,
pub show_command_menu: bool, pub show_command_menu: bool,
pub selected_command_idx: u16, pub selected_command_idx: usize,
} }
impl App { impl App {
@ -60,14 +82,20 @@ impl App {
ws_receiver: None, ws_receiver: None,
current_session_id: None, current_session_id: None,
sessions: Vec::new(), sessions: Vec::new(),
selected_session: 0,
show_archived: false,
messages: VecDeque::new(), messages: VecDeque::new(),
input: String::new(), input: String::new(),
input_cursor_pos: 0, input_cursor_pos: 0,
focus: Focus::Input,
modal: None,
show_help: false, show_help: false,
chat_scroll_offset: 0, chat_scroll_from_bottom: 0,
should_quit: false, should_quit: false,
ctrl_c_count: 0,
pending_quit: false, pending_quit: false,
connected: true,
pending_responses: 0,
status_message: Some("正在加载会话…".to_string()),
commands: Vec::new(), commands: Vec::new(),
show_command_menu: false, show_command_menu: false,
selected_command_idx: 0, selected_command_idx: 0,
@ -76,54 +104,161 @@ impl App {
pub fn add_message(&mut self, role: MessageRole, content: String) { pub fn add_message(&mut self, role: MessageRole, content: String) {
self.messages.push_back(ChatMessage { role, content }); 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<HistoryMessage>) {
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<SessionSummary>) { pub fn set_sessions(&mut self, sessions: Vec<SessionSummary>) {
self.sessions = sessions; 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<String>) { pub fn set_current_session(&mut self, session_id: Option<String>) {
self.current_session_id = session_id; if self.current_session_id != session_id {
self.messages.clear(); 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) { pub fn current_title(&self) -> &str {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1); 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) { pub fn selected_session_id(&self) -> Option<String> {
self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1); 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) { pub fn input_insert_char(&mut self, c: char) {
self.input.insert(self.input_cursor_pos, c); if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES {
self.input_cursor_pos += 1; 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) { pub fn input_delete_char(&mut self) {
if self.input_cursor_pos > 0 { if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) {
self.input.remove(self.input_cursor_pos - 1); self.input.drain(previous..self.input_cursor_pos);
self.input_cursor_pos -= 1; 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) { 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) { pub fn input_move_cursor_right(&mut self) {
if self.input_cursor_pos < self.input.len() { if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) {
self.input_cursor_pos += 1; self.input_cursor_pos = next;
} }
} }
pub fn input_move_cursor_to_start(&mut self) { 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) { 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 { pub fn take_input(&mut self) -> String {
@ -132,86 +267,108 @@ impl App {
input input
} }
pub fn toggle_help(&mut self) { pub fn handle_ctrl_c_for_quit(&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 {
if self.pending_quit { if self.pending_quit {
self.ctrl_c_count += 1; self.should_quit = true;
if self.ctrl_c_count >= 2 {
self.should_quit = true;
return true;
}
false
} else { } else {
self.pending_quit = true; self.pending_quit = true;
self.ctrl_c_count = 1; self.status_message = Some("再次按 Ctrl+C 退出".to_string());
false
} }
} }
/// Cancel pending quit if user presses any other key
pub fn cancel_pending_quit(&mut self) { pub fn cancel_pending_quit(&mut self) {
self.pending_quit = false; if self.pending_quit {
self.ctrl_c_count = 0; self.pending_quit = false;
self.status_message = None;
}
} }
// Command menu methods
pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) { pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) {
self.commands = commands; self.commands = commands;
} }
pub fn get_filtered_commands(&self) -> Vec<&SlashCommandInfo> { 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 self.commands
.iter() .iter()
.filter(|cmd| { .filter(|command| {
cmd.name.to_lowercase().contains(&input_lower) command.name.to_lowercase().contains(&query)
|| cmd.description.to_lowercase().contains(&input_lower) || command.description.to_lowercase().contains(&query)
|| cmd || command
.aliases .aliases
.iter() .iter()
.any(|a| a.to_lowercase().contains(&input_lower)) .any(|alias| alias.to_lowercase().starts_with(&query))
}) })
.collect() .collect()
} }
pub fn select_next_command(&mut self) { pub fn select_next_command(&mut self) {
let filtered = self.get_filtered_commands(); let len = self.get_filtered_commands().len();
if !filtered.is_empty() { if len > 0 {
self.selected_command_idx = (self.selected_command_idx + 1) % filtered.len() as u16; self.selected_command_idx = (self.selected_command_idx + 1) % len;
} }
} }
pub fn select_prev_command(&mut self) { pub fn select_previous_command(&mut self) {
let filtered = self.get_filtered_commands(); let len = self.get_filtered_commands().len();
if !filtered.is_empty() { if len > 0 {
self.selected_command_idx = if self.selected_command_idx == 0 { self.selected_command_idx = (self.selected_command_idx + len - 1) % len;
filtered.len() as u16 - 1
} else {
self.selected_command_idx - 1
};
} }
} }
pub fn get_selected_command(&self) -> Option<&SlashCommandInfo> { pub fn insert_selected_command(&mut self) {
let filtered = self.get_filtered_commands(); let command = self
filtered.get(self.selected_command_idx as usize).copied() .get_filtered_commands()
} .get(self.selected_command_idx)
.and_then(|command| command.aliases.first().cloned());
pub fn insert_command(&mut self) { if let Some(command) = command {
if let Some(cmd) = self.get_selected_command() { self.input = format!("{command} ");
// Use the first alias as the command to insert self.input_cursor_pos = self.input.len();
if let Some(alias) = cmd.aliases.first() {
self.input = alias.clone();
self.input_cursor_pos = self.input.len();
}
} }
} }
} }
fn previous_boundary(value: &str, offset: usize) -> Option<usize> {
value[..offset]
.char_indices()
.next_back()
.map(|(index, _)| index)
}
fn next_boundary(value: &str, offset: usize) -> Option<usize> {
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);
}
}

View File

@ -3,35 +3,60 @@ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::Line, text::{Line, Span},
widgets::{Block, Borders, List, ListItem}, widgets::{Block, Borders, Paragraph},
}; };
pub fn render(f: &mut Frame, area: Rect, app: &App) { pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app let content_width = area.width.saturating_sub(4).max(1) as usize;
.messages let mut lines = Vec::new();
.iter() if app.messages.is_empty() {
.map(|msg| { lines.push(Line::from("开始一段对话,或从左侧选择已有会话。"));
let (prefix, color) = match msg.role { }
MessageRole::User => ("[User] ", Color::Blue), for message in &app.messages {
MessageRole::Assistant => ("[Assistant] ", Color::Green), let (label, color) = match message.role {
MessageRole::System => ("[System] ", Color::Red), 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![ let visible_height = area.height.saturating_sub(2);
Line::from(vec![ratatui::text::Span::styled( let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
prefix, let max_scroll = line_count.saturating_sub(visible_height);
Style::default().fg(color).add_modifier(Modifier::BOLD), let scroll = max_scroll.saturating_sub(app.chat_scroll_from_bottom.min(max_scroll));
)]), let title = if app.chat_scroll_from_bottom > 0 {
Line::from(msg.content.as_str()), " 对话 · 已暂停自动滚动 "
Line::from(""), } else {
]; " 对话 "
};
ListItem::new(content) frame.render_widget(
}) Paragraph::new(lines)
.collect(); .scroll((scroll, 0))
.block(Block::default().title(title).borders(Borders::ALL)),
let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL)); area,
);
f.render_widget(list, area);
} }

View File

@ -4,7 +4,7 @@ use ratatui::{
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span}, text::{Line, Span},
widgets::{Block, Borders, List, ListItem}, widgets::{Block, Borders, List, ListItem, ListState},
}; };
pub fn render(f: &mut Frame, area: Rect, app: &App) { 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() .iter()
.enumerate() .enumerate()
.map(|(i, cmd)| { .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 { let style = if is_selected {
Style::default() Style::default()
.fg(Color::White) .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)); .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);
} }

View File

@ -2,41 +2,52 @@ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, 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) { pub fn render(frame: &mut Frame, area: Rect) {
f.render_widget(Clear, area); frame.render_widget(Clear, area);
let lines = vec![
let help_text = vec![ Line::from(Span::styled(
ListItem::new("Commands:"), "全局",
ListItem::new(" /new [title] - Archive current, start new"), Style::default().add_modifier(Modifier::BOLD),
ListItem::new(" /sessions - List all conversations"), )),
ListItem::new(" /switch <id> - Switch to conversation"), Line::from(" F1 / Ctrl+H 帮助 Tab 切换输入/会话焦点"),
ListItem::new(" /rename <t> - Rename current conversation"), Line::from(" Ctrl+N 新会话 Ctrl+S 聚焦会话列表"),
ListItem::new(" /archive - Archive current conversation"), Line::from(" Ctrl+R 重命名 Ctrl+O 显示/隐藏归档"),
ListItem::new(" /delete - Delete current conversation"), Line::from(" Ctrl+A 归档 Ctrl+D 删除"),
ListItem::new(" /compact - Trigger context compression"), Line::from(" Ctrl+L 清空历史 Ctrl+C 两次 退出"),
ListItem::new(" /info - Show session information"), Line::from(""),
ListItem::new(""), Line::from(Span::styled(
ListItem::new("Keyboard:"), "输入",
ListItem::new(" Enter - Send message"), Style::default().add_modifier(Modifier::BOLD),
ListItem::new(" Ctrl+C ×2 - Quit"), )),
ListItem::new(" ? - Show help"), Line::from(" Enter 发送 Shift/Alt+Enter 换行"),
ListItem::new(" Arrow keys - Navigate"), Line::from(" / 打开命令菜单 Tab 补全命令"),
ListItem::new(" / - Show command menu"), 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),
)),
]; ];
frame.render_widget(
let list = List::new(help_text).block( Paragraph::new(lines)
Block::default() .block(
.title("Help") Block::default()
.title_style( .title(" 帮助 ")
Style::default() .title_style(Style::default().fg(Color::Cyan))
.fg(Color::Cyan) .borders(Borders::ALL),
.add_modifier(Modifier::BOLD),
) )
.borders(Borders::ALL), .wrap(Wrap { trim: false }),
area,
); );
f.render_widget(list, area);
} }

View File

@ -1,21 +1,81 @@
use crate::client::tui::app::App; use crate::client::tui::app::{App, Focus};
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
style::{Color, Style}, 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) { pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let input = Paragraph::new(app.input.as_str()) let active = app.focus == Focus::Input && app.modal.is_none() && !app.show_help;
.style(Style::default().fg(Color::White)) let border_style = if active {
.block(Block::default().title("Input").borders(Borders::ALL)); 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); if active {
let x = area.x + 1 + cursor_col.min(inner_width.saturating_sub(1));
let cursor_x = area.x + 1 + app.input_cursor_pos as u16; let y = area.y + 1 + cursor_row.saturating_sub(vertical_scroll);
let cursor_y = area.y + 1; if x < area.right() && y < area.bottom() {
if cursor_x < area.right() && cursor_y < area.bottom() { frame.set_cursor_position((x, y));
f.set_cursor_position((cursor_x, cursor_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));
} }
} }

View File

@ -1,42 +1,60 @@
use crate::client::tui::app::App; use crate::client::tui::app::{App, Focus};
use ratatui::{ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, 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) { pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let items: Vec<ListItem> = app let items = app.sessions.iter().enumerate().map(|(index, session)| {
.sessions let selected = app.focus == Focus::Sessions && index == app.selected_session;
.iter() let current = app.current_session_id.as_ref() == Some(&session.session_id);
.map(|session| { let marker = if current { "" } else { " " };
let is_current = app.current_session_id.as_ref() == Some(&session.session_id); let archived = if session.archived_at.is_some() {
let archived = session.archived_at.is_some(); " 归档"
} else {
let mut content = if is_current { ""
format!("{}", session.title) };
} else { let style = if selected {
format!(" {}", session.title) Style::default().fg(Color::Black).bg(Color::Cyan)
}; } else if current {
Style::default()
if archived { .fg(Color::Yellow)
content.push_str(" [archived]"); .add_modifier(Modifier::BOLD)
} } else {
Style::default()
let style = if is_current { };
Style::default() ListItem::new(vec![
.fg(Color::Yellow) Line::from(vec![
.add_modifier(Modifier::BOLD) Span::raw(format!("{marker} ")),
} else { Span::styled(session.title.clone(), style),
Style::default().fg(Color::White) ]),
}; Line::from(Span::styled(
format!(" {}{archived}", session.message_count),
ListItem::new(content).style(style) Style::default().fg(Color::DarkGray),
}) )),
.collect(); ])
.style(style)
let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL)); });
let mode = if app.show_archived {
f.render_widget(list, area); "全部"
} 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);
} }

View File

@ -3,44 +3,34 @@ use ratatui::{
Frame, Frame,
layout::Rect, layout::Rect,
style::{Color, Modifier, Style}, style::{Color, Modifier, Style},
text::{Line, Span},
widgets::{Block, Borders, Paragraph}, widgets::{Block, Borders, Paragraph},
}; };
pub fn render(f: &mut Frame, area: Rect, app: &App) { pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let (title, style) = if app.pending_quit { let connection = if app.connected {
let msg = if let Some(session_id) = &app.current_session_id { Span::styled("● 已连接", Style::default().fg(Color::Green))
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),
)
} else { } else {
( Span::styled("● 已断开", Style::default().fg(Color::Red))
"PicoBot".to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
}; };
let pending = if app.pending_responses > 0 {
let paragraph = Paragraph::new(title) format!(" · {} 个请求处理中", app.pending_responses)
.style(style) } else {
.block(Block::default().borders(Borders::ALL)); String::new()
};
f.render_widget(paragraph, area); 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,
);
} }

View File

@ -1,134 +1,341 @@
use crate::client::tui::app::{App, MessageRole}; use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal};
use crate::protocol::WsInbound; use crate::protocol::{WsInbound, serialize_inbound};
use crate::protocol::serialize_inbound; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crossterm::event::{KeyCode, KeyEvent};
use futures_util::SinkExt; use futures_util::SinkExt;
use tokio_tungstenite::tungstenite::Message;
pub async fn handle_key_event(app: &mut App, key: KeyEvent) { pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
if app.show_help { if app.show_help {
match key.code { if matches!(key.code, KeyCode::Esc | KeyCode::F(1))
KeyCode::Esc | KeyCode::Char('q') => { || (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
app.toggle_help(); {
} app.show_help = false;
_ => {}
} }
return; 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 { if app.show_command_menu {
match key.code { match key.code {
KeyCode::Esc => { KeyCode::Esc => close_command_menu(app),
app.show_command_menu = false; KeyCode::Up => app.select_previous_command(),
app.selected_command_idx = 0; KeyCode::Down => app.select_next_command(),
}
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::Tab => { KeyCode::Tab => {
app.insert_command(); app.insert_selected_command();
close_command_menu(app);
} }
_ => { KeyCode::Enter if key.modifiers.is_empty() => {
// Handle normal input and check if menu should stay open app.insert_selected_command();
handle_normal_input(app, key).await; close_command_menu(app);
} }
_ => handle_input_key(app, key).await,
} }
return; return;
} }
handle_normal_input(app, key).await; if ctrl {
} match key.code {
KeyCode::Char('n') => {
async fn handle_normal_input(app: &mut App, key: KeyEvent) { send(app, WsInbound::CreateSession { title: None }).await;
// Handle Ctrl+C for quit (double press to exit) app.status_message = Some("正在创建会话…".to_string());
let is_ctrl_c = key.code == KeyCode::Char('c') }
&& key KeyCode::Char('s') => app.focus = Focus::Sessions,
.modifiers KeyCode::Char('r') => open_rename(app),
.contains(crossterm::event::KeyModifiers::CONTROL); KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)),
if is_ctrl_c { KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)),
if app.handle_ctrl_c_for_quit() { KeyCode::Char('l') => app.modal = Some(Modal::Confirm(ConfirmAction::ClearHistory)),
return; 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 { return;
app.cancel_pending_quit();
} }
match key.code { match key.code {
KeyCode::Char('?') => { KeyCode::Tab => {
app.toggle_help(); 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) => { KeyCode::Char(c) => {
app.input_insert_char(c); app.input_insert_char(c);
update_command_menu(app);
// 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;
}
} }
KeyCode::Backspace => { KeyCode::Backspace => {
app.input_delete_char(); app.input_delete_char();
update_command_menu(app);
// 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;
}
} }
KeyCode::Left => { KeyCode::Delete => app.input_delete_forward(),
app.input_move_cursor_left(); KeyCode::Left => app.input_move_cursor_left(),
} KeyCode::Right => app.input_move_cursor_right(),
KeyCode::Right => { KeyCode::Home => app.input_move_cursor_to_start(),
app.input_move_cursor_right(); KeyCode::End => app.input_move_cursor_to_end(),
} KeyCode::Up => app.scroll_chat_up(1),
KeyCode::Home => { KeyCode::Down => app.scroll_chat_down(1),
app.input_move_cursor_to_start(); KeyCode::Enter
} if key.modifiers.contains(KeyModifiers::SHIFT)
KeyCode::End => { || key.modifiers.contains(KeyModifiers::ALT) =>
app.input_move_cursor_to_end(); {
} app.input_insert_char('\n');
KeyCode::Up => {
app.scroll_chat_up();
}
KeyCode::Down => {
app.scroll_chat_down();
} }
KeyCode::Enter => { KeyCode::Enter => {
let input = app.take_input(); let input = app.take_input();
app.show_command_menu = false; close_command_menu(app);
app.selected_command_idx = 0; if !input.trim().is_empty() {
if !input.is_empty() { app.add_message(MessageRole::User, input.clone());
process_input(app, input).await; 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) { async fn handle_modal_key(app: &mut App, key: KeyEvent) {
app.add_message(MessageRole::User, input.clone()); match app.modal.take() {
if let Some(sender) = &mut app.ws_sender { Some(Modal::Confirm(action)) => match key.code {
let inbound = WsInbound::UserInput { KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
content: input, let target = target_session_id(app);
chat_id: app.current_session_id.clone(), let message = match action {
channel: None, ConfirmAction::Archive => target.map(|session_id| WsInbound::ArchiveSession {
sender_id: None, session_id: Some(session_id),
}; }),
if let Ok(text) = serialize_inbound(&inbound) { ConfirmAction::Delete => target.map(|session_id| WsInbound::DeleteSession {
let _ = sender session_id: Some(session_id),
.send(tokio_tungstenite::tungstenite::Message::Text(text.into())) }),
.await; 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<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
}

View File

@ -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 crate::client::tui::components::*;
use ratatui::{ use ratatui::{
Frame, Frame,
layout::{Constraint, Direction, Layout, Rect}, 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) { pub fn render_ui(frame: &mut Frame, app: &App) {
let size = f.area(); let area = frame.area();
let chunks = Layout::default() let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2;
let rows = Layout::default()
.direction(Direction::Vertical) .direction(Direction::Vertical)
.constraints([ .constraints([
Constraint::Length(3), Constraint::Length(3),
Constraint::Min(0), Constraint::Min(3),
Constraint::Length(5), 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() { if app.show_command_menu && !app.get_filtered_commands().is_empty() {
let menu_area = menu_above_input(chunks[2]); let height = (app.get_filtered_commands().len().min(6) + 2) as u16;
command_menu::render(f, menu_area, app); 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 { if app.show_help {
let help_area = centered_rect(60, 60, size); help_popup::render(frame, centered_rect(72, 26, area));
help_popup::render(f, help_area); }
if let Some(modal) = &app.modal {
render_modal(frame, centered_rect(64, 7, area), modal);
} }
} }
fn menu_above_input(input_area: Rect) -> Rect { fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
let max_commands = 6; // Show up to 6 commands let focus = match app.focus {
let menu_height = max_commands + 2; // +2 for borders 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 { fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) {
x: input_area.x + 1, frame.render_widget(Clear, area);
y: input_area.y.saturating_sub(menu_height), let block = Block::default()
width: input_area.width.saturating_sub(2), .borders(Borders::ALL)
height: menu_height, .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 { fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect {
let popup_layout = Layout::default() let width = max_width.min(area.width.saturating_sub(2)).max(1);
.direction(Direction::Vertical) let height = max_height.min(area.height.saturating_sub(2)).max(1);
.constraints([ Rect::new(
Constraint::Percentage((100 - percent_y) / 2), area.x + area.width.saturating_sub(width) / 2,
Constraint::Percentage(percent_y), area.y + area.height.saturating_sub(height) / 2,
Constraint::Percentage((100 - percent_y) / 2), width,
]) height,
.split(r); )
}
Layout::default()
.direction(Direction::Horizontal) #[cfg(test)]
.constraints([ mod tests {
Constraint::Percentage((100 - percent_x) / 2), use super::*;
Constraint::Percentage(percent_x), use ratatui::{Terminal, backend::TestBackend};
Constraint::Percentage((100 - percent_x) / 2),
]) #[test]
.split(popup_layout[1])[1] 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();
}
} }

View File

@ -233,13 +233,15 @@ impl GatewayState {
} }
} }
Ok(crate::session::session::HandleResult::CommandOutput(content)) => { 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 { let outbound = crate::bus::OutboundMessage {
channel: inbound.channel.clone(), channel: inbound.channel.clone(),
chat_id: inbound.chat_id.clone(), chat_id: inbound.chat_id.clone(),
content, content,
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: inbound.forwarded_metadata, metadata,
delivery: None, delivery: None,
}; };
if let Err(e) = bus.publish_outbound(outbound).await { if let Err(e) = bus.publish_outbound(outbound).await {
@ -339,6 +341,14 @@ impl GatewayState {
.await .await
.map(|session_id| SessionEvent::DialogSwitched { session_id }) .map(|session_id| SessionEvent::DialogSwitched { session_id })
.map_err(|e| ChannelError::Other(e.to_string())), .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 RenameDialog { session_id, title } => session_manager
.rename_dialog(&session_id, &title) .rename_dialog(&session_id, &title)
.await .await

View File

@ -1,21 +1,41 @@
use super::GatewayState; use super::GatewayState;
use crate::protocol::WsOutbound; use crate::protocol::WsOutbound;
use crate::protocol::serialize_outbound; use crate::protocol::serialize_outbound;
use axum::extract::State;
use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade}; use axum::extract::ws::{Message as WsMessage, WebSocket, WebSocketUpgrade};
use axum::extract::{Query, State};
use axum::response::Response; use axum::response::Response;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
use serde::Deserialize;
use std::sync::Arc; use std::sync::Arc;
use tokio::sync::mpsc; use tokio::sync::mpsc;
use tokio::time::{Duration, timeout}; use tokio::time::{Duration, timeout};
pub async fn ws_handler(ws: WebSocketUpgrade, State(state): State<Arc<GatewayState>>) -> Response { #[derive(Debug, Default, Deserialize)]
pub struct WsQuery {
client_id: Option<String>,
}
pub async fn ws_handler(
ws: WebSocketUpgrade,
Query(query): Query<WsQuery>,
State(state): State<Arc<GatewayState>>,
) -> Response {
ws.on_upgrade(|socket| async move { 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<GatewayState>) { fn valid_client_id(client_id: Option<String>) -> Option<String> {
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<GatewayState>, client_id: Option<String>) {
// Create channel for sending outbound messages to this client // Create channel for sending outbound messages to this client
let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100); let (sender, mut receiver) = mpsc::channel::<WsOutbound>(100);
@ -23,9 +43,9 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
let cli_chat_channel = state.cli_chat_channel(); let cli_chat_channel = state.cli_chat_channel();
// Register client with CliChatChannel and get initial session id // Register client with CliChatChannel and get initial session id
let (session_id, client) = cli_chat_channel.register_client(sender.clone()).await; let (session_id, client) = cli_chat_channel
let chat_id = client.chat_id().to_string(); .register_client(sender.clone(), client_id)
.await;
// Send session established message // Send session established message
let _ = sender let _ = sender
.send(WsOutbound::SessionEstablished { .send(WsOutbound::SessionEstablished {
@ -76,7 +96,7 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
} }
} }
cli_chat_channel.unregister_client(&chat_id).await; cli_chat_channel.unregister_client(&client).await;
drop(client); drop(client);
drop(sender); drop(sender);
if !writer_finished if !writer_finished
@ -89,3 +109,19 @@ async fn handle_socket(ws: WebSocket, state: Arc<GatewayState>) {
} }
tracing::info!(session_id = %session_id, "CLI session ended"); 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());
}
}

View File

@ -19,6 +19,15 @@ pub struct SlashCommandInfo {
pub aliases: Vec<String>, pub aliases: Vec<String>,
} }
#[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)] #[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")] #[serde(tag = "type")]
pub enum WsInbound { pub enum WsInbound {
@ -51,6 +60,12 @@ pub enum WsInbound {
}, },
#[serde(rename = "load_session")] #[serde(rename = "load_session")]
LoadSession { session_id: String }, LoadSession { session_id: String },
#[serde(rename = "get_session_history")]
GetSessionHistory {
session_id: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
limit: Option<u32>,
},
#[serde(rename = "rename_session")] #[serde(rename = "rename_session")]
RenameSession { RenameSession {
#[serde(default, skip_serializing_if = "Option::is_none")] #[serde(default, skip_serializing_if = "Option::is_none")]
@ -81,6 +96,8 @@ pub enum WsOutbound {
id: String, id: String,
content: String, content: String,
role: String, role: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
}, },
#[serde(rename = "error")] #[serde(rename = "error")]
Error { code: String, message: String }, Error { code: String, message: String },
@ -100,6 +117,11 @@ pub enum WsOutbound {
title: String, title: String,
message_count: i64, message_count: i64,
}, },
#[serde(rename = "session_history")]
SessionHistory {
session_id: String,
messages: Vec<HistoryMessage>,
},
#[serde(rename = "session_renamed")] #[serde(rename = "session_renamed")]
SessionRenamed { session_id: String, title: String }, SessionRenamed { session_id: String, title: String },
#[serde(rename = "session_archived")] #[serde(rename = "session_archived")]
@ -115,7 +137,11 @@ pub enum WsOutbound {
#[serde(rename = "command_executed")] #[serde(rename = "command_executed")]
CommandExecuted { message: String }, CommandExecuted { message: String },
#[serde(rename = "system_notification")] #[serde(rename = "system_notification")]
SystemNotification { content: String }, SystemNotification {
content: String,
#[serde(default, skip_serializing_if = "Option::is_none")]
session_id: Option<String>,
},
} }
pub fn parse_inbound(raw: &str) -> Result<WsInbound, serde_json::Error> { pub fn parse_inbound(raw: &str) -> Result<WsInbound, serde_json::Error> {

View File

@ -21,6 +21,11 @@ pub enum SessionCommand {
chat_id: String, chat_id: String,
dialog_id: String, dialog_id: String,
}, },
/// Load persisted messages for a dialog.
GetDialogHistory {
session_id: UnifiedSessionId,
limit: u32,
},
/// Get the current dialog for a chat /// Get the current dialog for a chat
GetCurrentDialog { channel: String, chat_id: String }, GetCurrentDialog { channel: String, chat_id: String },
/// Rename a dialog /// Rename a dialog

View File

@ -31,6 +31,11 @@ pub enum SessionEvent {
}, },
/// Dialog switched successfully /// Dialog switched successfully
DialogSwitched { session_id: UnifiedSessionId }, DialogSwitched { session_id: UnifiedSessionId },
/// Persisted dialog messages, ordered by sequence.
DialogHistory {
session_id: UnifiedSessionId,
messages: Vec<crate::storage::message::MessageMeta>,
},
/// Dialog renamed /// Dialog renamed
DialogRenamed { DialogRenamed {
session_id: UnifiedSessionId, session_id: UnifiedSessionId,

View File

@ -18,6 +18,10 @@ pub(super) type MessagePersistSnapshot = (
const SESSION_QUEUE_CAPACITY: usize = 32; const SESSION_QUEUE_CAPACITY: usize = 32;
fn outbound_session_metadata(session_id: &str) -> HashMap<String, String> {
HashMap::from([("_session_id".to_string(), session_id.to_string())])
}
tokio::task_local! { tokio::task_local! {
pub(super) static CURRENT_SOURCE_SESSION: Option<String>; pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
} }
@ -1622,6 +1626,22 @@ impl SessionManager {
Ok(unified_id) Ok(unified_id)
} }
pub async fn get_dialog_history(
&self,
session_id: &UnifiedSessionId,
limit: u32,
) -> Result<Vec<crate::storage::message::MessageMeta>, 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( pub async fn list_dialogs(
&self, &self,
channel: &str, channel: &str,
@ -1630,7 +1650,7 @@ impl SessionManager {
) -> Result<(Vec<DialogInfo>, Option<String>), AgentError> { ) -> Result<(Vec<DialogInfo>, Option<String>), AgentError> {
let metas = self let metas = self
.storage .storage
.list_sessions(channel, chat_id, 10, include_archived) .list_sessions(channel, chat_id, 100, include_archived)
.await .await
.map_err(|e| AgentError::Other(format!("failed to list dialogs: {}", e)))?; .map_err(|e| AgentError::Other(format!("failed to list dialogs: {}", e)))?;
let current_dialog_id = self let current_dialog_id = self
@ -1851,7 +1871,7 @@ impl SessionManager {
content: content.to_string(), content: content.to_string(),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&unified_id.to_string()),
delivery: None, delivery: None,
}; };
self.bus self.bus
@ -2042,6 +2062,7 @@ fn spawn_agent_worker(
'tasks: while let Some(task) = task_rx.recv().await { 'tasks: while let Some(task) = task_rx.recv().await {
let task_chan = task.channel.clone(); let task_chan = task.channel.clone();
let task_cid = task.chat_id.clone(); let task_cid = task.chat_id.clone();
let notification_session_id = unified_str.clone();
let (notify_tx, mut notify_rx) = mpsc::unbounded_channel(); 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 { while let Some(notif) = notify_rx.recv().await {
let mut metadata = HashMap::new(); let mut metadata = HashMap::new();
metadata.insert("_type".to_string(), "notification".to_string()); metadata.insert("_type".to_string(), "notification".to_string());
metadata.insert(
"_session_id".to_string(),
notification_session_id.clone(),
);
let outbound = OutboundMessage { let outbound = OutboundMessage {
channel: ch.clone(), channel: ch.clone(),
chat_id: cid.clone(), chat_id: cid.clone(),
@ -2093,7 +2118,7 @@ fn spawn_agent_worker(
content: "Failed to save your message, please try again.".to_string(), content: "Failed to save your message, please try again.".to_string(),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&unified_str),
delivery: None, delivery: None,
}; };
let _ = bus.publish_outbound(err_outbound).await; let _ = bus.publish_outbound(err_outbound).await;
@ -2120,7 +2145,7 @@ fn spawn_agent_worker(
.to_string(), .to_string(),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&unified_str),
delivery: None, delivery: None,
}; };
let _ = bus.publish_outbound(err_outbound).await; let _ = bus.publish_outbound(err_outbound).await;
@ -2230,6 +2255,7 @@ fn spawn_agent_worker(
let cid2 = task_cid.clone(); let cid2 = task_cid.clone();
let unified_str2 = unified_str.clone(); let unified_str2 = unified_str.clone();
let process_future = async move { let process_future = async move {
let response_session_id = unified_str2.clone();
let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope( let process_result = crate::agent::sub_agent::DELEGATE_CONTEXT.scope(
crate::agent::DelegateContext { crate::agent::DelegateContext {
session_id: unified_str2, session_id: unified_str2,
@ -2273,7 +2299,9 @@ fn spawn_agent_worker(
.to_string(), .to_string(),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(
&response_session_id,
),
delivery: None, delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
@ -2332,7 +2360,7 @@ fn spawn_agent_worker(
content: format!("Processing error: {}", e), content: format!("Processing error: {}", e),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&response_session_id),
delivery: None, delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
@ -2348,7 +2376,7 @@ fn spawn_agent_worker(
content: format!("Processing error: {}", e), content: format!("Processing error: {}", e),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&response_session_id),
delivery: None, delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
@ -2377,7 +2405,7 @@ fn spawn_agent_worker(
.to_string(), .to_string(),
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&response_session_id),
delivery: None, delivery: None,
}; };
let _ = bus2.publish_outbound(err_outbound).await; let _ = bus2.publish_outbound(err_outbound).await;
@ -2394,7 +2422,7 @@ fn spawn_agent_worker(
content: response, content: response,
reply_to: None, reply_to: None,
media: vec![], media: vec![],
metadata: HashMap::new(), metadata: outbound_session_metadata(&response_session_id),
delivery: None, delivery: None,
}; };
let _ = bus2.publish_outbound(outbound).await; let _ = bus2.publish_outbound(outbound).await;

View File

@ -793,6 +793,52 @@ impl Storage {
Ok(row.get::<i64, _>("max_seq")) Ok(row.get::<i64, _>("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<Vec<crate::storage::message::MessageMeta>, 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( pub async fn load_messages_after_timestamp(
&self, &self,
session_id: &str, session_id: &str,
@ -1515,6 +1561,24 @@ mod tests {
let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap(); let loaded = storage.load_messages(&session_meta.id, 0).await.unwrap();
assert_eq!(loaded.len(), 1); assert_eq!(loaded.len(), 1);
assert_eq!(loaded[0].content, "你好"); 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] #[tokio::test]

View File

@ -1,4 +1,4 @@
use picobot::protocol::{SessionSummary, WsInbound, WsOutbound}; use picobot::protocol::{HistoryMessage, SessionSummary, WsInbound, WsOutbound};
use picobot::providers::{ChatCompletionRequest, Message}; use picobot::providers::{ChatCompletionRequest, Message};
/// Test that message with special characters is properly escaped /// 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#""type":"clear_history""#));
assert!(json.contains(r#""session_id":"session-1""#)); 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:?}"),
}
}