From 0e763cfd98e4450d10d25ce4e7a9587aee677fda Mon Sep 17 00:00:00 2001
From: =?UTF-8?q?=E5=85=AC=E6=98=8E?=
<83812544+Ed1s0nZ@users.noreply.github.com>
Date: Fri, 27 Mar 2026 00:43:33 +0800
Subject: [PATCH] Add files via upload
---
web/static/i18n/en-US.json | 4 +
web/static/i18n/zh-CN.json | 4 +
web/static/js/chat.js | 231 +++++++++++++++++++++++++++++++++++--
web/templates/index.html | 18 +++
4 files changed, 246 insertions(+), 11 deletions(-)
diff --git a/web/static/i18n/en-US.json b/web/static/i18n/en-US.json
index 59e972a2..48f455f0 100644
--- a/web/static/i18n/en-US.json
+++ b/web/static/i18n/en-US.json
@@ -138,6 +138,7 @@
"deleteGroupConfirm": "Are you sure you want to delete this group? Conversations in the group will not be deleted, but will be removed from the group.",
"deleteConversationConfirm": "Are you sure you want to delete this conversation?",
"renameFailed": "Rename failed",
+ "downloadConversationFailed": "Failed to download conversation",
"viewAttackChainSelectConv": "Please select a conversation to view attack chain",
"viewAttackChainCurrentConv": "View attack chain of current conversation",
"executeFailed": "Execution failed",
@@ -1450,6 +1451,9 @@
},
"contextMenu": {
"viewAttackChain": "View attack chain",
+ "downloadMarkdown": "Download Markdown",
+ "downloadMarkdownSummary": "Summary",
+ "downloadMarkdownFull": "Full",
"rename": "Rename",
"pinConversation": "Pin conversation",
"unpinConversation": "Unpin",
diff --git a/web/static/i18n/zh-CN.json b/web/static/i18n/zh-CN.json
index cfcbd1af..434b1fa0 100644
--- a/web/static/i18n/zh-CN.json
+++ b/web/static/i18n/zh-CN.json
@@ -138,6 +138,7 @@
"deleteGroupConfirm": "确定要删除此分组吗?分组中的对话不会被删除,但会从分组中移除。",
"deleteConversationConfirm": "确定要删除此对话吗?",
"renameFailed": "重命名失败",
+ "downloadConversationFailed": "下载对话失败",
"viewAttackChainSelectConv": "请选择一个对话以查看攻击链",
"viewAttackChainCurrentConv": "查看当前对话的攻击链",
"executeFailed": "执行失败",
@@ -1450,6 +1451,9 @@
},
"contextMenu": {
"viewAttackChain": "查看攻击链",
+ "downloadMarkdown": "下载 Markdown",
+ "downloadMarkdownSummary": "简版",
+ "downloadMarkdownFull": "完整版",
"rename": "重命名",
"pinConversation": "置顶此对话",
"unpinConversation": "取消置顶",
diff --git a/web/static/js/chat.js b/web/static/js/chat.js
index e387e282..16c1beae 100644
--- a/web/static/js/chat.js
+++ b/web/static/js/chat.js
@@ -4404,9 +4404,14 @@ async function showConversationContextMenu(event) {
submenu.style.display = 'none';
submenuVisible = false;
}
+ const downloadSubmenu = document.getElementById('download-markdown-submenu');
+ if (downloadSubmenu) {
+ downloadSubmenu.style.display = 'none';
+ }
// 清除所有定时器
clearSubmenuHideTimeout();
clearSubmenuShowTimeout();
+ clearDownloadMarkdownSubmenuHideTimeout();
submenuLoading = false;
const convId = contextMenuConversationId;
@@ -4537,26 +4542,44 @@ async function showConversationContextMenu(event) {
menu.style.top = top + 'px';
// 如果菜单在右侧,子菜单应该在左侧显示
- if (submenu && left < event.clientX) {
- submenu.style.left = 'auto';
- submenu.style.right = '100%';
- submenu.style.marginLeft = '0';
- submenu.style.marginRight = '4px';
- } else if (submenu) {
- submenu.style.left = '100%';
- submenu.style.right = 'auto';
- submenu.style.marginLeft = '4px';
- submenu.style.marginRight = '0';
+ if (left < event.clientX) {
+ if (submenu) {
+ submenu.style.left = 'auto';
+ submenu.style.right = '100%';
+ submenu.style.marginLeft = '0';
+ submenu.style.marginRight = '4px';
+ }
+ if (downloadSubmenu) {
+ downloadSubmenu.style.left = 'auto';
+ downloadSubmenu.style.right = '100%';
+ downloadSubmenu.style.marginLeft = '0';
+ downloadSubmenu.style.marginRight = '4px';
+ }
+ } else {
+ if (submenu) {
+ submenu.style.left = '100%';
+ submenu.style.right = 'auto';
+ submenu.style.marginLeft = '4px';
+ submenu.style.marginRight = '0';
+ }
+ if (downloadSubmenu) {
+ downloadSubmenu.style.left = '100%';
+ downloadSubmenu.style.right = 'auto';
+ downloadSubmenu.style.marginLeft = '4px';
+ downloadSubmenu.style.marginRight = '0';
+ }
}
// 点击外部关闭菜单
const closeMenu = (e) => {
// 检查点击是否在主菜单或子菜单内
const moveToGroupSubmenuEl = document.getElementById('move-to-group-submenu');
+ const downloadMarkdownSubmenuEl = document.getElementById('download-markdown-submenu');
const clickedInMenu = menu.contains(e.target);
const clickedInSubmenu = moveToGroupSubmenuEl && moveToGroupSubmenuEl.contains(e.target);
+ const clickedInDownloadSubmenu = downloadMarkdownSubmenuEl && downloadMarkdownSubmenuEl.contains(e.target);
- if (!clickedInMenu && !clickedInSubmenu) {
+ if (!clickedInMenu && !clickedInSubmenu && !clickedInDownloadSubmenu) {
// 使用 closeContextMenu 确保同时关闭主菜单和子菜单
closeContextMenu();
document.removeEventListener('click', closeMenu);
@@ -4950,6 +4973,8 @@ let submenuShowTimeout = null;
let submenuLoading = false;
// 子菜单是否已显示
let submenuVisible = false;
+// 下载Markdown子菜单隐藏定时器
+let downloadMarkdownSubmenuHideTimeout = null;
// 隐藏移动到分组子菜单
function hideMoveToGroupSubmenu() {
@@ -4976,6 +5001,45 @@ function clearSubmenuShowTimeout() {
}
}
+function clearDownloadMarkdownSubmenuHideTimeout() {
+ if (downloadMarkdownSubmenuHideTimeout) {
+ clearTimeout(downloadMarkdownSubmenuHideTimeout);
+ downloadMarkdownSubmenuHideTimeout = null;
+ }
+}
+
+function showDownloadMarkdownSubmenu() {
+ const submenu = document.getElementById('download-markdown-submenu');
+ if (!submenu) return;
+ clearDownloadMarkdownSubmenuHideTimeout();
+ submenu.style.display = 'block';
+}
+
+function hideDownloadMarkdownSubmenu() {
+ const submenu = document.getElementById('download-markdown-submenu');
+ if (!submenu) return;
+ submenu.style.display = 'none';
+}
+
+function handleDownloadMarkdownSubmenuEnter() {
+ clearDownloadMarkdownSubmenuHideTimeout();
+ showDownloadMarkdownSubmenu();
+}
+
+function handleDownloadMarkdownSubmenuLeave(event) {
+ const submenu = document.getElementById('download-markdown-submenu');
+ if (!submenu) return;
+ const relatedTarget = event.relatedTarget;
+ if (relatedTarget && submenu.contains(relatedTarget)) {
+ return;
+ }
+ clearDownloadMarkdownSubmenuHideTimeout();
+ downloadMarkdownSubmenuHideTimeout = setTimeout(() => {
+ hideDownloadMarkdownSubmenu();
+ downloadMarkdownSubmenuHideTimeout = null;
+ }, 200);
+}
+
// 处理鼠标进入"移动到分组"菜单项(带防抖)
function handleMoveToGroupSubmenuEnter() {
// 清除隐藏定时器
@@ -5178,6 +5242,146 @@ function showAttackChainFromContext() {
showAttackChain(convId);
}
+function formatConversationDateForMarkdown(value) {
+ if (!value) return '';
+ const d = new Date(value);
+ if (isNaN(d.getTime())) return '';
+ const locale = (typeof window.__locale === 'string' && window.__locale.startsWith('zh')) ? 'zh-CN' : 'en-US';
+ return d.toLocaleString(locale, {
+ year: 'numeric',
+ month: '2-digit',
+ day: '2-digit',
+ hour: '2-digit',
+ minute: '2-digit',
+ second: '2-digit',
+ hour12: false
+ });
+}
+
+function getConversationRoleLabel(role) {
+ switch (role) {
+ case 'assistant':
+ return 'Assistant';
+ case 'user':
+ return 'User';
+ case 'system':
+ return 'System';
+ default:
+ return role || 'Unknown';
+ }
+}
+
+function formatConversationAsMarkdown(conversation, options = {}) {
+ const includeToolDetails = !!options.includeToolDetails;
+ const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'Untitled Conversation';
+ const createdAt = formatConversationDateForMarkdown(conversation && conversation.createdAt);
+ const updatedAt = formatConversationDateForMarkdown(conversation && conversation.updatedAt);
+ const messages = Array.isArray(conversation && conversation.messages) ? conversation.messages : [];
+
+ let markdown = `# ${title}\n\n`;
+ markdown += `- Conversation ID: \`${conversation && conversation.id ? conversation.id : ''}\`\n`;
+ if (createdAt) markdown += `- Created At: ${createdAt}\n`;
+ if (updatedAt) markdown += `- Updated At: ${updatedAt}\n`;
+ markdown += `- Message Count: ${messages.length}\n\n`;
+ markdown += '---\n\n';
+
+ if (messages.length === 0) {
+ markdown += '_No messages in this conversation._\n';
+ return markdown;
+ }
+
+ messages.forEach((msg, index) => {
+ const role = getConversationRoleLabel(msg && msg.role);
+ const timestamp = formatConversationDateForMarkdown(msg && msg.createdAt);
+ const content = msg && typeof msg.content === 'string' ? msg.content : '';
+
+ markdown += `## ${index + 1}. ${role}`;
+ if (timestamp) markdown += ` (${timestamp})`;
+ markdown += '\n\n';
+ markdown += content ? `${content}\n\n` : '_[Empty message]_\n\n';
+
+ if (Array.isArray(msg && msg.processDetails) && msg.processDetails.length > 0) {
+ markdown += '### Process Details\n\n';
+ msg.processDetails.forEach((detail) => {
+ const detailTime = formatConversationDateForMarkdown(detail && detail.timestamp);
+ const eventType = detail && detail.eventType ? detail.eventType : 'event';
+ const detailMsg = detail && detail.message ? detail.message : '';
+ // Avoid "[label]:" pattern because some Markdown parsers treat it as link reference definition.
+ markdown += `- \`${eventType}\``;
+ if (detailTime) markdown += ` ${detailTime}`;
+ if (detailMsg) markdown += `: ${detailMsg}`;
+ markdown += '\n';
+
+ if (includeToolDetails && detail && detail.data && (eventType === 'tool_call' || eventType === 'tool_result')) {
+ const pretty = JSON.stringify(detail.data, null, 2);
+ markdown += '\n```json\n';
+ markdown += pretty || '{}';
+ markdown += '\n```\n';
+ }
+ });
+ markdown += '\n';
+ }
+
+ if (Array.isArray(msg && msg.mcpExecutionIds) && msg.mcpExecutionIds.length > 0) {
+ markdown += `- MCP Execution IDs: ${msg.mcpExecutionIds.join(', ')}\n\n`;
+ }
+
+ markdown += '---\n\n';
+ });
+
+ return markdown;
+}
+
+function buildConversationMarkdownFileName(conversation, options = {}) {
+ const includeToolDetails = !!options.includeToolDetails;
+ const title = (conversation && conversation.title ? String(conversation.title) : '').trim() || 'conversation';
+ const safeTitle = title
+ .replace(/[\\/:*?"<>|]/g, '_')
+ .replace(/\s+/g, '_')
+ .slice(0, 60) || 'conversation';
+ const idPart = (conversation && conversation.id ? String(conversation.id) : '').slice(0, 8) || 'export';
+ const modePart = includeToolDetails ? 'full' : 'summary';
+ return `${safeTitle}_${idPart}_${modePart}.md`;
+}
+
+// 从上下文菜单下载对话 Markdown
+async function downloadConversationMarkdownFromContext(includeToolDetails = false) {
+ const convId = contextMenuConversationId;
+ if (!convId) return;
+
+ try {
+ const response = await apiFetch(`/api/conversations/${convId}`);
+ let conversation = null;
+ try {
+ conversation = await response.json();
+ } catch (e) {
+ conversation = null;
+ }
+ if (!response.ok) {
+ const errorMsg = conversation && conversation.error ? conversation.error : 'unknown error';
+ throw new Error(errorMsg);
+ }
+
+ const markdown = formatConversationAsMarkdown(conversation || {}, { includeToolDetails });
+ const blob = new Blob([markdown], { type: 'text/markdown;charset=utf-8' });
+ const url = URL.createObjectURL(blob);
+ const link = document.createElement('a');
+ link.href = url;
+ link.download = buildConversationMarkdownFileName(conversation || {}, { includeToolDetails });
+ document.body.appendChild(link);
+ link.click();
+ document.body.removeChild(link);
+ URL.revokeObjectURL(url);
+ } catch (error) {
+ console.error('下载对话 Markdown 失败:', error);
+ const failedLabel = typeof window.t === 'function' ? window.t('chat.downloadConversationFailed') : '下载失败';
+ const errMsg = error && error.message ? error.message : 'unknown error';
+ alert(failedLabel + ': ' + errMsg);
+ }
+
+ closeContextMenu();
+}
+
// 从上下文菜单删除对话
function deleteConversationFromContext() {
const convId = contextMenuConversationId;
@@ -5201,9 +5405,14 @@ function closeContextMenu() {
submenu.style.display = 'none';
submenuVisible = false;
}
+ const downloadSubmenu = document.getElementById('download-markdown-submenu');
+ if (downloadSubmenu) {
+ downloadSubmenu.style.display = 'none';
+ }
// 清除所有定时器
clearSubmenuHideTimeout();
clearSubmenuShowTimeout();
+ clearDownloadMarkdownSubmenuHideTimeout();
submenuLoading = false;
contextMenuConversationId = null;
}
diff --git a/web/templates/index.html b/web/templates/index.html
index 4415cf5d..51bf545b 100644
--- a/web/templates/index.html
+++ b/web/templates/index.html
@@ -2205,6 +2205,24 @@ version: 1.0.0
查看攻击链
+