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:
1
Cargo.lock
generated
1
Cargo.lock
generated
@@ -9485,6 +9485,7 @@ dependencies = [
|
||||
"async-trait",
|
||||
"base64 0.22.1",
|
||||
"chrono",
|
||||
"dirs",
|
||||
"reqwest 0.12.28",
|
||||
"serde",
|
||||
"serde_json",
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -21,3 +21,4 @@ tracing = { workspace = true }
|
||||
async-trait = { workspace = true }
|
||||
reqwest = { workspace = true }
|
||||
base64 = { workspace = true }
|
||||
dirs = { workspace = true }
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -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