更新ios

This commit is contained in:
2026-01-17 22:33:49 +08:00
parent f3426e7c64
commit 2ad5835813
18 changed files with 4597 additions and 49 deletions

Binary file not shown.

After

Width:  |  Height:  |  Size: 196 KiB

View File

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

View File

@@ -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 (
<Stack.Navigator
screenOptions={{
mode: "card",
headerShown: false,
}}
>
<Stack.Screen
name="AgentChat"
component={AgentChatScreen}
options={{
cardStyle: { backgroundColor: "#0F172A" },
}}
/>
</Stack.Navigator>
);
}
function ProfileStack(props) {
return (
<Stack.Navigator
@@ -783,6 +806,13 @@ function AppStack(props) {
headerShown: false,
}}
/>
<Drawer.Screen
name="AgentDrawer"
component={AgentStack}
options={{
headerShown: false,
}}
/>
<Drawer.Screen
name="ProfileDrawerNew"
component={NewProfileStack}

View File

@@ -0,0 +1,261 @@
/**
* Agent 功能常量定义
* 消息类型、主题配色、API 配置等
*/
// 消息类型枚举
export const MessageTypes = {
USER: 'user',
AGENT_THINKING: 'agent_thinking',
AGENT_DEEP_THINKING: 'agent_deep_thinking',
AGENT_PLAN: 'agent_plan',
AGENT_EXECUTING: 'agent_executing',
AGENT_RESPONSE: 'agent_response',
ERROR: 'error',
};
// SSE 事件类型
export const SSEEventTypes = {
STATUS: 'status',
THINKING: 'thinking',
THINKING_START: 'thinking_start',
THINKING_CHUNK: 'thinking_chunk',
THINKING_END: 'thinking_end',
REASONING: 'reasoning',
PLAN: 'plan',
STEP_START: 'step_start',
STEP_COMPLETE: 'step_complete',
SUMMARY_CHUNK: 'summary_chunk',
SESSION_TITLE: 'session_title',
DONE: 'done',
ERROR: 'error',
};
// Agent 深色主题配色
export const AgentTheme = {
// 背景色
background: '#0F172A',
backgroundSecondary: '#1E293B',
cardBg: 'rgba(30, 41, 59, 0.8)',
// 消息气泡
userBubbleBg: '#6366F1', // Indigo
userBubbleGradientStart: '#8B5CF6', // Purple
userBubbleGradientEnd: '#EC4899', // Pink
agentBubbleBg: 'rgba(55, 65, 81, 0.8)',
// 文字颜色
textPrimary: '#F1F5F9',
textSecondary: '#94A3B8',
textMuted: '#64748B',
// 强调色
accent: '#8B5CF6', // Purple
accentSecondary: '#6366F1', // Indigo
success: '#10B981', // Green
error: '#EF4444', // Red
warning: '#F59E0B', // Amber
// 边框
border: 'rgba(255, 255, 255, 0.1)',
borderLight: 'rgba(255, 255, 255, 0.05)',
// 毛玻璃效果
glassBg: 'rgba(17, 24, 39, 0.8)',
glassBlur: 12,
};
// AI 模型配置
export const AgentModels = {
DEEPMONEY: {
id: 'deepmoney',
name: 'DeepMoney',
description: '金融专业模型,支持深度思考',
contextLength: 84000,
maxTokens: 32768,
supportsDeepThinking: true,
},
DEEPSEEK: {
id: 'deepseek',
name: 'DeepSeek',
description: '通用对话模型',
contextLength: 32000,
maxTokens: 8192,
supportsDeepThinking: false,
},
KIMI_K2: {
id: 'kimi-k2',
name: 'Kimi K2',
description: '快速响应模型',
contextLength: 8000,
maxTokens: 8192,
supportsDeepThinking: false,
},
KIMI_K2_THINKING: {
id: 'kimi-k2-thinking',
name: 'Kimi K2 思考版',
description: '支持深度推理',
contextLength: 32000,
maxTokens: 32768,
supportsDeepThinking: true,
},
};
// 默认模型
export const DEFAULT_MODEL = AgentModels.DEEPMONEY.id;
// 工具分类
export const ToolCategories = {
NEWS: {
id: 'news',
name: '新闻资讯',
icon: 'newspaper',
},
CONCEPT: {
id: 'concept',
name: '概念板块',
icon: 'grid',
},
LIMIT_UP: {
id: 'limit_up',
name: '涨停分析',
icon: 'trending-up',
},
RESEARCH: {
id: 'research',
name: '研报路演',
icon: 'file-text',
},
STOCK_DATA: {
id: 'stock_data',
name: '股票数据',
icon: 'bar-chart-2',
},
USER_DATA: {
id: 'user_data',
name: '用户数据',
icon: 'user',
},
QUANT: {
id: 'quant',
name: '量化工具',
icon: 'cpu',
},
};
// MCP 工具定义
export const AgentTools = [
// 新闻资讯
{ id: 'search_china_news', name: '中国新闻搜索', category: 'news', description: '搜索国内财经新闻' },
{ id: 'search_global_news', name: '全球新闻搜索', category: 'news', description: '搜索全球财经新闻' },
{ id: 'search_medical_news', name: '医疗新闻搜索', category: 'news', description: '搜索医疗健康新闻' },
// 概念板块
{ id: 'search_concepts', name: '概念搜索', category: 'concept', description: '语义搜索概念板块' },
{ id: 'get_concept_details', name: '概念详情', category: 'concept', description: '获取概念详细信息' },
{ id: 'get_stock_concepts', name: '股票概念', category: 'concept', description: '查询股票所属概念' },
{ id: 'get_concept_statistics', name: '概念统计', category: 'concept', description: '概念涨跌统计' },
// 涨停分析
{ id: 'search_limit_up_stocks', name: '涨停搜索', category: 'limit_up', description: '搜索涨停股票' },
{ id: 'get_daily_stock_analysis', name: '涨停日报', category: 'limit_up', description: '每日涨停分析' },
// 研报路演
{ id: 'search_research_reports', name: '研报搜索', category: 'research', description: '搜索研究报告' },
{ id: 'search_roadshows', name: '路演搜索', category: 'research', description: '搜索路演活动' },
// 股票数据
{ id: 'get_stock_info', name: '股票信息', category: 'stock_data', description: '获取股票基本信息' },
{ id: 'get_financial_indicators', name: '财务指标', category: 'stock_data', description: '获取财务指标数据' },
{ id: 'get_trade_data', name: '交易数据', category: 'stock_data', description: '获取交易行情数据' },
{ id: 'get_balance_sheet', name: '资产负债表', category: 'stock_data', description: '获取资产负债表' },
{ id: 'get_cash_flow', name: '现金流量表', category: 'stock_data', description: '获取现金流量表' },
{ id: 'stock_screener', name: '条件选股', category: 'stock_data', description: '按条件筛选股票' },
{ id: 'compare_stocks', name: '股票对比', category: 'stock_data', description: '多只股票对比分析' },
// 用户数据
{ id: 'get_watchlist', name: '自选股', category: 'user_data', description: '获取用户自选股' },
{ id: 'get_followed_events', name: '关注事件', category: 'user_data', description: '获取关注的事件' },
// 量化工具 - 经典技术指标
{ id: 'calc_macd', name: 'MACD', category: 'quant', description: '计算MACD指标' },
{ id: 'calc_rsi_kdj', name: 'RSI/KDJ', category: 'quant', description: '计算RSI和KDJ' },
{ id: 'calc_bollinger', name: '布林带', category: 'quant', description: '计算布林带通道' },
{ id: 'calc_atr_stop', name: 'ATR止损', category: 'quant', description: '计算ATR动态止损' },
// 量化工具 - 资金与情绪
{ id: 'calc_market_heat', name: '市场热度', category: 'quant', description: '市场情绪热度指标' },
{ id: 'calc_volume_price_divergence', name: '量价背离', category: 'quant', description: '量价背离检测' },
{ id: 'calc_obv', name: 'OBV能量潮', category: 'quant', description: '计算OBV指标' },
// 量化工具 - 形态与突破
{ id: 'detect_new_high_breakout', name: '新高突破', category: 'quant', description: '检测新高突破' },
{ id: 'detect_kline_patterns', name: 'K线形态', category: 'quant', description: '识别K线形态' },
{ id: 'detect_gap', name: '跳空缺口', category: 'quant', description: '检测跳空缺口' },
// 量化工具 - 风险与估值
{ id: 'calc_max_drawdown', name: '最大回撤', category: 'quant', description: '计算最大回撤' },
{ id: 'calc_pe_percentile', name: 'PE百分位', category: 'quant', description: 'PE历史百分位' },
{ id: 'calc_z_score', name: 'Z-Score', category: 'quant', description: '价格Z-Score' },
// 量化工具 - 高级分析
{ id: 'calc_vpoc', name: 'VPOC', category: 'quant', description: '成交量分布中心' },
{ id: 'calc_realized_volatility', name: '已实现波动率', category: 'quant', description: '计算已实现波动率' },
{ id: 'calc_buy_sell_pressure', name: '买卖压力', category: 'quant', description: '买卖压力指标' },
{ id: 'calc_bollinger_squeeze', name: '布林挤压', category: 'quant', description: '布林带挤压信号' },
{ id: 'calc_trend_slope', name: '趋势斜率', category: 'quant', description: '趋势线性回归' },
{ id: 'calc_hurst_exponent', name: 'Hurst指数', category: 'quant', description: '趋势/均值回归判断' },
{ id: 'test_cointegration', name: '协整测试', category: 'quant', description: '配对交易协整性' },
{ id: 'calc_kelly_position', name: '凯利仓位', category: 'quant', description: '最优仓位计算' },
{ id: 'search_similar_kline', name: '相似K线', category: 'quant', description: '历史相似形态' },
{ id: 'decompose_trend_simple', name: '趋势分解', category: 'quant', description: '趋势周期分解' },
{ id: 'calc_price_entropy', name: '价格熵值', category: 'quant', description: '市场混乱度' },
];
// 默认选中的工具(非量化工具)
export const DEFAULT_TOOLS = AgentTools
.filter(tool => 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,
};

View File

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

View File

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

View File

@@ -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 }) => (
<View style={styles.header}>
<TouchableOpacity
style={styles.headerButton}
onPress={onMenuPress}
activeOpacity={0.7}
>
<Text style={styles.headerButtonText}></Text>
</TouchableOpacity>
<View style={styles.headerCenter}>
<View style={styles.headerAvatarContainer}>
<Image source={LuoxiAvatar} style={styles.headerAvatar} />
</View>
<Text style={styles.headerTitle} numberOfLines={1}>
{title || AGENT_NAME}
</Text>
</View>
<TouchableOpacity
style={styles.headerButton}
onPress={onNewPress}
activeOpacity={0.7}
>
<Text style={styles.headerButtonText}>+</Text>
</TouchableOpacity>
</View>
);
/**
* 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 }) => (
<View style={{ width: '100%' }}>
<MessageBubble message={item} />
</View>
), []);
/**
* 消息列表 Key 提取器
*/
const keyExtractor = useCallback((item) => item.id?.toString() || Math.random().toString(), []);
// 判断是否显示欢迎屏幕(只有一条欢迎消息时)
const showWelcome = messages.length <= 1 &&
messages[0]?.type === MessageTypes.AGENT_RESPONSE;
return (
<SafeAreaView style={styles.container}>
<StatusBar barStyle="light-content" backgroundColor={AgentTheme.background} />
{/* 头部 */}
<Header
title={currentSessionTitle || '价小前'}
onMenuPress={openDrawer}
onNewPress={handleNewSession}
/>
{/* 主体内容 */}
<KeyboardAvoidingView
style={styles.content}
behavior={Platform.OS === 'ios' ? 'padding' : undefined}
keyboardVerticalOffset={Platform.OS === 'ios' ? 0 : 0}
>
{showWelcome ? (
/* 欢迎屏幕 */
<WelcomeScreen onQuickQuestion={handleQuickQuestion} />
) : (
/* 消息列表 */
<FlatList
ref={flatListRef}
data={messages}
renderItem={renderMessageItem}
keyExtractor={keyExtractor}
style={styles.messageList}
contentContainerStyle={styles.messageListContent}
showsVerticalScrollIndicator={true}
onScroll={handleScroll}
onContentSizeChange={handleContentSizeChange}
scrollEventThrottle={16}
maintainVisibleContentPosition={{
minIndexForVisible: 0,
}}
/>
)}
{/* 输入框 */}
<ChatInput
onSend={handleSendMessage}
onCancel={cancelRequest}
isProcessing={isProcessing}
placeholder="输入消息,分析股票..."
/>
</KeyboardAvoidingView>
{/* 会话抽屉 */}
<SessionDrawer
visible={isDrawerOpen}
onClose={closeDrawer}
groupedSessions={groupedSessions}
currentSessionId={currentSessionId}
onSelectSession={switchSession}
onNewSession={handleNewSession}
isLoading={sessionsLoading}
/>
</SafeAreaView>
);
};
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;

