diff --git a/MeAgent/assets/imgs/luoxi.jpg b/MeAgent/assets/imgs/luoxi.jpg new file mode 100644 index 00000000..4134069e Binary files /dev/null and b/MeAgent/assets/imgs/luoxi.jpg differ diff --git a/MeAgent/navigation/Menu.js b/MeAgent/navigation/Menu.js index 87d6a8c4..1aa9a697 100644 --- a/MeAgent/navigation/Menu.js +++ b/MeAgent/navigation/Menu.js @@ -167,6 +167,7 @@ function CustomDrawerContent({ { title: "概念中心", navigateTo: "ConceptsDrawer", icon: "bulb", gradient: ["#06B6D4", "#22D3EE"] }, { title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] }, { title: "社区论坛", navigateTo: "CommunityDrawer", icon: "chatbubbles", gradient: ["#10B981", "#34D399"] }, + { title: "AI 助手", navigateTo: "AgentDrawer", icon: "sparkles", gradient: ["#8B5CF6", "#EC4899"] }, { title: "个人中心", navigateTo: "ProfileDrawerNew", icon: "person", gradient: ["#8B5CF6", "#A78BFA"] }, ]; return ( diff --git a/MeAgent/navigation/Screens.js b/MeAgent/navigation/Screens.js index b5ddcc77..31d0f6d5 100644 --- a/MeAgent/navigation/Screens.js +++ b/MeAgent/navigation/Screens.js @@ -65,6 +65,9 @@ import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile"; // 认证页面 import { LoginScreen } from "../src/screens/Auth"; +// AI 助手页面 +import { AgentChatScreen } from "../src/screens/Agent"; + // 推送通知处理 import PushNotificationHandler from "../src/components/PushNotificationHandler"; @@ -489,6 +492,26 @@ function NewProfileStack(props) { ); } +// AI 助手导航栈 +function AgentStack(props) { + return ( + + + + ); +} + function ProfileStack(props) { return ( + tool.category !== 'quant') + .map(tool => tool.id); + +// Agent 名称 +export const AGENT_NAME = '洛希'; + +// 欢迎消息模板 +export const WELCOME_MESSAGE_TEMPLATE = (nickname) => `你好${nickname ? ` ${nickname}` : ''}! + +我是**洛希**,你的 AI 投研助手。 + +「洛希」源自洛希极限(Roche Limit)—— 天体力学中描述引力边界的临界点。正如我帮你在市场的混沌中,找到价值与风险的平衡点。 + +**我能做什么?** +• 📊 深度分析股票基本面和技术面 +• 🔥 追踪市场热点和涨停板块 +• 📈 研究行业趋势和投资机会 +• 📰 汇总最新财经新闻和研报 +• 🧮 量化指标计算和回测分析 + +有什么可以帮你的?`; + +// 快捷问题 +export const QUICK_QUESTIONS = [ + '今天有哪些涨停板块?', + '帮我分析一下贵州茅台', + '最近有什么热门概念?', + '查看我的自选股表现', +]; + +// API 配置 +export const AGENT_API_CONFIG = { + // 生产环境 - 使用 api.valuefrontier.cn + BASE_URL: 'https://api.valuefrontier.cn/mcp', + // 开发环境 - 本地测试时使用 + // BASE_URL: 'http://localhost:8900', + ENDPOINTS: { + CHAT_STREAM: '/agent/chat/stream', + SESSIONS: '/agent/sessions', + HISTORY: '/agent/history', + }, + DEFAULT_TIMEOUT: 60000, // 60 秒 + MAX_HISTORY_LIMIT: 100, + MAX_SESSIONS_LIMIT: 50, +}; diff --git a/MeAgent/src/hooks/useAgentChat.js b/MeAgent/src/hooks/useAgentChat.js new file mode 100644 index 00000000..f936ab85 --- /dev/null +++ b/MeAgent/src/hooks/useAgentChat.js @@ -0,0 +1,391 @@ +/** + * useAgentChat Hook + * 处理 Agent 聊天核心逻辑:发送消息、SSE 流式响应、状态更新 + */ + +import { useCallback, useRef, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useAuth } from '../contexts/AuthContext'; +import { streamAgentChat, buildConversationHistory } from '../services/agentService'; +import { + MessageTypes, + SSEEventTypes, +} from '../constants/agentConstants'; +import { + addMessage, + updateLastMessageByType, + removeMessagesByType, + setIsProcessing, + resetStreamState, + appendThinkingContent, + appendDeepThinkingContent, + updateStreamState, + setPlan, + addStepResult, + setCurrentSession, + selectMessages, + selectIsProcessing, + selectCurrentSessionId, + selectSelectedModel, + selectSelectedTools, + selectStreamState, + fetchSessions, +} from '../store/slices/agentSlice'; + +/** + * Agent 聊天 Hook + * + * @returns {Object} 聊天相关的状态和方法 + */ +export const useAgentChat = () => { + const dispatch = useDispatch(); + const { user, subscription } = useAuth(); + + // 从 Redux 获取状态 + const messages = useSelector(selectMessages); + const isProcessing = useSelector(selectIsProcessing); + const currentSessionId = useSelector(selectCurrentSessionId); + const selectedModel = useSelector(selectSelectedModel); + const selectedTools = useSelector(selectSelectedTools); + const streamState = useSelector(selectStreamState); + + // AbortController 引用,用于取消请求 + const abortControllerRef = useRef(null); + + // 使用 ref 追踪累积的内容(避免闭包问题) + const thinkingContentRef = useRef(''); + const deepThinkingContentRef = useRef(''); + const responseContentRef = useRef(''); + const stepResultsRef = useRef([]); + const messagesRef = useRef(messages); + + // 保持 messagesRef 同步 + messagesRef.current = messages; + + // 组件卸载时清理 + useEffect(() => { + return () => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + }; + }, []); + + /** + * 处理 SSE 事件 + * 使用 ref 追踪累积内容,避免闭包导致的状态过时问题 + */ + const handleSSEEvent = useCallback((event) => { + const { type, data } = event; + + switch (type) { + case SSEEventTypes.STATUS: + // 状态更新(如 "正在思考...") + if (data?.message) { + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_THINKING, + updates: { content: data.message }, + })); + } + break; + + case SSEEventTypes.THINKING: + // 普通思考内容 + if (data?.content) { + thinkingContentRef.current += data.content; + dispatch(appendThinkingContent(data.content)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_THINKING, + updates: { thinkingContent: thinkingContentRef.current }, + })); + } + break; + + case SSEEventTypes.THINKING_START: + // 深度思考开始 + deepThinkingContentRef.current = ''; + dispatch(updateStreamState({ isDeepThinking: true })); + dispatch(addMessage({ + type: MessageTypes.AGENT_DEEP_THINKING, + content: '', + isStreaming: true, + })); + break; + + case SSEEventTypes.THINKING_CHUNK: + // 深度思考内容流 + if (data?.content) { + deepThinkingContentRef.current += data.content; + dispatch(appendDeepThinkingContent(data.content)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_DEEP_THINKING, + updates: { + content: deepThinkingContentRef.current, + }, + })); + } + break; + + case SSEEventTypes.THINKING_END: + // 深度思考结束 + dispatch(updateStreamState({ isDeepThinking: false })); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_DEEP_THINKING, + updates: { isStreaming: false }, + })); + break; + + case SSEEventTypes.REASONING: + // 推理过程 + if (data?.content) { + thinkingContentRef.current += data.content; + dispatch(appendThinkingContent(data.content)); + } + break; + + case SSEEventTypes.PLAN: + // 执行计划 + stepResultsRef.current = []; + dispatch(setPlan(data)); + dispatch(addMessage({ + type: MessageTypes.AGENT_PLAN, + content: data?.goal || '执行计划', + plan: data, + })); + // 添加执行中消息 + dispatch(addMessage({ + type: MessageTypes.AGENT_EXECUTING, + content: '正在执行...', + stepResults: [], + })); + break; + + case SSEEventTypes.STEP_START: + // 步骤开始 + if (data?.tool) { + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_EXECUTING, + updates: { + content: `正在执行: ${data.tool}`, + currentStep: data, + }, + })); + } + break; + + case SSEEventTypes.STEP_COMPLETE: + // 步骤完成 + stepResultsRef.current = [...stepResultsRef.current, data]; + dispatch(addStepResult(data)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_EXECUTING, + updates: { + stepResults: stepResultsRef.current, + }, + })); + break; + + case SSEEventTypes.SUMMARY_CHUNK: + // 总结流式输出 + if (data?.content) { + // 首次收到内容时创建消息 + if (responseContentRef.current === '') { + responseContentRef.current = data.content; + dispatch(addMessage({ + type: MessageTypes.AGENT_RESPONSE, + content: data.content, + isStreaming: true, + })); + } else { + responseContentRef.current += data.content; + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_RESPONSE, + updates: { + content: responseContentRef.current, + }, + })); + } + } + break; + + case SSEEventTypes.SESSION_TITLE: + // 会话标题 + if (data?.title) { + dispatch(setCurrentSession({ + sessionId: data.session_id || currentSessionId, + title: data.title, + })); + } + break; + + case SSEEventTypes.DONE: + // 完成 - 清理所有中间状态消息,确保动画停止 + dispatch(removeMessagesByType(MessageTypes.AGENT_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_EXECUTING)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_DEEP_THINKING, + updates: { isStreaming: false }, + })); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_RESPONSE, + updates: { isStreaming: false }, + })); + dispatch(setIsProcessing(false)); + // 重新加载会话列表 + if (user?.id) { + dispatch(fetchSessions(user.id)); + } + break; + + case SSEEventTypes.ERROR: + // 错误 - 移除 thinking 消息后再添加错误消息 + dispatch(removeMessagesByType(MessageTypes.AGENT_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_DEEP_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_EXECUTING)); + dispatch(addMessage({ + type: MessageTypes.ERROR, + content: data?.message || '发生错误,请重试', + })); + dispatch(setIsProcessing(false)); + break; + + case 'stream_end': + // 流结束 - 确保所有动画停止 + dispatch(removeMessagesByType(MessageTypes.AGENT_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_EXECUTING)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_DEEP_THINKING, + updates: { isStreaming: false }, + })); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_RESPONSE, + updates: { isStreaming: false }, + })); + dispatch(setIsProcessing(false)); + break; + + case 'aborted': + // 请求被取消 - 移除所有中间状态消息,停止动画 + dispatch(removeMessagesByType(MessageTypes.AGENT_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_DEEP_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_EXECUTING)); + dispatch(updateLastMessageByType({ + type: MessageTypes.AGENT_RESPONSE, + updates: { isStreaming: false }, + })); + dispatch(setIsProcessing(false)); + break; + + default: + console.log('[useAgentChat] 未处理的事件类型:', type, data); + } + }, [dispatch, currentSessionId, user?.id]); + + /** + * 发送消息 + */ + const sendMessage = useCallback(async (inputText) => { + if (!inputText?.trim() || isProcessing) return; + + const messageText = inputText.trim(); + + // 创建并取消之前的请求 + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + } + abortControllerRef.current = new AbortController(); + + // 重置 ref 追踪值 + thinkingContentRef.current = ''; + deepThinkingContentRef.current = ''; + responseContentRef.current = ''; + stepResultsRef.current = []; + + // 添加用户消息 + dispatch(addMessage({ + type: MessageTypes.USER, + content: messageText, + })); + + // 重置流式状态 + dispatch(resetStreamState()); + dispatch(setIsProcessing(true)); + + // 添加思考中消息 + dispatch(addMessage({ + type: MessageTypes.AGENT_THINKING, + content: '正在思考...', + })); + + try { + // 构建请求参数(使用 ref 获取最新 messages,避免依赖变化) + const params = { + message: messageText, + conversationHistory: buildConversationHistory(messagesRef.current), + userId: user?.id || 'anonymous', + userNickname: user?.nickname || user?.username || '', + userAvatar: user?.avatar || '', + subscriptionType: subscription?.type || 'free', // 从 subscription 对象获取 + sessionId: currentSessionId, + model: selectedModel, + tools: selectedTools, + }; + + // 发起 SSE 请求 + await streamAgentChat(params, handleSSEEvent, abortControllerRef.current.signal); + + } catch (error) { + if (error.name !== 'AbortError') { + console.error('[useAgentChat] 发送消息失败:', error); + // 移除 thinking 相关消息 + dispatch(removeMessagesByType(MessageTypes.AGENT_THINKING)); + dispatch(removeMessagesByType(MessageTypes.AGENT_DEEP_THINKING)); + dispatch(addMessage({ + type: MessageTypes.ERROR, + content: error.message || '网络错误,请检查网络连接后重试', + })); + } + dispatch(setIsProcessing(false)); + } + }, [dispatch, isProcessing, currentSessionId, selectedModel, selectedTools, user?.id, user?.nickname, user?.username, user?.avatar, subscription?.type, handleSSEEvent]); + + /** + * 取消当前请求 + */ + const cancelRequest = useCallback(() => { + if (abortControllerRef.current) { + abortControllerRef.current.abort(); + dispatch(setIsProcessing(false)); + } + }, [dispatch]); + + /** + * 重试最后一条消息 + */ + const retryLastMessage = useCallback(() => { + // 找到最后一条用户消息 + const lastUserMessage = [...messages].reverse().find(m => m.type === MessageTypes.USER); + if (lastUserMessage) { + // 移除错误消息和 AI 响应 + // 这里简化处理,实际可能需要更精细的逻辑 + sendMessage(lastUserMessage.content); + } + }, [messages, sendMessage]); + + return { + // 状态 + messages, + isProcessing, + streamState, + currentSessionId, + selectedModel, + selectedTools, + + // 方法 + sendMessage, + cancelRequest, + retryLastMessage, + }; +}; + +export default useAgentChat; diff --git a/MeAgent/src/hooks/useAgentSessions.js b/MeAgent/src/hooks/useAgentSessions.js new file mode 100644 index 00000000..35c99694 --- /dev/null +++ b/MeAgent/src/hooks/useAgentSessions.js @@ -0,0 +1,141 @@ +/** + * useAgentSessions Hook + * 处理 Agent 会话管理:会话列表、切换会话、新建会话 + */ + +import { useCallback, useEffect } from 'react'; +import { useDispatch, useSelector } from 'react-redux'; +import { useAuth } from '../contexts/AuthContext'; +import { + fetchSessions, + fetchSessionHistory, + initNewSession, + clearCurrentSession, + setSessionDrawerOpen, + selectSessions, + selectGroupedSessions, + selectSessionsLoading, + selectCurrentSessionId, + selectCurrentSessionTitle, + selectIsSessionDrawerOpen, +} from '../store/slices/agentSlice'; + +/** + * Agent 会话管理 Hook + * + * @returns {Object} 会话相关的状态和方法 + */ +export const useAgentSessions = () => { + const dispatch = useDispatch(); + const { user } = useAuth(); + + // 从 Redux 获取状态 + const sessions = useSelector(selectSessions); + const groupedSessions = useSelector(selectGroupedSessions); + const isLoading = useSelector(selectSessionsLoading); + const currentSessionId = useSelector(selectCurrentSessionId); + const currentSessionTitle = useSelector(selectCurrentSessionTitle); + const isDrawerOpen = useSelector(selectIsSessionDrawerOpen); + + /** + * 加载会话列表 + */ + const loadSessions = useCallback(() => { + if (user?.id) { + dispatch(fetchSessions(String(user.id))); + } + }, [dispatch, user?.id]); + + /** + * 切换到指定会话 + */ + const switchSession = useCallback((sessionId) => { + if (sessionId && sessionId !== currentSessionId) { + dispatch(fetchSessionHistory(sessionId)); + dispatch(setSessionDrawerOpen(false)); + } + }, [dispatch, currentSessionId]); + + /** + * 新建会话 + */ + const createNewSession = useCallback(() => { + dispatch(initNewSession({ nickname: user?.nickname || user?.username })); + dispatch(setSessionDrawerOpen(false)); + }, [dispatch, user?.nickname, user?.username]); + + /** + * 打开会话抽屉 + */ + const openDrawer = useCallback(() => { + dispatch(setSessionDrawerOpen(true)); + }, [dispatch]); + + /** + * 关闭会话抽屉 + */ + const closeDrawer = useCallback(() => { + dispatch(setSessionDrawerOpen(false)); + }, [dispatch]); + + /** + * 切换会话抽屉 + */ + const toggleDrawer = useCallback(() => { + dispatch(setSessionDrawerOpen(!isDrawerOpen)); + }, [dispatch, isDrawerOpen]); + + /** + * 搜索会话 + */ + const searchSessions = useCallback((keyword) => { + if (!keyword?.trim()) { + return groupedSessions; + } + + const lowerKeyword = keyword.toLowerCase(); + + // 过滤匹配的会话 + const filteredGroups = groupedSessions + .map(group => ({ + ...group, + sessions: group.sessions.filter(session => + (session.title || '').toLowerCase().includes(lowerKeyword) || + (session.session_id || '').toLowerCase().includes(lowerKeyword) + ), + })) + .filter(group => group.sessions.length > 0); + + return filteredGroups; + }, [groupedSessions]); + + /** + * 初始化:加载会话列表 + */ + useEffect(() => { + if (user?.id) { + loadSessions(); + } + }, [user?.id, loadSessions]); + + return { + // 状态 + sessions, + groupedSessions, + isLoading, + currentSessionId, + currentSessionTitle, + isDrawerOpen, + + // 方法 + loadSessions, + switchSession, + createNewSession, + openDrawer, + closeDrawer, + toggleDrawer, + searchSessions, + }; +}; + +export default useAgentSessions; diff --git a/MeAgent/src/screens/Agent/AgentChatScreen.js b/MeAgent/src/screens/Agent/AgentChatScreen.js new file mode 100644 index 00000000..19210204 --- /dev/null +++ b/MeAgent/src/screens/Agent/AgentChatScreen.js @@ -0,0 +1,330 @@ +/** + * AgentChatScreen + * AI 投研助手主聊天屏幕 + */ + +import React, { useEffect, useRef, useCallback, useState } from 'react'; +import { + View, + Text, + StyleSheet, + FlatList, + KeyboardAvoidingView, + Platform, + TouchableOpacity, + StatusBar, + SafeAreaView, + Image, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { useDispatch, useSelector } from 'react-redux'; + +import { useAuth } from '../../contexts/AuthContext'; +import { useAgentChat } from '../../hooks/useAgentChat'; +import { useAgentSessions } from '../../hooks/useAgentSessions'; +import { + MessageBubble, + ChatInput, + WelcomeScreen, + SessionDrawer, +} from './components'; +import { + AgentTheme, + MessageTypes, + AGENT_NAME, +} from '../../constants/agentConstants'; + +// 洛希头像 +const LuoxiAvatar = require('../../../assets/imgs/luoxi.jpg'); +import { + initNewSession, + selectMessages, +} from '../../store/slices/agentSlice'; + +/** + * 头部导航栏 + */ +const Header = ({ title, onMenuPress, onNewPress }) => ( + + + + + + + + + + + {title || AGENT_NAME} + + + + + + + + +); + +/** + * AgentChatScreen 主组件 + */ +const AgentChatScreen = ({ navigation }) => { + const dispatch = useDispatch(); + const { user, isLoggedIn } = useAuth(); + const flatListRef = useRef(null); + + // 智能滚动:跟踪用户是否在底部附近 + const isNearBottomRef = useRef(true); + const contentHeightRef = useRef(0); + const scrollOffsetRef = useRef(0); + const layoutHeightRef = useRef(0); + + // 使用自定义 Hooks + const { + messages, + isProcessing, + sendMessage, + cancelRequest, + } = useAgentChat(); + + const { + groupedSessions, + isLoading: sessionsLoading, + currentSessionId, + currentSessionTitle, + isDrawerOpen, + openDrawer, + closeDrawer, + switchSession, + createNewSession, + } = useAgentSessions(); + + /** + * 初始化:如果没有消息,显示欢迎界面 + */ + useEffect(() => { + if (messages.length === 0) { + dispatch(initNewSession({ nickname: user?.nickname || user?.username })); + } + }, []); + + /** + * 智能滚动:只有当用户在底部附近时才自动滚动 + */ + useEffect(() => { + if (messages.length > 0 && flatListRef.current && isNearBottomRef.current) { + setTimeout(() => { + flatListRef.current?.scrollToEnd({ animated: true }); + }, 100); + } + }, [messages]); + + /** + * 处理滚动事件,检测用户是否在底部附近 + */ + const handleScroll = useCallback((event) => { + const { contentOffset, contentSize, layoutMeasurement } = event.nativeEvent; + scrollOffsetRef.current = contentOffset.y; + contentHeightRef.current = contentSize.height; + layoutHeightRef.current = layoutMeasurement.height; + + // 判断是否在底部附近(距离底部 100 像素以内) + const distanceFromBottom = contentSize.height - layoutMeasurement.height - contentOffset.y; + isNearBottomRef.current = distanceFromBottom < 100; + }, []); + + /** + * 处理内容大小变化 + */ + const handleContentSizeChange = useCallback((width, height) => { + // 只有当用户在底部附近时才自动滚动 + if (isNearBottomRef.current && flatListRef.current) { + flatListRef.current.scrollToEnd({ animated: true }); + } + }, []); + + /** + * 处理快捷问题点击 + */ + const handleQuickQuestion = useCallback((question) => { + // 发送消息时重置滚动状态,确保自动滚动到底部 + isNearBottomRef.current = true; + sendMessage(question); + }, [sendMessage]); + + /** + * 处理发送消息(包装 sendMessage 以重置滚动状态) + */ + const handleSendMessage = useCallback((text) => { + isNearBottomRef.current = true; + sendMessage(text); + }, [sendMessage]); + + /** + * 处理新建对话 + */ + const handleNewSession = useCallback(() => { + isNearBottomRef.current = true; + createNewSession(); + }, [createNewSession]); + + /** + * 渲染消息项 + */ + const renderMessageItem = useCallback(({ item }) => ( + + + + ), []); + + /** + * 消息列表 Key 提取器 + */ + const keyExtractor = useCallback((item) => item.id?.toString() || Math.random().toString(), []); + + // 判断是否显示欢迎屏幕(只有一条欢迎消息时) + const showWelcome = messages.length <= 1 && + messages[0]?.type === MessageTypes.AGENT_RESPONSE; + + return ( + + + + {/* 头部 */} +
+ + {/* 主体内容 */} + + {showWelcome ? ( + /* 欢迎屏幕 */ + + ) : ( + /* 消息列表 */ + + )} + + {/* 输入框 */} + + + + {/* 会话抽屉 */} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + backgroundColor: AgentTheme.background, + }, + + // 头部 + header: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 12, + borderBottomWidth: 1, + borderBottomColor: AgentTheme.border, + }, + headerButton: { + width: 40, + height: 40, + borderRadius: 20, + backgroundColor: AgentTheme.cardBg, + justifyContent: 'center', + alignItems: 'center', + }, + headerButtonText: { + fontSize: 20, + color: AgentTheme.textPrimary, + }, + headerCenter: { + flexDirection: 'row', + alignItems: 'center', + flex: 1, + justifyContent: 'center', + marginHorizontal: 12, + }, + headerAvatarContainer: { + width: 36, + height: 36, + borderRadius: 18, + overflow: 'hidden', + marginRight: 10, + borderWidth: 2, + borderColor: 'rgba(139, 92, 246, 0.5)', + }, + headerAvatar: { + width: '100%', + height: '100%', + resizeMode: 'cover', + }, + headerTitle: { + fontSize: 17, + fontWeight: '600', + color: AgentTheme.textPrimary, + maxWidth: 200, + flexShrink: 1, + }, + + // 内容区域 + content: { + flex: 1, + }, + + // 消息列表 + messageList: { + flex: 1, + }, + messageListContent: { + paddingVertical: 16, + }, +}); + +export default AgentChatScreen; diff --git a/MeAgent/src/screens/Agent/components/ChatInput.js b/MeAgent/src/screens/Agent/components/ChatInput.js new file mode 100644 index 00000000..93f19407 --- /dev/null +++ b/MeAgent/src/screens/Agent/components/ChatInput.js @@ -0,0 +1,191 @@ +/** + * ChatInput 组件 + * 聊天输入框,支持发送消息 + */ + +import React, { memo, useState, useRef } from 'react'; +import { + View, + TextInput, + TouchableOpacity, + StyleSheet, + Keyboard, + Platform, + ActivityIndicator, +} from 'react-native'; +import { BlurView } from 'expo-blur'; +import { AgentTheme } from '../../../constants/agentConstants'; + +/** + * 发送按钮图标 + */ +const SendIcon = ({ color = '#FFFFFF', size = 20 }) => ( + + + +); + +/** + * 停止按钮图标 + */ +const StopIcon = ({ color = '#FFFFFF', size = 16 }) => ( + +); + +/** + * ChatInput 组件 + */ +const ChatInput = ({ + onSend, + onCancel, + isProcessing = false, + placeholder = '输入消息...', + disabled = false, +}) => { + const [inputText, setInputText] = useState(''); + const inputRef = useRef(null); + + /** + * 处理发送 + */ + const handleSend = () => { + if (inputText.trim() && !isProcessing && !disabled) { + onSend(inputText); + setInputText(''); + Keyboard.dismiss(); + } + }; + + /** + * 处理取消 + */ + const handleCancel = () => { + if (isProcessing && onCancel) { + onCancel(); + } + }; + + /** + * 处理按键提交 + */ + const handleSubmitEditing = () => { + handleSend(); + }; + + const canSend = inputText.trim().length > 0 && !isProcessing && !disabled; + + return ( + + + + + + + {isProcessing ? ( + + + + ) : ( + + + + )} + + + ); +}; + +const styles = StyleSheet.create({ + container: { + borderTopWidth: 1, + borderTopColor: AgentTheme.border, + paddingHorizontal: 16, + paddingVertical: 12, + paddingBottom: Platform.OS === 'ios' ? 28 : 12, + }, + inputWrapper: { + flexDirection: 'row', + alignItems: 'flex-end', + }, + inputContainer: { + flex: 1, + backgroundColor: AgentTheme.backgroundSecondary, + borderRadius: 20, + borderWidth: 1, + borderColor: AgentTheme.border, + paddingHorizontal: 16, + paddingVertical: Platform.OS === 'ios' ? 10 : 6, + marginRight: 10, + minHeight: 40, + maxHeight: 120, + }, + input: { + color: AgentTheme.textPrimary, + fontSize: 15, + lineHeight: 20, + maxHeight: 100, + }, + sendButton: { + width: 40, + height: 40, + borderRadius: 20, + justifyContent: 'center', + alignItems: 'center', + }, + sendButtonActive: { + backgroundColor: AgentTheme.accent, + }, + sendButtonDisabled: { + backgroundColor: AgentTheme.textMuted, + opacity: 0.5, + }, + cancelButton: { + backgroundColor: AgentTheme.error, + }, +}); + +export default memo(ChatInput); diff --git a/MeAgent/src/screens/Agent/components/MarkdownRenderer.js b/MeAgent/src/screens/Agent/components/MarkdownRenderer.js new file mode 100644 index 00000000..1a2a3d63 --- /dev/null +++ b/MeAgent/src/screens/Agent/components/MarkdownRenderer.js @@ -0,0 +1,975 @@ +/** + * MarkdownRenderer 组件 + * 支持 Markdown 渲染和 ECharts 图表渲染 + */ + +import React, { memo, useMemo, useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + ScrollView, + TouchableOpacity, + Dimensions, + ActivityIndicator, +} from 'react-native'; +import { WebView } from 'react-native-webview'; +import { AgentTheme } from '../../../constants/agentConstants'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CHART_WIDTH = SCREEN_WIDTH - 48; // 减去边距 +const CHART_HEIGHT = 300; + +/** + * 简单的 Markdown 解析器 + * 支持:标题、粗体、斜体、代码块、链接、列表、表格、Mermaid 图 + */ +const parseMarkdown = (text) => { + if (!text) return []; + + const parts = []; + let remaining = text; + + // 匹配所有特殊代码块:echarts, mermaid + const codeBlockRegex = /```(echarts|mermaid)\s*\n?([\s\S]*?)```/g; + let lastIndex = 0; + let match; + + while ((match = codeBlockRegex.exec(text)) !== null) { + // 添加代码块前的文本 + if (match.index > lastIndex) { + const textBefore = text.substring(lastIndex, match.index); + parts.push({ type: 'markdown', content: textBefore }); + } + + const blockType = match[1]; // echarts 或 mermaid + let content = match[2].trim(); + + // 处理转义字符 + if (content.includes('\\n')) { + content = content.replace(/\\n/g, '\n'); + } + + if (blockType === 'echarts') { + parts.push({ type: 'chart', content }); + } else if (blockType === 'mermaid') { + parts.push({ type: 'mermaid', content }); + } + + lastIndex = match.index + match[0].length; + } + + // 添加剩余文本 + if (lastIndex < text.length) { + parts.push({ type: 'markdown', content: text.substring(lastIndex) }); + } + + // 如果没有匹配到任何内容,返回原始文本 + if (parts.length === 0) { + parts.push({ type: 'markdown', content: text }); + } + + return parts; +}; + +/** + * 解析表格 + */ +const parseTable = (lines, startIndex) => { + const rows = []; + let i = startIndex; + + while (i < lines.length && lines[i].trim().startsWith('|')) { + const line = lines[i].trim(); + // 跳过分隔行 |---|---| + if (!/^\|[\s-:|]+\|$/.test(line)) { + const cells = line + .split('|') + .filter((cell, idx, arr) => idx > 0 && idx < arr.length - 1) + .map(cell => cell.trim()); + if (cells.length > 0) { + rows.push(cells); + } + } + i++; + } + + return { rows, endIndex: i }; +}; + +/** + * 表格组件 + */ +const TableView = memo(({ rows }) => { + if (!rows || rows.length === 0) return null; + + const header = rows[0]; + const body = rows.slice(1); + + return ( + + + + {/* 表头 */} + + {header.map((cell, idx) => ( + + {cell} + + ))} + + {/* 表体 */} + {body.map((row, rowIdx) => ( + + {row.map((cell, cellIdx) => ( + + {cell} + + ))} + + ))} + + + + ); +}); + +/** + * Markdown 文本渲染器(支持表格) + */ +const MarkdownText = memo(({ content }) => { + const lines = content.split('\n'); + const elements = []; + let i = 0; + + while (i < lines.length) { + const line = lines[i]; + + // 检测表格开始 + if (line.trim().startsWith('|') && line.trim().endsWith('|')) { + const { rows, endIndex } = parseTable(lines, i); + if (rows.length > 1) { + elements.push(); + i = endIndex; + continue; + } + } + + elements.push(); + i++; + } + + return ( + + {elements} + + ); +}); + +/** + * 单行 Markdown 渲染 + */ +const MarkdownLine = memo(({ line }) => { + // 空行 + if (!line.trim()) { + return ; + } + + // 标题 (# ## ### ####) + const headerMatch = line.match(/^(#{1,4})\s+(.*)$/); + if (headerMatch) { + const level = headerMatch[1].length; + const text = headerMatch[2]; + const headerStyles = [ + styles.h1, + styles.h2, + styles.h3, + styles.h4, + ]; + return ( + + {renderInlineStyles(text)} + + ); + } + + // 无序列表 (- 或 *) + const listMatch = line.match(/^[\s]*[-*]\s+(.*)$/); + if (listMatch) { + return ( + + + {renderInlineStyles(listMatch[1])} + + ); + } + + // 有序列表 (1. 2. 3.) + const orderedListMatch = line.match(/^[\s]*(\d+)\.\s+(.*)$/); + if (orderedListMatch) { + return ( + + {orderedListMatch[1]}. + {renderInlineStyles(orderedListMatch[2])} + + ); + } + + // 代码块 (```) + if (line.startsWith('```')) { + return null; // 代码块在外层处理 + } + + // 引用 (>) + const quoteMatch = line.match(/^>\s*(.*)$/); + if (quoteMatch) { + return ( + + {renderInlineStyles(quoteMatch[1])} + + ); + } + + // 分隔线 (--- or ***) + if (/^[-*]{3,}$/.test(line.trim())) { + return ; + } + + // 普通段落 + return ( + {renderInlineStyles(line)} + ); +}); + +/** + * 渲染行内样式(粗体、斜体、代码、链接) + */ +const renderInlineStyles = (text) => { + if (!text) return null; + + const elements = []; + let key = 0; + + // 为简化实现,这里只做基本渲染 + // 完整实现需要递归解析 + elements.push( + + {text + .replace(/\*\*(.+?)\*\*/g, (_, m) => m) // 移除粗体标记(简化) + .replace(/\*(.+?)\*/g, (_, m) => m) // 移除斜体标记 + .replace(/`([^`]+)`/g, (_, m) => `[${m}]`) // 标记代码 + } + + ); + + return elements; +}; + +/** + * 生成 ECharts HTML + */ +const generateEChartsHTML = (option, width, height) => { + // 深色主题配色 + const darkTheme = { + backgroundColor: 'transparent', + textStyle: { + color: '#E2E8F0' + }, + title: { + textStyle: { + color: '#F8FAFC' + }, + subtextStyle: { + color: '#94A3B8' + } + }, + legend: { + textStyle: { + color: '#CBD5E1' + } + }, + xAxis: { + axisLine: { + lineStyle: { + color: '#475569' + } + }, + axisLabel: { + color: '#94A3B8' + }, + splitLine: { + lineStyle: { + color: '#334155' + } + } + }, + yAxis: { + axisLine: { + lineStyle: { + color: '#475569' + } + }, + axisLabel: { + color: '#94A3B8' + }, + splitLine: { + lineStyle: { + color: '#334155' + } + } + } + }; + + return ` + + + + + + + + + +
+ + + + `; +}; + +/** + * ECharts 图表组件 + * 使用 WebView 渲染真实的 ECharts 图表 + */ +const EChartsView = memo(({ config }) => { + const [isLoading, setIsLoading] = useState(true); + const [error, setError] = useState(null); + const [showData, setShowData] = useState(false); + + // 解析图表配置 + const { chartOption, chartTitle, chartData } = useMemo(() => { + try { + const parsed = JSON.parse(config); + return { + chartOption: config, + chartTitle: parsed?.title?.text || '图表', + chartData: parsed, + }; + } catch (e) { + return { + chartOption: null, + chartTitle: '图表', + chartData: null, + error: e.message, + }; + } + }, [config]); + + const handleMessage = useCallback((event) => { + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type === 'loaded') { + setIsLoading(false); + } else if (data.type === 'error') { + setError(data.message); + setIsLoading(false); + } + } catch (e) { + // 忽略解析错误 + } + }, []); + + // 如果配置解析失败,显示错误 + if (!chartOption) { + return ( + + + 📊 + 图表解析失败 + + 无法解析图表配置 + + ); + } + + const html = generateEChartsHTML(chartOption, CHART_WIDTH, CHART_HEIGHT); + + return ( + + + 📊 + {chartTitle} + setShowData(!showData)} + style={styles.chartToggle} + > + + {showData ? '显示图表' : '查看数据'} + + + + + {error ? ( + + 图表渲染失败: {error} + + ) : showData ? ( + // 显示数据视图 + + + {chartData?.series?.map((series, idx) => ( + + {series.name || `系列 ${idx + 1}`} + {series.data?.slice(0, 10).map((item, i) => ( + + {typeof item === 'object' ? `${item.name}: ${item.value}` : item} + + ))} + {series.data?.length > 10 && ( + ...还有 {series.data.length - 10} 项 + )} + + ))} + + + ) : ( + // 显示图表 + + {isLoading && ( + + + 加载图表中... + + )} + + + )} + + ); +}); + +/** + * 生成 Mermaid HTML + */ +const generateMermaidHTML = (code, width) => { + return ` + + + + + + + + + +
+
+${code}
+        
+
+ + + + `; +}; + +/** + * Mermaid 图表组件 + */ +const MermaidView = memo(({ code }) => { + const [height, setHeight] = useState(200); + const [isLoading, setIsLoading] = useState(true); + + const handleMessage = useCallback((event) => { + try { + const data = JSON.parse(event.nativeEvent.data); + if (data.type === 'loaded') { + setIsLoading(false); + if (data.height) { + setHeight(Math.min(data.height, 400)); // 最大高度 400 + } + } + } catch (e) { + // 忽略解析错误 + } + }, []); + + const html = generateMermaidHTML(code, CHART_WIDTH); + + return ( + + + 📊 + 流程图 + + + {isLoading && ( + + + 加载中... + + )} + + + + ); +}); + +/** + * MarkdownRenderer 主组件 + */ +const MarkdownRenderer = ({ content }) => { + const parts = useMemo(() => parseMarkdown(content), [content]); + + return ( + + {parts.map((part, index) => { + if (part.type === 'chart') { + return ; + } + if (part.type === 'mermaid') { + return ; + } + return ; + })} + + ); +}; + +const styles = StyleSheet.create({ + container: { + // 不使用 flex: 1,让内容自适应 + }, + markdownContainer: { + // 不使用 flex: 1,让内容自适应 + }, + + // 文本样式 + text: { + color: AgentTheme.textPrimary, + fontSize: 15, + lineHeight: 22, + }, + paragraph: { + color: AgentTheme.textPrimary, + fontSize: 15, + lineHeight: 22, + marginBottom: 8, + }, + emptyLine: { + height: 8, + }, + + // 标题 + headerBase: { + color: AgentTheme.textPrimary, + fontWeight: '600', + marginTop: 16, + marginBottom: 8, + }, + h1: { + fontSize: 22, + }, + h2: { + fontSize: 20, + }, + h3: { + fontSize: 18, + }, + h4: { + fontSize: 16, + }, + + // 列表 + listItem: { + flexDirection: 'row', + alignItems: 'flex-start', + marginBottom: 4, + }, + listBullet: { + color: AgentTheme.accent, + fontSize: 14, + marginRight: 8, + marginTop: 2, + }, + listNumber: { + color: AgentTheme.accent, + fontSize: 14, + marginRight: 8, + minWidth: 20, + }, + listText: { + color: AgentTheme.textPrimary, + fontSize: 15, + lineHeight: 22, + flex: 1, + }, + + // 引用 + quote: { + borderLeftWidth: 3, + borderLeftColor: AgentTheme.accent, + paddingLeft: 12, + marginVertical: 8, + backgroundColor: 'rgba(139, 92, 246, 0.1)', + paddingVertical: 8, + borderRadius: 4, + }, + quoteText: { + color: AgentTheme.textSecondary, + fontSize: 14, + fontStyle: 'italic', + }, + + // 行内样式 + bold: { + fontWeight: '700', + }, + italic: { + fontStyle: 'italic', + }, + inlineCode: { + fontFamily: 'monospace', + backgroundColor: 'rgba(139, 92, 246, 0.2)', + paddingHorizontal: 4, + borderRadius: 3, + fontSize: 13, + color: AgentTheme.accent, + }, + + // 分隔线 + hr: { + height: 1, + backgroundColor: AgentTheme.border, + marginVertical: 16, + }, + + // 图表容器 + chartContainer: { + backgroundColor: 'rgba(99, 102, 241, 0.1)', + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(99, 102, 241, 0.3)', + padding: 12, + marginVertical: 12, + overflow: 'hidden', + }, + chartHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + chartIcon: { + fontSize: 18, + marginRight: 8, + }, + chartTitle: { + color: AgentTheme.accentSecondary, + fontSize: 15, + fontWeight: '600', + flex: 1, + }, + chartToggle: { + paddingHorizontal: 12, + paddingVertical: 4, + backgroundColor: 'rgba(99, 102, 241, 0.2)', + borderRadius: 12, + }, + chartToggleText: { + color: AgentTheme.accentSecondary, + fontSize: 12, + }, + + // WebView 图表 + chartWebViewContainer: { + height: CHART_HEIGHT, + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(15, 23, 42, 0.5)', + }, + chartWebView: { + flex: 1, + backgroundColor: 'transparent', + }, + chartLoading: { + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + justifyContent: 'center', + alignItems: 'center', + zIndex: 1, + }, + chartLoadingText: { + color: AgentTheme.textSecondary, + fontSize: 12, + marginTop: 8, + }, + + // 图表错误 + chartErrorContainer: { + padding: 20, + alignItems: 'center', + }, + chartError: { + color: '#F87171', + fontSize: 13, + textAlign: 'center', + }, + + // 数据展示 + chartDataScroll: { + maxHeight: 200, + marginTop: 8, + }, + chartDataContainer: { + flexDirection: 'row', + }, + seriesContainer: { + marginRight: 16, + minWidth: 120, + }, + seriesName: { + color: AgentTheme.textPrimary, + fontSize: 13, + fontWeight: '600', + marginBottom: 4, + }, + dataItem: { + color: AgentTheme.textSecondary, + fontSize: 12, + marginVertical: 1, + }, + dataMore: { + color: AgentTheme.textMuted, + fontSize: 11, + marginTop: 4, + }, + + // 表格样式 + tableContainer: { + marginVertical: 12, + borderRadius: 8, + overflow: 'hidden', + borderWidth: 1, + borderColor: AgentTheme.border, + }, + tableHeader: { + flexDirection: 'row', + backgroundColor: 'rgba(99, 102, 241, 0.15)', + }, + tableHeaderCell: { + backgroundColor: 'rgba(99, 102, 241, 0.15)', + }, + tableHeaderText: { + color: AgentTheme.textPrimary, + fontSize: 13, + fontWeight: '600', + }, + tableRow: { + flexDirection: 'row', + borderTopWidth: 1, + borderTopColor: AgentTheme.border, + }, + tableRowEven: { + backgroundColor: 'rgba(30, 41, 59, 0.5)', + }, + tableCell: { + minWidth: 100, + paddingHorizontal: 12, + paddingVertical: 10, + }, + tableCellText: { + color: AgentTheme.textSecondary, + fontSize: 13, + lineHeight: 18, + }, + + // Mermaid 样式 + mermaidContainer: { + backgroundColor: 'rgba(139, 92, 246, 0.1)', + borderRadius: 12, + borderWidth: 1, + borderColor: 'rgba(139, 92, 246, 0.3)', + padding: 12, + marginVertical: 12, + overflow: 'hidden', + }, + mermaidHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 8, + }, + mermaidIcon: { + fontSize: 18, + marginRight: 8, + }, + mermaidTitle: { + color: AgentTheme.accent, + fontSize: 15, + fontWeight: '600', + }, + mermaidWebViewContainer: { + borderRadius: 8, + overflow: 'hidden', + backgroundColor: 'rgba(15, 23, 42, 0.5)', + }, + mermaidWebView: { + flex: 1, + backgroundColor: 'transparent', + }, +}); + +export default memo(MarkdownRenderer); diff --git a/MeAgent/src/screens/Agent/components/MessageBubble.js b/MeAgent/src/screens/Agent/components/MessageBubble.js new file mode 100644 index 00000000..b1f5543b --- /dev/null +++ b/MeAgent/src/screens/Agent/components/MessageBubble.js @@ -0,0 +1,743 @@ +/** + * MessageBubble 组件 + * 消息气泡,支持不同消息类型的渲染 + * 支持 Markdown 渲染和图表展示 + */ + +import React, { memo, useState, useEffect, useRef } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ActivityIndicator, + Animated, + LayoutAnimation, + Platform, + UIManager, + ScrollView, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { MessageTypes, AgentTheme } from '../../../constants/agentConstants'; +import MarkdownRenderer from './MarkdownRenderer'; + +// 启用 LayoutAnimation (Android) +if (Platform.OS === 'android' && UIManager.setLayoutAnimationEnabledExperimental) { + UIManager.setLayoutAnimationEnabledExperimental(true); +} + +/** + * 用户消息气泡 + */ +const UserBubble = memo(({ content }) => ( + + + {content} + + +)); + +/** + * 思考中气泡 - 带动画效果 + */ +const ThinkingBubble = memo(({ content }) => { + const pulseAnim = useRef(new Animated.Value(1)).current; + + useEffect(() => { + const pulse = Animated.loop( + Animated.sequence([ + Animated.timing(pulseAnim, { + toValue: 0.6, + duration: 800, + useNativeDriver: true, + }), + Animated.timing(pulseAnim, { + toValue: 1, + duration: 800, + useNativeDriver: true, + }), + ]) + ); + pulse.start(); + return () => pulse.stop(); + }, []); + + return ( + + + + + {content || '正在思考...'} + + + + + + + ); +}); + +/** + * 思考中动态点 + */ +const ThinkingDots = memo(() => { + const [dots, setDots] = useState(''); + + useEffect(() => { + const interval = setInterval(() => { + setDots(prev => prev.length >= 3 ? '' : prev + '.'); + }, 500); + return () => clearInterval(interval); + }, []); + + return {dots}; +}); + +/** + * 深度思考气泡(可折叠)- 类似 Gemini 风格 + */ +const DeepThinkingBubble = memo(({ content, isStreaming }) => { + const [isExpanded, setIsExpanded] = useState(true); + const charCount = content?.length || 0; + const rotateAnim = useRef(new Animated.Value(0)).current; + const sparkleAnim = useRef(new Animated.Value(0)).current; + + // 折叠动画 + useEffect(() => { + Animated.timing(rotateAnim, { + toValue: isExpanded ? 1 : 0, + duration: 200, + useNativeDriver: true, + }).start(); + }, [isExpanded]); + + // 思考中闪烁动画 + useEffect(() => { + if (isStreaming) { + const sparkle = Animated.loop( + Animated.sequence([ + Animated.timing(sparkleAnim, { + toValue: 1, + duration: 1000, + useNativeDriver: true, + }), + Animated.timing(sparkleAnim, { + toValue: 0, + duration: 1000, + useNativeDriver: true, + }), + ]) + ); + sparkle.start(); + return () => sparkle.stop(); + } + }, [isStreaming]); + + const handleToggle = () => { + if (!isStreaming) { + LayoutAnimation.configureNext(LayoutAnimation.Presets.easeInEaseOut); + setIsExpanded(!isExpanded); + } + }; + + const rotation = rotateAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '180deg'], + }); + + return ( + + + {/* 标题栏 */} + + + {isStreaming ? ( + + + + ) : ( + 🧠 + )} + + {isStreaming ? '深度思考中...' : '深度思考'} + + {isStreaming && ( + + )} + + + + {charCount > 0 && ( + + {charCount} 字 + + )} + {!isStreaming && ( + + + + )} + + + + {/* 思考内容 */} + {(isExpanded || isStreaming) && content && ( + + {content} + {isStreaming && ( + + + + )} + + )} + + + ); +}); + +/** + * 执行计划气泡 + */ +const PlanBubble = memo(({ content, plan }) => ( + + + + 📋 + 执行计划 + + {plan?.goal || content} + {plan?.steps && ( + + {plan.steps.map((step, index) => ( + + + {index + 1} + + + {step.tool} + {step.reason && ( + {step.reason} + )} + + + ))} + + )} + + +)); + +/** + * 执行中气泡 - 带进度显示 + */ +const ExecutingBubble = memo(({ content, stepResults = [], currentStep }) => { + const spinAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + const spin = Animated.loop( + Animated.timing(spinAnim, { + toValue: 1, + duration: 1500, + useNativeDriver: true, + }) + ); + spin.start(); + return () => spin.stop(); + }, []); + + const rotation = spinAnim.interpolate({ + inputRange: [0, 1], + outputRange: ['0deg', '360deg'], + }); + + return ( + + + + + ⚙️ + + {content || '正在执行...'} + + + {stepResults.length > 0 && ( + + {stepResults.map((result, index) => ( + + + {result.status === 'success' ? '✓' : '✗'} + + {result.tool} + {result.execution_time && ( + + {(result.execution_time * 1000).toFixed(0)}ms + + )} + + ))} + + )} + + + ); +}); + +/** + * AI 响应气泡 - 支持 Markdown 和图表 + */ +const ResponseBubble = memo(({ content, isStreaming }) => { + const cursorAnim = useRef(new Animated.Value(0)).current; + + useEffect(() => { + if (isStreaming) { + const blink = Animated.loop( + Animated.sequence([ + Animated.timing(cursorAnim, { + toValue: 1, + duration: 500, + useNativeDriver: true, + }), + Animated.timing(cursorAnim, { + toValue: 0, + duration: 500, + useNativeDriver: true, + }), + ]) + ); + blink.start(); + return () => blink.stop(); + } + }, [isStreaming]); + + return ( + + + + {isStreaming && ( + + + + )} + + + ); +}); + +/** + * 错误气泡 + */ +const ErrorBubble = memo(({ content }) => ( + + + ⚠️ + + 出错了 + {content} + + + +)); + +/** + * MessageBubble 主组件 + */ +const MessageBubble = ({ message }) => { + const { type, content, isStreaming, plan, stepResults, currentStep, thinkingContent } = message; + + switch (type) { + case MessageTypes.USER: + return ; + + case MessageTypes.AGENT_THINKING: + return ; + + case MessageTypes.AGENT_DEEP_THINKING: + return ; + + case MessageTypes.AGENT_PLAN: + return ; + + case MessageTypes.AGENT_EXECUTING: + return ; + + case MessageTypes.AGENT_RESPONSE: + return ; + + case MessageTypes.ERROR: + return ; + + default: + return ; + } +}; + +const styles = StyleSheet.create({ + // 用户消息 + userBubbleContainer: { + width: '100%', + alignItems: 'flex-end', + marginVertical: 6, + paddingHorizontal: 16, + }, + userBubble: { + maxWidth: '80%', + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 18, + borderBottomRightRadius: 4, + }, + userText: { + color: '#FFFFFF', + fontSize: 15, + lineHeight: 22, + }, + + // AI 消息容器 + agentBubbleContainer: { + width: '100%', + alignItems: 'flex-start', + marginVertical: 6, + paddingHorizontal: 16, + }, + + // 思考中 + thinkingBubble: { + maxWidth: '70%', + backgroundColor: AgentTheme.cardBg, + paddingHorizontal: 16, + paddingVertical: 12, + borderRadius: 16, + borderWidth: 1, + borderColor: AgentTheme.border, + }, + thinkingRow: { + flexDirection: 'row', + alignItems: 'center', + }, + thinkingText: { + color: AgentTheme.textSecondary, + fontSize: 14, + marginLeft: 10, + }, + thinkingDots: { + marginTop: 4, + marginLeft: 34, + }, + dots: { + color: AgentTheme.accent, + fontSize: 18, + letterSpacing: 2, + }, + + // 深度思考 + deepThinkingBubble: { + maxWidth: '92%', + minWidth: '60%', + backgroundColor: 'rgba(139, 92, 246, 0.08)', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(139, 92, 246, 0.25)', + overflow: 'hidden', + }, + deepThinkingBubbleActive: { + borderColor: 'rgba(139, 92, 246, 0.5)', + shadowColor: '#8B5CF6', + shadowOffset: { width: 0, height: 0 }, + shadowOpacity: 0.3, + shadowRadius: 12, + elevation: 8, + }, + deepThinkingHeader: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 14, + paddingVertical: 12, + backgroundColor: 'rgba(139, 92, 246, 0.12)', + borderBottomWidth: 1, + borderBottomColor: 'rgba(139, 92, 246, 0.15)', + }, + deepThinkingTitleRow: { + flexDirection: 'row', + alignItems: 'center', + }, + sparkleIcon: { + fontSize: 16, + marginRight: 8, + }, + brainIcon: { + fontSize: 16, + marginRight: 8, + }, + deepThinkingTitle: { + color: AgentTheme.accent, + fontSize: 14, + fontWeight: '600', + }, + deepThinkingMeta: { + flexDirection: 'row', + alignItems: 'center', + }, + charCountBadge: { + backgroundColor: 'rgba(139, 92, 246, 0.2)', + paddingHorizontal: 8, + paddingVertical: 2, + borderRadius: 10, + marginRight: 8, + }, + charCount: { + color: AgentTheme.accent, + fontSize: 11, + fontWeight: '500', + }, + expandIcon: { + color: AgentTheme.textMuted, + fontSize: 10, + }, + deepThinkingContent: { + paddingHorizontal: 14, + paddingVertical: 12, + maxHeight: 250, + }, + deepThinkingContentStreaming: { + maxHeight: 300, + }, + deepThinkingText: { + color: AgentTheme.textSecondary, + fontSize: 13, + lineHeight: 20, + fontStyle: 'italic', + }, + cursorContainer: { + marginTop: 4, + }, + cursor: { + width: 2, + height: 16, + backgroundColor: AgentTheme.accent, + borderRadius: 1, + }, + + // 执行计划 + planBubble: { + maxWidth: '92%', + backgroundColor: 'rgba(16, 185, 129, 0.08)', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(16, 185, 129, 0.25)', + padding: 14, + }, + planHeader: { + flexDirection: 'row', + alignItems: 'center', + marginBottom: 10, + }, + planIcon: { + fontSize: 18, + marginRight: 8, + }, + planTitle: { + color: AgentTheme.success, + fontSize: 15, + fontWeight: '600', + }, + planGoal: { + color: AgentTheme.textPrimary, + fontSize: 14, + marginBottom: 12, + lineHeight: 20, + }, + planSteps: { + marginTop: 4, + }, + planStep: { + flexDirection: 'row', + alignItems: 'flex-start', + marginVertical: 6, + }, + stepNumber: { + width: 22, + height: 22, + borderRadius: 11, + backgroundColor: 'rgba(16, 185, 129, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginRight: 10, + }, + stepNumberText: { + color: AgentTheme.success, + fontSize: 12, + fontWeight: '600', + }, + stepContent: { + flex: 1, + }, + stepTool: { + color: AgentTheme.textPrimary, + fontSize: 13, + fontWeight: '500', + }, + stepReason: { + color: AgentTheme.textMuted, + fontSize: 12, + marginTop: 2, + }, + + // 执行中 + executingBubble: { + maxWidth: '92%', + backgroundColor: 'rgba(99, 102, 241, 0.08)', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(99, 102, 241, 0.25)', + padding: 14, + }, + executingHeader: { + flexDirection: 'row', + alignItems: 'center', + }, + gearIcon: { + fontSize: 18, + marginRight: 10, + }, + executingTitle: { + color: AgentTheme.accentSecondary, + fontSize: 14, + fontWeight: '500', + }, + stepResults: { + marginTop: 12, + borderTopWidth: 1, + borderTopColor: 'rgba(99, 102, 241, 0.15)', + paddingTop: 10, + }, + stepResult: { + flexDirection: 'row', + alignItems: 'center', + marginVertical: 4, + }, + stepStatus: { + fontSize: 14, + marginRight: 8, + width: 18, + }, + stepSuccess: { + color: AgentTheme.success, + }, + stepFailed: { + color: AgentTheme.error, + }, + stepName: { + color: AgentTheme.textSecondary, + fontSize: 13, + flex: 1, + }, + stepTime: { + color: AgentTheme.textMuted, + fontSize: 11, + }, + + // AI 响应 + responseBubble: { + maxWidth: '92%', + minWidth: '50%', + backgroundColor: AgentTheme.cardBg, + paddingHorizontal: 16, + paddingVertical: 14, + borderRadius: 18, + borderBottomLeftRadius: 4, + borderWidth: 1, + borderColor: AgentTheme.border, + }, + streamingIndicator: { + marginTop: 8, + }, + streamingCursor: { + width: 2, + height: 16, + backgroundColor: AgentTheme.accent, + borderRadius: 1, + }, + + // 错误 + errorBubble: { + maxWidth: '85%', + backgroundColor: 'rgba(239, 68, 68, 0.08)', + borderRadius: 16, + borderWidth: 1, + borderColor: 'rgba(239, 68, 68, 0.25)', + paddingHorizontal: 14, + paddingVertical: 12, + flexDirection: 'row', + alignItems: 'flex-start', + }, + errorIcon: { + fontSize: 18, + marginRight: 10, + marginTop: 2, + }, + errorContent: { + flex: 1, + }, + errorTitle: { + color: AgentTheme.error, + fontSize: 14, + fontWeight: '600', + marginBottom: 4, + }, + errorText: { + color: AgentTheme.textSecondary, + fontSize: 13, + lineHeight: 18, + }, +}); + +export default memo(MessageBubble); diff --git a/MeAgent/src/screens/Agent/components/SessionDrawer.js b/MeAgent/src/screens/Agent/components/SessionDrawer.js new file mode 100644 index 00000000..c3050a7c --- /dev/null +++ b/MeAgent/src/screens/Agent/components/SessionDrawer.js @@ -0,0 +1,402 @@ +/** + * SessionDrawer 组件 + * 会话历史抽屉,支持会话列表、搜索、新建 + */ + +import React, { memo, useState, useCallback } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + TextInput, + FlatList, + Modal, + Dimensions, + Animated, +} from 'react-native'; +import { BlurView } from 'expo-blur'; +import { AgentTheme } from '../../../constants/agentConstants'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const DRAWER_WIDTH = SCREEN_WIDTH * 0.85; + +/** + * 会话卡片 + */ +const SessionCard = memo(({ session, isActive, onPress }) => { + const title = session.title || '新对话'; + const messageCount = session.message_count || 0; + + return ( + onPress(session.session_id)} + activeOpacity={0.7} + > + + 💬 + + + + {title} + + + {messageCount} 条消息 + + + + ); +}); + +/** + * 日期分组标题 + */ +const DateGroupHeader = memo(({ title }) => ( + + {title} + +)); + +/** + * SessionDrawer 组件 + */ +const SessionDrawer = ({ + visible, + onClose, + groupedSessions = [], + currentSessionId, + onSelectSession, + onNewSession, + isLoading, +}) => { + const [searchText, setSearchText] = useState(''); + + /** + * 过滤会话 + */ + const filteredGroups = useCallback(() => { + if (!searchText.trim()) { + return groupedSessions; + } + + const lowerKeyword = searchText.toLowerCase(); + + return groupedSessions + .map(group => ({ + ...group, + sessions: group.sessions.filter(session => + (session.title || '').toLowerCase().includes(lowerKeyword) || + (session.session_id || '').toLowerCase().includes(lowerKeyword) + ), + })) + .filter(group => group.sessions.length > 0); + }, [groupedSessions, searchText]); + + /** + * 渲染会话项 + */ + const renderSessionItem = ({ item: session }) => ( + + ); + + /** + * 渲染分组 + */ + const renderGroup = ({ item: group }) => ( + + + {group.sessions.map(session => ( + + ))} + + ); + + const groups = filteredGroups(); + + return ( + + + {/* 点击遮罩关闭 */} + + + {/* 抽屉内容 */} + + + {/* 头部 */} + + 对话历史 + + + + + + {/* 新建按钮 */} + + + + 新建对话 + + + {/* 搜索框 */} + + 🔍 + + {searchText.length > 0 && ( + setSearchText('')} + style={styles.clearButton} + > + + + )} + + + {/* 会话列表 */} + {isLoading ? ( + + 加载中... + + ) : groups.length === 0 ? ( + + 📭 + + {searchText ? '没有找到匹配的对话' : '暂无对话历史'} + + + ) : ( + item.title} + showsVerticalScrollIndicator={false} + contentContainerStyle={styles.listContent} + /> + )} + + + + + ); +}; + +const styles = StyleSheet.create({ + overlay: { + flex: 1, + flexDirection: 'row', + }, + backdrop: { + flex: 1, + backgroundColor: 'rgba(0, 0, 0, 0.5)', + }, + drawer: { + width: DRAWER_WIDTH, + backgroundColor: AgentTheme.background, + }, + drawerContent: { + flex: 1, + }, + + // 头部 + header: { + flexDirection: 'row', + justifyContent: 'space-between', + alignItems: 'center', + paddingHorizontal: 20, + paddingTop: 60, + paddingBottom: 16, + borderBottomWidth: 1, + borderBottomColor: AgentTheme.border, + }, + headerTitle: { + fontSize: 20, + fontWeight: '700', + color: AgentTheme.textPrimary, + }, + closeButton: { + width: 32, + height: 32, + borderRadius: 16, + backgroundColor: AgentTheme.cardBg, + justifyContent: 'center', + alignItems: 'center', + }, + closeButtonText: { + fontSize: 16, + color: AgentTheme.textSecondary, + }, + + // 新建按钮 + newButton: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'center', + marginHorizontal: 20, + marginTop: 16, + paddingVertical: 12, + backgroundColor: AgentTheme.accent, + borderRadius: 12, + }, + newButtonIcon: { + fontSize: 20, + color: '#FFFFFF', + marginRight: 8, + }, + newButtonText: { + fontSize: 15, + fontWeight: '600', + color: '#FFFFFF', + }, + + // 搜索框 + searchContainer: { + flexDirection: 'row', + alignItems: 'center', + marginHorizontal: 20, + marginTop: 16, + marginBottom: 8, + paddingHorizontal: 12, + paddingVertical: 10, + backgroundColor: AgentTheme.cardBg, + borderRadius: 10, + borderWidth: 1, + borderColor: AgentTheme.border, + }, + searchIcon: { + fontSize: 14, + marginRight: 8, + }, + searchInput: { + flex: 1, + fontSize: 14, + color: AgentTheme.textPrimary, + }, + clearButton: { + padding: 4, + }, + clearButtonText: { + fontSize: 12, + color: AgentTheme.textMuted, + }, + + // 列表 + listContent: { + paddingHorizontal: 20, + paddingBottom: 40, + }, + groupContainer: { + marginTop: 16, + }, + + // 日期分组 + dateGroup: { + paddingVertical: 8, + }, + dateGroupText: { + fontSize: 12, + fontWeight: '600', + color: AgentTheme.textMuted, + textTransform: 'uppercase', + }, + + // 会话卡片 + sessionCard: { + flexDirection: 'row', + alignItems: 'center', + paddingHorizontal: 12, + paddingVertical: 12, + backgroundColor: AgentTheme.cardBg, + borderRadius: 10, + marginVertical: 4, + borderWidth: 1, + borderColor: 'transparent', + }, + sessionCardActive: { + borderColor: AgentTheme.accent, + backgroundColor: 'rgba(139, 92, 246, 0.1)', + }, + sessionIcon: { + width: 36, + height: 36, + borderRadius: 18, + backgroundColor: AgentTheme.backgroundSecondary, + justifyContent: 'center', + alignItems: 'center', + marginRight: 12, + }, + sessionIconText: { + fontSize: 18, + }, + sessionInfo: { + flex: 1, + }, + sessionTitle: { + fontSize: 14, + fontWeight: '500', + color: AgentTheme.textPrimary, + marginBottom: 2, + }, + sessionMeta: { + fontSize: 12, + color: AgentTheme.textMuted, + }, + + // 加载和空状态 + loadingContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + }, + loadingText: { + fontSize: 14, + color: AgentTheme.textMuted, + }, + emptyContainer: { + flex: 1, + justifyContent: 'center', + alignItems: 'center', + paddingHorizontal: 40, + }, + emptyIcon: { + fontSize: 48, + marginBottom: 16, + }, + emptyText: { + fontSize: 14, + color: AgentTheme.textMuted, + textAlign: 'center', + }, +}); + +export default memo(SessionDrawer); diff --git a/MeAgent/src/screens/Agent/components/WelcomeScreen.js b/MeAgent/src/screens/Agent/components/WelcomeScreen.js new file mode 100644 index 00000000..c2f13105 --- /dev/null +++ b/MeAgent/src/screens/Agent/components/WelcomeScreen.js @@ -0,0 +1,335 @@ +/** + * WelcomeScreen 组件 + * Agent 聊天欢迎界面 - Bento Card 风格 + */ + +import React, { memo } from 'react'; +import { + View, + Text, + StyleSheet, + TouchableOpacity, + ScrollView, + Image, + Dimensions, +} from 'react-native'; +import { LinearGradient } from 'expo-linear-gradient'; +import { AgentTheme, QUICK_QUESTIONS, AGENT_NAME } from '../../../constants/agentConstants'; + +const { width: SCREEN_WIDTH } = Dimensions.get('window'); +const CARD_WIDTH = (SCREEN_WIDTH - 48 - 12) / 2; // 两列布局 + +// 洛希头像 +const LuoxiAvatar = require('../../../../assets/imgs/luoxi.jpg'); + +/** + * AI 头像组件 + */ +const AIAvatar = () => ( + + + + +); + +/** + * Bento 功能卡片 + */ +const BentoCard = memo(({ icon, title, description, color, size = 'small' }) => ( + + {icon} + {title} + {description} + +)); + +/** + * 快捷问题卡片 + */ +const QuickQuestionCard = memo(({ question, onPress, index }) => ( + onPress(question)} + activeOpacity={0.7} + > + + + {question} + + + + + + +)); + +/** + * WelcomeScreen 组件 + */ +const WelcomeScreen = ({ onQuickQuestion }) => { + return ( + + {/* 头部 - 洛希介绍 */} + + + {AGENT_NAME} + AI 投研助手 · 洛希极限 + 在市场的混沌中,找到价值与风险的平衡点 + + + {/* Bento Grid 功能展示 */} + + + + + + + + + + + + {/* 快捷问题 */} + + + 快速开始 + 试试这些问题 + + + {QUICK_QUESTIONS.map((question, index) => ( + + ))} + + + + {/* 底部提示 */} + + + 💡 + + 直接输入股票代码或名称,我会为你进行全面分析 + + + + + ); +}; + +const styles = StyleSheet.create({ + container: { + flex: 1, + }, + contentContainer: { + paddingHorizontal: 20, + paddingTop: 20, + paddingBottom: 30, + }, + + // 头部 + header: { + alignItems: 'center', + marginBottom: 28, + }, + avatarContainer: { + width: 88, + height: 88, + marginBottom: 16, + position: 'relative', + }, + avatarGlow: { + position: 'absolute', + width: 96, + height: 96, + borderRadius: 48, + top: -4, + left: -4, + }, + avatar: { + width: 88, + height: 88, + borderRadius: 44, + borderWidth: 3, + borderColor: 'rgba(139, 92, 246, 0.5)', + }, + title: { + fontSize: 32, + fontWeight: '800', + color: AgentTheme.textPrimary, + marginBottom: 4, + letterSpacing: 1, + }, + subtitle: { + fontSize: 15, + color: AgentTheme.accent, + fontWeight: '500', + marginBottom: 8, + }, + tagline: { + fontSize: 13, + color: AgentTheme.textMuted, + textAlign: 'center', + paddingHorizontal: 20, + }, + + // Bento Grid + bentoGrid: { + marginBottom: 28, + }, + bentoRow: { + flexDirection: 'row', + justifyContent: 'space-between', + marginBottom: 12, + }, + bentoCard: { + width: CARD_WIDTH, + backgroundColor: AgentTheme.cardBg, + borderRadius: 16, + padding: 16, + borderWidth: 1, + borderColor: AgentTheme.border, + borderLeftWidth: 3, + }, + bentoCardLarge: { + width: '100%', + }, + bentoIcon: { + fontSize: 24, + marginBottom: 8, + }, + bentoTitle: { + fontSize: 15, + fontWeight: '600', + color: AgentTheme.textPrimary, + marginBottom: 4, + }, + bentoDesc: { + fontSize: 12, + color: AgentTheme.textMuted, + }, + + // 快捷问题区域 + quickSection: { + marginBottom: 20, + }, + sectionHeader: { + marginBottom: 14, + }, + sectionTitle: { + fontSize: 18, + fontWeight: '700', + color: AgentTheme.textPrimary, + marginBottom: 2, + }, + sectionSubtitle: { + fontSize: 13, + color: AgentTheme.textMuted, + }, + questionList: { + gap: 10, + }, + questionCard: { + borderRadius: 14, + overflow: 'hidden', + marginBottom: 8, + }, + questionGradient: { + borderRadius: 14, + borderWidth: 1, + borderColor: 'rgba(139, 92, 246, 0.2)', + }, + questionContent: { + flexDirection: 'row', + alignItems: 'center', + justifyContent: 'space-between', + paddingHorizontal: 16, + paddingVertical: 14, + }, + questionText: { + fontSize: 14, + color: AgentTheme.textPrimary, + flex: 1, + }, + questionArrowContainer: { + width: 28, + height: 28, + borderRadius: 14, + backgroundColor: 'rgba(139, 92, 246, 0.2)', + justifyContent: 'center', + alignItems: 'center', + marginLeft: 12, + }, + questionArrow: { + fontSize: 14, + color: AgentTheme.accent, + fontWeight: '600', + }, + + // 底部提示 + footer: { + marginTop: 8, + }, + tipCard: { + flexDirection: 'row', + alignItems: 'center', + backgroundColor: 'rgba(245, 158, 11, 0.1)', + borderRadius: 12, + padding: 14, + borderWidth: 1, + borderColor: 'rgba(245, 158, 11, 0.2)', + }, + tipIcon: { + fontSize: 18, + marginRight: 10, + }, + tipText: { + fontSize: 13, + color: AgentTheme.textSecondary, + flex: 1, + lineHeight: 18, + }, +}); + +export default memo(WelcomeScreen); diff --git a/MeAgent/src/screens/Agent/components/index.js b/MeAgent/src/screens/Agent/components/index.js new file mode 100644 index 00000000..d31f7dc8 --- /dev/null +++ b/MeAgent/src/screens/Agent/components/index.js @@ -0,0 +1,9 @@ +/** + * Agent 组件导出 + */ + +export { default as MessageBubble } from './MessageBubble'; +export { default as ChatInput } from './ChatInput'; +export { default as WelcomeScreen } from './WelcomeScreen'; +export { default as SessionDrawer } from './SessionDrawer'; +export { default as MarkdownRenderer } from './MarkdownRenderer'; diff --git a/MeAgent/src/screens/Agent/index.js b/MeAgent/src/screens/Agent/index.js new file mode 100644 index 00000000..14f635c3 --- /dev/null +++ b/MeAgent/src/screens/Agent/index.js @@ -0,0 +1,5 @@ +/** + * Agent 模块导出 + */ + +export { default as AgentChatScreen } from './AgentChatScreen'; diff --git a/MeAgent/src/services/agentService.js b/MeAgent/src/services/agentService.js new file mode 100644 index 00000000..b6ae2db3 --- /dev/null +++ b/MeAgent/src/services/agentService.js @@ -0,0 +1,439 @@ +/** + * Agent 服务层 + * 处理与 MCP Server 的通信,包括 SSE 流式聊天、会话管理等 + */ + +import { AGENT_API_CONFIG, SSEEventTypes } from '../constants/agentConstants'; + +const { BASE_URL, ENDPOINTS, DEFAULT_TIMEOUT, MAX_HISTORY_LIMIT, MAX_SESSIONS_LIMIT } = AGENT_API_CONFIG; + +/** + * SSE 流式聊天请求 (React Native 兼容版本) + * 使用 XMLHttpRequest 处理流式响应,因为 React Native 的 fetch 不支持 response.body.getReader() + * + * @param {Object} params - 请求参数 + * @param {string} params.message - 用户消息 + * @param {Array} params.conversationHistory - 对话历史 + * @param {string} params.userId - 用户ID + * @param {string} params.userNickname - 用户昵称 + * @param {string} params.userAvatar - 用户头像 + * @param {string} params.subscriptionType - 订阅类型 + * @param {string|null} params.sessionId - 会话ID + * @param {string} params.model - 模型名称 + * @param {Array} params.tools - 工具列表 + * @param {Function} onEvent - SSE 事件回调 + * @param {AbortSignal} signal - 取消信号 + * @returns {Promise} + */ +export const streamAgentChat = async (params, onEvent, signal) => { + const { + message, + conversationHistory = [], + userId, + userNickname, + userAvatar, + subscriptionType = 'free', + sessionId = null, + model = 'deepmoney', + tools = [], + } = params; + + const requestBody = { + message, + conversation_history: conversationHistory.map(msg => ({ + isUser: msg.isUser, + content: msg.content, + })), + user_id: String(userId), // 确保 user_id 是字符串类型 + user_nickname: userNickname || '', + user_avatar: userAvatar || '', + subscription_type: subscriptionType, + session_id: sessionId, + model, + tools, + }; + + return new Promise((resolve, reject) => { + const xhr = new XMLHttpRequest(); + let buffer = ''; + let lastProcessedIndex = 0; + + // 处理取消信号 + if (signal) { + signal.addEventListener('abort', () => { + xhr.abort(); + }); + } + + xhr.open('POST', `${BASE_URL}${ENDPOINTS.CHAT_STREAM}`, true); + xhr.setRequestHeader('Content-Type', 'application/json'); + xhr.setRequestHeader('Accept', 'text/event-stream'); + xhr.withCredentials = true; // 携带 Cookie + + xhr.onreadystatechange = () => { + // readyState 3 = LOADING (接收到部分数据) + if (xhr.readyState === 3 || xhr.readyState === 4) { + // 获取新增的响应文本 + const newData = xhr.responseText.substring(lastProcessedIndex); + lastProcessedIndex = xhr.responseText.length; + + if (newData) { + buffer += newData; + + // 按双换行符分割事件 + const events = buffer.split('\n\n'); + // 最后一个可能不完整,保留在 buffer 中 + buffer = events.pop() || ''; + + // 处理完整的事件 + for (const eventStr of events) { + if (eventStr.trim()) { + processSSEEvent(eventStr, onEvent); + } + } + } + } + + // readyState 4 = DONE (请求完成) + if (xhr.readyState === 4) { + // 处理剩余的 buffer + if (buffer.trim()) { + processSSEBuffer(buffer, onEvent); + } + + if (xhr.status >= 200 && xhr.status < 300) { + onEvent({ type: 'stream_end' }); + resolve(); + } else if (xhr.status === 0) { + // 请求被取消 + console.log('[AgentService] 请求被取消'); + onEvent({ type: 'aborted' }); + reject(new DOMException('Aborted', 'AbortError')); + } else { + const errorMessage = `HTTP ${xhr.status}: ${xhr.responseText}`; + console.error('[AgentService] SSE 错误:', errorMessage); + onEvent({ + type: SSEEventTypes.ERROR, + data: { message: xhr.responseText || '网络请求失败' } + }); + reject(new Error(errorMessage)); + } + } + }; + + xhr.onerror = () => { + console.error('[AgentService] 网络错误'); + onEvent({ + type: SSEEventTypes.ERROR, + data: { message: '网络连接失败,请检查网络设置' } + }); + reject(new Error('网络连接失败')); + }; + + xhr.ontimeout = () => { + console.error('[AgentService] 请求超时'); + onEvent({ + type: SSEEventTypes.ERROR, + data: { message: '请求超时,请稍后重试' } + }); + reject(new Error('请求超时')); + }; + + xhr.timeout = DEFAULT_TIMEOUT; + xhr.send(JSON.stringify(requestBody)); + }); +}; + +/** + * 处理 SSE 缓冲区 + */ +const processSSEBuffer = (buffer, onEvent) => { + const lines = buffer.split('\n'); + let eventType = null; + let eventData = null; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const dataStr = line.slice(5).trim(); + if (dataStr === '[DONE]') { + onEvent({ type: SSEEventTypes.DONE }); + return; + } + try { + eventData = JSON.parse(dataStr); + } catch (e) { + eventData = { raw: dataStr }; + } + } + } + + if (eventData) { + onEvent({ type: eventType || 'message', data: eventData }); + } +}; + +/** + * 处理单个 SSE 事件 + */ +const processSSEEvent = (eventStr, onEvent) => { + const lines = eventStr.split('\n'); + let eventType = null; + let eventData = null; + + for (const line of lines) { + if (line.startsWith('event:')) { + eventType = line.slice(6).trim(); + } else if (line.startsWith('data:')) { + const dataStr = line.slice(5).trim(); + + // 检查是否是结束标记 + if (dataStr === '[DONE]') { + onEvent({ type: SSEEventTypes.DONE }); + return; + } + + try { + eventData = JSON.parse(dataStr); + } catch (e) { + // 如果不是 JSON,保留原始字符串 + eventData = { raw: dataStr }; + } + } + } + + if (eventData) { + onEvent({ type: eventType || 'message', data: eventData }); + } +}; + +/** + * 创建带超时的 fetch 请求 + * @param {string} url - 请求 URL + * @param {Object} options - fetch 选项 + * @param {number} timeout - 超时时间(毫秒) + * @returns {Promise} + */ +const fetchWithTimeout = async (url, options = {}, timeout = DEFAULT_TIMEOUT) => { + const controller = new AbortController(); + const timeoutId = setTimeout(() => controller.abort(), timeout); + + try { + const response = await fetch(url, { + ...options, + signal: controller.signal, + // 重要:携带 Cookie 以支持 Session 认证 + credentials: 'include', + }); + clearTimeout(timeoutId); + return response; + } catch (error) { + clearTimeout(timeoutId); + if (error.name === 'AbortError') { + throw new Error('请求超时,请稍后重试'); + } + throw error; + } +}; + +/** + * 获取用户会话列表 + * + * @param {string} userId - 用户ID + * @param {number} limit - 返回数量限制 + * @returns {Promise} 会话列表 + */ +export const getSessions = async (userId, limit = MAX_SESSIONS_LIMIT) => { + try { + const response = await fetchWithTimeout( + `${BASE_URL}${ENDPOINTS.SESSIONS}?user_id=${encodeURIComponent(userId)}&limit=${limit}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + DEFAULT_TIMEOUT + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + // 后端返回格式: { success: true, data: [...] } + if (data.success) { + return data.data || []; + } + return []; + } catch (error) { + console.error('[AgentService] 获取会话列表失败:', error); + throw error; + } +}; + +/** + * 获取会话历史消息 + * + * @param {string} sessionId - 会话ID + * @param {number} limit - 返回数量限制 + * @returns {Promise} 会话历史数据 + */ +export const getSessionHistory = async (sessionId, limit = MAX_HISTORY_LIMIT) => { + try { + const response = await fetchWithTimeout( + `${BASE_URL}${ENDPOINTS.HISTORY}/${encodeURIComponent(sessionId)}?limit=${limit}`, + { + method: 'GET', + headers: { + 'Content-Type': 'application/json', + }, + }, + DEFAULT_TIMEOUT + ); + + if (!response.ok) { + throw new Error(`HTTP ${response.status}`); + } + + const data = await response.json(); + // 后端返回格式: { success: true, data: [...], session_id, title } + if (data.success) { + return { + sessionId: sessionId, + title: data.title || '', + messages: data.data || [], + }; + } + return { + sessionId: sessionId, + title: '', + messages: [], + }; + } catch (error) { + console.error('[AgentService] 获取会话历史失败:', error); + throw error; + } +}; + +/** + * 安全的 JSON 解析 + */ +const safeJsonParse = (str) => { + if (!str) return null; + if (typeof str === 'object') return str; + try { + return JSON.parse(str); + } catch (e) { + return null; + } +}; + +/** + * 将后端消息格式转换为前端格式 + * + * @param {Array} backendMessages - 后端消息格式 + * @returns {Array} 前端消息格式 + */ +export const transformMessages = (backendMessages) => { + return backendMessages.map((msg, index) => { + // 后端消息格式: { message_type: 'user' | 'assistant', message: string, plan, steps } + const isUser = msg.message_type === 'user'; + + return { + id: `history_${index}_${Date.now()}`, + type: isUser ? 'user' : 'agent_response', + content: msg.message || '', + plan: safeJsonParse(msg.plan), + stepResults: safeJsonParse(msg.steps), + isUser, + timestamp: msg.timestamp || new Date().toISOString(), + }; + }); +}; + +/** + * 构建对话历史(用于发送请求) + * + * @param {Array} messages - 消息列表 + * @returns {Array} 对话历史格式 + */ +export const buildConversationHistory = (messages) => { + return messages + .filter(msg => msg.type === 'user' || msg.type === 'agent_response') + .map(msg => ({ + isUser: msg.type === 'user', + content: msg.content, + })); +}; + +/** + * 按日期分组会话 + * + * @param {Array} sessions - 会话列表 + * @returns {Array} 分组后的会话 + */ +export const groupSessionsByDate = (sessions) => { + const groups = {}; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const yesterday = new Date(today); + yesterday.setDate(yesterday.getDate() - 1); + + const thisWeekStart = new Date(today); + thisWeekStart.setDate(thisWeekStart.getDate() - thisWeekStart.getDay()); + + sessions.forEach(session => { + const sessionDate = new Date(session.created_at || session.timestamp); + sessionDate.setHours(0, 0, 0, 0); + + let groupKey; + if (sessionDate.getTime() === today.getTime()) { + groupKey = '今天'; + } else if (sessionDate.getTime() === yesterday.getTime()) { + groupKey = '昨天'; + } else if (sessionDate >= thisWeekStart) { + groupKey = '本周'; + } else { + // 按月份分组 + groupKey = `${sessionDate.getFullYear()}年${sessionDate.getMonth() + 1}月`; + } + + if (!groups[groupKey]) { + groups[groupKey] = []; + } + groups[groupKey].push(session); + }); + + // 转换为数组格式并排序 + const orderedKeys = ['今天', '昨天', '本周']; + const result = []; + + // 先添加特殊分组 + orderedKeys.forEach(key => { + if (groups[key]) { + result.push({ title: key, sessions: groups[key] }); + delete groups[key]; + } + }); + + // 添加剩余的月份分组(按时间倒序) + Object.keys(groups) + .sort((a, b) => b.localeCompare(a)) + .forEach(key => { + result.push({ title: key, sessions: groups[key] }); + }); + + return result; +}; + +export default { + streamAgentChat, + getSessions, + getSessionHistory, + transformMessages, + buildConversationHistory, + groupSessionsByDate, +}; diff --git a/MeAgent/src/store/index.js b/MeAgent/src/store/index.js index 32e813a1..71b56ea0 100644 --- a/MeAgent/src/store/index.js +++ b/MeAgent/src/store/index.js @@ -7,6 +7,7 @@ import eventsReducer from './slices/eventsSlice'; import watchlistReducer from './slices/watchlistSlice'; import stockReducer from './slices/stockSlice'; import communityReducer from './slices/communitySlice'; +import agentReducer from './slices/agentSlice'; const store = configureStore({ reducer: { @@ -14,6 +15,7 @@ const store = configureStore({ watchlist: watchlistReducer, stock: stockReducer, community: communityReducer, + agent: agentReducer, }, middleware: (getDefaultMiddleware) => getDefaultMiddleware({ diff --git a/MeAgent/src/store/slices/agentSlice.js b/MeAgent/src/store/slices/agentSlice.js new file mode 100644 index 00000000..6bd29ae3 --- /dev/null +++ b/MeAgent/src/store/slices/agentSlice.js @@ -0,0 +1,341 @@ +/** + * Agent Redux Slice + * 管理 AI 助手相关的状态 + */ + +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit'; +import { + getSessions, + getSessionHistory, + transformMessages, + groupSessionsByDate, +} from '../../services/agentService'; +import { + MessageTypes, + DEFAULT_MODEL, + DEFAULT_TOOLS, + WELCOME_MESSAGE_TEMPLATE, +} from '../../constants/agentConstants'; + +// 异步 Thunk: 获取会话列表 +export const fetchSessions = createAsyncThunk( + 'agent/fetchSessions', + async (userId, { rejectWithValue }) => { + try { + const sessions = await getSessions(userId); + return sessions; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +// 异步 Thunk: 获取会话历史 +export const fetchSessionHistory = createAsyncThunk( + 'agent/fetchSessionHistory', + async (sessionId, { rejectWithValue }) => { + try { + const history = await getSessionHistory(sessionId); + return history; + } catch (error) { + return rejectWithValue(error.message); + } + } +); + +// 初始状态 +const initialState = { + // 消息相关 + messages: [], + isProcessing: false, + streamingContent: '', // 流式输出中的内容 + + // 会话相关 + currentSessionId: null, + currentSessionTitle: null, + sessions: [], + groupedSessions: [], + sessionsLoading: false, + sessionsError: null, + + // 配置相关 + selectedModel: DEFAULT_MODEL, + selectedTools: DEFAULT_TOOLS, + + // 流式状态追踪 + streamState: { + thinkingContent: '', + deepThinkingContent: '', + isDeepThinking: false, + plan: null, + stepResults: [], + }, + + // UI 状态 + isSessionDrawerOpen: false, + isToolSelectorOpen: false, +}; + +const agentSlice = createSlice({ + name: 'agent', + initialState, + reducers: { + // ========== 消息相关 ========== + + // 添加消息 + addMessage: (state, action) => { + const message = { + id: `msg_${Date.now()}_${Math.random().toString(36).substr(2, 9)}`, + timestamp: new Date().toISOString(), + ...action.payload, + }; + state.messages.push(message); + }, + + // 更新最后一条指定类型的消息 + updateLastMessageByType: (state, action) => { + const { type, updates } = action.payload; + for (let i = state.messages.length - 1; i >= 0; i--) { + if (state.messages[i].type === type) { + state.messages[i] = { ...state.messages[i], ...updates }; + break; + } + } + }, + + // 设置消息列表 + setMessages: (state, action) => { + state.messages = action.payload; + }, + + // 清空消息 + clearMessages: (state) => { + state.messages = []; + }, + + // 移除指定类型的消息 + removeMessagesByType: (state, action) => { + const type = action.payload; + state.messages = state.messages.filter(msg => msg.type !== type); + }, + + // 设置处理状态 + setIsProcessing: (state, action) => { + state.isProcessing = action.payload; + }, + + // 设置流式内容 + setStreamingContent: (state, action) => { + state.streamingContent = action.payload; + }, + + // 追加流式内容 + appendStreamingContent: (state, action) => { + state.streamingContent += action.payload; + }, + + // ========== 流式状态相关 ========== + + // 更新流式状态 + updateStreamState: (state, action) => { + state.streamState = { ...state.streamState, ...action.payload }; + }, + + // 重置流式状态 + resetStreamState: (state) => { + state.streamState = { + thinkingContent: '', + deepThinkingContent: '', + isDeepThinking: false, + plan: null, + stepResults: [], + }; + state.streamingContent = ''; + }, + + // 追加思考内容 + appendThinkingContent: (state, action) => { + state.streamState.thinkingContent += action.payload; + }, + + // 追加深度思考内容 + appendDeepThinkingContent: (state, action) => { + state.streamState.deepThinkingContent += action.payload; + }, + + // 设置执行计划 + setPlan: (state, action) => { + state.streamState.plan = action.payload; + }, + + // 添加步骤结果 + addStepResult: (state, action) => { + state.streamState.stepResults.push(action.payload); + }, + + // ========== 会话相关 ========== + + // 设置当前会话 + setCurrentSession: (state, action) => { + const { sessionId, title } = action.payload; + state.currentSessionId = sessionId; + state.currentSessionTitle = title; + }, + + // 清除当前会话(新建对话) + clearCurrentSession: (state) => { + state.currentSessionId = null; + state.currentSessionTitle = null; + state.messages = []; + }, + + // 设置会话列表 + setSessions: (state, action) => { + state.sessions = action.payload; + state.groupedSessions = groupSessionsByDate(action.payload); + }, + + // ========== 配置相关 ========== + + // 设置选中的模型 + setSelectedModel: (state, action) => { + state.selectedModel = action.payload; + }, + + // 设置选中的工具 + setSelectedTools: (state, action) => { + state.selectedTools = action.payload; + }, + + // 切换工具选择 + toggleTool: (state, action) => { + const toolId = action.payload; + const index = state.selectedTools.indexOf(toolId); + if (index > -1) { + state.selectedTools.splice(index, 1); + } else { + state.selectedTools.push(toolId); + } + }, + + // ========== UI 状态 ========== + + // 切换会话抽屉 + toggleSessionDrawer: (state) => { + state.isSessionDrawerOpen = !state.isSessionDrawerOpen; + }, + + // 设置会话抽屉状态 + setSessionDrawerOpen: (state, action) => { + state.isSessionDrawerOpen = action.payload; + }, + + // 切换工具选择器 + toggleToolSelector: (state) => { + state.isToolSelectorOpen = !state.isToolSelectorOpen; + }, + + // 设置工具选择器状态 + setToolSelectorOpen: (state, action) => { + state.isToolSelectorOpen = action.payload; + }, + + // ========== 初始化 ========== + + // 初始化新会话(添加欢迎消息) + initNewSession: (state, action) => { + const nickname = action.payload?.nickname || ''; + state.currentSessionId = null; + state.currentSessionTitle = null; + state.messages = [ + { + id: `welcome_${Date.now()}`, + type: MessageTypes.AGENT_RESPONSE, + content: WELCOME_MESSAGE_TEMPLATE(nickname), + timestamp: new Date().toISOString(), + }, + ]; + }, + }, + + extraReducers: (builder) => { + // 获取会话列表 + builder + .addCase(fetchSessions.pending, (state) => { + state.sessionsLoading = true; + state.sessionsError = null; + }) + .addCase(fetchSessions.fulfilled, (state, action) => { + state.sessionsLoading = false; + state.sessions = action.payload; + state.groupedSessions = groupSessionsByDate(action.payload); + }) + .addCase(fetchSessions.rejected, (state, action) => { + state.sessionsLoading = false; + state.sessionsError = action.payload; + }); + + // 获取会话历史 + builder + .addCase(fetchSessionHistory.pending, (state) => { + state.isProcessing = true; + }) + .addCase(fetchSessionHistory.fulfilled, (state, action) => { + state.isProcessing = false; + state.currentSessionId = action.payload.sessionId; + state.currentSessionTitle = action.payload.title; + state.messages = transformMessages(action.payload.messages); + }) + .addCase(fetchSessionHistory.rejected, (state, action) => { + state.isProcessing = false; + // 可以添加错误消息 + }); + }, +}); + +// 导出 actions +export const { + addMessage, + updateLastMessageByType, + setMessages, + clearMessages, + removeMessagesByType, + setIsProcessing, + setStreamingContent, + appendStreamingContent, + updateStreamState, + resetStreamState, + appendThinkingContent, + appendDeepThinkingContent, + setPlan, + addStepResult, + setCurrentSession, + clearCurrentSession, + setSessions, + setSelectedModel, + setSelectedTools, + toggleTool, + toggleSessionDrawer, + setSessionDrawerOpen, + toggleToolSelector, + setToolSelectorOpen, + initNewSession, +} = agentSlice.actions; + +// 导出 selectors +export const selectMessages = (state) => state.agent.messages; +export const selectIsProcessing = (state) => state.agent.isProcessing; +export const selectStreamingContent = (state) => state.agent.streamingContent; +export const selectStreamState = (state) => state.agent.streamState; +export const selectCurrentSessionId = (state) => state.agent.currentSessionId; +export const selectCurrentSessionTitle = (state) => state.agent.currentSessionTitle; +export const selectSessions = (state) => state.agent.sessions; +export const selectGroupedSessions = (state) => state.agent.groupedSessions; +export const selectSessionsLoading = (state) => state.agent.sessionsLoading; +export const selectSelectedModel = (state) => state.agent.selectedModel; +export const selectSelectedTools = (state) => state.agent.selectedTools; +export const selectIsSessionDrawerOpen = (state) => state.agent.isSessionDrawerOpen; +export const selectIsToolSelectorOpen = (state) => state.agent.isToolSelectorOpen; + +// 导出 reducer +export default agentSlice.reducer; diff --git a/mcp_server.py b/mcp_server.py index 446e66e0..983acb50 100644 --- a/mcp_server.py +++ b/mcp_server.py @@ -184,37 +184,6 @@ class AgentChatRequest(BaseModel): # ==================== MCP工具定义 ==================== TOOLS: List[ToolDefinition] = [ - ToolDefinition( - name="search_news", - description="搜索全球新闻,支持关键词搜索和日期过滤。适用于查找国际新闻、行业动态等。", - parameters={ - "type": "object", - "properties": { - "query": { - "type": "string", - "description": "搜索关键词,例如:'人工智能'、'新能源汽车'" - }, - "source": { - "type": "string", - "description": "新闻来源筛选,可选" - }, - "start_date": { - "type": "string", - "description": "开始日期,格式:YYYY-MM-DD" - }, - "end_date": { - "type": "string", - "description": "结束日期,格式:YYYY-MM-DD" - }, - "top_k": { - "type": "integer", - "description": "返回结果数量,默认20", - "default": 20 - } - }, - "required": ["query"] - } - ), ToolDefinition( name="search_china_news", description="搜索中国新闻,使用KNN语义搜索。支持精确匹配模式,适合查找股票、公司相关新闻。", @@ -1452,22 +1421,6 @@ async def call_tool(request: ToolCallRequest): # ==================== 工具处理函数 ==================== -async def handle_search_news(args: Dict[str, Any]) -> Any: - """处理新闻搜索""" - params = { - "query": args.get("query"), - "source": args.get("source"), - "start_date": args.get("start_date"), - "end_date": args.get("end_date"), - "top_k": args.get("top_k", 20) - } - # 移除None值 - params = {k: v for k, v in params.items() if v is not None} - - response = await HTTP_CLIENT.get(f"{ServiceEndpoints.NEWS_API}/search_news", params=params) - response.raise_for_status() - return response.json() - async def handle_search_china_news(args: Dict[str, Any]) -> Any: """处理中国新闻搜索""" params = { @@ -1916,7 +1869,6 @@ async def handle_get_stock_intraday_statistics(args: Dict[str, Any]) -> Any: # 工具处理函数映射 TOOL_HANDLERS = { - "search_news": handle_search_news, "search_china_news": handle_search_china_news, "search_medical_news": handle_search_medical_news, "search_roadshows": handle_search_roadshows, @@ -4375,7 +4327,7 @@ async def health_check(): services_status = {} try: - response = await HTTP_CLIENT.get(f"{ServiceEndpoints.NEWS_API}/search_news?query=test&top_k=1", timeout=5.0) + response = await HTTP_CLIENT.get(f"{ServiceEndpoints.NEWS_API}/search_china_news?query=test&top_k=1", timeout=5.0) services_status["news_api"] = "healthy" if response.status_code == 200 else "unhealthy" except: services_status["news_api"] = "unhealthy"