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

@@ -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,
@@ -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,
)
@@ -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 = {
@@ -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))
}
}
@@ -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,
)
}
@@ -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) },
@@ -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,
@@ -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),
@@ -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),
)
@@ -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,
)
}
@@ -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),
@@ -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,
@@ -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,
)
@@ -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),
)
@@ -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,
)
@@ -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,
@@ -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),
)
@@ -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
},
@@ -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
@@ -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 {
@@ -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),
@@ -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,
@@ -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))
}
}
},
@@ -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))
@@ -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))
@@ -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,
)
@@ -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,
),
)
}
@@ -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),
)
}
}
@@ -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,
@@ -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),
)
@@ -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)
}
@@ -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()) {
@@ -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,
)
@@ -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<ActionResponse>,
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<Application>().getString(
R.string.system_action_started,
getApplication<Application>().getString(labelRes),
),
)
}
}
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(
query = query,
onQueryChange = { query = it },
placeholder = "Search toolsets...",
placeholder = stringResource(R.string.toolsets_search_placeholder),
)
}
items(filteredToolsets, key = { it.name }) { toolset ->
@@ -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),
)