This commit is contained in:
aaron 2026-02-03 22:48:45 +08:00
parent d62d959b45
commit 3eac517d9c
8 changed files with 789 additions and 41 deletions

View File

@ -1522,6 +1522,8 @@ MA60{f"{ma['ma60']:.2f}" if ma['ma60'] else '计算中'}
# 构建分析提示 # 构建分析提示
prompt = f"""你是一位专业的美股分析师。请基于以下数据对 {name} ({symbol}) 进行全面分析。 prompt = f"""你是一位专业的美股分析师。请基于以下数据对 {name} ({symbol}) 进行全面分析。
**重要提示当前日期是 {current_time}请在分析中使用这个日期不要使用其他日期**
基本信息 基本信息
股票代码{symbol} 股票代码{symbol}
公司名称{name} 公司名称{name}
@ -1577,7 +1579,8 @@ MACD{f"{technical.get('macd'):.4f}" if technical.get('macd') else '计算中'
2. 分析客观理性基于数据和事实 2. 分析客观理性基于数据和事实
3. 每个部分独立成段段落间用空行分隔 3. 每个部分独立成段段落间用空行分隔
4. 控制在500-600 4. 控制在500-600
5. 最后声明"以上分析仅供参考,不构成投资建议。美股投资有风险,请谨慎决策。" 5. **不要在报告中添加日期标题直接开始分析内容**
6. 最后声明"以上分析仅供参考,不构成投资建议。美股投资有风险,请谨慎决策。"
""" """
try: 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句话
## 💼 公司基本面
- 行业地位和竞争优势
- 估值水平分析PEPB是否合理
- 盈利能力和成长性
## 📈 技术面分析
- 当前趋势判断基于均线系统
- 关键支撑位和压力位
- 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() smart_agent = SmartStockAgent()

View File

@ -2,8 +2,11 @@
对话API路由 对话API路由
""" """
from fastapi import APIRouter, HTTPException from fastapi import APIRouter, HTTPException
from fastapi.responses import StreamingResponse
from typing import Optional from typing import Optional
import uuid import uuid
import json
import asyncio
from app.models.chat import ChatRequest, ChatResponse from app.models.chat import ChatRequest, ChatResponse
from app.agent.smart_agent import smart_agent # 使用智能Agent from app.agent.smart_agent import smart_agent # 使用智能Agent
from app.utils.logger import logger from app.utils.logger import logger
@ -65,3 +68,60 @@ async def get_history(session_id: str, limit: int = 50):
except Exception as e: except Exception as e:
logger.error(f"获取历史失败: {e}") logger.error(f"获取历史失败: {e}")
raise HTTPException(status_code=500, detail=str(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))

View File

@ -43,6 +43,29 @@ class LLMService:
"""使用LLM分析用户意图""" """使用LLM分析用户意图"""
return self.multi_service.analyze_intent(user_message) 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() llm_service = LLMService()

View File

@ -170,6 +170,75 @@ class MultiLLMService:
logger.error(f"详细错误: {traceback.format_exc()}") logger.error(f"详细错误: {traceback.format_exc()}")
return None 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]: def analyze_intent(self, user_message: str) -> Dict[str, Any]:
"""使用LLM分析用户意图""" """使用LLM分析用户意图"""
if not self.current_model: if not self.current_model:

View File

@ -314,6 +314,42 @@ html, body {
flex-shrink: 0; 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 (hidden, used for rendering) */
.share-image-container { .share-image-container {
position: fixed; position: fixed;

View File

@ -14,7 +14,7 @@
<script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/marked/marked.min.js"></script>
<!-- Styles --> <!-- Styles -->
<link rel="stylesheet" href="/static/css/style.css"> <link rel="stylesheet" href="/static/css/style.css?v=3">
</head> </head>
<body> <body>
<div id="app"> <div id="app">
@ -85,8 +85,8 @@
<div v-else> <div v-else>
<div class="text markdown" v-html="renderMarkdown(msg.content)"></div> <div class="text markdown" v-html="renderMarkdown(msg.content)"></div>
<!-- Action Buttons for AI Messages --> <!-- Action Buttons for AI Messages (只在流式输出完成后显示) -->
<div class="message-actions"> <div v-if="!msg.streaming" class="message-actions">
<button class="action-btn" @click.stop="copyMessage(msg.content)" title="复制内容"> <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"> <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"/> <rect x="9" y="9" width="13" height="13" rx="2" ry="2"/>
@ -104,6 +104,13 @@
</button> </button>
</div> </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 --> <!-- Chart Display -->
<div v-if="msg.metadata && msg.metadata.type === 'chart'" class="chart-box"> <div v-if="msg.metadata && msg.metadata.type === 'chart'" class="chart-box">
<div :id="'chart-' + index" class="chart"></div> <div :id="'chart-' + index" class="chart"></div>
@ -111,17 +118,6 @@
</div> </div>
</div> </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>
</div> </div>
@ -183,6 +179,6 @@
<script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script> <script src="https://cdn.jsdelivr.net/npm/html2canvas@1.4.1/dist/html2canvas.min.js"></script>
<!-- App Script --> <!-- App Script -->
<script src="/static/js/app.js"></script> <script src="/static/js/app.js?v=4"></script>
</body> </body>
</html> </html>

View File

@ -42,11 +42,25 @@ createApp({
this.loading = true; 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 { try {
const response = await fetch('/api/chat/message', { // 使用流式API
const response = await fetch('/api/chat/message/stream', {
method: 'POST', method: 'POST',
headers: { headers: {
'Content-Type': 'application/json' 'Content-Type': 'application/json',
'Accept': 'text/event-stream',
'Cache-Control': 'no-cache'
}, },
body: JSON.stringify({ body: JSON.stringify({
message: message, message: message,
@ -58,37 +72,65 @@ createApp({
throw new Error('请求失败'); 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 while (true) {
const assistantMessage = { const { done, value } = await reader.read();
role: 'assistant', if (done) {
content: data.message, console.log(`流式接收完成,共 ${chunkCount} 个数据块`);
timestamp: new Date(), break;
metadata: data.metadata }
};
this.messages.push(assistantMessage); chunkCount++;
console.log(`接收数据块 ${chunkCount}: ${value ? value.length : 0} 字节`);
// Render chart if needed // 解码数据
if (data.metadata && data.metadata.type === 'chart') { buffer += decoder.decode(value, { stream: true });
this.$nextTick(() => { const lines = buffer.split('\n');
const index = this.messages.length - 1;
this.renderChart(index, data.metadata.data); // 保留最后一个不完整的行
}); 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.$nextTick(() => { // 确保流式标志被清除
this.scrollToBottom(); this.messages[messageIndex].streaming = false;
});
} catch (error) { } catch (error) {
console.error('发送消息失败:', error); console.error('发送消息失败:', error);
this.messages.push({ this.messages[messageIndex].content = '抱歉,发送消息失败,请稍后重试。';
role: 'assistant', this.messages[messageIndex].streaming = false;
content: '抱歉,发送消息失败,请稍后重试。',
timestamp: new Date()
});
} finally { } finally {
this.loading = false; this.loading = false;
} }

116
frontend/test-stream.html Normal file
View 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>