fix(growth,hands,kernel,desktop): Phase 1 用户可感知修复 — 6 项断链修复
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
Phase 1 修复内容: 1. Hand 执行前端字段映射 — instance_id → runId,修复 Hand 状态追踪 2. Heartbeat 痛点感知 — PAIN_POINTS_CACHE + VikingStorage 持久化 + 未解决痛点检查 3. Browser Hand 委托消息 — pending_execution → delegated_to_frontend + 中文摘要 4. 跨会话记忆检索增强 — 扩展 IdentityRecall 模式 26→43 + 弱身份信号检测 + 低结果 fallback 5. Twitter Hand 凭据持久化 — SetCredentials action + 文件持久化 + 启动恢复 6. Browser 测试修复 — 适配新的 delegated_to_frontend 响应格式 验证: cargo check ✅ | cargo test 912 PASS ✅ | tsc --noEmit ✅
This commit is contained in:
@@ -357,6 +357,7 @@ async fn execute_tick(
|
||||
let checks: Vec<(&str, fn(&str) -> Option<HeartbeatAlert>)> = vec![
|
||||
("pending-tasks", check_pending_tasks),
|
||||
("memory-health", check_memory_health),
|
||||
("unresolved-pains", check_unresolved_pains),
|
||||
("idle-greeting", check_idle_greeting),
|
||||
("personality-improvement", check_personality_improvement),
|
||||
("learning-opportunities", check_learning_opportunities),
|
||||
@@ -447,7 +448,27 @@ static MEMORY_STATS_CACHE: OnceLock<RwLock<StdHashMap<String, MemoryStatsCache>>
|
||||
/// Key: agent_id, Value: last interaction timestamp (RFC3339)
|
||||
static LAST_INTERACTION: OnceLock<RwLock<StdHashMap<String, String>>> = OnceLock::new();
|
||||
|
||||
/// Cached memory stats for an agent
|
||||
/// Global pain points cache (updated by frontend via Tauri command)
|
||||
/// Key: agent_id, Value: list of unresolved pain point descriptions
|
||||
static PAIN_POINTS_CACHE: OnceLock<RwLock<StdHashMap<String, Vec<String>>>> = OnceLock::new();
|
||||
|
||||
fn get_pain_points_cache() -> &'static RwLock<StdHashMap<String, Vec<String>>> {
|
||||
PAIN_POINTS_CACHE.get_or_init(|| RwLock::new(StdHashMap::new()))
|
||||
}
|
||||
|
||||
/// Update pain points cache (called from frontend or growth middleware)
|
||||
pub fn update_pain_points_cache(agent_id: &str, pain_points: Vec<String>) {
|
||||
let cache = get_pain_points_cache();
|
||||
if let Ok(mut cache) = cache.write() {
|
||||
cache.insert(agent_id.to_string(), pain_points);
|
||||
}
|
||||
}
|
||||
|
||||
/// Get cached pain points for an agent
|
||||
fn get_cached_pain_points(agent_id: &str) -> Option<Vec<String>> {
|
||||
let cache = get_pain_points_cache();
|
||||
cache.read().ok().and_then(|c| c.get(agent_id).cloned())
|
||||
}
|
||||
#[derive(Clone, Debug, Default)]
|
||||
pub struct MemoryStatsCache {
|
||||
pub task_count: usize,
|
||||
@@ -755,6 +776,32 @@ fn check_learning_opportunities(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
}
|
||||
}
|
||||
|
||||
/// Check for unresolved user pain points accumulated by the butler system.
|
||||
/// When pain points persist across multiple conversations, surface them as
|
||||
/// proactive suggestions.
|
||||
fn check_unresolved_pains(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
let pains = get_cached_pain_points(agent_id)?;
|
||||
if pains.is_empty() {
|
||||
return None;
|
||||
}
|
||||
let count = pains.len();
|
||||
let summary = if count <= 3 {
|
||||
pains.join("、")
|
||||
} else {
|
||||
format!("{}等 {} 项", pains[..3].join("、"), count)
|
||||
};
|
||||
Some(HeartbeatAlert {
|
||||
title: "未解决的用户痛点".to_string(),
|
||||
content: format!(
|
||||
"检测到 {} 个持续痛点:{}。建议主动提供解决方案或相关建议。",
|
||||
count, summary
|
||||
),
|
||||
urgency: if count >= 3 { Urgency::High } else { Urgency::Medium },
|
||||
source: "unresolved-pains".to_string(),
|
||||
timestamp: chrono::Utc::now().to_rfc3339(),
|
||||
})
|
||||
}
|
||||
|
||||
// === Tauri Commands ===
|
||||
|
||||
/// Heartbeat engine state for Tauri
|
||||
@@ -800,6 +847,9 @@ pub async fn heartbeat_init(
|
||||
// Restore heartbeat history from VikingStorage metadata
|
||||
engine.restore_history().await;
|
||||
|
||||
// Restore pain points cache from VikingStorage metadata
|
||||
restore_pain_points(&agent_id).await;
|
||||
|
||||
let mut engines = state.lock().await;
|
||||
engines.insert(agent_id, engine);
|
||||
Ok(())
|
||||
@@ -865,6 +915,33 @@ pub async fn restore_last_interaction(agent_id: &str) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Restore pain points cache from VikingStorage metadata.
|
||||
async fn restore_pain_points(agent_id: &str) {
|
||||
let key = format!("heartbeat:pain_points:{}", agent_id);
|
||||
match crate::viking_commands::get_storage().await {
|
||||
Ok(storage) => {
|
||||
match zclaw_growth::VikingStorage::get_metadata_json(&*storage, &key).await {
|
||||
Ok(Some(json)) => {
|
||||
if let Ok(pains) = serde_json::from_str::<Vec<String>>(&json) {
|
||||
let count = pains.len();
|
||||
update_pain_points_cache(agent_id, pains);
|
||||
tracing::info!("[heartbeat] Restored {} pain points for {}", count, agent_id);
|
||||
}
|
||||
}
|
||||
Ok(None) => {
|
||||
tracing::debug!("[heartbeat] No persisted pain points for {}", agent_id);
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heartbeat] Failed to restore pain points: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
Err(e) => {
|
||||
tracing::warn!("[heartbeat] Storage unavailable for pain points restore: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Start heartbeat engine for an agent
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
@@ -998,6 +1075,29 @@ pub async fn heartbeat_record_interaction(
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Update pain points cache for heartbeat pain-awareness checks.
|
||||
/// Called by frontend when pain points are extracted from conversations.
|
||||
// @connected
|
||||
#[tauri::command]
|
||||
pub async fn heartbeat_update_pain_points(
|
||||
agent_id: String,
|
||||
pain_points: Vec<String>,
|
||||
) -> Result<(), String> {
|
||||
update_pain_points_cache(&agent_id, pain_points.clone());
|
||||
// Persist to VikingStorage for survival across restarts
|
||||
let key = format!("heartbeat:pain_points:{}", agent_id);
|
||||
tokio::spawn(async move {
|
||||
if let Ok(storage) = crate::viking_commands::get_storage().await {
|
||||
if let Ok(json) = serde_json::to_string(&pain_points) {
|
||||
if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(&*storage, &key, &json).await {
|
||||
tracing::warn!("[heartbeat] Failed to persist pain points: {}", e);
|
||||
}
|
||||
}
|
||||
}
|
||||
});
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
@@ -381,6 +381,7 @@ pub fn run() {
|
||||
intelligence::heartbeat::heartbeat_update_memory_stats,
|
||||
intelligence::heartbeat::heartbeat_record_correction,
|
||||
intelligence::heartbeat::heartbeat_record_interaction,
|
||||
intelligence::heartbeat::heartbeat_update_pain_points,
|
||||
// Health Snapshot (on-demand query)
|
||||
intelligence::health_snapshot::health_snapshot,
|
||||
// Context Compactor
|
||||
|
||||
@@ -380,10 +380,14 @@ export function installApiMethods(ClientClass: { prototype: GatewayClient }): vo
|
||||
proto.triggerHand = async function (this: GatewayClient, name: string, params?: Record<string, unknown>): Promise<{ runId: string; status: string }> {
|
||||
try {
|
||||
const result = await this.restPost<{
|
||||
instance_id: string;
|
||||
status: string;
|
||||
success: boolean;
|
||||
run_id?: string;
|
||||
output?: { status?: string };
|
||||
}>(`/api/hands/${name}/activate`, params || {});
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
return {
|
||||
runId: result.run_id || '',
|
||||
status: result.output?.status || (result.success ? 'completed' : 'failed'),
|
||||
};
|
||||
} catch (err) {
|
||||
logger.error(`Hand trigger failed for ${name}`, { error: err });
|
||||
throw err;
|
||||
|
||||
@@ -91,19 +91,21 @@ export function installHandMethods(ClientClass: { prototype: KernelClient }): vo
|
||||
* Trigger/execute a hand
|
||||
*/
|
||||
proto.triggerHand = async function (this: KernelClient, name: string, params?: Record<string, unknown>, autonomyLevel?: string): Promise<{ runId: string; status: string }> {
|
||||
const result = await invoke<{ instance_id: string; status: string }>('hand_execute', {
|
||||
const result = await invoke<{ success: boolean; runId?: string; output?: { status?: string }; error?: string }>('hand_execute', {
|
||||
id: name,
|
||||
input: params || {},
|
||||
...(autonomyLevel ? { autonomyLevel } : {}),
|
||||
});
|
||||
const runId = result.runId || '';
|
||||
const status = result.output?.status || (result.success ? 'completed' : 'failed');
|
||||
// P2-25: Audit hand execution
|
||||
try {
|
||||
const { logSecurityEvent } = await import('./security-audit');
|
||||
logSecurityEvent('hand_executed', `Hand "${name}" executed (runId: ${result.instance_id}, status: ${result.status})`, {
|
||||
handId: name, runId: result.instance_id, status: result.status, autonomyLevel,
|
||||
logSecurityEvent('hand_executed', `Hand "${name}" executed (runId: ${runId}, status: ${status})`, {
|
||||
handId: name, runId, status, autonomyLevel,
|
||||
});
|
||||
} catch { /* audit failure is non-blocking */ }
|
||||
return { runId: result.instance_id, status: result.status };
|
||||
return { runId, status };
|
||||
};
|
||||
|
||||
/**
|
||||
|
||||
Reference in New Issue
Block a user