- 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 零回归
900 lines
36 KiB
HTML
900 lines
36 KiB
HTML
<!DOCTYPE html>
|
||
<html lang="zh-CN">
|
||
<head>
|
||
<meta charset="UTF-8">
|
||
<meta name="viewport" content="width=device-width, initial-scale=1.0, user-scalable=no">
|
||
<title>小猫AI · 智能喵语伙伴</title>
|
||
<!-- Font Awesome 6 (免费图标库) -->
|
||
<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">
|
||
<!-- highlight.js 代码高亮 + marked markdown解析 -->
|
||
<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>
|
||
/* ========== 融合小猫风格 + WebSocket增强样式 ========== */
|
||
* {
|
||
margin: 0;
|
||
padding: 0;
|
||
box-sizing: border-box;
|
||
font-family: "Microsoft YaHei", "Segoe UI", system-ui, -apple-system, 'Roboto', sans-serif;
|
||
}
|
||
|
||
html, body {
|
||
width: 100%;
|
||
height: 100%;
|
||
overflow: hidden;
|
||
}
|
||
|
||
/* 整体背景及容器 - 可爱猫咪氛围 */
|
||
body {
|
||
background: #1a1a2f;
|
||
background-image: url('/imgs/background.jpeg');
|
||
background-size: cover;
|
||
background-position: center center;
|
||
background-repeat: no-repeat;
|
||
background-attachment: fixed;
|
||
transition: background-image 0.3s ease, background-color 0.2s;
|
||
color: #f0ede8;
|
||
}
|
||
|
||
/* 主布局 flex,左侧聊天区,右侧猫咪头像+WS侧栏 */
|
||
.main-layout {
|
||
display: flex;
|
||
height: 100vh;
|
||
width: 100%;
|
||
position: relative;
|
||
}
|
||
|
||
/* 左侧内容区 (聊天框+输入区) */
|
||
.cat-chat-area {
|
||
flex: 1;
|
||
display: flex;
|
||
flex-direction: column;
|
||
position: relative;
|
||
background: rgba(0,0,0,0.2);
|
||
backdrop-filter: blur(2px);
|
||
min-width: 0;
|
||
}
|
||
|
||
/* 角色模型区域 - 右侧面板内 */
|
||
#roleArea {
|
||
width: 140px;
|
||
height: 140px;
|
||
margin: 0 auto 16px auto;
|
||
filter: drop-shadow(0 8px 16px rgba(0,0,0,0.4));
|
||
pointer-events: auto;
|
||
background: rgba(0,0,0,0.2);
|
||
border-radius: 50%;
|
||
backdrop-filter: blur(4px);
|
||
padding: 8px;
|
||
}
|
||
|
||
#roleArea img {
|
||
width: 100%;
|
||
height: 100%;
|
||
object-fit: contain;
|
||
border-radius: 50%;
|
||
transition: all 0.2s;
|
||
}
|
||
|
||
/* 聊天记录玻璃容器 */
|
||
.chat-box {
|
||
position: relative;
|
||
margin: 20px 20px 95px 20px;
|
||
background: rgba(0, 0, 0, 0.55);
|
||
backdrop-filter: blur(12px);
|
||
border-radius: 28px;
|
||
padding: 16px 20px;
|
||
overflow-y: auto;
|
||
height: calc(100vh - 130px);
|
||
scroll-behavior: smooth;
|
||
border: 1px solid rgba(255, 220, 180, 0.3);
|
||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.3);
|
||
}
|
||
|
||
.chat-box div {
|
||
margin: 10px 0;
|
||
animation: fadeSlideUp 0.2s ease;
|
||
}
|
||
|
||
.user {
|
||
text-align: right;
|
||
color: #b9f6ca;
|
||
font-weight: 500;
|
||
background: rgba(60, 50, 80, 0.7);
|
||
display: inline-block;
|
||
width: auto;
|
||
max-width: 85%;
|
||
margin-left: auto;
|
||
padding: 8px 16px;
|
||
border-radius: 26px;
|
||
border-bottom-right-radius: 4px;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
.ai {
|
||
text-align: left;
|
||
color: #fff0dd;
|
||
background: rgba(30, 30, 45, 0.8);
|
||
width: auto;
|
||
max-width: 85%;
|
||
padding: 8px 16px;
|
||
border-radius: 26px;
|
||
border-bottom-left-radius: 4px;
|
||
word-break: break-word;
|
||
white-space: pre-wrap;
|
||
}
|
||
|
||
/* 系统/错误/任务消息样式 */
|
||
.system-msg, .error-msg, .task-msg {
|
||
padding: 6px 12px;
|
||
border-radius: 20px;
|
||
font-size: 13px;
|
||
background: rgba(0,0,0,0.5);
|
||
margin: 6px 0;
|
||
}
|
||
.task-separator {
|
||
margin: 12px 0 8px;
|
||
padding: 6px 12px;
|
||
background: #ff980033;
|
||
border-left: 3px solid #ff9800;
|
||
border-radius: 12px;
|
||
font-size: 12px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 8px;
|
||
color: #ffcd94;
|
||
}
|
||
|
||
/* 输入框区域 - 支持多行文本 */
|
||
.input-area {
|
||
position: absolute;
|
||
bottom: 20px;
|
||
left: 20px;
|
||
right: 20px;
|
||
display: flex;
|
||
gap: 12px;
|
||
background: transparent;
|
||
z-index: 30;
|
||
align-items: flex-end;
|
||
}
|
||
|
||
.input-wrapper {
|
||
flex: 1;
|
||
background: rgba(20, 20, 35, 0.9);
|
||
backdrop-filter: blur(6px);
|
||
border-radius: 24px;
|
||
border: 1px solid rgba(255, 200, 150, 0.5);
|
||
transition: all 0.2s ease;
|
||
}
|
||
|
||
.input-wrapper:focus-within {
|
||
border-color: #ff9a5a;
|
||
box-shadow: 0 0 8px rgba(255, 154, 90, 0.3);
|
||
}
|
||
|
||
#msgInput {
|
||
width: 100%;
|
||
padding: 12px 18px;
|
||
background: transparent;
|
||
color: #fce8cf;
|
||
font-size: 14px;
|
||
outline: none;
|
||
border: none;
|
||
resize: none;
|
||
font-family: inherit;
|
||
line-height: 1.5;
|
||
max-height: 150px;
|
||
overflow-y: auto;
|
||
}
|
||
|
||
#msgInput::placeholder {
|
||
color: rgba(252, 232, 207, 0.6);
|
||
}
|
||
|
||
#msgInput::-webkit-scrollbar {
|
||
width: 4px;
|
||
}
|
||
|
||
#msgInput::-webkit-scrollbar-track {
|
||
background: rgba(255,255,255,0.1);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
#msgInput::-webkit-scrollbar-thumb {
|
||
background: rgba(255, 154, 90, 0.5);
|
||
border-radius: 4px;
|
||
}
|
||
|
||
#sendBtn {
|
||
padding: 0 28px;
|
||
height: 48px;
|
||
border-radius: 48px;
|
||
border: none;
|
||
background: linear-gradient(135deg, #ff9a5a, #ff6f61);
|
||
color: white;
|
||
font-weight: bold;
|
||
cursor: pointer;
|
||
transition: 0.2s;
|
||
flex-shrink: 0;
|
||
font-size: 16px;
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
|
||
#sendBtn:hover {
|
||
transform: scale(1.02);
|
||
box-shadow: 0 4px 12px rgba(255, 111, 97, 0.4);
|
||
}
|
||
|
||
#sendBtn:active {
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
/* 右侧整合面板 (猫咪头像 + WS控制台) */
|
||
.right-panel {
|
||
width: 320px;
|
||
background: rgba(30, 30, 40, 0.85);
|
||
backdrop-filter: blur(16px);
|
||
border-left: 1px solid rgba(255,255,200,0.2);
|
||
display: flex;
|
||
flex-direction: column;
|
||
overflow-y: auto;
|
||
z-index: 100;
|
||
padding: 24px 16px;
|
||
align-items: center;
|
||
}
|
||
|
||
/* 猫咪头像容器 */
|
||
.avatar-container {
|
||
text-align: center;
|
||
margin-bottom: 20px;
|
||
}
|
||
|
||
.avatar-name {
|
||
margin-top: 8px;
|
||
font-size: 18px;
|
||
font-weight: bold;
|
||
color: #ffd4a3;
|
||
letter-spacing: 1px;
|
||
}
|
||
|
||
/* WebSocket连接区域 */
|
||
.ws-card {
|
||
background: rgba(26, 188, 156, 0.75);
|
||
margin: 12px 0;
|
||
border-radius: 24px;
|
||
padding: 16px;
|
||
backdrop-filter: blur(4px);
|
||
width: 100%;
|
||
}
|
||
.ws-title {
|
||
font-weight: bold;
|
||
font-size: 14px;
|
||
margin-bottom: 10px;
|
||
border-bottom: 1px dashed rgba(255,255,240,0.5);
|
||
display: flex;
|
||
align-items: center;
|
||
gap: 6px;
|
||
}
|
||
.ws-control {
|
||
display: flex;
|
||
gap: 8px;
|
||
margin: 10px 0;
|
||
}
|
||
.ws-url-input {
|
||
width: 100%;
|
||
padding: 8px;
|
||
background: #2d2d3a;
|
||
border: 1px solid #5a5a6e;
|
||
border-radius: 20px;
|
||
color: white;
|
||
font-size: 12px;
|
||
margin-bottom: 10px;
|
||
}
|
||
.btn-sm {
|
||
padding: 6px 12px;
|
||
border-radius: 24px;
|
||
background: #4CAF50;
|
||
border: none;
|
||
color: white;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
flex: 1;
|
||
}
|
||
.btn-sm:disabled { opacity: 0.5; }
|
||
.status-dot {
|
||
width: 10px;
|
||
height: 10px;
|
||
border-radius: 50%;
|
||
display: inline-block;
|
||
margin-right: 6px;
|
||
}
|
||
.command-grid {
|
||
display: grid;
|
||
grid-template-columns: repeat(2,1fr);
|
||
gap: 6px;
|
||
margin-top: 8px;
|
||
}
|
||
.cmd-btn {
|
||
background: #3d3d4a;
|
||
padding: 6px;
|
||
border-radius: 20px;
|
||
font-size: 11px;
|
||
text-align: center;
|
||
cursor: pointer;
|
||
transition: 0.1s;
|
||
}
|
||
.cmd-btn:hover {
|
||
background: #5a5a6e;
|
||
transform: translateX(2px);
|
||
}
|
||
.stats-line {
|
||
font-size: 12px;
|
||
margin: 6px 0;
|
||
}
|
||
|
||
/* 流式消息游标动画 */
|
||
.streaming .message-text {
|
||
border-right: 2px solid #ffb347;
|
||
animation: blink 0.8s step-end infinite;
|
||
padding-right: 4px;
|
||
}
|
||
@keyframes blink { from,to { border-color: transparent; } 50% { border-color: #ffb347; } }
|
||
.markdown-body code { background: #1e1e2e; border-radius: 6px; }
|
||
.markdown-body pre { background: #0f0f1a; }
|
||
|
||
@keyframes fadeSlideUp {
|
||
from { opacity: 0; transform: translateY(8px);}
|
||
to { opacity: 1; transform: translateY(0);}
|
||
}
|
||
::-webkit-scrollbar { width: 5px; }
|
||
|
||
/* 更换背景按钮样式 */
|
||
.bg-btn {
|
||
background: rgba(255, 165, 100, 0.8);
|
||
border: none;
|
||
color: white;
|
||
padding: 8px 16px;
|
||
border-radius: 40px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
margin-top: 12px;
|
||
width: 100%;
|
||
transition: 0.2s;
|
||
}
|
||
.bg-btn:hover {
|
||
background: rgba(255, 140, 70, 1);
|
||
transform: scale(0.98);
|
||
}
|
||
|
||
/* 护眼模式开关样式 */
|
||
.eye-mode-btn {
|
||
background: rgba(100, 180, 120, 0.8);
|
||
border: none;
|
||
color: white;
|
||
padding: 8px 16px;
|
||
border-radius: 40px;
|
||
cursor: pointer;
|
||
font-size: 12px;
|
||
margin-top: 8px;
|
||
width: 100%;
|
||
transition: 0.2s;
|
||
}
|
||
.eye-mode-btn:hover {
|
||
background: rgba(80, 160, 100, 1);
|
||
}
|
||
|
||
.eye-protect {
|
||
background: #c8e6d9 !important;
|
||
background-image: none !important;
|
||
}
|
||
.eye-protect .chat-box {
|
||
background: rgba(180,200,150,0.85);
|
||
}
|
||
.eye-protect .right-panel {
|
||
background: rgba(80,100,70,0.9);
|
||
}
|
||
|
||
#sendBtn i {
|
||
font-size: 16px;
|
||
}
|
||
|
||
@media (max-width: 768px) {
|
||
.right-panel {
|
||
width: 280px;
|
||
}
|
||
.input-area {
|
||
gap: 8px;
|
||
}
|
||
#sendBtn {
|
||
padding: 0 20px;
|
||
font-size: 14px;
|
||
}
|
||
}
|
||
|
||
.proxy-info {
|
||
background: rgba(0,0,0,0.6);
|
||
padding: 4px 8px;
|
||
border-radius: 20px;
|
||
font-size: 10px;
|
||
margin-top: 8px;
|
||
text-align: center;
|
||
}
|
||
</style>
|
||
</head>
|
||
<body>
|
||
<div class="main-layout">
|
||
<!-- 左侧聊天区域 -->
|
||
<div class="cat-chat-area">
|
||
<div class="chat-box" id="chatBox">
|
||
<div class="ai">🐱 主人好呀~ 我是小猫AI,已整合智能引擎!点击右侧「连接服务器」开启AI对话 ✨<br>📝 提示:输入框支持多行文本,按 Enter 发送,Shift+Enter 换行<br>🔌 连接将通过 Socket.IO 代理服务转发</div>
|
||
</div>
|
||
<div class="input-area">
|
||
<div class="input-wrapper">
|
||
<textarea id="msgInput" placeholder="喵~ 跟小猫说说话吧... (Shift+Enter换行)" autocomplete="off" rows="1"></textarea>
|
||
</div>
|
||
<button id="sendBtn"><i class="fas fa-paper-plane"></i> 发送</button>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 右侧面板: 小猫头像 + WS控制台 + 背景/护眼功能 -->
|
||
<div class="right-panel">
|
||
<!-- 小猫头像区域 -->
|
||
<div class="avatar-container">
|
||
<div id="roleArea">
|
||
<img id="roleImg" src="data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' viewBox='0 0 200 200'%3E%3Ccircle cx='100' cy='100' r='80' fill='%23f9cfb0'/%3E%3Ccircle cx='70' cy='80' r='8' fill='%233b2b1f'/%3E%3Ccircle cx='130' cy='80' r='8' fill='%233b2b1f'/%3E%3Cpath d='M80 120 Q100 140 120 120' stroke='%23945c3a' fill='none' stroke-width='4'/%3E%3Cpolygon points='100,150 92,175 108,175' fill='%23ffaaa5'/%3E%3C/svg%3E" alt="小猫AI">
|
||
</div>
|
||
<div class="avatar-name">🐱 小猫 AI</div>
|
||
</div>
|
||
|
||
<!-- 背景与显示设置 -->
|
||
<div style="width: 100%; margin-bottom: 12px;">
|
||
<button id="changeBgBtn" class="bg-btn"><i class="fas fa-image"></i> 🖼️ 更换背景</button>
|
||
<button id="eyeModeBtn" class="eye-mode-btn"><i class="fas fa-eye"></i> 🌿 护眼模式</button>
|
||
</div>
|
||
|
||
<!-- WebSocket 智能连接卡片 -->
|
||
<div class="ws-card">
|
||
<div class="ws-title"><i class="fas fa-globe"></i> AI 智能引擎 (Socket.IO代理)</div>
|
||
<div style="display: flex; align-items: center; gap: 8px; margin-bottom: 10px;">
|
||
<span class="status-dot" id="wsStatusDot" style="background:#f44336;"></span>
|
||
<span id="wsStatusText" style="font-size:12px;">未连接</span>
|
||
</div>
|
||
<div class="proxy-info">
|
||
<i class="fas fa-shield-alt"></i> 通过 Socket.IO 代理服务连接
|
||
</div>
|
||
<input type="text" id="wsUrlInput" class="ws-url-input" value="ws://localhost:8765" placeholder="目标 ws://地址:端口">
|
||
<div class="ws-control">
|
||
<button id="wsConnectBtn" class="btn-sm"><i class="fas fa-plug"></i> 连接</button>
|
||
<button id="wsDisconnectBtn" class="btn-sm" style="background:#6c757d;" disabled><i class="fas fa-power-off"></i> 断开</button>
|
||
</div>
|
||
<div style="margin-top: 12px;">
|
||
<div class="ws-title" style="font-size:12px;"><i class="fas fa-terminal"></i> 快捷命令</div>
|
||
<div class="command-grid">
|
||
<div class="cmd-btn" data-cmd="/help">/help</div>
|
||
<div class="cmd-btn" data-cmd="/list">/list</div>
|
||
<div class="cmd-btn" data-cmd="/memories">/memories</div>
|
||
<div class="cmd-btn" data-cmd="/tasks">/tasks</div>
|
||
<div class="cmd-btn" data-cmd="/new">/new</div>
|
||
<div class="cmd-btn" data-cmd="/config">/config</div>
|
||
</div>
|
||
</div>
|
||
<div id="statsPanel" style="margin-top: 12px; display:none;">
|
||
<div class="stats-line"><i class="fas fa-clock"></i> 连接时间: <span id="connectTime">-</span></div>
|
||
<div class="stats-line"><i class="fas fa-envelope"></i> 消息数: <span id="msgCount">0</span></div>
|
||
<div class="stats-line"><i class="fas fa-tasks"></i> 活跃任务: <span id="taskCount">0</span></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<!-- 隐藏文件上传 -->
|
||
<input type="file" id="bgFile" accept="image/*" style="display:none;">
|
||
|
||
<script>
|
||
(function(){
|
||
// 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, '<');
|
||
}
|
||
|
||
// ---------- DOM 元素 ----------
|
||
const bgFile = document.getElementById('bgFile');
|
||
const roleImg = document.getElementById('roleImg');
|
||
const chatBox = document.getElementById('chatBox');
|
||
const msgInput = document.getElementById('msgInput');
|
||
const sendBtn = document.getElementById('sendBtn');
|
||
const changeBgBtn = document.getElementById('changeBgBtn');
|
||
const eyeModeBtn = document.getElementById('eyeModeBtn');
|
||
|
||
let eyeProtectActive = false;
|
||
|
||
// ---------- Socket.IO 连接管理 ----------
|
||
let socket = null;
|
||
let reconnectAttempts = 0;
|
||
let reconnectTimer = null;
|
||
let manualDisconnect = false;
|
||
let markdownEnabled = true;
|
||
let globalMsgCount = 0;
|
||
let activeTaskCount = 0;
|
||
let connectStartTime = null;
|
||
let targetWsUrl = null;
|
||
|
||
// 流式消息管理
|
||
let streamingChat = { element: null, content: '', timer: null, active: false };
|
||
let streamingTasks = {};
|
||
|
||
function updateMsgStats() {
|
||
document.getElementById('msgCount').innerText = globalMsgCount;
|
||
document.getElementById('taskCount').innerText = activeTaskCount;
|
||
}
|
||
|
||
function addSystemMessage(text, isError=false) {
|
||
const div = document.createElement('div');
|
||
div.className = isError ? 'error-msg' : 'system-msg';
|
||
div.style.cssText = 'background:rgba(0,0,0,0.6); border-radius:16px; padding:6px 12px; margin:6px 0; font-size:12px;';
|
||
// J2:text 为服务端内容(命令执行结果/AI 输出),禁止拼 innerHTML——
|
||
// 原实现 `${text}` 模板拼接使任意 HTML 注入执行;图标为静态字面量
|
||
const icon = document.createElement('i');
|
||
icon.className = `fas ${isError ? 'fa-exclamation-triangle' : 'fa-info-circle'}`;
|
||
div.appendChild(icon);
|
||
div.appendChild(document.createTextNode(' ' + text));
|
||
chatBox.appendChild(div);
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
}
|
||
|
||
function updateStreamingDisplay(type, execId=null) {
|
||
let stream = (type==='chat') ? streamingChat : (execId ? streamingTasks[execId] : null);
|
||
if(!stream || !stream.element) return;
|
||
const textDiv = stream.element.querySelector('.message-text');
|
||
if(textDiv) {
|
||
textDiv.setAttribute('data-raw', stream.content);
|
||
if(markdownEnabled) {
|
||
textDiv.innerHTML = renderMarkdown(stream.content);
|
||
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
|
||
} else textDiv.innerText = stream.content;
|
||
}
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
}
|
||
|
||
function handleStreamChunk(content, type='chat', taskMeta=null) {
|
||
if(type === 'chat') {
|
||
if(!streamingChat.element) {
|
||
const wrapper = document.createElement('div');
|
||
wrapper.className = 'ai streaming';
|
||
wrapper.innerHTML = `<div class="message-text markdown-body" data-raw=""></div>`;
|
||
streamingChat.element = wrapper;
|
||
chatBox.appendChild(wrapper);
|
||
streamingChat.content = '';
|
||
streamingChat.active = true;
|
||
}
|
||
streamingChat.content += content;
|
||
if(streamingChat.timer) clearTimeout(streamingChat.timer);
|
||
streamingChat.timer = setTimeout(() => updateStreamingDisplay('chat'), 50);
|
||
}
|
||
else if(type === 'task' && taskMeta && taskMeta.executionId) {
|
||
const eid = taskMeta.executionId;
|
||
if(!streamingTasks[eid]) {
|
||
const sep = document.createElement('div');
|
||
sep.className = 'task-separator';
|
||
// J2:taskMeta.taskTitle 为服务端可控内容,禁止拼 innerHTML
|
||
const bolt = document.createElement('i');
|
||
bolt.className = 'fas fa-bolt';
|
||
sep.appendChild(bolt);
|
||
sep.appendChild(document.createTextNode(
|
||
` 定时任务 #${taskMeta.taskId} (${taskMeta.taskTitle || '执行'}) 第${taskMeta.triggerCount||1}次执行`));
|
||
chatBox.appendChild(sep);
|
||
const taskDiv = document.createElement('div');
|
||
taskDiv.className = 'task-msg streaming';
|
||
taskDiv.style.cssText = 'background:#2d2a1e; border-radius:16px; margin:6px 0; padding:8px 12px;';
|
||
taskDiv.innerHTML = `<div class="message-text markdown-body"></div>`;
|
||
chatBox.appendChild(taskDiv);
|
||
streamingTasks[eid] = { element: taskDiv, content: '', timer: null };
|
||
activeTaskCount++;
|
||
updateMsgStats();
|
||
}
|
||
streamingTasks[eid].content += content;
|
||
if(streamingTasks[eid].timer) clearTimeout(streamingTasks[eid].timer);
|
||
streamingTasks[eid].timer = setTimeout(() => {
|
||
const st = streamingTasks[eid];
|
||
if(st && st.element) {
|
||
const textDiv = st.element.querySelector('.message-text');
|
||
if(textDiv) {
|
||
if(markdownEnabled) textDiv.innerHTML = renderMarkdown(st.content);
|
||
else textDiv.innerText = st.content;
|
||
textDiv.querySelectorAll('pre code').forEach(block => hljs.highlightElement(block));
|
||
}
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
}
|
||
}, 50);
|
||
}
|
||
}
|
||
|
||
function completeStream(type='chat', execId=null) {
|
||
if(type==='chat' && streamingChat.element) {
|
||
if(streamingChat.timer) clearTimeout(streamingChat.timer);
|
||
updateStreamingDisplay('chat');
|
||
streamingChat.element.classList.remove('streaming');
|
||
globalMsgCount++;
|
||
updateMsgStats();
|
||
streamingChat = { element: null, content: '', timer: null, active: false };
|
||
} else if(type==='task' && execId && streamingTasks[execId]) {
|
||
const task = streamingTasks[execId];
|
||
if(task.timer) clearTimeout(task.timer);
|
||
if(task.element) {
|
||
const txtDiv = task.element.querySelector('.message-text');
|
||
if(txtDiv && markdownEnabled) txtDiv.innerHTML = renderMarkdown(task.content);
|
||
task.element.classList.remove('streaming');
|
||
}
|
||
globalMsgCount++;
|
||
delete streamingTasks[execId];
|
||
activeTaskCount = Math.max(0, activeTaskCount-1);
|
||
updateMsgStats();
|
||
}
|
||
}
|
||
|
||
function handleWSMessage(data) {
|
||
if(data.type === 'chunk') handleStreamChunk(data.content, 'chat');
|
||
else if(data.type === 'complete') completeStream('chat');
|
||
else if(data.type === 'dialogue_ended') {
|
||
addSystemMessage("✨ 对话已结束,可开启新对话");
|
||
if(streamingChat.element) completeStream('chat');
|
||
}
|
||
else if(data.type === 'task_process') {
|
||
if(data.msg_type === 'chunk') {
|
||
handleStreamChunk(data.content, 'task', { taskId: data.task_id, executionId: data.execution_id, taskTitle: data.task_title, triggerCount: (data.execution_id?.split('_')[2]) });
|
||
} else if(data.msg_type === 'complete') completeStream('task', data.execution_id);
|
||
}
|
||
else if(data.type === 'task_status') addSystemMessage(`📌 任务${data.task_id}: ${data.title} - ${data.status}`);
|
||
else if(data.type === 'error') addSystemMessage(`❌ ${data.content}`, true);
|
||
else if(data.type === 'command_result') addSystemMessage(`📋 ${data.content}`);
|
||
else if(data.type === 'info') addSystemMessage(`ℹ️ ${data.content}`);
|
||
}
|
||
|
||
function sendWSMessage(message, isCommand=false) {
|
||
if(!socket || !socket.connected) {
|
||
addSystemMessage("未连接到AI服务器,请先点击连接", true);
|
||
return false;
|
||
}
|
||
const payload = isCommand ? { type: 'command', command: message } : { type: 'message', content: message };
|
||
socket.emit('ws_send', { message: JSON.stringify(payload) });
|
||
return true;
|
||
}
|
||
|
||
function connectWebSocket() {
|
||
// F3(审计 2026-08-20):CONNECTING 中的旧连接也要断开,否则重复点击产生孤儿连接/双后端会话
|
||
if(socket) {
|
||
socket.disconnect();
|
||
}
|
||
|
||
targetWsUrl = document.getElementById('wsUrlInput').value.trim();
|
||
if(!targetWsUrl) {
|
||
addSystemMessage("请输入WebSocket服务器地址", true);
|
||
return;
|
||
}
|
||
|
||
if (!targetWsUrl.startsWith('ws://') && !targetWsUrl.startsWith('wss://')) {
|
||
addSystemMessage("WebSocket地址必须以 ws:// 或 wss:// 开头", true);
|
||
return;
|
||
}
|
||
|
||
manualDisconnect = false;
|
||
|
||
socket = io({
|
||
transports: ['websocket', 'polling'],
|
||
withCredentials: true
|
||
});
|
||
|
||
socket.on('connect', () => {
|
||
console.log('Socket.IO connected, SID:', socket.id);
|
||
updateConnectionUI(true);
|
||
reconnectAttempts = 0;
|
||
connectStartTime = new Date();
|
||
document.getElementById('connectTime').innerText = connectStartTime.toLocaleTimeString();
|
||
addSystemMessage(`✅ Socket.IO 连接成功!正在连接目标 WebSocket: ${targetWsUrl}`);
|
||
socket.emit('ws_connect', { target_url: targetWsUrl });
|
||
});
|
||
|
||
socket.on('connected', (data) => {
|
||
addSystemMessage(`✅ ${data.message}`);
|
||
});
|
||
|
||
socket.on('ws_connected', (data) => {
|
||
addSystemMessage(`✅ ${data.message}`);
|
||
if(streamingChat.element) completeStream('chat');
|
||
});
|
||
|
||
socket.on('ws_message', (data) => {
|
||
try {
|
||
const messageData = JSON.parse(data.data);
|
||
handleWSMessage(messageData);
|
||
} catch(err) {
|
||
handleStreamChunk(data.data, 'chat');
|
||
}
|
||
});
|
||
|
||
socket.on('ws_error', (data) => {
|
||
addSystemMessage(`❌ ${data.error}`, true);
|
||
updateConnectionUI(false);
|
||
});
|
||
|
||
socket.on('disconnect', (reason) => {
|
||
console.log('Socket.IO disconnected:', reason);
|
||
updateConnectionUI(false);
|
||
|
||
if(!manualDisconnect && reconnectAttempts < 3) {
|
||
reconnectAttempts++;
|
||
addSystemMessage(`⚠️ 连接断开 (${reason}),${reconnectAttempts}/3 次重试...`);
|
||
if(reconnectTimer) clearTimeout(reconnectTimer);
|
||
reconnectTimer = setTimeout(() => connectWebSocket(), 2000);
|
||
} else if(!manualDisconnect) {
|
||
addSystemMessage(`❌ 连接断开 (${reason}),请手动重连`);
|
||
} else {
|
||
addSystemMessage("已手动断开连接");
|
||
}
|
||
});
|
||
|
||
socket.on('connect_error', (error) => {
|
||
console.error('Socket.IO connection error:', error);
|
||
addSystemMessage(`❌ 连接错误: ${error.message}`, true);
|
||
});
|
||
}
|
||
|
||
function disconnectWebSocket() {
|
||
manualDisconnect = true;
|
||
// F2(审计 2026-08-20):清除已排程的重连定时器,防止倒计时到期把连接"复活"
|
||
if(reconnectTimer) { clearTimeout(reconnectTimer); reconnectTimer = null; }
|
||
if(socket) {
|
||
socket.disconnect();
|
||
socket = null;
|
||
}
|
||
targetWsUrl = null;
|
||
updateConnectionUI(false);
|
||
}
|
||
|
||
function updateConnectionUI(connected) {
|
||
const dot = document.getElementById('wsStatusDot');
|
||
const text = document.getElementById('wsStatusText');
|
||
const connectBtn = document.getElementById('wsConnectBtn');
|
||
const disconnectBtn = document.getElementById('wsDisconnectBtn');
|
||
const statsPanel = document.getElementById('statsPanel');
|
||
|
||
if(connected) {
|
||
dot.style.background = "#4CAF50";
|
||
text.innerText = "已连接 (Socket.IO)";
|
||
connectBtn.disabled = true;
|
||
disconnectBtn.disabled = false;
|
||
statsPanel.style.display = "block";
|
||
} else {
|
||
dot.style.background = "#f44336";
|
||
text.innerText = "未连接";
|
||
connectBtn.disabled = false;
|
||
disconnectBtn.disabled = true;
|
||
statsPanel.style.display = "none";
|
||
activeTaskCount = 0;
|
||
globalMsgCount = 0;
|
||
updateMsgStats();
|
||
Object.keys(streamingTasks).forEach(k => completeStream('task', k));
|
||
if(streamingChat.element) completeStream('chat');
|
||
}
|
||
}
|
||
|
||
function onSendMessage() {
|
||
let text = msgInput.value.trim();
|
||
if(!text) return;
|
||
|
||
const userDiv = document.createElement('div');
|
||
userDiv.className = 'user';
|
||
userDiv.innerText = text;
|
||
chatBox.appendChild(userDiv);
|
||
|
||
msgInput.value = '';
|
||
autoResizeTextarea();
|
||
msgInput.focus();
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
|
||
if(socket && socket.connected) {
|
||
if(text.startsWith('/')) sendWSMessage(text, true);
|
||
else sendWSMessage(text, false);
|
||
} else {
|
||
setTimeout(() => {
|
||
let reply = "喵~ 请先连接WebSocket服务器获取智能回复~ ✨\n💡 提示:多行文本也支持哦!\n🔌 连接将通过 Socket.IO 代理服务转发";
|
||
const aiDiv = document.createElement('div');
|
||
aiDiv.className = 'ai';
|
||
aiDiv.innerText = `🐱 小猫AI:${reply}`;
|
||
chatBox.appendChild(aiDiv);
|
||
chatBox.scrollTop = chatBox.scrollHeight;
|
||
}, 200);
|
||
}
|
||
}
|
||
|
||
function autoResizeTextarea() {
|
||
if (!msgInput) return;
|
||
msgInput.style.height = 'auto';
|
||
const newHeight = Math.min(msgInput.scrollHeight, 150);
|
||
msgInput.style.height = newHeight + 'px';
|
||
}
|
||
|
||
msgInput.addEventListener('input', autoResizeTextarea);
|
||
autoResizeTextarea();
|
||
|
||
changeBgBtn.onclick = () => bgFile.click();
|
||
bgFile.onchange = (e) => {
|
||
let f = e.target.files[0];
|
||
if(f) {
|
||
let url = URL.createObjectURL(f);
|
||
document.body.style.backgroundImage = `url(${url})`;
|
||
document.body.style.backgroundSize = "cover";
|
||
document.body.style.backgroundPosition = "center center";
|
||
document.body.style.backgroundRepeat = "no-repeat";
|
||
if(eyeProtectActive) {
|
||
eyeProtectActive = false;
|
||
document.body.classList.remove('eye-protect');
|
||
addSystemMessage("护眼模式已自动关闭(背景已更换)");
|
||
}
|
||
addSystemMessage("✨ 背景已更换");
|
||
}
|
||
};
|
||
|
||
eyeModeBtn.onclick = () => {
|
||
if(!eyeProtectActive) {
|
||
document.body.classList.add('eye-protect');
|
||
document.body.style.backgroundImage = "none";
|
||
eyeProtectActive=true;
|
||
addSystemMessage("护眼模式已开启(背景图片暂时隐藏)");
|
||
eyeModeBtn.style.background = "rgba(60, 140, 80, 0.9)";
|
||
}
|
||
else {
|
||
document.body.classList.remove('eye-protect');
|
||
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
|
||
document.body.style.backgroundSize = "cover";
|
||
document.body.style.backgroundPosition = "center center";
|
||
document.body.style.backgroundRepeat = "no-repeat";
|
||
eyeProtectActive=false;
|
||
addSystemMessage("护眼模式关闭,恢复背景");
|
||
eyeModeBtn.style.background = "rgba(100, 180, 120, 0.8)";
|
||
}
|
||
};
|
||
|
||
document.getElementById('wsConnectBtn').onclick = connectWebSocket;
|
||
document.getElementById('wsDisconnectBtn').onclick = disconnectWebSocket;
|
||
document.querySelectorAll('.cmd-btn').forEach(btn => {
|
||
btn.onclick = () => {
|
||
let cmd = btn.getAttribute('data-cmd');
|
||
msgInput.value = cmd + ' ';
|
||
autoResizeTextarea();
|
||
msgInput.focus();
|
||
};
|
||
});
|
||
|
||
sendBtn.onclick = onSendMessage;
|
||
|
||
msgInput.addEventListener('keydown', (e) => {
|
||
if(e.key === 'Enter' && !e.shiftKey) {
|
||
e.preventDefault();
|
||
onSendMessage();
|
||
}
|
||
});
|
||
|
||
document.body.style.backgroundImage = "url('/imgs/background.jpeg')";
|
||
document.body.style.backgroundSize = "cover";
|
||
document.body.style.backgroundPosition = "center center";
|
||
document.body.style.backgroundRepeat = "no-repeat";
|
||
|
||
msgInput.focus();
|
||
})();
|
||
</script>
|
||
</body>
|
||
</html>
|