Improved Rendering
This commit is contained in:
+442
-8
@@ -1027,6 +1027,49 @@
|
||||
display: none;
|
||||
}
|
||||
|
||||
.report-stream-line {
|
||||
font-size: 12px;
|
||||
margin-bottom: 4px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.report-stream-line .timestamp {
|
||||
color: #cccccc;
|
||||
min-width: 60px;
|
||||
}
|
||||
|
||||
.report-stream-line .stream-badge {
|
||||
border: 1px solid #444444;
|
||||
padding: 1px 6px;
|
||||
font-size: 10px;
|
||||
text-transform: uppercase;
|
||||
color: #ffffff;
|
||||
letter-spacing: 0.5px;
|
||||
}
|
||||
|
||||
.report-stream-line .line-text {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.report-stream-line.chunk {
|
||||
color: #8fd5ff;
|
||||
}
|
||||
|
||||
.report-stream-line.warn {
|
||||
color: #ffd166;
|
||||
}
|
||||
|
||||
.report-stream-line.error {
|
||||
color: #ff6b6b;
|
||||
}
|
||||
|
||||
.report-stream-line.success {
|
||||
color: #80ffb5;
|
||||
}
|
||||
|
||||
.report-loading {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
@@ -1165,6 +1208,9 @@
|
||||
let systemStarted = false;
|
||||
let systemStarting = false;
|
||||
let configModalLocked = false;
|
||||
let socketConnected = false;
|
||||
let reportStreamConnected = false;
|
||||
let backendReachable = false;
|
||||
|
||||
const CONFIG_ENDPOINT = '/api/config';
|
||||
const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
|
||||
@@ -1276,6 +1322,7 @@
|
||||
setInterval(updateTime, 1000);
|
||||
checkStatus();
|
||||
setInterval(checkStatus, 5000);
|
||||
startConnectionProbe();
|
||||
|
||||
// 初始化密码切换功能(事件委托,只需调用一次)
|
||||
attachConfigPasswordToggles();
|
||||
@@ -1308,12 +1355,14 @@
|
||||
socket = io();
|
||||
|
||||
socket.on('connect', function() {
|
||||
updateConnectionStatus('已连接');
|
||||
socketConnected = true;
|
||||
refreshConnectionStatus();
|
||||
socket.emit('request_status');
|
||||
});
|
||||
|
||||
socket.on('disconnect', function() {
|
||||
updateConnectionStatus('连接断开');
|
||||
socketConnected = false;
|
||||
refreshConnectionStatus();
|
||||
});
|
||||
|
||||
socket.on('console_output', function(data) {
|
||||
@@ -2255,10 +2304,38 @@
|
||||
fetch('/api/status')
|
||||
.then(response => response.json())
|
||||
.then(data => {
|
||||
backendReachable = true;
|
||||
updateAppStatus(data);
|
||||
refreshConnectionStatus();
|
||||
})
|
||||
.catch(error => {
|
||||
console.error('状态检查失败:', error);
|
||||
backendReachable = false;
|
||||
refreshConnectionStatus();
|
||||
});
|
||||
}
|
||||
|
||||
function startConnectionProbe() {
|
||||
if (connectionProbeTimer) {
|
||||
clearInterval(connectionProbeTimer);
|
||||
}
|
||||
probeBackendConnection();
|
||||
connectionProbeTimer = setInterval(probeBackendConnection, CONNECTION_PROBE_INTERVAL);
|
||||
}
|
||||
|
||||
function probeBackendConnection() {
|
||||
fetch('/api/report/status?heartbeat=1', { cache: 'no-store' })
|
||||
.then(response => {
|
||||
if (!response.ok) throw new Error('heartbeat failed');
|
||||
return response.json();
|
||||
})
|
||||
.then(() => {
|
||||
backendReachable = true;
|
||||
refreshConnectionStatus();
|
||||
})
|
||||
.catch(() => {
|
||||
backendReachable = false;
|
||||
refreshConnectionStatus();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -2279,9 +2356,15 @@
|
||||
updateEmbeddedPage(currentApp);
|
||||
}
|
||||
|
||||
// 更新连接状态
|
||||
function updateConnectionStatus(status) {
|
||||
document.getElementById('connectionStatus').textContent = status;
|
||||
// 根据当前的Socket/SSE状态刷新底部连接指示
|
||||
function refreshConnectionStatus() {
|
||||
const statusEl = document.getElementById('connectionStatus');
|
||||
if (!statusEl) return;
|
||||
if (socketConnected || reportStreamConnected || backendReachable) {
|
||||
statusEl.textContent = '已连接';
|
||||
} else {
|
||||
statusEl.textContent = '连接断开';
|
||||
}
|
||||
}
|
||||
|
||||
// 更新时间
|
||||
@@ -2738,6 +2821,14 @@
|
||||
// Report Engine 相关函数
|
||||
let reportTaskId = null;
|
||||
let reportPollingInterval = null;
|
||||
let reportEventSource = null;
|
||||
let reportAutoPreviewLoaded = false;
|
||||
let reportStreamReconnectTimer = null;
|
||||
let reportStreamRetryDelay = 3000;
|
||||
let streamHeartbeatTimeout = null;
|
||||
let streamHeartbeatInterval = null;
|
||||
let connectionProbeTimer = null;
|
||||
const CONNECTION_PROBE_INTERVAL = 15000;
|
||||
|
||||
// 加载报告界面
|
||||
function loadReportInterface() {
|
||||
@@ -2811,6 +2902,8 @@
|
||||
|
||||
reportContent.innerHTML = interfaceHTML;
|
||||
initializeReportControls();
|
||||
resetReportStreamOutput('等待新的Report任务启动...');
|
||||
updateReportStreamStatus('idle');
|
||||
|
||||
// 立即更新状态信息
|
||||
updateEngineStatusDisplay(statusData);
|
||||
@@ -2818,8 +2911,22 @@
|
||||
// 如果有当前任务,显示任务状态
|
||||
if (statusData.current_task) {
|
||||
updateTaskProgressStatus(statusData.current_task);
|
||||
if (statusData.current_task.status === 'running') {
|
||||
reportTaskId = statusData.current_task.task_id;
|
||||
reportAutoPreviewLoaded = false;
|
||||
if (window.EventSource) {
|
||||
openReportStream(reportTaskId);
|
||||
} else {
|
||||
startProgressPolling(reportTaskId);
|
||||
}
|
||||
} else if (statusData.current_task.status === 'completed') {
|
||||
lastCompletedReportTask = statusData.current_task;
|
||||
updateDownloadButtonState(statusData.current_task);
|
||||
}
|
||||
} else {
|
||||
updateDownloadButtonState(null);
|
||||
safeCloseReportStream();
|
||||
reportTaskId = null;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -3054,10 +3161,13 @@
|
||||
|
||||
// 重置日志计数器,因为后台会清空日志文件
|
||||
reportLogLineCount = 0;
|
||||
reportAutoPreviewLoaded = false;
|
||||
safeCloseReportStream(true);
|
||||
|
||||
// 清空控制台显示
|
||||
const consoleOutput = document.getElementById('consoleOutput');
|
||||
consoleOutput.innerHTML = '<div class="console-line">[系统] 开始生成报告,日志已重置</div>';
|
||||
resetReportStreamOutput('Report Engine 正在调度任务...');
|
||||
|
||||
setGenerateButtonState(true);
|
||||
|
||||
@@ -3099,14 +3209,21 @@
|
||||
refreshReportLog();
|
||||
}, 500);
|
||||
|
||||
// 开始轮询任务状态
|
||||
startProgressPolling(data.task_id);
|
||||
appendReportStreamLine('任务创建成功,正在建立流式连接...', 'info', { force: true });
|
||||
if (window.EventSource) {
|
||||
openReportStream(reportTaskId);
|
||||
} else {
|
||||
startProgressPolling(data.task_id);
|
||||
}
|
||||
} else {
|
||||
updateTaskProgressStatus(null, 'error', '启动失败: ' + data.error);
|
||||
// 重置标志允许重新尝试
|
||||
autoGenerateTriggered = false;
|
||||
reportTaskId = null;
|
||||
setGenerateButtonState(false);
|
||||
appendReportStreamLine('任务启动失败: ' + (data.error || '未知错误'), 'error');
|
||||
updateReportStreamStatus('error');
|
||||
safeCloseReportStream();
|
||||
}
|
||||
})
|
||||
.catch(error => {
|
||||
@@ -3116,6 +3233,9 @@
|
||||
autoGenerateTriggered = false;
|
||||
reportTaskId = null;
|
||||
setGenerateButtonState(false);
|
||||
appendReportStreamLine('任务启动阶段异常: ' + error.message, 'error');
|
||||
updateReportStreamStatus('error');
|
||||
safeCloseReportStream();
|
||||
});
|
||||
}
|
||||
|
||||
@@ -3147,6 +3267,7 @@
|
||||
|
||||
// 自动显示报告
|
||||
viewReport(taskId);
|
||||
reportAutoPreviewLoaded = true;
|
||||
|
||||
// 重置自动生成标志,允许下次有新内容时自动生成
|
||||
autoGenerateTriggered = false;
|
||||
@@ -3225,6 +3346,319 @@
|
||||
updateTaskProgressStatus(task);
|
||||
}
|
||||
|
||||
// ====== Report Engine SSE流式辅助函数 ======
|
||||
// 重置流式日志入口,将提示语写入控制台,保持与右侧黑框一致
|
||||
function resetReportStreamOutput(message = '等待新的Report任务启动...') {
|
||||
appendReportStreamLine(message, 'info', { badge: 'REPORT', force: true });
|
||||
}
|
||||
|
||||
// 根据状态同步流式指示灯,与后端心跳保持一致
|
||||
function updateReportStreamStatus(state) {
|
||||
if (state === 'connected') {
|
||||
reportStreamConnected = true;
|
||||
} else if (['idle', 'error', 'connecting', 'reconnecting'].includes(state)) {
|
||||
reportStreamConnected = false;
|
||||
}
|
||||
|
||||
const statusEl = document.getElementById('reportStreamStatus');
|
||||
if (statusEl) {
|
||||
const textMap = {
|
||||
idle: '未连接',
|
||||
connecting: '连接中',
|
||||
connected: '实时更新中',
|
||||
reconnecting: '等待重连',
|
||||
error: '已断开'
|
||||
};
|
||||
statusEl.textContent = textMap[state] || state;
|
||||
statusEl.dataset.state = state;
|
||||
}
|
||||
|
||||
refreshConnectionStatus();
|
||||
}
|
||||
|
||||
// 往黑色控制台输出区域追加一条流式日志
|
||||
function appendReportStreamLine(message, level = 'info', options = {}) {
|
||||
const consoleOutput = document.getElementById('consoleOutput');
|
||||
if (!consoleOutput) return;
|
||||
|
||||
if (level === 'chunk' && !options.force) {
|
||||
return; // 章节内容流式写入不再逐条输出
|
||||
}
|
||||
|
||||
const line = document.createElement('div');
|
||||
line.className = `console-line report-stream-line ${level}`;
|
||||
|
||||
const timestampSpan = document.createElement('span');
|
||||
timestampSpan.className = 'timestamp';
|
||||
timestampSpan.textContent = new Date().toLocaleTimeString('zh-CN');
|
||||
line.appendChild(timestampSpan);
|
||||
|
||||
if (options.badge) {
|
||||
const badge = document.createElement('span');
|
||||
badge.className = 'stream-badge';
|
||||
badge.textContent = options.badge;
|
||||
line.appendChild(badge);
|
||||
}
|
||||
|
||||
const textSpan = document.createElement('span');
|
||||
textSpan.className = 'line-text';
|
||||
textSpan.textContent = message;
|
||||
line.appendChild(textSpan);
|
||||
|
||||
consoleOutput.appendChild(line);
|
||||
consoleOutput.scrollTop = consoleOutput.scrollHeight;
|
||||
}
|
||||
|
||||
function startStreamHeartbeat() {
|
||||
clearStreamHeartbeat();
|
||||
const emitHeartbeat = () => {
|
||||
appendReportStreamLine('Report Engine 正在流式生成,请耐心等待...', 'info', { badge: 'REPORT', force: true });
|
||||
};
|
||||
|
||||
const scheduleFirstTick = () => {
|
||||
const now = Date.now();
|
||||
const msToNextMinute = 60000 - (now % 60000);
|
||||
streamHeartbeatTimeout = setTimeout(() => {
|
||||
emitHeartbeat();
|
||||
streamHeartbeatInterval = setInterval(emitHeartbeat, 60000);
|
||||
}, msToNextMinute);
|
||||
};
|
||||
|
||||
scheduleFirstTick();
|
||||
}
|
||||
|
||||
function clearStreamHeartbeat() {
|
||||
if (streamHeartbeatTimeout) {
|
||||
clearTimeout(streamHeartbeatTimeout);
|
||||
streamHeartbeatTimeout = null;
|
||||
}
|
||||
if (streamHeartbeatInterval) {
|
||||
clearInterval(streamHeartbeatInterval);
|
||||
streamHeartbeatInterval = null;
|
||||
}
|
||||
}
|
||||
|
||||
// 建立SSE连接,实时订阅Report Engine推送
|
||||
function openReportStream(taskId, isRetry = false) {
|
||||
if (!taskId) return;
|
||||
if (!window.EventSource) {
|
||||
appendReportStreamLine('浏览器不支持SSE,已自动回退为轮询模式', 'warn', { badge: 'SSE', force: true });
|
||||
updateReportStreamStatus('error');
|
||||
clearStreamHeartbeat();
|
||||
startProgressPolling(taskId);
|
||||
return;
|
||||
}
|
||||
if (reportPollingInterval) {
|
||||
clearInterval(reportPollingInterval);
|
||||
reportPollingInterval = null;
|
||||
}
|
||||
if (reportEventSource && reportEventSource.__taskId === taskId) {
|
||||
if (reportEventSource.readyState !== EventSource.CLOSED) {
|
||||
return;
|
||||
}
|
||||
safeCloseReportStream(true, true);
|
||||
} else if (reportEventSource) {
|
||||
safeCloseReportStream(true, true);
|
||||
}
|
||||
|
||||
if (reportStreamReconnectTimer) {
|
||||
clearTimeout(reportStreamReconnectTimer);
|
||||
reportStreamReconnectTimer = null;
|
||||
}
|
||||
|
||||
if (!isRetry) {
|
||||
reportStreamRetryDelay = 3000;
|
||||
}
|
||||
|
||||
updateReportStreamStatus('connecting');
|
||||
appendReportStreamLine(
|
||||
isRetry ? '尝试重连Report Engine流式通道...' : '正在建立Report Engine流式连接...',
|
||||
'info',
|
||||
{ badge: 'SSE', force: true }
|
||||
);
|
||||
|
||||
reportEventSource = new EventSource(`/api/report/stream/${taskId}`);
|
||||
reportEventSource.__taskId = taskId;
|
||||
reportEventSource.onopen = () => {
|
||||
reportStreamRetryDelay = 3000;
|
||||
updateReportStreamStatus('connected');
|
||||
appendReportStreamLine(isRetry ? 'SSE重连成功' : 'Report Engine流式连接已建立', 'success', { badge: 'SSE' });
|
||||
startStreamHeartbeat();
|
||||
};
|
||||
reportEventSource.onerror = () => {
|
||||
appendReportStreamLine('检测到网络抖动,SSE正在等待自动重连...', 'warn', { badge: 'SSE' });
|
||||
updateReportStreamStatus('reconnecting');
|
||||
clearStreamHeartbeat();
|
||||
safeCloseReportStream(true, true);
|
||||
scheduleReportStreamReconnect(taskId);
|
||||
};
|
||||
|
||||
const events = ['status', 'stage', 'chapter_status', 'chapter_chunk', 'warning', 'html_ready', 'completed', 'error', 'heartbeat'];
|
||||
events.forEach(evt => {
|
||||
reportEventSource.addEventListener(evt, (event) => dispatchReportStreamEvent(evt, event));
|
||||
});
|
||||
reportEventSource.onmessage = (event) => dispatchReportStreamEvent(event.type || 'message', event);
|
||||
}
|
||||
|
||||
// 关闭SSE连接,可根据场景选择是否立即重置指示灯
|
||||
function safeCloseReportStream(keepIndicator = false, preserveRetryDelay = false) {
|
||||
if (reportEventSource) {
|
||||
reportEventSource.close();
|
||||
reportEventSource = null;
|
||||
}
|
||||
if (reportStreamReconnectTimer) {
|
||||
clearTimeout(reportStreamReconnectTimer);
|
||||
reportStreamReconnectTimer = null;
|
||||
}
|
||||
clearStreamHeartbeat();
|
||||
if (!keepIndicator) {
|
||||
updateReportStreamStatus('idle');
|
||||
} else {
|
||||
reportStreamConnected = false;
|
||||
refreshConnectionStatus();
|
||||
}
|
||||
if (!preserveRetryDelay) {
|
||||
reportStreamRetryDelay = 3000;
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleReportStreamReconnect(taskId) {
|
||||
if (!taskId || reportStreamReconnectTimer) {
|
||||
return;
|
||||
}
|
||||
reportStreamReconnectTimer = setTimeout(() => {
|
||||
reportStreamReconnectTimer = null;
|
||||
if (reportTaskId === taskId) {
|
||||
openReportStream(taskId, true);
|
||||
}
|
||||
}, reportStreamRetryDelay);
|
||||
reportStreamRetryDelay = Math.min(reportStreamRetryDelay * 2, 15000);
|
||||
}
|
||||
|
||||
// 统一的事件派发入口,负责解析JSON并交给业务处理
|
||||
function dispatchReportStreamEvent(eventType, event) {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
handleReportStreamEvent(eventType, data);
|
||||
} catch (error) {
|
||||
console.warn('解析流式事件失败:', error);
|
||||
}
|
||||
}
|
||||
|
||||
// 结合事件类型输出控件/状态,确保网络抖动时也能及时反馈
|
||||
function handleReportStreamEvent(eventType, eventData) {
|
||||
if (!eventData) return;
|
||||
const payload = eventData.payload || {};
|
||||
const task = payload.task;
|
||||
|
||||
if (eventType === 'status' && task) {
|
||||
updateTaskProgressStatus(task);
|
||||
reportTaskId = task.status === 'running' ? task.task_id : null;
|
||||
if (task.status === 'completed') {
|
||||
lastCompletedReportTask = task;
|
||||
setGenerateButtonState(false);
|
||||
} else if (task.status === 'running') {
|
||||
setGenerateButtonState(true);
|
||||
}
|
||||
}
|
||||
|
||||
switch (eventType) {
|
||||
case 'stage':
|
||||
appendReportStreamLine(
|
||||
payload.message || `阶段: ${payload.stage || ''}`,
|
||||
'info',
|
||||
{
|
||||
badge: payload.stage || '阶段',
|
||||
genericMessage: 'Report Engine 正在逐步生成,请耐心等待...'
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'chapter_status':
|
||||
appendReportStreamLine(
|
||||
`${payload.title || payload.chapterId || '章节'} ${payload.status === 'completed' ? '已完成' : '生成中'}`,
|
||||
payload.status === 'completed' ? 'success' : 'info',
|
||||
{
|
||||
badge: '章节',
|
||||
genericMessage: payload.status === 'completed'
|
||||
? `${payload.title || payload.chapterId || '章节'} 已完成`
|
||||
: '章节流式生成中,请稍候...'
|
||||
}
|
||||
);
|
||||
break;
|
||||
case 'chapter_chunk':
|
||||
if (payload.delta) {
|
||||
appendReportStreamLine(
|
||||
formatStreamChunk(payload.delta),
|
||||
'chunk',
|
||||
{
|
||||
badge: payload.title || payload.chapterId || '章节流',
|
||||
genericMessage: '章节内容流式写入中...'
|
||||
}
|
||||
);
|
||||
}
|
||||
break;
|
||||
case 'warning':
|
||||
appendReportStreamLine(payload.message || '检测到可重试的网络波动', 'warn');
|
||||
break;
|
||||
case 'html_ready':
|
||||
appendReportStreamLine('HTML渲染完成,正在刷新预览...', 'success');
|
||||
if (task) {
|
||||
updateDownloadButtonState(task);
|
||||
}
|
||||
if (eventData.task_id && !reportAutoPreviewLoaded) {
|
||||
viewReport(eventData.task_id);
|
||||
reportAutoPreviewLoaded = true;
|
||||
}
|
||||
break;
|
||||
case 'completed':
|
||||
appendReportStreamLine(payload.message || '任务完成', 'success');
|
||||
safeCloseReportStream();
|
||||
reportTaskId = null;
|
||||
setGenerateButtonState(false);
|
||||
if (task) {
|
||||
lastCompletedReportTask = task;
|
||||
updateDownloadButtonState(task);
|
||||
}
|
||||
if (eventData.task_id && !reportAutoPreviewLoaded) {
|
||||
viewReport(eventData.task_id);
|
||||
reportAutoPreviewLoaded = true;
|
||||
}
|
||||
break;
|
||||
case 'cancelled':
|
||||
appendReportStreamLine(payload.message || '任务已取消', 'warn');
|
||||
safeCloseReportStream();
|
||||
updateReportStreamStatus('idle');
|
||||
reportTaskId = null;
|
||||
setGenerateButtonState(false);
|
||||
break;
|
||||
case 'error':
|
||||
appendReportStreamLine(payload.message || '任务失败', 'error');
|
||||
safeCloseReportStream();
|
||||
updateReportStreamStatus('error');
|
||||
reportTaskId = null;
|
||||
setGenerateButtonState(false);
|
||||
break;
|
||||
case 'heartbeat':
|
||||
updateReportStreamStatus('connected');
|
||||
appendReportStreamLine(payload.message || '流式连接正常,请稍候...', 'info', {
|
||||
badge: 'SSE',
|
||||
genericMessage: '流式连接正常,请耐心等待...'
|
||||
});
|
||||
break;
|
||||
default:
|
||||
if (payload.message) {
|
||||
appendReportStreamLine(payload.message, 'info');
|
||||
}
|
||||
break;
|
||||
}
|
||||
}
|
||||
|
||||
// 清洗流式chunk,裁剪多余空白,避免影响UI
|
||||
function formatStreamChunk(text) {
|
||||
if (!text) return '';
|
||||
return text.replace(/\s+/g, ' ').trim().slice(0, 200);
|
||||
}
|
||||
|
||||
// 查看报告
|
||||
function viewReport(taskId) {
|
||||
const reportPreview = document.getElementById('reportPreview');
|
||||
@@ -3435,4 +3869,4 @@
|
||||
}
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
</html>
|
||||
|
||||
Reference in New Issue
Block a user