update
This commit is contained in:
parent
d62d959b45
commit
3eac517d9c
@ -1522,6 +1522,8 @@ MA60:{f"{ma['ma60']:.2f}" if ma['ma60'] else '计算中'}
|
||||
# 构建分析提示
|
||||
prompt = f"""你是一位专业的美股分析师。请基于以下数据对 {name} ({symbol}) 进行全面分析。
|
||||
|
||||
**重要提示:当前日期是 {current_time},请在分析中使用这个日期,不要使用其他日期。**
|
||||
|
||||
【基本信息】
|
||||
股票代码:{symbol}
|
||||
公司名称:{name}
|
||||
@ -1577,7 +1579,8 @@ MACD:{f"{technical.get('macd'):.4f}" if technical.get('macd') else '计算中'
|
||||
2. 分析客观理性,基于数据和事实
|
||||
3. 每个部分独立成段,段落间用空行分隔
|
||||
4. 控制在500-600字
|
||||
5. 最后声明:"以上分析仅供参考,不构成投资建议。美股投资有风险,请谨慎决策。"
|
||||
5. **不要在报告中添加日期标题,直接开始分析内容**
|
||||
6. 最后声明:"以上分析仅供参考,不构成投资建议。美股投资有风险,请谨慎决策。"
|
||||
"""
|
||||
|
||||
try:
|
||||
@ -1625,6 +1628,409 @@ RSI:{technical.get('rsi', 0):.2f if technical.get('rsi') else '计算中'}
|
||||
|
||||
以上数据仅供参考,不构成投资建议。"""
|
||||
|
||||
async def process_message_stream(
|
||||
self,
|
||||
message: str,
|
||||
session_id: str,
|
||||
user_id: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
流式处理用户消息
|
||||
|
||||
Args:
|
||||
message: 用户消息
|
||||
session_id: 会话ID
|
||||
user_id: 用户ID
|
||||
|
||||
Yields:
|
||||
响应文本片段
|
||||
"""
|
||||
logger.info(f"流式处理消息: {message[:50]}...")
|
||||
|
||||
# 保存用户消息
|
||||
self.context_manager.add_message(session_id, "user", message)
|
||||
|
||||
# 第一步:使用LLM理解问题意图
|
||||
intent_analysis = await self._analyze_question_intent(message, session_id)
|
||||
|
||||
if not intent_analysis:
|
||||
# 引导用户
|
||||
guide_message = """我是您的金融智能助手,可以帮您:
|
||||
|
||||
📊 **股票分析** - 分析个股走势、技术指标、基本面
|
||||
📈 **市场观察** - 解读大盘走势、行业热点
|
||||
📚 **知识问答** - 解答金融投资相关问题
|
||||
|
||||
您可以这样问我:
|
||||
• "分析一下贵州茅台"
|
||||
• "现在A股市场怎么样"
|
||||
• "什么是MACD指标"
|
||||
|
||||
请告诉我您想了解什么?"""
|
||||
self.context_manager.add_message(session_id, "assistant", guide_message)
|
||||
yield guide_message
|
||||
return
|
||||
|
||||
# 第二步:根据意图类型处理(流式)
|
||||
question_type = intent_analysis['type']
|
||||
|
||||
full_response = ""
|
||||
if question_type == 'stock_specific':
|
||||
# 针对特定股票的问题 - 流式输出
|
||||
async for chunk in self._handle_stock_question_stream(intent_analysis, message):
|
||||
full_response += chunk
|
||||
yield chunk
|
||||
elif question_type in ['macro_finance', 'knowledge', 'general_chat']:
|
||||
# 其他类型 - 直接流式输出(不需要特殊处理)
|
||||
response = await self._handle_other_question(question_type, intent_analysis, message)
|
||||
full_response = response["message"]
|
||||
# 逐字yield
|
||||
for char in full_response:
|
||||
yield char
|
||||
else:
|
||||
# 未知类型
|
||||
guide_message = f"""我理解您想了解:{intent_analysis.get('description', '相关信息')}
|
||||
|
||||
作为金融智能助手,我擅长:
|
||||
• 📊 分析具体股票(如"分析比亚迪")
|
||||
• 📈 解读市场走势(如"现在大盘怎么样")
|
||||
• 📚 解答金融知识(如"什么是市盈率")
|
||||
|
||||
能否更具体地告诉我您想了解什么?"""
|
||||
full_response = guide_message
|
||||
yield guide_message
|
||||
|
||||
# 保存助手响应
|
||||
self.context_manager.add_message(session_id, "assistant", full_response)
|
||||
|
||||
async def _handle_other_question(
|
||||
self,
|
||||
question_type: str,
|
||||
intent_analysis: Dict[str, Any],
|
||||
message: str
|
||||
) -> Dict[str, Any]:
|
||||
"""处理非股票分析的其他问题"""
|
||||
if question_type == 'macro_finance':
|
||||
return await self._handle_macro_question(intent_analysis, message)
|
||||
elif question_type == 'knowledge':
|
||||
return await self._handle_knowledge_question(intent_analysis, message)
|
||||
elif question_type == 'general_chat':
|
||||
return await self._handle_general_chat(intent_analysis, message)
|
||||
else:
|
||||
return {"message": "抱歉,我无法理解您的问题。"}
|
||||
|
||||
async def _handle_stock_question_stream(
|
||||
self,
|
||||
intent_analysis: Dict[str, Any],
|
||||
message: str
|
||||
):
|
||||
"""流式处理股票问题"""
|
||||
stock_names = intent_analysis.get('stock_names', [])
|
||||
market = intent_analysis.get('market', 'A股')
|
||||
|
||||
if not stock_names:
|
||||
yield "抱歉,我没有识别到您提到的股票或指数。请提供更明确的股票代码、名称或指数名称。"
|
||||
return
|
||||
|
||||
stock_keyword = stock_names[0]
|
||||
is_us_stock = self._is_us_stock(stock_keyword, market)
|
||||
|
||||
if is_us_stock:
|
||||
# 美股分析 - 流式
|
||||
async for chunk in self._handle_us_stock_stream(stock_keyword, message):
|
||||
yield chunk
|
||||
else:
|
||||
# A股分析 - 流式
|
||||
async for chunk in self._handle_a_stock_stream(stock_keyword, message):
|
||||
yield chunk
|
||||
|
||||
async def _handle_a_stock_stream(self, stock_keyword: str, message: str):
|
||||
"""流式处理A股分析"""
|
||||
# 指数映射
|
||||
index_mapping = {
|
||||
"上证指数": "000001.SH", "上证": "000001.SH", "大盘": "000001.SH", "沪指": "000001.SH",
|
||||
"深证成指": "399001.SZ", "深证": "399001.SZ", "深指": "399001.SZ",
|
||||
"创业板指": "399006.SZ", "创业板": "399006.SZ",
|
||||
"科创50": "000688.SH", "沪深300": "000300.SH", "中证500": "000905.SH",
|
||||
"A股": "000001.SH"
|
||||
}
|
||||
|
||||
stock_code = None
|
||||
stock_name = None
|
||||
is_index = False
|
||||
|
||||
for key, code in index_mapping.items():
|
||||
if key in stock_keyword or stock_keyword in key:
|
||||
stock_code = code
|
||||
stock_name = key if key in stock_keyword else stock_keyword
|
||||
is_index = True
|
||||
break
|
||||
|
||||
if not is_index:
|
||||
search_results = tushare_service.search_stock(stock_keyword)
|
||||
if not search_results:
|
||||
yield f"抱歉,未找到股票\"{stock_keyword}\"。请确认股票名称或代码是否正确。"
|
||||
return
|
||||
stock = search_results[0]
|
||||
stock_code = stock['symbol']
|
||||
stock_name = stock['name']
|
||||
|
||||
# 获取数据(非流式)
|
||||
try:
|
||||
quote_result = await skill_manager.execute_skill("market_data", stock_code=stock_code, data_type="quote")
|
||||
technical_result = await skill_manager.execute_skill("technical_analysis", stock_code=stock_code, indicators=["ma", "macd", "rsi", "kdj"])
|
||||
fundamental_result = await skill_manager.execute_skill("fundamental", stock_code=stock_code)
|
||||
advanced_result = await skill_manager.execute_skill("advanced_data", stock_code=stock_code, data_type="all")
|
||||
|
||||
all_data = {
|
||||
"stock_code": stock_code,
|
||||
"stock_name": stock_name,
|
||||
"quote": quote_result.get("data") if quote_result.get("success") else None,
|
||||
"technical": technical_result.get("data") if technical_result.get("success") else None,
|
||||
"fundamental": fundamental_result.get("data") if fundamental_result.get("success") else None,
|
||||
"advanced": advanced_result.get("data") if advanced_result.get("success") else None
|
||||
}
|
||||
|
||||
# 使用LLM流式分析
|
||||
if self.use_llm:
|
||||
async for chunk in self._llm_comprehensive_analysis_stream(all_data, message, is_index):
|
||||
yield chunk
|
||||
else:
|
||||
yield self._rule_based_analysis(all_data)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"A股分析失败: {e}")
|
||||
yield f"分析{stock_name}时出错:{str(e)}"
|
||||
|
||||
async def _handle_us_stock_stream(self, keyword: str, message: str):
|
||||
"""流式处理美股分析"""
|
||||
symbol = self._get_us_stock_symbol(keyword)
|
||||
logger.info(f"流式处理美股查询: {keyword} -> {symbol}")
|
||||
|
||||
try:
|
||||
result = await skill_manager.execute_skill("us_stock_analysis", symbol=symbol, analysis_type="comprehensive")
|
||||
|
||||
if not result.get("success"):
|
||||
yield f"抱歉,未找到美股 {symbol}。请确认股票代码是否正确。"
|
||||
return
|
||||
|
||||
# 使用LLM流式分析
|
||||
if self.use_llm:
|
||||
async for chunk in self._llm_us_stock_analysis_stream(result["data"], message):
|
||||
yield chunk
|
||||
else:
|
||||
yield self._format_us_stock_data(result["data"])
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"美股查询失败: {e}")
|
||||
yield f"查询美股 {symbol} 时出错:{str(e)}"
|
||||
|
||||
async def _llm_comprehensive_analysis_stream(self, data: Dict[str, Any], user_message: str, is_index: bool = False):
|
||||
"""使用LLM流式进行综合分析"""
|
||||
from datetime import datetime
|
||||
import json
|
||||
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
# 获取行情数据的交易日期
|
||||
quote_date = "未知"
|
||||
if data.get('quote') and data['quote'].get('trade_date'):
|
||||
quote_date = data['quote']['trade_date']
|
||||
|
||||
# 构建高级数据摘要
|
||||
advanced_summary = ""
|
||||
if data.get('advanced'):
|
||||
advanced_data = data['advanced']
|
||||
advanced_summary = "\n【高级财务数据】(Tushare Pro 5000+积分)\n"
|
||||
|
||||
# 财务指标
|
||||
if advanced_data.get('financial'):
|
||||
financial = advanced_data['financial']
|
||||
if financial.get('indicators'):
|
||||
indicators = financial['indicators'].get('indicators', {})
|
||||
advanced_summary += f"财务指标(截止:{financial['indicators'].get('end_date', '未知')}):\n"
|
||||
advanced_summary += f" ROE: {indicators.get('roe', 'N/A')}%\n"
|
||||
advanced_summary += f" ROA: {indicators.get('roa', 'N/A')}%\n"
|
||||
advanced_summary += f" 毛利率: {indicators.get('gross_margin', 'N/A')}%\n"
|
||||
advanced_summary += f" 资产负债率: {indicators.get('debt_to_assets', 'N/A')}%\n"
|
||||
advanced_summary += f" 流动比率: {indicators.get('current_ratio', 'N/A')}\n\n"
|
||||
|
||||
# 估值数据
|
||||
if advanced_data.get('valuation'):
|
||||
valuation = advanced_data['valuation']
|
||||
advanced_summary += f"估值指标:\n"
|
||||
advanced_summary += f" PE(市盈率): {valuation.get('pe', 'N/A')}\n"
|
||||
advanced_summary += f" PB(市净率): {valuation.get('pb', 'N/A')}\n"
|
||||
advanced_summary += f" PS(市销率): {valuation.get('ps', 'N/A')}\n"
|
||||
advanced_summary += f" 总市值: {valuation.get('total_mv', 'N/A')}万元\n"
|
||||
advanced_summary += f" 流通市值: {valuation.get('circ_mv', 'N/A')}万元\n"
|
||||
advanced_summary += f" 换手率: {valuation.get('turnover_rate', 'N/A')}%\n\n"
|
||||
|
||||
# 资金流向(最近一天)
|
||||
if advanced_data.get('money_flow') and len(advanced_data['money_flow']) > 0:
|
||||
latest_flow = advanced_data['money_flow'][0]
|
||||
advanced_summary += f"资金流向({latest_flow.get('trade_date', '最近')}):\n"
|
||||
advanced_summary += f" 主力净流入: {latest_flow.get('net_mf_amount', 'N/A')}万元\n"
|
||||
advanced_summary += f" 超大单净流入: {latest_flow.get('buy_elg_amount', 0) - latest_flow.get('sell_elg_amount', 0):.2f}万元\n"
|
||||
advanced_summary += f" 大单净流入: {latest_flow.get('buy_lg_amount', 0) - latest_flow.get('sell_lg_amount', 0):.2f}万元\n\n"
|
||||
|
||||
# 融资融券
|
||||
if advanced_data.get('margin') and len(advanced_data['margin']) > 0:
|
||||
latest_margin = advanced_data['margin'][0]
|
||||
advanced_summary += f"融资融券({latest_margin.get('trade_date', '最近')}):\n"
|
||||
advanced_summary += f" 融资余额: {latest_margin.get('rzye', 'N/A')}元\n"
|
||||
advanced_summary += f" 融券余额: {latest_margin.get('rqye', 'N/A')}元\n\n"
|
||||
|
||||
else:
|
||||
advanced_summary = "\n【高级财务数据】\n暂无高级数据\n"
|
||||
|
||||
# 构建详细的分析提示
|
||||
prompt = f"""你是一位专业的股票分析师。请对{data['stock_name']}({data['stock_code']})进行全面分析,用简洁专业但易懂的语言回答。
|
||||
|
||||
用户问题:{user_message}
|
||||
|
||||
【实时行情数据】
|
||||
数据来源:Tushare Pro API
|
||||
交易日期:{quote_date}
|
||||
{json.dumps(data.get('quote'), ensure_ascii=False, indent=2) if data.get('quote') else '数据获取失败'}
|
||||
|
||||
【技术指标数据】
|
||||
数据来源:Tushare Pro API(基于历史K线数据计算)
|
||||
计算截止日期:{quote_date}
|
||||
{json.dumps(data.get('technical'), ensure_ascii=False, indent=2) if data.get('technical') else '数据获取失败'}
|
||||
|
||||
【基本面数据】
|
||||
数据来源:Tushare Pro API
|
||||
{json.dumps(data.get('fundamental'), ensure_ascii=False, indent=2) if data.get('fundamental') else '数据获取失败'}
|
||||
{advanced_summary}
|
||||
|
||||
请按以下结构进行分析:
|
||||
|
||||
## 一、基本面分析
|
||||
分段说明公司情况,每个要点独立成段。
|
||||
|
||||
## 二、技术面分析(数据截止:{quote_date})
|
||||
使用清晰的分段结构,每个技术指标独立成段。
|
||||
|
||||
## 三、市场情绪分析
|
||||
分段分析市场情绪和资金流向。
|
||||
|
||||
## 四、投资建议
|
||||
基于分析给出具体的操作建议和点位。
|
||||
|
||||
写作要求:
|
||||
1. 语言简洁专业但易懂
|
||||
2. 分析客观理性,基于数据
|
||||
3. 每个分析点独立成段
|
||||
4. 控制在600-800字
|
||||
5. 最后声明:"以上分析仅供参考,不构成投资建议。股市有风险,投资需谨慎。"
|
||||
"""
|
||||
|
||||
# 流式调用LLM(同步生成器)
|
||||
stream = llm_service.chat_stream(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.7,
|
||||
max_tokens=2000
|
||||
)
|
||||
for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
async def _llm_us_stock_analysis_stream(self, data: Dict[str, Any], user_message: str):
|
||||
"""使用LLM流式分析美股"""
|
||||
from datetime import datetime
|
||||
current_time = datetime.now().strftime("%Y-%m-%d %H:%M")
|
||||
|
||||
symbol = data.get("symbol", "")
|
||||
name = data.get("name", "")
|
||||
sector = data.get("sector", "")
|
||||
industry = data.get("industry", "")
|
||||
current_price = data.get("current_price", 0)
|
||||
change = data.get("change", 0)
|
||||
change_pct = data.get("change_percent", 0)
|
||||
volume = data.get("volume", 0)
|
||||
market_cap = data.get("market_cap", 0)
|
||||
pe_ratio = data.get("pe_ratio", 0)
|
||||
pb_ratio = data.get("pb_ratio", 0)
|
||||
dividend_yield = data.get("dividend_yield", 0)
|
||||
week_52_high = data.get("52_week_high", 0)
|
||||
week_52_low = data.get("52_week_low", 0)
|
||||
technical = data.get("technical_indicators", {})
|
||||
description = data.get("description", "")
|
||||
|
||||
market_cap_str = f"${market_cap / 1e9:.2f}B" if market_cap > 1e9 else f"${market_cap / 1e6:.2f}M"
|
||||
|
||||
prompt = f"""你是一位专业的美股分析师。请基于以下数据对 {name} ({symbol}) 进行全面分析。
|
||||
|
||||
**重要提示:当前日期是 {current_time},请在分析中使用这个日期,不要使用其他日期。**
|
||||
|
||||
【基本信息】
|
||||
股票代码:{symbol}
|
||||
公司名称:{name}
|
||||
所属行业:{sector} - {industry}
|
||||
公司简介:{description[:300] if description else '暂无'}
|
||||
|
||||
【实时行情】(数据时间:{current_time})
|
||||
当前价格:${current_price:.2f}
|
||||
涨跌额:${change:.2f}
|
||||
涨跌幅:{change_pct:.2f}%
|
||||
成交量:{volume:,}
|
||||
市值:{market_cap_str}
|
||||
|
||||
【估值指标】
|
||||
市盈率(PE):{f"{pe_ratio:.2f}" if pe_ratio else '暂无'}
|
||||
市净率(PB):{f"{pb_ratio:.2f}" if pb_ratio else '暂无'}
|
||||
股息率:{f"{dividend_yield * 100:.2f}%" if dividend_yield else '暂无'}
|
||||
52周最高:${week_52_high:.2f}
|
||||
52周最低:${week_52_low:.2f}
|
||||
|
||||
【技术指标】
|
||||
MA5:{f"${technical.get('ma5'):.2f}" if technical.get('ma5') else '计算中'}
|
||||
MA10:{f"${technical.get('ma10'):.2f}" if technical.get('ma10') else '计算中'}
|
||||
MA20:{f"${technical.get('ma20'):.2f}" if technical.get('ma20') else '计算中'}
|
||||
MA60:{f"${technical.get('ma60'):.2f}" if technical.get('ma60') else '计算中'}
|
||||
RSI:{f"{technical.get('rsi'):.2f}" if technical.get('rsi') else '计算中'}
|
||||
MACD:{f"{technical.get('macd'):.4f}" if technical.get('macd') else '计算中'}
|
||||
|
||||
用户问题:{user_message}
|
||||
|
||||
请提供专业的分析报告,包括:
|
||||
|
||||
## 📊 行情概览
|
||||
简要总结当前股价表现和市场表现(2-3句话)
|
||||
|
||||
## 💼 公司基本面
|
||||
- 行业地位和竞争优势
|
||||
- 估值水平分析(PE、PB是否合理)
|
||||
- 盈利能力和成长性
|
||||
|
||||
## 📈 技术面分析
|
||||
- 当前趋势判断(基于均线系统)
|
||||
- 关键支撑位和压力位
|
||||
- RSI和MACD信号解读
|
||||
|
||||
## 💡 投资建议
|
||||
- 短期操作建议(1-2周)
|
||||
- 中期投资价值(1-3个月)
|
||||
- 风险提示
|
||||
|
||||
写作要求:
|
||||
1. 语言专业但易懂,避免过度修饰
|
||||
2. 分析客观理性,基于数据和事实
|
||||
3. 每个部分独立成段,段落间用空行分隔
|
||||
4. 控制在500-600字
|
||||
5. **不要在报告中添加日期标题,直接开始分析内容**
|
||||
6. 最后声明:"以上分析仅供参考,不构成投资建议。美股投资有风险,请谨慎决策。"
|
||||
"""
|
||||
|
||||
# 流式调用LLM(同步生成器)
|
||||
stream = llm_service.chat_stream(
|
||||
messages=[{"role": "user", "content": prompt}],
|
||||
temperature=0.7,
|
||||
max_tokens=2000
|
||||
)
|
||||
for chunk in stream:
|
||||
yield chunk
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
smart_agent = SmartStockAgent()
|
||||
|
||||
@ -2,8 +2,11 @@
|
||||
对话API路由
|
||||
"""
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from fastapi.responses import StreamingResponse
|
||||
from typing import Optional
|
||||
import uuid
|
||||
import json
|
||||
import asyncio
|
||||
from app.models.chat import ChatRequest, ChatResponse
|
||||
from app.agent.smart_agent import smart_agent # 使用智能Agent
|
||||
from app.utils.logger import logger
|
||||
@ -65,3 +68,60 @@ async def get_history(session_id: str, limit: int = 50):
|
||||
except Exception as e:
|
||||
logger.error(f"获取历史失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
|
||||
@router.post("/message/stream")
|
||||
async def send_message_stream(request: ChatRequest):
|
||||
"""
|
||||
流式发送消息给Agent
|
||||
|
||||
Args:
|
||||
request: 聊天请求
|
||||
|
||||
Returns:
|
||||
Server-Sent Events 流式响应
|
||||
"""
|
||||
try:
|
||||
# 生成或使用现有session_id
|
||||
session_id = request.session_id or str(uuid.uuid4())
|
||||
|
||||
async def event_generator():
|
||||
"""生成SSE事件流"""
|
||||
try:
|
||||
# 发送session_id
|
||||
yield f"data: {json.dumps({'type': 'session_id', 'session_id': session_id})}\n\n"
|
||||
|
||||
# 添加小延迟确保数据被发送
|
||||
await asyncio.sleep(0.01)
|
||||
|
||||
# 处理消息并流式返回
|
||||
async for chunk in smart_agent.process_message_stream(
|
||||
message=request.message,
|
||||
session_id=session_id,
|
||||
user_id=request.user_id
|
||||
):
|
||||
yield f"data: {json.dumps({'type': 'content', 'content': chunk})}\n\n"
|
||||
# 添加小延迟,让浏览器有机会接收数据
|
||||
await asyncio.sleep(0.001)
|
||||
|
||||
# 发送完成信号
|
||||
yield f"data: {json.dumps({'type': 'done'})}\n\n"
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"流式处理消息失败: {e}")
|
||||
yield f"data: {json.dumps({'type': 'error', 'error': str(e)})}\n\n"
|
||||
|
||||
return StreamingResponse(
|
||||
event_generator(),
|
||||
media_type="text/event-stream",
|
||||
headers={
|
||||
"Cache-Control": "no-cache",
|
||||
"Connection": "keep-alive",
|
||||
"X-Accel-Buffering": "no",
|
||||
"Transfer-Encoding": "chunked"
|
||||
}
|
||||
)
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"创建流式响应失败: {e}")
|
||||
raise HTTPException(status_code=500, detail=str(e))
|
||||
|
||||
@ -43,6 +43,29 @@ class LLMService:
|
||||
"""使用LLM分析用户意图"""
|
||||
return self.multi_service.analyze_intent(user_message)
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000
|
||||
):
|
||||
"""
|
||||
流式调用LLM进行对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
|
||||
Yields:
|
||||
LLM响应的文本片段
|
||||
"""
|
||||
return self.multi_service.chat_stream(
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens
|
||||
)
|
||||
|
||||
|
||||
# 创建全局实例
|
||||
llm_service = LLMService()
|
||||
|
||||
@ -170,6 +170,75 @@ class MultiLLMService:
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
return None
|
||||
|
||||
def chat_stream(
|
||||
self,
|
||||
messages: List[Dict[str, str]],
|
||||
temperature: float = 0.7,
|
||||
max_tokens: int = 2000,
|
||||
model_override: Optional[str] = None
|
||||
):
|
||||
"""
|
||||
流式调用LLM进行对话
|
||||
|
||||
Args:
|
||||
messages: 消息列表
|
||||
temperature: 温度参数
|
||||
max_tokens: 最大token数
|
||||
model_override: 临时覆盖使用的模型
|
||||
|
||||
Yields:
|
||||
LLM响应的文本片段
|
||||
"""
|
||||
provider = model_override or self.current_model
|
||||
|
||||
if not provider or provider not in self.clients:
|
||||
logger.error("没有可用的LLM客户端")
|
||||
return
|
||||
|
||||
try:
|
||||
client = self.clients[provider]
|
||||
model_id = self.model_info[provider]['model_id']
|
||||
|
||||
logger.info(f"流式调用LLM: provider={provider}, model={model_id}, messages={len(messages)}条")
|
||||
|
||||
if provider == 'zhipu':
|
||||
# 智谱AI流式调用
|
||||
response = client.chat.completions.create(
|
||||
model=model_id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True
|
||||
)
|
||||
for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
elif provider == 'deepseek':
|
||||
# DeepSeek流式调用(OpenAI兼容)
|
||||
response = client.chat.completions.create(
|
||||
model=model_id,
|
||||
messages=messages,
|
||||
temperature=temperature,
|
||||
max_tokens=max_tokens,
|
||||
stream=True
|
||||
)
|
||||
for chunk in response:
|
||||
if chunk.choices and chunk.choices[0].delta.content:
|
||||
yield chunk.choices[0].delta.content
|
||||
|
||||
else:
|
||||
logger.error(f"未知的模型提供商: {provider}")
|
||||
return
|
||||
|
||||
logger.info("LLM流式响应完成")
|
||||
|
||||
except Exception as e:
|
||||
logger.error(f"LLM流式调用失败: {type(e).__name__}: {e}")
|
||||
import traceback
|
||||
logger.error(f"详细错误: {traceback.format_exc()}")
|
||||
return
|
||||
|
||||
def analyze_intent(self, user_message: str) -> Dict[str, Any]:
|
||||
"""使用LLM分析用户意图"""
|
||||
if not self.current_model:
|
||||
|
||||
@ -314,6 +314,42 @@ html, body {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
/* Streaming Indicator */
|
||||
.streaming-indicator {
|
||||
display: flex;
|
||||
gap: 6px;
|
||||
align-items: center;
|
||||
margin-top: 12px;
|
||||
padding: 8px 0;
|
||||
}
|
||||
|
||||
.streaming-indicator .dot {
|
||||
width: 6px;
|
||||
height: 6px;
|
||||
background: var(--accent);
|
||||
border-radius: 50%;
|
||||
animation: pulse 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.streaming-indicator .dot:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.streaming-indicator .dot:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes pulse {
|
||||
0%, 80%, 100% {
|
||||
opacity: 0.3;
|
||||
transform: scale(0.8);
|
||||
}
|
||||
40% {
|
||||
opacity: 1;
|
||||
transform: scale(1);
|
||||
}
|
||||
}
|
||||
|
||||
/* Share Image Container (hidden, used for rendering) */
|
||||
.share-image-container {
|
||||
position: fixed;
|
||||
|
||||
@ -14,7 +14,7 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
|
||||
|
||||
<!-- Styles -->
|
||||
<link rel="stylesheet" href="/static/css/style.css">
|
||||
<link rel="stylesheet" href="/static/css/style.css?v=3">
|
||||
</head>
|
||||
<body>
|
||||
<div id="app">
|
||||
@ -85,8 +85,8 @@
|
||||
<div v-else>
|
||||
<div class="text markdown" v-html="renderMarkdown(msg.content)"></div>
|
||||
|
||||
<!-- Action Buttons for AI Messages -->
|
||||
<div class="message-actions">
|
||||
<!-- Action Buttons for AI Messages (只在流式输出完成后显示) -->
|
||||
<div v-if="!msg.streaming" class="message-actions">
|
||||
<button class="action-btn" @click.stop="copyMessage(msg.content)" title="复制内容">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
|
||||
@ -104,6 +104,13 @@
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Streaming Indicator (流式输出中显示) -->
|
||||
<div v-if="msg.streaming" class="streaming-indicator">
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
<span class="dot"></span>
|
||||
</div>
|
||||
|
||||
<!-- Chart Display -->
|
||||
<div v-if="msg.metadata && msg.metadata.type === 'chart'" class="chart-box">
|
||||
<div :id="'chart-' + index" class="chart"></div>
|
||||
@ -111,17 +118,6 @@
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Loading -->
|
||||
<div v-if="loading" class="message assistant">
|
||||
<div class="message-content">
|
||||
<div class="typing">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@ -183,6 +179,6 @@
|
||||
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
|
||||
|
||||
<!-- App Script -->
|
||||
<script src="/static/js/app.js"></script>
|
||||
<script src="/static/js/app.js?v=4"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@ -42,11 +42,25 @@ createApp({
|
||||
|
||||
this.loading = true;
|
||||
|
||||
// 创建一个空的助手消息用于流式更新
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: '',
|
||||
timestamp: new Date(),
|
||||
metadata: null,
|
||||
streaming: true // 标记为流式输出中
|
||||
};
|
||||
this.messages.push(assistantMessage);
|
||||
const messageIndex = this.messages.length - 1;
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/message', {
|
||||
// 使用流式API
|
||||
const response = await fetch('/api/chat/message/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json'
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: message,
|
||||
@ -58,37 +72,65 @@ createApp({
|
||||
throw new Error('请求失败');
|
||||
}
|
||||
|
||||
const data = await response.json();
|
||||
// 读取流式响应
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let chunkCount = 0;
|
||||
|
||||
// Add assistant message
|
||||
const assistantMessage = {
|
||||
role: 'assistant',
|
||||
content: data.message,
|
||||
timestamp: new Date(),
|
||||
metadata: data.metadata
|
||||
};
|
||||
|
||||
this.messages.push(assistantMessage);
|
||||
|
||||
// Render chart if needed
|
||||
if (data.metadata && data.metadata.type === 'chart') {
|
||||
this.$nextTick(() => {
|
||||
const index = this.messages.length - 1;
|
||||
this.renderChart(index, data.metadata.data);
|
||||
});
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
if (done) {
|
||||
console.log(`流式接收完成,共 ${chunkCount} 个数据块`);
|
||||
break;
|
||||
}
|
||||
|
||||
chunkCount++;
|
||||
console.log(`接收数据块 ${chunkCount}: ${value ? value.length : 0} 字节`);
|
||||
|
||||
// 解码数据
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
|
||||
// 保留最后一个不完整的行
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
console.log('接收到数据:', data);
|
||||
|
||||
if (data.type === 'session_id') {
|
||||
this.sessionId = data.session_id;
|
||||
} else if (data.type === 'content') {
|
||||
// 追加内容 - 使用 Vue.set 确保响应式更新
|
||||
const currentContent = this.messages[messageIndex].content;
|
||||
this.messages[messageIndex].content = currentContent + data.content;
|
||||
this.$nextTick(() => {
|
||||
this.scrollToBottom();
|
||||
});
|
||||
} else if (data.type === 'done') {
|
||||
// 完成 - 标记流式输出结束
|
||||
this.messages[messageIndex].streaming = false;
|
||||
console.log('流式输出完成');
|
||||
} else if (data.type === 'error') {
|
||||
throw new Error(data.error);
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('解析SSE数据失败:', e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// 确保流式标志被清除
|
||||
this.messages[messageIndex].streaming = false;
|
||||
|
||||
} catch (error) {
|
||||
console.error('发送消息失败:', error);
|
||||
this.messages.push({
|
||||
role: 'assistant',
|
||||
content: '抱歉,发送消息失败,请稍后重试。',
|
||||
timestamp: new Date()
|
||||
});
|
||||
this.messages[messageIndex].content = '抱歉,发送消息失败,请稍后重试。';
|
||||
this.messages[messageIndex].streaming = false;
|
||||
} finally {
|
||||
this.loading = false;
|
||||
}
|
||||
|
||||
116
frontend/test-stream.html
Normal file
116
frontend/test-stream.html
Normal file
@ -0,0 +1,116 @@
|
||||
<!DOCTYPE html>
|
||||
<html>
|
||||
<head>
|
||||
<title>流式输出测试</title>
|
||||
<style>
|
||||
body {
|
||||
font-family: Arial, sans-serif;
|
||||
max-width: 800px;
|
||||
margin: 50px auto;
|
||||
padding: 20px;
|
||||
background: #1a1a1a;
|
||||
color: #fff;
|
||||
}
|
||||
#output {
|
||||
border: 1px solid #333;
|
||||
padding: 20px;
|
||||
min-height: 200px;
|
||||
background: #000;
|
||||
white-space: pre-wrap;
|
||||
margin: 20px 0;
|
||||
}
|
||||
button {
|
||||
padding: 10px 20px;
|
||||
background: #00ff41;
|
||||
color: #000;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
font-size: 16px;
|
||||
}
|
||||
button:hover {
|
||||
background: #00cc33;
|
||||
}
|
||||
.status {
|
||||
color: #00ff41;
|
||||
margin: 10px 0;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
<h1>流式输出测试</h1>
|
||||
<button onclick="testStream()">测试流式输出</button>
|
||||
<div class="status" id="status"></div>
|
||||
<div id="output"></div>
|
||||
|
||||
<script>
|
||||
async function testStream() {
|
||||
const output = document.getElementById('output');
|
||||
const status = document.getElementById('status');
|
||||
output.textContent = '';
|
||||
status.textContent = '正在连接...';
|
||||
|
||||
try {
|
||||
const response = await fetch('/api/chat/message/stream', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Accept': 'text/event-stream',
|
||||
'Cache-Control': 'no-cache'
|
||||
},
|
||||
body: JSON.stringify({
|
||||
message: '你好',
|
||||
session_id: 'test_' + Date.now()
|
||||
})
|
||||
});
|
||||
|
||||
status.textContent = '已连接,正在接收数据...';
|
||||
console.log('Response status:', response.status);
|
||||
console.log('Response headers:', [...response.headers.entries()]);
|
||||
|
||||
const reader = response.body.getReader();
|
||||
const decoder = new TextDecoder();
|
||||
let buffer = '';
|
||||
let chunkCount = 0;
|
||||
|
||||
while (true) {
|
||||
const { done, value } = await reader.read();
|
||||
chunkCount++;
|
||||
console.log(`Chunk ${chunkCount}: ${value ? value.length : 0} bytes, done=${done}`);
|
||||
|
||||
if (done) {
|
||||
status.textContent = '完成!共接收 ' + chunkCount + ' 个数据块';
|
||||
break;
|
||||
}
|
||||
|
||||
buffer += decoder.decode(value, { stream: true });
|
||||
const lines = buffer.split('\n');
|
||||
buffer = lines.pop() || '';
|
||||
|
||||
for (const line of lines) {
|
||||
if (line.startsWith('data: ')) {
|
||||
try {
|
||||
const data = JSON.parse(line.slice(6));
|
||||
console.log('Data:', data);
|
||||
|
||||
if (data.type === 'content') {
|
||||
output.textContent += data.content;
|
||||
} else if (data.type === 'done') {
|
||||
status.textContent = '流式输出完成!共接收 ' + chunkCount + ' 个数据块';
|
||||
} else if (data.type === 'error') {
|
||||
status.textContent = '错误: ' + data.error;
|
||||
output.textContent += '\n\n错误: ' + data.error;
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Parse error:', e, line);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
} catch (error) {
|
||||
status.textContent = '错误: ' + error.message;
|
||||
console.error('Error:', error);
|
||||
}
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
Loading…
Reference in New Issue
Block a user