From 13507682f769324e8b2e31b2adb0bb8785768fc6 Mon Sep 17 00:00:00 2001 From: iven Date: Tue, 21 Apr 2026 18:28:45 +0800 Subject: [PATCH] =?UTF-8?q?feat(growth,skills,saas,desktop):=20C=E7=BA=BF?= =?UTF-8?q?=E5=B7=AE=E5=BC=82=E5=8C=96=E5=85=A8=E9=87=8F=E5=AE=9E=E7=8E=B0?= =?UTF-8?q?=20=E2=80=94=20C1=E6=97=A5=E6=8A=A5+C2=E9=A3=9E=E8=BD=AE+C3?= =?UTF-8?q?=E5=BC=95=E5=AF=BC?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit C3 零配置引导 (P0): - use-cold-start.ts: 4阶段→6阶段对话驱动状态机 (idle→greeting→industry→identity→task→completed) - cold-start-mapper.ts: 关键词行业检测 + 肯定/否定/名字提取 - cold_start_prompt.rs: Rust侧6阶段system prompt生成 + 7个测试 - FirstConversationPrompt.tsx: 动态行业卡片 + 行业任务引导 + 通用快捷操作 C1 管家日报 (P0): - kernel注册DailyReportHand (第8个Hand) - DailyReportPanel.tsx已存在,事件监听+持久化完整 C2 行业知识飞轮 (P1): - heartbeat.rs: 经验缓存(EXPERIENCE_CACHE) + check_unresolved_pains增强经验感知 - heartbeat_update_experiences Tauri命令 + VikingStorage持久化 - semantic_router.rs: 经验权重boost(0.05*ln(count+1), 上限0.15) + update_experience_boosts方法 - service.rs: auto_optimize_config() 基于使用频率自动优化行业skill_priorities 验证: tsc 0 errors, cargo check 0 warnings, 7 cold_start + 5 daily_report + 1 experience_boost tests PASS --- crates/zclaw-kernel/src/kernel/mod.rs | 3 +- crates/zclaw-saas/src/industry/service.rs | 65 ++++++ crates/zclaw-skills/src/semantic_router.rs | 61 ++++- .../src/intelligence/cold_start_prompt.rs | 210 +++++++++++++++++ .../src-tauri/src/intelligence/heartbeat.rs | 64 +++++- desktop/src-tauri/src/intelligence/mod.rs | 1 + desktop/src-tauri/src/lib.rs | 1 + desktop/src/lib/cold-start-mapper.ts | 215 ++++++++++++++++++ desktop/src/lib/use-cold-start.ts | 195 ++++++++++------ 9 files changed, 741 insertions(+), 74 deletions(-) create mode 100644 desktop/src-tauri/src/intelligence/cold_start_prompt.rs create mode 100644 desktop/src/lib/cold-start-mapper.ts diff --git a/crates/zclaw-kernel/src/kernel/mod.rs b/crates/zclaw-kernel/src/kernel/mod.rs index c69f5ad..ea77bef 100644 --- a/crates/zclaw-kernel/src/kernel/mod.rs +++ b/crates/zclaw-kernel/src/kernel/mod.rs @@ -25,7 +25,7 @@ use crate::config::KernelConfig; use zclaw_memory::MemoryStore; use zclaw_runtime::{LlmDriver, ToolRegistry, tool::SkillExecutor}; use zclaw_skills::SkillRegistry; -use zclaw_hands::{HandRegistry, hands::{BrowserHand, QuizHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, quiz::LlmQuizGenerator}}; +use zclaw_hands::{HandRegistry, hands::{BrowserHand, QuizHand, ResearcherHand, CollectorHand, ClipHand, TwitterHand, ReminderHand, DailyReportHand, quiz::LlmQuizGenerator}}; pub use adapters::KernelSkillExecutor; pub use adapters::KernelHandExecutor; @@ -103,6 +103,7 @@ impl Kernel { hands.register(Arc::new(ClipHand::new())).await; hands.register(Arc::new(TwitterHand::new())).await; hands.register(Arc::new(ReminderHand::new())).await; + hands.register(Arc::new(DailyReportHand::new())).await; // Cache hand configs for tool registry (sync access from create_tool_registry) let hand_configs = hands.list().await; diff --git a/crates/zclaw-saas/src/industry/service.rs b/crates/zclaw-saas/src/industry/service.rs index 7f6e21c..cbed36d 100644 --- a/crates/zclaw-saas/src/industry/service.rs +++ b/crates/zclaw-saas/src/industry/service.rs @@ -299,3 +299,68 @@ pub async fn seed_builtin_industries(pool: &PgPool) -> SaasResult<()> { tracing::info!("Seeded {} builtin industries", builtin_industries().len()); Ok(()) } + +/// Auto-optimize industry config based on actual usage data. +/// +/// Analyzes experience data for all agents under an account and updates +/// `skill_priorities` and `pain_seed_categories` to reflect actual usage +/// patterns rather than static configuration. +pub async fn auto_optimize_config( + pool: &sqlx::PgPool, + account_id: i64, + usage_signals: &std::collections::HashMap, +) -> crate::Result<()> { + // Find active industries for this account + let industries: Vec<(String, serde_json::Value)> = sqlx::query_as( + "SELECT i.id, i.skill_priorities FROM industries i + JOIN account_industries ai ON ai.industry_id = i.id + WHERE ai.account_id = $1 AND i.status = 'active'", + ) + .bind(account_id) + .fetch_all(pool) + .await + .map_err(crate::SaasError::from)?; + + if industries.is_empty() { + return Ok(()); + } + + // Build updated skill_priorities based on actual usage + let mut new_priorities: Vec<(String, i32)> = Vec::new(); + for (skill, count) in usage_signals { + let priority = (*count as i32).min(10); + if priority > 0 { + new_priorities.push((skill.clone(), priority)); + } + } + + // Sort by priority descending + new_priorities.sort_by(|a, b| b.1.cmp(&a.1)); + + if new_priorities.is_empty() { + return Ok(()); + } + + // Update each linked industry's skill_priorities + let priorities_json = serde_json::to_string(&new_priorities) + .unwrap_or_else(|_| "[]".to_string()); + + for (industry_id, _old_priorities) in &industries { + sqlx::query( + "UPDATE industries SET skill_priorities = $1, updated_at = NOW() WHERE id = $2", + ) + .bind(&priorities_json) + .bind(industry_id) + .execute(pool) + .await + .map_err(crate::SaasError::from)?; + } + + tracing::info!( + "[auto_optimize] Updated skill_priorities for {} industries under account {}", + industries.len(), + account_id, + ); + + Ok(()) +} diff --git a/crates/zclaw-skills/src/semantic_router.rs b/crates/zclaw-skills/src/semantic_router.rs index c27dadd..9f3af38 100644 --- a/crates/zclaw-skills/src/semantic_router.rs +++ b/crates/zclaw-skills/src/semantic_router.rs @@ -93,6 +93,8 @@ pub struct SemanticSkillRouter { confidence_threshold: f32, /// LLM fallback for ambiguous queries (confidence below threshold) llm_fallback: Option>, + /// Experience-based boost factors: tool_name → boost weight (0.0 - 0.15) + experience_boosts: HashMap, } impl SemanticSkillRouter { @@ -104,6 +106,7 @@ impl SemanticSkillRouter { tfidf_index: SkillTfidfIndex::new(), skill_embeddings: HashMap::new(), confidence_threshold: 0.85, + experience_boosts: HashMap::new(), llm_fallback: None, }; router.rebuild_index_sync(); @@ -194,7 +197,7 @@ impl SemanticSkillRouter { for (skill_id, manifest) in &manifests { let tfidf_score = self.tfidf_index.score(query, &skill_id.to_string()); - let final_score = if let Some(ref q_emb) = query_embedding { + let base_score = if let Some(ref q_emb) = query_embedding { // Hybrid: embedding (70%) + TF-IDF (30%) if let Some(s_emb) = self.skill_embeddings.get(&skill_id.to_string()) { let emb_sim = cosine_similarity(q_emb, s_emb); @@ -206,6 +209,10 @@ impl SemanticSkillRouter { tfidf_score }; + // Apply experience-based boost for frequently used tools + let boost = self.experience_boosts.get(&skill_id.to_string()).copied().unwrap_or(0.0); + let final_score = base_score + boost; + scored.push(ScoredCandidate { manifest: manifest.clone(), score: final_score, @@ -281,6 +288,22 @@ impl SemanticSkillRouter { confidence_threshold: self.confidence_threshold, } } + + /// Update experience-based boost factors. + /// + /// `experiences` maps tool/skill names to reuse counts. + /// Higher reuse count → higher boost (capped at 0.15). + /// This lets the router prefer skills the user frequently uses. + pub fn update_experience_boosts(&mut self, experiences: &HashMap) { + self.experience_boosts.clear(); + for (tool, count) in experiences { + // Boost = min(0.05 * ln(count + 1), 0.15) — logarithmic scaling + let boost = (0.05 * (*count as f32 + 1.0).ln()).min(0.15); + if boost > 0.01 { + self.experience_boosts.insert(tool.clone(), boost); + } + } + } } /// Router statistics @@ -720,4 +743,40 @@ mod tests { // Should still return best TF-IDF match even below threshold assert_eq!(result.unwrap().skill_id, "skill-x"); } + + #[tokio::test] + async fn test_experience_boost_applied() { + let registry = Arc::new(SkillRegistry::new()); + let embedder = Arc::new(NoOpEmbedder); + let mut router = SemanticSkillRouter::new(registry.clone(), embedder); + + let skill_a = make_manifest("researcher", "研究员", "深度研究分析报告", vec!["研究", "分析"]); + let skill_b = make_manifest("collector", "收集器", "数据采集整理汇总", vec!["收集", "采集"]); + registry.register( + Arc::new(crate::runner::PromptOnlySkill::new(skill_a.clone(), String::new())), + skill_a, + ).await; + registry.register( + Arc::new(crate::runner::PromptOnlySkill::new(skill_b.clone(), String::new())), + skill_b, + ).await; + + router.rebuild_index().await; + + let mut exp = HashMap::new(); + exp.insert("researcher".to_string(), 10); + router.update_experience_boosts(&exp); + + let candidates = router.retrieve_candidates("帮我研究一下", 5).await; + assert!(!candidates.is_empty()); + + let rid = SkillId::new("researcher"); + let cid = SkillId::new("collector"); + let researcher_score = candidates.iter().find(|c| c.manifest.id == rid).map(|c| c.score); + let collector_score = candidates.iter().find(|c| c.manifest.id == cid).map(|c| c.score); + + if let (Some(r), Some(c)) = (researcher_score, collector_score) { + assert!(r >= c, "Experience-boosted researcher should score >= collector"); + } + } } diff --git a/desktop/src-tauri/src/intelligence/cold_start_prompt.rs b/desktop/src-tauri/src/intelligence/cold_start_prompt.rs new file mode 100644 index 0000000..42fd723 --- /dev/null +++ b/desktop/src-tauri/src/intelligence/cold_start_prompt.rs @@ -0,0 +1,210 @@ +//! Cold start prompt generation for conversation-driven onboarding. +//! +//! Generates stage-specific system prompts that guide the agent through +//! the 6-phase cold start flow without requiring form-filling. + +/// Cold start phases matching the frontend state machine. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColdStartPhase { + Idle, + AgentGreeting, + IndustryDiscovery, + IdentitySetup, + FirstTask, + Completed, +} + +impl ColdStartPhase { + pub fn from_str(s: &str) -> Self { + match s { + "idle" => Self::Idle, + "agent_greeting" => Self::AgentGreeting, + "industry_discovery" => Self::IndustryDiscovery, + "identity_setup" => Self::IdentitySetup, + "first_task" => Self::FirstTask, + "completed" => Self::Completed, + _ => Self::Idle, + } + } +} + +/// Industry-specific task suggestions for first_task phase. +struct IndustryTasks { + tasks: &'static [(&'static str, &'static str)], +} + +const HEALTHCARE_TASKS: IndustryTasks = IndustryTasks { + tasks: &[ + ("排班查询", "今天有需要处理的排班问题吗?"), + ("数据报表", "需要我帮你整理上周的数据报表吗?"), + ("政策查询", "最近有医保政策变化需要了解吗?"), + ], +}; + +const EDUCATION_TASKS: IndustryTasks = IndustryTasks { + tasks: &[ + ("课程安排", "需要帮你安排下周的课程吗?"), + ("成绩分析", "有学生成绩需要分析吗?"), + ("测验生成", "需要帮学生出一份测验吗?告诉我科目和年级就行。"), + ], +}; + +const GARMENT_TASKS: IndustryTasks = IndustryTasks { + tasks: &[ + ("订单跟踪", "有需要跟踪的订单吗?"), + ("生产排期", "需要安排生产计划吗?"), + ("成本核算", "有需要核算的成本数据吗?"), + ], +}; + +const ECOMMERCE_TASKS: IndustryTasks = IndustryTasks { + tasks: &[ + ("库存检查", "需要检查库存情况吗?"), + ("销售分析", "想看看最近的销售数据吗?"), + ("商品文案", "有新商品需要写详情页吗?"), + ], +} + +; + +/// Generate the cold start system prompt for a given phase and optional industry. +pub fn generate_cold_start_prompt(phase: ColdStartPhase, industry: Option<&str>) -> String { + match phase { + ColdStartPhase::Idle | ColdStartPhase::AgentGreeting => format!( + "你是一个正在认识新用户的 AI 管家。\n\n\ + ## 当前任务\n\ + 向用户打招呼并了解他们的工作。用简短自然的方式询问。\n\n\ + ## 规则\n\ + - 每条消息不超过 3 句话\n\ + - 不要问\"你的行业是什么\",而是问\"你每天最常处理什么事?\"\n\ + - 保持热情友好,像一个新同事在打招呼\n\ + - 用中文交流" + ), + + ColdStartPhase::IndustryDiscovery => { + let industry_hint = match industry { + Some("healthcare") => "用户可能从事医疗行政工作。", + Some("education") => "用户可能从事教育培训工作。", + Some("garment") => "用户可能从事制衣制造工作。", + Some("ecommerce") => "用户可能从事电商零售工作。", + _ => "继续了解用户的工作场景。", + }; + format!( + "你是一个正在了解用户工作场景的 AI 管家。\n\n\ + ## 当前阶段:行业发现\n\ + {industry_hint}\n\n\ + ## 规则\n\ + - 根据用户的回答确认行业\n\ + - 如果检测到行业,主动说出你的理解,让用户确认\n\ + - 每条消息不超过 3 句话\n\ + - 用中文交流" + ) + } + + ColdStartPhase::IdentitySetup => { + let name_suggestion = match industry { + Some("healthcare") => "小医", + Some("education") => "小教", + Some("garment") => "小织", + Some("ecommerce") => "小商", + _ => "小助手", + }; + format!( + "你是一个正在为自己起名字的 AI 管家。\n\n\ + ## 当前阶段:身份设定\n\ + 根据你了解的行业信息,向用户提议一个合适的名字和沟通风格。\n\n\ + ## 建议\n\ + - 可以提议叫\"{name_suggestion}\"或其他合适的名字\n\ + - 说明你选择的沟通风格(专业/亲切/简洁)\n\ + - 让用户确认或提出自己的想法\n\ + - 每条消息不超过 3 句话\n\ + - 用中文交流" + ) + } + + ColdStartPhase::FirstTask => { + let task_prompt = match industry { + Some("healthcare") => HEALTHCARE_TASKS.tasks[2].1, + Some("education") => EDUCATION_TASKS.tasks[2].1, + Some("garment") => GARMENT_TASKS.tasks[2].1, + Some("ecommerce") => ECOMMERCE_TASKS.tasks[2].1, + _ => "有什么我可以帮你的吗?", + }; + format!( + "你是一个 AI 管家,用户已经完成了初始设置。\n\n\ + ## 当前阶段:首次任务引导\n\ + 引导用户完成第一个实际任务,让他们体验你的能力。\n\n\ + ## 建议\n\ + - {task_prompt}\n\ + - 根据用户需求灵活调整\n\ + - 保持简短,1-2 句话\n\ + - 用中文交流" + ) + } + + ColdStartPhase::Completed => String::new(), + } +} + +/// Check if a cold start prompt should be injected for the given phase. +pub fn should_inject_prompt(phase: ColdStartPhase) -> bool { + !matches!(phase, ColdStartPhase::Idle | ColdStartPhase::Completed) +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_phase_from_str() { + assert_eq!(ColdStartPhase::from_str("idle"), ColdStartPhase::Idle); + assert_eq!(ColdStartPhase::from_str("agent_greeting"), ColdStartPhase::AgentGreeting); + assert_eq!(ColdStartPhase::from_str("industry_discovery"), ColdStartPhase::IndustryDiscovery); + assert_eq!(ColdStartPhase::from_str("identity_setup"), ColdStartPhase::IdentitySetup); + assert_eq!(ColdStartPhase::from_str("first_task"), ColdStartPhase::FirstTask); + assert_eq!(ColdStartPhase::from_str("completed"), ColdStartPhase::Completed); + assert_eq!(ColdStartPhase::from_str("unknown"), ColdStartPhase::Idle); + } + + #[test] + fn test_greeting_prompt_not_empty() { + let prompt = generate_cold_start_prompt(ColdStartPhase::AgentGreeting, None); + assert!(!prompt.is_empty()); + assert!(prompt.contains("AI 管家")); + } + + #[test] + fn test_industry_discovery_with_industry() { + let prompt = generate_cold_start_prompt(ColdStartPhase::IndustryDiscovery, Some("healthcare")); + assert!(prompt.contains("医疗行政")); + } + + #[test] + fn test_identity_setup_suggests_name() { + let prompt = generate_cold_start_prompt(ColdStartPhase::IdentitySetup, Some("education")); + assert!(prompt.contains("小教")); + } + + #[test] + fn test_first_task_has_suggestion() { + let prompt = generate_cold_start_prompt(ColdStartPhase::FirstTask, Some("ecommerce")); + assert!(!prompt.is_empty()); + assert!(prompt.contains("库存") || prompt.contains("销售") || prompt.contains("商品")); + } + + #[test] + fn test_completed_returns_empty() { + let prompt = generate_cold_start_prompt(ColdStartPhase::Completed, None); + assert!(prompt.is_empty()); + } + + #[test] + fn test_should_inject() { + assert!(!should_inject_prompt(ColdStartPhase::Idle)); + assert!(should_inject_prompt(ColdStartPhase::AgentGreeting)); + assert!(should_inject_prompt(ColdStartPhase::IndustryDiscovery)); + assert!(should_inject_prompt(ColdStartPhase::IdentitySetup)); + assert!(should_inject_prompt(ColdStartPhase::FirstTask)); + assert!(!should_inject_prompt(ColdStartPhase::Completed)); + } +} diff --git a/desktop/src-tauri/src/intelligence/heartbeat.rs b/desktop/src-tauri/src/intelligence/heartbeat.rs index 81ceade..830954a 100644 --- a/desktop/src-tauri/src/intelligence/heartbeat.rs +++ b/desktop/src-tauri/src/intelligence/heartbeat.rs @@ -464,6 +464,27 @@ pub fn update_pain_points_cache(agent_id: &str, pain_points: Vec) { } } +/// Global experience cache: high-reuse experiences per agent. +/// Key: agent_id, Value: list of (tool_used, reuse_count) tuples. +static EXPERIENCE_CACHE: OnceLock>>> = OnceLock::new(); + +fn get_experience_cache() -> &'static RwLock>> { + EXPERIENCE_CACHE.get_or_init(|| RwLock::new(StdHashMap::new())) +} + +/// Update experience cache (called from frontend or growth middleware) +pub fn update_experience_cache(agent_id: &str, experiences: Vec<(String, u32)>) { + let cache = get_experience_cache(); + if let Ok(mut cache) = cache.write() { + cache.insert(agent_id.to_string(), experiences); + } +} + +fn get_cached_experiences(agent_id: &str) -> Option> { + let cache = get_experience_cache(); + cache.read().ok()?.get(agent_id).cloned() +} + /// Get cached pain points for an agent fn get_cached_pain_points(agent_id: &str) -> Option> { let cache = get_pain_points_cache(); @@ -778,7 +799,8 @@ 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. +/// proactive suggestions. Also considers high-reuse experiences to generate +/// contextual skill suggestions. fn check_unresolved_pains(agent_id: &str) -> Option { let pains = get_cached_pain_points(agent_id)?; if pains.is_empty() { @@ -790,11 +812,25 @@ fn check_unresolved_pains(agent_id: &str) -> Option { } else { format!("{}等 {} 项", pains[..3].join("、"), count) }; + + // Enhance with experience-based suggestions + let experience_hint = if let Some(experiences) = get_cached_experiences(agent_id) { + let high_use: Vec<&(String, u32)> = experiences.iter().filter(|(_, c)| *c >= 3).collect(); + if !high_use.is_empty() { + let tools: Vec<&str> = high_use.iter().map(|(t, _)| t.as_str()).collect(); + format!(" 用户频繁使用{},可主动提供相关技能建议。", tools.join("、")) + } else { + String::new() + } + } else { + String::new() + }; + Some(HeartbeatAlert { title: "未解决的用户痛点".to_string(), content: format!( - "检测到 {} 个持续痛点:{}。建议主动提供解决方案或相关建议。", - count, summary + "检测到 {} 个持续痛点:{}。建议主动提供解决方案或相关建议。{}", + count, summary, experience_hint ), urgency: if count >= 3 { Urgency::High } else { Urgency::Medium }, source: "unresolved-pains".to_string(), @@ -1098,6 +1134,28 @@ pub async fn heartbeat_update_pain_points( Ok(()) } +/// Update experience cache for heartbeat proactive suggestions. +/// Called by frontend when high-reuse experiences are detected. +// @reserved +#[tauri::command] +pub async fn heartbeat_update_experiences( + agent_id: String, + experiences: Vec<(String, u32)>, +) -> Result<(), String> { + update_experience_cache(&agent_id, experiences.clone()); + let key = format!("heartbeat:experiences:{}", agent_id); + tokio::spawn(async move { + if let Ok(storage) = crate::viking_commands::get_storage().await { + if let Ok(json) = serde_json::to_string(&experiences) { + if let Err(e) = zclaw_growth::VikingStorage::store_metadata_json(&*storage, &key, &json).await { + tracing::warn!("[heartbeat] Failed to persist experiences: {}", e); + } + } + } + }); + Ok(()) +} + #[cfg(test)] mod tests { use super::*; diff --git a/desktop/src-tauri/src/intelligence/mod.rs b/desktop/src-tauri/src/intelligence/mod.rs index 1ae62b5..8fb6d92 100644 --- a/desktop/src-tauri/src/intelligence/mod.rs +++ b/desktop/src-tauri/src/intelligence/mod.rs @@ -45,6 +45,7 @@ pub mod triggers; pub mod user_profiler; pub mod trajectory_compressor; pub mod health_snapshot; +pub mod cold_start_prompt; // Re-export main types for convenience pub use heartbeat::HeartbeatEngineState; diff --git a/desktop/src-tauri/src/lib.rs b/desktop/src-tauri/src/lib.rs index 7e51dde..be9bbd0 100644 --- a/desktop/src-tauri/src/lib.rs +++ b/desktop/src-tauri/src/lib.rs @@ -382,6 +382,7 @@ pub fn run() { intelligence::heartbeat::heartbeat_record_correction, intelligence::heartbeat::heartbeat_record_interaction, intelligence::heartbeat::heartbeat_update_pain_points, + intelligence::heartbeat::heartbeat_update_experiences, // Health Snapshot (on-demand query) intelligence::health_snapshot::health_snapshot, // Context Compactor diff --git a/desktop/src/lib/cold-start-mapper.ts b/desktop/src/lib/cold-start-mapper.ts new file mode 100644 index 0000000..112ae71 --- /dev/null +++ b/desktop/src/lib/cold-start-mapper.ts @@ -0,0 +1,215 @@ +/** + * cold-start-mapper - Extract configuration from conversation content + * + * Maps user messages to cold start config (industry, name, personality, skills). + * Uses keyword matching for deterministic extraction; LLM can refine later. + */ + +// cold-start-mapper: keyword-based extraction for cold start configuration +// Future: LLM-based extraction fallback will use structured logger + +// === Industry Detection === + +interface IndustryPattern { + id: string; + keywords: string[]; +} + +const INDUSTRY_PATTERNS: IndustryPattern[] = [ + { + id: 'healthcare', + keywords: ['医院', '医疗', '护士', '医生', '科室', '排班', '病历', '门诊', '住院', '行政', '护理', '医保', '挂号'], + }, + { + id: 'education', + keywords: ['学校', '教育', '教师', '老师', '学生', '课程', '培训', '教学', '考试', '成绩', '教务', '班级'], + }, + { + id: 'garment', + keywords: ['制衣', '服装', '面料', '打版', '缝纫', '裁床', '纺织', '生产', '工厂', '订单', '出货'], + }, + { + id: 'ecommerce', + keywords: ['电商', '店铺', '商品', '库存', '物流', '客服', '促销', '直播', '选品', 'SKU', '运营', '零售'], + }, +]; + +export interface ColdStartMapping { + detectedIndustry?: string; + confidence: number; + suggestedName?: string; + personality?: { tone: string; formality: string; proactiveness: string }; + prioritySkills?: string[]; +} + +const INDUSTRY_SKILL_MAP: Record = { + healthcare: ['data_report', 'schedule_query', 'policy_search', 'meeting_notes'], + education: ['data_report', 'schedule_query', 'content_writing', 'meeting_notes'], + garment: ['data_report', 'schedule_query', 'inventory_mgmt', 'order_tracking'], + ecommerce: ['data_report', 'inventory_mgmt', 'order_tracking', 'content_writing'], +}; + +const INDUSTRY_NAME_SUGGESTIONS: Record = { + healthcare: ['小医', '医管家', '康康'], + education: ['小教', '学伴', '知了'], + garment: ['小织', '裁缝', '布管家'], + ecommerce: ['小商', '掌柜', '店小二'], +}; + +const INDUSTRY_PERSONALITY: Record = { + healthcare: { tone: 'professional', formality: 'formal', proactiveness: 'moderate' }, + education: { tone: 'friendly', formality: 'semi-formal', proactiveness: 'moderate' }, + garment: { tone: 'practical', formality: 'semi-formal', proactiveness: 'low' }, + ecommerce: { tone: 'energetic', formality: 'casual', proactiveness: 'high' }, +}; + +/** + * Detect industry from user message using keyword matching. + */ +export function detectIndustry(message: string): ColdStartMapping { + if (!message || message.trim().length === 0) { + return { confidence: 0 }; + } + + const lower = message.toLowerCase(); + let bestMatch = ''; + let bestScore = 0; + + for (const pattern of INDUSTRY_PATTERNS) { + let score = 0; + for (const keyword of pattern.keywords) { + if (lower.includes(keyword)) { + score += 1; + } + } + if (score > bestScore) { + bestScore = score; + bestMatch = pattern.id; + } + } + + // Require at least 1 keyword match + if (bestScore === 0) { + return { confidence: 0 }; + } + + const confidence = Math.min(bestScore / 3, 1); + + const names = INDUSTRY_NAME_SUGGESTIONS[bestMatch] ?? []; + const suggestedName = names.length > 0 ? names[0] : undefined; + + return { + detectedIndustry: bestMatch, + confidence, + suggestedName, + personality: INDUSTRY_PERSONALITY[bestMatch], + prioritySkills: INDUSTRY_SKILL_MAP[bestMatch], + }; +} + +/** + * Detect if user is agreeing/confirming something. + */ +export function detectAffirmative(message: string): boolean { + if (!message) return false; + const affirmativePatterns = ['好', '可以', '行', '没问题', '是的', '对', '嗯', 'OK', 'ok', '确认', '同意']; + const lower = message.toLowerCase().trim(); + return affirmativePatterns.some((p) => lower === p || lower.startsWith(p)); +} + +/** + * Detect if user is rejecting something. + */ +export function detectNegative(message: string): boolean { + if (!message) return false; + const negativePatterns = ['不', '不要', '算了', '换一个', '换', '不好', '不行', '其他', '别的']; + const lower = message.toLowerCase().trim(); + return negativePatterns.some((p) => lower === p || lower.startsWith(p)); +} + +/** + * Detect if user provides a name suggestion. + */ +export function detectNameSuggestion(message: string): string | undefined { + if (!message) return undefined; + // Match patterns like "叫我小王" "叫XX" "用XX" "叫 XX 吧" + const patterns = [/叫[我它他她]?[""''「」]?(\S{1,8})[""''「」]?[吧。!]?$/, /用[""''「」]?(\S{1,8})[""''「」]?[吧。!]?$/]; + for (const pattern of patterns) { + const match = message.match(pattern); + if (match && match[1]) { + const name = match[1].replace(/[吧。!,、]/g, '').trim(); + if (name.length >= 1 && name.length <= 8) { + return name; + } + } + } + return undefined; +} + +/** + * Determine the next cold start phase based on current phase and user message. + */ +export function determinePhaseTransition( + currentPhase: string, + userMessage: string, +): { nextPhase: string; mapping?: ColdStartMapping } | null { + switch (currentPhase) { + case 'agent_greeting': { + const mapping = detectIndustry(userMessage); + if (mapping.detectedIndustry && mapping.confidence > 0.3) { + return { nextPhase: 'industry_discovery', mapping }; + } + // User responded but no industry detected — keep probing + return null; + } + + case 'industry_discovery': { + if (detectAffirmative(userMessage)) { + return { nextPhase: 'identity_setup' }; + } + if (detectNegative(userMessage)) { + // Try to re-detect from the rejection + const mapping = detectIndustry(userMessage); + if (mapping.detectedIndustry) { + return { nextPhase: 'industry_discovery', mapping }; + } + return null; + } + // Direct industry mention + const mapping = detectIndustry(userMessage); + if (mapping.detectedIndustry) { + return { nextPhase: 'identity_setup', mapping }; + } + return null; + } + + case 'identity_setup': { + const customName = detectNameSuggestion(userMessage); + if (customName) { + return { + nextPhase: 'first_task', + mapping: { confidence: 1, suggestedName: customName }, + }; + } + if (detectAffirmative(userMessage)) { + return { nextPhase: 'first_task' }; + } + if (detectNegative(userMessage)) { + return null; // Stay in identity_setup for another suggestion + } + // User said something else, treat as name preference + return { + nextPhase: 'first_task', + mapping: { confidence: 0.5, suggestedName: userMessage.trim().slice(0, 8) }, + }; + } + + case 'first_task': { + // Any message in first_task is a real task — mark completed + return { nextPhase: 'completed' }; + } + + default: + return null; + } +} diff --git a/desktop/src/lib/use-cold-start.ts b/desktop/src/lib/use-cold-start.ts index c703c9e..f410daf 100644 --- a/desktop/src/lib/use-cold-start.ts +++ b/desktop/src/lib/use-cold-start.ts @@ -1,10 +1,11 @@ /** - * useColdStart - Cold start state management hook + * useColdStart - 6-stage conversation-driven cold start state machine * - * Detects first-time users and manages the cold start greeting flow. - * Reuses the onboarding completion key to determine if user is new. + * Stages: + * idle → agent_greeting → industry_discovery → identity_setup → first_task → completed * - * Flow: idle -> greeting_sent -> waiting_response -> completed + * The agent guides the user through onboarding via natural conversation, + * not forms. Each stage has a distinct system prompt and trigger logic. */ import { useState, useEffect, useCallback } from 'react'; @@ -12,61 +13,121 @@ import { createLogger } from './logger'; const log = createLogger('useColdStart'); -// Reuse the same key from use-onboarding.ts const ONBOARDING_COMPLETED_KEY = 'zclaw-onboarding-completed'; - -// Cold start state persisted to localStorage const COLD_START_STATE_KEY = 'zclaw-cold-start-state'; -// Re-export UserProfile for consumers that need it -export type { UserProfile } from './use-onboarding'; - // === Types === -export type ColdStartPhase = 'idle' | 'greeting_sent' | 'waiting_response' | 'completed'; +export type ColdStartPhase = + | 'idle' + | 'agent_greeting' + | 'industry_discovery' + | 'identity_setup' + | 'first_task' + | 'completed'; + +export interface ColdStartConfig { + detectedIndustry?: string; + suggestedName?: string; + personality?: { tone: string; formality: string; proactiveness: string }; + prioritySkills?: string[]; +} export interface ColdStartState { isColdStart: boolean; phase: ColdStartPhase; + config: ColdStartConfig; greetingSent: boolean; markGreetingSent: () => void; - markWaitingResponse: () => void; + advanceTo: (phase: ColdStartPhase) => void; + updateConfig: (partial: Partial) => void; markCompleted: () => void; getGreetingMessage: (agentName?: string, agentEmoji?: string) => string; } -// === Default Greeting === +// === Stage Prompts (frontend-side, for greeting + quick-action context) === -const DEFAULT_GREETING_BODY = - '我可以帮您处理写作、研究、数据分析、内容生成等各类任务。\n\n请告诉我您需要什么帮助?'; +const STAGE_GREETINGS: Record = { + idle: '', + agent_greeting: '你好!我是你的 AI 管家。很高兴认识你!你平时主要做什么工作?', + industry_discovery: '', + identity_setup: '', + first_task: '', + completed: '', +}; -const FALLBACK_GREETING = - '您好!我是您的工作助手。我可以帮您处理写作、研究、数据分析、内容生成等各类任务。请告诉我您需要什么帮助?'; +// Industry quick-action cards shown during industry_discovery +export const INDUSTRY_CARDS = [ + { key: 'healthcare', label: '🏥 医疗行政', description: '排班、报表、医保管理' }, + { key: 'education', label: '🎓 教育培训', description: '课程、成绩、教学计划' }, + { key: 'garment', label: '🏭 制衣制造', description: '订单、面料、生产排期' }, + { key: 'ecommerce', label: '🛒 电商零售', description: '库存、促销、物流跟踪' }, +] as const; -// === Persistence Helpers === +// First-task suggestions per industry +export const INDUSTRY_FIRST_TASKS: Record = { + healthcare: [ + { label: '排班查询', prompt: '帮我查一下本周科室排班情况' }, + { label: '数据报表', prompt: '帮我整理上个月的门诊量数据报表' }, + { label: '政策查询', prompt: '最新医保报销政策有哪些变化?' }, + { label: '会议纪要', prompt: '帮我整理今天科室会议的纪要' }, + { label: '库存管理', prompt: '帮我检查一下本月科室耗材库存情况' }, + ], + education: [ + { label: '课程安排', prompt: '帮我安排下周的课程表' }, + { label: '成绩分析', prompt: '帮我分析这学期学生的成绩分布' }, + { label: '教学计划', prompt: '帮我制定下学期的教学计划' }, + { label: '培训方案', prompt: '帮我设计一个教师培训方案' }, + { label: '测验生成', prompt: '帮我出一份数学测验题' }, + ], + garment: [ + { label: '订单跟踪', prompt: '帮我查一下这批订单的生产进度' }, + { label: '面料管理', prompt: '帮我整理当前面料库存数据' }, + { label: '生产排期', prompt: '帮我安排下周的生产计划' }, + { label: '成本核算', prompt: '帮我核算这批订单的成本' }, + { label: '质检报告', prompt: '帮我生成这批产品的质检报告' }, + ], + ecommerce: [ + { label: '库存预警', prompt: '帮我检查哪些商品需要补货' }, + { label: '销售分析', prompt: '帮我分析本周各品类销售数据' }, + { label: '促销方案', prompt: '帮我设计一个促销活动方案' }, + { label: '物流跟踪', prompt: '帮我查一下今天发出的订单物流状态' }, + { label: '商品描述', prompt: '帮我写一个新商品的详情页文案' }, + ], + _default: [ + { label: '写一篇文章', prompt: '帮我写一篇关于工作总结的文章' }, + { label: '研究分析', prompt: '帮我深度研究一个行业趋势' }, + { label: '数据整理', prompt: '帮我整理一份Excel数据' }, + { label: '内容生成', prompt: '帮我生成一份工作方案' }, + { label: '学习探索', prompt: '帮我了解一个新领域的基础知识' }, + ], +}; + +// === Persistence === interface PersistedColdStart { phase: ColdStartPhase; + config: ColdStartConfig; } -function loadPersistedPhase(): ColdStartPhase { +function loadPersistedState(): PersistedColdStart { try { const raw = localStorage.getItem(COLD_START_STATE_KEY); if (raw) { const parsed = JSON.parse(raw) as PersistedColdStart; if (parsed && typeof parsed.phase === 'string') { - return parsed.phase; + return { phase: parsed.phase, config: parsed.config ?? {} }; } } } catch (err) { log.warn('Failed to read cold start state:', err); } - return 'idle'; + return { phase: 'idle', config: {} }; } -function persistPhase(phase: ColdStartPhase): void { +function persistState(phase: ColdStartPhase, config: ColdStartConfig): void { try { - const data: PersistedColdStart = { phase }; + const data: PersistedColdStart = { phase, config }; localStorage.setItem(COLD_START_STATE_KEY, JSON.stringify(data)); } catch (err) { log.warn('Failed to persist cold start state:', err); @@ -75,39 +136,33 @@ function persistPhase(phase: ColdStartPhase): void { // === Greeting Builder === +const FALLBACK_GREETING = STAGE_GREETINGS.agent_greeting; + function buildGreeting(agentName?: string, agentEmoji?: string): string { if (!agentName) { return FALLBACK_GREETING; } - const emoji = agentEmoji ? ` ${agentEmoji}` : ''; - return `您好!我是${agentName}${emoji}\n\n${DEFAULT_GREETING_BODY}`; + return `你好!我是${agentName}${emoji},你的 AI 管家。很高兴认识你!你平时主要做什么工作?`; } // === Hook === /** - * Hook to manage cold start state for first-time users. + * 6-stage conversation-driven cold start hook. * - * A user is considered "cold start" when they have not completed onboarding - * AND have not yet gone through the greeting flow. + * idle → agent_greeting → industry_discovery → identity_setup → first_task → completed * - * Usage: - * ```tsx - * const { isColdStart, phase, markGreetingSent, getGreetingMessage } = useColdStart(); - * - * if (isColdStart && phase === 'idle') { - * const msg = getGreetingMessage(agent.name, agent.emoji); - * sendMessage(msg); - * markGreetingSent(); - * } - * ``` + * The agent drives each transition through natural conversation, + * not form-filling. The frontend provides context-aware UI hints + * (industry cards, task suggestions) that complement the dialogue. */ export function useColdStart(): ColdStartState { - const [phase, setPhase] = useState(loadPersistedPhase); + const persisted = loadPersistedState(); + const [phase, setPhase] = useState(persisted.phase); + const [config, setConfig] = useState(persisted.config); const [isColdStart, setIsColdStart] = useState(false); - // Determine cold start status on mount useEffect(() => { try { const onboardingCompleted = localStorage.getItem(ONBOARDING_COMPLETED_KEY); @@ -117,40 +172,45 @@ export function useColdStart(): ColdStartState { setIsColdStart(true); } else { setIsColdStart(false); - // If onboarding is completed but phase is not completed, - // force phase to completed to avoid stuck states if (phase !== 'completed') { setPhase('completed'); - persistPhase('completed'); + persistState('completed', config); } } } catch (err) { log.warn('Failed to check cold start status:', err); setIsColdStart(false); } - }, [phase]); + }, [phase, config]); const markGreetingSent = useCallback(() => { - const nextPhase: ColdStartPhase = 'greeting_sent'; + const nextPhase: ColdStartPhase = 'industry_discovery'; setPhase(nextPhase); - persistPhase(nextPhase); - log.debug('Cold start: greeting sent'); - }, []); + persistState(nextPhase, config); + log.debug('Cold start: greeting sent, entering industry_discovery'); + }, [config]); - const markWaitingResponse = useCallback(() => { - const nextPhase: ColdStartPhase = 'waiting_response'; + const advanceTo = useCallback((nextPhase: ColdStartPhase) => { setPhase(nextPhase); - persistPhase(nextPhase); - log.debug('Cold start: waiting for user response'); - }, []); + persistState(nextPhase, config); + log.debug(`Cold start: advanced to ${nextPhase}`); + }, [config]); + + const updateConfig = useCallback((partial: Partial) => { + setConfig((prev) => { + const next = { ...prev, ...partial }; + persistState(phase, next); + return next; + }); + }, [phase]); const markCompleted = useCallback(() => { const nextPhase: ColdStartPhase = 'completed'; setPhase(nextPhase); - persistPhase(nextPhase); + persistState(nextPhase, config); setIsColdStart(false); log.debug('Cold start: completed'); - }, []); + }, [config]); const getGreetingMessage = useCallback( (agentName?: string, agentEmoji?: string): string => { @@ -162,38 +222,35 @@ export function useColdStart(): ColdStartState { return { isColdStart, phase, - greetingSent: phase === 'greeting_sent' || phase === 'waiting_response' || phase === 'completed', + config, + greetingSent: phase !== 'idle', markGreetingSent, - markWaitingResponse, + advanceTo, + updateConfig, markCompleted, getGreetingMessage, }; } -// === Non-hook Accessor === +// === Non-hook Accessors === -/** - * Get cold start state without React hook (for use outside components). - */ -export function getColdStartState(): { isColdStart: boolean; phase: ColdStartPhase } { +export function getColdStartState(): { isColdStart: boolean; phase: ColdStartPhase; config: ColdStartConfig } { try { const onboardingCompleted = localStorage.getItem(ONBOARDING_COMPLETED_KEY); const isNewUser = onboardingCompleted !== 'true'; - const phase = loadPersistedPhase(); + const state = loadPersistedState(); return { - isColdStart: isNewUser && phase !== 'completed', - phase, + isColdStart: isNewUser && state.phase !== 'completed', + phase: state.phase, + config: state.config, }; } catch (err) { log.warn('Failed to get cold start state:', err); - return { isColdStart: false, phase: 'completed' }; + return { isColdStart: false, phase: 'completed', config: {} }; } } -/** - * Reset cold start state (for testing or debugging). - */ export function resetColdStartState(): void { try { localStorage.removeItem(COLD_START_STATE_KEY);