/** * 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'; // 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式 const glassClass = config.launcherTheme ? ' csk-launcher--glass' : ''; launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`; 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, }; } // ==================== 拖拽支持 ==================== /** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */ export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragEnd?: (pos: { x: number; y: number }) => void): () => 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); // 拖拽结束回调:返回当前位置供持久化 if (onDragEnd) { const rect = windowEl.getBoundingClientRect(); onDragEnd({ x: rect.left, y: rect.top }); } }; headerEl.addEventListener('mousedown', onMouseDown); // 清理函数 return () => { headerEl.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); }; } /** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */ const LAUNCHER_MIN_BOTTOM = 16; /** Launcher 拖拽:计算当前视口下最大 bottom 值 */ function launcherMaxBottom(): number { return window.innerHeight - 76; // 按钮高度 60 + 16 间距 } /** * 启用 Launcher 按钮的拖拽功能(边缘吸附式) * * 设计要点: * - 仅允许垂直方向自由移动,水平方向释放时自动吸附到最近的左/右边缘 * - 移动距离 < 5px 视为点击(不干扰原有的 open/close 逻辑) * - 拖拽中按钮微放大 + 隐藏未读徽章,释放后带缓动吸附动画 * - 支持鼠标 + 触摸事件,触摸拖拽时阻止页面滚动 */ export function enableLauncherDrag( launcherEl: HTMLElement, config: Pick, onDragEnd?: (pos: { side: 'left' | 'right'; bottom: number }) => void, ): () => void { let dragging = false; let startX = 0; let startY = 0; let hasMoved = false; let startBottom = 0; let startRight = 0; let startLeft = 0; // 标记是否正在吸附过渡中,避免过渡期间的 mouseup 再次触发 let isSnapping = false; /** 获取当前 bottom 值(纯数值) */ function getCurrentBottom(): number { const val = parseFloat(getComputedStyle(launcherEl).bottom); return isNaN(val) ? 24 : val; } function onPointerDown(clientX: number, clientY: number): void { if (isSnapping) return; dragging = true; hasMoved = false; startX = clientX; startY = clientY; startBottom = getCurrentBottom(); // 记录水平起始位置(用于判断当前在哪一侧) const style = launcherEl.style; const cs = getComputedStyle(launcherEl); startRight = style.right ? parseFloat(style.right) : parseFloat(cs.right); startLeft = style.left ? parseFloat(style.left) : parseFloat(cs.left); } function onPointerMove(clientX: number, clientY: number, e: Event): void { if (!dragging) return; const dx = clientX - startX; const dy = clientY - startY; if (!hasMoved) { if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return; hasMoved = true; // 进入拖拽态 launcherEl.classList.add('csk-launcher--dragging'); launcherEl.classList.remove('csk-launcher--right', 'csk-launcher--left'); launcherEl.style.transition = 'none'; // 同步当前水平位置(从吸附态的 right/left 转换为固定像素) if (!isNaN(startRight) && startRight >= 0 && isNaN(startLeft)) { launcherEl.style.right = `${startRight}px`; launcherEl.style.left = 'auto'; } else if (!isNaN(startLeft) && startLeft >= 0) { launcherEl.style.left = `${startLeft}px`; launcherEl.style.right = 'auto'; } } if (hasMoved) { e.preventDefault(); // 阻止触摸滚动 const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom())); launcherEl.style.bottom = `${newBottom}px`; } } function onPointerUp(): void { if (!dragging) return; dragging = false; if (!hasMoved) return; // 移动距离不足 5px,视为点击,不处理 // 隐藏拖拽态(保留当前 bottom) const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom(); const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom())); // 判断吸附到哪一侧(取离哪边更近) const rect = launcherEl.getBoundingClientRect(); const center = rect.left + rect.width / 2; const snapSide: 'left' | 'right' = center <= window.innerWidth / 2 ? 'left' : 'right'; // 切换到吸附过渡动画 launcherEl.classList.remove('csk-launcher--dragging'); launcherEl.classList.add(`csk-launcher--${snapSide}`, 'csk-launcher--snap'); launcherEl.style.left = ''; launcherEl.style.right = ''; launcherEl.style.bottom = `${clampedBottom}px`; // 过渡结束后清理(保留 bottom,否则 CSS 默认 24px 会覆盖拖拽位置) isSnapping = true; const onTransitionEnd = (): void => { launcherEl.removeEventListener('transitionend', onTransitionEnd); launcherEl.classList.remove('csk-launcher--snap'); launcherEl.style.transition = ''; // 注意:不清除 style.bottom,保留用户拖拽的垂直位置 isSnapping = false; }; launcherEl.addEventListener('transitionend', onTransitionEnd); // 兜底:350ms 后强制清理(transitionend 可能不触发) setTimeout(() => { if (isSnapping) onTransitionEnd(); }, 350); // 恢复未读徽章 const badge = launcherEl.querySelector('.csk-launcher__badge'); if (badge) (badge as HTMLElement).style.display = ''; if (onDragEnd) { onDragEnd({ side: snapSide, bottom: clampedBottom }); } } // --- 鼠标事件 --- function onMouseDown(e: MouseEvent): void { onPointerDown(e.clientX, e.clientY); } function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); } function onMouseUp(): void { onPointerUp(); } // --- 触摸事件 --- function onTouchStart(e: TouchEvent): void { if (e.touches.length === 1) onPointerDown(e.touches[0].clientX, e.touches[0].clientY); } function onTouchMove(e: TouchEvent): void { if (e.touches.length === 1) onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e); } function onTouchEnd(): void { onPointerUp(); } launcherEl.addEventListener('mousedown', onMouseDown); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); launcherEl.addEventListener('touchstart', onTouchStart, { passive: true }); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); return () => { launcherEl.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); launcherEl.removeEventListener('touchstart', onTouchStart); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); }; } // ==================== 消息渲染 ==================== /** 渲染用户消息气泡 */ 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; } /** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */ function groupHistoryByDate(items: HistoryItemData[]): { label: string; items: HistoryItemData[] }[] { const now = new Date(); const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime(); const yesterdayStart = todayStart - 86400000; const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一 const groups: Record = { today: [], yesterday: [], week: [], earlier: [], }; for (const item of items) { // 尝试解析时间:优先 lastMessageTime,其次 createdAt const timeStr = item.lastMessageTime || item.createdAt; const ts = timeStr ? new Date(timeStr).getTime() : 0; if (ts >= todayStart) { groups.today.push(item); } else if (ts >= yesterdayStart) { groups.yesterday.push(item); } else if (ts >= weekStart) { groups.week.push(item); } else { groups.earlier.push(item); } } const result: { label: string; items: HistoryItemData[] }[] = []; if (groups.today.length) result.push({ label: t('history_group_today'), items: groups.today }); if (groups.yesterday.length) result.push({ label: t('history_group_yesterday'), items: groups.yesterday }); if (groups.week.length) result.push({ label: t('history_group_week'), items: groups.week }); if (groups.earlier.length) result.push({ label: t('history_group_earlier'), items: groups.earlier }); return result; } /** 渲染会话列表(带日期分组) */ 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; } // 按日期分组渲染 const groups = groupHistoryByDate(items); for (const group of groups) { // 分组标题 if (groups.length > 1) { const groupEl = document.createElement('div'); groupEl.className = 'csk-history-group'; const labelEl = document.createElement('div'); labelEl.className = 'csk-history-group__label'; labelEl.textContent = group.label; groupEl.appendChild(labelEl); listEl.appendChild(groupEl); } for (const item of group.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); } } // 关闭 group 循环 } /** 滚动消息区到底部 */ 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}`; }