统一工作目录管理

This commit is contained in:
xiaoski 2026-04-27 17:23:52 +08:00
parent 75a3bf9df4
commit f704900e07
8 changed files with 173 additions and 38 deletions

67
config.example.json Normal file
View File

@ -0,0 +1,67 @@
{
"providers": {
"aliyun": {
"type": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "<DASHSCOPE_API_KEY>",
"extra_headers": {}
},
"openai": {
"type": "openai",
"base_url": "https://api.openai.com/v1",
"api_key": "<OPENAI_API_KEY>",
"extra_headers": {}
},
"anthropic": {
"type": "anthropic",
"base_url": "https://api.anthropic.com/v1",
"api_key": "<ANTHROPIC_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": "<FEISHU_APP_ID>",
"app_secret": "<FEISHU_APP_SECRET>",
"allow_from": ["*"],
"agent": "default",
"media_dir": "~/.picobot/media/feishu",
"reaction_emoji": "Typing"
}
},
"workspace_dir": "~/.picobot/workspace"
}

View File

@ -239,6 +239,7 @@ impl AgentLoop {
pub fn new(provider_config: LLMProviderConfig) -> Result<Self, AgentError> { pub fn new(provider_config: LLMProviderConfig) -> Result<Self, AgentError> {
let max_iterations = provider_config.max_tool_iterations; let max_iterations = provider_config.max_tool_iterations;
let model_name = provider_config.model_id.clone(); let model_name = provider_config.model_id.clone();
let workspace_dir = provider_config.workspace_dir.clone();
let provider = create_provider(provider_config) let provider = create_provider(provider_config)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?; .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
@ -247,7 +248,7 @@ impl AgentLoop {
tools: Arc::new(ToolRegistry::new()), tools: Arc::new(ToolRegistry::new()),
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), workspace_dir,
model_name, model_name,
}) })
} }
@ -256,6 +257,7 @@ impl AgentLoop {
pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc<ToolRegistry>) -> Result<Self, AgentError> { pub fn with_tools(provider_config: LLMProviderConfig, tools: Arc<ToolRegistry>) -> Result<Self, AgentError> {
let max_iterations = provider_config.max_tool_iterations; let max_iterations = provider_config.max_tool_iterations;
let model_name = provider_config.model_id.clone(); let model_name = provider_config.model_id.clone();
let workspace_dir = provider_config.workspace_dir.clone();
let provider = create_provider(provider_config) let provider = create_provider(provider_config)
.map_err(|e| AgentError::ProviderCreation(e.to_string()))?; .map_err(|e| AgentError::ProviderCreation(e.to_string()))?;
@ -264,19 +266,19 @@ impl AgentLoop {
tools, tools,
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), workspace_dir,
model_name, model_name,
}) })
} }
/// Create a new AgentLoop with an existing shared provider. /// Create a new AgentLoop with an existing shared provider.
pub fn with_provider(provider: Arc<dyn LLMProvider>, max_iterations: usize, model_name: String) -> Self { pub fn with_provider(provider: Arc<dyn LLMProvider>, max_iterations: usize, model_name: String, workspace_dir: PathBuf) -> Self {
Self { Self {
provider, provider,
tools: Arc::new(ToolRegistry::new()), tools: Arc::new(ToolRegistry::new()),
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), workspace_dir,
model_name, model_name,
} }
} }
@ -287,13 +289,14 @@ impl AgentLoop {
tools: Arc<ToolRegistry>, tools: Arc<ToolRegistry>,
max_iterations: usize, max_iterations: usize,
model_name: String, model_name: String,
workspace_dir: PathBuf,
) -> Self { ) -> Self {
Self { Self {
provider, provider,
tools, tools,
observer: None, observer: None,
max_iterations, max_iterations,
workspace_dir: std::env::current_dir().unwrap_or_else(|_| PathBuf::from(".")), workspace_dir,
model_name, model_name,
} }
} }

View File

