tui客户端增强。
This commit is contained in:
parent
901b622b40
commit
27d126cf73
@ -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` |
|
||||
|
||||
@ -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"
|
||||
|
||||
18
README.md
18
README.md
@ -91,6 +91,21 @@ cargo run -- chat
|
||||
|
||||
CLI 默认连接 `ws://127.0.0.1:19876/ws`。如需指定地址,可使用 `--gateway-url`。
|
||||
|
||||
TUI 会把一个随机客户端标识保存到 `~/.picobot/tui_client_id`,因此关闭并重新打开客户端后会恢复同一组 dialog 和最近使用的会话。界面支持历史回放、会话列表与归档筛选、命令补全、Unicode/中文编辑、括号粘贴和多行输入。
|
||||
|
||||
常用快捷键:
|
||||
|
||||
| 快捷键 | 操作 |
|
||||
|--------|------|
|
||||
| `F1` / `Ctrl+H` | 打开帮助 |
|
||||
| `Tab` / `Ctrl+S` | 切换焦点 / 聚焦会话列表 |
|
||||
| `Ctrl+N` | 新建会话 |
|
||||
| `Ctrl+R` / `Ctrl+A` / `Ctrl+D` | 重命名 / 归档 / 删除所选会话 |
|
||||
| `Ctrl+L` / `Ctrl+O` | 清空历史 / 显示归档会话 |
|
||||
| `Enter` / `Shift+Enter` | 发送 / 换行 |
|
||||
| `PageUp` / `PageDown` | 滚动对话历史 |
|
||||
| 连按两次 `Ctrl+C` | 退出客户端 |
|
||||
|
||||
## 运行时数据流
|
||||
|
||||
用户消息进入 PicoBot 后,会被转换为统一的 inbound message,经由 MessageBus 交给 SessionManager。SessionManager 选择当前 dialog、组装上下文、调用 AgentLoop;AgentLoop 调用模型和工具,最终响应通过 outbound bus 回到原渠道。
|
||||
@ -242,13 +257,14 @@ Inbound 消息类型:
|
||||
| `create_session` | 可选 `title` |
|
||||
| `list_sessions` | `include_archived` |
|
||||
| `load_session` | `session_id` |
|
||||
| `get_session_history` | `session_id`,可选 `limit`(服务端限制为 1–2000) |
|
||||
| `rename_session` | 可选 `session_id`,`title` |
|
||||
| `archive_session` | 可选 `session_id` |
|
||||
| `delete_session` | 可选 `session_id` |
|
||||
| `get_slash_commands` | 无 |
|
||||
| `ping` | 无 |
|
||||
|
||||
Outbound 消息类型包括 `assistant_response`、`error`、`session_established`、`session_created`、`session_list`、`session_loaded`、`session_renamed`、`session_archived`、`session_deleted`、`history_cleared`、`slash_commands_list`、`pong`、`command_executed` 和 `system_notification`。
|
||||
Outbound 消息类型包括 `assistant_response`、`error`、`session_established`、`session_created`、`session_list`、`session_loaded`、`session_history`、`session_renamed`、`session_archived`、`session_deleted`、`history_cleared`、`slash_commands_list`、`pong`、`command_executed` 和 `system_notification`。其中异步 `assistant_response` / `system_notification` 可携带 `session_id`,客户端应避免把迟到结果显示到其他 dialog。
|
||||
|
||||
## 测试
|
||||
|
||||
|
||||
@ -23,6 +23,8 @@ PicoBot 只有一个二进制,提供两种模式:
|
||||
| Gateway | `cargo run -- gateway` | 组装服务、监听 HTTP/WebSocket、运行渠道、会话、调度器和后台任务 |
|
||||
| CLI client | `cargo run -- chat` | 运行 Ratatui UI,通过 WebSocket 使用 Gateway,不持有业务状态 |
|
||||
|
||||
CLI TUI 在 `~/.picobot/tui_client_id` 保存非敏感客户端标识,并通过 WebSocket 查询参数 `client_id` 传给 Gateway。`cli_chat` 以该标识作为稳定 chat scope;重连时恢复内存中的当前 dialog,Gateway 重启后则恢复该 scope 最近活跃的未归档 dialog。无效或缺失的标识会退化为连接级随机 scope。
|
||||
|
||||
Gateway 启动时会切换进程工作目录到 `workspace_dir`。因此所有相对路径都应按 workspace 解释,不能假设仍位于源码仓库。
|
||||
|
||||
## 3. 组件关系
|
||||
@ -118,6 +120,10 @@ sequenceDiagram
|
||||
|
||||
WebSocket dialog 操作通过 `ControlMessage` 携带一次性回复通道。Gateway 在统一 message processor 中调用 `SessionManager`,再将 `SessionEvent` 回传给发起者。Bus 只承载消息,不解释操作。
|
||||
|
||||
TUI 的历史回放同样走 control 队列:`get_session_history` 先校验 session 属于当前客户端 scope,再由 SessionManager 从 Storage 读取最近消息。单次查询限制为 1–2000 条,TUI 默认请求最近 1000 条;迟到的历史响应只有在目标仍是当前 dialog 时才允许更新界面。
|
||||
|
||||
Agent worker 发出的异步回复和通知在 OutboundMessage metadata 中标记来源 session,`cli_chat` 将其映射为 WebSocket `session_id`。TUI 切换 dialog 后不渲染其他 session 的迟到结果;结果仍按原 session 持久化,切回时通过历史回放显示。
|
||||
|
||||
## 5. 会话模型与并发不变量
|
||||
|
||||
Session ID 格式为:
|
||||
|
||||
@ -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))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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,14 +100,30 @@ async fn run_app(
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => {
|
||||
tracing::info!("Gateway disconnected");
|
||||
app.quit();
|
||||
app.connected = false;
|
||||
app.ws_sender = None;
|
||||
ws_open = false;
|
||||
app.status_message = Some("Gateway 连接已关闭;按两次 Ctrl+C 退出".to_string());
|
||||
}
|
||||
Some(Err(error)) => {
|
||||
app.connected = false;
|
||||
app.ws_sender = None;
|
||||
ws_open = false;
|
||||
app.status_message = Some(format!("Gateway 连接错误:{error}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
event_result = event_reader.next() => {
|
||||
if let Some(Ok(Event::Key(key))) = event_result {
|
||||
handle_key_event(&mut app, key).await;
|
||||
match event_result {
|
||||
Some(Ok(Event::Key(key))) if key.kind != KeyEventKind::Release => {
|
||||
handle_key_event(&mut app, key).await;
|
||||
}
|
||||
Some(Ok(Event::Paste(text))) => handle_paste(&mut app, &text),
|
||||
Some(Err(error)) => {
|
||||
app.status_message = Some(format!("终端输入错误:{error}"));
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -92,17 +138,39 @@ async fn run_app(
|
||||
|
||||
async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||
match outbound {
|
||||
WsOutbound::AssistantResponse { content, .. } => {
|
||||
app.add_message(MessageRole::Assistant, content);
|
||||
WsOutbound::AssistantResponse {
|
||||
content,
|
||||
session_id,
|
||||
..
|
||||
} => {
|
||||
app.pending_responses = app.pending_responses.saturating_sub(1);
|
||||
app.status_message = None;
|
||||
if session_id
|
||||
.as_ref()
|
||||
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
|
||||
{
|
||||
app.add_message(MessageRole::Assistant, content);
|
||||
} else {
|
||||
app.status_message = Some("另一个会话已完成响应".to_string());
|
||||
}
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::Error { message, .. } => {
|
||||
app.pending_responses = app.pending_responses.saturating_sub(1);
|
||||
app.status_message = Some(message.clone());
|
||||
app.add_message(MessageRole::System, format!("Error: {}", message));
|
||||
}
|
||||
WsOutbound::SessionEstablished { session_id } => {
|
||||
app.set_current_session(Some(session_id));
|
||||
app.connected = true;
|
||||
app.set_current_session(Some(session_id.clone()));
|
||||
request_history(app, session_id).await;
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SessionCreated { session_id, .. } => {
|
||||
app.set_current_session(Some(session_id));
|
||||
app.set_current_session(Some(session_id.clone()));
|
||||
app.status_message = None;
|
||||
request_history(app, session_id).await;
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SessionList {
|
||||
sessions,
|
||||
@ -110,31 +178,72 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||
} => {
|
||||
app.set_sessions(sessions);
|
||||
if let Some(id) = current_session_id {
|
||||
app.set_current_session(Some(id));
|
||||
let changed = app.current_session_id.as_deref() != Some(&id);
|
||||
app.set_current_session(Some(id.clone()));
|
||||
if changed {
|
||||
request_history(app, id).await;
|
||||
}
|
||||
}
|
||||
}
|
||||
WsOutbound::SessionLoaded { session_id, .. } => {
|
||||
app.set_current_session(Some(session_id));
|
||||
app.set_current_session(Some(session_id.clone()));
|
||||
request_history(app, session_id).await;
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SessionHistory {
|
||||
session_id,
|
||||
messages,
|
||||
} => app.set_history(&session_id, messages),
|
||||
WsOutbound::SessionRenamed { session_id, title } => {
|
||||
if let Some(session) = app
|
||||
.sessions
|
||||
.iter_mut()
|
||||
.find(|session| session.session_id == session_id)
|
||||
{
|
||||
session.title = title;
|
||||
}
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SessionArchived { session_id } => {
|
||||
app.sessions
|
||||
.retain(|session| session.session_id != session_id);
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SessionRenamed { .. } => {}
|
||||
WsOutbound::SessionArchived { .. } => {}
|
||||
WsOutbound::SessionDeleted { session_id } => {
|
||||
if app.current_session_id.as_ref() == Some(&session_id) {
|
||||
app.set_current_session(None);
|
||||
}
|
||||
app.sessions
|
||||
.retain(|session| session.session_id != session_id);
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::HistoryCleared { .. } => {
|
||||
app.messages.clear();
|
||||
WsOutbound::HistoryCleared { session_id } => {
|
||||
if app.current_session_id.as_deref() == Some(&session_id) {
|
||||
app.messages.clear();
|
||||
}
|
||||
app.status_message = None;
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SlashCommandsList { commands } => {
|
||||
app.set_commands(commands);
|
||||
}
|
||||
WsOutbound::Pong => {}
|
||||
WsOutbound::CommandExecuted { message } => {
|
||||
app.pending_responses = app.pending_responses.saturating_sub(1);
|
||||
app.status_message = None;
|
||||
app.add_message(MessageRole::System, message);
|
||||
request_session_list(app).await;
|
||||
}
|
||||
WsOutbound::SystemNotification { content } => {
|
||||
app.add_message(MessageRole::System, content);
|
||||
WsOutbound::SystemNotification {
|
||||
content,
|
||||
session_id,
|
||||
} => {
|
||||
if session_id
|
||||
.as_ref()
|
||||
.is_none_or(|session_id| app.current_session_id.as_ref() == Some(session_id))
|
||||
{
|
||||
app.add_message(MessageRole::System, content);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -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>) {
|
||||
self.current_session_id = session_id;
|
||||
self.messages.clear();
|
||||
if self.current_session_id != session_id {
|
||||
self.current_session_id = session_id;
|
||||
self.messages.clear();
|
||||
self.chat_scroll_from_bottom = 0;
|
||||
}
|
||||
if let Some(current) = &self.current_session_id
|
||||
&& let Some(index) = self
|
||||
.sessions
|
||||
.iter()
|
||||
.position(|session| &session.session_id == current)
|
||||
{
|
||||
self.selected_session = index;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn scroll_chat_up(&mut self) {
|
||||
self.chat_scroll_offset = self.chat_scroll_offset.saturating_add(1);
|
||||
pub fn current_title(&self) -> &str {
|
||||
self.current_session_id
|
||||
.as_ref()
|
||||
.and_then(|id| {
|
||||
self.sessions
|
||||
.iter()
|
||||
.find(|session| &session.session_id == id)
|
||||
})
|
||||
.map(|session| session.title.as_str())
|
||||
.unwrap_or("新对话")
|
||||
}
|
||||
|
||||
pub fn scroll_chat_down(&mut self) {
|
||||
self.chat_scroll_offset = self.chat_scroll_offset.saturating_sub(1);
|
||||
pub fn selected_session_id(&self) -> Option<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) {
|
||||
self.input.insert(self.input_cursor_pos, c);
|
||||
self.input_cursor_pos += 1;
|
||||
if self.input.len() + c.len_utf8() <= MAX_INPUT_BYTES {
|
||||
self.input.insert(self.input_cursor_pos, c);
|
||||
self.input_cursor_pos += c.len_utf8();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_insert_str(&mut self, text: &str) {
|
||||
let remaining = MAX_INPUT_BYTES.saturating_sub(self.input.len());
|
||||
let mut end = text.len().min(remaining);
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
self.input.insert_str(self.input_cursor_pos, &text[..end]);
|
||||
self.input_cursor_pos += end;
|
||||
}
|
||||
|
||||
pub fn input_delete_char(&mut self) {
|
||||
if self.input_cursor_pos > 0 {
|
||||
self.input.remove(self.input_cursor_pos - 1);
|
||||
self.input_cursor_pos -= 1;
|
||||
if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) {
|
||||
self.input.drain(previous..self.input_cursor_pos);
|
||||
self.input_cursor_pos = previous;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_delete_forward(&mut self) {
|
||||
if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) {
|
||||
self.input.drain(self.input_cursor_pos..next);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_move_cursor_left(&mut self) {
|
||||
self.input_cursor_pos = self.input_cursor_pos.saturating_sub(1);
|
||||
if let Some(previous) = previous_boundary(&self.input, self.input_cursor_pos) {
|
||||
self.input_cursor_pos = previous;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_move_cursor_right(&mut self) {
|
||||
if self.input_cursor_pos < self.input.len() {
|
||||
self.input_cursor_pos += 1;
|
||||
if let Some(next) = next_boundary(&self.input, self.input_cursor_pos) {
|
||||
self.input_cursor_pos = next;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn input_move_cursor_to_start(&mut self) {
|
||||
self.input_cursor_pos = 0;
|
||||
let line_start = self.input[..self.input_cursor_pos]
|
||||
.rfind('\n')
|
||||
.map_or(0, |index| index + 1);
|
||||
self.input_cursor_pos = line_start;
|
||||
}
|
||||
|
||||
pub fn input_move_cursor_to_end(&mut self) {
|
||||
self.input_cursor_pos = self.input.len();
|
||||
let tail = &self.input[self.input_cursor_pos..];
|
||||
self.input_cursor_pos += tail.find('\n').unwrap_or(tail.len());
|
||||
}
|
||||
|
||||
pub fn take_input(&mut self) -> String {
|
||||
@ -132,86 +267,108 @@ impl App {
|
||||
input
|
||||
}
|
||||
|
||||
pub fn toggle_help(&mut self) {
|
||||
self.show_help = !self.show_help;
|
||||
}
|
||||
|
||||
pub fn quit(&mut self) {
|
||||
self.should_quit = true;
|
||||
}
|
||||
|
||||
/// Handle Ctrl+C for quit confirmation (requires double press)
|
||||
pub fn handle_ctrl_c_for_quit(&mut self) -> bool {
|
||||
pub fn handle_ctrl_c_for_quit(&mut self) {
|
||||
if self.pending_quit {
|
||||
self.ctrl_c_count += 1;
|
||||
if self.ctrl_c_count >= 2 {
|
||||
self.should_quit = true;
|
||||
return true;
|
||||
}
|
||||
false
|
||||
self.should_quit = true;
|
||||
} else {
|
||||
self.pending_quit = true;
|
||||
self.ctrl_c_count = 1;
|
||||
false
|
||||
self.status_message = Some("再次按 Ctrl+C 退出".to_string());
|
||||
}
|
||||
}
|
||||
|
||||
/// Cancel pending quit if user presses any other key
|
||||
pub fn cancel_pending_quit(&mut self) {
|
||||
self.pending_quit = false;
|
||||
self.ctrl_c_count = 0;
|
||||
if self.pending_quit {
|
||||
self.pending_quit = false;
|
||||
self.status_message = None;
|
||||
}
|
||||
}
|
||||
|
||||
// Command menu methods
|
||||
pub fn set_commands(&mut self, commands: Vec<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();
|
||||
self.input_cursor_pos = self.input.len();
|
||||
}
|
||||
pub fn insert_selected_command(&mut self) {
|
||||
let command = self
|
||||
.get_filtered_commands()
|
||||
.get(self.selected_command_idx)
|
||||
.and_then(|command| command.aliases.first().cloned());
|
||||
if let Some(command) = command {
|
||||
self.input = format!("{command} ");
|
||||
self.input_cursor_pos = self.input.len();
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn previous_boundary(value: &str, offset: usize) -> Option<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);
|
||||
}
|
||||
}
|
||||
|
||||
@ -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),
|
||||
};
|
||||
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let content_width = area.width.saturating_sub(4).max(1) as usize;
|
||||
let mut lines = Vec::new();
|
||||
if app.messages.is_empty() {
|
||||
lines.push(Line::from("开始一段对话,或从左侧选择已有会话。"));
|
||||
}
|
||||
for message in &app.messages {
|
||||
let (label, color) = match message.role {
|
||||
MessageRole::User => ("你", Color::Blue),
|
||||
MessageRole::Assistant => ("PicoBot", Color::Green),
|
||||
MessageRole::System => ("系统", Color::Yellow),
|
||||
};
|
||||
lines.push(Line::from(Span::styled(
|
||||
label,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)));
|
||||
for source_line in message.content.lines() {
|
||||
let wrapped = textwrap::wrap(source_line, content_width);
|
||||
if wrapped.is_empty() {
|
||||
lines.push(Line::from(""));
|
||||
} else {
|
||||
lines.extend(
|
||||
wrapped
|
||||
.into_iter()
|
||||
.map(|line| Line::from(line.into_owned())),
|
||||
);
|
||||
}
|
||||
}
|
||||
lines.push(Line::from(""));
|
||||
}
|
||||
if app.pending_responses > 0 {
|
||||
lines.push(Line::from(Span::styled(
|
||||
"● 正在思考…",
|
||||
Style::default().fg(Color::Cyan),
|
||||
)));
|
||||
}
|
||||
|
||||
let content = vec![
|
||||
Line::from(vec![ratatui::text::Span::styled(
|
||||
prefix,
|
||||
Style::default().fg(color).add_modifier(Modifier::BOLD),
|
||||
)]),
|
||||
Line::from(msg.content.as_str()),
|
||||
Line::from(""),
|
||||
];
|
||||
|
||||
ListItem::new(content)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(Block::default().title("Conversation").borders(Borders::ALL));
|
||||
|
||||
f.render_widget(list, area);
|
||||
let visible_height = area.height.saturating_sub(2);
|
||||
let line_count = u16::try_from(lines.len()).unwrap_or(u16::MAX);
|
||||
let max_scroll = line_count.saturating_sub(visible_height);
|
||||
let scroll = max_scroll.saturating_sub(app.chat_scroll_from_bottom.min(max_scroll));
|
||||
let title = if app.chat_scroll_from_bottom > 0 {
|
||||
" 对话 · 已暂停自动滚动 "
|
||||
} else {
|
||||
" 对话 "
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.scroll((scroll, 0))
|
||||
.block(Block::default().title(title).borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
@ -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);
|
||||
}
|
||||
|
||||
@ -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(
|
||||
Block::default()
|
||||
.title("Help")
|
||||
.title_style(
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
frame.render_widget(
|
||||
Paragraph::new(lines)
|
||||
.block(
|
||||
Block::default()
|
||||
.title(" 帮助 ")
|
||||
.title_style(Style::default().fg(Color::Cyan))
|
||||
.borders(Borders::ALL),
|
||||
)
|
||||
.borders(Borders::ALL),
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
|
||||
f.render_widget(list, area);
|
||||
}
|
||||
|
||||
@ -1,21 +1,81 @@
|
||||
use crate::client::tui::app::App;
|
||||
use crate::client::tui::app::{App, Focus};
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Style},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
widgets::{Block, Borders, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
|
||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let input = Paragraph::new(app.input.as_str())
|
||||
.style(Style::default().fg(Color::White))
|
||||
.block(Block::default().title("Input").borders(Borders::ALL));
|
||||
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let active = app.focus == Focus::Input && app.modal.is_none() && !app.show_help;
|
||||
let border_style = if active {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let title = if app.connected {
|
||||
" 输入 · Enter 发送 / Shift+Enter 换行 "
|
||||
} else {
|
||||
" 输入 · Gateway 已断开 "
|
||||
};
|
||||
let inner_width = area.width.saturating_sub(2).max(1);
|
||||
let inner_height = area.height.saturating_sub(2).max(1);
|
||||
let (cursor_row, cursor_col) = cursor_position(&app.input[..app.input_cursor_pos], inner_width);
|
||||
let vertical_scroll = cursor_row.saturating_sub(inner_height.saturating_sub(1));
|
||||
frame.render_widget(
|
||||
Paragraph::new(app.input.as_str())
|
||||
.scroll((vertical_scroll, 0))
|
||||
.wrap(Wrap { trim: false })
|
||||
.style(Style::default().fg(Color::White))
|
||||
.block(
|
||||
Block::default()
|
||||
.title(title)
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border_style),
|
||||
),
|
||||
area,
|
||||
);
|
||||
|
||||
f.render_widget(input, area);
|
||||
|
||||
let cursor_x = area.x + 1 + app.input_cursor_pos as u16;
|
||||
let cursor_y = area.y + 1;
|
||||
if cursor_x < area.right() && cursor_y < area.bottom() {
|
||||
f.set_cursor_position((cursor_x, cursor_y));
|
||||
if active {
|
||||
let x = area.x + 1 + cursor_col.min(inner_width.saturating_sub(1));
|
||||
let y = area.y + 1 + cursor_row.saturating_sub(vertical_scroll);
|
||||
if x < area.right() && y < area.bottom() {
|
||||
frame.set_cursor_position((x, y));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn cursor_position(value: &str, width: u16) -> (u16, u16) {
|
||||
let mut row = 0_u16;
|
||||
let mut column = 0_u16;
|
||||
for character in value.chars() {
|
||||
if character == '\n' {
|
||||
row = row.saturating_add(1);
|
||||
column = 0;
|
||||
continue;
|
||||
}
|
||||
let character_width = character.width().unwrap_or(0) as u16;
|
||||
if column > 0 && column.saturating_add(character_width) > width {
|
||||
row = row.saturating_add(1);
|
||||
column = 0;
|
||||
}
|
||||
column = column.saturating_add(character_width);
|
||||
if column >= width {
|
||||
row = row.saturating_add(column / width);
|
||||
column %= width;
|
||||
}
|
||||
}
|
||||
(row, column)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn cursor_accounts_for_wide_characters_and_wrapping() {
|
||||
assert_eq!(cursor_position("你a", 10), (0, 3));
|
||||
assert_eq!(cursor_position("1234你", 5), (1, 2));
|
||||
}
|
||||
}
|
||||
|
||||
@ -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)
|
||||
} else {
|
||||
format!(" {}", session.title)
|
||||
};
|
||||
|
||||
if archived {
|
||||
content.push_str(" [archived]");
|
||||
}
|
||||
|
||||
let style = if is_current {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default().fg(Color::White)
|
||||
};
|
||||
|
||||
ListItem::new(content).style(style)
|
||||
})
|
||||
.collect();
|
||||
|
||||
let list = List::new(items).block(Block::default().title("Sessions").borders(Borders::ALL));
|
||||
|
||||
f.render_widget(list, area);
|
||||
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let items = app.sessions.iter().enumerate().map(|(index, session)| {
|
||||
let selected = app.focus == Focus::Sessions && index == app.selected_session;
|
||||
let current = app.current_session_id.as_ref() == Some(&session.session_id);
|
||||
let marker = if current { "●" } else { " " };
|
||||
let archived = if session.archived_at.is_some() {
|
||||
" 归档"
|
||||
} else {
|
||||
""
|
||||
};
|
||||
let style = if selected {
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan)
|
||||
} else if current {
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
ListItem::new(vec![
|
||||
Line::from(vec![
|
||||
Span::raw(format!("{marker} ")),
|
||||
Span::styled(session.title.clone(), style),
|
||||
]),
|
||||
Line::from(Span::styled(
|
||||
format!(" {} 条{archived}", session.message_count),
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
])
|
||||
.style(style)
|
||||
});
|
||||
let mode = if app.show_archived {
|
||||
"全部"
|
||||
} else {
|
||||
"活跃"
|
||||
};
|
||||
let border = if app.focus == Focus::Sessions {
|
||||
Style::default().fg(Color::Cyan)
|
||||
} else {
|
||||
Style::default()
|
||||
};
|
||||
let list = List::new(items).block(
|
||||
Block::default()
|
||||
.title(format!(" 会话 · {mode} "))
|
||||
.borders(Borders::ALL)
|
||||
.border_style(border),
|
||||
);
|
||||
let selected = (!app.sessions.is_empty()).then_some(app.selected_session);
|
||||
let mut state = ListState::default().with_selected(selected);
|
||||
frame.render_stateful_widget(list, area, &mut state);
|
||||
}
|
||||
|
||||
@ -3,44 +3,34 @@ use ratatui::{
|
||||
Frame,
|
||||
layout::Rect,
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Paragraph},
|
||||
};
|
||||
|
||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
||||
let (title, style) = if app.pending_quit {
|
||||
let msg = if let Some(session_id) = &app.current_session_id {
|
||||
format!(
|
||||
"PicoBot | Session: {} | Press Ctrl+C again to quit",
|
||||
session_id
|
||||
)
|
||||
} else {
|
||||
"PicoBot | Press Ctrl+C again to quit".to_string()
|
||||
};
|
||||
(
|
||||
msg,
|
||||
Style::default()
|
||||
.fg(Color::Yellow)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
} else if let Some(session_id) = &app.current_session_id {
|
||||
(
|
||||
format!("PicoBot | Session: {}", session_id),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
pub fn render(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let connection = if app.connected {
|
||||
Span::styled("● 已连接", Style::default().fg(Color::Green))
|
||||
} else {
|
||||
(
|
||||
"PicoBot".to_string(),
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
)
|
||||
Span::styled("● 已断开", Style::default().fg(Color::Red))
|
||||
};
|
||||
|
||||
let paragraph = Paragraph::new(title)
|
||||
.style(style)
|
||||
.block(Block::default().borders(Borders::ALL));
|
||||
|
||||
f.render_widget(paragraph, area);
|
||||
let pending = if app.pending_responses > 0 {
|
||||
format!(" · {} 个请求处理中", app.pending_responses)
|
||||
} else {
|
||||
String::new()
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(Line::from(vec![
|
||||
Span::styled(
|
||||
" PicoBot ",
|
||||
Style::default()
|
||||
.fg(Color::Cyan)
|
||||
.add_modifier(Modifier::BOLD),
|
||||
),
|
||||
Span::raw(format!("{} ", app.current_title())),
|
||||
connection,
|
||||
Span::styled(pending, Style::default().fg(Color::DarkGray)),
|
||||
]))
|
||||
.block(Block::default().borders(Borders::ALL)),
|
||||
area,
|
||||
);
|
||||
}
|
||||
|
||||
@ -1,134 +1,341 @@
|
||||
use crate::client::tui::app::{App, MessageRole};
|
||||
use crate::protocol::WsInbound;
|
||||
use crate::protocol::serialize_inbound;
|
||||
use crossterm::event::{KeyCode, KeyEvent};
|
||||
use crate::client::tui::app::{App, ConfirmAction, Focus, MessageRole, Modal};
|
||||
use crate::protocol::{WsInbound, serialize_inbound};
|
||||
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
|
||||
use futures_util::SinkExt;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
|
||||
if app.show_help {
|
||||
match key.code {
|
||||
KeyCode::Esc | KeyCode::Char('q') => {
|
||||
app.toggle_help();
|
||||
}
|
||||
_ => {}
|
||||
if matches!(key.code, KeyCode::Esc | KeyCode::F(1))
|
||||
|| (key.code == KeyCode::Char('q') && key.modifiers.is_empty())
|
||||
{
|
||||
app.show_help = false;
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
if app.modal.is_some() {
|
||||
handle_modal_key(app, key).await;
|
||||
return;
|
||||
}
|
||||
|
||||
let ctrl = key.modifiers.contains(KeyModifiers::CONTROL);
|
||||
if ctrl && key.code == KeyCode::Char('c') {
|
||||
app.handle_ctrl_c_for_quit();
|
||||
return;
|
||||
}
|
||||
app.cancel_pending_quit();
|
||||
|
||||
if matches!(key.code, KeyCode::F(1)) || (ctrl && key.code == KeyCode::Char('h')) {
|
||||
app.show_help = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if app.show_command_menu {
|
||||
match key.code {
|
||||
KeyCode::Esc => {
|
||||
app.show_command_menu = false;
|
||||
app.selected_command_idx = 0;
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.select_prev_command();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.select_next_command();
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
app.insert_command();
|
||||
app.show_command_menu = false;
|
||||
app.selected_command_idx = 0;
|
||||
}
|
||||
KeyCode::Esc => close_command_menu(app),
|
||||
KeyCode::Up => app.select_previous_command(),
|
||||
KeyCode::Down => app.select_next_command(),
|
||||
KeyCode::Tab => {
|
||||
app.insert_command();
|
||||
app.insert_selected_command();
|
||||
close_command_menu(app);
|
||||
}
|
||||
_ => {
|
||||
// Handle normal input and check if menu should stay open
|
||||
handle_normal_input(app, key).await;
|
||||
KeyCode::Enter if key.modifiers.is_empty() => {
|
||||
app.insert_selected_command();
|
||||
close_command_menu(app);
|
||||
}
|
||||
_ => handle_input_key(app, key).await,
|
||||
}
|
||||
return;
|
||||
}
|
||||
|
||||
handle_normal_input(app, key).await;
|
||||
}
|
||||
|
||||
async fn handle_normal_input(app: &mut App, key: KeyEvent) {
|
||||
// Handle Ctrl+C for quit (double press to exit)
|
||||
let is_ctrl_c = key.code == KeyCode::Char('c')
|
||||
&& key
|
||||
.modifiers
|
||||
.contains(crossterm::event::KeyModifiers::CONTROL);
|
||||
if is_ctrl_c {
|
||||
if app.handle_ctrl_c_for_quit() {
|
||||
return;
|
||||
if ctrl {
|
||||
match key.code {
|
||||
KeyCode::Char('n') => {
|
||||
send(app, WsInbound::CreateSession { title: None }).await;
|
||||
app.status_message = Some("正在创建会话…".to_string());
|
||||
}
|
||||
KeyCode::Char('s') => app.focus = Focus::Sessions,
|
||||
KeyCode::Char('r') => open_rename(app),
|
||||
KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)),
|
||||
KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)),
|
||||
KeyCode::Char('l') => app.modal = Some(Modal::Confirm(ConfirmAction::ClearHistory)),
|
||||
KeyCode::Char('o') => {
|
||||
app.show_archived = !app.show_archived;
|
||||
request_session_list(app).await;
|
||||
}
|
||||
KeyCode::Char('u') if app.focus == Focus::Input => {
|
||||
app.input.clear();
|
||||
app.input_cursor_pos = 0;
|
||||
}
|
||||
KeyCode::Up => app.scroll_chat_up(3),
|
||||
KeyCode::Down => app.scroll_chat_down(3),
|
||||
_ => {}
|
||||
}
|
||||
} else {
|
||||
app.cancel_pending_quit();
|
||||
return;
|
||||
}
|
||||
|
||||
match key.code {
|
||||
KeyCode::Char('?') => {
|
||||
app.toggle_help();
|
||||
KeyCode::Tab => {
|
||||
app.focus = match app.focus {
|
||||
Focus::Input => Focus::Sessions,
|
||||
Focus::Sessions => Focus::Input,
|
||||
};
|
||||
}
|
||||
KeyCode::Esc => app.focus = Focus::Input,
|
||||
KeyCode::PageUp => app.scroll_chat_up(10),
|
||||
KeyCode::PageDown => app.scroll_chat_down(10),
|
||||
KeyCode::Home if app.focus == Focus::Sessions => app.selected_session = 0,
|
||||
KeyCode::End if app.focus == Focus::Sessions => {
|
||||
app.selected_session = app.sessions.len().saturating_sub(1);
|
||||
}
|
||||
_ if app.focus == Focus::Sessions => handle_session_key(app, key).await,
|
||||
_ => handle_input_key(app, key).await,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn handle_paste(app: &mut App, text: &str) {
|
||||
if let Some(Modal::Rename { input, cursor }) = &mut app.modal {
|
||||
let remaining = 256_usize.saturating_sub(input.len());
|
||||
let mut end = text.len().min(remaining);
|
||||
while !text.is_char_boundary(end) {
|
||||
end -= 1;
|
||||
}
|
||||
input.insert_str(*cursor, &text[..end]);
|
||||
*cursor += end;
|
||||
} else if app.focus == Focus::Input {
|
||||
app.input_insert_str(text);
|
||||
update_command_menu(app);
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_session_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Up | KeyCode::Char('k') => app.select_previous_session(),
|
||||
KeyCode::Down | KeyCode::Char('j') => app.select_next_session(),
|
||||
KeyCode::Enter => {
|
||||
if let Some(session_id) = app.selected_session_id()
|
||||
&& app.current_session_id.as_deref() != Some(&session_id)
|
||||
{
|
||||
app.status_message = Some("正在载入会话…".to_string());
|
||||
send(app, WsInbound::LoadSession { session_id }).await;
|
||||
}
|
||||
}
|
||||
KeyCode::Char('n') => {
|
||||
send(app, WsInbound::CreateSession { title: None }).await;
|
||||
}
|
||||
KeyCode::Char('r') => open_rename(app),
|
||||
KeyCode::Char('a') => app.modal = Some(Modal::Confirm(ConfirmAction::Archive)),
|
||||
KeyCode::Char('d') => app.modal = Some(Modal::Confirm(ConfirmAction::Delete)),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_input_key(app: &mut App, key: KeyEvent) {
|
||||
match key.code {
|
||||
KeyCode::Char(c) => {
|
||||
app.input_insert_char(c);
|
||||
|
||||
// Show command menu when input starts with /
|
||||
if !app.show_command_menu
|
||||
&& (app.input == "/" || (app.input.len() > 1 && app.input.starts_with('/')))
|
||||
{
|
||||
app.show_command_menu = true;
|
||||
app.selected_command_idx = 0;
|
||||
} else if app.show_command_menu && !app.input.starts_with('/') {
|
||||
app.show_command_menu = false;
|
||||
}
|
||||
update_command_menu(app);
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
app.input_delete_char();
|
||||
|
||||
// Hide menu if input no longer starts with /
|
||||
if app.show_command_menu && !app.input.starts_with('/') {
|
||||
app.show_command_menu = false;
|
||||
app.selected_command_idx = 0;
|
||||
}
|
||||
update_command_menu(app);
|
||||
}
|
||||
KeyCode::Left => {
|
||||
app.input_move_cursor_left();
|
||||
}
|
||||
KeyCode::Right => {
|
||||
app.input_move_cursor_right();
|
||||
}
|
||||
KeyCode::Home => {
|
||||
app.input_move_cursor_to_start();
|
||||
}
|
||||
KeyCode::End => {
|
||||
app.input_move_cursor_to_end();
|
||||
}
|
||||
KeyCode::Up => {
|
||||
app.scroll_chat_up();
|
||||
}
|
||||
KeyCode::Down => {
|
||||
app.scroll_chat_down();
|
||||
KeyCode::Delete => app.input_delete_forward(),
|
||||
KeyCode::Left => app.input_move_cursor_left(),
|
||||
KeyCode::Right => app.input_move_cursor_right(),
|
||||
KeyCode::Home => app.input_move_cursor_to_start(),
|
||||
KeyCode::End => app.input_move_cursor_to_end(),
|
||||
KeyCode::Up => app.scroll_chat_up(1),
|
||||
KeyCode::Down => app.scroll_chat_down(1),
|
||||
KeyCode::Enter
|
||||
if key.modifiers.contains(KeyModifiers::SHIFT)
|
||||
|| key.modifiers.contains(KeyModifiers::ALT) =>
|
||||
{
|
||||
app.input_insert_char('\n');
|
||||
}
|
||||
KeyCode::Enter => {
|
||||
let input = app.take_input();
|
||||
app.show_command_menu = false;
|
||||
app.selected_command_idx = 0;
|
||||
if !input.is_empty() {
|
||||
process_input(app, input).await;
|
||||
close_command_menu(app);
|
||||
if !input.trim().is_empty() {
|
||||
app.add_message(MessageRole::User, input.clone());
|
||||
app.pending_responses = app.pending_responses.saturating_add(1);
|
||||
app.status_message = Some("PicoBot 正在处理…".to_string());
|
||||
let sent = send(
|
||||
app,
|
||||
WsInbound::UserInput {
|
||||
content: input,
|
||||
channel: None,
|
||||
// Session routing is owned by the server. A full session
|
||||
// id is not a chat id and must never be sent here.
|
||||
chat_id: None,
|
||||
sender_id: None,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
if !sent {
|
||||
app.pending_responses = app.pending_responses.saturating_sub(1);
|
||||
}
|
||||
}
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
|
||||
async fn process_input(app: &mut App, input: String) {
|
||||
app.add_message(MessageRole::User, input.clone());
|
||||
if let Some(sender) = &mut app.ws_sender {
|
||||
let inbound = WsInbound::UserInput {
|
||||
content: input,
|
||||
chat_id: app.current_session_id.clone(),
|
||||
channel: None,
|
||||
sender_id: None,
|
||||
};
|
||||
if let Ok(text) = serialize_inbound(&inbound) {
|
||||
let _ = sender
|
||||
.send(tokio_tungstenite::tungstenite::Message::Text(text.into()))
|
||||
.await;
|
||||
}
|
||||
async fn handle_modal_key(app: &mut App, key: KeyEvent) {
|
||||
match app.modal.take() {
|
||||
Some(Modal::Confirm(action)) => match key.code {
|
||||
KeyCode::Char('y') | KeyCode::Char('Y') | KeyCode::Enter => {
|
||||
let target = target_session_id(app);
|
||||
let message = match action {
|
||||
ConfirmAction::Archive => target.map(|session_id| WsInbound::ArchiveSession {
|
||||
session_id: Some(session_id),
|
||||
}),
|
||||
ConfirmAction::Delete => target.map(|session_id| WsInbound::DeleteSession {
|
||||
session_id: Some(session_id),
|
||||
}),
|
||||
ConfirmAction::ClearHistory => {
|
||||
target.map(|session_id| WsInbound::ClearHistory {
|
||||
chat_id: None,
|
||||
session_id: Some(session_id),
|
||||
})
|
||||
}
|
||||
};
|
||||
if let Some(message) = message {
|
||||
send(app, message).await;
|
||||
app.status_message = Some("正在更新会话…".to_string());
|
||||
}
|
||||
}
|
||||
KeyCode::Esc | KeyCode::Char('n') | KeyCode::Char('N') => {}
|
||||
_ => app.modal = Some(Modal::Confirm(action)),
|
||||
},
|
||||
Some(Modal::Rename {
|
||||
mut input,
|
||||
mut cursor,
|
||||
}) => match key.code {
|
||||
KeyCode::Esc => {}
|
||||
KeyCode::Enter => {
|
||||
let title = input.trim().to_string();
|
||||
if !title.is_empty() {
|
||||
send(
|
||||
app,
|
||||
WsInbound::RenameSession {
|
||||
session_id: target_session_id(app),
|
||||
title,
|
||||
},
|
||||
)
|
||||
.await;
|
||||
}
|
||||
}
|
||||
KeyCode::Char(character) => {
|
||||
input.insert(cursor, character);
|
||||
cursor += character.len_utf8();
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
KeyCode::Backspace => {
|
||||
if let Some((index, _)) = input[..cursor].char_indices().next_back() {
|
||||
input.drain(index..cursor);
|
||||
cursor = index;
|
||||
}
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
KeyCode::Delete => {
|
||||
if let Some(character) = input[cursor..].chars().next() {
|
||||
input.drain(cursor..cursor + character.len_utf8());
|
||||
}
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
KeyCode::Left => {
|
||||
if let Some((index, _)) = input[..cursor].char_indices().next_back() {
|
||||
cursor = index;
|
||||
}
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
KeyCode::Right => {
|
||||
if let Some(character) = input[cursor..].chars().next() {
|
||||
cursor += character.len_utf8();
|
||||
}
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
_ => app.modal = Some(Modal::Rename { input, cursor }),
|
||||
},
|
||||
None => {}
|
||||
}
|
||||
}
|
||||
|
||||
fn open_rename(app: &mut App) {
|
||||
if target_session_id(app).is_some() {
|
||||
let input = if app.focus == Focus::Sessions {
|
||||
app.sessions
|
||||
.get(app.selected_session)
|
||||
.map(|session| session.title.clone())
|
||||
.unwrap_or_default()
|
||||
} else {
|
||||
app.current_title().to_string()
|
||||
};
|
||||
let cursor = input.len();
|
||||
app.modal = Some(Modal::Rename { input, cursor });
|
||||
}
|
||||
}
|
||||
|
||||
fn target_session_id(app: &App) -> Option<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
|
||||
}
|
||||
|
||||
@ -1,73 +1,165 @@
|
||||
use crate::client::tui::app::App;
|
||||
use crate::client::tui::app::{App, ConfirmAction, Focus, Modal};
|
||||
use crate::client::tui::components::*;
|
||||
use ratatui::{
|
||||
Frame,
|
||||
layout::{Constraint, Direction, Layout, Rect},
|
||||
style::{Color, Modifier, Style},
|
||||
text::{Line, Span},
|
||||
widgets::{Block, Borders, Clear, Paragraph, Wrap},
|
||||
};
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
pub fn render_ui(f: &mut Frame, app: &App) {
|
||||
let size = f.area();
|
||||
let chunks = Layout::default()
|
||||
pub fn render_ui(frame: &mut Frame, app: &App) {
|
||||
let area = frame.area();
|
||||
let input_height = app.input.lines().count().clamp(1, 6) as u16 + 2;
|
||||
let rows = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Length(3),
|
||||
Constraint::Min(0),
|
||||
Constraint::Length(5),
|
||||
Constraint::Min(3),
|
||||
Constraint::Length(input_height),
|
||||
Constraint::Length(1),
|
||||
])
|
||||
.split(size);
|
||||
.split(area);
|
||||
|
||||
title_bar::render(f, chunks[0], app);
|
||||
title_bar::render(frame, rows[0], app);
|
||||
if area.width >= 80 {
|
||||
let columns = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Length(28), Constraint::Min(30)])
|
||||
.split(rows[1]);
|
||||
session_list::render(frame, columns[0], app);
|
||||
chat_history::render(frame, columns[1], app);
|
||||
} else {
|
||||
chat_history::render(frame, rows[1], app);
|
||||
}
|
||||
input_area::render(frame, rows[2], app);
|
||||
render_footer(frame, rows[3], app);
|
||||
|
||||
let middle_chunks = Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([Constraint::Percentage(25), Constraint::Percentage(75)])
|
||||
.split(chunks[1]);
|
||||
|
||||
session_list::render(f, middle_chunks[0], app);
|
||||
chat_history::render(f, middle_chunks[1], app);
|
||||
|
||||
input_area::render(f, chunks[2], app);
|
||||
|
||||
// Render command menu if needed - position above input area
|
||||
if app.show_command_menu && !app.get_filtered_commands().is_empty() {
|
||||
let menu_area = menu_above_input(chunks[2]);
|
||||
command_menu::render(f, menu_area, app);
|
||||
let height = (app.get_filtered_commands().len().min(6) + 2) as u16;
|
||||
let menu_area = Rect::new(
|
||||
rows[2].x.saturating_add(1),
|
||||
rows[2].y.saturating_sub(height),
|
||||
rows[2].width.saturating_sub(2),
|
||||
height,
|
||||
);
|
||||
command_menu::render(frame, menu_area, app);
|
||||
}
|
||||
|
||||
if app.show_help {
|
||||
let help_area = centered_rect(60, 60, size);
|
||||
help_popup::render(f, help_area);
|
||||
help_popup::render(frame, centered_rect(72, 26, area));
|
||||
}
|
||||
if let Some(modal) = &app.modal {
|
||||
render_modal(frame, centered_rect(64, 7, area), modal);
|
||||
}
|
||||
}
|
||||
|
||||
fn menu_above_input(input_area: Rect) -> Rect {
|
||||
let max_commands = 6; // Show up to 6 commands
|
||||
let menu_height = max_commands + 2; // +2 for borders
|
||||
fn render_footer(frame: &mut Frame, area: Rect, app: &App) {
|
||||
let focus = match app.focus {
|
||||
Focus::Input => "输入",
|
||||
Focus::Sessions => "会话",
|
||||
};
|
||||
let status =
|
||||
app.status_message
|
||||
.as_deref()
|
||||
.unwrap_or(if app.connected { "就绪" } else { "已断开" });
|
||||
let line = Line::from(vec![
|
||||
Span::styled(
|
||||
format!(" {focus} "),
|
||||
Style::default().fg(Color::Black).bg(Color::Cyan),
|
||||
),
|
||||
Span::raw(format!(" {status}")),
|
||||
Span::styled(
|
||||
" F1 帮助 Tab 切换焦点 Ctrl+N 新会话 ",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
),
|
||||
]);
|
||||
frame.render_widget(Paragraph::new(line), area);
|
||||
}
|
||||
|
||||
Rect {
|
||||
x: input_area.x + 1,
|
||||
y: input_area.y.saturating_sub(menu_height),
|
||||
width: input_area.width.saturating_sub(2),
|
||||
height: menu_height,
|
||||
fn render_modal(frame: &mut Frame, area: Rect, modal: &Modal) {
|
||||
frame.render_widget(Clear, area);
|
||||
let block = Block::default()
|
||||
.borders(Borders::ALL)
|
||||
.border_style(Style::default().fg(Color::Yellow));
|
||||
match modal {
|
||||
Modal::Rename { input, cursor } => {
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(Span::styled(
|
||||
"重命名会话",
|
||||
Style::default().add_modifier(Modifier::BOLD),
|
||||
)),
|
||||
Line::from(""),
|
||||
Line::from(input.as_str()),
|
||||
Line::from(Span::styled(
|
||||
"Enter 保存 · Esc 取消",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
])
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
let cursor_x = area.x
|
||||
+ 1
|
||||
+ UnicodeWidthStr::width(&input[..*cursor])
|
||||
.min(area.width.saturating_sub(3) as usize) as u16;
|
||||
let cursor_y = area.y.saturating_add(3);
|
||||
if cursor_x < area.right() && cursor_y < area.bottom() {
|
||||
frame.set_cursor_position((cursor_x, cursor_y));
|
||||
}
|
||||
}
|
||||
Modal::Confirm(action) => {
|
||||
let prompt = match action {
|
||||
ConfirmAction::Archive => "归档所选会话?",
|
||||
ConfirmAction::Delete => "永久删除所选会话?此操作不可撤销。",
|
||||
ConfirmAction::ClearHistory => "清空所选会话的全部历史?",
|
||||
};
|
||||
frame.render_widget(
|
||||
Paragraph::new(vec![
|
||||
Line::from(""),
|
||||
Line::from(prompt),
|
||||
Line::from(""),
|
||||
Line::from(Span::styled(
|
||||
"Y / Enter 确认 · N / Esc 取消",
|
||||
Style::default().fg(Color::DarkGray),
|
||||
)),
|
||||
])
|
||||
.block(block)
|
||||
.wrap(Wrap { trim: false }),
|
||||
area,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn centered_rect(percent_x: u16, percent_y: u16, r: Rect) -> Rect {
|
||||
let popup_layout = Layout::default()
|
||||
.direction(Direction::Vertical)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
Constraint::Percentage(percent_y),
|
||||
Constraint::Percentage((100 - percent_y) / 2),
|
||||
])
|
||||
.split(r);
|
||||
|
||||
Layout::default()
|
||||
.direction(Direction::Horizontal)
|
||||
.constraints([
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
Constraint::Percentage(percent_x),
|
||||
Constraint::Percentage((100 - percent_x) / 2),
|
||||
])
|
||||
.split(popup_layout[1])[1]
|
||||
fn centered_rect(max_width: u16, max_height: u16, area: Rect) -> Rect {
|
||||
let width = max_width.min(area.width.saturating_sub(2)).max(1);
|
||||
let height = max_height.min(area.height.saturating_sub(2)).max(1);
|
||||
Rect::new(
|
||||
area.x + area.width.saturating_sub(width) / 2,
|
||||
area.y + area.height.saturating_sub(height) / 2,
|
||||
width,
|
||||
height,
|
||||
)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use ratatui::{Terminal, backend::TestBackend};
|
||||
|
||||
#[test]
|
||||
fn narrow_terminal_renders_without_sidebar_or_panic() {
|
||||
let backend = TestBackend::new(48, 14);
|
||||
let mut terminal = Terminal::new(backend).unwrap();
|
||||
let mut app = App::new();
|
||||
app.input_insert_str("中文 input");
|
||||
app.add_message(
|
||||
crate::client::tui::app::MessageRole::Assistant,
|
||||
"一条很长的响应,用于验证窄终端换行。".repeat(4),
|
||||
);
|
||||
terminal.draw(|frame| render_ui(frame, &app)).unwrap();
|
||||
}
|
||||
}
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@ -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> {
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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;
|
||||
|
||||
@ -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]
|
||||
|
||||
@ -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:?}"),
|
||||
}
|
||||
}
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user