diff --git a/.github/workflows/pub-docker-img.yml b/.github/workflows/pub-docker-img.yml index 8267c9e4c..ab39d4f6f 100644 --- a/.github/workflows/pub-docker-img.yml +++ b/.github/workflows/pub-docker-img.yml @@ -181,6 +181,8 @@ jobs: context: . push: false load: true + build-args: | + ZEROCLAW_CARGO_FEATURES=channel-matrix tags: zeroclaw-release-candidate:${{ steps.meta.outputs.release_tag }} platforms: linux/amd64 cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }} @@ -282,7 +284,7 @@ jobs: context: . push: true build-args: | - ZEROCLAW_CARGO_ALL_FEATURES=true + ZEROCLAW_CARGO_FEATURES=channel-matrix tags: ${{ steps.meta.outputs.tags }} platforms: linux/amd64,linux/arm64 cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }} diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml index 3798d6253..5a04c1bc8 100644 --- a/.github/workflows/pub-release.yml +++ b/.github/workflows/pub-release.yml @@ -202,7 +202,7 @@ jobs: include: # Keep GNU Linux release artifacts on Ubuntu 22.04 to preserve # a broadly compatible GLIBC baseline for user distributions. - - os: ubuntu-22.04 + - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204] target: x86_64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz @@ -217,7 +217,7 @@ jobs: linker_env: "" linker: "" use_cross: true - - os: ubuntu-22.04 + - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204] target: aarch64-unknown-linux-gnu artifact: zeroclaw archive_ext: tar.gz @@ -232,7 +232,7 @@ jobs: linker_env: "" linker: "" use_cross: true - - os: ubuntu-22.04 + - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204] target: armv7-unknown-linux-gnueabihf artifact: zeroclaw archive_ext: tar.gz @@ -418,16 +418,21 @@ jobs: LINKER_ENV: ${{ matrix.linker_env }} LINKER: ${{ matrix.linker }} USE_CROSS: ${{ matrix.use_cross }} + ZEROCLAW_RELEASE_CARGO_FEATURES: channel-matrix run: | + BUILD_ARGS=(--profile release-fast --locked --target ${{ matrix.target }}) + if [ -n "$ZEROCLAW_RELEASE_CARGO_FEATURES" ]; then + BUILD_ARGS+=(--features "$ZEROCLAW_RELEASE_CARGO_FEATURES") + fi if [ -n "$LINKER_ENV" ] && [ -n "$LINKER" ]; then echo "Using linker override: $LINKER_ENV=$LINKER" export "$LINKER_ENV=$LINKER" fi if [ "$USE_CROSS" = "true" ]; then - echo "Using cross for MUSL target" - cross build --profile release-fast --locked --target ${{ matrix.target }} + echo "Using cross for official release build" + cross build "${BUILD_ARGS[@]}" else - cargo build --profile release-fast --locked --target ${{ matrix.target }} + cargo build "${BUILD_ARGS[@]}" fi - name: Check binary size (Unix) diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs index e3851de17..078fa2e8e 100644 --- a/src/agent/loop_.rs +++ b/src/agent/loop_.rs @@ -1808,7 +1808,7 @@ pub async fn run( message: Option, provider_override: Option, model_override: Option, - temperature: f64, + temperature: Option, peripheral_overrides: Vec, interactive: bool, ) -> Result { @@ -1881,6 +1881,7 @@ pub async fn run( .as_deref() .or(config.default_model.as_deref()) .unwrap_or("anthropic/claude-sonnet-4"); + let temperature = temperature.unwrap_or(config.default_temperature); let provider_runtime_options = providers::ProviderRuntimeOptions { auth_profile_override: None, diff --git a/src/config/schema.rs b/src/config/schema.rs index dee0e8f28..edd8f6aa8 100644 --- a/src/config/schema.rs +++ b/src/config/schema.rs @@ -6691,11 +6691,41 @@ impl Config { set_runtime_proxy_config(self.proxy.clone()); } - pub async fn save(&self) -> Result<()> { - // Encrypt secrets before serialization - let mut config_to_save = self.clone(); - let zeroclaw_dir = self + async fn resolve_config_path_for_save(&self) -> Result { + if self .config_path + .parent() + .is_some_and(|parent| !parent.as_os_str().is_empty()) + { + return Ok(self.config_path.clone()); + } + + let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?; + let (zeroclaw_dir, _workspace_dir, source) = + resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?; + let file_name = self + .config_path + .file_name() + .filter(|name| !name.is_empty()) + .unwrap_or_else(|| std::ffi::OsStr::new("config.toml")); + let resolved = zeroclaw_dir.join(file_name); + tracing::warn!( + path = %self.config_path.display(), + resolved = %resolved.display(), + source = source.as_str(), + "Config path missing parent directory; resolving from runtime environment" + ); + Ok(resolved) + } + + pub async fn save(&mut self) -> Result<()> { + // Encrypt secrets before serialization + let config_path = self.resolve_config_path_for_save().await?; + // Keep the in-memory config_path in sync so downstream reads + // (e.g. proxy_config, model_routing_config) use the resolved path. + self.config_path = config_path.clone(); + let mut config_to_save = self.clone(); + let zeroclaw_dir = config_path .parent() .context("Config path must have a parent directory")?; let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt); @@ -6764,8 +6794,7 @@ impl Config { let toml_str = toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?; - let parent_dir = self - .config_path + let parent_dir = config_path .parent() .context("Config path must have a parent directory")?; @@ -6776,8 +6805,7 @@ impl Config { ) })?; - let file_name = self - .config_path + let file_name = config_path .file_name() .and_then(|v| v.to_str()) .unwrap_or("config.toml"); @@ -6817,9 +6845,9 @@ impl Config { .context("Failed to fsync temporary config file")?; drop(temp_file); - let had_existing_config = self.config_path.exists(); + let had_existing_config = config_path.exists(); if had_existing_config { - fs::copy(&self.config_path, &backup_path) + fs::copy(&config_path, &backup_path) .await .with_context(|| { format!( @@ -6829,10 +6857,10 @@ impl Config { })?; } - if let Err(e) = fs::rename(&temp_path, &self.config_path).await { + if let Err(e) = fs::rename(&temp_path, &config_path).await { let _ = fs::remove_file(&temp_path).await; if had_existing_config && backup_path.exists() { - fs::copy(&backup_path, &self.config_path) + fs::copy(&backup_path, &config_path) .await .context("Failed to restore config backup")?; } @@ -6842,12 +6870,12 @@ impl Config { #[cfg(unix)] { use std::{fs::Permissions, os::unix::fs::PermissionsExt}; - fs::set_permissions(&self.config_path, Permissions::from_mode(0o600)) + fs::set_permissions(&config_path, Permissions::from_mode(0o600)) .await .with_context(|| { format!( "Failed to enforce secure permissions on config file: {}", - self.config_path.display() + config_path.display() ) })?; } @@ -7660,7 +7688,7 @@ tool_dispatcher = "xml" fs::create_dir_all(&dir).await.unwrap(); let config_path = dir.join("config.toml"); - let config = Config { + let mut config = Config { workspace_dir: dir.join("workspace"), config_path: config_path.clone(), api_key: Some("sk-roundtrip".into()), @@ -10486,6 +10514,44 @@ default_model = "legacy-model" ); } + #[test] + async fn save_repairs_bare_config_filename_using_runtime_resolution() { + let _env_guard = env_override_lock().await; + let temp_home = + std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4())); + let workspace_dir = temp_home.join("workspace"); + let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml"); + + let original_home = std::env::var("HOME").ok(); + std::env::set_var("HOME", &temp_home); + std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir); + + let mut config = Config::default(); + config.workspace_dir = workspace_dir; + config.config_path = PathBuf::from("config.toml"); + config.default_temperature = 0.5; + config.save().await.unwrap(); + + assert!(resolved_config_path.exists()); + assert_eq!( + config.config_path, resolved_config_path, + "save() must update config_path to the resolved path" + ); + let saved = tokio::fs::read_to_string(&resolved_config_path) + .await + .unwrap(); + let parsed: Config = toml::from_str(&saved).unwrap(); + assert_eq!(parsed.default_temperature, 0.5); + + std::env::remove_var("ZEROCLAW_WORKSPACE"); + if let Some(home) = original_home { + std::env::set_var("HOME", home); + } else { + std::env::remove_var("HOME"); + } + let _ = tokio::fs::remove_dir_all(temp_home).await; + } + #[cfg(unix)] #[test] async fn save_restricts_existing_world_readable_config_to_owner_only() { diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs index 26d09875f..e1516b1a9 100644 --- a/src/cron/scheduler.rs +++ b/src/cron/scheduler.rs @@ -173,7 +173,7 @@ async fn run_agent_job( Some(prefixed_prompt), None, model_override, - config.default_temperature, + Some(config.default_temperature), vec![], false, ) diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index 48793221a..89fe6541b 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -272,7 +272,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> { Some(prompt), None, None, - temp, + Some(temp), vec![], false, ) diff --git a/src/gateway/api.rs b/src/gateway/api.rs index 13da1c5e2..a13a59ae9 100644 --- a/src/gateway/api.rs +++ b/src/gateway/api.rs @@ -543,7 +543,7 @@ pub async fn handle_api_config_put( }; let current_config = state.config.lock().clone(); - let new_config = hydrate_config_for_save(incoming, ¤t_config); + let mut new_config = hydrate_config_for_save(incoming, ¤t_config); if let Err(e) = new_config.validate() { return ( @@ -752,7 +752,7 @@ pub async fn handle_api_integration_credentials_put( } } - let updated = match apply_integration_credentials_update(¤t, &id, &body.fields) { + let mut updated = match apply_integration_credentials_update(¤t, &id, &body.fields) { Ok(config) => config, Err(error) if error.starts_with("Unknown integration id:") => { return ( diff --git a/src/main.rs b/src/main.rs index 9fb5a3f51..fb1cb39f4 100644 --- a/src/main.rs +++ b/src/main.rs @@ -195,8 +195,8 @@ Examples: model: Option, /// Temperature (0.0 - 2.0) - #[arg(short, long, default_value = "0.7", value_parser = parse_temperature)] - temperature: f64, + #[arg(short, long, value_parser = parse_temperature)] + temperature: Option, /// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0) #[arg(long)] @@ -2235,6 +2235,44 @@ mod tests { ); } + #[test] + fn agent_cli_does_not_force_temperature_override_when_flag_is_absent() { + let cli = Cli::try_parse_from(["zeroclaw", "agent", "--provider", "openrouter", "-m", "hi"]) + .expect("agent invocation should parse without temperature"); + + match cli.command { + Commands::Agent { temperature, .. } => { + assert_eq!( + temperature, None, + "temperature should stay unset so config.default_temperature is preserved" + ); + } + other => panic!("expected agent command, got {other:?}"), + } + } + + #[test] + fn agent_cli_parses_explicit_temperature_override() { + let cli = Cli::try_parse_from([ + "zeroclaw", + "agent", + "--provider", + "openrouter", + "-m", + "hi", + "--temperature", + "1.1", + ]) + .expect("agent invocation should parse explicit temperature"); + + match cli.command { + Commands::Agent { temperature, .. } => { + assert_eq!(temperature, Some(1.1)); + } + other => panic!("expected agent command, got {other:?}"), + } + } + #[test] fn gateway_cli_accepts_new_pairing_flag() { let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"]) diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs index 159cd3aec..eccfde9d2 100644 --- a/src/onboard/wizard.rs +++ b/src/onboard/wizard.rs @@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result { // ── Build config ── // Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime - let config = Config { + let mut config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: if api_key.is_empty() { @@ -487,7 +487,7 @@ async fn run_quick_setup_with_home( // Create memory config based on backend choice let memory_config = memory_config_defaults_for_backend(&memory_backend_name); - let config = Config { + let mut config = Config { workspace_dir: workspace_dir.clone(), config_path: config_path.clone(), api_key: credential_override.map(|c| { diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs index ea26a1f0a..6b8022dc9 100644 --- a/src/tools/delegate.rs +++ b/src/tools/delegate.rs @@ -6,6 +6,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric}; use crate::providers::{self, ChatMessage, Provider}; use crate::security::policy::ToolOperation; use crate::security::SecurityPolicy; +use crate::tools::SharedToolRegistry; use async_trait::async_trait; use serde_json::json; use std::collections::HashMap; @@ -36,7 +37,7 @@ pub struct DelegateTool { /// Depth at which this tool instance lives in the delegation chain. depth: u32, /// Parent tool registry for agentic sub-agents. - parent_tools: Arc>>, + parent_tools: SharedToolRegistry, /// Inherited multimodal handling config for sub-agent loops. multimodal_config: crate::config::MultimodalConfig, /// Optional typed coordination bus used to trace delegate lifecycle events. @@ -72,7 +73,7 @@ impl DelegateTool { fallback_credential, provider_runtime_options, depth: 0, - parent_tools: Arc::new(Vec::new()), + parent_tools: crate::tools::new_shared_tool_registry(), multimodal_config: crate::config::MultimodalConfig::default(), coordination_bus, coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(), @@ -111,7 +112,7 @@ impl DelegateTool { fallback_credential, provider_runtime_options, depth, - parent_tools: Arc::new(Vec::new()), + parent_tools: crate::tools::new_shared_tool_registry(), multimodal_config: crate::config::MultimodalConfig::default(), coordination_bus, coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(), @@ -119,7 +120,7 @@ impl DelegateTool { } /// Attach parent tools used to build sub-agent allowlist registries. - pub fn with_parent_tools(mut self, parent_tools: Arc>>) -> Self { + pub fn with_parent_tools(mut self, parent_tools: SharedToolRegistry) -> Self { self.parent_tools = parent_tools; self } @@ -461,9 +462,13 @@ impl DelegateTool { .map(|name| name.trim()) .filter(|name| !name.is_empty()) .collect::>(); - - let sub_tools: Vec> = self + let parent_tools = self .parent_tools + .lock() + .map(|tools| tools.clone()) + .unwrap_or_default(); + + let sub_tools: Vec> = parent_tools .iter() .filter(|tool| allowed.contains(tool.name())) .filter(|tool| tool.name() != "delegate") @@ -967,6 +972,12 @@ mod tests { } } + fn shared_parent_tools(tools: Vec>) -> SharedToolRegistry { + let shared = crate::tools::new_shared_tool_registry(); + crate::tools::sync_shared_tool_registry(&shared, &tools); + shared + } + #[test] fn name_and_schema() { let tool = DelegateTool::new(sample_agents(), None, test_security()); @@ -1278,7 +1289,7 @@ mod tests { ); let tool = DelegateTool::new(agents, None, test_security()) - .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)])); + .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)])); let result = tool .execute(json!({"agent": "agentic", "prompt": "test"})) .await @@ -1296,7 +1307,7 @@ mod tests { async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() { let config = agentic_config(vec!["echo_tool".to_string()], 10); let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools( - Arc::new(vec![ + shared_parent_tools(vec![ Arc::new(EchoTool), Arc::new(DelegateTool::new(HashMap::new(), None, test_security())), ]), @@ -1313,11 +1324,33 @@ mod tests { assert!(result.output.contains("done")); } + #[tokio::test] + async fn execute_agentic_reads_late_bound_parent_tools() { + let config = agentic_config(vec!["echo_tool".to_string()], 10); + let parent_tools = crate::tools::new_shared_tool_registry(); + let tool = DelegateTool::new(HashMap::new(), None, test_security()) + .with_parent_tools(parent_tools.clone()); + + crate::tools::sync_shared_tool_registry( + &parent_tools, + &[Arc::new(EchoTool) as Arc], + ); + + let provider = OneToolThenFinalProvider; + let result = tool + .execute_agentic("agentic", &config, &provider, "run", 0.2) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("done")); + } + #[tokio::test] async fn execute_agentic_excludes_delegate_even_if_allowlisted() { let config = agentic_config(vec!["delegate".to_string()], 10); let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools( - Arc::new(vec![Arc::new(DelegateTool::new( + shared_parent_tools(vec![Arc::new(DelegateTool::new( HashMap::new(), None, test_security(), @@ -1342,7 +1375,7 @@ mod tests { async fn execute_agentic_respects_max_iterations() { let config = agentic_config(vec!["echo_tool".to_string()], 2); let tool = DelegateTool::new(HashMap::new(), None, test_security()) - .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)])); + .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)])); let provider = InfiniteToolCallProvider; let result = tool @@ -1362,7 +1395,7 @@ mod tests { async fn execute_agentic_propagates_provider_errors() { let config = agentic_config(vec!["echo_tool".to_string()], 10); let tool = DelegateTool::new(HashMap::new(), None, test_security()) - .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)])); + .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)])); let provider = FailingProvider; let result = tool diff --git a/src/tools/mod.rs b/src/tools/mod.rs index fef85de3a..f5bd45635 100644 --- a/src/tools/mod.rs +++ b/src/tools/mod.rs @@ -126,7 +126,7 @@ use crate::runtime::{NativeRuntime, RuntimeAdapter}; use crate::security::SecurityPolicy; use async_trait::async_trait; use std::collections::HashMap; -use std::sync::Arc; +use std::sync::{Arc, Mutex}; #[derive(Clone)] struct ArcDelegatingTool { @@ -162,6 +162,21 @@ fn boxed_registry_from_arcs(tools: Vec>) -> Vec> { tools.into_iter().map(ArcDelegatingTool::boxed).collect() } +pub(crate) type SharedToolRegistry = Arc>>>; + +pub(crate) fn new_shared_tool_registry() -> SharedToolRegistry { + Arc::new(Mutex::new(Vec::new())) +} + +pub(crate) fn sync_shared_tool_registry( + shared_registry: &SharedToolRegistry, + tools: &[Arc], +) { + if let Ok(mut guard) = shared_registry.lock() { + *guard = tools.to_vec(); + } +} + /// Create the default tool registry pub fn default_tools(security: Arc) -> Vec> { default_tools_with_runtime(security, Arc::new(NativeRuntime::new())) @@ -417,6 +432,7 @@ pub fn all_tools_with_runtime( } // Add delegation and sub-agent orchestration tools when agents are configured + let mut shared_parent_tools = None; if !agents.is_empty() { let delegate_agents: HashMap = agents .iter() @@ -442,7 +458,8 @@ pub fn all_tools_with_runtime( max_tokens_override: None, model_support_vision: root_config.model_support_vision, }; - let parent_tools = Arc::new(tool_arcs.clone()); + let parent_tools = new_shared_tool_registry(); + shared_parent_tools = Some(parent_tools.clone()); let mut delegate_tool = DelegateTool::new_with_options( delegate_agents.clone(), delegate_fallback_credential.clone(), @@ -536,6 +553,10 @@ pub fn all_tools_with_runtime( } } + if let Some(shared_registry) = shared_parent_tools.as_ref() { + sync_shared_tool_registry(shared_registry, &tool_arcs); + } + boxed_registry_from_arcs(tool_arcs) } diff --git a/src/tools/model_routing_config.rs b/src/tools/model_routing_config.rs index 1eaf7bb94..3adec24cc 100644 --- a/src/tools/model_routing_config.rs +++ b/src/tools/model_routing_config.rs @@ -921,7 +921,7 @@ mod tests { } async fn test_config(tmp: &TempDir) -> Arc { - let config = Config { + let mut config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() diff --git a/src/tools/proxy_config.rs b/src/tools/proxy_config.rs index 213a57e0c..f58beed20 100644 --- a/src/tools/proxy_config.rs +++ b/src/tools/proxy_config.rs @@ -450,7 +450,7 @@ mod tests { } async fn test_config(tmp: &TempDir) -> Arc { - let config = Config { + let mut config = Config { workspace_dir: tmp.path().join("workspace"), config_path: tmp.path().join("config.toml"), ..Config::default() diff --git a/src/tools/subagent_spawn.rs b/src/tools/subagent_spawn.rs index 488aa5ffe..17ba43946 100644 --- a/src/tools/subagent_spawn.rs +++ b/src/tools/subagent_spawn.rs @@ -11,6 +11,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric}; use crate::providers::{self, ChatMessage, Provider}; use crate::security::policy::ToolOperation; use crate::security::SecurityPolicy; +use crate::tools::SharedToolRegistry; use async_trait::async_trait; use chrono::Utc; use serde_json::json; @@ -32,7 +33,7 @@ pub struct SubAgentSpawnTool { fallback_credential: Option, provider_runtime_options: providers::ProviderRuntimeOptions, registry: Arc, - parent_tools: Arc>>, + parent_tools: SharedToolRegistry, multimodal_config: crate::config::MultimodalConfig, } @@ -44,7 +45,7 @@ impl SubAgentSpawnTool { security: Arc, provider_runtime_options: providers::ProviderRuntimeOptions, registry: Arc, - parent_tools: Arc>>, + parent_tools: SharedToolRegistry, multimodal_config: crate::config::MultimodalConfig, ) -> Self { Self { @@ -395,7 +396,7 @@ async fn run_agentic_background( agent_config: &DelegateAgentConfig, provider: &dyn Provider, full_prompt: &str, - parent_tools: &[Arc], + parent_tools: &SharedToolRegistry, multimodal_config: &crate::config::MultimodalConfig, ) -> anyhow::Result { if agent_config.allowed_tools.is_empty() { @@ -414,6 +415,10 @@ async fn run_agentic_background( .map(|name| name.trim()) .filter(|name| !name.is_empty()) .collect::>(); + let parent_tools = parent_tools + .lock() + .map(|tools| tools.clone()) + .unwrap_or_default(); let sub_tools: Vec> = parent_tools .iter() @@ -530,6 +535,100 @@ mod tests { agents } + #[derive(Default)] + struct EchoTool; + + #[async_trait] + impl Tool for EchoTool { + fn name(&self) -> &str { + "echo_tool" + } + + fn description(&self) -> &str { + "Echoes the `value` argument." + } + + fn parameters_schema(&self) -> serde_json::Value { + serde_json::json!({ + "type": "object", + "properties": { + "value": {"type": "string"} + }, + "required": ["value"] + }) + } + + async fn execute(&self, args: serde_json::Value) -> anyhow::Result { + let value = args + .get("value") + .and_then(serde_json::Value::as_str) + .unwrap_or_default() + .to_string(); + Ok(ToolResult { + success: true, + output: format!("echo:{value}"), + error: None, + }) + } + } + + struct OneToolThenFinalProvider; + + #[async_trait] + impl Provider for OneToolThenFinalProvider { + async fn chat_with_system( + &self, + _system_prompt: Option<&str>, + _message: &str, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + Ok("unused".to_string()) + } + + async fn chat( + &self, + request: crate::providers::ChatRequest<'_>, + _model: &str, + _temperature: f64, + ) -> anyhow::Result { + let has_tool_message = request.messages.iter().any(|m| m.role == "tool"); + if has_tool_message { + Ok(crate::providers::ChatResponse { + text: Some("done".to_string()), + tool_calls: Vec::new(), + usage: None, + reasoning_content: None, + }) + } else { + Ok(crate::providers::ChatResponse { + text: None, + tool_calls: vec![crate::providers::ToolCall { + id: "call_1".to_string(), + name: "echo_tool".to_string(), + arguments: "{\"value\":\"ping\"}".to_string(), + }], + usage: None, + reasoning_content: None, + }) + } + } + } + + fn agentic_config(allowed_tools: Vec, max_iterations: usize) -> DelegateAgentConfig { + DelegateAgentConfig { + provider: "openrouter".to_string(), + model: "model-test".to_string(), + system_prompt: Some("You are agentic.".to_string()), + api_key: Some("delegate-test-credential".to_string()), + temperature: Some(0.2), + max_depth: 3, + agentic: true, + allowed_tools, + max_iterations, + } + } + fn make_tool( agents: HashMap, security: Arc, @@ -540,7 +639,7 @@ mod tests { security, providers::ProviderRuntimeOptions::default(), Arc::new(SubAgentRegistry::new()), - Arc::new(Vec::new()), + crate::tools::new_shared_tool_registry(), crate::config::MultimodalConfig::default(), ) } @@ -705,7 +804,7 @@ mod tests { test_security(), providers::ProviderRuntimeOptions::default(), registry, - Arc::new(Vec::new()), + crate::tools::new_shared_tool_registry(), crate::config::MultimodalConfig::default(), ); @@ -726,4 +825,30 @@ mod tests { .unwrap(); assert!(desc.contains("researcher")); } + + #[tokio::test] + async fn run_agentic_background_reads_late_bound_parent_tools() { + let config = agentic_config(vec!["echo_tool".to_string()], 10); + let parent_tools = crate::tools::new_shared_tool_registry(); + let provider = OneToolThenFinalProvider; + + crate::tools::sync_shared_tool_registry( + &parent_tools, + &[Arc::new(EchoTool) as Arc], + ); + + let result = run_agentic_background( + "agentic", + &config, + &provider, + "run", + &parent_tools, + &crate::config::MultimodalConfig::default(), + ) + .await + .unwrap(); + + assert!(result.success); + assert!(result.output.contains("done")); + } } diff --git a/web/src/App.tsx b/web/src/App.tsx index 9f4b77fcc..594f23a31 100644 --- a/web/src/App.tsx +++ b/web/src/App.tsx @@ -12,7 +12,7 @@ import Cost from './pages/Cost'; import Logs from './pages/Logs'; import Doctor from './pages/Doctor'; import { AuthProvider, useAuth } from './hooks/useAuth'; -import { setLocale, type Locale } from './lib/i18n'; +import { setLocale, t, useLocale, type Locale } from './lib/i18n'; // Locale context interface LocaleContextType { @@ -21,12 +21,25 @@ interface LocaleContextType { } export const LocaleContext = createContext({ - locale: 'tr', + locale: 'en', setAppLocale: (_locale: Locale) => {}, }); export const useLocaleContext = () => useContext(LocaleContext); +const LOCALE_STORAGE_KEY = 'zeroclaw.web.locale'; + +function readStoredLocale(): Locale | null { + if (typeof window === 'undefined') { + return null; + } + + const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY); + return stored === 'en' || stored === 'tr' || stored === 'zh-CN' + ? stored + : null; +} + // Pairing dialog component function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) { const [code, setCode] = useState(''); @@ -40,7 +53,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) try { await onPair(code); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Pairing failed'); + setError(err instanceof Error ? err.message : t('auth.pairing_failed')); } finally { setLoading(false); } @@ -51,14 +64,14 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise })

