diff --git a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt index 2a24f7b9..b01b35dd 100644 --- a/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt +++ b/app/src/androidTest/java/com/m57/hermescontrol/ui/chat/TodoTaskCardTest.kt @@ -1,12 +1,15 @@ package com.m57.hermescontrol.ui.chat +import android.content.Context import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithText +import androidx.test.core.app.ApplicationProvider import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.filters.MediumTest +import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.HermesControlTheme import org.junit.Rule import org.junit.Test @@ -47,7 +50,7 @@ class TodoTaskCardTest { } composeTestRule.onNodeWithTag("todo_task_card").assertIsDisplayed() - composeTestRule.onNodeWithText("Tasks 1/3").assertIsDisplayed() + composeTestRule.onNodeWithText(taskCount(1, 3)).assertIsDisplayed() composeTestRule.onNodeWithText("Finished").assertIsDisplayed() composeTestRule.onNodeWithText("Working").assertIsDisplayed() composeTestRule.onNodeWithText("Later").assertIsDisplayed() @@ -94,7 +97,7 @@ class TodoTaskCardTest { } } - composeTestRule.onNodeWithText("Tasks 1/4").assertIsDisplayed() + composeTestRule.onNodeWithText(taskCount(1, 4)).assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Completed").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Cancelled").assertIsDisplayed() composeTestRule.onNodeWithText("Running alias").assertIsDisplayed() @@ -119,4 +122,12 @@ class TodoTaskCardTest { composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist() } + + private fun taskCount( + completed: Int, + total: Int, + ): String { + val context = ApplicationProvider.getApplicationContext() + return context.getString(R.string.todo_task_count, completed, total) + } } diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt index 4129370b..3d570202 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/GatewayFileClient.kt @@ -44,12 +44,20 @@ object GatewayFileClient { if (trimmedBase.isBlank()) return null val norm = normalizePath(path) ?: return null val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20") - return if (token.isNotBlank()) { - val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") - "$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken" - } else { - "$trimmedBase$DOWNLOAD_PATH?path=$encPath" - } + // Gated (basic-auth) dashboards authenticate the download via the + // session cookie in the shared CookieJar — a `?token=` query param is + // not consulted (the WS ticket the app stores is meaningless here), so + // don't stamp a bogus token into the URL. Loopback mode still needs it: + // there the query token IS the auth (auth_middleware compares it to the + // injected _SESSION_TOKEN). + val authQuery = + if (token.isNotBlank() && !AuthManager.isGatedMode()) { + val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") + "&token=$encToken" + } else { + "" + } + return "$trimmedBase$DOWNLOAD_PATH?path=$encPath$authQuery" } /** diff --git a/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt b/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt index 4f0eaa82..e6cd43d0 100644 --- a/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt +++ b/app/src/main/java/com/m57/hermescontrol/data/remote/PersistentCookieJar.kt @@ -99,6 +99,27 @@ class PersistentCookieJar( if (idx >= 0) bucket[idx] = cookie else bucket.add(cookie) } } + // Session cookies are host-agnostic: mirror every dashboard session + // cookie into the WILDCARD_HOST bucket (the same place the legacy + // migration buckets blank-domain cookies) so the authenticated + // session survives a host switch. Without this, logging in over one + // reachable address (e.g. Tailscale 100.x) and later talking to the + // same dashboard over another (e.g. LAN 192.168.x) loses the cookie + // entirely — every authenticated download / API call then 401s even + // though the WebSocket (dialed on the current host) keeps working. + val sessionCookies = cookies.filter { isSessionCookie(it) } + if (sessionCookies.isNotEmpty()) { + val wildcard = + cache + .getOrPut(serverId) { ConcurrentHashMap() } + .getOrPut(WILDCARD_HOST) { mutableListOf() } + synchronized(wildcard) { + for (cookie in sessionCookies) { + val idx = wildcard.indexOfFirst { it.name == cookie.name && it.path == cookie.path } + if (idx >= 0) wildcard[idx] = cookie else wildcard.add(cookie) + } + } + } // Persist the whole server scope asynchronously. persist(serverId) } @@ -108,11 +129,22 @@ class PersistentCookieJar( val hostKey = url.host val hosts = cache[serverId] ?: return emptyList() val now = System.currentTimeMillis() + // The WILDCARD bucket holds session cookies that must be usable on + // ANY host of this server scope (the dashboard is reachable over + // several IPs). They are exempted from the host-only domain match — + // OkHttp would reject them for a different host — but never sent to + // other servers because the scope itself is per-server. val buckets = listOfNotNull(hosts[hostKey], hosts[WILDCARD_HOST]) + val seen = mutableSetOf>() synchronized(buckets) { return buckets.flatMap { bucket -> synchronized(bucket) { - bucket.filter { it.matches(url) && (it.expiresAt > now || isSessionCookie(it)) } + bucket.filter { cookie -> + val alive = cookie.expiresAt > now || isSessionCookie(cookie) + val usable = isSessionCookie(cookie) || cookie.matches(url) + val key = cookie.name to cookie.path + alive && usable && seen.add(key) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt index a92a06b2..7c9323c2 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/analytics/AnalyticsScreen.kt @@ -171,13 +171,13 @@ private fun AnalyticsContent( .padding(horizontal = 16.dp, vertical = 8.dp), ) { Text( - text = "Most Used Components", + text = stringResource(R.string.analytics_most_used_components), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Overview of your most active models, skills, and tools.", + text = stringResource(R.string.analytics_overview_desc), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -221,7 +221,7 @@ private fun AnalyticsContent( } else { item { Text( - text = "No model data available", + text = stringResource(R.string.analytics_no_model_data), modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -243,7 +243,7 @@ private fun AnalyticsContent( } else { item { Text( - text = "No skill data available", + text = stringResource(R.string.analytics_no_skill_data), modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, @@ -260,7 +260,7 @@ private fun AnalyticsContent( } else { item { Text( - text = "No tool data available", + text = stringResource(R.string.analytics_no_tool_data), modifier = Modifier.padding(16.dp), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt index a4e46e0f..77df1e8d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/ChannelsScreen.kt @@ -183,7 +183,7 @@ fun ChannelsScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search channels…", + placeholder = stringResource(R.string.channels_search_placeholder), ) } @@ -350,7 +350,7 @@ internal fun ConfigureForm( } field.expiresAt?.let { expiry -> Text( - text = "Expires: ${formatTimestamp(expiry)}", + text = stringResource(R.string.channels_expiry_format, formatTimestamp(expiry)), color = MaterialTheme.colorScheme.onSurfaceVariant, style = MaterialTheme.typography.labelSmall, ) @@ -438,7 +438,7 @@ private fun AdminSection(envPath: String) { ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Credentials are written to $envPath", + text = stringResource(R.string.channels_creds_path_format, envPath), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt index 3e55c3a8..9f2941ec 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/PlatformCard.kt @@ -226,7 +226,7 @@ internal fun PlatformCard( } else { Icon( imageVector = Icons.Filled.Delete, - contentDescription = "Remove platform", + contentDescription = stringResource(R.string.platform_remove_desc), tint = MaterialTheme.colorScheme.error, ) } @@ -246,7 +246,7 @@ internal fun PlatformCard( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "Set up with QR", + text = stringResource(R.string.platform_setup_qr), style = MaterialTheme.typography.labelSmall, ) } @@ -267,8 +267,8 @@ internal fun PlatformCard( if (showRemoveConfirm) { AlertDialog( onDismissRequest = { showRemoveConfirm = false }, - title = { Text("Remove ${platform.name}?") }, - text = { Text("This will permanently remove this platform's configuration.") }, + title = { Text(stringResource(R.string.platform_remove_confirm_title, platform.name)) }, + text = { Text(stringResource(R.string.platform_remove_confirm_text)) }, confirmButton = { TextButton( onClick = { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt index 5d21bbed..00373f62 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/channels/components/TelegramOnboardingDialog.kt @@ -28,7 +28,9 @@ import androidx.compose.runtime.getValue import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.channels.ChannelsUiState import com.m57.hermescontrol.ui.channels.OnboardingPhase @@ -87,12 +89,12 @@ internal fun TelegramOnboardingDialog( OnboardingPhase.WAITING -> { Text( - text = "Open Telegram and scan the QR code displayed in the app.", + text = stringResource(R.string.tg_onboarding_scan_hint), style = MaterialTheme.typography.bodyMedium, ) if (state.onboardingExpiresIn.isNotEmpty()) { Text( - text = "QR code expires in: ${state.onboardingExpiresIn}", + text = stringResource(R.string.tg_onboarding_qr_expires, state.onboardingExpiresIn), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -102,13 +104,13 @@ internal fun TelegramOnboardingDialog( OnboardingPhase.READY -> { state.onboardingBotUsername?.let { username -> Text( - text = "Connected as @$username", + text = stringResource(R.string.tg_onboarding_connected_as, username), style = MaterialTheme.typography.bodyMedium, ) } Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Allowed users:", + text = stringResource(R.string.tg_onboarding_allowed_users), style = MaterialTheme.typography.titleSmall, ) state.onboardingDetectedOwnerId?.let { ownerId -> @@ -146,12 +148,15 @@ internal fun TelegramOnboardingDialog( OutlinedTextField( value = state.onboardingNewAllowedId, onValueChange = onNewAllowedIdChange, - placeholder = { Text("Telegram user ID") }, + placeholder = { Text(stringResource(R.string.tg_onboarding_user_id_placeholder)) }, singleLine = true, modifier = Modifier.weight(1f), ) IconButton(onClick = onAddAllowedId) { - Icon(Icons.Filled.Add, contentDescription = "Add user ID") + Icon( + Icons.Filled.Add, + contentDescription = stringResource(R.string.tg_onboarding_add_user_id), + ) } } } @@ -165,7 +170,7 @@ internal fun TelegramOnboardingDialog( CircularProgressIndicator() Spacer(modifier = Modifier.height(8.dp)) Text( - text = "Saving configuration…", + text = stringResource(R.string.tg_onboarding_saving), style = MaterialTheme.typography.bodyMedium, ) } @@ -183,7 +188,7 @@ internal fun TelegramOnboardingDialog( onClick = onApply, enabled = state.onboardingAllowedIds.isNotEmpty(), ) { - Text("Save and restart") + Text(stringResource(R.string.tg_onboarding_save_restart)) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt index f71e0e3d..cf8b6688 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatBubble.kt @@ -518,7 +518,7 @@ internal fun InlineAttachment( } else { Icon( imageVector = Icons.Default.Download, - contentDescription = "Save ${attachment.name}", + contentDescription = stringResource(R.string.chat_save_attachment, attachment.name), tint = MaterialTheme.colorScheme.primary, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt index 00eb34c0..bb835db0 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatScreen.kt @@ -786,7 +786,7 @@ fun ChatScreen( if (state.showModelPicker) { ModelPickerDialog( providers = state.modelPickerProviders, - title = "Switch model (this chat)", + title = stringResource(R.string.chat_switch_model_title), isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(), pinnedModels = state.modelPickerPinned, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt index f156f8e8..2cbae12e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ChatViewModel.kt @@ -2968,8 +2968,11 @@ class ChatViewModel( if (items.isEmpty()) return val baseUrl = AuthManager.getBaseUrl() - val token = AuthManager.getToken() - if (baseUrl.isBlank() || token.isNullOrBlank()) return + if (baseUrl.isBlank()) return + // NOTE: no token gate here. Gated (basic-auth) dashboards download via + // the session cookie, and even loopback hosts can serve files with an + // empty query token — requiring a non-blank token dropped every MEDIA + // attachment on gate-auth connections where getToken() returns null. val existingUrls = current.attachments @@ -2978,7 +2981,7 @@ class ChatViewModel( .toSet() val newAttachments = items.mapNotNull { item -> - val url = GatewayFileClient.buildDownloadUrl(baseUrl, token, item.path) ?: return@mapNotNull null + val url = GatewayFileClient.buildDownloadUrl(baseUrl, "", item.path) ?: return@mapNotNull null if (url in existingUrls) return@mapNotNull null Attachment( uri = url, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt index d8948b2e..e9f8b68a 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/TodoTaskCard.kt @@ -32,9 +32,11 @@ import androidx.compose.ui.Modifier import androidx.compose.ui.draw.alpha import androidx.compose.ui.graphics.Color import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors @Composable @@ -76,7 +78,7 @@ internal fun TodoTaskCard( ) } Text( - text = "Tasks $completed/${items.size}", + text = stringResource(R.string.todo_task_count, completed, items.size), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.SemiBold, modifier = Modifier.weight(1f), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt index 9710d9ab..65580fef 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/ToolBubble.kt @@ -491,7 +491,7 @@ private fun ExpandedToolContent( // ── Duration footer ── view.durationLabel?.let { Text( - text = "Duration: $it", + text = stringResource(R.string.tool_duration, it), style = MaterialTheme.typography.labelSmall.copy( color = contentColor.copy(alpha = 0.5f), @@ -593,7 +593,7 @@ internal fun SecurityRiskChip( ) if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { Text( - text = "· redacted", + text = stringResource(R.string.tool_redacted), style = MaterialTheme.typography.labelSmall, color = chipColor.copy(alpha = 0.7f), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt index 5c366d4b..fdc316e1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ComposerToolbar.kt @@ -30,8 +30,10 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R /** * Bottom toolbar row for the chat composer. @@ -71,7 +73,7 @@ fun ComposerToolbar( ) { Icon( imageVector = Icons.Default.AttachFile, - contentDescription = "Attach file", + contentDescription = stringResource(R.string.chat_attach_file), tint = MaterialTheme.colorScheme.onSurfaceVariant, ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt index 14420e9b..ee12405f 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextDetailSheet.kt @@ -20,8 +20,10 @@ import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.ui.chat.ContextBreakdown import kotlin.math.min @@ -67,7 +69,7 @@ fun ContextDetailSheet( verticalArrangement = Arrangement.spacedBy(12.dp), ) { Text( - text = "Context window", + text = stringResource(R.string.context_window), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.SemiBold, ) @@ -117,15 +119,15 @@ fun ContextDetailSheet( ) // Breakdown rows - ContextRow(label = "Prompt (input)", value = breakdown.inputTokens) - ContextRow(label = "Completion (output)", value = breakdown.outputTokens) - ContextRow(label = "Cache read", value = breakdown.cacheReadTokens) - ContextRow(label = "Cache write", value = breakdown.cacheWriteTokens) + ContextRow(label = stringResource(R.string.context_prompt_input), value = breakdown.inputTokens) + ContextRow(label = stringResource(R.string.context_completion_output), value = breakdown.outputTokens) + ContextRow(label = stringResource(R.string.context_cache_read), value = breakdown.cacheReadTokens) + ContextRow(label = stringResource(R.string.context_cache_write), value = breakdown.cacheWriteTokens) ContextRow(label = "Reasoning", value = breakdown.reasoningTokens) ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true) Text( - text = "Prompt tokens reflect total context used by this session so far.", + text = stringResource(R.string.context_tokens_note), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 16.dp), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt index 35185393..b9360a12 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/ContextUsageChip.kt @@ -15,8 +15,10 @@ import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.theme.LocalHermesStatusColors import kotlin.math.min @@ -103,7 +105,7 @@ fun ContextUsageChip( ) { if (compressionCount != null && compressionCount > 0) { Text( - text = "compressed ×$compressionCount", + text = stringResource(R.string.context_compressed, compressionCount), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.tertiary, maxLines = 1, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt index a23b7d84..b06a6a63 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/GifImageThumbnail.kt @@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.layout.ContentScale +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp import coil.compose.AsyncImage +import com.m57.hermescontrol.R @Composable fun GifImageThumbnail( @@ -125,7 +127,7 @@ fun GifImageThumbnail( ) { Icon( imageVector = Icons.Filled.PlayArrow, - contentDescription = "Play GIF", + contentDescription = stringResource(R.string.gif_play), modifier = Modifier.size(32.dp), tint = MaterialTheme.colorScheme.onSurface, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt index 030ab673..a7ea4335 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/InlineVideoPlayer.kt @@ -21,8 +21,10 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R /** * Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722). @@ -57,7 +59,7 @@ fun InlineVideoPlayer( Box(contentAlignment = Alignment.Center) { Icon( imageVector = Icons.Filled.PlayArrow, - contentDescription = "Play Video", + contentDescription = stringResource(R.string.video_play), tint = MaterialTheme.colorScheme.inverseOnSurface, modifier = Modifier.size(36.dp), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt index 115fea27..8783e25c 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/MessageCards.kt @@ -153,7 +153,7 @@ fun ReasoningCard( ) Spacer(Modifier.width(8.dp)) Text( - text = "Reasoning · $stepCount steps", + text = stringResource(R.string.message_reasoning_steps, stepCount), style = MaterialTheme.typography.labelMedium, color = MaterialTheme.colorScheme.onSurface, modifier = Modifier.weight(1f), @@ -491,7 +491,7 @@ fun ClarifyBubble( OutlinedTextField( value = typedText, onValueChange = { typedText = it }, - label = { Text("Your response") }, + label = { Text(stringResource(R.string.message_your_response)) }, modifier = Modifier.fillMaxWidth(), singleLine = true, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt index d14de01e..5cf2597e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/SubagentInspectionSheet.kt @@ -30,11 +30,13 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.platform.testTag +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontFamily import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.sp +import com.m57.hermescontrol.R import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.TodoItem @@ -78,7 +80,7 @@ fun SubagentInspectionSheet( ) Spacer(modifier = Modifier.width(8.dp)) Text( - text = "Task & Plan Inspection", + text = stringResource(R.string.subagent_task_plan_inspection), style = MaterialTheme.typography.titleMedium, fontWeight = FontWeight.Bold, ) @@ -96,7 +98,7 @@ fun SubagentInspectionSheet( if (indicators.isEmpty() && todos.isEmpty()) { Text( - text = "No active tasks or plan items.", + text = stringResource(R.string.subagent_no_active), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(vertical = 16.dp), @@ -120,7 +122,7 @@ fun SubagentInspectionSheet( ) Spacer(modifier = Modifier.width(6.dp)) Text( - text = "AGENT PLAN", + text = stringResource(R.string.subagent_agent_plan), style = MaterialTheme.typography.labelMedium, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.primary, @@ -242,7 +244,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { if (!indicator.summary.isNullOrBlank()) { Spacer(modifier = Modifier.height(4.dp)) Text( - text = "Summary: ${indicator.summary}", + text = stringResource(R.string.subagent_summary, indicator.summary), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -260,7 +262,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) { verticalArrangement = Arrangement.spacedBy(2.dp), ) { Text( - text = "LIVE TRANSCRIPT", + text = stringResource(R.string.subagent_live_transcript), style = MaterialTheme.typography.labelSmall, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.outline, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt index 544e9782..c6612817 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/chat/components/VideoViewerDialog.kt @@ -236,7 +236,7 @@ fun VideoViewerDialog( Box(contentAlignment = Alignment.Center) { Icon( imageVector = Icons.Filled.PlayArrow, - contentDescription = "Play Video", + contentDescription = stringResource(R.string.video_play), tint = MaterialTheme.colorScheme.inverseOnSurface, modifier = Modifier.size(40.dp), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt index 6bc134a2..09e97f9d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/config/ConfigScreen.kt @@ -101,7 +101,7 @@ fun ConfigScreen( IconButton(onClick = { viewModel.saveConfig() }) { Icon( imageVector = Icons.Filled.Save, - contentDescription = "Save changes", + contentDescription = stringResource(R.string.config_save_changes), ) } } @@ -166,7 +166,7 @@ private fun ConfigContent( // Path display state.path?.let { path -> Text( - text = "Path: $path", + text = stringResource(R.string.config_path, path), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(bottom = 8.dp), @@ -195,7 +195,7 @@ private fun ConfigContent( SearchBar( query = state.searchQuery, onQueryChange = onSearchQueryChange, - placeholder = "Search settings…", + placeholder = stringResource(R.string.config_search_placeholder), modifier = Modifier.weight(1f), ) } @@ -643,7 +643,7 @@ private fun ConfigFieldCard( if (defaultValue != null && defaultValue != row.value) { CompactIconButton( icon = Icons.Filled.Refresh, - contentDescription = "Reset to default", + contentDescription = stringResource(R.string.config_reset_default), onClick = onReset, ) } @@ -651,7 +651,7 @@ private fun ConfigFieldCard( if (field?.clearable == true && row.valueText.isNotEmpty()) { CompactIconButton( icon = Icons.Filled.Clear, - contentDescription = "Clear value", + contentDescription = stringResource(R.string.config_clear_value), onClick = onClear, ) } @@ -852,7 +852,7 @@ private fun SearchableSelectField( DropdownMenuItem( text = { Text( - text = "No matches", + text = stringResource(R.string.config_no_matches), color = MaterialTheme.colorScheme.onSurfaceVariant, ) }, @@ -965,7 +965,7 @@ private fun JsonField( isError = invalid, supportingText = if (invalid) { - { Text("Invalid JSON") } + { Text(stringResource(R.string.config_invalid_json)) } } else { null }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt index 95cdf199..cfb5b7b1 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/kanban/KanbanScreen.kt @@ -129,7 +129,7 @@ fun KanbanScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Filter tasks by title, status, or assignee...", + placeholder = stringResource(R.string.kanban_filter_placeholder), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp), ) // Board selector tab row diff --git a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt index 4d0f8be6..7fab44de 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/logs/LogsScreen.kt @@ -211,7 +211,7 @@ fun LogsScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search logs...", + placeholder = stringResource(R.string.logs_search_placeholder), modifier = Modifier.padding(bottom = spacing.xs), ) } @@ -330,7 +330,7 @@ private fun shareLogs( ) { // Also copy to clipboard val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager - clipboard?.setPrimaryClip(ClipData.newPlainText("Hermes Logs", text)) + clipboard?.setPrimaryClip(ClipData.newPlainText(context.getString(R.string.logs_clipboard_label), text)) val sendIntent = Intent().apply { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt index caba6a3d..c294de5d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/mcp/McpServersScreen.kt @@ -154,7 +154,7 @@ fun McpServersScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search MCP servers...", + placeholder = stringResource(R.string.mcp_search_placeholder), ) } @@ -171,7 +171,7 @@ fun McpServersScreen( } else if (filteredServers.isEmpty()) { item(key = "no-match") { Text( - text = "No servers match \"$query\"", + text = stringResource(R.string.mcp_no_match, query), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, modifier = Modifier.padding(spacing.md), diff --git a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt index a730ce38..b85fa06b 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/memory/MemoryScreen.kt @@ -305,12 +305,12 @@ private fun SelfImprovementSection(graph: LearningGraphResponse?) { horizontalArrangement = Arrangement.spacedBy(8.dp), ) { StatCard( - label = "Learned Skills", + label = stringResource(R.string.memory_learned_skills), value = "${skillNodes.size}", modifier = Modifier.weight(1f), ) StatCard( - label = "Memories & Facts", + label = stringResource(R.string.memory_memories_facts), value = "${memoryNodes.size}", modifier = Modifier.weight(1f), ) @@ -318,7 +318,7 @@ private fun SelfImprovementSection(graph: LearningGraphResponse?) { Spacer(modifier = Modifier.height(16.dp)) Text( - text = "Recent Agent Self-Improvement Events", + text = stringResource(R.string.memory_recent_events), style = MaterialTheme.typography.labelLarge, fontWeight = FontWeight.Bold, color = MaterialTheme.colorScheme.onSurface, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt index 48ae1c5f..0dcc6206 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/ModelScreen.kt @@ -157,7 +157,7 @@ fun ModelScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search models...", + placeholder = stringResource(R.string.model_search_placeholder), ) } @@ -221,7 +221,7 @@ fun ModelScreen( if (state.modelPickerConfirmMessage != null) { AlertDialog( onDismissRequest = { viewModel.dismissModelPickerConfirm() }, - title = { Text("Expensive Model") }, + title = { Text(stringResource(R.string.model_expensive_title)) }, text = { Text(state.modelPickerConfirmMessage!!) }, confirmButton = { Button(onClick = { viewModel.confirmModelPickerExpensive() }) { @@ -240,7 +240,7 @@ fun ModelScreen( if (state.showMainModelPicker) { ModelPickerDialog( providers = state.providers, - title = "Set Main Model", + title = stringResource(R.string.model_set_main), isLoading = state.isLoading && state.providers.isEmpty(), pinnedModels = state.pinnedModels, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, @@ -266,7 +266,7 @@ fun ModelScreen( if (state.showAuxModelPicker) { ModelPickerDialog( providers = state.providers, - title = "Set Aux: ${state.auxPickerTask}", + title = stringResource(R.string.model_set_aux, state.auxPickerTask), isLoading = state.isLoading && state.providers.isEmpty(), pinnedModels = state.pinnedModels, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, @@ -330,12 +330,12 @@ private fun ModelSettingsSection( modifier = Modifier.size(16.dp), ) Text( - text = "Model Settings", + text = stringResource(R.string.model_settings), style = MaterialTheme.typography.titleSmall, fontWeight = FontWeight.Bold, ) Text( - text = "applies to new sessions", + text = stringResource(R.string.model_new_sessions), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -353,7 +353,7 @@ private fun ModelSettingsSection( modifier = Modifier.size(16.dp), ) }, - label = "Main model", + label = stringResource(R.string.model_main_label), value = if (mainProvider.isNotBlank()) "$mainProvider · $mainModel" else "(unset)", actionLabel = "Change", onAction = onSetMainModel, @@ -371,7 +371,7 @@ private fun ModelSettingsSection( modifier = Modifier.size(16.dp), ) }, - label = "Auxiliary tasks", + label = stringResource(R.string.model_aux_tasks_label), value = if (auxOverrideCount > 0) { "$auxOverrideCount override · ${AUX_TASKS.size - auxOverrideCount} auto" @@ -394,7 +394,7 @@ private fun ModelSettingsSection( modifier = Modifier.size(16.dp), ) }, - label = "Mixture of Agents", + label = stringResource(R.string.model_moa_label), value = if (moaEnabled) moaSummary else "not loaded", actionLabel = "Configure", onAction = onConfigureMoa, @@ -461,7 +461,7 @@ private fun AuxTasksDialog( ) { AlertDialog( onDismissRequest = onDismiss, - title = { Text("Auxiliary Tasks") }, + title = { Text(stringResource(R.string.model_aux_tasks_title)) }, text = { Column { Text( @@ -530,7 +530,7 @@ private fun AuxTasksDialog( ) { Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Reset all to auto") + Text(stringResource(R.string.model_reset_all_auto)) } } }, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt index 675109cc..36f6fbab 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/MoaConfigDialog.kt @@ -34,8 +34,10 @@ import androidx.compose.runtime.remember import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.MoaConfigPreset import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.ui.common.SearchBar @@ -58,7 +60,7 @@ internal fun MoaConfigDialog( AlertDialog( onDismissRequest = onDismiss, - title = { Text("Mixture of Agents") }, + title = { Text(stringResource(R.string.moa_title)) }, text = { if (pickerTarget != null && effectivePreset != null) { // Inline model picker within the dialog @@ -228,13 +230,13 @@ private fun MoaConfigEditor( ) { Column { Text( - text = "Presets appear as models under the Mixture of Agents provider.", + text = stringResource(R.string.moa_presets_note), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) if (effectivePreset == null) { - Text("No presets configured", style = MaterialTheme.typography.bodyMedium) + Text(stringResource(R.string.moa_no_presets), style = MaterialTheme.typography.bodyMedium) return } @@ -248,7 +250,7 @@ private fun MoaConfigEditor( ) { // Simplified preset dropdown with buttons if (presetNames.isNotEmpty()) { - Text("Preset:", style = MaterialTheme.typography.bodySmall) + Text(stringResource(R.string.moa_preset_label), style = MaterialTheme.typography.bodySmall) presetNames.forEach { name -> TextButton( onClick = { onSelectPreset(name) }, @@ -286,7 +288,7 @@ private fun MoaConfigEditor( contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), enabled = presetNames.size > 1, ) { - Text("Set default", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.moa_set_default), style = MaterialTheme.typography.labelSmall) } OutlinedButton( onClick = onDeletePreset, @@ -306,13 +308,18 @@ private fun MoaConfigEditor( OutlinedTextField( value = newPresetName, onValueChange = onNewPresetNameChange, - placeholder = { Text("new preset name", style = MaterialTheme.typography.bodySmall) }, + placeholder = { + Text( + stringResource(R.string.moa_new_preset_name), + style = MaterialTheme.typography.bodySmall, + ) + }, singleLine = true, modifier = Modifier.weight(1f).height(48.dp), textStyle = MaterialTheme.typography.bodySmall, ) IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) { - Icon(Icons.Outlined.Add, contentDescription = "Add preset") + Icon(Icons.Outlined.Add, contentDescription = stringResource(R.string.moa_add_preset)) } } @@ -320,7 +327,7 @@ private fun MoaConfigEditor( // Reference models Text( - text = "Reference models", + text = stringResource(R.string.moa_reference_models), style = MaterialTheme.typography.bodySmall, fontWeight = FontWeight.Bold, ) @@ -355,7 +362,7 @@ private fun MoaConfigEditor( ) { Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp)) Spacer(modifier = Modifier.width(4.dp)) - Text("Add reference model", style = MaterialTheme.typography.labelSmall) + Text(stringResource(R.string.moa_add_reference), style = MaterialTheme.typography.labelSmall) } Spacer(modifier = Modifier.height(8.dp)) @@ -412,7 +419,7 @@ private fun MoaSlotPicker( SearchBar( query = pickerQuery, onQueryChange = { pickerQuery = it }, - placeholder = "Search providers...", + placeholder = stringResource(R.string.moa_search_providers), ) Spacer(modifier = Modifier.height(8.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt index f55a0a24..eaf37235 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/model/components/ModelPickerDialog.kt @@ -32,9 +32,11 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip +import androidx.compose.ui.res.stringResource import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.ui.common.LoadingState @@ -95,12 +97,12 @@ fun ModelPickerDialog( Column { if (isLoading) { LoadingState( - subtitle = "Loading models…", + subtitle = stringResource(R.string.model_picker_loading), modifier = Modifier.fillMaxWidth(), ) } else if (providers.isEmpty() && pinnedModels.isEmpty()) { Text( - text = "No models available.", + text = stringResource(R.string.model_picker_no_models), style = MaterialTheme.typography.bodyMedium, color = MaterialTheme.colorScheme.onSurfaceVariant, ) @@ -143,7 +145,7 @@ fun ModelPickerDialog( SearchBar( query = pickerQuery, onQueryChange = { pickerQuery = it }, - placeholder = "Search models and providers...", + placeholder = stringResource(R.string.model_picker_search), ) Spacer(modifier = Modifier.height(12.dp)) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt index 9cd816f1..e62e5f86 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/plugins/PluginsScreen.kt @@ -169,7 +169,7 @@ fun PluginsScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search plugins...", + placeholder = stringResource(R.string.plugins_search_placeholder), ) } @@ -336,7 +336,7 @@ private fun InstallSection( value = state.installUrl, onValueChange = { viewModel.updateInstallUrl(it) }, modifier = Modifier.fillMaxWidth(), - placeholder = { Text("owner/repo, owner/repo/subdir, or https://...") }, + placeholder = { Text(stringResource(R.string.plugins_repo_placeholder)) }, singleLine = true, enabled = !state.installBusy, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt index cd5bb288..e79d8e12 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/ProfilesScreen.kt @@ -137,7 +137,7 @@ fun ProfilesScreen( }) { Icon( imageVector = Icons.Default.Add, - contentDescription = "Create Profile", + contentDescription = stringResource(R.string.profiles_create), ) } } @@ -296,7 +296,10 @@ fun ProfilesScreen( IconButton(onClick = { showMenu = true }) { Icon( imageVector = Icons.Default.MoreVert, - contentDescription = "More options", + contentDescription = + stringResource( + R.string.profiles_more_options, + ), ) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt index f7819dd0..de3478f4 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/ProfileBuilderView.kt @@ -303,7 +303,7 @@ private fun IdentityStep( value = description, onValueChange = onDescriptionChange, label = { Text(stringResource(R.string.profiles_builder_label_description)) }, - placeholder = { Text("Describe the purpose of this profile") }, + placeholder = { Text(stringResource(R.string.profile_builder_placeholder)) }, modifier = Modifier.fillMaxWidth(), maxLines = 4, ) @@ -470,17 +470,17 @@ private fun McpStep( fontWeight = FontWeight.Bold, ) Text( - text = "Transport: ${server.transport.uppercase()}", + text = stringResource(R.string.profile_transport, server.transport.uppercase()), style = MaterialTheme.typography.bodySmall, ) if (server.transport == "sse") { Text( - text = "URL: ${server.url}", + text = "URL:${server.url}", style = MaterialTheme.typography.labelSmall, ) } else { Text( - text = "Command: ${server.command}", + text = stringResource(R.string.profile_command, server.command ?: ""), style = MaterialTheme.typography.labelSmall, ) } @@ -490,7 +490,7 @@ private fun McpStep( ) { Icon( imageVector = Icons.Default.Delete, - contentDescription = "Remove Server", + contentDescription = stringResource(R.string.profile_remove_server), ) } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt index d49f5362..d3492420 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/profiles/components/SkillsStep.kt @@ -257,7 +257,7 @@ fun SkillsStep( } if (!skill.source.isNullOrBlank()) { Text( - text = "Source: ${skill.source}", + text = stringResource(R.string.skills_source, skill.source), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.primary, fontStyle = FontStyle.Italic, diff --git a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt index 708906e3..17b71a86 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/providers/ProvidersScreen.kt @@ -188,7 +188,7 @@ private fun ProviderCard( provider.status.sourceLabel?.let { label -> Spacer(modifier = Modifier.height(2.dp)) Text( - text = "via $label", + text = stringResource(R.string.providers_via, label), style = MaterialTheme.typography.labelSmall, color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt index f3221694..071aca2e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/SettingsScreen.kt @@ -131,6 +131,7 @@ private fun appearanceSummary(state: SettingsUiState): String { val lang = when (state.appLanguage) { "system" -> stringResource(R.string.language_system) + "zh" -> stringResource(R.string.language_chinese) "ko" -> stringResource(R.string.language_korean) else -> stringResource(R.string.language_english) } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt index fa2c99ea..6248843e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/settings/components/AppearanceSection.kt @@ -201,6 +201,7 @@ internal fun AppearanceSection( listOf( LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system), "en" to stringResource(R.string.language_english), + "zh" to stringResource(R.string.language_chinese), "ko" to stringResource(R.string.language_korean), ) SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt index 482a508b..523f862d 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemScreen.kt @@ -1,5 +1,6 @@ package com.m57.hermescontrol.ui.system +import android.app.Application import android.net.Uri import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.result.contract.ActivityResultContracts @@ -125,8 +126,10 @@ private fun formatDuration(totalSeconds: Double): String { fun SystemScreen( modifier: Modifier = Modifier, onOpenDrawer: (() -> Unit)? = null, - viewModel: SystemViewModel = viewModel { SystemViewModel() }, + viewModel: SystemViewModel? = null, ) { + val app = LocalContext.current.applicationContext as Application + val viewModel = viewModel ?: viewModel { SystemViewModel(app) } val state by viewModel.uiState.collectAsStateWithLifecycle() val spacing = LocalSpacing.current val statusColors = LocalHermesStatusColors.current @@ -854,7 +857,7 @@ private fun LazyListScope.gatewaySection( // Auth required status.auth_required?.let { auth -> InfoRow( - label = "Auth required", + label = stringResource(R.string.system_auth_required), value = if (auth) "yes" else "no", ) } @@ -1670,7 +1673,7 @@ private fun LazyListScope.actionLogSection( } if (lines.size > 20) { Text( - text = "... ${lines.size - 20} more line(s)", + text = stringResource(R.string.system_more_lines, lines.size - 20), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, ) diff --git a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt index afcd40e4..b9fb781e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/system/SystemViewModel.kt @@ -1,9 +1,12 @@ package com.m57.hermescontrol.ui.system +import android.app.Application import android.util.Log -import androidx.lifecycle.ViewModel +import androidx.annotation.StringRes +import androidx.lifecycle.AndroidViewModel import androidx.lifecycle.viewModelScope import com.m57.hermescontrol.BuildConfig +import com.m57.hermescontrol.R import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.BackupTriggerRequest @@ -83,8 +86,8 @@ data class SystemUiState( val updateConfirmOpen: Boolean = false, ) -class SystemViewModel : - ViewModel(), +class SystemViewModel(application: Application) : + AndroidViewModel(application), ToastHost { companion object { private const val TAG = "SystemViewModel" @@ -348,7 +351,7 @@ class SystemViewModel : fun runCuratorNow() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.runCurator() } }, - label = "Curator run", + labelRes = R.string.system_curator_run, ) } @@ -433,35 +436,35 @@ class SystemViewModel : fun runSecurityAudit() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.runSecurityAudit() } }, - label = "Security audit", + labelRes = R.string.system_security_audit, ) } fun runPromptSize() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.runPromptSize() } }, - label = "Prompt size check", + labelRes = R.string.system_prompt_size_check, ) } fun runDump() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.runDump() } }, - label = "Dump", + labelRes = R.string.system_dump, ) } fun runConfigMigrate() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.runConfigMigrate() } }, - label = "Config migrate", + labelRes = R.string.system_config_migrate, ) } fun runUpdateSkills() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } }, - label = "Skills update", + labelRes = R.string.system_skills_update, ) } @@ -654,7 +657,7 @@ class SystemViewModel : fun pruneCheckpoints() { runOperation( apiCall = { safeApiCall { ApiClient.hermesApi.pruneCheckpoints() } }, - label = "Checkpoint prune", + labelRes = R.string.system_checkpoint_prune, ) } @@ -786,18 +789,35 @@ class SystemViewModel : private fun runOperation( apiCall: suspend () -> NetworkResult, - label: String, + @StringRes labelRes: Int, ) { viewModelScope.launch { val result = withContext(Dispatchers.IO) { apiCall() } when (result) { is NetworkResult.Success -> { result.data.name?.let { pollActionStatus(it) } - _uiState.update { it.copy(toastMessage = "$label started") } + _uiState.update { + it.copy( + toastMessage = + getApplication().getString( + R.string.system_action_started, + getApplication().getString(labelRes), + ), + ) + } } is NetworkResult.Failure -> { - _uiState.update { it.copy(toastMessage = "$label failed: ${result.error.message}") } + _uiState.update { + it.copy( + toastMessage = + getApplication().getString( + R.string.system_action_failed, + getApplication().getString(labelRes), + result.error.message, + ), + ) + } } } } diff --git a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt index 1e6d5b77..3f938d9e 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/toolsets/ToolsetsScreen.kt @@ -135,7 +135,7 @@ fun ToolsetsScreen( SearchBar( query = query, onQueryChange = { query = it }, - placeholder = "Search toolsets...", + placeholder = stringResource(R.string.toolsets_search_placeholder), ) } items(filteredToolsets, key = { it.name }) { toolset -> diff --git a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt index 6a6fa8e3..6fb6ddc3 100644 --- a/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt +++ b/app/src/main/java/com/m57/hermescontrol/ui/webhooks/WebhooksScreen.kt @@ -161,7 +161,7 @@ fun WebhooksScreen( value = state.createFormDescription, onValueChange = viewModel::updateCreateFormDescription, label = { Text(stringResource(R.string.webhooks_field_description)) }, - placeholder = { Text("What this webhook does...") }, + placeholder = { Text(stringResource(R.string.webhooks_placeholder)) }, singleLine = true, modifier = Modifier.fillMaxWidth(), ) @@ -291,7 +291,7 @@ fun WebhooksScreen( ) state.baseUrl?.let { Text( - text = "Receiver URL: $it", + text = stringResource(R.string.webhooks_receiver_url, it), style = MaterialTheme.typography.bodySmall, color = MaterialTheme.colorScheme.onSurfaceVariant, maxLines = 1, @@ -446,7 +446,7 @@ private fun SubscriptionCard( Spacer(modifier = Modifier.width(6.dp)) Icon( imageVector = Icons.Filled.Lock, - contentDescription = "Secret configured", + contentDescription = stringResource(R.string.webhooks_secret_configured), modifier = Modifier.size(14.dp), tint = MaterialTheme.colorScheme.primary, ) @@ -482,7 +482,7 @@ private fun SubscriptionCard( ) { Icon( imageVector = Icons.Filled.Delete, - contentDescription = "Delete subscription", + contentDescription = stringResource(R.string.webhooks_delete_subscription), modifier = Modifier.size(18.dp), tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), ) diff --git a/app/src/main/res/values-zh/strings.xml b/app/src/main/res/values-zh/strings.xml new file mode 100644 index 00000000..a81f0bda --- /dev/null +++ b/app/src/main/res/values-zh/strings.xml @@ -0,0 +1,1037 @@ + + + Hermes Mobile + 发送 + 正在聆听… + 语音输入需要麦克风权限 + 此设备不支持语音识别 + 发送 + 关闭 + 需要 Sudo 密码 + Agent 需要你的 sudo 密码才能继续,密码不会显示。 + 密码 + 需要密钥 + Agent 需要密钥(令牌或密码)才能继续,内容不会显示。 + 密钥 + 搜索消息… + 清除 + 上一个匹配 + 下一个匹配 + 关闭搜索 + 重试 + 取消 + 确认 + 清除 + 确定 + 启动中… + 运行中… + 已完成 + 失败 + 失败(退出码 %1$d) + 删除 + 返回 + 打开导航抽屉 + 刷新 + 保存 + 浏览技能中心 + 重试搜索 + 创建第一个任务 + 添加服务器 + 连接 Telegram 渠道 + 配置模型 + 开始新对话 + 浏览技能或定时任务 + 清除搜索 + 取消 + 清除搜索 + 未知错误 + 免费 + 用量 + 取消 + 续费:%1$s + 此服务器不支持计费功能(方法不可用) + 无有效套餐——你当前处于免费档。 + 登录 Nous Portal 查看你的套餐。 + 剩余额度:%1$s + 套餐 + 充值 + Hermes + AI Agent 控制台 + 对话 + 自动化 + 配置 + 检查 + 对话 + 技能 + 定时任务 + 系统 + 设置 + 配置 + Webhook + 网关 + 工具集 + 插件 + 配置 + MCP 服务器 + 记忆 + 模型 + 配对 + 密钥 + 渠道 + 日志 + 看板 + 成就 + 历史 + 进程 + 提供商 + 分析 + 计费 + 文件 + Hermes + 等待 Hermes 回复 + 新消息 + Hermes 需要确认 + 输入回复… + Hermes 后台服务 + 在后台保持连接 + Hermes 对话回复 + Hermes 新回复提醒 + 回复 + 搜索 + 关闭搜索 + 重新连接 + 重新登录 + 重新登录 Hermes + 用户名 + 密码 + 登录 + 取消 + 用户名和密码不能为空 + 准备就绪 + 发送消息开始对话 + 正在连接 Hermes… + 无法连接此会话 + 重新连接中… + 已断开 + 无网络 + 会话已过期 + 未连接… + 等待响应… + 已排队——Agent 空闲时发送… + 输入消息… + 执行错误:%1$s + 退出码:%1$d + 工具 + 显示原始 JSON + 显示解析输出 + 新建对话 + 滚动到底部 + 复制 + 编辑 + 移除附件 + 拍照失败 + 连接 + 认证令牌 + 行为 + 自动重连 + 断开后自动重新连接 + 外观 + 主题 + 对话 + 打字效果 + 逐字显示新消息动画 + 助手 + 延迟:%1$d 毫秒 + 10 毫秒 + 100 毫秒 + 测试连接 + 关于 + Hermes Mobile + 版本 + 构建 + 调试版 + 正式版 + 提交 + 更新 + 检查更新 + 检查中… + 已是最新(v%1$s) + 发现新版本:v%1$s + 更新 + 下载中 %1$d%% + 安装 v%1$s… + 允许安装来自此来源的应用 + 打开设置 + 重试 + 已保存 %1$d 个 + 打字效果已开启 + 打字效果已关闭 + 开 + 关 + 跟随系统 + 浅色 + 深色 + 保存 + 添加配置 + 编辑配置 + 添加连接配置 + 编辑连接配置 + 配置名称 + 删除配置? + 确定要删除"%1$s"吗?此操作无法撤销。 + 删除配置 + 隐藏令牌 + 显示令牌 + 跟随系统 + 浅色 + 深色 + 动态取色 + 从壁纸提取配色(Android 12+) + 预设主题 + 默认 + 单色 + Gruvbox + Catppuccin + AMOLED 纯黑(仅深色) + Nord + 语言 + 跟随系统 + English + 简体中文 + 한국어 + 配置:%1$s + 选择已保存配置 + 后端健康状态 + Hermes %1$s · %2$s + 需登录(OAuth) + 令牌 + 需要填写令牌 + 令牌无效(401 未授权) + 访问被拒绝(403 禁止访问) + 服务器返回 HTTP %1$d + 连接超时 + 连接被拒绝——Hermes 是否在运行? + 无法解析主机名 + 连接失败:%1$s + 保存连接配置 + 配置名称 + 例如:本地服务器、远程服务器 + 隐藏手动输入 + 改为手动输入 + API 令牌 + 服务器地址 + https://主机:9119/ 或 http://127.0.0.1:9119/ + 请输入有效的服务器地址(https:// 或 http://) + 连接 + 看板 + 未找到看板。 + 此看板没有列。 + 添加新任务 + 任务标题 + 任务描述 + 添加任务 + 待处理 + 就绪 + 阻塞 + 解除阻塞 + 完成 + 归档 + 将此任务标记为阻塞?将释放该 worker 的认领。 + 完成此任务?将释放该 worker 的认领。 + 归档此任务?它将从默认看板视图中消失。 + 完成任务 + 完成摘要 + 将作为任务结果保存 + 移动失败:%1$s + 实时 + 离线 + 暂无任务 + 添加 + 全部状态 + 已启用 + 已停用 + 全部 + 技能 + 搜索技能… + 编辑:%1$s + 预览:%1$s + 没有可预览的内容 + 搜索技能中心… + 搜索中心 + 技能中心未找到相关技能 + 输入关键词浏览技能中心 + 安装 + 已连接来源 + 已限流 + 离线 + 通过索引 + 精选技能 + 来自 Hermes 索引——搜索获取更多 + 在上方搜索可安装的技能 + 扫描 + 判定:%1$s + 允许安装 + 需要确认 + 禁止安装 + 未检测到风险模式 + 输入技能内容… + 未找到技能 + 放弃修改? + 有未保存的更改。确定要放弃吗? + 已安装 + 浏览中心 + 全部来源 + 放弃 + 编辑技能文件 + 卸载技能 + 从中心更新技能 + 保存更改 + 暂无日志 + Hermes 的日志输出将显示在这里。 + 暂停 + 继续 + 分享 + 滚动到底部 + 全部 + Agent + 错误 + 网关 + 警告 + 错误 + 信息 + 调试 + 工具 + CLI + 定时任务 + 无系统数据 + 点击刷新加载系统诊断信息。 + 主机 + 操作系统 + 架构 + 主机名 + Python + Hermes + CPU + 内存 + 磁盘 + 运行时间 + 平均负载 + 核 + %1$s / %2$s(%3$s%%) + 安装 psutil 扩展以获取 CPU / 内存 / 磁盘指标。 + 最新 + 有可用更新 + 落后 %1$d 个版本 + 检查更新 + 立即更新 + 更新 Hermes? + 这将运行 hermes update 并重启网关。 + 软件更新 + Nous Portal + 已登录 + 未登录 + 推理提供商:%1$s + 管理订阅 + 工具网关路由 + 使用 hermes portal 登录。 + 技能维护器 + 自我改进与学习动态 + Agent 技能、记忆与事实库随时间更新的日志。 + 暂无自我改进记录。 + 运行中 + 已暂停 + 已停用 + 每 %1$d 小时 + 上次运行 %1$s + 从未运行 + 恢复 + 暂停 + 立即运行 + 网关 + 运行中 + 已停止 + 启动 + 重启 + 停止 + 当前:%1$s + 内置记忆 + MEMORY.md:%1$s · USER.md:%2$s + 重置 MEMORY.md + 重置 USER.md + 全部重置 + 重置记忆 + 这将永久清除 %1$s 个内置记忆文件。此操作无法撤销。 + 无记忆数据 + 无法加载记忆状态。 + 提供商 + 启用中 + 凭据池 + 提供商 + API 密钥 + 标签 + 添加密钥 + 暂无池化凭据。在上方添加以启用密钥轮换。 + 移除凭据 + 移除此池化 API 密钥?Agent 将不再轮换使用它。 + 运维 + 运行 doctor 诊断 + 安全审计 + 更新技能 + 提示词大小 + 支持转储 + 迁移配置 + 创建备份 + 下载备份 + 备份 + 从上传恢复 + 上传恢复包 + 选择备份文件 + 恢复完整 Hermes 备份? + 这将覆盖当前的 Hermes 配置、技能、会话和数据。此操作无法撤销。 + 调试分享 + 将系统信息和日志上传到公开粘贴服务,返回链接发给 Hermes 团队。粘贴内容 6 小时后自动删除。 + 生成分享链接 + 上传中… + 上传前脱敏凭据类令牌(推荐) + 已上传 + 已脱敏 + 未脱敏 + %1$d 小时后自动删除 + 全部复制 + 上传失败 + 检查点 + %1$d 个会话 · %2$s + 清理 + 清理检查点 + 删除回滚检查点存储?现有的 /rollback 恢复点将丢失。 + Shell 钩子 + 新建钩子 + 未配置 shell 钩子。 + 匹配器:%1$s + 不可执行 + 已允许 + 未批准 + 新建 shell 钩子 + 事件 + 命令(绝对路径) + 匹配器(可选) + 超时(秒) + 立即批准(授权触发;否则保持已配置但未激活) + Shell 钩子会在此主机上执行任意命令。只添加你信任的脚本。下次网关/会话重启后生效。 + 创建钩子 + 操作日志 + 运行中 + 已完成 + 退出码 %1$d + 启动中… + 取消 + 恢复 + 立即更新 + 没有模型提供商数据 + 请检查配置或重试 + 标识:%1$s + 当前 + 可用模型: + 没有可用的模型,或提供商需要认证。 + 当前模型 + 固定模型 + 取消固定模型 + 切换固定区显示 + 暂无历史 + 之前的 Hermes 对话会话将显示在这里。 + 未命名 + %1$d 条消息 + 加载更多会话… + 搜索会话… + 无结果 + 没有匹配"%1$s"的会话 + 找到 %1$d 个结果("%2$s") + 播放于 %1$s + 匹配 + 模型 + 总计 + 消息 + 清理 + 清理旧会话 + 删除早于指定天数的会话。 + 天数阈值 + 清理 + 空会话 + 删除空会话? + 删除 %1$d 个空会话?活动与归档会话会被保留。 + 删除 + 删除会话? + 确定要删除"%1$s"吗?此操作无法撤销。 + 删除会话? + 确定要删除 %1$d 个会话吗?此操作无法撤销。 + 全选 + 取消选择 + 删除 %1$d 个 + 选择 + 重命名 + 置顶 + 取消置顶 + 已置顶 + 重命名会话 + 会话标题 + 没有工具集数据 + 请检查配置或重试 + 此工具集没有可配置的后端 + 就绪 + 需要密钥 + 需要登录 + 需要安装 + 启用中 + 使用此后端 + 需要登录 Nous Portal 才能激活 + 无需 API 密钥 + 已设置 + 未设置 + 保存 + 删除 + 移除密钥? + 这将从环境中清除 %1$s。 + 编辑 %1$s + 显示 %1$s + 隐藏 %1$s + 清除 %1$s + 运行安装 + 重新安装 + 安装中… + 已安装 + 为此后端安装缺失的依赖 + 依赖已安装 + 安装完成(退出码 %1$d) + 安装失败(退出码 %1$d) + 搜索:%1$s + 提取:%1$s + 全局 Webhook 状态 + 订阅 + 未配置 webhook 订阅。 + 暂无订阅 + 搜索订阅… + 添加订阅 + 创建 + 删除 + 创建订阅 + 删除订阅 + 确定要删除"%1$s"吗?此操作无法撤销。发送到此 URL 的所有事件都将被拒绝。 + 名称 * + 描述 + 事件 + 投递到 + 密钥 + 例如:github-push + 逗号分隔(例如 push, pull_request) + log、telegram、discord、origin、all… + 留空则自动生成 + 没有匹配的订阅 + 尝试调整搜索条件。 + 配置 + 重置 + 保存 + 保存 YAML + 没有可用配置 + Hermes 的连接配置将显示在这里。 + 模型:%1$s + 提供商:%1$s + %1$d 个技能 + 路径:%1$s + 自动生成 + 激活 + 编辑 Soul + 设置模型 + 克隆 + 编辑描述 + 当前配置 + 编辑 Soul 配置:%1$s + 设置模型:%1$s + 克隆配置:%1$s + 编辑描述:%1$s + 新配置名称 + 描述 + 暂无描述。请描述此配置的用途。 + 重命名 + 自动描述 + 安装命令 + 复制 + 已复制 ✓ + 重命名配置:%1$s + 删除配置:%1$s + 安装命令:%1$s + 这将永久删除配置"%1$s"及其数据。 + 路径:%1$s + 此配置有正在运行的网关——删除它会将其停止。 + 在 Hermes CLI 中运行此命令以配置此配置文件。 + 第 %1$d 步,共 5 步 + 身份 + 模型配置 + 技能选择 + MCP 服务器 + 检查与创建 + 上一步 + 下一步 + 创建配置 + 配置名称 + 描述 + 选择提供商(可选) + 选择模型 + 包含默认技能包 + 自动打包常用系统工具 + 选择本地技能 + 搜索中心 + 从 Hermes 中心添加技能 + 已添加技能: + 搜索结果: + 已配置的 MCP 服务器 + 添加服务器 + 服务器名称 + 传输协议 + 添加 MCP 服务器 + Stdio 命令 + SSE 服务器地址 + 命令 + 参数(逗号分隔) + SSE 地址 + 配置名称 + 描述 + 提供商:%1$s + 模型:%1$s + 模型设置 + 使用系统默认值 + 技能配置 + 完整默认包 + 已选 %1$d 个本地技能 + 已添加 %1$d 个中心技能 + MCP 服务器 + 未配置 MCP 服务器 + 尚未配置 MCP 服务器 + 添加 + 未找到插件 + 请检查配置或重试 + 无描述。 + 更新 + 卸载 + 安装 + 移除 + 安装插件 + 从 git/URL 来源安装插件 + 强制重装 + 安装后启用 + 插件提供商 + 选择启用的记忆与上下文提供商 + 记忆提供商 + 上下文引擎 + (配置默认值) + 移除插件 + 这将从你的 Agent 中移除"%1$s"插件。 + 需要认证 + 认证命令:%1$s + Dashboard 槽位:%1$s + 孤立 Dashboard 插件 + 在侧边栏显示 + 从侧边栏隐藏 + 分类 + 来源 + 版本 + 状态 + 描述 + 标签 + 传输 + 标签 + URL + 命令 + 环境变量 + 工具 + 事件 + 投递到 + 提示词 + 技能 + 创建时间 + 错误 + 已启用 + 已停用 + 信任级别 + 传输:%1$s + 命令: + 测试 + 添加服务器 + HTTP 地址 + 没有 MCP 服务器 + 在上方添加服务器,或从目录中选择。 + Stdio 命令 + 服务器名称 + URL(例如 http://localhost:8000/sse) + 命令(例如 npx) + 参数 + 添加服务器 + 添加中… + 重启 + 环境变量 + 添加环境变量 + 键 + 值 + 未配置环境变量。 + 目录 + 搜索目录… + 没有与搜索匹配的目录条目。 + 安装 + 安装中… + 来源:%1$s + 必需环境变量 + 认证方式 + 无 + 请求头 + OAuth + Bearer 令牌 + 授权 + MCP OAuth 流程 + 状态:%1$s + 错误:%1$s + 已在浏览器中打开授权页面。完成登录后返回这里。 + 打开浏览器 + 网关运行中 + 网关已停止 + 版本:%1$s + 控制 + 启动 + 停止 + 重启 + 活动平台 + 没有定时任务 + Hermes 的定时任务将显示在这里。 + 启用 + 已暂停 + 暂停 + 恢复 + 运行 + 编辑 + 添加定时任务 + 编辑定时任务 + 新建定时任务 + 从模板开始 + 空白任务 + 名称 + 调度 + 提示词 + 投递 + 技能 + 模型 + 提供商 + 基础地址 + 脚本 + 工作目录 + 无 Agent(仅脚本) + 监控模式 + 关 + 脚本 + URL + 监控脚本 + 监控 URL + 仅当来源输出变化时运行 Agent + 被配置阻止 + 无变化 + 例如 0 9 * * * 或 every 2h + 例如 origin、local、all 或 telegram:chat_id + HH:MM + 每行一个 + 保存 + 放弃修改? + 有未保存的更改。要放弃吗? + 删除任务? + 删除"%1$s"?此操作无法撤销。 + 还没有成就 + 完成任务解锁成就。 + 进度:%1$d / %2$d + 搜索成就… + 已解锁 + 已发现 + 秘密 + 总计 + 空闲 + 扫描中… + 重新扫描中… + 错误 + %1$d 次扫描 + 缓存 + 全部 + 已解锁 + 已发现 + 秘密 + 最近解锁 + %1$d / %2$d 个成就 + 显示条件 + 隐藏条件 + 解锁于 %1$s + 成就菜单 + 重新扫描全部会话 + 重置全部成就数据 + 重置成就? + 这将清空所有解锁与发现数据。下次扫描时将从头重新扫描成就。此操作无法撤销。 + 没有环境密钥数据 + Hermes 的环境密钥将显示在这里。 + 未配置 + 切换可见性 + 搜索密钥… + 没有匹配的密钥 + 尝试其他搜索词。 + 添加新密钥 + 密钥名称 + 密钥值 + 添加密钥 + 需要重启网关 + 环境更改在网关重启后生效。 + 重启网关 + 删除密钥 + 添加新环境密钥 + 删除密钥? + 确定要删除环境密钥"%1$s"吗?更改在重启网关后生效。 + 删除 + 添加环境密钥 + 复制值 + 密钥值已复制到剪贴板 + 已配置 + 未设置 + 例如 OPENAI_API_KEY + 例如 sk-... + 打开提供商文档 + 全部 + 托管文件根目录 + 这里没有文件 + 此文件夹为空。创建文件夹或上传文件以开始。 + 上一级 + 上传文件 + 新建文件夹 + 创建 + 删除 + 新建文件夹 + 文件夹名称 + my-folder + 删除? + 确定要删除"%1$s"吗?此操作无法撤销。 + 未配置渠道 + 在 Hermes 中添加消息平台后,会显示在这里。 + 配置 + 配置设置 + 保存设置 + 测试 + 立即重启 + 更改已保存。重启网关后生效。 + 待批准 + 已批准用户 + 全部清除 + 批准 + 撤销 + 没有配对请求 + 请求访问你消息渠道的用户将显示在这里等待批准。 + 暂无已批准用户。 + 未知用户 + %1$d 分钟前 + %1$d 小时 %2$d 分钟前 + 已保存配置(%1$d) + 退出登录 + 认证登录 + 连接到你的 Hermes dashboard + 服务器地址 + 会话令牌 + 用户名 + 密码 + 连接 + 正在探测 dashboard… + 正在检查 dashboard 配置… + 需要填写会话令牌 + 需要填写用户名 + 需要填写密码 + 无法访问 dashboard——检查 IP 和端口 + 此版本暂不支持 OAuth 登录(Nous Portal)。请将 dashboard 切换到 basic auth 或令牌模式。(issue #639) + 此 dashboard 使用 OAuth(Nous Portal)登录,该功能即将在 Hermes Mobile 中支持。 + 使用已登录的配置: + 以 %1$s 身份进入 + Hermes Mobile + Hermes Agent 控制面板 + 认证登录 + 没有 OAuth 提供商 + 已配置的 OAuth 提供商将显示在这里。连接账户即可在应用中使用。 + 连接 + 如何连接 + 断开 + 进行中… + 提交代码 + 打开浏览器授权 + 此提供商由外部 CLI 管理。在主机上运行下面的命令,然后刷新此列表。 + 在浏览器中授权后,将授权代码(任何 # 分隔符之前的部分)粘贴到下面。 + 授权代码 + 在另一台设备上打开验证地址并输入此代码,然后等待——此页面会自动轮询。 + %1$s 后过期 + OAuth 连接成功。 + 修复 + 忽略 + 完成 + 关闭 + Hermes Mobile %1$s 已可用 + 更新 + 没有运行中的进程 + 此会话的后台进程将显示在这里。 + 没有活动的对话会话 + 请先打开一个对话会话——进程列表按会话隔离。 + 运行中 + 结束进程? + 停止"%1$s"?此操作无法撤销。 + 暂无分析数据 + Agent 开始活动后,用量统计将显示在这里。 + %1$d 天 + 预估费用 + 会话 + API 调用 + 输入 + 输出 + 缓存读取 + 推理 + 已复制 + 显示全部 + 收起 + 每日预估费用 + 加载用量分析… + 此期间没有费用记录。 + %1$d 次使用 · 占总量的 %2$d%% + %1$d 次调用 · 占总量的 %2$d%% + 结束 + 结束进程 + 对话图片 + 关闭 + 保存到设备 + 分享 + 分享图片 + 管理提供商… + 没有提供商配置 + 此提供商没有可配置的界面。 + 保存设置 + 依赖:%1$s · 必需环境变量:%2$s + 安装 + 为此提供商安装缺失的依赖。 + 运行安装 + 安装成功完成。 + 安装存在失败项。 + 已设置 + 未设置 + API 密钥(留空保持不变) + 开 + 关 + 图片已保存到下载目录 + 无法保存图片 + 无法加载图片:%1$s + 无法分享图片 + + %1$d 处发现 + %1$d 处发现 + + + 最常用组件 + 暂无模型数据 + 暂无技能数据 + 暂无工具数据 + 你最常用的模型、技能和工具概览。 + 凭据将写入 %1$s + 过期时间:%1$s + 搜索渠道… + 附加文件 + 保存 %1$s + 切换模型(当前对话) + 清除值 + 无效的 JSON + 无匹配 + 路径:%1$s + 重置为默认 + 保存更改 + 搜索设置… + 缓存读取 + 缓存写入 + 补全(输出) + 已压缩 ×%1$s + 提示词(输入) + 提示词 token 反映此会话迄今使用的总上下文。 + 上下文窗口 + 播放 GIF + 按标题、状态或负责人筛选任务… + Hermes 日志 + 搜索日志… + 没有匹配"%1$s"的服务器 + 搜索 MCP 服务器… + 已学习技能 + 记忆与事实 + 最近的 Agent 自我改进事件 + 推理 · %1$s 步 + 你的回复 + 添加预设 + 添加参考模型 + 新预设名称 + 未配置预设 + 预设: + 预设会作为多 Agent 混合提供商下的模型显示。 + 参考模型 + 搜索提供商… + 设为默认 + 多 Agent 混合 + 辅助任务 + 辅助任务 + 高成本模型 + 主模型 + 多 Agent 混合 + 适用于新会话 + 正在加载模型… + 没有可用模型。 + 搜索模型和提供商… + 全部重置为自动 + 搜索模型… + 设置辅助:%1$s + 设置主模型 + 模型设置 + 这将永久移除该平台的配置。 + 移除 %1$s? + 移除平台 + 扫码设置 + owner/repo、owner/repo/subdir 或 https://... + 搜索插件… + 描述此配置的用途 + 命令:%1$s + 移除服务器 + 传输:%1$s + 创建配置 + 更多选项 + 通过 %1$s + 来源:%1$s + AGENT 计划 + 实时转录 + 没有活动任务或计划项。 + 摘要:%1$s + 任务与计划检查 + 需要认证 + 清理检查点 + 迁移配置 + 运行技能维护器 + … 还有 %1$d 行 + 提示词大小检查 + 安全审计 + 更新技能 + 添加用户 ID + 允许的用户: + 已连接为 @%1$s + 二维码将在 %1$s 后过期 + 保存并重启 + 正在保存配置… + 打开 Telegram 扫描应用内显示的二维码。 + Telegram 用户 ID + 任务 %1$d/%2$d + 耗时:%1$s + · 已脱敏 + 搜索工具集… + 播放视频 + 删除订阅 + 此 Webhook 的作用… + 接收地址:%1$s + 已配置密钥 + Dump + + %1$s 已开始 + %1$s 失败:%2$s + diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 340ebfe2..b56533a1 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -224,6 +224,7 @@ Language System English + 简体中文 한국어 @@ -1043,4 +1044,113 @@ Could not save image Could not load image: %1$s Could not share image + + Most Used Components + No model data available + No skill data available + No tool data available + Overview of your most active models, skills, and tools. + Credentials are written to %1$s + Expires: %1$s + Search channels… + Attach file + Save %1$s + Switch model (this chat) + Clear value + Invalid JSON + No matches + Path: %1$s + Reset to default + Save changes + Search settings… + Cache read + Cache write + Completion (output) + compressed ×%1$s + Prompt (input) + Prompt tokens reflect total context used by this session so far. + Context window + Play GIF + Filter tasks by title, status, or assignee... + Hermes Logs + Search logs... + No servers match "%1$s" + Search MCP servers... + Learned Skills + Memories & Facts + Recent Agent Self-Improvement Events + Reasoning · %1$s steps + Your response + Add preset + Add reference model + new preset name + No presets configured + Preset: + Presets appear as models under the Mixture of Agents provider. + Reference models + Search providers... + Set default + Mixture of Agents + Auxiliary tasks + Auxiliary Tasks + Expensive Model + Main model + Mixture of Agents + applies to new sessions + Loading models… + No models available. + Search models and providers... + Reset all to auto + Search models... + Set Aux: %1$s + Set Main Model + Model Settings + This will permanently remove this platform\'s configuration. + Remove %1$s? + Remove platform + Set up with QR + owner/repo, owner/repo/subdir, or https://... + Search plugins... + Describe the purpose of this profile + Command: %1$s + Remove Server + Transport: %1$s + Create Profile + More options + via %1$s + Source: %1$s + AGENT PLAN + LIVE TRANSCRIPT + No active tasks or plan items. + Summary: %1$s + Task & Plan Inspection + Auth required + Checkpoint prune + Config migrate + Curator run + ... %1$d more line(s) + Prompt size check + Security audit + Skills update + Add user ID + Allowed users: + Connected as @%1$s + QR code expires in: %1$s + Save and restart + Saving configuration… + Open Telegram and scan the QR code displayed in the app. + Telegram user ID + Tasks %1$d/%2$d + Duration: %1$s + · redacted + Search toolsets... + Play Video + Delete subscription + What this webhook does... + Receiver URL: %1$s + Secret configured + Dump + + %1$s started + %1$s failed: %2$s diff --git a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt index 7b873862..ffe83b49 100644 --- a/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/E2eIntegrationTest.kt @@ -1182,7 +1182,7 @@ class E2eIntegrationTest { Response.success(UpdateCheckResponse()) coEvery { mockApiService.triggerBackup(BackupTriggerRequest()) } returns Response.success(ActionResponse()) - val viewModel = SystemViewModel() + val viewModel = SystemViewModel(mockApp) viewModel.loadAll() advanceUntilIdle() diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt index 2d7ac3c4..4dbbfc15 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/CookieManagerTest.kt @@ -103,7 +103,7 @@ class CookieManagerTest { } @Test - fun setSessionCookie_hostScoped_onlyMatchesThatHost() = + fun setSessionCookie_isUsableAcrossHosts() = runTest { val jar = fakeJar() CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local")) @@ -115,8 +115,11 @@ class CookieManagerTest { assertEquals(1, onHost.size) assertEquals("sess-xyz", onHost[0].value) + // Session cookie mirrors to the WILDCARD bucket — usable on any + // host within the same server scope (WiFi/Tailscale switch-safe). val offHost = jar.loadForRequest("http://other.local/".toHttpUrl()) - assertEquals(0, offHost.size) + assertEquals(1, offHost.size) + assertEquals("sess-xyz", offHost[0].value) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt index 27942aed..84c6b02a 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/GatewayFileClientTest.kt @@ -1,15 +1,35 @@ package com.m57.hermescontrol.data.remote +import com.m57.hermescontrol.data.config.ServerStore +import com.m57.hermescontrol.data.config.ServerStoreState +import com.m57.hermescontrol.data.local.AuthManager +import io.mockk.every +import io.mockk.mockk import org.junit.Assert.assertEquals import org.junit.Assert.assertFalse import org.junit.Assert.assertNull import org.junit.Assert.assertTrue +import org.junit.Before import org.junit.Test class GatewayFileClientTest { private val base = "https://gw.example.com:9119" private val tok = "s e/cret" + @Before + fun setUp() { + // AuthManager.isGatedMode() reads serverStore — inject a mock whose + // wsAuthParam is NOT "ticket" so the tests exercise the loopback + // branch (token stamped into the URL). Keeps this builder test + // independent of real AuthManager initialization. + val mockStore = mockk(relaxed = true) + every { mockStore.getLatestState() } returns + ServerStoreState(wsAuthParam = "bearer") + val field = AuthManager::class.java.getDeclaredField("_serverStore") + field.isAccessible = true + field.set(AuthManager, mockStore) + } + @Test fun `buildDownloadUrl encodes path and token`() { val url = GatewayFileClient.buildDownloadUrl(base, tok, "/tmp/foo.png")!! diff --git a/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt b/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt index 6344db27..e729a6f2 100644 --- a/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/data/remote/PersistentCookieJarTest.kt @@ -54,7 +54,7 @@ class PersistentCookieJarTest { } @Test - fun cookie_doesNotLeakToOtherHost() = + fun sessionCookie_isUsableAcrossHostsWithinServer() = runTest { val jar = makeJar() jar.useStore("server-a") @@ -63,8 +63,12 @@ class PersistentCookieJarTest { listOf(sessionCookie("dashboard.local", "abc123")), ) + // Session cookies mirror to the WILDCARD bucket (host-agnostic) + // so the authenticated session survives WiFi/Tailscale host + // switches — but only within the same server scope. val loaded = jar.loadForRequest("http://evil.local/".toHttpUrl()) - assertTrue(loaded.isEmpty()) + assertEquals(1, loaded.size) + assertEquals("abc123", loaded[0].value) } @Test diff --git a/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt b/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt index 16369a97..0b01b4c3 100644 --- a/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt +++ b/app/src/test/java/com/m57/hermescontrol/ui/system/SystemViewModelTest.kt @@ -1,5 +1,7 @@ package com.m57.hermescontrol.ui.system +import android.app.Application +import io.mockk.mockk import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.StandardTestDispatcher @@ -14,6 +16,7 @@ import org.junit.Test @OptIn(ExperimentalCoroutinesApi::class) class SystemViewModelTest { private val testDispatcher = StandardTestDispatcher() + private val mockApp = mockk(relaxed = true) @Before fun setup() { @@ -27,7 +30,7 @@ class SystemViewModelTest { @Test fun `openUpdateConfirm sets updateConfirmOpen to true`() { - val viewModel = SystemViewModel() + val viewModel = SystemViewModel(mockApp) assertFalse(viewModel.uiState.value.updateConfirmOpen) @@ -38,7 +41,7 @@ class SystemViewModelTest { @Test fun `closeUpdateConfirm sets updateConfirmOpen to false`() { - val viewModel = SystemViewModel() + val viewModel = SystemViewModel(mockApp) // First open it viewModel.openUpdateConfirm()