/** * DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器 * * 设计要点: * - 头部含机器人头像 + 在线状态,增强产品感 * - 消息气泡带头像,左右区分清晰 * - 无消息时显示欢迎空状态 * - 输入区采用圆角容器包裹文本框与发送按钮 */ import { ResolvedConfig, RagSource } from './types'; import { debounce } from './utils'; import { t } from './i18n'; // ==================== 图标常量 ==================== /** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */ const BOT_ICON = ``; /** 用户头像图标 */ const USER_ICON = ``; // ==================== 悬浮按钮 ==================== /** 创建悬浮按钮 */ export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement { const launcher = document.createElement('div'); launcher.id = 'csk-launcher'; launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`; launcher.setAttribute('title', config.title); launcher.setAttribute('aria-label', config.title); launcher.setAttribute('role', 'button'); launcher.setAttribute('tabindex', '0'); // 图标内容 launcher.innerHTML = config.launcherIcon; // 未读徽章(初始隐藏,有未读消息时由 index.ts 显示) const badge = document.createElement('div'); badge.className = 'csk-launcher__badge csk-launcher__badge--hidden'; launcher.appendChild(badge); // 点击事件(300ms 防抖) const debouncedClick = debounce(onClick, 300); launcher.addEventListener('click', debouncedClick); // 键盘支持 launcher.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); debouncedClick(); } }); return launcher; } // ==================== 聊天弹窗 ==================== /** 创建聊天弹窗完整结构,返回各区域引用 */ export function createChatWindow(config: ResolvedConfig): { window: HTMLElement; messagesContainer: HTMLElement; inputEl: HTMLTextAreaElement; sendBtn: HTMLElement; clearBtn: HTMLElement | null; categorySelect: HTMLSelectElement | null; historyPanel: HTMLElement; welcomeEl: HTMLElement; newMsgBtn: HTMLElement; teaserEl: HTMLElement; searchInput: HTMLInputElement | null; ariaLiveEl: HTMLElement; showLoading: () => HTMLElement; hideLoading: () => void; } { // 最外层容器 const windowEl = document.createElement('div'); windowEl.id = 'csk-window'; windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden${config.theme === 'dark' ? ' csk-dark' : ''}`; windowEl.setAttribute('role', 'dialog'); windowEl.setAttribute('aria-label', config.title); windowEl.setAttribute('aria-modal', 'false'); // === 头部 === const header = document.createElement('div'); header.className = 'csk-header'; const headerLeft = document.createElement('div'); headerLeft.className = 'csk-header__left'; const headerAvatar = document.createElement('div'); headerAvatar.className = 'csk-header__avatar'; headerAvatar.innerHTML = BOT_ICON; const headerInfo = document.createElement('div'); headerInfo.className = 'csk-header__info'; const titleEl = document.createElement('span'); titleEl.className = 'csk-header__title'; titleEl.textContent = config.title; const statusEl = document.createElement('span'); statusEl.className = 'csk-header__status'; statusEl.innerHTML = `${t('status_online')}`; headerInfo.appendChild(titleEl); headerInfo.appendChild(statusEl); headerLeft.appendChild(headerAvatar); headerLeft.appendChild(headerInfo); const actions = document.createElement('div'); actions.className = 'csk-header__actions'; // 历史会话按钮(P2) const historyBtn = document.createElement('button'); historyBtn.className = 'csk-history-btn'; historyBtn.setAttribute('title', t('history_title')); historyBtn.innerHTML = ``; // 最小化按钮 const minimizeBtn = document.createElement('button'); minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize'; minimizeBtn.setAttribute('title', t('minimize')); minimizeBtn.innerHTML = ``; minimizeBtn.addEventListener('click', () => { windowEl.classList.add('csk-window--hidden'); }); // 关闭按钮 const closeBtn = document.createElement('button'); closeBtn.className = 'csk-header__btn csk-header__btn--close'; closeBtn.setAttribute('title', t('close')); closeBtn.innerHTML = ``; closeBtn.addEventListener('click', () => { windowEl.classList.add('csk-window--hidden'); }); actions.appendChild(historyBtn); actions.appendChild(minimizeBtn); actions.appendChild(closeBtn); header.appendChild(headerLeft); header.appendChild(actions); // === 消息区 === const messagesContainer = document.createElement('div'); messagesContainer.id = 'csk-messages'; messagesContainer.className = 'csk-messages'; // === 欢迎空状态 === const welcomeEl = document.createElement('div'); welcomeEl.className = 'csk-welcome'; welcomeEl.innerHTML = `
${BOT_ICON}
${t('welcome_title')}
${t('welcome_desc')}
`; // 快捷问题芯片(点击即自动发送) if (config.quickReplies.length > 0) { const chips = document.createElement('div'); chips.className = 'csk-quick-replies'; for (const reply of config.quickReplies) { const chip = document.createElement('button'); chip.type = 'button'; chip.className = 'csk-quick-reply'; chip.textContent = reply; chip.addEventListener('click', () => { windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } })); }); chips.appendChild(chip); } welcomeEl.appendChild(chips); } messagesContainer.appendChild(welcomeEl); // === 新消息提示按钮(用户上滑时新消息到达显示) === // 挂到 windowEl 而非 messagesContainer,绝对定位悬浮于输入区上方,避免随内容滚动 const newMsgBtn = document.createElement('button'); newMsgBtn.type = 'button'; newMsgBtn.className = 'csk-newmsg csk-newmsg--hidden'; newMsgBtn.innerHTML = ` ${t('new_message')} `; windowEl.appendChild(newMsgBtn); // === 会话管理面板(P2,默认隐藏) === const historyPanel = document.createElement('div'); historyPanel.className = 'csk-history-panel csk-history-panel--hidden'; historyPanel.innerHTML = `
${t('history_title')}
`; messagesContainer.appendChild(historyPanel); // 历史面板返回按钮 const backBtn = historyPanel.querySelector('#csk-history-back'); if (backBtn) { backBtn.addEventListener('click', () => { historyPanel.classList.add('csk-history-panel--hidden'); }); } // 历史按钮点击 historyBtn.addEventListener('click', (e) => { e.stopPropagation(); const isHidden = historyPanel.classList.contains('csk-history-panel--hidden'); historyPanel.classList.toggle('csk-history-panel--hidden'); if (isHidden) { // 触发自定义事件,通知加载会话列表 windowEl.dispatchEvent(new CustomEvent('csk:loadHistory')); } }); // === 知识库分类下拉框(P1) === let categorySelect: HTMLSelectElement | null = null; if (config.showCategorySwitch) { const categoryBar = document.createElement('div'); categoryBar.className = 'csk-category-bar'; const categoryLabel = document.createElement('span'); categoryLabel.className = 'csk-category-bar__label'; categoryLabel.textContent = '📚'; categorySelect = document.createElement('select'); categorySelect.id = 'csk-category-select'; categorySelect.className = 'csk-category-select'; categorySelect.innerHTML = ``; // onChange 触发自定义事件 categorySelect.addEventListener('change', () => { const selectedId = categorySelect!.value; windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', { detail: { categoryId: selectedId ? Number(selectedId) : undefined } })); }); categoryBar.appendChild(categoryLabel); categoryBar.appendChild(categorySelect); // 插入到 messages 和 inputArea 之间 windowEl.appendChild(header); windowEl.appendChild(messagesContainer); windowEl.appendChild(categoryBar); } else { windowEl.appendChild(header); windowEl.appendChild(messagesContainer); } // === 输入区 === const inputArea = document.createElement('div'); inputArea.className = 'csk-input-area'; const inputWrap = document.createElement('div'); inputWrap.className = 'csk-input-wrap'; const inputEl = document.createElement('textarea'); inputEl.id = 'csk-input'; inputEl.className = 'csk-input'; inputEl.setAttribute('placeholder', t('placeholder')); inputEl.setAttribute('rows', '1'); inputEl.setAttribute('autofocus', ''); const sendBtn = document.createElement('button'); sendBtn.id = 'csk-send-btn'; sendBtn.className = 'csk-send-btn'; sendBtn.setAttribute('title', t('send')); sendBtn.setAttribute('disabled', 'true'); sendBtn.innerHTML = ``; inputWrap.appendChild(inputEl); inputWrap.appendChild(sendBtn); inputArea.appendChild(inputWrap); windowEl.appendChild(inputArea); // 清空按钮(可选) let clearBtn: HTMLElement | null = null; if (config.showClear) { clearBtn = document.createElement('button'); clearBtn.className = 'csk-clear-btn'; clearBtn.textContent = t('clear'); clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示 // 插入到 categoryBar/inputArea 之前 windowEl.insertBefore(clearBtn, inputArea); } // === Loading 动画 === let loadingEl: HTMLElement | null = null; function showLoading(): HTMLElement { if (loadingEl) { loadingEl.style.display = 'flex'; return loadingEl; } const el = document.createElement('div'); el.className = 'csk-loading'; el.innerHTML = `
${BOT_ICON}
`; messagesContainer.appendChild(el); loadingEl = el; return el; } function hideLoading(): void { if (loadingEl && loadingEl.parentNode) { loadingEl.parentNode.removeChild(loadingEl); loadingEl = null; } } // === a11y: aria-live 播报区域(视觉隐藏,供屏幕阅读器监听新消息) === const ariaLiveEl = document.createElement('div'); ariaLiveEl.className = 'csk-sr-only'; ariaLiveEl.setAttribute('aria-live', 'polite'); ariaLiveEl.setAttribute('aria-atomic', 'true'); windowEl.appendChild(ariaLiveEl); // === 历史搜索输入框引用 === const searchInput = historyPanel.querySelector('#csk-history-search') as HTMLInputElement | null; // === Launcher 提示气泡(首访引导,初始隐藏,由 index.ts 定时控制) === const teaserEl = document.createElement('div'); teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`; teaserEl.setAttribute('role', 'status'); teaserEl.innerHTML = ` ${config.teaserText || t('teaser_text')} `; return { window: windowEl, messagesContainer, inputEl, sendBtn, clearBtn, categorySelect, historyPanel, welcomeEl, newMsgBtn, teaserEl, searchInput, ariaLiveEl, showLoading, hideLoading, }; } // ==================== 拖拽支持 ==================== /** 启用弹窗拖拽 */ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () => void { let dragging = false; let startX = 0; let startY = 0; let offsetX = 0; let offsetY = 0; const onMouseDown = (e: MouseEvent) => { // 忽略头部按钮点击触发的拖拽 const target = e.target as HTMLElement; if (target.closest('button')) return; dragging = true; startX = e.clientX; startY = e.clientY; const rect = windowEl.getBoundingClientRect(); offsetX = startX - rect.left; offsetY = startY - rect.top; document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); }; const onMouseMove = (e: MouseEvent) => { if (!dragging) return; const x = e.clientX - offsetX; const y = e.clientY - offsetY; // 边界限制,防止拖出视口 const maxX = window.innerWidth - windowEl.offsetWidth; const maxY = window.innerHeight - windowEl.offsetHeight; windowEl.style.right = 'auto'; windowEl.style.bottom = 'auto'; windowEl.style.left = `${Math.max(0, Math.min(x, maxX))}px`; windowEl.style.top = `${Math.max(0, Math.min(y, maxY))}px`; }; const onMouseUp = () => { dragging = false; document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; headerEl.addEventListener('mousedown', onMouseDown); // 清理函数 return () => { headerEl.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; } // ==================== 消息渲染 ==================== /** 渲染用户消息气泡 */ export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--user'; const avatar = document.createElement('div'); avatar.className = 'csk-msg__avatar csk-msg__avatar--user'; avatar.innerHTML = USER_ICON; const content = document.createElement('div'); content.className = 'csk-msg__content'; const bubble = document.createElement('div'); bubble.className = 'csk-msg__bubble'; bubble.textContent = text; const time = document.createElement('div'); time.className = 'csk-msg__time'; time.textContent = formatTime(timestamp); content.appendChild(bubble); content.appendChild(time); wrapper.appendChild(avatar); wrapper.appendChild(content); container.appendChild(wrapper); return wrapper; } /** 渲染 AI 消息气泡(支持 Markdown) */ export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--ai'; if (msgId) wrapper.dataset.cskMsgId = msgId; const avatar = document.createElement('div'); avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; avatar.innerHTML = BOT_ICON; const content = document.createElement('div'); content.className = 'csk-msg__content'; const bubble = document.createElement('div'); bubble.className = 'csk-msg__bubble'; // 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本 if (renderMd) { bubble.innerHTML = renderMd(text); } else { bubble.textContent = text; } const time = document.createElement('div'); time.className = 'csk-msg__time'; time.textContent = formatTime(timestamp); content.appendChild(bubble); // AI 操作条(复制 / 重试),插入到气泡与时间戳之间 content.appendChild(buildAIActions(bubble, wrapper)); content.appendChild(time); wrapper.appendChild(avatar); wrapper.appendChild(content); container.appendChild(wrapper); // 代码块复制按钮 enhanceCodeBlocks(bubble); return wrapper; } /** 创建空的 AI 气泡(流式追加用) */ export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming'; if (msgId) wrapper.dataset.cskMsgId = msgId; const avatar = document.createElement('div'); avatar.className = 'csk-msg__avatar csk-msg__avatar--ai'; avatar.innerHTML = BOT_ICON; const content = document.createElement('div'); content.className = 'csk-msg__content'; const bubble = document.createElement('div'); bubble.className = 'csk-msg__bubble'; bubble.innerHTML = ''; const time = document.createElement('div'); time.className = 'csk-msg__time'; time.textContent = formatTime(timestamp); content.appendChild(bubble); content.appendChild(buildAIActions(bubble, wrapper)); content.appendChild(time); wrapper.appendChild(avatar); wrapper.appendChild(content); container.appendChild(wrapper); return { wrapper, bubble }; } // ==================== AI 消息操作条 + 代码块复制 ==================== /** 复制图标 */ const COPY_ICON = ``; /** 重试图标 */ const RETRY_ICON = ``; /** 构建 AI 消息操作条(复制 / 重试 / 反馈) */ function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement { const bar = document.createElement('div'); bar.className = 'csk-msg__actions'; // 复制按钮 const copyBtn = document.createElement('button'); copyBtn.type = 'button'; copyBtn.className = 'csk-action-btn'; copyBtn.setAttribute('title', t('copy')); copyBtn.setAttribute('aria-label', t('copy')); copyBtn.innerHTML = COPY_ICON; copyBtn.addEventListener('click', async (e) => { e.stopPropagation(); const text = bubble.textContent || ''; await copyToClipboard(text, copyBtn); }); // 重试按钮 const retryBtn = document.createElement('button'); retryBtn.type = 'button'; retryBtn.className = 'csk-action-btn'; retryBtn.setAttribute('title', t('retry')); retryBtn.setAttribute('aria-label', t('retry')); retryBtn.innerHTML = RETRY_ICON; retryBtn.addEventListener('click', (e) => { e.stopPropagation(); const root = wrapper.closest('.csk-window'); if (root) { root.dispatchEvent(new CustomEvent('csk:retry', { detail: { msgId: wrapper.dataset.cskMsgId || '' } })); } }); // 反馈按钮(👍 / 👎) const THUMB_UP = ``; const THUMB_DOWN = ``; const upBtn = document.createElement('button'); upBtn.type = 'button'; upBtn.className = 'csk-feedback-btn'; upBtn.setAttribute('title', t('feedback_up')); upBtn.setAttribute('aria-label', t('feedback_up')); upBtn.innerHTML = THUMB_UP; upBtn.addEventListener('click', (e) => { e.stopPropagation(); const root = wrapper.closest('.csk-window'); if (root) { root.dispatchEvent(new CustomEvent('csk:feedback', { detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' } })); } }); const downBtn = document.createElement('button'); downBtn.type = 'button'; downBtn.className = 'csk-feedback-btn'; downBtn.setAttribute('title', t('feedback_down')); downBtn.setAttribute('aria-label', t('feedback_down')); downBtn.innerHTML = THUMB_DOWN; downBtn.addEventListener('click', (e) => { e.stopPropagation(); const root = wrapper.closest('.csk-window'); if (root) { root.dispatchEvent(new CustomEvent('csk:feedback', { detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' } })); } }); bar.appendChild(copyBtn); bar.appendChild(retryBtn); bar.appendChild(upBtn); bar.appendChild(downBtn); return bar; } /** * 更新反馈按钮的视觉状态(由 chat.ts 调用) * @param wrapper 消息 wrapper 元素 * @param value 当前反馈值:'up' | 'down' | undefined */ export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void { const btns = wrapper.querySelectorAll('.csk-feedback-btn'); if (btns.length < 2) return; btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down')); if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active'); if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down'); } /** 复制文本到剪贴板,带"已复制"反馈 */ async function copyToClipboard(text: string, btn: HTMLElement): Promise { const original = btn.innerHTML; try { if (navigator.clipboard && window.isSecureContext) { await navigator.clipboard.writeText(text); } else { // 降级方案:execCommand const ta = document.createElement('textarea'); ta.value = text; ta.style.position = 'fixed'; ta.style.opacity = '0'; document.body.appendChild(ta); ta.select(); document.execCommand('copy'); document.body.removeChild(ta); } btn.innerHTML = ``; btn.classList.add('csk-action-btn--done'); setTimeout(() => { btn.innerHTML = original; btn.classList.remove('csk-action-btn--done'); }, 1500); } catch { // 复制失败静默处理 } } /** 为气泡内所有代码块添加复制按钮 */ export function enhanceCodeBlocks(bubble: HTMLElement): void { const blocks = bubble.querySelectorAll('.csk-md-code-block'); blocks.forEach((pre) => { if (pre.querySelector('.csk-md-code-copy')) return; const codeEl = pre.querySelector('code'); const btn = document.createElement('button'); btn.type = 'button'; btn.className = 'csk-md-code-copy'; btn.setAttribute('title', t('copy')); btn.setAttribute('aria-label', t('copy')); btn.innerHTML = COPY_ICON; btn.addEventListener('click', async (e) => { e.stopPropagation(); await copyToClipboard(codeEl?.textContent || '', btn); }); (pre as HTMLElement).appendChild(btn); }); } /** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */ export function appendStreamingCaret(bubble: HTMLElement): void { if (bubble.querySelector('.csk-caret')) return; const caret = document.createElement('span'); caret.className = 'csk-caret'; caret.setAttribute('aria-hidden', 'true'); bubble.appendChild(caret); } /** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void { wrapper.classList.remove('csk-msg--streaming'); const caret = bubble.querySelector('.csk-caret'); if (caret) caret.remove(); enhanceCodeBlocks(bubble); } // ==================== P1: RAG 引用来源渲染 ==================== /** 渲染 RAG 引用来源卡片 */ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void { // 移除已有的来源卡片 const existing = wrapper.querySelector('.csk-sources'); if (existing) existing.remove(); if (!sources || sources.length === 0) return; const sourcesEl = document.createElement('div'); sourcesEl.className = 'csk-sources csk-sources--collapsed'; // 头部 const header = document.createElement('div'); header.className = 'csk-sources__header'; const titleSpan = document.createElement('span'); titleSpan.className = 'csk-sources__title'; titleSpan.textContent = `📚 ${t('source_count', { n: sources.length })}`; const arrow = document.createElement('span'); arrow.className = 'csk-sources__arrow'; arrow.textContent = '▼'; header.appendChild(titleSpan); header.appendChild(arrow); // 点击折叠/展开 header.addEventListener('click', () => { sourcesEl.classList.toggle('csk-sources--collapsed'); }); // 内容 const body = document.createElement('div'); body.className = 'csk-sources__body'; for (const src of sources) { const item = document.createElement('div'); item.className = 'csk-source-item'; const name = document.createElement('div'); name.className = 'csk-source-item__name'; name.textContent = src.title || src.sourceName || '未知文档'; if (src.snippet) { const snippet = document.createElement('div'); snippet.className = 'csk-source-item__snippet'; snippet.textContent = src.snippet; item.appendChild(snippet); } const meta = document.createElement('div'); meta.className = 'csk-source-item__meta'; const metaParts: string[] = []; if (src.sourceName) metaParts.push(src.sourceName); if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`); if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`); meta.textContent = metaParts.join(' · '); item.appendChild(name); item.appendChild(meta); body.appendChild(item); } sourcesEl.appendChild(header); sourcesEl.appendChild(body); // 插入到气泡和时间戳之间(时间戳在 content 容器内,故用其父节点插入) const timeEl = wrapper.querySelector('.csk-msg__time'); if (timeEl && timeEl.parentNode) { timeEl.parentNode.insertBefore(sourcesEl, timeEl); } else { wrapper.appendChild(sourcesEl); } } // ==================== P2: 会话管理面板渲染 ==================== /** 会话列表项数据 */ export interface HistoryItemData { id: string; chatId?: string; messageCount?: number; lastMessageTime?: string; lastMessagePreview?: string; createdAt?: string; } /** 渲染会话列表 */ export function renderHistoryList( listEl: HTMLElement, items: HistoryItemData[], onSelect: (conversationId: string) => void, onExport: (id: string) => void, onDelete: (id: string) => void, activeChatId?: string, emptyText?: string ): void { listEl.innerHTML = ''; if (items.length === 0) { const empty = document.createElement('div'); empty.className = 'csk-history-panel__empty'; empty.innerHTML = `
💬
${emptyText || t('history_empty')}
`; listEl.appendChild(empty); return; } for (const item of items) { const el = document.createElement('div'); el.className = 'csk-history-item'; // 高亮当前活跃会话 const convId = item.chatId || item.id; if (activeChatId && convId === activeChatId) { el.classList.add('csk-history-item--active'); } const info = document.createElement('div'); info.className = 'csk-history-item__info'; const idEl = document.createElement('div'); idEl.className = 'csk-history-item__id'; // 显示最后一条消息预览,没有则显示 chatId if (item.lastMessagePreview) { idEl.textContent = item.lastMessagePreview.length > 60 ? item.lastMessagePreview.substring(0, 60) + '...' : item.lastMessagePreview; } else { idEl.textContent = convId; } const metaEl = document.createElement('div'); metaEl.className = 'csk-history-item__meta'; const metaParts: string[] = []; if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`); if (item.lastMessageTime) metaParts.push(item.lastMessageTime); else if (item.createdAt) metaParts.push(item.createdAt); metaEl.textContent = metaParts.join(' · '); info.appendChild(idEl); info.appendChild(metaEl); const actionsEl = document.createElement('div'); actionsEl.className = 'csk-history-item__actions'; // 导出按钮 const exportBtn = document.createElement('button'); exportBtn.className = 'csk-history-action csk-history-action--export'; exportBtn.setAttribute('title', t('history_export')); exportBtn.innerHTML = ``; exportBtn.addEventListener('click', (e) => { e.stopPropagation(); onExport(item.id); }); // 删除按钮 const deleteBtn = document.createElement('button'); deleteBtn.className = 'csk-history-action csk-history-action--delete'; deleteBtn.setAttribute('title', t('history_delete')); deleteBtn.innerHTML = ``; deleteBtn.addEventListener('click', (e) => { e.stopPropagation(); onDelete(item.id); }); actionsEl.appendChild(exportBtn); actionsEl.appendChild(deleteBtn); el.appendChild(info); el.appendChild(actionsEl); // 点击整行 → 切换到该会话 el.addEventListener('click', () => { onSelect(convId); }); listEl.appendChild(el); } } /** 滚动消息区到底部 */ export function scrollToBottom(container: HTMLElement): void { container.scrollTop = container.scrollHeight; } /** 格式化时间戳 */ function formatTime(timestamp: number): string { const d = new Date(timestamp); const hh = String(d.getHours()).padStart(2, '0'); const mm = String(d.getMinutes()).padStart(2, '0'); return `${hh}:${mm}`; }