diff --git a/ui/static/graph.html b/ui/static/graph.html
index 4caac28..d489baf 100644
--- a/ui/static/graph.html
+++ b/ui/static/graph.html
@@ -298,11 +298,10 @@
text-align: right;
}
.chat-input-area {
- padding: 10px 12px;
- padding-bottom: max(10px, calc(10px + env(safe-area-inset-bottom)));
+ padding: 12px 16px;
border-top: 1px solid rgba(100, 100, 255, 0.15);
display: flex;
- gap: 6px;
+ gap: 8px;
flex-shrink: 0;
}
.chat-input-area input {
@@ -332,75 +331,6 @@
.chat-input-area button:hover {
background: rgba(68, 136, 255, 0.5);
}
-.attach-btn {
- background: rgba(68, 136, 255, 0.2);
- border: 1px solid rgba(68, 136, 255, 0.3);
- color: #aaf;
- width: 40px;
- height: 40px;
- border-radius: 6px;
- cursor: pointer;
- font-size: 18px;
- display: flex;
- align-items: center;
- justify-content: center;
- padding: 0;
- flex-shrink: 0;
-}
-.attach-btn:hover {
- background: rgba(68, 136, 255, 0.4);
-}
-.chat-files-container {
- display: flex;
- flex-wrap: wrap;
- gap: 3px;
- padding: 2px 12px 6px;
- min-height: 0;
-}
-.chat-file-chip {
- display: flex;
- align-items: center;
- gap: 4px;
- background: rgba(40, 40, 80, 0.7);
- border: 1px solid rgba(100, 100, 255, 0.2);
- border-radius: 3px;
- padding: 2px 6px;
- font-size: 11px;
- color: #dde;
- max-width: 160px;
- cursor: default;
-}
-.chat-file-chip .file-icon {
- color: #7af;
- flex-shrink: 0;
- font-size: 10px;
-}
-.chat-file-chip .file-name {
- white-space: nowrap;
- overflow: hidden;
- text-overflow: ellipsis;
- flex: 1;
- font-size: 11px;
-}
-.chat-file-chip .file-size {
- color: #667;
- font-size: 9px;
- flex-shrink: 0;
-}
-.chat-file-chip .file-remove {
- color: #f66;
- cursor: pointer;
- font-size: 12px;
- line-height: 1;
- flex-shrink: 0;
-}
-.chat-file-chip .file-remove:hover {
- color: #f33;
-}
-.chat-messages.drag-over {
- background: rgba(68, 136, 255, 0.12);
- border: 2px dashed rgba(68, 136, 255, 0.5);
-}
.chat-status {
padding: 8px 16px;
border-top: 1px solid rgba(100, 100, 255, 0.1);
@@ -454,8 +384,6 @@
-
-
@@ -508,14 +436,11 @@
-
-
-
- ● 已连接
+ 🟢 已连接
-
+
@@ -821,7 +746,7 @@ function init() {
if (nodes.length === 0) return;
- // 计算节点度数
+ // 计算节点度数(用于叶子节点判断)
const nodeDegrees = {};
nodes.forEach(n => nodeDegrees[n.id] = 0);
edges.forEach(e => {
@@ -829,97 +754,80 @@ function init() {
nodeDegrees[e.target] = (nodeDegrees[e.target] || 0) + 1;
});
- // 按度排序
- const sortedNodes = [...nodes].sort((a, b) => (nodeDegrees[b.id] || 0) - (nodeDegrees[a.id] || 0));
+ // 按 mention_count 排序(降序)
+ const sortedByMentions = [...nodes].sort((a, b) => (b.mention_count || 0) - (a.mention_count || 0));
- // 计算布局位置(分层布局)
+ // 计算 mention_count 范围
+ const mentionCounts = sortedByMentions.map(n => n.mention_count || 0);
+ const maxMentions = Math.max(...mentionCounts, 1);
+ const minMentions = Math.min(...mentionCounts, 0);
+ const mentionRange = maxMentions - minMentions || 1;
+
+ // 布局位置
const positions = {};
const nodeMap = {};
+ sortedByMentions.forEach(n => nodeMap[n.id] = n);
- // 第一层:度最高的节点放外层
- const maxDegree = Math.max(...Object.values(nodeDegrees));
- const layers = {
- outer: [], // 度 > 70% 最大值
- middle: [], // 度在 30%-70%
- inner: [] // 度 < 30%
- };
+ // 半径配置
+ const baseRadius = 15;
+ const maxRadius = 80;
- sortedNodes.forEach(node => {
- const degree = nodeDegrees[node.id] || 0;
- const ratio = maxDegree > 0 ? degree / maxDegree : 0;
+ // 角度分配:mention_count 越高的节点,角度间隔越大
+ const totalNodes = sortedByMentions.length;
+ let accumulatedAngle = 0;
- if (ratio > 0.7) {
- layers.outer.push(node);
- } else if (ratio > 0.3) {
- layers.middle.push(node);
- } else {
- layers.inner.push(node);
- }
- nodeMap[node.id] = node;
+ sortedByMentions.forEach((node, i) => {
+ const mention = node.mention_count || 0;
+ // 归一化 mention_count (0~1)
+ const mentionNorm = mentionRange > 0 ? (mention - minMentions) / mentionRange : 0;
+
+ // 与中心的距离:mention_count 越高,离中心越远
+ const radius = baseRadius + mentionNorm * (maxRadius - baseRadius);
+
+ // 角度间隔:mention_count 越高,间隔越大(拉开距离)
+ // 基础间隔 + 按 mention_count 加权的额外间隔
+ const baseAngleStep = (Math.PI * 2) / totalNodes;
+ const extraAngle = mentionNorm * baseAngleStep * 2; // 高提及节点额外拉开
+ const angle = accumulatedAngle + extraAngle / 2;
+
+ accumulatedAngle += baseAngleStep + extraAngle;
+
+ const x = radius * Math.cos(angle);
+ const z = radius * Math.sin(angle);
+ const y = (Math.random() - 0.5) * (10 + mentionNorm * 20); // 高提及节点Y轴范围更大
+
+ positions[node.id] = { x, y, z, mentionNorm, radius };
});
- // 分配位置
- let currentAngle = 0;
-
- // 外层(大半径)
- const outerRadius = 50;
- layers.outer.forEach((node, i) => {
- const angle = (i / layers.outer.length) * Math.PI * 2;
- const x = outerRadius * Math.cos(angle);
- const z = outerRadius * Math.sin(angle);
- const y = (Math.random() - 0.5) * 20;
- positions[node.id] = { x, y, z };
- });
-
- // 中层
- const middleRadius = 30;
- layers.middle.forEach((node, i) => {
- const angle = (i / layers.middle.length) * Math.PI * 2 + currentAngle;
- const x = middleRadius * Math.cos(angle);
- const z = middleRadius * Math.sin(angle);
- const y = (Math.random() - 0.5) * 15;
- positions[node.id] = { x, y, z };
- });
-
- // 内层(核心)
- const innerRadius = 12;
- layers.inner.forEach((node, i) => {
- // 叶子节点放在其父节点附近
+ // 处理叶子节点(度为1):放在其父节点附近,但保持一定距离
+ sortedByMentions.forEach(node => {
const degree = nodeDegrees[node.id] || 0;
if (degree === 1) {
- // 找到连接的节点
const edge = edges.find(e => e.source === node.id || e.target === node.id);
if (edge) {
const parentId = edge.source === node.id ? edge.target : edge.source;
if (positions[parentId]) {
const parentPos = positions[parentId];
- const offset = 5 + Math.random() * 3;
+ const mention = node.mention_count || 0;
+ const mentionNorm = mentionRange > 0 ? (mention - minMentions) / mentionRange : 0;
+ // 叶子节点偏移量也与 mention_count 相关
+ const offset = 6 + mentionNorm * 8 + Math.random() * 4;
const angle = Math.random() * Math.PI * 2;
positions[node.id] = {
x: parentPos.x + offset * Math.cos(angle),
- y: parentPos.y + (Math.random() - 0.5) * 4,
- z: parentPos.z + offset * Math.sin(angle)
+ y: parentPos.y + (Math.random() - 0.5) * (4 + mentionNorm * 6),
+ z: parentPos.z + offset * Math.sin(angle),
+ mentionNorm,
+ radius: offset
};
- return;
}
}
}
-
- // 其他节点均匀分布在核心区域
- const angle = (i / layers.inner.length) * Math.PI * 2;
- const x = innerRadius * Math.cos(angle);
- const z = innerRadius * Math.sin(angle);
- const y = (Math.random() - 0.5) * 8;
- positions[node.id] = { x, y, z };
});
- // 简单力导向模拟(迭代几次)— 使用度数加权距离
- function getIdealDist(id) {
- return 5 + ((nodeDegrees[id] || 0) * 0.8);
- }
-
- for (let iter = 0; iter < 30; iter++) {
- // 斥力:节点之间互相排斥
+ // 力导向模拟(迭代几次)
+ for (let iter = 0; iter < 50; iter++) {
+ // 斥力:节点之间互相排斥,与 mention_count 成正比
Object.keys(positions).forEach(id1 => {
Object.keys(positions).forEach(id2 => {
if (id1 >= id2) return;
@@ -930,14 +838,13 @@ function init() {
const dz = pos1.z - pos2.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
- // 动态阈值:度数越高理想距离越大,取两节点较大值
- const idealDist = getIdealDist(id1) + getIdealDist(id2);
- const repulsionThreshold = idealDist * 1.5;
+ // 斥力与两个节点的 mention_count 之和成正比
+ const m1 = positions[id1].mentionNorm || 0;
+ const m2 = positions[id2].mentionNorm || 0;
+ const repulsionFactor = 0.5 + (m1 + m2) * 0.5; // 高提及节点排斥更强
- if (dist < repulsionThreshold) {
- // 斥力强度按度数加权
- const degreeWeight = ((nodeDegrees[id1] || 0) + (nodeDegrees[id2] || 0)) * 0.5 + 1;
- const force = (0.04 * degreeWeight) / Math.max(dist, 0.5);
+ if (dist < 25) {
+ const force = (0.06 * repulsionFactor) / Math.max(dist, 0.5);
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
const fz = (dz / dist) * force;
@@ -963,12 +870,13 @@ function init() {
const dz = pos2.z - pos1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
- // 动态引力阈值:以两节点平均理想距离为基准
- const idealDist = (getIdealDist(edge.source) + getIdealDist(edge.target)) * 0.8;
- const attractionThreshold = idealDist * 1.5;
+ // 引力强度与 mention_count 成反比:高提及节点已经离得远,减少引力避免拉回
+ const m1 = positions[edge.source].mentionNorm || 0;
+ const m2 = positions[edge.target].mentionNorm || 0;
+ const attractionFactor = Math.max(0.3, 1.0 - (m1 + m2) * 0.3);
- if (dist > attractionThreshold) {
- const force = 0.05;
+ if (dist > 20) {
+ const force = 0.04 * attractionFactor;
const fx = (dx / dist) * force;
const fy = (dy / dist) * force;
const fz = (dz / dist) * force;
@@ -981,6 +889,19 @@ function init() {
pos2.z -= fz;
}
});
+
+ // 向心力约束:保持节点在合理范围内
+ Object.keys(positions).forEach(id => {
+ const pos = positions[id];
+ const distFromCenter = Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z);
+ const maxAllowed = maxRadius * 1.5;
+ if (distFromCenter > maxAllowed) {
+ const scale = maxAllowed / distFromCenter;
+ pos.x *= scale;
+ pos.y *= scale;
+ pos.z *= scale;
+ }
+ });
}
// 创建节点(球体)
@@ -988,32 +909,34 @@ function init() {
const pos = positions[node.id];
if (!pos) return;
- // 计算球体半径
- const radius = 0.4 + Math.min(node.mention_count * 0.15, 1.5);
+ // 球体半径:mention_count 越高,球体越大
+ const mention = node.mention_count || 0;
+ const mentionNorm = mentionRange > 0 ? (mention - minMentions) / mentionRange : 0;
+ const radius = 0.5 + mentionNorm * 2.0; // 0.5 ~ 2.5
const color = typeColors[node.type] || defaultColor;
- // 根据 mention_count 计算 emissiveIntensity
- const baseEmissiveIntensity = 0.5 + Math.min(node.mention_count * 0.05, 0.3);
+ // 发光强度也与 mention_count 成正比
+ const emissiveIntensity = 0.3 + mentionNorm * 0.7;
// 创建球体
const geometry = new THREE.SphereGeometry(radius, 16, 12);
const material = new THREE.MeshPhongMaterial({
color: color,
emissive: color,
- emissiveIntensity: baseEmissiveIntensity,
+ emissiveIntensity: emissiveIntensity,
shininess: 30
});
const sphere = new THREE.Mesh(geometry, material);
sphere.position.set(pos.x, pos.y, pos.z);
- sphere.userData = { nodeId: node.id, nodeData: node, baseEmissive: 0.5 + Math.min(node.mention_count * 0.05, 0.3) };
+ sphere.userData = { nodeId: node.id, nodeData: node, baseEmissive: emissiveIntensity };
- // 创建外层发光球体(圆形光晕,替代方形Sprite)
- const glowRadius = radius * 1.15;
+ // 创建外层发光球体
+ const glowRadius = radius * 1.2 + mentionNorm * 0.5;
const glowGeometry = new THREE.SphereGeometry(glowRadius, 16, 12);
const glowMaterial = new THREE.MeshBasicMaterial({
color: color,
transparent: true,
- opacity: 0.15,
+ opacity: 0.12 + mentionNorm * 0.08,
side: THREE.BackSide,
blending: THREE.AdditiveBlending
});
@@ -1021,15 +944,12 @@ function init() {
sphere.add(glowSphere);
sphere.userData.glowSphere = glowSphere;
- // 创建标签(改进样式)
+ // 创建标签
const canvas = document.createElement('canvas');
const context = canvas.getContext('2d');
canvas.width = 256;
canvas.height = 64;
-
- // 显式清空画布为透明
context.clearRect(0, 0, canvas.width, canvas.height);
- // 文字带发光效果
context.font = 'bold 24px Courier New';
context.fillStyle = '#ffffff';
context.textAlign = 'center';
@@ -1091,9 +1011,7 @@ function init() {
const dist = Math.sqrt(pos.x*pos.x + pos.y*pos.y + pos.z*pos.z);
maxDist = Math.max(maxDist, dist);
});
- // 相机距离:节点范围加 20u 余量,但不超过 120u,不低于 25u
- const targetDist = Math.min(Math.max(maxDist + 15, 25), 120);
- // 如果有待聚焦节点且自动视角开启,飞过去;否则全局定位
+ const targetDist = Math.min(Math.max(maxDist + 20, 30), 150);
if (_pendingFocusNodeId) {
const focusId = _pendingFocusNodeId;
_pendingFocusNodeId = null;
@@ -1250,18 +1168,6 @@ function smoothReposition() {
};
});
- // 重新计算度数(从当前节点 + 边)
- const smoothDegrees = {};
- nodeMeshes.forEach(m => { smoothDegrees[m.userData.nodeId] = 0; });
- edges.forEach(e => {
- smoothDegrees[e.source] = (smoothDegrees[e.source] || 0) + 1;
- smoothDegrees[e.target] = (smoothDegrees[e.target] || 0) + 1;
- });
-
- function getSmoothIdealDist(id) {
- return 5 + ((smoothDegrees[id] || 0) * 0.8);
- }
-
// 用力导向模拟迭代 30 次平滑
for (let iter = 0; iter < 30; iter++) {
// 斥力
@@ -1274,11 +1180,8 @@ function smoothReposition() {
const dy = a.y - b.y;
const dz = a.z - b.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
- const idealDist = getSmoothIdealDist(ids[i]) + getSmoothIdealDist(ids[j]);
- const repulsionThreshold = idealDist * 1.5;
- if (dist < repulsionThreshold) {
- const degreeWeight = ((smoothDegrees[ids[i]] || 0) + (smoothDegrees[ids[j]] || 0)) * 0.5 + 1;
- const force = (0.08 * degreeWeight) / Math.max(dist, 0.5);
+ if (dist < 12) {
+ const force = 0.08 / Math.max(dist, 0.5);
a.x += (dx/dist) * force;
a.y += (dy/dist) * force;
a.z += (dz/dist) * force;
@@ -1297,9 +1200,7 @@ function smoothReposition() {
const dy = p2.y - p1.y;
const dz = p2.z - p1.z;
const dist = Math.sqrt(dx*dx + dy*dy + dz*dz) + 0.1;
- const idealDist = (getSmoothIdealDist(e.source) + getSmoothIdealDist(e.target)) * 0.8;
- const attractionThreshold = idealDist * 1.5;
- if (dist > attractionThreshold) {
+ if (dist > 8) {
const force = 0.03;
p1.x += (dx/dist) * force;
p1.y += (dy/dist) * force;
@@ -1881,92 +1782,18 @@ function smoothReposition() {
const chatCloseBtn = document.getElementById('chatCloseBtn');
const toggleChat = document.getElementById('toggle-chat');
const chatPanel = document.getElementById('chat-panel');
- const attachBtn = document.getElementById('attachBtn');
- const fileInput = document.getElementById('fileInput');
- const chatFilesContainer = document.getElementById('chatFilesContainer');
-
- let attachedFiles = [];
-
- function getFileIcon(name) {
- const lower = name.toLowerCase();
- if (lower.endsWith('.pdf')) return '📕';
- if (lower.endsWith('.docx') || lower.endsWith('.doc')) return '📘';
- if (lower.endsWith('.xlsx') || lower.endsWith('.xls') || lower.endsWith('.csv')) return '📊';
- if (lower.endsWith('.pptx') || lower.endsWith('.ppt')) return '📙';
- if (lower.endsWith('.jpg') || lower.endsWith('.jpeg') || lower.endsWith('.png') || lower.endsWith('.gif') || lower.endsWith('.webp')) return '🖼️';
- if (lower.endsWith('.json') || lower.endsWith('.xml') || lower.endsWith('.yaml') || lower.endsWith('.yml') || lower.endsWith('.toml')) return '⚙️';
- if (lower.endsWith('.py') || lower.endsWith('.js') || lower.endsWith('.ts') || lower.endsWith('.java') || lower.endsWith('.c') || lower.endsWith('.cpp') || lower.endsWith('.h') || lower.endsWith('.sh') || lower.endsWith('.bat') || lower.endsWith('.ps1')) return '💻';
- return '📄';
- }
-
- function formatFileSize(bytes) {
- if (bytes < 1024) return bytes + 'B';
- if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + 'KB';
- return (bytes / (1024 * 1024)).toFixed(1) + 'MB';
- }
-
- function getFileKey(file) {
- return file.name + '_' + file.size;
- }
-
- function addFileChip(file) {
- const key = getFileKey(file);
- if (attachedFiles.some(f => getFileKey(f) === key)) return;
- attachedFiles.push(file);
- renderFileChips();
- }
-
- function removeFileChip(file) {
- const key = getFileKey(file);
- attachedFiles = attachedFiles.filter(f => getFileKey(f) !== key);
- renderFileChips();
- }
-
- function renderFileChips() {
- chatFilesContainer.innerHTML = '';
- attachedFiles.forEach(file => {
- const chip = document.createElement('div');
- chip.className = 'chat-file-chip';
- const icon = getFileIcon(file.name);
- chip.innerHTML = `${icon}${file.name}${formatFileSize(file.size)}✕`;
- chip.querySelector('.file-remove').onclick = () => removeFileChip(file);
- chatFilesContainer.appendChild(chip);
- });
- }
-
- attachBtn.addEventListener('click', () => fileInput.click());
-
- fileInput.addEventListener('change', () => {
- Array.from(fileInput.files).forEach(addFileChip);
- fileInput.value = '';
- });
-
- chatMessages.addEventListener('dragover', e => {
- e.preventDefault();
- chatMessages.classList.add('drag-over');
- });
-
- chatMessages.addEventListener('dragleave', () => {
- chatMessages.classList.remove('drag-over');
- });
-
- chatMessages.addEventListener('drop', e => {
- e.preventDefault();
- chatMessages.classList.remove('drag-over');
- Array.from(e.dataTransfer.files).forEach(addFileChip);
- });
let chatCollapsed = false;
toggleChat.addEventListener('click', () => {
chatCollapsed = !chatCollapsed;
chatPanel.classList.toggle('collapsed', chatCollapsed);
- toggleChat.innerHTML = chatCollapsed ? "" : "";
+ toggleChat.textContent = chatCollapsed ? '📎' : '📌';
setTimeout(onWindowResize, 300);
});
chatCloseBtn.addEventListener('click', () => {
chatCollapsed = true;
chatPanel.classList.add('collapsed');
- toggleChat.innerHTML = '';
+ toggleChat.textContent = '📎';
setTimeout(onWindowResize, 300);
});
@@ -2016,77 +1843,11 @@ function smoothReposition() {
}
}
- async function readFileContent(file) {
- const name = file.name.toLowerCase();
-
- // PDF 文件:用 pdf.js 解析
- if (name.endsWith('.pdf')) {
- if (typeof pdfjsLib === 'undefined') throw new Error('PDF.js 未加载');
- pdfjsLib.GlobalWorkerOptions.workerSrc = 'https://cdnjs.cloudflare.com/ajax/libs/pdf.js/2.16.105/pdf.worker.min.js';
- const arrayBuffer = await file.arrayBuffer();
- const pdf = await pdfjsLib.getDocument({ data: arrayBuffer }).promise;
- const pages = [];
- for (let i = 1; i <= pdf.numPages; i++) {
- const page = await pdf.getPage(i);
- const content = await page.getTextContent();
- const text = content.items.map(item => item.str).join(' ');
- pages.push(text);
- }
- return pages.join('\n\n');
- }
-
- // Word 文件:用 mammoth.js 解析
- if (name.endsWith('.docx')) {
- if (typeof mammoth === 'undefined') throw new Error('mammoth.js 未加载');
- const arrayBuffer = await file.arrayBuffer();
- const result = await mammoth.extractRawText({ arrayBuffer });
- return result.value || '(Word 文档内容为空)';
- }
-
- // 其他文件:当作纯文本读取
- return new Promise((resolve, reject) => {
- const reader = new FileReader();
- reader.onload = () => resolve(reader.result);
- reader.onerror = () => reject(new Error('文件读取失败'));
- reader.readAsText(file);
- });
- }
-
- function buildFullMessage(text, files) {
- if (!files || files.length === 0) return text;
- let parts = [];
- files.forEach(file => {
- parts.push(`文件名:${file.name}`);
- parts.push(`内容:`);
- parts.push(file.content);
- parts.push('---');
- });
- parts.push(text);
- return parts.join('\n');
- }
-
async function sendChatMessage() {
- const files = attachedFiles.slice();
const msg = chatInput.value.trim();
- if (!msg && files.length === 0) return;
+ if (!msg) return;
chatInput.value = '';
- // 读取文件内容
- let fullMsg = msg;
- if (files.length > 0) {
- const fileReads = files.map(async (file) => {
- try {
- file.content = await readFileContent(file);
- } catch(e) {
- file.content = `[文件读取失败: ${file.name}] ${e.message}`;
- }
- });
- await Promise.all(fileReads);
- fullMsg = buildFullMessage(msg, files);
- // 清除文件芯片
- attachedFiles = [];
- renderFileChips();
- }
- addChatMessage('user', fullMsg);
+ addChatMessage('user', msg);
chatInput.disabled = true;
chatSendBtn.disabled = true;
chatStatus.textContent = '⏳ 处理中...';
@@ -2096,22 +1857,22 @@ function smoothReposition() {
const res = await fetch('/api/message', {
method: 'POST',
headers: {'Content-Type': 'application/json'},
- body: JSON.stringify({message: fullMsg})
+ body: JSON.stringify({message: msg})
});
const data = await res.json();
if (data.success) {
const reply = data.content || data.data?.content || '(无回复)';
addChatMessage('assistant', reply);
- chatStatus.innerHTML = '● 已连接';
+ chatStatus.textContent = '🟢 已连接';
chatStatus.className = 'chat-status';
} else {
addChatMessage('assistant', '❌ ' + (data.error || '请求失败'));
- chatStatus.innerHTML = '● 错误: ' + (data.error || 'unknown');
+ chatStatus.textContent = '🔴 错误: ' + (data.error || 'unknown');
chatStatus.className = 'chat-status error';
}
} catch(e) {
addChatMessage('assistant', '❌ 网络错误: ' + e.message);
- chatStatus.innerHTML = '● 网络错误';
+ chatStatus.textContent = '🔴 网络错误';
chatStatus.className = 'chat-status error';
}
chatInput.disabled = false;