diff --git a/web/static/css/style.css b/web/static/css/style.css index 28a98570..a64879bc 100644 --- a/web/static/css/style.css +++ b/web/static/css/style.css @@ -236,6 +236,25 @@ header { gap: 4px; } +.conversation-group { + display: flex; + flex-direction: column; + gap: 4px; + margin-bottom: 14px; +} + +.conversation-group:last-child { + margin-bottom: 0; +} + +.conversation-group-title { + font-size: 0.75rem; + color: var(--text-muted); + font-weight: 600; + letter-spacing: 0.5px; + padding: 8px 8px 0; +} + .conversation-item { padding: 12px; border-radius: 8px; @@ -697,6 +716,16 @@ header { box-sizing: border-box; } +.chat-input-field { + flex: 1; + position: relative; + display: flex; +} + +.chat-input-field textarea { + width: 100%; +} + .chat-input-container textarea { flex: 1; min-width: 0; @@ -774,6 +803,174 @@ header { transform: translateY(0); } +.mention-suggestions { + position: absolute; + bottom: calc(100% + 8px); + left: 0; + right: 0; + background: #ffffff; + border: 1px solid rgba(15, 23, 42, 0.08); + border-radius: 16px; + box-shadow: 0 20px 45px rgba(15, 23, 42, 0.18); + padding: 0; + overflow: hidden; + display: none; + z-index: 15; + backdrop-filter: blur(6px); + animation: mentionFadeIn 0.15s ease-out; + box-sizing: border-box; +} + +.mention-suggestions-list { + max-height: 320px; + overflow-y: auto; + padding: 12px; + box-sizing: border-box; +} + +@keyframes mentionFadeIn { + from { + opacity: 0; + transform: translateY(4px); + } + to { + opacity: 1; + transform: translateY(0); + } +} + +.mention-item { + display: flex; + flex-direction: column; + gap: 6px; + padding: 12px 18px; + width: 100%; + border: 1px solid rgba(15, 23, 42, 0.05); + background: #f7f8fa !important; + cursor: pointer; + text-align: left; + font-size: 0.875rem; + color: rgba(15, 23, 42, 0.9) !important; + transition: background 0.18s ease, border-left-color 0.18s ease, color 0.15s ease, transform 0.18s ease; + border-left: 3px solid transparent; + outline: none; + border-radius: 12px; + margin: 0 0 8px 0; + box-shadow: 0 1px 3px rgba(15, 23, 42, 0.08); + -webkit-appearance: none; + appearance: none; +} + +.mention-item:focus, +.mention-item:focus-visible { + outline: none; + box-shadow: none; +} + +.mention-item:hover { + background: #ffffff !important; + border-left-color: rgba(0, 0, 0, 0.18); + transform: translateY(-1px); + box-shadow: 0 6px 18px rgba(15, 23, 42, 0.12); +} + +.mention-item.active, +.mention-item:focus, +.mention-item:focus-visible { + background: linear-gradient(105deg, rgba(0, 102, 255, 0.08), rgba(0, 102, 255, 0.02)) !important; + border-left-color: rgba(0, 102, 255, 0.6); + transform: translateY(-2px); + box-shadow: 0 10px 26px rgba(0, 102, 255, 0.22); + color: rgba(15, 23, 42, 0.95); +} + +.mention-item.active .mention-item-name, +.mention-item.active .mention-item-desc, +.mention-item.active .mention-item-meta, +.mention-item.active .mention-status { + color: rgba(15, 23, 42, 0.95); +} + +.mention-item.active .mention-status { + background: rgba(0, 102, 255, 0.12); + color: #0052cc; +} + +.mention-item.disabled { + opacity: 0.65; + box-shadow: none; +} + +.mention-item-name { + font-weight: 600; + display: flex; + align-items: center; + gap: 8px; + color: rgba(15, 23, 42, 0.95); +} + +.mention-item-icon { + font-size: 1rem; +} + +.mention-item-text { + flex: 1; +} + +.mention-item-desc { + font-size: 0.78rem; + color: var(--text-muted); + line-height: 1.4; + word-break: break-word; +} + +.mention-item-meta { + display: flex; + flex-wrap: wrap; + gap: 8px; + font-size: 0.75rem; +} + +.mention-status { + padding: 2px 8px; + border-radius: 999px; + font-weight: 600; +} + +.mention-status.enabled { + background: rgba(46, 204, 113, 0.18); + color: #1e8a4d; +} + +.mention-status.disabled { + background: rgba(231, 76, 60, 0.18); + color: #b23d2f; +} + +.mention-origin { + color: var(--text-secondary); +} + +.mention-item-badge { + font-size: 0.68rem; + padding: 2px 8px; + border-radius: 999px; + background: rgba(0, 0, 0, 0.06); + color: var(--text-primary); + font-weight: 600; +} + +.mention-item-badge.internal { + background: rgba(108, 117, 125, 0.18); + color: rgba(33, 37, 41, 0.9); +} + +.mention-empty { + padding: 10px 18px; + font-size: 0.78rem; + color: var(--text-muted); +} + /* MCP调用详情按钮 */ .mcp-detail-btn { display: inline-flex; diff --git a/web/static/js/chat.js b/web/static/js/chat.js index 0b9819e3..ba722f04 100644 --- a/web/static/js/chat.js +++ b/web/static/js/chat.js @@ -1,5 +1,18 @@ let currentConversationId = null; +// @ 提及相关状态 +let mentionTools = []; +let mentionToolsLoaded = false; +let mentionToolsLoadingPromise = null; +let mentionSuggestionsEl = null; +let mentionFilteredTools = []; +const mentionState = { + active: false, + startIndex: -1, + query: '', + selectedIndex: 0, +}; + // 发送消息 async function sendMessage() { const input = document.getElementById('chat-input'); @@ -86,6 +99,389 @@ async function sendMessage() { } } +function setupMentionSupport() { + mentionSuggestionsEl = document.getElementById('mention-suggestions'); + if (mentionSuggestionsEl) { + mentionSuggestionsEl.style.display = 'none'; + mentionSuggestionsEl.addEventListener('mousedown', (event) => { + // 防止点击候选项时输入框失焦 + event.preventDefault(); + }); + } + ensureMentionToolsLoaded().catch(() => { + // 忽略加载错误,稍后可重试 + }); +} + +function ensureMentionToolsLoaded() { + if (mentionToolsLoaded) { + return Promise.resolve(mentionTools); + } + if (mentionToolsLoadingPromise) { + return mentionToolsLoadingPromise; + } + mentionToolsLoadingPromise = fetchMentionTools().finally(() => { + mentionToolsLoadingPromise = null; + }); + return mentionToolsLoadingPromise; +} + +async function fetchMentionTools() { + const pageSize = 100; + let page = 1; + let totalPages = 1; + const seen = new Set(); + const collected = []; + + try { + while (page <= totalPages && page <= 20) { + const response = await apiFetch(`/api/config/tools?page=${page}&page_size=${pageSize}`); + if (!response.ok) { + break; + } + const result = await response.json(); + const tools = Array.isArray(result.tools) ? result.tools : []; + tools.forEach(tool => { + if (!tool || !tool.name || seen.has(tool.name)) { + return; + } + seen.add(tool.name); + collected.push({ + name: tool.name, + description: tool.description || '', + enabled: tool.enabled !== false, + isExternal: !!tool.is_external, + externalMcp: tool.external_mcp || '', + }); + }); + totalPages = result.total_pages || 1; + page += 1; + if (page > totalPages) { + break; + } + } + mentionTools = collected; + mentionToolsLoaded = true; + } catch (error) { + console.warn('加载工具列表失败,@提及功能可能不可用:', error); + } + return mentionTools; +} + +function handleChatInputInput(event) { + updateMentionStateFromInput(event.target); +} + +function handleChatInputClick(event) { + updateMentionStateFromInput(event.target); +} + +function handleChatInputKeydown(event) { + if (mentionState.active && mentionSuggestionsEl && mentionSuggestionsEl.style.display !== 'none') { + if (event.key === 'ArrowDown') { + event.preventDefault(); + moveMentionSelection(1); + return; + } + if (event.key === 'ArrowUp') { + event.preventDefault(); + moveMentionSelection(-1); + return; + } + if (event.key === 'Enter' || event.key === 'Tab') { + event.preventDefault(); + applyMentionSelection(); + return; + } + if (event.key === 'Escape') { + event.preventDefault(); + deactivateMentionState(); + return; + } + } + + if (event.key === 'Enter' && !event.shiftKey) { + event.preventDefault(); + sendMessage(); + } +} + +function updateMentionStateFromInput(textarea) { + if (!textarea) { + deactivateMentionState(); + return; + } + const caret = textarea.selectionStart || 0; + const textBefore = textarea.value.slice(0, caret); + const atIndex = textBefore.lastIndexOf('@'); + + if (atIndex === -1) { + deactivateMentionState(); + return; + } + + // 限制触发字符之前必须是空白或起始位置 + if (atIndex > 0) { + const boundaryChar = textBefore[atIndex - 1]; + if (boundaryChar && !/\s/.test(boundaryChar) && !'([{,。,.;:!?'.includes(boundaryChar)) { + deactivateMentionState(); + return; + } + } + + const querySegment = textBefore.slice(atIndex + 1); + + if (querySegment.includes(' ') || querySegment.includes('\n') || querySegment.includes('\t') || querySegment.includes('@')) { + deactivateMentionState(); + return; + } + + if (querySegment.length > 60) { + deactivateMentionState(); + return; + } + + mentionState.active = true; + mentionState.startIndex = atIndex; + mentionState.query = querySegment.toLowerCase(); + mentionState.selectedIndex = 0; + + if (!mentionToolsLoaded) { + renderMentionSuggestions({ showLoading: true }); + } else { + updateMentionCandidates(); + renderMentionSuggestions(); + } + + ensureMentionToolsLoaded().then(() => { + if (mentionState.active) { + updateMentionCandidates(); + renderMentionSuggestions(); + } + }); +} + +function updateMentionCandidates() { + if (!mentionState.active) { + mentionFilteredTools = []; + return; + } + const normalizedQuery = (mentionState.query || '').trim().toLowerCase(); + let filtered = mentionTools; + + if (normalizedQuery) { + filtered = mentionTools.filter(tool => { + const nameMatch = tool.name.toLowerCase().includes(normalizedQuery); + const descMatch = tool.description && tool.description.toLowerCase().includes(normalizedQuery); + return nameMatch || descMatch; + }); + } + + filtered = filtered.slice().sort((a, b) => { + if (normalizedQuery) { + const aStarts = a.name.toLowerCase().startsWith(normalizedQuery); + const bStarts = b.name.toLowerCase().startsWith(normalizedQuery); + if (aStarts !== bStarts) { + return aStarts ? -1 : 1; + } + } + if (a.enabled !== b.enabled) { + return a.enabled ? -1 : 1; + } + return a.name.localeCompare(b.name, 'zh-CN'); + }); + + mentionFilteredTools = filtered; + if (mentionFilteredTools.length === 0) { + mentionState.selectedIndex = 0; + } else if (mentionState.selectedIndex >= mentionFilteredTools.length) { + mentionState.selectedIndex = 0; + } +} + +function renderMentionSuggestions({ showLoading = false } = {}) { + if (!mentionSuggestionsEl || !mentionState.active) { + hideMentionSuggestions(); + return; + } + + const currentQuery = mentionState.query || ''; + const existingList = mentionSuggestionsEl.querySelector('.mention-suggestions-list'); + const canPreserveScroll = !showLoading && + existingList && + mentionSuggestionsEl.dataset.lastMentionQuery === currentQuery; + const previousScrollTop = canPreserveScroll ? existingList.scrollTop : 0; + + if (showLoading) { + mentionSuggestionsEl.innerHTML = '
正在加载工具...
'; + mentionSuggestionsEl.style.display = 'block'; + delete mentionSuggestionsEl.dataset.lastMentionQuery; + return; + } + + if (!mentionFilteredTools.length) { + mentionSuggestionsEl.innerHTML = '
没有匹配的工具
'; + mentionSuggestionsEl.style.display = 'block'; + mentionSuggestionsEl.dataset.lastMentionQuery = currentQuery; + return; + } + + const itemsHtml = mentionFilteredTools.map((tool, index) => { + const activeClass = index === mentionState.selectedIndex ? 'active' : ''; + const disabledClass = tool.enabled ? '' : 'disabled'; + const badge = tool.isExternal ? '外部' : '内置'; + const nameHtml = escapeHtml(tool.name); + const description = tool.description && tool.description.length > 0 ? escapeHtml(tool.description) : '暂无描述'; + const descHtml = `
${description}
`; + const statusLabel = tool.enabled ? '可用' : '已禁用'; + const statusClass = tool.enabled ? 'enabled' : 'disabled'; + const originLabel = tool.isExternal + ? (tool.externalMcp ? `来源:${escapeHtml(tool.externalMcp)}` : '来源:外部MCP') + : '来源:内置工具'; + + return ` + + `; + }).join(''); + + const listWrapper = document.createElement('div'); + listWrapper.className = 'mention-suggestions-list'; + listWrapper.innerHTML = itemsHtml; + + mentionSuggestionsEl.innerHTML = ''; + mentionSuggestionsEl.appendChild(listWrapper); + mentionSuggestionsEl.style.display = 'block'; + mentionSuggestionsEl.dataset.lastMentionQuery = currentQuery; + + if (canPreserveScroll) { + listWrapper.scrollTop = previousScrollTop; + } + + listWrapper.querySelectorAll('.mention-item').forEach(item => { + item.addEventListener('mousedown', (event) => { + event.preventDefault(); + const idx = parseInt(item.dataset.index, 10); + if (!Number.isNaN(idx)) { + mentionState.selectedIndex = idx; + } + applyMentionSelection(); + }); + }); + + scrollMentionSelectionIntoView(); +} + +function hideMentionSuggestions() { + if (mentionSuggestionsEl) { + mentionSuggestionsEl.style.display = 'none'; + mentionSuggestionsEl.innerHTML = ''; + delete mentionSuggestionsEl.dataset.lastMentionQuery; + } +} + +function deactivateMentionState() { + mentionState.active = false; + mentionState.startIndex = -1; + mentionState.query = ''; + mentionState.selectedIndex = 0; + mentionFilteredTools = []; + hideMentionSuggestions(); +} + +function moveMentionSelection(direction) { + if (!mentionFilteredTools.length) { + return; + } + const max = mentionFilteredTools.length - 1; + let nextIndex = mentionState.selectedIndex + direction; + if (nextIndex < 0) { + nextIndex = max; + } else if (nextIndex > max) { + nextIndex = 0; + } + mentionState.selectedIndex = nextIndex; + updateMentionActiveHighlight(); +} + +function updateMentionActiveHighlight() { + if (!mentionSuggestionsEl) { + return; + } + const items = mentionSuggestionsEl.querySelectorAll('.mention-item'); + if (!items.length) { + return; + } + items.forEach(item => item.classList.remove('active')); + + let targetIndex = mentionState.selectedIndex; + if (targetIndex < 0) { + targetIndex = 0; + } + if (targetIndex >= items.length) { + targetIndex = items.length - 1; + mentionState.selectedIndex = targetIndex; + } + + const activeItem = items[targetIndex]; + if (activeItem) { + activeItem.classList.add('active'); + scrollMentionSelectionIntoView(activeItem); + } +} + +function scrollMentionSelectionIntoView(targetItem = null) { + if (!mentionSuggestionsEl) { + return; + } + const activeItem = targetItem || mentionSuggestionsEl.querySelector('.mention-item.active'); + if (activeItem && typeof activeItem.scrollIntoView === 'function') { + activeItem.scrollIntoView({ + block: 'nearest', + inline: 'nearest', + behavior: 'auto' + }); + } +} + +function applyMentionSelection() { + const textarea = document.getElementById('chat-input'); + if (!textarea || mentionState.startIndex === -1 || !mentionFilteredTools.length) { + deactivateMentionState(); + return; + } + + const selectedTool = mentionFilteredTools[mentionState.selectedIndex] || mentionFilteredTools[0]; + if (!selectedTool) { + deactivateMentionState(); + return; + } + + const caret = textarea.selectionStart || 0; + const before = textarea.value.slice(0, mentionState.startIndex); + const after = textarea.value.slice(caret); + const mentionText = `@${selectedTool.name}`; + const needsSpace = after.length === 0 || !/^\s/.test(after); + const insertText = mentionText + (needsSpace ? ' ' : ''); + + textarea.value = before + insertText + after; + const newCaret = before.length + insertText.length; + textarea.focus(); + textarea.setSelectionRange(newCaret, newCaret); + + deactivateMentionState(); +} + function initializeChatUI() { const chatInputEl = document.getElementById('chat-input'); if (chatInputEl) { @@ -103,6 +499,7 @@ function initializeChatUI() { clearInterval(activeTaskInterval); } activeTaskInterval = setInterval(() => loadActiveTasks(), ACTIVE_TASK_REFRESH_INTERVAL); + setupMentionSupport(); } // 消息计数器,确保ID唯一 @@ -385,15 +782,21 @@ function removeMessage(id) { } } -// 回车发送消息,Shift+Enter 换行 +// 输入框事件绑定(回车发送 / @提及) const chatInput = document.getElementById('chat-input'); -chatInput.addEventListener('keydown', function(e) { - if (e.key === 'Enter' && !e.shiftKey) { - e.preventDefault(); - sendMessage(); - } - // Shift+Enter 允许默认行为(换行) -}); +if (chatInput) { + chatInput.addEventListener('keydown', handleChatInputKeydown); + chatInput.addEventListener('input', handleChatInputInput); + chatInput.addEventListener('click', handleChatInputClick); + chatInput.addEventListener('focus', handleChatInputClick); + chatInput.addEventListener('blur', () => { + setTimeout(() => { + if (!chatInput.matches(':focus')) { + deactivateMentionState(); + } + }, 120); + }); +} // 显示MCP调用详情 async function showMCPDetail(executionId) { @@ -462,121 +865,194 @@ function startNewConversation() { loadConversations(); } -// 加载对话列表 +// 加载对话列表(按时间分组) async function loadConversations() { try { const response = await apiFetch('/api/conversations?limit=50'); const conversations = await response.json(); - + const listContainer = document.getElementById('conversations-list'); - listContainer.innerHTML = ''; - - if (conversations.length === 0) { - listContainer.innerHTML = '
暂无历史对话
'; + if (!listContainer) { return; } - + + const emptyStateHtml = '
暂无历史对话
'; + listContainer.innerHTML = ''; + + if (!Array.isArray(conversations) || conversations.length === 0) { + listContainer.innerHTML = emptyStateHtml; + return; + } + + const now = new Date(); + const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()); + const weekday = todayStart.getDay() === 0 ? 7 : todayStart.getDay(); + const startOfWeek = new Date(todayStart); + startOfWeek.setDate(todayStart.getDate() - (weekday - 1)); + const yesterdayStart = new Date(todayStart); + yesterdayStart.setDate(todayStart.getDate() - 1); + + const groups = { + today: [], + yesterday: [], + thisWeek: [], + earlier: [], + }; + conversations.forEach(conv => { - const item = document.createElement('div'); - item.className = 'conversation-item'; - item.dataset.conversationId = conv.id; - if (conv.id === currentConversationId) { - item.classList.add('active'); - } - - // 创建内容容器 - const contentWrapper = document.createElement('div'); - contentWrapper.className = 'conversation-content'; - - const title = document.createElement('div'); - title.className = 'conversation-title'; - title.textContent = conv.title || '未命名对话'; - contentWrapper.appendChild(title); - - const time = document.createElement('div'); - time.className = 'conversation-time'; - // 解析时间,支持多种格式 - let dateObj; - if (conv.updatedAt) { - dateObj = new Date(conv.updatedAt); - // 检查日期是否有效 - if (isNaN(dateObj.getTime())) { - // 如果解析失败,尝试其他格式 - console.warn('时间解析失败:', conv.updatedAt); - dateObj = new Date(); - } - } else { - dateObj = new Date(); - } - - // 格式化时间显示 - const now = new Date(); - const today = new Date(now.getFullYear(), now.getMonth(), now.getDate()); - const yesterday = new Date(today); - yesterday.setDate(yesterday.getDate() - 1); - const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); - - let timeText; - if (messageDate.getTime() === today.getTime()) { - // 今天:只显示时间 - timeText = dateObj.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit' - }); - } else if (messageDate.getTime() === yesterday.getTime()) { - // 昨天 - timeText = '昨天 ' + dateObj.toLocaleTimeString('zh-CN', { - hour: '2-digit', - minute: '2-digit' - }); - } else if (now.getFullYear() === dateObj.getFullYear()) { - // 今年:显示月日和时间 - timeText = dateObj.toLocaleString('zh-CN', { - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } else { - // 去年或更早:显示完整日期和时间 - timeText = dateObj.toLocaleString('zh-CN', { - year: 'numeric', - month: 'short', - day: 'numeric', - hour: '2-digit', - minute: '2-digit' - }); - } - - time.textContent = timeText; - contentWrapper.appendChild(time); - - item.appendChild(contentWrapper); - - // 创建删除按钮 - const deleteBtn = document.createElement('button'); - deleteBtn.className = 'conversation-delete-btn'; - deleteBtn.innerHTML = ` - - - - `; - deleteBtn.title = '删除对话'; - deleteBtn.onclick = (e) => { - e.stopPropagation(); // 阻止触发对话加载 - deleteConversation(conv.id); - }; - item.appendChild(deleteBtn); - - item.onclick = () => loadConversation(conv.id); - listContainer.appendChild(item); + const dateObj = conv.updatedAt ? new Date(conv.updatedAt) : new Date(); + const validDate = isNaN(dateObj.getTime()) ? new Date() : dateObj; + const groupKey = getConversationGroup(validDate, todayStart, startOfWeek, yesterdayStart); + groups[groupKey].push({ + ...conv, + _time: validDate, + _timeText: formatConversationTimestamp(validDate, todayStart, yesterdayStart), + }); }); + + const groupOrder = [ + { key: 'today', label: '今天' }, + { key: 'yesterday', label: '昨天' }, + { key: 'thisWeek', label: '本周' }, + { key: 'earlier', label: '更早' }, + ]; + + const fragment = document.createDocumentFragment(); + let rendered = false; + + groupOrder.forEach(({ key, label }) => { + const items = groups[key]; + if (!items || items.length === 0) { + return; + } + rendered = true; + + const section = document.createElement('div'); + section.className = 'conversation-group'; + + const title = document.createElement('div'); + title.className = 'conversation-group-title'; + title.textContent = label; + section.appendChild(title); + + items.forEach(itemData => { + section.appendChild(createConversationListItem(itemData)); + }); + + fragment.appendChild(section); + }); + + if (!rendered) { + listContainer.innerHTML = emptyStateHtml; + return; + } + + listContainer.appendChild(fragment); + updateActiveConversation(); } catch (error) { console.error('加载对话列表失败:', error); } } +function createConversationListItem(conversation) { + const item = document.createElement('div'); + item.className = 'conversation-item'; + item.dataset.conversationId = conversation.id; + if (conversation.id === currentConversationId) { + item.classList.add('active'); + } + + const contentWrapper = document.createElement('div'); + contentWrapper.className = 'conversation-content'; + + const title = document.createElement('div'); + title.className = 'conversation-title'; + title.textContent = conversation.title || '未命名对话'; + contentWrapper.appendChild(title); + + const time = document.createElement('div'); + time.className = 'conversation-time'; + time.textContent = conversation._timeText || formatConversationTimestamp(conversation._time || new Date()); + contentWrapper.appendChild(time); + + item.appendChild(contentWrapper); + + const deleteBtn = document.createElement('button'); + deleteBtn.className = 'conversation-delete-btn'; + deleteBtn.innerHTML = ` + + + + `; + deleteBtn.title = '删除对话'; + deleteBtn.onclick = (e) => { + e.stopPropagation(); + deleteConversation(conversation.id); + }; + item.appendChild(deleteBtn); + + item.onclick = () => loadConversation(conversation.id); + return item; +} + +function formatConversationTimestamp(dateObj, todayStart, yesterdayStart) { + if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { + return ''; + } + const referenceToday = todayStart || new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); + const referenceYesterday = yesterdayStart || new Date(referenceToday.getTime() - 24 * 60 * 60 * 1000); + const messageDate = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); + + if (messageDate.getTime() === referenceToday.getTime()) { + return dateObj.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } + if (messageDate.getTime() === referenceYesterday.getTime()) { + return '昨天 ' + dateObj.toLocaleTimeString('zh-CN', { + hour: '2-digit', + minute: '2-digit' + }); + } + if (dateObj.getFullYear() === referenceToday.getFullYear()) { + return dateObj.toLocaleString('zh-CN', { + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); + } + return dateObj.toLocaleString('zh-CN', { + year: 'numeric', + month: 'short', + day: 'numeric', + hour: '2-digit', + minute: '2-digit' + }); +} + +function getConversationGroup(dateObj, todayStart, startOfWeek, yesterdayStart) { + if (!(dateObj instanceof Date) || isNaN(dateObj.getTime())) { + return 'earlier'; + } + const today = new Date(todayStart.getFullYear(), todayStart.getMonth(), todayStart.getDate()); + const yesterday = new Date(yesterdayStart.getFullYear(), yesterdayStart.getMonth(), yesterdayStart.getDate()); + const messageDay = new Date(dateObj.getFullYear(), dateObj.getMonth(), dateObj.getDate()); + + if (messageDay.getTime() === today.getTime() || messageDay > today) { + return 'today'; + } + if (messageDay.getTime() === yesterday.getTime()) { + return 'yesterday'; + } + if (messageDay >= startOfWeek && messageDay < today) { + return 'thisWeek'; + } + return 'earlier'; +} + // 加载对话 async function loadConversation(conversationId) { try { diff --git a/web/templates/index.html b/web/templates/index.html index ae6c3f98..4c3b7008 100644 --- a/web/templates/index.html +++ b/web/templates/index.html @@ -78,7 +78,10 @@
- +
+ +
+