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
查看攻击链 +
+ + + + + 下载 Markdown + + + + +