4281 lines
160 KiB
HTML
4281 lines
160 KiB
HTML
<!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
|
||
};
|
||
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 = {};
|
||
|
||
// 轻量日志虚拟渲染器:可视窗口渲染 + 节流 + 包级别截断,降低内存占用
|
||
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 = 2000; // 硬性保留的最大行数,超出时裁剪老旧数据
|
||
this.trimTarget = 1500; // 裁剪后保留的目标行数,避免频繁裁剪
|
||
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.attachScroll();
|
||
}
|
||
|
||
attachScroll() {
|
||
if (!this.scrollElement) return;
|
||
let scrollTimer = null;
|
||
this.scrollElement.addEventListener('scroll', () => {
|
||
// 防抖处理,避免频繁触发
|
||
if (scrollTimer) clearTimeout(scrollTimer);
|
||
scrollTimer = setTimeout(() => {
|
||
this.handleUserScroll();
|
||
}, 100);
|
||
}, { passive: true });
|
||
}
|
||
|
||
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 });
|
||
// 降低批处理阈值到 50,更快响应
|
||
if (this.pending.length > 50) {
|
||
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 = '';
|
||
}
|
||
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 节点池
|
||
while (this.pool.length < needed) {
|
||
const node = document.createElement('div');
|
||
node.className = 'console-line';
|
||
this.pool.push(node);
|
||
}
|
||
|
||
// 不要完全清空容器,而是更新现有节点
|
||
const existingChildren = Array.from(this.container.children);
|
||
const fragment = document.createDocumentFragment();
|
||
|
||
// 更新或创建前置占位符
|
||
let beforeSpacer = existingChildren.find(el => el.dataset.spacer === 'before');
|
||
if (!beforeSpacer) {
|
||
beforeSpacer = document.createElement('div');
|
||
beforeSpacer.dataset.spacer = 'before';
|
||
}
|
||
beforeSpacer.style.height = `${beforeHeight}px`;
|
||
|
||
// 更新或创建后置占位符
|
||
let afterSpacer = existingChildren.find(el => el.dataset.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 node = this.pool[idx - start];
|
||
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接入格式,推荐LLM:kimi-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接入格式,推荐LLM:gemini-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接入格式,推荐LLM:deepseek-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接入格式,推荐LLM:gemini-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接入格式,推荐LLM:qwen-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接入格式,推荐LLM:qwen-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();
|
||
setInterval(updateTime, 1000);
|
||
checkStatus();
|
||
setInterval(checkStatus, 5000);
|
||
startConnectionProbe();
|
||
|
||
// 初始化密码切换功能(事件委托,只需调用一次)
|
||
attachConfigPasswordToggles();
|
||
|
||
// 初始化Report Engine锁定状态检查
|
||
checkReportLockStatus();
|
||
reportLockCheckInterval = setInterval(checkReportLockStatus, 10000); // 每10秒检查一次
|
||
|
||
// 优化控制台刷新频率:从 1 秒改为 2 秒,减少不必要的 API 调用
|
||
setInterval(() => {
|
||
if (appStatus[currentApp] === 'running' || appStatus[currentApp] === 'starting') {
|
||
refreshConsoleOutput();
|
||
}
|
||
}, 2000);
|
||
|
||
// 优化论坛对话刷新频率:从 2 秒改为 3 秒
|
||
setInterval(() => {
|
||
if (currentApp === 'forum' || appStatus.forum === 'running') {
|
||
refreshForumMessages();
|
||
}
|
||
}, 3000);
|
||
|
||
// 初始化论坛相关功能
|
||
initializeForum();
|
||
|
||
// 延迟预加载iframe以确保应用启动完成
|
||
setTimeout(() => {
|
||
preloadIframes();
|
||
}, 3000);
|
||
});
|
||
|
||
// 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, '&')
|
||
.replace(/</g, '<')
|
||
.replace(/>/g, '>')
|
||
.replace(/"/g, '"')
|
||
.replace(/'/g, ''');
|
||
}
|
||
|
||
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已初始化
|
||
if (!iframesInitialized) {
|
||
preloadIframes();
|
||
}
|
||
|
||
// 向所有运行中的应用发送搜索请求(通过刷新iframe传递参数)
|
||
let totalRunning = 0;
|
||
const ports = { insight: 8501, media: 8502, query: 8503 };
|
||
|
||
Object.keys(appStatus).forEach(app => {
|
||
if (appStatus[app] === 'running' && preloadedIframes[app]) {
|
||
totalRunning++;
|
||
|
||
// 构建搜索URL
|
||
const searchUrl = `http://${window.location.hostname}:${ports[app]}?query=${encodeURIComponent(query)}&auto_search=true`;
|
||
console.log(`向 ${app} 发送搜索请求: ${searchUrl}`);
|
||
|
||
// 直接更新主iframe的src来传递搜索参数
|
||
preloadedIframes[app].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;
|
||
}
|
||
}
|
||
|
||
// 更新按钮状态
|
||
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);
|
||
}
|
||
|
||
// 更新嵌入页面
|
||
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;
|
||
|
||
// 预加载所有iframe(只执行一次)
|
||
function preloadIframes() {
|
||
if (iframesInitialized) return;
|
||
|
||
const ports = { insight: 8501, media: 8502, query: 8503 };
|
||
const content = document.getElementById('embeddedContent');
|
||
|
||
for (const [app, port] of Object.entries(ports)) {
|
||
const iframe = document.createElement('iframe');
|
||
iframe.src = `http://${window.location.hostname}:${port}`;
|
||
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}`;
|
||
|
||
// 直接添加到content区域
|
||
content.appendChild(iframe);
|
||
preloadedIframes[app] = iframe;
|
||
|
||
console.log(`预加载 ${app} 应用完成`);
|
||
}
|
||
|
||
iframesInitialized = true;
|
||
console.log('所有iframe预加载完成,准备进行无缝切换');
|
||
}
|
||
|
||
// 更新嵌入页面
|
||
function updateEmbeddedPage(app) {
|
||
const header = document.getElementById('embeddedHeader');
|
||
const content = document.getElementById('embeddedContent');
|
||
|
||
// 如果是Forum Engine,直接显示论坛界面
|
||
if (app === 'forum') {
|
||
header.textContent = 'Forum Engine - 多智能体交流';
|
||
|
||
// 隐藏所有iframe
|
||
if (typeof preloadedIframes !== 'undefined') {
|
||
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');
|
||
return;
|
||
}
|
||
|
||
// 如果是Report Engine,显示报告界面
|
||
if (app === 'report') {
|
||
header.textContent = 'Report Agent - 最终报告生成';
|
||
|
||
// 隐藏所有iframe
|
||
if (typeof preloadedIframes !== 'undefined') {
|
||
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');
|
||
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已初始化
|
||
if (!iframesInitialized) {
|
||
preloadIframes();
|
||
}
|
||
|
||
// 隐藏所有iframe
|
||
Object.values(preloadedIframes).forEach(iframe => {
|
||
iframe.style.display = 'none';
|
||
});
|
||
|
||
// 移除占位符
|
||
const placeholder = content.querySelector('.status-placeholder');
|
||
if (placeholder) {
|
||
placeholder.remove();
|
||
}
|
||
|
||
// 显示当前应用的iframe
|
||
if (preloadedIframes[app]) {
|
||
preloadedIframes[app].style.display = 'block';
|
||
console.log(`切换到 ${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>
|
||
`;
|
||
}
|
||
}
|
||
|
||
// 检查应用状态
|
||
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;
|
||
|
||
// 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');
|
||
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.2, Math.max(0.8, pageWidth / (target.clientWidth || pageWidth))),
|
||
useCORS: true,
|
||
scrollX: 0,
|
||
scrollY: -iframe.contentWindow.scrollY,
|
||
logging: false
|
||
},
|
||
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>
|