@ -169,7 +169,7 @@ impl PromptSection for SafetySection {
} }
} }
/// Workspace directory information. /// Workspace directory information and guidelines.
pub struct WorkspaceSection; pub struct WorkspaceSection;
impl PromptSection for WorkspaceSection { impl PromptSection for WorkspaceSection {
@ -184,7 +184,7 @@ impl PromptSection for WorkspaceSection {
.canonicalize() .canonicalize()
.unwrap_or_else(|_| ctx.workspace_dir.to_path_buf()); .unwrap_or_else(|_| ctx.workspace_dir.to_path_buf());
format!( 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() abs_path.display()
) )
} }

View File

@ -173,9 +173,13 @@ struct ParsedMessage {
impl FeishuChannel { impl FeishuChannel {
pub fn new( pub fn new(
config: FeishuChannelConfig, mut config: FeishuChannelConfig,
_provider_config: LLMProviderConfig, workspace_dir: &Path,
) -> Result<Self, ChannelError> { ) -> Result<Self, ChannelError> {
// 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 { Ok(Self {
config, config,
http_client: reqwest::Client::new(), http_client: reqwest::Client::new(),

View File

@ -43,19 +43,19 @@ impl ChannelManager {
pub async fn init( pub async fn init(
&self, &self,
config: &Config, config: &Config,
_provider_config: crate::config::LLMProviderConfig, workspace_dir: std::path::PathBuf,
) -> Result<(), ChannelError> { ) -> Result<(), ChannelError> {
// Initialize Feishu channel if enabled // Initialize Feishu channel if enabled
if let Some(feishu_config) = config.channels.get("feishu") { if let Some(feishu_config) = config.channels.get("feishu") {
if feishu_config.enabled { 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)))?; .map_err(|e| ChannelError::Other(format!("Failed to create Feishu channel: {}", e)))?;
self.channels self.channels
.write() .write()
.await .await
.insert("feishu".to_string(), Arc::new(channel)); .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 { } else {
tracing::info!("Feishu channel disabled in config"); tracing::info!("Feishu channel disabled in config");
} }

View File

@ -5,6 +5,39 @@ use std::env;
use std::fs; use std::fs;
use std::path::{Path, PathBuf}; 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<PathBuf, std::io::Error> {
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)] #[derive(Debug, Clone, Deserialize, Serialize)]
pub struct Config { pub struct Config {
pub providers: HashMap<String, ProviderConfig>, pub providers: HashMap<String, ProviderConfig>,
@ -16,6 +49,12 @@ pub struct Config {
pub client: ClientConfig, pub client: ClientConfig,
#[serde(default)] #[serde(default)]
pub channels: HashMap<String, FeishuChannelConfig>, pub channels: HashMap<String, FeishuChannelConfig>,
#[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)] #[derive(Debug, Clone, Deserialize, Serialize)]
@ -40,8 +79,10 @@ fn default_allow_from() -> Vec<String> {
} }
fn default_media_dir() -> String { fn default_media_dir() -> String {
let home = dirs::home_dir().unwrap_or_else(|| std::path::PathBuf::from(".")); get_user_config_dir()
home.join(".picobot/media/feishu").to_string_lossy().to_string() .join("media/feishu")
.to_string_lossy()
.to_string()
} }
fn default_reaction_emoji() -> String { fn default_reaction_emoji() -> String {
@ -146,11 +187,11 @@ pub struct LLMProviderConfig {
pub model_extra: HashMap<String, serde_json::Value>, pub model_extra: HashMap<String, serde_json::Value>,
pub max_tool_iterations: usize, pub max_tool_iterations: usize,
pub token_limit: usize, pub token_limit: usize,
pub workspace_dir: PathBuf,
} }
fn get_default_config_path() -> PathBuf { fn get_default_config_path() -> PathBuf {
let home = dirs::home_dir().unwrap_or_else(|| PathBuf::from(".")); get_user_config_dir().join("config.json")
home.join(".picobot").join("config.json")
} }
impl Config { impl Config {
@ -186,13 +227,19 @@ impl Config {
} }
pub fn get_provider_config(&self, agent_name: &str) -> Result<LLMProviderConfig, ConfigError> { pub fn get_provider_config(&self, agent_name: &str) -> Result<LLMProviderConfig, ConfigError> {
let agent = self.agents.get(agent_name) let agent = self
.agents
.get(agent_name)
.ok_or(ConfigError::AgentNotFound(agent_name.to_string()))?; .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()))?; .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_or(ConfigError::ModelNotFound(agent.model.clone()))?;
Ok(LLMProviderConfig { Ok(LLMProviderConfig {
@ -207,6 +254,7 @@ impl Config {
model_extra: model.extra.clone(), model_extra: model.extra.clone(),
max_tool_iterations: agent.max_tool_iterations, max_tool_iterations: agent.max_tool_iterations,
token_limit: agent.token_limit, token_limit: agent.token_limit,
workspace_dir: expand_path(&self.workspace_dir),
}) })
} }
} }
@ -260,7 +308,8 @@ fn resolve_env_placeholders(content: &str) -> String {
re.replace_all(content, |caps: &regex::Captures| { re.replace_all(content, |caps: &regex::Captures| {
let var_name = &caps[1]; let var_name = &caps[1];
env::var(var_name).unwrap_or_else(|_| caps[0].to_string()) env::var(var_name).unwrap_or_else(|_| caps[0].to_string())
}).to_string() })
.to_string()
} }
#[cfg(test)] #[cfg(test)]

View File

@ -8,12 +8,13 @@ use tokio::net::TcpListener;
use crate::bus::{ControlMessage, OutboundDispatcher}; use crate::bus::{ControlMessage, OutboundDispatcher};
use crate::channels::{ChannelManager, CliChatChannel}; use crate::channels::{ChannelManager, CliChatChannel};
use crate::channels::base::{Channel, ChannelError}; use crate::channels::base::{Channel, ChannelError};
use crate::config::Config; use crate::config::{Config, expand_path, ensure_workspace_dir};
use crate::logging; use crate::logging;
use crate::session::SessionManager; use crate::session::SessionManager;
pub struct GatewayState { pub struct GatewayState {
pub config: Config, pub config: Config,
pub workspace_dir: std::path::PathBuf,
pub session_manager: SessionManager, pub session_manager: SessionManager,
pub channel_manager: ChannelManager, pub channel_manager: ChannelManager,
} }
@ -22,8 +23,20 @@ impl GatewayState {
pub fn new() -> Result<Self, Box<dyn std::error::Error>> { pub fn new() -> Result<Self, Box<dyn std::error::Error>> {
let config = Config::load_default()?; 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 // 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) // Session TTL from config (default 4 hours)
let session_ttl_hours = config.gateway.session_ttl_hours.unwrap_or(4); let session_ttl_hours = config.gateway.session_ttl_hours.unwrap_or(4);
@ -36,6 +49,7 @@ impl GatewayState {
Ok(Self { Ok(Self {
config, config,
workspace_dir: workspace_path,
session_manager, session_manager,
channel_manager, channel_manager,
}) })
@ -188,13 +202,10 @@ pub async fn run(host: Option<String>, port: Option<u16>) -> Result<(), Box<dyn
let state = Arc::new(GatewayState::new()?); let state = Arc::new(GatewayState::new()?);
// Get provider config for channels // Initialize and start channels with workspace directory
let provider_config = state.config.get_provider_config("default")?;
// Initialize and start channels
state.channel_manager.init( state.channel_manager.init(
&state.config, &state.config,
provider_config.clone(), state.workspace_dir.clone(),
).await?; ).await?;
state.channel_manager.start_all().await?; state.channel_manager.start_all().await?;

View File

@ -168,6 +168,7 @@ impl Session {
self.tools.clone(), self.tools.clone(),
self.provider_config.max_tool_iterations, self.provider_config.max_tool_iterations,
self.provider_config.model_id.clone(), self.provider_config.model_id.clone(),
self.provider_config.workspace_dir.clone(),
)) ))
} }
} }