diff --git a/android/app/src/main/java/io/clawdroid/CalendarPickerActivity.kt b/android/app/src/main/java/io/clawdroid/CalendarPickerActivity.kt index d484afaddb..808c8ce1e2 100644 --- a/android/app/src/main/java/io/clawdroid/CalendarPickerActivity.kt +++ b/android/app/src/main/java/io/clawdroid/CalendarPickerActivity.kt @@ -20,6 +20,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.clawdroid.core.ui.theme.ClawDroidTheme import io.clawdroid.core.ui.theme.GlassBorder @@ -94,7 +95,7 @@ class CalendarPickerActivity : ComponentActivity() { onDismissRequest = onCancel, containerColor = DarkCard, title = { - Text("Select Calendar", color = NeonCyan) + Text(stringResource(R.string.calendar_picker_title), color = NeonCyan) }, text = { Surface( @@ -126,7 +127,7 @@ class CalendarPickerActivity : ComponentActivity() { confirmButton = {}, dismissButton = { TextButton(onClick = onCancel) { - Text("Cancel", color = TextSecondary) + Text(stringResource(R.string.action_cancel), color = TextSecondary) } }, ) diff --git a/android/app/src/main/java/io/clawdroid/assistant/AssistantService.kt b/android/app/src/main/java/io/clawdroid/assistant/AssistantService.kt index 42c41951ff..bcd3e9c115 100644 --- a/android/app/src/main/java/io/clawdroid/assistant/AssistantService.kt +++ b/android/app/src/main/java/io/clawdroid/assistant/AssistantService.kt @@ -115,7 +115,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { serviceScope = CoroutineScope(SupervisorJob() + Dispatchers.Main) - connection = AssistantConnectionImpl(httpClient) + connection = AssistantConnectionImpl(httpClient, applicationContext) toolRequestHandler = ToolRequestHandler( context = applicationContext, @@ -345,15 +345,11 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { ) { Column(modifier = Modifier.padding(24.dp)) { Text( - text = "画面キャプチャの設定", + text = getString(io.clawdroid.R.string.assistant_accessibility_title), style = MaterialTheme.typography.headlineSmall ) Text( - text = "画面キャプチャを使用するには、ユーザー補助の設定で" + - " ClawDroid を有効にしてください。\n\n" + - "有効にできない場合は、アプリ情報から" + - "「制限付き設定を許可」を実行してから" + - "再度お試しください。", + text = getString(io.clawdroid.R.string.assistant_accessibility_body), style = MaterialTheme.typography.bodyMedium, modifier = Modifier.padding(top = 16.dp) ) @@ -364,7 +360,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { horizontalArrangement = Arrangement.End ) { TextButton(onClick = { showAccessibilityGuide = false }) { - Text("キャンセル") + Text(getString(io.clawdroid.R.string.action_cancel)) } TextButton(onClick = { showAccessibilityGuide = false @@ -372,7 +368,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { .addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) startActivity(intent) }) { - Text("設定を開く") + Text(getString(io.clawdroid.R.string.assistant_accessibility_open_settings)) } } } @@ -414,7 +410,7 @@ class AssistantService : LifecycleService(), SavedStateRegistryOwner { return NotificationCompat.Builder(this, NotificationHelper.ASSISTANT_CHANNEL_ID) .setSmallIcon(R.drawable.ic_notification) .setContentTitle("ClawDroid Assistant") - .setContentText("Listening...") + .setContentText(getString(io.clawdroid.R.string.assistant_notification_listening)) .setPriority(NotificationCompat.PRIORITY_LOW) .setOngoing(true) .build() diff --git a/android/app/src/main/java/io/clawdroid/di/AppModule.kt b/android/app/src/main/java/io/clawdroid/di/AppModule.kt index dc4be240ce..375d3b98be 100644 --- a/android/app/src/main/java/io/clawdroid/di/AppModule.kt +++ b/android/app/src/main/java/io/clawdroid/di/AppModule.kt @@ -81,7 +81,7 @@ val appModule = module { val clientId = prefs.getString("client_id", null) ?: UUID.randomUUID().toString().also { prefs.edit { putString("client_id", it) } } - WebSocketClient(get(), get(), clientId) + WebSocketClient(get(), get(), clientId, context = androidContext()) } // ImageFileStorage diff --git a/android/app/src/main/res/values-ja/strings.xml b/android/app/src/main/res/values-ja/strings.xml index 48cbf7455c..86df5e5271 100644 --- a/android/app/src/main/res/values-ja/strings.xml +++ b/android/app/src/main/res/values-ja/strings.xml @@ -63,4 +63,18 @@ ゲートウェイ ゲートウェイバックエンドサービス ClawDroid + + + キャンセル + + + 画面キャプチャの設定 + 画面キャプチャを使用するには、ユーザー補助の設定で ClawDroid を有効にしてください。\n\n有効にできない場合は、アプリ情報から「制限付き設定を許可」を実行してから再度お試しください。 + 設定を開く + + + 聞いています… + + + カレンダーを選択 diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index af8f928ef7..46e0dd7d44 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -63,4 +63,18 @@ Gateway Gateway backend service ClawDroid + + + Cancel + + + Screen Capture Setup + To use screen capture, enable ClawDroid in Accessibility settings.\n\nIf you cannot enable it, go to App Info and select \"Allow restricted settings\", then try again. + Open Settings + + + Listening… + + + Select Calendar diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt index 5e02d6a7ec..df0a42e43e 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigApiClient.kt @@ -18,9 +18,9 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonObject +import android.content.Context import java.io.Closeable import java.io.IOException -import java.util.Locale @Serializable data class ConfigSchema(val sections: List) @@ -48,7 +48,7 @@ data class SaveConfigResult( class AuthException(message: String) : IOException(message) -class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeable { +class ConfigApiClient(private val settingsStore: GatewaySettingsStore, private val context: Context) : Closeable { private val baseUrl: String get() = settingsStore.settings.value.httpBaseUrl @@ -64,7 +64,7 @@ class ConfigApiClient(private val settingsStore: GatewaySettingsStore) : Closeab suspend fun getSchema(): ConfigSchema { return client.get("$baseUrl/api/config/schema") { if (apiKey.isNotEmpty()) header("Authorization", "Bearer $apiKey") - header("Accept-Language", Locale.getDefault().language) + header("Accept-Language", context.resources.configuration.locales[0].language) }.ensureSuccess().body() } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt index 7720cce6bf..3e8d8dc221 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigModule.kt @@ -1,11 +1,12 @@ package io.clawdroid.backend.config +import org.koin.android.ext.koin.androidContext import org.koin.core.definition.Callbacks import org.koin.core.module.dsl.viewModel import org.koin.core.module.dsl.withOptions import org.koin.dsl.module val configModule = module { - single { ConfigApiClient(get()) } withOptions { callbacks = Callbacks(onClose = { it?.close() }) } + single { ConfigApiClient(get(), androidContext()) } withOptions { callbacks = Callbacks(onClose = { it?.close() }) } viewModel { ConfigViewModel(get()) } } diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt index def21ce7c5..494cafff7a 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionDetailScreen.kt @@ -55,6 +55,7 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.graphics.Color import androidx.compose.ui.text.input.KeyboardType import androidx.compose.ui.text.input.PasswordVisualTransformation @@ -85,16 +86,19 @@ fun ConfigSectionDetailScreen( val detail = uiState.detailState val isDirty = detail?.fields?.any { it.value != it.originalValue } == true val isSaving = uiState.saveState is SaveState.Saving + val savedMsg = stringResource(R.string.config_saved) + val savedRestartingMsg = stringResource(R.string.config_saved_restarting) + val errorPrefix = stringResource(R.string.config_error_prefix) LaunchedEffect(uiState.saveState) { when (val state = uiState.saveState) { is SaveState.Success -> { - val msg = if (state.restart) "Saved. Server restarting..." else "Saved." + val msg = if (state.restart) savedRestartingMsg else savedMsg snackbarHostState.showSnackbar(msg) viewModel.dismissSaveResult() } is SaveState.Error -> { - snackbarHostState.showSnackbar("Error: ${state.message}") + snackbarHostState.showSnackbar("$errorPrefix${state.message}") viewModel.dismissSaveResult() } else -> {} @@ -118,7 +122,7 @@ fun ConfigSectionDetailScreen( }) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_arrow_left), - contentDescription = "Back", + contentDescription = stringResource(R.string.config_back), tint = TextSecondary, ) } @@ -142,7 +146,7 @@ fun ConfigSectionDetailScreen( strokeWidth = 2.dp, ) } else { - Text("Save") + Text(stringResource(R.string.config_save)) } } }, @@ -265,7 +269,7 @@ private fun StringField(field: FieldState, onValueChanged: (String) -> Unit) { { TextButton(onClick = { hidden = !hidden }) { Text( - if (hidden) "Show" else "Hide", + stringResource(if (hidden) R.string.config_show else R.string.config_hide), color = NeonCyan, style = MaterialTheme.typography.labelSmall, ) @@ -334,7 +338,7 @@ private fun StringArrayField(field: FieldState, onValueChanged: (String) -> Unit value = field.value, onValueChange = onValueChanged, label = { Text(field.label, color = TextSecondary) }, - placeholder = { Text("Comma-separated values", color = TextSecondary.copy(alpha = 0.5f)) }, + placeholder = { Text(stringResource(R.string.config_comma_separated), color = TextSecondary.copy(alpha = 0.5f)) }, singleLine = true, colors = configFieldColors(), modifier = Modifier.fillMaxWidth(), @@ -375,6 +379,7 @@ private fun DirectoryField( ) { val context = LocalContext.current val scope = rememberCoroutineScope() + val internalStorageOnlyMsg = stringResource(R.string.config_internal_storage_only) val launcher = rememberLauncherForActivityResult( contract = ActivityResultContracts.OpenDocumentTree() @@ -389,7 +394,7 @@ private fun DirectoryField( onValueChanged(path) } else { scope.launch { - snackbarHostState?.showSnackbar("Internal storage only") + snackbarHostState?.showSnackbar(internalStorageOnlyMsg) } } } @@ -404,7 +409,7 @@ private fun DirectoryField( IconButton(onClick = { launcher.launch(null) }) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_folder_open), - contentDescription = "Browse", + contentDescription = stringResource(R.string.config_browse), tint = NeonCyan, ) } @@ -413,7 +418,7 @@ private fun DirectoryField( modifier = Modifier.fillMaxWidth(), ) Text( - "Internal storage only for SAF picker", + stringResource(R.string.config_internal_storage_saf), style = MaterialTheme.typography.labelSmall, color = TextSecondary.copy(alpha = 0.6f), modifier = Modifier.padding(start = 16.dp, top = 2.dp), @@ -455,7 +460,7 @@ private fun CalendarField( null } } - val displayText = if (field.value.isEmpty()) "Pick each time" + val displayText = if (field.value.isEmpty()) stringResource(R.string.config_pick_each_time) else resolvedName ?: field.value Row( @@ -490,7 +495,7 @@ private fun CalendarField( } Icon( painter = painterResource(LucideR.drawable.lucide_ic_chevron_right), - contentDescription = "Select", + contentDescription = stringResource(R.string.config_select), tint = if (enabled) NeonCyan else NeonCyan.copy(alpha = 0.3f), modifier = Modifier.size(20.dp), ) @@ -499,12 +504,12 @@ private fun CalendarField( if (showDialog && calendars.isNotEmpty()) { androidx.compose.material3.AlertDialog( onDismissRequest = { showDialog = false }, - title = { Text("Select Calendar") }, + title = { Text(stringResource(R.string.config_select_calendar)) }, text = { Column { // "Pick each time" option to clear the fixed setting Text( - "Pick each time", + stringResource(R.string.config_pick_each_time), style = MaterialTheme.typography.bodyMedium, color = NeonCyan, modifier = Modifier @@ -535,7 +540,7 @@ private fun CalendarField( confirmButton = {}, dismissButton = { TextButton(onClick = { showDialog = false }) { - Text("Cancel", color = TextSecondary) + Text(stringResource(R.string.config_cancel), color = TextSecondary) } }, ) diff --git a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt index c53802c4e7..9ebe0ccc14 100644 --- a/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt +++ b/android/backend/config/src/main/java/io/clawdroid/backend/config/ConfigSectionListScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.runtime.getValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.clawdroid.core.ui.theme.DeepBlack import io.clawdroid.core.ui.theme.GlassBorder @@ -58,7 +59,7 @@ fun ConfigSectionListScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("Backend Config") }, + title = { Text(stringResource(R.string.config_backend_config)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent, ), @@ -66,7 +67,7 @@ fun ConfigSectionListScreen( IconButton(onClick = onNavigateBack) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_arrow_left), - contentDescription = "Back", + contentDescription = stringResource(R.string.config_back), tint = TextSecondary, ) } @@ -107,7 +108,7 @@ fun ConfigSectionListScreen( contentColor = DeepBlack, ), ) { - Text("Retry") + Text(stringResource(R.string.config_retry)) } } } @@ -134,7 +135,7 @@ fun ConfigSectionListScreen( contentColor = DeepBlack, ), ) { - Text("Connection Settings") + Text(stringResource(R.string.config_connection_settings)) } Spacer(Modifier.height(8.dp)) Button( @@ -144,7 +145,7 @@ fun ConfigSectionListScreen( contentColor = NeonCyan, ), ) { - Text("Retry") + Text(stringResource(R.string.config_retry)) } } } @@ -193,14 +194,14 @@ private fun SectionCard( color = TextPrimary, ) Text( - "${section.fieldCount} fields", + stringResource(R.string.config_fields_count, section.fieldCount), style = MaterialTheme.typography.bodySmall, color = TextSecondary, ) } Icon( painter = painterResource(LucideR.drawable.lucide_ic_chevron_right), - contentDescription = "Open", + contentDescription = stringResource(R.string.config_open), tint = TextSecondary, ) } diff --git a/android/backend/config/src/main/res/values-ja/strings.xml b/android/backend/config/src/main/res/values-ja/strings.xml new file mode 100644 index 0000000000..0b1f7a0452 --- /dev/null +++ b/android/backend/config/src/main/res/values-ja/strings.xml @@ -0,0 +1,22 @@ + + 保存 + 表示 + 非表示 + カンマ区切りの値 + 内部ストレージのみ + SAFピッカーは内部ストレージのみ + カレンダーを選択 + 毎回選択する + キャンセル + 戻る + 参照 + 保存しました。 + 保存しました。サーバーを再起動中… + エラー: + バックエンド設定 + リトライ + 接続設定 + 開く + %1$d 個の項目 + 選択 + diff --git a/android/backend/config/src/main/res/values/strings.xml b/android/backend/config/src/main/res/values/strings.xml new file mode 100644 index 0000000000..adf4e1e150 --- /dev/null +++ b/android/backend/config/src/main/res/values/strings.xml @@ -0,0 +1,22 @@ + + Save + Show + Hide + Comma-separated values + Internal storage only + Internal storage only for SAF picker + Select Calendar + Pick each time + Cancel + Back + Browse + Saved. + Saved. Server restarting… + Error: + Backend Config + Retry + Connection Settings + Open + %1$d fields + Select + diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt index cf1e90526d..c691f9a762 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/remote/WebSocketClient.kt @@ -22,14 +22,15 @@ import kotlinx.coroutines.isActive import kotlinx.coroutines.launch import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json -import java.util.Locale +import android.content.Context class WebSocketClient( private val client: HttpClient, private val scope: CoroutineScope, private val clientId: String, - private val clientType: String = "main" + private val clientType: String = "main", + private val context: Context ) { private val _connectionState = MutableStateFlow(ConnectionState.DISCONNECTED) @@ -57,7 +58,7 @@ class WebSocketClient( _setupRequired.value = false val currentWsUrl = wsUrl val separator = if ('?' in currentWsUrl) '&' else '?' - val locale = Locale.getDefault().language + val locale = context.resources.configuration.locales[0].language val url = "${currentWsUrl}${separator}client_id=$clientId&client_type=$clientType&locale=$locale" client.webSocket(url) { session = this diff --git a/android/core/data/src/main/java/io/clawdroid/core/data/repository/AssistantConnectionImpl.kt b/android/core/data/src/main/java/io/clawdroid/core/data/repository/AssistantConnectionImpl.kt index cae5e0c7c7..6fa2ae46f7 100644 --- a/android/core/data/src/main/java/io/clawdroid/core/data/repository/AssistantConnectionImpl.kt +++ b/android/core/data/src/main/java/io/clawdroid/core/data/repository/AssistantConnectionImpl.kt @@ -1,5 +1,6 @@ package io.clawdroid.core.data.repository +import android.content.Context import android.util.Log import io.ktor.client.HttpClient import io.clawdroid.core.data.remote.WebSocketClient @@ -25,12 +26,13 @@ import java.util.UUID typealias ToolRequestCallback = suspend (ToolRequest) -> String class AssistantConnectionImpl( - private val httpClient: HttpClient + private val httpClient: HttpClient, + private val context: Context ) : AssistantConnection { private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO) private val clientId = UUID.randomUUID().toString() - private val wsClient = WebSocketClient(httpClient, scope, clientId, "assistant") + private val wsClient = WebSocketClient(httpClient, scope, clientId, "assistant", context = context) private val json = Json { ignoreUnknownKeys = true } private val _messages = MutableSharedFlow(extraBufferCapacity = 64) diff --git a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/component/MessageInput.kt b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/component/MessageInput.kt index a5eead431c..0f62adab5d 100644 --- a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/component/MessageInput.kt +++ b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/component/MessageInput.kt @@ -21,6 +21,7 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.Color import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import io.clawdroid.core.ui.theme.DeepBlack import io.clawdroid.core.ui.theme.GlassBorder @@ -29,6 +30,7 @@ import io.clawdroid.core.ui.theme.NeonCyan import io.clawdroid.core.ui.theme.TextPrimary import io.clawdroid.core.ui.theme.TextSecondary import io.clawdroid.core.ui.theme.TextTertiary +import io.clawdroid.feature.chat.R import com.composables.icons.lucide.R as LucideR @Composable @@ -60,21 +62,21 @@ fun MessageInput( IconButton(onClick = onCameraClick) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_camera), - contentDescription = "Camera", + contentDescription = stringResource(R.string.input_camera), tint = TextSecondary ) } IconButton(onClick = onGalleryClick) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_image), - contentDescription = "Gallery", + contentDescription = stringResource(R.string.input_gallery), tint = TextSecondary ) } IconButton(onClick = onMicClick) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_mic), - contentDescription = "Voice", + contentDescription = stringResource(R.string.input_voice), tint = TextSecondary ) } @@ -82,7 +84,7 @@ fun MessageInput( value = text, onValueChange = onTextChanged, modifier = Modifier.weight(1f), - placeholder = { Text("Message...", color = TextTertiary) }, + placeholder = { Text(stringResource(R.string.input_placeholder), color = TextTertiary) }, maxLines = 4, shape = RoundedCornerShape(24.dp), colors = OutlinedTextFieldDefaults.colors( @@ -106,7 +108,7 @@ fun MessageInput( ) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_send_horizontal), - contentDescription = "Send" + contentDescription = stringResource(R.string.input_send) ) } } diff --git a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/ChatScreen.kt b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/ChatScreen.kt index 41dffd6dd4..098648e4e2 100644 --- a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/ChatScreen.kt +++ b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/screen/ChatScreen.kt @@ -35,6 +35,7 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.core.content.FileProvider import io.clawdroid.core.domain.model.ImageAttachment import io.clawdroid.core.ui.theme.DeepBlack @@ -44,6 +45,7 @@ import io.clawdroid.core.ui.theme.GradientPurple import io.clawdroid.core.ui.theme.TextSecondary import io.clawdroid.feature.chat.ChatEvent import io.clawdroid.feature.chat.ChatViewModel +import io.clawdroid.feature.chat.R import com.composables.icons.lucide.R as LucideR import io.clawdroid.feature.chat.component.ConnectionBanner import io.clawdroid.feature.chat.component.ImagePreviewRow @@ -227,7 +229,7 @@ fun ChatScreen( containerColor = Color.Transparent, topBar = { TopAppBar( - title = { Text("ClawDroid") }, + title = { Text(stringResource(R.string.chat_title)) }, colors = TopAppBarDefaults.topAppBarColors( containerColor = Color.Transparent ), @@ -235,7 +237,7 @@ fun ChatScreen( IconButton(onClick = onNavigateToSettings) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_settings), - contentDescription = "Settings", + contentDescription = stringResource(R.string.chat_settings), tint = TextSecondary ) } diff --git a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/voice/VoiceModeOverlay.kt b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/voice/VoiceModeOverlay.kt index 231cb44757..75bb032d19 100644 --- a/android/feature/chat/src/main/java/io/clawdroid/feature/chat/voice/VoiceModeOverlay.kt +++ b/android/feature/chat/src/main/java/io/clawdroid/feature/chat/voice/VoiceModeOverlay.kt @@ -36,10 +36,12 @@ import androidx.compose.ui.graphics.Brush import androidx.compose.ui.graphics.Color import androidx.lifecycle.compose.LocalLifecycleOwner import androidx.compose.ui.res.painterResource +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.unit.dp import androidx.compose.ui.viewinterop.AndroidView import io.clawdroid.core.domain.model.VoicePhase +import io.clawdroid.feature.chat.R import io.clawdroid.core.ui.theme.DeepBlack import io.clawdroid.core.ui.theme.GradientCyan import io.clawdroid.core.ui.theme.GradientPurple @@ -96,7 +98,7 @@ fun VoiceModeOverlay( ) { Icon( painter = painterResource(LucideR.drawable.lucide_ic_x), - contentDescription = "Close", + contentDescription = stringResource(R.string.voice_close), modifier = Modifier.size(28.dp), tint = TextSecondary ) @@ -199,7 +201,7 @@ fun VoiceModeOverlay( if (state.isCameraActive) LucideR.drawable.lucide_ic_camera_off else LucideR.drawable.lucide_ic_camera ), - contentDescription = if (state.isCameraActive) "Turn off camera" else "Turn on camera", + contentDescription = stringResource(if (state.isCameraActive) R.string.voice_camera_off else R.string.voice_camera_on), modifier = Modifier.size(28.dp), tint = if (state.isCameraActive) GradientCyan else TextSecondary ) @@ -209,12 +211,13 @@ fun VoiceModeOverlay( } } +@Composable private fun phaseLabel(phase: VoicePhase): String = when (phase) { VoicePhase.IDLE -> "" - VoicePhase.LISTENING -> "Listening..." - VoicePhase.PAUSED -> "Paused" - VoicePhase.SENDING -> "Sending..." - VoicePhase.THINKING -> "Thinking..." - VoicePhase.SPEAKING -> "Speaking..." - VoicePhase.ERROR -> "Error" + VoicePhase.LISTENING -> stringResource(R.string.voice_listening) + VoicePhase.PAUSED -> stringResource(R.string.voice_paused) + VoicePhase.SENDING -> stringResource(R.string.voice_sending) + VoicePhase.THINKING -> stringResource(R.string.voice_thinking) + VoicePhase.SPEAKING -> stringResource(R.string.voice_speaking) + VoicePhase.ERROR -> stringResource(R.string.voice_error) } diff --git a/android/feature/chat/src/main/res/values-ja/strings.xml b/android/feature/chat/src/main/res/values-ja/strings.xml index 372ae80ce1..bb8358a4a4 100644 --- a/android/feature/chat/src/main/res/values-ja/strings.xml +++ b/android/feature/chat/src/main/res/values-ja/strings.xml @@ -21,4 +21,26 @@ 音声 開く 戻る + + + 閉じる + カメラをオン + カメラをオフ + 聞いています… + 一時停止 + 送信中… + 思考中… + 話しています… + エラー + + + カメラ + ギャラリー + 音声 + 送信 + メッセージ… + + + ClawDroid + 設定 diff --git a/android/feature/chat/src/main/res/values/strings.xml b/android/feature/chat/src/main/res/values/strings.xml index 937a1f5f5d..55580361bc 100644 --- a/android/feature/chat/src/main/res/values/strings.xml +++ b/android/feature/chat/src/main/res/values/strings.xml @@ -21,4 +21,26 @@ Voice Open Back + + + Close + Turn on camera + Turn off camera + Listening… + Paused + Sending… + Thinking… + Speaking… + Error + + + Camera + Gallery + Voice + Send + Message… + + + ClawDroid + Settings diff --git a/pkg/agent/loop.go b/pkg/agent/loop.go index 61abecfbb2..e3f1824811 100644 --- a/pkg/agent/loop.go +++ b/pkg/agent/loop.go @@ -455,6 +455,18 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) return al.processSystemMessage(ctx, msg) } + // Extract input_mode and locale from metadata + inputMode := "text" + locale := "en" + if msg.Metadata != nil { + if mode, ok := msg.Metadata["input_mode"]; ok && mode != "" { + inputMode = mode + } + if l, ok := msg.Metadata["locale"]; ok && l != "" { + locale = i18n.NormalizeLocale(l) + } + } + // Check request rate limit if err := al.rateLimiter.checkRequest(); err != nil { logger.WarnCF("agent", "Request rate limited", @@ -462,26 +474,14 @@ func (al *AgentLoop) processMessage(ctx context.Context, msg bus.InboundMessage) "channel": msg.Channel, "sender_id": msg.SenderID, }) - return fmt.Sprintf("Rate limited: %v. Please try again later.", err), nil + return i18n.Tf(locale, "agent.rate_limited", err), nil } // Check for commands - if response, handled := al.handleCommand(ctx, msg); handled { + if response, handled := al.handleCommand(ctx, msg, locale); handled { return response, nil } - // Extract input_mode and locale from metadata - inputMode := "text" - locale := "en" - if msg.Metadata != nil { - if mode, ok := msg.Metadata["input_mode"]; ok && mode != "" { - inputMode = mode - } - if l, ok := msg.Metadata["locale"]; ok && l != "" { - locale = i18n.NormalizeLocale(l) - } - } - // Send migration notice once on first message al.migrationOnce.Do(func() { if al.userStore != nil && al.userStore.NeedsMigration() { @@ -937,7 +937,7 @@ func (al *AgentLoop) runLLMIteration(ctx context.Context, messages []providers.M }) toolResultMsg := providers.Message{ Role: "tool", - Content: fmt.Sprintf("Rate limited: %v", err), + Content: i18n.Tf(locale, "agent.rate_limited_tool", err), ToolCallID: tc.ID, } messages = append(messages, toolResultMsg) @@ -1361,7 +1361,7 @@ func (al *AgentLoop) estimateTokens(messages []providers.Message) int { return totalChars * 2 / 5 } -func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) (string, bool) { +func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage, locale string) (string, bool) { content := strings.TrimSpace(msg.Content) if !strings.HasPrefix(content, "/") { return "", false @@ -1378,41 +1378,40 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) switch cmd { case "/show": if len(args) < 1 { - return "Usage: /show [model|channel]", true + return i18n.T(locale, "cmd.show.usage"), true } switch args[0] { case "model": - return fmt.Sprintf("Current model: %s", al.model), true + return i18n.Tf(locale, "agent.cmd.show.model", al.model), true case "channel": - return fmt.Sprintf("Current channel: %s", msg.Channel), true + return i18n.Tf(locale, "agent.cmd.show.channel", msg.Channel), true default: - return fmt.Sprintf("Unknown show target: %s", args[0]), true + return i18n.Tf(locale, "agent.cmd.show.unknown", args[0]), true } case "/list": if len(args) < 1 { - return "Usage: /list [models|channels]", true + return i18n.T(locale, "cmd.list.usage"), true } switch args[0] { case "models": - // TODO: Fetch available models dynamically if possible - return "Available models: glm-4.7, claude-3-5-sonnet, gpt-4o (configured in config.json/env)", true + return i18n.T(locale, "agent.cmd.list.models"), true case "channels": if al.channelManager == nil { - return "Channel manager not initialized", true + return i18n.T(locale, "agent.cmd.channel_mgr_error"), true } channels := al.channelManager.GetEnabledChannels() if len(channels) == 0 { - return "No channels enabled", true + return i18n.T(locale, "agent.cmd.list.no_channels"), true } - return fmt.Sprintf("Enabled channels: %s", strings.Join(channels, ", ")), true + return i18n.Tf(locale, "agent.cmd.list.channels", strings.Join(channels, ", ")), true default: - return fmt.Sprintf("Unknown list target: %s", args[0]), true + return i18n.Tf(locale, "agent.cmd.list.unknown", args[0]), true } case "/switch": if len(args) < 3 || args[1] != "to" { - return "Usage: /switch [model|channel] to ", true + return i18n.T(locale, "agent.cmd.switch.usage"), true } target := args[0] value := args[2] @@ -1421,23 +1420,17 @@ func (al *AgentLoop) handleCommand(ctx context.Context, msg bus.InboundMessage) case "model": oldModel := al.model al.model = value - return fmt.Sprintf("Switched model from %s to %s", oldModel, value), true + return i18n.Tf(locale, "agent.cmd.switch.model", oldModel, value), true case "channel": - // This changes the 'default' channel for some operations, or effectively redirects output? - // For now, let's just validate if the channel exists if al.channelManager == nil { - return "Channel manager not initialized", true + return i18n.T(locale, "agent.cmd.channel_mgr_error"), true } if _, exists := al.channelManager.GetChannel(value); !exists && value != "cli" { - return fmt.Sprintf("Channel '%s' not found or not enabled", value), true + return i18n.Tf(locale, "agent.cmd.switch.not_found", value), true } - - // If message came from CLI, maybe we want to redirect CLI output to this channel? - // That would require state persistence about "redirected channel" - // For now, just acknowledged. - return fmt.Sprintf("Switched target channel to %s (Note: this currently only validates existence)", value), true + return i18n.Tf(locale, "agent.cmd.switch.channel", value), true default: - return fmt.Sprintf("Unknown switch target: %s", target), true + return i18n.Tf(locale, "agent.cmd.switch.unknown", target), true } } diff --git a/pkg/channels/telegram.go b/pkg/channels/telegram.go index 43d25f4c30..915ca7dfd9 100644 --- a/pkg/channels/telegram.go +++ b/pkg/channels/telegram.go @@ -17,6 +17,7 @@ import ( "github.com/KarakuriAgent/clawdroid/pkg/bus" "github.com/KarakuriAgent/clawdroid/pkg/config" + "github.com/KarakuriAgent/clawdroid/pkg/i18n" "github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/utils" ) @@ -307,7 +308,9 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes _, thinkCancel := context.WithTimeout(ctx, 5*time.Minute) c.stopThinking.Store(chatIDStr, &thinkingCancel{fn: thinkCancel}) - pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), "Thinking... 💭")) + locale := localeFromMessage(*message) + + pMsg, err := c.bot.SendMessage(ctx, tu.Message(tu.ID(chatID), i18n.T(locale, "channel.thinking"))) if err == nil { pID := pMsg.MessageID c.placeholders.Store(chatIDStr, pID) @@ -324,6 +327,7 @@ func (c *TelegramChannel) handleMessage(ctx context.Context, message *telego.Mes "first_name": user.FirstName, "sender_name": senderName, "is_group": fmt.Sprintf("%t", message.Chat.Type != "private"), + "locale": locale, } c.HandleMessage(fmt.Sprintf("%d", user.ID), fmt.Sprintf("%d", chatID), content, mediaPaths, metadata) diff --git a/pkg/channels/telegram_commands.go b/pkg/channels/telegram_commands.go index ea512100a3..8a8a27b59f 100644 --- a/pkg/channels/telegram_commands.go +++ b/pkg/channels/telegram_commands.go @@ -2,10 +2,10 @@ package channels import ( "context" - "fmt" "strings" "github.com/KarakuriAgent/clawdroid/pkg/config" + "github.com/KarakuriAgent/clawdroid/pkg/i18n" "github.com/mymmrac/telego" ) @@ -36,14 +36,10 @@ func commandArgs(text string) string { return strings.TrimSpace(parts[1]) } func (c *cmd) Help(ctx context.Context, message telego.Message) error { - msg := `/start - Start the bot -/help - Show this help message -/show [model|channel] - Show current configuration -/list [models|channels] - List available options - ` + locale := localeFromMessage(message) _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: msg, + Text: i18n.T(locale, "cmd.help"), ReplyParameters: &telego.ReplyParameters{ MessageID: message.MessageID, }, @@ -52,9 +48,10 @@ func (c *cmd) Help(ctx context.Context, message telego.Message) error { } func (c *cmd) Start(ctx context.Context, message telego.Message) error { + locale := localeFromMessage(message) _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Hello! I am ClawDroid 🦞", + Text: i18n.T(locale, "cmd.start"), ReplyParameters: &telego.ReplyParameters{ MessageID: message.MessageID, }, @@ -63,11 +60,12 @@ func (c *cmd) Start(ctx context.Context, message telego.Message) error { } func (c *cmd) Show(ctx context.Context, message telego.Message) error { + locale := localeFromMessage(message) args := commandArgs(message.Text) if args == "" { _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /show [model|channel]", + Text: i18n.T(locale, "cmd.show.usage"), ReplyParameters: &telego.ReplyParameters{ MessageID: message.MessageID, }, @@ -78,12 +76,11 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { var response string switch args { case "model": - response = fmt.Sprintf("Current Model: %s", - c.config.LLM.Model) + response = i18n.Tf(locale, "cmd.show.model", c.config.LLM.Model) case "channel": - response = "Current Channel: telegram" + response = i18n.T(locale, "cmd.show.channel") default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'model' or 'channel'.", args) + response = i18n.Tf(locale, "cmd.show.unknown", args) } _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ @@ -96,11 +93,12 @@ func (c *cmd) Show(ctx context.Context, message telego.Message) error { return err } func (c *cmd) List(ctx context.Context, message telego.Message) error { + locale := localeFromMessage(message) args := commandArgs(message.Text) if args == "" { _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ ChatID: telego.ChatID{ID: message.Chat.ID}, - Text: "Usage: /list [models|channels]", + Text: i18n.T(locale, "cmd.list.usage"), ReplyParameters: &telego.ReplyParameters{ MessageID: message.MessageID, }, @@ -111,8 +109,7 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { var response string switch args { case "models": - response = fmt.Sprintf("Configured Model: %s\n\nTo change models, update config.json", - c.config.LLM.Model) + response = i18n.Tf(locale, "cmd.list.models", c.config.LLM.Model) case "channels": var enabled []string @@ -131,10 +128,10 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { if c.config.Channels.LINE.Enabled { enabled = append(enabled, "line") } - response = fmt.Sprintf("Enabled Channels:\n- %s", strings.Join(enabled, "\n- ")) + response = i18n.Tf(locale, "cmd.list.channels", strings.Join(enabled, "\n- ")) default: - response = fmt.Sprintf("Unknown parameter: %s. Try 'models' or 'channels'.", args) + response = i18n.Tf(locale, "cmd.list.unknown", args) } _, err := c.bot.SendMessage(ctx, &telego.SendMessageParams{ @@ -146,3 +143,10 @@ func (c *cmd) List(ctx context.Context, message telego.Message) error { }) return err } + +func localeFromMessage(message telego.Message) string { + if message.From != nil { + return i18n.NormalizeLocale(message.From.LanguageCode) + } + return "en" +} diff --git a/pkg/channels/websocket.go b/pkg/channels/websocket.go index 945d78f1d6..234145bcd3 100644 --- a/pkg/channels/websocket.go +++ b/pkg/channels/websocket.go @@ -12,6 +12,7 @@ import ( "github.com/KarakuriAgent/clawdroid/pkg/broadcast" "github.com/KarakuriAgent/clawdroid/pkg/bus" "github.com/KarakuriAgent/clawdroid/pkg/config" + "github.com/KarakuriAgent/clawdroid/pkg/i18n" "github.com/KarakuriAgent/clawdroid/pkg/logger" "github.com/KarakuriAgent/clawdroid/pkg/tools" "github.com/google/uuid" @@ -254,7 +255,7 @@ func (c *WebSocketChannel) handleWS(w http.ResponseWriter, r *http.Request) { if c.configPath != "" { if _, err := os.Stat(c.configPath); os.IsNotExist(err) { setupMsg := wsOutgoing{ - Content: "Configuration required", + Content: i18n.T(locale, "channel.config_required"), Type: "setup_required", } if data, err := json.Marshal(setupMsg); err == nil { diff --git a/pkg/i18n/messages_agent.go b/pkg/i18n/messages_agent.go index 49e51e23d4..dc81697ac9 100644 --- a/pkg/i18n/messages_agent.go +++ b/pkg/i18n/messages_agent.go @@ -5,11 +5,15 @@ func init() { "agent.migration_notice": "USER.md found. User management has migrated to a new format (users.json).\nAsk in chat to migrate, or update manually.\n\nFor manual update, create ~/.clawdroid/data/users.json in this format:\n```json\n{\n \"users\": [{\n \"name\": \"Your Name\",\n \"channels\": { \"websocket\": [\"default\"] },\n \"memo\": [\"Preferred language: English\"]\n }]\n}\n```", "agent.context_window_warning": "⚠️ Context window exceeded. Compressing history and retrying...", "agent.memory_threshold_warning": "⚠️ Memory threshold reached. Optimizing conversation history...", + "agent.rate_limited": "Rate limited: %s. Please try again later.", + "agent.rate_limited_tool": "Rate limited: %s", }) register("ja", map[string]string{ "agent.migration_notice": "USER.md が見つかりました。ユーザー管理が新しい形式(users.json)に変わりました。\nチャットで移行を依頼するか、手動で更新してください。\n\n手動更新の場合、以下の形式で ~/.clawdroid/data/users.json を作成:\n```json\n{\n \"users\": [{\n \"name\": \"あなたの名前\",\n \"channels\": { \"websocket\": [\"default\"] },\n \"memo\": [\"Preferred language: Japanese\"]\n }]\n}\n```", "agent.context_window_warning": "⚠️ コンテキストウィンドウの上限を超えました。履歴を圧縮してリトライしています...", "agent.memory_threshold_warning": "⚠️ メモリしきい値に達しました。会話履歴を最適化しています...", + "agent.rate_limited": "レート制限中: %s。しばらくしてからお試しください。", + "agent.rate_limited_tool": "レート制限中: %s", }) } diff --git a/pkg/i18n/messages_channel.go b/pkg/i18n/messages_channel.go new file mode 100644 index 0000000000..253b573a2a --- /dev/null +++ b/pkg/i18n/messages_channel.go @@ -0,0 +1,83 @@ +package i18n + +func init() { + register("en", map[string]string{ + // Telegram + "channel.thinking": "Thinking... 💭", + + // WebSocket + "channel.config_required": "Configuration required", + + // Telegram commands (/help, /start, /show, /list) + "cmd.help": `/start - Start the bot +/help - Show this help message +/show [model|channel] - Show current configuration +/list [models|channels] - List available options +`, + "cmd.start": "Hello! I am ClawDroid 🦞", + "cmd.show.usage": "Usage: /show [model|channel]", + "cmd.show.model": "Current Model: %s", + "cmd.show.channel": "Current Channel: telegram", + "cmd.show.unknown": "Unknown parameter: %s. Try 'model' or 'channel'.", + "cmd.list.usage": "Usage: /list [models|channels]", + "cmd.list.models": "Configured Model: %s\n\nTo change models, update config.json", + "cmd.list.channels": "Enabled Channels:\n- %s", + "cmd.list.unknown": "Unknown parameter: %s. Try 'models' or 'channels'.", + + // Agent loop commands (/show, /list, /switch) + // cmd.show.usage and cmd.list.usage are shared with Telegram commands + "agent.cmd.show.model": "Current model: %s", + "agent.cmd.show.channel": "Current channel: %s", + "agent.cmd.show.unknown": "Unknown show target: %s", + "agent.cmd.list.models": "Available models: glm-4.7, claude-3-5-sonnet, gpt-4o (configured in config.json/env)", + "agent.cmd.list.no_channels": "No channels enabled", + "agent.cmd.list.channels": "Enabled channels: %s", + "agent.cmd.list.unknown": "Unknown list target: %s", + "agent.cmd.switch.usage": "Usage: /switch [model|channel] to ", + "agent.cmd.switch.model": "Switched model from %s to %s", + "agent.cmd.switch.channel": "Switched target channel to %s (Note: this currently only validates existence)", + "agent.cmd.switch.not_found": "Channel '%s' not found or not enabled", + "agent.cmd.switch.unknown": "Unknown switch target: %s", + "agent.cmd.channel_mgr_error": "Channel manager not initialized", + }) + + register("ja", map[string]string{ + // Telegram + "channel.thinking": "考え中... 💭", + + // WebSocket + "channel.config_required": "設定が必要です", + + // Telegram commands + "cmd.help": `/start - ボットを開始 +/help - このヘルプメッセージを表示 +/show [model|channel] - 現在の設定を表示 +/list [models|channels] - 利用可能なオプションを一覧表示 +`, + "cmd.start": "こんにちは!ClawDroid です 🦞", + "cmd.show.usage": "使い方: /show [model|channel]", + "cmd.show.model": "現在のモデル: %s", + "cmd.show.channel": "現在のチャンネル: telegram", + "cmd.show.unknown": "不明なパラメータ: %s。'model' か 'channel' を指定してください。", + "cmd.list.usage": "使い方: /list [models|channels]", + "cmd.list.models": "設定済みモデル: %s\n\nモデルを変更するには config.json を更新してください", + "cmd.list.channels": "有効なチャンネル:\n- %s", + "cmd.list.unknown": "不明なパラメータ: %s。'models' か 'channels' を指定してください。", + + // Agent loop commands + // cmd.show.usage and cmd.list.usage are shared with Telegram commands + "agent.cmd.show.model": "現在のモデル: %s", + "agent.cmd.show.channel": "現在のチャンネル: %s", + "agent.cmd.show.unknown": "不明な表示対象: %s", + "agent.cmd.list.models": "利用可能なモデル: glm-4.7, claude-3-5-sonnet, gpt-4o(config.json/env で設定)", + "agent.cmd.list.no_channels": "有効なチャンネルはありません", + "agent.cmd.list.channels": "有効なチャンネル: %s", + "agent.cmd.list.unknown": "不明な一覧対象: %s", + "agent.cmd.switch.usage": "使い方: /switch [model|channel] to <名前>", + "agent.cmd.switch.model": "モデルを %s から %s に切り替えました", + "agent.cmd.switch.channel": "対象チャンネルを %s に切り替えました(注: 現在は存在確認のみ)", + "agent.cmd.switch.not_found": "チャンネル '%s' が見つからないか有効ではありません", + "agent.cmd.switch.unknown": "不明な切り替え対象: %s", + "agent.cmd.channel_mgr_error": "チャンネルマネージャーが初期化されていません", + }) +}