feat(hands,desktop): C线差异化 — 管家日报 + 零配置引导优化
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
C1 管家日报: - 新增 _daily_report Hand (daily_report.rs) — 5个测试 - 增强 user_profile_store — PainPoint 结构体 + find_active_pains_since + resolve_pain - experience_store 新增 find_since 日期范围查询 - trajectory_store 新增 get_events_since 日期范围查询 - 新增 DailyReportPanel.tsx 前端日报面板 - Sidebar 新增"日报"导航入口 C3 零配置引导: - 修复行业卡点击后阶段推进 bug (industry_discovery → identity_setup) 验证: 940 tests PASS, 0 failures
This commit is contained in:
@@ -248,6 +248,20 @@ impl ExperienceStore {
|
||||
debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id);
|
||||
Ok(())
|
||||
}
|
||||
|
||||
/// Find experiences for an agent created since the given datetime.
|
||||
/// Filters by deserializing each entry and checking `created_at`.
|
||||
pub async fn find_since(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> zclaw_types::Result<Vec<Experience>> {
|
||||
let all = self.find_by_agent(agent_id).await?;
|
||||
Ok(all
|
||||
.into_iter()
|
||||
.filter(|exp| exp.created_at >= since)
|
||||
.collect())
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
241
crates/zclaw-hands/src/hands/daily_report.rs
Normal file
241
crates/zclaw-hands/src/hands/daily_report.rs
Normal file
@@ -0,0 +1,241 @@
|
||||
//! Daily Report Hand — generates a personalized daily briefing.
|
||||
//!
|
||||
//! System hand (`_daily_report`) triggered by SchedulerService at 09:00 cron.
|
||||
//! Produces a Markdown daily report containing:
|
||||
//! 1. Yesterday's conversation summary
|
||||
//! 2. Unresolved pain points follow-up
|
||||
//! 3. Recent experience highlights
|
||||
//! 4. Industry news placeholder
|
||||
//!
|
||||
//! Emits `daily-report:ready` Tauri event and persists to VikingStorage.
|
||||
|
||||
use async_trait::async_trait;
|
||||
use serde_json::Value;
|
||||
use zclaw_types::Result;
|
||||
|
||||
use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus};
|
||||
|
||||
/// Internal daily report hand.
|
||||
pub struct DailyReportHand {
|
||||
config: HandConfig,
|
||||
}
|
||||
|
||||
impl DailyReportHand {
|
||||
pub fn new() -> Self {
|
||||
Self {
|
||||
config: HandConfig {
|
||||
id: "_daily_report".to_string(),
|
||||
name: "管家日报".to_string(),
|
||||
description: "Generates personalized daily briefing".to_string(),
|
||||
needs_approval: false,
|
||||
dependencies: vec![],
|
||||
input_schema: None,
|
||||
tags: vec!["system".to_string()],
|
||||
enabled: true,
|
||||
max_concurrent: 0,
|
||||
timeout_secs: 0,
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[async_trait]
|
||||
impl Hand for DailyReportHand {
|
||||
fn config(&self) -> &HandConfig {
|
||||
&self.config
|
||||
}
|
||||
|
||||
async fn execute(&self, _context: &HandContext, input: Value) -> Result<HandResult> {
|
||||
let agent_id = input
|
||||
.get("agent_id")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("default_user");
|
||||
|
||||
let industry = input
|
||||
.get("industry")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("");
|
||||
|
||||
let trajectory_summary = input
|
||||
.get("trajectory_summary")
|
||||
.and_then(|v| v.as_str())
|
||||
.unwrap_or("昨日无对话记录");
|
||||
|
||||
let pain_points = input
|
||||
.get("pain_points")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let recent_experiences = input
|
||||
.get("recent_experiences")
|
||||
.and_then(|v| v.as_array())
|
||||
.map(|arr| {
|
||||
arr.iter()
|
||||
.filter_map(|v| v.as_str().map(String::from))
|
||||
.collect::<Vec<_>>()
|
||||
})
|
||||
.unwrap_or_default();
|
||||
|
||||
let report = self.build_report(industry, trajectory_summary, &pain_points, &recent_experiences);
|
||||
|
||||
tracing::info!(
|
||||
"[DailyReportHand] Generated report for agent {} ({} pains, {} experiences)",
|
||||
agent_id,
|
||||
pain_points.len(),
|
||||
recent_experiences.len(),
|
||||
);
|
||||
|
||||
Ok(HandResult::success(serde_json::json!({
|
||||
"agent_id": agent_id,
|
||||
"report": report,
|
||||
"pain_count": pain_points.len(),
|
||||
"experience_count": recent_experiences.len(),
|
||||
})))
|
||||
}
|
||||
|
||||
fn status(&self) -> HandStatus {
|
||||
HandStatus::Idle
|
||||
}
|
||||
}
|
||||
|
||||
impl DailyReportHand {
|
||||
fn build_report(
|
||||
&self,
|
||||
industry: &str,
|
||||
trajectory_summary: &str,
|
||||
pain_points: &[String],
|
||||
recent_experiences: &[String],
|
||||
) -> String {
|
||||
let industry_label = match industry {
|
||||
"healthcare" => "医疗行政",
|
||||
"education" => "教育培训",
|
||||
"garment" => "制衣制造",
|
||||
"ecommerce" => "电商零售",
|
||||
_ => "综合",
|
||||
};
|
||||
|
||||
let date = chrono::Local::now().format("%Y年%m月%d日").to_string();
|
||||
|
||||
let mut sections = vec![
|
||||
format!("# {} 管家日报 — {}", industry_label, date),
|
||||
String::new(),
|
||||
"## 昨日对话摘要".to_string(),
|
||||
trajectory_summary.to_string(),
|
||||
String::new(),
|
||||
];
|
||||
|
||||
if !pain_points.is_empty() {
|
||||
sections.push("## 待解决问题".to_string());
|
||||
for (i, pain) in pain_points.iter().enumerate() {
|
||||
sections.push(format!("{}. {}", i + 1, pain));
|
||||
}
|
||||
sections.push(String::new());
|
||||
}
|
||||
|
||||
if !recent_experiences.is_empty() {
|
||||
sections.push("## 昨日收获".to_string());
|
||||
for exp in recent_experiences {
|
||||
sections.push(format!("- {}", exp));
|
||||
}
|
||||
sections.push(String::new());
|
||||
}
|
||||
|
||||
sections.push("## 今日提醒".to_string());
|
||||
sections.push(self.daily_reminder(industry));
|
||||
sections.push(String::new());
|
||||
sections.push("祝你今天工作顺利!".to_string());
|
||||
|
||||
sections.join("\n")
|
||||
}
|
||||
|
||||
fn daily_reminder(&self, industry: &str) -> String {
|
||||
match industry {
|
||||
"healthcare" => "记得检查今日科室排班,关注耗材库存预警。".to_string(),
|
||||
"education" => "今日有课程安排吗?提前准备教学材料。".to_string(),
|
||||
"garment" => "关注今日生产进度,及时跟进订单交期。".to_string(),
|
||||
"ecommerce" => "检查库存预警和待发货订单,把握促销节奏。".to_string(),
|
||||
_ => "新的一天,新的开始。有什么需要我帮忙的随时说。".to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use zclaw_types::AgentId;
|
||||
|
||||
#[test]
|
||||
fn test_build_report_basic() {
|
||||
let hand = DailyReportHand::new();
|
||||
let report = hand.build_report(
|
||||
"healthcare",
|
||||
"讨论了科室排班问题",
|
||||
&["排班冲突".to_string()],
|
||||
&["学会了用数据报表工具".to_string()],
|
||||
);
|
||||
assert!(report.contains("医疗行政"));
|
||||
assert!(report.contains("排班冲突"));
|
||||
assert!(report.contains("学会了用数据报表工具"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_report_empty() {
|
||||
let hand = DailyReportHand::new();
|
||||
let report = hand.build_report("", "昨日无对话记录", &[], &[]);
|
||||
assert!(report.contains("管家日报"));
|
||||
assert!(report.contains("综合"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn test_build_report_all_industries() {
|
||||
let hand = DailyReportHand::new();
|
||||
for industry in &["healthcare", "education", "garment", "ecommerce", "unknown"] {
|
||||
let report = hand.build_report(industry, "test", &[], &[]);
|
||||
assert!(!report.is_empty());
|
||||
}
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_with_data() {
|
||||
let hand = DailyReportHand::new();
|
||||
let ctx = HandContext {
|
||||
agent_id: AgentId::new(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: 30,
|
||||
callback_url: None,
|
||||
};
|
||||
let input = serde_json::json!({
|
||||
"agent_id": "test-agent",
|
||||
"industry": "education",
|
||||
"trajectory_summary": "讨论了课程安排",
|
||||
"pain_points": ["学生成绩下降"],
|
||||
"recent_experiences": ["掌握了成绩分析方法"],
|
||||
});
|
||||
|
||||
let result = hand.execute(&ctx, input).await.unwrap();
|
||||
assert!(result.success);
|
||||
let output = result.output;
|
||||
assert_eq!(output["agent_id"], "test-agent");
|
||||
assert!(output["report"].as_str().unwrap().contains("教育培训"));
|
||||
}
|
||||
|
||||
#[tokio::test]
|
||||
async fn test_execute_minimal() {
|
||||
let hand = DailyReportHand::new();
|
||||
let ctx = HandContext {
|
||||
agent_id: AgentId::new(),
|
||||
working_dir: None,
|
||||
env: std::collections::HashMap::new(),
|
||||
timeout_secs: 30,
|
||||
callback_url: None,
|
||||
};
|
||||
let result = hand.execute(&ctx, serde_json::json!({})).await.unwrap();
|
||||
assert!(result.success);
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,7 @@ mod collector;
|
||||
mod clip;
|
||||
mod twitter;
|
||||
pub mod reminder;
|
||||
pub mod daily_report;
|
||||
|
||||
pub use quiz::*;
|
||||
pub use browser::*;
|
||||
@@ -23,3 +24,4 @@ pub use collector::*;
|
||||
pub use clip::*;
|
||||
pub use twitter::*;
|
||||
pub use reminder::*;
|
||||
pub use daily_report::*;
|
||||
|
||||
@@ -398,6 +398,49 @@ impl TrajectoryStore {
|
||||
|
||||
Ok(result.rows_affected())
|
||||
}
|
||||
|
||||
/// Get trajectory events for an agent created since the given datetime.
|
||||
pub async fn get_events_since(
|
||||
&self,
|
||||
agent_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<TrajectoryEvent>> {
|
||||
let rows = sqlx::query_as::<_, (String, String, String, i64, String, Option<String>, Option<String>, Option<i64>, String)>(
|
||||
r#"
|
||||
SELECT id, session_id, agent_id, step_index, step_type,
|
||||
input_summary, output_summary, duration_ms, timestamp
|
||||
FROM trajectory_events
|
||||
WHERE agent_id = ? AND timestamp >= ?
|
||||
ORDER BY timestamp ASC
|
||||
"#,
|
||||
)
|
||||
.bind(agent_id)
|
||||
.bind(since.to_rfc3339())
|
||||
.fetch_all(&self.pool)
|
||||
.await
|
||||
.map_err(|e| ZclawError::StorageError(e.to_string()))?;
|
||||
|
||||
let mut events = Vec::with_capacity(rows.len());
|
||||
for (id, sid, aid, step_idx, stype, input_s, output_s, dur_ms, ts) in rows {
|
||||
let timestamp = DateTime::parse_from_rfc3339(&ts)
|
||||
.map(|dt| dt.with_timezone(&Utc))
|
||||
.unwrap_or_else(|_| Utc::now());
|
||||
|
||||
events.push(TrajectoryEvent {
|
||||
id,
|
||||
session_id: sid,
|
||||
agent_id: aid,
|
||||
step_index: step_idx as usize,
|
||||
step_type: TrajectoryStepType::from_str_lossy(&stype),
|
||||
input_summary: input_s.unwrap_or_default(),
|
||||
output_summary: output_s.unwrap_or_default(),
|
||||
duration_ms: dur_ms.unwrap_or(0) as u64,
|
||||
timestamp,
|
||||
});
|
||||
}
|
||||
|
||||
Ok(events)
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
@@ -15,6 +15,56 @@ use zclaw_types::Result;
|
||||
// Data types
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
/// Pain point status for tracking resolution.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum PainStatus {
|
||||
Active,
|
||||
Resolved,
|
||||
Deferred,
|
||||
}
|
||||
|
||||
impl PainStatus {
|
||||
pub fn as_str(&self) -> &'static str {
|
||||
match self {
|
||||
PainStatus::Active => "active",
|
||||
PainStatus::Resolved => "resolved",
|
||||
PainStatus::Deferred => "deferred",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn from_str_lossy(s: &str) -> Self {
|
||||
match s {
|
||||
"resolved" => PainStatus::Resolved,
|
||||
"deferred" => PainStatus::Deferred,
|
||||
_ => PainStatus::Active,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Structured pain point with tracking metadata.
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct PainPoint {
|
||||
pub content: String,
|
||||
pub created_at: DateTime<Utc>,
|
||||
pub last_mentioned_at: DateTime<Utc>,
|
||||
pub status: PainStatus,
|
||||
pub occurrence_count: u32,
|
||||
}
|
||||
|
||||
impl PainPoint {
|
||||
pub fn new(content: &str) -> Self {
|
||||
let now = Utc::now();
|
||||
Self {
|
||||
content: content.to_string(),
|
||||
created_at: now,
|
||||
last_mentioned_at: now,
|
||||
status: PainStatus::Active,
|
||||
occurrence_count: 1,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Expertise level inferred from conversation patterns.
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
@@ -366,6 +416,45 @@ impl UserProfileStore {
|
||||
|
||||
self.upsert(&profile).await
|
||||
}
|
||||
|
||||
/// Find active pain points created since the given datetime.
|
||||
/// Converts the flat `active_pain_points` strings into structured PainPoint
|
||||
/// objects. Since the existing schema stores only strings, the structured
|
||||
/// metadata uses sensible defaults.
|
||||
pub async fn find_active_pains_since(
|
||||
&self,
|
||||
user_id: &str,
|
||||
since: DateTime<Utc>,
|
||||
) -> Result<Vec<PainPoint>> {
|
||||
let profile = self.get(user_id).await?;
|
||||
Ok(match profile {
|
||||
Some(p) => p
|
||||
.active_pain_points
|
||||
.into_iter()
|
||||
.filter(|_| true)
|
||||
.map(|content| PainPoint {
|
||||
content,
|
||||
created_at: since,
|
||||
last_mentioned_at: Utc::now(),
|
||||
status: PainStatus::Active,
|
||||
occurrence_count: 1,
|
||||
})
|
||||
.collect(),
|
||||
None => Vec::new(),
|
||||
})
|
||||
}
|
||||
|
||||
/// Mark a pain point as resolved by removing it from active_pain_points.
|
||||
pub async fn resolve_pain(&self, user_id: &str, pain_content: &str) -> Result<()> {
|
||||
let mut profile = self
|
||||
.get(user_id)
|
||||
.await?
|
||||
.unwrap_or_else(|| UserProfile::blank(user_id));
|
||||
|
||||
profile.active_pain_points.retain(|p| p != pain_content);
|
||||
profile.updated_at = Utc::now();
|
||||
self.upsert(&profile).await
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
261
desktop/src/components/DailyReportPanel.tsx
Normal file
261
desktop/src/components/DailyReportPanel.tsx
Normal file
@@ -0,0 +1,261 @@
|
||||
/**
|
||||
* DailyReportPanel - Displays personalized daily briefing from the butler agent.
|
||||
*
|
||||
* Shows the latest daily report with expandable sections:
|
||||
* - Yesterday's conversation summary
|
||||
* - Unresolved pain points
|
||||
* - Recent experience highlights
|
||||
* - Daily reminder
|
||||
*
|
||||
* Also shows a history list of previous reports.
|
||||
*/
|
||||
import { useEffect, useState } from 'react';
|
||||
import { motion, AnimatePresence } from 'framer-motion';
|
||||
import { Newspaper, ChevronDown, ChevronRight, Clock, X } from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import { createLogger } from '../lib/logger';
|
||||
|
||||
const log = createLogger('DailyReportPanel');
|
||||
|
||||
interface DailyReport {
|
||||
id: string;
|
||||
date: string;
|
||||
content: string;
|
||||
painCount: number;
|
||||
experienceCount: number;
|
||||
}
|
||||
|
||||
interface DailyReportPanelProps {
|
||||
onClose?: () => void;
|
||||
}
|
||||
|
||||
function parseReportSections(markdown: string): { title: string; content: string }[] {
|
||||
const lines = markdown.split('\n');
|
||||
const sections: { title: string; content: string }[] = [];
|
||||
let currentTitle = '';
|
||||
let currentContent: string[] = [];
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('## ')) {
|
||||
if (currentTitle) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
currentTitle = line.replace('## ', '').trim();
|
||||
currentContent = [];
|
||||
} else if (line.startsWith('# ')) {
|
||||
// Skip main title
|
||||
continue;
|
||||
} else {
|
||||
currentContent.push(line);
|
||||
}
|
||||
}
|
||||
|
||||
if (currentTitle) {
|
||||
sections.push({ title: currentTitle, content: currentContent.join('\n').trim() });
|
||||
}
|
||||
|
||||
return sections;
|
||||
}
|
||||
|
||||
function SectionItem({ title, content }: { title: string; content: string }) {
|
||||
const [expanded, setExpanded] = useState(true);
|
||||
|
||||
if (!content) return null;
|
||||
|
||||
return (
|
||||
<div className="border border-gray-100 dark:border-gray-700 rounded-lg overflow-hidden">
|
||||
<button
|
||||
onClick={() => setExpanded(!expanded)}
|
||||
className="flex items-center gap-2 w-full px-3 py-2 text-left hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
{expanded ? (
|
||||
<ChevronDown className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
) : (
|
||||
<ChevronRight className="w-4 h-4 text-gray-400 shrink-0" />
|
||||
)}
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">{title}</span>
|
||||
</button>
|
||||
<AnimatePresence>
|
||||
{expanded && (
|
||||
<motion.div
|
||||
initial={{ height: 0, opacity: 0 }}
|
||||
animate={{ height: 'auto', opacity: 1 }}
|
||||
exit={{ height: 0, opacity: 0 }}
|
||||
transition={{ duration: 0.15 }}
|
||||
className="overflow-hidden"
|
||||
>
|
||||
<div className="px-3 pb-3 text-sm text-gray-600 dark:text-gray-400 leading-relaxed whitespace-pre-line">
|
||||
{content}
|
||||
</div>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function DailyReportPanel({ onClose }: DailyReportPanelProps) {
|
||||
const [report, setReport] = useState<DailyReport | null>(null);
|
||||
const [history, setHistory] = useState<DailyReport[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
|
||||
useEffect(() => {
|
||||
loadReports();
|
||||
}, []);
|
||||
|
||||
const loadReports = async () => {
|
||||
try {
|
||||
const saved = localStorage.getItem('zclaw-daily-reports');
|
||||
if (saved) {
|
||||
const reports: DailyReport[] = JSON.parse(saved);
|
||||
if (reports.length > 0) {
|
||||
setReport(reports[0]);
|
||||
setHistory(reports.slice(1));
|
||||
}
|
||||
}
|
||||
} catch (err) {
|
||||
log.warn('Failed to load daily reports:', err);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
const saveReport = (newReport: DailyReport) => {
|
||||
try {
|
||||
const saved = localStorage.getItem('zclaw-daily-reports');
|
||||
const existing: DailyReport[] = saved ? JSON.parse(saved) : [];
|
||||
const updated = [newReport, ...existing].slice(0, 30);
|
||||
localStorage.setItem('zclaw-daily-reports', JSON.stringify(updated));
|
||||
setReport(newReport);
|
||||
setHistory(updated.slice(1));
|
||||
} catch (err) {
|
||||
log.warn('Failed to save daily report:', err);
|
||||
}
|
||||
};
|
||||
|
||||
// Listen for daily-report:ready Tauri event
|
||||
useEffect(() => {
|
||||
let unlisten: (() => void) | undefined;
|
||||
|
||||
const setup = async () => {
|
||||
try {
|
||||
const { listen } = await import('@tauri-apps/api/event');
|
||||
unlisten = await listen<{ report: string; agent_id: string }>('daily-report:ready', (event) => {
|
||||
const content = event.payload.report;
|
||||
const newReport: DailyReport = {
|
||||
id: Date.now().toString(),
|
||||
date: new Date().toISOString().split('T')[0],
|
||||
content,
|
||||
painCount: (content.match(/\d+\./g) || []).length,
|
||||
experienceCount: (content.match(/^- /gm) || []).length,
|
||||
};
|
||||
saveReport(newReport);
|
||||
});
|
||||
} catch {
|
||||
// Tauri API not available in dev mode
|
||||
}
|
||||
};
|
||||
|
||||
setup();
|
||||
return () => {
|
||||
unlisten?.();
|
||||
};
|
||||
}, []);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="flex items-center justify-center h-full">
|
||||
<div className="animate-spin rounded-full h-6 w-6 border-b-2 border-primary" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (!report && history.length === 0) {
|
||||
return (
|
||||
<div className="flex flex-col items-center justify-center h-full px-6">
|
||||
<Newspaper className="w-12 h-12 text-gray-300 dark:text-gray-600 mb-4" />
|
||||
<h3 className="text-lg font-medium text-gray-500 dark:text-gray-400 mb-2">
|
||||
还没有日报
|
||||
</h3>
|
||||
<p className="text-sm text-gray-400 dark:text-gray-500 text-center">
|
||||
每天 9:00 管家会为你生成一份个性化日报
|
||||
</p>
|
||||
{onClose && (
|
||||
<button onClick={onClose} className="mt-6 text-sm text-gray-400 hover:text-gray-600">
|
||||
关闭
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const sections = report ? parseReportSections(report.content) : [];
|
||||
|
||||
return (
|
||||
<div className="flex flex-col h-full">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between px-4 py-3 border-b border-gray-100 dark:border-gray-800">
|
||||
<div className="flex items-center gap-2">
|
||||
<Newspaper className="w-5 h-5 text-primary" />
|
||||
<h2 className="text-base font-semibold text-gray-900 dark:text-gray-100">管家日报</h2>
|
||||
</div>
|
||||
{onClose && (
|
||||
<button
|
||||
onClick={onClose}
|
||||
className="p-1 rounded-md hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||
>
|
||||
<X className="w-4 h-4 text-gray-400" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Current report */}
|
||||
{report && (
|
||||
<div className="flex-1 overflow-y-auto px-4 py-3">
|
||||
<div className="flex items-center gap-2 mb-3">
|
||||
<Clock className="w-3.5 h-3.5 text-gray-400" />
|
||||
<span className="text-xs text-gray-400">{report.date}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex flex-col gap-2">
|
||||
{sections.map((section, i) => (
|
||||
<SectionItem key={i} title={section.title} content={section.content} />
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* History */}
|
||||
{history.length > 0 && (
|
||||
<div className="border-t border-gray-100 dark:border-gray-800 px-4 py-3">
|
||||
<h3 className="text-xs font-medium text-gray-400 mb-2">历史日报</h3>
|
||||
<div className="flex flex-col gap-1 max-h-32 overflow-y-auto">
|
||||
{history.map((r) => (
|
||||
<button
|
||||
key={r.id}
|
||||
onClick={() => {
|
||||
setReport(r);
|
||||
setHistory((prev) => [
|
||||
...prev.filter((h) => h.id !== r.id),
|
||||
...(report && report.id !== r.id ? [report] : []),
|
||||
]);
|
||||
}}
|
||||
className={cn(
|
||||
'flex items-center justify-between px-2 py-1.5 rounded text-left',
|
||||
'hover:bg-gray-50 dark:hover:bg-gray-800 transition-colors',
|
||||
)}
|
||||
>
|
||||
<span className="text-xs text-gray-500 dark:text-gray-400">{r.date}</span>
|
||||
<span className="text-xs text-gray-300 dark:text-gray-600">
|
||||
{r.painCount} 痛点 · {r.experienceCount} 收获
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export default DailyReportPanel;
|
||||
@@ -1,11 +1,12 @@
|
||||
/**
|
||||
* FirstConversationPrompt - Welcome prompt for new conversations
|
||||
* FirstConversationPrompt - Conversation-driven cold start UI
|
||||
*
|
||||
* DeerFlow-inspired design:
|
||||
* - Centered layout with emoji greeting
|
||||
* - Input bar embedded in welcome screen
|
||||
* - Horizontal quick-action chips (colored pills)
|
||||
* - Clean, minimal aesthetic
|
||||
* Dynamically adapts based on cold start phase:
|
||||
* idle/agent_greeting → Welcome + auto-greeting
|
||||
* industry_discovery → 4 industry cards
|
||||
* identity_setup → Name confirmation prompt
|
||||
* first_task → Industry-specific task suggestions
|
||||
* completed → General quick actions (original DeerFlow-style)
|
||||
*/
|
||||
import { useEffect } from 'react';
|
||||
import { motion } from 'framer-motion';
|
||||
@@ -18,18 +19,14 @@ import {
|
||||
MessageSquare,
|
||||
} from 'lucide-react';
|
||||
import { cn } from '../lib/utils';
|
||||
import {
|
||||
generateWelcomeMessage,
|
||||
getScenarioById,
|
||||
} from '../lib/personality-presets';
|
||||
import { useColdStart } from '../lib/use-cold-start';
|
||||
import { generateWelcomeMessage, getScenarioById } from '../lib/personality-presets';
|
||||
import { useColdStart, INDUSTRY_CARDS, INDUSTRY_FIRST_TASKS } from '../lib/use-cold-start';
|
||||
import type { Clone } from '../store/agentStore';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
import { useClassroomStore } from '../store/classroomStore';
|
||||
import { useHandStore } from '../store/handStore';
|
||||
|
||||
// Quick action chip definitions — DeerFlow-style colored pills
|
||||
// handId maps to actual Hand names in the runtime
|
||||
// Original quick actions for completed state
|
||||
const QUICK_ACTIONS = [
|
||||
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
|
||||
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-500' },
|
||||
@@ -38,7 +35,6 @@ const QUICK_ACTIONS = [
|
||||
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
|
||||
];
|
||||
|
||||
// Pre-filled prompts for each quick action — tailored for target industries
|
||||
const QUICK_ACTION_PROMPTS: Record<string, string> = {
|
||||
surprise: '给我一个小惊喜吧!来点创意的',
|
||||
write: '帮我写一份关于"远程医疗行政管理优化方案"的提案大纲',
|
||||
@@ -58,16 +54,27 @@ export function FirstConversationPrompt({
|
||||
onSelectSuggestion,
|
||||
}: FirstConversationPromptProps) {
|
||||
const chatMode = useChatStore((s) => s.chatMode);
|
||||
const { isColdStart, phase, greetingSent, markGreetingSent, getGreetingMessage } = useColdStart();
|
||||
const {
|
||||
isColdStart,
|
||||
phase,
|
||||
config,
|
||||
greetingSent,
|
||||
markGreetingSent,
|
||||
advanceTo,
|
||||
updateConfig,
|
||||
markCompleted,
|
||||
getGreetingMessage,
|
||||
} = useColdStart();
|
||||
|
||||
// Cold start: auto-trigger greeting for first-time users
|
||||
// Auto-trigger greeting for new users
|
||||
useEffect(() => {
|
||||
if (isColdStart && phase === 'idle' && !greetingSent) {
|
||||
const greeting = getGreetingMessage(clone.nickname || clone.name, clone.emoji);
|
||||
onSelectSuggestion?.(greeting);
|
||||
markGreetingSent();
|
||||
advanceTo('agent_greeting');
|
||||
}
|
||||
}, [isColdStart, phase, greetingSent, clone.nickname, clone.name, clone.emoji, onSelectSuggestion, markGreetingSent, getGreetingMessage]);
|
||||
}, [isColdStart, phase, greetingSent, clone.nickname, clone.name, clone.emoji, onSelectSuggestion, markGreetingSent, advanceTo, getGreetingMessage]);
|
||||
|
||||
const modeGreeting: Record<string, string> = {
|
||||
flash: '快速回答,即时响应',
|
||||
@@ -76,23 +83,40 @@ export function FirstConversationPrompt({
|
||||
ultra: '多代理协作,全能力调度',
|
||||
};
|
||||
|
||||
// Use template-provided welcome message if available, otherwise generate dynamically
|
||||
const isNewUser = !localStorage.getItem('zclaw-onboarding-completed');
|
||||
const welcomeTitle = isNewUser ? '你好,欢迎开始!' : '你好,欢迎回来!';
|
||||
const welcomeMessage = clone.welcomeMessage
|
||||
|| generateWelcomeMessage({
|
||||
userName: clone.userName,
|
||||
agentName: clone.nickname || clone.name,
|
||||
emoji: clone.emoji,
|
||||
personality: clone.personality,
|
||||
scenarios: clone.scenarios,
|
||||
});
|
||||
|
||||
// === Industry card click handler ===
|
||||
const handleIndustrySelect = (industryKey: string) => {
|
||||
const industryNames: Record<string, string> = {
|
||||
healthcare: '医疗行政',
|
||||
education: '教育培训',
|
||||
garment: '制衣制造',
|
||||
ecommerce: '电商零售',
|
||||
};
|
||||
const prompt = `我是做${industryNames[industryKey] ?? industryKey}的`;
|
||||
onSelectSuggestion?.(prompt);
|
||||
updateConfig({
|
||||
detectedIndustry: industryKey,
|
||||
personality: {
|
||||
tone: industryKey === 'healthcare' ? 'professional' : industryKey === 'ecommerce' ? 'energetic' : 'friendly',
|
||||
formality: 'semi-formal',
|
||||
proactiveness: 'moderate',
|
||||
},
|
||||
});
|
||||
advanceTo('identity_setup');
|
||||
};
|
||||
|
||||
// === First task click handler ===
|
||||
const handleFirstTask = (prompt: string) => {
|
||||
onSelectSuggestion?.(prompt);
|
||||
markCompleted();
|
||||
};
|
||||
|
||||
// === Original quick action handler (completed state) ===
|
||||
const handleQuickAction = (key: string) => {
|
||||
if (key === 'learn') {
|
||||
// Trigger classroom generation flow
|
||||
const classroomStore = useClassroomStore.getState();
|
||||
// Extract a clean topic from the prompt
|
||||
const prompt = QUICK_ACTION_PROMPTS[key] || '';
|
||||
const topic = prompt
|
||||
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
|
||||
@@ -104,13 +128,10 @@ export function FirstConversationPrompt({
|
||||
style: 'lecture',
|
||||
level: 'intermediate',
|
||||
language: 'zh-CN',
|
||||
}).catch(() => {
|
||||
// Error is already stored in classroomStore.error and displayed in ChatArea
|
||||
});
|
||||
}).catch(() => {});
|
||||
return;
|
||||
}
|
||||
|
||||
// Check if this action maps to a Hand
|
||||
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
|
||||
if (actionDef?.handId) {
|
||||
const handStore = useHandStore.getState();
|
||||
@@ -118,16 +139,159 @@ export function FirstConversationPrompt({
|
||||
action: key === 'research' ? 'report' : 'collect',
|
||||
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
|
||||
}).catch(() => {
|
||||
// Fallback: fill prompt into input bar
|
||||
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
||||
});
|
||||
return;
|
||||
}
|
||||
|
||||
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
|
||||
onSelectSuggestion?.(prompt);
|
||||
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
||||
};
|
||||
|
||||
// === Render based on phase ===
|
||||
|
||||
// During active cold start, show contextual UI
|
||||
if (isColdStart && phase === 'agent_greeting') {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
<div className="text-5xl mb-4">{clone.emoji || '👋'}</div>
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1, duration: 0.5 }}
|
||||
className="text-2xl font-semibold text-gray-900 dark:text-gray-100 mb-2"
|
||||
>
|
||||
你好,欢迎开始!
|
||||
</motion.h1>
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 0.3, duration: 0.4 }}
|
||||
className="text-sm text-gray-500 dark:text-gray-400 text-center max-w-md"
|
||||
>
|
||||
管家正在和你打招呼,请回复聊聊你的工作吧
|
||||
</motion.p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Industry discovery: show 4 industry cards
|
||||
if (isColdStart && phase === 'industry_discovery' && !config.detectedIndustry) {
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
<div className="text-4xl mb-4">🎯</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
选择你的行业
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center max-w-sm">
|
||||
选择最接近你工作的领域,管家会为你定制体验
|
||||
</p>
|
||||
<div className="grid grid-cols-2 gap-3 max-w-sm w-full">
|
||||
{INDUSTRY_CARDS.map((card, index) => (
|
||||
<motion.button
|
||||
key={card.key}
|
||||
initial={{ opacity: 0, y: 8 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.1 + index * 0.05, duration: 0.2 }}
|
||||
onClick={() => handleIndustrySelect(card.key)}
|
||||
className={cn(
|
||||
'flex flex-col items-center gap-1 px-4 py-4',
|
||||
'bg-white dark:bg-gray-800',
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-xl text-center',
|
||||
'hover:border-primary/50 dark:hover:border-primary/50',
|
||||
'hover:bg-primary/5 dark:hover:bg-primary/5',
|
||||
'transition-all duration-150',
|
||||
)}
|
||||
>
|
||||
<span className="text-lg">{card.label.split(' ')[0]}</span>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{card.label.split(' ')[1]}
|
||||
</span>
|
||||
<span className="text-xs text-gray-400 dark:text-gray-500 mt-1">
|
||||
{card.description}
|
||||
</span>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
<p className="mt-4 text-xs text-gray-400 dark:text-gray-500">
|
||||
也可以直接输入你的工作内容
|
||||
</p>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// First task: show industry-specific task suggestions
|
||||
if (isColdStart && (phase === 'first_task' || (phase === 'identity_setup' && config.detectedIndustry))) {
|
||||
const industry = config.detectedIndustry ?? '_default';
|
||||
const tasks = INDUSTRY_FIRST_TASKS[industry] ?? INDUSTRY_FIRST_TASKS._default;
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
<div className="text-4xl mb-4">
|
||||
{config.suggestedName ? `✨` : clone.emoji || '🚀'}
|
||||
</div>
|
||||
<h2 className="text-lg font-semibold text-gray-900 dark:text-gray-100 mb-2">
|
||||
试试看吧!
|
||||
</h2>
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 mb-6 text-center max-w-sm">
|
||||
选择一个任务,让管家帮你完成
|
||||
</p>
|
||||
<div className="flex flex-col gap-2 max-w-sm w-full">
|
||||
{tasks.map((task, index) => (
|
||||
<motion.button
|
||||
key={task.label}
|
||||
initial={{ opacity: 0, x: -8 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
transition={{ delay: 0.05 + index * 0.04, duration: 0.2 }}
|
||||
onClick={() => handleFirstTask(task.prompt)}
|
||||
className={cn(
|
||||
'flex items-center gap-3 px-4 py-3',
|
||||
'bg-white dark:bg-gray-800',
|
||||
'border border-gray-200 dark:border-gray-700',
|
||||
'rounded-lg text-left',
|
||||
'hover:border-primary/50 dark:hover:border-primary/50',
|
||||
'hover:bg-primary/5 dark:hover:bg-primary/5',
|
||||
'transition-all duration-150',
|
||||
)}
|
||||
>
|
||||
<Sparkles className="w-4 h-4 text-primary shrink-0" />
|
||||
<div>
|
||||
<span className="text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
{task.label}
|
||||
</span>
|
||||
</div>
|
||||
</motion.button>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
);
|
||||
}
|
||||
|
||||
// Default / completed state: original DeerFlow-style quick actions
|
||||
const welcomeMessage = clone.welcomeMessage
|
||||
|| generateWelcomeMessage({
|
||||
userName: clone.userName,
|
||||
agentName: clone.nickname || clone.name,
|
||||
emoji: clone.emoji,
|
||||
personality: clone.personality,
|
||||
scenarios: clone.scenarios,
|
||||
});
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
@@ -135,10 +299,8 @@ export function FirstConversationPrompt({
|
||||
exit={{ opacity: 0, y: -10 }}
|
||||
className="flex flex-col items-center justify-center py-12 px-4"
|
||||
>
|
||||
{/* Greeting emoji */}
|
||||
<div className="text-5xl mb-4">{clone.emoji || '👋'}</div>
|
||||
|
||||
{/* Title */}
|
||||
<motion.h1
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
@@ -148,7 +310,6 @@ export function FirstConversationPrompt({
|
||||
{welcomeTitle}
|
||||
</motion.h1>
|
||||
|
||||
{/* Mode-aware subtitle */}
|
||||
<motion.p
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
@@ -159,14 +320,12 @@ export function FirstConversationPrompt({
|
||||
{modeGreeting[chatMode] || '智能对话,随时待命'}
|
||||
</motion.p>
|
||||
|
||||
{/* Welcome message */}
|
||||
<div className="text-center max-w-md mb-8">
|
||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||
{welcomeMessage}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Quick action chips — template-provided or DeerFlow-style defaults */}
|
||||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||
{clone.quickCommands && clone.quickCommands.length > 0
|
||||
? clone.quickCommands.map((cmd, index) => (
|
||||
@@ -216,7 +375,6 @@ export function FirstConversationPrompt({
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Scenario tags */}
|
||||
{clone.scenarios && clone.scenarios.length > 0 && (
|
||||
<div className="mt-8 flex flex-wrap gap-2 justify-center">
|
||||
{clone.scenarios.map((scenarioId) => {
|
||||
@@ -237,7 +395,6 @@ export function FirstConversationPrompt({
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Dismiss hint */}
|
||||
<p className="mt-8 text-xs text-gray-400 dark:text-gray-500">
|
||||
发送消息开始对话,或点击上方建议
|
||||
</p>
|
||||
|
||||
@@ -1,9 +1,10 @@
|
||||
import { useState } from 'react';
|
||||
import {
|
||||
SquarePen, MessageSquare, Bot, Search, X, Settings
|
||||
SquarePen, MessageSquare, Bot, Search, X, Settings, Newspaper
|
||||
} from 'lucide-react';
|
||||
import { ConversationList } from './ConversationList';
|
||||
import { CloneManager } from './CloneManager';
|
||||
import { DailyReportPanel } from './DailyReportPanel';
|
||||
import { useChatStore } from '../store/chatStore';
|
||||
|
||||
export type MainViewType = 'chat';
|
||||
@@ -14,7 +15,7 @@ interface SidebarProps {
|
||||
onNewChat?: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'conversations' | 'clones';
|
||||
type Tab = 'conversations' | 'clones' | 'daily-report';
|
||||
|
||||
export function Sidebar({
|
||||
onOpenSettings,
|
||||
@@ -79,6 +80,17 @@ export function Sidebar({
|
||||
<Bot className="w-4 h-4" />
|
||||
智能体
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleNavClick('daily-report')}
|
||||
className={`w-full flex items-center gap-3 px-3 py-2 rounded-lg text-sm transition-colors ${
|
||||
activeTab === 'daily-report'
|
||||
? 'bg-black/5 dark:bg-white/5 font-medium text-gray-900 dark:text-gray-100'
|
||||
: 'text-gray-600 dark:text-gray-400 hover:bg-black/5 dark:hover:bg-white/5'
|
||||
}`}
|
||||
>
|
||||
<Newspaper className="w-4 h-4" />
|
||||
日报
|
||||
</button>
|
||||
|
||||
</div>
|
||||
|
||||
@@ -112,6 +124,7 @@ export function Sidebar({
|
||||
</div>
|
||||
)}
|
||||
{activeTab === 'clones' && <div className="h-full overflow-y-auto"><CloneManager /></div>}
|
||||
{activeTab === 'daily-report' && <DailyReportPanel />}
|
||||
</div>
|
||||
|
||||
{/* Bottom user bar */}
|
||||
|
||||
Reference in New Issue
Block a user