diff --git a/web/static/css/style.css b/web/static/css/style.css index 8c94d8e2..7032f810 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -1046,6 +1046,9 @@ header { min-width: 120px; display: flex; flex-direction: column; + overflow-x: visible; + overflow-y: visible; + width: 100%; } .message.user .message-content { @@ -1056,6 +1059,7 @@ header { .message.assistant .message-content { align-items: flex-start; margin-right: auto; + min-width: 0; } .message.system .message-content { @@ -1071,6 +1075,11 @@ header { word-break: break-word; line-height: 1.6; box-shadow: var(--shadow-sm); + overflow-x: auto; + overflow-y: visible; + max-width: 100%; + -webkit-overflow-scrolling: touch; + position: relative; } /* Markdown 样式 */ @@ -1198,6 +1207,140 @@ header { text-decoration: underline; } +/* Markdown 表格样式 */ +/* 表格本身,允许根据内容自动扩展宽度 */ +.message-bubble table { + width: auto; + min-width: 100%; + border-collapse: collapse; + margin: 1em 0; + font-size: 0.9em; + display: table; + table-layout: auto; +} + +/* 确保表格能够超出容器宽度 */ +.message-bubble table * { + box-sizing: border-box; +} + +/* 确保表格内容不会被压缩 */ +.message-bubble table colgroup, +.message-bubble table col { + width: auto; +} + +.message-bubble table thead { + background: var(--bg-secondary); +} + +.message-bubble table th { + padding: 10px 12px; + text-align: left; + font-weight: 600; + color: var(--text-primary); + border-bottom: 2px solid var(--border-color); + white-space: nowrap; + min-width: fit-content; +} + +.message-bubble table td { + padding: 10px 12px; + border-bottom: 1px solid var(--border-color); + color: var(--text-primary); + width: auto; + max-width: 200px; + min-width: 120px; + vertical-align: top; + /* 强制不换行,内容会保持在一行,超出部分会溢出(由表格横向滚动处理) */ + white-space: nowrap !important; + overflow: visible; + text-overflow: clip; +} + + +.message-bubble table tbody tr:hover { + background: var(--bg-secondary); +} + +.message-bubble table tbody tr:last-child td { + border-bottom: none; +} + +/* 消息气泡相对定位,用于放置复制按钮 */ +.message-bubble { + position: relative; +} + +/* 消息复制按钮 - 位于消息气泡右下角 */ +.message-copy-btn { + position: absolute; + bottom: 8px; + right: 8px; + display: flex; + align-items: center; + gap: 4px; + padding: 6px 12px; + background: var(--bg-primary); + border: 1px solid var(--border-color); + border-radius: 6px; + color: var(--text-secondary); + font-size: 0.8125rem; + cursor: pointer; + transition: all 0.2s ease; + box-shadow: 0 2px 4px rgba(0, 0, 0, 0.1); + z-index: 10; + opacity: 0; +} + +.message-bubble:hover .message-copy-btn { + opacity: 1; +} + +.message-copy-btn:hover { + background: var(--bg-secondary); + border-color: var(--accent-color); + color: var(--accent-color); + box-shadow: 0 2px 8px rgba(0, 102, 255, 0.2); +} + +.message-copy-btn:active { + transform: scale(0.95); +} + +.message-copy-btn svg { + width: 16px; + height: 16px; + flex-shrink: 0; +} + +.message-copy-btn span { + font-weight: 500; +} + +/* 用户消息中的表格样式 */ +.message.user .message-bubble table { + color: rgba(255, 255, 255, 0.95); +} + +.message.user .message-bubble table thead { + background: rgba(255, 255, 255, 0.15); +} + +.message.user .message-bubble table th { + color: rgba(255, 255, 255, 0.95); + border-bottom-color: rgba(255, 255, 255, 0.3); +} + +.message.user .message-bubble table td { + color: rgba(255, 255, 255, 0.9); + border-bottom-color: rgba(255, 255, 255, 0.2); +} + +.message.user .message-bubble table tbody tr:hover { + background: rgba(255, 255, 255, 0.1); +} + .message.user .message-bubble { background: var(--accent-color); color: white; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 41761178..1bc00773 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -14,6 +14,9 @@ const mentionState = { selectedIndex: 0, }; +// IME输入法状态跟踪 +let isComposing = false; + // 输入框草稿保存相关 const DRAFT_STORAGE_KEY = 'cyberstrike-chat-draft'; let draftSaveTimer = null; @@ -85,13 +88,20 @@ function clearChatDraft() { function adjustTextareaHeight(textarea) { if (!textarea) return; - // 重置高度以获取准确的scrollHeight - textarea.style.height = '44px'; + // 先重置高度为auto,然后立即设置为固定值,确保能准确获取scrollHeight + textarea.style.height = 'auto'; + // 强制浏览器重新计算布局 + void textarea.offsetHeight; // 计算新高度(最小44px,最大不超过300px) const scrollHeight = textarea.scrollHeight; const newHeight = Math.min(Math.max(scrollHeight, 44), 300); textarea.style.height = newHeight + 'px'; + + // 如果内容为空或只有很少内容,立即重置到最小高度 + if (!textarea.value || textarea.value.trim().length === 0) { + textarea.style.height = '44px'; + } } // 发送消息 @@ -333,7 +343,10 @@ function handleChatInputInput(event) { const textarea = event.target; updateMentionStateFromInput(textarea); // 自动调整输入框高度 - adjustTextareaHeight(textarea); + // 使用requestAnimationFrame确保在DOM更新后立即调整,特别是在删除内容时 + requestAnimationFrame(() => { + adjustTextareaHeight(textarea); + }); // 保存输入内容到localStorage(防抖) saveChatDraftDebounced(textarea.value); } @@ -343,6 +356,12 @@ function handleChatInputClick(event) { } function handleChatInputKeydown(event) { + // 如果正在使用输入法输入(IME),回车键应该用于确认候选词,而不是发送消息 + // 使用 event.isComposing 或 isComposing 标志来判断 + if (event.isComposing || isComposing) { + return; + } + if (mentionState.active && mentionSuggestionsEl && mentionSuggestionsEl.style.display !== 'none') { if (event.key === 'ArrowDown') { event.preventDefault(); @@ -816,6 +835,24 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr bubble.innerHTML = formattedContent; contentWrapper.appendChild(bubble); + // 保存原始内容到消息元素,用于复制功能 + if (role === 'assistant') { + messageDiv.dataset.originalContent = content; + } + + // 为助手消息添加复制按钮(复制整个回复内容)- 放在消息气泡右下角 + if (role === 'assistant') { + const copyBtn = document.createElement('button'); + copyBtn.className = 'message-copy-btn'; + copyBtn.innerHTML = '复制'; + copyBtn.title = '复制消息内容'; + copyBtn.onclick = function(e) { + e.stopPropagation(); + copyMessageToClipboard(messageDiv, this); + }; + bubble.appendChild(copyBtn); + } + // 添加时间戳 const timeDiv = document.createElement('div'); timeDiv.className = 'message-time'; @@ -885,6 +922,65 @@ function addMessage(role, content, mcpExecutionIds = null, progressId = null, cr return id; } +// 复制消息内容到剪贴板(使用原始Markdown格式) +function copyMessageToClipboard(messageDiv, button) { + try { + // 获取保存的原始Markdown内容 + const originalContent = messageDiv.dataset.originalContent; + + if (!originalContent) { + // 如果没有保存原始内容,尝试从渲染后的HTML提取(降级方案) + const bubble = messageDiv.querySelector('.message-bubble'); + if (bubble) { + const tempDiv = document.createElement('div'); + tempDiv.innerHTML = bubble.innerHTML; + + // 移除复制按钮本身(避免复制按钮文本) + const copyBtnInTemp = tempDiv.querySelector('.message-copy-btn'); + if (copyBtnInTemp) { + copyBtnInTemp.remove(); + } + + // 提取纯文本内容 + let textContent = tempDiv.textContent || tempDiv.innerText || ''; + textContent = textContent.replace(/\n{3,}/g, '\n\n').trim(); + + navigator.clipboard.writeText(textContent).then(() => { + showCopySuccess(button); + }).catch(err => { + console.error('复制失败:', err); + alert('复制失败,请手动选择内容复制'); + }); + } + return; + } + + // 使用原始Markdown内容 + navigator.clipboard.writeText(originalContent).then(() => { + showCopySuccess(button); + }).catch(err => { + console.error('复制失败:', err); + alert('复制失败,请手动选择内容复制'); + }); + } catch (error) { + console.error('复制消息时出错:', error); + alert('复制失败,请手动选择内容复制'); + } +} + +// 显示复制成功提示 +function showCopySuccess(button) { + if (button) { + const originalText = button.innerHTML; + button.innerHTML = '已复制'; + button.style.color = '#28a745'; + setTimeout(() => { + button.innerHTML = originalText; + button.style.color = ''; + }, 2000); + } +} + // 渲染过程详情 function renderProcessDetails(messageId, processDetails) { const messageElement = document.getElementById(messageId); @@ -1057,6 +1153,13 @@ if (chatInput) { chatInput.addEventListener('input', handleChatInputInput); chatInput.addEventListener('click', handleChatInputClick); chatInput.addEventListener('focus', handleChatInputClick); + // IME输入法事件监听,用于跟踪输入法状态 + chatInput.addEventListener('compositionstart', () => { + isComposing = true; + }); + chatInput.addEventListener('compositionend', () => { + isComposing = false; + }); chatInput.addEventListener('blur', () => { setTimeout(() => { if (!chatInput.matches(':focus')) {