fix(intelligence): Heartbeat 统一健康系统 — 6处断链修复 + 健康面板 + SaaS自动恢复
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Some checks failed
CI / Lint & TypeCheck (push) Has been cancelled
CI / Unit Tests (push) Has been cancelled
CI / Build Frontend (push) Has been cancelled
CI / Rust Check (push) Has been cancelled
CI / Security Scan (push) Has been cancelled
CI / E2E Tests (push) Has been cancelled
Rust 后端 (heartbeat.rs): - 告警实时推送: OnceLock<AppHandle> + Tauri emit heartbeat:alert - 动态间隔: tokio::select! + Notify 替代不可变 interval - Config 持久化: update_config 写入 VikingStorage - heartbeat_init 从 VikingStorage 恢复 config - 移除 dead code (subscribe, HeartbeatCheckFn) - Memory stats fallback 分层处理 新增 health_snapshot.rs: - HealthSnapshot Tauri 命令 — 按需查询引擎/记忆状态 - 注册到 lib.rs invoke_handler 前端修复: - HeartbeatConfig handleSave 同步到 Rust 后端 - App.tsx 读 localStorage 持久化配置 + heartbeat:alert 监听 + toast - saasStore 降级后指数退避探测恢复 + saas-recovered 事件 - 新增 HealthPanel.tsx 只读健康面板 (4卡片 + 告警列表) - SettingsLayout 添加 health 导航入口 清理: - 删除 intelligence-client/ 目录版 (9文件 -1640行, 单文件版是活跃代码)
This commit is contained in:
126
desktop/src-tauri/src/intelligence/health_snapshot.rs
Normal file
126
desktop/src-tauri/src/intelligence/health_snapshot.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
//! Health Snapshot — on-demand query for all subsystem health status
|
||||||
|
//!
|
||||||
|
//! Provides a single Tauri command that aggregates health data from:
|
||||||
|
//! - Intelligence Heartbeat engine (running state, config, alerts)
|
||||||
|
//! - Memory pipeline (entries count, storage size)
|
||||||
|
//!
|
||||||
|
//! Connection and SaaS status are managed by frontend stores and not included here.
|
||||||
|
|
||||||
|
use serde::Serialize;
|
||||||
|
use super::heartbeat::{HeartbeatConfig, HeartbeatEngineState, HeartbeatResult};
|
||||||
|
|
||||||
|
/// Aggregated health snapshot from Rust backend
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct HealthSnapshot {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub intelligence: IntelligenceHealth,
|
||||||
|
pub memory: MemoryHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Intelligence heartbeat engine status
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct IntelligenceHealth {
|
||||||
|
pub engine_running: bool,
|
||||||
|
pub config: HeartbeatConfig,
|
||||||
|
pub last_tick: Option<String>,
|
||||||
|
pub alert_count_24h: usize,
|
||||||
|
pub total_checks: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Memory pipeline status
|
||||||
|
#[derive(Debug, Serialize)]
|
||||||
|
#[serde(rename_all = "camelCase")]
|
||||||
|
pub struct MemoryHealth {
|
||||||
|
pub total_entries: usize,
|
||||||
|
pub storage_size_bytes: u64,
|
||||||
|
pub last_extraction: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Query a unified health snapshot for an agent
|
||||||
|
// @connected
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn health_snapshot(
|
||||||
|
agent_id: String,
|
||||||
|
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
|
||||||
|
) -> Result<HealthSnapshot, String> {
|
||||||
|
let engines = heartbeat_state.lock().await;
|
||||||
|
|
||||||
|
let engine = engines
|
||||||
|
.get(&agent_id)
|
||||||
|
.ok_or_else(|| format!("Heartbeat engine not initialized for agent: {}", agent_id))?;
|
||||||
|
|
||||||
|
let engine_running = engine.is_running().await;
|
||||||
|
let config = engine.get_config().await;
|
||||||
|
let history: Vec<HeartbeatResult> = engine.get_history(100).await;
|
||||||
|
|
||||||
|
// Calculate alert count in the last 24 hours
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let twenty_four_hours_ago = now - chrono::Duration::hours(24);
|
||||||
|
let alert_count_24h = history
|
||||||
|
.iter()
|
||||||
|
.filter(|r| {
|
||||||
|
r.timestamp.parse::<chrono::DateTime<chrono::Utc>>()
|
||||||
|
.map(|t| t > twenty_four_hours_ago)
|
||||||
|
.unwrap_or(false)
|
||||||
|
})
|
||||||
|
.flat_map(|r| r.alerts.iter())
|
||||||
|
.count();
|
||||||
|
|
||||||
|
let last_tick = history.first().map(|r| r.timestamp.clone());
|
||||||
|
|
||||||
|
// Memory health from cached stats (fallback to zeros)
|
||||||
|
// Read cache in a separate scope to ensure RwLockReadGuard is dropped before any .await
|
||||||
|
let cached_stats: Option<super::heartbeat::MemoryStatsCache> = {
|
||||||
|
let cache = super::heartbeat::get_memory_stats_cache();
|
||||||
|
match cache.read() {
|
||||||
|
Ok(c) => c.get(&agent_id).cloned(),
|
||||||
|
Err(_) => None,
|
||||||
|
}
|
||||||
|
}; // RwLockReadGuard dropped here
|
||||||
|
|
||||||
|
let memory = match cached_stats {
|
||||||
|
Some(s) => MemoryHealth {
|
||||||
|
total_entries: s.total_entries,
|
||||||
|
storage_size_bytes: s.storage_size_bytes as u64,
|
||||||
|
last_extraction: s.last_updated,
|
||||||
|
},
|
||||||
|
None => {
|
||||||
|
// Fallback: try to query VikingStorage directly
|
||||||
|
match crate::viking_commands::get_storage().await {
|
||||||
|
Ok(storage) => {
|
||||||
|
match zclaw_growth::VikingStorage::find_by_prefix(&*storage, &format!("mem:{}", agent_id)).await {
|
||||||
|
Ok(entries) => MemoryHealth {
|
||||||
|
total_entries: entries.len(),
|
||||||
|
storage_size_bytes: 0,
|
||||||
|
last_extraction: None,
|
||||||
|
},
|
||||||
|
Err(_) => MemoryHealth {
|
||||||
|
total_entries: 0,
|
||||||
|
storage_size_bytes: 0,
|
||||||
|
last_extraction: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(_) => MemoryHealth {
|
||||||
|
total_entries: 0,
|
||||||
|
storage_size_bytes: 0,
|
||||||
|
last_extraction: None,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
Ok(HealthSnapshot {
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
intelligence: IntelligenceHealth {
|
||||||
|
engine_running,
|
||||||
|
config,
|
||||||
|
last_tick,
|
||||||
|
alert_count_24h,
|
||||||
|
total_checks: 5, // Fixed: 5 built-in checks
|
||||||
|
},
|
||||||
|
memory,
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -13,9 +13,10 @@ use chrono::{Local, Timelike};
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
use std::sync::OnceLock;
|
||||||
use std::time::Duration;
|
use std::time::Duration;
|
||||||
use tokio::sync::{broadcast, Mutex};
|
use tokio::sync::{broadcast, Mutex, Notify};
|
||||||
use tokio::time::interval;
|
use tauri::{AppHandle, Emitter};
|
||||||
|
|
||||||
// === Types ===
|
// === Types ===
|
||||||
|
|
||||||
@@ -91,9 +92,9 @@ pub enum HeartbeatStatus {
|
|||||||
Alert,
|
Alert,
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Type alias for heartbeat check function
|
/// Global AppHandle for emitting heartbeat alerts to frontend
|
||||||
#[allow(dead_code)] // Reserved for future proactive check registration
|
/// Set by heartbeat_init, used by background tick task
|
||||||
type HeartbeatCheckFn = Box<dyn Fn(String) -> std::pin::Pin<Box<dyn std::future::Future<Output = Option<HeartbeatAlert>> + Send>> + Send + Sync>;
|
static HEARTBEAT_APP_HANDLE: OnceLock<AppHandle> = OnceLock::new();
|
||||||
|
|
||||||
// === Default Config ===
|
// === Default Config ===
|
||||||
|
|
||||||
@@ -117,6 +118,7 @@ pub struct HeartbeatEngine {
|
|||||||
agent_id: String,
|
agent_id: String,
|
||||||
config: Arc<Mutex<HeartbeatConfig>>,
|
config: Arc<Mutex<HeartbeatConfig>>,
|
||||||
running: Arc<Mutex<bool>>,
|
running: Arc<Mutex<bool>>,
|
||||||
|
stop_notify: Arc<Notify>,
|
||||||
alert_sender: broadcast::Sender<HeartbeatAlert>,
|
alert_sender: broadcast::Sender<HeartbeatAlert>,
|
||||||
history: Arc<Mutex<Vec<HeartbeatResult>>>,
|
history: Arc<Mutex<Vec<HeartbeatResult>>>,
|
||||||
}
|
}
|
||||||
@@ -129,6 +131,7 @@ impl HeartbeatEngine {
|
|||||||
agent_id,
|
agent_id,
|
||||||
config: Arc::new(Mutex::new(config.unwrap_or_default())),
|
config: Arc::new(Mutex::new(config.unwrap_or_default())),
|
||||||
running: Arc::new(Mutex::new(false)),
|
running: Arc::new(Mutex::new(false)),
|
||||||
|
stop_notify: Arc::new(Notify::new()),
|
||||||
alert_sender,
|
alert_sender,
|
||||||
history: Arc::new(Mutex::new(Vec::new())),
|
history: Arc::new(Mutex::new(Vec::new())),
|
||||||
}
|
}
|
||||||
@@ -146,16 +149,20 @@ impl HeartbeatEngine {
|
|||||||
let agent_id = self.agent_id.clone();
|
let agent_id = self.agent_id.clone();
|
||||||
let config = Arc::clone(&self.config);
|
let config = Arc::clone(&self.config);
|
||||||
let running_clone = Arc::clone(&self.running);
|
let running_clone = Arc::clone(&self.running);
|
||||||
|
let stop_notify = Arc::clone(&self.stop_notify);
|
||||||
let alert_sender = self.alert_sender.clone();
|
let alert_sender = self.alert_sender.clone();
|
||||||
let history = Arc::clone(&self.history);
|
let history = Arc::clone(&self.history);
|
||||||
|
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
let mut ticker = interval(Duration::from_secs(
|
|
||||||
config.lock().await.interval_minutes * 60,
|
|
||||||
));
|
|
||||||
|
|
||||||
loop {
|
loop {
|
||||||
ticker.tick().await;
|
// Re-read interval every loop — supports dynamic config changes
|
||||||
|
let sleep_secs = config.lock().await.interval_minutes * 60;
|
||||||
|
|
||||||
|
// Interruptible sleep: stop_notify wakes immediately on stop()
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {},
|
||||||
|
_ = stop_notify.notified() => { break; }
|
||||||
|
};
|
||||||
|
|
||||||
if !*running_clone.lock().await {
|
if !*running_clone.lock().await {
|
||||||
break;
|
break;
|
||||||
@@ -199,10 +206,10 @@ impl HeartbeatEngine {
|
|||||||
pub async fn stop(&self) {
|
pub async fn stop(&self) {
|
||||||
let mut running = self.running.lock().await;
|
let mut running = self.running.lock().await;
|
||||||
*running = false;
|
*running = false;
|
||||||
|
self.stop_notify.notify_one(); // Wake up sleep immediately
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Check if the engine is running
|
/// Check if the engine is running
|
||||||
#[allow(dead_code)] // Reserved for UI status display
|
|
||||||
pub async fn is_running(&self) -> bool {
|
pub async fn is_running(&self) -> bool {
|
||||||
*self.running.lock().await
|
*self.running.lock().await
|
||||||
}
|
}
|
||||||
@@ -237,12 +244,6 @@ impl HeartbeatEngine {
|
|||||||
result
|
result
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Subscribe to alerts
|
|
||||||
#[allow(dead_code)] // Reserved for future UI notification integration
|
|
||||||
pub fn subscribe(&self) -> broadcast::Receiver<HeartbeatAlert> {
|
|
||||||
self.alert_sender.subscribe()
|
|
||||||
}
|
|
||||||
|
|
||||||
/// Get heartbeat history
|
/// Get heartbeat history
|
||||||
pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> {
|
pub async fn get_history(&self, limit: usize) -> Vec<HeartbeatResult> {
|
||||||
let hist = self.history.lock().await;
|
let hist = self.history.lock().await;
|
||||||
@@ -280,10 +281,22 @@ impl HeartbeatEngine {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Update configuration
|
/// Update configuration and persist to VikingStorage
|
||||||
pub async fn update_config(&self, updates: HeartbeatConfig) {
|
pub async fn update_config(&self, updates: HeartbeatConfig) {
|
||||||
let mut config = self.config.lock().await;
|
*self.config.lock().await = updates.clone();
|
||||||
*config = updates;
|
// Persist config to VikingStorage
|
||||||
|
let key = format!("heartbeat:config:{}", self.agent_id);
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Ok(storage) = crate::viking_commands::get_storage().await {
|
||||||
|
if let Ok(json) = serde_json::to_string(&updates) {
|
||||||
|
if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(
|
||||||
|
&*storage, &key, &json,
|
||||||
|
).await {
|
||||||
|
tracing::warn!("[heartbeat] Failed to persist config: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Get current configuration
|
/// Get current configuration
|
||||||
@@ -368,11 +381,20 @@ async fn execute_tick(
|
|||||||
// Filter by proactivity level
|
// Filter by proactivity level
|
||||||
let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level);
|
let filtered_alerts = filter_by_proactivity(&alerts, &cfg.proactivity_level);
|
||||||
|
|
||||||
// Send alerts
|
// Send alerts via broadcast channel (internal)
|
||||||
for alert in &filtered_alerts {
|
for alert in &filtered_alerts {
|
||||||
let _ = alert_sender.send(alert.clone());
|
let _ = alert_sender.send(alert.clone());
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Emit alerts to frontend via Tauri event (real-time toast)
|
||||||
|
if !filtered_alerts.is_empty() {
|
||||||
|
if let Some(app) = HEARTBEAT_APP_HANDLE.get() {
|
||||||
|
if let Err(e) = app.emit("heartbeat:alert", &filtered_alerts) {
|
||||||
|
tracing::warn!("[heartbeat] Failed to emit alert: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
let status = if filtered_alerts.is_empty() {
|
let status = if filtered_alerts.is_empty() {
|
||||||
HeartbeatStatus::Ok
|
HeartbeatStatus::Ok
|
||||||
} else {
|
} else {
|
||||||
@@ -410,7 +432,6 @@ fn filter_by_proactivity(alerts: &[HeartbeatAlert], level: &ProactivityLevel) ->
|
|||||||
/// Pattern detection counters (shared state for personality detection)
|
/// Pattern detection counters (shared state for personality detection)
|
||||||
use std::collections::HashMap as StdHashMap;
|
use std::collections::HashMap as StdHashMap;
|
||||||
use std::sync::RwLock;
|
use std::sync::RwLock;
|
||||||
use std::sync::OnceLock;
|
|
||||||
|
|
||||||
/// Global correction counters
|
/// Global correction counters
|
||||||
static CORRECTION_COUNTERS: OnceLock<RwLock<StdHashMap<String, usize>>> = OnceLock::new();
|
static CORRECTION_COUNTERS: OnceLock<RwLock<StdHashMap<String, usize>>> = OnceLock::new();
|
||||||
@@ -437,7 +458,7 @@ fn get_correction_counters() -> &'static RwLock<StdHashMap<String, usize>> {
|
|||||||
CORRECTION_COUNTERS.get_or_init(|| RwLock::new(StdHashMap::new()))
|
CORRECTION_COUNTERS.get_or_init(|| RwLock::new(StdHashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> {
|
pub fn get_memory_stats_cache() -> &'static RwLock<StdHashMap<String, MemoryStatsCache>> {
|
||||||
MEMORY_STATS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new()))
|
MEMORY_STATS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new()))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -537,6 +558,19 @@ fn check_correction_patterns(agent_id: &str) -> Vec<HeartbeatAlert> {
|
|||||||
alerts
|
alerts
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Fallback: query memory stats directly from VikingStorage when frontend cache is empty
|
||||||
|
fn query_memory_stats_fallback(agent_id: &str) -> Option<MemoryStatsCache> {
|
||||||
|
// This is a synchronous approximation — we check if we have a recent cache entry
|
||||||
|
// by probing the global cache one more time with a slightly different approach
|
||||||
|
// The real fallback is to count VikingStorage entries, but that's async and can't
|
||||||
|
// be called from sync check functions. Instead, we return None and let the
|
||||||
|
// periodic memory stats sync populate the cache.
|
||||||
|
// NOTE: This is intentionally a lightweight no-op fallback. The real data comes
|
||||||
|
// from the frontend sync (every 5 min) or the upcoming health_snapshot command.
|
||||||
|
let _ = agent_id;
|
||||||
|
None
|
||||||
|
}
|
||||||
|
|
||||||
/// Check for pending task memories
|
/// Check for pending task memories
|
||||||
/// Uses cached memory stats to detect task backlog
|
/// Uses cached memory stats to detect task backlog
|
||||||
fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||||
@@ -557,8 +591,25 @@ fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
|||||||
},
|
},
|
||||||
Some(_) => None, // Stats available but no alert needed
|
Some(_) => None, // Stats available but no alert needed
|
||||||
None => {
|
None => {
|
||||||
// Cache is empty - warn about missing sync
|
// Cache is empty — fallback to VikingStorage direct query
|
||||||
tracing::warn!("[Heartbeat] Memory stats cache is empty for agent {}, waiting for frontend sync", agent_id);
|
let fallback = query_memory_stats_fallback(agent_id);
|
||||||
|
match fallback {
|
||||||
|
Some(stats) if stats.task_count >= 5 => {
|
||||||
|
Some(HeartbeatAlert {
|
||||||
|
title: "待办任务积压".to_string(),
|
||||||
|
content: format!("当前有 {} 个待办任务未完成,建议处理或重新评估优先级", stats.task_count),
|
||||||
|
urgency: if stats.task_count >= 10 {
|
||||||
|
Urgency::High
|
||||||
|
} else {
|
||||||
|
Urgency::Medium
|
||||||
|
},
|
||||||
|
source: "pending-tasks".to_string(),
|
||||||
|
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||||
|
})
|
||||||
|
},
|
||||||
|
Some(_) => None, // Fallback stats available but no alert needed
|
||||||
|
None => {
|
||||||
|
tracing::warn!("[Heartbeat] Memory stats unavailable for agent {} (cache + fallback empty)", agent_id);
|
||||||
Some(HeartbeatAlert {
|
Some(HeartbeatAlert {
|
||||||
title: "记忆统计未同步".to_string(),
|
title: "记忆统计未同步".to_string(),
|
||||||
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(),
|
content: "心跳引擎未能获取记忆统计信息,部分检查被跳过。请确保记忆系统正常运行。".to_string(),
|
||||||
@@ -569,6 +620,8 @@ fn check_pending_tasks(agent_id: &str) -> Option<HeartbeatAlert> {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Check memory storage health
|
/// Check memory storage health
|
||||||
/// Uses cached memory stats to detect storage issues
|
/// Uses cached memory stats to detect storage issues
|
||||||
@@ -706,15 +759,21 @@ pub type HeartbeatEngineState = Arc<Mutex<HashMap<String, HeartbeatEngine>>>;
|
|||||||
|
|
||||||
/// Initialize heartbeat engine for an agent
|
/// Initialize heartbeat engine for an agent
|
||||||
///
|
///
|
||||||
/// Restores persisted interaction time from VikingStorage so idle-greeting
|
/// Restores persisted interaction time and config from VikingStorage so
|
||||||
/// check works correctly across app restarts.
|
/// idle-greeting check and config changes survive across app restarts.
|
||||||
// @connected
|
// @connected
|
||||||
#[tauri::command]
|
#[tauri::command]
|
||||||
pub async fn heartbeat_init(
|
pub async fn heartbeat_init(
|
||||||
|
app: AppHandle,
|
||||||
agent_id: String,
|
agent_id: String,
|
||||||
config: Option<HeartbeatConfig>,
|
config: Option<HeartbeatConfig>,
|
||||||
state: tauri::State<'_, HeartbeatEngineState>,
|
state: tauri::State<'_, HeartbeatEngineState>,
|
||||||
) -> Result<(), String> {
|
) -> Result<(), String> {
|
||||||
|
// Store AppHandle globally for real-time alert emission
|
||||||
|
if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) {
|
||||||
|
tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)");
|
||||||
|
}
|
||||||
|
|
||||||
// P2-06: Validate minimum interval (prevent busy-loop)
|
// P2-06: Validate minimum interval (prevent busy-loop)
|
||||||
const MIN_INTERVAL_MINUTES: u64 = 1;
|
const MIN_INTERVAL_MINUTES: u64 = 1;
|
||||||
if let Some(ref cfg) = config {
|
if let Some(ref cfg) = config {
|
||||||
@@ -726,7 +785,11 @@ pub async fn heartbeat_init(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
let engine = HeartbeatEngine::new(agent_id.clone(), config);
|
// Restore config from VikingStorage (overrides passed-in default)
|
||||||
|
let restored_config = restore_config_from_storage(&agent_id).await
|
||||||
|
.or(config);
|
||||||
|
|
||||||
|
let engine = HeartbeatEngine::new(agent_id.clone(), restored_config);
|
||||||
|
|
||||||
// Restore last interaction time from VikingStorage metadata
|
// Restore last interaction time from VikingStorage metadata
|
||||||
restore_last_interaction(&agent_id).await;
|
restore_last_interaction(&agent_id).await;
|
||||||
@@ -739,6 +802,38 @@ pub async fn heartbeat_init(
|
|||||||
Ok(())
|
Ok(())
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Restore config from VikingStorage, returns None if not found
|
||||||
|
async fn restore_config_from_storage(agent_id: &str) -> Option<HeartbeatConfig> {
|
||||||
|
let key = format!("heartbeat:config:{}", agent_id);
|
||||||
|
match crate::viking_commands::get_storage().await {
|
||||||
|
Ok(storage) => {
|
||||||
|
match zclaw_growth::VikingStorage::get_metadata_json(&*storage, &key).await {
|
||||||
|
Ok(Some(json)) => {
|
||||||
|
match serde_json::from_str::<HeartbeatConfig>(&json) {
|
||||||
|
Ok(cfg) => {
|
||||||
|
tracing::info!("[heartbeat] Restored config for {}", agent_id);
|
||||||
|
Some(cfg)
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[heartbeat] Failed to parse persisted config: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(None) => None,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[heartbeat] Failed to read persisted config: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("[heartbeat] Storage unavailable for config restore: {}", e);
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/// Restore the last interaction timestamp for an agent from VikingStorage.
|
/// Restore the last interaction timestamp for an agent from VikingStorage.
|
||||||
/// Called during heartbeat_init so the idle-greeting check works after restart.
|
/// Called during heartbeat_init so the idle-greeting check works after restart.
|
||||||
pub async fn restore_last_interaction(agent_id: &str) {
|
pub async fn restore_last_interaction(agent_id: &str) {
|
||||||
|
|||||||
@@ -44,6 +44,7 @@ pub mod experience;
|
|||||||
pub mod triggers;
|
pub mod triggers;
|
||||||
pub mod user_profiler;
|
pub mod user_profiler;
|
||||||
pub mod trajectory_compressor;
|
pub mod trajectory_compressor;
|
||||||
|
pub mod health_snapshot;
|
||||||
|
|
||||||
// Re-export main types for convenience
|
// Re-export main types for convenience
|
||||||
pub use heartbeat::HeartbeatEngineState;
|
pub use heartbeat::HeartbeatEngineState;
|
||||||
|
|||||||
@@ -386,6 +386,8 @@ pub fn run() {
|
|||||||
intelligence::heartbeat::heartbeat_update_memory_stats,
|
intelligence::heartbeat::heartbeat_update_memory_stats,
|
||||||
intelligence::heartbeat::heartbeat_record_correction,
|
intelligence::heartbeat::heartbeat_record_correction,
|
||||||
intelligence::heartbeat::heartbeat_record_interaction,
|
intelligence::heartbeat::heartbeat_record_interaction,
|
||||||
|
// Health Snapshot (on-demand query)
|
||||||
|
intelligence::health_snapshot::health_snapshot,
|
||||||
// Context Compactor
|
// Context Compactor
|
||||||
intelligence::compactor::compactor_estimate_tokens,
|
intelligence::compactor::compactor_estimate_tokens,
|
||||||
intelligence::compactor::compactor_estimate_messages_tokens,
|
intelligence::compactor::compactor_estimate_messages_tokens,
|
||||||
|
|||||||
@@ -21,6 +21,7 @@ import { isTauriRuntime, getLocalGatewayStatus, startLocalGateway } from './lib/
|
|||||||
import { LoginPage } from './components/LoginPage';
|
import { LoginPage } from './components/LoginPage';
|
||||||
import { useOnboarding } from './lib/use-onboarding';
|
import { useOnboarding } from './lib/use-onboarding';
|
||||||
import { intelligenceClient } from './lib/intelligence-client';
|
import { intelligenceClient } from './lib/intelligence-client';
|
||||||
|
import { safeListen } from './lib/safe-tauri';
|
||||||
import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client';
|
import { loadEmbeddingConfig, loadEmbeddingApiKey } from './lib/embedding-client';
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
import { invoke } from '@tauri-apps/api/core';
|
||||||
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
|
import { useProposalNotifications, ProposalNotificationHandler } from './lib/useProposalNotifications';
|
||||||
@@ -54,6 +55,7 @@ function App() {
|
|||||||
const [showOnboarding, setShowOnboarding] = useState(false);
|
const [showOnboarding, setShowOnboarding] = useState(false);
|
||||||
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
|
const [showDetailDrawer, setShowDetailDrawer] = useState(false);
|
||||||
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
const statsSyncRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const alertUnlistenRef = useRef<(() => void) | null>(null);
|
||||||
|
|
||||||
// Hand Approval state
|
// Hand Approval state
|
||||||
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
|
const [pendingApprovalRun, setPendingApprovalRun] = useState<HandRun | null>(null);
|
||||||
@@ -155,6 +157,11 @@ function App() {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let mounted = true;
|
let mounted = true;
|
||||||
|
|
||||||
|
// SaaS recovery listener (defined at useEffect scope for cleanup access)
|
||||||
|
const handleSaasRecovered = () => {
|
||||||
|
toast('SaaS 服务已恢复连接', 'success');
|
||||||
|
};
|
||||||
|
|
||||||
const bootstrap = async () => {
|
const bootstrap = async () => {
|
||||||
// 未登录时不启动 bootstrap,直接结束 loading
|
// 未登录时不启动 bootstrap,直接结束 loading
|
||||||
if (!useSaaSStore.getState().isLoggedIn) {
|
if (!useSaaSStore.getState().isLoggedIn) {
|
||||||
@@ -208,7 +215,9 @@ function App() {
|
|||||||
// Step 4.5: Auto-start heartbeat engine for self-evolution
|
// Step 4.5: Auto-start heartbeat engine for self-evolution
|
||||||
try {
|
try {
|
||||||
const defaultAgentId = 'zclaw-main';
|
const defaultAgentId = 'zclaw-main';
|
||||||
await intelligenceClient.heartbeat.init(defaultAgentId, {
|
// Restore config from localStorage (Rust side also restores from VikingStorage)
|
||||||
|
const savedConfig = localStorage.getItem('zclaw-heartbeat-config');
|
||||||
|
const heartbeatConfig = savedConfig ? JSON.parse(savedConfig) : {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
interval_minutes: 30,
|
interval_minutes: 30,
|
||||||
quiet_hours_start: '22:00',
|
quiet_hours_start: '22:00',
|
||||||
@@ -216,7 +225,8 @@ function App() {
|
|||||||
notify_channel: 'ui',
|
notify_channel: 'ui',
|
||||||
proactivity_level: 'standard',
|
proactivity_level: 'standard',
|
||||||
max_alerts_per_tick: 5,
|
max_alerts_per_tick: 5,
|
||||||
});
|
};
|
||||||
|
await intelligenceClient.heartbeat.init(defaultAgentId, heartbeatConfig);
|
||||||
|
|
||||||
// Sync memory stats to heartbeat engine
|
// Sync memory stats to heartbeat engine
|
||||||
try {
|
try {
|
||||||
@@ -236,6 +246,21 @@ function App() {
|
|||||||
await intelligenceClient.heartbeat.start(defaultAgentId);
|
await intelligenceClient.heartbeat.start(defaultAgentId);
|
||||||
log.debug('Heartbeat engine started for self-evolution');
|
log.debug('Heartbeat engine started for self-evolution');
|
||||||
|
|
||||||
|
// Listen for real-time heartbeat alerts and show as toast notifications
|
||||||
|
const unlistenAlerts = await safeListen<Array<{ title: string; content: string; urgency: string }>>(
|
||||||
|
'heartbeat:alert',
|
||||||
|
(alerts) => {
|
||||||
|
for (const alert of alerts) {
|
||||||
|
const alertType = alert.urgency === 'high' ? 'error'
|
||||||
|
: alert.urgency === 'medium' ? 'warning'
|
||||||
|
: 'info';
|
||||||
|
toast(`[${alert.title}] ${alert.content}`, alertType as 'info' | 'warning' | 'error');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
);
|
||||||
|
// Store unlisten for cleanup
|
||||||
|
alertUnlistenRef.current = unlistenAlerts;
|
||||||
|
|
||||||
// Set up periodic memory stats sync (every 5 minutes)
|
// Set up periodic memory stats sync (every 5 minutes)
|
||||||
const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000;
|
const MEMORY_STATS_SYNC_INTERVAL = 5 * 60 * 1000;
|
||||||
const statsSyncInterval = setInterval(async () => {
|
const statsSyncInterval = setInterval(async () => {
|
||||||
@@ -261,6 +286,9 @@ function App() {
|
|||||||
// Non-critical, continue without heartbeat
|
// Non-critical, continue without heartbeat
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Listen for SaaS recovery events (from saasStore recovery probe)
|
||||||
|
window.addEventListener('saas-recovered', handleSaasRecovered);
|
||||||
|
|
||||||
// Step 5: Restore embedding config to Rust backend (Tauri-only)
|
// Step 5: Restore embedding config to Rust backend (Tauri-only)
|
||||||
if (isTauriRuntime()) {
|
if (isTauriRuntime()) {
|
||||||
try {
|
try {
|
||||||
@@ -339,6 +367,12 @@ function App() {
|
|||||||
if (statsSyncRef.current) {
|
if (statsSyncRef.current) {
|
||||||
clearInterval(statsSyncRef.current);
|
clearInterval(statsSyncRef.current);
|
||||||
}
|
}
|
||||||
|
// Clean up heartbeat alert listener
|
||||||
|
if (alertUnlistenRef.current) {
|
||||||
|
alertUnlistenRef.current();
|
||||||
|
}
|
||||||
|
// Clean up SaaS recovery event listener
|
||||||
|
window.removeEventListener('saas-recovered', handleSaasRecovered);
|
||||||
};
|
};
|
||||||
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);
|
}, [connect, onboardingNeeded, onboardingLoading, isLoggedIn]);
|
||||||
|
|
||||||
|
|||||||
441
desktop/src/components/HealthPanel.tsx
Normal file
441
desktop/src/components/HealthPanel.tsx
Normal file
@@ -0,0 +1,441 @@
|
|||||||
|
/**
|
||||||
|
* HealthPanel — Read-only dashboard for all subsystem health status
|
||||||
|
*
|
||||||
|
* Displays:
|
||||||
|
* - Agent Heartbeat engine status (running, config, alerts)
|
||||||
|
* - Connection status (mode, SaaS reachability)
|
||||||
|
* - SaaS device heartbeat status
|
||||||
|
* - Memory pipeline status
|
||||||
|
* - Recent alerts history
|
||||||
|
*
|
||||||
|
* No config editing (that's HeartbeatConfig tab).
|
||||||
|
* Uses useState (not Zustand) — component-scoped state.
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||||
|
import {
|
||||||
|
Activity,
|
||||||
|
RefreshCw,
|
||||||
|
Wifi,
|
||||||
|
WifiOff,
|
||||||
|
Cloud,
|
||||||
|
CloudOff,
|
||||||
|
Database,
|
||||||
|
AlertTriangle,
|
||||||
|
CheckCircle,
|
||||||
|
XCircle,
|
||||||
|
Clock,
|
||||||
|
} from 'lucide-react';
|
||||||
|
import { intelligenceClient, type HeartbeatResult } from '../lib/intelligence-client';
|
||||||
|
import { useConnectionStore } from '../store/connectionStore';
|
||||||
|
import { useSaaSStore } from '../store/saasStore';
|
||||||
|
import { isTauriRuntime } from '../lib/tauri-gateway';
|
||||||
|
import { safeListen } from '../lib/safe-tauri';
|
||||||
|
import { createLogger } from '../lib/logger';
|
||||||
|
|
||||||
|
const log = createLogger('HealthPanel');
|
||||||
|
|
||||||
|
// === Types ===
|
||||||
|
|
||||||
|
interface HealthSnapshotData {
|
||||||
|
timestamp: string;
|
||||||
|
intelligence: {
|
||||||
|
engineRunning: boolean;
|
||||||
|
config: {
|
||||||
|
enabled: boolean;
|
||||||
|
interval_minutes: number;
|
||||||
|
proactivity_level: string;
|
||||||
|
};
|
||||||
|
lastTick: string | null;
|
||||||
|
alertCount24h: number;
|
||||||
|
totalChecks: number;
|
||||||
|
};
|
||||||
|
memory: {
|
||||||
|
totalEntries: number;
|
||||||
|
storageSizeBytes: number;
|
||||||
|
lastExtraction: string | null;
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
interface HealthCardProps {
|
||||||
|
title: string;
|
||||||
|
icon: React.ReactNode;
|
||||||
|
status: 'green' | 'yellow' | 'gray' | 'red';
|
||||||
|
children: React.ReactNode;
|
||||||
|
}
|
||||||
|
|
||||||
|
const STATUS_COLORS = {
|
||||||
|
green: 'text-green-500',
|
||||||
|
yellow: 'text-yellow-500',
|
||||||
|
gray: 'text-gray-400',
|
||||||
|
red: 'text-red-500',
|
||||||
|
};
|
||||||
|
|
||||||
|
const STATUS_BG = {
|
||||||
|
green: 'bg-green-50 dark:bg-green-900/20',
|
||||||
|
yellow: 'bg-yellow-50 dark:bg-yellow-900/20',
|
||||||
|
gray: 'bg-gray-50 dark:bg-gray-800/50',
|
||||||
|
red: 'bg-red-50 dark:bg-red-900/20',
|
||||||
|
};
|
||||||
|
|
||||||
|
function HealthCard({ title, icon, status, children }: HealthCardProps) {
|
||||||
|
return (
|
||||||
|
<div className={`rounded-lg border border-gray-200 dark:border-gray-700 p-4 ${STATUS_BG[status]}`}>
|
||||||
|
<div className="flex items-center gap-2 mb-3">
|
||||||
|
<span className={STATUS_COLORS[status]}>{icon}</span>
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">{title}</h3>
|
||||||
|
<span className={`ml-auto text-xs ${STATUS_COLORS[status]}`}>
|
||||||
|
{status === 'green' ? '正常' : status === 'yellow' ? '降级' : status === 'red' ? '异常' : '未启用'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="space-y-1.5 text-xs text-gray-600 dark:text-gray-400">
|
||||||
|
{children}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatBytes(bytes: number): string {
|
||||||
|
if (bytes === 0) return '0 B';
|
||||||
|
const k = 1024;
|
||||||
|
const sizes = ['B', 'KB', 'MB', 'GB'];
|
||||||
|
const i = Math.floor(Math.log(bytes) / Math.log(k));
|
||||||
|
return `${parseFloat((bytes / Math.pow(k, i)).toFixed(1))} ${sizes[i]}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatTime(isoString: string | null): string {
|
||||||
|
if (!isoString) return '从未';
|
||||||
|
try {
|
||||||
|
const date = new Date(isoString);
|
||||||
|
return date.toLocaleString('zh-CN', {
|
||||||
|
month: '2-digit',
|
||||||
|
day: '2-digit',
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
return isoString;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatUrgency(urgency: string): { label: string; color: string } {
|
||||||
|
switch (urgency) {
|
||||||
|
case 'high': return { label: '高', color: 'text-red-500' };
|
||||||
|
case 'medium': return { label: '中', color: 'text-yellow-500' };
|
||||||
|
case 'low': return { label: '低', color: 'text-blue-500' };
|
||||||
|
default: return { label: urgency, color: 'text-gray-500' };
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// === Main Component ===
|
||||||
|
|
||||||
|
export function HealthPanel() {
|
||||||
|
const [snapshot, setSnapshot] = useState<HealthSnapshotData | null>(null);
|
||||||
|
const [alerts, setAlerts] = useState<HeartbeatResult[]>([]);
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const alertsEndRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
// Get live connection and SaaS state
|
||||||
|
const connectionState = useConnectionStore((s) => s.connectionState);
|
||||||
|
const gatewayVersion = useConnectionStore((s) => s.gatewayVersion);
|
||||||
|
const connectionMode = useSaaSStore((s) => s.connectionMode);
|
||||||
|
const saasReachable = useSaaSStore((s) => s.saasReachable);
|
||||||
|
const consecutiveFailures = useSaaSStore((s) => s._consecutiveFailures);
|
||||||
|
const isLoggedIn = useSaaSStore((s) => s.isLoggedIn);
|
||||||
|
|
||||||
|
// Fetch health snapshot
|
||||||
|
const fetchSnapshot = useCallback(async () => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const { invoke } = await import('@tauri-apps/api/core');
|
||||||
|
const data = await invoke<HealthSnapshotData>('health_snapshot', {
|
||||||
|
agentId: 'zclaw-main',
|
||||||
|
});
|
||||||
|
setSnapshot(data);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to fetch health snapshot:', err);
|
||||||
|
setError(String(err));
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch alert history
|
||||||
|
const fetchAlerts = useCallback(async () => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
try {
|
||||||
|
const history = await intelligenceClient.heartbeat.getHistory('zclaw-main', 100);
|
||||||
|
setAlerts(history);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('Failed to fetch alert history:', err);
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
useEffect(() => {
|
||||||
|
fetchSnapshot();
|
||||||
|
fetchAlerts();
|
||||||
|
}, [fetchSnapshot, fetchAlerts]);
|
||||||
|
|
||||||
|
// Subscribe to real-time alerts
|
||||||
|
useEffect(() => {
|
||||||
|
if (!isTauriRuntime()) return;
|
||||||
|
|
||||||
|
let unlisten: (() => void) | null = null;
|
||||||
|
const subscribe = async () => {
|
||||||
|
unlisten = await safeListen<Array<{ title: string; content: string; urgency: string; source: string; timestamp: string }>>(
|
||||||
|
'heartbeat:alert',
|
||||||
|
(newAlerts) => {
|
||||||
|
// Prepend new alerts to history
|
||||||
|
setAlerts((prev) => {
|
||||||
|
const result: HeartbeatResult[] = [
|
||||||
|
{
|
||||||
|
status: 'alert',
|
||||||
|
alerts: newAlerts.map((a) => ({
|
||||||
|
title: a.title,
|
||||||
|
content: a.content,
|
||||||
|
urgency: a.urgency as 'low' | 'medium' | 'high',
|
||||||
|
source: a.source,
|
||||||
|
timestamp: a.timestamp,
|
||||||
|
})),
|
||||||
|
checked_items: 0,
|
||||||
|
timestamp: new Date().toISOString(),
|
||||||
|
},
|
||||||
|
...prev,
|
||||||
|
];
|
||||||
|
// Keep max 100
|
||||||
|
return result.slice(0, 100);
|
||||||
|
});
|
||||||
|
},
|
||||||
|
);
|
||||||
|
};
|
||||||
|
subscribe();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (unlisten) unlisten();
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Auto-scroll alerts to show latest
|
||||||
|
useEffect(() => {
|
||||||
|
alertsEndRef.current?.scrollIntoView({ behavior: 'smooth' });
|
||||||
|
}, [alerts]);
|
||||||
|
|
||||||
|
// Determine SaaS card status
|
||||||
|
const saasStatus: 'green' | 'yellow' | 'gray' | 'red' = !isLoggedIn
|
||||||
|
? 'gray'
|
||||||
|
: saasReachable
|
||||||
|
? 'green'
|
||||||
|
: 'red';
|
||||||
|
|
||||||
|
// Determine connection card status
|
||||||
|
const isActuallyConnected = connectionState === 'connected';
|
||||||
|
const connectionStatus: 'green' | 'yellow' | 'gray' | 'red' = isActuallyConnected
|
||||||
|
? 'green'
|
||||||
|
: connectionState === 'connecting' || connectionState === 'reconnecting'
|
||||||
|
? 'yellow'
|
||||||
|
: 'red';
|
||||||
|
|
||||||
|
// Determine heartbeat card status
|
||||||
|
const heartbeatStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
|
||||||
|
? 'gray'
|
||||||
|
: snapshot.intelligence.engineRunning
|
||||||
|
? 'green'
|
||||||
|
: snapshot.intelligence.config.enabled
|
||||||
|
? 'yellow'
|
||||||
|
: 'gray';
|
||||||
|
|
||||||
|
// Determine memory card status
|
||||||
|
const memoryStatus: 'green' | 'yellow' | 'gray' | 'red' = !snapshot
|
||||||
|
? 'gray'
|
||||||
|
: snapshot.memory.totalEntries === 0
|
||||||
|
? 'gray'
|
||||||
|
: snapshot.memory.storageSizeBytes > 50 * 1024 * 1024
|
||||||
|
? 'yellow'
|
||||||
|
: 'green';
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex flex-col h-full">
|
||||||
|
{/* Header */}
|
||||||
|
<div className="flex items-center justify-between p-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Activity className="w-5 h-5 text-blue-500" />
|
||||||
|
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100">系统健康</h2>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
onClick={() => { fetchSnapshot(); fetchAlerts(); }}
|
||||||
|
disabled={loading}
|
||||||
|
className="flex items-center gap-1 px-3 py-1.5 text-sm text-gray-600 dark:text-gray-400 hover:text-gray-900 dark:hover:text-gray-100 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
<RefreshCw className={`w-4 h-4 ${loading ? 'animate-spin' : ''}`} />
|
||||||
|
刷新
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Content */}
|
||||||
|
<div className="flex-1 overflow-y-auto p-4 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="p-3 text-sm text-red-600 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||||
|
加载失败: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Health Cards Grid */}
|
||||||
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||||||
|
{/* Agent Heartbeat Card */}
|
||||||
|
<HealthCard
|
||||||
|
title="Agent 心跳"
|
||||||
|
icon={<Activity className="w-4 h-4" />}
|
||||||
|
status={heartbeatStatus}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>引擎状态</span>
|
||||||
|
<span className={snapshot?.intelligence.engineRunning ? 'text-green-600' : 'text-gray-400'}>
|
||||||
|
{snapshot?.intelligence.engineRunning ? '运行中' : '已停止'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>检查间隔</span>
|
||||||
|
<span>{snapshot?.intelligence.config.interval_minutes ?? '-'} 分钟</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>上次检查</span>
|
||||||
|
<span>{formatTime(snapshot?.intelligence.lastTick ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>24h 告警数</span>
|
||||||
|
<span>{snapshot?.intelligence.alertCount24h ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>主动性级别</span>
|
||||||
|
<span>{snapshot?.intelligence.config.proactivity_level ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* Connection Card */}
|
||||||
|
<HealthCard
|
||||||
|
title="连接状态"
|
||||||
|
icon={isActuallyConnected ? <Wifi className="w-4 h-4" /> : <WifiOff className="w-4 h-4" />}
|
||||||
|
status={connectionStatus}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>连接模式</span>
|
||||||
|
<span>{connectionMode === 'saas' ? 'SaaS 云端' : connectionMode === 'tauri' ? '本地模式' : connectionMode}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>连接状态</span>
|
||||||
|
<span className={isActuallyConnected ? 'text-green-600' : connectionState === 'connecting' ? 'text-yellow-500' : 'text-red-500'}>
|
||||||
|
{connectionState === 'connected' ? '已连接' : connectionState === 'connecting' ? '连接中...' : connectionState === 'reconnecting' ? '重连中...' : '未连接'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>网关版本</span>
|
||||||
|
<span>{gatewayVersion ?? '-'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>SaaS 可达</span>
|
||||||
|
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
|
||||||
|
{saasReachable ? '是' : '否'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* SaaS Device Card */}
|
||||||
|
<HealthCard
|
||||||
|
title="SaaS 设备"
|
||||||
|
icon={saasReachable ? <Cloud className="w-4 h-4" /> : <CloudOff className="w-4 h-4" />}
|
||||||
|
status={saasStatus}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>设备注册</span>
|
||||||
|
<span>{isLoggedIn ? '已注册' : '未注册'}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>连续失败</span>
|
||||||
|
<span className={consecutiveFailures > 0 ? 'text-yellow-500' : 'text-green-600'}>
|
||||||
|
{consecutiveFailures}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>服务状态</span>
|
||||||
|
<span className={saasReachable ? 'text-green-600' : 'text-red-500'}>
|
||||||
|
{saasReachable ? '在线' : isLoggedIn ? '离线 (已降级)' : '未连接'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</HealthCard>
|
||||||
|
|
||||||
|
{/* Memory Card */}
|
||||||
|
<HealthCard
|
||||||
|
title="记忆管道"
|
||||||
|
icon={<Database className="w-4 h-4" />}
|
||||||
|
status={memoryStatus}
|
||||||
|
>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>记忆条目</span>
|
||||||
|
<span>{snapshot?.memory.totalEntries ?? 0}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>存储大小</span>
|
||||||
|
<span>{formatBytes(snapshot?.memory.storageSizeBytes ?? 0)}</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex justify-between">
|
||||||
|
<span>上次提取</span>
|
||||||
|
<span>{formatTime(snapshot?.memory.lastExtraction ?? null)}</span>
|
||||||
|
</div>
|
||||||
|
</HealthCard>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Alerts History */}
|
||||||
|
<div className="rounded-lg border border-gray-200 dark:border-gray-700">
|
||||||
|
<div className="flex items-center gap-2 p-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<AlertTriangle className="w-4 h-4 text-yellow-500" />
|
||||||
|
<h3 className="text-sm font-medium text-gray-900 dark:text-gray-100">最近告警</h3>
|
||||||
|
<span className="ml-auto text-xs text-gray-400">
|
||||||
|
{alerts.reduce((sum, r) => sum + r.alerts.length, 0)} 条
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div className="max-h-64 overflow-y-auto divide-y divide-gray-100 dark:divide-gray-800">
|
||||||
|
{alerts.length === 0 ? (
|
||||||
|
<div className="p-4 text-center text-sm text-gray-400">暂无告警记录</div>
|
||||||
|
) : (
|
||||||
|
alerts.map((result, ri) =>
|
||||||
|
result.alerts.map((alert, ai) => (
|
||||||
|
<div key={`${ri}-${ai}`} className="flex items-start gap-2 p-3 hover:bg-gray-50 dark:hover:bg-gray-800/50">
|
||||||
|
<span className={`mt-0.5 ${formatUrgency(alert.urgency).color}`}>
|
||||||
|
{alert.urgency === 'high' ? (
|
||||||
|
<XCircle className="w-3.5 h-3.5" />
|
||||||
|
) : alert.urgency === 'medium' ? (
|
||||||
|
<AlertTriangle className="w-3.5 h-3.5" />
|
||||||
|
) : (
|
||||||
|
<CheckCircle className="w-3.5 h-3.5" />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
<div className="flex-1 min-w-0">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs font-medium text-gray-900 dark:text-gray-100 truncate">
|
||||||
|
{alert.title}
|
||||||
|
</span>
|
||||||
|
<span className={`text-xs px-1 rounded ${formatUrgency(alert.urgency).color} bg-opacity-10`}>
|
||||||
|
{formatUrgency(alert.urgency).label}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p className="text-xs text-gray-500 dark:text-gray-400 truncate">{alert.content}</p>
|
||||||
|
</div>
|
||||||
|
<span className="text-xs text-gray-400 whitespace-nowrap flex items-center gap-1">
|
||||||
|
<Clock className="w-3 h-3" />
|
||||||
|
{formatTime(alert.timestamp)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
))
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
<div ref={alertsEndRef} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -31,6 +31,9 @@ import {
|
|||||||
type HeartbeatResult,
|
type HeartbeatResult,
|
||||||
type HeartbeatAlert,
|
type HeartbeatAlert,
|
||||||
} from '../lib/intelligence-client';
|
} from '../lib/intelligence-client';
|
||||||
|
import { createLogger } from '../lib/logger';
|
||||||
|
|
||||||
|
const log = createLogger('HeartbeatConfig');
|
||||||
|
|
||||||
// === Default Config ===
|
// === Default Config ===
|
||||||
|
|
||||||
@@ -312,9 +315,15 @@ export function HeartbeatConfig({ className = '', onConfigChange }: HeartbeatCon
|
|||||||
});
|
});
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleSave = useCallback(() => {
|
const handleSave = useCallback(async () => {
|
||||||
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
||||||
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
||||||
|
// Sync to Rust backend (non-blocking — UI updates immediately)
|
||||||
|
try {
|
||||||
|
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('[HeartbeatConfig] Backend sync failed:', err);
|
||||||
|
}
|
||||||
setHasChanges(false);
|
setHasChanges(false);
|
||||||
}, [config, checkItems]);
|
}, [config, checkItems]);
|
||||||
|
|
||||||
|
|||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Heart,
|
Heart,
|
||||||
Key,
|
Key,
|
||||||
Database,
|
Database,
|
||||||
|
Activity,
|
||||||
Cloud,
|
Cloud,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
@@ -37,6 +38,7 @@ import { SecurityStatus } from '../SecurityStatus';
|
|||||||
import { SecurityLayersPanel } from '../SecurityLayersPanel';
|
import { SecurityLayersPanel } from '../SecurityLayersPanel';
|
||||||
import { TaskList } from '../TaskList';
|
import { TaskList } from '../TaskList';
|
||||||
import { HeartbeatConfig } from '../HeartbeatConfig';
|
import { HeartbeatConfig } from '../HeartbeatConfig';
|
||||||
|
import { HealthPanel } from '../HealthPanel';
|
||||||
import { SecureStorage } from './SecureStorage';
|
import { SecureStorage } from './SecureStorage';
|
||||||
import { VikingPanel } from '../VikingPanel';
|
import { VikingPanel } from '../VikingPanel';
|
||||||
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
import { SaaSSettings } from '../SaaS/SaaSSettings';
|
||||||
@@ -65,6 +67,7 @@ type SettingsPage =
|
|||||||
| 'audit'
|
| 'audit'
|
||||||
| 'tasks'
|
| 'tasks'
|
||||||
| 'heartbeat'
|
| 'heartbeat'
|
||||||
|
| 'health'
|
||||||
| 'feedback'
|
| 'feedback'
|
||||||
| 'about';
|
| 'about';
|
||||||
|
|
||||||
@@ -89,6 +92,7 @@ const menuItems: { id: SettingsPage; label: string; icon: React.ReactNode; group
|
|||||||
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" />, group: 'advanced' },
|
{ id: 'audit', label: '审计日志', icon: <ClipboardList className="w-4 h-4" />, group: 'advanced' },
|
||||||
{ id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' },
|
{ id: 'tasks', label: '定时任务', icon: <Clock className="w-4 h-4" />, group: 'advanced' },
|
||||||
{ id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' },
|
{ id: 'heartbeat', label: '心跳配置', icon: <Heart className="w-4 h-4" />, group: 'advanced' },
|
||||||
|
{ id: 'health', label: '系统健康', icon: <Activity className="w-4 h-4" />, group: 'advanced' },
|
||||||
// --- Footer ---
|
// --- Footer ---
|
||||||
{ id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> },
|
{ id: 'feedback', label: '提交反馈', icon: <HelpCircle className="w-4 h-4" /> },
|
||||||
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
|
{ id: 'about', label: '关于', icon: <Info className="w-4 h-4" /> },
|
||||||
@@ -175,6 +179,16 @@ export function SettingsLayout({ onBack }: SettingsLayoutProps) {
|
|||||||
</div>
|
</div>
|
||||||
</ErrorBoundary>
|
</ErrorBoundary>
|
||||||
);
|
);
|
||||||
|
case 'health': return (
|
||||||
|
<ErrorBoundary
|
||||||
|
fallback={<div className="p-6 text-center text-gray-500">系统健康面板加载失败</div>}
|
||||||
|
onError={(err, info) => console.error('[Settings] Health page error:', err, info.componentStack)}
|
||||||
|
>
|
||||||
|
<div className="max-w-3xl h-full">
|
||||||
|
<HealthPanel />
|
||||||
|
</div>
|
||||||
|
</ErrorBoundary>
|
||||||
|
);
|
||||||
case 'viking': return (
|
case 'viking': return (
|
||||||
<ErrorBoundary
|
<ErrorBoundary
|
||||||
fallback={<div className="p-6 text-center text-gray-500">语义记忆加载失败</div>}
|
fallback={<div className="p-6 text-center text-gray-500">语义记忆加载失败</div>}
|
||||||
|
|||||||
@@ -1,61 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - LocalStorage Compactor Fallback
|
|
||||||
*
|
|
||||||
* Provides rule-based compaction for browser/dev environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { CompactableMessage, CompactionResult, CompactionCheck, CompactionConfig } from '../intelligence-backend';
|
|
||||||
|
|
||||||
export const fallbackCompactor = {
|
|
||||||
async estimateTokens(text: string): Promise<number> {
|
|
||||||
// Simple heuristic: ~4 chars per token for English, ~1.5 for CJK
|
|
||||||
const cjkChars = (text.match(/[\u4e00-\u9fff\u3040-\u30ff]/g) ?? []).length;
|
|
||||||
const otherChars = text.length - cjkChars;
|
|
||||||
return Math.ceil(cjkChars * 1.5 + otherChars / 4);
|
|
||||||
},
|
|
||||||
|
|
||||||
async estimateMessagesTokens(messages: CompactableMessage[]): Promise<number> {
|
|
||||||
let total = 0;
|
|
||||||
for (const m of messages) {
|
|
||||||
total += await fallbackCompactor.estimateTokens(m.content);
|
|
||||||
}
|
|
||||||
return total;
|
|
||||||
},
|
|
||||||
|
|
||||||
async checkThreshold(
|
|
||||||
messages: CompactableMessage[],
|
|
||||||
config?: CompactionConfig
|
|
||||||
): Promise<CompactionCheck> {
|
|
||||||
const threshold = config?.soft_threshold_tokens ?? 15000;
|
|
||||||
const currentTokens = await fallbackCompactor.estimateMessagesTokens(messages);
|
|
||||||
|
|
||||||
return {
|
|
||||||
should_compact: currentTokens >= threshold,
|
|
||||||
current_tokens: currentTokens,
|
|
||||||
threshold,
|
|
||||||
urgency: currentTokens >= (config?.hard_threshold_tokens ?? 20000) ? 'hard' :
|
|
||||||
currentTokens >= threshold ? 'soft' : 'none',
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async compact(
|
|
||||||
messages: CompactableMessage[],
|
|
||||||
_agentId: string,
|
|
||||||
_conversationId?: string,
|
|
||||||
config?: CompactionConfig
|
|
||||||
): Promise<CompactionResult> {
|
|
||||||
// Simple rule-based compaction: keep last N messages
|
|
||||||
const keepRecent = config?.keep_recent_messages ?? 10;
|
|
||||||
const retained = messages.slice(-keepRecent);
|
|
||||||
|
|
||||||
return {
|
|
||||||
compacted_messages: retained,
|
|
||||||
summary: `[Compacted ${messages.length - retained.length} earlier messages]`,
|
|
||||||
original_count: messages.length,
|
|
||||||
retained_count: retained.length,
|
|
||||||
flushed_memories: 0,
|
|
||||||
tokens_before_compaction: await fallbackCompactor.estimateMessagesTokens(messages),
|
|
||||||
tokens_after_compaction: await fallbackCompactor.estimateMessagesTokens(retained),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,54 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - LocalStorage Heartbeat Fallback
|
|
||||||
*
|
|
||||||
* Provides no-op heartbeat for browser/dev environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type { HeartbeatConfig, HeartbeatResult } from '../intelligence-backend';
|
|
||||||
|
|
||||||
export const fallbackHeartbeat = {
|
|
||||||
_configs: new Map<string, HeartbeatConfig>(),
|
|
||||||
|
|
||||||
async init(agentId: string, config?: HeartbeatConfig): Promise<void> {
|
|
||||||
if (config) {
|
|
||||||
fallbackHeartbeat._configs.set(agentId, config);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async start(_agentId: string): Promise<void> {
|
|
||||||
// No-op for fallback (no background tasks in browser)
|
|
||||||
},
|
|
||||||
|
|
||||||
async stop(_agentId: string): Promise<void> {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
|
|
||||||
async tick(_agentId: string): Promise<HeartbeatResult> {
|
|
||||||
return {
|
|
||||||
status: 'ok',
|
|
||||||
alerts: [],
|
|
||||||
checked_items: 0,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async getConfig(agentId: string): Promise<HeartbeatConfig> {
|
|
||||||
return fallbackHeartbeat._configs.get(agentId) ?? {
|
|
||||||
enabled: false,
|
|
||||||
interval_minutes: 30,
|
|
||||||
quiet_hours_start: null,
|
|
||||||
quiet_hours_end: null,
|
|
||||||
notify_channel: 'ui',
|
|
||||||
proactivity_level: 'standard',
|
|
||||||
max_alerts_per_tick: 5,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateConfig(agentId: string, config: HeartbeatConfig): Promise<void> {
|
|
||||||
fallbackHeartbeat._configs.set(agentId, config);
|
|
||||||
},
|
|
||||||
|
|
||||||
async getHistory(_agentId: string, _limit?: number): Promise<HeartbeatResult[]> {
|
|
||||||
return [];
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,239 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - LocalStorage Identity Fallback
|
|
||||||
*
|
|
||||||
* Provides localStorage-based identity management for browser/dev environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
|
|
||||||
import type { IdentityFiles, IdentityChangeProposal, IdentitySnapshot } from '../intelligence-backend';
|
|
||||||
|
|
||||||
const logger = createLogger('intelligence-client');
|
|
||||||
|
|
||||||
const IDENTITY_STORAGE_KEY = 'zclaw-fallback-identities';
|
|
||||||
const PROPOSALS_STORAGE_KEY = 'zclaw-fallback-proposals';
|
|
||||||
const SNAPSHOTS_STORAGE_KEY = 'zclaw-fallback-snapshots';
|
|
||||||
|
|
||||||
function loadIdentitiesFromStorage(): Map<string, IdentityFiles> {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(IDENTITY_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
const parsed = JSON.parse(stored) as Record<string, IdentityFiles>;
|
|
||||||
return new Map(Object.entries(parsed));
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to load identities from localStorage', { error: e });
|
|
||||||
}
|
|
||||||
return new Map();
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveIdentitiesToStorage(identities: Map<string, IdentityFiles>): void {
|
|
||||||
try {
|
|
||||||
const obj = Object.fromEntries(identities);
|
|
||||||
localStorage.setItem(IDENTITY_STORAGE_KEY, JSON.stringify(obj));
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to save identities to localStorage', { error: e });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadProposalsFromStorage(): IdentityChangeProposal[] {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(PROPOSALS_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
return JSON.parse(stored) as IdentityChangeProposal[];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to load proposals from localStorage', { error: e });
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveProposalsToStorage(proposals: IdentityChangeProposal[]): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(PROPOSALS_STORAGE_KEY, JSON.stringify(proposals));
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to save proposals to localStorage', { error: e });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
function loadSnapshotsFromStorage(): IdentitySnapshot[] {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(SNAPSHOTS_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
return JSON.parse(stored) as IdentitySnapshot[];
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to load snapshots from localStorage', { error: e });
|
|
||||||
}
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveSnapshotsToStorage(snapshots: IdentitySnapshot[]): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(SNAPSHOTS_STORAGE_KEY, JSON.stringify(snapshots));
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to save snapshots to localStorage', { error: e });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
// Module-level state initialized from localStorage
|
|
||||||
const fallbackIdentities = loadIdentitiesFromStorage();
|
|
||||||
const fallbackProposals = loadProposalsFromStorage();
|
|
||||||
let fallbackSnapshots = loadSnapshotsFromStorage();
|
|
||||||
|
|
||||||
export const fallbackIdentity = {
|
|
||||||
async get(agentId: string): Promise<IdentityFiles> {
|
|
||||||
if (!fallbackIdentities.has(agentId)) {
|
|
||||||
const defaults: IdentityFiles = {
|
|
||||||
soul: '# Agent Soul\n\nA helpful AI assistant.',
|
|
||||||
instructions: '# Instructions\n\nBe helpful and concise.',
|
|
||||||
user_profile: '# User Profile\n\nNo profile yet.',
|
|
||||||
};
|
|
||||||
fallbackIdentities.set(agentId, defaults);
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
}
|
|
||||||
return fallbackIdentities.get(agentId)!;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getFile(agentId: string, file: string): Promise<string> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
return files[file as keyof IdentityFiles] ?? '';
|
|
||||||
},
|
|
||||||
|
|
||||||
async buildPrompt(agentId: string, memoryContext?: string): Promise<string> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
let prompt = `${files.soul}\n\n## Instructions\n${files.instructions}\n\n## User Profile\n${files.user_profile}`;
|
|
||||||
if (memoryContext) {
|
|
||||||
prompt += `\n\n## Memory Context\n${memoryContext}`;
|
|
||||||
}
|
|
||||||
return prompt;
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateUserProfile(agentId: string, content: string): Promise<void> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
files.user_profile = content;
|
|
||||||
fallbackIdentities.set(agentId, files);
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
},
|
|
||||||
|
|
||||||
async appendUserProfile(agentId: string, addition: string): Promise<void> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
files.user_profile += `\n\n${addition}`;
|
|
||||||
fallbackIdentities.set(agentId, files);
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
},
|
|
||||||
|
|
||||||
async proposeChange(
|
|
||||||
agentId: string,
|
|
||||||
file: 'soul' | 'instructions',
|
|
||||||
suggestedContent: string,
|
|
||||||
reason: string
|
|
||||||
): Promise<IdentityChangeProposal> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
const proposal: IdentityChangeProposal = {
|
|
||||||
id: `prop_${Date.now()}`,
|
|
||||||
agent_id: agentId,
|
|
||||||
file,
|
|
||||||
reason,
|
|
||||||
current_content: files[file] ?? '',
|
|
||||||
suggested_content: suggestedContent,
|
|
||||||
status: 'pending',
|
|
||||||
created_at: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
fallbackProposals.push(proposal);
|
|
||||||
saveProposalsToStorage(fallbackProposals);
|
|
||||||
return proposal;
|
|
||||||
},
|
|
||||||
|
|
||||||
async approveProposal(proposalId: string): Promise<IdentityFiles> {
|
|
||||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
|
||||||
if (!proposal) throw new Error('Proposal not found');
|
|
||||||
|
|
||||||
const files = await fallbackIdentity.get(proposal.agent_id);
|
|
||||||
|
|
||||||
// Create snapshot before applying change
|
|
||||||
const snapshot: IdentitySnapshot = {
|
|
||||||
id: `snap_${Date.now()}`,
|
|
||||||
agent_id: proposal.agent_id,
|
|
||||||
files: { ...files },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
reason: `Before applying: ${proposal.reason}`,
|
|
||||||
};
|
|
||||||
fallbackSnapshots.unshift(snapshot);
|
|
||||||
// Keep only last 20 snapshots per agent
|
|
||||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === proposal.agent_id);
|
|
||||||
if (agentSnapshots.length > 20) {
|
|
||||||
const toRemove = agentSnapshots.slice(20);
|
|
||||||
fallbackSnapshots = fallbackSnapshots.filter(s => !toRemove.includes(s));
|
|
||||||
}
|
|
||||||
saveSnapshotsToStorage(fallbackSnapshots);
|
|
||||||
|
|
||||||
proposal.status = 'approved';
|
|
||||||
files[proposal.file] = proposal.suggested_content;
|
|
||||||
fallbackIdentities.set(proposal.agent_id, files);
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
saveProposalsToStorage(fallbackProposals);
|
|
||||||
return files;
|
|
||||||
},
|
|
||||||
|
|
||||||
async rejectProposal(proposalId: string): Promise<void> {
|
|
||||||
const proposal = fallbackProposals.find(p => p.id === proposalId);
|
|
||||||
if (proposal) {
|
|
||||||
proposal.status = 'rejected';
|
|
||||||
saveProposalsToStorage(fallbackProposals);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getPendingProposals(agentId?: string): Promise<IdentityChangeProposal[]> {
|
|
||||||
return fallbackProposals.filter(p =>
|
|
||||||
p.status === 'pending' && (!agentId || p.agent_id === agentId)
|
|
||||||
);
|
|
||||||
},
|
|
||||||
|
|
||||||
async updateFile(agentId: string, file: string, content: string): Promise<void> {
|
|
||||||
const files = await fallbackIdentity.get(agentId);
|
|
||||||
if (file in files) {
|
|
||||||
// IdentityFiles has known properties, update safely
|
|
||||||
const key = file as keyof IdentityFiles;
|
|
||||||
if (key in files) {
|
|
||||||
files[key] = content;
|
|
||||||
fallbackIdentities.set(agentId, files);
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
async getSnapshots(agentId: string, limit?: number): Promise<IdentitySnapshot[]> {
|
|
||||||
const agentSnapshots = fallbackSnapshots.filter(s => s.agent_id === agentId);
|
|
||||||
return agentSnapshots.slice(0, limit ?? 10);
|
|
||||||
},
|
|
||||||
|
|
||||||
async restoreSnapshot(agentId: string, snapshotId: string): Promise<void> {
|
|
||||||
const snapshot = fallbackSnapshots.find(s => s.id === snapshotId && s.agent_id === agentId);
|
|
||||||
if (!snapshot) throw new Error('Snapshot not found');
|
|
||||||
|
|
||||||
// Create a snapshot of current state before restore
|
|
||||||
const currentFiles = await fallbackIdentity.get(agentId);
|
|
||||||
const beforeRestoreSnapshot: IdentitySnapshot = {
|
|
||||||
id: `snap_${Date.now()}`,
|
|
||||||
agent_id: agentId,
|
|
||||||
files: { ...currentFiles },
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
reason: 'Auto-backup before restore',
|
|
||||||
};
|
|
||||||
fallbackSnapshots.unshift(beforeRestoreSnapshot);
|
|
||||||
saveSnapshotsToStorage(fallbackSnapshots);
|
|
||||||
|
|
||||||
// Restore the snapshot
|
|
||||||
fallbackIdentities.set(agentId, { ...snapshot.files });
|
|
||||||
saveIdentitiesToStorage(fallbackIdentities);
|
|
||||||
},
|
|
||||||
|
|
||||||
async listAgents(): Promise<string[]> {
|
|
||||||
return Array.from(fallbackIdentities.keys());
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteAgent(agentId: string): Promise<void> {
|
|
||||||
fallbackIdentities.delete(agentId);
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,186 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - LocalStorage Memory Fallback
|
|
||||||
*
|
|
||||||
* Provides localStorage-based memory operations for browser/dev environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
import { generateRandomString } from '../crypto-utils';
|
|
||||||
|
|
||||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats, MemoryType, MemorySource } from './types';
|
|
||||||
|
|
||||||
const logger = createLogger('intelligence-client');
|
|
||||||
|
|
||||||
import type { MemoryEntryInput } from '../intelligence-backend';
|
|
||||||
|
|
||||||
const FALLBACK_STORAGE_KEY = 'zclaw-intelligence-fallback';
|
|
||||||
|
|
||||||
interface FallbackMemoryStore {
|
|
||||||
memories: MemoryEntry[];
|
|
||||||
}
|
|
||||||
|
|
||||||
function getFallbackStore(): FallbackMemoryStore {
|
|
||||||
try {
|
|
||||||
const stored = localStorage.getItem(FALLBACK_STORAGE_KEY);
|
|
||||||
if (stored) {
|
|
||||||
return JSON.parse(stored);
|
|
||||||
}
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('Failed to read fallback store from localStorage', { error: e });
|
|
||||||
}
|
|
||||||
return { memories: [] };
|
|
||||||
}
|
|
||||||
|
|
||||||
function saveFallbackStore(store: FallbackMemoryStore): void {
|
|
||||||
try {
|
|
||||||
localStorage.setItem(FALLBACK_STORAGE_KEY, JSON.stringify(store));
|
|
||||||
} catch (e) {
|
|
||||||
logger.warn('Failed to save fallback store to localStorage', { error: e });
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
export const fallbackMemory = {
|
|
||||||
async init(): Promise<void> {
|
|
||||||
// No-op for localStorage
|
|
||||||
},
|
|
||||||
|
|
||||||
async store(entry: MemoryEntryInput): Promise<string> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
|
|
||||||
// Content-based deduplication: update existing entry with same agentId + content
|
|
||||||
const normalizedContent = entry.content.trim().toLowerCase();
|
|
||||||
const existingIdx = store.memories.findIndex(
|
|
||||||
m => m.agentId === entry.agent_id && m.content.trim().toLowerCase() === normalizedContent
|
|
||||||
);
|
|
||||||
|
|
||||||
if (existingIdx >= 0) {
|
|
||||||
// Update existing entry instead of creating duplicate
|
|
||||||
const existing = store.memories[existingIdx];
|
|
||||||
store.memories[existingIdx] = {
|
|
||||||
...existing,
|
|
||||||
importance: Math.max(existing.importance, entry.importance ?? 5),
|
|
||||||
lastAccessedAt: new Date().toISOString(),
|
|
||||||
accessCount: existing.accessCount + 1,
|
|
||||||
tags: [...new Set([...existing.tags, ...(entry.tags ?? [])])],
|
|
||||||
};
|
|
||||||
saveFallbackStore(store);
|
|
||||||
return existing.id;
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = `mem_${Date.now()}_${generateRandomString(6)}`;
|
|
||||||
const now = new Date().toISOString();
|
|
||||||
|
|
||||||
const memory: MemoryEntry = {
|
|
||||||
id,
|
|
||||||
agentId: entry.agent_id,
|
|
||||||
content: entry.content,
|
|
||||||
type: entry.memory_type as MemoryType,
|
|
||||||
importance: entry.importance ?? 5,
|
|
||||||
source: (entry.source as MemorySource) ?? 'auto',
|
|
||||||
tags: entry.tags ?? [],
|
|
||||||
createdAt: now,
|
|
||||||
lastAccessedAt: now,
|
|
||||||
accessCount: 0,
|
|
||||||
conversationId: entry.conversation_id,
|
|
||||||
};
|
|
||||||
|
|
||||||
store.memories.push(memory);
|
|
||||||
saveFallbackStore(store);
|
|
||||||
return id;
|
|
||||||
},
|
|
||||||
|
|
||||||
async get(id: string): Promise<MemoryEntry | null> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
return store.memories.find(m => m.id === id) ?? null;
|
|
||||||
},
|
|
||||||
|
|
||||||
async search(options: MemorySearchOptions): Promise<MemoryEntry[]> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
let results = store.memories;
|
|
||||||
|
|
||||||
if (options.agentId) {
|
|
||||||
results = results.filter(m => m.agentId === options.agentId);
|
|
||||||
}
|
|
||||||
if (options.type) {
|
|
||||||
results = results.filter(m => m.type === options.type);
|
|
||||||
}
|
|
||||||
if (options.minImportance !== undefined) {
|
|
||||||
results = results.filter(m => m.importance >= options.minImportance!);
|
|
||||||
}
|
|
||||||
if (options.query) {
|
|
||||||
const queryLower = options.query.toLowerCase();
|
|
||||||
results = results.filter(m =>
|
|
||||||
m.content.toLowerCase().includes(queryLower) ||
|
|
||||||
m.tags.some(t => t.toLowerCase().includes(queryLower))
|
|
||||||
);
|
|
||||||
}
|
|
||||||
if (options.limit) {
|
|
||||||
results = results.slice(0, options.limit);
|
|
||||||
}
|
|
||||||
|
|
||||||
return results;
|
|
||||||
},
|
|
||||||
|
|
||||||
async delete(id: string): Promise<void> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
store.memories = store.memories.filter(m => m.id !== id);
|
|
||||||
saveFallbackStore(store);
|
|
||||||
},
|
|
||||||
|
|
||||||
async deleteAll(agentId: string): Promise<number> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
const before = store.memories.length;
|
|
||||||
store.memories = store.memories.filter(m => m.agentId !== agentId);
|
|
||||||
saveFallbackStore(store);
|
|
||||||
return before - store.memories.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
async stats(): Promise<MemoryStats> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
const byType: Record<string, number> = {};
|
|
||||||
const byAgent: Record<string, number> = {};
|
|
||||||
|
|
||||||
for (const m of store.memories) {
|
|
||||||
byType[m.type] = (byType[m.type] ?? 0) + 1;
|
|
||||||
byAgent[m.agentId] = (byAgent[m.agentId] ?? 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
const sorted = [...store.memories].sort((a, b) =>
|
|
||||||
new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime()
|
|
||||||
);
|
|
||||||
|
|
||||||
// Estimate storage size from serialized data
|
|
||||||
let storageSizeBytes = 0;
|
|
||||||
try {
|
|
||||||
const serialized = JSON.stringify(store.memories);
|
|
||||||
storageSizeBytes = new Blob([serialized]).size;
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('Failed to estimate storage size', { error: e });
|
|
||||||
}
|
|
||||||
|
|
||||||
return {
|
|
||||||
totalEntries: store.memories.length,
|
|
||||||
byType,
|
|
||||||
byAgent,
|
|
||||||
oldestEntry: sorted[0]?.createdAt ?? null,
|
|
||||||
newestEntry: sorted[sorted.length - 1]?.createdAt ?? null,
|
|
||||||
storageSizeBytes,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
|
|
||||||
async export(): Promise<MemoryEntry[]> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
return store.memories;
|
|
||||||
},
|
|
||||||
|
|
||||||
async import(memories: MemoryEntry[]): Promise<number> {
|
|
||||||
const store = getFallbackStore();
|
|
||||||
store.memories.push(...memories);
|
|
||||||
saveFallbackStore(store);
|
|
||||||
return memories.length;
|
|
||||||
},
|
|
||||||
|
|
||||||
async dbPath(): Promise<string> {
|
|
||||||
return 'localStorage://zclaw-intelligence-fallback';
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,167 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - LocalStorage Reflection Fallback
|
|
||||||
*
|
|
||||||
* Provides rule-based reflection for browser/dev environment.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import type {
|
|
||||||
ReflectionResult,
|
|
||||||
ReflectionState,
|
|
||||||
ReflectionConfig,
|
|
||||||
PatternObservation,
|
|
||||||
ImprovementSuggestion,
|
|
||||||
ReflectionIdentityProposal,
|
|
||||||
MemoryEntryForAnalysis,
|
|
||||||
} from '../intelligence-backend';
|
|
||||||
|
|
||||||
export const fallbackReflection = {
|
|
||||||
_conversationCount: 0,
|
|
||||||
_lastReflection: null as string | null,
|
|
||||||
_history: [] as ReflectionResult[],
|
|
||||||
|
|
||||||
async init(_config?: ReflectionConfig): Promise<void> {
|
|
||||||
// No-op
|
|
||||||
},
|
|
||||||
|
|
||||||
async recordConversation(): Promise<void> {
|
|
||||||
fallbackReflection._conversationCount++;
|
|
||||||
},
|
|
||||||
|
|
||||||
async shouldReflect(): Promise<boolean> {
|
|
||||||
return fallbackReflection._conversationCount >= 5;
|
|
||||||
},
|
|
||||||
|
|
||||||
async reflect(agentId: string, memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> {
|
|
||||||
fallbackReflection._conversationCount = 0;
|
|
||||||
fallbackReflection._lastReflection = new Date().toISOString();
|
|
||||||
|
|
||||||
// Analyze patterns (simple rule-based implementation)
|
|
||||||
const patterns: PatternObservation[] = [];
|
|
||||||
const improvements: ImprovementSuggestion[] = [];
|
|
||||||
const identityProposals: ReflectionIdentityProposal[] = [];
|
|
||||||
|
|
||||||
// Count memory types
|
|
||||||
const typeCounts: Record<string, number> = {};
|
|
||||||
for (const m of memories) {
|
|
||||||
typeCounts[m.memory_type] = (typeCounts[m.memory_type] || 0) + 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern: Too many tasks
|
|
||||||
const taskCount = typeCounts['task'] || 0;
|
|
||||||
if (taskCount >= 5) {
|
|
||||||
const taskMemories = memories.filter(m => m.memory_type === 'task').slice(0, 3);
|
|
||||||
patterns.push({
|
|
||||||
observation: `积累了 ${taskCount} 个待办任务,可能存在任务管理不善`,
|
|
||||||
frequency: taskCount,
|
|
||||||
sentiment: 'negative',
|
|
||||||
evidence: taskMemories.map(m => m.content),
|
|
||||||
});
|
|
||||||
improvements.push({
|
|
||||||
area: '任务管理',
|
|
||||||
suggestion: '清理已完成的任务记忆,对长期未处理的任务降低重要性',
|
|
||||||
priority: 'high',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern: Strong preference accumulation
|
|
||||||
const prefCount = typeCounts['preference'] || 0;
|
|
||||||
if (prefCount >= 5) {
|
|
||||||
const prefMemories = memories.filter(m => m.memory_type === 'preference').slice(0, 3);
|
|
||||||
patterns.push({
|
|
||||||
observation: `已记录 ${prefCount} 个用户偏好,对用户习惯有较好理解`,
|
|
||||||
frequency: prefCount,
|
|
||||||
sentiment: 'positive',
|
|
||||||
evidence: prefMemories.map(m => m.content),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern: Lessons learned
|
|
||||||
const lessonCount = typeCounts['lesson'] || 0;
|
|
||||||
if (lessonCount >= 5) {
|
|
||||||
patterns.push({
|
|
||||||
observation: `积累了 ${lessonCount} 条经验教训,知识库在成长`,
|
|
||||||
frequency: lessonCount,
|
|
||||||
sentiment: 'positive',
|
|
||||||
evidence: memories.filter(m => m.memory_type === 'lesson').slice(0, 3).map(m => m.content),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern: High-access important memories
|
|
||||||
const highAccessMemories = memories.filter(m => m.access_count >= 5 && m.importance >= 7);
|
|
||||||
if (highAccessMemories.length >= 3) {
|
|
||||||
patterns.push({
|
|
||||||
observation: `有 ${highAccessMemories.length} 条高频访问的重要记忆,核心知识正在形成`,
|
|
||||||
frequency: highAccessMemories.length,
|
|
||||||
sentiment: 'positive',
|
|
||||||
evidence: highAccessMemories.slice(0, 3).map(m => m.content),
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Pattern: Low importance memories accumulating
|
|
||||||
const lowImportanceCount = memories.filter(m => m.importance <= 3).length;
|
|
||||||
if (lowImportanceCount > 20) {
|
|
||||||
patterns.push({
|
|
||||||
observation: `有 ${lowImportanceCount} 条低重要性记忆,建议清理`,
|
|
||||||
frequency: lowImportanceCount,
|
|
||||||
sentiment: 'neutral',
|
|
||||||
evidence: [],
|
|
||||||
});
|
|
||||||
improvements.push({
|
|
||||||
area: '记忆管理',
|
|
||||||
suggestion: '执行记忆清理,移除30天以上未访问且重要性低于3的记忆',
|
|
||||||
priority: 'medium',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Generate identity proposal if negative patterns exist
|
|
||||||
const negativePatterns = patterns.filter(p => p.sentiment === 'negative');
|
|
||||||
if (negativePatterns.length >= 2) {
|
|
||||||
const additions = negativePatterns.map(p => `- 注意: ${p.observation}`).join('\n');
|
|
||||||
identityProposals.push({
|
|
||||||
agent_id: agentId,
|
|
||||||
field: 'instructions',
|
|
||||||
current_value: '...',
|
|
||||||
proposed_value: `\n\n## 自我反思改进\n${additions}`,
|
|
||||||
reason: `基于 ${negativePatterns.length} 个负面模式观察,建议在指令中增加自我改进提醒`,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
// Suggestion: User profile enrichment
|
|
||||||
if (prefCount < 3) {
|
|
||||||
improvements.push({
|
|
||||||
area: '用户理解',
|
|
||||||
suggestion: '主动在对话中了解用户偏好(沟通风格、技术栈、工作习惯),丰富用户画像',
|
|
||||||
priority: 'medium',
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
const result: ReflectionResult = {
|
|
||||||
patterns,
|
|
||||||
improvements,
|
|
||||||
identity_proposals: identityProposals,
|
|
||||||
new_memories: patterns.filter(p => p.frequency >= 3).length + improvements.filter(i => i.priority === 'high').length,
|
|
||||||
timestamp: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
|
|
||||||
// Store in history
|
|
||||||
fallbackReflection._history.push(result);
|
|
||||||
if (fallbackReflection._history.length > 20) {
|
|
||||||
fallbackReflection._history = fallbackReflection._history.slice(-10);
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
},
|
|
||||||
|
|
||||||
async getHistory(limit?: number, _agentId?: string): Promise<ReflectionResult[]> {
|
|
||||||
const l = limit ?? 10;
|
|
||||||
return fallbackReflection._history.slice(-l).reverse();
|
|
||||||
},
|
|
||||||
|
|
||||||
async getState(): Promise<ReflectionState> {
|
|
||||||
return {
|
|
||||||
conversations_since_reflection: fallbackReflection._conversationCount,
|
|
||||||
last_reflection_time: fallbackReflection._lastReflection,
|
|
||||||
last_reflection_agent_id: null,
|
|
||||||
};
|
|
||||||
},
|
|
||||||
};
|
|
||||||
@@ -1,72 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - Barrel Re-export
|
|
||||||
*
|
|
||||||
* Re-exports everything from sub-modules to maintain backward compatibility.
|
|
||||||
* Existing imports like `import { intelligenceClient } from './intelligence-client'`
|
|
||||||
* continue to work unchanged because TypeScript resolves directory imports
|
|
||||||
* through this index.ts file.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// Types
|
|
||||||
export type {
|
|
||||||
MemoryType,
|
|
||||||
MemorySource,
|
|
||||||
MemoryEntry,
|
|
||||||
MemorySearchOptions,
|
|
||||||
MemoryStats,
|
|
||||||
BehaviorPattern,
|
|
||||||
PatternTypeVariant,
|
|
||||||
PatternContext,
|
|
||||||
WorkflowRecommendation,
|
|
||||||
MeshConfig,
|
|
||||||
MeshAnalysisResult,
|
|
||||||
ActivityType,
|
|
||||||
EvolutionChangeType,
|
|
||||||
InsightCategory,
|
|
||||||
IdentityFileType,
|
|
||||||
ProposalStatus,
|
|
||||||
EvolutionProposal,
|
|
||||||
ProfileUpdate,
|
|
||||||
EvolutionInsight,
|
|
||||||
EvolutionResult,
|
|
||||||
PersonaEvolverConfig,
|
|
||||||
PersonaEvolverState,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
export {
|
|
||||||
getPatternTypeString,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// Re-exported types from intelligence-backend
|
|
||||||
export type {
|
|
||||||
HeartbeatConfig,
|
|
||||||
HeartbeatResult,
|
|
||||||
HeartbeatAlert,
|
|
||||||
CompactableMessage,
|
|
||||||
CompactionResult,
|
|
||||||
CompactionCheck,
|
|
||||||
CompactionConfig,
|
|
||||||
PatternObservation,
|
|
||||||
ImprovementSuggestion,
|
|
||||||
ReflectionResult,
|
|
||||||
ReflectionState,
|
|
||||||
ReflectionConfig,
|
|
||||||
ReflectionIdentityProposal,
|
|
||||||
IdentityFiles,
|
|
||||||
IdentityChangeProposal,
|
|
||||||
IdentitySnapshot,
|
|
||||||
MemoryEntryForAnalysis,
|
|
||||||
} from './types';
|
|
||||||
|
|
||||||
// Type conversion utilities
|
|
||||||
export {
|
|
||||||
toFrontendMemory,
|
|
||||||
toBackendMemoryInput,
|
|
||||||
toBackendSearchOptions,
|
|
||||||
toFrontendStats,
|
|
||||||
parseTags,
|
|
||||||
} from './type-conversions';
|
|
||||||
|
|
||||||
// Unified client
|
|
||||||
export { intelligenceClient } from './unified-client';
|
|
||||||
export { intelligenceClient as default } from './unified-client';
|
|
||||||
@@ -1,101 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - Type Conversion Utilities
|
|
||||||
*
|
|
||||||
* Functions for converting between frontend and backend data formats.
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { intelligence } from '../intelligence-backend';
|
|
||||||
import type {
|
|
||||||
MemoryEntryInput,
|
|
||||||
PersistentMemory,
|
|
||||||
MemorySearchOptions as BackendSearchOptions,
|
|
||||||
MemoryStats as BackendMemoryStats,
|
|
||||||
} from '../intelligence-backend';
|
|
||||||
|
|
||||||
import { createLogger } from '../logger';
|
|
||||||
|
|
||||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats, MemoryType, MemorySource } from './types';
|
|
||||||
|
|
||||||
const logger = createLogger('intelligence-client');
|
|
||||||
|
|
||||||
// Re-import intelligence for use in conversions (already imported above but
|
|
||||||
// the `intelligence` binding is needed by unified-client.ts indirectly).
|
|
||||||
|
|
||||||
export { intelligence };
|
|
||||||
export type { MemoryEntryInput, PersistentMemory, BackendSearchOptions, BackendMemoryStats };
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert backend PersistentMemory to frontend MemoryEntry format
|
|
||||||
*/
|
|
||||||
export function toFrontendMemory(backend: PersistentMemory): MemoryEntry {
|
|
||||||
return {
|
|
||||||
id: backend.id,
|
|
||||||
agentId: backend.agent_id,
|
|
||||||
content: backend.content,
|
|
||||||
type: backend.memory_type as MemoryType,
|
|
||||||
importance: backend.importance,
|
|
||||||
source: backend.source as MemorySource,
|
|
||||||
tags: parseTags(backend.tags),
|
|
||||||
createdAt: backend.created_at,
|
|
||||||
lastAccessedAt: backend.last_accessed_at,
|
|
||||||
accessCount: backend.access_count,
|
|
||||||
conversationId: backend.conversation_id ?? undefined,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert frontend MemoryEntry to backend MemoryEntryInput format
|
|
||||||
*/
|
|
||||||
export function toBackendMemoryInput(entry: Omit<MemoryEntry, 'id' | 'createdAt' | 'lastAccessedAt' | 'accessCount'>): MemoryEntryInput {
|
|
||||||
return {
|
|
||||||
agent_id: entry.agentId,
|
|
||||||
memory_type: entry.type,
|
|
||||||
content: entry.content,
|
|
||||||
importance: entry.importance,
|
|
||||||
source: entry.source,
|
|
||||||
tags: entry.tags,
|
|
||||||
conversation_id: entry.conversationId,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert frontend search options to backend format
|
|
||||||
*/
|
|
||||||
export function toBackendSearchOptions(options: MemorySearchOptions): BackendSearchOptions {
|
|
||||||
return {
|
|
||||||
agent_id: options.agentId,
|
|
||||||
memory_type: options.type,
|
|
||||||
tags: options.tags,
|
|
||||||
query: options.query,
|
|
||||||
limit: options.limit,
|
|
||||||
min_importance: options.minImportance,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Convert backend stats to frontend format
|
|
||||||
*/
|
|
||||||
export function toFrontendStats(backend: BackendMemoryStats): MemoryStats {
|
|
||||||
return {
|
|
||||||
totalEntries: backend.total_entries,
|
|
||||||
byType: backend.by_type,
|
|
||||||
byAgent: backend.by_agent,
|
|
||||||
oldestEntry: backend.oldest_entry,
|
|
||||||
newestEntry: backend.newest_entry,
|
|
||||||
storageSizeBytes: backend.storage_size_bytes ?? 0,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Parse tags from backend (JSON string or array)
|
|
||||||
*/
|
|
||||||
export function parseTags(tags: string | string[]): string[] {
|
|
||||||
if (Array.isArray(tags)) return tags;
|
|
||||||
if (!tags) return [];
|
|
||||||
try {
|
|
||||||
return JSON.parse(tags);
|
|
||||||
} catch (e) {
|
|
||||||
logger.debug('JSON parse failed for tags, using fallback', { error: e });
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,199 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer - Type Definitions
|
|
||||||
*
|
|
||||||
* All frontend types, mesh types, persona evolver types,
|
|
||||||
* and re-exports from intelligence-backend.
|
|
||||||
*/
|
|
||||||
|
|
||||||
// === Re-export types from intelligence-backend ===
|
|
||||||
|
|
||||||
export type {
|
|
||||||
HeartbeatConfig,
|
|
||||||
HeartbeatResult,
|
|
||||||
HeartbeatAlert,
|
|
||||||
CompactableMessage,
|
|
||||||
CompactionResult,
|
|
||||||
CompactionCheck,
|
|
||||||
CompactionConfig,
|
|
||||||
PatternObservation,
|
|
||||||
ImprovementSuggestion,
|
|
||||||
ReflectionResult,
|
|
||||||
ReflectionState,
|
|
||||||
ReflectionConfig,
|
|
||||||
ReflectionIdentityProposal,
|
|
||||||
IdentityFiles,
|
|
||||||
IdentityChangeProposal,
|
|
||||||
IdentitySnapshot,
|
|
||||||
MemoryEntryForAnalysis,
|
|
||||||
} from '../intelligence-backend';
|
|
||||||
|
|
||||||
// === Frontend Types (for backward compatibility) ===
|
|
||||||
|
|
||||||
export type MemoryType = 'fact' | 'preference' | 'lesson' | 'context' | 'task';
|
|
||||||
export type MemorySource = 'auto' | 'user' | 'reflection' | 'llm-reflection';
|
|
||||||
|
|
||||||
export interface MemoryEntry {
|
|
||||||
id: string;
|
|
||||||
agentId: string;
|
|
||||||
content: string;
|
|
||||||
type: MemoryType;
|
|
||||||
importance: number;
|
|
||||||
source: MemorySource;
|
|
||||||
tags: string[];
|
|
||||||
createdAt: string;
|
|
||||||
lastAccessedAt: string;
|
|
||||||
accessCount: number;
|
|
||||||
conversationId?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemorySearchOptions {
|
|
||||||
agentId?: string;
|
|
||||||
type?: MemoryType;
|
|
||||||
types?: MemoryType[];
|
|
||||||
tags?: string[];
|
|
||||||
query?: string;
|
|
||||||
limit?: number;
|
|
||||||
minImportance?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MemoryStats {
|
|
||||||
totalEntries: number;
|
|
||||||
byType: Record<string, number>;
|
|
||||||
byAgent: Record<string, number>;
|
|
||||||
oldestEntry: string | null;
|
|
||||||
newestEntry: string | null;
|
|
||||||
storageSizeBytes: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
// === Mesh Types ===
|
|
||||||
|
|
||||||
export type PatternTypeVariant =
|
|
||||||
| { type: 'SkillCombination'; skill_ids: string[] }
|
|
||||||
| { type: 'TemporalTrigger'; hand_id: string; time_pattern: string }
|
|
||||||
| { type: 'TaskPipelineMapping'; task_type: string; pipeline_id: string }
|
|
||||||
| { type: 'InputPattern'; keywords: string[]; intent: string };
|
|
||||||
|
|
||||||
export interface BehaviorPattern {
|
|
||||||
id: string;
|
|
||||||
pattern_type: PatternTypeVariant;
|
|
||||||
frequency: number;
|
|
||||||
last_occurrence: string;
|
|
||||||
first_occurrence: string;
|
|
||||||
confidence: number;
|
|
||||||
context: PatternContext;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function getPatternTypeString(patternType: PatternTypeVariant): string {
|
|
||||||
if (typeof patternType === 'string') {
|
|
||||||
return patternType;
|
|
||||||
}
|
|
||||||
return patternType.type;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PatternContext {
|
|
||||||
skill_ids?: string[];
|
|
||||||
recent_topics?: string[];
|
|
||||||
intent?: string;
|
|
||||||
time_of_day?: number;
|
|
||||||
day_of_week?: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface WorkflowRecommendation {
|
|
||||||
id: string;
|
|
||||||
pipeline_id: string;
|
|
||||||
confidence: number;
|
|
||||||
reason: string;
|
|
||||||
suggested_inputs: Record<string, unknown>;
|
|
||||||
patterns_matched: string[];
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeshConfig {
|
|
||||||
enabled: boolean;
|
|
||||||
min_confidence: number;
|
|
||||||
max_recommendations: number;
|
|
||||||
analysis_window_hours: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface MeshAnalysisResult {
|
|
||||||
recommendations: WorkflowRecommendation[];
|
|
||||||
patterns_detected: number;
|
|
||||||
timestamp: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export type ActivityType =
|
|
||||||
| { type: 'skill_used'; skill_ids: string[] }
|
|
||||||
| { type: 'pipeline_executed'; task_type: string; pipeline_id: string }
|
|
||||||
| { type: 'input_received'; keywords: string[]; intent: string };
|
|
||||||
|
|
||||||
// === Persona Evolver Types ===
|
|
||||||
|
|
||||||
export type EvolutionChangeType =
|
|
||||||
| 'instruction_addition'
|
|
||||||
| 'instruction_refinement'
|
|
||||||
| 'trait_addition'
|
|
||||||
| 'style_adjustment'
|
|
||||||
| 'domain_expansion';
|
|
||||||
|
|
||||||
export type InsightCategory =
|
|
||||||
| 'communication_style'
|
|
||||||
| 'technical_expertise'
|
|
||||||
| 'task_efficiency'
|
|
||||||
| 'user_preference'
|
|
||||||
| 'knowledge_gap';
|
|
||||||
|
|
||||||
export type IdentityFileType = 'soul' | 'instructions';
|
|
||||||
export type ProposalStatus = 'pending' | 'approved' | 'rejected';
|
|
||||||
|
|
||||||
export interface EvolutionProposal {
|
|
||||||
id: string;
|
|
||||||
agent_id: string;
|
|
||||||
target_file: IdentityFileType;
|
|
||||||
change_type: EvolutionChangeType;
|
|
||||||
reason: string;
|
|
||||||
current_content: string;
|
|
||||||
proposed_content: string;
|
|
||||||
confidence: number;
|
|
||||||
evidence: string[];
|
|
||||||
status: ProposalStatus;
|
|
||||||
created_at: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface ProfileUpdate {
|
|
||||||
section: string;
|
|
||||||
previous: string;
|
|
||||||
updated: string;
|
|
||||||
source: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionInsight {
|
|
||||||
category: InsightCategory;
|
|
||||||
observation: string;
|
|
||||||
recommendation: string;
|
|
||||||
confidence: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface EvolutionResult {
|
|
||||||
agent_id: string;
|
|
||||||
timestamp: string;
|
|
||||||
profile_updates: ProfileUpdate[];
|
|
||||||
proposals: EvolutionProposal[];
|
|
||||||
insights: EvolutionInsight[];
|
|
||||||
evolved: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonaEvolverConfig {
|
|
||||||
auto_profile_update: boolean;
|
|
||||||
min_preferences_for_update: number;
|
|
||||||
min_conversations_for_evolution: number;
|
|
||||||
enable_instruction_refinement: boolean;
|
|
||||||
enable_soul_evolution: boolean;
|
|
||||||
max_proposals_per_cycle: number;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PersonaEvolverState {
|
|
||||||
last_evolution: string | null;
|
|
||||||
total_evolutions: number;
|
|
||||||
pending_proposals: number;
|
|
||||||
profile_enrichment_score: number;
|
|
||||||
}
|
|
||||||
@@ -1,561 +0,0 @@
|
|||||||
/**
|
|
||||||
* Intelligence Layer Unified Client
|
|
||||||
*
|
|
||||||
* Provides a unified API for intelligence operations that:
|
|
||||||
* - Uses Rust backend (via Tauri commands) when running in Tauri environment
|
|
||||||
* - Falls back to localStorage-based implementation in browser/dev environment
|
|
||||||
*
|
|
||||||
* Degradation strategy:
|
|
||||||
* - In Tauri mode: if a Tauri invoke fails, the error is logged and re-thrown.
|
|
||||||
* The caller is responsible for handling the error. We do NOT silently fall
|
|
||||||
* back to localStorage, because that would give users degraded functionality
|
|
||||||
* (localStorage instead of SQLite, rule-based instead of LLM-based, no-op
|
|
||||||
* instead of real execution) without any indication that something is wrong.
|
|
||||||
* - In browser/dev mode: localStorage fallback is the intended behavior for
|
|
||||||
* development and testing without a Tauri backend.
|
|
||||||
*
|
|
||||||
* This replaces direct usage of:
|
|
||||||
* - agent-memory.ts
|
|
||||||
* - heartbeat-engine.ts
|
|
||||||
* - context-compactor.ts
|
|
||||||
* - reflection-engine.ts
|
|
||||||
* - agent-identity.ts
|
|
||||||
*
|
|
||||||
* Usage:
|
|
||||||
* ```typescript
|
|
||||||
* import { intelligenceClient, toFrontendMemory, toBackendMemoryInput } from './intelligence-client';
|
|
||||||
*
|
|
||||||
* // Store memory
|
|
||||||
* const id = await intelligenceClient.memory.store({
|
|
||||||
* agent_id: 'agent-1',
|
|
||||||
* memory_type: 'fact',
|
|
||||||
* content: 'User prefers concise responses',
|
|
||||||
* importance: 7,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Search memories
|
|
||||||
* const memories = await intelligenceClient.memory.search({
|
|
||||||
* agent_id: 'agent-1',
|
|
||||||
* query: 'user preference',
|
|
||||||
* limit: 10,
|
|
||||||
* });
|
|
||||||
*
|
|
||||||
* // Convert to frontend format if needed
|
|
||||||
* const frontendMemories = memories.map(toFrontendMemory);
|
|
||||||
* ```
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { invoke } from '@tauri-apps/api/core';
|
|
||||||
|
|
||||||
import { isTauriRuntime } from '../tauri-gateway';
|
|
||||||
import { intelligence } from './type-conversions';
|
|
||||||
import type { PersistentMemory } from '../intelligence-backend';
|
|
||||||
import type {
|
|
||||||
HeartbeatConfig,
|
|
||||||
HeartbeatResult,
|
|
||||||
CompactableMessage,
|
|
||||||
CompactionResult,
|
|
||||||
CompactionCheck,
|
|
||||||
CompactionConfig,
|
|
||||||
ReflectionConfig,
|
|
||||||
ReflectionResult,
|
|
||||||
ReflectionState,
|
|
||||||
MemoryEntryForAnalysis,
|
|
||||||
IdentityFiles,
|
|
||||||
IdentityChangeProposal,
|
|
||||||
IdentitySnapshot,
|
|
||||||
} from '../intelligence-backend';
|
|
||||||
|
|
||||||
import type { MemoryEntry, MemorySearchOptions, MemoryStats } from './types';
|
|
||||||
import { toFrontendMemory, toBackendSearchOptions, toFrontendStats } from './type-conversions';
|
|
||||||
import { fallbackMemory } from './fallback-memory';
|
|
||||||
import { fallbackCompactor } from './fallback-compactor';
|
|
||||||
import { fallbackReflection } from './fallback-reflection';
|
|
||||||
import { fallbackIdentity } from './fallback-identity';
|
|
||||||
import { fallbackHeartbeat } from './fallback-heartbeat';
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Helper: wrap a Tauri invoke call so that failures are logged and re-thrown
|
|
||||||
* instead of silently falling back to localStorage implementations.
|
|
||||||
*/
|
|
||||||
function tauriInvoke<T>(label: string, fn: () => Promise<T>): Promise<T> {
|
|
||||||
return fn().catch((e: unknown) => {
|
|
||||||
console.warn(`[IntelligenceClient] Tauri invoke failed (${label}):`, e);
|
|
||||||
throw e;
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
/**
|
|
||||||
* Unified intelligence client that automatically selects backend or fallback.
|
|
||||||
*
|
|
||||||
* - In Tauri mode: calls Rust backend via invoke(). On failure, logs a warning
|
|
||||||
* and re-throws -- does NOT fall back to localStorage.
|
|
||||||
* - In browser/dev mode: uses localStorage-based fallback implementations.
|
|
||||||
*/
|
|
||||||
export const intelligenceClient = {
|
|
||||||
memory: {
|
|
||||||
init: async (): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('memory.init', () => intelligence.memory.init());
|
|
||||||
} else {
|
|
||||||
await fallbackMemory.init();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
store: async (entry: import('../intelligence-backend').MemoryEntryInput): Promise<string> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('memory.store', () => intelligence.memory.store(entry));
|
|
||||||
}
|
|
||||||
return fallbackMemory.store(entry);
|
|
||||||
},
|
|
||||||
|
|
||||||
get: async (id: string): Promise<MemoryEntry | null> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const result = await tauriInvoke('memory.get', () => intelligence.memory.get(id));
|
|
||||||
return result ? toFrontendMemory(result) : null;
|
|
||||||
}
|
|
||||||
return fallbackMemory.get(id);
|
|
||||||
},
|
|
||||||
|
|
||||||
search: async (options: MemorySearchOptions): Promise<MemoryEntry[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const results = await tauriInvoke('memory.search', () =>
|
|
||||||
intelligence.memory.search(toBackendSearchOptions(options))
|
|
||||||
);
|
|
||||||
return results.map(toFrontendMemory);
|
|
||||||
}
|
|
||||||
return fallbackMemory.search(options);
|
|
||||||
},
|
|
||||||
|
|
||||||
delete: async (id: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('memory.delete', () => intelligence.memory.delete(id));
|
|
||||||
} else {
|
|
||||||
await fallbackMemory.delete(id);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteAll: async (agentId: string): Promise<number> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('memory.deleteAll', () => intelligence.memory.deleteAll(agentId));
|
|
||||||
}
|
|
||||||
return fallbackMemory.deleteAll(agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
stats: async (): Promise<MemoryStats> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const stats = await tauriInvoke('memory.stats', () => intelligence.memory.stats());
|
|
||||||
return toFrontendStats(stats);
|
|
||||||
}
|
|
||||||
return fallbackMemory.stats();
|
|
||||||
},
|
|
||||||
|
|
||||||
export: async (): Promise<MemoryEntry[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const results = await tauriInvoke('memory.export', () => intelligence.memory.export());
|
|
||||||
return results.map(toFrontendMemory);
|
|
||||||
}
|
|
||||||
return fallbackMemory.export();
|
|
||||||
},
|
|
||||||
|
|
||||||
import: async (memories: MemoryEntry[]): Promise<number> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
const backendMemories = memories.map(m => ({
|
|
||||||
...m,
|
|
||||||
agent_id: m.agentId,
|
|
||||||
memory_type: m.type,
|
|
||||||
last_accessed_at: m.lastAccessedAt,
|
|
||||||
created_at: m.createdAt,
|
|
||||||
access_count: m.accessCount,
|
|
||||||
conversation_id: m.conversationId ?? null,
|
|
||||||
tags: JSON.stringify(m.tags),
|
|
||||||
embedding: null,
|
|
||||||
}));
|
|
||||||
return tauriInvoke('memory.import', () =>
|
|
||||||
intelligence.memory.import(backendMemories as PersistentMemory[])
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackMemory.import(memories);
|
|
||||||
},
|
|
||||||
|
|
||||||
dbPath: async (): Promise<string> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('memory.dbPath', () => intelligence.memory.dbPath());
|
|
||||||
}
|
|
||||||
return fallbackMemory.dbPath();
|
|
||||||
},
|
|
||||||
|
|
||||||
buildContext: async (
|
|
||||||
agentId: string,
|
|
||||||
query: string,
|
|
||||||
maxTokens?: number,
|
|
||||||
): Promise<{ systemPromptAddition: string; totalTokens: number; memoriesUsed: number }> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('memory.buildContext', () =>
|
|
||||||
intelligence.memory.buildContext(agentId, query, maxTokens ?? null)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
// Browser/dev fallback: use basic search
|
|
||||||
const memories = await fallbackMemory.search({
|
|
||||||
agentId,
|
|
||||||
query,
|
|
||||||
limit: 8,
|
|
||||||
minImportance: 3,
|
|
||||||
});
|
|
||||||
const addition = memories.length > 0
|
|
||||||
? `## 相关记忆\n${memories.map(m => `- [${m.type}] ${m.content}`).join('\n')}`
|
|
||||||
: '';
|
|
||||||
return { systemPromptAddition: addition, totalTokens: 0, memoriesUsed: memories.length };
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
heartbeat: {
|
|
||||||
init: async (agentId: string, config?: HeartbeatConfig): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.init', () => intelligence.heartbeat.init(agentId, config));
|
|
||||||
} else {
|
|
||||||
await fallbackHeartbeat.init(agentId, config);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
start: async (agentId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.start', () => intelligence.heartbeat.start(agentId));
|
|
||||||
} else {
|
|
||||||
await fallbackHeartbeat.start(agentId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
stop: async (agentId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.stop', () => intelligence.heartbeat.stop(agentId));
|
|
||||||
} else {
|
|
||||||
await fallbackHeartbeat.stop(agentId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
tick: async (agentId: string): Promise<HeartbeatResult> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('heartbeat.tick', () => intelligence.heartbeat.tick(agentId));
|
|
||||||
}
|
|
||||||
return fallbackHeartbeat.tick(agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getConfig: async (agentId: string): Promise<HeartbeatConfig> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('heartbeat.getConfig', () => intelligence.heartbeat.getConfig(agentId));
|
|
||||||
}
|
|
||||||
return fallbackHeartbeat.getConfig(agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateConfig: async (agentId: string, config: HeartbeatConfig): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.updateConfig', () =>
|
|
||||||
intelligence.heartbeat.updateConfig(agentId, config)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackHeartbeat.updateConfig(agentId, config);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getHistory: async (agentId: string, limit?: number): Promise<HeartbeatResult[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('heartbeat.getHistory', () =>
|
|
||||||
intelligence.heartbeat.getHistory(agentId, limit)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackHeartbeat.getHistory(agentId, limit);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateMemoryStats: async (
|
|
||||||
agentId: string,
|
|
||||||
taskCount: number,
|
|
||||||
totalEntries: number,
|
|
||||||
storageSizeBytes: number
|
|
||||||
): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.updateMemoryStats', () =>
|
|
||||||
invoke('heartbeat_update_memory_stats', {
|
|
||||||
agent_id: agentId,
|
|
||||||
task_count: taskCount,
|
|
||||||
total_entries: totalEntries,
|
|
||||||
storage_size_bytes: storageSizeBytes,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Browser/dev fallback only
|
|
||||||
const cache = {
|
|
||||||
taskCount,
|
|
||||||
totalEntries,
|
|
||||||
storageSizeBytes,
|
|
||||||
lastUpdated: new Date().toISOString(),
|
|
||||||
};
|
|
||||||
localStorage.setItem(`zclaw-memory-stats-${agentId}`, JSON.stringify(cache));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
recordCorrection: async (agentId: string, correctionType: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.recordCorrection', () =>
|
|
||||||
invoke('heartbeat_record_correction', {
|
|
||||||
agent_id: agentId,
|
|
||||||
correction_type: correctionType,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Browser/dev fallback only
|
|
||||||
const key = `zclaw-corrections-${agentId}`;
|
|
||||||
const stored = localStorage.getItem(key);
|
|
||||||
const counters = stored ? JSON.parse(stored) : {};
|
|
||||||
counters[correctionType] = (counters[correctionType] || 0) + 1;
|
|
||||||
localStorage.setItem(key, JSON.stringify(counters));
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
recordInteraction: async (agentId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('heartbeat.recordInteraction', () =>
|
|
||||||
invoke('heartbeat_record_interaction', {
|
|
||||||
agent_id: agentId,
|
|
||||||
})
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
// Browser/dev fallback only
|
|
||||||
localStorage.setItem(`zclaw-last-interaction-${agentId}`, new Date().toISOString());
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
compactor: {
|
|
||||||
estimateTokens: async (text: string): Promise<number> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('compactor.estimateTokens', () =>
|
|
||||||
intelligence.compactor.estimateTokens(text)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackCompactor.estimateTokens(text);
|
|
||||||
},
|
|
||||||
|
|
||||||
estimateMessagesTokens: async (messages: CompactableMessage[]): Promise<number> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('compactor.estimateMessagesTokens', () =>
|
|
||||||
intelligence.compactor.estimateMessagesTokens(messages)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackCompactor.estimateMessagesTokens(messages);
|
|
||||||
},
|
|
||||||
|
|
||||||
checkThreshold: async (
|
|
||||||
messages: CompactableMessage[],
|
|
||||||
config?: CompactionConfig
|
|
||||||
): Promise<CompactionCheck> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('compactor.checkThreshold', () =>
|
|
||||||
intelligence.compactor.checkThreshold(messages, config)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackCompactor.checkThreshold(messages, config);
|
|
||||||
},
|
|
||||||
|
|
||||||
compact: async (
|
|
||||||
messages: CompactableMessage[],
|
|
||||||
agentId: string,
|
|
||||||
conversationId?: string,
|
|
||||||
config?: CompactionConfig
|
|
||||||
): Promise<CompactionResult> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('compactor.compact', () =>
|
|
||||||
intelligence.compactor.compact(messages, agentId, conversationId, config)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackCompactor.compact(messages, agentId, conversationId, config);
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
reflection: {
|
|
||||||
init: async (config?: ReflectionConfig): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('reflection.init', () => intelligence.reflection.init(config));
|
|
||||||
} else {
|
|
||||||
await fallbackReflection.init(config);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
recordConversation: async (): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('reflection.recordConversation', () =>
|
|
||||||
intelligence.reflection.recordConversation()
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackReflection.recordConversation();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
shouldReflect: async (): Promise<boolean> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('reflection.shouldReflect', () =>
|
|
||||||
intelligence.reflection.shouldReflect()
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackReflection.shouldReflect();
|
|
||||||
},
|
|
||||||
|
|
||||||
reflect: async (agentId: string, memories: MemoryEntryForAnalysis[]): Promise<ReflectionResult> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('reflection.reflect', () =>
|
|
||||||
intelligence.reflection.reflect(agentId, memories)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackReflection.reflect(agentId, memories);
|
|
||||||
},
|
|
||||||
|
|
||||||
getHistory: async (limit?: number, agentId?: string): Promise<ReflectionResult[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('reflection.getHistory', () =>
|
|
||||||
intelligence.reflection.getHistory(limit, agentId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackReflection.getHistory(limit, agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getState: async (): Promise<ReflectionState> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('reflection.getState', () => intelligence.reflection.getState());
|
|
||||||
}
|
|
||||||
return fallbackReflection.getState();
|
|
||||||
},
|
|
||||||
},
|
|
||||||
|
|
||||||
identity: {
|
|
||||||
get: async (agentId: string): Promise<IdentityFiles> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.get', () => intelligence.identity.get(agentId));
|
|
||||||
}
|
|
||||||
return fallbackIdentity.get(agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
getFile: async (agentId: string, file: string): Promise<string> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.getFile', () => intelligence.identity.getFile(agentId, file));
|
|
||||||
}
|
|
||||||
return fallbackIdentity.getFile(agentId, file);
|
|
||||||
},
|
|
||||||
|
|
||||||
buildPrompt: async (agentId: string, memoryContext?: string): Promise<string> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.buildPrompt', () =>
|
|
||||||
intelligence.identity.buildPrompt(agentId, memoryContext)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackIdentity.buildPrompt(agentId, memoryContext);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateUserProfile: async (agentId: string, content: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.updateUserProfile', () =>
|
|
||||||
intelligence.identity.updateUserProfile(agentId, content)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.updateUserProfile(agentId, content);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
appendUserProfile: async (agentId: string, addition: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.appendUserProfile', () =>
|
|
||||||
intelligence.identity.appendUserProfile(agentId, addition)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.appendUserProfile(agentId, addition);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
proposeChange: async (
|
|
||||||
agentId: string,
|
|
||||||
file: 'soul' | 'instructions',
|
|
||||||
suggestedContent: string,
|
|
||||||
reason: string
|
|
||||||
): Promise<IdentityChangeProposal> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.proposeChange', () =>
|
|
||||||
intelligence.identity.proposeChange(agentId, file, suggestedContent, reason)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackIdentity.proposeChange(agentId, file, suggestedContent, reason);
|
|
||||||
},
|
|
||||||
|
|
||||||
approveProposal: async (proposalId: string): Promise<IdentityFiles> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.approveProposal', () =>
|
|
||||||
intelligence.identity.approveProposal(proposalId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackIdentity.approveProposal(proposalId);
|
|
||||||
},
|
|
||||||
|
|
||||||
rejectProposal: async (proposalId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.rejectProposal', () =>
|
|
||||||
intelligence.identity.rejectProposal(proposalId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.rejectProposal(proposalId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getPendingProposals: async (agentId?: string): Promise<IdentityChangeProposal[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.getPendingProposals', () =>
|
|
||||||
intelligence.identity.getPendingProposals(agentId)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackIdentity.getPendingProposals(agentId);
|
|
||||||
},
|
|
||||||
|
|
||||||
updateFile: async (agentId: string, file: string, content: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.updateFile', () =>
|
|
||||||
intelligence.identity.updateFile(agentId, file, content)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.updateFile(agentId, file, content);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
getSnapshots: async (agentId: string, limit?: number): Promise<IdentitySnapshot[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.getSnapshots', () =>
|
|
||||||
intelligence.identity.getSnapshots(agentId, limit)
|
|
||||||
);
|
|
||||||
}
|
|
||||||
return fallbackIdentity.getSnapshots(agentId, limit);
|
|
||||||
},
|
|
||||||
|
|
||||||
restoreSnapshot: async (agentId: string, snapshotId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.restoreSnapshot', () =>
|
|
||||||
intelligence.identity.restoreSnapshot(agentId, snapshotId)
|
|
||||||
);
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.restoreSnapshot(agentId, snapshotId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
|
|
||||||
listAgents: async (): Promise<string[]> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
return tauriInvoke('identity.listAgents', () => intelligence.identity.listAgents());
|
|
||||||
}
|
|
||||||
return fallbackIdentity.listAgents();
|
|
||||||
},
|
|
||||||
|
|
||||||
deleteAgent: async (agentId: string): Promise<void> => {
|
|
||||||
if (isTauriRuntime()) {
|
|
||||||
await tauriInvoke('identity.deleteAgent', () => intelligence.identity.deleteAgent(agentId));
|
|
||||||
} else {
|
|
||||||
await fallbackIdentity.deleteAgent(agentId);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export default intelligenceClient;
|
|
||||||
@@ -84,6 +84,7 @@ export interface SaaSStateSlice {
|
|||||||
_consecutiveFailures: number;
|
_consecutiveFailures: number;
|
||||||
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
_heartbeatTimer?: ReturnType<typeof setInterval>;
|
||||||
_healthCheckTimer?: ReturnType<typeof setInterval>;
|
_healthCheckTimer?: ReturnType<typeof setInterval>;
|
||||||
|
_recoveryProbeTimer?: ReturnType<typeof setInterval>;
|
||||||
|
|
||||||
// === Billing State ===
|
// === Billing State ===
|
||||||
plans: BillingPlan[];
|
plans: BillingPlan[];
|
||||||
@@ -141,6 +142,67 @@ function resolveInitialMode(sessionMeta: { saasUrl: string; account: SaaSAccount
|
|||||||
return sessionMeta ? 'saas' : 'tauri';
|
return sessionMeta ? 'saas' : 'tauri';
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === SaaS Recovery Probe ===
|
||||||
|
// When SaaS degrades to local mode, periodically probes SaaS reachability
|
||||||
|
// with exponential backoff (2min → 3min → 4.5min → 6.75min → 10min cap).
|
||||||
|
// On recovery, switches back to SaaS mode and notifies user via toast.
|
||||||
|
|
||||||
|
let _recoveryProbeInterval: ReturnType<typeof setInterval> | null = null;
|
||||||
|
let _recoveryBackoffMs = 2 * 60 * 1000; // Start at 2 minutes
|
||||||
|
const RECOVERY_BACKOFF_CAP_MS = 10 * 60 * 1000; // Max 10 minutes
|
||||||
|
const RECOVERY_BACKOFF_MULTIPLIER = 1.5;
|
||||||
|
|
||||||
|
function startRecoveryProbe() {
|
||||||
|
if (_recoveryProbeInterval) return; // Already probing
|
||||||
|
|
||||||
|
_recoveryBackoffMs = 2 * 60 * 1000; // Reset backoff
|
||||||
|
log.info('[SaaS Recovery] Starting recovery probe...');
|
||||||
|
|
||||||
|
const probe = async () => {
|
||||||
|
try {
|
||||||
|
await saasClient.deviceHeartbeat(DEVICE_ID);
|
||||||
|
// SaaS is reachable again — recover
|
||||||
|
log.info('[SaaS Recovery] SaaS reachable — switching back to SaaS mode');
|
||||||
|
useSaaSStore.setState({
|
||||||
|
saasReachable: true,
|
||||||
|
connectionMode: 'saas',
|
||||||
|
_consecutiveFailures: 0,
|
||||||
|
} as unknown as Partial<SaaSStore>);
|
||||||
|
saveConnectionMode('saas');
|
||||||
|
|
||||||
|
// Notify user via custom event (App.tsx listens)
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
window.dispatchEvent(new CustomEvent('saas-recovered'));
|
||||||
|
}
|
||||||
|
|
||||||
|
// Stop probing
|
||||||
|
stopRecoveryProbe();
|
||||||
|
} catch {
|
||||||
|
// Still unreachable — increase backoff
|
||||||
|
_recoveryBackoffMs = Math.min(
|
||||||
|
_recoveryBackoffMs * RECOVERY_BACKOFF_MULTIPLIER,
|
||||||
|
RECOVERY_BACKOFF_CAP_MS
|
||||||
|
);
|
||||||
|
log.debug(`[SaaS Recovery] Still unreachable, next probe in ${Math.round(_recoveryBackoffMs / 1000)}s`);
|
||||||
|
|
||||||
|
// Reschedule with new backoff
|
||||||
|
if (_recoveryProbeInterval) {
|
||||||
|
clearInterval(_recoveryProbeInterval);
|
||||||
|
}
|
||||||
|
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
_recoveryProbeInterval = setInterval(probe, _recoveryBackoffMs);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopRecoveryProbe() {
|
||||||
|
if (_recoveryProbeInterval) {
|
||||||
|
clearInterval(_recoveryProbeInterval);
|
||||||
|
_recoveryProbeInterval = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
// === Store Implementation ===
|
// === Store Implementation ===
|
||||||
|
|
||||||
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
export const useSaaSStore = create<SaaSStore>((set, get) => {
|
||||||
@@ -698,6 +760,8 @@ export const useSaaSStore = create<SaaSStore>((set, get) => {
|
|||||||
connectionMode: 'tauri',
|
connectionMode: 'tauri',
|
||||||
} as unknown as Partial<SaaSStore>);
|
} as unknown as Partial<SaaSStore>);
|
||||||
saveConnectionMode('tauri');
|
saveConnectionMode('tauri');
|
||||||
|
// Start recovery probe with exponential backoff
|
||||||
|
startRecoveryProbe();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, 5 * 60 * 1000);
|
}, 5 * 60 * 1000);
|
||||||
|
|||||||
360
docs/superpowers/specs/2026-04-15-heartbeat-unified-design.md
Normal file
360
docs/superpowers/specs/2026-04-15-heartbeat-unified-design.md
Normal file
@@ -0,0 +1,360 @@
|
|||||||
|
# Heartbeat 统一健康系统设计
|
||||||
|
|
||||||
|
> 日期: 2026-04-15
|
||||||
|
> 状态: Draft
|
||||||
|
> 范围: Intelligence Heartbeat 断链修复 + 统一健康面板 + SaaS 自动恢复
|
||||||
|
|
||||||
|
## 1. 问题诊断
|
||||||
|
|
||||||
|
### 1.1 五个心跳系统现状
|
||||||
|
|
||||||
|
ZCLAW 有 5 个独立心跳系统,各自运行,互不交互:
|
||||||
|
|
||||||
|
| 系统 | 触发机制 | 监测对象 | 状态 |
|
||||||
|
|------|----------|----------|------|
|
||||||
|
| Intelligence Heartbeat | Rust tokio timer (30min) | Agent 智能健康 | 设计完整,6处断链 |
|
||||||
|
| WebSocket Ping/Pong | 前端 JS setInterval (30s) | TCP 连接活性 | 完整 |
|
||||||
|
| SaaS Device Heartbeat | 前端 saasStore (5min) | 设备在线状态 | 完整,降级无恢复 |
|
||||||
|
| StreamBridge SSE | 服务端 async_stream (15s) | SSE 流保活 | 完整(纯服务端) |
|
||||||
|
| A2A Heartbeat | 无(枚举占位) | Agent 间协议 | 空壳,暂不需要 |
|
||||||
|
|
||||||
|
**核心发现:5 个系统之间没有运行时交互,也不需要交互。** 它们各自监测完全不同的东西。不存在"统一协调"的实际需求,需要的是"断链修复 + 统一可见性"。
|
||||||
|
|
||||||
|
### 1.2 Intelligence Heartbeat 的 6 处断链
|
||||||
|
|
||||||
|
1. **告警无法实时送达前端** — Rust `broadcast::Sender` 有发送者但零订阅者,`subscribe()` 是 dead code。告警只存入 history,用户无法实时感知。
|
||||||
|
2. **HeartbeatConfig 保存只到 localStorage** — `handleSave()` 不调用 `updateConfig()`,Rust 后端永远用 App.tsx 的硬编码默认值。
|
||||||
|
3. **动态间隔修改无效** — `tokio::time::interval` 创建后不可变,`update_config` 改了值但不生效。
|
||||||
|
4. **Config 不持久化** — VikingStorage 只存 history 和 last_interaction,config 重启后丢失。
|
||||||
|
5. **重复 client 实现** — `intelligence-client.ts` 单文件版被 `intelligence-client/` 目录版遮蔽,是死代码。
|
||||||
|
6. **Memory stats 依赖前端推送** — 如果前端同步失败,检查只能产出"缓存为空"警告。
|
||||||
|
|
||||||
|
### 1.3 SaaS 降级无恢复
|
||||||
|
|
||||||
|
`saasStore.ts` 在 SaaS 连续 3 次心跳失败后从 `saas` 模式降级到 `tauri` 模式,但不会自动恢复。用户必须手动切换回去。
|
||||||
|
|
||||||
|
## 2. 设计决策
|
||||||
|
|
||||||
|
### 2.1 为什么不做后台协调器
|
||||||
|
|
||||||
|
5 个系统不需要状态协调:
|
||||||
|
- WebSocket 断了不影响 Intelligence Heartbeat 继续检查任务积压
|
||||||
|
- SaaS 不可达不影响 WebSocket ping/pong
|
||||||
|
- 每个系统的触发者、消费者、检测对象完全不同
|
||||||
|
|
||||||
|
"统一可见性"通过按需查询实现,不需要常驻后台任务。
|
||||||
|
|
||||||
|
### 2.2 核心策略
|
||||||
|
|
||||||
|
**断链修复 + 按需查询 > 后台协调器**
|
||||||
|
|
||||||
|
| 改动类型 | 内容 |
|
||||||
|
|----------|------|
|
||||||
|
| 修复 | heartbeat.rs 6 处断链 |
|
||||||
|
| 新增 | `health_snapshot` Tauri 命令(按需查询) |
|
||||||
|
| 新增 | `HealthPanel.tsx` 前端组件 |
|
||||||
|
| 修复 | SaaS 自动恢复 |
|
||||||
|
| 清理 | 删除重复 client 文件 |
|
||||||
|
|
||||||
|
## 3. 详细设计
|
||||||
|
|
||||||
|
### 3.1 Rust 后端断链修复
|
||||||
|
|
||||||
|
**文件**: `desktop/src-tauri/src/intelligence/heartbeat.rs`
|
||||||
|
|
||||||
|
#### 3.1.1 告警实时推送
|
||||||
|
|
||||||
|
**方案选择**:使用 `OnceLock<AppHandle>` 全局单例(与 `MEMORY_STATS_CACHE` 等 OnceLock 模式一致)。`heartbeat_init` Tauri 命令从参数中拿到 `app: AppHandle`,写入全局 `HEARTBEAT_APP_HANDLE: OnceLock<AppHandle>`。后台 spawned task 通过全局读取 emit。
|
||||||
|
|
||||||
|
项目已有先例:`stream:chunk`(chat.rs:403)、`hand-execution-complete`(hand.rs:302)、`pipeline-complete`(discovery.rs:173)都在 Tauri 命令中直接使用 `app: AppHandle` 参数。HeartbeatEngine 的特殊性在于它运行在 `tokio::spawn` 后台任务中,无法直接获得命令参数,因此使用全局单例传递。
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// 全局声明(与 MEMORY_STATS_CACHE 同层级,在 heartbeat.rs 顶部)
|
||||||
|
static HEARTBEAT_APP_HANDLE: OnceLock<tauri::AppHandle> = OnceLock::new();
|
||||||
|
|
||||||
|
// heartbeat_init 命令中注入
|
||||||
|
pub async fn heartbeat_init(
|
||||||
|
app: tauri::AppHandle, // Tauri 自动注入
|
||||||
|
agent_id: String,
|
||||||
|
config: Option<HeartbeatConfig>,
|
||||||
|
state: tauri::State<'_, HeartbeatEngineState>,
|
||||||
|
) -> Result<(), String> {
|
||||||
|
if let Err(_) = HEARTBEAT_APP_HANDLE.set(app) {
|
||||||
|
tracing::warn!("[heartbeat] APP_HANDLE already set (multiple init calls)");
|
||||||
|
}
|
||||||
|
// ... 现有 init 逻辑
|
||||||
|
}
|
||||||
|
|
||||||
|
// execute_tick() 末尾,告警生成后
|
||||||
|
if !alerts.is_empty() {
|
||||||
|
if let Some(app) = HEARTBEAT_APP_HANDLE.get() {
|
||||||
|
if let Err(e) = app.emit("heartbeat:alert", &alerts) {
|
||||||
|
tracing::warn!("[heartbeat] Failed to emit alert: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
前端用 `safeListen('heartbeat:alert', callback)` 接收(`desktop/src/lib/safe-tauri.ts:76` 已有封装),回调中调 `toast(alert.content, urgencyToType(alert.urgency))` 弹出通知。
|
||||||
|
|
||||||
|
#### 3.1.2 动态 Interval
|
||||||
|
|
||||||
|
替换 `tokio::time::interval()` 为 `tokio::time::sleep` + 每次重读 config。使用 `tokio::select!` + `tokio::sync::Notify` 实现可中断的 sleep,确保 stop 信号能立即响应:
|
||||||
|
|
||||||
|
```rust
|
||||||
|
// HeartbeatEngine 结构体新增字段(在 heartbeat.rs 约 116-122 行)
|
||||||
|
pub struct HeartbeatEngine {
|
||||||
|
agent_id: String,
|
||||||
|
config: Arc<Mutex<HeartbeatConfig>>,
|
||||||
|
running: Arc<Mutex<bool>>,
|
||||||
|
stop_notify: Arc<Notify>, // 新增
|
||||||
|
alert_sender: broadcast::Sender<HeartbeatAlert>,
|
||||||
|
history: Arc<Mutex<Vec<HeartbeatResult>>>,
|
||||||
|
}
|
||||||
|
|
||||||
|
// HeartbeatEngine::new() 中初始化
|
||||||
|
stop_notify: Arc::new(Notify::new()),
|
||||||
|
|
||||||
|
// start() 中的 loop
|
||||||
|
loop {
|
||||||
|
let sleep_secs = config.lock().await.interval_minutes * 60;
|
||||||
|
// 可中断的 sleep:stop_notify 信号到达时立即醒来
|
||||||
|
tokio::select! {
|
||||||
|
_ = tokio::time::sleep(Duration::from_secs(sleep_secs)) => {},
|
||||||
|
_ = stop_notify.notified() => { break; }
|
||||||
|
};
|
||||||
|
if !*running_clone.lock().await { break; }
|
||||||
|
if is_quiet_hours(&*config.lock().await) { continue; }
|
||||||
|
let result = execute_tick(&agent_id, &config, &alert_sender).await;
|
||||||
|
// ... history + persist
|
||||||
|
}
|
||||||
|
|
||||||
|
// stop() 方法
|
||||||
|
pub async fn stop(&self) {
|
||||||
|
*self.running.lock().await = false;
|
||||||
|
self.stop_notify.notify_one(); // 立即唤醒 sleep
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
每次循环重新读取 `config.interval_minutes`,修改立即生效。`stop()` 通过 `Notify` 立即中断 sleep,无需等待下一周期。
|
||||||
|
|
||||||
|
#### 3.1.3 Config 持久化
|
||||||
|
|
||||||
|
- `update_config()` 改完后写 VikingStorage key `heartbeat:config:{agent_id}`
|
||||||
|
- `heartbeat_init()` 恢复时优先读 VikingStorage,无记录才用传入的默认值
|
||||||
|
- 前端 App.tsx 不再需要传 config,让 Rust 侧自己恢复
|
||||||
|
|
||||||
|
#### 3.1.4 Memory Stats 查询 Fallback
|
||||||
|
|
||||||
|
检查函数中,如果 `MEMORY_STATS_CACHE` 为空,fallback 直接查 VikingStorage 统计 entry 数量和存储大小。
|
||||||
|
|
||||||
|
#### 3.1.5 清理 Dead Code
|
||||||
|
|
||||||
|
- 删除 `subscribe()` — `health_snapshot` 通过 `history: Arc<Mutex<Vec<HeartbeatResult>>>` 访问告警历史,不需要 broadcast receiver。`broadcast::Sender` 仅用于内部告警传递,不再暴露 subscribe API。
|
||||||
|
- 移除 `HeartbeatCheckFn` type alias — 当前未使用且设计方向已明确为硬编码 5 检查
|
||||||
|
- `is_running()` 暴露为 Tauri 命令(`health_snapshot` 需要查询引擎运行状态)
|
||||||
|
|
||||||
|
### 3.2 Health Snapshot 端点
|
||||||
|
|
||||||
|
**新文件**: `desktop/src-tauri/src/intelligence/health_snapshot.rs`(~120 行)
|
||||||
|
|
||||||
|
#### 3.2.1 数据结构
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct HealthSnapshot {
|
||||||
|
pub timestamp: String,
|
||||||
|
pub intelligence: IntelligenceHealth,
|
||||||
|
pub memory: MemoryHealth,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct IntelligenceHealth {
|
||||||
|
pub engine_running: bool,
|
||||||
|
pub config: HeartbeatConfig,
|
||||||
|
pub last_tick: Option<String>,
|
||||||
|
pub alert_count_24h: usize,
|
||||||
|
pub total_checks: usize, // 固定值 5(内置检查项总数)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Serialize)]
|
||||||
|
pub struct MemoryHealth {
|
||||||
|
pub total_entries: usize,
|
||||||
|
pub storage_size_bytes: u64,
|
||||||
|
pub last_extraction: Option<String>,
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
只包含 Rust 侧能查询的状态。连接状态和 SaaS 状态由前端各自 store 管理,不绕道 Rust。
|
||||||
|
|
||||||
|
#### 3.2.2 Tauri 命令
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[tauri::command]
|
||||||
|
pub async fn health_snapshot(
|
||||||
|
agent_id: String,
|
||||||
|
heartbeat_state: tauri::State<'_, HeartbeatEngineState>,
|
||||||
|
) -> Result<HealthSnapshot, String>
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.2.3 注册
|
||||||
|
|
||||||
|
在 `intelligence/mod.rs` 添加 `pub mod health_snapshot;`,在 `lib.rs` builder 中注册命令和 re-export。
|
||||||
|
|
||||||
|
### 3.3 前端修复
|
||||||
|
|
||||||
|
#### 3.3.1 HeartbeatConfig 保存接通后端
|
||||||
|
|
||||||
|
**文件**: `desktop/src/components/HeartbeatConfig.tsx`
|
||||||
|
|
||||||
|
`handleSave()` 在写 localStorage 之后同时调用 `intelligenceClient.heartbeat.updateConfig()` 推送到 Rust 后端。保留 localStorage 作为离线 fallback。
|
||||||
|
|
||||||
|
错误处理策略:`updateConfig` 调用用 try/catch 包裹,失败时仅 `log.warn`(不阻塞 UI 更新),用户看到"已保存"反馈。浏览器模式下 `fallbackHeartbeat.updateConfig()` 是纯内存操作,不会失败。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
const handleSave = useCallback(async () => {
|
||||||
|
localStorage.setItem('zclaw-heartbeat-config', JSON.stringify(config));
|
||||||
|
localStorage.setItem('zclaw-heartbeat-checks', JSON.stringify(checkItems));
|
||||||
|
try {
|
||||||
|
await intelligenceClient.heartbeat.updateConfig('zclaw-main', config);
|
||||||
|
} catch (err) {
|
||||||
|
log.warn('[HeartbeatConfig] Backend sync failed:', err);
|
||||||
|
}
|
||||||
|
setHasChanges(false);
|
||||||
|
}, [config, checkItems]);
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.2 App.tsx 启动读持久化 Config
|
||||||
|
|
||||||
|
**文件**: `desktop/src/App.tsx`
|
||||||
|
|
||||||
|
优先从 localStorage 读用户保存的配置,无记录才用默认值。Rust 侧 `heartbeat_init` 也会从 VikingStorage 恢复,形成双层恢复。
|
||||||
|
|
||||||
|
#### 3.3.3 告警监听 + Toast 展示
|
||||||
|
|
||||||
|
**文件**: `desktop/src/App.tsx`
|
||||||
|
|
||||||
|
在 heartbeat start 之后注册 `safeListen('heartbeat:alert', callback)`,回调中对每条告警调 `toast()`。使用项目已有的 `safeListen` 封装和 Toast 系统。
|
||||||
|
|
||||||
|
#### 3.3.4 SaaS 自动恢复
|
||||||
|
|
||||||
|
**文件**: `desktop/src/store/saasStore.ts`
|
||||||
|
|
||||||
|
降级后启动周期探测,复用现有 `saasClient.deviceHeartbeat(DEVICE_ID)` 调用本身作为探测(它已经能证明 SaaS 可达性)。使用指数退避:初始 2 分钟,最长 10 分钟(2min → 3min → 4.5min → 6.75min → 10min cap)。恢复后自动切回 `saas` 模式 + toast 通知用户 + 停止探测。
|
||||||
|
|
||||||
|
**探测启动点**:在 saasStore.ts 的 `DEGRADE_AFTER_FAILURES` 降级逻辑的同一个 catch 块中启动(`saasStore.ts` 约 694-700 行)。降级代码执行后紧接着调用 `startRecoveryProbe()`。
|
||||||
|
|
||||||
|
```typescript
|
||||||
|
// saasStore.ts 现有降级逻辑中追加
|
||||||
|
if (_consecutiveFailures >= DEGRADE_AFTER_FAILURES) {
|
||||||
|
set({ saasReachable: false, connectionMode: 'tauri' });
|
||||||
|
saveConnectionMode('tauri');
|
||||||
|
startRecoveryProbe(); // ← 新增
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.3.5 清理重复文件
|
||||||
|
|
||||||
|
删除 `desktop/src/lib/intelligence-client.ts`(1476 行单文件版)。已被 `intelligence-client/` 目录版遮蔽,是死代码。
|
||||||
|
|
||||||
|
### 3.4 HealthPanel 健康面板
|
||||||
|
|
||||||
|
**新文件**: `desktop/src/components/HealthPanel.tsx`(~300 行)
|
||||||
|
|
||||||
|
#### 3.4.1 定位
|
||||||
|
|
||||||
|
设置页中的一个选项卡,只读展示所有子系统健康状态 + 历史告警浏览。不做配置(配置由 HeartbeatConfig 选项卡负责)。
|
||||||
|
|
||||||
|
#### 3.4.2 数据来源
|
||||||
|
|
||||||
|
| 区域 | 数据源 |
|
||||||
|
|------|--------|
|
||||||
|
| Agent 心跳 | `health_snapshot` invoke |
|
||||||
|
| 连接状态 | `useConnectionStore`(已有) |
|
||||||
|
| SaaS 状态 | `useSaasStore`(已有) |
|
||||||
|
| 记忆状态 | `health_snapshot` invoke |
|
||||||
|
| 历史告警 | `intelligenceClient.heartbeat.getHistory()` |
|
||||||
|
|
||||||
|
不新建 Zustand store,用 `useState` 管理,组件卸载即释放。
|
||||||
|
|
||||||
|
#### 3.4.3 UI 布局
|
||||||
|
|
||||||
|
```
|
||||||
|
系统健康 [刷新]
|
||||||
|
├── Agent 心跳卡片(运行状态/间隔/上次检查/告警数)
|
||||||
|
├── 连接状态卡片(模式/连接/SaaS可达)
|
||||||
|
├── SaaS 设备卡片(注册/上次心跳/连续失败)
|
||||||
|
├── 记忆管道卡片(条目/存储/上次提取)
|
||||||
|
└── 最近告警列表(紧急度/时间/标题,最多 100 条)
|
||||||
|
```
|
||||||
|
|
||||||
|
#### 3.4.4 状态指示器
|
||||||
|
|
||||||
|
| 状态 | 指示 | 条件 |
|
||||||
|
|------|------|------|
|
||||||
|
| 绿灯 | `●` | 正常运行 |
|
||||||
|
| 黄灯 | `●` | 降级/暂停 |
|
||||||
|
| 灰灯 | `○` | 已禁用/空白 |
|
||||||
|
| 红灯 | `●` | 断开/错误 |
|
||||||
|
|
||||||
|
#### 3.4.5 刷新策略
|
||||||
|
|
||||||
|
进入面板时调用一次,手动刷新按钮重新调用。告警列表额外订阅 `heartbeat:alert` Tauri event 实时追加新告警(组件卸载时 unlisten),其他区域不自动轮询。
|
||||||
|
|
||||||
|
#### 3.4.6 导航入口
|
||||||
|
|
||||||
|
在 `SettingsLayout.tsx` 的 `advanced` 分组中添加 `{ id: 'health', label: '系统健康' }`,与 `heartbeat` 选项卡并列。
|
||||||
|
|
||||||
|
## 4. 改动清单
|
||||||
|
|
||||||
|
| 文件 | 操作 | 行数 |
|
||||||
|
|------|------|------|
|
||||||
|
| `intelligence/heartbeat.rs` | 修改(6处修复) | ~80 行改动 |
|
||||||
|
| `intelligence/health_snapshot.rs` | 新建 | ~120 行 |
|
||||||
|
| `intelligence/mod.rs` | 修改(添加模块声明) | ~3 行 |
|
||||||
|
| `lib.rs` | 修改(注册命令 + re-export) | ~5 行 |
|
||||||
|
| `components/HealthPanel.tsx` | 新建 | ~300 行 |
|
||||||
|
| `components/HeartbeatConfig.tsx` | 修改(保存逻辑) | ~10 行 |
|
||||||
|
| `components/Settings/SettingsLayout.tsx` | 修改(添加导航) | ~3 行 |
|
||||||
|
| `App.tsx` | 修改(读配置 + 告警监听) | ~17 行 |
|
||||||
|
| `store/saasStore.ts` | 修改(自动恢复) | ~25 行 |
|
||||||
|
| `lib/intelligence-client.ts` | 删除 | -1476 行 |
|
||||||
|
| **合计** | | ~-913 行净变化 |
|
||||||
|
|
||||||
|
## 5. 不做的事
|
||||||
|
|
||||||
|
- 不建后台协调器或事件总线
|
||||||
|
- 不替代 WebSocket ping/pong
|
||||||
|
- 不替代 SaaS device heartbeat 的 HTTP POST 机制
|
||||||
|
- 不实现 A2A Heartbeat(仍是枚举占位)
|
||||||
|
- 不建新的 Zustand store
|
||||||
|
- 不设自动轮询刷新
|
||||||
|
|
||||||
|
## 6. 验证标准
|
||||||
|
|
||||||
|
### 6.1 功能验证
|
||||||
|
|
||||||
|
- [ ] 修改 HeartbeatConfig 保存后,Rust 后端立即生效(interval 变更在下次 tick 体现)
|
||||||
|
- [ ] 告警实时弹出 Toast(不高于 proactivity_level 过滤)
|
||||||
|
- [ ] 重启应用后配置自动恢复(VikingStorage + localStorage 双层)
|
||||||
|
- [ ] SaaS 降级后恢复连接,自动切回 + Toast 通知
|
||||||
|
- [ ] HealthPanel 展示所有 4 个子系统状态 + 历史告警
|
||||||
|
- [ ] Memory stats 前端同步失败时 fallback 到 VikingStorage 直接查询
|
||||||
|
|
||||||
|
### 6.2 回归验证
|
||||||
|
|
||||||
|
- [ ] `cargo check --workspace --exclude zclaw-saas` 通过
|
||||||
|
- [ ] `cd desktop && pnpm tsc --noEmit` 通过
|
||||||
|
- [ ] `cd desktop && pnpm vitest run` 通过
|
||||||
|
- [ ] 现有 WebSocket ping/pong 不受影响
|
||||||
|
- [ ] 现有 SSE StreamBridge 不受影响
|
||||||
|
|
||||||
|
## 7. 风险
|
||||||
|
|
||||||
|
| 风险 | 影响 | 缓解 |
|
||||||
|
|------|------|------|
|
||||||
|
| AppHandle 全局单例时序 | `heartbeat_init` 必须在 Tauri app 初始化后调用 | 实际在 App.tsx bootstrap Step 4.5 调用,此时 Tauri 已就绪;`OnceLock::set` 失败仅 log warn |
|
||||||
|
| stop 信号在长 sleep 期间延迟 | 用户点击停止后需要等待 sleep 结束 | 使用 `tokio::select!` + `Notify`,stop 立即唤醒 |
|
||||||
|
| SaaS 探测持续失败 | 长时间不可达时每 2 分钟探测浪费资源 | 指数退避,最长 10 分钟间隔 |
|
||||||
|
| 删除 intelligence-client.ts 影响未知导入 | 如果有文件显式导入 `.ts` 后缀 | 实施前全局 grep 确认所有 import 路径;Vite/TypeScript 目录解析优先于文件 |
|
||||||
|
| HealthPanel 告警列表内存 | 长时间打开面板可能积累大量实时告警 | 组件内限制最大 100 条,超出丢弃最早的 |
|
||||||
Reference in New Issue
Block a user