468 lines
17 KiB
JavaScript
468 lines
17 KiB
JavaScript
/**
|
||
* 流式状态显示组件
|
||
* 用于显示RAG检索过程的实时状态
|
||
*/
|
||
class StreamStatusDisplay {
|
||
constructor(container) {
|
||
this.container = container;
|
||
this.statusElements = {};
|
||
this.retrievalHistory = []; // 记录检索历史
|
||
this.statusHistory = []; // 记录所有状态历史
|
||
this.maxDisplayItems = 3; // 最多显示3条状态
|
||
this.eventQueue = []; // 事件队列
|
||
this.isProcessing = false; // 是否正在处理队列
|
||
this.minDisplayTime = 500; // 每个状态最少显示500ms
|
||
this.init();
|
||
}
|
||
|
||
init() {
|
||
// 创建状态显示区域
|
||
this.statusArea = document.createElement('div');
|
||
this.statusArea.className = 'stream-status-area';
|
||
this.statusArea.style.cssText = `
|
||
padding: 12px 16px;
|
||
background: linear-gradient(135deg, #f6f9fc 0%, #f0f4f8 100%);
|
||
border-radius: 12px;
|
||
margin-bottom: 16px;
|
||
font-size: 14px;
|
||
color: #475569;
|
||
min-height: 48px;
|
||
transition: all 0.3s cubic-bezier(0.4, 0, 0.2, 1);
|
||
box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
position: relative;
|
||
overflow: hidden;
|
||
`;
|
||
|
||
// 添加动画背景
|
||
const animBg = document.createElement('div');
|
||
animBg.style.cssText = `
|
||
position: absolute;
|
||
top: 0;
|
||
left: -100%;
|
||
width: 100%;
|
||
height: 100%;
|
||
background: linear-gradient(90deg, transparent, rgba(99, 102, 241, 0.1), transparent);
|
||
animation: shimmer 2s infinite;
|
||
`;
|
||
this.statusArea.appendChild(animBg);
|
||
|
||
// 添加CSS动画
|
||
if (!document.getElementById('stream-status-styles')) {
|
||
const style = document.createElement('style');
|
||
style.id = 'stream-status-styles';
|
||
style.textContent = `
|
||
@keyframes shimmer {
|
||
0% { left: -100%; }
|
||
100% { left: 100%; }
|
||
}
|
||
@keyframes pulse {
|
||
0%, 100% { opacity: 1; }
|
||
50% { opacity: 0.5; }
|
||
}
|
||
@keyframes rotate {
|
||
from { transform: rotate(0deg); }
|
||
to { transform: rotate(360deg); }
|
||
}
|
||
.status-icon {
|
||
font-size: 20px;
|
||
animation: pulse 1.5s ease-in-out infinite;
|
||
}
|
||
.status-spinner {
|
||
animation: rotate 1s linear infinite;
|
||
}
|
||
`;
|
||
document.head.appendChild(style);
|
||
}
|
||
|
||
// 状态内容容器
|
||
this.contentArea = document.createElement('div');
|
||
this.contentArea.style.cssText = `
|
||
position: relative;
|
||
z-index: 1;
|
||
flex: 1;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
`;
|
||
this.statusArea.appendChild(this.contentArea);
|
||
|
||
this.container.appendChild(this.statusArea);
|
||
}
|
||
|
||
updateStatus(event) {
|
||
// 清除淡出定时器
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
}
|
||
|
||
switch (event.type) {
|
||
case 'cache_hit':
|
||
case 'cache':
|
||
if (event.cached) {
|
||
this.showCacheHit();
|
||
} else {
|
||
this.showCacheMiss();
|
||
}
|
||
break;
|
||
|
||
case 'cache_miss':
|
||
this.showCacheMiss();
|
||
break;
|
||
|
||
case 'starting':
|
||
this.showStatus('🔍 开始分析查询...', '#6366f1');
|
||
break;
|
||
|
||
case 'complexity_check':
|
||
const complexity = event.data;
|
||
if (complexity) {
|
||
if (complexity.is_complex) {
|
||
this.showStatus(
|
||
`📊 复杂查询 (${complexity.level || '高'}) - 置信度: ${(complexity.confidence * 100).toFixed(1)}%`,
|
||
'#f59e0b'
|
||
);
|
||
} else {
|
||
this.showStatus(
|
||
`📊 简单查询 - 置信度: ${(complexity.confidence * 100).toFixed(1)}%`,
|
||
'#10b981'
|
||
);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'documents':
|
||
const docs = event.data;
|
||
if (docs) {
|
||
// 记录检索历史
|
||
this.retrievalHistory.push({
|
||
count: docs.count,
|
||
new_docs: docs.new_docs || 0,
|
||
is_incremental: docs.is_incremental || false,
|
||
retrieval_type: docs.retrieval_type || '检索',
|
||
retrieval_reason: docs.retrieval_reason || ''
|
||
});
|
||
|
||
// 根据是否是增量检索显示不同信息
|
||
let message = '';
|
||
if (docs.is_incremental) {
|
||
// 增量检索(第二次及以后)
|
||
const reason = docs.retrieval_reason || '继续检索';
|
||
message = `📚 ${reason}:新增 ${docs.new_docs || 0} 篇文档(总计 ${docs.count} 篇)`;
|
||
this.showStatus(message, '#8b5cf6'); // 紫色表示增量
|
||
} else {
|
||
// 初始检索
|
||
message = `📚 已检索 ${docs.count} 篇文档`;
|
||
if (docs.retrieval_type) {
|
||
message = `📚 ${docs.retrieval_type}:${docs.count} 篇文档`;
|
||
}
|
||
this.showStatus(message, '#6366f1'); // 蓝色表示初始
|
||
}
|
||
|
||
// 显示检索历史汇总
|
||
if (this.retrievalHistory.length > 1) {
|
||
this.showRetrievalSummary();
|
||
}
|
||
|
||
// 显示来源文档
|
||
if (docs.sources && docs.sources.length > 0) {
|
||
this.addSources(docs.sources);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'sufficiency_check':
|
||
const sufficient = event.data;
|
||
if (sufficient) {
|
||
// 置信度可能是数字或未定义
|
||
const confidence = sufficient.confidence !== undefined ? sufficient.confidence : 0.5;
|
||
if (sufficient.is_sufficient) {
|
||
this.showStatus(
|
||
`✅ 信息充分 (置信度: ${(confidence * 100).toFixed(1)}%)`,
|
||
'#10b981'
|
||
);
|
||
} else {
|
||
this.showStatus(
|
||
`⚠️ 信息不足,继续检索... (置信度: ${(confidence * 100).toFixed(1)}%)`,
|
||
'#f59e0b'
|
||
);
|
||
}
|
||
}
|
||
break;
|
||
|
||
case 'sub_queries':
|
||
const queries = event.data;
|
||
if (queries && queries.length > 0) {
|
||
this.showStatus(`🔄 生成了 ${queries.length} 个子查询`, '#8b5cf6');
|
||
this.showSubQueries(queries);
|
||
}
|
||
break;
|
||
|
||
case 'iteration':
|
||
const iter = event.data;
|
||
if (iter) {
|
||
// 只显示当前轮次,不显示最大轮次
|
||
this.showStatus(`🔄 第 ${iter.current} 轮迭代`, '#6366f1');
|
||
}
|
||
break;
|
||
|
||
case 'generating':
|
||
// 不要在这里调用scheduleFadeOut
|
||
this.showStatus('✨ 正在生成答案...', '#f59e0b'); // 使用黄色表示进行中
|
||
break;
|
||
|
||
case 'cached':
|
||
this.showStatus('💾 结果已缓存', '#10b981');
|
||
this.scheduleFadeOut();
|
||
break;
|
||
|
||
case 'complete':
|
||
this.showStatus('✅ 答案生成完成', '#10b981');
|
||
// 答案生成完成后,2秒后自动消失
|
||
setTimeout(() => {
|
||
this.fadeOutAndHide();
|
||
}, 2000);
|
||
break;
|
||
|
||
case 'cancelled':
|
||
// 清除之前的所有状态
|
||
this.statusHistory = [];
|
||
// 显示取消状态
|
||
this.showStatus('⚠️ 任务已停止', '#ef4444');
|
||
// 1.5秒后淡出
|
||
setTimeout(() => {
|
||
this.fadeOutAndHide();
|
||
}, 1500);
|
||
break;
|
||
|
||
default:
|
||
if (event.message) {
|
||
this.showStatus(event.message, '#6366f1');
|
||
}
|
||
}
|
||
}
|
||
|
||
showStatus(message, color = '#6366f1') {
|
||
// 添加到历史记录
|
||
this.statusHistory.push({
|
||
message: message,
|
||
color: color,
|
||
timestamp: Date.now()
|
||
});
|
||
|
||
// 使用独立的渲染方法
|
||
this.renderStatuses();
|
||
}
|
||
|
||
showCacheHit() {
|
||
this.statusArea.style.background = 'linear-gradient(135deg, #dcfce7 0%, #d1fae5 100%)';
|
||
this.statusArea.style.borderLeft = '3px solid #10b981';
|
||
this.contentArea.innerHTML = `
|
||
<div class="status-icon" style="color: #10b981;">📦</div>
|
||
<div class="status-message" style="color: #065f46; font-weight: 500;">
|
||
使用缓存结果(伪流式输出)
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
showCacheMiss() {
|
||
this.statusArea.style.background = 'linear-gradient(135deg, #fef3c7 0%, #fde68a 100%)';
|
||
this.statusArea.style.borderLeft = '3px solid #f59e0b';
|
||
this.contentArea.innerHTML = `
|
||
<div class="status-icon status-spinner" style="color: #f59e0b;">🔍</div>
|
||
<div class="status-message" style="color: #78350f; font-weight: 500;">
|
||
开始实时检索...
|
||
</div>
|
||
`;
|
||
}
|
||
|
||
showSubQueries(queries) {
|
||
const maxDisplay = 3;
|
||
const displayQueries = queries.slice(0, maxDisplay);
|
||
const remaining = queries.length - maxDisplay;
|
||
|
||
// 构建子查询列表HTML
|
||
const queriesList = displayQueries.map((q, i) =>
|
||
`${i + 1}. ${q.length > 30 ? q.substring(0, 30) + '...' : q}`
|
||
).join('<br>');
|
||
|
||
const moreText = remaining > 0 ? `<br>... 还有 ${remaining} 个` : '';
|
||
|
||
// 更新最后一个状态的消息,添加子查询列表
|
||
if (this.statusHistory.length > 0) {
|
||
const lastStatus = this.statusHistory[this.statusHistory.length - 1];
|
||
// 如果消息中还没有子查询列表,就添加
|
||
if (!lastStatus.message.includes('<br>')) {
|
||
lastStatus.message += `<br><span style="color: #4f46e5; font-size: 12px; margin-left: 32px;">${queriesList}${moreText}</span>`;
|
||
// 重新渲染
|
||
this.renderStatuses();
|
||
}
|
||
}
|
||
}
|
||
|
||
showRetrievalSummary() {
|
||
// 显示检索历史汇总
|
||
const totalRounds = this.retrievalHistory.length;
|
||
const totalDocs = this.retrievalHistory[this.retrievalHistory.length - 1].count;
|
||
|
||
// 创建一个小的汇总显示
|
||
const summaryHtml = `
|
||
<span style="
|
||
display: inline-block;
|
||
margin-left: 12px;
|
||
padding: 2px 8px;
|
||
background: rgba(99, 102, 241, 0.1);
|
||
border-radius: 4px;
|
||
font-size: 12px;
|
||
color: #6366f1;
|
||
">共 ${totalRounds} 轮检索</span>
|
||
`;
|
||
|
||
// 添加到当前状态显示中
|
||
if (!this.contentArea.querySelector('.retrieval-summary')) {
|
||
const summarySpan = document.createElement('span');
|
||
summarySpan.className = 'retrieval-summary';
|
||
summarySpan.innerHTML = summaryHtml;
|
||
this.contentArea.appendChild(summarySpan);
|
||
} else {
|
||
this.contentArea.querySelector('.retrieval-summary').innerHTML = summaryHtml;
|
||
}
|
||
}
|
||
|
||
addSources(sources) {
|
||
// 不单独添加,而是将来源信息合并到最新的状态消息中
|
||
const maxDisplay = 3;
|
||
const displaySources = sources.slice(0, maxDisplay);
|
||
const sourceText = displaySources.join(', ');
|
||
const moreText = sources.length > maxDisplay ? ` 等${sources.length}个` : '';
|
||
|
||
// 更新最后一个状态的消息,添加来源信息
|
||
if (this.statusHistory.length > 0) {
|
||
const lastStatus = this.statusHistory[this.statusHistory.length - 1];
|
||
// 如果消息中还没有来源信息,就添加
|
||
if (!lastStatus.message.includes('来源:')) {
|
||
lastStatus.message += `<br><span style="color: #64748b; font-size: 12px; margin-left: 32px;">来源: ${sourceText}${moreText}</span>`;
|
||
// 重新渲染
|
||
this.renderStatuses();
|
||
}
|
||
}
|
||
}
|
||
|
||
renderStatuses() {
|
||
// 抽取渲染逻辑为独立方法
|
||
const recentStatuses = this.statusHistory.slice(-this.maxDisplayItems);
|
||
|
||
this.contentArea.innerHTML = `
|
||
<div style="display: flex; flex-direction: column; gap: 8px;">
|
||
${recentStatuses.map((status, index) => {
|
||
const isLatest = index === recentStatuses.length - 1;
|
||
const opacity = isLatest ? 1 : 0.6;
|
||
return `
|
||
<div style="
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 12px;
|
||
opacity: ${opacity};
|
||
padding: ${isLatest ? '8px 0' : '4px 0'};
|
||
border-bottom: ${!isLatest ? '1px solid rgba(226, 232, 240, 0.5)' : 'none'};
|
||
">
|
||
<div class="status-icon" style="color: ${status.color}; font-size: ${isLatest ? '20px' : '16px'};">⚡</div>
|
||
<div class="status-message" style="
|
||
color: #334155;
|
||
font-weight: ${isLatest ? '500' : '400'};
|
||
font-size: ${isLatest ? '14px' : '12px'};
|
||
">
|
||
${status.message}
|
||
</div>
|
||
</div>
|
||
`;
|
||
}).join('')}
|
||
</div>
|
||
`;
|
||
|
||
// 更新背景色
|
||
if (recentStatuses.length > 0) {
|
||
const latestStatus = recentStatuses[recentStatuses.length - 1];
|
||
this.statusArea.style.background = `linear-gradient(135deg, ${this.hexToRgba(latestStatus.color, 0.05)} 0%, ${this.hexToRgba(latestStatus.color, 0.1)} 100%)`;
|
||
this.statusArea.style.borderLeft = `3px solid ${latestStatus.color}`;
|
||
}
|
||
}
|
||
|
||
scheduleFadeOut() {
|
||
// 清除之前的定时器
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
}
|
||
// 5秒后淡出
|
||
this.fadeTimeout = setTimeout(() => {
|
||
this.fadeOut();
|
||
}, 5000);
|
||
}
|
||
|
||
fadeOut() {
|
||
this.statusArea.style.opacity = '0.3';
|
||
this.statusArea.style.transform = 'scale(0.98)';
|
||
// 不完全隐藏,保留淡化状态
|
||
// setTimeout(() => {
|
||
// this.statusArea.style.display = 'none';
|
||
// }, 300);
|
||
}
|
||
|
||
fadeOutAndHide() {
|
||
// 添加过渡动画
|
||
this.statusArea.style.transition = 'all 0.5s ease-out';
|
||
this.statusArea.style.opacity = '0';
|
||
this.statusArea.style.transform = 'scale(0.95)';
|
||
this.statusArea.style.maxHeight = this.statusArea.offsetHeight + 'px';
|
||
|
||
// 动画结束后完全隐藏
|
||
setTimeout(() => {
|
||
this.statusArea.style.maxHeight = '0';
|
||
this.statusArea.style.padding = '0';
|
||
this.statusArea.style.margin = '0';
|
||
this.statusArea.style.overflow = 'hidden';
|
||
|
||
// 再过渡一段时间后完全移除显示
|
||
setTimeout(() => {
|
||
this.statusArea.style.display = 'none';
|
||
}, 500);
|
||
}, 500);
|
||
}
|
||
|
||
clear() {
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
}
|
||
this.contentArea.innerHTML = '';
|
||
this.statusArea.style.opacity = '1';
|
||
this.statusArea.style.transform = 'scale(1)';
|
||
this.statusArea.style.display = 'flex';
|
||
this.statusArea.style.background = 'linear-gradient(135deg, #f6f9fc 0%, #f0f4f8 100%)';
|
||
this.statusArea.style.borderLeft = 'none';
|
||
// 重置历史记录
|
||
this.retrievalHistory = [];
|
||
this.statusHistory = [];
|
||
}
|
||
|
||
destroy() {
|
||
if (this.fadeTimeout) {
|
||
clearTimeout(this.fadeTimeout);
|
||
}
|
||
if (this.statusArea && this.statusArea.parentNode) {
|
||
this.statusArea.parentNode.removeChild(this.statusArea);
|
||
}
|
||
}
|
||
|
||
hexToRgba(hex, alpha) {
|
||
const r = parseInt(hex.slice(1, 3), 16);
|
||
const g = parseInt(hex.slice(3, 5), 16);
|
||
const b = parseInt(hex.slice(5, 7), 16);
|
||
return `rgba(${r}, ${g}, ${b}, ${alpha})`;
|
||
}
|
||
}
|
||
|
||
// 导出
|
||
window.StreamStatusDisplay = StreamStatusDisplay;
|
||
console.log('[StreamStatusDisplay] 组件已加载'); |