Files
zclaw_openfang/desktop/src/components/FirstConversationPrompt.tsx
iven ae56aba366
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
feat(hands,desktop): C线差异化 — 管家日报 + 零配置引导优化
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
2026-04-21 18:23:36 +08:00

406 lines
15 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* FirstConversationPrompt - Conversation-driven cold start UI
*
* 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';
import {
Sparkles,
PenLine,
Microscope,
Layers,
GraduationCap,
MessageSquare,
} from 'lucide-react';
import { cn } from '../lib/utils';
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';
// 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' },
{ key: 'research', label: '研究', icon: Microscope, color: 'text-purple-500', handId: 'researcher' },
{ key: 'collect', label: '收集', icon: Layers, color: 'text-green-500', handId: 'collector' },
{ key: 'learn', label: '学习', icon: GraduationCap, color: 'text-indigo-500' },
];
const QUICK_ACTION_PROMPTS: Record<string, string> = {
surprise: '给我一个小惊喜吧!来点创意的',
write: '帮我写一份关于"远程医疗行政管理优化方案"的提案大纲',
research: '帮我深度研究"2026年教育数字化转型趋势",包括政策、技术和实践三个维度',
collect: '帮我采集 5 个主流 AI 教育工具的产品信息,对比功能和价格',
learn: '我想了解汕头玩具产业 2026 年出口趋势,能帮我分析一下吗?',
};
interface FirstConversationPromptProps {
clone: Clone;
onSelectSuggestion?: (text: string) => void;
onDismiss?: () => void;
}
export function FirstConversationPrompt({
clone,
onSelectSuggestion,
}: FirstConversationPromptProps) {
const chatMode = useChatStore((s) => s.chatMode);
const {
isColdStart,
phase,
config,
greetingSent,
markGreetingSent,
advanceTo,
updateConfig,
markCompleted,
getGreetingMessage,
} = useColdStart();
// 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, advanceTo, getGreetingMessage]);
const modeGreeting: Record<string, string> = {
flash: '快速回答,即时响应',
thinking: '深度分析,逐步推理',
pro: '专业规划,系统思考',
ultra: '多代理协作,全能力调度',
};
const isNewUser = !localStorage.getItem('zclaw-onboarding-completed');
const welcomeTitle = isNewUser ? '你好,欢迎开始!' : '你好,欢迎回来!';
// === 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') {
const classroomStore = useClassroomStore.getState();
const prompt = QUICK_ACTION_PROMPTS[key] || '';
const topic = prompt
.replace(/^[你我].*?(想了解|想学|了解|学习|分析|研究|探索)\s*/g, '')
.replace(/[,。?!].*$/g, '')
.replace(/^(能|帮|请|可不可以).*/g, '')
.trim() || '互动课堂';
classroomStore.startGeneration({
topic,
style: 'lecture',
level: 'intermediate',
language: 'zh-CN',
}).catch(() => {});
return;
}
const actionDef = QUICK_ACTIONS.find((a) => a.key === key);
if (actionDef?.handId) {
const handStore = useHandStore.getState();
handStore.triggerHand(actionDef.handId, {
action: key === 'research' ? 'report' : 'collect',
query: { query: QUICK_ACTION_PROMPTS[key] || '' },
}).catch(() => {
onSelectSuggestion?.(QUICK_ACTION_PROMPTS[key] || '你好!');
});
return;
}
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 }}
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"
>
{welcomeTitle}
</motion.h1>
<motion.p
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ delay: 0.2, duration: 0.4 }}
className="text-sm text-orange-500 dark:text-orange-400 font-medium mb-4 flex items-center gap-1.5"
>
<Sparkles className="w-3.5 h-3.5" />
{modeGreeting[chatMode] || '智能对话,随时待命'}
</motion.p>
<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>
<div className="flex items-center justify-center gap-2 flex-wrap">
{clone.quickCommands && clone.quickCommands.length > 0
? clone.quickCommands.map((cmd, index) => (
<motion.button
key={cmd.label}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.05, duration: 0.2 }}
onClick={() => onSelectSuggestion?.(cmd.command)}
className={cn(
'flex items-center gap-2 px-4 py-2',
'bg-white dark:bg-gray-800',
'border border-gray-200 dark:border-gray-700',
'rounded-full text-sm text-gray-600 dark:text-gray-300',
'hover:border-gray-300 dark:hover:border-gray-600',
'hover:bg-gray-50 dark:hover:bg-gray-750',
'transition-all duration-150'
)}
>
<MessageSquare className="w-4 h-4 text-primary" />
<span>{cmd.label}</span>
</motion.button>
))
: QUICK_ACTIONS.map((action, index) => {
const ActionIcon = action.icon;
return (
<motion.button
key={action.key}
initial={{ opacity: 0, y: 8 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.3 + index * 0.05, duration: 0.2 }}
onClick={() => handleQuickAction(action.key)}
className={cn(
'flex items-center gap-2 px-4 py-2',
'bg-white dark:bg-gray-800',
'border border-gray-200 dark:border-gray-700',
'rounded-full text-sm text-gray-600 dark:text-gray-300',
'hover:border-gray-300 dark:hover:border-gray-600',
'hover:bg-gray-50 dark:hover:bg-gray-750',
'transition-all duration-150'
)}
>
<ActionIcon className={`w-4 h-4 ${action.color}`} />
<span>{action.label}</span>
</motion.button>
);
})}
</div>
{clone.scenarios && clone.scenarios.length > 0 && (
<div className="mt-8 flex flex-wrap gap-2 justify-center">
{clone.scenarios.map((scenarioId) => {
const scenario = getScenarioById(scenarioId);
if (!scenario) return null;
return (
<span
key={scenarioId}
className={cn(
'px-3 py-1 rounded-full text-xs font-medium',
'bg-primary/10 text-primary dark:bg-primary/20 dark:text-primary'
)}
>
{scenario.label}
</span>
);
})}
</div>
)}
<p className="mt-8 text-xs text-gray-400 dark:text-gray-500">
</p>
</motion.div>
);
}
export default FirstConversationPrompt;