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`)
- `.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)
- 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` |

View File

@ -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"

View File

@ -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、组装上下文、调用 AgentLoopAgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。
@ -242,13 +257,14 @@ Inbound 消息类型:
| `create_session` | 可选 `title` |
| `list_sessions` | `include_archived` |
| `load_session` | `session_id` |
| `get_session_history` | `session_id`,可选 `limit`(服务端限制为 12000 |
| `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。
## 测试

View File

@ -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重连时恢复内存中的当前 dialogGateway 重启后则恢复该 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 读取最近消息。单次查询限制为 12000 条TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session`cli_chat` 将其映射为 WebSocket `session_id`。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。
## 5. 会话模型与并发不变量
Session ID 格式为:

View File

@ -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<WsOutbound>,
requested_chat_id: Option<String>,
) -> (String, Arc<Client>) {
// 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<Client>) {
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<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]
@ -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))
);
}
}

View File

@ -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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<dyn std::error::Error>> {
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<CrosstermBackend<io::Stdout>>,
mut app: App,
) -> Result<(), Box<dyn std::error::Error>> {
let mut ws_receiver = app.ws_receiver.take().unwrap();
let mut event_reader = event::EventStream::new();
let mut ws_open = true;
// 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::<WsOutbound>(&text) {
@ -70,15 +100,31 @@ 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 {
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, .. } => {
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 { .. } => {
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 } => {
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);
}
}
}
}

View File

@ -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<String>,
pub sessions: Vec<SessionSummary>,
pub selected_session: usize,
pub show_archived: bool,
pub messages: VecDeque<ChatMessage>,
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<Modal>,
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<String>,
pub commands: Vec<SlashCommandInfo>,
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<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>) {
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>) {
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<String> {
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) {
if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES {
self.input.insert(self.input_cursor_pos, c);
self.input_cursor_pos += 1;
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
} 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) {
if self.pending_quit {
self.pending_quit = false;
self.ctrl_c_count = 0;
self.status_message = None;
}
}
// Command menu methods
pub fn set_commands(&mut self, commands: Vec<SlashCommandInfo>) {
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();
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<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,
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<ListItem> = 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),
};
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);
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 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,
);
}

View File

@ -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);
}

View File

@ -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 <id> - Switch to conversation"),
ListItem::new(" /rename <t> - 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(
frame.render_widget(
Paragraph::new(lines)
.block(
Block::default()
.title("Help")
.title_style(
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
.title(" 帮助 ")
.title_style(Style::default().fg(Color::Cyan))
.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::{
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())
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("Input").borders(Borders::ALL));
.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));
}
}

View File

@ -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<ListItem> = 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)
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 {
format!(" {}", session.title)
""
};
if archived {
content.push_str(" [archived]");
}
let style = if is_current {
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().fg(Color::White)
Style::default()
};
ListItem::new(content).style(style)
})
.collect();
let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL));
f.render_widget(list, area);
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);
}

View File

@ -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
)
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
let connection = if app.connected {
Span::styled("● 已连接", Style::default().fg(Color::Green))
} else {
"PicoBot | Press Ctrl+C again to quit".to_string()
Span::styled("● 已断开", Style::default().fg(Color::Red))
};
(
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),
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),
)
} else {
(
"PicoBot".to_string(),
Style::default()
.fg(Color::Cyan)
.add_modifier(Modifier::BOLD),
)
};
let paragraph = Paragraph::new(title)
.style(style)
.block(Block::default().borders(Borders::ALL));
f.render_widget(paragraph, area);
),
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::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;
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),
_ => {}
}
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;
}
} else {
app.cancel_pending_quit();
}
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,
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 Ok(text) = serialize_inbound(&inbound) {
let _ = sender
.send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
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 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);
let middle_chunks = Layout::default()
title_bar::render(frame, rows[0], app);
if area.width >= 80 {
let columns = Layout::default()
.direction(Direction::Horizontal)
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
.split(chunks[1]);
.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);
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
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_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);
}
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),
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),
)),
])
.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]
.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(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();
}
}

View File

@ -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

View File

@ -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<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 {
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
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();
// 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<GatewayState>) {
}
}
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<GatewayState>) {
}
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>,
}
#[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<u32>,
},
#[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<String>,
},
#[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<HistoryMessage>,
},
#[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<String>,
},
}
pub fn parse_inbound(raw: &str) -> Result<WsInbound, serde_json::Error> {

View File

@ -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

View File

@ -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<crate::storage::message::MessageMeta>,
},
/// Dialog renamed
DialogRenamed {
session_id: UnifiedSessionId,

View File

@ -18,6 +18,10 @@ pub(super) type MessagePersistSnapshot = (
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! {
pub(super) static CURRENT_SOURCE_SESSION: Option<String>;
}
@ -1622,6 +1626,22 @@ impl SessionManager {
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(
&self,
channel: &str,
@ -1630,7 +1650,7 @@ impl SessionManager {
) -> Result<(Vec<DialogInfo>, Option<String>), 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;

View File

@ -793,6 +793,52 @@ impl Storage {
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(
&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]

View File

@ -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:?}"),
}
}