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] 组件已加载');
|