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 @@