feat: web channel support multiple message and picture display

This commit is contained in:
Saboteur7
2025-05-23 00:43:54 +08:00
parent 70d7e52df0
commit 5f7ade20dc
5 changed files with 848 additions and 265 deletions

View File

@@ -170,6 +170,24 @@
flex: 1;
}
#github-link {
display: flex;
align-items: center;
margin-left: 15px;
color: var(--text-color);
text-decoration: none;
transition: opacity 0.2s;
}
#github-link:hover {
opacity: 0.8;
}
#github-icon {
height: 24px;
width: 24px;
}
#messages {
flex: 1;
overflow-y: auto;
@@ -210,7 +228,7 @@
}
.user-container .avatar {
margin-left: 15px;
margin-left: 20px;
margin-right: 0;
}
@@ -219,6 +237,7 @@
}
.user-container .message {
padding: 13px 16px;
background-color: var(--bot-msg-bg);
border-radius: 10px;
margin-bottom: 8px;
@@ -266,7 +285,7 @@
}
.message {
padding: 12px 16px;
padding: 5px 16px;
border-radius: 10px;
margin-top: 0;
margin-bottom: 8px;
@@ -400,7 +419,7 @@
.message img {
max-width: 100%;
height: auto;
margin: 1em 0;
margin: 0 0;
}
.timestamp {
@@ -548,10 +567,12 @@
left: -260px;
height: 100%;
z-index: 1000;
transition: left 0.3s ease;
}
#sidebar.active {
left: 0;
box-shadow: 2px 0 10px rgba(0, 0, 0, 0.2);
}
#menu-toggle {
@@ -577,6 +598,23 @@
#header-logo {
height: 24px;
}
/* 添加遮罩层,当侧边栏打开时显示 */
#sidebar-overlay {
display: none;
position: fixed;
top: 0;
left: 0;
right: 0;
bottom: 0;
background-color: rgba(0, 0, 0, 0.5);
z-index: 999;
cursor: pointer;
}
#sidebar.active + #sidebar-overlay {
display: block;
}
}
/* Dark mode support */
@@ -717,7 +755,7 @@
</div>
</div>
</div>
<div id="sidebar-overlay"></div>
<div id="main-content">
<div id="chat-header">
<button id="menu-toggle">
@@ -725,6 +763,9 @@
</button>
<img id="header-logo" src="assets/logo.jpg" alt="AI Assistant Logo">
<div id="chat-title">AI 助手</div>
<a id="github-link" href="https://github.com/zhayujie/chatgpt-on-wechat" target="_blank" rel="noopener noreferrer">
<img id="github-icon" src="assets/github.png" alt="GitHub">
</a>
</div>
<div id="messages">
@@ -778,10 +819,17 @@
const newChatButton = document.getElementById('new-chat');
const chatHistory = document.getElementById('chat-history');
// 简化变量,只保留用户ID
let userId = 'user_' + Math.random().toString(36).substring(2, 10);
let currentSessionId = 'default_session'; // 使用固定会话ID
// 生成新的会话ID
function generateSessionId() {
return 'session_' + ([1e7]+-1e3+-4e3+-8e3+-1e11).replace(/[018]/g, c =>
(c ^ crypto.getRandomValues(new Uint8Array(1))[0] & 15 >> c / 4).toString(16)
);
}
// 生成初始会话ID
let sessionId = generateSessionId();
console.log('Session ID:', sessionId);
// 添加一个变量来跟踪输入法状态
let isComposing = false;
@@ -815,15 +863,18 @@
});
// 处理菜单切换
menuToggle.addEventListener('click', function() {
menuToggle.addEventListener('click', function(event) {
event.stopPropagation(); // 防止事件冒泡到 main-content
sidebar.classList.toggle('active');
});
// 处理新对话按钮 - 创建新的用户ID和清空当前对话
// 处理新对话按钮 - 创建新的会话ID和清空当前对话
newChatButton.addEventListener('click', function() {
// 生成新的用户ID
userId = 'user_' + Math.random().toString(36).substring(2, 10);
console.log('New conversation started with user ID:', userId);
// 生成新的会话ID
sessionId = generateSessionId();
// 将新的会话ID保存到全局变量供轮询函数使用
window.sessionId = sessionId;
console.log('New conversation started with new session ID:', sessionId);
// 清空聊天记录
clearChat();
@@ -881,27 +932,42 @@
input.style.height = '52px';
sendButton.disabled = true;
// 发送到服务器并等待响应
// 使用当前的全局会话ID
const currentSessionId = window.sessionId || sessionId;
// 发送到服务器并获取请求ID
axios({
method: 'post',
url: '/message',
data: {
user_id: userId,
session_id: currentSessionId, // 使用最新的会话ID
message: userMessage,
timestamp: timestamp.toISOString(),
session_id: currentSessionId
timestamp: timestamp.toISOString()
},
timeout: 120000 // 120秒超时
timeout: 10000 // 10秒超时
})
.then(response => {
// 移除加载消息
if (loadingContainer.parentNode) {
messagesDiv.removeChild(loadingContainer);
}
// 添加AI回复
if (response.data.reply) {
addBotMessage(response.data.reply, new Date());
if (response.data.status === "success") {
// 保存当前请求ID用于识别响应
const currentRequestId = response.data.request_id;
// 如果还没有开始轮询,则开始轮询
if (!window.isPolling) {
startPolling(currentSessionId);
}
// 将请求ID和加载容器关联起来
window.loadingContainers = window.loadingContainers || {};
window.loadingContainers[currentRequestId] = loadingContainer;
// 初始化请求的响应容器映射
window.requestContainers = window.requestContainers || {};
} else {
// 处理错误
if (loadingContainer.parentNode) {
messagesDiv.removeChild(loadingContainer);
}
addBotMessage("抱歉,发生了错误,请稍后再试。", new Date());
}
})
.catch(error => {
@@ -920,178 +986,108 @@
}
}
// 添加加载中的消息
function addLoadingMessage() {
const botContainer = document.createElement('div');
botContainer.className = 'bot-container loading-container';
// 修改轮询函数,确保正确处理多条回复
function startPolling(sessionId) {
if (window.isPolling) return;
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
window.isPolling = true;
console.log('Starting polling with session ID:', sessionId);
messageContainer.innerHTML = `
<div class="avatar bot-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-content">
<div class="message">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
`;
botContainer.appendChild(messageContainer);
messagesDiv.appendChild(botContainer);
scrollToBottom();
return botContainer;
}
// 替换 formatMessage 函数,使用 markdown-it 替代 marked
function formatMessage(content) {
try {
// 初始化 markdown-it 实例
const md = window.markdownit({
html: false, // 禁用 HTML 标签
xhtmlOut: false, // 使用 '/' 关闭单标签
breaks: true, // 将换行符转换为 <br>
linkify: true, // 自动将 URL 转换为链接
typographer: true, // 启用一些语言中性的替换和引号美化
highlight: function(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (e) {
console.error('Error highlighting code:', e);
function poll() {
if (!window.isPolling) return;
// 如果页面已关闭或导航离开,停止轮询
if (document.hidden) {
setTimeout(poll, 5000); // 页面不可见时降低轮询频率
return;
}
// 使用当前的会话ID而不是闭包中的sessionId
const currentSessionId = window.sessionId || sessionId;
axios({
method: 'post',
url: '/poll',
data: {
session_id: currentSessionId
},
timeout: 5000
})
.then(response => {
if (response.data.status === "success") {
if (response.data.has_content) {
console.log('Received response:', response.data);
// 获取请求ID和内容
const requestId = response.data.request_id;
const content = response.data.content;
const timestamp = new Date(response.data.timestamp * 1000);
// 检查是否有对应的加载容器
if (window.loadingContainers && window.loadingContainers[requestId]) {
// 移除加载容器
const loadingContainer = window.loadingContainers[requestId];
if (loadingContainer && loadingContainer.parentNode) {
messagesDiv.removeChild(loadingContainer);
}
// 删除已处理的加载容器引用
delete window.loadingContainers[requestId];
}
// 始终创建新的消息,无论是否是同一个请求的后续回复
addBotMessage(content, timestamp, requestId);
// 滚动到底部
scrollToBottom();
}
return hljs.highlightAuto(str).value;
}
});
// 渲染 Markdown
return md.render(content);
} catch (e) {
console.error('Error parsing markdown:', e);
// 如果解析失败,至少确保换行符正确显示
return content.replace(/\n/g, '<br>');
}
}
// 更新 applyHighlighting 函数
function applyHighlighting() {
try {
document.querySelectorAll('pre code').forEach((block) => {
// 确保代码块有正确的类
if (!block.classList.contains('hljs')) {
block.classList.add('hljs');
}
// 尝试获取语言
let language = '';
block.classList.forEach(cls => {
if (cls.startsWith('language-')) {
language = cls.replace('language-', '');
}
});
// 应用高亮
if (language && hljs.getLanguage(language)) {
try {
hljs.highlightBlock(block);
} catch (e) {
console.error('Error highlighting specific language:', e);
hljs.highlightAuto(block);
}
// 继续轮询使用原来的2秒间隔
setTimeout(poll, 2000);
} else {
hljs.highlightAuto(block);
// 处理错误但继续轮询
console.error('Error in polling response:', response.data.message);
setTimeout(poll, 3000);
}
})
.catch(error => {
console.error('Error polling for response:', error);
// 出错后继续轮询,但间隔更长
setTimeout(poll, 3000);
});
} catch (e) {
console.error('Error applying code highlighting:', e);
}
}
// 添加用户消息的函数 (保存到localStorage)
function addUserMessage(content, timestamp) {
// 显示消息
displayUserMessage(content, timestamp);
// 保存到localStorage
saveMessageToLocalStorage({
role: 'user',
content: content,
timestamp: timestamp.getTime()
});
// 开始轮询
poll();
}
// 添加机器人消息的函数 (保存到localStorage)
function addBotMessage(content, timestamp) {
// 添加机器人消息的函数 (保存到localStorage)增加requestId参数
function addBotMessage(content, timestamp, requestId) {
// 显示消息
displayBotMessage(content, timestamp);
displayBotMessage(content, timestamp, requestId);
// 保存到localStorage
saveMessageToLocalStorage({
role: 'assistant',
content: content,
timestamp: timestamp.getTime()
timestamp: timestamp.getTime(),
requestId: requestId
});
}
// 只显示用户消息而不保存到localStorage
function displayUserMessage(content, timestamp) {
const userContainer = document.createElement('div');
userContainer.className = 'user-container';
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
// 安全地格式化消息
let formattedContent;
try {
formattedContent = formatMessage(content);
} catch (e) {
console.error('Error formatting user message:', e);
formattedContent = `<p>${content.replace(/\n/g, '<br>')}</p>`;
}
messageContainer.innerHTML = `
<div class="avatar user-avatar">
<i class="fas fa-user"></i>
</div>
<div class="message-content">
<div class="message">${formattedContent}</div>
<div class="timestamp">${formatTimestamp(timestamp)}</div>
</div>
`;
userContainer.appendChild(messageContainer);
messagesDiv.appendChild(userContainer);
// 应用代码高亮
setTimeout(() => {
applyHighlighting();
}, 0);
scrollToBottom();
}
// 只显示机器人消息而不保存到localStorage
function displayBotMessage(content, timestamp) {
// 修改显示机器人消息的函数增加requestId参数
function displayBotMessage(content, timestamp, requestId) {
const botContainer = document.createElement('div');
botContainer.className = 'bot-container';
// 如果有requestId将其存储在数据属性中
if (requestId) {
botContainer.dataset.requestId = requestId;
}
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
// 确保时间戳是有效的 Date 对象
if (!(timestamp instanceof Date) || isNaN(timestamp)) {
timestamp = new Date();
}
// 安全地格式化消息
let formattedContent;
try {
@@ -1114,45 +1110,82 @@
botContainer.appendChild(messageContainer);
messagesDiv.appendChild(botContainer);
// 使用setTimeout确保DOM已更新并延长等待时间
// 应用代码高亮
setTimeout(() => {
try {
// 直接对新添加的消息应用高亮
const codeBlocks = botContainer.querySelectorAll('pre code');
codeBlocks.forEach(block => {
// 确保代码块有正确的类
if (!block.classList.contains('hljs')) {
block.classList.add('hljs');
}
// 尝试获取语言
let language = '';
block.classList.forEach(cls => {
if (cls.startsWith('language-')) {
language = cls.replace('language-', '');
}
});
// 应用高亮
if (language && hljs.getLanguage(language)) {
try {
hljs.highlightBlock(block);
} catch (e) {
console.error('Error highlighting specific language:', e);
hljs.highlightAuto(block);
}
} else {
hljs.highlightAuto(block);
}
});
} catch (e) {
console.error('Error in delayed highlighting:', e);
}
}, 100); // 增加延迟以确保DOM完全更新
applyHighlighting();
}, 0);
scrollToBottom();
}
// 处理响应
function handleResponse(requestId, content) {
// 获取该请求的加载容器
const loadingContainer = window.loadingContainers && window.loadingContainers[requestId];
// 如果有加载容器,移除它
if (loadingContainer && loadingContainer.parentNode) {
messagesDiv.removeChild(loadingContainer);
delete window.loadingContainers[requestId];
}
// 为每个请求创建一个新的消息容器
if (!window.requestContainers[requestId]) {
window.requestContainers[requestId] = createBotMessageContainer(content, new Date());
} else {
// 更新现有消息容器
updateBotMessageContent(window.requestContainers[requestId], content);
}
// 保存消息到localStorage
saveMessageToLocalStorage({
role: 'assistant',
content: content,
timestamp: new Date().getTime(),
request_id: requestId
});
}
// 修改createBotMessageContainer函数使其返回创建的容器
function createBotMessageContainer(content, timestamp) {
const botContainer = document.createElement('div');
botContainer.className = 'bot-container';
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
// 安全地格式化消息
let formattedContent;
try {
formattedContent = formatMessage(content);
} catch (e) {
console.error('Error formatting bot message:', e);
formattedContent = `<p>${content.replace(/\n/g, '<br>')}</p>`;
}
messageContainer.innerHTML = `
<div class="avatar bot-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-content">
<div class="message">${formattedContent}</div>
<div class="timestamp">${formatTimestamp(timestamp)}</div>
</div>
`;
botContainer.appendChild(messageContainer);
messagesDiv.appendChild(botContainer);
// 应用代码高亮
setTimeout(() => {
applyHighlighting();
}, 0);
scrollToBottom();
return botContainer;
}
// 格式化时间戳
function formatTimestamp(date) {
return date.toLocaleTimeString();
@@ -1223,8 +1256,8 @@
});
});
// 清空localStorage中的消息 - 使用用户ID作为键
localStorage.setItem(`chatMessages_${userId}`, JSON.stringify([]));
// 清空localStorage中的消息 - 使用会话ID作为键
localStorage.setItem(`chatMessages_${sessionId}`, JSON.stringify([]));
// 在移动设备上关闭侧边栏
if (window.innerWidth <= 768) {
@@ -1232,26 +1265,242 @@
}
}
// 从localStorage加载消息 - 使用用户ID作为键
// 从localStorage加载消息 - 使用会话ID作为键
function loadMessagesFromLocalStorage() {
try {
return JSON.parse(localStorage.getItem(`chatMessages_${userId}`) || '[]');
return JSON.parse(localStorage.getItem(`chatMessages_${sessionId}`) || '[]');
} catch (error) {
console.error('Error loading messages from localStorage:', error);
return [];
}
}
// 保存消息到localStorage - 使用用户ID作为键
// 保存消息到localStorage - 使用会话ID作为键
function saveMessageToLocalStorage(message) {
try {
const messages = loadMessagesFromLocalStorage();
messages.push(message);
localStorage.setItem(`chatMessages_${userId}`, JSON.stringify(messages));
localStorage.setItem(`chatMessages_${sessionId}`, JSON.stringify(messages));
} catch (error) {
console.error('Error saving message to localStorage:', error);
}
}
// 添加用户消息的函数 (保存到localStorage)
function addUserMessage(content, timestamp) {
// 显示消息
displayUserMessage(content, timestamp);
// 保存到localStorage
saveMessageToLocalStorage({
role: 'user',
content: content,
timestamp: timestamp.getTime()
});
}
// 只显示用户消息而不保存到localStorage
function displayUserMessage(content, timestamp) {
const userContainer = document.createElement('div');
userContainer.className = 'user-container';
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
// 安全地格式化消息
let formattedContent;
try {
formattedContent = formatMessage(content);
} catch (e) {
console.error('Error formatting user message:', e);
formattedContent = `<p>${content.replace(/\n/g, '<br>')}</p>`;
}
messageContainer.innerHTML = `
<div class="avatar user-avatar">
<i class="fas fa-user"></i>
</div>
<div class="message-content">
<div class="message">${formattedContent}</div>
<div class="timestamp">${formatTimestamp(timestamp)}</div>
</div>
`;
userContainer.appendChild(messageContainer);
messagesDiv.appendChild(userContainer);
// 应用代码高亮
setTimeout(() => {
applyHighlighting();
}, 0);
scrollToBottom();
}
// 添加加载中的消息
function addLoadingMessage() {
const botContainer = document.createElement('div');
botContainer.className = 'bot-container loading-container';
const messageContainer = document.createElement('div');
messageContainer.className = 'message-container';
messageContainer.innerHTML = `
<div class="avatar bot-avatar">
<i class="fas fa-robot"></i>
</div>
<div class="message-content">
<div class="message">
<div class="typing-indicator">
<span></span>
<span></span>
<span></span>
</div>
</div>
</div>
`;
botContainer.appendChild(messageContainer);
messagesDiv.appendChild(botContainer);
scrollToBottom();
return botContainer;
}
// 自动将链接设置为在新标签页打开
const externalLinksPlugin = (md) => {
// 保存原始的链接渲染器
const defaultRender = md.renderer.rules.link_open || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
// 重写链接渲染器
md.renderer.rules.link_open = function(tokens, idx, options, env, self) {
// 为所有链接添加 target="_blank" 和 rel="noopener noreferrer"
const token = tokens[idx];
// 添加 target="_blank" 属性
token.attrPush(['target', '_blank']);
// 添加 rel="noopener noreferrer" 以提高安全性
token.attrPush(['rel', 'noopener noreferrer']);
// 调用默认渲染器
return defaultRender(tokens, idx, options, env, self);
};
};
// 替换 formatMessage 函数,使用 markdown-it 替代 marked
function formatMessage(content) {
try {
// 初始化 markdown-it 实例
const md = window.markdownit({
html: false, // 禁用 HTML 标签
xhtmlOut: false, // 使用 '/' 关闭单标签
breaks: true, // 将换行符转换为 <br>
linkify: true, // 自动将 URL 转换为链接
typographer: true, // 启用一些语言中性的替换和引号美化
highlight: function(str, lang) {
if (lang && hljs.getLanguage(lang)) {
try {
return hljs.highlight(str, { language: lang }).value;
} catch (e) {
console.error('Error highlighting code:', e);
}
}
return hljs.highlightAuto(str).value;
}
});
// 自动将图片URL转换为图片标签
const autoImagePlugin = (md) => {
const defaultRender = md.renderer.rules.text || function(tokens, idx, options, env, self) {
return self.renderToken(tokens, idx, options);
};
md.renderer.rules.text = function(tokens, idx, options, env, self) {
const token = tokens[idx];
const text = token.content.trim();
// 检测是否完全是一个图片链接 (以https://开头,以图片扩展名结尾)
const imageRegex = /^https?:\/\/\S+\.(jpg|jpeg|png|gif|webp)(\?\S*)?$/i;
if (imageRegex.test(text)) {
return `<img src="${text}" alt="Image" style="max-width: 100%; height: auto;" />`;
}
// 使用默认渲染
return defaultRender(tokens, idx, options, env, self);
};
};
// 应用插件
md.use(autoImagePlugin);
// 应用外部链接插件
md.use(externalLinksPlugin);
// 渲染 Markdown
return md.render(content);
} catch (e) {
console.error('Error parsing markdown:', e);
// 如果解析失败,至少确保换行符正确显示
return content.replace(/\n/g, '<br>');
}
}
// 更新 applyHighlighting 函数
function applyHighlighting() {
try {
document.querySelectorAll('pre code').forEach((block) => {
// 确保代码块有正确的类
if (!block.classList.contains('hljs')) {
block.classList.add('hljs');
}
// 尝试获取语言
let language = '';
block.classList.forEach(cls => {
if (cls.startsWith('language-')) {
language = cls.replace('language-', '');
}
});
// 应用高亮
if (language && hljs.getLanguage(language)) {
try {
hljs.highlightBlock(block);
} catch (e) {
console.error('Error highlighting specific language:', e);
hljs.highlightAuto(block);
}
} else {
hljs.highlightAuto(block);
}
});
} catch (e) {
console.error('Error applying code highlighting:', e);
}
}
// 在 #main-content 上添加点击事件,用于关闭侧边栏
document.getElementById('main-content').addEventListener('click', function(event) {
// 只在移动视图下且侧边栏打开时处理
if (window.innerWidth <= 768 && sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
}
});
// 阻止侧边栏内部点击事件冒泡到 main-content
document.getElementById('sidebar').addEventListener('click', function(event) {
event.stopPropagation();
});
// 添加遮罩层点击事件,用于关闭侧边栏
document.getElementById('sidebar-overlay').addEventListener('click', function() {
if (sidebar.classList.contains('active')) {
sidebar.classList.remove('active');
}
});
</script>
</body>
</html>

Binary file not shown.

After

Width:  |  Height:  |  Size: 3.4 KiB

View File

@@ -2,6 +2,7 @@ import sys
import time
import web
import json
import uuid
from queue import Queue, Empty
from bridge.context import *
from bridge.reply import Reply, ReplyType
@@ -12,6 +13,8 @@ from common.singleton import singleton
from config import conf
import os
import mimetypes # 添加这行来处理MIME类型
import threading
import logging
class WebMessage(ChatMessage):
def __init__(
@@ -43,39 +46,54 @@ class WebChannel(ChatChannel):
def __init__(self):
super().__init__()
self.message_queues = {} # 为每个用户存储一个消息队列
self.msg_id_counter = 0 # 添加消息ID计数器
self.session_queues = {} # 存储session_id到队列的映射
self.request_to_session = {} # 存储request_id到session_id的映射
def _generate_msg_id(self):
"""生成唯一的消息ID"""
self.msg_id_counter += 1
return str(int(time.time())) + str(self.msg_id_counter)
def _generate_request_id(self):
"""生成唯一的请求ID"""
return str(uuid.uuid4())
def send(self, reply: Reply, context: Context):
try:
if reply.type in self.NOT_SUPPORT_REPLYTYPE:
logger.warning(f"Web channel doesn't support {reply.type} yet")
return
if reply.type == ReplyType.IMAGE_URL:
time.sleep(0.5)
# 获取请求ID和会话ID
request_id = context.get("request_id", None)
if not request_id:
logger.error("No request_id found in context, cannot send message")
return
# 获取用户ID
user_id = context.get("receiver", None)
if not user_id:
logger.error("No receiver found in context, cannot send message")
# 通过request_id获取session_id
session_id = self.request_to_session.get(request_id)
if not session_id:
logger.error(f"No session_id found for request {request_id}")
return
# 检查是否有响应队列
response_queue = context.get("response_queue", None)
if response_queue:
# 直接将响应放入队列
# 检查是否有会话队列
if session_id in self.session_queues:
# 创建响应数据包含请求ID以区分不同请求的响应
response_data = {
"type": str(reply.type),
"content": reply.content,
"timestamp": time.time()
"timestamp": time.time(),
"request_id": request_id
}
response_queue.put(response_data)
logger.debug(f"Response sent to queue for user {user_id}")
self.session_queues[session_id].put(response_data)
logger.debug(f"Response sent to queue for session {session_id}, request {request_id}")
else:
logger.warning(f"No response queue found for user {user_id}, response dropped")
logger.warning(f"No response queue found for session {session_id}, response dropped")
except Exception as e:
logger.error(f"Error in send method: {e}")
@@ -83,57 +101,83 @@ class WebChannel(ChatChannel):
def post_message(self):
"""
Handle incoming messages from users via POST request.
Returns a request_id for tracking this specific request.
"""
try:
data = web.data() # 获取原始POST数据
json_data = json.loads(data)
user_id = json_data.get('user_id', 'default_user')
prompt = json_data.get('message', '')
session_id = json_data.get('session_id', f'session_{int(time.time())}')
except json.JSONDecodeError:
return json.dumps({"status": "error", "message": "Invalid JSON"})
except Exception as e:
return json.dumps({"status": "error", "message": str(e)})
if not prompt:
return json.dumps({"status": "error", "message": "No message provided"})
prompt = json_data.get('message', '')
try:
msg_id = self._generate_msg_id()
web_message = WebMessage(
msg_id=msg_id,
content=prompt,
from_user_id=user_id,
to_user_id="Chatgpt",
other_user_id=user_id
)
# 生成请求ID
request_id = self._generate_request_id()
context = self._compose_context(ContextType.TEXT, prompt, msg=web_message)
if not context:
return json.dumps({"status": "error", "message": "Failed to process message"})
# 创建一个响应队列
response_queue = Queue()
# 将请求ID与会话ID关联
self.request_to_session[request_id] = session_id
# 确保上下文包含必要的信息
context["isgroup"] = False
context["receiver"] = user_id
context["session_id"] = user_id
context["response_queue"] = response_queue
# 发送消息到处理队列
self.produce(context)
# 确保会话队列存在
if session_id not in self.session_queues:
self.session_queues[session_id] = Queue()
# 等待响应最多等待30秒
try:
response = response_queue.get(timeout=120)
return json.dumps({"status": "success", "reply": response["content"]})
except Empty:
return json.dumps({"status": "error", "message": "Response timeout"})
# 创建消息对象
msg = WebMessage(self._generate_msg_id(), prompt)
msg.from_user_id = session_id # 使用会话ID作为用户ID
# 创建上下文
context = self._compose_context(ContextType.TEXT, prompt, msg=msg)
# 添加必要的字段
context["session_id"] = session_id
context["request_id"] = request_id
context["isgroup"] = False # 添加 isgroup 字段
context["receiver"] = session_id # 添加 receiver 字段
# 异步处理消息 - 只传递上下文
threading.Thread(target=self.produce, args=(context,)).start()
# 返回请求ID
return json.dumps({"status": "success", "request_id": request_id})
except Exception as e:
logger.error(f"Error processing message: {e}")
return json.dumps({"status": "error", "message": "Internal server error"})
return json.dumps({"status": "error", "message": str(e)})
def poll_response(self):
"""
Poll for responses using the session_id.
"""
try:
# 不记录轮询请求的日志
web.ctx.log_request = False
data = web.data()
json_data = json.loads(data)
session_id = json_data.get('session_id')
if not session_id or session_id not in self.session_queues:
return json.dumps({"status": "error", "message": "Invalid session ID"})
# 尝试从队列获取响应,不等待
try:
# 使用peek而不是get这样如果前端没有成功处理下次还能获取到
response = self.session_queues[session_id].get(block=False)
# 返回响应包含请求ID以区分不同请求
return json.dumps({
"status": "success",
"has_content": True,
"content": response["content"],
"request_id": response["request_id"],
"timestamp": response["timestamp"]
})
except Empty:
# 没有新响应
return json.dumps({"status": "success", "has_content": False})
except Exception as e:
logger.error(f"Error polling response: {e}")
return json.dumps({"status": "error", "message": str(e)})
def chat_page(self):
"""Serve the chat HTML page."""
@@ -153,6 +197,7 @@ class WebChannel(ChatChannel):
urls = (
'/', 'RootHandler', # 添加根路径处理器
'/message', 'MessageHandler',
'/poll', 'PollHandler', # 添加轮询处理器
'/chat', 'ChatHandler',
'/assets/(.*)', 'AssetsHandler', # 匹配 /assets/任何路径
)
@@ -163,6 +208,12 @@ class WebChannel(ChatChannel):
import io
from contextlib import redirect_stdout
# 配置web.py的日志级别为ERROR只显示错误
logging.getLogger("web").setLevel(logging.ERROR)
# 禁用web.httpserver的日志
logging.getLogger("web.httpserver").setLevel(logging.ERROR)
# 临时重定向标准输出捕获web.py的启动消息
with redirect_stdout(io.StringIO()):
web.httpserver.runsimple(app.wsgifunc(), ("0.0.0.0", port))
@@ -179,6 +230,11 @@ class MessageHandler:
return WebChannel().post_message()
class PollHandler:
def POST(self):
return WebChannel().poll_response()
class ChatHandler:
def GET(self):
# 正常返回聊天页面

View File

@@ -1,5 +1,5 @@
{
"channel_type": "wx",
"channel_type": "web",
"model": "",
"open_ai_api_key": "YOUR API KEY",
"claude_api_key": "YOUR API KEY",

278
simple_login_form_test.html Normal file
View File

@@ -0,0 +1,278 @@
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<title>登录</title>
<style>
/* Reset and base */
* {
box-sizing: border-box;
}
body, html {
margin: 0; padding: 0; height: 100%;
font-family: "Segoe UI", Tahoma, Geneva, Verdana, sans-serif;
background: linear-gradient(135deg, #667eea, #764ba2);
display: flex;
justify-content: center;
align-items: center;
}
.login-container {
background: rgba(255, 255, 255, 0.95);
padding: 2.5rem 3rem;
border-radius: 12px;
box-shadow: 0 8px 24px rgba(0,0,0,0.15);
width: 100%;
max-width: 400px;
}
h2 {
margin-bottom: 1.5rem;
color: #333;
text-align: center;
}
form {
display: flex;
flex-direction: column;
}
label {
font-weight: 600;
margin-bottom: 0.4rem;
color: #444;
}
input[type="text"],
input[type="email"],
input[type="password"] {
padding: 0.6rem 0.8rem;
font-size: 1rem;
border: 1.8px solid #ccc;
border-radius: 6px;
transition: border-color 0.3s ease;
outline-offset: 2px;
}
input[type="text"]:focus,
input[type="email"]:focus,
input[type="password"]:focus {
border-color: #667eea;
}
.password-wrapper {
position: relative;
display: flex;
align-items: center;
}
.toggle-password {
position: absolute;
right: 0.8rem;
background: none;
border: none;
cursor: pointer;
font-size: 1rem;
color: #667eea;
user-select: none;
}
.login-button {
margin-top: 1.5rem;
padding: 0.75rem;
font-size: 1.1rem;
font-weight: 700;
background-color: #667eea;
color: white;
border: none;
border-radius: 8px;
cursor: pointer;
transition: background-color 0.3s ease;
}
.login-button:disabled {
background-color: #a3a9f7;
cursor: not-allowed;
}
.forgot-password {
margin-top: 1rem;
text-align: right;
}
.forgot-password a {
color: #667eea;
text-decoration: none;
font-size: 0.9rem;
}
.forgot-password a:hover {
text-decoration: underline;
}
.error-message {
margin-top: 1rem;
color: #d93025;
font-weight: 600;
text-align: center;
}
.loading-spinner {
border: 3px solid #f3f3f3;
border-top: 3px solid #667eea;
border-radius: 50%;
width: 20px;
height: 20px;
animation: spin 1s linear infinite;
display: inline-block;
vertical-align: middle;
margin-left: 8px;
}
@keyframes spin {
0% { transform: rotate(0deg);}
100% { transform: rotate(360deg);}
}
/* Responsive */
@media (max-width: 480px) {
.login-container {
margin: 1rem;
padding: 2rem 1.5rem;
}
}
</style>
</head>
<body>
<div class="login-container" role="main" aria-label="登录表单">
<h2>用户登录</h2>
<form id="loginForm" novalidate>
<label for="usernameEmail">用户名或邮箱</label>
<input type="text" id="usernameEmail" name="usernameEmail" autocomplete="username" placeholder="请输入用户名或邮箱" required aria-describedby="usernameEmailError" />
<div id="usernameEmailError" class="error-message" aria-live="polite"></div>
<label for="password" style="margin-top:1rem;">密码</label>
<div class="password-wrapper">
<input type="password" id="password" name="password" autocomplete="current-password" placeholder="请输入密码" required minlength="6" aria-describedby="passwordError" />
<button type="button" class="toggle-password" aria-label="切换密码可见性" title="切换密码可见性">👁️</button>
</div>
<div id="passwordError" class="error-message" aria-live="polite"></div>
<button type="submit" id="loginButton" class="login-button" disabled>登录</button>
<div class="forgot-password">
<a href="/forgot-password.html" target="_blank" rel="noopener noreferrer">忘记密码?</a>
</div>
<div id="submitError" class="error-message" aria-live="polite"></div>
</form>
</div>
<script>
(function(){
const usernameEmailInput = document.getElementById('usernameEmail');
const passwordInput = document.getElementById('password');
const loginButton = document.getElementById('loginButton');
const usernameEmailError = document.getElementById('usernameEmailError');
const passwordError = document.getElementById('passwordError');
const submitError = document.getElementById('submitError');
const togglePasswordBtn = document.querySelector('.toggle-password');
const form = document.getElementById('loginForm');
// 校验用户名或邮箱格式
function validateUsernameEmail(value) {
if (!value.trim()) {
return "用户名或邮箱不能为空";
}
// 简单邮箱正则
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/;
// 用户名规则允许字母数字下划线长度3-20
const usernameRegex = /^[a-zA-Z0-9_]{3,20}$/;
if (emailRegex.test(value)) {
return "";
} else if (usernameRegex.test(value)) {
return "";
} else {
return "请输入有效的用户名或邮箱格式";
}
}
// 校验密码格式
function validatePassword(value) {
if (!value) {
return "密码不能为空";
}
if (value.length < 6) {
return "密码长度不能少于6位";
}
return "";
}
// 实时校验并更新错误提示和按钮状态
function validateForm() {
const usernameEmailVal = usernameEmailInput.value;
const passwordVal = passwordInput.value;
const usernameEmailErrMsg = validateUsernameEmail(usernameEmailVal);
const passwordErrMsg = validatePassword(passwordVal);
usernameEmailError.textContent = usernameEmailErrMsg;
passwordError.textContent = passwordErrMsg;
submitError.textContent = "";
const isValid = !usernameEmailErrMsg && !passwordErrMsg;
loginButton.disabled = !isValid;
return isValid;
}
// 密码可见切换
togglePasswordBtn.addEventListener('click', () => {
if (passwordInput.type === 'password') {
passwordInput.type = 'text';
togglePasswordBtn.textContent = '🙈';
togglePasswordBtn.setAttribute('aria-label', '隐藏密码');
togglePasswordBtn.setAttribute('title', '隐藏密码');
} else {
passwordInput.type = 'password';
togglePasswordBtn.textContent = '👁️';
togglePasswordBtn.setAttribute('aria-label', '显示密码');
togglePasswordBtn.setAttribute('title', '显示密码');
}
});
// 监听输入事件实时校验
usernameEmailInput.addEventListener('input', validateForm);
passwordInput.addEventListener('input', validateForm);
// 模拟登录请求
function fakeLoginRequest(data) {
return new Promise((resolve, reject) => {
setTimeout(() => {
// 模拟用户名/邮箱为 "user" 或 "user@example.com" 且密码为 "password123" 才成功
const validUsers = ["user", "user@example.com"];
if (validUsers.includes(data.usernameEmail.toLowerCase()) && data.password === "password123") {
resolve();
} else {
reject(new Error("用户名或密码错误"));
}
}, 1500);
});
}
// 表单提交处理
form.addEventListener('submit', async (e) => {
e.preventDefault();
if (!validateForm()) return;
loginButton.disabled = true;
const originalText = loginButton.textContent;
loginButton.textContent = "登录中";
const spinner = document.createElement('span');
spinner.className = 'loading-spinner';
loginButton.appendChild(spinner);
submitError.textContent = "";
try {
await fakeLoginRequest({
usernameEmail: usernameEmailInput.value.trim(),
password: passwordInput.value
});
// 登录成功跳转此处用alert模拟
alert("登录成功,跳转到用户主页");
// window.location.href = "/user-home.html"; // 实际跳转
} catch (err) {
submitError.textContent = err.message;
} finally {
loginButton.disabled = false;
loginButton.textContent = originalText;
}
});
// 页面加载时校验一次,防止缓存值导致按钮状态异常
validateForm();
})();
<\/script>
<\/body>
<\/html>