Files
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

1715 lines
66 KiB
HTML
Raw Permalink 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智能助手 - WebSocket直连</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>
<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;
}
.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;
}
.message {
display: flex;
gap: 16px;
margin-bottom: 24px;
animation: fadeIn 0.3s ease;
}
@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);
}
.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;
}
.message-header {
display: flex;
align-items: center;
gap: 8px;
margin-bottom: 6px;
font-size: 13px;
}
.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;
}
.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;
}
.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;
}
.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;
}
</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>WebSocket 直连设置</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> WebSocket 直连后端服务器
</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="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-arrow-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>请点击"连接"按钮直接连接到 WebSocket 服务器。</p>
<p>连接后,您可以直接输入问题或使用 /help 查看可用命令。</p>
<p><strong>✨ 支持Markdown格式</strong> - 代码、表格、列表等都将美化显示</p>
<p><strong>⚡ 定时任务将使用橙色显示,每个任务独立显示,不会混在一起</strong></p>
<p><strong>🔄 多个定时任务可以同时执行,互不干扰</strong></p>
<p><strong>🔌 使用 WebSocket 直连后端服务器</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> WebSocket直连</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 streamingMessages = {
chat: {
element: null,
content: '',
timer: null,
lastUpdateTime: 0,
isActive: false
},
tasks: {}
};
let activeTaskCount = 0;
let messageCount = 0;
let toolCallCount = 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 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');
container.scrollTop = container.scrollHeight;
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();
}
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();
container.scrollTop = container.scrollHeight;
}
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');
messageCount++;
const countSpan = document.getElementById('message-count');
if (countSpan) {
countSpan.textContent = messageCount;
}
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');
messageCount++;
const countSpan = document.getElementById('message-count');
if (countSpan) {
countSpan.textContent = messageCount;
}
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;
document.getElementById('messages-container').appendChild(clone);
document.getElementById('messages-container').scrollTop =
document.getElementById('messages-container').scrollHeight;
}
// 处理对话结束
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'
});
document.getElementById('messages-container').appendChild(clone);
}
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
};
document.getElementById('messages-container').scrollTop =
document.getElementById('messages-container').scrollHeight;
}
// 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 ? '开启' : '关闭'}`);
});
}
// ==================== WebSocket 直连管理 ====================
let ws = null;
let reconnectAttempts = 0;
const MAX_RECONNECT_ATTEMPTS = 5;
let manualDisconnect = false;
let heartbeatTimer = null;
const HEARTBEAT_INTERVAL = 10000;
function connectWebSocket() {
const wsUrl = document.getElementById('ws-url').value.trim();
if (!wsUrl) {
showSystemMessage('请输入 WebSocket 地址', 'error');
return null;
}
// 验证地址格式
if (!wsUrl.startsWith('ws://') && !wsUrl.startsWith('wss://')) {
showSystemMessage('WebSocket地址必须以 ws:// 或 wss:// 开头', 'error');
return null;
}
console.log(`连接 WebSocket: ${wsUrl}`);
manualDisconnect = false;
const webSocket = new WebSocket(wsUrl);
webSocket.onopen = () => {
console.log('WebSocket 连接成功');
reconnectAttempts = 0;
updateConnectionStatus(true);
showSystemMessage(`✅ WebSocket 连接成功!服务器: ${wsUrl}`);
// 启动心跳保活,防止前置代理空闲超时断连
if (heartbeatTimer) clearInterval(heartbeatTimer);
heartbeatTimer = setInterval(() => {
if (webSocket.readyState === WebSocket.OPEN) {
webSocket.send(JSON.stringify({ type: 'ping' }));
}
}, HEARTBEAT_INTERVAL);
};
webSocket.onmessage = (event) => {
try {
const data = JSON.parse(event.data);
handleWebSocketMessage(data);
} catch (err) {
// 如果不是 JSON,当作普通文本处理
console.log('收到非JSON消息:', event.data);
handleStreamChunk(event.data, 'chat');
}
};
webSocket.onerror = (error) => {
console.error('WebSocket 错误:', error);
showSystemMessage(`❌ WebSocket 错误`, 'error');
};
webSocket.onclose = (event) => {
console.log('WebSocket 断开连接:', event.code, event.reason);
if (heartbeatTimer) { clearInterval(heartbeatTimer); heartbeatTimer = null; }
updateConnectionStatus(false);
if (!manualDisconnect && reconnectAttempts < MAX_RECONNECT_ATTEMPTS) {
reconnectAttempts++;
// F6(审计 2026-08-20):实际延迟为 3000*N ms,文案与此对齐
showSystemMessage(`⚠️ 连接断开,${3 * reconnectAttempts} 秒后重试(${reconnectAttempts}/${MAX_RECONNECT_ATTEMPTS})...`);
setTimeout(() => {
// F2(审计 2026-08-20):闭包到期时再判 manualDisconnect,
// 防止手动断开后已排程定时器把连接"复活"
if (!manualDisconnect && (!ws || ws.readyState !== WebSocket.OPEN)) {
console.log('尝试重连...');
ws = connectWebSocket();
}
}, 3000 * reconnectAttempts);
} else if (!manualDisconnect) {
showSystemMessage('❌ 连接断开,请手动重连', 'error');
} else {
showSystemMessage('已手动断开连接');
}
clearAllStreamingMessages();
};
return webSocket;
}
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 (type === 'connection_ack') {
showSystemMessage('✅ ' + content);
} else if (type === 'notification') {
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 = '已连接 (WebSocket)';
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 || !ws || ws.readyState !== WebSocket.OPEN) {
if (!ws || ws.readyState !== WebSocket.OPEN) {
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;
document.getElementById('messages-container').appendChild(clone);
document.getElementById('messages-container').scrollTop =
document.getElementById('messages-container').scrollHeight;
// 发送消息到服务器 - 根据消息格式判断类型
let sendData;
if (message.startsWith('/')) {
sendData = {
type: 'command',
command: message
};
} else {
sendData = {
type: 'message',
content: message
};
}
try {
ws.send(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 (ws && ws.readyState !== WebSocket.CLOSED) {
ws.close();
}
reconnectAttempts = 0;
manualDisconnect = false;
ws = connectWebSocket();
});
document.getElementById('disconnect-btn').addEventListener('click', function() {
manualDisconnect = true;
if (ws) {
ws.close();
ws = 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,
// 且后续 clearAllStreamingMessages/计数清零全部中断不执行
const welcomeEl = document.querySelector('.welcome-message');
const welcomeHtml = welcomeEl ? welcomeEl.cloneNode(true) : null;
container.innerHTML = '';
if (welcomeHtml) container.appendChild(welcomeHtml);
clearAllStreamingMessages();
messageCount = 0;
toolCallCount = 0;
const countSpan = document.getElementById('message-count');
if (countSpan) countSpan.textContent = '0';
const toolSpan = document.getElementById('tool-count');
if (toolSpan) toolSpan.textContent = '0';
});
document.getElementById('scroll-bottom-btn').addEventListener('click', function() {
const container = document.getElementById('messages-container');
container.scrollTop = container.scrollHeight;
});
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>