Files
blackbird/aibox/entry.html
T
qing 9db2bc112d fix(aibox/frontend): Batch M 波次3 前端功能修复(F1/F2/F3/F4/F6)
- F1: entry/frontend 清空按钮先 cloneNode 缓存 welcome-message 再清空,修 TypeError 中断
- F2: 三页面手动断开清除/作废重连定时器,杜绝连接复活(entry 新增 reconnectTimer 句柄)
- F3: 三页面连接前关闭 CONNECTING 中旧连接,防孤儿连接/双后端会话
- F4: 6 个 CDN CSS link 补 SRI integrity+crossorigin(sha384 实测计算)
- F6: frontend 重连文案与 3000*N 实际延迟对齐

三页面内联脚本 node --check 通过;测试 493 passed/4 failed(既有平台项)/1 skipped 零回归
2026-08-20 13:25:12 +08:00

1798 lines
68 KiB
HTML
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>AI智能助手 - Socket.IO客户端</title>
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/font-awesome/6.0.0/css/all.min.css" integrity="sha384-3B6NwesSXE7YJlcLI9RpRqGf2p/EgVH8BgoKTaUrmKNDkHPStTQ3EyoYjCGXaOTS" crossorigin="anonymous">
<link rel="stylesheet" href="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/styles/github-dark.min.css" integrity="sha384-wH75j6z1lH97ZOpMOInqhgKzFkAInZPPSPlZpYKYTOqsaizPvhQZmAtLcPKXpLyH" crossorigin="anonymous">
<!-- J13(审计 2026-08-19):CDN 脚本补 SRI integrity,防供应链劫持 -->
<script src="https://cdnjs.cloudflare.com/ajax/libs/marked/9.1.6/marked.min.js" integrity="sha384-odPBjvtXVM/5hOYIr3A1dB+flh0c3wAT3bSesIOqEGmyUA4JoKf/YTWy0XKOYAY7" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/highlight.js/11.8.0/highlight.min.js" integrity="sha384-g4mRvs7AO0/Ol5LxcGyz4Doe21pVhGNnC3EQw5shw+z+aXDN86HqUdwXWO+Gz2zI" crossorigin="anonymous"></script>
<script src="https://cdnjs.cloudflare.com/ajax/libs/dompurify/3.2.4/purify.min.js" integrity="sha384-eEu5CTj3qGvu9PdJuS+YlkNi7d2XxQROAFYOr59zgObtlcux1ae1Il3u7jvdCSWu" crossorigin="anonymous"></script>
<!-- Socket.IO 客户端库 -->
<script src="https://cdn.socket.io/4.5.0/socket.io.min.js" integrity="sha384-7EyYLQZgWBi67fBtVxw60/OWl1kjsfrPFcaU0pp0nAh+i8FD068QogUvg85Ewy1k" crossorigin="anonymous"></script>
<style>
/* 样式保持不变,完全使用原有的样式 */
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, 'Open Sans', 'Helvetica Neue', sans-serif;
background: #1a1a1a;
color: #e0e0e0;
height: 100vh;
overflow: hidden;
}
.app-container {
display: flex;
height: 100vh;
background: #1e1e1e;
}
.sidebar {
width: 280px;
background: #252526;
border-right: 1px solid #3d3d3d;
display: flex;
flex-direction: column;
overflow-y: auto;
}
.sidebar-header {
padding: 20px;
border-bottom: 1px solid #3d3d3d;
display: flex;
justify-content: space-between;
align-items: center;
}
.sidebar-header h2 {
font-size: 18px;
font-weight: 600;
color: #fff;
}
.sidebar-header h2 i {
color: #4CAF50;
margin-right: 8px;
}
.connection-status {
display: flex;
align-items: center;
gap: 6px;
font-size: 12px;
}
.status-dot {
width: 8px;
height: 8px;
border-radius: 50%;
display: inline-block;
}
.status-dot.connected {
background: #4CAF50;
box-shadow: 0 0 8px #4CAF50;
}
.status-dot.disconnected {
background: #f44336;
}
.status-text {
color: #8b949e;
}
.connection-panel {
padding: 16px;
border-bottom: 1px solid #3d3d3d;
}
.connection-panel h3 {
font-size: 14px;
color: #8b949e;
margin-bottom: 12px;
font-weight: 500;
}
.input-group {
margin-bottom: 12px;
}
.input-group label {
display: block;
font-size: 12px;
color: #8b949e;
margin-bottom: 4px;
}
.input-group input {
width: 100%;
padding: 8px 12px;
background: #3d3d3d;
border: 1px solid #4d4d4d;
border-radius: 6px;
color: #fff;
font-size: 13px;
outline: none;
transition: all 0.3s;
}
.input-group input:focus {
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.button-group {
display: flex;
gap: 8px;
}
.btn {
padding: 8px 12px;
border: none;
border-radius: 6px;
font-size: 13px;
font-weight: 500;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s;
flex: 1;
justify-content: center;
}
.btn:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-primary {
background: #4CAF50;
color: #fff;
}
.btn-primary:hover:not(:disabled) {
background: #45a049;
transform: translateY(-1px);
}
.btn-secondary {
background: #3d3d3d;
color: #8b949e;
}
.btn-secondary:hover:not(:disabled) {
background: #4d4d4d;
color: #fff;
}
.commands-panel {
padding: 16px;
border-bottom: 1px solid #3d3d3d;
}
.commands-panel h3 {
font-size: 14px;
color: #8b949e;
margin-bottom: 12px;
font-weight: 500;
}
.command-list {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 8px;
}
.command-item {
padding: 6px 8px;
background: #3d3d3d;
border: none;
border-radius: 4px;
color: #8b949e;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s;
}
.command-item:hover {
background: #4d4d4d;
color: #fff;
transform: translateY(-1px);
}
.stats-panel {
padding: 16px;
border-bottom: 1px solid #3d3d3d;
}
.stats-panel h3 {
font-size: 14px;
color: #8b949e;
margin-bottom: 12px;
font-weight: 500;
}
.stats-content p {
font-size: 13px;
margin-bottom: 8px;
display: flex;
align-items: center;
gap: 8px;
color: #8b949e;
}
.stats-content i {
width: 16px;
color: #4CAF50;
}
.chat-area {
flex: 1;
display: flex;
flex-direction: column;
background: #1e1e1e;
min-width: 0;
}
.chat-header {
padding: 16px 24px;
background: #252526;
border-bottom: 1px solid #3d3d3d;
display: flex;
justify-content: space-between;
align-items: center;
}
.chat-title {
font-size: 16px;
font-weight: 500;
color: #fff;
display: flex;
align-items: center;
gap: 8px;
}
.chat-title i {
color: #4CAF50;
}
.chat-actions {
display: flex;
gap: 8px;
}
.btn-icon {
width: 32px;
height: 32px;
background: #3d3d3d;
border: none;
border-radius: 6px;
color: #8b949e;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.btn-icon:hover {
background: #4d4d4d;
color: #fff;
transform: translateY(-1px);
}
.btn-icon.active {
color: #4CAF50;
background: rgba(76, 175, 80, 0.1);
}
.messages-container {
flex: 1;
overflow-y: auto;
padding: 24px;
min-width: 0;
}
.message {
display: flex;
gap: 16px;
margin-bottom: 24px;
animation: fadeIn 0.3s ease;
min-width: 0;
}
@keyframes fadeIn {
from { opacity: 0; transform: translateY(10px); }
to { opacity: 1; transform: translateY(0); }
}
.message-avatar {
width: 36px;
height: 36px;
border-radius: 6px;
background: #3d3d3d;
display: flex;
align-items: center;
justify-content: center;
font-size: 18px;
flex-shrink: 0;
}
.user-message {
border-right: 3px solid #2196F3;
padding-right: 12px;
margin-right: -15px;
}
.user-message .message-avatar {
background: #1e3a8a;
color: #2196F3;
box-shadow: 0 0 10px rgba(33, 150, 243, 0.3);
}
.user-message .message-header .message-sender {
color: #2196F3;
font-weight: 700;
font-size: 14px;
}
.user-message .message-text {
font-size: 15px;
font-weight: 500;
background: rgba(33, 150, 243, 0.05);
border-radius: 8px;
padding: 12px 16px;
border: 1px solid rgba(33, 150, 243, 0.1);
word-wrap: break-word;
overflow-wrap: break-word;
word-break: break-all;
overflow-x: auto;
max-width: 100%;
}
.ai-message .message-avatar {
background: #1e4620;
color: #4CAF50;
}
.task-message {
border-left: 3px solid #ff9800;
padding-left: 12px;
margin-left: -15px;
}
.task-message .message-avatar {
background: #4a2c1e;
color: #ff9800;
}
.task-message .message-header .message-sender {
color: #ff9800;
font-weight: 700;
font-size: 14px;
}
.task-message .message-header .message-sender::before {
content: "⚡ ";
font-size: 12px;
}
.system-message .message-avatar {
background: #1e3a5f;
color: #2196F3;
}
.error-message .message-avatar {
background: #5f1e1e;
color: #f44336;
}
.message-content {
flex: 1;
min-width: 0;
overflow: hidden;
max-width: 100%;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
flex-wrap: wrap;
}
.message-sender {
font-weight: 600;
color: #fff;
}
.message-time {
color: #8b949e;
font-size: 11px;
}
.message-text {
font-size: 14px;
line-height: 1.6;
color: #e0e0e0;
word-wrap: break-word;
white-space: pre-wrap;
overflow-wrap: break-word;
word-break: break-all;
overflow-x: auto;
max-width: 100%;
}
.message.streaming .message-text {
border-right: 2px solid #4CAF50;
animation: blink-cursor 1s step-end infinite;
padding-right: 4px;
}
.task-message.streaming .message-text {
border-right-color: #ff9800;
}
@keyframes blink-cursor {
from, to { border-color: transparent; }
50% { border-color: #4CAF50; }
}
.input-area {
padding: 20px 24px;
background: #252526;
border-top: 1px solid #3d3d3d;
}
.message-type-tabs {
display: flex;
gap: 8px;
margin-bottom: 12px;
}
.tab-btn {
padding: 6px 12px;
background: #3d3d3d;
border: none;
border-radius: 4px;
color: #8b949e;
font-size: 12px;
cursor: pointer;
display: flex;
align-items: center;
gap: 6px;
transition: all 0.3s;
}
.tab-btn.active {
background: #4CAF50;
color: #fff;
}
.input-wrapper {
display: flex;
gap: 12px;
align-items: flex-end;
}
.input-wrapper textarea {
flex: 1;
padding: 12px 16px;
background: #3d3d3d;
border: 1px solid #4d4d4d;
border-radius: 8px;
color: #fff;
font-size: 14px;
line-height: 1.5;
resize: none;
max-height: 200px;
outline: none;
transition: all 0.3s;
}
.input-wrapper textarea:focus {
border-color: #4CAF50;
box-shadow: 0 0 0 2px rgba(76, 175, 80, 0.2);
}
.input-wrapper textarea:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.btn-send {
width: 44px;
height: 44px;
background: #4CAF50;
border: none;
border-radius: 8px;
color: #fff;
font-size: 18px;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.3s;
}
.btn-send:hover:not(:disabled) {
background: #45a049;
transform: translateY(-1px);
}
.btn-send:disabled {
opacity: 0.5;
cursor: not-allowed;
}
.input-tips {
display: flex;
gap: 16px;
margin-top: 8px;
font-size: 11px;
color: #8b949e;
}
.input-tips i {
margin-right: 4px;
color: #4CAF50;
}
.markdown-body {
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Helvetica, Arial, sans-serif;
font-size: 14px;
line-height: 1.6;
word-wrap: break-word;
}
.markdown-body pre {
background-color: #1e1e1e;
border-radius: 6px;
padding: 16px;
overflow: auto;
margin: 10px 0;
position: relative;
max-width: 100%;
}
.markdown-body code {
background-color: rgba(175, 184, 193, 0.2);
border-radius: 4px;
padding: 0.2em 0.4em;
font-family: 'SF Mono', Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace;
font-size: 85%;
}
.markdown-body pre code {
background-color: transparent;
padding: 0;
white-space: pre;
}
.markdown-body table {
border-collapse: collapse;
width: 100%;
margin: 10px 0;
max-width: 100%;
overflow-x: auto;
display: block;
}
.markdown-body th,
.markdown-body td {
border: 1px solid #3d3d3d;
padding: 6px 13px;
}
.markdown-body th {
background-color: #2d2d2d;
font-weight: 600;
}
.markdown-body blockquote {
padding: 0 1em;
color: #8b949e;
border-left: 0.25em solid #3d3d3d;
margin: 0;
}
.code-block-wrapper {
position: relative;
}
.copy-code-btn {
position: absolute;
top: 8px;
right: 8px;
padding: 4px 8px;
background-color: #2d2d2d;
border: 1px solid #3d3d3d;
border-radius: 4px;
color: #8b949e;
font-size: 12px;
cursor: pointer;
transition: all 0.3s;
z-index: 10;
}
.copy-code-btn:hover {
background-color: #3d3d3d;
color: #ffffff;
}
.welcome-message .ai-message {
opacity: 0.8;
}
.task-separator {
margin: 20px 0 10px 0;
padding: 8px 12px;
background: linear-gradient(to right, #2d2d2d, #1e1e1e);
border-radius: 6px;
font-size: 13px;
color: #ff9800;
display: flex;
align-items: center;
gap: 8px;
border-left: 3px solid #ff9800;
font-weight: 500;
}
.task-separator i {
font-size: 14px;
color: #ff9800;
}
.task-separator span {
flex: 1;
}
.task-separator .task-id {
color: #8b949e;
font-size: 11px;
font-weight: normal;
background: #3d3d3d;
padding: 2px 8px;
border-radius: 12px;
}
.multiple-tasks-info {
margin: 15px 0;
padding: 12px;
background: #2d2d2d;
border-radius: 8px;
border: 1px solid #3d3d3d;
display: flex;
align-items: center;
gap: 12px;
color: #8b949e;
}
.multiple-tasks-info i {
color: #ff9800;
font-size: 18px;
}
.multiple-tasks-info span {
flex: 1;
font-size: 13px;
}
.task-execution-count {
font-size: 11px;
color: #ff9800;
background: rgba(255, 152, 0, 0.1);
padding: 2px 6px;
border-radius: 12px;
margin-left: 8px;
}
::-webkit-scrollbar {
width: 8px;
height: 8px;
}
::-webkit-scrollbar-track {
background: #1e1e1e;
}
::-webkit-scrollbar-thumb {
background: #3d3d3d;
border-radius: 4px;
}
::-webkit-scrollbar-thumb:hover {
background: #4d4d4d;
}
/* 自动滚动按钮特殊样式 */
#auto-scroll-toggle.active {
color: #4CAF50;
background: rgba(76, 175, 80, 0.15);
}
</style>
</head>
<body>
<div class="app-container">
<aside class="sidebar">
<div class="sidebar-header">
<h2><i class="fas fa-robot"></i> AI助手</h2>
<div class="connection-status" id="connection-status">
<span class="status-dot disconnected"></span>
<span class="status-text">未连接</span>
</div>
</div>
<div class="connection-panel">
<h3>连接设置</h3>
<div class="input-group">
<label for="ws-url">目标WebSocket地址:</label>
<input type="text" id="ws-url" value="ws://localhost:8765" placeholder="ws://localhost:8765">
</div>
<div class="button-group">
<button id="connect-btn" class="btn btn-primary">
<i class="fas fa-plug"></i> 连接
</button>
<button id="disconnect-btn" class="btn btn-secondary" disabled>
<i class="fas fa-power-off"></i> 断开
</button>
</div>
<div class="input-tips" style="margin-top: 8px; font-size: 11px;">
<i class="fas fa-info-circle"></i> 通过 Socket.IO 代理连接
</div>
</div>
<div class="commands-panel">
<h3>常用命令</h3>
<div class="command-list">
<button class="command-item" data-command="/help">
<i class="fas fa-question-circle"></i> /help
</button>
<button class="command-item" data-command="/list">
<i class="fas fa-list"></i> /list
</button>
<button class="command-item" data-command="/memories">
<i class="fas fa-brain"></i> /memories
</button>
<button class="command-item" data-command="/memos">
<i class="fas fa-sticky-note"></i> /memos
</button>
<button class="command-item" data-command="/check">
<i class="fas fa-bell"></i> /check
</button>
<button class="command-item" data-command="/config">
<i class="fas fa-cog"></i> /config
</button>
<button class="command-item" data-command="/new">
<i class="fas fa-plus-circle"></i> /new
</button>
<button class="command-item" data-command="/tasks">
<i class="fas fa-tasks"></i> /tasks
</button>
</div>
</div>
<div class="stats-panel" id="stats-panel" style="display: none;">
<h3>会话统计</h3>
<div class="stats-content">
<p><i class="fas fa-clock"></i> <span id="connect-time">-</span></p>
<p><i class="fas fa-envelope"></i> <span id="message-count">0</span> 条消息</p>
<p><i class="fas fa-bolt"></i> <span id="tool-count">0</span> 工具调用</p>
<p><i class="fas fa-tasks"></i> <span id="task-count">0</span> 个活跃任务</p>
</div>
</div>
</aside>
<main class="chat-area">
<div class="chat-header">
<div class="chat-title">
<i class="fas fa-comment-dots"></i>
<span>对话窗口</span>
</div>
<div class="chat-actions">
<button id="auto-scroll-toggle" class="btn-icon active" title="自动滚动到底部">
<i class="fas fa-arrow-down"></i>
</button>
<button id="markdown-toggle" class="btn-icon" title="切换Markdown预览">
<i class="fas fa-markdown"></i>
</button>
<button id="clear-chat-btn" class="btn-icon" title="清空聊天">
<i class="fas fa-trash-alt"></i>
</button>
<button id="scroll-bottom-btn" class="btn-icon" title="滚动到底部">
<i class="fas fa-chevron-down"></i>
</button>
</div>
</div>
<div class="messages-container" id="messages-container">
<div class="welcome-message">
<div class="message ai-message">
<div class="message-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">AI助手</span>
<span class="message-time">刚刚</span>
</div>
<div class="message-text markdown-body">
<p>欢迎使用AI智能助手!</p>
<p>请点击"连接"按钮连接到服务器。</p>
<p>连接后,您可以直接输入问题或使用 /help 查看可用命令。</p>
<p><strong>✨ 支持Markdown格式</strong> - 代码、表格、列表等都将美化显示</p>
<p><strong>⚡ 定时任务将使用橙色显示,每个任务独立显示,不会混在一起</strong></p>
<p><strong>🔄 多个定时任务可以同时执行,互不干扰</strong></p>
<p><strong>🔌 通过 Socket.IO 代理连接,支持 WebSocket 代理</strong></p>
<p><strong>📌 点击顶部 <i class="fas fa-arrow-down"></i> 按钮可开关自动滚动</strong></p>
</div>
</div>
</div>
</div>
</div>
<div class="input-area">
<div class="message-type-tabs">
<button class="tab-btn active" data-type="message">
<i class="fas fa-comment"></i> 消息
</button>
<button class="tab-btn" data-type="command">
<i class="fas fa-terminal"></i> 命令
</button>
</div>
<div class="input-wrapper">
<textarea
id="message-input"
placeholder="输入消息... (按 Enter 发送,Shift+Enter 换行)"
rows="1"
disabled
></textarea>
<button id="send-btn" class="btn-send" disabled>
<i class="fas fa-paper-plane"></i>
</button>
</div>
<div class="input-tips">
<span><i class="fas fa-keyboard"></i> Enter发送,Shift+Enter换行</span>
<span><i class="fas fa-terminal"></i> 命令以 / 开头</span>
<span><i class="fas fa-markdown"></i> 支持Markdown</span>
<span><i class="fas fa-plug"></i> Socket.IO代理</span>
</div>
</div>
</main>
</div>
<template id="message-template">
<div class="message user-message">
<div class="message-avatar">
<i class="fas fa-user"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">用户</span>
<span class="message-time"></span>
</div>
<div class="message-text"></div>
</div>
</div>
</template>
<template id="ai-message-template">
<div class="message ai-message">
<div class="message-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">AI助手</span>
<span class="message-time"></span>
</div>
<div class="message-text markdown-body"></div>
</div>
</div>
</template>
<template id="task-message-template">
<div class="message task-message">
<div class="message-avatar">
<i class="fas fa-tasks"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">定时任务</span>
<span class="message-time"></span>
<span class="task-execution-count"></span>
</div>
<div class="message-text markdown-body"></div>
</div>
</div>
</template>
<template id="task-separator-template">
<div class="task-separator">
<i class="fas fa-bolt"></i>
<span class="task-title">定时任务执行</span>
<span class="task-id" id="task-id-display"></span>
</div>
</template>
<template id="multiple-tasks-info-template">
<div class="multiple-tasks-info">
<i class="fas fa-tasks"></i>
<span>多个定时任务正在同时执行,每个任务独立显示</span>
</div>
</template>
<template id="system-message-template">
<div class="message system-message">
<div class="message-avatar">
<i class="fas fa-info-circle"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">系统</span>
<span class="message-time"></span>
</div>
<div class="message-text"></div>
</div>
</div>
</template>
<template id="error-message-template">
<div class="message error-message">
<div class="message-avatar">
<i class="fas fa-exclamation-triangle"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">错误</span>
<span class="message-time"></span>
</div>
<div class="message-text"></div>
</div>
</div>
</template>
<template id="dialogue-ended-message-template">
<div class="message system-message dialogue-ended">
<div class="message-avatar">
<i class="fas fa-check-circle"></i>
</div>
<div class="message-content">
<div class="message-header">
<span class="message-sender">系统</span>
<span class="message-time"></span>
</div>
<div class="message-text" style="color: #4CAF50; font-weight: bold;">对话已结束,可以开始新对话</div>
</div>
</div>
</template>
<script>
// J2(审计 2026-08-19):marked 输出经 DOMPurify 净化——AI 输出/命令
// 执行结果是不可信输入,含原始 HTML 即可注入脚本;DOMPurify 缺失时
// 退化为转义文本(只显示不执行,防 XSS 兜底)
function renderMarkdown(content) {
const html = marked.parse(content);
if (window.DOMPurify) return DOMPurify.sanitize(html);
return html.replace(/</g, '&lt;');
}
document.addEventListener('DOMContentLoaded', function() {
// 配置marked
marked.setOptions({
highlight: function(code, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(code, { language: lang }).value;
} catch (err) {
console.error(err);
}
}
return hljs.highlightAuto(code).value;
},
breaks: true,
gfm: true,
headerIds: false,
mangle: false
});
// ==================== 自动滚动控制 ====================
let autoScrollEnabled = true;
const autoScrollToggle = document.getElementById('auto-scroll-toggle');
autoScrollToggle.addEventListener('click', function() {
autoScrollEnabled = !autoScrollEnabled;
this.classList.toggle('active', autoScrollEnabled);
// 如果开启自动滚动,立即滚动到底部
if (autoScrollEnabled) {
const container = document.getElementById('messages-container');
container.scrollTop = container.scrollHeight;
showSystemMessage('✅ 自动滚动已开启');
} else {
showSystemMessage('⏸️ 自动滚动已暂停');
}
});
// 监听用户手动滚动,如果用户向上滚动,我们不做干预,保持当前状态
// 但如果用户滚动到底部,且自动滚动开启,我们继续保持
const container = document.getElementById('messages-container');
container.addEventListener('scroll', function() {
// 如果用户手动滚动到底部,且自动滚动开启,不需要额外操作
// 如果用户向上滚动,我们保持自动滚动状态不变,但不会强制滚动
});
// ==================== 流式消息管理 ====================
let streamingMessages = {
chat: {
element: null,
content: '',
timer: null,
lastUpdateTime: 0,
isActive: false
},
tasks: {}
};
let activeTaskCount = 0;
const UPDATE_INTERVAL = 50;
// 清除流式消息
function clearStreamingMessage(type = 'chat', executionId = null) {
if (type === 'chat') {
if (streamingMessages.chat.element) {
streamingMessages.chat.element.classList.remove('streaming');
}
if (streamingMessages.chat.timer) {
clearTimeout(streamingMessages.chat.timer);
}
streamingMessages.chat = {
element: null,
content: '',
timer: null,
lastUpdateTime: 0,
isActive: false
};
} else if (type === 'task' && executionId) {
const taskStream = streamingMessages.tasks[executionId];
if (taskStream && taskStream.element) {
taskStream.element.classList.remove('streaming');
}
if (taskStream && taskStream.timer) {
clearTimeout(taskStream.timer);
}
delete streamingMessages.tasks[executionId];
activeTaskCount--;
if (activeTaskCount < 0) activeTaskCount = 0;
const taskCountSpan = document.getElementById('task-count');
if (taskCountSpan) {
taskCountSpan.textContent = activeTaskCount;
}
}
}
// 清除所有流式消息
function clearAllStreamingMessages() {
clearStreamingMessage('chat');
Object.keys(streamingMessages.tasks).forEach(execId => {
clearStreamingMessage('task', execId);
});
}
// 判断是否应该自动滚动到底部 - 简化逻辑,只要自动滚动开启就滚动
function shouldAutoScroll(container) {
if (!autoScrollEnabled) return false;
if (!container) return false;
return true;
}
// 安全地滚动到底部
function scrollToBottomIfNeeded(container) {
if (shouldAutoScroll(container)) {
container.scrollTop = container.scrollHeight;
}
}
// 强制滚动到底部(用户主动操作时使用)
function forceScrollToBottom(container) {
if (container) {
container.scrollTop = container.scrollHeight;
}
}
// 更新流式显示
function updateStreamingDisplay(type = 'chat', executionId = null) {
let streamData;
if (type === 'chat') {
streamData = streamingMessages.chat;
} else if (type === 'task' && executionId) {
streamData = streamingMessages.tasks[executionId];
}
if (!streamData || !streamData.element) return;
const textDiv = streamData.element.querySelector('.message-text');
if (!textDiv) return;
textDiv.setAttribute('data-raw-content', streamData.content);
if (markdownEnabled) {
textDiv.innerHTML = renderMarkdown(streamData.content);
textDiv.querySelectorAll('pre code').forEach((block) => {
hljs.highlightElement(block);
});
addCopyButtonsToCodeBlocks(textDiv);
} else {
textDiv.textContent = streamData.content;
}
const container = document.getElementById('messages-container');
// 如果自动滚动开启,滚动到底部
scrollToBottomIfNeeded(container);
streamData.lastUpdateTime = Date.now();
streamData.timer = null;
}
// 处理流式chunk
function handleStreamChunk(content, type = 'chat', taskInfo = null) {
let streamData;
if (type === 'chat') {
streamData = streamingMessages.chat;
if (!streamData.element) {
const container = document.getElementById('messages-container');
const template = document.getElementById('ai-message-template');
const clone = template.content.cloneNode(true);
streamData.element = clone.querySelector('.message');
streamData.element.classList.add('streaming');
streamData.isActive = true;
const timeSpan = streamData.element.querySelector('.message-time');
timeSpan.textContent = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
container.appendChild(clone);
const textDiv = streamData.element.querySelector('.message-text');
textDiv.setAttribute('data-raw-content', '');
streamData.lastUpdateTime = Date.now();
// 新消息添加时判断是否滚动
scrollToBottomIfNeeded(container);
}
streamData.content += content;
}
else if (type === 'task' && taskInfo && taskInfo.executionId) {
const executionId = taskInfo.executionId;
let triggerCount = 1;
if (executionId) {
const parts = executionId.split('_');
if (parts.length >= 3) {
triggerCount = parseInt(parts[2]) || 1;
}
}
if (!streamingMessages.tasks[executionId]) {
streamingMessages.tasks[executionId] = {
element: null,
content: '',
timer: null,
lastUpdateTime: 0,
taskId: taskInfo.taskId,
executionId: executionId,
taskTitle: taskInfo.taskTitle || '未知任务',
triggerCount: triggerCount,
isActive: true
};
activeTaskCount++;
const taskCountSpan = document.getElementById('task-count');
if (taskCountSpan) {
taskCountSpan.textContent = activeTaskCount;
}
const container = document.getElementById('messages-container');
const separatorTemplate = document.getElementById('task-separator-template');
const separatorClone = separatorTemplate.content.cloneNode(true);
const taskIdSpan = separatorClone.querySelector('#task-id-display');
if (taskIdSpan) {
taskIdSpan.textContent = `任务 #${taskInfo.taskId} - ${streamingMessages.tasks[executionId].taskTitle} (第${triggerCount}次执行)`;
}
container.appendChild(separatorClone);
const template = document.getElementById('task-message-template');
const clone = template.content.cloneNode(true);
const element = clone.querySelector('.message');
element.classList.add('streaming');
element.setAttribute('data-execution-id', executionId);
const timeSpan = element.querySelector('.message-time');
timeSpan.textContent = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const senderSpan = element.querySelector('.message-sender');
if (senderSpan) {
senderSpan.textContent = `定时任务 #${taskInfo.taskId}`;
}
const countSpan = element.querySelector('.task-execution-count');
if (countSpan) {
countSpan.textContent = `第${triggerCount}次执行`;
}
container.appendChild(clone);
streamingMessages.tasks[executionId].element = element;
const textDiv = element.querySelector('.message-text');
textDiv.setAttribute('data-raw-content', '');
streamingMessages.tasks[executionId].lastUpdateTime = Date.now();
// 新任务添加时判断是否滚动
scrollToBottomIfNeeded(container);
}
streamData = streamingMessages.tasks[executionId];
streamData.content += content;
}
if (streamData) {
const now = Date.now();
if (now - streamData.lastUpdateTime >= UPDATE_INTERVAL) {
if (streamData.timer) {
clearTimeout(streamData.timer);
streamData.timer = null;
}
updateStreamingDisplay(type, taskInfo?.executionId);
} else if (!streamData.timer) {
streamData.timer = setTimeout(
() => updateStreamingDisplay(type, taskInfo?.executionId),
UPDATE_INTERVAL - (now - streamData.lastUpdateTime)
);
}
}
}
// 完成流式消息
function completeStreamMessage(type = null, executionId = null) {
if (type === 'chat') {
const streamData = streamingMessages.chat;
if (streamData && streamData.element) {
if (streamData.timer) {
clearTimeout(streamData.timer);
}
updateStreamingDisplay('chat');
streamData.element.classList.remove('streaming');
const countSpan = document.getElementById('message-count');
if (countSpan) {
const currentCount = parseInt(countSpan.textContent) || 0;
countSpan.textContent = currentCount + 1;
}
const messageText = streamData.element.querySelector('.message-text');
addCopyButtonsToCodeBlocks(messageText);
streamData.isActive = false;
}
clearStreamingMessage('chat');
} else if (type === 'task' && executionId) {
const streamData = streamingMessages.tasks[executionId];
if (streamData && streamData.element) {
if (streamData.timer) {
clearTimeout(streamData.timer);
}
updateStreamingDisplay('task', executionId);
streamData.element.classList.remove('streaming');
const countSpan = document.getElementById('message-count');
if (countSpan) {
const currentCount = parseInt(countSpan.textContent) || 0;
countSpan.textContent = currentCount + 1;
}
const messageText = streamData.element.querySelector('.message-text');
addCopyButtonsToCodeBlocks(messageText);
}
clearStreamingMessage('task', executionId);
}
}
// 为代码块添加复制按钮
function addCopyButtonsToCodeBlocks(container) {
if (!container) return;
const codeBlocks = container.querySelectorAll('pre');
codeBlocks.forEach((block) => {
if (block.parentNode.classList.contains('code-block-wrapper')) return;
const wrapper = document.createElement('div');
wrapper.className = 'code-block-wrapper';
wrapper.style.position = 'relative';
const copyBtn = document.createElement('button');
copyBtn.className = 'copy-code-btn';
copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
copyBtn.onclick = function() {
const code = block.querySelector('code')?.innerText || block.innerText;
navigator.clipboard.writeText(code).then(() => {
copyBtn.innerHTML = '<i class="fas fa-check"></i> 已复制';
setTimeout(() => {
copyBtn.innerHTML = '<i class="fas fa-copy"></i> 复制';
}, 2000);
});
};
block.parentNode.insertBefore(wrapper, block);
wrapper.appendChild(block);
wrapper.appendChild(copyBtn);
});
}
// 显示系统消息
function showSystemMessage(message, type = 'info') {
const templateId = type === 'error' ? 'error-message-template' : 'system-message-template';
const template = document.getElementById(templateId);
const clone = template.content.cloneNode(true);
const messageText = clone.querySelector('.message-text');
const timeSpan = clone.querySelector('.message-time');
timeSpan.textContent = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
messageText.textContent = message;
const container = document.getElementById('messages-container');
container.appendChild(clone);
// 同样只在接近底部时自动滚动
scrollToBottomIfNeeded(container);
}
// 处理对话结束
function handleDialogueEnded() {
console.log('对话结束信号收到,重置聊天流式状态');
const template = document.getElementById('dialogue-ended-message-template');
if (template) {
const clone = template.content.cloneNode(true);
const timeSpan = clone.querySelector('.message-time');
timeSpan.textContent = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const container = document.getElementById('messages-container');
container.appendChild(clone);
scrollToBottomIfNeeded(container);
}
if (streamingMessages.chat.element) {
streamingMessages.chat.element.classList.remove('streaming');
streamingMessages.chat.element = null;
}
if (streamingMessages.chat.timer) {
clearTimeout(streamingMessages.chat.timer);
}
streamingMessages.chat = {
element: null,
content: '',
timer: null,
lastUpdateTime: 0,
isActive: false
};
const container = document.getElementById('messages-container');
scrollToBottomIfNeeded(container);
}
// Markdown切换
let markdownEnabled = true;
const markdownToggle = document.getElementById('markdown-toggle');
if (markdownToggle) {
markdownToggle.addEventListener('click', function() {
markdownEnabled = !markdownEnabled;
this.classList.toggle('active', markdownEnabled);
const allMessages = document.querySelectorAll('.ai-message .message-text, .task-message .message-text');
allMessages.forEach(msgDiv => {
const rawContent = msgDiv.getAttribute('data-raw-content');
if (rawContent) {
if (markdownEnabled) {
msgDiv.innerHTML = renderMarkdown(rawContent);
msgDiv.classList.add('markdown-body');
addCopyButtonsToCodeBlocks(msgDiv);
} else {
msgDiv.textContent = rawContent;
msgDiv.classList.remove('markdown-body');
}
}
});
showSystemMessage(`Markdown预览已${markdownEnabled ? '开启' : '关闭'}`);
});
}
// ==================== Socket.IO 连接管理 ====================
let socket = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
let targetWsUrl = null;
let reconnectTimer = null; // F2(审计 2026-08-20):重连定时器句柄,手动断开时清除
function createSocketConnection() {
targetWsUrl = document.getElementById('ws-url').value.trim();
if (!targetWsUrl) {
showSystemMessage('请输入目标 WebSocket 地址', 'error');
return null;
}
// 验证目标地址格式
if (!targetWsUrl.startsWith('ws://') && !targetWsUrl.startsWith('wss://')) {
showSystemMessage('WebSocket地址必须以 ws:// 或 wss:// 开头', 'error');
return null;
}
console.log(`创建 Socket.IO 连接,目标: ${targetWsUrl}`);
// 创建 Socket.IO 连接(连接到当前服务器)
const socketIo = io({
transports: ['websocket', 'polling'],
withCredentials: true
});
socketIo.on('connect', () => {
console.log('Socket.IO 连接成功, SID:', socketIo.id);
reconnectAttempts = 0;
updateConnectionStatus(true);
showSystemMessage(`✅ Socket.IO 连接成功!正在连接目标 WebSocket: ${targetWsUrl}`);
// 发送 WebSocket 代理连接请求
socketIo.emit('ws_connect', { target_url: targetWsUrl });
});
socketIo.on('connected', (data) => {
console.log('连接确认:', data);
});
socketIo.on('ws_connected', (data) => {
console.log('WebSocket 代理连接成功:', data);
showSystemMessage(`✅ ${data.message}`);
if (streamingMessages.chat.element) completeStreamMessage('chat');
});
socketIo.on('ws_message', (data) => {
try {
const messageData = JSON.parse(data.data);
handleWebSocketMessage(messageData);
} catch (err) {
// 如果不是 JSON,当作普通文本流式处理
handleStreamChunk(data.data, 'chat');
}
});
socketIo.on('ws_error', (data) => {
console.error('WebSocket 代理错误:', data);
showSystemMessage(`❌ ${data.error}`, 'error');
updateConnectionStatus(false);
});
socketIo.on('disconnect', (reason) => {
console.log('Socket.IO 断开连接:', reason);
updateConnectionStatus(false);
if (reason !== 'io client disconnect' && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
showSystemMessage(`⚠️ 连接断开,${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS} 次重试...`);
reconnectTimer = setTimeout(() => {
reconnectTimer = null;
if (!socket || !socket.connected) {
console.log('尝试重连...');
socket = createSocketConnection();
}
}, 3000);
} else if (reason !== 'io client disconnect') {
showSystemMessage('❌ 连接断开,请手动重连', 'error');
} else {
showSystemMessage('已手动断开连接');
}
clearAllStreamingMessages();
});
socketIo.on('connect_error', (error) => {
console.error('Socket.IO 连接错误:', error);
showSystemMessage(`❌ 连接错误: ${error.message}`, 'error');
});
return socketIo;
}
function handleWebSocketMessage(data) {
const type = data.type;
const content = data.content;
if (type === 'chunk') {
handleStreamChunk(content, 'chat');
} else if (type === 'complete') {
completeStreamMessage('chat');
} else if (type === 'dialogue_ended') {
handleDialogueEnded();
} else if (type === 'task_process') {
const msgType = data.msg_type;
const taskContent = data.content;
const taskId = data.task_id;
const executionId = data.execution_id;
const taskTitle = data.task_title;
if (msgType === 'chunk') {
handleStreamChunk(taskContent, 'task', { taskId, executionId, taskTitle });
} else if (msgType === 'complete') {
completeStreamMessage('task', executionId);
}
} else if (type === 'task_status') {
const taskId = data.task_id;
const title = data.title;
const status = data.status;
const message = data.message || content;
let statusIcon = '📋';
if (status === 'executing') statusIcon = '⚙️';
else if (status === 'complete') statusIcon = '✅';
else if (status === 'failed') statusIcon = '❌';
else if (status === 'added') statusIcon = '➕';
else if (status === 'batch_start') statusIcon = '🚀';
else if (status === 'batch_complete') statusIcon = '🏁';
showSystemMessage(`${statusIcon} [任务 ${taskId}] ${title}: ${message}`);
} else if (type === 'task_error') {
const taskId = data.task_id;
const executionId = data.execution_id;
const taskTitle = data.task_title;
const error = data.error;
showSystemMessage(`❌ [任务 ${taskId}] ${taskTitle}: ${error}`, 'error');
if (executionId) {
completeStreamMessage('task', executionId);
}
} else if (type === 'error') {
showSystemMessage(content, 'error');
} else if (type === 'info') {
showSystemMessage('ℹ️ ' + content);
} else if (type === 'command_result') {
showSystemMessage('📋 ' + content);
} else {
if (content) {
handleStreamChunk(content, 'chat');
}
}
}
function updateConnectionStatus(connected) {
const statusDot = document.querySelector('.status-dot');
const statusText = document.querySelector('.status-text');
const connectBtn = document.getElementById('connect-btn');
const disconnectBtn = document.getElementById('disconnect-btn');
const messageInput = document.getElementById('message-input');
const sendBtn = document.getElementById('send-btn');
const statsPanel = document.getElementById('stats-panel');
if (connected) {
statusDot.className = 'status-dot connected';
statusText.textContent = '已连接 (Socket.IO)';
connectBtn.disabled = true;
disconnectBtn.disabled = false;
messageInput.disabled = false;
sendBtn.disabled = false;
statsPanel.style.display = 'block';
const now = new Date();
document.getElementById('connect-time').textContent = now.toLocaleTimeString();
} else {
statusDot.className = 'status-dot disconnected';
statusText.textContent = '未连接';
connectBtn.disabled = false;
disconnectBtn.disabled = true;
messageInput.disabled = true;
sendBtn.disabled = true;
statsPanel.style.display = 'none';
}
}
function sendMessage() {
const input = document.getElementById('message-input');
const message = input.value.trim();
if (!message || !socket || !socket.connected) {
if (!socket || !socket.connected) {
showSystemMessage('❌ 未连接到服务器,请先连接', 'error');
}
return;
}
input.value = '';
input.style.height = 'auto';
const timestamp = new Date().toLocaleTimeString('zh-CN', {
hour: '2-digit',
minute: '2-digit',
second: '2-digit'
});
const template = document.getElementById('message-template');
const clone = template.content.cloneNode(true);
const messageText = clone.querySelector('.message-text');
const timeSpan = clone.querySelector('.message-time');
timeSpan.textContent = timestamp;
messageText.textContent = message;
const container = document.getElementById('messages-container');
container.appendChild(clone);
// 用户发送消息后总是滚动到底部,便于查看
forceScrollToBottom(container);
// 发送消息到服务器
let sendData;
if (message.startsWith('/')) {
sendData = {
type: 'command',
command: message
};
} else {
sendData = {
type: 'message',
content: message
};
}
try {
socket.emit('ws_send', { message: JSON.stringify(sendData) });
console.log('消息已发送:', sendData);
} catch (e) {
console.error('发送消息失败:', e);
showSystemMessage('❌ 发送消息失败: ' + e.message, 'error');
}
}
// 事件绑定
document.getElementById('connect-btn').addEventListener('click', function() {
// F3(审计 2026-08-20):CONNECTING 中的旧连接也要断开,否则重复点击产生孤儿连接/双后端会话
if (socket) {
socket.disconnect();
}
reconnectAttempts = 0;
socket = createSocketConnection();
});
document.getElementById('disconnect-btn').addEventListener('click', function() {
// F2:清除已排程的重连定时器,防止倒计时到期把连接"复活"
if (reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
if (socket) {
socket.disconnect();
socket = null;
reconnectAttempts = MAX_RECONNECT_ATTEMPTS;
updateConnectionStatus(false);
showSystemMessage('已手动断开连接');
}
});
document.getElementById('send-btn').addEventListener('click', sendMessage);
document.getElementById('message-input').addEventListener('keydown', function(e) {
if (e.key === 'Enter' && !e.shiftKey) {
e.preventDefault();
sendMessage();
}
});
document.getElementById('message-input').addEventListener('input', function() {
this.style.height = 'auto';
this.style.height = (this.scrollHeight) + 'px';
});
document.querySelectorAll('.command-item').forEach(btn => {
btn.addEventListener('click', function() {
const command = this.dataset.command;
document.getElementById('message-input').value = command + ' ';
document.getElementById('message-input').focus();
});
});
document.getElementById('clear-chat-btn').addEventListener('click', function() {
const container = document.getElementById('messages-container');
// F1(审计 2026-08-20):welcome-message 在 container 内,
// 必须先 cloneNode 缓存再清空,否则 querySelector 返回 null 抛 TypeError
const welcomeEl = document.querySelector('.welcome-message');
const welcomeHtml = welcomeEl ? welcomeEl.cloneNode(true) : null;
container.innerHTML = '';
if (welcomeHtml) container.appendChild(welcomeHtml);
clearAllStreamingMessages();
});
document.getElementById('scroll-bottom-btn').addEventListener('click', function() {
const container = document.getElementById('messages-container');
forceScrollToBottom(container);
});
document.querySelectorAll('.tab-btn').forEach(btn => {
btn.addEventListener('click', function() {
document.querySelectorAll('.tab-btn').forEach(b => b.classList.remove('active'));
this.classList.add('active');
const type = this.dataset.type;
const input = document.getElementById('message-input');
if (type === 'command') {
if (!input.value.startsWith('/') && input.value.trim()) {
input.value = '/' + input.value;
}
}
});
});
// 保存原始内容用于Markdown切换
function preserveRawContent() {
document.querySelectorAll('.ai-message .message-text, .task-message .message-text').forEach(msgDiv => {
if (!msgDiv.getAttribute('data-raw-content')) {
msgDiv.setAttribute('data-raw-content', msgDiv.textContent);
}
});
}
const observer = new MutationObserver(function(mutations) {
mutations.forEach(function(mutation) {
mutation.addedNodes.forEach(function(node) {
if (node.nodeType === 1) {
if (node.classList && (node.classList.contains('ai-message') || node.classList.contains('task-message'))) {
const textDiv = node.querySelector('.message-text');
if (textDiv && !textDiv.getAttribute('data-raw-content')) {
textDiv.setAttribute('data-raw-content', textDiv.textContent);
}
}
}
});
});
});
observer.observe(document.getElementById('messages-container'), {
childList: true,
subtree: true
});
});
</script>
</body>
</html>