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);
|
debug!("[ExperienceStore] Deleted experience {} for agent {}", exp.id, exp.agent_id);
|
||||||
Ok(())
|
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 clip;
|
||||||
mod twitter;
|
mod twitter;
|
||||||
pub mod reminder;
|
pub mod reminder;
|
||||||
|
pub mod daily_report;
|
||||||
|
|
||||||
pub use quiz::*;
|
pub use quiz::*;
|
||||||
pub use browser::*;
|
pub use browser::*;
|
||||||
@@ -23,3 +24,4 @@ pub use collector::*;
|
|||||||
pub use clip::*;
|
pub use clip::*;
|
||||||
pub use twitter::*;
|
pub use twitter::*;
|
||||||
pub use reminder::*;
|
pub use reminder::*;
|
||||||
|
pub use daily_report::*;
|
||||||
|
|||||||
@@ -398,6 +398,49 @@ impl TrajectoryStore {
|
|||||||
|
|
||||||
Ok(result.rows_affected())
|
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
|
// 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.
|
/// Expertise level inferred from conversation patterns.
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
@@ -366,6 +416,45 @@ impl UserProfileStore {
|
|||||||
|
|
||||||
self.upsert(&profile).await
|
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:
|
* Dynamically adapts based on cold start phase:
|
||||||
* - Centered layout with emoji greeting
|
* idle/agent_greeting → Welcome + auto-greeting
|
||||||
* - Input bar embedded in welcome screen
|
* industry_discovery → 4 industry cards
|
||||||
* - Horizontal quick-action chips (colored pills)
|
* identity_setup → Name confirmation prompt
|
||||||
* - Clean, minimal aesthetic
|
* first_task → Industry-specific task suggestions
|
||||||
|
* completed → General quick actions (original DeerFlow-style)
|
||||||
*/
|
*/
|
||||||
import { useEffect } from 'react';
|
import { useEffect } from 'react';
|
||||||
import { motion } from 'framer-motion';
|
import { motion } from 'framer-motion';
|
||||||
@@ -18,18 +19,14 @@ import {
|
|||||||
MessageSquare,
|
MessageSquare,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { cn } from '../lib/utils';
|
import { cn } from '../lib/utils';
|
||||||
import {
|
import { generateWelcomeMessage, getScenarioById } from '../lib/personality-presets';
|
||||||
generateWelcomeMessage,
|
import { useColdStart, INDUSTRY_CARDS, INDUSTRY_FIRST_TASKS } from '../lib/use-cold-start';
|
||||||
getScenarioById,
|
|
||||||
} from '../lib/personality-presets';
|
|
||||||
import { useColdStart } from '../lib/use-cold-start';
|
|
||||||
import type { Clone } from '../store/agentStore';
|
import type { Clone } from '../store/agentStore';
|
||||||
import { useChatStore } from '../store/chatStore';
|
import { useChatStore } from '../store/chatStore';
|
||||||
import { useClassroomStore } from '../store/classroomStore';
|
import { useClassroomStore } from '../store/classroomStore';
|
||||||
import { useHandStore } from '../store/handStore';
|
import { useHandStore } from '../store/handStore';
|
||||||
|
|
||||||
// Quick action chip definitions — DeerFlow-style colored pills
|
// Original quick actions for completed state
|
||||||
// handId maps to actual Hand names in the runtime
|
|
||||||
const QUICK_ACTIONS = [
|
const QUICK_ACTIONS = [
|
||||||
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
|
{ key: 'surprise', label: '小惊喜', icon: Sparkles, color: 'text-orange-500' },
|
||||||
{ key: 'write', label: '写作', icon: PenLine, color: 'text-blue-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' },
|
{ 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> = {
|
const QUICK_ACTION_PROMPTS: Record<string, string> = {
|
||||||
surprise: '给我一个小惊喜吧!来点创意的',
|
surprise: '给我一个小惊喜吧!来点创意的',
|
||||||
write: '帮我写一份关于"远程医疗行政管理优化方案"的提案大纲',
|
write: '帮我写一份关于"远程医疗行政管理优化方案"的提案大纲',
|
||||||
@@ -58,16 +54,27 @@ export function FirstConversationPrompt({
|
|||||||
onSelectSuggestion,
|
onSelectSuggestion,
|
||||||
}: FirstConversationPromptProps) {
|
}: FirstConversationPromptProps) {
|
||||||
const chatMode = useChatStore((s) => s.chatMode);
|
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(() => {
|
useEffect(() => {
|
||||||
if (isColdStart && phase === 'idle' && !greetingSent) {
|
if (isColdStart && phase === 'idle' && !greetingSent) {
|
||||||
const greeting = getGreetingMessage(clone.nickname || clone.name, clone.emoji);
|
const greeting = getGreetingMessage(clone.nickname || clone.name, clone.emoji);
|
||||||
onSelectSuggestion?.(greeting);
|
onSelectSuggestion?.(greeting);
|
||||||
markGreetingSent();
|
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> = {
|
const modeGreeting: Record<string, string> = {
|
||||||
flash: '快速回答,即时响应',
|
flash: '快速回答,即时响应',
|
||||||
@@ -76,23 +83,40 @@ export function FirstConversationPrompt({
|
|||||||
ultra: '多代理协作,全能力调度',
|
ultra: '多代理协作,全能力调度',
|
||||||
};
|
};
|
||||||
|
|
||||||
// Use template-provided welcome message if available, otherwise generate dynamically
|
|
||||||
const isNewUser = !localStorage.getItem('zclaw-onboarding-completed');
|
const isNewUser = !localStorage.getItem('zclaw-onboarding-completed');
|
||||||
const welcomeTitle = isNewUser ? '你好,欢迎开始!' : '你好,欢迎回来!';
|
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) => {
|
const handleQuickAction = (key: string) => {
|
||||||
if (key === 'learn') {
|
if (key === 'learn') {
|
||||||
// Trigger classroom generation flow
|
|
||||||
const classroomStore = useClassroomStore.getState();
|
const classroomStore = useClassroomStore.getState();
|
||||||
// Extract a clean topic from the prompt
|
|
||||||
const prompt = QUICK_ACTION_PROMPTS[key] || '';
|
const prompt = QUICK_ACTION_PROMPTS[key] || '';
|
||||||
const topic = prompt
|
const topic = prompt
|
||||||
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
|
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
|
||||||
@@ -104,13 +128,10 @@ export function FirstConversationPrompt({
|
|||||||
style: 'lecture',
|
style: 'lecture',
|
||||||
level: 'intermediate',
|
level: 'intermediate',
|
||||||
language: 'zh-CN',
|
language: 'zh-CN',
|
||||||
}).catch(() => {
|
}).catch(() => {});
|
||||||
// Error is already stored in classroomStore.error and displayed in ChatArea
|
|
||||||
});
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Check if this action maps to a Hand
|
|
||||||
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
|
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
|
||||||
if (actionDef?.handId) {
|
if (actionDef?.handId) {
|
||||||
const handStore = useHandStore.getState();
|
const handStore = useHandStore.getState();
|
||||||
@@ -118,16 +139,159 @@ export function FirstConversationPrompt({
|
|||||||
action: key === 'research' ? 'report' : 'collect',
|
action: key === 'research' ? 'report' : 'collect',
|
||||||
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
|
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
|
||||||
}).catch(() => {
|
}).catch(() => {
|
||||||
// Fallback: fill prompt into input bar
|
|
||||||
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
||||||
});
|
});
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const prompt = QUICK_ACTION_PROMPTS[key] || '你好!';
|
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
|
||||||
onSelectSuggestion?.(prompt);
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// === 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 (
|
return (
|
||||||
<motion.div
|
<motion.div
|
||||||
initial={{ opacity: 0, y: 20 }}
|
initial={{ opacity: 0, y: 20 }}
|
||||||
@@ -135,10 +299,8 @@ export function FirstConversationPrompt({
|
|||||||
exit={{ opacity: 0, y: -10 }}
|
exit={{ opacity: 0, y: -10 }}
|
||||||
className="flex flex-col items-center justify-center py-12 px-4"
|
className="flex flex-col items-center justify-center py-12 px-4"
|
||||||
>
|
>
|
||||||
{/* Greeting emoji */}
|
|
||||||
<div className="text-5xl mb-4">{clone.emoji || '👋'}</div>
|
<div className="text-5xl mb-4">{clone.emoji || '👋'}</div>
|
||||||
|
|
||||||
{/* Title */}
|
|
||||||
<motion.h1
|
<motion.h1
|
||||||
initial={{ opacity: 0, y: 10 }}
|
initial={{ opacity: 0, y: 10 }}
|
||||||
animate={{ opacity: 1, y: 0 }}
|
animate={{ opacity: 1, y: 0 }}
|
||||||
@@ -148,7 +310,6 @@ export function FirstConversationPrompt({
|
|||||||
{welcomeTitle}
|
{welcomeTitle}
|
||||||
</motion.h1>
|
</motion.h1>
|
||||||
|
|
||||||
{/* Mode-aware subtitle */}
|
|
||||||
<motion.p
|
<motion.p
|
||||||
initial={{ opacity: 0 }}
|
initial={{ opacity: 0 }}
|
||||||
animate={{ opacity: 1 }}
|
animate={{ opacity: 1 }}
|
||||||
@@ -159,14 +320,12 @@ export function FirstConversationPrompt({
|
|||||||
{modeGreeting[chatMode] || '智能对话,随时待命'}
|
{modeGreeting[chatMode] || '智能对话,随时待命'}
|
||||||
</motion.p>
|
</motion.p>
|
||||||
|
|
||||||
{/* Welcome message */}
|
|
||||||
<div className="text-center max-w-md mb-8">
|
<div className="text-center max-w-md mb-8">
|
||||||
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
<p className="text-sm text-gray-500 dark:text-gray-400 leading-relaxed">
|
||||||
{welcomeMessage}
|
{welcomeMessage}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Quick action chips — template-provided or DeerFlow-style defaults */}
|
|
||||||
<div className="flex items-center justify-center gap-2 flex-wrap">
|
<div className="flex items-center justify-center gap-2 flex-wrap">
|
||||||
{clone.quickCommands && clone.quickCommands.length > 0
|
{clone.quickCommands && clone.quickCommands.length > 0
|
||||||
? clone.quickCommands.map((cmd, index) => (
|
? clone.quickCommands.map((cmd, index) => (
|
||||||
@@ -216,7 +375,6 @@ export function FirstConversationPrompt({
|
|||||||
})}
|
})}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Scenario tags */}
|
|
||||||
{clone.scenarios && clone.scenarios.length > 0 && (
|
{clone.scenarios && clone.scenarios.length > 0 && (
|
||||||
<div className="mt-8 flex flex-wrap gap-2 justify-center">
|
<div className="mt-8 flex flex-wrap gap-2 justify-center">
|
||||||
{clone.scenarios.map((scenarioId) => {
|
{clone.scenarios.map((scenarioId) => {
|
||||||
@@ -237,7 +395,6 @@ export function FirstConversationPrompt({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Dismiss hint */}
|
|
||||||
<p className="mt-8 text-xs text-gray-400 dark:text-gray-500">
|
<p className="mt-8 text-xs text-gray-400 dark:text-gray-500">
|
||||||
发送消息开始对话,或点击上方建议
|
发送消息开始对话,或点击上方建议
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import {
|
import {
|
||||||
SquarePen, MessageSquare, Bot, Search, X, Settings
|
SquarePen, MessageSquare, Bot, Search, X, Settings, Newspaper
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { ConversationList } from './ConversationList';
|
import { ConversationList } from './ConversationList';
|
||||||
import { CloneManager } from './CloneManager';
|
import { CloneManager } from './CloneManager';
|
||||||
|
import { DailyReportPanel } from './DailyReportPanel';
|
||||||
import { useChatStore } from '../store/chatStore';
|
import { useChatStore } from '../store/chatStore';
|
||||||
|
|
||||||
export type MainViewType = 'chat';
|
export type MainViewType = 'chat';
|
||||||
@@ -14,7 +15,7 @@ interface SidebarProps {
|
|||||||
onNewChat?: () => void;
|
onNewChat?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
type Tab = 'conversations' | 'clones';
|
type Tab = 'conversations' | 'clones' | 'daily-report';
|
||||||
|
|
||||||
export function Sidebar({
|
export function Sidebar({
|
||||||
onOpenSettings,
|
onOpenSettings,
|
||||||
@@ -79,6 +80,17 @@ export function Sidebar({
|
|||||||
<Bot className="w-4 h-4" />
|
<Bot className="w-4 h-4" />
|
||||||
智能体
|
智能体
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
@@ -112,6 +124,7 @@ export function Sidebar({
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
{activeTab === 'clones' && <div className="h-full overflow-y-auto"><CloneManager /></div>}
|
{activeTab === 'clones' && <div className="h-full overflow-y-auto"><CloneManager /></div>}
|
||||||
|
{activeTab === 'daily-report' && <DailyReportPanel />}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Bottom user bar */}
|
{/* Bottom user bar */}
|
||||||
|
|||||||
Reference in New Issue
Block a user