更新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: "ConceptsDrawer", icon: "bulb", gradient: ["#06B6D4", "#22D3EE"] },
|
||||||
{ title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] },
|
{ title: "我的自选", navigateTo: "WatchlistDrawer", icon: "star", gradient: ["#EC4899", "#F472B6"] },
|
||||||
{ title: "社区论坛", navigateTo: "CommunityDrawer", icon: "chatbubbles", gradient: ["#10B981", "#34D399"] },
|
{ 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"] },
|
{ title: "个人中心", navigateTo: "ProfileDrawerNew", icon: "person", gradient: ["#8B5CF6", "#A78BFA"] },
|
||||||
];
|
];
|
||||||
return (
|
return (
|
||||||
|
|||||||
@@ -65,6 +65,9 @@ import { ProfileScreen as NewProfileScreen } from "../src/screens/Profile";
|
|||||||
// 认证页面
|
// 认证页面
|
||||||
import { LoginScreen } from "../src/screens/Auth";
|
import { LoginScreen } from "../src/screens/Auth";
|
||||||
|
|
||||||
|
// AI 助手页面
|
||||||
|
import { AgentChatScreen } from "../src/screens/Agent";
|
||||||
|
|
||||||
// 推送通知处理
|
// 推送通知处理
|
||||||
import PushNotificationHandler from "../src/components/PushNotificationHandler";
|
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) {
|
function ProfileStack(props) {
|
||||||
return (
|
return (
|
||||||
<Stack.Navigator
|
<Stack.Navigator
|
||||||
@@ -783,6 +806,13 @@ function AppStack(props) {
|
|||||||
headerShown: false,
|
headerShown: false,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
|
<Drawer.Screen
|
||||||
|
name="AgentDrawer"
|
||||||
|
component={AgentStack}
|
||||||
|
options={{
|
||||||
|
headerShown: false,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
<Drawer.Screen
|
<Drawer.Screen
|
||||||
name="ProfileDrawerNew"
|
name="ProfileDrawerNew"
|
||||||
component={NewProfileStack}
|
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 watchlistReducer from './slices/watchlistSlice';
|
||||||
import stockReducer from './slices/stockSlice';
|
import stockReducer from './slices/stockSlice';
|
||||||
import communityReducer from './slices/communitySlice';
|
import communityReducer from './slices/communitySlice';
|
||||||
|
import agentReducer from './slices/agentSlice';
|
||||||
|
|
||||||
const store = configureStore({
|
const store = configureStore({
|
||||||
reducer: {
|
reducer: {
|
||||||
@@ -14,6 +15,7 @@ const store = configureStore({
|
|||||||
watchlist: watchlistReducer,
|
watchlist: watchlistReducer,
|
||||||
stock: stockReducer,
|
stock: stockReducer,
|
||||||
community: communityReducer,
|
community: communityReducer,
|
||||||
|
agent: agentReducer,
|
||||||
},
|
},
|
||||||
middleware: (getDefaultMiddleware) =>
|
middleware: (getDefaultMiddleware) =>
|
||||||
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工具定义 ====================
|
# ==================== MCP工具定义 ====================
|
||||||
|
|
||||||
TOOLS: List[ToolDefinition] = [
|
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(
|
ToolDefinition(
|
||||||
name="search_china_news",
|
name="search_china_news",
|
||||||
description="搜索中国新闻,使用KNN语义搜索。支持精确匹配模式,适合查找股票、公司相关新闻。",
|
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:
|
async def handle_search_china_news(args: Dict[str, Any]) -> Any:
|
||||||
"""处理中国新闻搜索"""
|
"""处理中国新闻搜索"""
|
||||||
params = {
|
params = {
|
||||||
@@ -1916,7 +1869,6 @@ async def handle_get_stock_intraday_statistics(args: Dict[str, Any]) -> Any:
|
|||||||
|
|
||||||
# 工具处理函数映射
|
# 工具处理函数映射
|
||||||
TOOL_HANDLERS = {
|
TOOL_HANDLERS = {
|
||||||
"search_news": handle_search_news,
|
|
||||||
"search_china_news": handle_search_china_news,
|
"search_china_news": handle_search_china_news,
|
||||||
"search_medical_news": handle_search_medical_news,
|
"search_medical_news": handle_search_medical_news,
|
||||||
"search_roadshows": handle_search_roadshows,
|
"search_roadshows": handle_search_roadshows,
|
||||||
@@ -4375,7 +4327,7 @@ async def health_check():
|
|||||||
services_status = {}
|
services_status = {}
|
||||||
|
|
||||||
try:
|
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"
|
services_status["news_api"] = "healthy" if response.status_code == 200 else "unhealthy"
|
||||||
except:
|
except:
|
||||||
services_status["news_api"] = "unhealthy"
|
services_status["news_api"] = "unhealthy"
|
||||||
|
|||||||
Reference in New Issue
Block a user