Merge branch 'dev' into issue-3153-codex-mcp-config

This commit is contained in:
Argenis 2026-03-11 15:39:24 -04:00 committed by GitHub
commit e17afefdfa
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
13 changed files with 798 additions and 266 deletions

View File

@ -202,7 +202,7 @@ jobs:
include:
# Keep GNU Linux release artifacts on Ubuntu 22.04 to preserve
# a broadly compatible GLIBC baseline for user distributions.
- os: ubuntu-22.04
- os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: x86_64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
@ -217,7 +217,7 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- os: ubuntu-22.04
- os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: aarch64-unknown-linux-gnu
artifact: zeroclaw
archive_ext: tar.gz
@ -232,7 +232,7 @@ jobs:
linker_env: ""
linker: ""
use_cross: true
- os: ubuntu-22.04
- os: [self-hosted, Linux, X64, blacksmith-2vcpu-ubuntu-2204]
target: armv7-unknown-linux-gnueabihf
artifact: zeroclaw
archive_ext: tar.gz

View File

@ -12,7 +12,7 @@ import Cost from './pages/Cost';
import Logs from './pages/Logs';
import Doctor from './pages/Doctor';
import { AuthProvider, useAuth } from './hooks/useAuth';
import { setLocale, type Locale } from './lib/i18n';
import { setLocale, t, useLocale, type Locale } from './lib/i18n';
// Locale context
interface LocaleContextType {
@ -21,12 +21,25 @@ interface LocaleContextType {
}
export const LocaleContext = createContext<LocaleContextType>({
locale: 'tr',
locale: 'en',
setAppLocale: (_locale: Locale) => {},
});
export const useLocaleContext = () => useContext(LocaleContext);
const LOCALE_STORAGE_KEY = 'zeroclaw.web.locale';
function readStoredLocale(): Locale | null {
if (typeof window === 'undefined') {
return null;
}
const stored = window.localStorage.getItem(LOCALE_STORAGE_KEY);
return stored === 'en' || stored === 'tr' || stored === 'zh-CN'
? stored
: null;
}
// Pairing dialog component
function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> }) {
const [code, setCode] = useState('');
@ -40,7 +53,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
try {
await onPair(code);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Pairing failed');
setError(err instanceof Error ? err.message : t('auth.pairing_failed'));
} finally {
setLoading(false);
}
@ -51,14 +64,14 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
<div className="bg-gray-900 rounded-xl p-8 w-full max-w-md border border-gray-800">
<div className="text-center mb-6">
<h1 className="text-2xl font-bold text-white mb-2">ZeroClaw</h1>
<p className="text-gray-400">Enter the pairing code from your terminal</p>
<p className="text-gray-400">{t('auth.enter_code_terminal')}</p>
</div>
<form onSubmit={handleSubmit}>
<input
type="text"
value={code}
onChange={(e) => setCode(e.target.value)}
placeholder="6-digit code"
placeholder={t('auth.code_placeholder')}
className="w-full px-4 py-3 bg-gray-800 border border-gray-700 rounded-lg text-white text-center text-2xl tracking-widest focus:outline-none focus:border-blue-500 mb-4"
maxLength={6}
autoFocus
@ -71,7 +84,7 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
disabled={loading || code.length < 6}
className="w-full py-3 bg-blue-600 hover:bg-blue-700 disabled:bg-gray-700 disabled:text-gray-500 text-white rounded-lg font-medium transition-colors"
>
{loading ? 'Pairing...' : 'Pair'}
{loading ? t('auth.pairing_progress') : t('auth.pair_button')}
</button>
</form>
</div>
@ -81,9 +94,29 @@ function PairingDialog({ onPair }: { onPair: (code: string) => Promise<void> })
function AppContent() {
const { isAuthenticated, loading, pair, logout } = useAuth();
const [locale, setLocaleState] = useState<Locale>('tr');
const { locale: detectedLocale } = useLocale();
const [locale, setLocaleState] = useState<Locale>(() => readStoredLocale() ?? detectedLocale);
const [hasStoredLocale, setHasStoredLocale] = useState(() => readStoredLocale() !== null);
useEffect(() => {
if (hasStoredLocale) {
const storedLocale = readStoredLocale();
if (storedLocale) {
setLocaleState(storedLocale);
setLocale(storedLocale);
}
return;
}
setLocaleState(detectedLocale);
setLocale(detectedLocale);
}, [detectedLocale, hasStoredLocale]);
const setAppLocale = (newLocale: Locale) => {
if (typeof window !== 'undefined') {
window.localStorage.setItem(LOCALE_STORAGE_KEY, newLocale);
}
setHasStoredLocale(true);
setLocaleState(newLocale);
setLocale(newLocale);
};
@ -100,7 +133,7 @@ function AppContent() {
if (loading) {
return (
<div className="min-h-screen bg-gray-950 flex items-center justify-center">
<p className="text-gray-400">Connecting...</p>
<p className="text-gray-400">{t('agent.connecting')}</p>
</div>
);
}

View File

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

View File

@ -2,6 +2,7 @@ import { useState, useEffect, useRef } from 'react';
import { Send, Bot, User, AlertCircle } from 'lucide-react';
import type { WsMessage } from '@/types/api';
import { WebSocketClient } from '@/lib/ws';
import { getLocale, t, tf } from '@/lib/i18n';
interface ChatMessage {
id: string;
@ -18,8 +19,6 @@ interface PersistedChatMessage {
}
let fallbackMessageIdCounter = 0;
const EMPTY_DONE_FALLBACK =
'Tool execution completed, but no final response text was returned.';
const CHAT_HISTORY_STORAGE_KEY = 'zeroclaw.agent_chat.messages.v1';
const MAX_PERSISTED_MESSAGES = 500;
@ -124,7 +123,7 @@ export default function AgentChat() {
};
ws.onError = () => {
setError('Connection error. Attempting to reconnect...');
setError(t('agent.connection_error'));
};
ws.onMessage = (msg: WsMessage) => {
@ -137,7 +136,7 @@ export default function AgentChat() {
case 'message':
case 'done': {
const content = (msg.full_response ?? msg.content ?? pendingContentRef.current ?? '').trim();
const finalContent = content || EMPTY_DONE_FALLBACK;
const finalContent = content || t('agent.done_without_text');
setMessages((prev) => [
...prev,
@ -160,7 +159,10 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
content: `[Tool Call] ${msg.name ?? 'unknown'}(${JSON.stringify(msg.args ?? {})})`,
content: tf('agent.tool_call_message', {
name: msg.name ?? t('agent.unknown_tool'),
args: JSON.stringify(msg.args ?? {}),
}),
timestamp: new Date(),
},
]);
@ -172,7 +174,9 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
content: `[Tool Result] ${msg.output ?? ''}`,
content: tf('agent.tool_result_message', {
output: msg.output ?? '',
}),
timestamp: new Date(),
},
]);
@ -184,7 +188,9 @@ export default function AgentChat() {
{
id: makeMessageId(),
role: 'agent',
content: `[Error] ${msg.message ?? 'Unknown error'}`,
content: tf('agent.error_message', {
message: msg.message ?? t('agent.unknown_error'),
}),
timestamp: new Date(),
},
]);
@ -229,7 +235,7 @@ export default function AgentChat() {
setTyping(true);
pendingContentRef.current = '';
} catch {
setError('Failed to send message. Please try again.');
setError(t('agent.send_failed'));
}
setInput('');
@ -258,8 +264,8 @@ export default function AgentChat() {
{messages.length === 0 && (
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Bot className="h-12 w-12 mb-3 text-gray-600" />
<p className="text-lg font-medium">ZeroClaw Agent</p>
<p className="text-sm mt-1">Send a message to start the conversation</p>
<p className="text-lg font-medium">{t('agent.empty_title')}</p>
<p className="text-sm mt-1">{t('agent.empty_subtitle')}</p>
</div>
)}
@ -296,7 +302,7 @@ export default function AgentChat() {
msg.role === 'user' ? 'text-blue-200' : 'text-gray-500'
}`}
>
{msg.timestamp.toLocaleTimeString()}
{msg.timestamp.toLocaleTimeString(getLocale())}
</p>
</div>
</div>
@ -313,7 +319,7 @@ export default function AgentChat() {
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '150ms' }} />
<span className="w-2 h-2 bg-gray-400 rounded-full animate-bounce" style={{ animationDelay: '300ms' }} />
</div>
<p className="text-xs text-gray-500 mt-1">Typing...</p>
<p className="text-xs text-gray-500 mt-1">{t('agent.typing')}</p>
</div>
</div>
)}
@ -331,7 +337,7 @@ export default function AgentChat() {
value={input}
onChange={(e) => setInput(e.target.value)}
onKeyDown={handleKeyDown}
placeholder={connected ? 'Type a message...' : 'Connecting...'}
placeholder={connected ? t('agent.placeholder') : t('agent.connecting')}
disabled={!connected}
className="w-full bg-gray-800 border border-gray-700 rounded-xl px-4 py-3 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent disabled:opacity-50"
/>
@ -351,7 +357,7 @@ export default function AgentChat() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
</div>

View File

@ -7,6 +7,7 @@ import {
ShieldAlert,
} from 'lucide-react';
import { getConfig, putConfig } from '@/lib/api';
import { t } from '@/lib/i18n';
export default function Config() {
const [config, setConfig] = useState('');
@ -31,9 +32,9 @@ export default function Config() {
setSuccess(null);
try {
await putConfig(config);
setSuccess('Configuration saved successfully.');
setSuccess(t('config.saved'));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to save configuration');
setError(err instanceof Error ? err.message : t('config.error'));
} finally {
setSaving(false);
}
@ -60,7 +61,7 @@ export default function Config() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Settings className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Configuration</h2>
<h2 className="text-base font-semibold text-white">{t('config.title')}</h2>
</div>
<button
onClick={handleSave}
@ -68,7 +69,7 @@ export default function Config() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors disabled:opacity-50"
>
<Save className="h-4 w-4" />
{saving ? 'Saving...' : 'Save'}
{saving ? t('common.saving') : t('common.save')}
</button>
</div>
@ -77,11 +78,10 @@ export default function Config() {
<ShieldAlert className="h-5 w-5 text-yellow-400 flex-shrink-0 mt-0.5" />
<div>
<p className="text-sm text-yellow-300 font-medium">
Sensitive fields are masked
{t('config.sensitive_title')}
</p>
<p className="text-sm text-yellow-400/70 mt-0.5">
API keys, tokens, and passwords are hidden for security. To update a
masked field, replace the entire masked value with your new value.
{t('config.sensitive_body')}
</p>
</div>
</div>
@ -106,10 +106,10 @@ export default function Config() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="flex items-center justify-between px-4 py-2 border-b border-gray-800 bg-gray-800/50">
<span className="text-xs text-gray-400 font-medium uppercase tracking-wider">
TOML Configuration
{t('config.editor_title')}
</span>
<span className="text-xs text-gray-500">
{config.split('\n').length} lines
{config.split('\n').length} {t('common.lines')}
</span>
</div>
<textarea

View File

@ -7,6 +7,7 @@ import {
} from 'lucide-react';
import type { CostSummary } from '@/types/api';
import { getCost } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatUSD(value: number): string {
return `$${value.toFixed(4)}`;
@ -28,7 +29,7 @@ export default function Cost() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cost data: {error}
{tf('cost.load_error', { error })}
</div>
</div>
);
@ -53,7 +54,7 @@ export default function Cost() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<DollarSign className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Session Cost</span>
<span className="text-sm text-gray-400">{t('cost.session')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.session_cost_usd)}
@ -65,7 +66,7 @@ export default function Cost() {
<div className="p-2 bg-green-600/20 rounded-lg">
<TrendingUp className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Daily Cost</span>
<span className="text-sm text-gray-400">{t('cost.daily')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.daily_cost_usd)}
@ -77,7 +78,7 @@ export default function Cost() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Layers className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Monthly Cost</span>
<span className="text-sm text-gray-400">{t('cost.monthly')}</span>
</div>
<p className="text-2xl font-bold text-white">
{formatUSD(cost.monthly_cost_usd)}
@ -89,10 +90,10 @@ export default function Cost() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Hash className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Total Requests</span>
<span className="text-sm text-gray-400">{t('cost.total_requests')}</span>
</div>
<p className="text-2xl font-bold text-white">
{cost.request_count.toLocaleString()}
{cost.request_count.toLocaleString(getLocale())}
</p>
</div>
</div>
@ -100,25 +101,25 @@ export default function Cost() {
{/* Token Statistics */}
<div className="bg-gray-900 rounded-xl border border-gray-800 p-5">
<h3 className="text-base font-semibold text-white mb-4">
Token Statistics
{t('cost.token_statistics')}
</h3>
<div className="grid grid-cols-1 sm:grid-cols-3 gap-4">
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Total Tokens</p>
<p className="text-sm text-gray-400">{t('cost.total_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens.toLocaleString()}
{cost.total_tokens.toLocaleString(getLocale())}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Avg Tokens / Request</p>
<p className="text-sm text-gray-400">{t('cost.average_tokens_per_request')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.request_count > 0
? Math.round(cost.total_tokens / cost.request_count).toLocaleString()
? Math.round(cost.total_tokens / cost.request_count).toLocaleString(getLocale())
: '0'}
</p>
</div>
<div className="bg-gray-800/50 rounded-lg p-4">
<p className="text-sm text-gray-400">Cost per 1K Tokens</p>
<p className="text-sm text-gray-400">{t('cost.cost_per_1k_tokens')}</p>
<p className="text-xl font-bold text-white mt-1">
{cost.total_tokens > 0
? formatUSD((cost.monthly_cost_usd / cost.total_tokens) * 1000)
@ -132,12 +133,12 @@ export default function Cost() {
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-hidden">
<div className="px-5 py-4 border-b border-gray-800">
<h3 className="text-base font-semibold text-white">
Model Breakdown
{t('cost.model_breakdown')}
</h3>
</div>
{models.length === 0 ? (
<div className="p-8 text-center text-gray-500">
No model data available.
{t('cost.no_models')}
</div>
) : (
<div className="overflow-x-auto">
@ -145,19 +146,19 @@ export default function Cost() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Model
{t('cost.model')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Cost
{t('cost.usd')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Tokens
{t('cost.tokens')}
</th>
<th className="text-right px-5 py-3 text-gray-400 font-medium">
Requests
{t('cost.requests')}
</th>
<th className="text-left px-5 py-3 text-gray-400 font-medium">
Share
{t('cost.share')}
</th>
</tr>
</thead>
@ -181,10 +182,10 @@ export default function Cost() {
{formatUSD(m.cost_usd)}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
{m.total_tokens.toLocaleString()}
{m.total_tokens.toLocaleString(getLocale())}
</td>
<td className="px-5 py-3 text-gray-300 text-right">
{m.request_count.toLocaleString()}
{m.request_count.toLocaleString(getLocale())}
</td>
<td className="px-5 py-3">
<div className="flex items-center gap-2">

View File

@ -10,11 +10,12 @@ import {
} from 'lucide-react';
import type { CronJob } from '@/types/api';
import { getCronJobs, addCronJob, deleteCronJob } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatDate(iso: string | null): string {
if (!iso) return '-';
const d = new Date(iso);
return d.toLocaleString();
return d.toLocaleString(getLocale());
}
export default function Cron() {
@ -45,7 +46,7 @@ export default function Cron() {
const handleAdd = async () => {
if (!formSchedule.trim() || !formCommand.trim()) {
setFormError('Schedule and command are required.');
setFormError(t('cron.required_fields'));
return;
}
setSubmitting(true);
@ -62,7 +63,7 @@ export default function Cron() {
setFormSchedule('');
setFormCommand('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to add job');
setFormError(err instanceof Error ? err.message : t('cron.add_error'));
} finally {
setSubmitting(false);
}
@ -73,7 +74,7 @@ export default function Cron() {
await deleteCronJob(id);
setJobs((prev) => prev.filter((j) => j.id !== id));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete job');
setError(err instanceof Error ? err.message : t('cron.delete_error'));
} finally {
setConfirmDelete(null);
}
@ -97,7 +98,7 @@ export default function Cron() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load cron jobs: {error}
{tf('cron.load_error', { error })}
</div>
</div>
);
@ -118,7 +119,7 @@ export default function Cron() {
<div className="flex items-center gap-2">
<Clock className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Scheduled Tasks ({jobs.length})
{t('cron.title')} ({jobs.length})
</h2>
</div>
<button
@ -126,7 +127,7 @@ export default function Cron() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Job
{t('cron.add')}
</button>
</div>
@ -135,7 +136,7 @@ export default function Cron() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Cron Job</h3>
<h3 className="text-lg font-semibold text-white">{t('cron.modal_title')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -156,37 +157,37 @@ export default function Cron() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Name (optional)
{t('cron.name_optional')}
</label>
<input
type="text"
value={formName}
onChange={(e) => setFormName(e.target.value)}
placeholder="e.g. Daily cleanup"
placeholder={t('cron.name_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Schedule <span className="text-red-400">*</span>
{t('cron.schedule')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formSchedule}
onChange={(e) => setFormSchedule(e.target.value)}
placeholder="e.g. 0 0 * * * (cron expression)"
placeholder={t('cron.schedule_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Command <span className="text-red-400">*</span>
{t('cron.command')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formCommand}
onChange={(e) => setFormCommand(e.target.value)}
placeholder="e.g. cleanup --older-than 7d"
placeholder={t('cron.command_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -200,14 +201,14 @@ export default function Cron() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Adding...' : 'Add Job'}
{submitting ? t('cron.adding') : t('cron.add')}
</button>
</div>
</div>
@ -218,35 +219,35 @@ export default function Cron() {
{jobs.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Clock className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No scheduled tasks configured.</p>
<p className="text-gray-400">{t('cron.empty_configured')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
ID
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Command
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Next Run
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Last Status
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Enabled
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
</tr>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('common.id')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.command')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.next_run')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.last_status')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('cron.enabled')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
{t('common.actions')}
</th>
</tr>
</thead>
<tbody>
{jobs.map((job) => (
@ -282,24 +283,24 @@ export default function Cron() {
: 'bg-gray-800 text-gray-500 border border-gray-700'
}`}
>
{job.enabled ? 'Enabled' : 'Disabled'}
{job.enabled ? t('common.enabled') : t('common.disabled')}
</span>
</td>
<td className="px-4 py-3 text-right">
{confirmDelete === job.id ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('cron.delete_prompt')}</span>
<button
onClick={() => handleDelete(job.id)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -10,6 +10,7 @@ import {
} from 'lucide-react';
import type { StatusResponse, CostSummary } from '@/types/api';
import { getStatus, getCost } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function formatUptime(seconds: number): string {
const d = Math.floor(seconds / 86400);
@ -70,7 +71,7 @@ export default function Dashboard() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load dashboard: {error}
{tf('dashboard.load_error', { error })}
</div>
</div>
);
@ -95,10 +96,10 @@ export default function Dashboard() {
<div className="p-2 bg-blue-600/20 rounded-lg">
<Cpu className="h-5 w-5 text-blue-400" />
</div>
<span className="text-sm text-gray-400">Provider / Model</span>
<span className="text-sm text-gray-400">{t('dashboard.provider_model')}</span>
</div>
<p className="text-lg font-semibold text-white truncate">
{status.provider ?? 'Unknown'}
{status.provider ?? t('common.unknown')}
</p>
<p className="text-sm text-gray-400 truncate">{status.model}</p>
</div>
@ -108,12 +109,12 @@ export default function Dashboard() {
<div className="p-2 bg-green-600/20 rounded-lg">
<Clock className="h-5 w-5 text-green-400" />
</div>
<span className="text-sm text-gray-400">Uptime</span>
<span className="text-sm text-gray-400">{t('dashboard.uptime')}</span>
</div>
<p className="text-lg font-semibold text-white">
{formatUptime(status.uptime_seconds)}
</p>
<p className="text-sm text-gray-400">Since last restart</p>
<p className="text-sm text-gray-400">{t('dashboard.since_restart')}</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
@ -121,12 +122,14 @@ export default function Dashboard() {
<div className="p-2 bg-purple-600/20 rounded-lg">
<Globe className="h-5 w-5 text-purple-400" />
</div>
<span className="text-sm text-gray-400">Gateway Port</span>
<span className="text-sm text-gray-400">{t('dashboard.gateway_port')}</span>
</div>
<p className="text-lg font-semibold text-white">
:{status.gateway_port}
</p>
<p className="text-sm text-gray-400">Locale: {status.locale}</p>
<p className="text-sm text-gray-400">
{t('dashboard.locale')}: {status.locale}
</p>
</div>
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
@ -134,13 +137,13 @@ export default function Dashboard() {
<div className="p-2 bg-orange-600/20 rounded-lg">
<Database className="h-5 w-5 text-orange-400" />
</div>
<span className="text-sm text-gray-400">Memory Backend</span>
<span className="text-sm text-gray-400">{t('dashboard.memory_backend')}</span>
</div>
<p className="text-lg font-semibold text-white capitalize">
{status.memory_backend}
</p>
<p className="text-sm text-gray-400">
Paired: {status.paired ? 'Yes' : 'No'}
{t('dashboard.paired')}: {status.paired ? t('common.yes') : t('common.no')}
</p>
</div>
</div>
@ -150,13 +153,13 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<DollarSign className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Cost Overview</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.cost_overview')}</h2>
</div>
<div className="space-y-4">
{[
{ label: 'Session', value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: 'Daily', value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: 'Monthly', value: cost.monthly_cost_usd, color: 'bg-purple-500' },
{ label: t('cost.session'), value: cost.session_cost_usd, color: 'bg-blue-500' },
{ label: t('cost.daily'), value: cost.daily_cost_usd, color: 'bg-green-500' },
{ label: t('cost.monthly'), value: cost.monthly_cost_usd, color: 'bg-purple-500' },
].map(({ label, value, color }) => (
<div key={label}>
<div className="flex justify-between text-sm mb-1">
@ -173,12 +176,12 @@ export default function Dashboard() {
))}
</div>
<div className="mt-4 pt-3 border-t border-gray-800 flex justify-between text-sm">
<span className="text-gray-400">Total Tokens</span>
<span className="text-white">{cost.total_tokens.toLocaleString()}</span>
<span className="text-gray-400">{t('cost.total_tokens')}</span>
<span className="text-white">{cost.total_tokens.toLocaleString(getLocale())}</span>
</div>
<div className="flex justify-between text-sm mt-1">
<span className="text-gray-400">Requests</span>
<span className="text-white">{cost.request_count.toLocaleString()}</span>
<span className="text-gray-400">{t('cost.requests')}</span>
<span className="text-white">{cost.request_count.toLocaleString(getLocale())}</span>
</div>
</div>
@ -186,11 +189,11 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Radio className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Active Channels</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.active_channels')}</h2>
</div>
<div className="space-y-2">
{Object.entries(status.channels).length === 0 ? (
<p className="text-sm text-gray-500">No channels configured</p>
<p className="text-sm text-gray-500">{t('dashboard.no_channels')}</p>
) : (
Object.entries(status.channels).map(([name, active]) => (
<div
@ -205,7 +208,7 @@ export default function Dashboard() {
}`}
/>
<span className="text-xs text-gray-400">
{active ? 'Active' : 'Inactive'}
{active ? t('common.active') : t('common.inactive')}
</span>
</div>
</div>
@ -218,11 +221,11 @@ export default function Dashboard() {
<div className="bg-gray-900 rounded-xl p-5 border border-gray-800">
<div className="flex items-center gap-2 mb-4">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Component Health</h2>
<h2 className="text-base font-semibold text-white">{t('dashboard.component_health')}</h2>
</div>
<div className="grid grid-cols-2 gap-3">
{Object.entries(status.health.components).length === 0 ? (
<p className="text-sm text-gray-500 col-span-2">No components reporting</p>
<p className="text-sm text-gray-500 col-span-2">{t('dashboard.no_components')}</p>
) : (
Object.entries(status.health.components).map(([name, comp]) => (
<div
@ -238,7 +241,7 @@ export default function Dashboard() {
<p className="text-xs text-gray-400 capitalize">{comp.status}</p>
{comp.restart_count > 0 && (
<p className="text-xs text-yellow-400 mt-1">
Restarts: {comp.restart_count}
{tf('dashboard.restarts', { count: comp.restart_count })}
</p>
)}
</div>

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { DiagResult } from '@/types/api';
import { runDoctor } from '@/lib/api';
import { t } from '@/lib/i18n';
function severityIcon(severity: DiagResult['severity']) {
switch (severity) {
@ -43,6 +44,17 @@ function severityBg(severity: DiagResult['severity']): string {
}
}
function severityLabel(severity: DiagResult['severity']): string {
switch (severity) {
case 'ok':
return t('doctor.ok');
case 'warn':
return t('doctor.warn');
case 'error':
return t('doctor.error');
}
}
export default function Doctor() {
const [results, setResults] = useState<DiagResult[] | null>(null);
const [loading, setLoading] = useState(false);
@ -56,7 +68,7 @@ export default function Doctor() {
const data = await runDoctor();
setResults(data);
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to run diagnostics');
setError(err instanceof Error ? err.message : t('doctor.run_error'));
} finally {
setLoading(false);
}
@ -82,7 +94,7 @@ export default function Doctor() {
<div className="flex items-center justify-between">
<div className="flex items-center gap-2">
<Stethoscope className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Diagnostics</h2>
<h2 className="text-base font-semibold text-white">{t('doctor.title')}</h2>
</div>
<button
onClick={handleRun}
@ -92,12 +104,12 @@ export default function Doctor() {
{loading ? (
<>
<Loader2 className="h-4 w-4 animate-spin" />
Running...
{t('doctor.running_short')}
</>
) : (
<>
<Play className="h-4 w-4" />
Run Diagnostics
{t('doctor.run')}
</>
)}
</button>
@ -114,9 +126,9 @@ export default function Doctor() {
{loading && (
<div className="flex flex-col items-center justify-center py-16">
<Loader2 className="h-10 w-10 text-blue-500 animate-spin mb-4" />
<p className="text-gray-400">Running diagnostics...</p>
<p className="text-gray-400">{t('doctor.running')}</p>
<p className="text-sm text-gray-500 mt-1">
This may take a few seconds.
{t('doctor.may_take_seconds')}
</p>
</div>
)}
@ -129,27 +141,21 @@ export default function Doctor() {
<div className="flex items-center gap-2">
<CheckCircle className="h-5 w-5 text-green-400" />
<span className="text-sm text-white font-medium">
{okCount} <span className="text-gray-400 font-normal">ok</span>
{okCount} <span className="text-gray-400 font-normal">{t('doctor.ok')}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<AlertTriangle className="h-5 w-5 text-yellow-400" />
<span className="text-sm text-white font-medium">
{warnCount}{' '}
<span className="text-gray-400 font-normal">
warning{warnCount !== 1 ? 's' : ''}
</span>
{warnCount} <span className="text-gray-400 font-normal">{t('doctor.warn')}</span>
</span>
</div>
<div className="w-px h-5 bg-gray-700" />
<div className="flex items-center gap-2">
<XCircle className="h-5 w-5 text-red-400" />
<span className="text-sm text-white font-medium">
{errorCount}{' '}
<span className="text-gray-400 font-normal">
error{errorCount !== 1 ? 's' : ''}
</span>
{errorCount} <span className="text-gray-400 font-normal">{t('doctor.error')}</span>
</span>
</div>
@ -157,15 +163,15 @@ export default function Doctor() {
<div className="ml-auto">
{errorCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-red-900/40 text-red-400 border border-red-700/50">
Issues Found
{t('doctor.issues_found')}
</span>
) : warnCount > 0 ? (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-yellow-900/40 text-yellow-400 border border-yellow-700/50">
Warnings
{t('doctor.warnings')}
</span>
) : (
<span className="inline-flex items-center gap-1.5 px-3 py-1 rounded-full text-xs font-medium bg-green-900/40 text-green-400 border border-green-700/50">
All Clear
{t('doctor.all_clear')}
</span>
)}
</div>
@ -191,7 +197,7 @@ export default function Doctor() {
<div className="min-w-0">
<p className="text-sm text-white">{result.message}</p>
<p className="text-xs text-gray-500 mt-0.5 capitalize">
{result.severity}
{severityLabel(result.severity)}
</p>
</div>
</div>
@ -206,9 +212,9 @@ export default function Doctor() {
{!results && !loading && !error && (
<div className="flex flex-col items-center justify-center py-16 text-gray-500">
<Stethoscope className="h-12 w-12 text-gray-600 mb-4" />
<p className="text-lg font-medium">System Diagnostics</p>
<p className="text-lg font-medium">{t('doctor.title')}</p>
<p className="text-sm mt-1">
Click "Run Diagnostics" to check your ZeroClaw installation.
{t('doctor.empty_help')}
</p>
</div>
)}

View File

@ -13,25 +13,26 @@ import {
getStatus,
putIntegrationCredentials,
} from '@/lib/api';
import { t, tf } from '@/lib/i18n';
function statusBadge(status: Integration['status']) {
switch (status) {
case 'Active':
return {
icon: Check,
label: 'Active',
label: t('integrations.active'),
classes: 'bg-green-900/40 text-green-400 border-green-700/50',
};
case 'Available':
return {
icon: Zap,
label: 'Available',
label: t('integrations.available'),
classes: 'bg-blue-900/40 text-blue-400 border-blue-700/50',
};
case 'ComingSoon':
return {
icon: Clock,
label: 'Coming Soon',
label: t('integrations.coming_soon'),
classes: 'bg-gray-800 text-gray-400 border-gray-700',
};
}
@ -70,9 +71,9 @@ const FALLBACK_MODEL_OPTIONS: Record<string, string[]> = {
function customModelFormatHint(integrationId: string): string {
if (integrationId === 'openrouter' || integrationId === 'vercel') {
return 'Format: anthropic/claude-sonnet-4-6';
return t('integrations.custom_model_hint_scoped');
}
return 'Format: claude-sonnet-4-6 (or provider/model when required)';
return t('integrations.custom_model_hint_generic');
}
function modelOptionsForField(
@ -175,7 +176,7 @@ export default function Integrations() {
setRuntimeStatus(status ? { model: status.model } : null);
return nextSettingsByName;
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load integrations');
setError(err instanceof Error ? err.message : t('common.error'));
setActiveAiIntegrationId(null);
setRuntimeStatus(null);
return null;
@ -252,7 +253,7 @@ export default function Integrations() {
if (isSelectField) {
if (value === SELECT_KEEP) {
if (field.required && !field.has_value) {
setSaveError(`${field.label} is required.`);
setSaveError(tf('integrations.field_required', { field: field.label }));
return;
}
if (isDirty) {
@ -268,12 +269,14 @@ export default function Integrations() {
const trimmed = resolvedValue.trim();
if (isSelectField && value === SELECT_CUSTOM && !trimmed) {
setSaveError(`Enter a custom value for ${field.label} or choose a recommended model.`);
setSaveError(
tf('integrations.custom_value_required', { field: field.label }),
);
return;
}
if (field.required && !trimmed && !field.has_value) {
setSaveError(`${field.label} is required.`);
setSaveError(tf('integrations.field_required', { field: field.label }));
return;
}
@ -289,7 +292,7 @@ export default function Integrations() {
Object.keys(payload).length === 0 &&
!activeEditor.activates_default_provider
) {
setSaveError('No changes to save.');
setSaveError(t('integrations.no_changes'));
return;
}
@ -298,9 +301,13 @@ export default function Integrations() {
activeAiIntegrationId &&
activeEditor.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const currentProvider =
activeAiIntegration?.name ?? t('integrations.current_provider');
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${activeEditor.name}?`,
tf('integrations.confirm_switch_provider', {
current: currentProvider,
target: activeEditor.name,
}),
);
if (!confirmed) {
return;
@ -315,10 +322,10 @@ export default function Integrations() {
});
await loadData(false);
setSaveSuccess(`${activeEditor.name} credentials saved.`);
setSaveSuccess(tf('integrations.credentials_saved', { name: activeEditor.name }));
closeEditor();
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to save credentials';
const message = err instanceof Error ? err.message : t('integrations.save_error');
if (message.includes('API 409')) {
const refreshed = await loadData(false);
if (refreshed) {
@ -331,7 +338,7 @@ export default function Integrations() {
}
}
setSaveError(
'Configuration changed elsewhere. Refreshed latest settings; re-enter values and save again.',
t('integrations.stale_save'),
);
} else {
setSaveError(message);
@ -357,9 +364,14 @@ export default function Integrations() {
!isActiveDefaultProvider &&
integration.id !== activeAiIntegrationId
) {
const currentProvider = activeAiIntegration?.name ?? 'current provider';
const currentProvider =
activeAiIntegration?.name ?? t('integrations.current_provider');
const confirmed = window.confirm(
`Switch default AI provider from ${currentProvider} to ${integration.name} and set model to ${trimmedTarget}?`,
tf('integrations.confirm_switch_provider_with_model', {
current: currentProvider,
target: integration.name,
model: trimmedTarget,
}),
);
if (!confirmed) {
return;
@ -378,19 +390,22 @@ export default function Integrations() {
});
await loadData(false);
setSaveSuccess(`Model updated to ${trimmedTarget} for ${integration.name}.`);
setSaveSuccess(
tf('integrations.model_updated', {
model: trimmedTarget,
name: integration.name,
}),
);
setQuickModelDrafts((prev) => {
const next = { ...prev };
delete next[integration.id];
return next;
});
} catch (err: unknown) {
const message = err instanceof Error ? err.message : 'Failed to update model';
const message = err instanceof Error ? err.message : t('integrations.update_model_error');
if (message.includes('API 409')) {
await loadData(false);
setQuickModelError(
'Configuration changed elsewhere. Refreshed latest settings; choose the model again.',
);
setQuickModelError(t('integrations.stale_model'));
} else {
setQuickModelError(message);
}
@ -421,7 +436,7 @@ export default function Integrations() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load integrations: {error}
{tf('integrations.load_error', { error })}
</div>
</div>
);
@ -441,7 +456,7 @@ export default function Integrations() {
<div className="flex items-center gap-2">
<Puzzle className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Integrations ({integrations.length})
{t('integrations.title')} ({integrations.length})
</h2>
</div>
@ -469,7 +484,7 @@ export default function Integrations() {
: 'bg-gray-900 text-gray-400 border border-gray-700 hover:bg-gray-800 hover:text-white'
}`}
>
{cat === 'all' ? 'All' : formatCategory(cat)}
{cat === 'all' ? t('common.all') : formatCategory(cat)}
</button>
))}
</div>
@ -478,7 +493,7 @@ export default function Integrations() {
{Object.keys(grouped).length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Puzzle className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No integrations found.</p>
<p className="text-gray-400">{t('integrations.empty')}</p>
</div>
) : (
Object.entries(grouped)
@ -510,8 +525,8 @@ export default function Integrations() {
const modelSummary = currentModel
? currentModel
: fallbackModel
? `default: ${fallbackModel}`
: 'default';
? tf('integrations.default_summary', { model: fallbackModel })
: t('integrations.default_only');
const modelBaseline = currentModel ?? fallbackModel ?? '';
const quickDraft = editable
? quickModelDrafts[editable.id] ?? modelBaseline
@ -555,7 +570,9 @@ export default function Integrations() {
: 'bg-gray-800 text-gray-300 border-gray-700'
}`}
>
{isActiveDefaultProvider ? 'Default' : 'Configured'}
{isActiveDefaultProvider
? t('integrations.default_badge')
: t('integrations.configured_badge')}
</span>
)}
<span
@ -571,7 +588,7 @@ export default function Integrations() {
<div className="mt-3 rounded-lg border border-gray-800 bg-gray-950/50 p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<span className="text-[11px] uppercase tracking-wider text-gray-500">
Current model
{t('integrations.current_model')}
</span>
<span className="text-xs text-gray-200 truncate" title={modelSummary}>
{modelSummary}
@ -615,11 +632,13 @@ export default function Integrations() {
}
className="px-2.5 py-1.5 rounded-lg text-xs font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{quickModelSavingId === editable.id ? 'Saving...' : 'Apply'}
{quickModelSavingId === editable.id
? t('common.saving')
: t('common.apply')}
</button>
</div>
<p className="text-[11px] text-gray-500">
For custom model IDs, use Edit Keys.
{t('integrations.quick_model_help')}
</p>
</div>
)}
@ -632,17 +651,19 @@ export default function Integrations() {
{editable.configured
? editable.activates_default_provider
? isActiveDefaultProvider
? 'Default provider configured'
: 'Provider configured'
: 'Credentials configured'
: 'Credentials not configured'}
? t('integrations.default_provider_configured')
: t('integrations.provider_configured')
: t('integrations.credentials_configured')
: t('integrations.credentials_not_configured')}
</div>
<button
onClick={() => openEditor(editable)}
className="inline-flex items-center gap-1.5 px-3 py-1.5 rounded-lg border border-blue-700/70 bg-blue-900/30 hover:bg-blue-900/50 text-blue-300 text-xs font-medium transition-colors"
>
<KeyRound className="h-3.5 w-3.5" />
{editable.configured ? 'Edit Keys' : 'Configure'}
{editable.configured
? t('integrations.edit_keys')
: t('integrations.configure')}
</button>
</div>
)}
@ -667,19 +688,19 @@ export default function Integrations() {
<div className="px-5 py-4 border-b border-gray-800 flex items-center justify-between gap-3">
<div>
<h3 className="text-sm font-semibold text-white">
Configure {activeEditor.name}
{tf('integrations.configure_title', { name: activeEditor.name })}
</h3>
<p className="text-xs text-gray-400 mt-0.5">
{activeEditor.configured
? 'Enter only fields you want to update.'
: 'Enter required fields to configure this integration.'}
? t('integrations.configure_intro_update')
: t('integrations.configure_intro_new')}
</p>
</div>
<button
onClick={closeEditor}
disabled={saving}
className="text-gray-400 hover:text-white transition-colors disabled:opacity-50"
aria-label="Close"
aria-label={t('common.close')}
>
<X className="h-4 w-4" />
</button>
@ -688,10 +709,11 @@ export default function Integrations() {
<div className="p-5 space-y-4">
{activeEditor.activates_default_provider && (
<div className="rounded-lg border border-blue-800 bg-blue-950/30 p-3 text-xs text-blue-200">
Saving here updates credentials and switches your default AI provider to{' '}
<strong>{activeEditor.name}</strong>. For advanced provider settings, use{' '}
{t('integrations.default_provider_notice_prefix')}{' '}
<strong>{activeEditor.name}</strong>.{' '}
{t('integrations.default_provider_notice_suffix')}{' '}
<Link to="/config" className="underline underline-offset-2 hover:text-blue-100">
Configuration
{t('config.title')}
</Link>
.
</div>
@ -712,8 +734,10 @@ export default function Integrations() {
field.current_value?.trim() ||
(activeEditorIsDefaultProvider ? runtimeStatus?.model?.trim() || '' : '');
const keepCurrentLabel = currentModelValue
? `Keep current model (${currentModelValue})`
: 'Keep current model';
? tf('integrations.keep_current_model_with_value', {
model: currentModelValue,
})
: t('integrations.keep_current_model');
return (
<div key={field.key}>
@ -722,7 +746,7 @@ export default function Integrations() {
{field.required && <span className="text-red-400">*</span>}
{field.has_value && (
<span className="text-[11px] text-green-400 bg-green-900/30 border border-green-800 px-1.5 py-0.5 rounded">
Configured
{t('common.configured')}
</span>
)}
</label>
@ -737,7 +761,7 @@ export default function Integrations() {
<option value={SELECT_KEEP}>{keepCurrentLabel}</option>
) : (
<option value="" disabled>
Select a recommended model
{t('integrations.select_recommended_model')}
</option>
)}
{selectOptions.map((option) => (
@ -745,8 +769,12 @@ export default function Integrations() {
{option}
</option>
))}
<option value={SELECT_CUSTOM}>Custom model...</option>
{field.has_value && <option value={SELECT_CLEAR}>Clear current model</option>}
<option value={SELECT_CUSTOM}>{t('integrations.custom_model')}</option>
{field.has_value && (
<option value={SELECT_CLEAR}>
{t('integrations.clear_current_model')}
</option>
)}
</select>
{fieldValues[field.key] === SELECT_CUSTOM && (
@ -760,14 +788,17 @@ export default function Integrations() {
)}
<p className="text-[11px] text-gray-500">
Pick a recommended model or choose Custom model. {customModelFormatHint(activeEditor.id)}.
{tf('integrations.pick_model_help', {
hint: customModelFormatHint(activeEditor.id),
})}
</p>
</div>
) : (
<div className="space-y-2">
{maskedSecretValue && (
<p className="text-[11px] text-gray-500">
Current value: <span className="font-mono text-gray-300">{maskedSecretValue}</span>
{t('integrations.current_value')}{' '}
<span className="font-mono text-gray-300">{maskedSecretValue}</span>
</p>
)}
<input
@ -777,11 +808,11 @@ export default function Integrations() {
placeholder={
field.required
? field.has_value
? 'Enter a new value to replace current'
: 'Enter value'
? t('integrations.replace_current_placeholder')
: t('integrations.enter_value_placeholder')
: field.has_value
? 'Type new value, or leave empty to keep current'
: 'Optional'
? t('integrations.keep_current_placeholder')
: t('common.optional')
}
className="w-full px-3 py-2 rounded-lg bg-gray-950 border border-gray-700 text-sm text-gray-200 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
@ -805,7 +836,7 @@ export default function Integrations() {
disabled={saving}
className="px-4 py-2 rounded-lg text-sm border border-gray-700 text-gray-300 hover:bg-gray-800 transition-colors disabled:opacity-50"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={saveCredentials}
@ -813,10 +844,10 @@ export default function Integrations() {
className="px-4 py-2 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors disabled:opacity-50"
>
{saving
? 'Saving...'
? t('common.saving')
: activeEditor.activates_default_provider
? 'Save & Activate'
: 'Save Keys'}
? t('integrations.save_activate')
: t('integrations.save_keys')}
</button>
</div>
</div>

View File

@ -8,10 +8,11 @@ import {
} from 'lucide-react';
import type { SSEEvent } from '@/types/api';
import { SSEClient } from '@/lib/sse';
import { getLocale, t } from '@/lib/i18n';
function formatTimestamp(ts?: string): string {
if (!ts) return new Date().toLocaleTimeString();
return new Date(ts).toLocaleTimeString();
if (!ts) return new Date().toLocaleTimeString(getLocale());
return new Date(ts).toLocaleTimeString(getLocale());
}
function eventTypeBadgeColor(type: string): string {
@ -138,7 +139,7 @@ export default function Logs() {
<div className="flex items-center justify-between px-6 py-3 border-b border-gray-800 bg-gray-900">
<div className="flex items-center gap-3">
<Activity className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">Live Logs</h2>
<h2 className="text-base font-semibold text-white">{t('logs.title')}</h2>
<div className="flex items-center gap-2 ml-2">
<span
className={`inline-block h-2 w-2 rounded-full ${
@ -146,11 +147,11 @@ export default function Logs() {
}`}
/>
<span className="text-xs text-gray-500">
{connected ? 'Connected' : 'Disconnected'}
{connected ? t('agent.connected') : t('agent.disconnected')}
</span>
</div>
<span className="text-xs text-gray-500 ml-2">
{filteredEntries.length} events
{filteredEntries.length} {t('logs.events')}
</span>
</div>
@ -166,11 +167,11 @@ export default function Logs() {
>
{paused ? (
<>
<Play className="h-3.5 w-3.5" /> Resume
<Play className="h-3.5 w-3.5" /> {t('logs.resume')}
</>
) : (
<>
<Pause className="h-3.5 w-3.5" /> Pause
<Pause className="h-3.5 w-3.5" /> {t('logs.pause')}
</>
)}
</button>
@ -182,7 +183,7 @@ export default function Logs() {
className="flex items-center gap-1.5 px-3 py-1.5 rounded-lg text-sm font-medium bg-blue-600 hover:bg-blue-700 text-white transition-colors"
>
<ArrowDown className="h-3.5 w-3.5" />
Jump to bottom
{t('logs.jump_to_bottom')}
</button>
)}
</div>
@ -192,7 +193,7 @@ export default function Logs() {
{allTypes.length > 0 && (
<div className="flex items-center gap-2 px-6 py-2 border-b border-gray-800 bg-gray-900/80 overflow-x-auto">
<Filter className="h-4 w-4 text-gray-500 flex-shrink-0" />
<span className="text-xs text-gray-500 flex-shrink-0">Filter:</span>
<span className="text-xs text-gray-500 flex-shrink-0">{t('logs.filter_label')}</span>
{allTypes.map((type) => (
<label
key={type}
@ -212,7 +213,7 @@ export default function Logs() {
onClick={() => setTypeFilters(new Set())}
className="text-xs text-blue-400 hover:text-blue-300 flex-shrink-0 ml-1"
>
Clear
{t('logs.clear')}
</button>
)}
</div>
@ -228,9 +229,7 @@ export default function Logs() {
<div className="flex flex-col items-center justify-center h-full text-gray-500">
<Activity className="h-10 w-10 text-gray-600 mb-3" />
<p className="text-sm">
{paused
? 'Log streaming is paused.'
: 'Waiting for events...'}
{paused ? t('logs.paused_empty') : t('logs.waiting_empty')}
</p>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { MemoryEntry } from '@/types/api';
import { getMemory, storeMemory, deleteMemory } from '@/lib/api';
import { getLocale, t, tf } from '@/lib/i18n';
function truncate(text: string, max: number): string {
if (text.length <= max) return text;
@ -17,7 +18,7 @@ function truncate(text: string, max: number): string {
function formatDate(iso: string): string {
const d = new Date(iso);
return d.toLocaleString();
return d.toLocaleString(getLocale());
}
export default function Memory() {
@ -60,7 +61,7 @@ export default function Memory() {
const handleAdd = async () => {
if (!formKey.trim() || !formContent.trim()) {
setFormError('Key and content are required.');
setFormError(t('memory.required_fields'));
return;
}
setSubmitting(true);
@ -77,7 +78,7 @@ export default function Memory() {
setFormContent('');
setFormCategory('');
} catch (err: unknown) {
setFormError(err instanceof Error ? err.message : 'Failed to store memory');
setFormError(err instanceof Error ? err.message : t('memory.store_error'));
} finally {
setSubmitting(false);
}
@ -88,7 +89,7 @@ export default function Memory() {
await deleteMemory(key);
setEntries((prev) => prev.filter((e) => e.key !== key));
} catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete memory');
setError(err instanceof Error ? err.message : t('memory.delete_error'));
} finally {
setConfirmDelete(null);
}
@ -98,7 +99,7 @@ export default function Memory() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load memory: {error}
{tf('memory.load_error', { error })}
</div>
</div>
);
@ -111,7 +112,7 @@ export default function Memory() {
<div className="flex items-center gap-2">
<Brain className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Memory ({entries.length})
{t('memory.title')} ({entries.length})
</h2>
</div>
<button
@ -119,7 +120,7 @@ export default function Memory() {
className="flex items-center gap-2 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium px-4 py-2 rounded-lg transition-colors"
>
<Plus className="h-4 w-4" />
Add Memory
{t('memory.add_button')}
</button>
</div>
@ -132,7 +133,7 @@ export default function Memory() {
value={search}
onChange={(e) => setSearch(e.target.value)}
onKeyDown={handleKeyDown}
placeholder="Search memory entries..."
placeholder={t('memory.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -143,7 +144,7 @@ export default function Memory() {
onChange={(e) => setCategoryFilter(e.target.value)}
className="bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-8 py-2.5 text-sm text-white appearance-none focus:outline-none focus:ring-2 focus:ring-blue-500 cursor-pointer"
>
<option value="">All Categories</option>
<option value="">{t('memory.all_categories')}</option>
{categories.map((cat) => (
<option key={cat} value={cat}>
{cat}
@ -155,7 +156,7 @@ export default function Memory() {
onClick={handleSearch}
className="px-4 py-2.5 bg-blue-600 hover:bg-blue-700 text-white text-sm font-medium rounded-lg transition-colors"
>
Search
{t('common.search')}
</button>
</div>
@ -171,7 +172,7 @@ export default function Memory() {
<div className="fixed inset-0 bg-black/60 flex items-center justify-center z-50">
<div className="bg-gray-900 border border-gray-700 rounded-xl p-6 w-full max-w-md mx-4">
<div className="flex items-center justify-between mb-4">
<h3 className="text-lg font-semibold text-white">Add Memory</h3>
<h3 className="text-lg font-semibold text-white">{t('memory.add_button')}</h3>
<button
onClick={() => {
setShowForm(false);
@ -192,37 +193,37 @@ export default function Memory() {
<div className="space-y-4">
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Key <span className="text-red-400">*</span>
{t('memory.key')} <span className="text-red-400">*</span>
</label>
<input
type="text"
value={formKey}
onChange={(e) => setFormKey(e.target.value)}
placeholder="e.g. user_preferences"
placeholder={t('memory.key_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Content <span className="text-red-400">*</span>
{t('memory.content')} <span className="text-red-400">*</span>
</label>
<textarea
value={formContent}
onChange={(e) => setFormContent(e.target.value)}
placeholder="Memory content..."
placeholder={t('memory.content_placeholder')}
rows={4}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 resize-none"
/>
</div>
<div>
<label className="block text-sm font-medium text-gray-300 mb-1">
Category (optional)
{t('memory.category_optional')}
</label>
<input
type="text"
value={formCategory}
onChange={(e) => setFormCategory(e.target.value)}
placeholder="e.g. preferences, context, facts"
placeholder={t('memory.category_placeholder')}
className="w-full bg-gray-800 border border-gray-700 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500"
/>
</div>
@ -236,14 +237,14 @@ export default function Memory() {
}}
className="px-4 py-2 text-sm font-medium text-gray-300 hover:text-white border border-gray-700 rounded-lg hover:bg-gray-800 transition-colors"
>
Cancel
{t('common.cancel')}
</button>
<button
onClick={handleAdd}
disabled={submitting}
className="px-4 py-2 text-sm font-medium text-white bg-blue-600 hover:bg-blue-700 rounded-lg transition-colors disabled:opacity-50"
>
{submitting ? 'Saving...' : 'Save'}
{submitting ? t('common.saving') : t('common.save')}
</button>
</div>
</div>
@ -258,29 +259,29 @@ export default function Memory() {
) : entries.length === 0 ? (
<div className="bg-gray-900 rounded-xl border border-gray-800 p-8 text-center">
<Brain className="h-10 w-10 text-gray-600 mx-auto mb-3" />
<p className="text-gray-400">No memory entries found.</p>
<p className="text-gray-400">{t('memory.empty')}</p>
</div>
) : (
<div className="bg-gray-900 rounded-xl border border-gray-800 overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Key
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Content
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Timestamp
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
Actions
</th>
</tr>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.key')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.content')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.category')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
{t('memory.timestamp')}
</th>
<th className="text-right px-4 py-3 text-gray-400 font-medium">
{t('common.actions')}
</th>
</tr>
</thead>
<tbody>
{entries.map((entry) => (
@ -307,18 +308,18 @@ export default function Memory() {
<td className="px-4 py-3 text-right">
{confirmDelete === entry.key ? (
<div className="flex items-center justify-end gap-2">
<span className="text-xs text-red-400">Delete?</span>
<span className="text-xs text-red-400">{t('memory.delete_prompt')}</span>
<button
onClick={() => handleDelete(entry.key)}
className="text-red-400 hover:text-red-300 text-xs font-medium"
>
Yes
{t('common.yes')}
</button>
<button
onClick={() => setConfirmDelete(null)}
className="text-gray-400 hover:text-white text-xs font-medium"
>
No
{t('common.no')}
</button>
</div>
) : (

View File

@ -9,6 +9,7 @@ import {
} from 'lucide-react';
import type { ToolSpec, CliTool } from '@/types/api';
import { getTools, getCliTools } from '@/lib/api';
import { t, tf } from '@/lib/i18n';
export default function Tools() {
const [tools, setTools] = useState<ToolSpec[]>([]);
@ -44,7 +45,7 @@ export default function Tools() {
return (
<div className="p-6">
<div className="rounded-lg bg-red-900/30 border border-red-700 p-4 text-red-300">
Failed to load tools: {error}
{tf('tools.load_error', { error })}
</div>
</div>
);
@ -67,7 +68,7 @@ export default function Tools() {
type="text"
value={search}
onChange={(e) => setSearch(e.target.value)}
placeholder="Search tools..."
placeholder={t('tools.search')}
className="w-full bg-gray-900 border border-gray-700 rounded-lg pl-10 pr-4 py-2.5 text-sm text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent"
/>
</div>
@ -77,12 +78,12 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Wrench className="h-5 w-5 text-blue-400" />
<h2 className="text-base font-semibold text-white">
Agent Tools ({filtered.length})
{t('tools.agent_tools')} ({filtered.length})
</h2>
</div>
{filtered.length === 0 ? (
<p className="text-sm text-gray-500">No tools match your search.</p>
<p className="text-sm text-gray-500">{t('tools.no_match')}</p>
) : (
<div className="grid grid-cols-1 md:grid-cols-2 xl:grid-cols-3 gap-4">
{filtered.map((tool) => {
@ -119,7 +120,7 @@ export default function Tools() {
{isExpanded && tool.parameters && (
<div className="border-t border-gray-800 p-4">
<p className="text-xs text-gray-500 mb-2 font-medium uppercase tracking-wider">
Parameter Schema
{t('tools.parameter_schema')}
</p>
<pre className="text-xs text-gray-300 bg-gray-950 rounded-lg p-3 overflow-x-auto max-h-64 overflow-y-auto">
{JSON.stringify(tool.parameters, null, 2)}
@ -139,7 +140,7 @@ export default function Tools() {
<div className="flex items-center gap-2 mb-4">
<Terminal className="h-5 w-5 text-green-400" />
<h2 className="text-base font-semibold text-white">
CLI Tools ({filteredCli.length})
{t('tools.cli_tools')} ({filteredCli.length})
</h2>
</div>
@ -148,16 +149,16 @@ export default function Tools() {
<thead>
<tr className="border-b border-gray-800">
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Name
{t('common.name')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Path
{t('common.path')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Version
{t('common.version')}
</th>
<th className="text-left px-4 py-3 text-gray-400 font-medium">
Category
{t('integrations.category')}
</th>
</tr>
</thead>