更新ios
This commit is contained in:
BIN
MeAgent/assets/imgs/luoxi.jpg
Normal file
BIN
MeAgent/assets/imgs/luoxi.jpg
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 196 KiB |
@@ -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 (
|
||||
|
||||
@@ -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}
|
||||
|
||||
261
MeAgent/src/constants/agentConstants.js
Normal file
261
MeAgent/src/constants/agentConstants.js
Normal 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,
|
||||
};
|
||||
391
MeAgent/src/hooks/useAgentChat.js
Normal file
391
MeAgent/src/hooks/useAgentChat.js
Normal 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;
|
||||
141
MeAgent/src/hooks/useAgentSessions.js
Normal file
141
MeAgent/src/hooks/useAgentSessions.js
Normal 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;
|
||||
330
MeAgent/src/screens/Agent/AgentChatScreen.js
Normal file
330
MeAgent/src/screens/Agent/AgentChatScreen.js
Normal 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;
|
||||
191
MeAgent/src/screens/Agent/components/ChatInput.js
Normal file
191
MeAgent/src/screens/Agent/components/ChatInput.js
Normal 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);
|
||||
975
MeAgent/src/screens/Agent/components/MarkdownRenderer.js
Normal file
975
MeAgent/src/screens/Agent/components/MarkdownRenderer.js
Normal 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);
|
||||
743
MeAgent/src/screens/Agent/components/MessageBubble.js
Normal file
743
MeAgent/src/screens/Agent/components/MessageBubble.js
Normal 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);
|
||||
402
MeAgent/src/screens/Agent/components/SessionDrawer.js
Normal file
402
MeAgent/src/screens/Agent/components/SessionDrawer.js
Normal 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);
|
||||
335
MeAgent/src/screens/Agent/components/WelcomeScreen.js
Normal file
335
MeAgent/src/screens/Agent/components/WelcomeScreen.js
Normal 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);
|
||||
9
MeAgent/src/screens/Agent/components/index.js
Normal file
9
MeAgent/src/screens/Agent/components/index.js
Normal 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';
|
||||
5
MeAgent/src/screens/Agent/index.js
Normal file
5
MeAgent/src/screens/Agent/index.js
Normal file
@@ -0,0 +1,5 @@
|
||||
/**
|
||||
* Agent 模块导出
|
||||
*/
|
||||
|
||||
export { default as AgentChatScreen } from './AgentChatScreen';
|
||||
439
MeAgent/src/services/agentService.js
Normal file
439
MeAgent/src/services/agentService.js
Normal 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,
|
||||
};
|
||||
@@ -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({
|
||||
|
||||
341
MeAgent/src/store/slices/agentSlice.js
Normal file
341
MeAgent/src/store/slices/agentSlice.js
Normal 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;
|
||||
@@ -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"
|
||||
|
||||
Reference in New Issue
Block a user