feat(growth,skills,saas,desktop): C线差异化全量实现 — C1日报+C2飞轮+C3引导
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
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
This commit is contained in:
@@ -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;
|
||||
|
||||
@@ -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<String, u32>,
|
||||
) -> 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(())
|
||||
}
|
||||
|
||||
@@ -93,6 +93,8 @@ pub struct SemanticSkillRouter {
|
||||
confidence_threshold: f32,
|
||||
/// LLM fallback for ambiguous queries (confidence below threshold)
|
||||
llm_fallback: Option<Arc<dyn RuntimeLlmIntent>>,
|
||||
/// Experience-based boost factors: tool_name → boost weight (0.0 - 0.15)
|
||||
experience_boosts: HashMap<String, f32>,
|
||||
}
|
||||
|
||||
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<String, u32>) {
|
||||
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");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
210
desktop/src-tauri/src/intelligence/cold_start_prompt.rs
Normal file
210
desktop/src-tauri/src/intelligence/cold_start_prompt.rs
Normal file
@@ -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));
|
||||
}
|
||||
}
|
||||
@@ -464,6 +464,27 @@ pub fn update_pain_points_cache(agent_id: &str, pain_points: Vec<String>) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Global experience cache: high-reuse experiences per agent.
|
||||
/// Key: agent_id, Value: list of (tool_used, reuse_count) tuples.
|
||||
static EXPERIENCE_CACHE: OnceLock<RwLock<StdHashMap<String, Vec<(String, u32)>>>> = OnceLock::new();
|
||||
|
||||
fn get_experience_cache() -> &'static RwLock<StdHashMap<String, Vec<(String, u32)>>> {
|
||||
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<Vec<(String, u32)>> {
|
||||
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<Vec<String>> {
|
||||
let cache = get_pain_points_cache();
|
||||
@@ -778,7 +799,8 @@ 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.
|
||||
/// proactive suggestions. Also considers high-reuse experiences to generate
|
||||
/// contextual skill suggestions.
|
||||
fn check_unresolved_pains(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
let pains = get_cached_pain_points(agent_id)?;
|
||||
if pains.is_empty() {
|
||||
@@ -790,11 +812,25 @@ fn check_unresolved_pains(agent_id: &str) -> Option<HeartbeatAlert> {
|
||||
} 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::*;
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
|
||||
215
desktop/src/lib/cold-start-mapper.ts
Normal file
215
desktop/src/lib/cold-start-mapper.ts
Normal file
@@ -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<string, string[]> = {
|
||||
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<string, string[]> = {
|
||||
healthcare: ['小医', '医管家', '康康'],
|
||||
education: ['小教', '学伴', '知了'],
|
||||
garment: ['小织', '裁缝', '布管家'],
|
||||
ecommerce: ['小商', '掌柜', '店小二'],
|
||||
};
|
||||
|
||||
const INDUSTRY_PERSONALITY: Record<string, { tone: string; formality: string; proactiveness: string }> = {
|
||||
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;
|
||||
}
|
||||
}
|
||||
@@ -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<ColdStartConfig>) => 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<ColdStartPhase, string> = {
|
||||
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<string, { label: string; prompt: string }[]> = {
|
||||
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<ColdStartPhase>(loadPersistedPhase);
|
||||
const persisted = loadPersistedState();
|
||||
const [phase, setPhase] = useState<ColdStartPhase>(persisted.phase);
|
||||
const [config, setConfig] = useState<ColdStartConfig>(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<ColdStartConfig>) => {
|
||||
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);
|
||||
|
||||
Reference in New Issue
Block a user