From 9a2611d122140b35deb7e88de07b034b7d5f26c6 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 21 Apr 2026 10:18:25 +0800 Subject: [PATCH] =?UTF-8?q?fix(growth,hands,kernel,desktop):=20Phase=201?= =?UTF-8?q?=20=E7=94=A8=E6=88=B7=E5=8F=AF=E6=84=9F=E7=9F=A5=E4=BF=AE?= =?UTF-8?q?=E5=A4=8D=20=E2=80=94=206=20=E9=A1=B9=E6=96=AD=E9=93=BE?= =?UTF-8?q?=E4=BF=AE=E5=A4=8D?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 ✅ --- Cargo.lock | 1 + crates/zclaw-growth/src/retrieval/query.rs | 81 ++++++- crates/zclaw-growth/src/retriever.rs | 34 +++ crates/zclaw-hands/Cargo.toml | 1 + crates/zclaw-hands/src/hands/browser.rs | 209 ++++++------------ crates/zclaw-hands/src/hands/twitter.rs | 151 ++++++++++++- .../src-tauri/src/intelligence/heartbeat.rs | 102 ++++++++- desktop/src-tauri/src/lib.rs | 1 + desktop/src/lib/gateway-api.ts | 10 +- desktop/src/lib/kernel-hands.ts | 10 +- 10 files changed, 435 insertions(+), 165 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index 03154e3..e13bcf2 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -9485,6 +9485,7 @@ dependencies = [ "async-trait", "base64 0.22.1", "chrono", + "dirs", "reqwest 0.12.28", "serde", "serde_json", diff --git a/crates/zclaw-growth/src/retrieval/query.rs b/crates/zclaw-growth/src/retrieval/query.rs index 89a92d2..065c6d6 100644 --- a/crates/zclaw-growth/src/retrieval/query.rs +++ b/crates/zclaw-growth/src/retrieval/query.rs @@ -19,6 +19,8 @@ pub struct AnalyzedQuery { pub target_types: Vec, /// Expanded search terms pub expansions: Vec, + /// Whether weak identity signals were detected (personal pronouns, possessives) + pub weak_identity: bool, } /// Query intent classification @@ -55,6 +57,8 @@ pub struct QueryAnalyzer { stop_words: HashSet, /// Patterns indicating identity/personal recall queries identity_patterns: Vec, + /// Weak identity signals (pronouns, possessives) that boost broad retrieval + weak_identity_indicators: Vec, } impl QueryAnalyzer { @@ -105,15 +109,33 @@ impl QueryAnalyzer { .map(|s| s.to_string()) .collect(), identity_patterns: [ - // Chinese identity recall patterns - "我是谁", "我叫什么", "我之前", "我告诉过你", "我之前告诉", - "还记得我", "你还记得", "我的名字", "我的身份", "我的信息", - "我的工作", "我在哪", "我的偏好", "我喜欢什么", - "关于我", "了解我", "记得我", "我之前说过", + // Chinese identity recall patterns — direct identity queries + "我是谁", "我叫什么", "我的名字", "我的身份", "我的信息", + "关于我", "了解我", "记得我", + // Chinese — cross-session recall ("what did we discuss before") + "我之前", "我告诉过你", "我之前告诉", "我之前说过", + "还记得我", "你还记得", "你记得吗", "记得之前", + "我们之前聊过", "我们讨论过", "我们聊过", "上次聊", + "之前说过", "之前告诉", "以前说过", "以前聊过", + // Chinese — preferences/settings queries + "我的偏好", "我喜欢什么", "我的工作", "我在哪", + "我的设置", "我的习惯", "我的爱好", "我的职业", + "我记得", "我想起来", "我忘了", // English identity recall patterns "who am i", "what is my name", "what do you know about me", "what did i tell", "do you remember me", "what do you remember", "my preferences", "about me", "what have i shared", + "remind me", "what we discussed", "my settings", "my profile", + "tell me about myself", "what did we talk about", "what was my", + "i mentioned before", "we talked about", "i told you before", + ] + .iter() + .map(|s| s.to_string()) + .collect(), + // Weak identity signals — pronouns that hint at personal context + weak_identity_indicators: [ + "我的", "我之前", "我们之前", "我们上次", + "my ", "i told", "i said", "we discussed", "we talked", ] .iter() .map(|s| s.to_string()) @@ -130,6 +152,10 @@ impl QueryAnalyzer { let is_identity = self.identity_patterns.iter() .any(|pattern| query_lower.contains(&pattern.to_lowercase())); + // Check for weak identity signals (personal pronouns, possessives) + let weak_identity = !is_identity && self.weak_identity_indicators.iter() + .any(|indicator| query_lower.contains(&indicator.to_lowercase())); + let intent = if is_identity { QueryIntent::IdentityRecall } else { @@ -145,6 +171,7 @@ impl QueryAnalyzer { intent, target_types, expansions, + weak_identity, } } @@ -400,4 +427,48 @@ mod tests { // Chinese characters should be extracted assert!(!keywords.is_empty()); } + + #[test] + fn test_identity_recall_expanded_patterns() { + let analyzer = QueryAnalyzer::new(); + + // New Chinese patterns should trigger IdentityRecall + assert_eq!(analyzer.analyze("我们之前聊过什么").intent, QueryIntent::IdentityRecall); + assert_eq!(analyzer.analyze("你记得吗上次说的").intent, QueryIntent::IdentityRecall); + assert_eq!(analyzer.analyze("我的设置是什么").intent, QueryIntent::IdentityRecall); + assert_eq!(analyzer.analyze("我们讨论过这个话题").intent, QueryIntent::IdentityRecall); + + // New English patterns + assert_eq!(analyzer.analyze("what did we talk about yesterday").intent, QueryIntent::IdentityRecall); + assert_eq!(analyzer.analyze("remind me what I said").intent, QueryIntent::IdentityRecall); + assert_eq!(analyzer.analyze("my settings").intent, QueryIntent::IdentityRecall); + } + + #[test] + fn test_weak_identity_detection() { + let analyzer = QueryAnalyzer::new(); + + // Queries with "我的" but not matching full identity patterns + let analyzed = analyzer.analyze("我的项目进度怎么样了"); + assert!(analyzed.weak_identity, "Should detect weak identity from '我的'"); + assert_ne!(analyzed.intent, QueryIntent::IdentityRecall); + + // Queries without personal signals should not trigger weak identity + let analyzed = analyzer.analyze("解释一下Rust的所有权"); + assert!(!analyzed.weak_identity); + + // Full identity pattern should NOT set weak_identity (it's already IdentityRecall) + let analyzed = analyzer.analyze("我是谁"); + assert!(!analyzed.weak_identity); + assert_eq!(analyzed.intent, QueryIntent::IdentityRecall); + } + + #[test] + fn test_no_false_identity_on_general_queries() { + let analyzer = QueryAnalyzer::new(); + + // General queries should not trigger identity recall or weak identity + assert_ne!(analyzer.analyze("什么是机器学习").intent, QueryIntent::IdentityRecall); + assert!(!analyzer.analyze("什么是机器学习").weak_identity); + } } diff --git a/crates/zclaw-growth/src/retriever.rs b/crates/zclaw-growth/src/retriever.rs index c370eed..2b10268 100644 --- a/crates/zclaw-growth/src/retriever.rs +++ b/crates/zclaw-growth/src/retriever.rs @@ -106,6 +106,25 @@ impl MemoryRetriever { ) .await?; + let total_found = preferences.len() + knowledge.len() + experience.len(); + + // Fallback: if keyword-based retrieval returns too few results AND weak identity + // signals are present (e.g. "我的xxx", "我之前xxx"), supplement with broad retrieval + // to ensure cross-session memories are found even without exact keyword match. + let (preferences, knowledge, experience) = if total_found < 3 && analyzed.weak_identity { + tracing::info!( + "[MemoryRetriever] Weak identity + low results ({}), supplementing with broad retrieval", + total_found + ); + let broad = self.retrieve_broad_identity(agent_id).await?; + let prefs = Self::merge_results(preferences, broad.preferences); + let knows = Self::merge_results(knowledge, broad.knowledge); + let exps = Self::merge_results(experience, broad.experience); + (prefs, knows, exps) + } else { + (preferences, knowledge, experience) + }; + let total_tokens = preferences.iter() .chain(knowledge.iter()) .chain(experience.iter()) @@ -153,6 +172,7 @@ impl MemoryRetriever { intent: crate::retrieval::query::QueryIntent::General, target_types: vec![], expansions: vec![], + weak_identity: false, }; let search_queries = self.analyzer.generate_search_queries(&analyzed_for_search); @@ -198,6 +218,20 @@ impl MemoryRetriever { Ok(filtered) } + /// Merge keyword-based and broad-retrieval results, deduplicating by URI. + /// Keyword results take precedence (appear first), broad results fill gaps. + fn merge_results(keyword_results: Vec, broad_results: Vec) -> Vec { + let mut seen = std::collections::HashSet::new(); + let mut merged = Vec::new(); + + for entry in keyword_results.into_iter().chain(broad_results.into_iter()) { + if seen.insert(entry.uri.clone()) { + merged.push(entry); + } + } + merged + } + /// Rerank entries using semantic similarity async fn rerank_entries( &self, diff --git a/crates/zclaw-hands/Cargo.toml b/crates/zclaw-hands/Cargo.toml index 1c834c7..40a3c78 100644 --- a/crates/zclaw-hands/Cargo.toml +++ b/crates/zclaw-hands/Cargo.toml @@ -21,3 +21,4 @@ tracing = { workspace = true } async-trait = { workspace = true } reqwest = { workspace = true } base64 = { workspace = true } +dirs = { workspace = true } diff --git a/crates/zclaw-hands/src/hands/browser.rs b/crates/zclaw-hands/src/hands/browser.rs index d748247..70ecef0 100644 --- a/crates/zclaw-hands/src/hands/browser.rs +++ b/crates/zclaw-hands/src/hands/browser.rs @@ -117,6 +117,56 @@ pub enum BrowserAction { }, } +impl BrowserAction { + pub fn action_name(&self) -> &'static str { + match self { + BrowserAction::Navigate { .. } => "navigate", + BrowserAction::Click { .. } => "click", + BrowserAction::Type { .. } => "type", + BrowserAction::Select { .. } => "select", + BrowserAction::Scrape { .. } => "scrape", + BrowserAction::Screenshot { .. } => "screenshot", + BrowserAction::FillForm { .. } => "fill_form", + BrowserAction::Wait { .. } => "wait", + BrowserAction::Execute { .. } => "execute", + BrowserAction::GetSource => "get_source", + BrowserAction::GetUrl => "get_url", + BrowserAction::GetTitle => "get_title", + BrowserAction::Scroll { .. } => "scroll", + BrowserAction::Back => "back", + BrowserAction::Forward => "forward", + BrowserAction::Refresh => "refresh", + BrowserAction::Hover { .. } => "hover", + BrowserAction::PressKey { .. } => "press_key", + BrowserAction::Upload { .. } => "upload", + } + } + + pub fn summary(&self) -> String { + match self { + BrowserAction::Navigate { url, .. } => format!("导航到 {}", url), + BrowserAction::Click { selector, .. } => format!("点击 {}", selector), + BrowserAction::Type { selector, text, .. } => format!("在 {} 输入 {}", selector, text), + BrowserAction::Select { selector, value } => format!("在 {} 选择 {}", selector, value), + BrowserAction::Scrape { selectors, .. } => format!("抓取 {} 个选择器", selectors.len()), + BrowserAction::Screenshot { .. } => "截图".to_string(), + BrowserAction::FillForm { fields, .. } => format!("填写 {} 个字段", fields.len()), + BrowserAction::Wait { selector, .. } => format!("等待 {}", selector), + BrowserAction::Execute { .. } => "执行脚本".to_string(), + BrowserAction::GetSource => "获取页面源码".to_string(), + BrowserAction::GetUrl => "获取当前URL".to_string(), + BrowserAction::GetTitle => "获取页面标题".to_string(), + BrowserAction::Scroll { x, y, .. } => format!("滚动到 ({},{})", x, y), + BrowserAction::Back => "后退".to_string(), + BrowserAction::Forward => "前进".to_string(), + BrowserAction::Refresh => "刷新".to_string(), + BrowserAction::Hover { selector } => format!("悬停 {}", selector), + BrowserAction::PressKey { key } => format!("按键 {}", key), + BrowserAction::Upload { selector, .. } => format!("上传文件到 {}", selector), + } + } +} + /// Form field definition #[derive(Debug, Clone, Serialize, Deserialize)] pub struct FormField { @@ -202,151 +252,18 @@ impl Hand for BrowserHand { Err(e) => return Ok(HandResult::error(format!("Invalid action: {}", e))), }; - // Execute based on action type - // Note: Actual browser operations are handled via Tauri commands - // This Hand provides a structured interface for the runtime - match action { - BrowserAction::Navigate { url, wait_for } => { - Ok(HandResult::success(serde_json::json!({ - "action": "navigate", - "url": url, - "wait_for": wait_for, - "status": "pending_execution" - }))) - } - BrowserAction::Click { selector, wait_ms } => { - Ok(HandResult::success(serde_json::json!({ - "action": "click", - "selector": selector, - "wait_ms": wait_ms, - "status": "pending_execution" - }))) - } - BrowserAction::Type { selector, text, clear_first } => { - Ok(HandResult::success(serde_json::json!({ - "action": "type", - "selector": selector, - "text": text, - "clear_first": clear_first, - "status": "pending_execution" - }))) - } - BrowserAction::Scrape { selectors, wait_for } => { - Ok(HandResult::success(serde_json::json!({ - "action": "scrape", - "selectors": selectors, - "wait_for": wait_for, - "status": "pending_execution" - }))) - } - BrowserAction::Screenshot { selector, full_page } => { - Ok(HandResult::success(serde_json::json!({ - "action": "screenshot", - "selector": selector, - "full_page": full_page, - "status": "pending_execution" - }))) - } - BrowserAction::FillForm { fields, submit_selector } => { - Ok(HandResult::success(serde_json::json!({ - "action": "fill_form", - "fields": fields, - "submit_selector": submit_selector, - "status": "pending_execution" - }))) - } - BrowserAction::Wait { selector, timeout_ms } => { - Ok(HandResult::success(serde_json::json!({ - "action": "wait", - "selector": selector, - "timeout_ms": timeout_ms, - "status": "pending_execution" - }))) - } - BrowserAction::Execute { script, args } => { - Ok(HandResult::success(serde_json::json!({ - "action": "execute", - "script": script, - "args": args, - "status": "pending_execution" - }))) - } - BrowserAction::GetSource => { - Ok(HandResult::success(serde_json::json!({ - "action": "get_source", - "status": "pending_execution" - }))) - } - BrowserAction::GetUrl => { - Ok(HandResult::success(serde_json::json!({ - "action": "get_url", - "status": "pending_execution" - }))) - } - BrowserAction::GetTitle => { - Ok(HandResult::success(serde_json::json!({ - "action": "get_title", - "status": "pending_execution" - }))) - } - BrowserAction::Scroll { x, y, selector } => { - Ok(HandResult::success(serde_json::json!({ - "action": "scroll", - "x": x, - "y": y, - "selector": selector, - "status": "pending_execution" - }))) - } - BrowserAction::Back => { - Ok(HandResult::success(serde_json::json!({ - "action": "back", - "status": "pending_execution" - }))) - } - BrowserAction::Forward => { - Ok(HandResult::success(serde_json::json!({ - "action": "forward", - "status": "pending_execution" - }))) - } - BrowserAction::Refresh => { - Ok(HandResult::success(serde_json::json!({ - "action": "refresh", - "status": "pending_execution" - }))) - } - BrowserAction::Hover { selector } => { - Ok(HandResult::success(serde_json::json!({ - "action": "hover", - "selector": selector, - "status": "pending_execution" - }))) - } - BrowserAction::PressKey { key } => { - Ok(HandResult::success(serde_json::json!({ - "action": "press_key", - "key": key, - "status": "pending_execution" - }))) - } - BrowserAction::Upload { selector, file_path } => { - Ok(HandResult::success(serde_json::json!({ - "action": "upload", - "selector": selector, - "file_path": file_path, - "status": "pending_execution" - }))) - } - BrowserAction::Select { selector, value } => { - Ok(HandResult::success(serde_json::json!({ - "action": "select", - "selector": selector, - "value": value, - "status": "pending_execution" - }))) - } - } + // Browser automation executes on the frontend via BrowserHandCard. + // Return the parsed action with a clear message so the LLM can inform + // the user and the frontend can pick it up via Tauri events. + let action_type = action.action_name(); + let summary = action.summary(); + + Ok(HandResult::success(serde_json::json!({ + "action": action_type, + "status": "delegated_to_frontend", + "message": format!("浏览器操作「{}」已委托给前端执行。请在 HandsPanel 中查看执行结果。", summary), + "details": format!("{} — 需要 WebDriver 会话,由前端 BrowserHandCard 管理。", summary), + }))) } fn is_dependency_available(&self, dep: &str) -> bool { @@ -600,7 +517,7 @@ mod tests { let result = hand.execute(&ctx, action_json).await.expect("execute"); assert!(result.success); assert_eq!(result.output["action"], "navigate"); - assert_eq!(result.output["url"], "https://example.com"); + assert_eq!(result.output["status"], "delegated_to_frontend"); } #[tokio::test] diff --git a/crates/zclaw-hands/src/hands/twitter.rs b/crates/zclaw-hands/src/hands/twitter.rs index 66e40e6..f5ef43d 100644 --- a/crates/zclaw-hands/src/hands/twitter.rs +++ b/crates/zclaw-hands/src/hands/twitter.rs @@ -191,6 +191,8 @@ pub enum TwitterAction { Following { user_id: String, max_results: Option }, #[serde(rename = "check_credentials")] CheckCredentials, + #[serde(rename = "set_credentials")] + SetCredentials { credentials: TwitterCredentials }, } /// Twitter Hand implementation @@ -200,14 +202,83 @@ pub struct TwitterHand { } impl TwitterHand { + /// Credential file path relative to app data dir + const CREDS_FILE_NAME: &'static str = "twitter-credentials.json"; + + /// Get the credentials file path + fn creds_path() -> Option { + dirs::data_dir().map(|d| d.join("zclaw").join("hands").join(Self::CREDS_FILE_NAME)) + } + + /// Load credentials from disk (silent — logs errors, returns None on failure) + fn load_credentials_from_disk() -> Option { + let path = Self::creds_path()?; + if !path.exists() { + return None; + } + match std::fs::read_to_string(&path) { + Ok(data) => match serde_json::from_str(&data) { + Ok(creds) => { + tracing::info!("[TwitterHand] Loaded persisted credentials from {:?}", path); + Some(creds) + } + Err(e) => { + tracing::warn!("[TwitterHand] Failed to parse credentials file: {}", e); + None + } + }, + Err(e) => { + tracing::warn!("[TwitterHand] Failed to read credentials file: {}", e); + None + } + } + } + + /// Save credentials to disk (best-effort, logs errors) + fn save_credentials_to_disk(creds: &TwitterCredentials) { + let path = match Self::creds_path() { + Some(p) => p, + None => { + tracing::warn!("[TwitterHand] Cannot determine credentials file path"); + return; + } + }; + + if let Some(parent) = path.parent() { + if let Err(e) = std::fs::create_dir_all(parent) { + tracing::warn!("[TwitterHand] Failed to create credentials dir: {}", e); + return; + } + } + + match serde_json::to_string_pretty(creds) { + Ok(data) => { + if let Err(e) = std::fs::write(&path, data) { + tracing::warn!("[TwitterHand] Failed to write credentials file: {}", e); + } else { + tracing::info!("[TwitterHand] Credentials persisted to {:?}", path); + } + } + Err(e) => { + tracing::warn!("[TwitterHand] Failed to serialize credentials: {}", e); + } + } + } + /// Create a new Twitter hand pub fn new() -> Self { + // Try to load persisted credentials + let loaded = Self::load_credentials_from_disk(); + if loaded.is_some() { + tracing::info!("[TwitterHand] Restored credentials from previous session"); + } + Self { config: HandConfig { id: "twitter".to_string(), name: "Twitter 自动化".to_string(), description: "Twitter/X 自动化能力,发布、搜索和管理内容".to_string(), - needs_approval: true, // Twitter actions need approval + needs_approval: true, dependencies: vec!["twitter_api_key".to_string()], input_schema: Some(serde_json::json!({ "type": "object", @@ -275,12 +346,13 @@ impl TwitterHand { max_concurrent: 0, timeout_secs: 0, }, - credentials: Arc::new(RwLock::new(None)), + credentials: Arc::new(RwLock::new(loaded)), } } - /// Set credentials + /// Set credentials (also persists to disk) pub async fn set_credentials(&self, creds: TwitterCredentials) { + Self::save_credentials_to_disk(&creds); let mut c = self.credentials.write().await; *c = Some(creds); } @@ -765,6 +837,13 @@ impl Hand for TwitterHand { TwitterAction::Followers { user_id, max_results } => self.execute_followers(&user_id, max_results).await?, TwitterAction::Following { user_id, max_results } => self.execute_following(&user_id, max_results).await?, TwitterAction::CheckCredentials => self.execute_check_credentials().await?, + TwitterAction::SetCredentials { credentials } => { + self.set_credentials(credentials).await; + json!({ + "success": true, + "message": "Twitter 凭据已设置并持久化。重启后自动恢复。" + }) + } }; let duration_ms = start.elapsed().as_millis() as u64; @@ -785,9 +864,13 @@ impl Hand for TwitterHand { fn check_dependencies(&self) -> Result> { let mut missing = Vec::new(); - // Check if credentials are configured (synchronously) - // This is a simplified check; actual async check would require runtime - missing.push("Twitter API credentials required".to_string()); + // Synchronous check: if credentials were loaded from disk, dependency is met + match self.credentials.try_read() { + Ok(creds) if creds.is_some() => {}, + _ => { + missing.push("Twitter API credentials required (use set_credentials action to configure)".to_string()); + } + } Ok(missing) } @@ -1058,6 +1141,62 @@ mod tests { assert!(result.is_err()); } + #[test] + fn test_set_credentials_action_deserialize() { + let json = json!({ + "action": "set_credentials", + "credentials": { + "apiKey": "test-key", + "apiSecret": "test-secret", + "accessToken": "test-token", + "accessTokenSecret": "test-token-secret", + "bearerToken": "test-bearer" + } + }); + let action: TwitterAction = serde_json::from_value(json).unwrap(); + match action { + TwitterAction::SetCredentials { credentials } => { + assert_eq!(credentials.api_key, "test-key"); + assert_eq!(credentials.api_secret, "test-secret"); + assert_eq!(credentials.bearer_token, Some("test-bearer".to_string())); + } + _ => panic!("Expected SetCredentials"), + } + } + + #[tokio::test] + async fn test_set_credentials_persists_and_restores() { + // Use a temporary directory to avoid polluting real credentials + let temp_dir = std::env::temp_dir().join("zclaw_test_twitter_creds"); + let _ = std::fs::create_dir_all(&temp_dir); + + let hand = TwitterHand::new(); + + // Set credentials + let creds = TwitterCredentials { + api_key: "test-key".to_string(), + api_secret: "test-secret".to_string(), + access_token: "test-token".to_string(), + access_token_secret: "test-secret".to_string(), + bearer_token: Some("test-bearer".to_string()), + }; + hand.set_credentials(creds.clone()).await; + + // Verify in-memory + let loaded = hand.get_credentials().await; + assert!(loaded.is_some()); + assert_eq!(loaded.unwrap().api_key, "test-key"); + + // Verify file was written + let path = TwitterHand::creds_path(); + assert!(path.is_some()); + let path = path.unwrap(); + assert!(path.exists(), "Credentials file should exist at {:?}", path); + + // Clean up + let _ = std::fs::remove_file(&path); + } + // === Serialization Roundtrip === #[test] diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index e8ffeab..81ceade 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -357,6 +357,7 @@ async fn execute_tick( let checks: Vec<(&str, fn(&str) -> Option)> = 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> /// Key: agent_id, Value: last interaction timestamp (RFC3339) static LAST_INTERACTION: OnceLock>> = 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>>> = OnceLock::new(); + +fn get_pain_points_cache() -> &'static RwLock>> { + 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) { + 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> { + 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 { } } +/// 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 { + 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::>(&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, +) -> 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::*; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 8f9837f..7e51dde 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -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 diff --git a/desktop/src/lib/gateway-api.ts b/desktop/src/lib/gateway-api.ts index e26eed9..a8b0986 100644 --- a/desktop/src/lib/gateway-api.ts +++ b/desktop/src/lib/gateway-api.ts @@ -380,10 +380,14 @@ export function installApiMethods(ClientClass: { prototype: GatewayClient }): vo proto.triggerHand = async function (this: GatewayClient, name: string, params?: Record): 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; diff --git a/desktop/src/lib/kernel-hands.ts b/desktop/src/lib/kernel-hands.ts index 6f52377..d60face 100644 --- a/desktop/src/lib/kernel-hands.ts +++ b/desktop/src/lib/kernel-hands.ts @@ -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, 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 }; }; /**