更新ios
This commit is contained in:
@@ -204,7 +204,10 @@ function CustomDrawerContent({
|
||||
return (
|
||||
<Pressable
|
||||
key={index}
|
||||
onPress={() => navigation.navigate(item.navigateTo)}
|
||||
onPress={() => {
|
||||
navigation.navigate(item.navigateTo);
|
||||
navigation.closeDrawer();
|
||||
}}
|
||||
mb={2}
|
||||
>
|
||||
{isFocused ? (
|
||||
|
||||
@@ -266,12 +266,13 @@ function ArticlesStack(props) {
|
||||
}
|
||||
|
||||
// 事件中心导航栈
|
||||
function EventsStack(props) {
|
||||
function EventsStack({ navigation }) {
|
||||
return (
|
||||
<Stack.Navigator
|
||||
screenOptions={{
|
||||
mode: "card",
|
||||
headerShown: false, // 隐藏默认header,使用自定义
|
||||
gestureEnabled: true, // 启用返回手势
|
||||
}}
|
||||
>
|
||||
<Stack.Screen
|
||||
@@ -280,12 +281,25 @@ function EventsStack(props) {
|
||||
options={{
|
||||
cardStyle: { backgroundColor: "#0F172A" },
|
||||
}}
|
||||
listeners={{
|
||||
focus: () => {
|
||||
// 在列表页允许 Drawer 手势
|
||||
navigation.setOptions({ swipeEnabled: true });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
name="EventDetail"
|
||||
component={EventDetail}
|
||||
options={{
|
||||
cardStyle: { backgroundColor: "#0F172A" },
|
||||
gestureEnabled: true, // 确保返回手势启用
|
||||
}}
|
||||
listeners={{
|
||||
focus: () => {
|
||||
// 在详情页禁用 Drawer 手势,让滑动只触发返回
|
||||
navigation.setOptions({ swipeEnabled: false });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
<Stack.Screen
|
||||
@@ -293,6 +307,12 @@ function EventsStack(props) {
|
||||
component={StockDetailScreen}
|
||||
options={{
|
||||
cardStyle: { backgroundColor: "#0A0A0F" },
|
||||
gestureEnabled: true,
|
||||
}}
|
||||
listeners={{
|
||||
focus: () => {
|
||||
navigation.setOptions({ swipeEnabled: false });
|
||||
},
|
||||
}}
|
||||
/>
|
||||
</Stack.Navigator>
|
||||
|
||||
@@ -27,6 +27,7 @@ export const SSEEventTypes = {
|
||||
STEP_START: 'step_start',
|
||||
STEP_COMPLETE: 'step_complete',
|
||||
SUMMARY_CHUNK: 'summary_chunk',
|
||||
SUMMARY_RESET: 'summary_reset', // 总结阶段检测到新工具调用,重置已有内容
|
||||
SESSION_TITLE: 'session_title',
|
||||
DONE: 'done',
|
||||
ERROR: 'error',
|
||||
|
||||
@@ -26,24 +26,41 @@ export const AuthProvider = ({ children }) => {
|
||||
|
||||
// 先检查本地是否有登录状态
|
||||
const storedUser = await authService.getStoredUser();
|
||||
if (storedUser) {
|
||||
const hasToken = await authService.getAccessToken();
|
||||
|
||||
if (storedUser && hasToken) {
|
||||
// 有本地用户信息和 token,先恢复登录状态
|
||||
setUser(storedUser);
|
||||
setIsLoggedIn(true);
|
||||
console.log('[AuthContext] 从本地恢复登录状态:', storedUser.username);
|
||||
|
||||
// 然后尝试从服务器获取最新用户信息
|
||||
const serverUser = await authService.getCurrentUser();
|
||||
if (serverUser) {
|
||||
setUser(serverUser);
|
||||
// 获取订阅信息
|
||||
const subInfo = await authService.getSubscription();
|
||||
if (subInfo.success) {
|
||||
setSubscription(subInfo.data || subInfo.subscription);
|
||||
// 然后在后台尝试从服务器验证并获取最新用户信息
|
||||
// 使用 setTimeout 确保 UI 先渲染
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const serverUser = await authService.getCurrentUser();
|
||||
if (serverUser) {
|
||||
setUser(serverUser);
|
||||
console.log('[AuthContext] 服务器验证成功,更新用户信息');
|
||||
// 获取订阅信息
|
||||
const subInfo = await authService.getSubscription();
|
||||
if (subInfo.success) {
|
||||
setSubscription(subInfo.data || subInfo.subscription);
|
||||
}
|
||||
} else {
|
||||
// 服务器验证失败,但保留本地登录状态
|
||||
// 用户可以继续使用,下次操作时如果需要会提示重新登录
|
||||
console.warn('[AuthContext] 服务器验证失败,保留本地登录状态');
|
||||
}
|
||||
} catch (error) {
|
||||
// 网络错误等,保留本地状态
|
||||
console.warn('[AuthContext] 后台验证出错,保留本地登录状态:', error.message);
|
||||
}
|
||||
} else {
|
||||
// 服务器验证失败,清除本地状态
|
||||
setUser(null);
|
||||
setIsLoggedIn(false);
|
||||
}
|
||||
}, 100);
|
||||
} else if (storedUser) {
|
||||
// 有用户信息但没有 token,需要重新登录
|
||||
console.log('[AuthContext] 没有有效 token,需要重新登录');
|
||||
await authService.clearLocalAuth();
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('[AuthContext] 初始化失败:', error);
|
||||
|
||||
@@ -212,6 +212,15 @@ export const useAgentChat = () => {
|
||||
}));
|
||||
break;
|
||||
|
||||
case SSEEventTypes.SUMMARY_RESET:
|
||||
// 总结阶段检测到新工具调用,重置已有内容
|
||||
// 这发生在模型在总结过程中又输出了工具调用标签的情况
|
||||
console.log('[useAgentChat] 收到 summary_reset 事件,重置响应内容');
|
||||
responseContentRef.current = '';
|
||||
// 移除已有的 AGENT_RESPONSE 消息
|
||||
dispatch(removeMessagesByType(MessageTypes.AGENT_RESPONSE));
|
||||
break;
|
||||
|
||||
case SSEEventTypes.SUMMARY_CHUNK:
|
||||
// 总结流式输出
|
||||
if (data?.content) {
|
||||
|
||||
@@ -44,14 +44,15 @@ import {
|
||||
/**
|
||||
* 头部导航栏
|
||||
*/
|
||||
const Header = ({ title, onMenuPress, onNewPress }) => (
|
||||
const Header = ({ title, onHistoryPress, onNewPress }) => (
|
||||
<View style={styles.header}>
|
||||
<TouchableOpacity
|
||||
style={styles.headerButton}
|
||||
onPress={onMenuPress}
|
||||
onPress={onHistoryPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.headerButtonText}>☰</Text>
|
||||
{/* 使用时钟图标表示历史记录 */}
|
||||
<Text style={styles.headerButtonText}>🕐</Text>
|
||||
</TouchableOpacity>
|
||||
|
||||
<View style={styles.headerCenter}>
|
||||
@@ -68,7 +69,8 @@ const Header = ({ title, onMenuPress, onNewPress }) => (
|
||||
onPress={onNewPress}
|
||||
activeOpacity={0.7}
|
||||
>
|
||||
<Text style={styles.headerButtonText}>+</Text>
|
||||
{/* 使用铅笔图标表示新建对话 */}
|
||||
<Text style={styles.headerButtonText}>✏️</Text>
|
||||
</TouchableOpacity>
|
||||
</View>
|
||||
);
|
||||
@@ -201,7 +203,7 @@ const AgentChatScreen = ({ navigation }) => {
|
||||
{/* 头部 */}
|
||||
<Header
|
||||
title={currentSessionTitle || '价小前'}
|
||||
onMenuPress={openDrawer}
|
||||
onHistoryPress={openDrawer}
|
||||
onNewPress={handleNewSession}
|
||||
/>
|
||||
|
||||
|
||||
@@ -345,6 +345,13 @@ const ChannelList = ({ navigation }) => {
|
||||
}
|
||||
showsVerticalScrollIndicator={false}
|
||||
contentContainerStyle={styles.listContent}
|
||||
// 性能优化 - 防止真机渲染问题
|
||||
initialNumToRender={20}
|
||||
maxToRenderPerBatch={20}
|
||||
windowSize={10}
|
||||
removeClippedSubviews={false}
|
||||
// 确保所有 section 都渲染
|
||||
getItemLayout={undefined}
|
||||
/>
|
||||
</Box>
|
||||
);
|
||||
|
||||
@@ -286,7 +286,7 @@ const StockDetailScreen = () => {
|
||||
return (
|
||||
<Box flex={1} bg="#0A0A0F">
|
||||
<SafeAreaView style={styles.container} edges={['top']}>
|
||||
{/* 价格头部 - Wind 风格(固定在顶部) */}
|
||||
{/* 价格头部 - 固定在顶部,使用 WebSocket 实时行情 */}
|
||||
<PriceHeader
|
||||
stock={{ stock_code: stockCode, stock_name: displayStockName }}
|
||||
quote={quote}
|
||||
@@ -296,38 +296,39 @@ const StockDetailScreen = () => {
|
||||
isRealtime={wsConnected && !!realtimeQuote?.current_price}
|
||||
/>
|
||||
|
||||
{/* 图表类型切换 */}
|
||||
<ChartTypeTabs
|
||||
activeType={chartType}
|
||||
onChange={handleChartTypeChange}
|
||||
/>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Box mt={2}>
|
||||
{chartType === 'minute' ? (
|
||||
<MinuteChart
|
||||
data={currentChartData}
|
||||
preClose={minutePrevClose || quote.pre_close}
|
||||
loading={isChartLoading}
|
||||
/>
|
||||
) : (
|
||||
<KlineChart
|
||||
data={currentChartData}
|
||||
type={chartType}
|
||||
loading={isChartLoading}
|
||||
riseAnalysisData={riseAnalysisData}
|
||||
onAnalysisPress={handleAnalysisPress}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 5档盘口 + 行情数据 + 相关信息(可滚动区域) */}
|
||||
{/* 可滚动区域 - 包含图表和其他内容 */}
|
||||
<ScrollView
|
||||
flex={1}
|
||||
showsVerticalScrollIndicator={false}
|
||||
nestedScrollEnabled={true}
|
||||
contentContainerStyle={styles.scrollContent}
|
||||
bounces={true}
|
||||
>
|
||||
{/* 图表类型切换 */}
|
||||
<ChartTypeTabs
|
||||
activeType={chartType}
|
||||
onChange={handleChartTypeChange}
|
||||
/>
|
||||
|
||||
{/* 图表区域 */}
|
||||
<Box mt={2}>
|
||||
{chartType === 'minute' ? (
|
||||
<MinuteChart
|
||||
data={currentChartData}
|
||||
preClose={minutePrevClose || quote.pre_close}
|
||||
loading={isChartLoading}
|
||||
/>
|
||||
) : (
|
||||
<KlineChart
|
||||
data={currentChartData}
|
||||
type={chartType}
|
||||
loading={isChartLoading}
|
||||
riseAnalysisData={riseAnalysisData}
|
||||
onAnalysisPress={handleAnalysisPress}
|
||||
/>
|
||||
)}
|
||||
</Box>
|
||||
|
||||
{/* 5档盘口 - 优先 WebSocket 实时数据,降级到 API 数据 */}
|
||||
{chartType === 'minute' && (
|
||||
<OrderBook
|
||||
@@ -344,8 +345,8 @@ const StockDetailScreen = () => {
|
||||
{/* 相关信息 Tab */}
|
||||
<RelatedInfoTabs activeTab={infoTab} onChange={setInfoTab} />
|
||||
|
||||
{/* 相关信息内容(给予固定最小高度,内部自己滚动) */}
|
||||
<Box minH={350} pb={4}>
|
||||
{/* 相关信息内容 */}
|
||||
<Box minH={350} pb={100}>
|
||||
{renderInfoContent()}
|
||||
</Box>
|
||||
</ScrollView>
|
||||
|
||||
@@ -3,8 +3,8 @@
|
||||
* 支持搜索股票并添加到自选
|
||||
*/
|
||||
|
||||
import React, { useState, useCallback, useRef } from 'react';
|
||||
import { StyleSheet, Keyboard, ActivityIndicator } from 'react-native';
|
||||
import React, { useState, useCallback, useRef, useMemo, memo } from 'react';
|
||||
import { Keyboard, ActivityIndicator, Dimensions } from 'react-native';
|
||||
import {
|
||||
Modal,
|
||||
Box,
|
||||
@@ -18,24 +18,33 @@ import {
|
||||
useToast,
|
||||
} from 'native-base';
|
||||
import { Ionicons } from '@expo/vector-icons';
|
||||
import { BlurView } from 'expo-blur';
|
||||
import { stockDetailService } from '../../services/stockService';
|
||||
import { useWatchlist } from '../../hooks/useWatchlist';
|
||||
|
||||
// 搜索结果项组件
|
||||
const SearchResultItem = ({ item, onAdd, isAdding, isInWatchlist }) => {
|
||||
const { height: SCREEN_HEIGHT } = Dimensions.get('window');
|
||||
|
||||
// 列表项高度常量(用于 getItemLayout 优化)
|
||||
const ITEM_HEIGHT = 56;
|
||||
|
||||
// 搜索结果项组件 - 使用 memo 避免不必要的重渲染
|
||||
const SearchResultItem = memo(({ item, onAdd, isAdding, alreadyAdded }) => {
|
||||
const { stock_code, stock_name, industry } = item;
|
||||
const alreadyAdded = isInWatchlist(stock_code);
|
||||
|
||||
const handlePress = useCallback(() => {
|
||||
if (!alreadyAdded && !isAdding) {
|
||||
onAdd(item);
|
||||
}
|
||||
}, [alreadyAdded, isAdding, onAdd, item]);
|
||||
|
||||
return (
|
||||
<Pressable
|
||||
onPress={() => !alreadyAdded && !isAdding && onAdd(item)}
|
||||
onPress={handlePress}
|
||||
disabled={alreadyAdded || isAdding}
|
||||
>
|
||||
{({ pressed }) => (
|
||||
<HStack
|
||||
h={ITEM_HEIGHT}
|
||||
px={4}
|
||||
py={3}
|
||||
alignItems="center"
|
||||
justifyContent="space-between"
|
||||
bg={pressed && !alreadyAdded ? 'rgba(255,255,255,0.05)' : 'transparent'}
|
||||
@@ -95,7 +104,7 @@ const SearchResultItem = ({ item, onAdd, isAdding, isInWatchlist }) => {
|
||||
)}
|
||||
</Pressable>
|
||||
);
|
||||
};
|
||||
});
|
||||
|
||||
// 热门股票配置
|
||||
const HOT_STOCKS = [
|
||||
@@ -121,11 +130,24 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
const [isSearching, setIsSearching] = useState(false);
|
||||
const [addingCode, setAddingCode] = useState(null);
|
||||
const searchTimeoutRef = useRef(null);
|
||||
const abortControllerRef = useRef(null);
|
||||
const toast = useToast();
|
||||
|
||||
const { addStock, isInWatchlist } = useWatchlist({ autoLoad: false });
|
||||
const { addStock, isInWatchlist, stocks } = useWatchlist({ autoLoad: false });
|
||||
|
||||
// 搜索股票
|
||||
// 创建已添加股票代码的 Set 用于快速查找
|
||||
const watchlistCodesSet = useMemo(() => {
|
||||
const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code;
|
||||
return new Set(stocks.map(s => normalizeCode(s.stock_code)));
|
||||
}, [stocks]);
|
||||
|
||||
// 检查是否在自选中(使用 Set 优化查找)
|
||||
const checkInWatchlist = useCallback((stockCode) => {
|
||||
const normalizeCode = (code) => String(code).match(/\d{6}/)?.[0] || code;
|
||||
return watchlistCodesSet.has(normalizeCode(stockCode));
|
||||
}, [watchlistCodesSet]);
|
||||
|
||||
// 搜索股票(带取消功能)
|
||||
const handleSearch = useCallback(async (text) => {
|
||||
setSearchText(text);
|
||||
|
||||
@@ -134,14 +156,22 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
|
||||
// 取消之前的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
|
||||
if (!text.trim()) {
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// 防抖搜索
|
||||
// 防抖搜索 - 200ms 更快响应
|
||||
searchTimeoutRef.current = setTimeout(async () => {
|
||||
setIsSearching(true);
|
||||
abortControllerRef.current = new AbortController();
|
||||
|
||||
try {
|
||||
const response = await stockDetailService.searchStocks(text, 20);
|
||||
if (response.success && Array.isArray(response.data)) {
|
||||
@@ -150,12 +180,14 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
setSearchResults([]);
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('搜索股票失败:', error);
|
||||
setSearchResults([]);
|
||||
if (error.name !== 'AbortError') {
|
||||
console.error('搜索股票失败:', error);
|
||||
setSearchResults([]);
|
||||
}
|
||||
} finally {
|
||||
setIsSearching(false);
|
||||
}
|
||||
}, 300);
|
||||
}, 200);
|
||||
}, []);
|
||||
|
||||
// 添加到自选
|
||||
@@ -189,35 +221,67 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
}
|
||||
}, [addStock, toast]);
|
||||
|
||||
// 关闭弹窗
|
||||
// 关闭弹窗 - 清理资源
|
||||
const handleClose = useCallback(() => {
|
||||
Keyboard.dismiss();
|
||||
// 取消进行中的请求
|
||||
if (abortControllerRef.current) {
|
||||
abortControllerRef.current.abort();
|
||||
}
|
||||
if (searchTimeoutRef.current) {
|
||||
clearTimeout(searchTimeoutRef.current);
|
||||
}
|
||||
setSearchText('');
|
||||
setSearchResults([]);
|
||||
setIsSearching(false);
|
||||
onClose?.();
|
||||
}, [onClose]);
|
||||
|
||||
// 渲染列表数据
|
||||
const displayData = searchText.trim() ? searchResults : HOT_STOCKS;
|
||||
// 渲染列表数据 - 使用 useMemo 缓存
|
||||
const displayData = useMemo(() => {
|
||||
return searchText.trim() ? searchResults : HOT_STOCKS;
|
||||
}, [searchText, searchResults]);
|
||||
|
||||
const listTitle = searchText.trim() ? '搜索结果' : '热门股票';
|
||||
|
||||
// FlatList 性能优化配置
|
||||
const getItemLayout = useCallback((_, index) => ({
|
||||
length: ITEM_HEIGHT,
|
||||
offset: ITEM_HEIGHT * index,
|
||||
index,
|
||||
}), []);
|
||||
|
||||
const keyExtractor = useCallback((item) => item.stock_code, []);
|
||||
|
||||
const renderItem = useCallback(({ item }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
onAdd={handleAdd}
|
||||
isAdding={addingCode === item.stock_code}
|
||||
alreadyAdded={checkInWatchlist(item.stock_code)}
|
||||
/>
|
||||
), [handleAdd, addingCode, checkInWatchlist]);
|
||||
|
||||
return (
|
||||
<Modal isOpen={isOpen} onClose={handleClose} size="full">
|
||||
<Modal
|
||||
isOpen={isOpen}
|
||||
onClose={handleClose}
|
||||
size="full"
|
||||
animationPreset="slide"
|
||||
_backdrop={{
|
||||
bg: 'rgba(0,0,0,0.6)',
|
||||
}}
|
||||
>
|
||||
<Modal.Content
|
||||
bg="transparent"
|
||||
maxH="85%"
|
||||
bg="#14141E"
|
||||
h={SCREEN_HEIGHT * 0.75}
|
||||
marginBottom={0}
|
||||
marginTop="auto"
|
||||
borderTopRadius={24}
|
||||
borderBottomRadius={0}
|
||||
shadow={9}
|
||||
>
|
||||
<BlurView intensity={80} tint="dark" style={StyleSheet.absoluteFill} />
|
||||
<Box
|
||||
flex={1}
|
||||
bg="rgba(20, 20, 30, 0.95)"
|
||||
borderTopRadius={24}
|
||||
overflow="hidden"
|
||||
>
|
||||
<Box flex={1} bg="#14141E" borderTopRadius={24}>
|
||||
{/* 头部 */}
|
||||
<HStack
|
||||
px={4}
|
||||
@@ -297,15 +361,13 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
{/* 股票列表 */}
|
||||
<FlatList
|
||||
data={displayData}
|
||||
keyExtractor={(item) => item.stock_code}
|
||||
renderItem={({ item }) => (
|
||||
<SearchResultItem
|
||||
item={item}
|
||||
onAdd={handleAdd}
|
||||
isAdding={addingCode === item.stock_code}
|
||||
isInWatchlist={isInWatchlist}
|
||||
/>
|
||||
)}
|
||||
keyExtractor={keyExtractor}
|
||||
renderItem={renderItem}
|
||||
getItemLayout={getItemLayout}
|
||||
initialNumToRender={10}
|
||||
maxToRenderPerBatch={10}
|
||||
windowSize={5}
|
||||
removeClippedSubviews={true}
|
||||
ListEmptyComponent={
|
||||
<Box py={10} alignItems="center">
|
||||
<Icon
|
||||
@@ -329,6 +391,4 @@ const AddWatchlistModal = ({ isOpen, onClose }) => {
|
||||
);
|
||||
};
|
||||
|
||||
const styles = StyleSheet.create({});
|
||||
|
||||
export default AddWatchlistModal;
|
||||
|
||||
@@ -84,9 +84,10 @@ export const authService = {
|
||||
|
||||
/**
|
||||
* 获取当前用户信息
|
||||
* @param {boolean} clearOnFail - 失败时是否清除本地状态(默认 false)
|
||||
* @returns {Promise<object>} 用户信息
|
||||
*/
|
||||
getCurrentUser: async () => {
|
||||
getCurrentUser: async (clearOnFail = false) => {
|
||||
try {
|
||||
const response = await apiRequest('/api/account/user-info');
|
||||
|
||||
@@ -97,11 +98,18 @@ export const authService = {
|
||||
return response.user;
|
||||
}
|
||||
|
||||
// 如果返回 401/403,说明 token 无效
|
||||
if (response.status === 401 || response.status === 403) {
|
||||
console.warn('[AuthService] Token 无效,需要重新登录');
|
||||
if (clearOnFail) {
|
||||
await authService.clearLocalAuth();
|
||||
}
|
||||
}
|
||||
|
||||
return null;
|
||||
} catch (error) {
|
||||
console.error('[AuthService] 获取用户信息失败:', error);
|
||||
// 可能是未登录,清除本地状态
|
||||
await authService.clearLocalAuth();
|
||||
// 网络错误等,不清除本地状态,让用户可以继续使用
|
||||
return null;
|
||||
}
|
||||
},
|
||||
|
||||
401
mcp_server.py
401
mcp_server.py
@@ -2934,129 +2934,312 @@ A股交易时间: 上午 9:30-11:30,下午 13:00-15:00
|
||||
steps=[ToolCall(tool=s["tool"], arguments=s["arguments"], reason=s["reason"]) for s in plan_steps],
|
||||
)
|
||||
|
||||
# 阶段3: 生成最终总结
|
||||
yield self._format_sse("status", {"stage": "summarizing", "message": "正在生成最终总结..."})
|
||||
|
||||
# 收集成功的结果
|
||||
successful_results = [r for r in step_results if r.status == "success"]
|
||||
|
||||
# 初始化 final_summary
|
||||
# 阶段3: 生成最终总结(支持在总结中检测到工具调用后继续执行)
|
||||
max_summary_iterations = 3 # 总结阶段最多迭代次数,防止无限循环
|
||||
summary_iteration = 0
|
||||
final_summary = ""
|
||||
|
||||
if not successful_results and step_index == 0:
|
||||
# 如果没有执行任何工具(模型直接回复),使用模型的回复
|
||||
if assistant_message and assistant_message.content:
|
||||
final_summary = assistant_message.content
|
||||
# 流式发送(虽然已经是完整的,但保持前端兼容)
|
||||
while summary_iteration < max_summary_iterations:
|
||||
summary_iteration += 1
|
||||
logger.info(f"[Summary] 开始第 {summary_iteration} 轮总结生成")
|
||||
|
||||
yield self._format_sse("status", {"stage": "summarizing", "message": f"正在生成最终总结{'(第 ' + str(summary_iteration) + ' 轮)' if summary_iteration > 1 else ''}..."})
|
||||
|
||||
# 收集成功的结果
|
||||
successful_results = [r for r in step_results if r.status == "success"]
|
||||
|
||||
# 本轮是否需要继续执行工具调用
|
||||
need_more_tool_calls = False
|
||||
detected_tool_calls = []
|
||||
|
||||
if not successful_results and step_index == 0:
|
||||
# 如果没有执行任何工具(模型直接回复),使用模型的回复
|
||||
if assistant_message and assistant_message.content:
|
||||
final_summary = assistant_message.content
|
||||
# 流式发送(虽然已经是完整的,但保持前端兼容)
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
else:
|
||||
final_summary = "抱歉,我无法处理您的请求。"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
elif not successful_results:
|
||||
# 所有步骤都失败
|
||||
final_summary = "很抱歉,所有步骤都执行失败,无法生成分析报告。"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
else:
|
||||
final_summary = "抱歉,我无法处理您的请求。"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
elif not successful_results:
|
||||
# 所有步骤都失败
|
||||
final_summary = "很抱歉,所有步骤都执行失败,无法生成分析报告。"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
else:
|
||||
# 有成功的工具调用,使用流式 API 生成最终回复
|
||||
try:
|
||||
# 使用流式 API 生成最终回复(不再传入 tools,让模型生成文本回复)
|
||||
summary_stream = llm_client.chat.completions.create(
|
||||
model=llm_model,
|
||||
messages=messages, # messages 已包含所有工具调用历史
|
||||
temperature=0.7,
|
||||
max_tokens=llm_max_tokens,
|
||||
stream=True, # 启用流式输出
|
||||
)
|
||||
# 有成功的工具调用,使用流式 API 生成最终回复
|
||||
try:
|
||||
# 使用流式 API 生成最终回复(不再传入 tools,让模型生成文本回复)
|
||||
summary_stream = llm_client.chat.completions.create(
|
||||
model=llm_model,
|
||||
messages=messages, # messages 已包含所有工具调用历史
|
||||
temperature=0.7,
|
||||
max_tokens=llm_max_tokens,
|
||||
stream=True, # 启用流式输出
|
||||
)
|
||||
|
||||
# 状态机处理 <think>...</think> 标签
|
||||
# 状态: "normal" - 正常内容, "thinking" - 思考内容
|
||||
parse_state = "normal"
|
||||
buffer = ""
|
||||
thinking_content = ""
|
||||
thinking_sent = False # 标记是否已发送过 thinking 事件
|
||||
# 状态机处理 <think>...</think> 标签和 <minimax:tool_call>...</minimax:tool_call> 标签
|
||||
# 状态: "normal" - 正常内容, "thinking" - 思考内容
|
||||
parse_state = "normal"
|
||||
buffer = ""
|
||||
thinking_content = ""
|
||||
thinking_sent = False # 标记是否已发送过 thinking 事件
|
||||
accumulated_content = "" # 累积全部内容用于检测工具调用
|
||||
|
||||
# 逐块发送总结内容
|
||||
for chunk in summary_stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
content_chunk = chunk.choices[0].delta.content
|
||||
buffer += content_chunk
|
||||
# 逐块发送总结内容
|
||||
for chunk in summary_stream:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
content_chunk = chunk.choices[0].delta.content
|
||||
buffer += content_chunk
|
||||
accumulated_content += content_chunk
|
||||
|
||||
# 处理 buffer 中的内容
|
||||
while buffer:
|
||||
if parse_state == "normal":
|
||||
# 查找 <think> 开始标签
|
||||
think_start = buffer.find("<think>")
|
||||
if think_start != -1:
|
||||
# 发送 <think> 之前的内容
|
||||
if think_start > 0:
|
||||
normal_content = buffer[:think_start]
|
||||
final_summary += normal_content
|
||||
yield self._format_sse("summary_chunk", {"content": normal_content})
|
||||
# 切换到思考状态
|
||||
parse_state = "thinking"
|
||||
buffer = buffer[think_start + 7:] # 跳过 <think>
|
||||
if not thinking_sent:
|
||||
yield self._format_sse("thinking_start", {"message": "正在深度思考..."})
|
||||
thinking_sent = True
|
||||
else:
|
||||
# 没有 <think> 标签,检查是否可能是不完整的标签
|
||||
if buffer.endswith("<") or buffer.endswith("<t") or buffer.endswith("<th") or \
|
||||
buffer.endswith("<thi") or buffer.endswith("<thin") or buffer.endswith("<think"):
|
||||
# 保留可能不完整的标签,等待更多内容
|
||||
break
|
||||
# 检测是否有完整的工具调用标签
|
||||
if '<minimax:tool_call>' in accumulated_content and '</minimax:tool_call>' in accumulated_content:
|
||||
logger.info(f"[Summary] 检测到工具调用标签,准备解析")
|
||||
detected_tool_calls = self._parse_text_tool_calls(accumulated_content)
|
||||
if detected_tool_calls:
|
||||
logger.info(f"[Summary] 解析到 {len(detected_tool_calls)} 个工具调用: {detected_tool_calls}")
|
||||
need_more_tool_calls = True
|
||||
# 发送提示信息
|
||||
yield self._format_sse("status", {"stage": "executing", "message": f"模型需要继续调用 {len(detected_tool_calls)} 个工具..."})
|
||||
break # 中断流式输出,准备执行工具调用
|
||||
|
||||
# 处理 buffer 中的内容
|
||||
while buffer:
|
||||
if parse_state == "normal":
|
||||
# 查找 <think> 开始标签
|
||||
think_start = buffer.find("<think>")
|
||||
# 查找 <minimax:tool_call> 开始标签(不完整时暂存)
|
||||
tool_call_start = buffer.find("<minimax:tool_call>")
|
||||
|
||||
if think_start != -1:
|
||||
# 发送 <think> 之前的内容
|
||||
if think_start > 0:
|
||||
normal_content = buffer[:think_start]
|
||||
# 过滤掉可能包含的工具调用标签
|
||||
if '<minimax:tool_call>' not in normal_content:
|
||||
final_summary += normal_content
|
||||
yield self._format_sse("summary_chunk", {"content": normal_content})
|
||||
# 切换到思考状态
|
||||
parse_state = "thinking"
|
||||
buffer = buffer[think_start + 7:] # 跳过 <think>
|
||||
if not thinking_sent:
|
||||
yield self._format_sse("thinking_start", {"message": "正在深度思考..."})
|
||||
thinking_sent = True
|
||||
elif tool_call_start != -1:
|
||||
# 发送 <minimax:tool_call> 之前的内容
|
||||
if tool_call_start > 0:
|
||||
normal_content = buffer[:tool_call_start]
|
||||
final_summary += normal_content
|
||||
yield self._format_sse("summary_chunk", {"content": normal_content})
|
||||
# 保留工具调用标签部分,等待完整内容
|
||||
buffer = buffer[tool_call_start:]
|
||||
break # 等待更多内容
|
||||
else:
|
||||
# 发送全部内容
|
||||
final_summary += buffer
|
||||
yield self._format_sse("summary_chunk", {"content": buffer})
|
||||
buffer = ""
|
||||
# 没有特殊标签,检查是否可能是不完整的标签
|
||||
incomplete_patterns = [
|
||||
"<", "<t", "<th", "<thi", "<thin", "<think",
|
||||
"<m", "<mi", "<min", "<mini", "<minim", "<minima", "<minimax",
|
||||
"<minimax:", "<minimax:t", "<minimax:to", "<minimax:too",
|
||||
"<minimax:tool", "<minimax:tool_", "<minimax:tool_c",
|
||||
"<minimax:tool_ca", "<minimax:tool_cal", "<minimax:tool_call"
|
||||
]
|
||||
is_incomplete = any(buffer.endswith(p) for p in incomplete_patterns)
|
||||
if is_incomplete:
|
||||
# 保留可能不完整的标签,等待更多内容
|
||||
break
|
||||
else:
|
||||
# 发送全部内容
|
||||
final_summary += buffer
|
||||
yield self._format_sse("summary_chunk", {"content": buffer})
|
||||
buffer = ""
|
||||
|
||||
elif parse_state == "thinking":
|
||||
# 查找 </think> 结束标签
|
||||
think_end = buffer.find("</think>")
|
||||
if think_end != -1:
|
||||
# 发送思考内容
|
||||
if think_end > 0:
|
||||
think_chunk = buffer[:think_end]
|
||||
thinking_content += think_chunk
|
||||
yield self._format_sse("thinking_chunk", {"content": think_chunk})
|
||||
# 切换回正常状态
|
||||
parse_state = "normal"
|
||||
buffer = buffer[think_end + 8:] # 跳过 </think>
|
||||
yield self._format_sse("thinking_end", {"content": thinking_content})
|
||||
else:
|
||||
# 没有 </think> 标签,检查是否可能是不完整的标签
|
||||
if buffer.endswith("<") or buffer.endswith("</") or buffer.endswith("</t") or \
|
||||
buffer.endswith("</th") or buffer.endswith("</thi") or buffer.endswith("</thin") or buffer.endswith("</think"):
|
||||
# 保留可能不完整的标签,等待更多内容
|
||||
break
|
||||
elif parse_state == "thinking":
|
||||
# 查找 </think> 结束标签
|
||||
think_end = buffer.find("</think>")
|
||||
if think_end != -1:
|
||||
# 发送思考内容
|
||||
if think_end > 0:
|
||||
think_chunk = buffer[:think_end]
|
||||
thinking_content += think_chunk
|
||||
yield self._format_sse("thinking_chunk", {"content": think_chunk})
|
||||
# 切换回正常状态
|
||||
parse_state = "normal"
|
||||
buffer = buffer[think_end + 8:] # 跳过 </think>
|
||||
yield self._format_sse("thinking_end", {"content": thinking_content})
|
||||
else:
|
||||
# 发送全部思考内容
|
||||
thinking_content += buffer
|
||||
yield self._format_sse("thinking_chunk", {"content": buffer})
|
||||
buffer = ""
|
||||
# 没有 </think> 标签,检查是否可能是不完整的标签
|
||||
if buffer.endswith("<") or buffer.endswith("</") or buffer.endswith("</t") or \
|
||||
buffer.endswith("</th") or buffer.endswith("</thi") or buffer.endswith("</thin") or buffer.endswith("</think"):
|
||||
# 保留可能不完整的标签,等待更多内容
|
||||
break
|
||||
else:
|
||||
# 发送全部思考内容
|
||||
thinking_content += buffer
|
||||
yield self._format_sse("thinking_chunk", {"content": buffer})
|
||||
buffer = ""
|
||||
|
||||
# 处理剩余的 buffer
|
||||
if buffer:
|
||||
if parse_state == "thinking":
|
||||
thinking_content += buffer
|
||||
yield self._format_sse("thinking_chunk", {"content": buffer})
|
||||
yield self._format_sse("thinking_end", {"content": thinking_content})
|
||||
# 如果需要继续执行工具调用,跳过剩余 buffer 处理
|
||||
if need_more_tool_calls:
|
||||
pass # 后续会处理工具调用
|
||||
else:
|
||||
final_summary += buffer
|
||||
yield self._format_sse("summary_chunk", {"content": buffer})
|
||||
# 处理剩余的 buffer
|
||||
if buffer:
|
||||
# 过滤掉可能残留的工具调用标签
|
||||
buffer_filtered = self._filter_tool_call_tags(buffer)
|
||||
if parse_state == "thinking":
|
||||
thinking_content += buffer_filtered
|
||||
yield self._format_sse("thinking_chunk", {"content": buffer_filtered})
|
||||
yield self._format_sse("thinking_end", {"content": thinking_content})
|
||||
else:
|
||||
final_summary += buffer_filtered
|
||||
if buffer_filtered:
|
||||
yield self._format_sse("summary_chunk", {"content": buffer_filtered})
|
||||
|
||||
logger.info(f"[Summary] 流式总结完成,思考内容长度: {len(thinking_content)}")
|
||||
logger.info(f"[Summary] 流式总结完成,思考内容长度: {len(thinking_content)}")
|
||||
|
||||
except Exception as llm_error:
|
||||
logger.error(f"[Summary] 流式总结失败: {llm_error}")
|
||||
# 降级:使用工具调用结果的简单拼接
|
||||
results_text = "\n\n".join([
|
||||
f"**{r.tool}**: {str(r.result)[:500]}..."
|
||||
for r in successful_results[:5]
|
||||
])
|
||||
final_summary = f"根据查询结果:\n\n{results_text}"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
logger.warning("[Summary] 使用降级方案")
|
||||
except Exception as llm_error:
|
||||
logger.error(f"[Summary] 流式总结失败: {llm_error}")
|
||||
# 降级:使用工具调用结果的简单拼接
|
||||
results_text = "\n\n".join([
|
||||
f"**{r.tool}**: {str(r.result)[:500]}..."
|
||||
for r in successful_results[:5]
|
||||
])
|
||||
final_summary = f"根据查询结果:\n\n{results_text}"
|
||||
yield self._format_sse("summary_chunk", {"content": final_summary})
|
||||
logger.warning("[Summary] 使用降级方案")
|
||||
|
||||
# 如果需要继续执行工具调用
|
||||
if need_more_tool_calls and detected_tool_calls:
|
||||
logger.info(f"[Summary] 执行在总结中检测到的 {len(detected_tool_calls)} 个工具调用")
|
||||
|
||||
# 将 assistant 消息添加到历史
|
||||
messages.append({"role": "assistant", "content": accumulated_content})
|
||||
|
||||
# 发送 plan_update 事件
|
||||
current_round_steps = [
|
||||
{"tool": tc["name"], "arguments": tc["arguments"], "reason": f"调用 {tc['name']}"}
|
||||
for tc in detected_tool_calls
|
||||
]
|
||||
yield self._format_sse("plan_update", {
|
||||
"new_steps": current_round_steps,
|
||||
"round": summary_iteration,
|
||||
"message": f"模型在总结中决定继续调用 {len(detected_tool_calls)} 个工具"
|
||||
})
|
||||
|
||||
# 执行每个工具调用
|
||||
for tc in detected_tool_calls:
|
||||
tool_name = tc["name"]
|
||||
arguments = tc["arguments"]
|
||||
tool_call_id = f"summary_call_{summary_iteration}_{step_index}_{tool_name}"
|
||||
|
||||
logger.info(f"[Tool Call] ========== 总结阶段工具调用开始 ==========")
|
||||
logger.info(f"[Tool Call] 工具名: {tool_name}")
|
||||
logger.info(f"[Tool Call] 参数内容: {json.dumps(arguments, ensure_ascii=False)}")
|
||||
|
||||
# 发送步骤开始事件
|
||||
yield self._format_sse("step_start", {
|
||||
"step_index": step_index,
|
||||
"tool": tool_name,
|
||||
"arguments": arguments,
|
||||
"reason": f"调用 {tool_name}",
|
||||
})
|
||||
|
||||
start_time = datetime.now()
|
||||
|
||||
try:
|
||||
# 特殊处理 summarize_news
|
||||
if tool_name == "summarize_news":
|
||||
data_arg = arguments.get("data", "")
|
||||
if data_arg in ["前面的新闻数据", "前面收集的所有数据", ""]:
|
||||
arguments["data"] = json.dumps(collected_data, ensure_ascii=False, indent=2)
|
||||
|
||||
# 执行工具
|
||||
result = await self.execute_tool(tool_name, arguments, tool_handlers)
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
|
||||
# 记录结果
|
||||
step_result = StepResult(
|
||||
step_index=step_index,
|
||||
tool=tool_name,
|
||||
arguments=arguments,
|
||||
status="success",
|
||||
result=result,
|
||||
execution_time=execution_time,
|
||||
)
|
||||
step_results.append(step_result)
|
||||
collected_data[f"step_{step_index+1}_{tool_name}"] = result
|
||||
plan_steps.append({"tool": tool_name, "arguments": arguments, "reason": f"调用 {tool_name}"})
|
||||
|
||||
# 更新 plan 对象
|
||||
plan = ExecutionPlan(
|
||||
goal=f"分析用户问题:{user_query[:50]}...",
|
||||
reasoning="使用工具获取相关数据进行分析",
|
||||
steps=[ToolCall(tool=s["tool"], arguments=s["arguments"], reason=s["reason"]) for s in plan_steps],
|
||||
)
|
||||
|
||||
# 发送步骤完成事件
|
||||
yield self._format_sse("step_complete", {
|
||||
"step_index": step_index,
|
||||
"tool": tool_name,
|
||||
"status": "success",
|
||||
"result": result,
|
||||
"execution_time": execution_time,
|
||||
})
|
||||
|
||||
# 将工具结果添加到消息历史
|
||||
result_str = json.dumps(result, ensure_ascii=False) if isinstance(result, (dict, list)) else str(result)
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"[工具调用结果] {tool_name}: {result_str[:3000]}"
|
||||
})
|
||||
|
||||
logger.info(f"[Tool Call] 执行成功,耗时 {execution_time:.2f}s")
|
||||
|
||||
except Exception as e:
|
||||
execution_time = (datetime.now() - start_time).total_seconds()
|
||||
error_msg = str(e)
|
||||
|
||||
step_result = StepResult(
|
||||
step_index=step_index,
|
||||
tool=tool_name,
|
||||
arguments=arguments,
|
||||
status="failed",
|
||||
error=error_msg,
|
||||
execution_time=execution_time,
|
||||
)
|
||||
step_results.append(step_result)
|
||||
|
||||
yield self._format_sse("step_complete", {
|
||||
"step_index": step_index,
|
||||
"tool": tool_name,
|
||||
"status": "failed",
|
||||
"error": error_msg,
|
||||
"execution_time": execution_time,
|
||||
})
|
||||
|
||||
messages.append({
|
||||
"role": "user",
|
||||
"content": f"[工具调用失败] {tool_name}: {error_msg}"
|
||||
})
|
||||
|
||||
logger.error(f"[Tool Call] 执行失败: {error_msg}")
|
||||
|
||||
logger.info(f"[Tool Call] ========== 总结阶段工具调用结束 ==========")
|
||||
step_index += 1
|
||||
|
||||
# 重置 final_summary,准备重新生成
|
||||
final_summary = ""
|
||||
# 发送重置事件,通知前端清空已有的响应内容
|
||||
yield self._format_sse("summary_reset", {"message": "准备重新生成总结"})
|
||||
# 继续循环,重新生成总结
|
||||
continue
|
||||
else:
|
||||
# 没有更多工具调用,退出循环
|
||||
break
|
||||
|
||||
# 如果达到最大迭代次数,记录警告
|
||||
if summary_iteration >= max_summary_iterations:
|
||||
logger.warning(f"[Summary] 达到最大迭代次数 {max_summary_iterations},停止工具调用循环")
|
||||
|
||||
# 过滤掉可能残留的工具调用标签(MiniMax 等模型可能在总结中输出工具调用)
|
||||
final_summary = self._filter_tool_call_tags(final_summary)
|
||||
|
||||
Reference in New Issue
Block a user