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

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:
iven
2026-04-21 18:23:36 +08:00
parent a43806ccc2
commit ae56aba366
8 changed files with 864 additions and 44 deletions

View File

@@ -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())
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View 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);
}
}

View File

@@ -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::*;

View File

@@ -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)
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View File

@@ -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
}
} }
// --------------------------------------------------------------------------- // ---------------------------------------------------------------------------

View 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;

View File

@@ -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>

View File

@@ -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 */}