From d061ae4201811065a780bd2700ef480f8476b389 Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 15:38:30 -0400
Subject: [PATCH 1/6] ci(release): pin GNU Linux release runners back to Ubuntu
22.04 (#3211)
---
.github/workflows/pub-release.yml | 6 +++---
1 file changed, 3 insertions(+), 3 deletions(-)
diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml
index 3798d6253..d0129cfb8 100644
--- a/.github/workflows/pub-release.yml
+++ b/.github/workflows/pub-release.yml
@@ -202,7 +202,7 @@ jobs:
include:
# Keep GNU Linux release artifacts on Ubuntu 22.04 to preserve
# a broadly compatible GLIBC baseline for user distributions.
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
@@ -217,7 +217,7 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
@@ -232,7 +232,7 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- - os: ubuntu-22.04
+ - os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: armv7-unknown-linux-gnueabihf
artifact: zeroclaw
archive_ext: tar.gz
From e3c39f64db4e29ff5a9f64d09b2af8781bce19ad Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 15:39:08 -0400
Subject: [PATCH 2/6] feat(web): localize gateway pages for i18n (#3210)
---
web/src/App.tsx | 49 +++-
web/src/lib/i18n.ts | 450 +++++++++++++++++++++++++++++++++
web/src/pages/AgentChat.tsx | 34 ++-
web/src/pages/Config.tsx | 18 +-
web/src/pages/Cost.tsx | 43 ++--
web/src/pages/Cron.tsx | 89 +++----
web/src/pages/Dashboard.tsx | 49 ++--
web/src/pages/Doctor.tsx | 48 ++--
web/src/pages/Integrations.tsx | 151 ++++++-----
web/src/pages/Logs.tsx | 25 +-
web/src/pages/Memory.tsx | 81 +++---
web/src/pages/Tools.tsx | 21 +-
12 files changed, 795 insertions(+), 263 deletions(-)
diff --git a/web/src/App.tsx b/web/src/App.tsx
index 9f4b77fcc..594f23a31 100644
--- a/web/src/App.tsx
+++ b/web/src/App.tsx
@@ -12,7 +12,7 @@ import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import { AuthProvider, useAuth } from './hooks/useAuth';
-import { setLocale, type Locale } from './lib/i18n';
+import { setLocale, t, useLocale, type Locale } from './lib/i18n';
// Locale context
interface LocaleContextType {
@@ -21,12 +21,25 @@ interface LocaleContextType {
}
export const LocaleContext = createContext({
- locale: 'tr',
+ locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
+const LOCALE_STORAGE_KEY = 'zeroclaw.web.locale';
+
+function readStoredLocale(): Locale | null {
+ if (typeof window === 'undefined') {
+ return null;
+ }
+
+ const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
+ return stored === 'en' || stored === 'tr' || stored === 'zh-CN'
+ ? stored
+ : null;
+}
+
// Pairing dialog component
function PairingDialog({ onPair }: { onPair: (code: string) => Promise }) {
const [code, setCode] = useState('');
@@ -40,7 +53,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise })
try {
await onPair(code);
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : 'Pairing failed');
+ setError(err instanceof Error ? err.message : t('auth.pairing_failed'));
} finally {
setLoading(false);
}
@@ -51,14 +64,14 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise })
ZeroClaw
-
Enter the pairing code from your terminal
+
{t('auth.enter_code_terminal')}
@@ -81,9 +94,29 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
- const [locale, setLocaleState] = useState('tr');
+ const { locale: detectedLocale } = useLocale();
+ const [locale, setLocaleState] = useState(() => readStoredLocale() ?? detectedLocale);
+ const [hasStoredLocale, setHasStoredLocale] = useState(() => readStoredLocale() !== null);
+
+ useEffect(() => {
+ if (hasStoredLocale) {
+ const storedLocale = readStoredLocale();
+ if (storedLocale) {
+ setLocaleState(storedLocale);
+ setLocale(storedLocale);
+ }
+ return;
+ }
+
+ setLocaleState(detectedLocale);
+ setLocale(detectedLocale);
+ }, [detectedLocale, hasStoredLocale]);
const setAppLocale = (newLocale: Locale) => {
+ if (typeof window !== 'undefined') {
+ window.localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
+ }
+ setHasStoredLocale(true);
setLocaleState(newLocale);
setLocale(newLocale);
};
@@ -100,7 +133,7 @@ function AppContent() {
if (loading) {
return (
-
Connecting...
+
{t('agent.connecting')}
);
}
diff --git a/web/src/lib/i18n.ts b/web/src/lib/i18n.ts
index 0ebb95ecc..69cfe5d50 100644
--- a/web/src/lib/i18n.ts
+++ b/web/src/lib/i18n.ts
@@ -37,6 +37,15 @@ const translations: Record> = {
'dashboard.overview': 'Overview',
'dashboard.system_info': 'System Information',
'dashboard.quick_actions': 'Quick Actions',
+ 'dashboard.provider_model': 'Provider / Model',
+ 'dashboard.since_restart': 'Since last restart',
+ 'dashboard.cost_overview': 'Cost Overview',
+ 'dashboard.active_channels': 'Active Channels',
+ 'dashboard.no_channels': 'No channels configured',
+ 'dashboard.component_health': 'Component Health',
+ 'dashboard.no_components': 'No components reporting',
+ 'dashboard.restarts': 'Restarts: {count}',
+ 'dashboard.load_error': 'Failed to load dashboard: {error}',
// Agent / Chat
'agent.title': 'Agent Chat',
@@ -49,6 +58,17 @@ const translations: Record> = {
'agent.thinking': 'Thinking...',
'agent.tool_call': 'Tool Call',
'agent.tool_result': 'Tool Result',
+ 'agent.empty_title': 'ZeroClaw Agent',
+ 'agent.empty_subtitle': 'Send a message to start the conversation',
+ 'agent.connection_error': 'Connection error. Attempting to reconnect...',
+ 'agent.send_failed': 'Failed to send message. Please try again.',
+ 'agent.done_without_text': 'Tool execution completed, but no final response text was returned.',
+ 'agent.tool_call_message': '[Tool Call] {name}({args})',
+ 'agent.tool_result_message': '[Tool Result] {output}',
+ 'agent.error_message': '[Error] {message}',
+ 'agent.typing': 'Typing...',
+ 'agent.unknown_tool': 'unknown',
+ 'agent.unknown_error': 'Unknown error',
// Tools
'tools.title': 'Available Tools',
@@ -58,6 +78,11 @@ const translations: Record> = {
'tools.search': 'Search tools...',
'tools.empty': 'No tools available.',
'tools.count': 'Total tools',
+ 'tools.agent_tools': 'Agent Tools',
+ 'tools.cli_tools': 'CLI Tools',
+ 'tools.parameter_schema': 'Parameter Schema',
+ 'tools.no_match': 'No tools match your search.',
+ 'tools.load_error': 'Failed to load tools: {error}',
// Cron
'cron.title': 'Scheduled Jobs',
@@ -74,6 +99,18 @@ const translations: Record> = {
'cron.enabled': 'Enabled',
'cron.empty': 'No scheduled jobs.',
'cron.confirm_delete': 'Are you sure you want to delete this job?',
+ 'cron.load_error': 'Failed to load cron jobs: {error}',
+ 'cron.required_fields': 'Schedule and command are required.',
+ 'cron.add_error': 'Failed to add job',
+ 'cron.delete_error': 'Failed to delete job',
+ 'cron.modal_title': 'Add Cron Job',
+ 'cron.name_optional': 'Name (optional)',
+ 'cron.name_placeholder': 'e.g. Daily cleanup',
+ 'cron.schedule_placeholder': 'e.g. 0 0 * * * (cron expression)',
+ 'cron.command_placeholder': 'e.g. cleanup --older-than 7d',
+ 'cron.adding': 'Adding...',
+ 'cron.empty_configured': 'No scheduled tasks configured.',
+ 'cron.delete_prompt': 'Delete?',
// Integrations
'integrations.title': 'Integrations',
@@ -86,6 +123,58 @@ const translations: Record> = {
'integrations.empty': 'No integrations found.',
'integrations.activate': 'Activate',
'integrations.deactivate': 'Deactivate',
+ 'integrations.load_error': 'Failed to load integrations: {error}',
+ 'integrations.current_provider': 'current provider',
+ 'integrations.custom_model_hint_scoped': 'Format: anthropic/claude-sonnet-4-6',
+ 'integrations.custom_model_hint_generic': 'Format: claude-sonnet-4-6 (or provider/model when required)',
+ 'integrations.field_required': '{field} is required.',
+ 'integrations.custom_value_required':
+ 'Enter a custom value for {field} or choose a recommended model.',
+ 'integrations.no_changes': 'No changes to save.',
+ 'integrations.confirm_switch_provider':
+ 'Switch default AI provider from {current} to {target}?',
+ 'integrations.confirm_switch_provider_with_model':
+ 'Switch default AI provider from {current} to {target} and set model to {model}?',
+ 'integrations.credentials_saved': '{name} credentials saved.',
+ 'integrations.save_error': 'Failed to save credentials',
+ 'integrations.stale_save':
+ 'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.',
+ 'integrations.model_updated': 'Model updated to {model} for {name}.',
+ 'integrations.update_model_error': 'Failed to update model',
+ 'integrations.stale_model':
+ 'Configuration changed elsewhere. Refreshed latest settings; choose the model again.',
+ 'integrations.default_summary': 'default: {model}',
+ 'integrations.default_only': 'default',
+ 'integrations.default_badge': 'Default',
+ 'integrations.configured_badge': 'Configured',
+ 'integrations.current_model': 'Current model',
+ 'integrations.quick_model_help': 'For custom model IDs, use Edit Keys.',
+ 'integrations.default_provider_configured': 'Default provider configured',
+ 'integrations.provider_configured': 'Provider configured',
+ 'integrations.credentials_configured': 'Credentials configured',
+ 'integrations.credentials_not_configured': 'Credentials not configured',
+ 'integrations.edit_keys': 'Edit Keys',
+ 'integrations.configure': 'Configure',
+ 'integrations.configure_title': 'Configure {name}',
+ 'integrations.configure_intro_update': 'Enter only fields you want to update.',
+ 'integrations.configure_intro_new': 'Enter required fields to configure this integration.',
+ 'integrations.default_provider_notice_prefix':
+ 'Saving here updates credentials and switches your default AI provider to',
+ 'integrations.default_provider_notice_suffix':
+ 'For advanced provider settings, use',
+ 'integrations.keep_current_model': 'Keep current model',
+ 'integrations.keep_current_model_with_value': 'Keep current model ({model})',
+ 'integrations.select_recommended_model': 'Select a recommended model',
+ 'integrations.custom_model': 'Custom model...',
+ 'integrations.clear_current_model': 'Clear current model',
+ 'integrations.pick_model_help':
+ 'Pick a recommended model or choose Custom model. {hint}.',
+ 'integrations.current_value': 'Current value:',
+ 'integrations.replace_current_placeholder': 'Enter a new value to replace current',
+ 'integrations.enter_value_placeholder': 'Enter value',
+ 'integrations.keep_current_placeholder': 'Type new value, or leave empty to keep current',
+ 'integrations.save_activate': 'Save & Activate',
+ 'integrations.save_keys': 'Save Keys',
// Memory
'memory.title': 'Memory Store',
@@ -101,6 +190,16 @@ const translations: Record> = {
'memory.empty': 'No memory entries found.',
'memory.confirm_delete': 'Are you sure you want to delete this memory entry?',
'memory.all_categories': 'All Categories',
+ 'memory.load_error': 'Failed to load memory: {error}',
+ 'memory.required_fields': 'Key and content are required.',
+ 'memory.store_error': 'Failed to store memory',
+ 'memory.delete_error': 'Failed to delete memory',
+ 'memory.add_button': 'Add Memory',
+ 'memory.key_placeholder': 'e.g. user_preferences',
+ 'memory.content_placeholder': 'Memory content...',
+ 'memory.category_optional': 'Category (optional)',
+ 'memory.category_placeholder': 'e.g. preferences, context, facts',
+ 'memory.delete_prompt': 'Delete?',
// Config
'config.title': 'Configuration',
@@ -110,6 +209,10 @@ const translations: Record> = {
'config.error': 'Failed to save configuration.',
'config.loading': 'Loading configuration...',
'config.editor_placeholder': 'TOML configuration...',
+ 'config.sensitive_title': 'Sensitive fields are masked',
+ 'config.sensitive_body':
+ 'API keys, tokens, and passwords are hidden for security. To update a masked field, replace the entire masked value with your new value.',
+ 'config.editor_title': 'TOML Configuration',
// Cost
'cost.title': 'Cost Tracker',
@@ -123,6 +226,14 @@ const translations: Record> = {
'cost.tokens': 'Tokens',
'cost.requests': 'Requests',
'cost.usd': 'Cost (USD)',
+ 'cost.load_error': 'Failed to load cost data: {error}',
+ 'cost.total_requests': 'Total Requests',
+ 'cost.token_statistics': 'Token Statistics',
+ 'cost.average_tokens_per_request': 'Avg Tokens / Request',
+ 'cost.cost_per_1k_tokens': 'Cost per 1K Tokens',
+ 'cost.model_breakdown': 'Model Breakdown',
+ 'cost.no_models': 'No model data available.',
+ 'cost.share': 'Share',
// Logs
'logs.title': 'Live Logs',
@@ -133,6 +244,11 @@ const translations: Record> = {
'logs.empty': 'No log entries.',
'logs.connected': 'Connected to event stream.',
'logs.disconnected': 'Disconnected from event stream.',
+ 'logs.jump_to_bottom': 'Jump to bottom',
+ 'logs.events': 'events',
+ 'logs.filter_label': 'Filter:',
+ 'logs.paused_empty': 'Log streaming is paused.',
+ 'logs.waiting_empty': 'Waiting for events...',
// Doctor
'doctor.title': 'System Diagnostics',
@@ -146,6 +262,13 @@ const translations: Record> = {
'doctor.message': 'Message',
'doctor.empty': 'No diagnostics have been run yet.',
'doctor.summary': 'Diagnostic Summary',
+ 'doctor.run_error': 'Failed to run diagnostics',
+ 'doctor.running_short': 'Running...',
+ 'doctor.may_take_seconds': 'This may take a few seconds.',
+ 'doctor.issues_found': 'Issues Found',
+ 'doctor.warnings': 'Warnings',
+ 'doctor.all_clear': 'All Clear',
+ 'doctor.empty_help': 'Click "Run Diagnostics" to check your ZeroClaw installation.',
// Auth / Pairing
'auth.pair': 'Pair Device',
@@ -155,6 +278,9 @@ const translations: Record> = {
'auth.pairing_success': 'Pairing successful!',
'auth.pairing_failed': 'Pairing failed. Please try again.',
'auth.enter_code': 'Enter your pairing code to connect to the agent.',
+ 'auth.enter_code_terminal': 'Enter the pairing code from your terminal',
+ 'auth.code_placeholder': '6-digit code',
+ 'auth.pairing_progress': 'Pairing...',
// Common
'common.loading': 'Loading...',
@@ -178,6 +304,27 @@ const translations: Record> = {
'common.status': 'Status',
'common.created': 'Created',
'common.updated': 'Updated',
+ 'common.saving': 'Saving...',
+ 'common.adding': 'Adding...',
+ 'common.enabled': 'Enabled',
+ 'common.disabled': 'Disabled',
+ 'common.active': 'Active',
+ 'common.inactive': 'Inactive',
+ 'common.optional': 'optional',
+ 'common.id': 'ID',
+ 'common.path': 'Path',
+ 'common.version': 'Version',
+ 'common.all': 'All',
+ 'common.unknown': 'Unknown',
+ 'common.current': 'Current',
+ 'common.current_value': 'Current value',
+ 'common.apply': 'Apply',
+ 'common.configured': 'Configured',
+ 'common.configure': 'Configure',
+ 'common.default': 'Default',
+ 'common.events': 'events',
+ 'common.lines': 'lines',
+ 'common.select': 'Select',
// Health
'health.title': 'System Health',
@@ -220,6 +367,15 @@ const translations: Record> = {
'dashboard.overview': 'Genel Bakis',
'dashboard.system_info': 'Sistem Bilgisi',
'dashboard.quick_actions': 'Hizli Islemler',
+ 'dashboard.provider_model': 'Saglayici / Model',
+ 'dashboard.since_restart': 'Son yeniden baslatmadan beri',
+ 'dashboard.cost_overview': 'Maliyet Ozeti',
+ 'dashboard.active_channels': 'Aktif Kanallar',
+ 'dashboard.no_channels': 'Yapilandirilmis kanal yok',
+ 'dashboard.component_health': 'Bilesen Sagligi',
+ 'dashboard.no_components': 'Raporlayan bilesen yok',
+ 'dashboard.restarts': 'Yeniden baslatmalar: {count}',
+ 'dashboard.load_error': 'Kontrol paneli yuklenemedi: {error}',
// Agent / Chat
'agent.title': 'Ajan Sohbet',
@@ -232,6 +388,17 @@ const translations: Record> = {
'agent.thinking': 'Dusunuyor...',
'agent.tool_call': 'Arac Cagrisi',
'agent.tool_result': 'Arac Sonucu',
+ 'agent.empty_title': 'ZeroClaw Ajan',
+ 'agent.empty_subtitle': 'Sohbeti baslatmak icin bir mesaj gonderin',
+ 'agent.connection_error': 'Baglanti hatasi. Yeniden baglanmayi deniyor...',
+ 'agent.send_failed': 'Mesaj gonderilemedi. Lutfen tekrar deneyin.',
+ 'agent.done_without_text': 'Arac calismasi tamamlandi ancak son yanit metni dondurulmedi.',
+ 'agent.tool_call_message': '[Arac Cagrisi] {name}({args})',
+ 'agent.tool_result_message': '[Arac Sonucu] {output}',
+ 'agent.error_message': '[Hata] {message}',
+ 'agent.typing': 'Yaziyor...',
+ 'agent.unknown_tool': 'bilinmeyen',
+ 'agent.unknown_error': 'Bilinmeyen hata',
// Tools
'tools.title': 'Mevcut Araclar',
@@ -241,6 +408,11 @@ const translations: Record> = {
'tools.search': 'Arac ara...',
'tools.empty': 'Mevcut arac yok.',
'tools.count': 'Toplam arac',
+ 'tools.agent_tools': 'Ajan Araclari',
+ 'tools.cli_tools': 'CLI Araclari',
+ 'tools.parameter_schema': 'Parametre Semasi',
+ 'tools.no_match': 'Aramanizla eslesen arac yok.',
+ 'tools.load_error': 'Araclar yuklenemedi: {error}',
// Cron
'cron.title': 'Zamanlanmis Gorevler',
@@ -257,6 +429,18 @@ const translations: Record> = {
'cron.enabled': 'Etkin',
'cron.empty': 'Zamanlanmis gorev yok.',
'cron.confirm_delete': 'Bu gorevi silmek istediginizden emin misiniz?',
+ 'cron.load_error': 'Zamanlanmis gorevler yuklenemedi: {error}',
+ 'cron.required_fields': 'Zamanlama ve komut gereklidir.',
+ 'cron.add_error': 'Gorev eklenemedi',
+ 'cron.delete_error': 'Gorev silinemedi',
+ 'cron.modal_title': 'Cron Gorevi Ekle',
+ 'cron.name_optional': 'Ad (istege bagli)',
+ 'cron.name_placeholder': 'ornegin Gunluk temizleme',
+ 'cron.schedule_placeholder': 'ornegin 0 0 * * * (cron ifadesi)',
+ 'cron.command_placeholder': 'ornegin cleanup --older-than 7d',
+ 'cron.adding': 'Ekleniyor...',
+ 'cron.empty_configured': 'Yapilandirilmis zamanlanmis gorev yok.',
+ 'cron.delete_prompt': 'Silinsin mi?',
// Integrations
'integrations.title': 'Entegrasyonlar',
@@ -269,6 +453,60 @@ const translations: Record> = {
'integrations.empty': 'Entegrasyon bulunamadi.',
'integrations.activate': 'Etkinlestir',
'integrations.deactivate': 'Devre Disi Birak',
+ 'integrations.load_error': 'Entegrasyonlar yuklenemedi: {error}',
+ 'integrations.current_provider': 'mevcut saglayici',
+ 'integrations.custom_model_hint_scoped': 'Format: anthropic/claude-sonnet-4-6',
+ 'integrations.custom_model_hint_generic':
+ 'Format: claude-sonnet-4-6 (gerektiginde provider/model)',
+ 'integrations.field_required': '{field} gereklidir.',
+ 'integrations.custom_value_required':
+ '{field} icin ozel bir deger girin veya onerilen bir model secin.',
+ 'integrations.no_changes': 'Kaydedilecek degisiklik yok.',
+ 'integrations.confirm_switch_provider':
+ 'Varsayilan AI saglayicisini {current} konumundan {target} konumuna gecirmek istiyor musunuz?',
+ 'integrations.confirm_switch_provider_with_model':
+ 'Varsayilan AI saglayicisini {current} konumundan {target} konumuna gecirip modeli {model} olarak ayarlamak istiyor musunuz?',
+ 'integrations.credentials_saved': '{name} kimlik bilgileri kaydedildi.',
+ 'integrations.save_error': 'Kimlik bilgileri kaydedilemedi',
+ 'integrations.stale_save':
+ 'Yapilandirma baska yerde degisti. En guncel ayarlar yenilendi; degerleri yeniden girip tekrar kaydedin.',
+ 'integrations.model_updated': '{name} icin model {model} olarak guncellendi.',
+ 'integrations.update_model_error': 'Model guncellenemedi',
+ 'integrations.stale_model':
+ 'Yapilandirma baska yerde degisti. En guncel ayarlar yenilendi; modeli yeniden secin.',
+ 'integrations.default_summary': 'varsayilan: {model}',
+ 'integrations.default_only': 'varsayilan',
+ 'integrations.default_badge': 'Varsayilan',
+ 'integrations.configured_badge': 'Yapilandirildi',
+ 'integrations.current_model': 'Guncel model',
+ 'integrations.quick_model_help': 'Ozel model kimlikleri icin Anahtarlari Duzenle secenegini kullanin.',
+ 'integrations.default_provider_configured': 'Varsayilan saglayici yapilandirildi',
+ 'integrations.provider_configured': 'Saglayici yapilandirildi',
+ 'integrations.credentials_configured': 'Kimlik bilgileri yapilandirildi',
+ 'integrations.credentials_not_configured': 'Kimlik bilgileri yapilandirilmadi',
+ 'integrations.edit_keys': 'Anahtarlari Duzenle',
+ 'integrations.configure': 'Yapilandir',
+ 'integrations.configure_title': '{name} Yapilandir',
+ 'integrations.configure_intro_update': 'Yalnizca guncellemek istediginiz alanlari girin.',
+ 'integrations.configure_intro_new': 'Bu entegrasyonu yapilandirmak icin gerekli alanlari girin.',
+ 'integrations.default_provider_notice_prefix':
+ 'Burada kaydetmek kimlik bilgilerini gunceller ve varsayilan AI saglayicinizi su olarak degistirir:',
+ 'integrations.default_provider_notice_suffix':
+ 'Gelismis saglayici ayarlari icin sunu kullanin:',
+ 'integrations.keep_current_model': 'Guncel modeli koru',
+ 'integrations.keep_current_model_with_value': 'Guncel modeli koru ({model})',
+ 'integrations.select_recommended_model': 'Onerilen bir model secin',
+ 'integrations.custom_model': 'Ozel model...',
+ 'integrations.clear_current_model': 'Guncel modeli temizle',
+ 'integrations.pick_model_help':
+ 'Onerilen bir model secin veya Ozel model secenegini kullanin. {hint}.',
+ 'integrations.current_value': 'Guncel deger:',
+ 'integrations.replace_current_placeholder': 'Gunceli degistirmek icin yeni bir deger girin',
+ 'integrations.enter_value_placeholder': 'Deger girin',
+ 'integrations.keep_current_placeholder':
+ 'Yeni deger yazin veya gunceli korumak icin bos birakin',
+ 'integrations.save_activate': 'Kaydet ve Etkinlestir',
+ 'integrations.save_keys': 'Anahtarlari Kaydet',
// Memory
'memory.title': 'Hafiza Deposu',
@@ -284,6 +522,16 @@ const translations: Record> = {
'memory.empty': 'Hafiza kaydi bulunamadi.',
'memory.confirm_delete': 'Bu hafiza kaydini silmek istediginizden emin misiniz?',
'memory.all_categories': 'Tum Kategoriler',
+ 'memory.load_error': 'Hafiza yuklenemedi: {error}',
+ 'memory.required_fields': 'Anahtar ve icerik gereklidir.',
+ 'memory.store_error': 'Hafiza kaydedilemedi',
+ 'memory.delete_error': 'Hafiza silinemedi',
+ 'memory.add_button': 'Hafiza Ekle',
+ 'memory.key_placeholder': 'ornegin user_preferences',
+ 'memory.content_placeholder': 'Hafiza icerigi...',
+ 'memory.category_optional': 'Kategori (istege bagli)',
+ 'memory.category_placeholder': 'ornegin preferences, context, facts',
+ 'memory.delete_prompt': 'Silinsin mi?',
// Config
'config.title': 'Yapilandirma',
@@ -293,6 +541,10 @@ const translations: Record> = {
'config.error': 'Yapilandirma kaydedilemedi.',
'config.loading': 'Yapilandirma yukleniyor...',
'config.editor_placeholder': 'TOML yapilandirmasi...',
+ 'config.sensitive_title': 'Hassas alanlar maskelenir',
+ 'config.sensitive_body':
+ 'Guvenlik icin API anahtarlari, tokenlar ve parolalar gizlenir. Maskeli bir alani guncellemek icin tum maskeli degeri yeni degerinizle degistirin.',
+ 'config.editor_title': 'TOML Yapilandirmasi',
// Cost
'cost.title': 'Maliyet Takibi',
@@ -306,6 +558,14 @@ const translations: Record> = {
'cost.tokens': 'Token',
'cost.requests': 'Istekler',
'cost.usd': 'Maliyet (USD)',
+ 'cost.load_error': 'Maliyet verileri yuklenemedi: {error}',
+ 'cost.total_requests': 'Toplam Istek',
+ 'cost.token_statistics': 'Token Istatistikleri',
+ 'cost.average_tokens_per_request': 'Istek Basina Ort. Token',
+ 'cost.cost_per_1k_tokens': '1K Token Basina Maliyet',
+ 'cost.model_breakdown': 'Model Dagilimi',
+ 'cost.no_models': 'Model verisi yok.',
+ 'cost.share': 'Pay',
// Logs
'logs.title': 'Canli Kayitlar',
@@ -316,6 +576,11 @@ const translations: Record> = {
'logs.empty': 'Kayit girisi yok.',
'logs.connected': 'Olay akisina baglandi.',
'logs.disconnected': 'Olay akisi baglantisi kesildi.',
+ 'logs.jump_to_bottom': 'En alta git',
+ 'logs.events': 'olay',
+ 'logs.filter_label': 'Filtre:',
+ 'logs.paused_empty': 'Kayit akisi duraklatildi.',
+ 'logs.waiting_empty': 'Olaylar bekleniyor...',
// Doctor
'doctor.title': 'Sistem Teshisleri',
@@ -329,6 +594,13 @@ const translations: Record> = {
'doctor.message': 'Mesaj',
'doctor.empty': 'Henuz teshis calistirilmadi.',
'doctor.summary': 'Teshis Ozeti',
+ 'doctor.run_error': 'Teshisler calistirilamadi',
+ 'doctor.running_short': 'Calisiyor...',
+ 'doctor.may_take_seconds': 'Bu islem birkac saniye surebilir.',
+ 'doctor.issues_found': 'Sorunlar Bulundu',
+ 'doctor.warnings': 'Uyarilar',
+ 'doctor.all_clear': 'Her Sey Yolunda',
+ 'doctor.empty_help': 'ZeroClaw kurulumunuzu kontrol etmek icin "Teshis Calistir" dugmesine tiklayin.',
// Auth / Pairing
'auth.pair': 'Cihaz Esle',
@@ -338,6 +610,9 @@ const translations: Record> = {
'auth.pairing_success': 'Eslestirme basarili!',
'auth.pairing_failed': 'Eslestirme basarisiz. Lutfen tekrar deneyin.',
'auth.enter_code': 'Ajana baglanmak icin eslestirme kodunuzu girin.',
+ 'auth.enter_code_terminal': 'Terminalinizdeki eslestirme kodunu girin',
+ 'auth.code_placeholder': '6 haneli kod',
+ 'auth.pairing_progress': 'Eslestiriliyor...',
// Common
'common.loading': 'Yukleniyor...',
@@ -361,6 +636,27 @@ const translations: Record> = {
'common.status': 'Durum',
'common.created': 'Olusturulma',
'common.updated': 'Guncellenme',
+ 'common.saving': 'Kaydediliyor...',
+ 'common.adding': 'Ekleniyor...',
+ 'common.enabled': 'Etkin',
+ 'common.disabled': 'Devre disi',
+ 'common.active': 'Aktif',
+ 'common.inactive': 'Pasif',
+ 'common.optional': 'istege bagli',
+ 'common.id': 'Kimlik',
+ 'common.path': 'Yol',
+ 'common.version': 'Surum',
+ 'common.all': 'Tum',
+ 'common.unknown': 'Bilinmeyen',
+ 'common.current': 'Guncel',
+ 'common.current_value': 'Guncel deger',
+ 'common.apply': 'Uygula',
+ 'common.configured': 'Yapilandirildi',
+ 'common.configure': 'Yapilandir',
+ 'common.default': 'Varsayilan',
+ 'common.events': 'olay',
+ 'common.lines': 'satir',
+ 'common.select': 'Sec',
// Health
'health.title': 'Sistem Sagligi',
@@ -403,6 +699,15 @@ const translations: Record> = {
'dashboard.overview': '总览',
'dashboard.system_info': '系统信息',
'dashboard.quick_actions': '快捷操作',
+ 'dashboard.provider_model': '提供商 / 模型',
+ 'dashboard.since_restart': '自上次重启以来',
+ 'dashboard.cost_overview': '成本总览',
+ 'dashboard.active_channels': '活跃渠道',
+ 'dashboard.no_channels': '未配置任何渠道',
+ 'dashboard.component_health': '组件健康状态',
+ 'dashboard.no_components': '没有组件上报状态',
+ 'dashboard.restarts': '重启次数:{count}',
+ 'dashboard.load_error': '加载仪表盘失败:{error}',
// Agent / Chat
'agent.title': '智能体聊天',
@@ -415,6 +720,17 @@ const translations: Record> = {
'agent.thinking': '思考中...',
'agent.tool_call': '工具调用',
'agent.tool_result': '工具结果',
+ 'agent.empty_title': 'ZeroClaw 智能体',
+ 'agent.empty_subtitle': '发送一条消息以开始对话',
+ 'agent.connection_error': '连接出错,正在尝试重新连接...',
+ 'agent.send_failed': '发送消息失败,请重试。',
+ 'agent.done_without_text': '工具执行已完成,但未返回最终响应文本。',
+ 'agent.tool_call_message': '[工具调用] {name}({args})',
+ 'agent.tool_result_message': '[工具结果] {output}',
+ 'agent.error_message': '[错误] {message}',
+ 'agent.typing': '输入中...',
+ 'agent.unknown_tool': '未知',
+ 'agent.unknown_error': '未知错误',
// Tools
'tools.title': '可用工具',
@@ -424,6 +740,11 @@ const translations: Record> = {
'tools.search': '搜索工具...',
'tools.empty': '暂无可用工具。',
'tools.count': '工具总数',
+ 'tools.agent_tools': '智能体工具',
+ 'tools.cli_tools': 'CLI 工具',
+ 'tools.parameter_schema': '参数结构',
+ 'tools.no_match': '没有工具符合你的搜索。',
+ 'tools.load_error': '加载工具失败:{error}',
// Cron
'cron.title': '定时任务',
@@ -440,6 +761,18 @@ const translations: Record> = {
'cron.enabled': '已启用',
'cron.empty': '暂无定时任务。',
'cron.confirm_delete': '确定要删除此任务吗?',
+ 'cron.load_error': '加载定时任务失败:{error}',
+ 'cron.required_fields': '计划和命令为必填项。',
+ 'cron.add_error': '添加任务失败',
+ 'cron.delete_error': '删除任务失败',
+ 'cron.modal_title': '添加 Cron 任务',
+ 'cron.name_optional': '名称(可选)',
+ 'cron.name_placeholder': '例如:每日清理',
+ 'cron.schedule_placeholder': '例如:0 0 * * *(cron 表达式)',
+ 'cron.command_placeholder': '例如:cleanup --older-than 7d',
+ 'cron.adding': '添加中...',
+ 'cron.empty_configured': '尚未配置定时任务。',
+ 'cron.delete_prompt': '删除?',
// Integrations
'integrations.title': '集成',
@@ -452,6 +785,55 @@ const translations: Record> = {
'integrations.empty': '未找到集成。',
'integrations.activate': '激活',
'integrations.deactivate': '停用',
+ 'integrations.load_error': '加载集成失败:{error}',
+ 'integrations.current_provider': '当前提供商',
+ 'integrations.custom_model_hint_scoped': '格式:anthropic/claude-sonnet-4-6',
+ 'integrations.custom_model_hint_generic':
+ '格式:claude-sonnet-4-6(需要时使用 provider/model)',
+ 'integrations.field_required': '{field} 为必填项。',
+ 'integrations.custom_value_required': '请为 {field} 输入自定义值,或选择推荐模型。',
+ 'integrations.no_changes': '没有可保存的更改。',
+ 'integrations.confirm_switch_provider': '要将默认 AI 提供商从 {current} 切换到 {target} 吗?',
+ 'integrations.confirm_switch_provider_with_model':
+ '要将默认 AI 提供商从 {current} 切换到 {target},并将模型设置为 {model} 吗?',
+ 'integrations.credentials_saved': '{name} 凭据已保存。',
+ 'integrations.save_error': '保存凭据失败',
+ 'integrations.stale_save':
+ '配置已在其他地方发生变化。已刷新最新设置;请重新输入值后再次保存。',
+ 'integrations.model_updated': '{name} 的模型已更新为 {model}。',
+ 'integrations.update_model_error': '更新模型失败',
+ 'integrations.stale_model':
+ '配置已在其他地方发生变化。已刷新最新设置;请重新选择模型。',
+ 'integrations.default_summary': '默认:{model}',
+ 'integrations.default_only': '默认',
+ 'integrations.default_badge': '默认',
+ 'integrations.configured_badge': '已配置',
+ 'integrations.current_model': '当前模型',
+ 'integrations.quick_model_help': '如需自定义模型 ID,请使用“编辑密钥”。',
+ 'integrations.default_provider_configured': '默认提供商已配置',
+ 'integrations.provider_configured': '提供商已配置',
+ 'integrations.credentials_configured': '凭据已配置',
+ 'integrations.credentials_not_configured': '凭据未配置',
+ 'integrations.edit_keys': '编辑密钥',
+ 'integrations.configure': '配置',
+ 'integrations.configure_title': '配置 {name}',
+ 'integrations.configure_intro_update': '只输入你想更新的字段。',
+ 'integrations.configure_intro_new': '输入必填字段以配置此集成。',
+ 'integrations.default_provider_notice_prefix':
+ '在此保存会更新凭据,并将你的默认 AI 提供商切换为',
+ 'integrations.default_provider_notice_suffix': '如需高级提供商设置,请使用',
+ 'integrations.keep_current_model': '保留当前模型',
+ 'integrations.keep_current_model_with_value': '保留当前模型({model})',
+ 'integrations.select_recommended_model': '选择推荐模型',
+ 'integrations.custom_model': '自定义模型...',
+ 'integrations.clear_current_model': '清除当前模型',
+ 'integrations.pick_model_help': '请选择推荐模型或使用自定义模型。{hint}。',
+ 'integrations.current_value': '当前值:',
+ 'integrations.replace_current_placeholder': '输入新值以替换当前值',
+ 'integrations.enter_value_placeholder': '输入值',
+ 'integrations.keep_current_placeholder': '输入新值,或留空以保留当前值',
+ 'integrations.save_activate': '保存并激活',
+ 'integrations.save_keys': '保存密钥',
// Memory
'memory.title': '记忆存储',
@@ -467,6 +849,16 @@ const translations: Record> = {
'memory.empty': '未找到记忆条目。',
'memory.confirm_delete': '确定要删除此记忆条目吗?',
'memory.all_categories': '全部分类',
+ 'memory.load_error': '加载记忆失败:{error}',
+ 'memory.required_fields': '键和内容为必填项。',
+ 'memory.store_error': '存储记忆失败',
+ 'memory.delete_error': '删除记忆失败',
+ 'memory.add_button': '添加记忆',
+ 'memory.key_placeholder': '例如:user_preferences',
+ 'memory.content_placeholder': '记忆内容...',
+ 'memory.category_optional': '分类(可选)',
+ 'memory.category_placeholder': '例如:preferences、context、facts',
+ 'memory.delete_prompt': '删除?',
// Config
'config.title': '配置',
@@ -476,6 +868,10 @@ const translations: Record> = {
'config.error': '配置保存失败。',
'config.loading': '配置加载中...',
'config.editor_placeholder': 'TOML 配置...',
+ 'config.sensitive_title': '敏感字段已隐藏',
+ 'config.sensitive_body':
+ '为了安全,API 密钥、令牌和密码会被隐藏。要更新已隐藏的字段,请用你的新值替换整个隐藏值。',
+ 'config.editor_title': 'TOML 配置',
// Cost
'cost.title': '成本追踪',
@@ -489,6 +885,14 @@ const translations: Record> = {
'cost.tokens': 'Token',
'cost.requests': '请求',
'cost.usd': '成本(USD)',
+ 'cost.load_error': '加载成本数据失败:{error}',
+ 'cost.total_requests': '总请求数',
+ 'cost.token_statistics': 'Token 统计',
+ 'cost.average_tokens_per_request': '每次请求平均 Token',
+ 'cost.cost_per_1k_tokens': '每 1K Token 成本',
+ 'cost.model_breakdown': '模型拆分',
+ 'cost.no_models': '暂无模型数据。',
+ 'cost.share': '占比',
// Logs
'logs.title': '实时日志',
@@ -499,6 +903,11 @@ const translations: Record> = {
'logs.empty': '暂无日志条目。',
'logs.connected': '已连接到事件流。',
'logs.disconnected': '与事件流断开连接。',
+ 'logs.jump_to_bottom': '跳到末尾',
+ 'logs.events': '事件',
+ 'logs.filter_label': '筛选:',
+ 'logs.paused_empty': '日志流已暂停。',
+ 'logs.waiting_empty': '正在等待事件...',
// Doctor
'doctor.title': '系统诊断',
@@ -512,6 +921,13 @@ const translations: Record> = {
'doctor.message': '消息',
'doctor.empty': '尚未运行诊断。',
'doctor.summary': '诊断摘要',
+ 'doctor.run_error': '运行诊断失败',
+ 'doctor.running_short': '运行中...',
+ 'doctor.may_take_seconds': '这可能需要几秒钟。',
+ 'doctor.issues_found': '发现问题',
+ 'doctor.warnings': '警告',
+ 'doctor.all_clear': '一切正常',
+ 'doctor.empty_help': '点击“运行诊断”以检查你的 ZeroClaw 安装。',
// Auth / Pairing
'auth.pair': '设备配对',
@@ -521,6 +937,9 @@ const translations: Record> = {
'auth.pairing_success': '配对成功!',
'auth.pairing_failed': '配对失败,请重试。',
'auth.enter_code': '输入配对码以连接到智能体。',
+ 'auth.enter_code_terminal': '输入终端中的配对码',
+ 'auth.code_placeholder': '6 位代码',
+ 'auth.pairing_progress': '配对中...',
// Common
'common.loading': '加载中...',
@@ -544,6 +963,27 @@ const translations: Record> = {
'common.status': '状态',
'common.created': '创建时间',
'common.updated': '更新时间',
+ 'common.saving': '保存中...',
+ 'common.adding': '添加中...',
+ 'common.enabled': '已启用',
+ 'common.disabled': '已禁用',
+ 'common.active': '激活',
+ 'common.inactive': '未激活',
+ 'common.optional': '可选',
+ 'common.id': 'ID',
+ 'common.path': '路径',
+ 'common.version': '版本',
+ 'common.all': '全部',
+ 'common.unknown': '未知',
+ 'common.current': '当前',
+ 'common.current_value': '当前值',
+ 'common.apply': '应用',
+ 'common.configured': '已配置',
+ 'common.configure': '配置',
+ 'common.default': '默认',
+ 'common.events': '事件',
+ 'common.lines': '行',
+ 'common.select': '选择',
// Health
'health.title': '系统健康',
@@ -584,6 +1024,16 @@ export function t(key: string): string {
return translations[currentLocale]?.[key] ?? translations.en[key] ?? key;
}
+export function tf(
+ key: string,
+ values: Record,
+): string {
+ return Object.entries(values).reduce((result, [name, value]) => {
+ const token = `{${name}}`;
+ return result.split(token).join(String(value));
+ }, t(key));
+}
+
/**
* Get the translation for a specific locale. Falls back to English, then to the
* raw key.
diff --git a/web/src/pages/AgentChat.tsx b/web/src/pages/AgentChat.tsx
index a926c34b5..0a275bca1 100644
--- a/web/src/pages/AgentChat.tsx
+++ b/web/src/pages/AgentChat.tsx
@@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, AlertCircle } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
+import { getLocale, t, tf } from '@/lib/i18n';
interface ChatMessage {
id: string;
@@ -18,8 +19,6 @@ interface PersistedChatMessage {
}
let fallbackMessageIdCounter = 0;
-const EMPTY_DONE_FALLBACK =
- 'Tool execution completed, but no final response text was returned.';
const CHAT_HISTORY_STORAGE_KEY = 'zeroclaw.agent_chat.messages.v1';
const MAX_PERSISTED_MESSAGES = 500;
@@ -124,7 +123,7 @@ export default function AgentChat() {
};
ws.onError = () => {
- setError('Connection error. Attempting to reconnect...');
+ setError(t('agent.connection_error'));
};
ws.onMessage = (msg: WsMessage) => {
@@ -137,7 +136,7 @@ export default function AgentChat() {
case 'message':
case 'done': {
const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim();
- const finalContent = content || EMPTY_DONE_FALLBACK;
+ const finalContent = content || t('agent.done_without_text');
setMessages((prev) => [
...prev,
@@ -160,7 +159,10 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
- content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
+ content: tf('agent.tool_call_message', {
+ name: msg.name ?? t('agent.unknown_tool'),
+ args: JSON.stringify(msg.args ?? {}),
+ }),
timestamp: new Date(),
},
]);
@@ -172,7 +174,9 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
- content: `[Tool Result] ${msg.output ?? ''}`,
+ content: tf('agent.tool_result_message', {
+ output: msg.output ?? '',
+ }),
timestamp: new Date(),
},
]);
@@ -184,7 +188,9 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
- content: `[Error] ${msg.message ?? 'Unknown error'}`,
+ content: tf('agent.error_message', {
+ message: msg.message ?? t('agent.unknown_error'),
+ }),
timestamp: new Date(),
},
]);
@@ -229,7 +235,7 @@ export default function AgentChat() {
setTyping(true);
pendingContentRef.current = '';
} catch {
- setError('Failed to send message. Please try again.');
+ setError(t('agent.send_failed'));
}
setInput('');
@@ -258,8 +264,8 @@ export default function AgentChat() {
{messages.length === 0 && (
-
ZeroClaw Agent
-
Send a message to start the conversation
+
{t('agent.empty_title')}
+
{t('agent.empty_subtitle')}
)}
@@ -296,7 +302,7 @@ export default function AgentChat() {
msg.role === 'user' ? 'text-blue-200' : 'text-gray-500'
}`}
>
- {msg.timestamp.toLocaleTimeString()}
+ {msg.timestamp.toLocaleTimeString(getLocale())}
@@ -313,7 +319,7 @@ export default function AgentChat() {
- Typing...
+ {t('agent.typing')}
)}
@@ -331,7 +337,7 @@ export default function AgentChat() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
- placeholder={connected ? 'Type a message...' : 'Connecting...'}
+ placeholder={connected ? t('agent.placeholder') : t('agent.connecting')}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
@@ -351,7 +357,7 @@ export default function AgentChat() {
}`}
/>
- {connected ? 'Connected' : 'Disconnected'}
+ {connected ? t('agent.connected') : t('agent.disconnected')}
diff --git a/web/src/pages/Config.tsx b/web/src/pages/Config.tsx
index 17a4868d2..eb89945c0 100644
--- a/web/src/pages/Config.tsx
+++ b/web/src/pages/Config.tsx
@@ -7,6 +7,7 @@ import {
ShieldAlert,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
+import { t } from '@/lib/i18n';
export default function Config() {
const [config, setConfig] = useState('');
@@ -31,9 +32,9 @@ export default function Config() {
setSuccess(null);
try {
await putConfig(config);
- setSuccess('Configuration saved successfully.');
+ setSuccess(t('config.saved'));
} catch (err: unknown) {
- setError(err instanceof Error ? err.message : 'Failed to save configuration');
+ setError(err instanceof Error ? err.message : t('config.error'));
} finally {
setSaving(false);
}
@@ -60,7 +61,7 @@ export default function Config() {
-
Configuration
+ {t('config.title')}
- {saving ? 'Saving...' : 'Save'}
+ {saving ? t('common.saving') : t('common.save')}
@@ -77,11 +78,10 @@ export default function Config() {
- Sensitive fields are masked
+ {t('config.sensitive_title')}
- API keys, tokens, and passwords are hidden for security. To update a
- masked field, replace the entire masked value with your new value.
+ {t('config.sensitive_body')}
@@ -106,10 +106,10 @@ export default function Config() {
- TOML Configuration
+ {t('config.editor_title')}
- {config.split('\n').length} lines
+ {config.split('\n').length} {t('common.lines')}
);
@@ -53,7 +54,7 @@ export default function Cost() {
- Session Cost
+ {t('cost.session')}
{formatUSD(cost.session_cost_usd)}
@@ -65,7 +66,7 @@ export default function Cost() {
- Daily Cost
+ {t('cost.daily')}
{formatUSD(cost.daily_cost_usd)}
@@ -77,7 +78,7 @@ export default function Cost() {
- Monthly Cost
+ {t('cost.monthly')}
{formatUSD(cost.monthly_cost_usd)}
@@ -89,10 +90,10 @@ export default function Cost() {
- Total Requests
+ {t('cost.total_requests')}
- {cost.request_count.toLocaleString()}
+ {cost.request_count.toLocaleString(getLocale())}
@@ -100,25 +101,25 @@ export default function Cost() {
{/* Token Statistics */}
- Token Statistics
+ {t('cost.token_statistics')}
-
Total Tokens
+
{t('cost.total_tokens')}
- {cost.total_tokens.toLocaleString()}
+ {cost.total_tokens.toLocaleString(getLocale())}
-
Avg Tokens / Request
+
{t('cost.average_tokens_per_request')}
{cost.request_count > 0
- ? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
+ ? Math.round(cost.total_tokens / cost.request_count).toLocaleString(getLocale())
: '0'}
-
Cost per 1K Tokens
+
{t('cost.cost_per_1k_tokens')}
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
@@ -132,12 +133,12 @@ export default function Cost() {
- Model Breakdown
+ {t('cost.model_breakdown')}
{models.length === 0 ? (
- No model data available.
+ {t('cost.no_models')}
) : (
@@ -145,19 +146,19 @@ export default function Cost() {
- Model
+ {t('cost.model')}
- Cost
+ {t('cost.usd')}
- Tokens
+ {t('cost.tokens')}
- Requests
+ {t('cost.requests')}
- Share
+ {t('cost.share')}
@@ -181,10 +182,10 @@ export default function Cost() {
{formatUSD(m.cost_usd)}
- {m.total_tokens.toLocaleString()}
+ {m.total_tokens.toLocaleString(getLocale())}
- {m.request_count.toLocaleString()}
+ {m.request_count.toLocaleString(getLocale())}
diff --git a/web/src/pages/Cron.tsx b/web/src/pages/Cron.tsx
index ed4451472..cadd60d84 100644
--- a/web/src/pages/Cron.tsx
+++ b/web/src/pages/Cron.tsx
@@ -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 (
- Failed to load cron jobs: {error}
+ {tf('cron.load_error', { error })}
);
@@ -118,7 +119,7 @@ export default function Cron() {
- Scheduled Tasks ({jobs.length})
+ {t('cron.title')} ({jobs.length})
- Add Job
+ {t('cron.add')}
@@ -135,7 +136,7 @@ export default function Cron() {
-
Add Cron Job
+
{t('cron.modal_title')}
{
setShowForm(false);
@@ -156,37 +157,37 @@ export default function Cron() {
@@ -218,35 +219,35 @@ export default function Cron() {
{jobs.length === 0 ? (
-
No scheduled tasks configured.
+
{t('cron.empty_configured')}
) : (
-
-
- ID
-
-
- Name
-
-
- Command
-
-
- Next Run
-
-
- Last Status
-
-
- Enabled
-
-
- Actions
-
-
+
+
+ {t('common.id')}
+
+
+ {t('cron.name')}
+
+
+ {t('cron.command')}
+
+
+ {t('cron.next_run')}
+
+
+ {t('cron.last_status')}
+
+
+ {t('cron.enabled')}
+
+
+ {t('common.actions')}
+
+
{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')}
{confirmDelete === job.id ? (
- Delete?
+ {t('cron.delete_prompt')}
handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
- Yes
+ {t('common.yes')}
setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
- No
+ {t('common.no')}
) : (
diff --git a/web/src/pages/Dashboard.tsx b/web/src/pages/Dashboard.tsx
index 2df44e667..f76bf188d 100644
--- a/web/src/pages/Dashboard.tsx
+++ b/web/src/pages/Dashboard.tsx
@@ -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 (
- Failed to load dashboard: {error}
+ {tf('dashboard.load_error', { error })}
);
@@ -95,10 +96,10 @@ export default function Dashboard() {
- Provider / Model
+ {t('dashboard.provider_model')}
- {status.provider ?? 'Unknown'}
+ {status.provider ?? t('common.unknown')}
{status.model}
@@ -108,12 +109,12 @@ export default function Dashboard() {
- Uptime
+ {t('dashboard.uptime')}
{formatUptime(status.uptime_seconds)}
- Since last restart
+ {t('dashboard.since_restart')}
@@ -121,12 +122,14 @@ export default function Dashboard() {
-
Gateway Port
+
{t('dashboard.gateway_port')}
:{status.gateway_port}
- Locale: {status.locale}
+
+ {t('dashboard.locale')}: {status.locale}
+
@@ -134,13 +137,13 @@ export default function Dashboard() {
-
Memory Backend
+
{t('dashboard.memory_backend')}
{status.memory_backend}
- Paired: {status.paired ? 'Yes' : 'No'}
+ {t('dashboard.paired')}: {status.paired ? t('common.yes') : t('common.no')}
@@ -150,13 +153,13 @@ export default function Dashboard() {
-
Cost Overview
+ {t('dashboard.cost_overview')}
{[
- { 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 }) => (
@@ -173,12 +176,12 @@ export default function Dashboard() {
))}
- Total Tokens
- {cost.total_tokens.toLocaleString()}
+ {t('cost.total_tokens')}
+ {cost.total_tokens.toLocaleString(getLocale())}
- Requests
- {cost.request_count.toLocaleString()}
+ {t('cost.requests')}
+ {cost.request_count.toLocaleString(getLocale())}
@@ -186,11 +189,11 @@ export default function Dashboard() {
-
Active Channels
+ {t('dashboard.active_channels')}
{Object.entries(status.channels).length === 0 ? (
-
No channels configured
+
{t('dashboard.no_channels')}
) : (
Object.entries(status.channels).map(([name, active]) => (
- {active ? 'Active' : 'Inactive'}
+ {active ? t('common.active') : t('common.inactive')}
@@ -218,11 +221,11 @@ export default function Dashboard() {
-
Component Health
+
{t('dashboard.component_health')}
{Object.entries(status.health.components).length === 0 ? (
-
No components reporting
+
{t('dashboard.no_components')}
) : (
Object.entries(status.health.components).map(([name, comp]) => (
{comp.status}
{comp.restart_count > 0 && (
- Restarts: {comp.restart_count}
+ {tf('dashboard.restarts', { count: comp.restart_count })}
)}
diff --git a/web/src/pages/Doctor.tsx b/web/src/pages/Doctor.tsx
index 5305b8c68..b3b9a056e 100644
--- a/web/src/pages/Doctor.tsx
+++ b/web/src/pages/Doctor.tsx
@@ -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
(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() {
-
Diagnostics
+ {t('doctor.title')}
- Running...
+ {t('doctor.running_short')}
>
) : (
<>
- Run Diagnostics
+ {t('doctor.run')}
>
)}
@@ -114,9 +126,9 @@ export default function Doctor() {
{loading && (
-
Running diagnostics...
+
{t('doctor.running')}
- This may take a few seconds.
+ {t('doctor.may_take_seconds')}
)}
@@ -129,27 +141,21 @@ export default function Doctor() {
- {okCount} ok
+ {okCount} {t('doctor.ok')}
- {warnCount}{' '}
-
- warning{warnCount !== 1 ? 's' : ''}
-
+ {warnCount} {t('doctor.warn')}
- {errorCount}{' '}
-
- error{errorCount !== 1 ? 's' : ''}
-
+ {errorCount} {t('doctor.error')}
@@ -157,15 +163,15 @@ export default function Doctor() {
{errorCount > 0 ? (
- Issues Found
+ {t('doctor.issues_found')}
) : warnCount > 0 ? (
- Warnings
+ {t('doctor.warnings')}
) : (
- All Clear
+ {t('doctor.all_clear')}
)}
@@ -191,7 +197,7 @@ export default function Doctor() {
{result.message}
- {result.severity}
+ {severityLabel(result.severity)}
@@ -206,9 +212,9 @@ export default function Doctor() {
{!results && !loading && !error && (
-
System Diagnostics
+
{t('doctor.title')}
- Click "Run Diagnostics" to check your ZeroClaw installation.
+ {t('doctor.empty_help')}
)}
diff --git a/web/src/pages/Integrations.tsx b/web/src/pages/Integrations.tsx
index e4b1e5fcf..2f383cdd1 100644
--- a/web/src/pages/Integrations.tsx
+++ b/web/src/pages/Integrations.tsx
@@ -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 = {
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 (
- Failed to load integrations: {error}
+ {tf('integrations.load_error', { error })}
);
@@ -441,7 +456,7 @@ export default function Integrations() {
- Integrations ({integrations.length})
+ {t('integrations.title')} ({integrations.length})
@@ -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)}
))}
@@ -478,7 +493,7 @@ export default function Integrations() {
{Object.keys(grouped).length === 0 ? (
-
No integrations found.
+
{t('integrations.empty')}
) : (
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')}
)}
- Current model
+ {t('integrations.current_model')}
{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')}
- For custom model IDs, use Edit Keys.
+ {t('integrations.quick_model_help')}
)}
@@ -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')}
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"
>
- {editable.configured ? 'Edit Keys' : 'Configure'}
+ {editable.configured
+ ? t('integrations.edit_keys')
+ : t('integrations.configure')}
)}
@@ -667,19 +688,19 @@ export default function Integrations() {
- Configure {activeEditor.name}
+ {tf('integrations.configure_title', { name: activeEditor.name })}
{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')}
@@ -688,10 +709,11 @@ export default function Integrations() {
{activeEditor.activates_default_provider && (
- Saving here updates credentials and switches your default AI provider to{' '}
- {activeEditor.name} . For advanced provider settings, use{' '}
+ {t('integrations.default_provider_notice_prefix')}{' '}
+ {activeEditor.name} .{' '}
+ {t('integrations.default_provider_notice_suffix')}{' '}
- Configuration
+ {t('config.title')}
.
@@ -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 (
@@ -722,7 +746,7 @@ export default function Integrations() {
{field.required &&
* }
{field.has_value && (
- Configured
+ {t('common.configured')}
)}
@@ -737,7 +761,7 @@ export default function Integrations() {
{keepCurrentLabel}
) : (
- Select a recommended model
+ {t('integrations.select_recommended_model')}
)}
{selectOptions.map((option) => (
@@ -745,8 +769,12 @@ export default function Integrations() {
{option}
))}
-
Custom model...
- {field.has_value &&
Clear current model }
+
{t('integrations.custom_model')}
+ {field.has_value && (
+
+ {t('integrations.clear_current_model')}
+
+ )}
{fieldValues[field.key] === SELECT_CUSTOM && (
@@ -760,14 +788,17 @@ export default function Integrations() {
)}
- Pick a recommended model or choose Custom model. {customModelFormatHint(activeEditor.id)}.
+ {tf('integrations.pick_model_help', {
+ hint: customModelFormatHint(activeEditor.id),
+ })}
) : (
{maskedSecretValue && (
- Current value: {maskedSecretValue}
+ {t('integrations.current_value')}{' '}
+ {maskedSecretValue}
)}
@@ -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')}
{saving
- ? 'Saving...'
+ ? t('common.saving')
: activeEditor.activates_default_provider
- ? 'Save & Activate'
- : 'Save Keys'}
+ ? t('integrations.save_activate')
+ : t('integrations.save_keys')}
diff --git a/web/src/pages/Logs.tsx b/web/src/pages/Logs.tsx
index af1330583..aae19dcde 100644
--- a/web/src/pages/Logs.tsx
+++ b/web/src/pages/Logs.tsx
@@ -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() {
-
Live Logs
+
{t('logs.title')}
- {connected ? 'Connected' : 'Disconnected'}
+ {connected ? t('agent.connected') : t('agent.disconnected')}
- {filteredEntries.length} events
+ {filteredEntries.length} {t('logs.events')}
@@ -166,11 +167,11 @@ export default function Logs() {
>
{paused ? (
<>
-
Resume
+
{t('logs.resume')}
>
) : (
<>
-
Pause
+
{t('logs.pause')}
>
)}
@@ -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"
>
- Jump to bottom
+ {t('logs.jump_to_bottom')}
)}
@@ -192,7 +193,7 @@ export default function Logs() {
{allTypes.length > 0 && (
- Filter:
+ {t('logs.filter_label')}
{allTypes.map((type) => (
setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
>
- Clear
+ {t('logs.clear')}
)}
@@ -228,9 +229,7 @@ export default function Logs() {
- {paused
- ? 'Log streaming is paused.'
- : 'Waiting for events...'}
+ {paused ? t('logs.paused_empty') : t('logs.waiting_empty')}
) : (
diff --git a/web/src/pages/Memory.tsx b/web/src/pages/Memory.tsx
index 59258eaf9..0179b08d4 100644
--- a/web/src/pages/Memory.tsx
+++ b/web/src/pages/Memory.tsx
@@ -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 (
- Failed to load memory: {error}
+ {tf('memory.load_error', { error })}
);
@@ -111,7 +112,7 @@ export default function Memory() {
- Memory ({entries.length})
+ {t('memory.title')} ({entries.length})
- Add Memory
+ {t('memory.add_button')}
@@ -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"
/>
@@ -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"
>
- All Categories
+ {t('memory.all_categories')}
{categories.map((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')}
@@ -171,7 +172,7 @@ export default function Memory() {
-
Add Memory
+
{t('memory.add_button')}
{
setShowForm(false);
@@ -192,37 +193,37 @@ export default function Memory() {
@@ -258,29 +259,29 @@ export default function Memory() {
) : entries.length === 0 ? (
-
No memory entries found.
+
{t('memory.empty')}
) : (
-
-
- Key
-
-
- Content
-
-
- Category
-
-
- Timestamp
-
-
- Actions
-
-
+
+
+ {t('memory.key')}
+
+
+ {t('memory.content')}
+
+
+ {t('memory.category')}
+
+
+ {t('memory.timestamp')}
+
+
+ {t('common.actions')}
+
+
{entries.map((entry) => (
@@ -307,18 +308,18 @@ export default function Memory() {
{confirmDelete === entry.key ? (
- Delete?
+ {t('memory.delete_prompt')}
handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
- Yes
+ {t('common.yes')}
setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
- No
+ {t('common.no')}
) : (
diff --git a/web/src/pages/Tools.tsx b/web/src/pages/Tools.tsx
index dc10677dd..bd047455f 100644
--- a/web/src/pages/Tools.tsx
+++ b/web/src/pages/Tools.tsx
@@ -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([]);
@@ -44,7 +45,7 @@ export default function Tools() {
return (
- Failed to load tools: {error}
+ {tf('tools.load_error', { error })}
);
@@ -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"
/>
@@ -77,12 +78,12 @@ export default function Tools() {
- Agent Tools ({filtered.length})
+ {t('tools.agent_tools')} ({filtered.length})
{filtered.length === 0 ? (
- No tools match your search.
+ {t('tools.no_match')}
) : (
{filtered.map((tool) => {
@@ -119,7 +120,7 @@ export default function Tools() {
{isExpanded && tool.parameters && (
- Parameter Schema
+ {t('tools.parameter_schema')}
{JSON.stringify(tool.parameters, null, 2)}
@@ -139,7 +140,7 @@ export default function Tools() {
- CLI Tools ({filteredCli.length})
+ {t('tools.cli_tools')} ({filteredCli.length})
@@ -148,16 +149,16 @@ export default function Tools() {
- Name
+ {t('common.name')}
- Path
+ {t('common.path')}
- Version
+ {t('common.version')}
- Category
+ {t('integrations.category')}
From 5c432daba41a0b3d45cdcc98f00038aec758f3bf Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 18:40:54 -0400
Subject: [PATCH 3/6] fix(tools): sync delegate parent registry with runtime
tools (#3161)
* fix(tools): sync delegate parent registry with runtime tools
* test(tools): cover late-bound subagent spawn registry
---
src/tools/delegate.rs | 55 ++++++++++++---
src/tools/mod.rs | 25 ++++++-
src/tools/subagent_spawn.rs | 135 ++++++++++++++++++++++++++++++++++--
3 files changed, 197 insertions(+), 18 deletions(-)
diff --git a/src/tools/delegate.rs b/src/tools/delegate.rs
index ea26a1f0a..6b8022dc9 100644
--- a/src/tools/delegate.rs
+++ b/src/tools/delegate.rs
@@ -6,6 +6,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
use crate::providers::{self, ChatMessage, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
+use crate::tools::SharedToolRegistry;
use async_trait::async_trait;
use serde_json::json;
use std::collections::HashMap;
@@ -36,7 +37,7 @@ pub struct DelegateTool {
/// Depth at which this tool instance lives in the delegation chain.
depth: u32,
/// Parent tool registry for agentic sub-agents.
- parent_tools: Arc>>,
+ parent_tools: SharedToolRegistry,
/// Inherited multimodal handling config for sub-agent loops.
multimodal_config: crate::config::MultimodalConfig,
/// Optional typed coordination bus used to trace delegate lifecycle events.
@@ -72,7 +73,7 @@ impl DelegateTool {
fallback_credential,
provider_runtime_options,
depth: 0,
- parent_tools: Arc::new(Vec::new()),
+ parent_tools: crate::tools::new_shared_tool_registry(),
multimodal_config: crate::config::MultimodalConfig::default(),
coordination_bus,
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
@@ -111,7 +112,7 @@ impl DelegateTool {
fallback_credential,
provider_runtime_options,
depth,
- parent_tools: Arc::new(Vec::new()),
+ parent_tools: crate::tools::new_shared_tool_registry(),
multimodal_config: crate::config::MultimodalConfig::default(),
coordination_bus,
coordination_lead_agent: DEFAULT_COORDINATION_LEAD_AGENT.to_string(),
@@ -119,7 +120,7 @@ impl DelegateTool {
}
/// Attach parent tools used to build sub-agent allowlist registries.
- pub fn with_parent_tools(mut self, parent_tools: Arc>>) -> Self {
+ pub fn with_parent_tools(mut self, parent_tools: SharedToolRegistry) -> Self {
self.parent_tools = parent_tools;
self
}
@@ -461,9 +462,13 @@ impl DelegateTool {
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect::>();
-
- let sub_tools: Vec> = self
+ let parent_tools = self
.parent_tools
+ .lock()
+ .map(|tools| tools.clone())
+ .unwrap_or_default();
+
+ let sub_tools: Vec> = parent_tools
.iter()
.filter(|tool| allowed.contains(tool.name()))
.filter(|tool| tool.name() != "delegate")
@@ -967,6 +972,12 @@ mod tests {
}
}
+ fn shared_parent_tools(tools: Vec>) -> SharedToolRegistry {
+ let shared = crate::tools::new_shared_tool_registry();
+ crate::tools::sync_shared_tool_registry(&shared, &tools);
+ shared
+ }
+
#[test]
fn name_and_schema() {
let tool = DelegateTool::new(sample_agents(), None, test_security());
@@ -1278,7 +1289,7 @@ mod tests {
);
let tool = DelegateTool::new(agents, None, test_security())
- .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
+ .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let result = tool
.execute(json!({"agent": "agentic", "prompt": "test"}))
.await
@@ -1296,7 +1307,7 @@ mod tests {
async fn execute_agentic_runs_tool_call_loop_with_filtered_tools() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
- Arc::new(vec![
+ shared_parent_tools(vec![
Arc::new(EchoTool),
Arc::new(DelegateTool::new(HashMap::new(), None, test_security())),
]),
@@ -1313,11 +1324,33 @@ mod tests {
assert!(result.output.contains("done"));
}
+ #[tokio::test]
+ async fn execute_agentic_reads_late_bound_parent_tools() {
+ let config = agentic_config(vec!["echo_tool".to_string()], 10);
+ let parent_tools = crate::tools::new_shared_tool_registry();
+ let tool = DelegateTool::new(HashMap::new(), None, test_security())
+ .with_parent_tools(parent_tools.clone());
+
+ crate::tools::sync_shared_tool_registry(
+ &parent_tools,
+ &[Arc::new(EchoTool) as Arc],
+ );
+
+ let provider = OneToolThenFinalProvider;
+ let result = tool
+ .execute_agentic("agentic", &config, &provider, "run", 0.2)
+ .await
+ .unwrap();
+
+ assert!(result.success);
+ assert!(result.output.contains("done"));
+ }
+
#[tokio::test]
async fn execute_agentic_excludes_delegate_even_if_allowlisted() {
let config = agentic_config(vec!["delegate".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security()).with_parent_tools(
- Arc::new(vec![Arc::new(DelegateTool::new(
+ shared_parent_tools(vec![Arc::new(DelegateTool::new(
HashMap::new(),
None,
test_security(),
@@ -1342,7 +1375,7 @@ mod tests {
async fn execute_agentic_respects_max_iterations() {
let config = agentic_config(vec!["echo_tool".to_string()], 2);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
- .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
+ .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let provider = InfiniteToolCallProvider;
let result = tool
@@ -1362,7 +1395,7 @@ mod tests {
async fn execute_agentic_propagates_provider_errors() {
let config = agentic_config(vec!["echo_tool".to_string()], 10);
let tool = DelegateTool::new(HashMap::new(), None, test_security())
- .with_parent_tools(Arc::new(vec![Arc::new(EchoTool)]));
+ .with_parent_tools(shared_parent_tools(vec![Arc::new(EchoTool)]));
let provider = FailingProvider;
let result = tool
diff --git a/src/tools/mod.rs b/src/tools/mod.rs
index fef85de3a..f5bd45635 100644
--- a/src/tools/mod.rs
+++ b/src/tools/mod.rs
@@ -126,7 +126,7 @@ use crate::runtime::{NativeRuntime, RuntimeAdapter};
use crate::security::SecurityPolicy;
use async_trait::async_trait;
use std::collections::HashMap;
-use std::sync::Arc;
+use std::sync::{Arc, Mutex};
#[derive(Clone)]
struct ArcDelegatingTool {
@@ -162,6 +162,21 @@ fn boxed_registry_from_arcs(tools: Vec>) -> Vec> {
tools.into_iter().map(ArcDelegatingTool::boxed).collect()
}
+pub(crate) type SharedToolRegistry = Arc>>>;
+
+pub(crate) fn new_shared_tool_registry() -> SharedToolRegistry {
+ Arc::new(Mutex::new(Vec::new()))
+}
+
+pub(crate) fn sync_shared_tool_registry(
+ shared_registry: &SharedToolRegistry,
+ tools: &[Arc],
+) {
+ if let Ok(mut guard) = shared_registry.lock() {
+ *guard = tools.to_vec();
+ }
+}
+
/// Create the default tool registry
pub fn default_tools(security: Arc) -> Vec> {
default_tools_with_runtime(security, Arc::new(NativeRuntime::new()))
@@ -417,6 +432,7 @@ pub fn all_tools_with_runtime(
}
// Add delegation and sub-agent orchestration tools when agents are configured
+ let mut shared_parent_tools = None;
if !agents.is_empty() {
let delegate_agents: HashMap = agents
.iter()
@@ -442,7 +458,8 @@ pub fn all_tools_with_runtime(
max_tokens_override: None,
model_support_vision: root_config.model_support_vision,
};
- let parent_tools = Arc::new(tool_arcs.clone());
+ let parent_tools = new_shared_tool_registry();
+ shared_parent_tools = Some(parent_tools.clone());
let mut delegate_tool = DelegateTool::new_with_options(
delegate_agents.clone(),
delegate_fallback_credential.clone(),
@@ -536,6 +553,10 @@ pub fn all_tools_with_runtime(
}
}
+ if let Some(shared_registry) = shared_parent_tools.as_ref() {
+ sync_shared_tool_registry(shared_registry, &tool_arcs);
+ }
+
boxed_registry_from_arcs(tool_arcs)
}
diff --git a/src/tools/subagent_spawn.rs b/src/tools/subagent_spawn.rs
index 488aa5ffe..17ba43946 100644
--- a/src/tools/subagent_spawn.rs
+++ b/src/tools/subagent_spawn.rs
@@ -11,6 +11,7 @@ use crate::observability::traits::{Observer, ObserverEvent, ObserverMetric};
use crate::providers::{self, ChatMessage, Provider};
use crate::security::policy::ToolOperation;
use crate::security::SecurityPolicy;
+use crate::tools::SharedToolRegistry;
use async_trait::async_trait;
use chrono::Utc;
use serde_json::json;
@@ -32,7 +33,7 @@ pub struct SubAgentSpawnTool {
fallback_credential: Option,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc,
- parent_tools: Arc>>,
+ parent_tools: SharedToolRegistry,
multimodal_config: crate::config::MultimodalConfig,
}
@@ -44,7 +45,7 @@ impl SubAgentSpawnTool {
security: Arc,
provider_runtime_options: providers::ProviderRuntimeOptions,
registry: Arc,
- parent_tools: Arc>>,
+ parent_tools: SharedToolRegistry,
multimodal_config: crate::config::MultimodalConfig,
) -> Self {
Self {
@@ -395,7 +396,7 @@ async fn run_agentic_background(
agent_config: &DelegateAgentConfig,
provider: &dyn Provider,
full_prompt: &str,
- parent_tools: &[Arc],
+ parent_tools: &SharedToolRegistry,
multimodal_config: &crate::config::MultimodalConfig,
) -> anyhow::Result {
if agent_config.allowed_tools.is_empty() {
@@ -414,6 +415,10 @@ async fn run_agentic_background(
.map(|name| name.trim())
.filter(|name| !name.is_empty())
.collect::>();
+ let parent_tools = parent_tools
+ .lock()
+ .map(|tools| tools.clone())
+ .unwrap_or_default();
let sub_tools: Vec> = parent_tools
.iter()
@@ -530,6 +535,100 @@ mod tests {
agents
}
+ #[derive(Default)]
+ struct EchoTool;
+
+ #[async_trait]
+ impl Tool for EchoTool {
+ fn name(&self) -> &str {
+ "echo_tool"
+ }
+
+ fn description(&self) -> &str {
+ "Echoes the `value` argument."
+ }
+
+ fn parameters_schema(&self) -> serde_json::Value {
+ serde_json::json!({
+ "type": "object",
+ "properties": {
+ "value": {"type": "string"}
+ },
+ "required": ["value"]
+ })
+ }
+
+ async fn execute(&self, args: serde_json::Value) -> anyhow::Result {
+ let value = args
+ .get("value")
+ .and_then(serde_json::Value::as_str)
+ .unwrap_or_default()
+ .to_string();
+ Ok(ToolResult {
+ success: true,
+ output: format!("echo:{value}"),
+ error: None,
+ })
+ }
+ }
+
+ struct OneToolThenFinalProvider;
+
+ #[async_trait]
+ impl Provider for OneToolThenFinalProvider {
+ async fn chat_with_system(
+ &self,
+ _system_prompt: Option<&str>,
+ _message: &str,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ Ok("unused".to_string())
+ }
+
+ async fn chat(
+ &self,
+ request: crate::providers::ChatRequest<'_>,
+ _model: &str,
+ _temperature: f64,
+ ) -> anyhow::Result {
+ let has_tool_message = request.messages.iter().any(|m| m.role == "tool");
+ if has_tool_message {
+ Ok(crate::providers::ChatResponse {
+ text: Some("done".to_string()),
+ tool_calls: Vec::new(),
+ usage: None,
+ reasoning_content: None,
+ })
+ } else {
+ Ok(crate::providers::ChatResponse {
+ text: None,
+ tool_calls: vec![crate::providers::ToolCall {
+ id: "call_1".to_string(),
+ name: "echo_tool".to_string(),
+ arguments: "{\"value\":\"ping\"}".to_string(),
+ }],
+ usage: None,
+ reasoning_content: None,
+ })
+ }
+ }
+ }
+
+ fn agentic_config(allowed_tools: Vec, max_iterations: usize) -> DelegateAgentConfig {
+ DelegateAgentConfig {
+ provider: "openrouter".to_string(),
+ model: "model-test".to_string(),
+ system_prompt: Some("You are agentic.".to_string()),
+ api_key: Some("delegate-test-credential".to_string()),
+ temperature: Some(0.2),
+ max_depth: 3,
+ agentic: true,
+ allowed_tools,
+ max_iterations,
+ }
+ }
+
fn make_tool(
agents: HashMap,
security: Arc,
@@ -540,7 +639,7 @@ mod tests {
security,
providers::ProviderRuntimeOptions::default(),
Arc::new(SubAgentRegistry::new()),
- Arc::new(Vec::new()),
+ crate::tools::new_shared_tool_registry(),
crate::config::MultimodalConfig::default(),
)
}
@@ -705,7 +804,7 @@ mod tests {
test_security(),
providers::ProviderRuntimeOptions::default(),
registry,
- Arc::new(Vec::new()),
+ crate::tools::new_shared_tool_registry(),
crate::config::MultimodalConfig::default(),
);
@@ -726,4 +825,30 @@ mod tests {
.unwrap();
assert!(desc.contains("researcher"));
}
+
+ #[tokio::test]
+ async fn run_agentic_background_reads_late_bound_parent_tools() {
+ let config = agentic_config(vec!["echo_tool".to_string()], 10);
+ let parent_tools = crate::tools::new_shared_tool_registry();
+ let provider = OneToolThenFinalProvider;
+
+ crate::tools::sync_shared_tool_registry(
+ &parent_tools,
+ &[Arc::new(EchoTool) as Arc],
+ );
+
+ let result = run_agentic_background(
+ "agentic",
+ &config,
+ &provider,
+ "run",
+ &parent_tools,
+ &crate::config::MultimodalConfig::default(),
+ )
+ .await
+ .unwrap();
+
+ assert!(result.success);
+ assert!(result.output.contains("done"));
+ }
}
From cfb2d548be027526f45abdcf2c66776e7f7816e2 Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 18:41:06 -0400
Subject: [PATCH 4/6] fix(agent): honor configured default temperature (#3167)
---
src/agent/loop_.rs | 3 ++-
src/cron/scheduler.rs | 2 +-
src/daemon/mod.rs | 2 +-
src/main.rs | 42 ++++++++++++++++++++++++++++++++++++++++--
4 files changed, 44 insertions(+), 5 deletions(-)
diff --git a/src/agent/loop_.rs b/src/agent/loop_.rs
index e3851de17..078fa2e8e 100644
--- a/src/agent/loop_.rs
+++ b/src/agent/loop_.rs
@@ -1808,7 +1808,7 @@ pub async fn run(
message: Option,
provider_override: Option,
model_override: Option,
- temperature: f64,
+ temperature: Option,
peripheral_overrides: Vec,
interactive: bool,
) -> Result {
@@ -1881,6 +1881,7 @@ pub async fn run(
.as_deref()
.or(config.default_model.as_deref())
.unwrap_or("anthropic/claude-sonnet-4");
+ let temperature = temperature.unwrap_or(config.default_temperature);
let provider_runtime_options = providers::ProviderRuntimeOptions {
auth_profile_override: None,
diff --git a/src/cron/scheduler.rs b/src/cron/scheduler.rs
index 26d09875f..e1516b1a9 100644
--- a/src/cron/scheduler.rs
+++ b/src/cron/scheduler.rs
@@ -173,7 +173,7 @@ async fn run_agent_job(
Some(prefixed_prompt),
None,
model_override,
- config.default_temperature,
+ Some(config.default_temperature),
vec![],
false,
)
diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs
index 48793221a..89fe6541b 100644
--- a/src/daemon/mod.rs
+++ b/src/daemon/mod.rs
@@ -272,7 +272,7 @@ async fn run_heartbeat_worker(config: Config) -> Result<()> {
Some(prompt),
None,
None,
- temp,
+ Some(temp),
vec![],
false,
)
diff --git a/src/main.rs b/src/main.rs
index 9fb5a3f51..fb1cb39f4 100644
--- a/src/main.rs
+++ b/src/main.rs
@@ -195,8 +195,8 @@ Examples:
model: Option,
/// Temperature (0.0 - 2.0)
- #[arg(short, long, default_value = "0.7", value_parser = parse_temperature)]
- temperature: f64,
+ #[arg(short, long, value_parser = parse_temperature)]
+ temperature: Option,
/// Attach a peripheral (board:path, e.g. nucleo-f401re:/dev/ttyACM0)
#[arg(long)]
@@ -2235,6 +2235,44 @@ mod tests {
);
}
+ #[test]
+ fn agent_cli_does_not_force_temperature_override_when_flag_is_absent() {
+ let cli = Cli::try_parse_from(["zeroclaw", "agent", "--provider", "openrouter", "-m", "hi"])
+ .expect("agent invocation should parse without temperature");
+
+ match cli.command {
+ Commands::Agent { temperature, .. } => {
+ assert_eq!(
+ temperature, None,
+ "temperature should stay unset so config.default_temperature is preserved"
+ );
+ }
+ other => panic!("expected agent command, got {other:?}"),
+ }
+ }
+
+ #[test]
+ fn agent_cli_parses_explicit_temperature_override() {
+ let cli = Cli::try_parse_from([
+ "zeroclaw",
+ "agent",
+ "--provider",
+ "openrouter",
+ "-m",
+ "hi",
+ "--temperature",
+ "1.1",
+ ])
+ .expect("agent invocation should parse explicit temperature");
+
+ match cli.command {
+ Commands::Agent { temperature, .. } => {
+ assert_eq!(temperature, Some(1.1));
+ }
+ other => panic!("expected agent command, got {other:?}"),
+ }
+ }
+
#[test]
fn gateway_cli_accepts_new_pairing_flag() {
let cli = Cli::try_parse_from(["zeroclaw", "gateway", "--new-pairing"])
From b21223a6aa4de91d5626d355b75d2e444772f802 Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 18:42:04 -0400
Subject: [PATCH 5/6] fix(release): include matrix channel in official builds
(#3166)
---
.github/workflows/pub-docker-img.yml | 4 +++-
.github/workflows/pub-release.yml | 11 ++++++++---
2 files changed, 11 insertions(+), 4 deletions(-)
diff --git a/.github/workflows/pub-docker-img.yml b/.github/workflows/pub-docker-img.yml
index 8267c9e4c..ab39d4f6f 100644
--- a/.github/workflows/pub-docker-img.yml
+++ b/.github/workflows/pub-docker-img.yml
@@ -181,6 +181,8 @@ jobs:
context: .
push: false
load: true
+ build-args: |
+ ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: zeroclaw-release-candidate:${{ steps.meta.outputs.release_tag }}
platforms: linux/amd64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}
@@ -282,7 +284,7 @@ jobs:
context: .
push: true
build-args: |
- ZEROCLAW_CARGO_ALL_FEATURES=true
+ ZEROCLAW_CARGO_FEATURES=channel-matrix
tags: ${{ steps.meta.outputs.tags }}
platforms: linux/amd64,linux/arm64
cache-from: type=gha,scope=pub-docker-release-${{ steps.meta.outputs.release_tag }}
diff --git a/.github/workflows/pub-release.yml b/.github/workflows/pub-release.yml
index d0129cfb8..5a04c1bc8 100644
--- a/.github/workflows/pub-release.yml
+++ b/.github/workflows/pub-release.yml
@@ -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)
From 069b8e058671120da7fb3d0b7974b9faab3edd8a Mon Sep 17 00:00:00 2001
From: Argenis
Date: Wed, 11 Mar 2026 18:47:01 -0400
Subject: [PATCH 6/6] fix(config): recover docker runtime path on save (#3165)
* fix(config): recover docker runtime path on save
* fix: update config_path in-memory after save() resolves bare filename
Change save() signature from &self to &mut self so it can assign the
resolved config_path back to the struct. This ensures downstream reads
(proxy_config, model_routing_config) use the correct absolute path
instead of a stale bare filename.
Add test assertion verifying config.config_path equals resolved path
after save(). Update all callers to use mutable bindings.
Co-Authored-By: Claude Opus 4.6
---------
Co-authored-by: Claude Opus 4.6
---
src/config/schema.rs | 96 ++++++++++++++++++++++++++-----
src/gateway/api.rs | 4 +-
src/onboard/wizard.rs | 4 +-
src/tools/model_routing_config.rs | 2 +-
src/tools/proxy_config.rs | 2 +-
5 files changed, 87 insertions(+), 21 deletions(-)
diff --git a/src/config/schema.rs b/src/config/schema.rs
index dee0e8f28..edd8f6aa8 100644
--- a/src/config/schema.rs
+++ b/src/config/schema.rs
@@ -6691,11 +6691,41 @@ impl Config {
set_runtime_proxy_config(self.proxy.clone());
}
- pub async fn save(&self) -> Result<()> {
- // Encrypt secrets before serialization
- let mut config_to_save = self.clone();
- let zeroclaw_dir = self
+ async fn resolve_config_path_for_save(&self) -> Result {
+ if self
.config_path
+ .parent()
+ .is_some_and(|parent| !parent.as_os_str().is_empty())
+ {
+ return Ok(self.config_path.clone());
+ }
+
+ let (default_zeroclaw_dir, default_workspace_dir) = default_config_and_workspace_dirs()?;
+ let (zeroclaw_dir, _workspace_dir, source) =
+ resolve_runtime_config_dirs(&default_zeroclaw_dir, &default_workspace_dir).await?;
+ let file_name = self
+ .config_path
+ .file_name()
+ .filter(|name| !name.is_empty())
+ .unwrap_or_else(|| std::ffi::OsStr::new("config.toml"));
+ let resolved = zeroclaw_dir.join(file_name);
+ tracing::warn!(
+ path = %self.config_path.display(),
+ resolved = %resolved.display(),
+ source = source.as_str(),
+ "Config path missing parent directory; resolving from runtime environment"
+ );
+ Ok(resolved)
+ }
+
+ pub async fn save(&mut self) -> Result<()> {
+ // Encrypt secrets before serialization
+ let config_path = self.resolve_config_path_for_save().await?;
+ // Keep the in-memory config_path in sync so downstream reads
+ // (e.g. proxy_config, model_routing_config) use the resolved path.
+ self.config_path = config_path.clone();
+ let mut config_to_save = self.clone();
+ let zeroclaw_dir = config_path
.parent()
.context("Config path must have a parent directory")?;
let store = crate::security::SecretStore::new(zeroclaw_dir, self.secrets.encrypt);
@@ -6764,8 +6794,7 @@ impl Config {
let toml_str =
toml::to_string_pretty(&config_to_save).context("Failed to serialize config")?;
- let parent_dir = self
- .config_path
+ let parent_dir = config_path
.parent()
.context("Config path must have a parent directory")?;
@@ -6776,8 +6805,7 @@ impl Config {
)
})?;
- let file_name = self
- .config_path
+ let file_name = config_path
.file_name()
.and_then(|v| v.to_str())
.unwrap_or("config.toml");
@@ -6817,9 +6845,9 @@ impl Config {
.context("Failed to fsync temporary config file")?;
drop(temp_file);
- let had_existing_config = self.config_path.exists();
+ let had_existing_config = config_path.exists();
if had_existing_config {
- fs::copy(&self.config_path, &backup_path)
+ fs::copy(&config_path, &backup_path)
.await
.with_context(|| {
format!(
@@ -6829,10 +6857,10 @@ impl Config {
})?;
}
- if let Err(e) = fs::rename(&temp_path, &self.config_path).await {
+ if let Err(e) = fs::rename(&temp_path, &config_path).await {
let _ = fs::remove_file(&temp_path).await;
if had_existing_config && backup_path.exists() {
- fs::copy(&backup_path, &self.config_path)
+ fs::copy(&backup_path, &config_path)
.await
.context("Failed to restore config backup")?;
}
@@ -6842,12 +6870,12 @@ impl Config {
#[cfg(unix)]
{
use std::{fs::Permissions, os::unix::fs::PermissionsExt};
- fs::set_permissions(&self.config_path, Permissions::from_mode(0o600))
+ fs::set_permissions(&config_path, Permissions::from_mode(0o600))
.await
.with_context(|| {
format!(
"Failed to enforce secure permissions on config file: {}",
- self.config_path.display()
+ config_path.display()
)
})?;
}
@@ -7660,7 +7688,7 @@ tool_dispatcher = "xml"
fs::create_dir_all(&dir).await.unwrap();
let config_path = dir.join("config.toml");
- let config = Config {
+ let mut config = Config {
workspace_dir: dir.join("workspace"),
config_path: config_path.clone(),
api_key: Some("sk-roundtrip".into()),
@@ -10486,6 +10514,44 @@ default_model = "legacy-model"
);
}
+ #[test]
+ async fn save_repairs_bare_config_filename_using_runtime_resolution() {
+ let _env_guard = env_override_lock().await;
+ let temp_home =
+ std::env::temp_dir().join(format!("zeroclaw_test_home_{}", uuid::Uuid::new_v4()));
+ let workspace_dir = temp_home.join("workspace");
+ let resolved_config_path = temp_home.join(".zeroclaw").join("config.toml");
+
+ let original_home = std::env::var("HOME").ok();
+ std::env::set_var("HOME", &temp_home);
+ std::env::set_var("ZEROCLAW_WORKSPACE", &workspace_dir);
+
+ let mut config = Config::default();
+ config.workspace_dir = workspace_dir;
+ config.config_path = PathBuf::from("config.toml");
+ config.default_temperature = 0.5;
+ config.save().await.unwrap();
+
+ assert!(resolved_config_path.exists());
+ assert_eq!(
+ config.config_path, resolved_config_path,
+ "save() must update config_path to the resolved path"
+ );
+ let saved = tokio::fs::read_to_string(&resolved_config_path)
+ .await
+ .unwrap();
+ let parsed: Config = toml::from_str(&saved).unwrap();
+ assert_eq!(parsed.default_temperature, 0.5);
+
+ std::env::remove_var("ZEROCLAW_WORKSPACE");
+ if let Some(home) = original_home {
+ std::env::set_var("HOME", home);
+ } else {
+ std::env::remove_var("HOME");
+ }
+ let _ = tokio::fs::remove_dir_all(temp_home).await;
+ }
+
#[cfg(unix)]
#[test]
async fn save_restricts_existing_world_readable_config_to_owner_only() {
diff --git a/src/gateway/api.rs b/src/gateway/api.rs
index 13da1c5e2..a13a59ae9 100644
--- a/src/gateway/api.rs
+++ b/src/gateway/api.rs
@@ -543,7 +543,7 @@ pub async fn handle_api_config_put(
};
let current_config = state.config.lock().clone();
- let new_config = hydrate_config_for_save(incoming, ¤t_config);
+ let mut new_config = hydrate_config_for_save(incoming, ¤t_config);
if let Err(e) = new_config.validate() {
return (
@@ -752,7 +752,7 @@ pub async fn handle_api_integration_credentials_put(
}
}
- let updated = match apply_integration_credentials_update(¤t, &id, &body.fields) {
+ let mut updated = match apply_integration_credentials_update(¤t, &id, &body.fields) {
Ok(config) => config,
Err(error) if error.starts_with("Unknown integration id:") => {
return (
diff --git a/src/onboard/wizard.rs b/src/onboard/wizard.rs
index 159cd3aec..eccfde9d2 100644
--- a/src/onboard/wizard.rs
+++ b/src/onboard/wizard.rs
@@ -128,7 +128,7 @@ pub async fn run_wizard(force: bool) -> Result {
// ── Build config ──
// Defaults: SQLite memory, supervised autonomy, workspace-scoped, native runtime
- let config = Config {
+ let mut config = Config {
workspace_dir: workspace_dir.clone(),
config_path: config_path.clone(),
api_key: if api_key.is_empty() {
@@ -487,7 +487,7 @@ async fn run_quick_setup_with_home(
// Create memory config based on backend choice
let memory_config = memory_config_defaults_for_backend(&memory_backend_name);
- let config = Config {
+ let mut config = Config {
workspace_dir: workspace_dir.clone(),
config_path: config_path.clone(),
api_key: credential_override.map(|c| {
diff --git a/src/tools/model_routing_config.rs b/src/tools/model_routing_config.rs
index 1eaf7bb94..3adec24cc 100644
--- a/src/tools/model_routing_config.rs
+++ b/src/tools/model_routing_config.rs
@@ -921,7 +921,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc {
- let config = Config {
+ let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()
diff --git a/src/tools/proxy_config.rs b/src/tools/proxy_config.rs
index 213a57e0c..f58beed20 100644
--- a/src/tools/proxy_config.rs
+++ b/src/tools/proxy_config.rs
@@ -450,7 +450,7 @@ mod tests {
}
async fn test_config(tmp: &TempDir) -> Arc {
- let config = Config {
+ let mut config = Config {
workspace_dir: tmp.path().join("workspace"),
config_path: tmp.path().join("config.toml"),
..Config::default()