Compare commits
No commits in common. "c11eb348f9dfe99a1a99f566d2c0d05a68fea2eb" and "cc733441924d6129ce3792d587c48ae0c07dadf7" have entirely different histories.
c11eb348f9
...
cc73344192
@ -51,17 +51,14 @@ Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionM
|
|||||||
| `session` | Conversation session lifecycle, dialog operations | `SessionManager`, `Session` |
|
| `session` | Conversation session lifecycle, dialog operations | `SessionManager`, `Session` |
|
||||||
| `agent` | LLM call loop, tool execution, context compression | `AgentLoop` |
|
| `agent` | LLM call loop, tool execution, context compression | `AgentLoop` |
|
||||||
| `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` |
|
| `providers` | LLM API clients (OpenAI-compatible, Anthropic) | `LLMProvider` trait, factory `create_provider()` |
|
||||||
| `tools` | Agent tools (bash, file operations, http, web, get_skill) | `ToolRegistry`, `Tool` trait |
|
| `tools` | Agent tools (bash, file operations, http, web) | `ToolRegistry`, `Tool` trait |
|
||||||
| `skills` | Skills loading, management, and prompt building | `SkillsLoader`, `Skill` |
|
|
||||||
|
|
||||||
### Functional Boundaries
|
### Functional Boundaries
|
||||||
|
|
||||||
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
|
- **Channels** only send/receive messages via `MessageBus`; they know nothing about sessions or LLM
|
||||||
- **MessageBus** is a pure async queue; it routes nothing, just passes messages
|
- **MessageBus** is a pure async queue; it routes nothing, just passes messages
|
||||||
- **SessionManager** owns session state and dialog operations; it does NOT call LLM directly
|
- **SessionManager** owns session state and dialog operations; it does NOT call LLM directly
|
||||||
- SessionManager is responsible for injecting skills prompt into conversation history
|
|
||||||
- **AgentLoop** receives dialog events from `SessionManager`, calls LLM via `providers`, executes tools, returns text responses
|
- **AgentLoop** receives dialog events from `SessionManager`, calls LLM via `providers`, executes tools, returns text responses
|
||||||
- AgentLoop is stateless; all state is managed by Session/SessionManager
|
|
||||||
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
- **Providers** are pure HTTP clients; no bus/session/channel awareness
|
||||||
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results
|
- **Tools** are executed by `AgentLoop`; they receive raw arguments and return string results
|
||||||
|
|
||||||
@ -73,4 +70,4 @@ Channel → MessageBus → SessionManager → AgentLoop → (tools) → SessionM
|
|||||||
|
|
||||||
## Known Issues
|
## Known Issues
|
||||||
|
|
||||||
- (No known issues at this time)
|
- `src/session/session.rs:657` — `LLMProviderConfig` struct requires `workspace_dir` but test helper at line 656-669 doesn't provide it; test code needs `workspace_dir: PathBuf::new()` added
|
||||||
|
|||||||
@ -5,7 +5,7 @@ use uuid::Uuid;
|
|||||||
|
|
||||||
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
use crate::bus::{ControlMessage, InboundMessage, MessageBus, OutboundMessage};
|
||||||
use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId};
|
use crate::session::{SessionCommand, SessionEvent, UnifiedSessionId};
|
||||||
use crate::protocol::{parse_inbound, WsInbound, WsOutbound, SlashCommandInfo};
|
use crate::protocol::{parse_inbound, WsInbound, WsOutbound};
|
||||||
|
|
||||||
use super::base::{Channel, ChannelError};
|
use super::base::{Channel, ChannelError};
|
||||||
use super::slash_command::parse_slash_command;
|
use super::slash_command::parse_slash_command;
|
||||||
@ -435,43 +435,6 @@ impl CliChatChannel {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
WsInbound::GetSlashCommands => {
|
|
||||||
// Get commands from session manager via control message
|
|
||||||
let (reply_tx, mut reply_rx) = mpsc::channel(1);
|
|
||||||
bus.publish_control(ControlMessage {
|
|
||||||
op: SessionCommand::GetSlashCommands {
|
|
||||||
channel: "cli_chat".to_string(),
|
|
||||||
chat_id: "".to_string(),
|
|
||||||
},
|
|
||||||
reply_tx,
|
|
||||||
}).await?;
|
|
||||||
|
|
||||||
if let Some(result) = reply_rx.recv().await {
|
|
||||||
match result {
|
|
||||||
Ok(SessionEvent::SlashCommandsList { commands }) => {
|
|
||||||
// Convert to SlashCommand to SlashCommandInfo
|
|
||||||
let command_infos: Vec<SlashCommandInfo> = commands.into_iter().map(|cmd| {
|
|
||||||
SlashCommandInfo {
|
|
||||||
name: cmd.name.to_string(),
|
|
||||||
description: cmd.description.to_string(),
|
|
||||||
aliases: cmd.aliases.iter().map(|&a| a.to_string()).collect(),
|
|
||||||
}
|
|
||||||
}).collect();
|
|
||||||
let _ = client.sender.send(WsOutbound::SlashCommandsList { commands: command_infos }).await;
|
|
||||||
}
|
|
||||||
Ok(SessionEvent::Error { code, message }) => {
|
|
||||||
let _ = client.sender.send(WsOutbound::Error { code, message }).await;
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
let _ = client.sender.send(WsOutbound::Error {
|
|
||||||
code: "GET_COMMANDS_ERROR".to_string(),
|
|
||||||
message: e.to_string()
|
|
||||||
}).await;
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
WsInbound::Ping => {
|
WsInbound::Ping => {
|
||||||
let _ = client.sender.send(WsOutbound::Pong).await;
|
let _ = client.sender.send(WsOutbound::Pong).await;
|
||||||
}
|
}
|
||||||
|
|||||||
@ -12,7 +12,7 @@ use tokio::sync::{broadcast, RwLock};
|
|||||||
|
|
||||||
use crate::bus::{MessageBus, MediaItem, OutboundMessage};
|
use crate::bus::{MessageBus, MediaItem, OutboundMessage};
|
||||||
use crate::channels::base::{Channel, ChannelError};
|
use crate::channels::base::{Channel, ChannelError};
|
||||||
use crate::config::FeishuChannelConfig;
|
use crate::config::{FeishuChannelConfig, LLMProviderConfig};
|
||||||
|
|
||||||
const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis";
|
const FEISHU_API_BASE: &str = "https://open.feishu.cn/open-apis";
|
||||||
const FEISHU_WS_BASE: &str = "https://open.feishu.cn";
|
const FEISHU_WS_BASE: &str = "https://open.feishu.cn";
|
||||||
|
|||||||
@ -10,7 +10,7 @@ use crossterm::{
|
|||||||
execute,
|
execute,
|
||||||
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
terminal::{disable_raw_mode, enable_raw_mode, EnterAlternateScreen, LeaveAlternateScreen},
|
||||||
};
|
};
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::StreamExt;
|
||||||
use ratatui::{prelude::CrosstermBackend, Terminal};
|
use ratatui::{prelude::CrosstermBackend, Terminal};
|
||||||
use std::io;
|
use std::io;
|
||||||
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
use tokio_tungstenite::{connect_async, tungstenite::Message};
|
||||||
@ -25,6 +25,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
app.ws_sender = Some(ws_sender);
|
app.ws_sender = Some(ws_sender);
|
||||||
app.ws_receiver = Some(ws_receiver);
|
app.ws_receiver = Some(ws_receiver);
|
||||||
|
|
||||||
|
// 初始化终端
|
||||||
enable_raw_mode()?;
|
enable_raw_mode()?;
|
||||||
let mut stdout = io::stdout();
|
let mut stdout = io::stdout();
|
||||||
execute!(stdout, EnterAlternateScreen)?;
|
execute!(stdout, EnterAlternateScreen)?;
|
||||||
@ -34,7 +35,7 @@ pub async fn run(gateway_url: &str) -> Result<(), Box<dyn std::error::Error>> {
|
|||||||
|
|
||||||
let result = run_app(&mut terminal, app).await;
|
let result = run_app(&mut terminal, app).await;
|
||||||
|
|
||||||
// Cleanup terminal, ignore errors
|
// 清理终端 - 确保正确的顺序,忽略错误
|
||||||
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
let _ = execute!(terminal.backend_mut(), LeaveAlternateScreen);
|
||||||
let _ = disable_raw_mode();
|
let _ = disable_raw_mode();
|
||||||
let _ = terminal.show_cursor();
|
let _ = terminal.show_cursor();
|
||||||
@ -49,14 +50,6 @@ async fn run_app(
|
|||||||
let mut ws_receiver = app.ws_receiver.take().unwrap();
|
let mut ws_receiver = app.ws_receiver.take().unwrap();
|
||||||
let mut event_reader = event::EventStream::new();
|
let mut event_reader = event::EventStream::new();
|
||||||
|
|
||||||
// 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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
terminal.draw(|f| render_ui(f, &app))?;
|
terminal.draw(|f| render_ui(f, &app))?;
|
||||||
|
|
||||||
@ -65,7 +58,7 @@ async fn run_app(
|
|||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
|
if let Ok(outbound) = serde_json::from_str::<WsOutbound>(&text) {
|
||||||
handle_ws_message(&mut app, outbound).await;
|
handle_ws_message(&mut app, outbound);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Some(Ok(Message::Close(_))) | None => {
|
Some(Ok(Message::Close(_))) | None => {
|
||||||
@ -90,7 +83,7 @@ async fn run_app(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
||||||
match outbound {
|
match outbound {
|
||||||
WsOutbound::AssistantResponse { content, .. } => {
|
WsOutbound::AssistantResponse { content, .. } => {
|
||||||
app.add_message(MessageRole::Assistant, content);
|
app.add_message(MessageRole::Assistant, content);
|
||||||
@ -123,12 +116,6 @@ async fn handle_ws_message(app: &mut App, outbound: WsOutbound) {
|
|||||||
WsOutbound::HistoryCleared { .. } => {
|
WsOutbound::HistoryCleared { .. } => {
|
||||||
app.messages.clear();
|
app.messages.clear();
|
||||||
}
|
}
|
||||||
WsOutbound::SlashCommandsList { commands } => {
|
_ => {}
|
||||||
app.set_commands(commands);
|
|
||||||
}
|
|
||||||
WsOutbound::Pong => {}
|
|
||||||
WsOutbound::CommandExecuted { message } => {
|
|
||||||
app.add_message(MessageRole::System, message);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,4 +1,4 @@
|
|||||||
use crate::protocol::{SessionSummary, SlashCommandInfo};
|
use crate::protocol::SessionSummary;
|
||||||
use std::collections::VecDeque;
|
use std::collections::VecDeque;
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
@ -53,11 +53,6 @@ pub struct App {
|
|||||||
pub chat_scroll_offset: u16,
|
pub chat_scroll_offset: u16,
|
||||||
pub session_scroll_offset: u16,
|
pub session_scroll_offset: u16,
|
||||||
pub should_quit: bool,
|
pub should_quit: bool,
|
||||||
|
|
||||||
// Command menu state
|
|
||||||
pub commands: Vec<SlashCommandInfo>,
|
|
||||||
pub show_command_menu: bool,
|
|
||||||
pub selected_command_idx: u16,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@ -76,9 +71,6 @@ impl App {
|
|||||||
chat_scroll_offset: 0,
|
chat_scroll_offset: 0,
|
||||||
session_scroll_offset: 0,
|
session_scroll_offset: 0,
|
||||||
should_quit: false,
|
should_quit: false,
|
||||||
commands: Vec::new(),
|
|
||||||
show_command_menu: false,
|
|
||||||
selected_command_idx: 0,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -160,57 +152,4 @@ impl App {
|
|||||||
pub fn quit(&mut self) {
|
pub fn quit(&mut self) {
|
||||||
self.should_quit = true;
|
self.should_quit = true;
|
||||||
}
|
}
|
||||||
|
|
||||||
// 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();
|
|
||||||
self.commands
|
|
||||||
.iter()
|
|
||||||
.filter(|cmd| {
|
|
||||||
cmd.name.to_lowercase().contains(&input_lower)
|
|
||||||
|| cmd.description.to_lowercase().contains(&input_lower)
|
|
||||||
|| cmd
|
|
||||||
.aliases
|
|
||||||
.iter()
|
|
||||||
.any(|a| a.to_lowercase().contains(&input_lower))
|
|
||||||
})
|
|
||||||
.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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
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 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();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,52 +0,0 @@
|
|||||||
use crate::client::tui::app::App;
|
|
||||||
use ratatui::{
|
|
||||||
layout::Rect,
|
|
||||||
style::{Color, Modifier, Style},
|
|
||||||
text::{Line, Span},
|
|
||||||
widgets::{Block, Borders, List, ListItem},
|
|
||||||
Frame,
|
|
||||||
};
|
|
||||||
|
|
||||||
pub fn render(f: &mut Frame, area: Rect, app: &App) {
|
|
||||||
let filtered = app.get_filtered_commands();
|
|
||||||
|
|
||||||
if filtered.is_empty() {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
let items: Vec<ListItem> = filtered
|
|
||||||
.iter()
|
|
||||||
.enumerate()
|
|
||||||
.map(|(i, cmd)| {
|
|
||||||
let is_selected = i == app.selected_command_idx as usize;
|
|
||||||
let style = if is_selected {
|
|
||||||
Style::default()
|
|
||||||
.fg(Color::White)
|
|
||||||
.bg(Color::Blue)
|
|
||||||
.add_modifier(Modifier::BOLD)
|
|
||||||
} else {
|
|
||||||
Style::default().fg(Color::White)
|
|
||||||
};
|
|
||||||
|
|
||||||
// Show first alias as the command
|
|
||||||
let alias = cmd.aliases.first().map(|a| a.as_str()).unwrap_or(&cmd.name);
|
|
||||||
|
|
||||||
ListItem::new(Line::from(vec![
|
|
||||||
Span::styled(alias, style.clone()),
|
|
||||||
Span::styled(" - ", Style::default().fg(Color::Gray)),
|
|
||||||
Span::styled(&cmd.description, style),
|
|
||||||
]))
|
|
||||||
})
|
|
||||||
.collect();
|
|
||||||
|
|
||||||
let list = List::new(items)
|
|
||||||
.block(
|
|
||||||
Block::default()
|
|
||||||
.title("Commands")
|
|
||||||
.borders(Borders::ALL)
|
|
||||||
.border_style(Style::default().fg(Color::Cyan)),
|
|
||||||
)
|
|
||||||
.highlight_style(Style::default().add_modifier(Modifier::BOLD));
|
|
||||||
|
|
||||||
f.render_widget(list, area);
|
|
||||||
}
|
|
||||||
@ -1,5 +1,4 @@
|
|||||||
pub mod chat_history;
|
pub mod chat_history;
|
||||||
pub mod command_menu;
|
|
||||||
pub mod help_popup;
|
pub mod help_popup;
|
||||||
pub mod input_area;
|
pub mod input_area;
|
||||||
pub mod session_list;
|
pub mod session_list;
|
||||||
|
|||||||
@ -15,38 +15,6 @@ pub async fn handle_key_event(app: &mut App, key: KeyEvent) {
|
|||||||
return;
|
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::Tab => {
|
|
||||||
app.insert_command();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Handle normal input and check if menu should stay open
|
|
||||||
handle_normal_input(app, key).await;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
handle_normal_input(app, key).await;
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn handle_normal_input(app: &mut App, key: KeyEvent) {
|
|
||||||
match key.code {
|
match key.code {
|
||||||
KeyCode::Esc | KeyCode::Char('q') => {
|
KeyCode::Esc | KeyCode::Char('q') => {
|
||||||
app.quit();
|
app.quit();
|
||||||
@ -56,23 +24,9 @@ async fn handle_normal_input(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
app.input_insert_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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyCode::Backspace => {
|
KeyCode::Backspace => {
|
||||||
app.input_delete_char();
|
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;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
KeyCode::Left => {
|
KeyCode::Left => {
|
||||||
app.input_move_cursor_left();
|
app.input_move_cursor_left();
|
||||||
@ -94,8 +48,6 @@ async fn handle_normal_input(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let input = app.take_input();
|
let input = app.take_input();
|
||||||
app.show_command_menu = false;
|
|
||||||
app.selected_command_idx = 0;
|
|
||||||
if !input.is_empty() {
|
if !input.is_empty() {
|
||||||
process_input(app, input).await;
|
process_input(app, input).await;
|
||||||
}
|
}
|
||||||
@ -105,6 +57,94 @@ async fn handle_normal_input(app: &mut App, key: KeyEvent) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
async fn process_input(app: &mut App, input: String) {
|
async fn process_input(app: &mut App, input: String) {
|
||||||
|
let trimmed = input.trim();
|
||||||
|
|
||||||
|
if let Some(cmd) = parse_command(trimmed) {
|
||||||
|
match cmd {
|
||||||
|
InputCommand::Quit => {
|
||||||
|
app.quit();
|
||||||
|
}
|
||||||
|
InputCommand::Help => {
|
||||||
|
app.toggle_help();
|
||||||
|
}
|
||||||
|
InputCommand::Clear => {
|
||||||
|
if let Some(session_id) = &app.current_session_id {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::ClearHistory {
|
||||||
|
chat_id: None,
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
};
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::New(title) => {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::CreateSession { title };
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::Sessions => {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::ListSessions {
|
||||||
|
include_archived: true,
|
||||||
|
};
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::Use(session_id) => {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::LoadSession { session_id };
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::Rename(title) => {
|
||||||
|
if let Some(session_id) = &app.current_session_id {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::RenameSession {
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
title,
|
||||||
|
};
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::Archive => {
|
||||||
|
if let Some(session_id) = &app.current_session_id {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::ArchiveSession {
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
};
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
InputCommand::Delete => {
|
||||||
|
if let Some(session_id) = &app.current_session_id {
|
||||||
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
|
let inbound = WsInbound::DeleteSession {
|
||||||
|
session_id: Some(session_id.clone()),
|
||||||
|
};
|
||||||
|
if let Ok(text) = serialize_inbound(&inbound) {
|
||||||
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
app.add_message(MessageRole::User, input.clone());
|
app.add_message(MessageRole::User, input.clone());
|
||||||
if let Some(sender) = &mut app.ws_sender {
|
if let Some(sender) = &mut app.ws_sender {
|
||||||
let inbound = WsInbound::UserInput {
|
let inbound = WsInbound::UserInput {
|
||||||
@ -117,4 +157,40 @@ async fn process_input(app: &mut App, input: String) {
|
|||||||
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
let _ = sender.send(tokio_tungstenite::tungstenite::Message::Text(text.into())).await;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
enum InputCommand {
|
||||||
|
Quit,
|
||||||
|
Help,
|
||||||
|
Clear,
|
||||||
|
New(Option<String>),
|
||||||
|
Sessions,
|
||||||
|
Use(String),
|
||||||
|
Rename(String),
|
||||||
|
Archive,
|
||||||
|
Delete,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn parse_command(input: &str) -> Option<InputCommand> {
|
||||||
|
if !input.starts_with('/') {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let parts: Vec<&str> = input.splitn(2, char::is_whitespace).collect();
|
||||||
|
let cmd = parts[0];
|
||||||
|
let arg = parts.get(1).map(|s| s.trim().to_string()).filter(|s| !s.is_empty());
|
||||||
|
|
||||||
|
match cmd {
|
||||||
|
"/quit" | "/exit" | "/q" => Some(InputCommand::Quit),
|
||||||
|
"/help" | "/?" => Some(InputCommand::Help),
|
||||||
|
"/clear" | "/reset" => Some(InputCommand::Clear),
|
||||||
|
"/new" => Some(InputCommand::New(arg)),
|
||||||
|
"/sessions" => Some(InputCommand::Sessions),
|
||||||
|
"/use" => arg.map(InputCommand::Use),
|
||||||
|
"/rename" => arg.map(InputCommand::Rename),
|
||||||
|
"/archive" => Some(InputCommand::Archive),
|
||||||
|
"/delete" => Some(InputCommand::Delete),
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,10 +1,12 @@
|
|||||||
use crate::client::tui::app::App;
|
use crate::client::tui::app::App;
|
||||||
use crate::client::tui::components::*;
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
layout::{Constraint, Direction, Layout, Rect},
|
layout::{Constraint, Direction, Layout, Rect},
|
||||||
|
widgets::{Block, Borders, Clear, List, ListItem, Paragraph, Wrap},
|
||||||
Frame,
|
Frame,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
use super::components::*;
|
||||||
|
|
||||||
pub fn render_ui(f: &mut Frame, app: &App) {
|
pub fn render_ui(f: &mut Frame, app: &App) {
|
||||||
let size = f.size();
|
let size = f.size();
|
||||||
let chunks = Layout::default()
|
let chunks = Layout::default()
|
||||||
@ -28,27 +30,9 @@ pub fn render_ui(f: &mut Frame, app: &App) {
|
|||||||
|
|
||||||
input_area::render(f, chunks[2], 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);
|
|
||||||
}
|
|
||||||
|
|
||||||
if app.show_help {
|
if app.show_help {
|
||||||
let help_area = centered_rect(60, 60, size);
|
let area = centered_rect(60, 60, size);
|
||||||
help_popup::render(f, help_area);
|
help_popup::render(f, area);
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fn menu_above_input(input_area: Rect) -> Rect {
|
|
||||||
let max_commands = 6; // Show up to 6 commands
|
|
||||||
let menu_height = max_commands + 2; // +2 for borders
|
|
||||||
|
|
||||||
Rect {
|
|
||||||
x: input_area.x + 1,
|
|
||||||
y: input_area.y.saturating_sub(menu_height),
|
|
||||||
width: input_area.width.saturating_sub(2),
|
|
||||||
height: menu_height,
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -10,5 +10,4 @@ pub mod channels;
|
|||||||
pub mod logging;
|
pub mod logging;
|
||||||
pub mod observability;
|
pub mod observability;
|
||||||
pub mod storage;
|
pub mod storage;
|
||||||
pub mod skills;
|
|
||||||
pub mod tools;
|
pub mod tools;
|
||||||
|
|||||||
@ -12,13 +12,6 @@ pub struct SessionSummary {
|
|||||||
pub archived_at: Option<i64>,
|
pub archived_at: Option<i64>,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
|
||||||
pub struct SlashCommandInfo {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub aliases: Vec<String>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsInbound {
|
pub enum WsInbound {
|
||||||
@ -50,7 +43,9 @@ pub enum WsInbound {
|
|||||||
include_archived: bool,
|
include_archived: bool,
|
||||||
},
|
},
|
||||||
#[serde(rename = "load_session")]
|
#[serde(rename = "load_session")]
|
||||||
LoadSession { session_id: String },
|
LoadSession {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
#[serde(rename = "rename_session")]
|
#[serde(rename = "rename_session")]
|
||||||
RenameSession {
|
RenameSession {
|
||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
@ -67,8 +62,6 @@ pub enum WsInbound {
|
|||||||
#[serde(default, skip_serializing_if = "Option::is_none")]
|
#[serde(default, skip_serializing_if = "Option::is_none")]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
},
|
},
|
||||||
#[serde(rename = "get_slash_commands")]
|
|
||||||
GetSlashCommands,
|
|
||||||
#[serde(rename = "ping")]
|
#[serde(rename = "ping")]
|
||||||
Ping,
|
Ping,
|
||||||
}
|
}
|
||||||
@ -77,11 +70,7 @@ pub enum WsInbound {
|
|||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum WsOutbound {
|
pub enum WsOutbound {
|
||||||
#[serde(rename = "assistant_response")]
|
#[serde(rename = "assistant_response")]
|
||||||
AssistantResponse {
|
AssistantResponse { id: String, content: String, role: String },
|
||||||
id: String,
|
|
||||||
content: String,
|
|
||||||
role: String,
|
|
||||||
},
|
|
||||||
#[serde(rename = "error")]
|
#[serde(rename = "error")]
|
||||||
Error { code: String, message: String },
|
Error { code: String, message: String },
|
||||||
#[serde(rename = "session_established")]
|
#[serde(rename = "session_established")]
|
||||||
@ -108,8 +97,6 @@ pub enum WsOutbound {
|
|||||||
SessionDeleted { session_id: String },
|
SessionDeleted { session_id: String },
|
||||||
#[serde(rename = "history_cleared")]
|
#[serde(rename = "history_cleared")]
|
||||||
HistoryCleared { session_id: String },
|
HistoryCleared { session_id: String },
|
||||||
#[serde(rename = "slash_commands_list")]
|
|
||||||
SlashCommandsList { commands: Vec<SlashCommandInfo> },
|
|
||||||
#[serde(rename = "pong")]
|
#[serde(rename = "pong")]
|
||||||
Pong,
|
Pong,
|
||||||
#[serde(rename = "command_executed")]
|
#[serde(rename = "command_executed")]
|
||||||
|
|||||||
@ -1,23 +1,19 @@
|
|||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use std::time::{Duration, Instant};
|
use std::time::{Duration, Instant};
|
||||||
|
|
||||||
use tokio::sync::{Mutex, mpsc};
|
use tokio::sync::{Mutex, mpsc};
|
||||||
use uuid::Uuid;
|
use uuid::Uuid;
|
||||||
|
|
||||||
use crate::bus::ChatMessage;
|
use crate::bus::ChatMessage;
|
||||||
use crate::config::LLMProviderConfig;
|
use crate::config::LLMProviderConfig;
|
||||||
use crate::agent::{AgentLoop, AgentError, ContextCompressor};
|
use crate::agent::{AgentLoop, AgentError, ContextCompressor};
|
||||||
use crate::agent::context_compressor::ContextCompressionConfig;
|
|
||||||
use crate::protocol::WsOutbound;
|
use crate::protocol::WsOutbound;
|
||||||
use crate::providers::{create_provider, LLMProvider};
|
use crate::providers::{create_provider, LLMProvider};
|
||||||
use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID};
|
use crate::session::session_id::{UnifiedSessionId, DEFAULT_DIALOG_ID};
|
||||||
use crate::session::events::DialogInfo;
|
use crate::session::events::DialogInfo;
|
||||||
use crate::skills::SkillsLoader;
|
|
||||||
use crate::storage::{SessionRecord, SessionStore};
|
use crate::storage::{SessionRecord, SessionStore};
|
||||||
use crate::tools::{
|
use crate::tools::{
|
||||||
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
BashTool, CalculatorTool, FileEditTool, FileReadTool, FileWriteTool,
|
||||||
GetSkillTool, HttpRequestTool, ToolRegistry, WebFetchTool,
|
HttpRequestTool, ToolRegistry, WebFetchTool,
|
||||||
};
|
};
|
||||||
|
|
||||||
/// Generate a short ID (8 characters) from a UUID
|
/// Generate a short ID (8 characters) from a UUID
|
||||||
@ -50,11 +46,6 @@ impl Session {
|
|||||||
.map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?;
|
.map_err(|e| AgentError::Other(format!("provider creation error: {}", e)))?;
|
||||||
let provider: Arc<dyn LLMProvider> = Arc::from(provider_box);
|
let provider: Arc<dyn LLMProvider> = Arc::from(provider_box);
|
||||||
|
|
||||||
let compressor_config = ContextCompressionConfig {
|
|
||||||
protect_first_n: 2,
|
|
||||||
..Default::default()
|
|
||||||
};
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
id,
|
id,
|
||||||
messages: Vec::new(),
|
messages: Vec::new(),
|
||||||
@ -62,7 +53,7 @@ impl Session {
|
|||||||
provider_config: provider_config.clone(),
|
provider_config: provider_config.clone(),
|
||||||
provider: provider.clone(),
|
provider: provider.clone(),
|
||||||
tools,
|
tools,
|
||||||
compressor: ContextCompressor::with_config(provider.clone(), provider_config.token_limit, compressor_config),
|
compressor: ContextCompressor::new(provider.clone(), provider_config.token_limit),
|
||||||
store,
|
store,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@ -189,7 +180,6 @@ pub struct SessionManager {
|
|||||||
provider_config: LLMProviderConfig,
|
provider_config: LLMProviderConfig,
|
||||||
tools: Arc<ToolRegistry>,
|
tools: Arc<ToolRegistry>,
|
||||||
store: Arc<SessionStore>,
|
store: Arc<SessionStore>,
|
||||||
skills_loader: Arc<SkillsLoader>,
|
|
||||||
}
|
}
|
||||||
|
|
||||||
struct SessionManagerInner {
|
struct SessionManagerInner {
|
||||||
@ -199,7 +189,7 @@ struct SessionManagerInner {
|
|||||||
session_ttl: Duration,
|
session_ttl: Duration,
|
||||||
}
|
}
|
||||||
|
|
||||||
fn create_default_tools(skills_loader: Arc<SkillsLoader>) -> ToolRegistry {
|
fn default_tools() -> ToolRegistry {
|
||||||
let mut registry = ToolRegistry::new();
|
let mut registry = ToolRegistry::new();
|
||||||
registry.register(CalculatorTool::new());
|
registry.register(CalculatorTool::new());
|
||||||
registry.register(FileReadTool::new());
|
registry.register(FileReadTool::new());
|
||||||
@ -207,13 +197,12 @@ fn create_default_tools(skills_loader: Arc<SkillsLoader>) -> ToolRegistry {
|
|||||||
registry.register(FileEditTool::new());
|
registry.register(FileEditTool::new());
|
||||||
registry.register(BashTool::new());
|
registry.register(BashTool::new());
|
||||||
registry.register(HttpRequestTool::new(
|
registry.register(HttpRequestTool::new(
|
||||||
vec!["*".to_string()],
|
vec!["*".to_string()], // 允许所有域名,实际使用时建议限制
|
||||||
1_000_000,
|
1_000_000, // max_response_size
|
||||||
30,
|
30, // timeout_secs
|
||||||
false,
|
false, // allow_private_hosts
|
||||||
));
|
));
|
||||||
registry.register(WebFetchTool::new(50_000, 30));
|
registry.register(WebFetchTool::new(50_000, 30)); // max_chars, timeout_secs
|
||||||
registry.register(GetSkillTool::new(skills_loader));
|
|
||||||
registry
|
registry
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -252,12 +241,6 @@ impl SessionManager {
|
|||||||
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
.map_err(|err| AgentError::Other(format!("session store init error: {}", err)))?,
|
||||||
);
|
);
|
||||||
|
|
||||||
let skills_loader = SkillsLoader::new();
|
|
||||||
skills_loader.load_skills();
|
|
||||||
let skills_loader = Arc::new(skills_loader);
|
|
||||||
|
|
||||||
let tools = Arc::new(create_default_tools(skills_loader.clone()));
|
|
||||||
|
|
||||||
Ok(Self {
|
Ok(Self {
|
||||||
inner: Arc::new(Mutex::new(SessionManagerInner {
|
inner: Arc::new(Mutex::new(SessionManagerInner {
|
||||||
sessions: HashMap::new(),
|
sessions: HashMap::new(),
|
||||||
@ -265,9 +248,8 @@ impl SessionManager {
|
|||||||
session_ttl: Duration::from_secs(session_ttl_hours * 3600),
|
session_ttl: Duration::from_secs(session_ttl_hours * 3600),
|
||||||
})),
|
})),
|
||||||
provider_config,
|
provider_config,
|
||||||
tools,
|
tools: Arc::new(default_tools()),
|
||||||
store,
|
store,
|
||||||
skills_loader,
|
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -289,6 +271,7 @@ impl SessionManager {
|
|||||||
chat_id: &str,
|
chat_id: &str,
|
||||||
current_session_id: Option<&UnifiedSessionId>,
|
current_session_id: Option<&UnifiedSessionId>,
|
||||||
) -> Result<(Option<UnifiedSessionId>, String), AgentError> {
|
) -> Result<(Option<UnifiedSessionId>, String), AgentError> {
|
||||||
|
// 查找匹配的 command
|
||||||
let cmd = SLASH_COMMANDS
|
let cmd = SLASH_COMMANDS
|
||||||
.iter()
|
.iter()
|
||||||
.find(|c| c.name == command)
|
.find(|c| c.name == command)
|
||||||
@ -296,6 +279,7 @@ impl SessionManager {
|
|||||||
|
|
||||||
match cmd.name {
|
match cmd.name {
|
||||||
"reset" => {
|
"reset" => {
|
||||||
|
// Archive current session if exists
|
||||||
if let Some(sid) = current_session_id {
|
if let Some(sid) = current_session_id {
|
||||||
let unified_str = sid.to_string();
|
let unified_str = sid.to_string();
|
||||||
self.store
|
self.store
|
||||||
@ -303,6 +287,7 @@ impl SessionManager {
|
|||||||
.map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?;
|
.map_err(|e| AgentError::Other(format!("archive session error: {}", e)))?;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Create new dialog
|
||||||
let (new_id, _title) = self.create_session(channel, chat_id, None).await?;
|
let (new_id, _title) = self.create_session(channel, chat_id, None).await?;
|
||||||
Ok((Some(new_id), "Starting a fresh conversation...".to_string()))
|
Ok((Some(new_id), "Starting a fresh conversation...".to_string()))
|
||||||
}
|
}
|
||||||
@ -353,15 +338,20 @@ impl SessionManager {
|
|||||||
pub fn clear_session_messages(&self, session_id: &str) -> Result<(), AgentError> {
|
pub fn clear_session_messages(&self, session_id: &str) -> Result<(), AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.clear_messages(session_id)
|
.clear_messages(session_id)
|
||||||
.map_err(|err| AgentError::Other(format!("clear session messages error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("clear session error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn load_session_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, AgentError> {
|
pub fn load_session_messages(&self, session_id: &str) -> Result<Vec<ChatMessage>, AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.load_messages(session_id)
|
.load_messages(session_id)
|
||||||
.map_err(|err| AgentError::Other(format!("load session messages error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("load messages error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// =========================================================================
|
||||||
|
// Dialog management methods (UnifiedSessionId based)
|
||||||
|
// =========================================================================
|
||||||
|
|
||||||
|
/// Create a new session (dialog) and return (session_id, title)
|
||||||
pub async fn create_session(
|
pub async fn create_session(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -378,10 +368,12 @@ impl SessionManager {
|
|||||||
.map(ToOwned::to_owned)
|
.map(ToOwned::to_owned)
|
||||||
.unwrap_or_else(|| format!("Dialog {}", &dialog_id));
|
.unwrap_or_else(|| format!("Dialog {}", &dialog_id));
|
||||||
|
|
||||||
|
// Ensure storage record exists
|
||||||
self.store
|
self.store
|
||||||
.ensure_channel_session(channel, chat_id, &dialog_id)
|
.ensure_channel_session(channel, chat_id, &dialog_id)
|
||||||
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))?;
|
.map_err(|err| AgentError::Other(format!("create session error: {}", err)))?;
|
||||||
|
|
||||||
|
// Create session instance
|
||||||
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
||||||
let session = Session::new(
|
let session = Session::new(
|
||||||
unified_id.clone(),
|
unified_id.clone(),
|
||||||
@ -389,7 +381,8 @@ impl SessionManager {
|
|||||||
user_tx,
|
user_tx,
|
||||||
self.tools.clone(),
|
self.tools.clone(),
|
||||||
self.store.clone(),
|
self.store.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(session));
|
let arc = Arc::new(Mutex::new(session));
|
||||||
let inner = &mut *self.inner.lock().await;
|
let inner = &mut *self.inner.lock().await;
|
||||||
@ -399,16 +392,21 @@ impl SessionManager {
|
|||||||
Ok((unified_id, title))
|
Ok((unified_id, title))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get or create a session by UnifiedSessionId
|
||||||
pub async fn get_or_create_session(&self, unified_id: &UnifiedSessionId) -> Result<Arc<Mutex<Session>>, AgentError> {
|
pub async fn get_or_create_session(&self, unified_id: &UnifiedSessionId) -> Result<Arc<Mutex<Session>>, AgentError> {
|
||||||
let session_id_str = unified_id.to_string();
|
let session_id_str = unified_id.to_string();
|
||||||
let inner = &mut *self.inner.lock().await;
|
let inner = &mut *self.inner.lock().await;
|
||||||
|
|
||||||
|
// Check if session exists
|
||||||
if let Some(session) = inner.sessions.get(&session_id_str) {
|
if let Some(session) = inner.sessions.get(&session_id_str) {
|
||||||
|
// Update timestamp
|
||||||
inner.session_timestamps.insert(session_id_str, Instant::now());
|
inner.session_timestamps.insert(session_id_str, Instant::now());
|
||||||
return Ok(session.clone());
|
return Ok(session.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Check if session exists in storage
|
||||||
if let Ok(Some(_)) = self.store.get_session(&session_id_str) {
|
if let Ok(Some(_)) = self.store.get_session(&session_id_str) {
|
||||||
|
// Create session instance from storage
|
||||||
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
||||||
let session = Session::new(
|
let session = Session::new(
|
||||||
unified_id.clone(),
|
unified_id.clone(),
|
||||||
@ -416,7 +414,8 @@ impl SessionManager {
|
|||||||
user_tx,
|
user_tx,
|
||||||
self.tools.clone(),
|
self.tools.clone(),
|
||||||
self.store.clone(),
|
self.store.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(session));
|
let arc = Arc::new(Mutex::new(session));
|
||||||
inner.sessions.insert(session_id_str.clone(), arc.clone());
|
inner.sessions.insert(session_id_str.clone(), arc.clone());
|
||||||
@ -424,6 +423,7 @@ impl SessionManager {
|
|||||||
return Ok(arc);
|
return Ok(arc);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Session doesn't exist - create new directly
|
||||||
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
let (user_tx, _rx) = mpsc::channel::<WsOutbound>(100);
|
||||||
let session = Session::new(
|
let session = Session::new(
|
||||||
unified_id.clone(),
|
unified_id.clone(),
|
||||||
@ -431,7 +431,8 @@ impl SessionManager {
|
|||||||
user_tx,
|
user_tx,
|
||||||
self.tools.clone(),
|
self.tools.clone(),
|
||||||
self.store.clone(),
|
self.store.clone(),
|
||||||
).await?;
|
)
|
||||||
|
.await?;
|
||||||
|
|
||||||
let arc = Arc::new(Mutex::new(session));
|
let arc = Arc::new(Mutex::new(session));
|
||||||
inner.sessions.insert(session_id_str.clone(), arc.clone());
|
inner.sessions.insert(session_id_str.clone(), arc.clone());
|
||||||
@ -439,6 +440,7 @@ impl SessionManager {
|
|||||||
Ok(arc)
|
Ok(arc)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all dialogs for a chat scope (internal)
|
||||||
async fn list_dialogs_for_chat(
|
async fn list_dialogs_for_chat(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -452,6 +454,7 @@ impl SessionManager {
|
|||||||
let dialogs: Vec<DialogInfo> = records
|
let dialogs: Vec<DialogInfo> = records
|
||||||
.into_iter()
|
.into_iter()
|
||||||
.filter(|r| {
|
.filter(|r| {
|
||||||
|
// Filter to only dialogs for this chat_id
|
||||||
if let Some(sid) = UnifiedSessionId::parse(&r.id) {
|
if let Some(sid) = UnifiedSessionId::parse(&r.id) {
|
||||||
sid.chat_id == chat_id
|
sid.chat_id == chat_id
|
||||||
} else {
|
} else {
|
||||||
@ -474,6 +477,7 @@ impl SessionManager {
|
|||||||
Ok(dialogs)
|
Ok(dialogs)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get the most recent dialog for a chat scope (from storage)
|
||||||
pub async fn get_most_recent_dialog(
|
pub async fn get_most_recent_dialog(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -497,12 +501,14 @@ impl SessionManager {
|
|||||||
Ok(most_recent.map(|r| UnifiedSessionId::parse(&r.id).unwrap()))
|
Ok(most_recent.map(|r| UnifiedSessionId::parse(&r.id).unwrap()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Rename a dialog
|
||||||
pub fn rename_dialog(&self, session_id: &UnifiedSessionId, title: &str) -> Result<(), AgentError> {
|
pub fn rename_dialog(&self, session_id: &UnifiedSessionId, title: &str) -> Result<(), AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.rename_session(&session_id.to_string(), title)
|
.rename_session(&session_id.to_string(), title)
|
||||||
.map_err(|err| AgentError::Other(format!("rename dialog error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("rename dialog error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Create a new dialog (wrapper for create_session to match gateway interface)
|
||||||
pub async fn create_dialog(
|
pub async fn create_dialog(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -512,6 +518,7 @@ impl SessionManager {
|
|||||||
self.create_session(channel, chat_id, title).await
|
self.create_session(channel, chat_id, title).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Get current dialog for a chat (wrapper for get_most_recent_dialog)
|
||||||
pub async fn get_current_dialog(
|
pub async fn get_current_dialog(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -520,6 +527,8 @@ impl SessionManager {
|
|||||||
self.get_most_recent_dialog(channel, chat_id).await
|
self.get_most_recent_dialog(channel, chat_id).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Switch to a different dialog - not applicable in new architecture
|
||||||
|
/// Each Session IS a dialog, so switching is just loading that session
|
||||||
pub async fn switch_dialog(
|
pub async fn switch_dialog(
|
||||||
&self,
|
&self,
|
||||||
_channel: &str,
|
_channel: &str,
|
||||||
@ -529,6 +538,7 @@ impl SessionManager {
|
|||||||
Err(AgentError::Other("switch_dialog not applicable in new architecture".to_string()))
|
Err(AgentError::Other("switch_dialog not applicable in new architecture".to_string()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// List all dialogs for a chat scope (returns tuple for gateway compatibility)
|
||||||
pub async fn list_dialogs(
|
pub async fn list_dialogs(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -540,24 +550,28 @@ impl SessionManager {
|
|||||||
Ok((dialogs, current.map(|id| id.to_string())))
|
Ok((dialogs, current.map(|id| id.to_string())))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Archive a dialog
|
||||||
pub fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
pub fn archive_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.archive_session(&session_id.to_string())
|
.archive_session(&session_id.to_string())
|
||||||
.map_err(|err| AgentError::Other(format!("archive dialog error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("archive dialog error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Delete a dialog
|
||||||
pub fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
pub fn delete_dialog(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.delete_session(&session_id.to_string())
|
.delete_session(&session_id.to_string())
|
||||||
.map_err(|err| AgentError::Other(format!("delete dialog error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("delete dialog error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Clear dialog history
|
||||||
pub fn clear_dialog_history(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
pub fn clear_dialog_history(&self, session_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
||||||
self.store
|
self.store
|
||||||
.clear_messages(&session_id.to_string())
|
.clear_messages(&session_id.to_string())
|
||||||
.map_err(|err| AgentError::Other(format!("clear dialog history error: {}", err)))
|
.map_err(|err| AgentError::Other(format!("clear dialog history error: {}", err)))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 处理消息:路由到对应 session 的 agent
|
||||||
pub async fn handle_message(
|
pub async fn handle_message(
|
||||||
&self,
|
&self,
|
||||||
channel: &str,
|
channel: &str,
|
||||||
@ -567,14 +581,21 @@ impl SessionManager {
|
|||||||
content: &str,
|
content: &str,
|
||||||
media: Vec<crate::bus::MediaItem>,
|
media: Vec<crate::bus::MediaItem>,
|
||||||
) -> Result<String, AgentError> {
|
) -> Result<String, AgentError> {
|
||||||
|
// 确定 dialog_id
|
||||||
let dialog_id = dialog_id.unwrap_or(DEFAULT_DIALOG_ID);
|
let dialog_id = dialog_id.unwrap_or(DEFAULT_DIALOG_ID);
|
||||||
|
|
||||||
|
// 获取或创建 session
|
||||||
let unified_id = UnifiedSessionId::new(channel, chat_id, dialog_id);
|
let unified_id = UnifiedSessionId::new(channel, chat_id, dialog_id);
|
||||||
let session = self.get_or_create_session(&unified_id).await?;
|
let session = self.get_or_create_session(&unified_id).await?;
|
||||||
|
|
||||||
|
// 处理消息
|
||||||
let response: String = {
|
let response: String = {
|
||||||
let mut session_guard = session.lock().await;
|
let mut session_guard = session.lock().await;
|
||||||
|
|
||||||
|
// 确保 session 持久化记录存在
|
||||||
session_guard.ensure_persistent_session()?;
|
session_guard.ensure_persistent_session()?;
|
||||||
|
|
||||||
|
// 添加用户消息到历史
|
||||||
let media_refs: Vec<String> = media.iter().map(|m| m.path.clone()).collect();
|
let media_refs: Vec<String> = media.iter().map(|m| m.path.clone()).collect();
|
||||||
#[cfg(debug_assertions)]
|
#[cfg(debug_assertions)]
|
||||||
if !media_refs.is_empty() {
|
if !media_refs.is_empty() {
|
||||||
@ -585,24 +606,20 @@ impl SessionManager {
|
|||||||
session_guard.add_message(user_message.clone());
|
session_guard.add_message(user_message.clone());
|
||||||
session_guard.append_message(&user_message)?;
|
session_guard.append_message(&user_message)?;
|
||||||
|
|
||||||
|
// 加载历史
|
||||||
session_guard.load_history()?;
|
session_guard.load_history()?;
|
||||||
|
|
||||||
let mut history = session_guard.get_history().to_vec();
|
// 压缩历史(如果需要)
|
||||||
|
let history = session_guard.get_history().to_vec();
|
||||||
let skills_prompt = self.skills_loader.build_skills_prompt();
|
|
||||||
if !skills_prompt.is_empty() {
|
|
||||||
let skills_message = ChatMessage::system(skills_prompt);
|
|
||||||
history.insert(0, skills_message);
|
|
||||||
tracing::debug!("Injected skills into context");
|
|
||||||
}
|
|
||||||
|
|
||||||
let history = session_guard.compressor
|
let history = session_guard.compressor
|
||||||
.compress_if_needed(history)
|
.compress_if_needed(history)
|
||||||
.await?;
|
.await?;
|
||||||
|
|
||||||
|
// 创建 agent 并处理
|
||||||
let agent = session_guard.create_agent()?;
|
let agent = session_guard.create_agent()?;
|
||||||
let result = agent.process(history).await?;
|
let result = agent.process(history).await?;
|
||||||
|
|
||||||
|
// 持久化 assistant 消息
|
||||||
for msg in &result.emitted_messages {
|
for msg in &result.emitted_messages {
|
||||||
session_guard.append_message(msg)?;
|
session_guard.append_message(msg)?;
|
||||||
}
|
}
|
||||||
@ -621,6 +638,7 @@ impl SessionManager {
|
|||||||
Ok(response)
|
Ok(response)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// 清除指定 session 的所有历史
|
||||||
pub async fn clear_session_history(&self, unified_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
pub async fn clear_session_history(&self, unified_id: &UnifiedSessionId) -> Result<(), AgentError> {
|
||||||
let session = self.get_or_create_session(unified_id).await?;
|
let session = self.get_or_create_session(unified_id).await?;
|
||||||
let mut session_guard = session.lock().await;
|
let mut session_guard = session.lock().await;
|
||||||
@ -648,7 +666,6 @@ mod tests {
|
|||||||
model_extra: HashMap::new(),
|
model_extra: HashMap::new(),
|
||||||
max_tool_iterations: 1,
|
max_tool_iterations: 1,
|
||||||
token_limit: 4096,
|
token_limit: 4096,
|
||||||
workspace_dir: std::path::PathBuf::from("/tmp/test-workspace"),
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -1,504 +0,0 @@
|
|||||||
use std::path::{Path, PathBuf};
|
|
||||||
use std::sync::{Arc, Mutex};
|
|
||||||
use std::time::SystemTime;
|
|
||||||
|
|
||||||
/// Skill definition
|
|
||||||
#[derive(Debug, Clone)]
|
|
||||||
pub struct Skill {
|
|
||||||
pub name: String,
|
|
||||||
pub description: String,
|
|
||||||
pub content: String,
|
|
||||||
pub always: bool,
|
|
||||||
pub path: Option<PathBuf>,
|
|
||||||
}
|
|
||||||
|
|
||||||
struct SkillMarkdownMeta {
|
|
||||||
name: Option<String>,
|
|
||||||
description: Option<String>,
|
|
||||||
always: Option<bool>,
|
|
||||||
}
|
|
||||||
|
|
||||||
#[derive(Clone)]
|
|
||||||
struct SkillsState {
|
|
||||||
loaded_skills: Vec<Skill>,
|
|
||||||
last_picobot_mtime: Option<SystemTime>,
|
|
||||||
last_agent_mtime: Option<SystemTime>,
|
|
||||||
last_load_time: SystemTime,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillsState {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
loaded_skills: Vec::new(),
|
|
||||||
last_picobot_mtime: None,
|
|
||||||
last_agent_mtime: None,
|
|
||||||
last_load_time: SystemTime::now(),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Skills loader - loads skills from multiple directories
|
|
||||||
#[derive(Clone)]
|
|
||||||
pub struct SkillsLoader {
|
|
||||||
picobot_skills_dir: PathBuf,
|
|
||||||
agent_skills_dir: PathBuf,
|
|
||||||
state: Arc<Mutex<SkillsState>>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl SkillsLoader {
|
|
||||||
/// Create a new loader with default paths
|
|
||||||
pub fn new() -> Self {
|
|
||||||
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from("."));
|
|
||||||
Self {
|
|
||||||
picobot_skills_dir: home.join(".picobot/skills"),
|
|
||||||
agent_skills_dir: home.join(".agent/skills"),
|
|
||||||
state: Arc::new(Mutex::new(SkillsState::default())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
pub(crate) fn new_for_testing(picobot_dir: PathBuf, agent_dir: PathBuf) -> Self {
|
|
||||||
Self {
|
|
||||||
picobot_skills_dir: picobot_dir,
|
|
||||||
agent_skills_dir: agent_dir,
|
|
||||||
state: Arc::new(Mutex::new(SkillsState::default())),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load all skills from both directories and record modification times
|
|
||||||
pub fn load_skills(&self) {
|
|
||||||
let mut state = self.state.lock().unwrap();
|
|
||||||
state.loaded_skills.clear();
|
|
||||||
|
|
||||||
// Load from ~/.picobot/skills
|
|
||||||
if self.picobot_skills_dir.exists() {
|
|
||||||
let loaded = self.load_skills_from_dir(&self.picobot_skills_dir);
|
|
||||||
tracing::debug!(
|
|
||||||
dir = %self.picobot_skills_dir.display(),
|
|
||||||
count = loaded.len(),
|
|
||||||
"Loaded skills from picobot directory"
|
|
||||||
);
|
|
||||||
state.loaded_skills.extend(loaded);
|
|
||||||
state.last_picobot_mtime = Self::get_dir_mtime(&self.picobot_skills_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Load from ~/.agent/skills
|
|
||||||
if self.agent_skills_dir.exists() {
|
|
||||||
let loaded = self.load_skills_from_dir(&self.agent_skills_dir);
|
|
||||||
tracing::debug!(
|
|
||||||
dir = %self.agent_skills_dir.display(),
|
|
||||||
count = loaded.len(),
|
|
||||||
"Loaded skills from agent directory"
|
|
||||||
);
|
|
||||||
state.loaded_skills.extend(loaded);
|
|
||||||
state.last_agent_mtime = Self::get_dir_mtime(&self.agent_skills_dir);
|
|
||||||
}
|
|
||||||
|
|
||||||
state.last_load_time = SystemTime::now();
|
|
||||||
|
|
||||||
if state.loaded_skills.is_empty() {
|
|
||||||
tracing::debug!("No skills found in any skills directory");
|
|
||||||
} else {
|
|
||||||
tracing::info!(count = state.loaded_skills.len(), "Loaded {} skills total", state.loaded_skills.len());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Check if skills directories have been modified since last load
|
|
||||||
fn has_changed(&self) -> bool {
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
let picobot_changed = if self.picobot_skills_dir.exists() {
|
|
||||||
let current_mtime = Self::get_dir_mtime(&self.picobot_skills_dir);
|
|
||||||
current_mtime != state.last_picobot_mtime
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
let agent_changed = if self.agent_skills_dir.exists() {
|
|
||||||
let current_mtime = Self::get_dir_mtime(&self.agent_skills_dir);
|
|
||||||
current_mtime != state.last_agent_mtime
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
};
|
|
||||||
|
|
||||||
picobot_changed || agent_changed
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Reload skills if changes are detected
|
|
||||||
pub fn reload_if_changed(&self) -> bool {
|
|
||||||
if self.has_changed() {
|
|
||||||
tracing::info!("Skills directories changed, reloading...");
|
|
||||||
self.load_skills();
|
|
||||||
true
|
|
||||||
} else {
|
|
||||||
false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get the latest modification time of a directory or any of its children
|
|
||||||
fn get_dir_mtime(dir: &Path) -> Option<SystemTime> {
|
|
||||||
let mut max_mtime = None;
|
|
||||||
|
|
||||||
if let Ok(metadata) = std::fs::metadata(dir) {
|
|
||||||
if let Ok(mtime) = metadata.modified() {
|
|
||||||
max_mtime = Some(mtime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
if let Ok(entries) = std::fs::read_dir(dir) {
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if let Ok(metadata) = std::fs::metadata(&path) {
|
|
||||||
if let Ok(mtime) = metadata.modified() {
|
|
||||||
if max_mtime.map_or(true, |current| mtime > current) {
|
|
||||||
max_mtime = Some(mtime);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
max_mtime
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a copy of loaded skills (checks for changes first)
|
|
||||||
pub fn get_loaded_skills(&self) -> Vec<Skill> {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
state.loaded_skills.clone()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get skills marked as always (checks for changes first)
|
|
||||||
pub fn get_always_skills(&self) -> Vec<Skill> {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
state.loaded_skills.iter().filter(|s| s.always).cloned().collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get a specific skill by name (checks for changes first)
|
|
||||||
pub fn get_skill(&self, name: &str) -> Option<Skill> {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
state.loaded_skills.iter().find(|s| s.name == name).cloned()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// List all skills (name + description) (checks for changes first)
|
|
||||||
pub fn list_skills(&self) -> Vec<(String, String)> {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
state.loaded_skills
|
|
||||||
.iter()
|
|
||||||
.map(|s| (s.name.clone(), s.description.clone()))
|
|
||||||
.collect()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build XML summary of all skills (for progressive disclosure) (checks for changes first)
|
|
||||||
pub fn build_skills_summary(&self) -> String {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
|
|
||||||
if state.loaded_skills.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut lines = vec!["<skills>".to_string()];
|
|
||||||
|
|
||||||
for skill in &state.loaded_skills {
|
|
||||||
if skill.always {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push(" <skill>".to_string());
|
|
||||||
lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
|
|
||||||
lines.push(format!(
|
|
||||||
" <description>{}</description>",
|
|
||||||
escape_xml(&skill.description)
|
|
||||||
));
|
|
||||||
if let Some(path) = &skill.path {
|
|
||||||
lines.push(format!(" <path>{}</path>", escape_xml(&path.to_string_lossy())));
|
|
||||||
}
|
|
||||||
lines.push(" </skill>".to_string());
|
|
||||||
}
|
|
||||||
|
|
||||||
lines.push("</skills>".to_string());
|
|
||||||
lines.join("\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build prompt for always-injected skills (checks for changes first)
|
|
||||||
pub fn build_always_skills_prompt(&self) -> String {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
|
|
||||||
let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect();
|
|
||||||
if always_skills.is_empty() {
|
|
||||||
return String::new();
|
|
||||||
}
|
|
||||||
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
for skill in always_skills {
|
|
||||||
parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content));
|
|
||||||
}
|
|
||||||
|
|
||||||
parts.join("\n\n---\n\n")
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Build full skills prompt combining always skills and summary (checks for changes first)
|
|
||||||
pub fn build_skills_prompt(&self) -> String {
|
|
||||||
self.reload_if_changed();
|
|
||||||
let state = self.state.lock().unwrap();
|
|
||||||
|
|
||||||
let mut prompt = String::new();
|
|
||||||
|
|
||||||
let always_skills: Vec<_> = state.loaded_skills.iter().filter(|s| s.always).collect();
|
|
||||||
if !always_skills.is_empty() {
|
|
||||||
let mut parts = Vec::new();
|
|
||||||
for skill in always_skills {
|
|
||||||
parts.push(format!("## Skill: {}\n\n{}", skill.name, skill.content));
|
|
||||||
}
|
|
||||||
prompt.push_str(&parts.join("\n\n---\n\n"));
|
|
||||||
prompt.push_str("\n\n");
|
|
||||||
}
|
|
||||||
|
|
||||||
let has_other_skills = state.loaded_skills.iter().any(|s| !s.always);
|
|
||||||
if has_other_skills {
|
|
||||||
prompt.push_str("## Available Skills\n\n");
|
|
||||||
prompt.push_str("Skills teach the agent how to use specific capabilities. Use the `get_skill` tool to load a skill's full content when needed.\n\n");
|
|
||||||
|
|
||||||
let mut lines = vec!["<skills>".to_string()];
|
|
||||||
for skill in &state.loaded_skills {
|
|
||||||
if skill.always {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
lines.push(" <skill>".to_string());
|
|
||||||
lines.push(format!(" <name>{}</name>", escape_xml(&skill.name)));
|
|
||||||
lines.push(format!(
|
|
||||||
" <description>{}</description>",
|
|
||||||
escape_xml(&skill.description)
|
|
||||||
));
|
|
||||||
if let Some(path) = &skill.path {
|
|
||||||
lines.push(format!(" <path>{}</path>", escape_xml(&path.to_string_lossy())));
|
|
||||||
}
|
|
||||||
lines.push(" </skill>".to_string());
|
|
||||||
}
|
|
||||||
lines.push("</skills>".to_string());
|
|
||||||
prompt.push_str(&lines.join("\n"));
|
|
||||||
}
|
|
||||||
|
|
||||||
prompt
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Load skills from a specific directory
|
|
||||||
fn load_skills_from_dir(&self, dir: &Path) -> Vec<Skill> {
|
|
||||||
let mut skills = Vec::new();
|
|
||||||
|
|
||||||
let Ok(entries) = std::fs::read_dir(dir) else {
|
|
||||||
tracing::warn!(dir = %dir.display(), "Failed to read skills directory");
|
|
||||||
return skills;
|
|
||||||
};
|
|
||||||
|
|
||||||
for entry in entries.flatten() {
|
|
||||||
let path = entry.path();
|
|
||||||
if !path.is_dir() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
let skill_file = path.join("SKILL.md");
|
|
||||||
if !skill_file.exists() {
|
|
||||||
continue;
|
|
||||||
}
|
|
||||||
|
|
||||||
match std::fs::read_to_string(&skill_file) {
|
|
||||||
Ok(content) => {
|
|
||||||
match self.parse_skill(&path, &content) {
|
|
||||||
Some(skill) => {
|
|
||||||
tracing::debug!(
|
|
||||||
skill = %skill.name,
|
|
||||||
path = %skill_file.display(),
|
|
||||||
always = skill.always,
|
|
||||||
"Loaded skill"
|
|
||||||
);
|
|
||||||
skills.push(skill);
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
tracing::warn!(
|
|
||||||
path = %skill_file.display(),
|
|
||||||
"Failed to parse skill"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Err(e) => {
|
|
||||||
tracing::warn!(
|
|
||||||
path = %skill_file.display(),
|
|
||||||
error = %e,
|
|
||||||
"Failed to read skill file"
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
skills
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse a skill from markdown content
|
|
||||||
fn parse_skill(&self, dir: &Path, content: &str) -> Option<Skill> {
|
|
||||||
let (meta, body) = self.parse_skill_markdown(content);
|
|
||||||
|
|
||||||
let name = meta.name.or_else(|| {
|
|
||||||
dir.file_name()
|
|
||||||
.and_then(|n| n.to_str())
|
|
||||||
.map(|s| s.to_string())
|
|
||||||
})?;
|
|
||||||
|
|
||||||
let description = meta
|
|
||||||
.description
|
|
||||||
.unwrap_or_else(|| extract_description(&body));
|
|
||||||
|
|
||||||
Some(Skill {
|
|
||||||
name,
|
|
||||||
description,
|
|
||||||
content: body,
|
|
||||||
always: meta.always.unwrap_or(false),
|
|
||||||
path: Some(dir.to_path_buf()),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse skill markdown, extracting frontmatter and body
|
|
||||||
fn parse_skill_markdown(&self, content: &str) -> (SkillMarkdownMeta, String) {
|
|
||||||
let normalized = content.replace("\r\n", "\n");
|
|
||||||
|
|
||||||
if let Some(stripped) = normalized.strip_prefix("---\n") {
|
|
||||||
if let Some(idx) = stripped.find("\n---\n") {
|
|
||||||
let frontmatter = stripped[..idx].to_string();
|
|
||||||
let body = stripped[idx + 5..].trim().to_string();
|
|
||||||
let meta = self.parse_frontmatter(&frontmatter);
|
|
||||||
return (meta, body);
|
|
||||||
}
|
|
||||||
if let Some(frontmatter) = stripped.strip_suffix("\n---") {
|
|
||||||
return (self.parse_frontmatter(frontmatter), String::new());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
(SkillMarkdownMeta::default(), normalized)
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Parse simple YAML-like frontmatter
|
|
||||||
fn parse_frontmatter(&self, content: &str) -> SkillMarkdownMeta {
|
|
||||||
let mut meta = SkillMarkdownMeta::default();
|
|
||||||
|
|
||||||
for line in content.lines() {
|
|
||||||
let Some((key, val)) = line.split_once(':') else {
|
|
||||||
continue;
|
|
||||||
};
|
|
||||||
let key = key.trim();
|
|
||||||
let val = val.trim().trim_matches('"').trim_matches('\'');
|
|
||||||
|
|
||||||
match key {
|
|
||||||
"name" => meta.name = Some(val.to_string()),
|
|
||||||
"description" => meta.description = Some(val.to_string()),
|
|
||||||
"always" => {
|
|
||||||
meta.always = match val.to_lowercase().as_str() {
|
|
||||||
"true" | "1" | "yes" | "on" => Some(true),
|
|
||||||
"false" | "0" | "no" | "off" => Some(false),
|
|
||||||
_ => None,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
meta
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillsLoader {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self::new()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
impl Default for SkillMarkdownMeta {
|
|
||||||
fn default() -> Self {
|
|
||||||
Self {
|
|
||||||
name: None,
|
|
||||||
description: None,
|
|
||||||
always: None,
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Extract first non-empty, non-heading line as description
|
|
||||||
fn extract_description(content: &str) -> String {
|
|
||||||
content
|
|
||||||
.lines()
|
|
||||||
.find(|line| !line.starts_with('#') && !line.trim().is_empty())
|
|
||||||
.map(|l| l.trim().to_string())
|
|
||||||
.unwrap_or_else(|| "No description".to_string())
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Escape XML special characters
|
|
||||||
fn escape_xml(s: &str) -> String {
|
|
||||||
let mut result = String::with_capacity(s.len());
|
|
||||||
for c in s.chars() {
|
|
||||||
match c {
|
|
||||||
'&' => result.push_str("&"),
|
|
||||||
'<' => result.push_str("<"),
|
|
||||||
'>' => result.push_str(">"),
|
|
||||||
'"' => result.push_str("""),
|
|
||||||
'\'' => result.push_str("'"),
|
|
||||||
_ => result.push(c),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
result
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_skill_without_frontmatter() {
|
|
||||||
let loader = SkillsLoader::new();
|
|
||||||
let content = "# My Skill\n\nThis is the content.";
|
|
||||||
let (meta, body) = loader.parse_skill_markdown(content);
|
|
||||||
|
|
||||||
assert!(meta.name.is_none());
|
|
||||||
assert!(meta.description.is_none());
|
|
||||||
assert!(body.contains("My Skill"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_parse_skill_with_frontmatter() {
|
|
||||||
let loader = SkillsLoader::new();
|
|
||||||
let content = r#"---
|
|
||||||
name: test-skill
|
|
||||||
description: A test skill
|
|
||||||
always: true
|
|
||||||
---
|
|
||||||
# Test Skill
|
|
||||||
|
|
||||||
This is the content.
|
|
||||||
"#;
|
|
||||||
|
|
||||||
let (meta, body) = loader.parse_skill_markdown(content);
|
|
||||||
|
|
||||||
assert_eq!(meta.name, Some("test-skill".to_string()));
|
|
||||||
assert_eq!(meta.description, Some("A test skill".to_string()));
|
|
||||||
assert_eq!(meta.always, Some(true));
|
|
||||||
assert!(body.contains("Test Skill"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_escape_xml() {
|
|
||||||
assert_eq!(escape_xml("a & b"), "a & b");
|
|
||||||
assert_eq!(escape_xml("<tag>"), "<tag>");
|
|
||||||
assert_eq!(escape_xml("\"quote\""), ""quote"");
|
|
||||||
}
|
|
||||||
|
|
||||||
#[test]
|
|
||||||
fn test_extract_description() {
|
|
||||||
assert_eq!(
|
|
||||||
extract_description("# Title\n\nFirst line of content."),
|
|
||||||
"First line of content."
|
|
||||||
);
|
|
||||||
assert_eq!(extract_description("# Title"), "No description");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -1,159 +0,0 @@
|
|||||||
use std::sync::Arc;
|
|
||||||
|
|
||||||
use async_trait::async_trait;
|
|
||||||
use serde_json::json;
|
|
||||||
|
|
||||||
use crate::skills::{Skill, SkillsLoader};
|
|
||||||
use crate::tools::traits::{Tool, ToolResult};
|
|
||||||
|
|
||||||
pub struct GetSkillTool {
|
|
||||||
skills_loader: Arc<SkillsLoader>,
|
|
||||||
}
|
|
||||||
|
|
||||||
impl GetSkillTool {
|
|
||||||
pub fn new(skills_loader: Arc<SkillsLoader>) -> Self {
|
|
||||||
Self { skills_loader }
|
|
||||||
}
|
|
||||||
|
|
||||||
fn format_skill(&self, skill: &Skill) -> String {
|
|
||||||
let mut result = format!("# Skill: {}\n\n{}", skill.name, skill.description);
|
|
||||||
|
|
||||||
if let Some(path) = &skill.path {
|
|
||||||
result.push_str(&format!(
|
|
||||||
"\n\n**Skill Root Directory:** `{}`\n\nAll files and references in this skill are relative to this directory.",
|
|
||||||
path.to_string_lossy()
|
|
||||||
));
|
|
||||||
}
|
|
||||||
|
|
||||||
result.push_str(&format!("\n\n---\n\n{}", skill.content));
|
|
||||||
result
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[async_trait]
|
|
||||||
impl Tool for GetSkillTool {
|
|
||||||
fn name(&self) -> &str {
|
|
||||||
"get_skill"
|
|
||||||
}
|
|
||||||
|
|
||||||
fn description(&self) -> &str {
|
|
||||||
"Get complete content and guidance for a specified skill. Use this when you need detailed instructions for a specific type of task."
|
|
||||||
}
|
|
||||||
|
|
||||||
fn parameters_schema(&self) -> serde_json::Value {
|
|
||||||
json!({
|
|
||||||
"type": "object",
|
|
||||||
"properties": {
|
|
||||||
"skill_name": {
|
|
||||||
"type": "string",
|
|
||||||
"description": "Name of the skill to retrieve"
|
|
||||||
}
|
|
||||||
},
|
|
||||||
"required": ["skill_name"]
|
|
||||||
})
|
|
||||||
}
|
|
||||||
|
|
||||||
fn read_only(&self) -> bool {
|
|
||||||
true
|
|
||||||
}
|
|
||||||
|
|
||||||
async fn execute(&self, args: serde_json::Value) -> anyhow::Result<ToolResult> {
|
|
||||||
let skill_name = match args.get("skill_name").and_then(|v| v.as_str()) {
|
|
||||||
Some(name) => name,
|
|
||||||
None => {
|
|
||||||
return Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some("Missing required parameter: skill_name".to_string()),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
match self.skills_loader.get_skill(skill_name) {
|
|
||||||
Some(skill) => {
|
|
||||||
let formatted = self.format_skill(&skill);
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: true,
|
|
||||||
output: formatted,
|
|
||||||
error: None,
|
|
||||||
})
|
|
||||||
}
|
|
||||||
None => {
|
|
||||||
let available = self.skills_loader.list_skills();
|
|
||||||
let available_str = if available.is_empty() {
|
|
||||||
"No skills available".to_string()
|
|
||||||
} else {
|
|
||||||
available
|
|
||||||
.iter()
|
|
||||||
.map(|(name, _)| name.as_str())
|
|
||||||
.collect::<Vec<_>>()
|
|
||||||
.join(", ")
|
|
||||||
};
|
|
||||||
Ok(ToolResult {
|
|
||||||
success: false,
|
|
||||||
output: String::new(),
|
|
||||||
error: Some(format!(
|
|
||||||
"Skill '{}' not found. Available skills: {}",
|
|
||||||
skill_name, available_str
|
|
||||||
)),
|
|
||||||
})
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
#[cfg(test)]
|
|
||||||
mod tests {
|
|
||||||
use super::*;
|
|
||||||
use tempfile::tempdir;
|
|
||||||
use std::fs::File;
|
|
||||||
use std::io::Write;
|
|
||||||
use std::path::PathBuf;
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_existing_skill() {
|
|
||||||
let temp_dir = tempdir().unwrap();
|
|
||||||
|
|
||||||
let skill_dir = temp_dir.path().join("test-skill");
|
|
||||||
std::fs::create_dir(&skill_dir).unwrap();
|
|
||||||
|
|
||||||
let mut skill_file = File::create(skill_dir.join("SKILL.md")).unwrap();
|
|
||||||
writeln!(skill_file, "---").unwrap();
|
|
||||||
writeln!(skill_file, "name: test-skill").unwrap();
|
|
||||||
writeln!(skill_file, "description: A test skill").unwrap();
|
|
||||||
writeln!(skill_file, "---").unwrap();
|
|
||||||
writeln!(skill_file, "# Test Skill").unwrap();
|
|
||||||
writeln!(skill_file, "This is the test content.").unwrap();
|
|
||||||
|
|
||||||
let mut loader = SkillsLoader::new_for_testing(
|
|
||||||
temp_dir.path().to_path_buf(),
|
|
||||||
PathBuf::from("/nonexistent"),
|
|
||||||
);
|
|
||||||
loader.load_skills();
|
|
||||||
|
|
||||||
let tool = GetSkillTool::new(Arc::new(loader));
|
|
||||||
|
|
||||||
let result = tool
|
|
||||||
.execute(json!({ "skill_name": "test-skill" }))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(result.success);
|
|
||||||
assert!(result.output.contains("test-skill"));
|
|
||||||
assert!(result.output.contains("test content"));
|
|
||||||
}
|
|
||||||
|
|
||||||
#[tokio::test]
|
|
||||||
async fn test_get_nonexistent_skill() {
|
|
||||||
let loader = SkillsLoader::new();
|
|
||||||
let tool = GetSkillTool::new(Arc::new(loader));
|
|
||||||
|
|
||||||
let result = tool
|
|
||||||
.execute(json!({ "skill_name": "nonexistent" }))
|
|
||||||
.await
|
|
||||||
.unwrap();
|
|
||||||
|
|
||||||
assert!(!result.success);
|
|
||||||
assert!(result.error.is_some());
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@ -3,7 +3,6 @@ pub mod calculator;
|
|||||||
pub mod file_edit;
|
pub mod file_edit;
|
||||||
pub mod file_read;
|
pub mod file_read;
|
||||||
pub mod file_write;
|
pub mod file_write;
|
||||||
pub mod get_skill;
|
|
||||||
pub mod http_request;
|
pub mod http_request;
|
||||||
pub mod registry;
|
pub mod registry;
|
||||||
pub mod schema;
|
pub mod schema;
|
||||||
@ -15,7 +14,6 @@ pub use calculator::CalculatorTool;
|
|||||||
pub use file_edit::FileEditTool;
|
pub use file_edit::FileEditTool;
|
||||||
pub use file_read::FileReadTool;
|
pub use file_read::FileReadTool;
|
||||||
pub use file_write::FileWriteTool;
|
pub use file_write::FileWriteTool;
|
||||||
pub use get_skill::GetSkillTool;
|
|
||||||
pub use http_request::HttpRequestTool;
|
pub use http_request::HttpRequestTool;
|
||||||
pub use registry::ToolRegistry;
|
pub use registry::ToolRegistry;
|
||||||
pub use schema::{CleaningStrategy, SchemaCleanr};
|
pub use schema::{CleaningStrategy, SchemaCleanr};
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user