feat(chat): add explicit busy-send modes and recoverable queue (#1254)
Co-authored-by: m57 <41645758+Hy4ri@users.noreply.github.com>
This commit is contained in:
1 parent
f3921957b5
commit
35e6ebc8d5
33 files changed
+4482
-229
No files matched your search
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.data.config
|
||||
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
@@ -22,6 +23,7 @@ data class ServerStoreState(
|
||||
val pinnedModels: List<PinnedModel> = emptyList(),
|
||||
val wsAuthParam: String = "token",
|
||||
val typingEffectEnabled: Boolean = true,
|
||||
val busySendMode: BusySendMode = BusySendMode.CORRECT,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
val chatFontScale: Float = 1.0f,
|
||||
val messageStatsEnabled: Boolean = false,
|
||||
|
||||
@@ -13,6 +13,7 @@ import com.m57.hermescontrol.data.config.ServerUrlMigration
|
||||
import com.m57.hermescontrol.data.config.resolvedBaseUrl
|
||||
import com.m57.hermescontrol.data.config.resolvedHost
|
||||
import com.m57.hermescontrol.data.config.resolvedPort
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.PinnedModel
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.CookieManager
|
||||
@@ -805,6 +806,12 @@ object AuthManager {
|
||||
|
||||
fun isTypingEffectEnabled(): Boolean = serverStore.getLatestState().typingEffectEnabled
|
||||
|
||||
fun getBusySendMode(): BusySendMode = serverStore.getLatestState().busySendMode
|
||||
|
||||
fun setBusySendMode(mode: BusySendMode) {
|
||||
serverStore.update { it.copy(busySendMode = mode) }
|
||||
}
|
||||
|
||||
fun setTypingEffectEnabled(enabled: Boolean) {
|
||||
serverStore.update { it.copy(typingEffectEnabled = enabled) }
|
||||
}
|
||||
|
||||
@@ -86,4 +86,7 @@ interface ChatMessageDao {
|
||||
|
||||
@Query("DELETE FROM chat_messages WHERE session_id = :sessionId")
|
||||
suspend fun deleteMessagesForSession(sessionId: String)
|
||||
|
||||
@Query("DELETE FROM chat_messages WHERE id = :id AND rest_id IS NULL")
|
||||
suspend fun deleteUnconfirmedMessage(id: String)
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
package com.m57.hermescontrol.data.model
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
enum class BusySendMode {
|
||||
CORRECT,
|
||||
QUEUE,
|
||||
GUIDE,
|
||||
INTERRUPT,
|
||||
}
|
||||
@@ -11,6 +11,7 @@ object WsMethods {
|
||||
const val SESSION_CREATE = "session.create"
|
||||
const val SESSION_INTERRUPT = "session.interrupt"
|
||||
const val SESSION_REDIRECT = "session.redirect"
|
||||
const val SESSION_STEER = "session.steer"
|
||||
const val SESSION_DELETE = "session.delete"
|
||||
const val SESSION_TITLE = "session.title"
|
||||
const val SESSION_BRANCH = "session.branch"
|
||||
|
||||
@@ -0,0 +1,15 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
|
||||
@Composable
|
||||
fun BusySendMode.label(): String =
|
||||
when (this) {
|
||||
BusySendMode.CORRECT -> stringResource(R.string.chat_busy_correct)
|
||||
BusySendMode.QUEUE -> stringResource(R.string.chat_busy_queue)
|
||||
BusySendMode.GUIDE -> stringResource(R.string.chat_busy_guide)
|
||||
BusySendMode.INTERRUPT -> stringResource(R.string.chat_busy_interrupt)
|
||||
}
|
||||
@@ -84,4 +84,8 @@ open class ChatPersistenceRepository(
|
||||
suspend fun clearMessagesForSession(sessionId: String) {
|
||||
daoProvider().deleteMessagesForSession(sessionId)
|
||||
}
|
||||
|
||||
suspend fun deleteMessage(id: String) {
|
||||
daoProvider().deleteUnconfirmedMessage(id)
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,17 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
/** Presentation only: retain queued messages internally for persistence and receipt matching. */
|
||||
internal fun messagesWithoutUnsentQueue(
|
||||
messages: List<ChatMessage>,
|
||||
pendingSends: List<PendingSend>,
|
||||
): List<ChatMessage> {
|
||||
val waitingIds =
|
||||
pendingSends
|
||||
.filter { it.state == PendingSendState.QUEUED || it.state == PendingSendState.PARKED }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
if (waitingIds.isEmpty()) return messages
|
||||
return messages.filterNot {
|
||||
it.role == MessageRole.USER && it.id in waitingIds && it.canonicalRestId == null
|
||||
}
|
||||
}
|
||||
@@ -86,6 +86,7 @@ import com.m57.hermescontrol.NavigationController
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.AttachmentSource
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.reasoningSupport
|
||||
import com.m57.hermescontrol.data.ws.ConnectionStatus
|
||||
import com.m57.hermescontrol.data.ws.HermesWsClient
|
||||
@@ -172,7 +173,11 @@ fun ChatScreen(
|
||||
// Snapshot-backed search state — read directly so only the scopes that
|
||||
// read its fields recompose on search changes (bar, matched bubbles).
|
||||
val searchState = viewModel.searchState
|
||||
val displayedMessages = timelineState.historyMessages ?: state.messages
|
||||
val sourceMessages = timelineState.historyMessages ?: state.messages
|
||||
val displayedMessages =
|
||||
remember(sourceMessages, state.pendingSends) {
|
||||
messagesWithoutUnsentQueue(sourceMessages, state.pendingSends)
|
||||
}
|
||||
val lifecycleOwner = LocalLifecycleOwner.current
|
||||
val context = LocalContext.current
|
||||
var browserAuthInFlight by rememberSaveable { mutableStateOf(false) }
|
||||
@@ -195,6 +200,7 @@ fun ChatScreen(
|
||||
}
|
||||
|
||||
Lifecycle.Event.ON_RESUME -> {
|
||||
viewModel.refreshSettings()
|
||||
connectorsViewModel.onResume()
|
||||
val legacyBrowserReturned = browserAuthInFlight && browserAuthDeparted
|
||||
val operationId = connectionBrowserOperationId.takeIf { connectionBrowserDeparted }
|
||||
@@ -314,7 +320,7 @@ fun ChatScreen(
|
||||
// streaming tail.
|
||||
LaunchedEffect(
|
||||
timelineState.isHistorical,
|
||||
state.messages,
|
||||
displayedMessages,
|
||||
streamingState.streamingMessage,
|
||||
streamingState.isThinking,
|
||||
state.subagentIndicators,
|
||||
@@ -328,13 +334,13 @@ fun ChatScreen(
|
||||
scrollController.onTailChanged(
|
||||
tailKey =
|
||||
tailContentKey(
|
||||
messages = state.messages,
|
||||
messages = displayedMessages,
|
||||
streamingMessage = streamingState.streamingMessage,
|
||||
isThinking = streamingState.isThinking,
|
||||
subagentIndicators = state.subagentIndicators,
|
||||
clarifyRequest = state.clarifyRequest,
|
||||
),
|
||||
messageCount = state.messages.size,
|
||||
messageCount = displayedMessages.size,
|
||||
)
|
||||
}
|
||||
LaunchedEffect(timelineState.historyAnchorRowId) {
|
||||
@@ -872,6 +878,12 @@ fun ChatScreen(
|
||||
},
|
||||
)
|
||||
|
||||
com.m57.hermescontrol.ui.chat.components.PendingSendPanel(
|
||||
sends = state.pendingSends,
|
||||
mainTurnBusy = state.isMainTurnBusy,
|
||||
onSendNow = viewModel::sendQueuedNow,
|
||||
)
|
||||
|
||||
ChatInputBar(
|
||||
inputFieldValue = inputFieldValue,
|
||||
onInputChange = { inputFieldValue = it },
|
||||
@@ -882,9 +894,16 @@ fun ChatScreen(
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onBusySend = { mode ->
|
||||
if (viewModel.sendMessage(inputFieldValue.text, mode)) {
|
||||
inputFieldValue = TextFieldValue("")
|
||||
scrollController.jumpToBottom(animated = true)
|
||||
}
|
||||
},
|
||||
onMicTap = mediaLaunchers.onMicTap,
|
||||
isListening = mediaLaunchers.isListening,
|
||||
isAgentTyping = state.isAgentTyping,
|
||||
isMainTurnBusy = state.isMainTurnBusy,
|
||||
isConnected = state.isConnected,
|
||||
isSessionReady = state.isSessionReady && !timelineState.isHistorical,
|
||||
sessionPreparationFailed = state.resumeError != null,
|
||||
|
||||
@@ -0,0 +1,149 @@
|
||||
package com.m57.hermescontrol.ui.chat
|
||||
|
||||
import android.content.Context
|
||||
import android.content.SharedPreferences
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import kotlinx.serialization.Serializable
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
/** The receipt stays on device until REST confirms a user row; a gateway queue is only volatile. */
|
||||
@Serializable
|
||||
data class PendingSend(
|
||||
val id: String,
|
||||
val scope: String,
|
||||
val sessionId: String,
|
||||
val text: String,
|
||||
val attachments: List<Attachment> = emptyList(),
|
||||
val mode: BusySendMode,
|
||||
val state: PendingSendState = PendingSendState.QUEUED,
|
||||
val createdAt: Long = System.currentTimeMillis(),
|
||||
val attempts: Int = 0,
|
||||
/** Legacy source URIs are retained for an explicit user retry, never auto-dispatched. */
|
||||
val requiresAttachmentRecovery: Boolean = false,
|
||||
)
|
||||
|
||||
@Serializable
|
||||
enum class PendingSendState {
|
||||
QUEUED,
|
||||
PARKED,
|
||||
SENDING,
|
||||
ACCEPTED,
|
||||
UNKNOWN,
|
||||
REJECTED,
|
||||
}
|
||||
|
||||
/**
|
||||
* Reconcile only identities that the transcript merge already mapped to a durable REST row.
|
||||
* Content matching belongs to [matchTranscriptMessages], which normalizes gateway wrappers and
|
||||
* consumes duplicate occurrences once; repeating that matching against receipts can acknowledge
|
||||
* more sends than the history page actually contains.
|
||||
*/
|
||||
internal fun pendingSendIdsConfirmedByDurableAliases(
|
||||
confirmedAliases: List<ChatMessage>,
|
||||
pending: List<PendingSend>,
|
||||
): Set<String> {
|
||||
val durableUserAliasIds =
|
||||
confirmedAliases
|
||||
.asSequence()
|
||||
.filter { it.role == MessageRole.USER && it.canonicalRestId != null }
|
||||
.map { it.id }
|
||||
.toSet()
|
||||
return pending
|
||||
.asSequence()
|
||||
.filter {
|
||||
it.state in
|
||||
setOf(PendingSendState.SENDING, PendingSendState.ACCEPTED, PendingSendState.UNKNOWN)
|
||||
}.map { it.id }
|
||||
.filter { it in durableUserAliasIds }
|
||||
.toSet()
|
||||
}
|
||||
|
||||
/** Synchronous writes keep the queue recoverable when Android kills the process just after a tap. */
|
||||
class ChatSendStore(
|
||||
private val prefs: SharedPreferences? = null,
|
||||
) {
|
||||
constructor(context: Context) : this(
|
||||
context.applicationContext.getSharedPreferences("chat_send_outbox", Context.MODE_PRIVATE),
|
||||
)
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
private var rows: List<PendingSend> =
|
||||
prefs?.getString("rows", null)?.let { raw ->
|
||||
runCatching { json.decodeFromString<List<PendingSend>>(raw) }.getOrDefault(emptyList())
|
||||
} ?: emptyList()
|
||||
|
||||
init {
|
||||
// A request may have reached the gateway before process death. Never replay it automatically.
|
||||
replace(
|
||||
rows.map {
|
||||
it.quarantineLegacyAttachments().let { quarantined ->
|
||||
if (quarantined.state == PendingSendState.SENDING ||
|
||||
quarantined.state == PendingSendState.ACCEPTED
|
||||
) {
|
||||
quarantined.copy(state = PendingSendState.UNKNOWN)
|
||||
} else {
|
||||
quarantined
|
||||
}
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun PendingSend.quarantineLegacyAttachments(): PendingSend =
|
||||
if (attachments.any { !it.uri.startsWith("file:", ignoreCase = true) }) {
|
||||
copy(state = PendingSendState.REJECTED, requiresAttachmentRecovery = true)
|
||||
} else {
|
||||
this
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun all(): List<PendingSend> = rows
|
||||
|
||||
@Synchronized
|
||||
fun put(row: PendingSend) {
|
||||
replace(rows.filterNot { it.id == row.id } + row.quarantineLegacyAttachments())
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun remove(id: String) {
|
||||
replace(rows.filterNot { it.id == id })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun update(
|
||||
id: String,
|
||||
transform: (PendingSend) -> PendingSend,
|
||||
) {
|
||||
replace(rows.map { if (it.id == id) transform(it) else it })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun promote(id: String) {
|
||||
val row = rows.firstOrNull { it.id == id } ?: return
|
||||
replace(listOf(row.copy(state = PendingSendState.QUEUED)) + rows.filterNot { it.id == id })
|
||||
}
|
||||
|
||||
@Synchronized
|
||||
fun park(
|
||||
scope: String,
|
||||
sessionId: String,
|
||||
) {
|
||||
replace(
|
||||
rows.map {
|
||||
if (it.scope == scope && it.sessionId == sessionId && it.state == PendingSendState.QUEUED) {
|
||||
it.copy(state = PendingSendState.PARKED)
|
||||
} else {
|
||||
it
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
private fun replace(next: List<PendingSend>) {
|
||||
if (prefs != null && !prefs.edit().putString("rows", json.encodeToString(next)).commit()) {
|
||||
error("Could not save chat send queue")
|
||||
}
|
||||
rows = next
|
||||
}
|
||||
}
|
||||
File diff suppressed because it is too large.
Load diff
@@ -1,5 +1,6 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.animation.AnimatedVisibility
|
||||
import androidx.compose.animation.expandVertically
|
||||
import androidx.compose.animation.fadeIn
|
||||
@@ -37,6 +38,7 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
@@ -59,6 +61,7 @@ import androidx.compose.ui.unit.dp
|
||||
import coil3.compose.AsyncImage
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.Attachment
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.model.ProfileInfo
|
||||
import com.m57.hermescontrol.data.ws.CommandBlocklist
|
||||
import com.m57.hermescontrol.data.ws.CommandCatalog
|
||||
@@ -75,9 +78,11 @@ fun ChatInputBar(
|
||||
inputFieldValue: TextFieldValue,
|
||||
onInputChange: (TextFieldValue) -> Unit,
|
||||
onSend: () -> Unit,
|
||||
onBusySend: (BusySendMode) -> Unit = {},
|
||||
onMicTap: () -> Unit,
|
||||
isListening: Boolean,
|
||||
isAgentTyping: Boolean,
|
||||
isMainTurnBusy: Boolean = false,
|
||||
isConnected: Boolean,
|
||||
commandCatalog: CommandCatalog,
|
||||
isSessionReady: Boolean,
|
||||
@@ -111,10 +116,14 @@ fun ChatInputBar(
|
||||
val canSend =
|
||||
pendingReasoningLevel == null &&
|
||||
ChatInputPolicy.canSend(inputFieldValue.text, pendingAttachments, isConnected, isSessionReady)
|
||||
val hasDraft = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty()
|
||||
val isSlashDraft = inputFieldValue.text.trimStart().startsWith("/")
|
||||
val showBusyActions = isMainTurnBusy && hasDraft && !isSlashDraft
|
||||
|
||||
// Attachment tray state
|
||||
var showAttachmentTray by remember { mutableStateOf(false) }
|
||||
val palette = composerPalette()
|
||||
BackHandler(enabled = showAttachmentTray) { showAttachmentTray = false }
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = true,
|
||||
@@ -319,8 +328,11 @@ fun ChatInputBar(
|
||||
reasoningLevel = reasoningLevel,
|
||||
isListening = isListening,
|
||||
canSend = canSend,
|
||||
showSend = inputFieldValue.text.isNotBlank() || pendingAttachments.isNotEmpty(),
|
||||
showSend = hasDraft,
|
||||
showQueue = showBusyActions,
|
||||
onSend = onSend,
|
||||
onQueue = { onBusySend(BusySendMode.QUEUE) },
|
||||
onStopAndSend = { onBusySend(BusySendMode.INTERRUPT) },
|
||||
onAttachTap = { showAttachmentTray = !showAttachmentTray },
|
||||
onModelTap = onModelTap,
|
||||
onReasoningSelected = onReasoningTap,
|
||||
|
||||
@@ -6,8 +6,10 @@ import androidx.compose.animation.fadeIn
|
||||
import androidx.compose.animation.fadeOut
|
||||
import androidx.compose.animation.scaleIn
|
||||
import androidx.compose.animation.scaleOut
|
||||
import androidx.compose.foundation.ExperimentalFoundationApi
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -27,6 +29,7 @@ import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.automirrored.filled.Send
|
||||
import androidx.compose.material.icons.filled.Add
|
||||
import androidx.compose.material.icons.filled.AddToQueue
|
||||
import androidx.compose.material.icons.filled.Bolt
|
||||
import androidx.compose.material.icons.filled.ExpandMore
|
||||
import androidx.compose.material.icons.filled.Stop
|
||||
@@ -56,7 +59,9 @@ import androidx.compose.ui.draw.clipToBounds
|
||||
import androidx.compose.ui.draw.scale
|
||||
import androidx.compose.ui.platform.testTag
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.semantics.CustomAccessibilityAction
|
||||
import androidx.compose.ui.semantics.contentDescription
|
||||
import androidx.compose.ui.semantics.customActions
|
||||
import androidx.compose.ui.semantics.semantics
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
@@ -83,6 +88,7 @@ import com.m57.hermescontrol.R
|
||||
* When [supportsReasoning] is false the model takes no reasoning parameter
|
||||
* and the level menu is disabled.
|
||||
*/
|
||||
@OptIn(ExperimentalFoundationApi::class)
|
||||
@Composable
|
||||
fun ComposerToolbar(
|
||||
isConnected: Boolean,
|
||||
@@ -96,7 +102,10 @@ fun ComposerToolbar(
|
||||
modifier: Modifier = Modifier,
|
||||
canSend: Boolean = false,
|
||||
showSend: Boolean = canSend,
|
||||
showQueue: Boolean = false,
|
||||
onSend: () -> Unit = {},
|
||||
onQueue: () -> Unit = {},
|
||||
onStopAndSend: () -> Unit = {},
|
||||
canDisableReasoning: Boolean? = null,
|
||||
supportsReasoning: Boolean? = null,
|
||||
fastMode: Boolean = false,
|
||||
@@ -430,29 +439,62 @@ fun ComposerToolbar(
|
||||
}
|
||||
}
|
||||
|
||||
AnimatedVisibility(
|
||||
visible = showQueue,
|
||||
enter = fadeIn() + scaleIn(initialScale = 0.8f),
|
||||
exit = fadeOut() + scaleOut(targetScale = 0.8f),
|
||||
) {
|
||||
FilledIconButton(
|
||||
onClick = onQueue,
|
||||
enabled = canSend,
|
||||
colors = flatIconButtonColors(palette),
|
||||
modifier = Modifier.size(48.dp).testTag("queue_button"),
|
||||
) {
|
||||
Icon(
|
||||
imageVector = Icons.Default.AddToQueue,
|
||||
contentDescription = stringResource(R.string.chat_busy_queue_action),
|
||||
modifier = Modifier.size(20.dp),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Action button — send when a send is possible, mic / stop otherwise
|
||||
FilledIconButton(
|
||||
onClick = if (showSend) onSend else onMicTap,
|
||||
enabled = if (showSend) canSend else isConnected,
|
||||
colors =
|
||||
if (!showSend && isListening) {
|
||||
listeningIconButtonColors()
|
||||
} else {
|
||||
IconButtonDefaults.filledIconButtonColors(
|
||||
containerColor = palette.action,
|
||||
contentColor = palette.onAction,
|
||||
)
|
||||
},
|
||||
val stopAndSendLabel = stringResource(R.string.chat_busy_stop_and_send)
|
||||
val actionEnabled = if (showSend) canSend else isConnected
|
||||
Box(
|
||||
modifier =
|
||||
Modifier
|
||||
.size(ControlSize)
|
||||
.testTag(
|
||||
.size(if (showSend) 48.dp else ControlSize)
|
||||
.clip(CircleShape)
|
||||
.background(
|
||||
if (!showSend && isListening) {
|
||||
MaterialTheme.colorScheme.errorContainer
|
||||
} else {
|
||||
palette.action
|
||||
},
|
||||
).semantics {
|
||||
if (showSend && showQueue && canSend) {
|
||||
customActions =
|
||||
listOf(
|
||||
CustomAccessibilityAction(stopAndSendLabel) {
|
||||
onStopAndSend()
|
||||
true
|
||||
},
|
||||
)
|
||||
}
|
||||
}.combinedClickable(
|
||||
enabled = actionEnabled,
|
||||
onClick = if (showSend) onSend else onMicTap,
|
||||
onLongClick = if (showSend && showQueue) onStopAndSend else null,
|
||||
onLongClickLabel = if (showSend && showQueue) stopAndSendLabel else null,
|
||||
).testTag(
|
||||
when {
|
||||
showSend -> "send_button"
|
||||
isListening -> "mic_stop_button"
|
||||
else -> "mic_button"
|
||||
},
|
||||
),
|
||||
contentAlignment = Alignment.Center,
|
||||
) {
|
||||
Crossfade(
|
||||
targetState =
|
||||
@@ -468,15 +510,24 @@ fun ComposerToolbar(
|
||||
Icon(
|
||||
imageVector = Icons.AutoMirrored.Filled.Send,
|
||||
contentDescription = stringResource(R.string.chat_send_desc),
|
||||
tint = palette.onAction,
|
||||
)
|
||||
}
|
||||
|
||||
ActionGlyph.STOP -> {
|
||||
Icon(imageVector = Icons.Default.Stop, contentDescription = "Stop listening")
|
||||
Icon(
|
||||
imageVector = Icons.Default.Stop,
|
||||
contentDescription = "Stop listening",
|
||||
tint = if (isListening) MaterialTheme.colorScheme.onErrorContainer else palette.onAction,
|
||||
)
|
||||
}
|
||||
|
||||
ActionGlyph.VOICE -> {
|
||||
Icon(imageVector = Icons.Outlined.Mic, contentDescription = "Mic")
|
||||
Icon(
|
||||
imageVector = Icons.Outlined.Mic,
|
||||
contentDescription = "Mic",
|
||||
tint = palette.onAction,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,103 @@
|
||||
package com.m57.hermescontrol.ui.chat.components
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.items
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Surface
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextOverflow
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.ui.chat.PendingSend
|
||||
import com.m57.hermescontrol.ui.chat.PendingSendState
|
||||
|
||||
@Composable
|
||||
fun PendingSendPanel(
|
||||
sends: List<PendingSend>,
|
||||
mainTurnBusy: Boolean,
|
||||
onSendNow: (String) -> Unit,
|
||||
) {
|
||||
// Delivery receipts stay internal; only queued work and recovery need UI.
|
||||
val visibleSends = sends.filter { it.state != PendingSendState.SENDING && it.state != PendingSendState.ACCEPTED }
|
||||
if (visibleSends.isEmpty()) return
|
||||
Surface(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 12.dp),
|
||||
shape = MaterialTheme.shapes.medium,
|
||||
color = MaterialTheme.colorScheme.surfaceContainer,
|
||||
) {
|
||||
Column(modifier = Modifier.padding(8.dp)) {
|
||||
Text(stringResource(R.string.chat_pending_sends), style = MaterialTheme.typography.titleSmall)
|
||||
if (mainTurnBusy || visibleSends.any { it.state == PendingSendState.UNKNOWN }) {
|
||||
Text(
|
||||
stringResource(R.string.chat_pending_send_now_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
LazyColumn(modifier = Modifier.heightIn(max = 170.dp)) {
|
||||
items(visibleSends, key = { it.id }) { send ->
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceBetween) {
|
||||
Column(modifier = Modifier.weight(1f)) {
|
||||
Text(
|
||||
send.text.ifBlank { stringResource(R.string.chat_pending_attachments) },
|
||||
maxLines = 2,
|
||||
overflow = TextOverflow.Ellipsis,
|
||||
)
|
||||
Text(
|
||||
text =
|
||||
when (send.state) {
|
||||
PendingSendState.QUEUED -> stringResource(R.string.chat_pending_queued)
|
||||
PendingSendState.PARKED -> stringResource(R.string.chat_pending_parked)
|
||||
PendingSendState.SENDING -> stringResource(R.string.chat_pending_sending)
|
||||
PendingSendState.ACCEPTED -> stringResource(R.string.chat_pending_accepted)
|
||||
PendingSendState.UNKNOWN -> stringResource(R.string.chat_pending_unknown)
|
||||
PendingSendState.REJECTED -> stringResource(R.string.chat_pending_rejected)
|
||||
},
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
}
|
||||
if (send.state in
|
||||
setOf(
|
||||
PendingSendState.QUEUED,
|
||||
PendingSendState.PARKED,
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.REJECTED,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
TextButton(onClick = { onSendNow(send.id) }) {
|
||||
Text(
|
||||
stringResource(
|
||||
if (mainTurnBusy) {
|
||||
R.string.chat_busy_stop_and_send
|
||||
} else if (send.state in
|
||||
setOf(
|
||||
PendingSendState.UNKNOWN,
|
||||
PendingSendState.ACCEPTED,
|
||||
)
|
||||
) {
|
||||
R.string.chat_pending_send_again
|
||||
} else {
|
||||
R.string.chat_pending_send_now
|
||||
},
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -193,6 +193,8 @@ internal fun SettingsChatPage(
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
ChatSection(
|
||||
busySendMode = state.busySendMode,
|
||||
onBusySendModeChange = viewModel::onBusySendModeChange,
|
||||
typingEffectEnabled = state.typingEffectEnabled,
|
||||
onTypingEffectEnabledChange = viewModel::onTypingEffectEnabledChange,
|
||||
typingEffectDelayMs = state.typingEffectDelayMs,
|
||||
|
||||
@@ -6,6 +6,7 @@ import com.m57.hermescontrol.data.config.ConnectionProfile
|
||||
import com.m57.hermescontrol.data.config.resolveBaseUrl
|
||||
import com.m57.hermescontrol.data.local.AuthManager
|
||||
import com.m57.hermescontrol.data.local.SessionListCacheStore
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.data.remote.ApiClient
|
||||
import com.m57.hermescontrol.data.remote.CleartextPolicy
|
||||
import com.m57.hermescontrol.data.remote.NetworkResult
|
||||
@@ -37,6 +38,7 @@ data class SettingsUiState(
|
||||
val testResult: String? = null,
|
||||
val isSaved: Boolean = false,
|
||||
val typingEffectEnabled: Boolean = false,
|
||||
val busySendMode: BusySendMode = BusySendMode.CORRECT,
|
||||
val typingEffectDelayMs: Int = 30,
|
||||
val chatFontScale: Float = 1.0f,
|
||||
val messageStatsEnabled: Boolean = false,
|
||||
@@ -85,6 +87,7 @@ class SettingsViewModel(
|
||||
val useDynamicColors = AuthManager.isUseDynamicColors()
|
||||
val themePreset = AuthManager.getThemePreset()
|
||||
val typingEffectEnabled = AuthManager.isTypingEffectEnabled()
|
||||
val busySendMode = AuthManager.getBusySendMode()
|
||||
val typingEffectDelayMs = AuthManager.getTypingEffectDelayMs()
|
||||
val chatFontScale = AuthManager.getChatFontScale()
|
||||
val messageStatsEnabled = AuthManager.isMessageStatsEnabled()
|
||||
@@ -112,6 +115,7 @@ class SettingsViewModel(
|
||||
useDynamicColors = useDynamicColors,
|
||||
themePreset = themePreset,
|
||||
typingEffectEnabled = typingEffectEnabled,
|
||||
busySendMode = busySendMode,
|
||||
typingEffectDelayMs = typingEffectDelayMs,
|
||||
chatFontScale = chatFontScale,
|
||||
messageStatsEnabled = messageStatsEnabled,
|
||||
@@ -336,6 +340,11 @@ class SettingsViewModel(
|
||||
AuthManager.setTypingEffectEnabled(enabled)
|
||||
}
|
||||
|
||||
fun onBusySendModeChange(mode: BusySendMode) {
|
||||
_uiState.update { it.copy(busySendMode = mode, isSaved = false) }
|
||||
AuthManager.setBusySendMode(mode)
|
||||
}
|
||||
|
||||
fun onTypingEffectDelayMsChange(delayMs: Int) {
|
||||
_uiState.update { it.copy(typingEffectDelayMs = delayMs, isSaved = false) }
|
||||
AuthManager.setTypingEffectDelayMs(delayMs)
|
||||
|
||||
@@ -38,8 +38,10 @@ import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.Density
|
||||
import androidx.compose.ui.unit.dp
|
||||
import com.m57.hermescontrol.R
|
||||
import com.m57.hermescontrol.data.model.BusySendMode
|
||||
import com.m57.hermescontrol.theme.ThemePreference
|
||||
import com.m57.hermescontrol.theme.ThemePreset
|
||||
import com.m57.hermescontrol.ui.chat.label
|
||||
import com.m57.hermescontrol.ui.settings.SectionCard
|
||||
import kotlin.math.abs
|
||||
import kotlin.math.roundToInt
|
||||
@@ -215,6 +217,8 @@ internal fun ChatSection(
|
||||
onTokensPerSecondChange: (Boolean) -> Unit = {},
|
||||
showModelProvider: Boolean = false,
|
||||
onShowModelProviderChange: (Boolean) -> Unit = {},
|
||||
busySendMode: BusySendMode = BusySendMode.CORRECT,
|
||||
onBusySendModeChange: (BusySendMode) -> Unit = {},
|
||||
) {
|
||||
val fontScaleOptions = listOf(0.85f, 1.0f, 1.15f, 1.30f, 1.50f)
|
||||
val currentIndex =
|
||||
@@ -223,6 +227,38 @@ internal fun ChatSection(
|
||||
.takeIf { it >= 0 } ?: 1
|
||||
|
||||
SectionCard {
|
||||
var busyModeExpanded by remember { mutableStateOf(false) }
|
||||
Text(
|
||||
text = stringResource(R.string.chat_busy_mode_title),
|
||||
style = MaterialTheme.typography.bodyLarge,
|
||||
)
|
||||
Text(
|
||||
text = stringResource(R.string.chat_busy_mode_desc),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Box {
|
||||
OutlinedButton(onClick = { busyModeExpanded = true }, modifier = Modifier.testTag("busy_send_default")) {
|
||||
Text(busySendMode.label())
|
||||
}
|
||||
DropdownMenu(expanded = busyModeExpanded, onDismissRequest = { busyModeExpanded = false }) {
|
||||
BusySendMode.entries.forEach { mode ->
|
||||
DropdownMenuItem(
|
||||
text = { Text(mode.label()) },
|
||||
onClick = {
|
||||
onBusySendModeChange(mode)
|
||||
busyModeExpanded = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
Text(
|
||||
text = stringResource(R.string.chat_busy_interrupt_warning),
|
||||
style = MaterialTheme.typography.bodySmall,
|
||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||
)
|
||||
Spacer(modifier = Modifier.height(16.dp))
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
|
||||
@@ -1543,6 +1543,7 @@
|
||||
<string name="media_player_play_video">تشغيل الفيديو</string>
|
||||
<string name="chat_session_preparing">جارٍ تجهيز الجلسة…</string>
|
||||
<string name="chat_session_not_ready">الجلسة غير جاهزة — أعد المحاولة للمتابعة</string>
|
||||
|
||||
<string name="connection_setup_target_title">إعداد %1$s</string>
|
||||
<string name="connection_setup_progress">%1$d من %2$d</string>
|
||||
<string name="connection_setup_authorized_no_tools">تم التفويض، لكن الأدوات غير متاحة</string>
|
||||
@@ -1556,4 +1557,25 @@
|
||||
<string name="connection_setup_required">%1$s مطلوب</string>
|
||||
<string name="connection_setup_request_failed">تعذر إرسال رد الإعداد. حاول مرة أخرى.</string>
|
||||
<string name="connection_setup_pending">بانتظار Hermes…</string>
|
||||
</resources>
|
||||
|
||||
<string name="chat_busy_mode_title">أثناء عمل الوكيل</string>
|
||||
<string name="chat_busy_mode_desc">اختر كيفية وصول الرسالة الجديدة إلى المحادثة الحالية. تُرسل الرسائل بشكل عادي عند الخمول.</string>
|
||||
<string name="chat_busy_correct">تصحيح الدور الحالي</string>
|
||||
<string name="chat_busy_queue">الإرسال في الدور التالي</string>
|
||||
<string name="chat_busy_guide">توجيه عند النقطة الآمنة التالية</string>
|
||||
<string name="chat_busy_interrupt">مقاطعة قسرية ثم إرسال</string>
|
||||
<string name="chat_busy_interrupt_warning">قد توقف المقاطعة القسرية الوكلاء الفرعيين. يسمح الانتظار والتوجيه باستمرار العمل الحالي.</string>
|
||||
<string name="chat_busy_override">وضع إرسال هذه الرسالة</string>
|
||||
<string name="chat_busy_override_once">يسري هذا الاختيار مرة واحدة فقط ولا يغير الإعداد الافتراضي المحفوظ.</string>
|
||||
<string name="chat_pending_sends">رسائل معلّقة</string>
|
||||
<string name="chat_pending_queued">في قائمة الانتظار على هذا الجهاز</string>
|
||||
<string name="chat_pending_parked">موقوفة بعد الإيقاف</string>
|
||||
<string name="chat_pending_sending">جارٍ الإرسال…</string>
|
||||
<string name="chat_pending_accepted">قبلها الخادم؛ بانتظار ظهورها في السجل. قد تؤدي إعادة الإرسال إلى تكرارها.</string>
|
||||
<string name="chat_pending_unknown">حالة التسليم غير معروفة؛ قد يؤدي الإرسال مجددًا إلى تكرارها</string>
|
||||
<string name="chat_pending_rejected">لم تُسلّم؛ اختر الإرسال الآن لإعادة المحاولة</string>
|
||||
<string name="chat_pending_send_now">إرسال الآن</string>
|
||||
<string name="chat_pending_send_now_warning">قد يقطع إرسال رسالة معلّقة الآن العمل الجاري ويوقف الوكلاء الفرعيين.</string>
|
||||
<string name="chat_pending_send_again">إرسال مجددًا</string>
|
||||
<string name="chat_pending_attachments">ستُرسل المرفقات مع هذه الرسالة.</string>
|
||||
</resources>
|
||||
@@ -1199,6 +1199,7 @@
|
||||
<string name="media_player_play_video">비디오 재생</string>
|
||||
<string name="chat_session_preparing">세션 준비 중…</string>
|
||||
<string name="chat_session_not_ready">세션이 준비되지 않았습니다. 다시 시도하세요</string>
|
||||
|
||||
<string name="connection_setup_target_title">%1$s 설정</string>
|
||||
<string name="connection_setup_progress">%2$d개 중 %1$d개</string>
|
||||
<string name="connection_setup_authorized_no_tools">인증되었지만 도구를 사용할 수 없습니다</string>
|
||||
@@ -1239,4 +1240,25 @@
|
||||
<string name="kanban_attachment_error">첨부 파일 오류: %1$s</string>
|
||||
<string name="kanban_active_workers">활성 작업자</string>
|
||||
<string name="kanban_no_active_workers">활성 작업자가 없습니다</string>
|
||||
</resources>
|
||||
|
||||
<string name="chat_busy_mode_title">에이전트가 작업 중일 때</string>
|
||||
<string name="chat_busy_mode_desc">새 메시지를 현재 채팅에 전달할 방식을 선택합니다. 유휴 상태에서는 일반 전송됩니다.</string>
|
||||
<string name="chat_busy_correct">현재 턴 수정</string>
|
||||
<string name="chat_busy_queue">다음 턴 대기열에 추가</string>
|
||||
<string name="chat_busy_guide">다음 안전 지점에서 안내</string>
|
||||
<string name="chat_busy_interrupt">강제 중단 후 전송</string>
|
||||
<string name="chat_busy_interrupt_warning">강제 중단은 하위 에이전트를 중지할 수 있습니다. 대기열과 안내는 현재 작업을 계속합니다.</string>
|
||||
<string name="chat_busy_override">이 메시지의 전송 방식</string>
|
||||
<string name="chat_busy_override_once">이 선택은 한 번만 적용되며 저장된 기본값은 바뀌지 않습니다.</string>
|
||||
<string name="chat_pending_sends">대기 중인 메시지</string>
|
||||
<string name="chat_pending_queued">이 기기에서 대기 중</string>
|
||||
<string name="chat_pending_parked">중지 후 보류됨</string>
|
||||
<string name="chat_pending_sending">전송 중…</string>
|
||||
<string name="chat_pending_accepted">서버에서 수락됨; 대화 기록 대기 중. 다시 보내면 중복될 수 있습니다.</string>
|
||||
<string name="chat_pending_unknown">전달 여부를 알 수 없습니다. 다시 보내면 중복될 수 있습니다.</string>
|
||||
<string name="chat_pending_rejected">전달되지 않았습니다. 지금 보내기를 눌러 재시도하세요.</string>
|
||||
<string name="chat_pending_send_now">지금 보내기</string>
|
||||
<string name="chat_pending_send_now_warning">대기 중인 메시지를 지금 보내면 진행 중인 작업이 중단되고 하위 에이전트가 멈출 수 있습니다.</string>
|
||||
<string name="chat_pending_send_again">다시 보내기</string>
|
||||
<string name="chat_pending_attachments">첨부 파일도 함께 전송됩니다.</string>
|
||||
</resources>
|
||||
@@ -1509,6 +1509,26 @@
|
||||
<string name="connection_setup_required">%1$s为必填项</string>
|
||||
<string name="connection_setup_request_failed">无法发送设置响应,请重试。</string>
|
||||
<string name="connection_setup_pending">正在等待 Hermes…</string>
|
||||
<string name="chat_busy_mode_title">智能体工作时</string>
|
||||
<string name="chat_busy_mode_desc">选择新消息如何送达当前聊天。空闲时正常发送。</string>
|
||||
<string name="chat_busy_correct">修正当前轮次</string>
|
||||
<string name="chat_busy_queue">排队等待下一轮</string>
|
||||
<string name="chat_busy_guide">在下一个安全点提供指导</string>
|
||||
<string name="chat_busy_interrupt">强制中断后发送</string>
|
||||
<string name="chat_busy_interrupt_warning">强制中断可能停止子智能体。排队和指导会让当前工作继续。</string>
|
||||
<string name="chat_busy_override">本条消息的发送方式</string>
|
||||
<string name="chat_busy_override_once">此选择仅生效一次,不会更改保存的默认值。</string>
|
||||
<string name="chat_pending_sends">待发送消息</string>
|
||||
<string name="chat_pending_queued">已在本机排队</string>
|
||||
<string name="chat_pending_parked">停止后已暂停</string>
|
||||
<string name="chat_pending_sending">正在发送…</string>
|
||||
<string name="chat_pending_accepted">服务器已接收,等待写入聊天记录。再次发送可能造成重复。</string>
|
||||
<string name="chat_pending_unknown">送达状态未知;再次发送可能重复</string>
|
||||
<string name="chat_pending_rejected">未送达;点按“立即发送”重试</string>
|
||||
<string name="chat_pending_send_now">立即发送</string>
|
||||
<string name="chat_pending_send_now_warning">现在发送待处理消息可能会中断当前任务并停止子代理。</string>
|
||||
<string name="chat_pending_send_again">再次发送</string>
|
||||
<string name="chat_pending_attachments">附件将随这条消息发送。</string>
|
||||
<string name="chat_action_timeline">时间线</string>
|
||||
<string name="chat_history_window_latest">回到最新</string>
|
||||
<string name="chat_history_window_more_both">此窗口之前和之后还有更多消息。</string>
|
||||
|
||||
@@ -194,6 +194,29 @@
|
||||
<string name="chat_input_placeholder_waiting">Waiting for response\u2026</string>
|
||||
<string name="chat_input_placeholder_queue">Queued \u2014 sends when agent is free\u2026</string>
|
||||
<string name="chat_input_placeholder_type_message">Type a message\u2026</string>
|
||||
<string name="chat_busy_mode_title">While the agent is working</string>
|
||||
<string name="chat_busy_mode_desc">Choose how a new message reaches the current chat. Idle messages send normally.</string>
|
||||
<string name="chat_busy_correct">Correct current turn</string>
|
||||
<string name="chat_busy_queue">Queue next turn</string>
|
||||
<string name="chat_busy_guide">Guide at next safe point</string>
|
||||
<string name="chat_busy_interrupt">Hard interrupt, then send</string>
|
||||
<string name="chat_busy_interrupt_warning">Hard interrupt can stop child agents. Queue and guide let current work continue.</string>
|
||||
<string name="chat_busy_queue_action">Queue</string>
|
||||
<string name="chat_busy_stop_and_send">Stop & send</string>
|
||||
<string name="chat_busy_more">More</string>
|
||||
<string name="chat_busy_override">Send mode for this message</string>
|
||||
<string name="chat_busy_override_once">This choice applies once; the saved default stays the same.</string>
|
||||
<string name="chat_pending_sends">Pending messages</string>
|
||||
<string name="chat_pending_queued">Queued on this device</string>
|
||||
<string name="chat_pending_parked">Parked after Stop</string>
|
||||
<string name="chat_pending_sending">Sending\u2026</string>
|
||||
<string name="chat_pending_accepted">Accepted by server; awaiting transcript. Sending again may duplicate it.</string>
|
||||
<string name="chat_pending_unknown">Delivery unknown; sending again may duplicate it</string>
|
||||
<string name="chat_pending_rejected">Not delivered; choose Send now to retry</string>
|
||||
<string name="chat_pending_send_now">Send now</string>
|
||||
<string name="chat_pending_send_now_warning">Sending a pending message now may interrupt active work and stop child agents.</string>
|
||||
<string name="chat_pending_send_again">Send again</string>
|
||||
<string name="chat_pending_attachments">Attachments will be sent with this message.</string>
|
||||
<string name="chat_tool_execution_error">Execution Error: %1$s</string>
|
||||
<string name="chat_tool_exit_code">Exit Code: %1$d</string>
|
||||
<string name="chat_tool_output_omitted">%1$d characters omitted from process output</string>
|
||||
|
||||
Reference in new issue
Block a user