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:
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 = {
|
||||||
|
|||||||
+13
-8
@@ -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,
|
||||||
)
|
)
|
||||||
|
|||||||
+7
-5
@@ -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,
|
||||||
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+5
-5
@@ -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
@@ -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 "%1$s"</string>
|
||||||
|
<string name="mcp_search_placeholder">Search MCP servers...</string>
|
||||||
|
<string name="memory_learned_skills">Learned Skills</string>
|
||||||
|
<string name="memory_memories_facts">Memories & 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 & 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()
|
||||||
|
|||||||
Reference in new issue
Block a user