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

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

View File

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