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

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:
iven
2026-04-21 10:18:25 +08:00
parent 2f5e9f1755
commit 9a2611d122
10 changed files with 435 additions and 165 deletions

1
Cargo.lock generated
View File

@@ -9485,6 +9485,7 @@ dependencies = [
"async-trait",
"base64 0.22.1",
"chrono",
"dirs",
"reqwest 0.12.28",
"serde",
"serde_json",

View File

@@ -19,6 +19,8 @@ pub struct AnalyzedQuery {
pub target_types: Vec<MemoryType>,
/// Expanded search terms
pub expansions: Vec<String>,
/// 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<String>,
/// Patterns indicating identity/personal recall queries
identity_patterns: Vec<String>,
/// Weak identity signals (pronouns, possessives) that boost broad retrieval
weak_identity_indicators: Vec<String>,
}
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);
}
}

View File

@@ -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<MemoryEntry>, broad_results: Vec<MemoryEntry>) -> Vec<MemoryEntry> {
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,

View File

@@ -21,3 +21,4 @@ tracing = { workspace = true }
async-trait = { workspace = true }
reqwest = { workspace = true }
base64 = { workspace = true }
dirs = { workspace = true }

View File

@@ -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,152 +252,19 @@ 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 } => {
// 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": "navigate",
"url": url,
"wait_for": wait_for,
"status": "pending_execution"
"action": action_type,
"status": "delegated_to_frontend",
"message": format!("浏览器操作「{}」已委托给前端执行。请在 HandsPanel 中查看执行结果。", summary),
"details": format!("{} — 需要 WebDriver 会话,由前端 BrowserHandCard 管理。", summary),
})))
}
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"
})))
}
}
}
fn is_dependency_available(&self, dep: &str) -> bool {
match dep {
@@ -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]

View File

@@ -191,6 +191,8 @@ pub enum TwitterAction {
Following { user_id: String, max_results: Option<u32> },
#[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<std::path::PathBuf> {
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<TwitterCredentials> {
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<Vec<String>> {
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]

View File

@@ -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::*;

View File

@@ -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

View File

@@ -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;

View File

@@ -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 };
};
/**