mirror of
https://github.com/zhayujie/chatgpt-on-wechat.git
synced 2026-07-19 21:07:28 +08:00
Merge pull request #2865 from core-power/feat/web-console-improvements
feat: message management and code block enhancements
This commit is contained in:
@@ -291,7 +291,7 @@ class ConversationStore:
|
|||||||
|
|
||||||
def __init__(self, db_path: Path):
|
def __init__(self, db_path: Path):
|
||||||
self._db_path = db_path
|
self._db_path = db_path
|
||||||
self._lock = threading.Lock()
|
self._lock = threading.RLock() # Use RLock to allow reentrant locking
|
||||||
self._init_db()
|
self._init_db()
|
||||||
|
|
||||||
# ------------------------------------------------------------------
|
# ------------------------------------------------------------------
|
||||||
@@ -524,6 +524,109 @@ class ConversationStore:
|
|||||||
finally:
|
finally:
|
||||||
conn.close()
|
conn.close()
|
||||||
|
|
||||||
|
def delete_message_pair(self, session_id: str, user_seq: int, delete_user: bool = True, cascade: bool = False) -> int:
|
||||||
|
"""Delete a user message and/or its corresponding assistant reply.
|
||||||
|
|
||||||
|
The assistant reply is identified as all messages between user_seq
|
||||||
|
and the next visible user message (or end of session).
|
||||||
|
|
||||||
|
Args:
|
||||||
|
session_id: Session identifier.
|
||||||
|
user_seq: The seq number of the user message.
|
||||||
|
delete_user: If True (default), delete the user message too.
|
||||||
|
If False, only delete assistant reply (for regenerate scenarios).
|
||||||
|
cascade: If True, also delete all subsequent turns after this one.
|
||||||
|
Used by edit-message which removes this turn and everything after.
|
||||||
|
|
||||||
|
Returns:
|
||||||
|
Number of message rows deleted.
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
conn = self._connect()
|
||||||
|
try:
|
||||||
|
with conn:
|
||||||
|
# Verify this is a user message
|
||||||
|
row = conn.execute(
|
||||||
|
"SELECT role FROM messages WHERE session_id = ? AND seq = ?",
|
||||||
|
(session_id, user_seq),
|
||||||
|
).fetchone()
|
||||||
|
if not row or row[0] != "user":
|
||||||
|
return 0
|
||||||
|
|
||||||
|
if cascade:
|
||||||
|
# Delete from this message to end of session
|
||||||
|
start_seq = user_seq if delete_user else user_seq + 1
|
||||||
|
end_seq_row = conn.execute(
|
||||||
|
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||||
|
else:
|
||||||
|
# Find the next visible user message seq (exclude tool_result)
|
||||||
|
# Use batched query to avoid loading too many rows at once
|
||||||
|
next_user_seq = None
|
||||||
|
batch_size = 100
|
||||||
|
offset = 0
|
||||||
|
while True:
|
||||||
|
batch = conn.execute(
|
||||||
|
"""
|
||||||
|
SELECT seq, content FROM messages
|
||||||
|
WHERE session_id = ? AND seq > ? AND role = 'user'
|
||||||
|
ORDER BY seq ASC
|
||||||
|
LIMIT ? OFFSET ?
|
||||||
|
""",
|
||||||
|
(session_id, user_seq, batch_size, offset),
|
||||||
|
).fetchall()
|
||||||
|
if not batch:
|
||||||
|
break
|
||||||
|
for seq, content in batch:
|
||||||
|
try:
|
||||||
|
content_obj = json.loads(content)
|
||||||
|
except Exception:
|
||||||
|
content_obj = content
|
||||||
|
if _is_visible_user_message(content_obj):
|
||||||
|
next_user_seq = seq
|
||||||
|
break
|
||||||
|
if next_user_seq is not None:
|
||||||
|
break
|
||||||
|
offset += batch_size
|
||||||
|
|
||||||
|
# Determine the end boundary for deletion
|
||||||
|
if next_user_seq is not None:
|
||||||
|
end_seq = next_user_seq
|
||||||
|
else:
|
||||||
|
end_seq_row = conn.execute(
|
||||||
|
"SELECT MAX(seq) FROM messages WHERE session_id = ?",
|
||||||
|
(session_id,),
|
||||||
|
).fetchone()
|
||||||
|
end_seq = (end_seq_row[0] or user_seq) + 1
|
||||||
|
|
||||||
|
# Determine the start boundary for deletion
|
||||||
|
start_seq = user_seq if delete_user else user_seq + 1
|
||||||
|
|
||||||
|
# Delete messages from start_seq to end_seq (exclusive)
|
||||||
|
cur = conn.execute(
|
||||||
|
"DELETE FROM messages WHERE session_id = ? AND seq >= ? AND seq < ?",
|
||||||
|
(session_id, start_seq, end_seq),
|
||||||
|
)
|
||||||
|
deleted = cur.rowcount
|
||||||
|
|
||||||
|
# Update session msg_count
|
||||||
|
conn.execute(
|
||||||
|
"""
|
||||||
|
UPDATE sessions
|
||||||
|
SET msg_count = (
|
||||||
|
SELECT COUNT(*) FROM messages WHERE session_id = ?
|
||||||
|
)
|
||||||
|
WHERE session_id = ?
|
||||||
|
""",
|
||||||
|
(session_id, session_id),
|
||||||
|
)
|
||||||
|
|
||||||
|
return deleted
|
||||||
|
finally:
|
||||||
|
conn.close()
|
||||||
|
|
||||||
def prune_scheduled_messages(
|
def prune_scheduled_messages(
|
||||||
self,
|
self,
|
||||||
session_id: str,
|
session_id: str,
|
||||||
@@ -1053,3 +1156,4 @@ def get_conversation_store() -> ConversationStore:
|
|||||||
_store_instance = ConversationStore(db_path)
|
_store_instance = ConversationStore(db_path)
|
||||||
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
logger.debug(f"[ConversationStore] Using shared DB at: {db_path}")
|
||||||
return _store_instance
|
return _store_instance
|
||||||
|
|
||||||
|
|||||||
@@ -1399,3 +1399,171 @@
|
|||||||
.agent-cancelled-tag {
|
.agent-cancelled-tag {
|
||||||
font-style: italic;
|
font-style: italic;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Code Block Enhancements
|
||||||
|
===================================================================== */
|
||||||
|
.code-block-wrapper {
|
||||||
|
position: relative;
|
||||||
|
margin: 1em 0;
|
||||||
|
border-radius: 8px;
|
||||||
|
overflow: hidden;
|
||||||
|
background: #f8f9fa;
|
||||||
|
border: 1px solid #e2e8f0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .code-block-wrapper {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.5em 1em;
|
||||||
|
background: #e2e8f0;
|
||||||
|
border-bottom: 1px solid #cbd5e1;
|
||||||
|
font-size: 0.85em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .code-block-header {
|
||||||
|
background: #0f172a;
|
||||||
|
border-bottom-color: #334155;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-lang {
|
||||||
|
color: #64748b;
|
||||||
|
font-weight: 500;
|
||||||
|
text-transform: lowercase;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .code-block-lang {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn {
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: #64748b;
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25em 0.5em;
|
||||||
|
border-radius: 4px;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-copy-btn:hover {
|
||||||
|
background: rgba(100, 116, 139, 0.1);
|
||||||
|
color: #475569;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .code-copy-btn {
|
||||||
|
color: #94a3b8;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .code-copy-btn:hover {
|
||||||
|
background: rgba(148, 163, 184, 0.1);
|
||||||
|
color: #cbd5e1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.code-block-wrapper pre {
|
||||||
|
margin: 0;
|
||||||
|
border-radius: 0;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Drag and Drop Overlay
|
||||||
|
===================================================================== */
|
||||||
|
.drag-overlay {
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: rgba(59, 130, 246, 0.1);
|
||||||
|
backdrop-filter: blur(2px);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 9999;
|
||||||
|
pointer-events: none;
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay.active {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay.hidden {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay-content {
|
||||||
|
background: white;
|
||||||
|
border: 3px dashed #3b82f6;
|
||||||
|
border-radius: 16px;
|
||||||
|
padding: 3em 4em;
|
||||||
|
text-align: center;
|
||||||
|
box-shadow: 0 20px 25px -5px rgba(0, 0, 0, 0.1);
|
||||||
|
animation: bounce 1s ease infinite;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .drag-overlay-content {
|
||||||
|
background: #1e293b;
|
||||||
|
border-color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay-content i {
|
||||||
|
font-size: 4em;
|
||||||
|
color: #3b82f6;
|
||||||
|
margin-bottom: 0.5em;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .drag-overlay-content i {
|
||||||
|
color: #60a5fa;
|
||||||
|
}
|
||||||
|
|
||||||
|
.drag-overlay-content p {
|
||||||
|
font-size: 1.5em;
|
||||||
|
font-weight: 600;
|
||||||
|
color: #1e293b;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dark .drag-overlay-content p {
|
||||||
|
color: #f1f5f9;
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes bounce {
|
||||||
|
0%, 100% { transform: translateY(0); }
|
||||||
|
50% { transform: translateY(-10px); }
|
||||||
|
}
|
||||||
|
|
||||||
|
/* =====================================================================
|
||||||
|
Message Action Buttons
|
||||||
|
===================================================================== */
|
||||||
|
.edit-msg-btn,
|
||||||
|
.delete-msg-btn,
|
||||||
|
.regenerate-msg-btn {
|
||||||
|
opacity: 0;
|
||||||
|
transition: opacity 0.2s, color 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.user-message-group:hover .edit-msg-btn,
|
||||||
|
.user-message-group:hover .delete-msg-btn,
|
||||||
|
.flex.gap-3:hover .regenerate-msg-btn,
|
||||||
|
.flex.gap-3:hover .delete-msg-btn {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.edit-msg-btn:hover,
|
||||||
|
.regenerate-msg-btn:hover {
|
||||||
|
color: #3b82f6 !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-msg-btn:hover {
|
||||||
|
color: #ef4444 !important;
|
||||||
|
}
|
||||||
|
|||||||
@@ -184,6 +184,8 @@ const I18N = {
|
|||||||
today: '今天', yesterday: '昨天', earlier: '更早',
|
today: '今天', yesterday: '昨天', earlier: '更早',
|
||||||
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
delete_session_confirm: '确认删除该会话?所有消息将被清除。',
|
||||||
delete_session_title: '删除会话',
|
delete_session_title: '删除会话',
|
||||||
|
delete_message_confirm: '确认删除这条消息?',
|
||||||
|
delete_message_title: '删除消息',
|
||||||
untitled_session: '新对话',
|
untitled_session: '新对话',
|
||||||
context_cleared: '— 以上内容已从上下文中移除 —',
|
context_cleared: '— 以上内容已从上下文中移除 —',
|
||||||
tip_new_chat: '新建对话',
|
tip_new_chat: '新建对话',
|
||||||
@@ -206,6 +208,10 @@ const I18N = {
|
|||||||
confirm_cancel: '取消',
|
confirm_cancel: '取消',
|
||||||
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
error_send: '发送失败,请稍后再试。', error_timeout: '请求超时,请再试一次。',
|
||||||
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时',
|
thinking_in_progress: '思考中...', thinking_done: '已深度思考', thinking_duration: '耗时',
|
||||||
|
edit_message: '编辑消息',
|
||||||
|
regenerate_response: '重新生成',
|
||||||
|
edit_save: '保存并发送',
|
||||||
|
edit_cancel: '取消',
|
||||||
},
|
},
|
||||||
en: {
|
en: {
|
||||||
console: 'Console',
|
console: 'Console',
|
||||||
@@ -380,6 +386,8 @@ const I18N = {
|
|||||||
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
today: 'Today', yesterday: 'Yesterday', earlier: 'Earlier',
|
||||||
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
delete_session_confirm: 'Delete this session? All messages will be removed.',
|
||||||
delete_session_title: 'Delete Session',
|
delete_session_title: 'Delete Session',
|
||||||
|
delete_message_confirm: 'Delete this message?',
|
||||||
|
delete_message_title: 'Delete Message',
|
||||||
untitled_session: 'New Chat',
|
untitled_session: 'New Chat',
|
||||||
context_cleared: '— Context above has been cleared —',
|
context_cleared: '— Context above has been cleared —',
|
||||||
tip_new_chat: 'New Chat',
|
tip_new_chat: 'New Chat',
|
||||||
@@ -402,6 +410,10 @@ const I18N = {
|
|||||||
confirm_cancel: 'Cancel',
|
confirm_cancel: 'Cancel',
|
||||||
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
error_send: 'Failed to send. Please try again.', error_timeout: 'Request timeout. Please try again.',
|
||||||
thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration',
|
thinking_in_progress: 'Thinking...', thinking_done: 'Thought', thinking_duration: 'Duration',
|
||||||
|
edit_message: 'Edit message',
|
||||||
|
regenerate_response: 'Regenerate',
|
||||||
|
edit_save: 'Save and send',
|
||||||
|
edit_cancel: 'Cancel',
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -821,11 +833,45 @@ function renderMarkdown(text) {
|
|||||||
let html = md.render(text);
|
let html = md.render(text);
|
||||||
html = _rewriteLocalImgSrc(html);
|
html = _rewriteLocalImgSrc(html);
|
||||||
// Order matters: video first (more specific), then image.
|
// Order matters: video first (more specific), then image.
|
||||||
return injectImagePreviews(injectVideoPlayers(html));
|
html = injectImagePreviews(injectVideoPlayers(html));
|
||||||
|
// Note: Code block headers are added via DOM manipulation after insertion
|
||||||
|
// See addCodeBlockHeadersToElement()
|
||||||
|
return html;
|
||||||
}
|
}
|
||||||
catch (e) { return text.replace(/\n/g, '<br>'); }
|
catch (e) { return text.replace(/\n/g, '<br>'); }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function _addCodeBlockHeaders(container) {
|
||||||
|
// Add header with language label and copy button to each <pre> block using DOM manipulation
|
||||||
|
const preBlocks = container.querySelectorAll('pre');
|
||||||
|
preBlocks.forEach(pre => {
|
||||||
|
if (pre.parentElement && pre.parentElement.classList.contains('code-block-wrapper')) return;
|
||||||
|
|
||||||
|
const codeEl = pre.querySelector('code');
|
||||||
|
if (!codeEl) return;
|
||||||
|
|
||||||
|
const langClass = Array.from(codeEl.classList).find(c => c.startsWith('language-'));
|
||||||
|
const language = langClass ? langClass.replace('language-', '') : 'code';
|
||||||
|
const langLabel = language.charAt(0).toUpperCase() + language.slice(1);
|
||||||
|
|
||||||
|
const wrapper = document.createElement('div');
|
||||||
|
wrapper.className = 'code-block-wrapper';
|
||||||
|
|
||||||
|
const header = document.createElement('div');
|
||||||
|
header.className = 'code-block-header';
|
||||||
|
header.innerHTML = `
|
||||||
|
<span class="code-block-lang">${langLabel}</span>
|
||||||
|
<button class="code-copy-btn" title="Copy code">
|
||||||
|
<i class="fas fa-copy"></i>
|
||||||
|
</button>
|
||||||
|
`;
|
||||||
|
|
||||||
|
pre.parentNode.insertBefore(wrapper, pre);
|
||||||
|
wrapper.appendChild(header);
|
||||||
|
wrapper.appendChild(pre);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
// Chat Module
|
// Chat Module
|
||||||
// =====================================================================
|
// =====================================================================
|
||||||
@@ -1085,6 +1131,22 @@ messagesDiv.addEventListener('scroll', () => {
|
|||||||
|
|
||||||
// Intercept internal navigation links in chat messages
|
// Intercept internal navigation links in chat messages
|
||||||
messagesDiv.addEventListener('click', (e) => {
|
messagesDiv.addEventListener('click', (e) => {
|
||||||
|
// Code block copy button
|
||||||
|
const codeCopyBtn = e.target.closest('.code-copy-btn');
|
||||||
|
if (codeCopyBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const wrapper = codeCopyBtn.closest('.code-block-wrapper');
|
||||||
|
const codeEl = wrapper && wrapper.querySelector('pre code');
|
||||||
|
if (codeEl) {
|
||||||
|
const codeText = codeEl.textContent;
|
||||||
|
copyToClipboard(codeText).then(() => {
|
||||||
|
const icon = codeCopyBtn.querySelector('i');
|
||||||
|
if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const copyBtn = e.target.closest('.copy-msg-btn');
|
const copyBtn = e.target.closest('.copy-msg-btn');
|
||||||
if (copyBtn) {
|
if (copyBtn) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -1092,13 +1154,97 @@ messagesDiv.addEventListener('click', (e) => {
|
|||||||
const answerEl = msgRoot && msgRoot.querySelector('.answer-content');
|
const answerEl = msgRoot && msgRoot.querySelector('.answer-content');
|
||||||
const rawMd = answerEl && answerEl.dataset.rawMd;
|
const rawMd = answerEl && answerEl.dataset.rawMd;
|
||||||
if (rawMd) {
|
if (rawMd) {
|
||||||
navigator.clipboard.writeText(rawMd).then(() => {
|
copyToClipboard(rawMd).then(() => {
|
||||||
const icon = copyBtn.querySelector('i');
|
const icon = copyBtn.querySelector('i');
|
||||||
if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
|
if (icon) { icon.className = 'fas fa-check'; setTimeout(() => { icon.className = 'fas fa-copy'; }, 1500); }
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Edit user message
|
||||||
|
const editBtn = e.target.closest('.edit-msg-btn');
|
||||||
|
if (editBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const msgRoot = editBtn.closest('.user-message-group');
|
||||||
|
if (msgRoot) editUserMessage(msgRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate bot response
|
||||||
|
const regenerateBtn = e.target.closest('.regenerate-msg-btn');
|
||||||
|
if (regenerateBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const botMsgRoot = regenerateBtn.closest('.flex.gap-3');
|
||||||
|
if (botMsgRoot) regenerateResponse(botMsgRoot);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete message
|
||||||
|
const deleteBtn = e.target.closest('.delete-msg-btn');
|
||||||
|
if (deleteBtn) {
|
||||||
|
e.preventDefault();
|
||||||
|
const userMsgEl = deleteBtn.closest('.user-message-group');
|
||||||
|
const botMsgEl = deleteBtn.closest('.flex.gap-3:not(.user-message-group)');
|
||||||
|
|
||||||
|
if (userMsgEl) {
|
||||||
|
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
|
||||||
|
let nextSibling = userMsgEl.nextElementSibling;
|
||||||
|
let botReplyEl = null;
|
||||||
|
while (nextSibling) {
|
||||||
|
if (nextSibling.classList && nextSibling.classList.contains('flex') && nextSibling.classList.contains('gap-3') && !nextSibling.classList.contains('user-message-group')) {
|
||||||
|
botReplyEl = nextSibling;
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
nextSibling = nextSibling.nextElementSibling;
|
||||||
|
}
|
||||||
|
userMsgEl.remove();
|
||||||
|
if (botReplyEl) botReplyEl.remove();
|
||||||
|
|
||||||
|
const userSeq = userMsgEl.dataset.seq;
|
||||||
|
if (userSeq) {
|
||||||
|
fetch('/api/messages/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ session_id: sessionId, user_seq: parseInt(userSeq) })
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') console.log(`Deleted ${data.deleted} messages`);
|
||||||
|
}).catch(err => console.error('Failed to delete:', err));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
} else if (botMsgEl) {
|
||||||
|
showConfirmModal(t('delete_message_title'), t('delete_message_confirm'), () => {
|
||||||
|
// Find the preceding user message to get its seq
|
||||||
|
let prevUserEl = botMsgEl.previousElementSibling;
|
||||||
|
while (prevUserEl && !prevUserEl.classList.contains('user-message-group')) {
|
||||||
|
prevUserEl = prevUserEl.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete from database (keep user message, only delete bot reply)
|
||||||
|
if (prevUserEl) {
|
||||||
|
const userSeq = prevUserEl.dataset.seq;
|
||||||
|
if (userSeq) {
|
||||||
|
fetch('/api/messages/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_seq: parseInt(userSeq),
|
||||||
|
delete_user: false
|
||||||
|
})
|
||||||
|
}).then(r => r.json()).then(data => {
|
||||||
|
if (data.status === 'success') console.log(`Deleted ${data.deleted} bot reply messages`);
|
||||||
|
}).catch(err => console.error('Failed to delete bot reply:', err));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove from DOM
|
||||||
|
botMsgEl.remove();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const a = e.target.closest('a');
|
const a = e.target.closest('a');
|
||||||
if (!a) return;
|
if (!a) return;
|
||||||
const href = a.getAttribute('href') || '';
|
const href = a.getAttribute('href') || '';
|
||||||
@@ -1376,14 +1522,83 @@ document.addEventListener('click', (e) => {
|
|||||||
hideAttachMenu();
|
hideAttachMenu();
|
||||||
});
|
});
|
||||||
|
|
||||||
// Drag-and-drop support on chat input area
|
// Drag-and-drop support on entire chat view
|
||||||
|
const chatView = document.getElementById('view-chat');
|
||||||
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
const chatInputArea = chatInput.closest('.flex-shrink-0');
|
||||||
chatInputArea.addEventListener('dragover', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.add('drag-over'); });
|
|
||||||
chatInputArea.addEventListener('dragleave', (e) => { e.preventDefault(); e.stopPropagation(); chatInputArea.classList.remove('drag-over'); });
|
// Create drag overlay for visual feedback
|
||||||
chatInputArea.addEventListener('drop', (e) => {
|
let dragOverlay = document.getElementById('drag-overlay');
|
||||||
e.preventDefault(); e.stopPropagation();
|
if (!dragOverlay) {
|
||||||
|
dragOverlay = document.createElement('div');
|
||||||
|
dragOverlay.id = 'drag-overlay';
|
||||||
|
dragOverlay.className = 'drag-overlay hidden';
|
||||||
|
dragOverlay.innerHTML = `
|
||||||
|
<div class="drag-overlay-content">
|
||||||
|
<i class="fas fa-cloud-arrow-up"></i>
|
||||||
|
<p>Drop files here to upload</p>
|
||||||
|
</div>
|
||||||
|
`;
|
||||||
|
chatView.appendChild(dragOverlay);
|
||||||
|
}
|
||||||
|
|
||||||
|
let dragCounter = 0;
|
||||||
|
|
||||||
|
function showDragOverlay() {
|
||||||
|
dragOverlay.classList.remove('hidden');
|
||||||
|
dragOverlay.classList.add('active');
|
||||||
|
}
|
||||||
|
|
||||||
|
function hideDragOverlay() {
|
||||||
|
dragOverlay.classList.remove('active');
|
||||||
|
dragOverlay.classList.add('hidden');
|
||||||
|
}
|
||||||
|
|
||||||
|
chatView.addEventListener('dragenter', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounter++;
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
showDragOverlay();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chatView.addEventListener('dragover', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
chatInputArea.classList.add('drag-over');
|
||||||
|
});
|
||||||
|
|
||||||
|
chatView.addEventListener('dragleave', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounter--;
|
||||||
|
if (dragCounter === 0) {
|
||||||
|
hideDragOverlay();
|
||||||
chatInputArea.classList.remove('drag-over');
|
chatInputArea.classList.remove('drag-over');
|
||||||
if (e.dataTransfer.files.length) handleFileSelect(e.dataTransfer.files);
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
chatView.addEventListener('drop', (e) => {
|
||||||
|
e.preventDefault();
|
||||||
|
e.stopPropagation();
|
||||||
|
dragCounter = 0;
|
||||||
|
hideDragOverlay();
|
||||||
|
chatInputArea.classList.remove('drag-over');
|
||||||
|
if (e.dataTransfer.files.length) {
|
||||||
|
handleFileSelect(e.dataTransfer.files);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('dragover', (e) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
document.body.addEventListener('drop', (e) => {
|
||||||
|
if (e.dataTransfer.types.includes('Files')) {
|
||||||
|
e.preventDefault();
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
// Paste image support
|
// Paste image support
|
||||||
@@ -1761,6 +1976,183 @@ function addUserVoiceMessage(audioUrl, caption, timestamp) {
|
|||||||
scrollChatToBottom(true);
|
scrollChatToBottom(true);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Clipboard helper with fallback for non-HTTPS environments
|
||||||
|
function copyToClipboard(text) {
|
||||||
|
if (navigator.clipboard && window.isSecureContext) {
|
||||||
|
return navigator.clipboard.writeText(text);
|
||||||
|
}
|
||||||
|
// Fallback for HTTP environments
|
||||||
|
return new Promise((resolve, reject) => {
|
||||||
|
const textArea = document.createElement('textarea');
|
||||||
|
textArea.value = text;
|
||||||
|
textArea.style.position = 'fixed';
|
||||||
|
textArea.style.left = '-999999px';
|
||||||
|
textArea.style.top = '-999999px';
|
||||||
|
document.body.appendChild(textArea);
|
||||||
|
textArea.focus();
|
||||||
|
textArea.select();
|
||||||
|
try {
|
||||||
|
document.execCommand('copy') ? resolve() : reject(new Error('Copy failed'));
|
||||||
|
} catch (err) {
|
||||||
|
reject(err);
|
||||||
|
} finally {
|
||||||
|
textArea.remove();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// Edit user message: extract content, remove this and subsequent messages, fill input
|
||||||
|
async function editUserMessage(msgEl) {
|
||||||
|
const rawContent = msgEl.dataset.rawContent;
|
||||||
|
if (!rawContent) return;
|
||||||
|
|
||||||
|
// Delete this message and ALL subsequent messages from database (cascade)
|
||||||
|
// Must await to ensure delete completes before user sends a new message
|
||||||
|
const userSeq = msgEl.dataset.seq;
|
||||||
|
if (userSeq) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/messages/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_seq: parseInt(userSeq),
|
||||||
|
delete_user: true,
|
||||||
|
cascade: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete old messages:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Find all subsequent messages (this message and everything after it)
|
||||||
|
const messagesToRemove = [];
|
||||||
|
let current = msgEl;
|
||||||
|
while (current) {
|
||||||
|
if (current.classList && (current.classList.contains('user-message-group') || current.classList.contains('flex'))) {
|
||||||
|
messagesToRemove.push(current);
|
||||||
|
}
|
||||||
|
current = current.nextElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove all messages from this one onwards
|
||||||
|
messagesToRemove.forEach(el => {
|
||||||
|
if (el && el.parentNode) el.parentNode.removeChild(el);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Fill input with the original content
|
||||||
|
chatInput.value = rawContent;
|
||||||
|
chatInput.style.height = 'auto';
|
||||||
|
chatInput.style.height = chatInput.scrollHeight + 'px';
|
||||||
|
chatInput.focus();
|
||||||
|
scrollChatToBottom();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Regenerate bot response: find the preceding user message and resend it
|
||||||
|
async function regenerateResponse(botMsgEl) {
|
||||||
|
let prevEl = botMsgEl.previousElementSibling;
|
||||||
|
while (prevEl && !prevEl.classList.contains('user-message-group')) {
|
||||||
|
prevEl = prevEl.previousElementSibling;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!prevEl) {
|
||||||
|
console.warn('No preceding user message found');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const userContent = prevEl.dataset.rawContent;
|
||||||
|
if (!userContent) {
|
||||||
|
console.warn('No content in preceding user message');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Delete both the old user message AND bot reply from database
|
||||||
|
// (because /message will create a fresh user message + new bot reply)
|
||||||
|
// Must await to ensure delete completes before /message is sent
|
||||||
|
const userSeq = prevEl.dataset.seq;
|
||||||
|
if (userSeq) {
|
||||||
|
try {
|
||||||
|
const resp = await fetch('/api/messages/delete', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
session_id: sessionId,
|
||||||
|
user_seq: parseInt(userSeq),
|
||||||
|
delete_user: true
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const data = await resp.json();
|
||||||
|
if (data.status === 'success') console.log(`Deleted ${data.deleted} old messages`);
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Failed to delete old messages:', err);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Remove both the old user message and bot message from DOM
|
||||||
|
if (prevEl.parentNode) prevEl.parentNode.removeChild(prevEl);
|
||||||
|
if (botMsgEl.parentNode) botMsgEl.parentNode.removeChild(botMsgEl);
|
||||||
|
|
||||||
|
// Re-add the user message to DOM (so it appears before the loading indicator)
|
||||||
|
addUserMessage(userContent, new Date());
|
||||||
|
|
||||||
|
// Show loading indicator
|
||||||
|
const loadingEl = addLoadingIndicator();
|
||||||
|
|
||||||
|
// Resend the message
|
||||||
|
const timestamp = new Date();
|
||||||
|
const body = { session_id: sessionId, message: userContent, stream: true, timestamp: timestamp.toISOString(), lang: currentLang };
|
||||||
|
|
||||||
|
const MAX_RETRIES = 2;
|
||||||
|
const RETRY_DELAY_MS = 1000;
|
||||||
|
|
||||||
|
function postWithRetry(attempt) {
|
||||||
|
fetch('/message', {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body)
|
||||||
|
})
|
||||||
|
.then(r => r.json())
|
||||||
|
.then(data => {
|
||||||
|
if (data.status === 'success') {
|
||||||
|
if (data.inline_reply) {
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(data.inline_reply, new Date());
|
||||||
|
} else if (data.stream) {
|
||||||
|
setSendBtnCancelMode(data.request_id);
|
||||||
|
startSSE(data.request_id, loadingEl, timestamp, null);
|
||||||
|
} else {
|
||||||
|
loadingContainers[data.request_id] = loadingEl;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
resetSendBtnSendMode();
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch(err => {
|
||||||
|
if (err.name === 'AbortError') {
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(t('error_timeout'), new Date());
|
||||||
|
resetSendBtnSendMode();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (attempt < MAX_RETRIES) {
|
||||||
|
console.warn(`[regenerateResponse] attempt ${attempt + 1} failed, retrying...`, err);
|
||||||
|
setTimeout(() => postWithRetry(attempt + 1), RETRY_DELAY_MS * (attempt + 1));
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
loadingEl.remove();
|
||||||
|
addBotMessage(t('error_send'), new Date());
|
||||||
|
resetSendBtnSendMode();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
postWithRetry(0);
|
||||||
|
}
|
||||||
|
|
||||||
function sendMessage() {
|
function sendMessage() {
|
||||||
// Do NOT branch on sendBtnMode here: Enter should always send (so
|
// Do NOT branch on sendBtnMode here: Enter should always send (so
|
||||||
// typing "/cancel" submits normally). Cancel is wired only to the
|
// typing "/cancel" submits normally). Cancel is wired only to the
|
||||||
@@ -2252,7 +2644,7 @@ function startPolling() {
|
|||||||
|
|
||||||
function createUserMessageEl(content, timestamp, attachments) {
|
function createUserMessageEl(content, timestamp, attachments) {
|
||||||
const el = document.createElement('div');
|
const el = document.createElement('div');
|
||||||
el.className = 'flex justify-end px-4 sm:px-6 py-3';
|
el.className = 'flex justify-end px-4 sm:px-6 py-3 user-message-group';
|
||||||
|
|
||||||
let attachHtml = '';
|
let attachHtml = '';
|
||||||
if (attachments && attachments.length > 0) {
|
if (attachments && attachments.length > 0) {
|
||||||
@@ -2277,9 +2669,19 @@ function createUserMessageEl(content, timestamp, attachments) {
|
|||||||
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
|
<div class="bg-primary-400 text-white rounded-2xl px-4 py-2.5 text-sm leading-relaxed msg-content user-bubble">
|
||||||
${attachHtml}${textHtml}
|
${attachHtml}${textHtml}
|
||||||
</div>
|
</div>
|
||||||
<div class="text-xs text-slate-400 dark:text-slate-500 mt-1.5 text-right">${formatTime(timestamp)}</div>
|
<div class="flex items-center justify-end gap-2 mt-1.5">
|
||||||
|
<button class="edit-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('edit_message')}">
|
||||||
|
<i class="fas fa-pen-to-square"></i>
|
||||||
|
</button>
|
||||||
|
<button class="delete-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors cursor-pointer" title="${t('delete_message_title')}">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
|
<span class="text-xs text-slate-400 dark:text-slate-500">${formatTime(timestamp)}</span>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
`;
|
`;
|
||||||
|
// Store raw content for editing
|
||||||
|
el.dataset.rawContent = content || '';
|
||||||
return el;
|
return el;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2487,6 +2889,12 @@ function createBotMessageEl(content, timestamp, requestId, msg) {
|
|||||||
<button class="copy-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${currentLang === 'zh' ? '复制' : 'Copy'}">
|
<button class="copy-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${currentLang === 'zh' ? '复制' : 'Copy'}">
|
||||||
<i class="fas fa-copy"></i>
|
<i class="fas fa-copy"></i>
|
||||||
</button>
|
</button>
|
||||||
|
<button class="regenerate-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-primary-400 dark:hover:text-primary-400 transition-colors cursor-pointer" title="${t('regenerate_response')}">
|
||||||
|
<i class="fas fa-rotate-right"></i>
|
||||||
|
</button>
|
||||||
|
<button class="delete-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-red-500 dark:hover:text-red-400 transition-colors cursor-pointer" title="${t('delete_message_title')}">
|
||||||
|
<i class="fas fa-trash"></i>
|
||||||
|
</button>
|
||||||
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
<button class="speak-msg-btn text-xs text-slate-300 dark:text-slate-600 hover:text-slate-500 dark:hover:text-slate-400 transition-colors cursor-pointer" title="${t('speak_msg')}" style="display:none;">
|
||||||
<i class="fas fa-volume-up"></i>
|
<i class="fas fa-volume-up"></i>
|
||||||
</button>
|
</button>
|
||||||
@@ -2709,6 +3117,10 @@ function loadHistory(page) {
|
|||||||
const el = msg.role === 'user'
|
const el = msg.role === 'user'
|
||||||
? createUserMessageEl(msg.content, ts)
|
? createUserMessageEl(msg.content, ts)
|
||||||
: createBotMessageEl(msg.content || '', ts, null, msg);
|
: createBotMessageEl(msg.content || '', ts, null, msg);
|
||||||
|
// Store seq for delete functionality
|
||||||
|
if (msg._seq !== undefined) {
|
||||||
|
el.dataset.seq = msg._seq;
|
||||||
|
}
|
||||||
fragment.appendChild(el);
|
fragment.appendChild(el);
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -3285,6 +3697,8 @@ function applyHighlighting(container) {
|
|||||||
hljsLib.highlightElement(block);
|
hljsLib.highlightElement(block);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
// Add language labels and copy buttons to code blocks
|
||||||
|
_addCodeBlockHeaders(root);
|
||||||
}, 0);
|
}, 0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1096,6 +1096,7 @@ class WebChannel(ChatChannel):
|
|||||||
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
'/api/sessions/(.*)/clear_context', 'SessionClearContextHandler',
|
||||||
'/api/sessions/(.*)', 'SessionDetailHandler',
|
'/api/sessions/(.*)', 'SessionDetailHandler',
|
||||||
'/api/history', 'HistoryHandler',
|
'/api/history', 'HistoryHandler',
|
||||||
|
'/api/messages/delete', 'MessageDeleteHandler',
|
||||||
'/api/logs', 'LogsHandler',
|
'/api/logs', 'LogsHandler',
|
||||||
'/api/version', 'VersionHandler',
|
'/api/version', 'VersionHandler',
|
||||||
'/assets/(.*)', 'AssetsHandler',
|
'/assets/(.*)', 'AssetsHandler',
|
||||||
@@ -3920,6 +3921,56 @@ class HistoryHandler:
|
|||||||
return json.dumps({"status": "error", "message": str(e)})
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
|
class MessageDeleteHandler:
|
||||||
|
def POST(self):
|
||||||
|
_require_auth()
|
||||||
|
web.header('Content-Type', 'application/json; charset=utf-8')
|
||||||
|
web.header('Access-Control-Allow-Origin', '*')
|
||||||
|
try:
|
||||||
|
data = json.loads(web.data())
|
||||||
|
session_id = data.get('session_id', '').strip()
|
||||||
|
user_seq = data.get('user_seq')
|
||||||
|
delete_user = data.get('delete_user', True)
|
||||||
|
cascade = data.get('cascade', False)
|
||||||
|
|
||||||
|
if not session_id or user_seq is None:
|
||||||
|
return json.dumps({"status": "error", "message": "session_id and user_seq required"})
|
||||||
|
|
||||||
|
# 1. Delete from database
|
||||||
|
from agent.memory import get_conversation_store
|
||||||
|
store = get_conversation_store()
|
||||||
|
deleted = store.delete_message_pair(session_id, int(user_seq), delete_user=delete_user, cascade=cascade)
|
||||||
|
|
||||||
|
# 2. Sync agent's in-memory context
|
||||||
|
try:
|
||||||
|
from bridge import Bridge
|
||||||
|
ab = Bridge().get_agent_bridge()
|
||||||
|
agent = ab.get_agent(session_id)
|
||||||
|
if agent and hasattr(agent, 'messages') and hasattr(agent, 'messages_lock'):
|
||||||
|
with agent.messages_lock:
|
||||||
|
# Rebuild agent.messages from database
|
||||||
|
# load_messages returns: [{"role": "user", "content": "..."}, ...]
|
||||||
|
remaining_msgs = store.load_messages(session_id, max_turns=1000)
|
||||||
|
|
||||||
|
agent.messages.clear()
|
||||||
|
for msg in remaining_msgs:
|
||||||
|
agent.messages.append({
|
||||||
|
"role": msg["role"],
|
||||||
|
"content": msg["content"]
|
||||||
|
})
|
||||||
|
|
||||||
|
logger.info(f"[WebChannel] Synced agent memory for session {session_id}, "
|
||||||
|
f"remaining messages: {len(agent.messages)}")
|
||||||
|
except Exception as sync_err:
|
||||||
|
logger.warning(f"[WebChannel] Failed to sync agent memory: {sync_err}")
|
||||||
|
# Don't fail the request if memory sync fails
|
||||||
|
|
||||||
|
return json.dumps({"status": "success", "deleted": deleted}, ensure_ascii=False)
|
||||||
|
except Exception as e:
|
||||||
|
logger.error(f"[WebChannel] Message delete error: {e}")
|
||||||
|
return json.dumps({"status": "error", "message": str(e)})
|
||||||
|
|
||||||
|
|
||||||
class LogsHandler:
|
class LogsHandler:
|
||||||
def GET(self):
|
def GET(self):
|
||||||
_require_auth()
|
_require_auth()
|
||||||
|
|||||||
Reference in New Issue
Block a user