mirror of
https://github.com/openclaw/openclaw.git
synced 2026-06-06 05:51:15 +08:00
feat(android): add v2 cron job editor
This commit is contained in:
@@ -93,6 +93,7 @@ class MainViewModel(
|
||||
val cronStatus: StateFlow<GatewayCronStatus> = runtimeState(initial = GatewayCronStatus(enabled = false, jobs = 0, nextWakeAtMs = null)) { it.cronStatus }
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = runtimeState(initial = emptyList()) { it.cronJobs }
|
||||
val cronRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.cronRefreshing }
|
||||
val cronSaving: StateFlow<Boolean> = runtimeState(initial = false) { it.cronSaving }
|
||||
val cronErrorText: StateFlow<String?> = runtimeState(initial = null) { it.cronErrorText }
|
||||
val usageSummary: StateFlow<GatewayUsageSummary> = runtimeState(initial = GatewayUsageSummary(updatedAtMs = null, providers = emptyList())) { it.usageSummary }
|
||||
val usageRefreshing: StateFlow<Boolean> = runtimeState(initial = false) { it.usageRefreshing }
|
||||
@@ -402,6 +403,20 @@ class MainViewModel(
|
||||
ensureRuntime().refreshCronJobs()
|
||||
}
|
||||
|
||||
fun createCronJob(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
ensureRuntime().createCronJob(
|
||||
name = name,
|
||||
message = message,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
ensureRuntime().refreshUsage()
|
||||
}
|
||||
|
||||
@@ -70,6 +70,7 @@ import kotlinx.serialization.json.JsonArray
|
||||
import kotlinx.serialization.json.JsonObject
|
||||
import kotlinx.serialization.json.JsonPrimitive
|
||||
import kotlinx.serialization.json.buildJsonObject
|
||||
import java.time.Instant
|
||||
import java.util.UUID
|
||||
import java.util.concurrent.atomic.AtomicLong
|
||||
|
||||
@@ -322,6 +323,8 @@ class NodeRuntime(
|
||||
val cronJobs: StateFlow<List<GatewayCronJobSummary>> = _cronJobs.asStateFlow()
|
||||
private val _cronRefreshing = MutableStateFlow(false)
|
||||
val cronRefreshing: StateFlow<Boolean> = _cronRefreshing.asStateFlow()
|
||||
private val _cronSaving = MutableStateFlow(false)
|
||||
val cronSaving: StateFlow<Boolean> = _cronSaving.asStateFlow()
|
||||
private val _cronErrorText = MutableStateFlow<String?>(null)
|
||||
val cronErrorText: StateFlow<String?> = _cronErrorText.asStateFlow()
|
||||
private val _usageSummary = MutableStateFlow(GatewayUsageSummary(updatedAtMs = null, providers = emptyList()))
|
||||
@@ -719,6 +722,22 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
fun createCronJob(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
scope.launch {
|
||||
createCronJobOnGateway(
|
||||
name = name,
|
||||
message = message,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
fun refreshUsage() {
|
||||
scope.launch {
|
||||
refreshUsageFromGateway()
|
||||
@@ -1744,6 +1763,119 @@ class NodeRuntime(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun createCronJobOnGateway(
|
||||
name: String,
|
||||
message: String,
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
) {
|
||||
_cronSaving.value = true
|
||||
_cronErrorText.value = null
|
||||
if (!operatorConnected) {
|
||||
_cronErrorText.value = "Connect the gateway before creating a cron job."
|
||||
_cronSaving.value = false
|
||||
return
|
||||
}
|
||||
try {
|
||||
val cleanName = name.trim()
|
||||
val cleanMessage = message.trim()
|
||||
if (cleanName.isEmpty()) {
|
||||
throw IllegalArgumentException("Add a job name.")
|
||||
}
|
||||
if (cleanMessage.isEmpty()) {
|
||||
throw IllegalArgumentException("Add the message OpenClaw should run.")
|
||||
}
|
||||
val schedule = buildCronCreateSchedule(scheduleKind = scheduleKind, scheduleValue = scheduleValue.trim())
|
||||
val params =
|
||||
buildJsonObject {
|
||||
put("name", JsonPrimitive(cleanName))
|
||||
put("enabled", JsonPrimitive(true))
|
||||
put("deleteAfterRun", JsonPrimitive(scheduleKind == "Once"))
|
||||
put("sessionTarget", JsonPrimitive("isolated"))
|
||||
put("wakeMode", JsonPrimitive("now"))
|
||||
put("schedule", schedule)
|
||||
put(
|
||||
"payload",
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("agentTurn"))
|
||||
put("message", JsonPrimitive(cleanMessage))
|
||||
},
|
||||
)
|
||||
put(
|
||||
"delivery",
|
||||
buildJsonObject {
|
||||
put("mode", JsonPrimitive("announce"))
|
||||
put("channel", JsonPrimitive("last"))
|
||||
put("bestEffort", JsonPrimitive(true))
|
||||
},
|
||||
)
|
||||
}
|
||||
operatorSession.request("cron.add", params.toString())
|
||||
refreshCronFromGateway()
|
||||
} catch (err: IllegalArgumentException) {
|
||||
_cronErrorText.value = err.message ?: "Could not create cron job."
|
||||
} catch (_: Throwable) {
|
||||
_cronErrorText.value = "Could not create cron job."
|
||||
} finally {
|
||||
_cronSaving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
private fun buildCronCreateSchedule(
|
||||
scheduleKind: String,
|
||||
scheduleValue: String,
|
||||
): JsonObject =
|
||||
when (scheduleKind) {
|
||||
"Every" ->
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("every"))
|
||||
put("everyMs", JsonPrimitive(parseCronDurationMs(scheduleValue)))
|
||||
}
|
||||
"Once" ->
|
||||
buildJsonObject {
|
||||
put("kind", JsonPrimitive("at"))
|
||||
put("at", JsonPrimitive(resolveCronAtIso(scheduleValue)))
|
||||
}
|
||||
"Cron" ->
|
||||
buildJsonObject {
|
||||
val expr = scheduleValue.takeIf { it.isNotEmpty() } ?: throw IllegalArgumentException("Add a cron expression.")
|
||||
put("kind", JsonPrimitive("cron"))
|
||||
put("expr", JsonPrimitive(expr))
|
||||
put("staggerMs", JsonPrimitive(0))
|
||||
}
|
||||
else -> throw IllegalArgumentException("Choose a schedule.")
|
||||
}
|
||||
|
||||
private fun resolveCronAtIso(value: String): String {
|
||||
val clean = value.trim()
|
||||
if (clean.startsWith("+")) {
|
||||
return Instant.ofEpochMilli(System.currentTimeMillis() + parseCronDurationMs(clean.drop(1))).toString()
|
||||
}
|
||||
return try {
|
||||
Instant.parse(clean).toString()
|
||||
} catch (_: Throwable) {
|
||||
throw IllegalArgumentException("Use ISO time or +30m.")
|
||||
}
|
||||
}
|
||||
|
||||
private fun parseCronDurationMs(value: String): Long {
|
||||
val match =
|
||||
Regex("""^(\d+)\s*([mhd])$""")
|
||||
.matchEntire(value.trim().lowercase())
|
||||
?: throw IllegalArgumentException("Use 15m, 1h, or 1d.")
|
||||
val amount = match.groupValues[1].toLong()
|
||||
if (amount <= 0) {
|
||||
throw IllegalArgumentException("Use a duration greater than zero.")
|
||||
}
|
||||
val unit = match.groupValues[2]
|
||||
return amount *
|
||||
when (unit) {
|
||||
"m" -> 60_000L
|
||||
"h" -> 3_600_000L
|
||||
else -> 86_400_000L
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun refreshUsageFromGateway() {
|
||||
_usageRefreshing.value = true
|
||||
_usageErrorText.value = null
|
||||
|
||||
@@ -177,8 +177,14 @@ private fun V2CronJobsSettingsScreen(
|
||||
val cronStatus by viewModel.cronStatus.collectAsState()
|
||||
val cronJobs by viewModel.cronJobs.collectAsState()
|
||||
val cronRefreshing by viewModel.cronRefreshing.collectAsState()
|
||||
val cronSaving by viewModel.cronSaving.collectAsState()
|
||||
val cronErrorText by viewModel.cronErrorText.collectAsState()
|
||||
val isConnected by viewModel.isConnected.collectAsState()
|
||||
var showEditor by remember { mutableStateOf(false) }
|
||||
var jobName by remember { mutableStateOf("") }
|
||||
var jobMessage by remember { mutableStateOf("") }
|
||||
var scheduleKind by remember { mutableStateOf("Every") }
|
||||
var scheduleValue by remember { mutableStateOf("1h") }
|
||||
|
||||
LaunchedEffect(isConnected) {
|
||||
if (isConnected) {
|
||||
@@ -197,6 +203,36 @@ private fun V2CronJobsSettingsScreen(
|
||||
)
|
||||
Row(modifier = Modifier.fillMaxWidth(), horizontalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
ClawSecondaryButton(text = if (cronRefreshing) "Refreshing" else "Refresh", onClick = viewModel::refreshCronJobs, enabled = isConnected && !cronRefreshing, modifier = Modifier.weight(1f))
|
||||
ClawPrimaryButton(text = if (showEditor) "Close" else "New Job", onClick = { showEditor = !showEditor }, enabled = isConnected, modifier = Modifier.weight(1f))
|
||||
}
|
||||
if (showEditor) {
|
||||
V2CronJobEditorPanel(
|
||||
name = jobName,
|
||||
onNameChange = { jobName = it },
|
||||
message = jobMessage,
|
||||
onMessageChange = { jobMessage = it },
|
||||
scheduleKind = scheduleKind,
|
||||
onScheduleKindChange = {
|
||||
scheduleKind = it
|
||||
scheduleValue =
|
||||
when (it) {
|
||||
"Once" -> "+30m"
|
||||
"Cron" -> "0 9 * * *"
|
||||
else -> "1h"
|
||||
}
|
||||
},
|
||||
scheduleValue = scheduleValue,
|
||||
onScheduleValueChange = { scheduleValue = it },
|
||||
saving = cronSaving,
|
||||
onSave = {
|
||||
viewModel.createCronJob(
|
||||
name = jobName,
|
||||
message = jobMessage,
|
||||
scheduleKind = scheduleKind,
|
||||
scheduleValue = scheduleValue,
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
cronErrorText?.let { errorText ->
|
||||
ClawPanel {
|
||||
@@ -212,7 +248,7 @@ private fun V2CronJobsSettingsScreen(
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(3.dp)) {
|
||||
Text(text = "No scheduled jobs.", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
Text(text = "Create jobs from the WebUI or CLI and they will appear here.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
Text(text = "Create recurring OpenClaw work here or from the WebUI.", style = ClawTheme.type.body, color = ClawTheme.colors.textMuted)
|
||||
}
|
||||
}
|
||||
else -> V2CronJobsPanel(jobs = cronJobs)
|
||||
@@ -220,6 +256,43 @@ private fun V2CronJobsSettingsScreen(
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun V2CronJobEditorPanel(
|
||||
name: String,
|
||||
onNameChange: (String) -> Unit,
|
||||
message: String,
|
||||
onMessageChange: (String) -> Unit,
|
||||
scheduleKind: String,
|
||||
onScheduleKindChange: (String) -> Unit,
|
||||
scheduleValue: String,
|
||||
onScheduleValueChange: (String) -> Unit,
|
||||
saving: Boolean,
|
||||
onSave: () -> Unit,
|
||||
) {
|
||||
ClawPanel {
|
||||
Column(verticalArrangement = Arrangement.spacedBy(10.dp)) {
|
||||
Text(text = "New Job", style = ClawTheme.type.section, color = ClawTheme.colors.text)
|
||||
ClawTextField(value = name, onValueChange = onNameChange, placeholder = "Name")
|
||||
ClawTextField(value = message, onValueChange = onMessageChange, placeholder = "Message OpenClaw should run", minLines = 3)
|
||||
ClawSegmentedControl(
|
||||
options = listOf("Every", "Once", "Cron"),
|
||||
selected = scheduleKind,
|
||||
onSelect = onScheduleKindChange,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
ClawTextField(value = scheduleValue, onValueChange = onScheduleValueChange, placeholder = cronSchedulePlaceholder(scheduleKind))
|
||||
ClawPrimaryButton(text = if (saving) "Saving" else "Save Job", onClick = onSave, enabled = !saving, modifier = Modifier.fillMaxWidth())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cronSchedulePlaceholder(scheduleKind: String): String =
|
||||
when (scheduleKind) {
|
||||
"Once" -> "+30m or 2026-05-19T18:00:00Z"
|
||||
"Cron" -> "0 9 * * *"
|
||||
else -> "15m, 1h, or 1d"
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun V2AgentsSettingsScreen(
|
||||
viewModel: MainViewModel,
|
||||
|
||||
Reference in New Issue
Block a user