Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions docs/android.md
Original file line number Diff line number Diff line change
Expand Up @@ -64,9 +64,15 @@ runaway token burn events:
- prompt text, paths, session IDs, tool arguments, and API keys are never
included in notifications;
- repeated runaway events are debounced by `AndroidAlertGate`;
- runaway events set Vimichi into a visible alarm state in the Android UI;
- idle/recovery events clear the alarm state;
- Android 8+ receives a `vimit-agent-alerts` notification channel;
- Android 13+ still requires the user to grant notification permission at
runtime before notifications are visible.

Runtime permission UX for Android 13+ should be implemented as a separate
Android task before relying on notifications as the only alert channel.

The Android alert bridge consumes `AgentBurnEvent` from the shared core
detector. The foreground/background polling source for those events is tracked
as a separate Android task.
125 changes: 124 additions & 1 deletion src/lib.rs
Original file line number Diff line number Diff line change
Expand Up @@ -453,7 +453,7 @@ impl AgentPulse {

pub const DEFAULT_STUCK_BURN_MIN_RATE_PER_SEC: f64 = 0.1;
pub const DEFAULT_STUCK_BURN_TIMEOUT_SECS: u64 = 90;
pub const DEFAULT_ANDROID_ALERT_COOLDOWN_SECS: u64 = 300;
pub const DEFAULT_ANDROID_ALERT_COOLDOWN_SECS: u64 = 60;

#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum AgentBurnState {
Expand Down Expand Up @@ -561,6 +561,61 @@ pub fn android_runaway_alert_text() -> AndroidAlertText {
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidBurnUiState {
pub visible: bool,
pub level: &'static str,
pub text: &'static str,
pub creature_state: &'static str,
pub delta_level: &'static str,
pub delta_text: &'static str,
}

pub fn android_burn_ui_state(event: &AgentBurnEvent) -> AndroidBurnUiState {
match event.state {
AgentBurnState::Runaway => AndroidBurnUiState {
visible: true,
level: "danger",
text: "Vimichi: тревога, токены горят",
creature_state: "critical",
delta_level: "danger",
delta_text: "токены горят",
},
AgentBurnState::Active => AndroidBurnUiState {
visible: false,
level: "ok",
text: "",
creature_state: "awake",
delta_level: "ok",
delta_text: "агент работает",
},
AgentBurnState::Recovery => AndroidBurnUiState {
visible: false,
level: "ok",
text: "",
creature_state: "recovery",
delta_level: "ok",
delta_text: "расход остановлен",
},
AgentBurnState::Idle => AndroidBurnUiState {
visible: false,
level: "ok",
text: "",
creature_state: "sleeping",
delta_level: "ok",
delta_text: "агент простаивает",
},
AgentBurnState::Unknown => AndroidBurnUiState {
visible: false,
level: "unknown",
text: "",
creature_state: "awake",
delta_level: "ok",
delta_text: "нет данных",
},
}
}

#[derive(Debug, Clone, PartialEq, Eq)]
pub struct AndroidAlertGate {
cooldown_secs: u64,
Expand Down Expand Up @@ -626,6 +681,28 @@ impl AndroidAlertBridge {
}
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
pub fn android_handle_burn_event(
app: &AppWindow,
bridge: &AndroidAlertBridge,
event: &AgentBurnEvent,
now_secs: u64,
) -> Result<bool, String> {
android_apply_burn_ui_state(app, event);
bridge.alert_runaway(event, now_secs)
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
fn android_apply_burn_ui_state(app: &AppWindow, event: &AgentBurnEvent) {
let state = android_burn_ui_state(event);
app.set_agent_alert_visible(state.visible);
app.set_agent_alert_level(state.level.into());
app.set_agent_alert_text(state.text.into());
app.set_overlay_creature_state(state.creature_state.into());
app.set_overlay_delta_level(state.delta_level.into());
app.set_overlay_delta_text(state.delta_text.into());
}

#[cfg(all(target_os = "android", feature = "android-gui"))]
fn android_vibrate(app: &slint::android::AndroidApp, duration_ms: i64) -> Result<(), String> {
let app = app.clone();
Expand Down Expand Up @@ -1523,4 +1600,50 @@ mod tests {
assert!(!combined.contains("session"));
assert!(!combined.contains("c:\\"));
}

#[test]
fn android_burn_ui_state_marks_runaway_as_vimichi_alarm() {
let event = AgentBurnEvent {
state: AgentBurnState::Runaway,
burning_for_secs: Some(90),
token_rate_per_sec: 12.0,
};

let state = android_burn_ui_state(&event);

assert!(state.visible);
assert_eq!(state.level, "danger");
assert_eq!(state.text, "Vimichi: тревога, токены горят");
assert_eq!(state.creature_state, "critical");
assert_eq!(state.delta_level, "danger");
}

#[test]
fn android_burn_ui_state_resets_after_recovery() {
let event = AgentBurnEvent {
state: AgentBurnState::Recovery,
burning_for_secs: None,
token_rate_per_sec: 0.0,
};

let state = android_burn_ui_state(&event);

assert!(!state.visible);
assert_eq!(state.creature_state, "recovery");
assert_eq!(state.delta_text, "расход остановлен");
}

#[test]
fn android_alert_gate_default_uses_one_minute_cooldown() {
let mut gate = AndroidAlertGate::default();
let event = AgentBurnEvent {
state: AgentBurnState::Runaway,
burning_for_secs: Some(90),
token_rate_per_sec: 7.0,
};

assert!(gate.should_alert(&event, 1000));
assert!(!gate.should_alert(&event, 1059));
assert!(gate.should_alert(&event, 1060));
}
}
19 changes: 19 additions & 0 deletions ui/app.slint
Original file line number Diff line number Diff line change
Expand Up @@ -465,6 +465,9 @@ export component AppWindow inherits Window {
in property <string> source-text: "источник: загрузка";
in property <string> agent-text: "агенты: нет данных";
in property <string> token-rate-text: "токены/мин: нет данных";
in property <bool> agent-alert-visible: false;
in property <string> agent-alert-level: "ok";
in property <string> agent-alert-text: "";
in property <string> footer-text: ".env: текущая папка, затем рядом с exe";
in-out property <bool> settings-open: false;
in-out property <int> period-mode: 0;
Expand Down Expand Up @@ -640,6 +643,22 @@ export component AppWindow inherits Window {
font-weight: 600;
}

if (root.agent-alert-visible) : Rectangle {
height: 34px;
background: root.agent-alert-level == "danger" ? Theme.danger : Theme.warning;
border-radius: 8px;

Text {
text: root.agent-alert-text;
color: #ffffff;
font-size: 13px;
font-weight: 800;
horizontal-alignment: center;
vertical-alignment: center;
wrap: word-wrap;
}
}

if (!root.is-android) : HorizontalLayout {
spacing: 10px;
height: 34px;
Expand Down
Loading