ZeroClaw

-

Enter the pairing code from your terminal

+

{t('auth.enter_code_terminal')}

setCode(e.target.value)} - placeholder="6-digit code" + placeholder={t('auth.code_placeholder')} className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4" maxLength={6} autoFocus @@ -71,7 +84,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) disabled={loading || code.length < 6} className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors" > - {loading ? 'Pairing...' : 'Pair'} + {loading ? t('auth.pairing_progress') : t('auth.pair_button')}
@@ -81,9 +94,29 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) function AppContent() { const { isAuthenticated, loading, pair, logout } = useAuth(); - const [locale, setLocaleState] = useState('tr'); + const { locale: detectedLocale } = useLocale(); + const [locale, setLocaleState] = useState(() => readStoredLocale() ?? detectedLocale); + const [hasStoredLocale, setHasStoredLocale] = useState(() => readStoredLocale() !== null); + + useEffect(() => { + if (hasStoredLocale) { + const storedLocale = readStoredLocale(); + if (storedLocale) { + setLocaleState(storedLocale); + setLocale(storedLocale); + } + return; + } + + setLocaleState(detectedLocale); + setLocale(detectedLocale); + }, [detectedLocale, hasStoredLocale]); const setAppLocale = (newLocale: Locale) => { + if (typeof window !== 'undefined') { + window.localStorage.setItem(LOCALE_STORAGE_KEY, newLocale); + } + setHasStoredLocale(true); setLocaleState(newLocale); setLocale(newLocale); }; @@ -100,7 +133,7 @@ function AppContent() { if (loading) { return (
-

Connecting...

+

{t('agent.connecting')}

); } diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts index 0ebb95ecc..69cfe5d50 100644 --- a/web/src/lib/i18n.ts +++ b/web/src/lib/i18n.ts @@ -37,6 +37,15 @@ const translations: Record> = { 'dashboard.overview': 'Overview', 'dashboard.system_info': 'System Information', 'dashboard.quick_actions': 'Quick Actions', + 'dashboard.provider_model': 'Provider / Model', + 'dashboard.since_restart': 'Since last restart', + 'dashboard.cost_overview': 'Cost Overview', + 'dashboard.active_channels': 'Active Channels', + 'dashboard.no_channels': 'No channels configured', + 'dashboard.component_health': 'Component Health', + 'dashboard.no_components': 'No components reporting', + 'dashboard.restarts': 'Restarts: {count}', + 'dashboard.load_error': 'Failed to load dashboard: {error}', // Agent / Chat 'agent.title': 'Agent Chat', @@ -49,6 +58,17 @@ const translations: Record> = { 'agent.thinking': 'Thinking...', 'agent.tool_call': 'Tool Call', 'agent.tool_result': 'Tool Result', + 'agent.empty_title': 'ZeroClaw Agent', + 'agent.empty_subtitle': 'Send a message to start the conversation', + 'agent.connection_error': 'Connection error. Attempting to reconnect...', + 'agent.send_failed': 'Failed to send message. Please try again.', + 'agent.done_without_text': 'Tool execution completed, but no final response text was returned.', + 'agent.tool_call_message': '[Tool Call] {name}({args})', + 'agent.tool_result_message': '[Tool Result] {output}', + 'agent.error_message': '[Error] {message}', + 'agent.typing': 'Typing...', + 'agent.unknown_tool': 'unknown', + 'agent.unknown_error': 'Unknown error', // Tools 'tools.title': 'Available Tools', @@ -58,6 +78,11 @@ const translations: Record> = { 'tools.search': 'Search tools...', 'tools.empty': 'No tools available.', 'tools.count': 'Total tools', + 'tools.agent_tools': 'Agent Tools', + 'tools.cli_tools': 'CLI Tools', + 'tools.parameter_schema': 'Parameter Schema', + 'tools.no_match': 'No tools match your search.', + 'tools.load_error': 'Failed to load tools: {error}', // Cron 'cron.title': 'Scheduled Jobs', @@ -74,6 +99,18 @@ const translations: Record> = { 'cron.enabled': 'Enabled', 'cron.empty': 'No scheduled jobs.', 'cron.confirm_delete': 'Are you sure you want to delete this job?', + 'cron.load_error': 'Failed to load cron jobs: {error}', + 'cron.required_fields': 'Schedule and command are required.', + 'cron.add_error': 'Failed to add job', + 'cron.delete_error': 'Failed to delete job', + 'cron.modal_title': 'Add Cron Job', + 'cron.name_optional': 'Name (optional)', + 'cron.name_placeholder': 'e.g. Daily cleanup', + 'cron.schedule_placeholder': 'e.g. 0 0 * * * (cron expression)', + 'cron.command_placeholder': 'e.g. cleanup --older-than 7d', + 'cron.adding': 'Adding...', + 'cron.empty_configured': 'No scheduled tasks configured.', + 'cron.delete_prompt': 'Delete?', // Integrations 'integrations.title': 'Integrations', @@ -86,6 +123,58 @@ const translations: Record> = { 'integrations.empty': 'No integrations found.', 'integrations.activate': 'Activate', 'integrations.deactivate': 'Deactivate', + 'integrations.load_error': 'Failed to load integrations: {error}', + 'integrations.current_provider': 'current provider', + 'integrations.custom_model_hint_scoped': 'Format: anthropic/claude-sonnet-4-6', + 'integrations.custom_model_hint_generic': 'Format: claude-sonnet-4-6 (or provider/model when required)', + 'integrations.field_required': '{field} is required.', + 'integrations.custom_value_required': + 'Enter a custom value for {field} or choose a recommended model.', + 'integrations.no_changes': 'No changes to save.', + 'integrations.confirm_switch_provider': + 'Switch default AI provider from {current} to {target}?', + 'integrations.confirm_switch_provider_with_model': + 'Switch default AI provider from {current} to {target} and set model to {model}?', + 'integrations.credentials_saved': '{name} credentials saved.', + 'integrations.save_error': 'Failed to save credentials', + 'integrations.stale_save': + 'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.', + 'integrations.model_updated': 'Model updated to {model} for {name}.', + 'integrations.update_model_error': 'Failed to update model', + 'integrations.stale_model': + 'Configuration changed elsewhere. Refreshed latest settings; choose the model again.', + 'integrations.default_summary': 'default: {model}', + 'integrations.default_only': 'default', + 'integrations.default_badge': 'Default', + 'integrations.configured_badge': 'Configured', + 'integrations.current_model': 'Current model', + 'integrations.quick_model_help': 'For custom model IDs, use Edit Keys.', + 'integrations.default_provider_configured': 'Default provider configured', + 'integrations.provider_configured': 'Provider configured', + 'integrations.credentials_configured': 'Credentials configured', + 'integrations.credentials_not_configured': 'Credentials not configured', + 'integrations.edit_keys': 'Edit Keys', + 'integrations.configure': 'Configure', + 'integrations.configure_title': 'Configure {name}', + 'integrations.configure_intro_update': 'Enter only fields you want to update.', + 'integrations.configure_intro_new': 'Enter required fields to configure this integration.', + 'integrations.default_provider_notice_prefix': + 'Saving here updates credentials and switches your default AI provider to', + 'integrations.default_provider_notice_suffix': + 'For advanced provider settings, use', + 'integrations.keep_current_model': 'Keep current model', + 'integrations.keep_current_model_with_value': 'Keep current model ({model})', + 'integrations.select_recommended_model': 'Select a recommended model', + 'integrations.custom_model': 'Custom model...', + 'integrations.clear_current_model': 'Clear current model', + 'integrations.pick_model_help': + 'Pick a recommended model or choose Custom model. {hint}.', + 'integrations.current_value': 'Current value:', + 'integrations.replace_current_placeholder': 'Enter a new value to replace current', + 'integrations.enter_value_placeholder': 'Enter value', + 'integrations.keep_current_placeholder': 'Type new value, or leave empty to keep current', + 'integrations.save_activate': 'Save & Activate', + 'integrations.save_keys': 'Save Keys', // Memory 'memory.title': 'Memory Store', @@ -101,6 +190,16 @@ const translations: Record> = { 'memory.empty': 'No memory entries found.', 'memory.confirm_delete': 'Are you sure you want to delete this memory entry?', 'memory.all_categories': 'All Categories', + 'memory.load_error': 'Failed to load memory: {error}', + 'memory.required_fields': 'Key and content are required.', + 'memory.store_error': 'Failed to store memory', + 'memory.delete_error': 'Failed to delete memory', + 'memory.add_button': 'Add Memory', + 'memory.key_placeholder': 'e.g. user_preferences', + 'memory.content_placeholder': 'Memory content...', + 'memory.category_optional': 'Category (optional)', + 'memory.category_placeholder': 'e.g. preferences, context, facts', + 'memory.delete_prompt': 'Delete?', // Config 'config.title': 'Configuration', @@ -110,6 +209,10 @@ const translations: Record> = { 'config.error': 'Failed to save configuration.', 'config.loading': 'Loading configuration...', 'config.editor_placeholder': 'TOML configuration...', + 'config.sensitive_title': 'Sensitive fields are masked', + 'config.sensitive_body': + 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.', + 'config.editor_title': 'TOML Configuration', // Cost 'cost.title': 'Cost Tracker', @@ -123,6 +226,14 @@ const translations: Record> = { 'cost.tokens': 'Tokens', 'cost.requests': 'Requests', 'cost.usd': 'Cost (USD)', + 'cost.load_error': 'Failed to load cost data: {error}', + 'cost.total_requests': 'Total Requests', + 'cost.token_statistics': 'Token Statistics', + 'cost.average_tokens_per_request': 'Avg Tokens / Request', + 'cost.cost_per_1k_tokens': 'Cost per 1K Tokens', + 'cost.model_breakdown': 'Model Breakdown', + 'cost.no_models': 'No model data available.', + 'cost.share': 'Share', // Logs 'logs.title': 'Live Logs', @@ -133,6 +244,11 @@ const translations: Record> = { 'logs.empty': 'No log entries.', 'logs.connected': 'Connected to event stream.', 'logs.disconnected': 'Disconnected from event stream.', + 'logs.jump_to_bottom': 'Jump to bottom', + 'logs.events': 'events', + 'logs.filter_label': 'Filter:', + 'logs.paused_empty': 'Log streaming is paused.', + 'logs.waiting_empty': 'Waiting for events...', // Doctor 'doctor.title': 'System Diagnostics', @@ -146,6 +262,13 @@ const translations: Record> = { 'doctor.message': 'Message', 'doctor.empty': 'No diagnostics have been run yet.', 'doctor.summary': 'Diagnostic Summary', + 'doctor.run_error': 'Failed to run diagnostics', + 'doctor.running_short': 'Running...', + 'doctor.may_take_seconds': 'This may take a few seconds.', + 'doctor.issues_found': 'Issues Found', + 'doctor.warnings': 'Warnings', + 'doctor.all_clear': 'All Clear', + 'doctor.empty_help': 'Click "Run Diagnostics" to check your ZeroClaw installation.', // Auth / Pairing 'auth.pair': 'Pair Device', @@ -155,6 +278,9 @@ const translations: Record> = { 'auth.pairing_success': 'Pairing successful!', 'auth.pairing_failed': 'Pairing failed. Please try again.', 'auth.enter_code': 'Enter your pairing code to connect to the agent.', + 'auth.enter_code_terminal': 'Enter the pairing code from your terminal', + 'auth.code_placeholder': '6-digit code', + 'auth.pairing_progress': 'Pairing...', // Common 'common.loading': 'Loading...', @@ -178,6 +304,27 @@ const translations: Record> = { 'common.status': 'Status', 'common.created': 'Created', 'common.updated': 'Updated', + 'common.saving': 'Saving...', + 'common.adding': 'Adding...', + 'common.enabled': 'Enabled', + 'common.disabled': 'Disabled', + 'common.active': 'Active', + 'common.inactive': 'Inactive', + 'common.optional': 'optional', + 'common.id': 'ID', + 'common.path': 'Path', + 'common.version': 'Version', + 'common.all': 'All', + 'common.unknown': 'Unknown', + 'common.current': 'Current', + 'common.current_value': 'Current value', + 'common.apply': 'Apply', + 'common.configured': 'Configured', + 'common.configure': 'Configure', + 'common.default': 'Default', + 'common.events': 'events', + 'common.lines': 'lines', + 'common.select': 'Select', // Health 'health.title': 'System Health', @@ -220,6 +367,15 @@ const translations: Record> = { 'dashboard.overview': 'Genel Bakis', 'dashboard.system_info': 'Sistem Bilgisi', 'dashboard.quick_actions': 'Hizli Islemler', + 'dashboard.provider_model': 'Saglayici / Model', + 'dashboard.since_restart': 'Son yeniden baslatmadan beri', + 'dashboard.cost_overview': 'Maliyet Ozeti', + 'dashboard.active_channels': 'Aktif Kanallar', + 'dashboard.no_channels': 'Yapilandirilmis kanal yok', + 'dashboard.component_health': 'Bilesen Sagligi', + 'dashboard.no_components': 'Raporlayan bilesen yok', + 'dashboard.restarts': 'Yeniden baslatmalar: {count}', + 'dashboard.load_error': 'Kontrol paneli yuklenemedi: {error}', // Agent / Chat 'agent.title': 'Ajan Sohbet', @@ -232,6 +388,17 @@ const translations: Record> = { 'agent.thinking': 'Dusunuyor...', 'agent.tool_call': 'Arac Cagrisi', 'agent.tool_result': 'Arac Sonucu', + 'agent.empty_title': 'ZeroClaw Ajan', + 'agent.empty_subtitle': 'Sohbeti baslatmak icin bir mesaj gonderin', + 'agent.connection_error': 'Baglanti hatasi. Yeniden baglanmayi deniyor...', + 'agent.send_failed': 'Mesaj gonderilemedi. Lutfen tekrar deneyin.', + 'agent.done_without_text': 'Arac calismasi tamamlandi ancak son yanit metni dondurulmedi.', + 'agent.tool_call_message': '[Arac Cagrisi] {name}({args})', + 'agent.tool_result_message': '[Arac Sonucu] {output}', + 'agent.error_message': '[Hata] {message}', + 'agent.typing': 'Yaziyor...', + 'agent.unknown_tool': 'bilinmeyen', + 'agent.unknown_error': 'Bilinmeyen hata', // Tools 'tools.title': 'Mevcut Araclar', @@ -241,6 +408,11 @@ const translations: Record> = { 'tools.search': 'Arac ara...', 'tools.empty': 'Mevcut arac yok.', 'tools.count': 'Toplam arac', + 'tools.agent_tools': 'Ajan Araclari', + 'tools.cli_tools': 'CLI Araclari', + 'tools.parameter_schema': 'Parametre Semasi', + 'tools.no_match': 'Aramanizla eslesen arac yok.', + 'tools.load_error': 'Araclar yuklenemedi: {error}', // Cron 'cron.title': 'Zamanlanmis Gorevler', @@ -257,6 +429,18 @@ const translations: Record> = { 'cron.enabled': 'Etkin', 'cron.empty': 'Zamanlanmis gorev yok.', 'cron.confirm_delete': 'Bu gorevi silmek istediginizden emin misiniz?', + 'cron.load_error': 'Zamanlanmis gorevler yuklenemedi: {error}', + 'cron.required_fields': 'Zamanlama ve komut gereklidir.', + 'cron.add_error': 'Gorev eklenemedi', + 'cron.delete_error': 'Gorev silinemedi', + 'cron.modal_title': 'Cron Gorevi Ekle', + 'cron.name_optional': 'Ad (istege bagli)', + 'cron.name_placeholder': 'ornegin Gunluk temizleme', + 'cron.schedule_placeholder': 'ornegin 0 0 * * * (cron ifadesi)', + 'cron.command_placeholder': 'ornegin cleanup --older-than 7d', + 'cron.adding': 'Ekleniyor...', + 'cron.empty_configured': 'Yapilandirilmis zamanlanmis gorev yok.', + 'cron.delete_prompt': 'Silinsin mi?', // Integrations 'integrations.title': 'Entegrasyonlar', @@ -269,6 +453,60 @@ const translations: Record> = { 'integrations.empty': 'Entegrasyon bulunamadi.', 'integrations.activate': 'Etkinlestir', 'integrations.deactivate': 'Devre Disi Birak', + 'integrations.load_error': 'Entegrasyonlar yuklenemedi: {error}', + 'integrations.current_provider': 'mevcut saglayici', + 'integrations.custom_model_hint_scoped': 'Format: anthropic/claude-sonnet-4-6', + 'integrations.custom_model_hint_generic': + 'Format: claude-sonnet-4-6 (gerektiginde provider/model)', + 'integrations.field_required': '{field} gereklidir.', + 'integrations.custom_value_required': + '{field} icin ozel bir deger girin veya onerilen bir model secin.', + 'integrations.no_changes': 'Kaydedilecek degisiklik yok.', + 'integrations.confirm_switch_provider': + 'Varsayilan AI saglayicisini {current} konumundan {target} konumuna gecirmek istiyor musunuz?', + 'integrations.confirm_switch_provider_with_model': + 'Varsayilan AI saglayicisini {current} konumundan {target} konumuna gecirip modeli {model} olarak ayarlamak istiyor musunuz?', + 'integrations.credentials_saved': '{name} kimlik bilgileri kaydedildi.', + 'integrations.save_error': 'Kimlik bilgileri kaydedilemedi', + 'integrations.stale_save': + 'Yapilandirma baska yerde degisti. En guncel ayarlar yenilendi; degerleri yeniden girip tekrar kaydedin.', + 'integrations.model_updated': '{name} icin model {model} olarak guncellendi.', + 'integrations.update_model_error': 'Model guncellenemedi', + 'integrations.stale_model': + 'Yapilandirma baska yerde degisti. En guncel ayarlar yenilendi; modeli yeniden secin.', + 'integrations.default_summary': 'varsayilan: {model}', + 'integrations.default_only': 'varsayilan', + 'integrations.default_badge': 'Varsayilan', + 'integrations.configured_badge': 'Yapilandirildi', + 'integrations.current_model': 'Guncel model', + 'integrations.quick_model_help': 'Ozel model kimlikleri icin Anahtarlari Duzenle secenegini kullanin.', + 'integrations.default_provider_configured': 'Varsayilan saglayici yapilandirildi', + 'integrations.provider_configured': 'Saglayici yapilandirildi', + 'integrations.credentials_configured': 'Kimlik bilgileri yapilandirildi', + 'integrations.credentials_not_configured': 'Kimlik bilgileri yapilandirilmadi', + 'integrations.edit_keys': 'Anahtarlari Duzenle', + 'integrations.configure': 'Yapilandir', + 'integrations.configure_title': '{name} Yapilandir', + 'integrations.configure_intro_update': 'Yalnizca guncellemek istediginiz alanlari girin.', + 'integrations.configure_intro_new': 'Bu entegrasyonu yapilandirmak icin gerekli alanlari girin.', + 'integrations.default_provider_notice_prefix': + 'Burada kaydetmek kimlik bilgilerini gunceller ve varsayilan AI saglayicinizi su olarak degistirir:', + 'integrations.default_provider_notice_suffix': + 'Gelismis saglayici ayarlari icin sunu kullanin:', + 'integrations.keep_current_model': 'Guncel modeli koru', + 'integrations.keep_current_model_with_value': 'Guncel modeli koru ({model})', + 'integrations.select_recommended_model': 'Onerilen bir model secin', + 'integrations.custom_model': 'Ozel model...', + 'integrations.clear_current_model': 'Guncel modeli temizle', + 'integrations.pick_model_help': + 'Onerilen bir model secin veya Ozel model secenegini kullanin. {hint}.', + 'integrations.current_value': 'Guncel deger:', + 'integrations.replace_current_placeholder': 'Gunceli degistirmek icin yeni bir deger girin', + 'integrations.enter_value_placeholder': 'Deger girin', + 'integrations.keep_current_placeholder': + 'Yeni deger yazin veya gunceli korumak icin bos birakin', + 'integrations.save_activate': 'Kaydet ve Etkinlestir', + 'integrations.save_keys': 'Anahtarlari Kaydet', // Memory 'memory.title': 'Hafiza Deposu', @@ -284,6 +522,16 @@ const translations: Record> = { 'memory.empty': 'Hafiza kaydi bulunamadi.', 'memory.confirm_delete': 'Bu hafiza kaydini silmek istediginizden emin misiniz?', 'memory.all_categories': 'Tum Kategoriler', + 'memory.load_error': 'Hafiza yuklenemedi: {error}', + 'memory.required_fields': 'Anahtar ve icerik gereklidir.', + 'memory.store_error': 'Hafiza kaydedilemedi', + 'memory.delete_error': 'Hafiza silinemedi', + 'memory.add_button': 'Hafiza Ekle', + 'memory.key_placeholder': 'ornegin user_preferences', + 'memory.content_placeholder': 'Hafiza icerigi...', + 'memory.category_optional': 'Kategori (istege bagli)', + 'memory.category_placeholder': 'ornegin preferences, context, facts', + 'memory.delete_prompt': 'Silinsin mi?', // Config 'config.title': 'Yapilandirma', @@ -293,6 +541,10 @@ const translations: Record> = { 'config.error': 'Yapilandirma kaydedilemedi.', 'config.loading': 'Yapilandirma yukleniyor...', 'config.editor_placeholder': 'TOML yapilandirmasi...', + 'config.sensitive_title': 'Hassas alanlar maskelenir', + 'config.sensitive_body': + 'Guvenlik icin API anahtarlari, tokenlar ve parolalar gizlenir. Maskeli bir alani guncellemek icin tum maskeli degeri yeni degerinizle degistirin.', + 'config.editor_title': 'TOML Yapilandirmasi', // Cost 'cost.title': 'Maliyet Takibi', @@ -306,6 +558,14 @@ const translations: Record> = { 'cost.tokens': 'Token', 'cost.requests': 'Istekler', 'cost.usd': 'Maliyet (USD)', + 'cost.load_error': 'Maliyet verileri yuklenemedi: {error}', + 'cost.total_requests': 'Toplam Istek', + 'cost.token_statistics': 'Token Istatistikleri', + 'cost.average_tokens_per_request': 'Istek Basina Ort. Token', + 'cost.cost_per_1k_tokens': '1K Token Basina Maliyet', + 'cost.model_breakdown': 'Model Dagilimi', + 'cost.no_models': 'Model verisi yok.', + 'cost.share': 'Pay', // Logs 'logs.title': 'Canli Kayitlar', @@ -316,6 +576,11 @@ const translations: Record> = { 'logs.empty': 'Kayit girisi yok.', 'logs.connected': 'Olay akisina baglandi.', 'logs.disconnected': 'Olay akisi baglantisi kesildi.', + 'logs.jump_to_bottom': 'En alta git', + 'logs.events': 'olay', + 'logs.filter_label': 'Filtre:', + 'logs.paused_empty': 'Kayit akisi duraklatildi.', + 'logs.waiting_empty': 'Olaylar bekleniyor...', // Doctor 'doctor.title': 'Sistem Teshisleri', @@ -329,6 +594,13 @@ const translations: Record> = { 'doctor.message': 'Mesaj', 'doctor.empty': 'Henuz teshis calistirilmadi.', 'doctor.summary': 'Teshis Ozeti', + 'doctor.run_error': 'Teshisler calistirilamadi', + 'doctor.running_short': 'Calisiyor...', + 'doctor.may_take_seconds': 'Bu islem birkac saniye surebilir.', + 'doctor.issues_found': 'Sorunlar Bulundu', + 'doctor.warnings': 'Uyarilar', + 'doctor.all_clear': 'Her Sey Yolunda', + 'doctor.empty_help': 'ZeroClaw kurulumunuzu kontrol etmek icin "Teshis Calistir" dugmesine tiklayin.', // Auth / Pairing 'auth.pair': 'Cihaz Esle', @@ -338,6 +610,9 @@ const translations: Record> = { 'auth.pairing_success': 'Eslestirme basarili!', 'auth.pairing_failed': 'Eslestirme basarisiz. Lutfen tekrar deneyin.', 'auth.enter_code': 'Ajana baglanmak icin eslestirme kodunuzu girin.', + 'auth.enter_code_terminal': 'Terminalinizdeki eslestirme kodunu girin', + 'auth.code_placeholder': '6 haneli kod', + 'auth.pairing_progress': 'Eslestiriliyor...', // Common 'common.loading': 'Yukleniyor...', @@ -361,6 +636,27 @@ const translations: Record> = { 'common.status': 'Durum', 'common.created': 'Olusturulma', 'common.updated': 'Guncellenme', + 'common.saving': 'Kaydediliyor...', + 'common.adding': 'Ekleniyor...', + 'common.enabled': 'Etkin', + 'common.disabled': 'Devre disi', + 'common.active': 'Aktif', + 'common.inactive': 'Pasif', + 'common.optional': 'istege bagli', + 'common.id': 'Kimlik', + 'common.path': 'Yol', + 'common.version': 'Surum', + 'common.all': 'Tum', + 'common.unknown': 'Bilinmeyen', + 'common.current': 'Guncel', + 'common.current_value': 'Guncel deger', + 'common.apply': 'Uygula', + 'common.configured': 'Yapilandirildi', + 'common.configure': 'Yapilandir', + 'common.default': 'Varsayilan', + 'common.events': 'olay', + 'common.lines': 'satir', + 'common.select': 'Sec', // Health 'health.title': 'Sistem Sagligi', @@ -403,6 +699,15 @@ const translations: Record> = { 'dashboard.overview': '总览', 'dashboard.system_info': '系统信息', 'dashboard.quick_actions': '快捷操作', + 'dashboard.provider_model': '提供商 / 模型', + 'dashboard.since_restart': '自上次重启以来', + 'dashboard.cost_overview': '成本总览', + 'dashboard.active_channels': '活跃渠道', + 'dashboard.no_channels': '未配置任何渠道', + 'dashboard.component_health': '组件健康状态', + 'dashboard.no_components': '没有组件上报状态', + 'dashboard.restarts': '重启次数:{count}', + 'dashboard.load_error': '加载仪表盘失败:{error}', // Agent / Chat 'agent.title': '智能体聊天', @@ -415,6 +720,17 @@ const translations: Record> = { 'agent.thinking': '思考中...', 'agent.tool_call': '工具调用', 'agent.tool_result': '工具结果', + 'agent.empty_title': 'ZeroClaw 智能体', + 'agent.empty_subtitle': '发送一条消息以开始对话', + 'agent.connection_error': '连接出错,正在尝试重新连接...', + 'agent.send_failed': '发送消息失败,请重试。', + 'agent.done_without_text': '工具执行已完成,但未返回最终响应文本。', + 'agent.tool_call_message': '[工具调用] {name}({args})', + 'agent.tool_result_message': '[工具结果] {output}', + 'agent.error_message': '[错误] {message}', + 'agent.typing': '输入中...', + 'agent.unknown_tool': '未知', + 'agent.unknown_error': '未知错误', // Tools 'tools.title': '可用工具', @@ -424,6 +740,11 @@ const translations: Record> = { 'tools.search': '搜索工具...', 'tools.empty': '暂无可用工具。', 'tools.count': '工具总数', + 'tools.agent_tools': '智能体工具', + 'tools.cli_tools': 'CLI 工具', + 'tools.parameter_schema': '参数结构', + 'tools.no_match': '没有工具符合你的搜索。', + 'tools.load_error': '加载工具失败:{error}', // Cron 'cron.title': '定时任务', @@ -440,6 +761,18 @@ const translations: Record> = { 'cron.enabled': '已启用', 'cron.empty': '暂无定时任务。', 'cron.confirm_delete': '确定要删除此任务吗?', + 'cron.load_error': '加载定时任务失败:{error}', + 'cron.required_fields': '计划和命令为必填项。', + 'cron.add_error': '添加任务失败', + 'cron.delete_error': '删除任务失败', + 'cron.modal_title': '添加 Cron 任务', + 'cron.name_optional': '名称(可选)', + 'cron.name_placeholder': '例如:每日清理', + 'cron.schedule_placeholder': '例如:0 0 * * *(cron 表达式)', + 'cron.command_placeholder': '例如:cleanup --older-than 7d', + 'cron.adding': '添加中...', + 'cron.empty_configured': '尚未配置定时任务。', + 'cron.delete_prompt': '删除?', // Integrations 'integrations.title': '集成', @@ -452,6 +785,55 @@ const translations: Record> = { 'integrations.empty': '未找到集成。', 'integrations.activate': '激活', 'integrations.deactivate': '停用', + 'integrations.load_error': '加载集成失败:{error}', + 'integrations.current_provider': '当前提供商', + 'integrations.custom_model_hint_scoped': '格式:anthropic/claude-sonnet-4-6', + 'integrations.custom_model_hint_generic': + '格式:claude-sonnet-4-6(需要时使用 provider/model)', + 'integrations.field_required': '{field} 为必填项。', + 'integrations.custom_value_required': '请为 {field} 输入自定义值,或选择推荐模型。', + 'integrations.no_changes': '没有可保存的更改。', + 'integrations.confirm_switch_provider': '要将默认 AI 提供商从 {current} 切换到 {target} 吗?', + 'integrations.confirm_switch_provider_with_model': + '要将默认 AI 提供商从 {current} 切换到 {target},并将模型设置为 {model} 吗?', + 'integrations.credentials_saved': '{name} 凭据已保存。', + 'integrations.save_error': '保存凭据失败', + 'integrations.stale_save': + '配置已在其他地方发生变化。已刷新最新设置;请重新输入值后再次保存。', + 'integrations.model_updated': '{name} 的模型已更新为 {model}。', + 'integrations.update_model_error': '更新模型失败', + 'integrations.stale_model': + '配置已在其他地方发生变化。已刷新最新设置;请重新选择模型。', + 'integrations.default_summary': '默认:{model}', + 'integrations.default_only': '默认', + 'integrations.default_badge': '默认', + 'integrations.configured_badge': '已配置', + 'integrations.current_model': '当前模型', + 'integrations.quick_model_help': '如需自定义模型 ID,请使用“编辑密钥”。', + 'integrations.default_provider_configured': '默认提供商已配置', + 'integrations.provider_configured': '提供商已配置', + 'integrations.credentials_configured': '凭据已配置', + 'integrations.credentials_not_configured': '凭据未配置', + 'integrations.edit_keys': '编辑密钥', + 'integrations.configure': '配置', + 'integrations.configure_title': '配置 {name}', + 'integrations.configure_intro_update': '只输入你想更新的字段。', + 'integrations.configure_intro_new': '输入必填字段以配置此集成。', + 'integrations.default_provider_notice_prefix': + '在此保存会更新凭据,并将你的默认 AI 提供商切换为', + 'integrations.default_provider_notice_suffix': '如需高级提供商设置,请使用', + 'integrations.keep_current_model': '保留当前模型', + 'integrations.keep_current_model_with_value': '保留当前模型({model})', + 'integrations.select_recommended_model': '选择推荐模型', + 'integrations.custom_model': '自定义模型...', + 'integrations.clear_current_model': '清除当前模型', + 'integrations.pick_model_help': '请选择推荐模型或使用自定义模型。{hint}。', + 'integrations.current_value': '当前值:', + 'integrations.replace_current_placeholder': '输入新值以替换当前值', + 'integrations.enter_value_placeholder': '输入值', + 'integrations.keep_current_placeholder': '输入新值,或留空以保留当前值', + 'integrations.save_activate': '保存并激活', + 'integrations.save_keys': '保存密钥', // Memory 'memory.title': '记忆存储', @@ -467,6 +849,16 @@ const translations: Record> = { 'memory.empty': '未找到记忆条目。', 'memory.confirm_delete': '确定要删除此记忆条目吗?', 'memory.all_categories': '全部分类', + 'memory.load_error': '加载记忆失败:{error}', + 'memory.required_fields': '键和内容为必填项。', + 'memory.store_error': '存储记忆失败', + 'memory.delete_error': '删除记忆失败', + 'memory.add_button': '添加记忆', + 'memory.key_placeholder': '例如:user_preferences', + 'memory.content_placeholder': '记忆内容...', + 'memory.category_optional': '分类(可选)', + 'memory.category_placeholder': '例如:preferences、context、facts', + 'memory.delete_prompt': '删除?', // Config 'config.title': '配置', @@ -476,6 +868,10 @@ const translations: Record> = { 'config.error': '配置保存失败。', 'config.loading': '配置加载中...', 'config.editor_placeholder': 'TOML 配置...', + 'config.sensitive_title': '敏感字段已隐藏', + 'config.sensitive_body': + '为了安全,API 密钥、令牌和密码会被隐藏。要更新已隐藏的字段,请用你的新值替换整个隐藏值。', + 'config.editor_title': 'TOML 配置', // Cost 'cost.title': '成本追踪', @@ -489,6 +885,14 @@ const translations: Record> = { 'cost.tokens': 'Token', 'cost.requests': '请求', 'cost.usd': '成本(USD)', + 'cost.load_error': '加载成本数据失败:{error}', + 'cost.total_requests': '总请求数', + 'cost.token_statistics': 'Token 统计', + 'cost.average_tokens_per_request': '每次请求平均 Token', + 'cost.cost_per_1k_tokens': '每 1K Token 成本', + 'cost.model_breakdown': '模型拆分', + 'cost.no_models': '暂无模型数据。', + 'cost.share': '占比', // Logs 'logs.title': '实时日志', @@ -499,6 +903,11 @@ const translations: Record> = { 'logs.empty': '暂无日志条目。', 'logs.connected': '已连接到事件流。', 'logs.disconnected': '与事件流断开连接。', + 'logs.jump_to_bottom': '跳到末尾', + 'logs.events': '事件', + 'logs.filter_label': '筛选:', + 'logs.paused_empty': '日志流已暂停。', + 'logs.waiting_empty': '正在等待事件...', // Doctor 'doctor.title': '系统诊断', @@ -512,6 +921,13 @@ const translations: Record> = { 'doctor.message': '消息', 'doctor.empty': '尚未运行诊断。', 'doctor.summary': '诊断摘要', + 'doctor.run_error': '运行诊断失败', + 'doctor.running_short': '运行中...', + 'doctor.may_take_seconds': '这可能需要几秒钟。', + 'doctor.issues_found': '发现问题', + 'doctor.warnings': '警告', + 'doctor.all_clear': '一切正常', + 'doctor.empty_help': '点击“运行诊断”以检查你的 ZeroClaw 安装。', // Auth / Pairing 'auth.pair': '设备配对', @@ -521,6 +937,9 @@ const translations: Record> = { 'auth.pairing_success': '配对成功!', 'auth.pairing_failed': '配对失败,请重试。', 'auth.enter_code': '输入配对码以连接到智能体。', + 'auth.enter_code_terminal': '输入终端中的配对码', + 'auth.code_placeholder': '6 位代码', + 'auth.pairing_progress': '配对中...', // Common 'common.loading': '加载中...', @@ -544,6 +963,27 @@ const translations: Record> = { 'common.status': '状态', 'common.created': '创建时间', 'common.updated': '更新时间', + 'common.saving': '保存中...', + 'common.adding': '添加中...', + 'common.enabled': '已启用', + 'common.disabled': '已禁用', + 'common.active': '激活', + 'common.inactive': '未激活', + 'common.optional': '可选', + 'common.id': 'ID', + 'common.path': '路径', + 'common.version': '版本', + 'common.all': '全部', + 'common.unknown': '未知', + 'common.current': '当前', + 'common.current_value': '当前值', + 'common.apply': '应用', + 'common.configured': '已配置', + 'common.configure': '配置', + 'common.default': '默认', + 'common.events': '事件', + 'common.lines': '行', + 'common.select': '选择', // Health 'health.title': '系统健康', @@ -584,6 +1024,16 @@ export function t(key: string): string { return translations[currentLocale]?.[key] ?? translations.en[key] ?? key; } +export function tf( + key: string, + values: Record, +): string { + return Object.entries(values).reduce((result, [name, value]) => { + const token = `{${name}}`; + return result.split(token).join(String(value)); + }, t(key)); +} + /** * Get the translation for a specific locale. Falls back to English, then to the * raw key. diff --git a/web/src/pages/AgentChat.tsx b/web/src/pages/AgentChat.tsx index a926c34b5..0a275bca1 100644 --- a/web/src/pages/AgentChat.tsx +++ b/web/src/pages/AgentChat.tsx @@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react'; import { Send, Bot, User, AlertCircle } from 'lucide-react'; import type { WsMessage } from '@/types/api'; import { WebSocketClient } from '@/lib/ws'; +import { getLocale, t, tf } from '@/lib/i18n'; interface ChatMessage { id: string; @@ -18,8 +19,6 @@ interface PersistedChatMessage { } let fallbackMessageIdCounter = 0; -const EMPTY_DONE_FALLBACK = - 'Tool execution completed, but no final response text was returned.'; const CHAT_HISTORY_STORAGE_KEY = 'zeroclaw.agent_chat.messages.v1'; const MAX_PERSISTED_MESSAGES = 500; @@ -124,7 +123,7 @@ export default function AgentChat() { }; ws.onError = () => { - setError('Connection error. Attempting to reconnect...'); + setError(t('agent.connection_error')); }; ws.onMessage = (msg: WsMessage) => { @@ -137,7 +136,7 @@ export default function AgentChat() { case 'message': case 'done': { const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim(); - const finalContent = content || EMPTY_DONE_FALLBACK; + const finalContent = content || t('agent.done_without_text'); setMessages((prev) => [ ...prev, @@ -160,7 +159,10 @@ export default function AgentChat() { { id: makeMessageId(), role: 'agent', - content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`, + content: tf('agent.tool_call_message', { + name: msg.name ?? t('agent.unknown_tool'), + args: JSON.stringify(msg.args ?? {}), + }), timestamp: new Date(), }, ]); @@ -172,7 +174,9 @@ export default function AgentChat() { { id: makeMessageId(), role: 'agent', - content: `[Tool Result] ${msg.output ?? ''}`, + content: tf('agent.tool_result_message', { + output: msg.output ?? '', + }), timestamp: new Date(), }, ]); @@ -184,7 +188,9 @@ export default function AgentChat() { { id: makeMessageId(), role: 'agent', - content: `[Error] ${msg.message ?? 'Unknown error'}`, + content: tf('agent.error_message', { + message: msg.message ?? t('agent.unknown_error'), + }), timestamp: new Date(), }, ]); @@ -229,7 +235,7 @@ export default function AgentChat() { setTyping(true); pendingContentRef.current = ''; } catch { - setError('Failed to send message. Please try again.'); + setError(t('agent.send_failed')); } setInput(''); @@ -258,8 +264,8 @@ export default function AgentChat() { {messages.length === 0 && (
-

ZeroClaw Agent

-

Send a message to start the conversation

+

{t('agent.empty_title')}

+

{t('agent.empty_subtitle')}

)} @@ -296,7 +302,7 @@ export default function AgentChat() { msg.role === 'user' ? 'text-blue-200' : 'text-gray-500' }`} > - {msg.timestamp.toLocaleTimeString()} + {msg.timestamp.toLocaleTimeString(getLocale())}

@@ -313,7 +319,7 @@ export default function AgentChat() { -

Typing...

+

{t('agent.typing')}

)} @@ -331,7 +337,7 @@ export default function AgentChat() { value={input} onChange={(e) => setInput(e.target.value)} onKeyDown={handleKeyDown} - placeholder={connected ? 'Type a message...' : 'Connecting...'} + placeholder={connected ? t('agent.placeholder') : t('agent.connecting')} disabled={!connected} className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50" /> @@ -351,7 +357,7 @@ export default function AgentChat() { }`} /> - {connected ? 'Connected' : 'Disconnected'} + {connected ? t('agent.connected') : t('agent.disconnected')} diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx index 17a4868d2..eb89945c0 100644 --- a/web/src/pages/Config.tsx +++ b/web/src/pages/Config.tsx @@ -7,6 +7,7 @@ import { ShieldAlert, } from 'lucide-react'; import { getConfig, putConfig } from '@/lib/api'; +import { t } from '@/lib/i18n'; export default function Config() { const [config, setConfig] = useState(''); @@ -31,9 +32,9 @@ export default function Config() { setSuccess(null); try { await putConfig(config); - setSuccess('Configuration saved successfully.'); + setSuccess(t('config.saved')); } catch (err: unknown) { - setError(err instanceof Error ? err.message : 'Failed to save configuration'); + setError(err instanceof Error ? err.message : t('config.error')); } finally { setSaving(false); } @@ -60,7 +61,7 @@ export default function Config() {
-

Configuration

+

{t('config.title')}

@@ -77,11 +78,10 @@ export default function Config() {

- Sensitive fields are masked + {t('config.sensitive_title')}

- API keys, tokens, and passwords are hidden for security. To update a - masked field, replace the entire masked value with your new value. + {t('config.sensitive_body')}

@@ -106,10 +106,10 @@ export default function Config() {
- TOML Configuration + {t('config.editor_title')} - {config.split('\n').length} lines + {config.split('\n').length} {t('common.lines')}