From f704900e07ac8febdb16b84a5fc3e65342dbc29a Mon Sep 17 00:00:00 2001 From: xiaoski Date: Mon, 27 Apr 2026 17:23:52 +0800 Subject: [PATCH] =?UTF-8?q?=E7=BB=9F=E4=B8=80=E5=B7=A5=E4=BD=9C=E7=9B=AE?= =?UTF-8?q?=E5=BD=95=E7=AE=A1=E7=90=86?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- config.example.json | 67 +++++++++++++++++++++++++++++ src/agent/agent_loop.rs | 13 +++--- src/agent/system_prompt.rs | 4 +- src/channels/feishu.rs | 8 +++- src/channels/manager.rs | 6 +-- src/config/mod.rs | 87 +++++++++++++++++++++++++++++--------- src/gateway/mod.rs | 25 ++++++++--- src/session/session.rs | 1 + 8 files changed, 173 insertions(+), 38 deletions(-) create mode 100644 config.example.json diff --git a/config.example.json b/config.example.json new file mode 100644 index 0000000..43ef441 --- /dev/null +++ b/config.example.json @@ -0,0 +1,67 @@ +{ + "providers": { + "aliyun": { + "type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "", + "extra_headers": {} + }, + "openai": { + "type": "openai", + "base_url": "https://api.openai.com/v1", + "api_key": "", + "extra_headers": {} + }, + "anthropic": { + "type": "anthropic", + "base_url": "https://api.anthropic.com/v1", + "api_key": "", + "extra_headers": {} + } + }, + "models": { + "qwen-plus": { + "model_id": "qwen-plus", + "temperature": 0.0, + "max_tokens": 8192 + }, + "gpt-4o": { + "model_id": "gpt-4o", + "temperature": 0.7, + "max_tokens": 4096 + }, + "claude-sonnet-4-20250514": { + "model_id": "claude-sonnet-4-20250514", + "temperature": 0.7, + "max_tokens": 8192 + } + }, + "agents": { + "default": { + "provider": "aliyun", + "model": "qwen-plus", + "max_tool_iterations": 20, + "token_limit": 128000 + } + }, + "gateway": { + "host": "127.0.0.1", + "port": 19876, + "session_ttl_hours": 168 + }, + "client": { + "gateway_url": "ws://127.0.0.1:19876/ws" + }, + "channels": { + "feishu": { + "enabled": true, + "app_id": "", + "app_secret": "", + "allow_from": ["*"], + "agent": "default", + "media_dir": "~/.picobot/media/feishu", + "reaction_emoji": "Typing" + } + }, + "workspace_dir": "~/.picobot/workspace" +} diff --git a/src/agent/agent_loop.rs b/src/agent/agent_loop.rs index 8d41882..8bcfa10 100644 --- a/src/agent/agent_loop.rs +++ b/src/agent/agent_loop.rs @@ -239,6 +239,7 @@ impl AgentLoop { pub fn new(provider_config: LLMProviderConfig) -> Result { let max_iterations = provider_config.max_tool_iterations; let model_name = provider_config.model_id.clone(); + let workspace_dir = provider_config.workspace_dir.clone(); let provider = create_provider(provider_config) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; @@ -247,7 +248,7 @@ impl AgentLoop { tools: Arc::new(ToolRegistry::new()), observer: None, max_iterations, - workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + workspace_dir, model_name, }) } @@ -256,6 +257,7 @@ impl AgentLoop { pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc) -> Result { let max_iterations = provider_config.max_tool_iterations; let model_name = provider_config.model_id.clone(); + let workspace_dir = provider_config.workspace_dir.clone(); let provider = create_provider(provider_config) .map_err(|e| AgentError::ProviderCreation(e.to_string()))?; @@ -264,19 +266,19 @@ impl AgentLoop { tools, observer: None, max_iterations, - workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + workspace_dir, model_name, }) } /// Create a new AgentLoop with an existing shared provider. - pub fn with_provider(provider: Arc, max_iterations: usize, model_name: String) -> Self { + pub fn with_provider(provider: Arc, max_iterations: usize, model_name: String, workspace_dir: PathBuf) -> Self { Self { provider, tools: Arc::new(ToolRegistry::new()), observer: None, max_iterations, - workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + workspace_dir, model_name, } } @@ -287,13 +289,14 @@ impl AgentLoop { tools: Arc, max_iterations: usize, model_name: String, + workspace_dir: PathBuf, ) -> Self { Self { provider, tools, observer: None, max_iterations, - workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), + workspace_dir, model_name, } } diff --git a/src/agent/system_prompt.rs b/src/agent/system_prompt.rs index 65a3ca0..b2575bf 100644 --- a/src/agent/system_prompt.rs +++ b/src/agent/system_prompt.rs @@ -169,7 +169,7 @@ impl PromptSection for SafetySection { } } -/// Workspace directory information. +/// Workspace directory information and guidelines. pub struct WorkspaceSection; impl PromptSection for WorkspaceSection { @@ -184,7 +184,7 @@ impl PromptSection for WorkspaceSection { .canonicalize() .unwrap_or_else(|_| ctx.workspace_dir.to_path_buf()); format!( - "## Workspace\n\nWorking directory: `{}`", + "## Workspace\n\nWorking directory: `{}`\n\n### File Storage Guidelines\n\n- **Generated files**: Store all generated files (code, documents, artifacts) in the workspace directory or its subdirectories.\n- **Downloaded files**: Save downloaded files to the workspace directory, organized by task.\n- **One task, one folder**: Create a dedicated subfolder for each task or project (e.g., `task_2024_01_01/`).\n- **Temporary files**: If files are only needed during processing and won't be kept, use `/tmp/` or create a temp folder (e.g., `/tmp/picobot_task_xxx/`) instead of cluttering the workspace.\n\n### Working Directory Structure\n\nThe workspace is your home base for this session. Keep it organized by creating subdirectories for different tasks.", abs_path.display() ) } diff --git a/src/channels/feishu.rs b/src/channels/feishu.rs index b33fb25..c95a883 100644 --- a/src/channels/feishu.rs +++ b/src/channels/feishu.rs @@ -173,9 +173,13 @@ struct ParsedMessage { impl FeishuChannel { pub fn new( - config: FeishuChannelConfig, - _provider_config: LLMProviderConfig, + mut config: FeishuChannelConfig, + workspace_dir: &Path, ) -> Result { + // Override media_dir to use workspace_dir/media/feishu + let media_dir = workspace_dir.join("media").join("feishu"); + config.media_dir = media_dir.to_string_lossy().to_string(); + Ok(Self { config, http_client: reqwest::Client::new(), diff --git a/src/channels/manager.rs b/src/channels/manager.rs index cf6a547..b144dd3 100644 --- a/src/channels/manager.rs +++ b/src/channels/manager.rs @@ -43,19 +43,19 @@ impl ChannelManager { pub async fn init( &self, config: &Config, - _provider_config: crate::config::LLMProviderConfig, + workspace_dir: std::path::PathBuf, ) -> Result<(), ChannelError> { // Initialize Feishu channel if enabled if let Some(feishu_config) = config.channels.get("feishu") { if feishu_config.enabled { - let channel = FeishuChannel::new(feishu_config.clone(), _provider_config) + let channel = FeishuChannel::new(feishu_config.clone(), &workspace_dir) .map_err(|e| ChannelError::Other(format!("Failed to create Feishu channel: {}", e)))?; self.channels .write() .await .insert("feishu".to_string(), Arc::new(channel)); - tracing::info!("Feishu channel registered"); + tracing::info!("Feishu channel registered (media_dir: {}/media/feishu)", workspace_dir.display()); } else { tracing::info!("Feishu channel disabled in config"); } diff --git a/src/config/mod.rs b/src/config/mod.rs index e2fecce..eed56ca 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -5,6 +5,39 @@ use std::env; use std::fs; use std::path::{Path, PathBuf}; +/// Get the user configuration directory (~/.picobot) +pub fn get_user_config_dir() -> PathBuf { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(".picobot") +} + +/// Get the default workspace directory (~/.picobot/workspace) +pub fn get_default_workspace_dir() -> PathBuf { + get_user_config_dir().join("workspace") +} + +/// Expand ~ in path to user home directory +pub fn expand_path(path: &str) -> PathBuf { + if path.starts_with("~/") { + dirs::home_dir() + .unwrap_or_else(|| PathBuf::from(".")) + .join(&path[2..]) + } else { + PathBuf::from(path) + } +} + +/// Ensure workspace directory exists, create if needed +pub fn ensure_workspace_dir(path: &Path) -> Result { + if !path.exists() { + tracing::info!("Creating workspace directory: {}", path.display()); + fs::create_dir_all(path)?; + } + // Return canonical path + path.canonicalize().or_else(|_| Ok(path.to_path_buf())) +} + #[derive(Debug, Clone, Deserialize, Serialize)] pub struct Config { pub providers: HashMap, @@ -16,6 +49,12 @@ pub struct Config { pub client: ClientConfig, #[serde(default)] pub channels: HashMap, + #[serde(default = "default_workspace_dir")] + pub workspace_dir: String, +} + +fn default_workspace_dir() -> String { + get_default_workspace_dir().to_string_lossy().to_string() } #[derive(Debug, Clone, Deserialize, Serialize)] @@ -40,8 +79,10 @@ fn default_allow_from() -> Vec { } fn default_media_dir() -> String { - let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); - home.join(".picobot/media/feishu").to_string_lossy().to_string() + get_user_config_dir() + .join("media/feishu") + .to_string_lossy() + .to_string() } fn default_reaction_emoji() -> String { @@ -146,11 +187,11 @@ pub struct LLMProviderConfig { pub model_extra: HashMap, pub max_tool_iterations: usize, pub token_limit: usize, + pub workspace_dir: PathBuf, } fn get_default_config_path() -> PathBuf { - let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); - home.join(".picobot").join("config.json") + get_user_config_dir().join("config.json") } impl Config { @@ -186,13 +227,19 @@ impl Config { } pub fn get_provider_config(&self, agent_name: &str) -> Result { - let agent = self.agents.get(agent_name) + let agent = self + .agents + .get(agent_name) .ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?; - let provider = self.providers.get(&agent.provider) + let provider = self + .providers + .get(&agent.provider) .ok_or(ConfigError::ProviderNotFound(agent.provider.clone()))?; - let model = self.models.get(&agent.model) + let model = self + .models + .get(&agent.model) .ok_or(ConfigError::ModelNotFound(agent.model.clone()))?; Ok(LLMProviderConfig { @@ -207,6 +254,7 @@ impl Config { model_extra: model.extra.clone(), max_tool_iterations: agent.max_tool_iterations, token_limit: agent.token_limit, + workspace_dir: expand_path(&self.workspace_dir), }) } } @@ -260,18 +308,19 @@ fn resolve_env_placeholders(content: &str) -> String { re.replace_all(content, |caps: ®ex::Captures| { let var_name = &caps[1]; env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) - }).to_string() + }) + .to_string() } #[cfg(test)] mod tests { use super::*; - fn write_test_config() -> tempfile::NamedTempFile { - let file = tempfile::NamedTempFile::new().unwrap(); - std::fs::write( - file.path(), - r#"{ + fn write_test_config() -> tempfile::NamedTempFile { + let file = tempfile::NamedTempFile::new().unwrap(); + std::fs::write( + file.path(), + r#"{ "providers": { "aliyun": { "type": "openai", @@ -306,15 +355,15 @@ mod tests { "port": 19876 } }"#, - ) - .unwrap(); - file - } + ) + .unwrap(); + file + } #[test] fn test_config_load() { - let file = write_test_config(); - let config = Config::load(file.path().to_str().unwrap()).unwrap(); + let file = write_test_config(); + let config = Config::load(file.path().to_str().unwrap()).unwrap(); // Check providers assert!(config.providers.contains_key("volcengine")); diff --git a/src/gateway/mod.rs b/src/gateway/mod.rs index c755085..52d6710 100644 --- a/src/gateway/mod.rs +++ b/src/gateway/mod.rs @@ -8,12 +8,13 @@ use tokio::net::TcpListener; use crate::bus::{ControlMessage, OutboundDispatcher}; use crate::channels::{ChannelManager, CliChatChannel}; use crate::channels::base::{Channel, ChannelError}; -use crate::config::Config; +use crate::config::{Config, expand_path, ensure_workspace_dir}; use crate::logging; use crate::session::SessionManager; pub struct GatewayState { pub config: Config, + pub workspace_dir: std::path::PathBuf, pub session_manager: SessionManager, pub channel_manager: ChannelManager, } @@ -22,8 +23,20 @@ impl GatewayState { pub fn new() -> Result> { let config = Config::load_default()?; + // Initialize workspace directory: expand path and ensure it exists + let workspace_path = expand_path(&config.workspace_dir); + let workspace_path = ensure_workspace_dir(&workspace_path)?; + + // Switch current working directory to workspace + std::env::set_current_dir(&workspace_path) + .map_err(|e| format!("Failed to switch to workspace directory {}: {}", workspace_path.display(), e))?; + + tracing::info!("Using workspace directory: {}", workspace_path.display()); + // Get provider config for SessionManager - let provider_config = config.get_provider_config("default")?; + let mut provider_config = config.get_provider_config("default")?; + // Override workspace_dir with the ensured path + provider_config.workspace_dir = workspace_path.clone(); // Session TTL from config (default 4 hours) let session_ttl_hours = config.gateway.session_ttl_hours.unwrap_or(4); @@ -36,6 +49,7 @@ impl GatewayState { Ok(Self { config, + workspace_dir: workspace_path, session_manager, channel_manager, }) @@ -188,13 +202,10 @@ pub async fn run(host: Option, port: Option) -> Result<(), Box