import { renderMarkdown, attachCopyButtons } from '../markdown.js'; import { icon } from '../icons.js'; const MAX_VISIBLE = 50; function escapeHtml(str) { return String(str) .replace(/&/g, '&') .replace(//g, '>') .replace(/"/g, '"'); } function getTextContent(message) { if (typeof message.content === 'string') return message.content; if (Array.isArray(message.content)) { const textParts = message.content.filter(p => p.type === 'text').map(p => p.text); return textParts.join('\n'); } return ''; } function getImageParts(message) { if (!Array.isArray(message.content)) return []; return message.content.filter(p => p.type === 'image_url'); } function getVideoParts(message) { if (!Array.isArray(message.content)) return []; return message.content.filter(p => p.type === 'video_url'); } function formatTime(iso) { try { return new Date(iso).toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' }); } catch { return ''; } } export class Chat { constructor() { this.el = null; this._messages = []; this._offset = 0; // how many messages we've hidden this._streamingEl = null; this._typingEl = null; this._errorEl = null; } render() { const el = document.createElement('div'); el.className = 'flex flex-col h-full'; el.innerHTML = `
${this._welcomeScreen()}
`; this.el = el; return this.el; } _welcomeScreen() { return `

需要我为你做些什么?

直接开始提问,或先在 Settings 中配置模型与上下文限制。

`; } loadConversation(conversation) { this._messages = conversation?.messages || []; this._streamingEl = null; this._typingEl = null; this._rerender(); } _rerender() { const container = this.el.querySelector('#chat-messages'); if (!container) return; container.innerHTML = ''; if (this._messages.length === 0) { container.innerHTML = this._welcomeScreen(); return; } const msgs = this._messages; const total = msgs.length; this._offset = Math.max(0, total - MAX_VISIBLE); const visible = msgs.slice(this._offset); if (this._offset > 0) { const loadMore = document.createElement('div'); loadMore.className = 'max-w-4xl mx-auto w-full px-6 flex justify-center py-4'; loadMore.innerHTML = ``; loadMore.querySelector('#load-older-btn').addEventListener('click', () => this._loadOlder(container)); container.appendChild(loadMore); } visible.forEach(msg => { const el = this._buildMessageEl(msg); container.appendChild(el); }); this._scrollToBottom(); } _loadOlder(container) { const loadMoreDiv = container.querySelector('#load-older-btn')?.parentElement; const msgs = this._messages; const newOffset = Math.max(0, this._offset - MAX_VISIBLE); const olderMsgs = msgs.slice(newOffset, this._offset); this._offset = newOffset; const fragment = document.createDocumentFragment(); if (newOffset > 0) { const newLoadMore = document.createElement('div'); newLoadMore.className = 'max-w-4xl mx-auto w-full px-6 flex justify-center py-4'; newLoadMore.innerHTML = ``; newLoadMore.querySelector('#load-older-btn').addEventListener('click', () => this._loadOlder(container)); fragment.appendChild(newLoadMore); } olderMsgs.forEach(msg => { fragment.appendChild(this._buildMessageEl(msg)); }); if (loadMoreDiv) { container.insertBefore(fragment, loadMoreDiv); loadMoreDiv.remove(); } else { container.insertBefore(fragment, container.firstChild); } } _buildMessageEl(msg) { const isUser = msg.role === 'user'; const text = getTextContent(msg); const images = getImageParts(msg); const videos = getVideoParts(msg); const time = formatTime(msg.timestamp); const wrapper = document.createElement('div'); wrapper.className = 'message-enter max-w-4xl mx-auto w-full px-6 mb-4'; wrapper.dataset.msgId = msg.timestamp || Math.random(); if (isUser) { const imageHtml = images.map(img => ` Attached image `).join(''); const videoHtml = videos.map(vid => ` `).join(''); wrapper.innerHTML = `
${imageHtml} ${videoHtml}
${escapeHtml(text)}
${time ? `
${time}
` : ''}
`; } else { const msgDiv = document.createElement('div'); msgDiv.className = 'w-full'; const bubble = document.createElement('div'); bubble.className = 'text-[13.5px] prose-dark leading-relaxed'; bubble.innerHTML = renderMarkdown(text); attachCopyButtons(bubble); msgDiv.appendChild(bubble); if (time) { const timeEl = document.createElement('div'); timeEl.className = 'text-[11px] text-[var(--c-tx3)] mt-2'; timeEl.textContent = time; msgDiv.appendChild(timeEl); } wrapper.appendChild(msgDiv); } return wrapper; } appendUserMessage(message) { this._messages.push(message); const container = this.el.querySelector('#chat-messages'); // Remove welcome screen if present const welcome = container.querySelector('#welcome-screen'); if (welcome) welcome.remove(); const el = this._buildMessageEl(message); container.appendChild(el); this._scrollToBottom(); } showTypingIndicator() { const container = this.el.querySelector('#chat-messages'); this.hideTypingIndicator(); this._typingEl = document.createElement('div'); this._typingEl.className = 'max-w-4xl mx-auto w-full px-6 mb-4'; this._typingEl.innerHTML = `
`; container.appendChild(this._typingEl); this._scrollToBottom(); } hideTypingIndicator() { if (this._typingEl) { this._typingEl.remove(); this._typingEl = null; } } startAssistantMessage() { this.hideTypingIndicator(); const container = this.el.querySelector('#chat-messages'); const wrapper = document.createElement('div'); wrapper.className = 'message-enter max-w-4xl mx-auto w-full px-6 mb-4'; const msgDiv = document.createElement('div'); msgDiv.className = 'w-full'; const bubble = document.createElement('div'); bubble.className = 'text-[13.5px] prose-dark leading-relaxed'; bubble.innerHTML = ''; msgDiv.appendChild(bubble); wrapper.appendChild(msgDiv); container.appendChild(wrapper); this._scrollToBottom(); this._streamingEl = bubble; this._streamingText = ''; return bubble; } appendToAssistantMessage(chunk) { if (!this._streamingEl) return; this._streamingText = (this._streamingText || '') + chunk; // Re-render markdown during streaming for better UX this._streamingEl.innerHTML = renderMarkdown(this._streamingText) + ''; this._scrollToBottom(); } finalizeAssistantMessage(fullText) { if (!this._streamingEl) return; this._streamingEl.innerHTML = renderMarkdown(fullText); attachCopyButtons(this._streamingEl); this._streamingEl = null; this._streamingText = ''; this._messages.push({ role: 'assistant', content: fullText, timestamp: new Date().toISOString(), }); this._scrollToBottom(); } showError(message) { const container = this.el.querySelector('#chat-messages'); this.hideTypingIndicator(); if (this._streamingEl) { this._streamingEl.innerHTML = `${escapeHtml(message)}`; this._streamingEl = null; this._streamingText = ''; return; } const errEl = document.createElement('div'); errEl.className = 'max-w-4xl mx-auto w-full px-6 mb-4 message-enter'; errEl.innerHTML = `
${icon('x')} ${escapeHtml(message)}
`; this._errorEl = errEl; container.appendChild(errEl); this._scrollToBottom(); } clearError() { if (this._errorEl) { this._errorEl.remove(); this._errorEl = null; } } showSystemMessage(text) { const container = this.el.querySelector('#chat-messages'); if (!container) return; const welcome = container.querySelector('#welcome-screen'); if (welcome) welcome.remove(); const el = document.createElement('div'); el.className = 'max-w-4xl mx-auto w-full px-6 my-3 message-enter flex justify-center'; el.innerHTML = `
${escapeHtml(text)}
`; container.appendChild(el); this._scrollToBottom(); } _scrollToBottom() { const container = this.el.querySelector('#chat-messages'); if (container) { requestAnimationFrame(() => { container.scrollTop = container.scrollHeight; }); } } clear() { this._messages = []; this._streamingEl = null; this._streamingText = ''; this._typingEl = null; this._errorEl = null; const container = this.el.querySelector('#chat-messages'); if (container) container.innerHTML = this._welcomeScreen(); } }