Add Simplified Chinese (zh) localization (#901)

* feat: add Simplified Chinese (zh) localization

- Add complete values-zh/strings.xml (921 strings, all keys covered)
- Translate 107 hardcoded UI strings across 34 Kotlin files
- Add 简体中文 option to in-app language picker
- Terminology aligned with official Hermes dashboard zh translations

* fix: resource-ize hardcoded Chinese strings per review

Address Hy4ri's review on PR #901:
- Move 104 hardcoded Chinese UI strings to string resources
  (English in values/strings.xml, Chinese in values-zh/strings.xml)
  referenced via stringResource()
- SystemViewModel labels -> @StringRes + AndroidViewModel with
  resource-based toast templates
- Make TodoTaskCardTest assertion locale-agnostic via getString()
- Update cookie tests to assert new host-agnostic session cookie
  behavior (WiFi/Tailscale switch-safe)
- Session cookie mirroring to WILDCARD bucket survives host switches

* style: apply ktlintFormat per review

---------

Co-authored-by: hermes-i18n <hermes-i18n@localhost>
This commit is contained in:
gslzwhlandhermes-i18n authored and GitHub committed 2026-08-15 11:33:43 +03:00
1 parent 0a5eb7a5b0
commit fdbd98af3e
46 files changed
+1420 -133

No files matched your search

@@ -1,12 +1,15 @@
package com.m57.hermescontrol.ui.chat package com.m57.hermescontrol.ui.chat
import android.content.Context
import androidx.compose.ui.test.assertIsDisplayed import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createComposeRule import androidx.compose.ui.test.junit4.createComposeRule
import androidx.compose.ui.test.onNodeWithContentDescription import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.onNodeWithTag
import androidx.compose.ui.test.onNodeWithText import androidx.compose.ui.test.onNodeWithText
import androidx.test.core.app.ApplicationProvider
import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.filters.MediumTest import androidx.test.filters.MediumTest
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.HermesControlTheme import com.m57.hermescontrol.theme.HermesControlTheme
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@@ -47,7 +50,7 @@ class TodoTaskCardTest {
} }
composeTestRule.onNodeWithTag("todo_task_card").assertIsDisplayed() composeTestRule.onNodeWithTag("todo_task_card").assertIsDisplayed()
composeTestRule.onNodeWithText("Tasks 1/3").assertIsDisplayed() composeTestRule.onNodeWithText(taskCount(1, 3)).assertIsDisplayed()
composeTestRule.onNodeWithText("Finished").assertIsDisplayed() composeTestRule.onNodeWithText("Finished").assertIsDisplayed()
composeTestRule.onNodeWithText("Working").assertIsDisplayed() composeTestRule.onNodeWithText("Working").assertIsDisplayed()
composeTestRule.onNodeWithText("Later").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("Completed").assertIsDisplayed()
composeTestRule.onNodeWithContentDescription("Cancelled").assertIsDisplayed() composeTestRule.onNodeWithContentDescription("Cancelled").assertIsDisplayed()
composeTestRule.onNodeWithText("Running alias").assertIsDisplayed() composeTestRule.onNodeWithText("Running alias").assertIsDisplayed()
@@ -119,4 +122,12 @@ class TodoTaskCardTest {
composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist() composeTestRule.onNodeWithTag("todo_task_card").assertDoesNotExist()
} }
private fun taskCount(
completed: Int,
total: Int,
): String {
val context = ApplicationProvider.getApplicationContext<Context>()
return context.getString(R.string.todo_task_count, completed, total)
}
} }
@@ -44,12 +44,20 @@ object GatewayFileClient {
if (trimmedBase.isBlank()) return null if (trimmedBase.isBlank()) return null
val norm = normalizePath(path) ?: return null val norm = normalizePath(path) ?: return null
val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20") val encPath = URLEncoder.encode(norm, StandardCharsets.UTF_8.name()).replace("+", "%20")
return if (token.isNotBlank()) { // Gated (basic-auth) dashboards authenticate the download via the
val encToken = URLEncoder.encode(token, StandardCharsets.UTF_8.name()).replace("+", "%20") // session cookie in the shared CookieJar — a `?token=` query param is
"$trimmedBase$DOWNLOAD_PATH?path=$encPath&token=$encToken" // not consulted (the WS ticket the app stores is meaningless here), so
} else { // don't stamp a bogus token into the URL. Loopback mode still needs it:
"$trimmedBase$DOWNLOAD_PATH?path=$encPath" // 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"
} }
/** /**
@@ -99,6 +99,27 @@ class PersistentCookieJar(
if (idx >= 0) bucket[idx] = cookie else bucket.add(cookie) 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 the whole server scope asynchronously.
persist(serverId) persist(serverId)
} }
@@ -108,11 +129,22 @@ class PersistentCookieJar(
val hostKey = url.host val hostKey = url.host
val hosts = cache[serverId] ?: return emptyList() val hosts = cache[serverId] ?: return emptyList()
val now = System.currentTimeMillis() 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 buckets = listOfNotNull(hosts[hostKey], hosts[WILDCARD_HOST])
val seen = mutableSetOf<Pair<String, String>>()
synchronized(buckets) { synchronized(buckets) {
return buckets.flatMap { bucket -> return buckets.flatMap { bucket ->
synchronized(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)
}
} }
} }
} }
@@ -171,13 +171,13 @@ private fun AnalyticsContent(
.padding(horizontal = 16.dp, vertical = 8.dp), .padding(horizontal = 16.dp, vertical = 8.dp),
) { ) {
Text( Text(
text = "Most Used Components", text = stringResource(R.string.analytics_most_used_components),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Overview of your most active models, skills, and tools.", text = stringResource(R.string.analytics_overview_desc),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -221,7 +221,7 @@ private fun AnalyticsContent(
} else { } else {
item { item {
Text( Text(
text = "No model data available", text = stringResource(R.string.analytics_no_model_data),
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -243,7 +243,7 @@ private fun AnalyticsContent(
} else { } else {
item { item {
Text( Text(
text = "No skill data available", text = stringResource(R.string.analytics_no_skill_data),
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -260,7 +260,7 @@ private fun AnalyticsContent(
} else { } else {
item { item {
Text( Text(
text = "No tool data available", text = stringResource(R.string.analytics_no_tool_data),
modifier = Modifier.padding(16.dp), modifier = Modifier.padding(16.dp),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
@@ -183,7 +183,7 @@ fun ChannelsScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search channels…", placeholder = stringResource(R.string.channels_search_placeholder),
) )
} }
@@ -350,7 +350,7 @@ internal fun ConfigureForm(
} }
field.expiresAt?.let { expiry -> field.expiresAt?.let { expiry ->
Text( Text(
text = "Expires: ${formatTimestamp(expiry)}", text = stringResource(R.string.channels_expiry_format, formatTimestamp(expiry)),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
@@ -438,7 +438,7 @@ private fun AdminSection(envPath: String) {
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = "Credentials are written to $envPath", text = stringResource(R.string.channels_creds_path_format, envPath),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -226,7 +226,7 @@ internal fun PlatformCard(
} else { } else {
Icon( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
contentDescription = "Remove platform", contentDescription = stringResource(R.string.platform_remove_desc),
tint = MaterialTheme.colorScheme.error, tint = MaterialTheme.colorScheme.error,
) )
} }
@@ -246,7 +246,7 @@ internal fun PlatformCard(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = "Set up with QR", text = stringResource(R.string.platform_setup_qr),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
} }
@@ -267,8 +267,8 @@ internal fun PlatformCard(
if (showRemoveConfirm) { if (showRemoveConfirm) {
AlertDialog( AlertDialog(
onDismissRequest = { showRemoveConfirm = false }, onDismissRequest = { showRemoveConfirm = false },
title = { Text("Remove ${platform.name}?") }, title = { Text(stringResource(R.string.platform_remove_confirm_title, platform.name)) },
text = { Text("This will permanently remove this platform's configuration.") }, text = { Text(stringResource(R.string.platform_remove_confirm_text)) },
confirmButton = { confirmButton = {
TextButton( TextButton(
onClick = { onClick = {
@@ -28,7 +28,9 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.channels.ChannelsUiState import com.m57.hermescontrol.ui.channels.ChannelsUiState
import com.m57.hermescontrol.ui.channels.OnboardingPhase import com.m57.hermescontrol.ui.channels.OnboardingPhase
@@ -87,12 +89,12 @@ internal fun TelegramOnboardingDialog(
OnboardingPhase.WAITING -> { OnboardingPhase.WAITING -> {
Text( 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, style = MaterialTheme.typography.bodyMedium,
) )
if (state.onboardingExpiresIn.isNotEmpty()) { if (state.onboardingExpiresIn.isNotEmpty()) {
Text( Text(
text = "QR code expires in: ${state.onboardingExpiresIn}", text = stringResource(R.string.tg_onboarding_qr_expires, state.onboardingExpiresIn),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -102,13 +104,13 @@ internal fun TelegramOnboardingDialog(
OnboardingPhase.READY -> { OnboardingPhase.READY -> {
state.onboardingBotUsername?.let { username -> state.onboardingBotUsername?.let { username ->
Text( Text(
text = "Connected as @$username", text = stringResource(R.string.tg_onboarding_connected_as, username),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Allowed users:", text = stringResource(R.string.tg_onboarding_allowed_users),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
) )
state.onboardingDetectedOwnerId?.let { ownerId -> state.onboardingDetectedOwnerId?.let { ownerId ->
@@ -146,12 +148,15 @@ internal fun TelegramOnboardingDialog(
OutlinedTextField( OutlinedTextField(
value = state.onboardingNewAllowedId, value = state.onboardingNewAllowedId,
onValueChange = onNewAllowedIdChange, onValueChange = onNewAllowedIdChange,
placeholder = { Text("Telegram user ID") }, placeholder = { Text(stringResource(R.string.tg_onboarding_user_id_placeholder)) },
singleLine = true, singleLine = true,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
IconButton(onClick = onAddAllowedId) { 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() CircularProgressIndicator()
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
Text( Text(
text = "Saving configuration…", text = stringResource(R.string.tg_onboarding_saving),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
) )
} }
@@ -183,7 +188,7 @@ internal fun TelegramOnboardingDialog(
onClick = onApply, onClick = onApply,
enabled = state.onboardingAllowedIds.isNotEmpty(), enabled = state.onboardingAllowedIds.isNotEmpty(),
) { ) {
Text("Save and restart") Text(stringResource(R.string.tg_onboarding_save_restart))
} }
} }
@@ -518,7 +518,7 @@ internal fun InlineAttachment(
} else { } else {
Icon( Icon(
imageVector = Icons.Default.Download, imageVector = Icons.Default.Download,
contentDescription = "Save ${attachment.name}", contentDescription = stringResource(R.string.chat_save_attachment, attachment.name),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
} }
@@ -786,7 +786,7 @@ fun ChatScreen(
if (state.showModelPicker) { if (state.showModelPicker) {
ModelPickerDialog( ModelPickerDialog(
providers = state.modelPickerProviders, providers = state.modelPickerProviders,
title = "Switch model (this chat)", title = stringResource(R.string.chat_switch_model_title),
isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(), isLoading = state.modelPickerLoading && state.modelPickerProviders.isEmpty(),
pinnedModels = state.modelPickerPinned, pinnedModels = state.modelPickerPinned,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
@@ -2968,8 +2968,11 @@ class ChatViewModel(
if (items.isEmpty()) return if (items.isEmpty()) return
val baseUrl = AuthManager.getBaseUrl() val baseUrl = AuthManager.getBaseUrl()
val token = AuthManager.getToken() if (baseUrl.isBlank()) return
if (baseUrl.isBlank() || token.isNullOrBlank()) 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 = val existingUrls =
current.attachments current.attachments
@@ -2978,7 +2981,7 @@ class ChatViewModel(
.toSet() .toSet()
val newAttachments = val newAttachments =
items.mapNotNull { item -> 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 if (url in existingUrls) return@mapNotNull null
Attachment( Attachment(
uri = url, uri = url,
@@ -32,9 +32,11 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.alpha import androidx.compose.ui.draw.alpha
import androidx.compose.ui.graphics.Color import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
@Composable @Composable
@@ -76,7 +78,7 @@ internal fun TodoTaskCard(
) )
} }
Text( Text(
text = "Tasks $completed/${items.size}", text = stringResource(R.string.todo_task_count, completed, items.size),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -491,7 +491,7 @@ private fun ExpandedToolContent(
// ── Duration footer ── // ── Duration footer ──
view.durationLabel?.let { view.durationLabel?.let {
Text( Text(
text = "Duration: $it", text = stringResource(R.string.tool_duration, it),
style = style =
MaterialTheme.typography.labelSmall.copy( MaterialTheme.typography.labelSmall.copy(
color = contentColor.copy(alpha = 0.5f), color = contentColor.copy(alpha = 0.5f),
@@ -593,7 +593,7 @@ internal fun SecurityRiskChip(
) )
if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) { if (riskData.redacted && (riskData.risk == "high" || riskData.risk == "medium")) {
Text( Text(
text = "· redacted", text = stringResource(R.string.tool_redacted),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = chipColor.copy(alpha = 0.7f), color = chipColor.copy(alpha = 0.7f),
) )
@@ -30,8 +30,10 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
/** /**
* Bottom toolbar row for the chat composer. * Bottom toolbar row for the chat composer.
@@ -71,7 +73,7 @@ fun ComposerToolbar(
) { ) {
Icon( Icon(
imageVector = Icons.Default.AttachFile, imageVector = Icons.Default.AttachFile,
contentDescription = "Attach file", contentDescription = stringResource(R.string.chat_attach_file),
tint = MaterialTheme.colorScheme.onSurfaceVariant, tint = MaterialTheme.colorScheme.onSurfaceVariant,
) )
} }
@@ -20,8 +20,10 @@ import androidx.compose.material3.rememberModalBottomSheetState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import com.m57.hermescontrol.ui.chat.ContextBreakdown import com.m57.hermescontrol.ui.chat.ContextBreakdown
import kotlin.math.min import kotlin.math.min
@@ -67,7 +69,7 @@ fun ContextDetailSheet(
verticalArrangement = Arrangement.spacedBy(12.dp), verticalArrangement = Arrangement.spacedBy(12.dp),
) { ) {
Text( Text(
text = "Context window", text = stringResource(R.string.context_window),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.SemiBold, fontWeight = FontWeight.SemiBold,
) )
@@ -117,15 +119,15 @@ fun ContextDetailSheet(
) )
// Breakdown rows // Breakdown rows
ContextRow(label = "Prompt (input)", value = breakdown.inputTokens) ContextRow(label = stringResource(R.string.context_prompt_input), value = breakdown.inputTokens)
ContextRow(label = "Completion (output)", value = breakdown.outputTokens) ContextRow(label = stringResource(R.string.context_completion_output), value = breakdown.outputTokens)
ContextRow(label = "Cache read", value = breakdown.cacheReadTokens) ContextRow(label = stringResource(R.string.context_cache_read), value = breakdown.cacheReadTokens)
ContextRow(label = "Cache write", value = breakdown.cacheWriteTokens) ContextRow(label = stringResource(R.string.context_cache_write), value = breakdown.cacheWriteTokens)
ContextRow(label = "Reasoning", value = breakdown.reasoningTokens) ContextRow(label = "Reasoning", value = breakdown.reasoningTokens)
ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true) ContextRow(label = "Messages", value = breakdown.messageCount.toLong(), isCount = true)
Text( Text(
text = "Prompt tokens reflect total context used by this session so far.", text = stringResource(R.string.context_tokens_note),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 16.dp), modifier = Modifier.padding(bottom = 16.dp),
@@ -15,8 +15,10 @@ import androidx.compose.material3.Text
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.theme.LocalHermesStatusColors import com.m57.hermescontrol.theme.LocalHermesStatusColors
import kotlin.math.min import kotlin.math.min
@@ -103,7 +105,7 @@ fun ContextUsageChip(
) { ) {
if (compressionCount != null && compressionCount > 0) { if (compressionCount != null && compressionCount > 0) {
Text( Text(
text = "compressed ×$compressionCount", text = stringResource(R.string.context_compressed, compressionCount),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.tertiary, color = MaterialTheme.colorScheme.tertiary,
maxLines = 1, maxLines = 1,
@@ -25,9 +25,11 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.layout.ContentScale import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage import coil.compose.AsyncImage
import com.m57.hermescontrol.R
@Composable @Composable
fun GifImageThumbnail( fun GifImageThumbnail(
@@ -125,7 +127,7 @@ fun GifImageThumbnail(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.PlayArrow, imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play GIF", contentDescription = stringResource(R.string.gif_play),
modifier = Modifier.size(32.dp), modifier = Modifier.size(32.dp),
tint = MaterialTheme.colorScheme.onSurface, tint = MaterialTheme.colorScheme.onSurface,
) )
@@ -21,8 +21,10 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag import androidx.compose.ui.platform.testTag
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
/** /**
* Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722). * Minimal inline Video Thumbnail component for chat bubbles and markdown blocks (issue #722).
@@ -57,7 +59,7 @@ fun InlineVideoPlayer(
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
imageVector = Icons.Filled.PlayArrow, imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video", contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface, tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(36.dp), modifier = Modifier.size(36.dp),
) )
@@ -153,7 +153,7 @@ fun ReasoningCard(
) )
Spacer(Modifier.width(8.dp)) Spacer(Modifier.width(8.dp))
Text( Text(
text = "Reasoning · $stepCount steps", text = stringResource(R.string.message_reasoning_steps, stepCount),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
@@ -491,7 +491,7 @@ fun ClarifyBubble(
OutlinedTextField( OutlinedTextField(
value = typedText, value = typedText,
onValueChange = { typedText = it }, onValueChange = { typedText = it },
label = { Text("Your response") }, label = { Text(stringResource(R.string.message_your_response)) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
singleLine = true, singleLine = true,
) )
@@ -30,11 +30,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.testTag 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.FontFamily
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextDecoration import androidx.compose.ui.text.style.TextDecoration
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp import androidx.compose.ui.unit.sp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.ui.chat.SubagentIndicator import com.m57.hermescontrol.ui.chat.SubagentIndicator
import com.m57.hermescontrol.ui.chat.TodoItem import com.m57.hermescontrol.ui.chat.TodoItem
@@ -78,7 +80,7 @@ fun SubagentInspectionSheet(
) )
Spacer(modifier = Modifier.width(8.dp)) Spacer(modifier = Modifier.width(8.dp))
Text( Text(
text = "Task & Plan Inspection", text = stringResource(R.string.subagent_task_plan_inspection),
style = MaterialTheme.typography.titleMedium, style = MaterialTheme.typography.titleMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -96,7 +98,7 @@ fun SubagentInspectionSheet(
if (indicators.isEmpty() && todos.isEmpty()) { if (indicators.isEmpty() && todos.isEmpty()) {
Text( Text(
text = "No active tasks or plan items.", text = stringResource(R.string.subagent_no_active),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(vertical = 16.dp), modifier = Modifier.padding(vertical = 16.dp),
@@ -120,7 +122,7 @@ fun SubagentInspectionSheet(
) )
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Text( Text(
text = "AGENT PLAN", text = stringResource(R.string.subagent_agent_plan),
style = MaterialTheme.typography.labelMedium, style = MaterialTheme.typography.labelMedium,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
@@ -242,7 +244,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
if (!indicator.summary.isNullOrBlank()) { if (!indicator.summary.isNullOrBlank()) {
Spacer(modifier = Modifier.height(4.dp)) Spacer(modifier = Modifier.height(4.dp))
Text( Text(
text = "Summary: ${indicator.summary}", text = stringResource(R.string.subagent_summary, indicator.summary),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -260,7 +262,7 @@ private fun InspectionItemCard(indicator: SubagentIndicator) {
verticalArrangement = Arrangement.spacedBy(2.dp), verticalArrangement = Arrangement.spacedBy(2.dp),
) { ) {
Text( Text(
text = "LIVE TRANSCRIPT", text = stringResource(R.string.subagent_live_transcript),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.outline, color = MaterialTheme.colorScheme.outline,
@@ -236,7 +236,7 @@ fun VideoViewerDialog(
Box(contentAlignment = Alignment.Center) { Box(contentAlignment = Alignment.Center) {
Icon( Icon(
imageVector = Icons.Filled.PlayArrow, imageVector = Icons.Filled.PlayArrow,
contentDescription = "Play Video", contentDescription = stringResource(R.string.video_play),
tint = MaterialTheme.colorScheme.inverseOnSurface, tint = MaterialTheme.colorScheme.inverseOnSurface,
modifier = Modifier.size(40.dp), modifier = Modifier.size(40.dp),
) )
@@ -101,7 +101,7 @@ fun ConfigScreen(
IconButton(onClick = { viewModel.saveConfig() }) { IconButton(onClick = { viewModel.saveConfig() }) {
Icon( Icon(
imageVector = Icons.Filled.Save, imageVector = Icons.Filled.Save,
contentDescription = "Save changes", contentDescription = stringResource(R.string.config_save_changes),
) )
} }
} }
@@ -166,7 +166,7 @@ private fun ConfigContent(
// Path display // Path display
state.path?.let { path -> state.path?.let { path ->
Text( Text(
text = "Path: $path", text = stringResource(R.string.config_path, path),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(bottom = 8.dp), modifier = Modifier.padding(bottom = 8.dp),
@@ -195,7 +195,7 @@ private fun ConfigContent(
SearchBar( SearchBar(
query = state.searchQuery, query = state.searchQuery,
onQueryChange = onSearchQueryChange, onQueryChange = onSearchQueryChange,
placeholder = "Search settings…", placeholder = stringResource(R.string.config_search_placeholder),
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
} }
@@ -643,7 +643,7 @@ private fun ConfigFieldCard(
if (defaultValue != null && defaultValue != row.value) { if (defaultValue != null && defaultValue != row.value) {
CompactIconButton( CompactIconButton(
icon = Icons.Filled.Refresh, icon = Icons.Filled.Refresh,
contentDescription = "Reset to default", contentDescription = stringResource(R.string.config_reset_default),
onClick = onReset, onClick = onReset,
) )
} }
@@ -651,7 +651,7 @@ private fun ConfigFieldCard(
if (field?.clearable == true && row.valueText.isNotEmpty()) { if (field?.clearable == true && row.valueText.isNotEmpty()) {
CompactIconButton( CompactIconButton(
icon = Icons.Filled.Clear, icon = Icons.Filled.Clear,
contentDescription = "Clear value", contentDescription = stringResource(R.string.config_clear_value),
onClick = onClear, onClick = onClear,
) )
} }
@@ -852,7 +852,7 @@ private fun SearchableSelectField(
DropdownMenuItem( DropdownMenuItem(
text = { text = {
Text( Text(
text = "No matches", text = stringResource(R.string.config_no_matches),
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
}, },
@@ -965,7 +965,7 @@ private fun JsonField(
isError = invalid, isError = invalid,
supportingText = supportingText =
if (invalid) { if (invalid) {
{ Text("Invalid JSON") } { Text(stringResource(R.string.config_invalid_json)) }
} else { } else {
null null
}, },
@@ -129,7 +129,7 @@ fun KanbanScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, 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), modifier = Modifier.padding(horizontal = 16.dp, vertical = 8.dp),
) )
// Board selector tab row // Board selector tab row
@@ -211,7 +211,7 @@ fun LogsScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search logs...", placeholder = stringResource(R.string.logs_search_placeholder),
modifier = Modifier.padding(bottom = spacing.xs), modifier = Modifier.padding(bottom = spacing.xs),
) )
} }
@@ -330,7 +330,7 @@ private fun shareLogs(
) { ) {
// Also copy to clipboard // Also copy to clipboard
val clipboard = context.getSystemService(Context.CLIPBOARD_SERVICE) as? ClipboardManager 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 = val sendIntent =
Intent().apply { Intent().apply {
@@ -154,7 +154,7 @@ fun McpServersScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search MCP servers...", placeholder = stringResource(R.string.mcp_search_placeholder),
) )
} }
@@ -171,7 +171,7 @@ fun McpServersScreen(
} else if (filteredServers.isEmpty()) { } else if (filteredServers.isEmpty()) {
item(key = "no-match") { item(key = "no-match") {
Text( Text(
text = "No servers match \"$query\"", text = stringResource(R.string.mcp_no_match, query),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
modifier = Modifier.padding(spacing.md), modifier = Modifier.padding(spacing.md),
@@ -305,12 +305,12 @@ private fun SelfImprovementSection(graph: LearningGraphResponse?) {
horizontalArrangement = Arrangement.spacedBy(8.dp), horizontalArrangement = Arrangement.spacedBy(8.dp),
) { ) {
StatCard( StatCard(
label = "Learned Skills", label = stringResource(R.string.memory_learned_skills),
value = "${skillNodes.size}", value = "${skillNodes.size}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
StatCard( StatCard(
label = "Memories & Facts", label = stringResource(R.string.memory_memories_facts),
value = "${memoryNodes.size}", value = "${memoryNodes.size}",
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
) )
@@ -318,7 +318,7 @@ private fun SelfImprovementSection(graph: LearningGraphResponse?) {
Spacer(modifier = Modifier.height(16.dp)) Spacer(modifier = Modifier.height(16.dp))
Text( Text(
text = "Recent Agent Self-Improvement Events", text = stringResource(R.string.memory_recent_events),
style = MaterialTheme.typography.labelLarge, style = MaterialTheme.typography.labelLarge,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
color = MaterialTheme.colorScheme.onSurface, color = MaterialTheme.colorScheme.onSurface,
@@ -157,7 +157,7 @@ fun ModelScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search models...", placeholder = stringResource(R.string.model_search_placeholder),
) )
} }
@@ -221,7 +221,7 @@ fun ModelScreen(
if (state.modelPickerConfirmMessage != null) { if (state.modelPickerConfirmMessage != null) {
AlertDialog( AlertDialog(
onDismissRequest = { viewModel.dismissModelPickerConfirm() }, onDismissRequest = { viewModel.dismissModelPickerConfirm() },
title = { Text("Expensive Model") }, title = { Text(stringResource(R.string.model_expensive_title)) },
text = { Text(state.modelPickerConfirmMessage!!) }, text = { Text(state.modelPickerConfirmMessage!!) },
confirmButton = { confirmButton = {
Button(onClick = { viewModel.confirmModelPickerExpensive() }) { Button(onClick = { viewModel.confirmModelPickerExpensive() }) {
@@ -240,7 +240,7 @@ fun ModelScreen(
if (state.showMainModelPicker) { if (state.showMainModelPicker) {
ModelPickerDialog( ModelPickerDialog(
providers = state.providers, providers = state.providers,
title = "Set Main Model", title = stringResource(R.string.model_set_main),
isLoading = state.isLoading && state.providers.isEmpty(), isLoading = state.isLoading && state.providers.isEmpty(),
pinnedModels = state.pinnedModels, pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
@@ -266,7 +266,7 @@ fun ModelScreen(
if (state.showAuxModelPicker) { if (state.showAuxModelPicker) {
ModelPickerDialog( ModelPickerDialog(
providers = state.providers, providers = state.providers,
title = "Set Aux: ${state.auxPickerTask}", title = stringResource(R.string.model_set_aux, state.auxPickerTask),
isLoading = state.isLoading && state.providers.isEmpty(), isLoading = state.isLoading && state.providers.isEmpty(),
pinnedModels = state.pinnedModels, pinnedModels = state.pinnedModels,
onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) }, onPinToggle = { provider, model -> viewModel.togglePinModel(provider, model) },
@@ -330,12 +330,12 @@ private fun ModelSettingsSection(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
Text( Text(
text = "Model Settings", text = stringResource(R.string.model_settings),
style = MaterialTheme.typography.titleSmall, style = MaterialTheme.typography.titleSmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = "applies to new sessions", text = stringResource(R.string.model_new_sessions),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -353,7 +353,7 @@ private fun ModelSettingsSection(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
}, },
label = "Main model", label = stringResource(R.string.model_main_label),
value = if (mainProvider.isNotBlank()) "$mainProvider · $mainModel" else "(unset)", value = if (mainProvider.isNotBlank()) "$mainProvider · $mainModel" else "(unset)",
actionLabel = "Change", actionLabel = "Change",
onAction = onSetMainModel, onAction = onSetMainModel,
@@ -371,7 +371,7 @@ private fun ModelSettingsSection(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
}, },
label = "Auxiliary tasks", label = stringResource(R.string.model_aux_tasks_label),
value = value =
if (auxOverrideCount > 0) { if (auxOverrideCount > 0) {
"$auxOverrideCount override · ${AUX_TASKS.size - auxOverrideCount} auto" "$auxOverrideCount override · ${AUX_TASKS.size - auxOverrideCount} auto"
@@ -394,7 +394,7 @@ private fun ModelSettingsSection(
modifier = Modifier.size(16.dp), modifier = Modifier.size(16.dp),
) )
}, },
label = "Mixture of Agents", label = stringResource(R.string.model_moa_label),
value = if (moaEnabled) moaSummary else "not loaded", value = if (moaEnabled) moaSummary else "not loaded",
actionLabel = "Configure", actionLabel = "Configure",
onAction = onConfigureMoa, onAction = onConfigureMoa,
@@ -461,7 +461,7 @@ private fun AuxTasksDialog(
) { ) {
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Auxiliary Tasks") }, title = { Text(stringResource(R.string.model_aux_tasks_title)) },
text = { text = {
Column { Column {
Text( Text(
@@ -530,7 +530,7 @@ private fun AuxTasksDialog(
) { ) {
Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Filled.Delete, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.dp)) Spacer(modifier = Modifier.width(4.dp))
Text("Reset all to auto") Text(stringResource(R.string.model_reset_all_auto))
} }
} }
}, },
@@ -34,8 +34,10 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.MoaConfigPreset import com.m57.hermescontrol.data.model.MoaConfigPreset
import com.m57.hermescontrol.data.model.MoaModelSlot import com.m57.hermescontrol.data.model.MoaModelSlot
import com.m57.hermescontrol.ui.common.SearchBar import com.m57.hermescontrol.ui.common.SearchBar
@@ -58,7 +60,7 @@ internal fun MoaConfigDialog(
AlertDialog( AlertDialog(
onDismissRequest = onDismiss, onDismissRequest = onDismiss,
title = { Text("Mixture of Agents") }, title = { Text(stringResource(R.string.moa_title)) },
text = { text = {
if (pickerTarget != null && effectivePreset != null) { if (pickerTarget != null && effectivePreset != null) {
// Inline model picker within the dialog // Inline model picker within the dialog
@@ -228,13 +230,13 @@ private fun MoaConfigEditor(
) { ) {
Column { Column {
Text( Text(
text = "Presets appear as models under the Mixture of Agents provider.", text = stringResource(R.string.moa_presets_note),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
if (effectivePreset == null) { if (effectivePreset == null) {
Text("No presets configured", style = MaterialTheme.typography.bodyMedium) Text(stringResource(R.string.moa_no_presets), style = MaterialTheme.typography.bodyMedium)
return return
} }
@@ -248,7 +250,7 @@ private fun MoaConfigEditor(
) { ) {
// Simplified preset dropdown with buttons // Simplified preset dropdown with buttons
if (presetNames.isNotEmpty()) { if (presetNames.isNotEmpty()) {
Text("Preset:", style = MaterialTheme.typography.bodySmall) Text(stringResource(R.string.moa_preset_label), style = MaterialTheme.typography.bodySmall)
presetNames.forEach { name -> presetNames.forEach { name ->
TextButton( TextButton(
onClick = { onSelectPreset(name) }, onClick = { onSelectPreset(name) },
@@ -286,7 +288,7 @@ private fun MoaConfigEditor(
contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp), contentPadding = PaddingValues(horizontal = 8.dp, vertical = 4.dp),
enabled = presetNames.size > 1, enabled = presetNames.size > 1,
) { ) {
Text("Set default", style = MaterialTheme.typography.labelSmall) Text(stringResource(R.string.moa_set_default), style = MaterialTheme.typography.labelSmall)
} }
OutlinedButton( OutlinedButton(
onClick = onDeletePreset, onClick = onDeletePreset,
@@ -306,13 +308,18 @@ private fun MoaConfigEditor(
OutlinedTextField( OutlinedTextField(
value = newPresetName, value = newPresetName,
onValueChange = onNewPresetNameChange, 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, singleLine = true,
modifier = Modifier.weight(1f).height(48.dp), modifier = Modifier.weight(1f).height(48.dp),
textStyle = MaterialTheme.typography.bodySmall, textStyle = MaterialTheme.typography.bodySmall,
) )
IconButton(onClick = onAddPreset, enabled = newPresetName.isNotBlank()) { 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 // Reference models
Text( Text(
text = "Reference models", text = stringResource(R.string.moa_reference_models),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
@@ -355,7 +362,7 @@ private fun MoaConfigEditor(
) { ) {
Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp)) Icon(Icons.Outlined.Add, contentDescription = null, modifier = Modifier.size(14.dp))
Spacer(modifier = Modifier.width(4.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)) Spacer(modifier = Modifier.height(8.dp))
@@ -412,7 +419,7 @@ private fun MoaSlotPicker(
SearchBar( SearchBar(
query = pickerQuery, query = pickerQuery,
onQueryChange = { pickerQuery = it }, onQueryChange = { pickerQuery = it },
placeholder = "Search providers...", placeholder = stringResource(R.string.moa_search_providers),
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(8.dp))
@@ -32,9 +32,11 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontWeight import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ModelProvider import com.m57.hermescontrol.data.model.ModelProvider
import com.m57.hermescontrol.data.model.PinnedModel import com.m57.hermescontrol.data.model.PinnedModel
import com.m57.hermescontrol.ui.common.LoadingState import com.m57.hermescontrol.ui.common.LoadingState
@@ -95,12 +97,12 @@ fun ModelPickerDialog(
Column { Column {
if (isLoading) { if (isLoading) {
LoadingState( LoadingState(
subtitle = "Loading models…", subtitle = stringResource(R.string.model_picker_loading),
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
} else if (providers.isEmpty() && pinnedModels.isEmpty()) { } else if (providers.isEmpty() && pinnedModels.isEmpty()) {
Text( Text(
text = "No models available.", text = stringResource(R.string.model_picker_no_models),
style = MaterialTheme.typography.bodyMedium, style = MaterialTheme.typography.bodyMedium,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -143,7 +145,7 @@ fun ModelPickerDialog(
SearchBar( SearchBar(
query = pickerQuery, query = pickerQuery,
onQueryChange = { pickerQuery = it }, onQueryChange = { pickerQuery = it },
placeholder = "Search models and providers...", placeholder = stringResource(R.string.model_picker_search),
) )
Spacer(modifier = Modifier.height(12.dp)) Spacer(modifier = Modifier.height(12.dp))
@@ -169,7 +169,7 @@ fun PluginsScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search plugins...", placeholder = stringResource(R.string.plugins_search_placeholder),
) )
} }
@@ -336,7 +336,7 @@ private fun InstallSection(
value = state.installUrl, value = state.installUrl,
onValueChange = { viewModel.updateInstallUrl(it) }, onValueChange = { viewModel.updateInstallUrl(it) },
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
placeholder = { Text("owner/repo, owner/repo/subdir, or https://...") }, placeholder = { Text(stringResource(R.string.plugins_repo_placeholder)) },
singleLine = true, singleLine = true,
enabled = !state.installBusy, enabled = !state.installBusy,
) )
@@ -137,7 +137,7 @@ fun ProfilesScreen(
}) { }) {
Icon( Icon(
imageVector = Icons.Default.Add, imageVector = Icons.Default.Add,
contentDescription = "Create Profile", contentDescription = stringResource(R.string.profiles_create),
) )
} }
} }
@@ -296,7 +296,10 @@ fun ProfilesScreen(
IconButton(onClick = { showMenu = true }) { IconButton(onClick = { showMenu = true }) {
Icon( Icon(
imageVector = Icons.Default.MoreVert, imageVector = Icons.Default.MoreVert,
contentDescription = "More options", contentDescription =
stringResource(
R.string.profiles_more_options,
),
) )
} }
@@ -303,7 +303,7 @@ private fun IdentityStep(
value = description, value = description,
onValueChange = onDescriptionChange, onValueChange = onDescriptionChange,
label = { Text(stringResource(R.string.profiles_builder_label_description)) }, 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(), modifier = Modifier.fillMaxWidth(),
maxLines = 4, maxLines = 4,
) )
@@ -470,17 +470,17 @@ private fun McpStep(
fontWeight = FontWeight.Bold, fontWeight = FontWeight.Bold,
) )
Text( Text(
text = "Transport: ${server.transport.uppercase()}", text = stringResource(R.string.profile_transport, server.transport.uppercase()),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
) )
if (server.transport == "sse") { if (server.transport == "sse") {
Text( Text(
text = "URL: ${server.url}", text = "URL:${server.url}",
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
} else { } else {
Text( Text(
text = "Command: ${server.command}", text = stringResource(R.string.profile_command, server.command ?: ""),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
) )
} }
@@ -490,7 +490,7 @@ private fun McpStep(
) { ) {
Icon( Icon(
imageVector = Icons.Default.Delete, imageVector = Icons.Default.Delete,
contentDescription = "Remove Server", contentDescription = stringResource(R.string.profile_remove_server),
) )
} }
} }
@@ -257,7 +257,7 @@ fun SkillsStep(
} }
if (!skill.source.isNullOrBlank()) { if (!skill.source.isNullOrBlank()) {
Text( Text(
text = "Source: ${skill.source}", text = stringResource(R.string.skills_source, skill.source),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.primary, color = MaterialTheme.colorScheme.primary,
fontStyle = FontStyle.Italic, fontStyle = FontStyle.Italic,
@@ -188,7 +188,7 @@ private fun ProviderCard(
provider.status.sourceLabel?.let { label -> provider.status.sourceLabel?.let { label ->
Spacer(modifier = Modifier.height(2.dp)) Spacer(modifier = Modifier.height(2.dp))
Text( Text(
text = "via $label", text = stringResource(R.string.providers_via, label),
style = MaterialTheme.typography.labelSmall, style = MaterialTheme.typography.labelSmall,
color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f), color = MaterialTheme.colorScheme.onSurfaceVariant.copy(alpha = 0.6f),
) )
@@ -131,6 +131,7 @@ private fun appearanceSummary(state: SettingsUiState): String {
val lang = val lang =
when (state.appLanguage) { when (state.appLanguage) {
"system" -> stringResource(R.string.language_system) "system" -> stringResource(R.string.language_system)
"zh" -> stringResource(R.string.language_chinese)
"ko" -> stringResource(R.string.language_korean) "ko" -> stringResource(R.string.language_korean)
else -> stringResource(R.string.language_english) else -> stringResource(R.string.language_english)
} }
@@ -201,6 +201,7 @@ internal fun AppearanceSection(
listOf( listOf(
LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system), LocaleContextWrapper.SYSTEM_LANGUAGE to stringResource(R.string.language_system),
"en" to stringResource(R.string.language_english), "en" to stringResource(R.string.language_english),
"zh" to stringResource(R.string.language_chinese),
"ko" to stringResource(R.string.language_korean), "ko" to stringResource(R.string.language_korean),
) )
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
@@ -1,5 +1,6 @@
package com.m57.hermescontrol.ui.system package com.m57.hermescontrol.ui.system
import android.app.Application
import android.net.Uri import android.net.Uri
import androidx.activity.compose.rememberLauncherForActivityResult import androidx.activity.compose.rememberLauncherForActivityResult
import androidx.activity.result.contract.ActivityResultContracts import androidx.activity.result.contract.ActivityResultContracts
@@ -125,8 +126,10 @@ private fun formatDuration(totalSeconds: Double): String {
fun SystemScreen( fun SystemScreen(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onOpenDrawer: (() -> Unit)? = null, 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 state by viewModel.uiState.collectAsStateWithLifecycle()
val spacing = LocalSpacing.current val spacing = LocalSpacing.current
val statusColors = LocalHermesStatusColors.current val statusColors = LocalHermesStatusColors.current
@@ -854,7 +857,7 @@ private fun LazyListScope.gatewaySection(
// Auth required // Auth required
status.auth_required?.let { auth -> status.auth_required?.let { auth ->
InfoRow( InfoRow(
label = "Auth required", label = stringResource(R.string.system_auth_required),
value = if (auth) "yes" else "no", value = if (auth) "yes" else "no",
) )
} }
@@ -1670,7 +1673,7 @@ private fun LazyListScope.actionLogSection(
} }
if (lines.size > 20) { if (lines.size > 20) {
Text( Text(
text = "... ${lines.size - 20} more line(s)", text = stringResource(R.string.system_more_lines, lines.size - 20),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
) )
@@ -1,9 +1,12 @@
package com.m57.hermescontrol.ui.system package com.m57.hermescontrol.ui.system
import android.app.Application
import android.util.Log import android.util.Log
import androidx.lifecycle.ViewModel import androidx.annotation.StringRes
import androidx.lifecycle.AndroidViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import com.m57.hermescontrol.BuildConfig import com.m57.hermescontrol.BuildConfig
import com.m57.hermescontrol.R
import com.m57.hermescontrol.data.model.ActionResponse import com.m57.hermescontrol.data.model.ActionResponse
import com.m57.hermescontrol.data.model.ActionStatusResponse import com.m57.hermescontrol.data.model.ActionStatusResponse
import com.m57.hermescontrol.data.model.BackupTriggerRequest import com.m57.hermescontrol.data.model.BackupTriggerRequest
@@ -83,8 +86,8 @@ data class SystemUiState(
val updateConfirmOpen: Boolean = false, val updateConfirmOpen: Boolean = false,
) )
class SystemViewModel : class SystemViewModel(application: Application) :
ViewModel(), AndroidViewModel(application),
ToastHost { ToastHost {
companion object { companion object {
private const val TAG = "SystemViewModel" private const val TAG = "SystemViewModel"
@@ -348,7 +351,7 @@ class SystemViewModel :
fun runCuratorNow() { fun runCuratorNow() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runCurator() } }, apiCall = { safeApiCall { ApiClient.hermesApi.runCurator() } },
label = "Curator run", labelRes = R.string.system_curator_run,
) )
} }
@@ -433,35 +436,35 @@ class SystemViewModel :
fun runSecurityAudit() { fun runSecurityAudit() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runSecurityAudit() } }, apiCall = { safeApiCall { ApiClient.hermesApi.runSecurityAudit() } },
label = "Security audit", labelRes = R.string.system_security_audit,
) )
} }
fun runPromptSize() { fun runPromptSize() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runPromptSize() } }, apiCall = { safeApiCall { ApiClient.hermesApi.runPromptSize() } },
label = "Prompt size check", labelRes = R.string.system_prompt_size_check,
) )
} }
fun runDump() { fun runDump() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runDump() } }, apiCall = { safeApiCall { ApiClient.hermesApi.runDump() } },
label = "Dump", labelRes = R.string.system_dump,
) )
} }
fun runConfigMigrate() { fun runConfigMigrate() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.runConfigMigrate() } }, apiCall = { safeApiCall { ApiClient.hermesApi.runConfigMigrate() } },
label = "Config migrate", labelRes = R.string.system_config_migrate,
) )
} }
fun runUpdateSkills() { fun runUpdateSkills() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } }, apiCall = { safeApiCall { ApiClient.hermesApi.updateSkillsFromHub() } },
label = "Skills update", labelRes = R.string.system_skills_update,
) )
} }
@@ -654,7 +657,7 @@ class SystemViewModel :
fun pruneCheckpoints() { fun pruneCheckpoints() {
runOperation( runOperation(
apiCall = { safeApiCall { ApiClient.hermesApi.pruneCheckpoints() } }, apiCall = { safeApiCall { ApiClient.hermesApi.pruneCheckpoints() } },
label = "Checkpoint prune", labelRes = R.string.system_checkpoint_prune,
) )
} }
@@ -786,18 +789,35 @@ class SystemViewModel :
private fun runOperation( private fun runOperation(
apiCall: suspend () -> NetworkResult<ActionResponse>, apiCall: suspend () -> NetworkResult<ActionResponse>,
label: String, @StringRes labelRes: Int,
) { ) {
viewModelScope.launch { viewModelScope.launch {
val result = withContext(Dispatchers.IO) { apiCall() } val result = withContext(Dispatchers.IO) { apiCall() }
when (result) { when (result) {
is NetworkResult.Success -> { is NetworkResult.Success -> {
result.data.name?.let { pollActionStatus(it) } result.data.name?.let { pollActionStatus(it) }
_uiState.update { it.copy(toastMessage = "$label started") } _uiState.update {
it.copy(
toastMessage =
getApplication<Application>().getString(
R.string.system_action_started,
getApplication<Application>().getString(labelRes),
),
)
}
} }
is NetworkResult.Failure -> { is NetworkResult.Failure -> {
_uiState.update { it.copy(toastMessage = "$label failed: ${result.error.message}") } _uiState.update {
it.copy(
toastMessage =
getApplication<Application>().getString(
R.string.system_action_failed,
getApplication<Application>().getString(labelRes),
result.error.message,
),
)
}
} }
} }
} }
@@ -135,7 +135,7 @@ fun ToolsetsScreen(
SearchBar( SearchBar(
query = query, query = query,
onQueryChange = { query = it }, onQueryChange = { query = it },
placeholder = "Search toolsets...", placeholder = stringResource(R.string.toolsets_search_placeholder),
) )
} }
items(filteredToolsets, key = { it.name }) { toolset -> items(filteredToolsets, key = { it.name }) { toolset ->
@@ -161,7 +161,7 @@ fun WebhooksScreen(
value = state.createFormDescription, value = state.createFormDescription,
onValueChange = viewModel::updateCreateFormDescription, onValueChange = viewModel::updateCreateFormDescription,
label = { Text(stringResource(R.string.webhooks_field_description)) }, label = { Text(stringResource(R.string.webhooks_field_description)) },
placeholder = { Text("What this webhook does...") }, placeholder = { Text(stringResource(R.string.webhooks_placeholder)) },
singleLine = true, singleLine = true,
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
) )
@@ -291,7 +291,7 @@ fun WebhooksScreen(
) )
state.baseUrl?.let { state.baseUrl?.let {
Text( Text(
text = "Receiver URL: $it", text = stringResource(R.string.webhooks_receiver_url, it),
style = MaterialTheme.typography.bodySmall, style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant, color = MaterialTheme.colorScheme.onSurfaceVariant,
maxLines = 1, maxLines = 1,
@@ -446,7 +446,7 @@ private fun SubscriptionCard(
Spacer(modifier = Modifier.width(6.dp)) Spacer(modifier = Modifier.width(6.dp))
Icon( Icon(
imageVector = Icons.Filled.Lock, imageVector = Icons.Filled.Lock,
contentDescription = "Secret configured", contentDescription = stringResource(R.string.webhooks_secret_configured),
modifier = Modifier.size(14.dp), modifier = Modifier.size(14.dp),
tint = MaterialTheme.colorScheme.primary, tint = MaterialTheme.colorScheme.primary,
) )
@@ -482,7 +482,7 @@ private fun SubscriptionCard(
) { ) {
Icon( Icon(
imageVector = Icons.Filled.Delete, imageVector = Icons.Filled.Delete,
contentDescription = "Delete subscription", contentDescription = stringResource(R.string.webhooks_delete_subscription),
modifier = Modifier.size(18.dp), modifier = Modifier.size(18.dp),
tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f), tint = MaterialTheme.colorScheme.error.copy(alpha = 0.7f),
) )
File diff suppressed because it is too large. Load diff
+110
View File
@@ -224,6 +224,7 @@
<string name="settings_item_language">Language</string> <string name="settings_item_language">Language</string>
<string name="language_system">System</string> <string name="language_system">System</string>
<string name="language_english">English</string> <string name="language_english">English</string>
<string name="language_chinese">简体中文</string>
<string name="language_korean">한국어</string> <string name="language_korean">한국어</string>
<!-- Connect Screen --> <!-- Connect Screen -->
@@ -1043,4 +1044,113 @@
<string name="image_viewer_save_failed">Could not save image</string> <string name="image_viewer_save_failed">Could not save image</string>
<string name="image_viewer_load_failed">Could not load image: %1$s</string> <string name="image_viewer_load_failed">Could not load image: %1$s</string>
<string name="image_viewer_share_failed">Could not share image</string> <string name="image_viewer_share_failed">Could not share image</string>
<!-- hardcoded-UI string migration (en) -->
<string name="analytics_most_used_components">Most Used Components</string>
<string name="analytics_no_model_data">No model data available</string>
<string name="analytics_no_skill_data">No skill data available</string>
<string name="analytics_no_tool_data">No tool data available</string>
<string name="analytics_overview_desc">Overview of your most active models, skills, and tools.</string>
<string name="channels_creds_path_format">Credentials are written to %1$s</string>
<string name="channels_expiry_format">Expires: %1$s</string>
<string name="channels_search_placeholder">Search channels…</string>
<string name="chat_attach_file">Attach file</string>
<string name="chat_save_attachment">Save %1$s</string>
<string name="chat_switch_model_title">Switch model (this chat)</string>
<string name="config_clear_value">Clear value</string>
<string name="config_invalid_json">Invalid JSON</string>
<string name="config_no_matches">No matches</string>
<string name="config_path">Path: %1$s</string>
<string name="config_reset_default">Reset to default</string>
<string name="config_save_changes">Save changes</string>
<string name="config_search_placeholder">Search settings…</string>
<string name="context_cache_read">Cache read</string>
<string name="context_cache_write">Cache write</string>
<string name="context_completion_output">Completion (output)</string>
<string name="context_compressed">compressed ×%1$s</string>
<string name="context_prompt_input">Prompt (input)</string>
<string name="context_tokens_note">Prompt tokens reflect total context used by this session so far.</string>
<string name="context_window">Context window</string>
<string name="gif_play">Play GIF</string>
<string name="kanban_filter_placeholder">Filter tasks by title, status, or assignee...</string>
<string name="logs_clipboard_label">Hermes Logs</string>
<string name="logs_search_placeholder">Search logs...</string>
<string name="mcp_no_match">No servers match &quot;%1$s&quot;</string>
<string name="mcp_search_placeholder">Search MCP servers...</string>
<string name="memory_learned_skills">Learned Skills</string>
<string name="memory_memories_facts">Memories &amp; Facts</string>
<string name="memory_recent_events">Recent Agent Self-Improvement Events</string>
<string name="message_reasoning_steps">Reasoning · %1$s steps</string>
<string name="message_your_response">Your response</string>
<string name="moa_add_preset">Add preset</string>
<string name="moa_add_reference">Add reference model</string>
<string name="moa_new_preset_name">new preset name</string>
<string name="moa_no_presets">No presets configured</string>
<string name="moa_preset_label">Preset:</string>
<string name="moa_presets_note">Presets appear as models under the Mixture of Agents provider.</string>
<string name="moa_reference_models">Reference models</string>
<string name="moa_search_providers">Search providers...</string>
<string name="moa_set_default">Set default</string>
<string name="moa_title">Mixture of Agents</string>
<string name="model_aux_tasks_label">Auxiliary tasks</string>
<string name="model_aux_tasks_title">Auxiliary Tasks</string>
<string name="model_expensive_title">Expensive Model</string>
<string name="model_main_label">Main model</string>
<string name="model_moa_label">Mixture of Agents</string>
<string name="model_new_sessions">applies to new sessions</string>
<string name="model_picker_loading">Loading models…</string>
<string name="model_picker_no_models">No models available.</string>
<string name="model_picker_search">Search models and providers...</string>
<string name="model_reset_all_auto">Reset all to auto</string>
<string name="model_search_placeholder">Search models...</string>
<string name="model_set_aux">Set Aux: %1$s</string>
<string name="model_set_main">Set Main Model</string>
<string name="model_settings">Model Settings</string>
<string name="platform_remove_confirm_text">This will permanently remove this platform\'s configuration.</string>
<string name="platform_remove_confirm_title">Remove %1$s?</string>
<string name="platform_remove_desc">Remove platform</string>
<string name="platform_setup_qr">Set up with QR</string>
<string name="plugins_repo_placeholder">owner/repo, owner/repo/subdir, or https://...</string>
<string name="plugins_search_placeholder">Search plugins...</string>
<string name="profile_builder_placeholder">Describe the purpose of this profile</string>
<string name="profile_command">Command: %1$s</string>
<string name="profile_remove_server">Remove Server</string>
<string name="profile_transport">Transport: %1$s</string>
<string name="profiles_create">Create Profile</string>
<string name="profiles_more_options">More options</string>
<string name="providers_via">via %1$s</string>
<string name="skills_source">Source: %1$s</string>
<string name="subagent_agent_plan">AGENT PLAN</string>
<string name="subagent_live_transcript">LIVE TRANSCRIPT</string>
<string name="subagent_no_active">No active tasks or plan items.</string>
<string name="subagent_summary">Summary: %1$s</string>
<string name="subagent_task_plan_inspection">Task &amp; Plan Inspection</string>
<string name="system_auth_required">Auth required</string>
<string name="system_checkpoint_prune">Checkpoint prune</string>
<string name="system_config_migrate">Config migrate</string>
<string name="system_curator_run">Curator run</string>
<string name="system_more_lines">... %1$d more line(s)</string>
<string name="system_prompt_size_check">Prompt size check</string>
<string name="system_security_audit">Security audit</string>
<string name="system_skills_update">Skills update</string>
<string name="tg_onboarding_add_user_id">Add user ID</string>
<string name="tg_onboarding_allowed_users">Allowed users:</string>
<string name="tg_onboarding_connected_as">Connected as @%1$s</string>
<string name="tg_onboarding_qr_expires">QR code expires in: %1$s</string>
<string name="tg_onboarding_save_restart">Save and restart</string>
<string name="tg_onboarding_saving">Saving configuration…</string>
<string name="tg_onboarding_scan_hint">Open Telegram and scan the QR code displayed in the app.</string>
<string name="tg_onboarding_user_id_placeholder">Telegram user ID</string>
<string name="todo_task_count">Tasks %1$d/%2$d</string>
<string name="tool_duration">Duration: %1$s</string>
<string name="tool_redacted">· redacted</string>
<string name="toolsets_search_placeholder">Search toolsets...</string>
<string name="video_play">Play Video</string>
<string name="webhooks_delete_subscription">Delete subscription</string>
<string name="webhooks_placeholder">What this webhook does...</string>
<string name="webhooks_receiver_url">Receiver URL: %1$s</string>
<string name="webhooks_secret_configured">Secret configured</string>
<string name="system_dump">Dump</string>
<!-- system action toast templates -->
<string name="system_action_started">%1$s started</string>
<string name="system_action_failed">%1$s failed: %2$s</string>
</resources> </resources>
@@ -1182,7 +1182,7 @@ class E2eIntegrationTest {
Response.success(UpdateCheckResponse()) Response.success(UpdateCheckResponse())
coEvery { mockApiService.triggerBackup(BackupTriggerRequest()) } returns Response.success(ActionResponse()) coEvery { mockApiService.triggerBackup(BackupTriggerRequest()) } returns Response.success(ActionResponse())
val viewModel = SystemViewModel() val viewModel = SystemViewModel(mockApp)
viewModel.loadAll() viewModel.loadAll()
advanceUntilIdle() advanceUntilIdle()
@@ -103,7 +103,7 @@ class CookieManagerTest {
} }
@Test @Test
fun setSessionCookie_hostScoped_onlyMatchesThatHost() = fun setSessionCookie_isUsableAcrossHosts() =
runTest { runTest {
val jar = fakeJar() val jar = fakeJar()
CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local")) CookieManager.setSessionCookie("sess-xyz", ep("dashboard.local"))
@@ -115,8 +115,11 @@ class CookieManagerTest {
assertEquals(1, onHost.size) assertEquals(1, onHost.size)
assertEquals("sess-xyz", onHost[0].value) 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()) val offHost = jar.loadForRequest("http://other.local/".toHttpUrl())
assertEquals(0, offHost.size) assertEquals(1, offHost.size)
assertEquals("sess-xyz", offHost[0].value)
} }
@Test @Test
@@ -1,15 +1,35 @@
package com.m57.hermescontrol.data.remote 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.assertEquals
import org.junit.Assert.assertFalse import org.junit.Assert.assertFalse
import org.junit.Assert.assertNull import org.junit.Assert.assertNull
import org.junit.Assert.assertTrue import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Test import org.junit.Test
class GatewayFileClientTest { class GatewayFileClientTest {
private val base = "https://gw.example.com:9119" private val base = "https://gw.example.com:9119"
private val tok = "s e/cret" 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<ServerStore>(relaxed = true)
every { mockStore.getLatestState() } returns
ServerStoreState(wsAuthParam = "bearer")
val field = AuthManager::class.java.getDeclaredField("_serverStore")
field.isAccessible = true
field.set(AuthManager, mockStore)
}
@Test @Test
fun `buildDownloadUrl encodes path and token`() { fun `buildDownloadUrl encodes path and token`() {
val url = GatewayFileClient.buildDownloadUrl(base, tok, "/tmp/foo.png")!! val url = GatewayFileClient.buildDownloadUrl(base, tok, "/tmp/foo.png")!!
@@ -54,7 +54,7 @@ class PersistentCookieJarTest {
} }
@Test @Test
fun cookie_doesNotLeakToOtherHost() = fun sessionCookie_isUsableAcrossHostsWithinServer() =
runTest { runTest {
val jar = makeJar() val jar = makeJar()
jar.useStore("server-a") jar.useStore("server-a")
@@ -63,8 +63,12 @@ class PersistentCookieJarTest {
listOf(sessionCookie("dashboard.local", "abc123")), 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()) val loaded = jar.loadForRequest("http://evil.local/".toHttpUrl())
assertTrue(loaded.isEmpty()) assertEquals(1, loaded.size)
assertEquals("abc123", loaded[0].value)
} }
@Test @Test
@@ -1,5 +1,7 @@
package com.m57.hermescontrol.ui.system package com.m57.hermescontrol.ui.system
import android.app.Application
import io.mockk.mockk
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.StandardTestDispatcher
@@ -14,6 +16,7 @@ import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class) @OptIn(ExperimentalCoroutinesApi::class)
class SystemViewModelTest { class SystemViewModelTest {
private val testDispatcher = StandardTestDispatcher() private val testDispatcher = StandardTestDispatcher()
private val mockApp = mockk<Application>(relaxed = true)
@Before @Before
fun setup() { fun setup() {
@@ -27,7 +30,7 @@ class SystemViewModelTest {
@Test @Test
fun `openUpdateConfirm sets updateConfirmOpen to true`() { fun `openUpdateConfirm sets updateConfirmOpen to true`() {
val viewModel = SystemViewModel() val viewModel = SystemViewModel(mockApp)
assertFalse(viewModel.uiState.value.updateConfirmOpen) assertFalse(viewModel.uiState.value.updateConfirmOpen)
@@ -38,7 +41,7 @@ class SystemViewModelTest {
@Test @Test
fun `closeUpdateConfirm sets updateConfirmOpen to false`() { fun `closeUpdateConfirm sets updateConfirmOpen to false`() {
val viewModel = SystemViewModel() val viewModel = SystemViewModel(mockApp)
// First open it // First open it
viewModel.openUpdateConfirm() viewModel.openUpdateConfirm()