View File

@@ -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 }) => (
<View style={{ width: size, height: size, justifyContent: 'center', alignItems: 'center' }}>
<View
style={{
width: 0,
height: 0,
borderLeftWidth: size * 0.6,
borderTopWidth: size * 0.35,
borderBottomWidth: size * 0.35,
borderLeftColor: color,
borderTopColor: 'transparent',
borderBottomColor: 'transparent',
marginLeft: 3,
}}
/>
</View>
);
/**
* 停止按钮图标
*/
const StopIcon = ({ color = '#FFFFFF', size = 16 }) => (
<View
style={{
width: size,
height: size,
backgroundColor: color,
borderRadius: 2,
}}
/>
);
/**
* 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 (
<BlurView intensity={20} tint="dark" style={styles.container}>
<View style={styles.inputWrapper}>
<View style={styles.inputContainer}>
<TextInput
ref={inputRef}
style={styles.input}
value={inputText}
onChangeText={setInputText}
placeholder={placeholder}
placeholderTextColor={AgentTheme.textMuted}
multiline
maxLength={2000}
editable={!disabled}
returnKeyType="send"
blurOnSubmit={false}
onSubmitEditing={handleSubmitEditing}
/>
</View>
{isProcessing ? (
<TouchableOpacity
style={[styles.sendButton, styles.cancelButton]}
onPress={handleCancel}
activeOpacity={0.7}
>
<StopIcon color="#FFFFFF" size={14} />
</TouchableOpacity>
) : (
<TouchableOpacity
style={[
styles.sendButton,
canSend ? styles.sendButtonActive : styles.sendButtonDisabled,
]}
onPress={handleSend}
disabled={!canSend}
activeOpacity={0.7}
>
<SendIcon color="#FFFFFF" size={18} />
</TouchableOpacity>
)}
</View>
</BlurView>
);
};
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);

View File

@@ -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 (
<View style={styles.tableContainer}>
<ScrollView horizontal showsHorizontalScrollIndicator={false}>
<View>
{/* 表头 */}
<View style={styles.tableHeader}>
{header.map((cell, idx) => (
<View key={idx} style={[styles.tableCell, styles.tableHeaderCell]}>
<Text style={styles.tableHeaderText}>{cell}</Text>
</View>
))}
</View>
{/* 表体 */}
{body.map((row, rowIdx) => (
<View key={rowIdx} style={[styles.tableRow, rowIdx % 2 === 0 && styles.tableRowEven]}>
{row.map((cell, cellIdx) => (
<View key={cellIdx} style={styles.tableCell}>
<Text style={styles.tableCellText}>{cell}</Text>
</View>
))}
</View>
))}
</View>
</ScrollView>
</View>
);
});
/**
* 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(<TableView key={`table-${i}`} rows={rows} />);
i = endIndex;
continue;
}
}
elements.push(<MarkdownLine key={i} line={line} />);
i++;
}
return (
<View style={styles.markdownContainer}>
{elements}
</View>
);
});
/**
* 单行 Markdown 渲染
*/
const MarkdownLine = memo(({ line }) => {
// 空行
if (!line.trim()) {
return <View style={styles.emptyLine} />;
}
// 标题 (# ## ### ####)
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 (
<Text style={[styles.headerBase, headerStyles[level - 1]]}>
{renderInlineStyles(text)}
</Text>
);
}
// 无序列表 (- 或 *)
const listMatch = line.match(/^[\s]*[-*]\s+(.*)$/);
if (listMatch) {
return (
<View style={styles.listItem}>
<Text style={styles.listBullet}></Text>
<Text style={styles.listText}>{renderInlineStyles(listMatch[1])}</Text>
</View>
);
}
// 有序列表 (1. 2. 3.)
const orderedListMatch = line.match(/^[\s]*(\d+)\.\s+(.*)$/);
if (orderedListMatch) {
return (
<View style={styles.listItem}>
<Text style={styles.listNumber}>{orderedListMatch[1]}.</Text>
<Text style={styles.listText}>{renderInlineStyles(orderedListMatch[2])}</Text>
</View>
);
}
// 代码块 (```)
if (line.startsWith('```')) {
return null; // 代码块在外层处理
}
// 引用 (>)
const quoteMatch = line.match(/^>\s*(.*)$/);
if (quoteMatch) {
return (
<View style={styles.quote}>
<Text style={styles.quoteText}>{renderInlineStyles(quoteMatch[1])}</Text>
</View>
);
}
// 分隔线 (--- or ***)
if (/^[-*]{3,}$/.test(line.trim())) {
return <View style={styles.hr} />;
}
// 普通段落
return (
<Text style={styles.paragraph}>{renderInlineStyles(line)}</Text>
);
});
/**
* 渲染行内样式(粗体、斜体、代码、链接)
*/
const renderInlineStyles = (text) => {
if (!text) return null;
const elements = [];
let key = 0;
// 为简化实现,这里只做基本渲染
// 完整实现需要递归解析
elements.push(
<Text key={key++} style={styles.text}>
{text
.replace(/\*\*(.+?)\*\*/g, (_, m) => m) // 移除粗体标记(简化)
.replace(/\*(.+?)\*/g, (_, m) => m) // 移除斜体标记
.replace(/`([^`]+)`/g, (_, m) => `[${m}]`) // 标记代码
}
</Text>
);
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 `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
height: 100%;
background: transparent;
overflow: hidden;
}
#chart {
width: ${width}px;
height: ${height}px;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/echarts@5.4.3/dist/echarts.min.js"></script>
</head>
<body>
<div id="chart"></div>
<script>
try {
var chart = echarts.init(document.getElementById('chart'));
var option = ${option};
// 合并深色主题
var darkTheme = ${JSON.stringify(darkTheme)};
// 应用主题样式
if (!option.backgroundColor) {
option.backgroundColor = darkTheme.backgroundColor;
}
if (option.title && !option.title.textStyle) {
option.title.textStyle = darkTheme.title.textStyle;
}
if (option.title && !option.title.subtextStyle) {
option.title.subtextStyle = darkTheme.title.subtextStyle;
}
if (option.legend && !option.legend.textStyle) {
option.legend.textStyle = darkTheme.legend.textStyle;
}
if (option.xAxis) {
var xAxis = Array.isArray(option.xAxis) ? option.xAxis : [option.xAxis];
xAxis.forEach(function(axis) {
if (!axis || typeof axis !== 'object') return;
if (!axis.axisLine) axis.axisLine = {};
if (!axis.axisLine.lineStyle) axis.axisLine.lineStyle = darkTheme.xAxis.axisLine.lineStyle;
if (!axis.axisLabel) axis.axisLabel = {};
if (!axis.axisLabel.color) axis.axisLabel.color = darkTheme.xAxis.axisLabel.color;
if (!axis.splitLine) axis.splitLine = {};
if (!axis.splitLine.lineStyle) axis.splitLine.lineStyle = darkTheme.xAxis.splitLine.lineStyle;
});
}
if (option.yAxis) {
var yAxis = Array.isArray(option.yAxis) ? option.yAxis : [option.yAxis];
yAxis.forEach(function(axis) {
if (!axis || typeof axis !== 'object') return;
if (!axis.axisLine) axis.axisLine = {};
if (!axis.axisLine.lineStyle) axis.axisLine.lineStyle = darkTheme.yAxis.axisLine.lineStyle;
if (!axis.axisLabel) axis.axisLabel = {};
if (!axis.axisLabel.color) axis.axisLabel.color = darkTheme.yAxis.axisLabel.color;
if (!axis.splitLine) axis.splitLine = {};
if (!axis.splitLine.lineStyle) axis.splitLine.lineStyle = darkTheme.yAxis.splitLine.lineStyle;
});
}
chart.setOption(option);
// 通知 React Native 加载完成
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'loaded' }));
} catch (e) {
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'error',
message: e.message
}));
}
</script>
</body>
</html>
`;
};
/**
* 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 (
<View style={styles.chartContainer}>
<View style={styles.chartHeader}>
<Text style={styles.chartIcon}>📊</Text>
<Text style={styles.chartTitle}>图表解析失败</Text>
</View>
<Text style={styles.chartError}>无法解析图表配置</Text>
</View>
);
}
const html = generateEChartsHTML(chartOption, CHART_WIDTH, CHART_HEIGHT);
return (
<View style={styles.chartContainer}>
<View style={styles.chartHeader}>
<Text style={styles.chartIcon}>📊</Text>
<Text style={styles.chartTitle}>{chartTitle}</Text>
<TouchableOpacity
onPress={() => setShowData(!showData)}
style={styles.chartToggle}
>
<Text style={styles.chartToggleText}>
{showData ? '显示图表' : '查看数据'}
</Text>
</TouchableOpacity>
</View>
{error ? (
<View style={styles.chartErrorContainer}>
<Text style={styles.chartError}>图表渲染失败: {error}</Text>
</View>
) : showData ? (
// 显示数据视图
<ScrollView style={styles.chartDataScroll} horizontal>
<View style={styles.chartDataContainer}>
{chartData?.series?.map((series, idx) => (
<View key={idx} style={styles.seriesContainer}>
<Text style={styles.seriesName}>{series.name || `系列 ${idx + 1}`}</Text>
{series.data?.slice(0, 10).map((item, i) => (
<Text key={i} style={styles.dataItem}>
{typeof item === 'object' ? `${item.name}: ${item.value}` : item}
</Text>
))}
{series.data?.length > 10 && (
<Text style={styles.dataMore}>...还有 {series.data.length - 10} </Text>
)}
</View>
))}
</View>
</ScrollView>
) : (
// 显示图表
<View style={styles.chartWebViewContainer}>
{isLoading && (
<View style={styles.chartLoading}>
<ActivityIndicator size="small" color={AgentTheme.accent} />
<Text style={styles.chartLoadingText}>加载图表中...</Text>
</View>
)}
<WebView
source={{ html }}
style={[
styles.chartWebView,
{ opacity: isLoading ? 0 : 1 }
]}
scrollEnabled={false}
bounces={false}
showsHorizontalScrollIndicator={false}
showsVerticalScrollIndicator={false}
onMessage={handleMessage}
originWhitelist={['*']}
javaScriptEnabled={true}
domStorageEnabled={true}
startInLoadingState={false}
scalesPageToFit={false}
/>
</View>
)}
</View>
);
});
/**
* 生成 Mermaid HTML
*/
const generateMermaidHTML = (code, width) => {
return `
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0, maximum-scale=1.0, user-scalable=no">
<style>
* { margin: 0; padding: 0; box-sizing: border-box; }
html, body {
width: 100%;
background: transparent;
overflow: hidden;
}
#container {
width: ${width}px;
padding: 10px;
display: flex;
justify-content: center;
}
.mermaid {
background: transparent;
}
/* 深色主题 */
.node rect, .node circle, .node ellipse, .node polygon {
fill: #334155 !important;
stroke: #8B5CF6 !important;
}
.nodeLabel, .label {
color: #E2E8F0 !important;
fill: #E2E8F0 !important;
}
.edgePath path {
stroke: #64748B !important;
}
.arrowheadPath {
fill: #64748B !important;
}
</style>
<script src="https://cdn.jsdelivr.net/npm/mermaid@10/dist/mermaid.min.js"></script>
</head>
<body>
<div id="container">
<pre class="mermaid">
${code}
</pre>
</div>
<script>
mermaid.initialize({
startOnLoad: true,
theme: 'dark',
themeVariables: {
primaryColor: '#334155',
primaryTextColor: '#E2E8F0',
primaryBorderColor: '#8B5CF6',
lineColor: '#64748B',
secondaryColor: '#1E293B',
tertiaryColor: '#0F172A',
background: 'transparent',
}
});
// 渲染完成后发送高度
setTimeout(() => {
const svg = document.querySelector('svg');
if (svg) {
const height = svg.getBoundingClientRect().height;
window.ReactNativeWebView.postMessage(JSON.stringify({
type: 'loaded',
height: height + 20
}));
} else {
window.ReactNativeWebView.postMessage(JSON.stringify({ type: 'loaded', height: 200 }));
}
}, 500);
</script>
</body>
</html>
`;
};
/**
* 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 (
<View style={styles.mermaidContainer}>
<View style={styles.mermaidHeader}>
<Text style={styles.mermaidIcon}>📊</Text>
<Text style={styles.mermaidTitle}>流程图</Text>
</View>
<View style={[styles.mermaidWebViewContainer, { height }]}>
{isLoading && (
<View style={styles.chartLoading}>
<ActivityIndicator size="small" color={AgentTheme.accent} />
<Text style={styles.chartLoadingText}>加载中...</Text>
</View>
)}
<WebView
source={{ html }}
style={[styles.mermaidWebView, { opacity: isLoading ? 0 : 1 }]}
scrollEnabled={false}
bounces={false}
onMessage={handleMessage}
originWhitelist={['*']}
javaScriptEnabled={true}
/>
</View>
</View>
);
});
/**
* MarkdownRenderer 主组件
*/
const MarkdownRenderer = ({ content }) => {
const parts = useMemo(() => parseMarkdown(content), [content]);
return (
<View style={styles.container}>
{parts.map((part, index) => {
if (part.type === 'chart') {
return <EChartsView key={index} config={part.content} />;
}
if (part.type === 'mermaid') {
return <MermaidView key={index} code={part.content} />;
}
return <MarkdownText key={index} content={part.content} />;
})}
</View>
);
};
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);

View File

@@ -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 }) => (
<View style={styles.userBubbleContainer}>
<LinearGradient
colors={[AgentTheme.userBubbleGradientStart, AgentTheme.userBubbleGradientEnd]}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.userBubble}
>
<Text style={styles.userText}>{content}</Text>
</LinearGradient>
</View>
));
/**
* 思考中气泡 - 带动画效果
*/
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 (
<View style={styles.agentBubbleContainer}>
<Animated.View style={[styles.thinkingBubble, { opacity: pulseAnim }]}>
<View style={styles.thinkingRow}>
<ActivityIndicator size="small" color={AgentTheme.accent} />
<Text style={styles.thinkingText}>{content || '正在思考...'}</Text>
</View>
<View style={styles.thinkingDots}>
<ThinkingDots />
</View>
</Animated.View>
</View>
);
});
/**
* 思考中动态点
*/
const ThinkingDots = memo(() => {
const [dots, setDots] = useState('');
useEffect(() => {
const interval = setInterval(() => {
setDots(prev => prev.length >= 3 ? '' : prev + '.');
}, 500);
return () => clearInterval(interval);
}, []);
return <Text style={styles.dots}>{dots}</Text>;
});
/**
* 深度思考气泡(可折叠)- 类似 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 (
<View style={styles.agentBubbleContainer}>
<View style={[
styles.deepThinkingBubble,
isStreaming && styles.deepThinkingBubbleActive,
]}>
{/* 标题栏 */}
<TouchableOpacity
style={styles.deepThinkingHeader}
onPress={handleToggle}
activeOpacity={isStreaming ? 1 : 0.7}
disabled={isStreaming}
>
<View style={styles.deepThinkingTitleRow}>
{isStreaming ? (
<Animated.View style={{
opacity: sparkleAnim.interpolate({
inputRange: [0, 1],
outputRange: [0.5, 1],
}),
}}>
<Text style={styles.sparkleIcon}></Text>
</Animated.View>
) : (
<Text style={styles.brainIcon}>🧠</Text>
)}
<Text style={styles.deepThinkingTitle}>
{isStreaming ? '深度思考中...' : '深度思考'}
</Text>
{isStreaming && (
<ActivityIndicator
size="small"
color={AgentTheme.accent}
style={{ marginLeft: 8 }}
/>
)}
</View>
<View style={styles.deepThinkingMeta}>
{charCount > 0 && (
<View style={styles.charCountBadge}>
<Text style={styles.charCount}>{charCount} </Text>
</View>
)}
{!isStreaming && (
<Animated.View style={{ transform: [{ rotate: rotation }] }}>
<Text style={styles.expandIcon}></Text>
</Animated.View>
)}
</View>
</TouchableOpacity>
{/* 思考内容 */}
{(isExpanded || isStreaming) && content && (
<ScrollView
style={[
styles.deepThinkingContent,
isStreaming && styles.deepThinkingContentStreaming,
]}
showsVerticalScrollIndicator={true}
nestedScrollEnabled={true}
>
<Text style={styles.deepThinkingText}>{content}</Text>
{isStreaming && (
<View style={styles.cursorContainer}>
<Animated.View
style={[
styles.cursor,
{
opacity: sparkleAnim.interpolate({
inputRange: [0, 0.5, 1],
outputRange: [0, 1, 0],
}),
},
]}
/>
</View>
)}
</ScrollView>
)}
</View>
</View>
);
});
/**
* 执行计划气泡
*/
const PlanBubble = memo(({ content, plan }) => (
<View style={styles.agentBubbleContainer}>
<View style={styles.planBubble}>
<View style={styles.planHeader}>
<Text style={styles.planIcon}>📋</Text>
<Text style={styles.planTitle}>执行计划</Text>
</View>
<Text style={styles.planGoal}>{plan?.goal || content}</Text>
{plan?.steps && (
<View style={styles.planSteps}>
{plan.steps.map((step, index) => (
<View key={index} style={styles.planStep}>
<View style={styles.stepNumber}>
<Text style={styles.stepNumberText}>{index + 1}</Text>
</View>
<View style={styles.stepContent}>
<Text style={styles.stepTool}>{step.tool}</Text>
{step.reason && (
<Text style={styles.stepReason}>{step.reason}</Text>
)}
</View>
</View>
))}
</View>
)}
</View>
</View>
));
/**
* 执行中气泡 - 带进度显示
*/
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 (
<View style={styles.agentBubbleContainer}>
<View style={styles.executingBubble}>
<View style={styles.executingHeader}>
<Animated.View style={{ transform: [{ rotate: rotation }] }}>
<Text style={styles.gearIcon}></Text>
</Animated.View>
<Text style={styles.executingTitle}>{content || '正在执行...'}</Text>
</View>
{stepResults.length > 0 && (
<View style={styles.stepResults}>
{stepResults.map((result, index) => (
<View key={index} style={styles.stepResult}>
<Text style={[
styles.stepStatus,
result.status === 'success' ? styles.stepSuccess : styles.stepFailed
]}>
{result.status === 'success' ? '✓' : '✗'}
</Text>
<Text style={styles.stepName}>{result.tool}</Text>
{result.execution_time && (
<Text style={styles.stepTime}>
{(result.execution_time * 1000).toFixed(0)}ms
</Text>
)}
</View>
))}
</View>
)}
</View>
</View>
);
});
/**
* 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 (
<View style={styles.agentBubbleContainer}>
<View style={styles.responseBubble}>
<MarkdownRenderer content={content} />
{isStreaming && (
<View style={styles.streamingIndicator}>
<Animated.View
style={[
styles.streamingCursor,
{ opacity: cursorAnim }
]}
/>
</View>
)}
</View>
</View>
);
});
/**
* 错误气泡
*/
const ErrorBubble = memo(({ content }) => (
<View style={styles.agentBubbleContainer}>
<View style={styles.errorBubble}>
<Text style={styles.errorIcon}></Text>
<View style={styles.errorContent}>
<Text style={styles.errorTitle}>出错了</Text>
<Text style={styles.errorText}>{content}</Text>
</View>
</View>
</View>
));
/**
* MessageBubble 主组件
*/
const MessageBubble = ({ message }) => {
const { type, content, isStreaming, plan, stepResults, currentStep, thinkingContent } = message;
switch (type) {
case MessageTypes.USER:
return <UserBubble content={content} />;
case MessageTypes.AGENT_THINKING:
return <ThinkingBubble content={content} />;
case MessageTypes.AGENT_DEEP_THINKING:
return <DeepThinkingBubble content={content} isStreaming={isStreaming} />;
case MessageTypes.AGENT_PLAN:
return <PlanBubble content={content} plan={plan} />;
case MessageTypes.AGENT_EXECUTING:
return <ExecutingBubble content={content} stepResults={stepResults} currentStep={currentStep} />;
case MessageTypes.AGENT_RESPONSE:
return <ResponseBubble content={content} isStreaming={isStreaming} />;
case MessageTypes.ERROR:
return <ErrorBubble content={content} />;
default:
return <ResponseBubble content={content} />;
}
};
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);

View File

@@ -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 (
<TouchableOpacity
style={[styles.sessionCard, isActive && styles.sessionCardActive]}
onPress={() => onPress(session.session_id)}
activeOpacity={0.7}
>
<View style={styles.sessionIcon}>
<Text style={styles.sessionIconText}>💬</Text>
</View>
<View style={styles.sessionInfo}>
<Text style={styles.sessionTitle} numberOfLines={1}>
{title}
</Text>
<Text style={styles.sessionMeta}>
{messageCount} 条消息
</Text>
</View>
</TouchableOpacity>
);
});
/**
* 日期分组标题
*/
const DateGroupHeader = memo(({ title }) => (
<View style={styles.dateGroup}>
<Text style={styles.dateGroupText}>{title}</Text>
</View>
));
/**
* 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 }) => (
<SessionCard
session={session}
isActive={session.session_id === currentSessionId}
onPress={onSelectSession}
/>
);
/**
* 渲染分组
*/
const renderGroup = ({ item: group }) => (
<View style={styles.groupContainer}>
<DateGroupHeader title={group.title} />
{group.sessions.map(session => (
<SessionCard
key={session.session_id}
session={session}
isActive={session.session_id === currentSessionId}
onPress={onSelectSession}
/>
))}
</View>
);
const groups = filteredGroups();
return (
<Modal
visible={visible}
animationType="slide"
transparent
onRequestClose={onClose}
>
<View style={styles.overlay}>
{/* 点击遮罩关闭 */}
<TouchableOpacity
style={styles.backdrop}
activeOpacity={1}
onPress={onClose}
/>
{/* 抽屉内容 */}
<View style={styles.drawer}>
<BlurView intensity={30} tint="dark" style={styles.drawerContent}>
{/* 头部 */}
<View style={styles.header}>
<Text style={styles.headerTitle}>对话历史</Text>
<TouchableOpacity
style={styles.closeButton}
onPress={onClose}
activeOpacity={0.7}
>
<Text style={styles.closeButtonText}></Text>
</TouchableOpacity>
</View>
{/* 新建按钮 */}
<TouchableOpacity
style={styles.newButton}
onPress={onNewSession}
activeOpacity={0.7}
>
<Text style={styles.newButtonIcon}>+</Text>
<Text style={styles.newButtonText}>新建对话</Text>
</TouchableOpacity>
{/* 搜索框 */}
<View style={styles.searchContainer}>
<Text style={styles.searchIcon}>🔍</Text>
<TextInput
style={styles.searchInput}
value={searchText}
onChangeText={setSearchText}
placeholder="搜索对话..."
placeholderTextColor={AgentTheme.textMuted}
/>
{searchText.length > 0 && (
<TouchableOpacity
onPress={() => setSearchText('')}
style={styles.clearButton}
>
<Text style={styles.clearButtonText}></Text>
</TouchableOpacity>
)}
</View>
{/* 会话列表 */}
{isLoading ? (
<View style={styles.loadingContainer}>
<Text style={styles.loadingText}>加载中...</Text>
</View>
) : groups.length === 0 ? (
<View style={styles.emptyContainer}>
<Text style={styles.emptyIcon}>📭</Text>
<Text style={styles.emptyText}>
{searchText ? '没有找到匹配的对话' : '暂无对话历史'}
</Text>
</View>
) : (
<FlatList
data={groups}
renderItem={renderGroup}
keyExtractor={(item) => item.title}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
/>
)}
</BlurView>
</View>
</View>
</Modal>
);
};
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);

View File

@@ -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 = () => (
<View style={styles.avatarContainer}>
<LinearGradient
colors={['rgba(139, 92, 246, 0.3)', 'rgba(236, 72, 153, 0.3)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.avatarGlow}
/>
<Image source={LuoxiAvatar} style={styles.avatar} />
</View>
);
/**
* Bento 功能卡片
*/
const BentoCard = memo(({ icon, title, description, color, size = 'small' }) => (
<View style={[
styles.bentoCard,
size === 'large' && styles.bentoCardLarge,
{ borderLeftColor: color }
]}>
<Text style={styles.bentoIcon}>{icon}</Text>
<Text style={styles.bentoTitle}>{title}</Text>
<Text style={styles.bentoDesc}>{description}</Text>
</View>
));
/**
* 快捷问题卡片
*/
const QuickQuestionCard = memo(({ question, onPress, index }) => (
<TouchableOpacity
style={styles.questionCard}
onPress={() => onPress(question)}
activeOpacity={0.7}
>
<LinearGradient
colors={['rgba(139, 92, 246, 0.1)', 'rgba(99, 102, 241, 0.05)']}
start={{ x: 0, y: 0 }}
end={{ x: 1, y: 1 }}
style={styles.questionGradient}
>
<View style={styles.questionContent}>
<Text style={styles.questionText}>{question}</Text>
<View style={styles.questionArrowContainer}>
<Text style={styles.questionArrow}></Text>
</View>
</View>
</LinearGradient>
</TouchableOpacity>
));
/**
* WelcomeScreen 组件
*/
const WelcomeScreen = ({ onQuickQuestion }) => {
return (
<ScrollView
style={styles.container}
contentContainerStyle={styles.contentContainer}
showsVerticalScrollIndicator={false}
>
{/* 头部 - 洛希介绍 */}
<View style={styles.header}>
<AIAvatar />
<Text style={styles.title}>{AGENT_NAME}</Text>
<Text style={styles.subtitle}>AI 投研助手 · 洛希极限</Text>
<Text style={styles.tagline}>在市场的混沌中找到价值与风险的平衡点</Text>
</View>
{/* Bento Grid 功能展示 */}
<View style={styles.bentoGrid}>
<View style={styles.bentoRow}>
<BentoCard
icon="📊"
title="深度分析"
description="基本面与技术面"
color="#8B5CF6"
/>
<BentoCard
icon="🔥"
title="热点追踪"
description="涨停板块监控"
color="#EC4899"
/>
</View>
<View style={styles.bentoRow}>
<BentoCard
icon="📈"
title="趋势研究"
description="行业投资机会"
color="#10B981"
/>
<BentoCard
icon="🧮"
title="量化分析"
description="因子指标计算"
color="#F59E0B"
/>
</View>
</View>
{/* 快捷问题 */}
<View style={styles.quickSection}>
<View style={styles.sectionHeader}>
<Text style={styles.sectionTitle}>快速开始</Text>
<Text style={styles.sectionSubtitle}>试试这些问题</Text>
</View>
<View style={styles.questionList}>
{QUICK_QUESTIONS.map((question, index) => (
<QuickQuestionCard
key={index}
question={question}
onPress={onQuickQuestion}
index={index}
/>
))}
</View>
</View>
{/* 底部提示 */}
<View style={styles.footer}>
<View style={styles.tipCard}>
<Text style={styles.tipIcon}>💡</Text>
<Text style={styles.tipText}>
直接输入股票代码或名称我会为你进行全面分析
</Text>
</View>
</View>
</ScrollView>
);
};
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);

View File

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

View File

@@ -0,0 +1,5 @@
/**
* Agent 模块导出
*/
export { default as AgentChatScreen } from './AgentChatScreen';

View File

@@ -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<string>} params.tools - 工具列表
* @param {Function} onEvent - SSE 事件回调
* @param {AbortSignal} signal - 取消信号
* @returns {Promise<void>}
*/
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<Response>}
*/
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<Array>} 会话列表
*/
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<Object>} 会话历史数据
*/
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,
};

View File

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

View File

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

View File

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