Merge branch 'dev' into issue-3082-allowed-roots-direct-paths

This commit is contained in:
SimianAstronaut7
2026-03-12 00:20:19 +00:00
committed by GitHub
26 changed files with 1137 additions and 314 deletions
+3 -1
View File
@@ -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 }}
+11 -6
View File
@@ -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)
+2 -1
View File
@@ -1808,7 +1808,7 @@ pub async fn run(
message: Option<String>,
provider_override: Option<String>,
model_override: Option<String>,
temperature: f64,
temperature: Option<f64>,
peripheral_overrides: Vec<String>,
interactive: bool,
) -> Result<String> {
@@ -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,
+81 -15
View File
@@ -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<PathBuf> {
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() {
+1 -1
View File
@@ -173,7 +173,7 @@ async fn run_agent_job(
Some(prefixed_prompt),
None,
model_override,
config.default_temperature,
Some(config.default_temperature),
vec![],
false,
)
+1 -1
View File
@@ -272,7 +272,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
Some(prompt),
None,
None,
temp,
Some(temp),
vec![],
false,
)
+2 -2
View File
@@ -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, &current_config);
let mut new_config = hydrate_config_for_save(incoming, &current_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(&current, &id, &body.fields) {
let mut updated = match apply_integration_credentials_update(&current, &id, &body.fields) {
Ok(config) => config,
Err(error) if error.starts_with("Unknown integration id:") => {
return (
+40 -2
View File
@@ -195,8 +195,8 @@ Examples:
model: Option<String>,
/// 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<f64>,
/// 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"])
+2 -2
View File
@@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result<Config> {
// ── 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| {
+44 -11
View File
@@ -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<Vec<Arc<dyn Tool>>>,
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<Vec<Arc<dyn Tool>>>) -> 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::<std::collections::HashSet<_>>();
let sub_tools: Vec<Box<dyn Tool>> = self
let parent_tools = self
.parent_tools
.lock()
.map(|tools| tools.clone())
.unwrap_or_default();
let sub_tools: Vec<Box<dyn Tool>> = 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<Arc<dyn Tool>>) -> 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<dyn Tool>],
);
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
+23 -2
View File
@@ -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<Arc<dyn Tool>>) -> Vec<Box<dyn Tool>> {
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
}
pub(crate) type SharedToolRegistry = Arc<Mutex<Vec<Arc<dyn Tool>>>>;
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<dyn Tool>],
) {
if let Ok(mut guard) = shared_registry.lock() {
*guard = tools.to_vec();
}
}
/// Create the default tool registry
pub fn default_tools(security: Arc<SecurityPolicy>) -> Vec<Box<dyn Tool>> {
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<String, DelegateAgentConfig> = 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)
}
+1 -1
View File
@@ -921,7 +921,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
+1 -1
View File
@@ -450,7 +450,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc<Config> {
let config = Config {
let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
+130 -5
View File
@@ -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<String>,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc<SubAgentRegistry>,
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
parent_tools: SharedToolRegistry,
multimodal_config: crate::config::MultimodalConfig,
}
@@ -44,7 +45,7 @@ impl SubAgentSpawnTool {
security: Arc<SecurityPolicy>,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc<SubAgentRegistry>,
parent_tools: Arc<Vec<Arc<dyn Tool>>>,
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<dyn Tool>],
parent_tools: &SharedToolRegistry,
multimodal_config: &crate::config::MultimodalConfig,
) -> anyhow::Result<ToolResult> {
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::<std::collections::HashSet<_>>();
let parent_tools = parent_tools
.lock()
.map(|tools| tools.clone())
.unwrap_or_default();
let sub_tools: Vec<Box<dyn Tool>> = 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<ToolResult> {
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<String> {
Ok("unused".to_string())
}
async fn chat(
&self,
request: crate::providers::ChatRequest<'_>,
_model: &str,
_temperature: f64,
) -> anyhow::Result<crate::providers::ChatResponse> {
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<String>, 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<String, DelegateAgentConfig>,
security: Arc<SecurityPolicy>,
@@ -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<dyn Tool>],
);
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"));
}
}
+41 -8
View File
@@ -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<LocaleContextType>({
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<void> }) {
const [code, setCode] = useState('');
@@ -40,7 +53,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
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<void> })
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
<p className="text-gray-400">Enter the pairing code from your terminal</p>
<p className="text-gray-400">{t('auth.enter_code_terminal')}</p>
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => 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<void> })
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')}
</button>
</form>
</div>
@@ -81,9 +94,29 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState<Locale>('tr');
const { locale: detectedLocale } = useLocale();
const [locale, setLocaleState] = useState<Locale>(() => 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 (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<p className="text-gray-400">Connecting...</p>
<p className="text-gray-400">{t('agent.connecting')}</p>
</div>
);
}
+450
View File
@@ -37,6 +37,15 @@ const translations: Record<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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<Locale, Record<string, string>> = {
'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, string | number>,
): 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.
+20 -14
View File
@@ -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 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
<p className="text-lg font-medium">{t('agent.empty_title')}</p>
<p className="text-sm mt-1">{t('agent.empty_subtitle')}</p>
</div>
)}
@@ -296,7 +302,7 @@ export default function AgentChat() {
msg.role === 'user' ? 'text-blue-200' : 'text-gray-500'
}`}
>
{msg.timestamp.toLocaleTimeString()}
{msg.timestamp.toLocaleTimeString(getLocale())}
</p>
</div>
</div>
@@ -313,7 +319,7 @@ export default function AgentChat() {
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
<p className="text-xs text-gray-500 mt-1">{t('agent.typing')}</p>
</div>
</div>
)}
@@ -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() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
</div>
+9 -9
View File
@@ -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() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
<h2 className="text-base font-semibold text-white">{t('config.title')}</h2>
</div>
<button
onClick={handleSave}
@@ -68,7 +69,7 @@ export default function Config() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@@ -77,11 +78,10 @@ export default function Config() {
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-300 font-medium">
Sensitive fields are masked
{t('config.sensitive_title')}
</p>
<p className="text-sm text-yellow-400/70 mt-0.5">
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')}
</p>
</div>
</div>
@@ -106,10 +106,10 @@ export default function Config() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
TOML Configuration
{t('config.editor_title')}
</span>
<span className="text-xs text-gray-500">
{config.split('\n').length} lines
{config.split('\n').length} {t('common.lines')}
</span>
</div>
<textarea
+22 -21
View File
@@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import type { CostSummary } from '@/types/api';
import { getCost } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
@@ -28,7 +29,7 @@ export default function Cost() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cost data: {error}
{tf('cost.load_error', { error })}
</div>
</div>
);
@@ -53,7 +54,7 @@ export default function Cost() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Session Cost</span>
<span className="text-sm text-gray-400">{t('cost.session')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.session_cost_usd)}
@@ -65,7 +66,7 @@ export default function Cost() {
<div className="p-2 bg-green-600/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Daily Cost</span>
<span className="text-sm text-gray-400">{t('cost.daily')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.daily_cost_usd)}
@@ -77,7 +78,7 @@ export default function Cost() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Layers className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Monthly Cost</span>
<span className="text-sm text-gray-400">{t('cost.monthly')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.monthly_cost_usd)}
@@ -89,10 +90,10 @@ export default function Cost() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Hash className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Total Requests</span>
<span className="text-sm text-gray-400">{t('cost.total_requests')}</span>
</div>
<p className="text-2xl font-bold text-white">
{cost.request_count.toLocaleString()}
{cost.request_count.toLocaleString(getLocale())}
</p>
</div>
</div>
@@ -100,25 +101,25 @@ export default function Cost() {
{/* Token Statistics */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-base font-semibold text-white mb-4">
Token Statistics
{t('cost.token_statistics')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Total Tokens</p>
<p className="text-sm text-gray-400">{t('cost.total_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens.toLocaleString()}
{cost.total_tokens.toLocaleString(getLocale())}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Avg Tokens / Request</p>
<p className="text-sm text-gray-400">{t('cost.average_tokens_per_request')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.request_count > 0
? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
? Math.round(cost.total_tokens / cost.request_count).toLocaleString(getLocale())
: '0'}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Cost per 1K Tokens</p>
<p className="text-sm text-gray-400">{t('cost.cost_per_1k_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
@@ -132,12 +133,12 @@ export default function Cost() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-white">
Model Breakdown
{t('cost.model_breakdown')}
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No model data available.
{t('cost.no_models')}
</div>
) : (
<div className="overflow-x-auto">
@@ -145,19 +146,19 @@ export default function Cost() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Model
{t('cost.model')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Cost
{t('cost.usd')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Tokens
{t('cost.tokens')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Requests
{t('cost.requests')}
</th>
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Share
{t('cost.share')}
</th>
</tr>
</thead>
@@ -181,10 +182,10 @@ export default function Cost() {
{formatUSD(m.cost_usd)}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
{m.total_tokens.toLocaleString()}
{m.total_tokens.toLocaleString(getLocale())}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
{m.request_count.toLocaleString()}
{m.request_count.toLocaleString(getLocale())}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">
+45 -44
View File
@@ -10,11 +10,12 @@ import {
} from 'lucide-react';
import type { CronJob } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatDate(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleString();
return d.toLocaleString(getLocale());
}
export default function Cron() {
@@ -45,7 +46,7 @@ export default function Cron() {
const handleAdd = async () => {
if (!formSchedule.trim() || !formCommand.trim()) {
setFormError('Schedule and command are required.');
setFormError(t('cron.required_fields'));
return;
}
setSubmitting(true);
@@ -62,7 +63,7 @@ export default function Cron() {
setFormSchedule('');
setFormCommand('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to add job');
setFormError(err instanceof Error ? err.message : t('cron.add_error'));
} finally {
setSubmitting(false);
}
@@ -73,7 +74,7 @@ export default function Cron() {
await deleteCronJob(id);
setJobs((prev) => prev.filter((j) => j.id !== id));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete job');
setError(err instanceof Error ? err.message : t('cron.delete_error'));
} finally {
setConfirmDelete(null);
}
@@ -97,7 +98,7 @@ export default function Cron() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cron jobs: {error}
{tf('cron.load_error', { error })}
</div>
</div>
);
@@ -118,7 +119,7 @@ export default function Cron() {
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Scheduled Tasks ({jobs.length})
{t('cron.title')} ({jobs.length})
</h2>
</div>
<button
@@ -126,7 +127,7 @@ export default function Cron() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Job
{t('cron.add')}
</button>
</div>
@@ -135,7 +136,7 @@ export default function Cron() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Cron Job</h3>
<h3 className="text-lg font-semibold text-white">{t('cron.modal_title')}</h3>
<button
onClick={() => {
setShowForm(false);
@@ -156,37 +157,37 @@ export default function Cron() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Name (optional)
{t('cron.name_optional')}
</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Daily cleanup"
placeholder={t('cron.name_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Schedule <span className="text-red-400">*</span>
{t('cron.schedule')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formSchedule}
onChange={(e) => setFormSchedule(e.target.value)}
placeholder="e.g. 0 0 * * * (cron expression)"
placeholder={t('cron.schedule_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Command <span className="text-red-400">*</span>
{t('cron.command')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formCommand}
onChange={(e) => setFormCommand(e.target.value)}
placeholder="e.g. cleanup --older-than 7d"
placeholder={t('cron.command_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@@ -200,14 +201,14 @@ export default function Cron() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Adding...' : 'Add Job'}
{submitting ? t('cron.adding') : t('cron.add')}
</button>
</div>
</div>
@@ -218,35 +219,35 @@ export default function Cron() {
{jobs.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Clock className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No scheduled tasks configured.</p>
<p className="text-gray-400">{t('cron.empty_configured')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
ID
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Command
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Next Run
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Last Status
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Enabled
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
</tr>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('common.id')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.command')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.next_run')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.last_status')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.enabled')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
{t('common.actions')}
</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
@@ -282,24 +283,24 @@ export default function Cron() {
: 'bg-gray-800 text-gray-500 border border-gray-700'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
{job.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('cron.delete_prompt')}</span>
<button
onClick={() => handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (
+26 -23
View File
@@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
@@ -70,7 +71,7 @@ export default function Dashboard() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load dashboard: {error}
{tf('dashboard.load_error', { error })}
</div>
</div>
);
@@ -95,10 +96,10 @@ export default function Dashboard() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<Cpu className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Provider / Model</span>
<span className="text-sm text-gray-400">{t('dashboard.provider_model')}</span>
</div>
<p className="text-lg font-semibold text-white truncate">
{status.provider ?? 'Unknown'}
{status.provider ?? t('common.unknown')}
</p>
<p className="text-sm text-gray-400 truncate">{status.model}</p>
</div>
@@ -108,12 +109,12 @@ export default function Dashboard() {
<div className="p-2 bg-green-600/20 rounded-lg">
<Clock className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Uptime</span>
<span className="text-sm text-gray-400">{t('dashboard.uptime')}</span>
</div>
<p className="text-lg font-semibold text-white">
{formatUptime(status.uptime_seconds)}
</p>
<p className="text-sm text-gray-400">Since last restart</p>
<p className="text-sm text-gray-400">{t('dashboard.since_restart')}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
@@ -121,12 +122,14 @@ export default function Dashboard() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Globe className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Gateway Port</span>
<span className="text-sm text-gray-400">{t('dashboard.gateway_port')}</span>
</div>
<p className="text-lg font-semibold text-white">
:{status.gateway_port}
</p>
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
<p className="text-sm text-gray-400">
{t('dashboard.locale')}: {status.locale}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
@@ -134,13 +137,13 @@ export default function Dashboard() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Database className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Memory Backend</span>
<span className="text-sm text-gray-400">{t('dashboard.memory_backend')}</span>
</div>
<p className="text-lg font-semibold text-white capitalize">
{status.memory_backend}
</p>
<p className="text-sm text-gray-400">
Paired: {status.paired ? 'Yes' : 'No'}
{t('dashboard.paired')}: {status.paired ? t('common.yes') : t('common.no')}
</p>
</div>
</div>
@@ -150,13 +153,13 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.cost_overview')}</h2>
</div>
<div className="space-y-4">
{[
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
{ label: t('cost.session'), value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: t('cost.daily'), value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: t('cost.monthly'), value: cost.monthly_cost_usd, color: 'bg-purple-500' },
].map(({ label, value, color }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1">
@@ -173,12 +176,12 @@ export default function Dashboard() {
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total Tokens</span>
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
<span className="text-gray-400">{t('cost.total_tokens')}</span>
<span className="text-white">{cost.total_tokens.toLocaleString(getLocale())}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-400">Requests</span>
<span className="text-white">{cost.request_count.toLocaleString()}</span>
<span className="text-gray-400">{t('cost.requests')}</span>
<span className="text-white">{cost.request_count.toLocaleString(getLocale())}</span>
</div>
</div>
@@ -186,11 +189,11 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Radio className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Active Channels</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.active_channels')}</h2>
</div>
<div className="space-y-2">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-gray-500">No channels configured</p>
<p className="text-sm text-gray-500">{t('dashboard.no_channels')}</p>
) : (
Object.entries(status.channels).map(([name, active]) => (
<div
@@ -205,7 +208,7 @@ export default function Dashboard() {
}`}
/>
<span className="text-xs text-gray-400">
{active ? 'Active' : 'Inactive'}
{active ? t('common.active') : t('common.inactive')}
</span>
</div>
</div>
@@ -218,11 +221,11 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Component Health</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.component_health')}</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
<p className="text-sm text-gray-500 col-span-2">{t('dashboard.no_components')}</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<div
@@ -238,7 +241,7 @@ export default function Dashboard() {
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
{comp.restart_count > 0 && (
<p className="text-xs text-yellow-400 mt-1">
Restarts: {comp.restart_count}
{tf('dashboard.restarts', { count: comp.restart_count })}
</p>
)}
</div>
+27 -21
View File
@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { DiagResult } from '@/types/api';
import { runDoctor } from '@/lib/api';
import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
@@ -43,6 +44,17 @@ function severityBg(severity: DiagResult['severity']): string {
}
}
function severityLabel(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return t('doctor.ok');
case 'warn':
return t('doctor.warn');
case 'error':
return t('doctor.error');
}
}
export default function Doctor() {
const [results, setResults] = useState<DiagResult[] | null>(null);
const [loading, setLoading] = useState(false);
@@ -56,7 +68,7 @@ export default function Doctor() {
const data = await runDoctor();
setResults(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
setError(err instanceof Error ? err.message : t('doctor.run_error'));
} finally {
setLoading(false);
}
@@ -82,7 +94,7 @@ export default function Doctor() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Diagnostics</h2>
<h2 className="text-base font-semibold text-white">{t('doctor.title')}</h2>
</div>
<button
onClick={handleRun}
@@ -92,12 +104,12 @@ export default function Doctor() {
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
{t('doctor.running_short')}
</>
) : (
<>
<Play className="h-4 w-4" />
Run Diagnostics
{t('doctor.run')}
</>
)}
</button>
@@ -114,9 +126,9 @@ export default function Doctor() {
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin mb-4" />
<p className="text-gray-400">Running diagnostics...</p>
<p className="text-gray-400">{t('doctor.running')}</p>
<p className="text-sm text-gray-500 mt-1">
This may take a few seconds.
{t('doctor.may_take_seconds')}
</p>
</div>
)}
@@ -129,27 +141,21 @@ export default function Doctor() {
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">ok</span>
{okCount} <span className="text-gray-400 font-normal">{t('doctor.ok')}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
warning{warnCount !== 1 ? 's' : ''}
</span>
{warnCount} <span className="text-gray-400 font-normal">{t('doctor.warn')}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-400" />
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
error{errorCount !== 1 ? 's' : ''}
</span>
{errorCount} <span className="text-gray-400 font-normal">{t('doctor.error')}</span>
</span>
</div>
@@ -157,15 +163,15 @@ export default function Doctor() {
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-900/40 text-red-400 border border-red-700/50">
Issues Found
{t('doctor.issues_found')}
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-yellow-900/40 text-yellow-400 border border-yellow-700/50">
Warnings
{t('doctor.warnings')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-green-900/40 text-green-400 border border-green-700/50">
All Clear
{t('doctor.all_clear')}
</span>
)}
</div>
@@ -191,7 +197,7 @@ export default function Doctor() {
<div className="min-w-0">
<p className="text-sm text-white">{result.message}</p>
<p className="text-xs text-gray-500 mt-0.5 capitalize">
{result.severity}
{severityLabel(result.severity)}
</p>
</div>
</div>
@@ -206,9 +212,9 @@ export default function Doctor() {
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">System Diagnostics</p>
<p className="text-lg font-medium">{t('doctor.title')}</p>
<p className="text-sm mt-1">
Click "Run Diagnostics" to check your ZeroClaw installation.
{t('doctor.empty_help')}
</p>
</div>
)}
+91 -60
View File
@@ -13,25 +13,26 @@ import {
getStatus,
putIntegrationCredentials,
} from '@/lib/api';
import { t, tf } from '@/lib/i18n';
function statusBadge(status: Integration['status']) {
switch (status) {
case 'Active':
return {
icon: Check,
label: 'Active',
label: t('integrations.active'),
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
case 'Available':
return {
icon: Zap,
label: 'Available',
label: t('integrations.available'),
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
case 'ComingSoon':
return {
icon: Clock,
label: 'Coming Soon',
label: t('integrations.coming_soon'),
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
}
@@ -70,9 +71,9 @@ const FALLBACK_MODEL_OPTIONS: Record<string, string[]> = {
function customModelFormatHint(integrationId: string): string {
if (integrationId === 'openrouter' || integrationId === 'vercel') {
return 'Format: anthropic/claude-sonnet-4-6';
return t('integrations.custom_model_hint_scoped');
}
return 'Format: claude-sonnet-4-6 (or provider/model when required)';
return t('integrations.custom_model_hint_generic');
}
function modelOptionsForField(
@@ -175,7 +176,7 @@ export default function Integrations() {
setRuntimeStatus(status ? { model: status.model } : null);
return nextSettingsByName;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load integrations');
setError(err instanceof Error ? err.message : t('common.error'));
setActiveAiIntegrationId(null);
setRuntimeStatus(null);
return null;
@@ -252,7 +253,7 @@ export default function Integrations() {
if (isSelectField) {
if (value === SELECT_KEEP) {
if (field.required && !field.has_value) {
setSaveError(`${field.label} is required.`);
setSaveError(tf('integrations.field_required', { field: field.label }));
return;
}
if (isDirty) {
@@ -268,12 +269,14 @@ export default function Integrations() {
const trimmed = resolvedValue.trim();
if (isSelectField && value === SELECT_CUSTOM && !trimmed) {
setSaveError(`Enter a custom value for ${field.label} or choose a recommended model.`);
setSaveError(
tf('integrations.custom_value_required', { field: field.label }),
);
return;
}
if (field.required && !trimmed && !field.has_value) {
setSaveError(`${field.label} is required.`);
setSaveError(tf('integrations.field_required', { field: field.label }));
return;
}
@@ -289,7 +292,7 @@ export default function Integrations() {
Object.keys(payload).length === 0 &&
!activeEditor.activates_default_provider
) {
setSaveError('No changes to save.');
setSaveError(t('integrations.no_changes'));
return;
}
@@ -298,9 +301,13 @@ export default function Integrations() {
activeAiIntegrationId &&
activeEditor.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const currentProvider =
activeAiIntegration?.name ?? t('integrations.current_provider');
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${activeEditor.name}?`,
tf('integrations.confirm_switch_provider', {
current: currentProvider,
target: activeEditor.name,
}),
);
if (!confirmed) {
return;
@@ -315,10 +322,10 @@ export default function Integrations() {
});
await loadData(false);
setSaveSuccess(`${activeEditor.name} credentials saved.`);
setSaveSuccess(tf('integrations.credentials_saved', { name: activeEditor.name }));
closeEditor();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save credentials';
const message = err instanceof Error ? err.message : t('integrations.save_error');
if (message.includes('API 409')) {
const refreshed = await loadData(false);
if (refreshed) {
@@ -331,7 +338,7 @@ export default function Integrations() {
}
}
setSaveError(
'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.',
t('integrations.stale_save'),
);
} else {
setSaveError(message);
@@ -357,9 +364,14 @@ export default function Integrations() {
!isActiveDefaultProvider &&
integration.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const currentProvider =
activeAiIntegration?.name ?? t('integrations.current_provider');
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${integration.name} and set model to ${trimmedTarget}?`,
tf('integrations.confirm_switch_provider_with_model', {
current: currentProvider,
target: integration.name,
model: trimmedTarget,
}),
);
if (!confirmed) {
return;
@@ -378,19 +390,22 @@ export default function Integrations() {
});
await loadData(false);
setSaveSuccess(`Model updated to ${trimmedTarget} for ${integration.name}.`);
setSaveSuccess(
tf('integrations.model_updated', {
model: trimmedTarget,
name: integration.name,
}),
);
setQuickModelDrafts((prev) => {
const next = { ...prev };
delete next[integration.id];
return next;
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update model';
const message = err instanceof Error ? err.message : t('integrations.update_model_error');
if (message.includes('API 409')) {
await loadData(false);
setQuickModelError(
'Configuration changed elsewhere. Refreshed latest settings; choose the model again.',
);
setQuickModelError(t('integrations.stale_model'));
} else {
setQuickModelError(message);
}
@@ -421,7 +436,7 @@ export default function Integrations() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load integrations: {error}
{tf('integrations.load_error', { error })}
</div>
</div>
);
@@ -441,7 +456,7 @@ export default function Integrations() {
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Integrations ({integrations.length})
{t('integrations.title')} ({integrations.length})
</h2>
</div>
@@ -469,7 +484,7 @@ export default function Integrations() {
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-white'
}`}
>
{cat === 'all' ? 'All' : formatCategory(cat)}
{cat === 'all' ? t('common.all') : formatCategory(cat)}
</button>
))}
</div>
@@ -478,7 +493,7 @@ export default function Integrations() {
{Object.keys(grouped).length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Puzzle className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No integrations found.</p>
<p className="text-gray-400">{t('integrations.empty')}</p>
</div>
) : (
Object.entries(grouped)
@@ -510,8 +525,8 @@ export default function Integrations() {
const modelSummary = currentModel
? currentModel
: fallbackModel
? `default: ${fallbackModel}`
: 'default';
? tf('integrations.default_summary', { model: fallbackModel })
: t('integrations.default_only');
const modelBaseline = currentModel ?? fallbackModel ?? '';
const quickDraft = editable
? quickModelDrafts[editable.id] ?? modelBaseline
@@ -555,7 +570,9 @@ export default function Integrations() {
: 'bg-gray-800 text-gray-300 border-gray-700'
}`}
>
{isActiveDefaultProvider ? 'Default' : 'Configured'}
{isActiveDefaultProvider
? t('integrations.default_badge')
: t('integrations.configured_badge')}
</span>
)}
<span
@@ -571,7 +588,7 @@ export default function Integrations() {
<div className="mt-3 rounded-lg border border-gray-800 bg-gray-950/50 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
Current model
{t('integrations.current_model')}
</span>
<span className="text-xs text-gray-200 truncate" title={modelSummary}>
{modelSummary}
@@ -615,11 +632,13 @@ export default function Integrations() {
}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{quickModelSavingId === editable.id ? 'Saving...' : 'Apply'}
{quickModelSavingId === editable.id
? t('common.saving')
: t('common.apply')}
</button>
</div>
<p className="text-[11px] text-gray-500">
For custom model IDs, use Edit Keys.
{t('integrations.quick_model_help')}
</p>
</div>
)}
@@ -632,17 +651,19 @@ export default function Integrations() {
{editable.configured
? editable.activates_default_provider
? isActiveDefaultProvider
? 'Default provider configured'
: 'Provider configured'
: 'Credentials configured'
: 'Credentials not configured'}
? t('integrations.default_provider_configured')
: t('integrations.provider_configured')
: t('integrations.credentials_configured')
: t('integrations.credentials_not_configured')}
</div>
<button
onClick={() => openEditor(editable)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-blue-700/70 bg-blue-900/30 hover:bg-blue-900/50 text-blue-300 text-xs font-medium transition-colors"
>
<KeyRound className="h-3.5 w-3.5" />
{editable.configured ? 'Edit Keys' : 'Configure'}
{editable.configured
? t('integrations.edit_keys')
: t('integrations.configure')}
</button>
</div>
)}
@@ -667,19 +688,19 @@ export default function Integrations() {
<div className="px-5 py-4 border-b border-gray-800 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">
Configure {activeEditor.name}
{tf('integrations.configure_title', { name: activeEditor.name })}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{activeEditor.configured
? 'Enter only fields you want to update.'
: 'Enter required fields to configure this integration.'}
? t('integrations.configure_intro_update')
: t('integrations.configure_intro_new')}
</p>
</div>
<button
onClick={closeEditor}
disabled={saving}
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="h-4 w-4" />
</button>
@@ -688,10 +709,11 @@ export default function Integrations() {
<div className="p-5 space-y-4">
{activeEditor.activates_default_provider && (
<div className="rounded-lg border border-blue-800 bg-blue-950/30 p-3 text-xs text-blue-200">
Saving here updates credentials and switches your default AI provider to{' '}
<strong>{activeEditor.name}</strong>. For advanced provider settings, use{' '}
{t('integrations.default_provider_notice_prefix')}{' '}
<strong>{activeEditor.name}</strong>.{' '}
{t('integrations.default_provider_notice_suffix')}{' '}
<Link to="/config" className="underline underline-offset-2 hover:text-blue-100">
Configuration
{t('config.title')}
</Link>
.
</div>
@@ -712,8 +734,10 @@ export default function Integrations() {
field.current_value?.trim() ||
(activeEditorIsDefaultProvider ? runtimeStatus?.model?.trim() || '' : '');
const keepCurrentLabel = currentModelValue
? `Keep current model (${currentModelValue})`
: 'Keep current model';
? tf('integrations.keep_current_model_with_value', {
model: currentModelValue,
})
: t('integrations.keep_current_model');
return (
<div key={field.key}>
@@ -722,7 +746,7 @@ export default function Integrations() {
{field.required && <span className="text-red-400">*</span>}
{field.has_value && (
<span className="text-[11px] text-green-400 bg-green-900/30 border border-green-800 px-1.5 py-0.5 rounded">
Configured
{t('common.configured')}
</span>
)}
</label>
@@ -737,7 +761,7 @@ export default function Integrations() {
<option value={SELECT_KEEP}>{keepCurrentLabel}</option>
) : (
<option value="" disabled>
Select a recommended model
{t('integrations.select_recommended_model')}
</option>
)}
{selectOptions.map((option) => (
@@ -745,8 +769,12 @@ export default function Integrations() {
{option}
</option>
))}
<option value={SELECT_CUSTOM}>Custom model...</option>
{field.has_value && <option value={SELECT_CLEAR}>Clear current model</option>}
<option value={SELECT_CUSTOM}>{t('integrations.custom_model')}</option>
{field.has_value && (
<option value={SELECT_CLEAR}>
{t('integrations.clear_current_model')}
</option>
)}
</select>
{fieldValues[field.key] === SELECT_CUSTOM && (
@@ -760,14 +788,17 @@ export default function Integrations() {
)}
<p className="text-[11px] text-gray-500">
Pick a recommended model or choose Custom model. {customModelFormatHint(activeEditor.id)}.
{tf('integrations.pick_model_help', {
hint: customModelFormatHint(activeEditor.id),
})}
</p>
</div>
) : (
<div className="space-y-2">
{maskedSecretValue && (
<p className="text-[11px] text-gray-500">
Current value: <span className="font-mono text-gray-300">{maskedSecretValue}</span>
{t('integrations.current_value')}{' '}
<span className="font-mono text-gray-300">{maskedSecretValue}</span>
</p>
)}
<input
@@ -777,11 +808,11 @@ export default function Integrations() {
placeholder={
field.required
? field.has_value
? 'Enter a new value to replace current'
: 'Enter value'
? t('integrations.replace_current_placeholder')
: t('integrations.enter_value_placeholder')
: field.has_value
? 'Type new value, or leave empty to keep current'
: 'Optional'
? t('integrations.keep_current_placeholder')
: t('common.optional')
}
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
@@ -805,7 +836,7 @@ export default function Integrations() {
disabled={saving}
className="px-4 py-2 rounded-lg text-sm border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors disabled:opacity-50"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={saveCredentials}
@@ -813,10 +844,10 @@ export default function Integrations() {
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{saving
? 'Saving...'
? t('common.saving')
: activeEditor.activates_default_provider
? 'Save & Activate'
: 'Save Keys'}
? t('integrations.save_activate')
: t('integrations.save_keys')}
</button>
</div>
</div>
+12 -13
View File
@@ -8,10 +8,11 @@ import {
} from 'lucide-react';
import type { SSEEvent } from '@/types/api';
import { SSEClient } from '@/lib/sse';
import { getLocale, t } from '@/lib/i18n';
function formatTimestamp(ts?: string): string {
if (!ts) return new Date().toLocaleTimeString();
return new Date(ts).toLocaleTimeString();
if (!ts) return new Date().toLocaleTimeString(getLocale());
return new Date(ts).toLocaleTimeString(getLocale());
}
function eventTypeBadgeColor(type: string): string {
@@ -138,7 +139,7 @@ export default function Logs() {
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Live Logs</h2>
<h2 className="text-base font-semibold text-white">{t('logs.title')}</h2>
<div className="flex items-center gap-2 ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
@@ -146,11 +147,11 @@ export default function Logs() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
<span className="text-xs text-gray-500 ml-2">
{filteredEntries.length} events
{filteredEntries.length} {t('logs.events')}
</span>
</div>
@@ -166,11 +167,11 @@ export default function Logs() {
>
{paused ? (
<>
<Play className="h-3.5 w-3.5" /> Resume
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
</>
) : (
<>
<Pause className="h-3.5 w-3.5" /> Pause
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
</>
)}
</button>
@@ -182,7 +183,7 @@ export default function Logs() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to bottom
{t('logs.jump_to_bottom')}
</button>
)}
</div>
@@ -192,7 +193,7 @@ export default function Logs() {
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-gray-800 bg-gray-900/80 overflow-x-auto">
<Filter className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="text-xs text-gray-500 flex-shrink-0">Filter:</span>
<span className="text-xs text-gray-500 flex-shrink-0">{t('logs.filter_label')}</span>
{allTypes.map((type) => (
<label
key={type}
@@ -212,7 +213,7 @@ export default function Logs() {
onClick={() => setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
>
Clear
{t('logs.clear')}
</button>
)}
</div>
@@ -228,9 +229,7 @@ export default function Logs() {
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Activity className="h-10 w-10 text-gray-600 mb-3" />
<p className="text-sm">
{paused
? 'Log streaming is paused.'
: 'Waiting for events...'}
{paused ? t('logs.paused_empty') : t('logs.waiting_empty')}
</p>
</div>
) : (
+41 -40
View File
@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { MemoryEntry } from '@/types/api';
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
@@ -17,7 +18,7 @@ function truncate(text: string, max: number): string {
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
return d.toLocaleString(getLocale());
}
export default function Memory() {
@@ -60,7 +61,7 @@ export default function Memory() {
const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) {
setFormError('Key and content are required.');
setFormError(t('memory.required_fields'));
return;
}
setSubmitting(true);
@@ -77,7 +78,7 @@ export default function Memory() {
setFormContent('');
setFormCategory('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to store memory');
setFormError(err instanceof Error ? err.message : t('memory.store_error'));
} finally {
setSubmitting(false);
}
@@ -88,7 +89,7 @@ export default function Memory() {
await deleteMemory(key);
setEntries((prev) => prev.filter((e) => e.key !== key));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete memory');
setError(err instanceof Error ? err.message : t('memory.delete_error'));
} finally {
setConfirmDelete(null);
}
@@ -98,7 +99,7 @@ export default function Memory() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load memory: {error}
{tf('memory.load_error', { error })}
</div>
</div>
);
@@ -111,7 +112,7 @@ export default function Memory() {
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Memory ({entries.length})
{t('memory.title')} ({entries.length})
</h2>
</div>
<button
@@ -119,7 +120,7 @@ export default function Memory() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Memory
{t('memory.add_button')}
</button>
</div>
@@ -132,7 +133,7 @@ export default function Memory() {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search memory entries..."
placeholder={t('memory.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@@ -143,7 +144,7 @@ export default function Memory() {
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-8 py-2.5 text-sm text-white appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="">All Categories</option>
<option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
@@ -155,7 +156,7 @@ export default function Memory() {
onClick={handleSearch}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Search
{t('common.search')}
</button>
</div>
@@ -171,7 +172,7 @@ export default function Memory() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Memory</h3>
<h3 className="text-lg font-semibold text-white">{t('memory.add_button')}</h3>
<button
onClick={() => {
setShowForm(false);
@@ -192,37 +193,37 @@ export default function Memory() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Key <span className="text-red-400">*</span>
{t('memory.key')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
placeholder={t('memory.key_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Content <span className="text-red-400">*</span>
{t('memory.content')} <span className="text-red-400">*</span>
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
placeholder={t('memory.content_placeholder')}
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Category (optional)
{t('memory.category_optional')}
</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
placeholder={t('memory.category_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@@ -236,14 +237,14 @@ export default function Memory() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
{submitting ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
@@ -258,29 +259,29 @@ export default function Memory() {
) : entries.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Brain className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No memory entries found.</p>
<p className="text-gray-400">{t('memory.empty')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Key
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Content
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Timestamp
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
</tr>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.key')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.content')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.category')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.timestamp')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
{t('common.actions')}
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
@@ -307,18 +308,18 @@ export default function Memory() {
<td className="px-4 py-3 text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('memory.delete_prompt')}</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (
+11 -10
View File
@@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';
import { t, tf } from '@/lib/i18n';
export default function Tools() {
const [tools, setTools] = useState<ToolSpec[]>([]);
@@ -44,7 +45,7 @@ export default function Tools() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load tools: {error}
{tf('tools.load_error', { error })}
</div>
</div>
);
@@ -67,7 +68,7 @@ export default function Tools() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
placeholder={t('tools.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@@ -77,12 +78,12 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Agent Tools ({filtered.length})
{t('tools.agent_tools')} ({filtered.length})
</h2>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-gray-500">No tools match your search.</p>
<p className="text-sm text-gray-500">{t('tools.no_match')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((tool) => {
@@ -119,7 +120,7 @@ export default function Tools() {
{isExpanded && tool.parameters && (
<div className="border-t border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wider">
Parameter Schema
{t('tools.parameter_schema')}
</p>
<pre className="text-xs text-gray-300 bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-64 overflow-y-auto">
{JSON.stringify(tool.parameters, null, 2)}
@@ -139,7 +140,7 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-green-400" />
<h2 className="text-base font-semibold text-white">
CLI Tools ({filteredCli.length})
{t('tools.cli_tools')} ({filteredCli.length})
</h2>
</div>
@@ -148,16 +149,16 @@ export default function Tools() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('common.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Path
{t('common.path')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Version
{t('common.version')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('integrations.category')}
</th>
</tr>
</thead>