Files
bettafish-company/templates/index.html
T

4720 lines
176 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>微舆</title>
<script src="https://cdnjs.cloudflare.com/ajax/libs/socket.io/4.0.0/socket.io.js"></script>
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: 'Arial', sans-serif;
background-color: #ffffff;
color: #000000;
line-height: 1.6;
overflow-x: hidden;
}
.container {
max-width: 100vw;
height: 100vh; /* 固定高度为视口高度 */
display: flex;
flex-direction: column;
border: 2px solid #000000;
overflow: hidden; /* 防止整体滚动 */
}
/* 搜索框区域 */
.search-section {
border-bottom: 2px solid #000000;
padding: 20px;
background-color: #ffffff;
}
.search-title {
font-size: 24px;
font-weight: bold;
text-align: center;
margin-bottom: 20px;
letter-spacing: 1px;
}
.search-row {
display: flex;
align-items: stretch;
gap: 12px;
max-width: 950px;
margin: 0 auto 10px;
}
.config-button {
display: flex;
align-items: center;
justify-content: center;
padding: 0 24px;
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
min-width: 120px;
}
.config-button:hover {
background-color: #000000;
color: #ffffff;
}
.config-password-wrapper {
display: flex;
align-items: center;
gap: 8px;
}
.config-password-wrapper .config-field-input {
flex: 1;
}
.config-password-toggle {
padding: 8px 12px;
border: 2px solid #000000;
background-color: #ffffff;
cursor: pointer;
font-size: 0;
transition: all 0.3s ease;
display: flex;
align-items: center;
justify-content: center;
min-width: 44px;
min-height: 38px;
}
.config-password-toggle svg {
width: 20px;
height: 20px;
stroke: #000000;
fill: none;
stroke-width: 2;
stroke-linecap: round;
stroke-linejoin: round;
transition: stroke 0.3s ease;
}
.config-password-toggle:hover,
.config-password-toggle.revealed {
background-color: #000000;
}
.config-password-toggle:hover svg,
.config-password-toggle.revealed svg {
stroke: #ffffff;
}
.search-box {
display: flex;
flex: 1;
border: 2px solid #000000;
}
.search-input {
flex: 1;
padding: 15px;
border: none;
outline: none;
font-size: 16px;
background-color: #ffffff;
}
.search-button {
padding: 15px 30px;
border: none;
border-left: 2px solid #000000;
background-color: #000000;
color: #ffffff;
cursor: pointer;
font-size: 16px;
font-weight: bold;
transition: all 0.3s ease;
}
.search-button:hover {
background-color: #333333;
}
.search-button:disabled {
background-color: #666666;
cursor: not-allowed;
}
.upload-button {
padding: 15px 20px;
border: none;
border-left: 2px solid #000000;
background-color: #ffffff;
color: #000000;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
position: relative;
overflow: hidden;
}
.upload-button:hover {
background-color: #f0f0f0;
}
.upload-button input[type="file"] {
position: absolute;
left: 0;
top: 0;
width: 100%;
height: 100%;
opacity: 0;
cursor: pointer;
}
.upload-status {
font-size: 12px;
margin: 10px auto 0;
text-align: center;
color: #666666;
max-width: 950px;
}
.upload-status.success {
color: #4a6741;
}
.upload-status.error {
color: #8b4513;
}
/* 主内容区域 */
.main-content {
flex: 1;
display: flex;
height: calc(100vh - 140px);
min-height: 0; /* 允许子元素缩小 */
}
/* 嵌入页面区域 */
.embedded-section {
flex: 1.8; /* 稍微缩小左侧区域 */
border-right: 2px solid #000000;
background-color: #ffffff;
position: relative;
}
.embedded-header {
padding: 15px;
border-bottom: 2px solid #000000;
background-color: #ffffff;
font-weight: bold;
text-align: center;
}
.embedded-content {
height: calc(100% - 60px);
position: relative;
overflow: hidden;
}
/* 控制台输出区域 */
.console-section {
flex: 1.2; /* 稍微扩大右侧区域 */
display: flex;
flex-direction: column;
background-color: #ffffff;
min-height: 0; /* 允许子元素缩小 */
overflow: hidden; /* 防止内容溢出 */
}
/* 应用切换按钮 */
.app-switcher {
display: flex;
border-bottom: 2px solid #000000;
}
.app-button {
flex: 1;
padding: 15px;
border: none;
border-right: 2px solid #000000;
background-color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
position: relative;
}
.app-button:last-child {
border-right: none;
}
.app-button.active {
background-color: #000000;
color: #ffffff;
}
.app-button:not(.active):hover {
background-color: #f0f0f0;
}
.app-button.locked {
background-color: #f5f5f5;
color: #999999;
cursor: not-allowed;
position: relative;
}
.app-button.locked:hover {
background-color: #f5f5f5;
}
.app-button.locked::after {
content: "";
position: absolute;
top: 50%;
right: 10px;
transform: translateY(-50%);
font-size: 12px;
}
.app-button.locked .status-indicator {
background-color: #cccccc;
}
.status-indicator {
position: absolute;
top: 5px;
right: 5px;
width: 8px;
height: 8px;
border-radius: 50%;
background-color: #ff0000;
}
.status-indicator.running {
background-color: #00ff00;
}
.status-indicator.starting {
background-color: #ffff00;
}
/* 控制台输出 */
.console-output {
flex: 1;
padding: 15px;
background-color: #000000;
color: #00ff00;
font-family: 'Courier New', monospace;
font-size: 12px;
overflow-y: auto;
overflow-x: hidden;
white-space: pre-wrap;
word-break: break-all;
min-height: 0; /* 允许内容缩小 */
}
.console-layer {
display: none;
width: 100%;
min-height: 100%;
}
.console-layer.active {
display: block;
}
.console-line {
margin-bottom: 2px;
}
/* 状态信息 */
.status-bar {
padding: 10px 20px;
border-top: 2px solid #000000;
background-color: #ffffff;
font-size: 12px;
display: flex;
justify-content: space-between;
align-items: center;
}
.config-modal-overlay {
position: fixed;
inset: 0;
background-color: rgba(0, 0, 0, 0.35);
display: none;
align-items: center;
justify-content: center;
z-index: 999;
padding: 20px;
}
.config-modal-overlay.visible {
display: flex;
}
.config-modal {
background-color: #ffffff;
border: 2px solid #000000;
width: 720px;
max-width: 90vw;
max-height: 85vh;
display: flex;
flex-direction: column;
box-shadow: 6px 6px 0 #000000;
}
.config-modal-header {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-bottom: 2px solid #000000;
background-color: #ffffff;
}
.config-modal-title {
font-size: 18px;
font-weight: bold;
}
.config-modal-actions {
display: flex;
gap: 10px;
align-items: center;
}
.config-close-button {
width: 32px;
height: 32px;
border: 2px solid #000000;
background-color: #ffffff;
font-size: 18px;
line-height: 1;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.config-close-button:hover {
background-color: #000000;
color: #ffffff;
}
.config-close-button:disabled {
opacity: 0.4;
cursor: not-allowed;
background-color: #f0f0f0;
color: #666666;
}
.config-secondary-button {
padding: 8px 18px;
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
cursor: pointer;
font-size: 13px;
font-weight: bold;
transition: all 0.3s ease;
}
.config-secondary-button:hover {
background-color: #f0f0f0;
}
.config-modal-body {
padding: 20px;
overflow-y: auto;
}
.config-group {
border: 2px solid #000000;
padding: 16px;
margin-bottom: 16px;
background-color: #ffffff;
}
.config-group-title {
font-size: 15px;
font-weight: bold;
margin-bottom: 10px;
}
.config-group-subtitle {
font-size: 12px;
color: #555555;
margin-bottom: 12px;
}
.config-field {
display: flex;
flex-direction: column;
margin-bottom: 12px;
}
.config-field-label {
font-size: 12px;
font-weight: bold;
margin-bottom: 6px;
}
.config-field-input {
padding: 10px 12px;
border: 2px solid #000000;
font-size: 14px;
background-color: #ffffff;
}
.config-field-input:focus {
outline: none;
border-color: #333333;
}
.config-field-input[data-field-type="select"] {
cursor: pointer;
appearance: none;
background-image: url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='12' height='12' viewBox='0 0 12 12'%3E%3Cpath fill='%23333' d='M6 9L1 4h10z'/%3E%3C/svg%3E");
background-repeat: no-repeat;
background-position: right 12px center;
padding-right: 36px;
}
.config-modal-footer {
display: flex;
justify-content: space-between;
align-items: center;
padding: 16px 20px;
border-top: 2px solid #000000;
background-color: #ffffff;
}
.config-modal-footer-actions {
display: flex;
gap: 10px;
}
.config-status-message {
font-size: 12px;
color: #555555;
}
.config-status-message.error {
color: #8b4513;
}
.config-status-message.success {
color: #4a6741;
}
.config-save-button {
padding: 10px 24px;
border: none;
background-color: #000000;
color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.config-save-button:hover {
background-color: #333333;
}
.config-save-button:disabled {
background-color: #666666;
cursor: not-allowed;
}
.config-start-button {
padding: 10px 24px;
border: none;
background-color: #000000;
color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.config-start-button:hover {
background-color: #333333;
}
.config-start-button:disabled {
background-color: #666666;
cursor: not-allowed;
}
.loading {
display: inline-block;
width: 12px;
height: 12px;
border: 2px solid #000000;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
}
/* 专门用于报告状态的加载指示器,不会让整个容器旋转 */
.report-loading-spinner {
display: inline-block;
width: 16px;
height: 16px;
border: 2px solid #ffa500;
border-radius: 50%;
border-top-color: transparent;
animation: spin 1s ease-in-out infinite;
margin-right: 8px;
vertical-align: middle;
}
/* 任务进度条样式 */
.task-progress-container {
margin: 0; /* 移除margin,使用父容器的gap控制间距 */
padding: 20px;
border: 2px solid #000000;
background-color: #f5f5f0;
}
.task-progress-header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 15px;
font-weight: bold;
font-size: 16px;
}
.task-progress-title {
display: flex;
align-items: center;
flex-shrink: 0;
}
.task-progress-bar {
width: 200px;
height: 20px;
background-color: #e8e8e0;
border: 1px solid #666666;
margin-left: 20px;
position: relative;
overflow: hidden;
border-radius: 3px;
flex-shrink: 0;
}
.task-progress-fill {
height: 100%;
background: linear-gradient(90deg, #6b8a5a 0%, #8fb584 100%);
transition: width 0.5s ease;
position: absolute;
top: 0;
left: 0;
z-index: 1;
min-width: 2px; /* 确保即使是0%也有一点显示 */
}
.task-progress-text {
position: absolute;
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
color: #000000;
font-weight: bold;
font-size: 12px;
z-index: 2;
pointer-events: none;
}
.task-info-line {
display: flex;
align-items: center;
margin-top: 10px;
font-size: 13px;
padding: 8px;
background-color: #f0f0e8;
border: 1px solid #d0d0c0;
border-radius: 3px;
}
.task-info-item {
display: flex;
align-items: center;
margin-right: 30px;
}
.task-info-item:last-child {
margin-right: 0;
}
.task-info-label {
font-weight: bold;
color: #666;
margin-right: 8px;
}
.task-info-value {
color: #000;
}
.task-status-badge {
display: inline-block;
padding: 4px 12px;
border-radius: 15px;
font-size: 12px;
font-weight: bold;
margin-left: 10px;
}
.task-status-running {
background-color: #fff3cd;
color: #856404;
border: 1px solid #ffeaa7;
}
.task-status-completed {
background-color: #d4edda;
color: #155724;
border: 1px solid #c3e6cb;
}
.task-status-error {
background-color: #f8d7da;
color: #721c24;
border: 1px solid #f5c6cb;
}
.task-error-message {
margin-top: 15px;
padding: 10px;
background-color: #ffe6e6;
border-left: 4px solid #ff4444;
border-radius: 3px;
font-size: 13px;
}
.task-actions {
margin-top: 15px;
display: flex;
gap: 12px;
flex-wrap: wrap;
}
@keyframes spin {
to { transform: rotate(360deg); }
}
/* 响应式设计 */
@media (max-width: 768px) {
.main-content {
flex-direction: column;
height: auto;
}
.embedded-section {
border-right: none;
border-bottom: 2px solid #000000;
height: 400px;
}
.console-section {
height: 300px;
}
}
/* 消息提示 */
.message {
position: fixed;
top: 20px;
right: 0px;
padding: 15px 20px;
border: 2px solid #000000;
background-color: #ffffff;
color: #000000;
font-weight: bold;
transform: translateX(100%);
transition: transform 0.3s ease;
z-index: 1000;
}
.message.show {
transform: translateX(-5%);
}
.message.success {
border-color: #00aa00;
background-color: #f0fff0;
}
.message.error {
border-color: #aa0000;
background-color: #fff0f0;
}
/* Forum Engine 专用样式 */
.forum-container {
display: none;
height: 100%;
flex-direction: column;
}
.forum-container.active {
display: flex;
}
.forum-chat-area {
flex: 1;
display: flex;
flex-direction: column;
padding: 20px;
overflow-y: auto;
}
.forum-message {
margin-bottom: 20px;
min-width: 200px;
max-width: 85%;
padding: 15px;
border: 2px solid #000000;
border-radius: 0;
word-wrap: break-word;
}
.forum-message.user {
align-self: flex-end;
background-color: #000000;
color: #ffffff;
}
.forum-message.system {
align-self: flex-start;
background-color:rgb(182 182 182);
color: #000000;
border-color:rgb(62 62 62);
}
.forum-message.agent {
align-self: stretch; /* 占满整个宽度 */
color: #000000;
width: 100%; /* 确保占满宽度 */
max-width: 100%; /* 移除最大宽度限制 */
margin: 0 0 20px 0; /* 移除左右边距 */
}
/* 不同Engine的颜色区分 */
.forum-message.agent:has(.forum-message-header:contains("Query Engine")) {
background-color: #eaf1f8;
border-color: #608ab1;
}
.forum-message.agent:has(.forum-message-header:contains("QUERY Engine")) {
background-color: #eaf1f8;
border-color: #608ab1;
}
.forum-message.agent:has(.forum-message-header:contains("Insight Engine")) {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
.forum-message.agent:has(.forum-message-header:contains("INSIGHT Engine")) {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
.forum-message.agent:has(.forum-message-header:contains("Media Engine")) {
background-color: #ebf2ea;
border-color: #6a9a6e;
}
.forum-message.agent:has(.forum-message-header:contains("MEDIA Engine")) {
background-color: #ebf2ea;
border-color: #6a9a6e;
}
/* 备用方案:通过JavaScript添加的类 */
.forum-message.query-engine {
background-color: #eaf1f8;
border-color: #608ab1;
}
.forum-message.insight-engine {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
.forum-message.media-engine {
background-color: #ebf2ea;
border-color: #6a9a6e;
}
.forum-message.agent.QUERY {
background-color: #eaf1f8;
border-color: #608ab1;
}
.forum-message.agent.query-engine {
background-color: #eaf1f8;
border-color: #608ab1;
}
.forum-message.agent.MEDIA {
background-color: #ebf2ea;
border-color: #6a9a6e;
}
.forum-message.agent.media-engine {
background-color: #ebf2ea;
border-color: #6a9a6e;
}
.forum-message.agent.INSIGHT {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
.forum-message.agent.insight-engine {
background-color: #f2ebf3;
border-color: #8e6a9f;
}
/* HOST主持人样式 */
.forum-message.host {
align-self: stretch;
background-color: #fff8dc; /* 浅黄色背景 */
border-color: #daa520; /* 金色边框 */
color: #000000;
width: 100%;
max-width: 100%;
margin: 0 0 20px 0;
font-style: italic; /* 斜体字体显示主持人发言 */
}
.forum-message-header {
font-weight: bold;
margin-bottom: 8px;
font-size: 12px;
opacity: 0.8;
}
.forum-message-content {
line-height: 1.4;
word-wrap: break-word;
}
.forum-timestamp {
font-size: 10px;
opacity: 0.6;
margin-top: 5px;
}
/* Report Engine 专用样式 */
.report-container {
display: none;
height: 100%;
flex-direction: column;
position: relative;
}
.report-container.active {
display: flex;
}
.report-content {
flex: 1;
padding: 10px 20px 20px 20px; /* 减少上边距 */
overflow-y: auto;
overflow-x: hidden;
background-color: #ffffff;
display: flex;
flex-direction: column;
min-height: 0; /* 允许子元素缩小 */
gap: 15px; /* 统一子元素间距 */
}
.report-controls {
display: flex;
gap: 10px;
margin-bottom: 20px;
padding: 15px;
border-bottom: 2px solid #000000;
background-color: #f8f9fa;
}
.report-button {
padding: 10px 20px;
border: 2px solid #000000;
background-color: #ffffff;
cursor: pointer;
font-size: 14px;
font-weight: bold;
transition: all 0.3s ease;
}
.report-button:hover {
background-color: #f0f0f0;
}
.report-button:disabled {
background-color: #e0e0e0;
cursor: not-allowed;
}
.report-button.primary {
background-color: #000000;
color: #ffffff;
}
.report-button.primary:hover {
background-color: #333333;
}
.report-status {
padding: 15px;
margin: 0; /* 移除margin,使用父容器的gap控制间距 */
border: 2px solid #000000;
background-color: #f8f9fa;
font-family: 'Courier New', monospace;
}
.report-status.loading {
border-color: #b8860b;
background-color: #faf5e6;
/* 确保loading状态的报告框不会旋转 */
animation: none;
}
.report-status.success {
border-color: #4a6741;
background-color: #f0f5ed;
}
.report-status.error {
border-color: #8b4513;
background-color: #fdf5e6;
}
.report-preview {
border: 2px solid #000000;
background-color: #ffffff;
min-height: 400px;
max-height: none; /* 移除最大高度限制 */
overflow-y: hidden; /* 强制不显示垂直滚动条 */
overflow-x: hidden; /* 强制不显示水平滚动条 */
flex: 1; /* 让预览区域占用剩余空间 */
}
.report-preview iframe {
width: 100%;
min-height: 400px;
border: none;
/* 让iframe自适应内容高度 */
height: auto;
/* 强制不显示滚动条 */
overflow: hidden;
scrollbar-width: none; /* Firefox */
-ms-overflow-style: none; /* IE and Edge */
}
/* 隐藏webkit浏览器的滚动条 */
.report-preview iframe::-webkit-scrollbar {
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;
justify-content: center;
height: 200px;
color: #666;
font-size: 14px;
}
</style>
</head>
<body>
<!-- 顶层容器:同时包裹搜索区、双列主工作区与状态栏 -->
<div class="container">
<!-- 搜索框区域 -->
<div class="search-section">
<div class="search-title">微舆 - 致力于打造简洁通用的舆情分析平台</div>
<div class="search-row">
<button class="config-button" id="openConfigButton">LLM 配置</button>
<div class="search-box">
<input type="text" class="search-input" id="searchInput" placeholder="请输入要分析的内容...">
<button class="search-button" id="searchButton">开始</button>
<button class="upload-button" id="uploadButton">
上传模板
<input type="file" id="templateFileInput" accept=".md,.txt" title="上传自定义报告模板(支持 .md 和 .txt 文件)">
</button>
</div>
</div>
<div class="upload-status" id="uploadStatus"></div>
</div>
<!-- 主内容区域 -->
<div class="main-content">
<!-- 嵌入页面区域 -->
<div class="embedded-section">
<div class="embedded-header" id="embeddedHeader">嵌入的页面</div>
<div class="embedded-content" id="embeddedContent">
<!-- 论坛聊天界面 -->
<div class="forum-container" id="forumContainer">
<div class="forum-chat-area" id="forumChatArea">
<!-- 动态添加的Engine对话消息将在这里显示 -->
</div>
</div>
<!-- 报告引擎界面 -->
<div class="report-container" id="reportContainer">
<div class="report-content" id="reportContent">
<!-- 报告内容将在这里显示 -->
</div>
</div>
<div style="display: flex; align-items: center; justify-content: center; height: 100%; color: #666;">
<span>默认只显示第一个页面 - 点击按钮切换页面</span>
</div>
</div>
</div>
<!-- 控制台输出区域 -->
<div class="console-section">
<!-- 应用切换按钮 -->
<div class="app-switcher">
<button class="app-button active" data-app="insight">
<span class="status-indicator" id="status-insight"></span>
Insight Engine
</button>
<button class="app-button" data-app="media">
<span class="status-indicator" id="status-media"></span>
Media Engine
</button>
<button class="app-button" data-app="query">
<span class="status-indicator" id="status-query"></span>
Query Engine
</button>
<button class="app-button" data-app="forum">
<span class="status-indicator running" id="status-forum"></span>
Forum Engine
</button>
<button class="app-button locked" data-app="report" title="需等待其余三个Agent工作完毕">
<span class="status-indicator" id="status-report"></span>
Report Engine
</button>
</div>
<!-- 控制台输出 -->
<div class="console-output" id="consoleOutput"></div>
</div>
</div>
<!-- 状态栏:实时展示WebSocket连接状态与系统时钟 -->
<div class="status-bar">
<span id="connectionStatus">连接中...</span>
<span id="systemTime"></span>
</div>
</div>
<!-- 配置弹窗:与后端.env互通,允许在线修改LLM参数 -->
<div class="config-modal-overlay" id="configModal">
<div class="config-modal">
<div class="config-modal-header">
<div class="config-modal-title">LLM 配置 - 与.env文件双向同步</div>
<div class="config-modal-actions">
<button class="config-secondary-button" id="refreshConfigButton">刷新</button>
<button class="config-close-button" id="closeConfigModal" aria-label="关闭配置窗口">×</button>
</div>
</div>
<div class="config-modal-body" id="configFormContainer">
<!-- 由脚本填充 -->
</div>
<div class="config-modal-footer">
<div class="config-status-message" id="configStatusMessage"></div>
<div class="config-modal-footer-actions">
<button class="config-save-button" id="saveConfigButton">保存</button>
<button class="config-start-button" id="startSystemButton">保存并启动系统</button>
</div>
</div>
</div>
</div>
<!-- 消息提示:右上角滑出式成功/错误提醒 -->
<div class="message" id="message"></div>
<!-- 前端业务脚本:维护Socket连接、引擎启动状态与Report Engine交互 -->
<script>
// 全局变量
let socket;
let currentApp = 'insight';
let appStatus = {
insight: 'stopped',
media: 'stopped',
query: 'stopped',
forum: 'stopped', // 前端启动后再标记为 running
report: 'stopped' // Report Engine
};
// 为每个Engine存储进度条状态
let engineProgress = {
insight: null,
media: null,
query: null,
report: null
};
let customTemplate = ''; // 存储用户上传的自定义模板内容
let configValues = {};
let configDirty = false;
let configAutoRefreshTimer = null;
let systemStarted = false;
let systemStarting = false;
let configModalLocked = false;
let socketConnected = false;
let reportStreamConnected = false;
let backendReachable = false;
const consoleLayerApps = ['insight', 'media', 'query', 'forum', 'report'];
const consoleLayers = {};
const consoleLayerScrollPositions = {};
let activeConsoleLayer = currentApp;
const logRenderers = {};
// 页面可见性状态管理
let isPageVisible = !document.hidden;
let allTimers = {
updateTime: null,
checkStatus: null,
refreshConsole: null,
refreshForum: null,
reportLockCheck: null,
connectionProbe: null,
updateEngineProgress: null // 新增:更新所有Engine进度的定时器
};
// 页面可见性变化处理
function handleVisibilityChange() {
isPageVisible = !document.hidden;
if (isPageVisible) {
console.log('页面可见,恢复定时器');
startAllTimers();
} else {
console.log('页面隐藏,暂停定时器以节省资源');
pauseAllTimers();
}
}
// 启动所有定时器
function startAllTimers() {
// 清理旧定时器
stopAllTimers();
// 时间更新定时器 - 只在页面可见时更新
if (isPageVisible) {
allTimers.updateTime = setInterval(updateTime, 1000);
}
// 状态检查定时器 - 从5秒增加到10秒
allTimers.checkStatus = setInterval(checkStatus, 10000);
// 控制台刷新定时器 - 从2秒增加到3秒,只在有运行中应用时执行
allTimers.refreshConsole = setInterval(() => {
if (appStatus[currentApp] === 'running' || appStatus[currentApp] === 'starting') {
refreshConsoleOutput();
}
}, 3000);
// 论坛刷新定时器 - 从3秒增加到5秒
allTimers.refreshForum = setInterval(() => {
if (currentApp === 'forum' || appStatus.forum === 'running') {
refreshForumMessages();
}
}, 5000);
// 报告锁定检查定时器 - 从10秒增加到15秒
allTimers.reportLockCheck = setInterval(checkReportLockStatus, 15000);
// 更新所有Engine进度的定时器 - 每5秒更新一次
allTimers.updateEngineProgress = setInterval(updateAllEngineProgress, 5000);
}
// 暂停所有定时器
function pauseAllTimers() {
// 只保留关键的连接检查定时器,其他全部暂停
Object.keys(allTimers).forEach(key => {
if (key !== 'connectionProbe' && allTimers[key]) {
clearInterval(allTimers[key]);
allTimers[key] = null;
}
});
}
// 停止所有定时器
function stopAllTimers() {
Object.keys(allTimers).forEach(key => {
if (allTimers[key]) {
clearInterval(allTimers[key]);
allTimers[key] = null;
}
});
}
// 页面卸载时清理资源
function cleanupOnUnload() {
console.log('页面卸载,清理所有资源');
// 停止所有定时器
stopAllTimers();
// 清理所有日志渲染器
Object.values(logRenderers).forEach(renderer => {
if (renderer && typeof renderer.dispose === 'function') {
renderer.dispose();
}
});
// 卸载所有iframe
Object.keys(preloadedIframes).forEach(app => {
unloadIframe(app);
});
// 关闭Socket连接
if (socket) {
socket.close();
}
// 关闭SSE连接
safeCloseReportStream();
// 清理全局变量
Object.keys(consoleLayers).forEach(key => {
delete consoleLayers[key];
});
Object.keys(logRenderers).forEach(key => {
delete logRenderers[key];
});
}
// 轻量日志虚拟渲染器:可视窗口渲染 + 节流 + 包级别截断,降低内存占用
class LogVirtualList {
constructor(container) {
this.container = container;
this.scrollElement = document.getElementById('consoleOutput') || container;
this.lines = [];
this.pending = [];
this.pool = [];
this.lineHeight = 18;
this.maxVisible = 120;
this.maxLines = 500; // 减少到500行,降低75%内存占用
this.trimTarget = 300; // 裁剪后保留300行
this.maxPoolSize = 200; // 限制DOM节点池大小
this.rafId = null;
this.autoScrollEnabled = true;
this.resumeDelay = 3000; // 手动滚动后重新自动滚动的延迟(降低到3秒)
this.resumeTimer = null;
this.lastRenderHash = null; // 用于检测内容是否真正变化
this.scrollLocked = false; // 防止滚动冲突的锁
this.needsScroll = false; // 标记是否需要滚动
this.lastScrollTime = 0; // 上次滚动时间,用于节流
this.scrollThrottle = 100; // 滚动节流时间(毫秒)
this.scrollHandler = null; // 存储滚动处理器引用
this.attachScroll();
}
attachScroll() {
if (!this.scrollElement) return;
if (this.scrollHandler) return; // 防止重复绑定
let scrollTimer = null;
this.scrollHandler = () => {
// 防抖处理,避免频繁触发
if (scrollTimer) clearTimeout(scrollTimer);
scrollTimer = setTimeout(() => {
this.handleUserScroll();
}, 150); // 增加防抖时间到150ms
};
this.scrollElement.addEventListener('scroll', this.scrollHandler, { passive: true });
}
// 添加清理方法
dispose() {
// 清理定时器
if (this.rafId) {
cancelAnimationFrame(this.rafId);
this.rafId = null;
}
this.clearResumeTimer();
// 移除事件监听器
if (this.scrollElement && this.scrollHandler) {
this.scrollElement.removeEventListener('scroll', this.scrollHandler);
this.scrollHandler = null;
}
// 清空数据结构
this.lines = [];
this.pending = [];
// 清空并释放DOM节点池
this.pool.forEach(node => {
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
});
this.pool = [];
// 清空容器
if (this.container) {
this.container.innerHTML = '';
}
}
handleUserScroll() {
if (!this.scrollElement || this.scrollLocked) return;
const atBottom = this.isNearBottom();
if (atBottom) {
this.autoScrollEnabled = true;
this.clearResumeTimer();
return;
}
// 用户主动向上滚动,禁用自动滚动
this.autoScrollEnabled = false;
this.clearResumeTimer();
// 设置定时器,在用户停止滚动一段时间后自动恢复吸底
this.resumeTimer = setTimeout(() => {
this.autoScrollEnabled = true;
this.scrollToBottom();
}, this.resumeDelay);
}
clearResumeTimer() {
if (this.resumeTimer) {
clearTimeout(this.resumeTimer);
this.resumeTimer = null;
}
}
isNearBottom() {
if (!this.scrollElement) return true;
const { scrollTop, clientHeight, scrollHeight } = this.scrollElement;
// 增加阈值到 50px,使吸底判断更宽容
return (scrollTop + clientHeight) >= (scrollHeight - 50);
}
scrollToBottom() {
if (!this.scrollElement) return;
// 节流:如果上次滚动时间距离现在不到 scrollThrottle 毫秒,则跳过
const now = Date.now();
if (now - this.lastScrollTime < this.scrollThrottle) {
return;
}
this.lastScrollTime = now;
// 使用锁防止重入
if (this.scrollLocked) return;
this.scrollLocked = true;
// 使用 requestAnimationFrame 确保在下一帧执行,避免闪烁
requestAnimationFrame(() => {
if (!this.scrollElement) {
this.scrollLocked = false;
return;
}
// 平滑滚动到底部,避免突然跳跃
const targetScroll = this.scrollElement.scrollHeight;
const currentScroll = this.scrollElement.scrollTop;
// 如果已经在底部附近,直接设置,否则平滑滚动
if (Math.abs(targetScroll - currentScroll) < 100) {
this.scrollElement.scrollTop = targetScroll;
} else {
// 使用平滑滚动
this.scrollElement.scrollTo({
top: targetScroll,
behavior: 'auto' // 使用 auto 而不是 smooth,避免性能问题
});
}
// 延迟解锁,避免立即触发 scroll 事件导致循环
setTimeout(() => {
this.scrollLocked = false;
this.needsScroll = false; // 滚动完成后重置标志
}, 150);
});
}
setLineHeight(px) {
if (px > 0) this.lineHeight = px;
}
append(text, className = 'console-line') {
// 在添加内容前检查是否在底部,如果是则标记需要滚动
if (this.autoScrollEnabled && this.isNearBottom()) {
this.needsScroll = true;
}
this.pending.push({ text, className });
// 增加批处理阈值到 100,减少渲染频率
if (this.pending.length > 100) {
this.flush();
}
this.maybeTrim();
this.scheduleRender();
}
clear(message = null) {
this.lines = [];
this.pending = [];
this.pool = [];
if (message) {
this.lines.push({ text: message, className: 'console-line' });
}
this.lastRenderHash = null;
this.needsScroll = true; // 清空后需要滚动到底部
this.scheduleRender(true);
}
flush() {
if (!this.pending.length) return;
this.lines.push(...this.pending);
this.pending = [];
this.maybeTrim();
}
maybeTrim() {
// 在 flush 之后调用:控制 lines 总量,减少内存
if (this.lines.length <= this.maxLines) return;
const toDrop = this.lines.length - this.trimTarget;
if (toDrop > 0) {
this.lines.splice(0, toDrop);
// 不调整滚动位置,让用户保持当前位置或自动吸底
}
}
scheduleRender(force = false) {
if (!this.container) return;
if (!force && this.rafId) return;
// 取消之前的请求,使用节流
if (this.rafId) {
cancelAnimationFrame(this.rafId);
}
this.rafId = requestAnimationFrame(() => {
this.rafId = null;
this.render();
});
}
render() {
this.flush();
const total = this.lines.length;
if (!total) {
if (this.container.innerHTML !== '') {
this.container.innerHTML = '';
// 清空时也清理pool
this.pool = [];
}
return;
}
// 计算内容哈希,只在内容真正变化时才更新 DOM
const contentHash = `${total}-${this.lines[total - 1].text}`;
if (this.lastRenderHash === contentHash) {
// 内容没有变化,只需要处理滚动(如果需要的话)
if (this.needsScroll && this.autoScrollEnabled) {
this.scrollToBottom();
}
return;
}
this.lastRenderHash = contentHash;
const lh = this.lineHeight;
const viewport = (this.scrollElement && this.scrollElement.clientHeight) || 1;
const visible = Math.max(Math.ceil(viewport / lh) + 20, this.maxVisible);
const scrollTop = (this.scrollElement && this.scrollElement.scrollTop) || 0;
const halfVisible = Math.floor(visible / 2);
const rawStart = Math.floor(scrollTop / lh) - halfVisible;
const start = Math.max(0, Math.min(total, rawStart));
const end = Math.min(total, start + visible);
const beforeHeight = start * lh;
const afterHeight = (total - end) * lh;
const needed = Math.max(0, end - start);
// 限制DOM节点池大小,防止内存泄漏
if (this.pool.length > this.maxPoolSize) {
const excess = this.pool.length - this.maxPoolSize;
// 移除多余的节点
this.pool.splice(this.maxPoolSize, excess).forEach(node => {
if (node && node.parentNode) {
node.parentNode.removeChild(node);
}
});
}
// 复用现有的 DOM 节点池
while (this.pool.length < needed && this.pool.length < this.maxPoolSize) {
const node = document.createElement('div');
node.className = 'console-line';
this.pool.push(node);
}
// 使用DocumentFragment来减少DOM重绘
const fragment = document.createDocumentFragment();
// 更新或创建前置占位符
let beforeSpacer = this.container.querySelector('[data-spacer="before"]');
if (!beforeSpacer) {
beforeSpacer = document.createElement('div');
beforeSpacer.dataset.spacer = 'before';
}
beforeSpacer.style.height = `${beforeHeight}px`;
// 更新或创建后置占位符
let afterSpacer = this.container.querySelector('[data-spacer="after"]');
if (!afterSpacer) {
afterSpacer = document.createElement('div');
afterSpacer.dataset.spacer = 'after';
}
afterSpacer.style.height = `${afterHeight}px`;
// 只更新可见区域的节点
for (let idx = start; idx < end; idx++) {
const line = this.lines[idx];
const poolIdx = idx - start;
const node = this.pool[poolIdx];
if (!node) continue;
// 只在内容或类名变化时才更新节点
if (node.textContent !== line.text || node.className !== (line.className || 'console-line')) {
node.className = line.className || 'console-line';
node.textContent = line.text;
}
fragment.appendChild(node);
}
// 一次性更新 DOM
this.container.innerHTML = '';
this.container.appendChild(beforeSpacer);
this.container.appendChild(fragment);
this.container.appendChild(afterSpacer);
// 只在有标记且自动滚动启用时才滚动到底部
if (this.needsScroll && this.autoScrollEnabled) {
// 延迟执行滚动,确保 DOM 已经更新完毕
requestAnimationFrame(() => {
this.scrollToBottom();
});
}
}
}
const CONFIG_ENDPOINT = '/api/config';
const SYSTEM_STATUS_ENDPOINT = '/api/system/status';
const SYSTEM_START_ENDPOINT = '/api/system/start';
const START_BUTTON_DEFAULT_TEXT = '保存并启动系统';
const configFieldGroups = [
{
title: '数据库连接',
subtitle: '用于连接社媒数据库的基本配置,注意数据库默认为空,需要单独部署MindSpider爬取数据',
fields: [
{ key: 'DB_DIALECT', label: '数据库类型', type: 'select', options: ['mysql', 'postgresql'] },
{ key: 'DB_HOST', label: '主机地址' },
{ key: 'DB_PORT', label: '端口' },
{ key: 'DB_USER', label: '用户名' },
{ key: 'DB_PASSWORD', label: '密码', type: 'password' },
{ key: 'DB_NAME', label: '数据库名称' },
{ key: 'DB_CHARSET', label: '字符集' }
]
},
{
title: 'Insight Agent',
subtitle: 'OpenAi接入格式,推荐LLMkimi-k2',
fields: [
{ key: 'INSIGHT_ENGINE_API_KEY', label: 'API Key', type: 'password' },
{ key: 'INSIGHT_ENGINE_BASE_URL', label: 'Base URL' },
{ key: 'INSIGHT_ENGINE_MODEL_NAME', label: '模型名称' }
]
},
{
title: 'Media Agent',
subtitle: 'OpenAi接入格式,推荐LLMgemini-2.5-pro',
fields: [
{ key: 'MEDIA_ENGINE_API_KEY', label: 'API Key', type: 'password' },
{ key: 'MEDIA_ENGINE_BASE_URL', label: 'Base URL' },
{ key: 'MEDIA_ENGINE_MODEL_NAME', label: '模型名称' }
]
},
{
title: 'Query Agent',
subtitle: 'OpenAi接入格式,推荐LLMdeepseek-chat',
fields: [
{ key: 'QUERY_ENGINE_API_KEY', label: 'API Key', type: 'password' },
{ key: 'QUERY_ENGINE_BASE_URL', label: 'Base URL' },
{ key: 'QUERY_ENGINE_MODEL_NAME', label: '模型名称' }
]
},
{
title: 'Report Agent',
subtitle: 'OpenAi接入格式,推荐LLMgemini-2.5-pro',
fields: [
{ key: 'REPORT_ENGINE_API_KEY', label: 'API Key', type: 'password' },
{ key: 'REPORT_ENGINE_BASE_URL', label: 'Base URL' },
{ key: 'REPORT_ENGINE_MODEL_NAME', label: '模型名称' }
]
},
{
title: 'Forum Host',
subtitle: 'OpenAi接入格式,推荐LLMqwen-plus',
fields: [
{ key: 'FORUM_HOST_API_KEY', label: 'API Key', type: 'password' },
{ key: 'FORUM_HOST_BASE_URL', label: 'Base URL' },
{ key: 'FORUM_HOST_MODEL_NAME', label: '模型名称' }
]
},
{
title: 'Keyword Optimizer',
subtitle: 'OpenAi接入格式,推荐LLMqwen-plus',
fields: [
{ key: 'KEYWORD_OPTIMIZER_API_KEY', label: 'API Key', type: 'password' },
{ key: 'KEYWORD_OPTIMIZER_BASE_URL', label: 'Base URL' },
{ key: 'KEYWORD_OPTIMIZER_MODEL_NAME', label: '模型名称' }
]
},
{
title: '外部检索工具',
subtitle: '联动搜索引擎、网站抓取等在线服务,两个都需配置',
fields: [
{ key: 'TAVILY_API_KEY', label: 'Tavily API Key', type: 'password' },
{ key: 'BOCHA_WEB_SEARCH_API_KEY', label: 'Bocha API Key', type: 'password' }
]
}
];
// 应用名称映射
const appNames = {
insight: 'Insight Engine',
media: 'Media Engine',
query: 'Query Engine',
forum: 'Forum Engine',
report: 'Report Engine'
};
// 页面头部显示的完整Agent介绍
const agentTitles = {
insight: 'Insight Agent - 私有数据库挖掘',
media: 'Media Agent - 多模态内容分析',
query: 'Query Agent - 精准信息搜索',
forum: 'Forum Engine - 多智能体交流',
report: 'Report Agent - 最终报告生成'
};
// 初始化
document.addEventListener('DOMContentLoaded', function() {
initializeConsoleLayers();
initializeSocket();
initializeEventListeners();
ensureSystemReadyOnLoad();
loadConsoleOutput(currentApp);
// 使用新的定时器管理系统
updateTime(); // 立即更新一次
checkStatus(); // 立即检查一次
checkReportLockStatus(); // 立即检查一次
// 启动所有定时器
startAllTimers();
// 立即更新一次所有Engine的进度,恢复刷新前的状态
updateAllEngineProgress();
// 监听页面可见性变化
document.addEventListener('visibilitychange', handleVisibilityChange);
// 监听页面卸载事件
window.addEventListener('beforeunload', cleanupOnUnload);
window.addEventListener('unload', cleanupOnUnload);
// 初始化密码切换功能(事件委托,只需调用一次)
attachConfigPasswordToggles();
// 初始化论坛相关功能
initializeForum();
// 延迟预加载iframe以确保应用启动完成,并且只在页面可见时加载
setTimeout(() => {
if (isPageVisible) {
preloadIframes();
}
}, 5000); // 延迟时间从3秒增加到5秒,减少初始加载压力
// 连接探测定时器(保持运行)
startConnectionProbe();
});
// Socket.IO连接
function initializeSocket() {
socket = io();
socket.on('connect', function() {
socketConnected = true;
refreshConnectionStatus();
socket.emit('request_status');
});
socket.on('disconnect', function() {
socketConnected = false;
refreshConnectionStatus();
});
socket.on('console_output', function(data) {
// 处理控制台输出
addConsoleOutput(data.line, data.app);
// 如果是forum的输出,同时也处理为论坛消息
if (data.app === 'forum') {
const parsed = parseForumMessage(data.line);
if (parsed) {
// addForumMessage(parsed);
}
}
});
socket.on('forum_message', function(data) {
// addForumMessage(data);
});
socket.on('status_update', function(data) {
updateAppStatus(data);
});
}
// 事件监听器
function initializeEventListeners() {
// 搜索按钮
document.getElementById('searchButton').addEventListener('click', performSearch);
document.getElementById('searchInput').addEventListener('keypress', function(e) {
if (e.key === 'Enter') {
performSearch();
}
});
// 文件上传
document.getElementById('templateFileInput').addEventListener('change', handleTemplateUpload);
// 应用切换按钮
document.querySelectorAll('.app-button').forEach(button => {
button.addEventListener('click', function() {
const app = this.dataset.app;
switchToApp(app);
});
});
// LLM 配置弹窗
const openConfigButton = document.getElementById('openConfigButton');
if (openConfigButton) {
openConfigButton.addEventListener('click', () => openConfigModal({ lock: !systemStarted }));
}
const closeConfigButton = document.getElementById('closeConfigModal');
if (closeConfigButton) {
closeConfigButton.addEventListener('click', () => closeConfigModal());
}
const refreshConfigButton = document.getElementById('refreshConfigButton');
if (refreshConfigButton) {
refreshConfigButton.addEventListener('click', () => refreshConfigFromServer(true));
}
const saveConfigButton = document.getElementById('saveConfigButton');
if (saveConfigButton) {
saveConfigButton.addEventListener('click', () => saveConfigUpdates());
}
const startSystemButton = document.getElementById('startSystemButton');
if (startSystemButton) {
startSystemButton.addEventListener('click', () => startSystem());
}
const configModal = document.getElementById('configModal');
if (configModal) {
configModal.addEventListener('click', (event) => {
if (event.target === configModal) {
closeConfigModal();
}
});
}
const configFormContainer = document.getElementById('configFormContainer');
if (configFormContainer) {
configFormContainer.addEventListener('input', () => {
configDirty = true;
setConfigStatus('已修改,尚未保存');
});
}
document.addEventListener('keydown', function(event) {
if (event.key === 'Escape' && isConfigModalVisible()) {
closeConfigModal();
}
});
}
function isConfigModalVisible() {
const modal = document.getElementById('configModal');
return modal ? modal.classList.contains('visible') : false;
}
function openConfigModal(options = {}) {
const { lock = false, message = '' } = options;
const modal = document.getElementById('configModal');
if (!modal) {
return;
}
configModalLocked = lock;
modal.classList.add('visible');
configDirty = false;
const initialMessage = message || '正在读取配置...';
setConfigStatus(initialMessage, '');
const messageAfterLoad = message || '';
refreshConfigFromServer(true, messageAfterLoad);
if (configAutoRefreshTimer) {
clearInterval(configAutoRefreshTimer);
}
configAutoRefreshTimer = setInterval(() => {
if (!configDirty) {
refreshConfigFromServer(false, messageAfterLoad);
}
}, 10000);
updateStartButtonState();
updateConfigCloseButton();
}
function closeConfigModal(force = false) {
if (!force && configModalLocked && !systemStarted) {
setConfigStatus('请先完成配置并启动系统', 'error');
showMessage('请先完成配置并启动系统', 'error');
return;
}
const modal = document.getElementById('configModal');
if (modal) {
modal.classList.remove('visible');
}
if (configAutoRefreshTimer) {
clearInterval(configAutoRefreshTimer);
configAutoRefreshTimer = null;
}
configDirty = false;
configModalLocked = false;
setConfigStatus('', '');
updateStartButtonState();
updateConfigCloseButton();
}
function refreshConfigFromServer(showFeedback = false, messageOverride = '') {
if (showFeedback && configDirty) {
const proceed = window.confirm('当前修改尚未保存,确定要刷新并放弃更改吗?');
if (!proceed) {
return;
}
}
fetch(CONFIG_ENDPOINT)
.then(response => response.json())
.then(data => {
if (!data.success) {
throw new Error(data.message || '读取配置失败');
}
configValues = data.config || {};
renderConfigForm(configValues);
configDirty = false;
if (messageOverride) {
setConfigStatus(messageOverride);
} else if (showFeedback) {
setConfigStatus('已加载最新配置');
} else {
setConfigStatus('已同步最新配置');
}
})
.catch(error => {
console.error(error);
setConfigStatus(`读取配置失败: ${error.message}`, 'error');
});
}
function escapeHtml(str) {
return str.replace(/&/g, '&amp;')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
}
function renderConfigForm(values) {
const container = document.getElementById('configFormContainer');
if (!container) {
return;
}
const sections = configFieldGroups.map(group => {
const fieldsHtml = group.fields.map(field => {
const value = values[field.key] !== undefined ? values[field.key] : '';
const safeValue = escapeHtml(String(value || ''));
let control;
if (field.type === 'select' && field.options) {
// 下拉选择框
const optionsHtml = field.options.map(option => {
const selected = option === value ? 'selected' : '';
const safeOption = escapeHtml(String(option));
return `<option value="${safeOption}" ${selected}>${safeOption}</option>`;
}).join('');
control = `
<select
class="config-field-input"
data-config-key="${field.key}"
data-field-type="select"
>
${optionsHtml}
</select>
`;
} else if (field.type === 'password') {
// 密码输入框
const inputElement = `
<input
type="password"
class="config-field-input"
data-config-key="${field.key}"
data-field-type="password"
value="${safeValue}"
placeholder="填写${field.label}"
autocomplete="off"
>
`;
// 眼睛图标 - 闭眼状态(默认隐藏密码)
const eyeOffIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
control = `
<div class="config-password-wrapper">
${inputElement}
<button type="button" class="config-password-toggle" data-target="${field.key}" title="显示/隐藏密码">
${eyeOffIcon}
</button>
</div>
`;
} else {
// 普通文本输入框
const inputType = field.type || 'text';
control = `
<input
type="${inputType}"
class="config-field-input"
data-config-key="${field.key}"
data-field-type="${inputType}"
value="${safeValue}"
placeholder="填写${field.label}"
autocomplete="on"
>
`;
}
return `
<label class="config-field">
<span class="config-field-label">${field.label}</span>
${control}
</label>
`;
}).join('');
const subtitle = group.subtitle ? `<div class="config-group-subtitle">${group.subtitle}</div>` : '';
return `
<section class="config-group">
<div class="config-group-title">${group.title}</div>
${subtitle}
${fieldsHtml}
</section>
`;
}).join('');
container.innerHTML = sections;
// 不再需要每次调用 attachConfigPasswordToggles
// 事件委托已在页面初始化时设置
}
function attachConfigPasswordToggles() {
// 定义眼睛图标的SVG
const eyeOffIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M17.94 17.94A10.07 10.07 0 0 1 12 20c-7 0-11-8-11-8a18.45 18.45 0 0 1 5.06-5.94M9.9 4.24A9.12 9.12 0 0 1 12 4c7 0 11 8 11 8a18.5 18.5 0 0 1-2.16 3.19m-6.72-1.07a3 3 0 1 1-4.24-4.24"></path>
<line x1="1" y1="1" x2="23" y2="23"></line>
</svg>
`;
const eyeOnIcon = `
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24">
<path d="M1 12s4-8 11-8 11 8 11 8-4 8-11 8-11-8-11-8z"></path>
<circle cx="12" cy="12" r="3"></circle>
</svg>
`;
// 使用事件委托,只在容器上绑定一次事件
const container = document.getElementById('configFormContainer');
if (!container) {
return;
}
// 防止重复绑定
if (container.dataset.passwordToggleAttached === 'true') {
return;
}
container.addEventListener('click', (event) => {
// 查找是否点击了密码切换按钮或其内部的SVG
const toggle = event.target.closest('.config-password-toggle');
if (!toggle) {
return;
}
const key = toggle.dataset.target;
const input = container.querySelector(`.config-field-input[data-config-key="${key}"]`);
if (!input) {
return;
}
const reveal = input.getAttribute('type') === 'password';
input.setAttribute('type', reveal ? 'text' : 'password');
toggle.innerHTML = reveal ? eyeOnIcon : eyeOffIcon;
toggle.classList.toggle('revealed', reveal);
});
// 标记已绑定,防止重复
container.dataset.passwordToggleAttached = 'true';
}
function collectConfigUpdates() {
const inputs = document.querySelectorAll('#configFormContainer [data-config-key]');
const updates = {};
inputs.forEach(input => {
const key = input.dataset.configKey;
if (!key) {
return;
}
const fieldType = input.dataset.fieldType || 'text';
let value = input.value;
if (fieldType !== 'password' && typeof value === 'string') {
value = value.trim();
}
if (value !== '' && /PORT$/i.test(key)) {
const numeric = Number(value);
if (!Number.isNaN(numeric)) {
updates[key] = numeric;
return;
}
}
updates[key] = value;
});
return updates;
}
function setConfigStatus(message, type = '') {
const status = document.getElementById('configStatusMessage');
if (!status) {
return;
}
status.textContent = message || '';
status.classList.remove('error', 'success');
if (type) {
status.classList.add(type);
}
}
async function saveConfigUpdates(options = {}) {
const { silent = false } = options;
const saveButton = document.getElementById('saveConfigButton');
if (!silent && saveButton) {
saveButton.disabled = true;
saveButton.textContent = '保存中...';
}
if (!silent) {
setConfigStatus('正在保存配置...', '');
}
const updates = collectConfigUpdates();
try {
const response = await fetch(CONFIG_ENDPOINT, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(updates)
});
const data = await response.json();
if (!data.success) {
throw new Error(data.message || '保存失败');
}
configValues = data.config || {};
renderConfigForm(configValues);
configDirty = false;
if (silent) {
setConfigStatus('配置已保存', 'success');
} else {
setConfigStatus('配置已保存', 'success');
showMessage('配置已保存', 'success');
}
return true;
} catch (error) {
console.error(error);
setConfigStatus(`保存失败: ${error.message}`, 'error');
if (!silent) {
showMessage(`保存失败: ${error.message}`, 'error');
}
return false;
} finally {
if (!silent && saveButton) {
saveButton.disabled = false;
saveButton.textContent = '保存';
}
}
}
function updateStartButtonState() {
const startButton = document.getElementById('startSystemButton');
if (!startButton) {
return;
}
if (systemStarting) {
startButton.disabled = true;
startButton.textContent = '启动中...';
} else if (systemStarted) {
startButton.disabled = true;
startButton.textContent = '系统已启动';
} else {
startButton.disabled = false;
startButton.textContent = START_BUTTON_DEFAULT_TEXT;
}
}
function updateConfigCloseButton() {
const closeButton = document.getElementById('closeConfigModal');
if (!closeButton) {
return;
}
if (configModalLocked && !systemStarted) {
closeButton.setAttribute('disabled', 'disabled');
} else {
closeButton.removeAttribute('disabled');
}
}
function applySystemState(state) {
if (!state) {
return;
}
if (Object.prototype.hasOwnProperty.call(state, 'started')) {
systemStarted = !!state.started;
}
if (Object.prototype.hasOwnProperty.call(state, 'starting')) {
systemStarting = !!state.starting;
}
updateStartButtonState();
updateConfigCloseButton();
}
async function fetchSystemStatus() {
try {
const response = await fetch(SYSTEM_STATUS_ENDPOINT);
const data = await response.json();
if (data && data.success) {
applySystemState(data);
}
return data;
} catch (error) {
console.error('获取系统状态失败', error);
return null;
}
}
async function ensureSystemReadyOnLoad() {
const status = await fetchSystemStatus();
if (!status || !status.success) {
openConfigModal({
lock: true,
message: '无法获取系统状态,请检查配置后重试。'
});
return;
}
if (!status.started) {
openConfigModal({
lock: true,
message: '请先确认配置,然后点击“保存并启动系统”'
});
} else {
applySystemState(status);
configModalLocked = false;
}
}
async function startSystem() {
if (systemStarting) {
setConfigStatus('系统正在启动,请稍候...', '');
return;
}
systemStarting = true;
updateStartButtonState();
try {
if (configDirty) {
setConfigStatus('检测到未保存的修改,正在保存配置...', '');
const saved = await saveConfigUpdates({ silent: true });
if (!saved) {
systemStarting = false;
updateStartButtonState();
return;
}
}
setConfigStatus('正在启动系统...', '');
const response = await fetch(SYSTEM_START_ENDPOINT, { method: 'POST' });
const data = await response.json();
if (!response.ok || !data.success) {
const message = data && data.message ? data.message : '系统启动失败';
throw new Error(message);
}
showMessage('系统启动成功', 'success');
setConfigStatus('系统启动成功', 'success');
applySystemState({ started: true, starting: false });
configModalLocked = false;
setTimeout(() => {
closeConfigModal();
}, 800);
setTimeout(() => {
checkStatus();
}, 1000);
setTimeout(() => {
window.location.reload();
}, 1200);
} catch (error) {
setConfigStatus(`系统启动失败: ${error.message}`, 'error');
showMessage(`系统启动失败: ${error.message}`, 'error');
applySystemState({ started: false, starting: false });
} finally {
systemStarting = false;
updateStartButtonState();
await fetchSystemStatus();
}
}
// 执行搜索
function performSearch() {
const query = document.getElementById('searchInput').value.trim();
if (!query) {
showMessage('请输入搜索内容', 'error');
return;
}
const button = document.getElementById('searchButton');
button.disabled = true;
button.innerHTML = '<span class="loading"></span> 搜索中...';
// 清除现有报告,重置自动生成标志,为新搜索做准备
const reportPreview = document.getElementById('reportPreview');
if (reportPreview) {
reportPreview.innerHTML = '<div class="report-loading">等待新的搜索结果生成报告...</div>';
}
// 清除任务进度显示
const taskProgressArea = document.getElementById('taskProgressArea');
if (taskProgressArea) {
taskProgressArea.innerHTML = '';
}
// 重置自动生成相关标志
autoGenerateTriggered = false;
reportTaskId = null;
// 停止可能正在进行的轮询
if (reportPollingInterval) {
clearInterval(reportPollingInterval);
reportPollingInterval = null;
}
// 向所有运行中的应用发送搜索请求(通过刷新iframe传递参数)
let totalRunning = 0;
const ports = { insight: 8501, media: 8502, query: 8503 };
Object.keys(appStatus).forEach(app => {
if (appStatus[app] === 'running' && ports[app]) {
totalRunning++;
// 懒加载iframe(如果还没有加载)
let iframe = preloadedIframes[app];
if (!iframe) {
iframe = lazyLoadIframe(app);
}
if (iframe) {
// 构建搜索URL
const searchUrl = `http://${window.location.hostname}:${ports[app]}?query=${encodeURIComponent(query)}&auto_search=true`;
console.log(`${app} 发送搜索请求: ${searchUrl}`);
// 直接更新iframe的src来传递搜索参数
iframe.src = searchUrl;
}
}
});
if (totalRunning === 0) {
button.disabled = false;
button.innerHTML = '搜索';
showMessage('没有运行中的应用,无法执行搜索', 'error');
} else {
button.disabled = false;
button.innerHTML = '搜索';
showMessage(`搜索请求已发送到 ${totalRunning} 个应用,页面将刷新以开始研究`, 'success');
}
}
// 切换应用
function switchToApp(app) {
if (app === currentApp) return;
// 检查Report Engine是否被锁定
if (app === 'report') {
const reportButton = document.querySelector(`[data-app="report"]`);
if (reportButton.classList.contains('locked')) {
showMessage('需等待其余三个Agent工作完毕', 'error');
return;
}
}
// 隐藏当前Engine的进度条
const engines = ['insight', 'media', 'query'];
if (engines.includes(currentApp)) {
hideEngineProgress(currentApp);
}
// 更新按钮状态
document.querySelectorAll('.app-button').forEach(btn => {
btn.classList.remove('active');
});
document.querySelector(`[data-app="${app}"]`).classList.add('active');
currentApp = app;
setActiveConsoleLayer(app);
// 根据应用类型处理不同的显示逻辑
if (app === 'forum') {
// 切换到论坛模式
document.getElementById('embeddedHeader').textContent = 'Forum Engine - 多智能体交流';
// 显示论坛容器,隐藏其他内容
document.getElementById('forumContainer').classList.add('active');
document.getElementById('reportContainer').classList.remove('active');
// 追加提示并加载forum日志
appendConsoleTextLine('forum', '[系统] 切换到论坛模式');
loadForumLog();
} else if (app === 'report') {
// 切换到报告模式
document.getElementById('embeddedHeader').textContent = 'Report Agent - 最终报告生成';
// 显示报告容器,隐藏其他内容
document.getElementById('reportContainer').classList.add('active');
document.getElementById('forumContainer').classList.remove('active');
// 追加提示并加载report日志
appendConsoleTextLine('report', '[系统] 切换到报告生成模式');
loadReportLog();
// 只在报告界面未初始化时才重新加载
const reportContent = document.getElementById('reportContent');
if (!reportContent || reportContent.children.length === 0) {
loadReportInterface();
}
// 切换到report页面时检查是否可以自动生成报告
setTimeout(() => {
checkReportLockStatus();
}, 500);
} else {
// 切换到普通Engine模式
document.getElementById('embeddedHeader').textContent = agentTitles[app] || appNames[app];
// 隐藏论坛和报告容器
document.getElementById('forumContainer').classList.remove('active');
document.getElementById('reportContainer').classList.remove('active');
// 追加提示并加载新的控制台输出
appendConsoleTextLine(app, '[系统] 切换到 ' + appNames[app]);
loadConsoleOutput(app);
// 显示该Engine的进度条(如果有)
if (engines.includes(app)) {
showEngineProgress(app);
// 立即更新一次进度,确保显示最新状态
updateEngineProgress(app);
}
}
// 更新嵌入页面
updateEmbeddedPage(app);
}
// 存储最后显示的行数,避免重复加载
let lastLineCount = {};
function getConsoleContainer() {
return document.getElementById('consoleOutput');
}
function initializeConsoleLayers() {
const container = getConsoleContainer();
if (!container) return;
container.innerHTML = '';
consoleLayerApps.forEach(app => {
const layer = document.createElement('div');
layer.className = 'console-layer';
layer.dataset.app = app;
if (app === currentApp) {
layer.classList.add('active');
layer.style.display = 'block';
activeConsoleLayer = app;
} else {
layer.style.display = 'none';
}
logRenderers[app] = new LogVirtualList(layer);
container.appendChild(layer);
consoleLayers[app] = layer;
// 初始提示仅在渲染器内部渲染,不保留在 DOM
logRenderers[app].clear(`[系统] ${appNames[app] || app} 日志就绪`);
});
container.scrollTop = container.scrollHeight;
}
function getConsoleLayer(app) {
if (consoleLayers[app]) {
return consoleLayers[app];
}
const container = getConsoleContainer();
if (!container) return null;
const layer = document.createElement('div');
layer.className = 'console-layer';
layer.dataset.app = app;
layer.style.display = app === currentApp ? 'block' : 'none';
if (app === currentApp) {
layer.classList.add('active');
activeConsoleLayer = app;
}
container.appendChild(layer);
consoleLayers[app] = layer;
logRenderers[app] = new LogVirtualList(layer);
return layer;
}
function setActiveConsoleLayer(app) {
const container = getConsoleContainer();
if (!container) return;
if (activeConsoleLayer && consoleLayers[activeConsoleLayer]) {
consoleLayerScrollPositions[activeConsoleLayer] = container.scrollTop;
consoleLayers[activeConsoleLayer].classList.remove('active');
consoleLayers[activeConsoleLayer].style.display = 'none';
}
const targetLayer = getConsoleLayer(app);
if (!targetLayer) return;
targetLayer.style.display = 'block';
targetLayer.classList.add('active');
activeConsoleLayer = app;
const storedScroll = consoleLayerScrollPositions[app];
if (typeof storedScroll === 'number') {
container.scrollTop = storedScroll;
} else {
container.scrollTop = container.scrollHeight;
}
}
function syncConsoleScroll(app) {
// 这个函数已经不需要了,因为 LogVirtualList 内部已经处理了滚动
// 保留函数签名以避免破坏现有调用,但不执行任何操作
return;
}
function appendConsoleTextLine(app, text, className = 'console-line') {
const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app)));
renderer.append(text, className);
}
function appendConsoleElement(app, element) {
const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app)));
if (!element || !renderer.container) return;
// 将元素转换为文本行,统一使用 LogVirtualList 的渲染逻辑
const text = element.textContent || element.innerText || '';
const className = element.className || 'console-line';
renderer.append(text, className);
}
function clearConsoleLayer(app, message = null) {
const renderer = logRenderers[app] || (logRenderers[app] = new LogVirtualList(getConsoleLayer(app)));
renderer.clear(message);
}
// 加载控制台输出
function loadConsoleOutput(app) {
if (app === 'forum') {
loadForumLog();
return;
}
if (app === 'report') {
loadReportLog();
return;
}
fetch(`/api/output/${app}`)
.then(response => response.json())
.then(data => {
if (data.success && data.output.length > 0) {
const lastCount = lastLineCount[app] || 0;
const newLines = data.output.slice(lastCount);
if (newLines.length > 0) {
newLines.forEach(line => {
appendConsoleTextLine(app, line);
});
lastLineCount[app] = data.output.length;
}
}
})
.catch(error => {
console.error('加载输出失败:', error);
});
}
// 刷新当前应用的控制台输出
function refreshConsoleOutput() {
if (currentApp === 'forum') {
refreshForumLog();
return;
}
if (currentApp === 'report') {
refreshReportLog();
return;
}
if (appStatus[currentApp] === 'running' || appStatus[currentApp] === 'starting') {
fetch(`/api/output/${currentApp}`)
.then(response => response.json())
.then(data => {
if (data.success && data.output.length > 0) {
// 只添加新的行
const lastCount = lastLineCount[currentApp] || 0;
const newLines = data.output.slice(lastCount);
if (newLines.length > 0) {
newLines.forEach(line => {
appendConsoleTextLine(currentApp, line);
});
lastLineCount[currentApp] = data.output.length;
}
}
})
.catch(error => {
console.error('刷新输出失败:', error);
});
}
}
// 添加控制台输出
function addConsoleOutput(line, app = currentApp) {
const targetApp = app || currentApp;
appendConsoleTextLine(targetApp, line);
if (targetApp !== 'report') {
lastLineCount[targetApp] = (lastLineCount[targetApp] || 0) + 1;
}
}
// 预加载的iframe存储
let preloadedIframes = {};
let iframesInitialized = false;
let currentVisibleIframe = null; // 跟踪当前可见的iframe
// 懒加载iframe - 只在真正需要时才创建
function lazyLoadIframe(app) {
// 如果iframe已存在,直接返回
if (preloadedIframes[app]) {
return preloadedIframes[app];
}
const ports = { insight: 8501, media: 8502, query: 8503 };
if (!ports[app]) {
console.warn(`未知的应用: ${app}`);
return null;
}
const content = document.getElementById('embeddedContent');
const iframe = document.createElement('iframe');
iframe.src = `http://${window.location.hostname}:${ports[app]}`;
iframe.style.width = '100%';
iframe.style.height = '100%';
iframe.style.border = 'none';
iframe.style.position = 'absolute';
iframe.style.top = '0';
iframe.style.left = '0';
iframe.style.display = 'none';
iframe.id = `iframe-${app}`;
// 添加加载完成事件
iframe.addEventListener('load', () => {
console.log(`${app} iframe 加载完成`);
});
content.appendChild(iframe);
preloadedIframes[app] = iframe;
console.log(`懒加载 ${app} iframe`);
return iframe;
}
// 卸载不需要的iframe以释放内存
function unloadIframe(app) {
if (!preloadedIframes[app]) return;
const iframe = preloadedIframes[app];
// 先隐藏iframe
iframe.style.display = 'none';
// 清空iframe内容以释放内存
if (iframe.contentWindow) {
try {
// 尝试清空iframe的DOM
iframe.src = 'about:blank';
} catch (e) {
console.warn(`无法清空 ${app} iframe:`, e);
}
}
// 从DOM中移除iframe
if (iframe.parentNode) {
iframe.parentNode.removeChild(iframe);
}
// 从缓存中删除
delete preloadedIframes[app];
console.log(`卸载 ${app} iframe,释放内存`);
}
// 卸载所有非当前应用的iframe
function unloadInactiveIframes(currentApp) {
const apps = ['insight', 'media', 'query'];
apps.forEach(app => {
if (app !== currentApp && preloadedIframes[app]) {
// 延迟卸载,给一些缓冲时间
setTimeout(() => {
if (currentApp !== app) { // 再次确认没有切换回来
unloadIframe(app);
}
}, 30000); // 30秒后卸载不活跃的iframe
}
});
}
// 预加载所有iframe(只执行一次)- 已废弃,改用懒加载
function preloadIframes() {
// 不再预加载所有iframe,改用懒加载机制
console.log('使用懒加载机制,不再预加载所有iframe');
iframesInitialized = true;
}
// 更新嵌入页面
function updateEmbeddedPage(app) {
const header = document.getElementById('embeddedHeader');
const content = document.getElementById('embeddedContent');
// 如果是Forum Engine,直接显示论坛界面
if (app === 'forum') {
header.textContent = 'Forum Engine - 多智能体交流';
// 隐藏所有iframe
Object.values(preloadedIframes).forEach(iframe => {
iframe.style.display = 'none';
});
// 移除占位符
const placeholder = content.querySelector('.status-placeholder');
if (placeholder) {
placeholder.remove();
}
// 显示论坛容器,隐藏报告容器
document.getElementById('forumContainer').classList.add('active');
document.getElementById('reportContainer').classList.remove('active');
// 卸载不活跃的iframe
unloadInactiveIframes(null);
currentVisibleIframe = null;
return;
}
// 如果是Report Engine,显示报告界面
if (app === 'report') {
header.textContent = 'Report Agent - 最终报告生成';
// 隐藏所有iframe
Object.values(preloadedIframes).forEach(iframe => {
iframe.style.display = 'none';
});
// 移除占位符
const placeholder = content.querySelector('.status-placeholder');
if (placeholder) {
placeholder.remove();
}
// 显示报告容器,隐藏论坛容器
document.getElementById('reportContainer').classList.add('active');
document.getElementById('forumContainer').classList.remove('active');
// 卸载不活跃的iframe
unloadInactiveIframes(null);
currentVisibleIframe = null;
return;
}
// 隐藏论坛和报告容器
document.getElementById('forumContainer').classList.remove('active');
document.getElementById('reportContainer').classList.remove('active');
header.textContent = agentTitles[app] || appNames[app] || app;
// 如果应用正在运行,显示对应的iframe(使用懒加载)
if (appStatus[app] === 'running') {
// 懒加载当前应用的iframe
const iframe = lazyLoadIframe(app);
if (!iframe) {
console.error(`无法加载 ${app} iframe`);
return;
}
// 隐藏所有iframe
Object.values(preloadedIframes).forEach(otherIframe => {
otherIframe.style.display = 'none';
});
// 移除占位符
const placeholder = content.querySelector('.status-placeholder');
if (placeholder) {
placeholder.remove();
}
// 显示当前应用的iframe
iframe.style.display = 'block';
currentVisibleIframe = app;
console.log(`切换到 ${app} 应用 - 懒加载模式`);
// 卸载不活跃的iframe30秒后)
unloadInactiveIframes(app);
} else {
// 隐藏所有iframe
Object.values(preloadedIframes).forEach(iframe => {
iframe.style.display = 'none';
});
// 显示状态信息
let placeholder = content.querySelector('.status-placeholder');
if (!placeholder) {
placeholder = document.createElement('div');
placeholder.className = 'status-placeholder';
placeholder.style.cssText = 'display: flex; align-items: center; justify-content: center; height: 100%; color: #666; flex-direction: column; position: absolute; top: 0; left: 0; width: 100%;';
content.appendChild(placeholder);
}
placeholder.innerHTML = `
<div style="margin-bottom: 10px;">${appNames[app]} 未运行</div>
<div style="font-size: 12px;">状态: ${appStatus[app]}</div>
`;
currentVisibleIframe = null;
}
}
// 检查应用状态
function checkStatus() {
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();
});
}
// 更新应用状态
function updateAppStatus(data) {
for (const [app, info] of Object.entries(data)) {
// 适配实际的API格式:{app: {status: string, port: int, output_lines: int}}
const status = info.status === 'running' ? 'running' : 'stopped';
appStatus[app] = status;
const indicator = document.getElementById(`status-${app}`);
if (indicator) {
indicator.className = `status-indicator ${status}`;
}
}
// 如果当前显示的应用状态发生变化,更新嵌入页面
updateEmbeddedPage(currentApp);
}
// 根据当前的Socket/SSE状态刷新底部连接指示
function refreshConnectionStatus() {
const statusEl = document.getElementById('connectionStatus');
if (!statusEl) return;
if (socketConnected || reportStreamConnected || backendReachable) {
statusEl.textContent = '已连接';
} else {
statusEl.textContent = '连接断开';
}
}
// 更新时间
function updateTime() {
const now = new Date();
const timeString = now.toLocaleTimeString('zh-CN');
document.getElementById('systemTime').textContent = timeString;
}
// 显示消息
function showMessage(text, type = 'info') {
const message = document.getElementById('message');
// 清除之前的定时器
if (message.hideTimer) {
clearTimeout(message.hideTimer);
}
message.textContent = text;
message.className = `message ${type}`;
message.classList.add('show');
message.hideTimer = setTimeout(() => {
message.classList.remove('show');
// 延迟清除内容,等待动画完成
setTimeout(() => {
message.textContent = '';
message.className = 'message';
}, 300);
}, 3000);
}
// 处理模板文件上传
function handleTemplateUpload(event) {
const file = event.target.files[0];
const statusDiv = document.getElementById('uploadStatus');
if (!file) {
statusDiv.textContent = '';
statusDiv.className = 'upload-status';
customTemplate = '';
return;
}
// 检查文件类型
const allowedTypes = ['text/markdown', 'text/plain', '.md', '.txt'];
const fileName = file.name.toLowerCase();
const isValidType = fileName.endsWith('.md') || fileName.endsWith('.txt') ||
allowedTypes.includes(file.type);
if (!isValidType) {
statusDiv.textContent = '错误: 请选择 .md 或 .txt 文件';
statusDiv.className = 'upload-status error';
customTemplate = '';
event.target.value = ''; // 清空文件输入
return;
}
// 检查文件大小 (最大 1MB)
const maxSize = 1024 * 1024; // 1MB
if (file.size > maxSize) {
statusDiv.textContent = '错误: 文件大小不能超过 1MB';
statusDiv.className = 'upload-status error';
customTemplate = '';
event.target.value = '';
return;
}
statusDiv.textContent = '正在读取文件...';
statusDiv.className = 'upload-status';
// 读取文件内容
const reader = new FileReader();
reader.onload = function(e) {
try {
customTemplate = e.target.result;
statusDiv.textContent = `成功: 已加载自定义模板 "${file.name}" (${(file.size/1024).toFixed(1)}KB)`;
statusDiv.className = 'upload-status success';
showMessage(`自定义模板已加载: ${file.name}`, 'success');
} catch (error) {
statusDiv.textContent = '错误: 文件读取失败';
statusDiv.className = 'upload-status error';
customTemplate = '';
event.target.value = '';
}
};
reader.onerror = function() {
statusDiv.textContent = '错误: 文件读取失败';
statusDiv.className = 'upload-status error';
customTemplate = '';
event.target.value = '';
};
reader.readAsText(file, 'utf-8');
}
// Forum Engine 相关函数
let forumLogLineCount = 0;
// 更新所有Engine的进度条
function updateAllEngineProgress() {
// 通过现有的status API获取所有Engine的状态
fetch('/api/status')
.then(response => response.json())
.then(data => {
// 为每个需要进度显示的Engine更新状态
const engines = ['insight', 'media', 'query'];
engines.forEach(engine => {
if (data[engine]) {
const info = data[engine];
const status = info.status === 'running' ? 'running' : 'stopped';
// 如果Engine正在运行,显示进度条
if (status === 'running') {
// 尝试从API获取详细进度,如果失败则显示基本运行状态
updateEngineProgress(engine);
} else {
// Engine未运行,清除进度信息
engineProgress[engine] = null;
const progressContainer = document.getElementById(`progress-${engine}`);
if (progressContainer && progressContainer.parentNode) {
progressContainer.parentNode.removeChild(progressContainer);
}
}
}
});
})
.catch(error => {
console.log('获取Engine状态失败:', error);
});
}
// 更新单个Engine的进度
function updateEngineProgress(engine) {
// 先尝试从专用进度API获取
fetch(`/api/${engine}/progress`)
.then(response => {
if (!response.ok) {
throw new Error('Progress API not available');
}
return response.json();
})
.then(data => {
if (data.success && data.progress) {
// 存储进度信息
engineProgress[engine] = {
status: data.progress.status || 'running',
progress: data.progress.progress || 0,
message: data.progress.message || '正在处理...',
updated_at: new Date().toISOString()
};
// 如果当前正在查看该Engine,更新显示
if (currentApp === engine) {
displayEngineProgress(engine);
}
}
})
.catch(error => {
// 如果专用API不可用,使用基本的运行状态
if (appStatus[engine] === 'running') {
// 使用基本的进度信息
if (!engineProgress[engine]) {
engineProgress[engine] = {
status: 'running',
progress: 50, // 默认显示50%表示运行中
message: '正在分析中...',
updated_at: new Date().toISOString()
};
}
// 如果当前正在查看该Engine,更新显示
if (currentApp === engine) {
displayEngineProgress(engine);
}
}
});
}
// 在嵌入页面区域显示Engine进度
function displayEngineProgress(engine) {
const progress = engineProgress[engine];
if (!progress) return;
// 查找或创建进度显示容器
let progressContainer = document.getElementById(`progress-${engine}`);
if (!progressContainer) {
// 在嵌入内容区域的顶部创建进度条容器
const embeddedContent = document.getElementById('embeddedContent');
if (!embeddedContent) return;
progressContainer = document.createElement('div');
progressContainer.id = `progress-${engine}`;
progressContainer.className = 'task-progress-container';
progressContainer.style.position = 'absolute';
progressContainer.style.top = '10px';
progressContainer.style.left = '10px';
progressContainer.style.right = '10px';
progressContainer.style.zIndex = '100';
progressContainer.style.backgroundColor = '#f5f5f0';
embeddedContent.insertBefore(progressContainer, embeddedContent.firstChild);
}
// 更新进度条内容
const loadingIndicator = progress.status !== 'completed' && progress.status !== 'error'
? '<span class="report-loading-spinner"></span>'
: '';
progressContainer.innerHTML = `
<div class="task-progress-header">
<div class="task-progress-title">
${loadingIndicator}${appNames[engine] || engine} - ${progress.message}
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${Math.min(Math.max(progress.progress || 0, 0), 100)}%"></div>
<div class="task-progress-text">${progress.progress || 0}%</div>
</div>
</div>
`;
// 如果任务已完成,5秒后淡出进度条
if (progress.status === 'completed') {
setTimeout(() => {
if (progressContainer && progressContainer.parentNode) {
progressContainer.style.transition = 'opacity 1s';
progressContainer.style.opacity = '0';
setTimeout(() => {
if (progressContainer && progressContainer.parentNode) {
progressContainer.parentNode.removeChild(progressContainer);
}
}, 1000);
}
}, 5000);
}
}
// 隐藏指定Engine的进度条(切换时使用)
function hideEngineProgress(engine) {
const progressContainer = document.getElementById(`progress-${engine}`);
if (progressContainer) {
progressContainer.style.display = 'none';
}
}
// 显示指定Engine的进度条(切换时使用)
function showEngineProgress(engine) {
const progressContainer = document.getElementById(`progress-${engine}`);
if (progressContainer) {
progressContainer.style.display = 'block';
} else if (engineProgress[engine]) {
// 如果有缓存的进度信息但容器不存在,重新创建
displayEngineProgress(engine);
}
}
// Report Engine 相关函数
let reportLogLineCount = 0;
let reportLockCheckInterval = null;
let lastCompletedReportTask = null;
// 实时刷新论坛消息(适用于所有页面)
function refreshForumMessages() {
fetch('/api/forum/log')
.then(response => response.json())
.then(data => {
if (!data.success) return;
const logLines = data.log_lines || [];
const parsedMessages = data.parsed_messages || [];
if (logLines.length > forumLogLineCount) {
const newLines = logLines.slice(forumLogLineCount);
newLines.forEach(line => {
appendConsoleTextLine('forum', line);
});
}
if (parsedMessages.length > 0) {
const chatArea = document.getElementById('forumChatArea');
if (chatArea) {
chatArea.innerHTML = '';
parsedMessages.forEach(message => {
addForumMessage(message);
});
}
}
forumLogLineCount = logLines.length;
})
.catch(error => {
console.error('刷新论坛消息失败:', error);
});
}
// 初始化论坛功能
function initializeForum() {
// 初始化时加载一次论坛日志
refreshForumMessages();
}
// 加载论坛日志
function loadForumLog() {
fetch('/api/forum/log')
.then(response => response.json())
.then(data => {
if (!data.success) return;
const chatArea = document.getElementById('forumChatArea');
if (chatArea) {
chatArea.innerHTML = '';
}
const logLines = data.log_lines || [];
const parsedMessages = data.parsed_messages || [];
if (logLines.length > 0) {
clearConsoleLayer('forum', '[系统] Forum Engine 日志输出');
logLines.forEach(line => appendConsoleTextLine('forum', line));
} else {
forumLogLineCount = 0;
}
if (parsedMessages.length > 0) {
parsedMessages.forEach(message => addForumMessage(message));
}
forumLogLineCount = logLines.length;
})
.catch(error => {
console.error('加载论坛日志失败:', error);
});
}
// 刷新论坛日志
function refreshForumLog() {
fetch('/api/forum/log')
.then(response => response.json())
.then(data => {
if (!data.success) return;
const logLines = data.log_lines || [];
const parsedMessages = data.parsed_messages || [];
if (logLines.length > forumLogLineCount) {
const newLines = logLines.slice(forumLogLineCount);
newLines.forEach(line => appendConsoleTextLine('forum', line));
}
if (parsedMessages.length && parsedMessages.length !== getForumMessageCount()) {
const chatArea = document.getElementById('forumChatArea');
if (chatArea) {
chatArea.innerHTML = '';
parsedMessages.forEach(message => addForumMessage(message));
}
}
forumLogLineCount = logLines.length;
})
.catch(error => {
console.error('刷新论坛日志失败:', error);
});
}
function getForumMessageCount() {
const chatArea = document.getElementById('forumChatArea');
if (!chatArea) return 0;
return chatArea.querySelectorAll('.forum-message').length;
}
// 刷新Report Engine日志
// 检查Report Engine锁定状态并自动生成报告
let autoGenerateTriggered = false; // 防止重复触发
function checkReportLockStatus() {
fetch('/api/report/status')
.then(response => response.json())
.then(data => {
const reportButton = document.querySelector('[data-app="report"]');
if (data.success && data.engines_ready) {
// 文件准备就绪,解锁按钮
reportButton.classList.remove('locked');
reportButton.title = 'Report Engine - 智能报告生成\n所有引擎都有新文件,可以生成报告';
// 检查是否已经有报告在显示
const reportPreview = document.getElementById('reportPreview');
const hasReport = reportPreview && reportPreview.querySelector('iframe');
// 如果当前在report页面且还没有触发自动生成且没有正在进行的任务且没有已显示的报告,则自动生成报告
if (currentApp === 'report' && !autoGenerateTriggered && !reportTaskId && !hasReport) {
autoGenerateTriggered = true;
console.log('检测到锁消失且无现有报告,自动开始生成报告');
setTimeout(() => {
generateReport();
}, 1000); // 延迟1秒开始生成
}
} else {
// 文件未准备就绪,锁定按钮
reportButton.classList.add('locked');
// 构建详细的提示信息
let titleInfo = '\n';
if (data.missing_files && data.missing_files.length > 0) {
titleInfo += '等待新文件:\n' + data.missing_files.join('\n');
} else {
titleInfo += '等待三个Agent工作完毕';
}
reportButton.title = titleInfo;
}
})
.catch(error => {
console.error('检查Report Engine状态失败:', error);
// 出错时默认锁定
const reportButton = document.querySelector('[data-app="report"]');
reportButton.classList.add('locked');
reportButton.title = 'Report Engine状态检查失败';
});
}
function refreshReportLog() {
fetch('/api/report/log')
.then(response => response.json())
.then(data => {
if (data.success && data.log_lines.length > reportLogLineCount) {
// 只添加新的行
const newLines = data.log_lines.slice(reportLogLineCount);
newLines.forEach(line => {
appendConsoleTextLine('report', line);
});
reportLogLineCount = data.log_lines.length;
}
})
.catch(error => {
console.error('刷新Report日志失败:', error);
});
}
// 加载Report Engine日志
function loadReportLog() {
fetch('/api/report/log')
.then(response => response.json())
.then(data => {
if (data.success) {
if (reportLogLineCount === 0) {
clearConsoleLayer('report', '[系统] Report Engine 日志监控已启动');
}
if (data.log_lines && data.log_lines.length > 0) {
const newLines = data.log_lines.slice(reportLogLineCount);
const linesToProcess = reportLogLineCount === 0 ? data.log_lines : newLines;
linesToProcess.forEach(line => {
appendConsoleTextLine('report', line);
});
// 重置计数器以确保后续消息能正确显示
reportLogLineCount = data.log_lines.length;
} else {
// 如果没有日志,重置计数器
reportLogLineCount = 0;
}
}
})
.catch(error => {
console.error('加载Report日志失败:', error);
});
}
// 解析论坛消息并添加到对话区
function parseForumMessage(logLine) {
try {
// 解析日志行格式: [HH:MM:SS] [SOURCE] content
const timeMatch = logLine.match(/^\[(\d{2}:\d{2}:\d{2})\]/);
if (!timeMatch) return null;
const timestamp = timeMatch[1];
const restContent = logLine.substring(timeMatch[0].length).trim();
// 解析源标签
const sourceMatch = restContent.match(/^\[([^\]]+)\]\s*(.*)$/);
if (!sourceMatch) return null;
const source = sourceMatch[1];
const content = sourceMatch[2];
// 处理四种消息类型:三个Engine和HOST,过滤掉系统消息和空内容
if (!['QUERY', 'INSIGHT', 'MEDIA', 'HOST'].includes(source.toUpperCase()) ||
!content || content.includes('=== ForumEngine')) {
return null;
}
// 根据源类型确定消息类型
let messageType = 'agent';
let displayName = '';
switch(source.toUpperCase()) {
case 'INSIGHT':
displayName = 'Insight Engine';
break;
case 'MEDIA':
displayName = 'Media Engine';
break;
case 'QUERY':
displayName = 'Query Engine';
break;
case 'HOST':
messageType = 'host';
displayName = 'Forum Host';
break;
}
// 处理内容中的转义字符
const displayContent = content.replace(/\\n/g, '\n').replace(/\\r/g, '');
// 返回解析后的消息对象
return {
type: messageType,
source: displayName,
content: displayContent,
timestamp: timestamp
};
} catch (error) {
console.error('解析论坛消息失败:', error);
return null;
}
}
// 添加论坛消息到对话区
function addForumMessage(data) {
const chatArea = document.getElementById('forumChatArea');
const messageDiv = document.createElement('div');
const messageType = data.type || 'system';
messageDiv.className = `forum-message ${messageType}`;
// 根据来源添加特定的CSS类用于颜色区分
if (data.source) {
const sourceClass = data.source.toLowerCase().replace(/\s+/g, '-');
messageDiv.classList.add(sourceClass);
// 添加具体的engine类
if (data.source.toLowerCase().includes('query')) {
messageDiv.classList.add('query-engine');
} else if (data.source.toLowerCase().includes('insight')) {
messageDiv.classList.add('insight-engine');
} else if (data.source.toLowerCase().includes('media')) {
messageDiv.classList.add('media-engine');
} else if (data.source.toLowerCase().includes('host')) {
messageDiv.classList.add('host');
}
}
// 构建消息头部,显示来源名称
const headerText = data.sender || data.source || getMessageHeader(messageType);
messageDiv.innerHTML = `
<div class="forum-message-header">${headerText}</div>
<div class="forum-message-content">${formatMessageContent(data.content)}</div>
<div class="forum-timestamp">${data.timestamp || new Date().toLocaleTimeString('zh-CN')}</div>
`;
chatArea.appendChild(messageDiv);
// 自动滚动到底部
chatArea.scrollTop = chatArea.scrollHeight;
}
// 格式化消息内容
function formatMessageContent(content) {
if (!content) return '';
// 将换行符转换为HTML换行
return content.replace(/\n/g, '<br>');
}
// 获取消息头部
function getMessageHeader(type) {
switch(type) {
case 'user': return '用户';
case 'agent': return 'AI助手';
case 'system': return '系统';
case 'host': return '论坛主持人';
default: return '未知';
}
}
// 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() {
const reportContent = document.getElementById('reportContent');
// 检查ReportEngine状态
fetch('/api/report/status')
.then(response => response.json())
.then(data => {
if (data.success) {
// 更新ReportEngine状态指示器
const indicator = document.getElementById('status-report');
if (indicator) {
if (data.initialized) {
indicator.className = 'status-indicator running';
appStatus.report = 'running';
} else {
indicator.className = 'status-indicator';
appStatus.report = 'stopped';
}
}
// 渲染报告界面
renderReportInterface(data);
} else {
reportContent.innerHTML = `
<div class="report-status error">
<strong>错误:</strong> ${data.error}
</div>
`;
}
})
.catch(error => {
console.error('加载报告界面失败:', error);
reportContent.innerHTML = `
<div class="report-status error">
<strong>加载失败:</strong> ${error.message}
</div>
`;
});
}
// 渲染报告界面
function renderReportInterface(statusData) {
const reportContent = document.getElementById('reportContent');
let interfaceHTML = `
<!-- 固定的状态信息块 -->
<div class="engine-status-info" id="engineStatusBlock">
<div class="report-status" id="engineStatusContent">
<div>正在初始化...</div>
</div>
</div>
<!-- 控制按钮区域 -->
<div class="report-controls">
<button class="report-button primary" id="generateReportButton">生成最终报告</button>
<button class="report-button" id="downloadReportButton" disabled>下载HTML</button>
<button class="report-button" id="downloadPdfButton" disabled>下载PDF</button>
</div>
<!-- 任务进度区域 -->
<div id="taskProgressArea"></div>
<!-- 报告预览区域 -->
<div class="report-preview" id="reportPreview">
<div class="report-loading">
点击"生成最终报告"开始生成综合分析报告
</div>
</div>
`;
reportContent.innerHTML = interfaceHTML;
initializeReportControls();
resetReportStreamOutput('等待新的Report任务启动...');
updateReportStreamStatus('idle');
// 立即更新状态信息
updateEngineStatusDisplay(statusData);
// 如果有当前任务,显示任务状态
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;
}
}
function initializeReportControls() {
const generateButton = document.getElementById('generateReportButton');
if (generateButton && !generateButton.dataset.bound) {
generateButton.dataset.bound = 'true';
generateButton.addEventListener('click', () => {
if (reportTaskId) {
showMessage('已有报告生成任务在运行', 'info');
return;
}
const reportButton = document.querySelector('[data-app="report"]');
if (reportButton && reportButton.classList.contains('locked')) {
showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error');
return;
}
generateReport();
});
}
const downloadButton = document.getElementById('downloadReportButton');
const downloadPdfButton = document.getElementById('downloadPdfButton');
if (downloadButton && !downloadButton.dataset.bound) {
downloadButton.dataset.bound = 'true';
downloadButton.addEventListener('click', () => downloadReport());
}
if (downloadPdfButton && !downloadPdfButton.dataset.bound) {
downloadPdfButton.dataset.bound = 'true';
downloadPdfButton.addEventListener('click', () => downloadPdfFromPreview());
}
if (reportTaskId) {
setGenerateButtonState(true);
} else {
setGenerateButtonState(false);
}
if (lastCompletedReportTask) {
updateDownloadButtonState(lastCompletedReportTask);
}
}
function setGenerateButtonState(forceLoading = false) {
const generateButton = document.getElementById('generateReportButton');
if (!generateButton) return;
if (forceLoading || reportTaskId) {
if (!generateButton.dataset.originalText) {
generateButton.dataset.originalText = generateButton.textContent || '生成最终报告';
}
generateButton.disabled = true;
generateButton.textContent = '生成中...';
} else {
const originalText = generateButton.dataset.originalText || '生成最终报告';
generateButton.disabled = false;
generateButton.textContent = originalText;
}
}
function updateDownloadButtonState(task) {
const downloadButton = document.getElementById('downloadReportButton');
const downloadPdfButton = document.getElementById('downloadPdfButton');
if (!downloadButton || !downloadPdfButton) return;
if (task && task.status === 'completed' && (task.report_file_ready || task.report_file_path)) {
downloadButton.disabled = false;
downloadButton.dataset.taskId = task.task_id;
downloadButton.dataset.filename = task.report_file_name || '';
const label = task.report_file_name ? `下载HTML (${task.report_file_name})` : '下载HTML';
downloadButton.textContent = label;
downloadPdfButton.disabled = false;
downloadPdfButton.dataset.taskId = task.task_id;
lastCompletedReportTask = task;
} else if (!lastCompletedReportTask || (task && task.status !== 'completed')) {
downloadButton.disabled = true;
downloadButton.dataset.taskId = '';
downloadButton.dataset.filename = '';
downloadButton.textContent = '下载HTML';
downloadPdfButton.disabled = true;
downloadPdfButton.dataset.taskId = '';
if (!reportTaskId) {
lastCompletedReportTask = null;
}
}
}
function downloadReport(taskId = null) {
const downloadButton = document.getElementById('downloadReportButton');
const targetTaskId = taskId || (downloadButton ? downloadButton.dataset.taskId : '');
if (!targetTaskId) {
showMessage('暂无可下载的报告,请先生成最终报告', 'error');
return;
}
let preferredFileName = '';
if (downloadButton && downloadButton.dataset.filename) {
preferredFileName = downloadButton.dataset.filename;
} else if (lastCompletedReportTask && lastCompletedReportTask.task_id === targetTaskId) {
preferredFileName = lastCompletedReportTask.report_file_name || '';
}
fetch(`/api/report/download/${targetTaskId}`)
.then(response => {
if (!response.ok) {
const contentType = response.headers.get('Content-Type') || '';
if (contentType.includes('application/json')) {
return response.json().then(err => {
throw new Error(err.error || '下载失败');
});
}
throw new Error('下载失败');
}
const disposition = response.headers.get('Content-Disposition') || '';
return response.blob().then(blob => ({ blob, disposition }));
})
.then(({ blob, disposition }) => {
let filename = preferredFileName;
if (!filename) {
const match = disposition.match(/filename="?([^";]+)"?/i);
filename = match ? match[1] : `final_report_${targetTaskId}.html`;
}
const url = window.URL.createObjectURL(blob);
const link = document.createElement('a');
link.href = url;
link.download = filename || 'final_report.html';
document.body.appendChild(link);
link.click();
document.body.removeChild(link);
window.URL.revokeObjectURL(url);
showMessage('报告文件已开始下载', 'success');
})
.catch(error => {
console.error('下载报告失败:', error);
showMessage('下载报告失败: ' + error.message, 'error');
});
}
async function downloadPdfFromPreview() {
const iframe = document.getElementById('report-iframe');
const btn = document.getElementById('downloadPdfButton');
if (!iframe || !iframe.contentDocument) {
showMessage('请先加载报告预览再下载PDF', 'error');
return;
}
const target = iframe.contentDocument.documentElement;
if (!target) {
showMessage('报告内容未就绪', 'error');
return;
}
if (btn) btn.disabled = true;
showMessage('正在生成PDF,请稍候...', 'info');
try {
const { jsPDF } = window.jspdf || {};
if (!jsPDF) {
throw new Error('PDF依赖未加载');
}
const pdf = new jsPDF('p', 'mm', 'a4');
// 添加中文字体支持
try {
const fontData = iframe.contentWindow.pdfFontData || window.pdfFontData;
if (fontData) {
pdf.addFileToVFS('SourceHanSerifSC-Medium.otf', fontData);
pdf.addFont('SourceHanSerifSC-Medium.otf', 'SourceHanSerif', 'normal');
pdf.setFont('SourceHanSerif');
console.log('PDF字体已加载:SourceHanSerif');
} else {
console.warn('PDF字体数据未找到,将使用默认字体');
}
} catch (fontErr) {
console.warn('PDF字体加载失败:', fontErr);
}
const pageWidth = pdf.internal.pageSize.getWidth();
const pxWidth = Math.max(target.scrollWidth || 0, Math.round(pageWidth * 3.78));
const renderTask = pdf.html(target, {
x: 10,
y: 10,
width: pageWidth - 20,
windowWidth: pxWidth,
margin: [10, 10, 16, 10],
autoPaging: 'text',
html2canvas: {
scale: Math.min(1.5, Math.max(1.0, pageWidth / (target.clientWidth || pageWidth))),
useCORS: true,
scrollX: 0,
scrollY: -iframe.contentWindow.scrollY,
logging: false,
allowTaint: true,
backgroundColor: '#ffffff'
},
pagebreak: {
mode: ['css', 'legacy'],
avoid: ['.chapter', '.callout', '.chart-card', '.table-wrap', '.kpi-grid', '.hero-section'],
before: '.chapter-divider'
}
});
await (renderTask && typeof renderTask.then === 'function' ? renderTask : Promise.resolve());
pdf.save('report.pdf');
showMessage('PDF生成完成,已开始下载', 'success');
} catch (err) {
console.error('生成PDF失败:', err);
showMessage('生成PDF失败: ' + err.message, 'error');
} finally {
if (btn) btn.disabled = false;
}
}
// 渲染任务状态(使用新的进度条样式)
function renderTaskStatus(task) {
// 状态文本的中文映射
const statusText = {
'running': '正在生成',
'completed': '已完成',
'error': '生成失败',
'pending': '等待中'
};
// 状态徽章样式
const statusBadgeClass = {
'running': 'task-status-running',
'completed': 'task-status-completed',
'error': 'task-status-error',
'pending': 'task-status-running'
};
// 为运行状态添加加载指示器
const loadingIndicator = task.status !== 'completed' && task.status !== 'error'
? '<span class="report-loading-spinner"></span>'
: '';
let statusHTML = `
<div class="task-progress-container">
<div class="task-progress-header">
<div class="task-progress-title">
${loadingIndicator}报告生成任务
</div>
<div class="task-progress-bar">
<div class="task-progress-fill" style="width: ${Math.min(Math.max(task.progress || 0, 0), 100)}%"></div>
<div class="task-progress-text">${task.progress || 0}%</div>
</div>
</div>
<div class="task-info-line">
<div class="task-info-item">
<span class="task-info-label">任务ID:</span>
<span class="task-info-value">${task.task_id}</span>
</div>
<div class="task-info-item">
<span class="task-info-label">查询内容:</span>
<span class="task-info-value">${task.query}</span>
</div>
<div class="task-info-item">
<span class="task-info-label">开始时间:</span>
<span class="task-info-value">${new Date(task.created_at).toLocaleString()}</span>
</div>
<div class="task-info-item">
<span class="task-info-label">更新时间:</span>
<span class="task-info-value">${new Date(task.updated_at).toLocaleString()}</span>
</div>
</div>
`;
if (task.report_file_path) {
statusHTML += `
<div class="task-info-line">
<div class="task-info-item">
<span class="task-info-label">保存路径:</span>
<span class="task-info-value">${task.report_file_path}</span>
</div>
</div>
`;
}
if (task.error_message) {
statusHTML += `
<div class="task-error-message">
<strong>错误信息:</strong> ${task.error_message}
</div>
`;
}
if (task.status === 'completed') {
statusHTML += `
<div class="task-actions">
<button class="report-button primary" onclick="viewReport('${task.task_id}')">重新加载</button>
${task.report_file_ready ? `<button class="report-button" onclick="downloadReport('${task.task_id}')">下载HTML</button>` : ''}
</div>
`;
}
statusHTML += '</div>';
return statusHTML;
}
// 生成报告
function generateReport() {
if (reportTaskId) {
showMessage('已有报告生成任务在运行', 'info');
return;
}
const reportButton = document.querySelector('[data-app="report"]');
if (reportButton && reportButton.classList.contains('locked')) {
showMessage('需等待三个Agent完成最新分析后才能生成最终报告', 'error');
return;
}
const query = document.getElementById('searchInput').value.trim() || '智能舆情分析报告';
// 重置日志计数器,因为后台会清空日志文件
reportLogLineCount = 0;
reportAutoPreviewLoaded = false;
safeCloseReportStream(true);
// 清空控制台显示
clearConsoleLayer('report', '[系统] 开始生成报告,日志已重置');
resetReportStreamOutput('Report Engine 正在调度任务...');
setGenerateButtonState(true);
// 在现有状态信息后添加任务进度状态,而不是替换
addTaskProgressStatus('正在启动报告生成任务...', 'loading');
// 构建请求数据,包含自定义模板(如果有的话)
const requestData = { query: query };
if (customTemplate && customTemplate.trim()) {
requestData.custom_template = customTemplate;
console.log('使用自定义模板生成报告');
}
fetch('/api/report/generate', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify(requestData)
})
.then(response => response.json())
.then(data => {
if (data.success) {
reportTaskId = data.task_id;
showMessage('报告生成已启动', 'success');
// 更新任务状态显示
updateTaskProgressStatus({
task_id: data.task_id,
query: query,
status: 'running',
progress: 5, // 初始进度设为5%,确保进度条可见
created_at: new Date().toISOString(),
updated_at: new Date().toISOString()
});
// 立即刷新一次日志以确保同步
setTimeout(() => {
refreshReportLog();
}, 500);
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 => {
console.error('生成报告失败:', error);
updateTaskProgressStatus(null, 'error', '生成报告失败: ' + error.message);
// 重置标志允许重新尝试
autoGenerateTriggered = false;
reportTaskId = null;
setGenerateButtonState(false);
appendReportStreamLine('任务启动阶段异常: ' + error.message, 'error');
updateReportStreamStatus('error');
safeCloseReportStream();
});
}
// 开始进度轮询
function startProgressPolling(taskId) {
if (reportPollingInterval) {
clearInterval(reportPollingInterval);
}
reportPollingInterval = setInterval(() => {
checkTaskProgress(taskId);
}, 2000);
}
// 检查任务进度
function checkTaskProgress(taskId) {
fetch(`/api/report/progress/${taskId}`)
.then(response => response.json())
.then(data => {
if (data.success) {
updateProgressDisplay(data.task);
// 在检查进度时也刷新日志
refreshReportLog();
if (data.task.status === 'completed') {
clearInterval(reportPollingInterval);
showMessage('报告生成完成!', 'success');
// 自动显示报告
viewReport(taskId);
reportAutoPreviewLoaded = true;
// 重置自动生成标志,允许下次有新内容时自动生成
autoGenerateTriggered = false;
reportTaskId = null;
setGenerateButtonState(false);
} else if (data.task.status === 'error') {
clearInterval(reportPollingInterval);
showMessage('报告生成失败: ' + data.task.error_message, 'error');
// 重置自动生成标志,允许重新尝试
autoGenerateTriggered = false;
reportTaskId = null;
setGenerateButtonState(false);
}
}
})
.catch(error => {
console.error('检查进度失败:', error);
});
}
// 添加任务进度状态(使用固定区域)
function addTaskProgressStatus(message, status) {
const taskArea = document.getElementById('taskProgressArea');
if (taskArea) {
const loadingIndicator = status === 'loading' ? '<span class="report-loading-spinner"></span>' : '';
taskArea.innerHTML = `
<div class="task-progress-container">
<div class="task-progress-header">
${loadingIndicator}任务状态: ${message}
</div>
</div>
`;
}
}
// 更新任务进度状态(使用固定区域)
function updateTaskProgressStatus(task, status = null, errorMessage = null) {
const taskArea = document.getElementById('taskProgressArea');
if (!taskArea) {
console.error('taskProgressArea not found');
return;
}
if (task) {
taskArea.innerHTML = renderTaskStatus(task);
if (task.status === 'completed') {
lastCompletedReportTask = task;
} else if (task.status === 'running') {
lastCompletedReportTask = null;
}
updateDownloadButtonState(task);
} else if (status && errorMessage) {
const loadingIndicator = status === 'loading' ? '<span class="report-loading-spinner"></span>' : '';
const statusBadgeClass = status === 'error' ? 'task-status-error' : 'task-status-running';
const statusText = status === 'error' ? '错误' : '处理中';
taskArea.innerHTML = `
<div class="task-progress-container">
<div class="task-progress-header">
${loadingIndicator}任务状态: ${statusText}
</div>
<div style="margin-top: 10px; font-size: 14px;">
${errorMessage}
</div>
</div>
`;
}
}
// 更新进度显示(保持向后兼容)
function updateProgressDisplay(task) {
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 = {}) {
if (level === 'chunk' && !options.force) {
return; // 章节内容流式写入不再逐条输出
}
// 格式化时间戳
const timestamp = new Date().toLocaleTimeString('zh-CN');
// 构建文本内容而不是 DOM 元素
let textContent = `[${timestamp}]`;
if (options.badge) {
textContent += ` [${options.badge}]`;
}
textContent += ` ${message}`;
// 使用统一的文本添加方法,避免直接操作 DOM
appendConsoleTextLine('report', textContent, `console-line report-stream-line ${level}`);
}
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');
reportPreview.innerHTML = '<div class="report-loading"><span class="report-loading-spinner"></span>加载报告中...</div>';
fetch(`/api/report/result/${taskId}`)
.then(response => {
if (response.ok) {
return response.text();
} else {
throw new Error('报告加载失败');
}
})
.then(rawContent => {
let htmlContent = rawContent;
// 检查是否是JSON格式的响应(包含html_content字段)
try {
if (rawContent.includes('"html_content":')) {
// 提取JSON中的html_content
const jsonMatch = rawContent.match(/\{[\s\S]*\}/);
if (jsonMatch) {
const jsonData = JSON.parse(jsonMatch[0]);
if (jsonData.html_content) {
htmlContent = jsonData.html_content;
// 处理转义字符
htmlContent = htmlContent.replace(/\\"/g, '"').replace(/\\n/g, '\n');
}
}
}
} catch (e) {
console.warn('解析JSON格式报告失败,使用原始内容:', e);
}
// 创建iframe来显示HTML内容
const iframe = document.createElement('iframe');
iframe.style.width = '100%';
iframe.style.border = 'none';
iframe.style.minHeight = '800px'; // 增加最小高度
iframe.style.overflow = 'hidden'; // 强制不显示滚动条
iframe.style.scrollbarWidth = 'none'; // Firefox
iframe.style.msOverflowStyle = 'none'; // IE and Edge
iframe.scrolling = 'no'; // 传统方式禁用滚动
iframe.id = 'report-iframe';
reportPreview.innerHTML = '';
reportPreview.appendChild(iframe);
// 将HTML内容写入iframe
iframe.contentDocument.open();
iframe.contentDocument.write(htmlContent);
iframe.contentDocument.close();
// 确保iframe内部文档也不显示滚动条
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
if (iframeDoc) {
// 设置body样式
if (iframeDoc.body) {
iframeDoc.body.style.overflow = 'hidden';
iframeDoc.body.style.scrollbarWidth = 'none';
iframeDoc.body.style.msOverflowStyle = 'none';
}
// 设置html样式
if (iframeDoc.documentElement) {
iframeDoc.documentElement.style.overflow = 'hidden';
iframeDoc.documentElement.style.scrollbarWidth = 'none';
iframeDoc.documentElement.style.msOverflowStyle = 'none';
}
// 添加CSS规则隐藏webkit滚动条
const style = iframeDoc.createElement('style');
style.textContent = `
body::-webkit-scrollbar, html::-webkit-scrollbar {
display: none !important;
}
body, html {
overflow: hidden !important;
scrollbar-width: none !important;
-ms-overflow-style: none !important;
}
`;
iframeDoc.head.appendChild(style);
}
// 等待内容加载完成后调整iframe高度
iframe.onload = function() {
setTimeout(() => {
try {
// 获取iframe内容的实际高度
const iframeDoc = iframe.contentDocument || iframe.contentWindow.document;
// 等待所有资源加载完成
let contentHeight = 0;
// 尝试多种方式获取内容高度
if (iframeDoc.body) {
contentHeight = Math.max(
iframeDoc.body.scrollHeight || 0,
iframeDoc.body.offsetHeight || 0,
iframeDoc.body.clientHeight || 0
);
}
if (iframeDoc.documentElement) {
contentHeight = Math.max(
contentHeight,
iframeDoc.documentElement.scrollHeight || 0,
iframeDoc.documentElement.offsetHeight || 0,
iframeDoc.documentElement.clientHeight || 0
);
}
// 设置iframe高度为内容高度,最小800px
const finalHeight = Math.max(contentHeight + 100, 800); // 添加100px的缓冲
iframe.style.height = finalHeight + 'px';
console.log(`报告iframe高度已调整为: ${finalHeight}px (内容高度: ${contentHeight}px)`);
// 确保父容器也能正确显示
reportPreview.style.minHeight = finalHeight + 'px';
} catch (error) {
console.error('调整iframe高度失败:', error);
// 如果调整失败,使用更大的默认高度
iframe.style.height = '1200px';
reportPreview.style.minHeight = '1200px';
}
}, 1000); // 延迟1秒等待内容完全渲染
};
// 备用方案:如果onload没有触发,延迟调整高度
setTimeout(() => {
if (iframe.style.height === 'auto' || iframe.style.height === '') {
iframe.style.height = '1200px';
reportPreview.style.minHeight = '1200px';
console.log('使用备用高度设置: 1200px');
}
}, 3000);
})
.catch(error => {
console.error('查看报告失败:', error);
reportPreview.innerHTML = `
<div class="report-loading">
报告加载失败: ${error.message}
</div>
`;
});
}
// 检查报告状态(不重新加载整个界面)
function checkReportStatus() {
// 只更新状态信息,不重新渲染整个界面
fetch('/api/report/status')
.then(response => response.json())
.then(data => {
if (data.success) {
// 更新ReportEngine状态指示器
const indicator = document.getElementById('status-report');
if (indicator) {
if (data.initialized) {
indicator.className = 'status-indicator running';
appStatus.report = 'running';
} else {
indicator.className = 'status-indicator';
appStatus.report = 'stopped';
}
}
// 更新状态信息(如果存在)
updateEngineStatusDisplay(data);
showMessage('状态检查完成', 'success');
} else {
showMessage('状态检查失败: ' + data.error, 'error');
}
})
.catch(error => {
console.error('检查报告状态失败:', error);
showMessage('状态检查失败: ' + error.message, 'error');
});
}
// 更新引擎状态显示(只更新文本内容)
function updateEngineStatusDisplay(statusData) {
const statusContent = document.getElementById('engineStatusContent');
if (statusContent) {
// 确定状态样式
const statusClass = statusData.initialized ? 'success' : 'error';
// 更新状态信息内容
let statusHTML = '';
if (statusData.initialized) {
statusHTML = `
<strong>ReportEngine状态:</strong> 已初始化<br>
<strong>文件检查:</strong> ${statusData.engines_ready ? '准备就绪' : '文件未就绪'}<br>
<strong>找到文件:</strong> ${statusData.files_found ? statusData.files_found.join(', ') : '无'}<br>
${statusData.missing_files && statusData.missing_files.length > 0 ?
`<strong>缺失文件:</strong> ${statusData.missing_files.join(', ')}` : ''}
`;
} else {
statusHTML = `<strong>ReportEngine状态:</strong> 未初始化`;
}
// 更新内容和样式
statusContent.innerHTML = statusHTML;
statusContent.className = `report-status ${statusClass}`;
}
}
</script>
</body>
</html>