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}) 进行全面分析。
**重要提示当前日期是 {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句话
## 💼 公司基本面
- 行业地位和竞争优势
- 估值水平分析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()

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@ -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
};
while (true) {
const { done, value } = await reader.read();
if (done) {
console.log(`流式接收完成,共 ${chunkCount} 个数据块`);
break;
}
this.messages.push(assistantMessage);
chunkCount++;
console.log(`接收数据块 ${chunkCount}: ${value ? value.length : 0} 字节`);
// 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);
});
// 解码数据
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.$nextTick(() => {
this.scrollToBottom();
});
// 确保流式标志被清除
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
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>