更新ios

This commit is contained in:
2026-01-19 17:29:55 +08:00
parent cbde81d6cd
commit 639325b7c9
11 changed files with 512 additions and 201 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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}
/>

View File

@@ -345,6 +345,13 @@ const ChannelList = ({ navigation }) => {
}
showsVerticalScrollIndicator={false}
contentContainerStyle={styles.listContent}
// 性能优化 - 防止真机渲染问题
initialNumToRender={20}
maxToRenderPerBatch={20}
windowSize={10}
removeClippedSubviews={false}
// 确保所有 section 都渲染
getItemLayout={undefined}
/>
</Box>
);

View File

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

View File

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

View File

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

View File

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