/** * DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器 * * 设计要点: * - 头部含机器人头像 + 在线状态,增强产品感 * - 消息气泡带头像,左右区分清晰 * - 无消息时显示欢迎空状态 * - 输入区采用圆角容器包裹文本框与发送按钮 */ import { ResolvedConfig, RagSource } from './types'; import { debounce, formatTime } from './utils'; import { t } from './i18n'; import { DialogPlugin } from 'tdesign-web-components/lib/dialog/index.js'; // omi 的 render/h 用于创建带 on* 函数回调的组件(见 createEventful 说明) import { render, h } from 'omi'; /** * 用 omi render 创建带函数事件回调的 TDesign 组件。 * * 为什么不能直接 createElement + property 赋值: * omi(tdesign-web-components 底层框架)的 isComplexType 对 /^on|children/ 前缀返回 false, * 纯 DOM 下 on* 回调(onChange/onClick 等)既不走 handleComplexProps 的 accessor,也无法从 * attribute 读取,导致 `el.onChange = fn` 静默失效;而 select/tag 内部是直接 * `this.props.onChange.call()`,没有 fire 的 dispatchEvent 兜底,必须借助 omi render 把 * 函数作为 vnode attributes 同步进 props。 */ function createEventful( tagName: string, props: Record, container: HTMLElement, children?: string | unknown[], ): HTMLElement { // children 作为 vnode 的 children 传入(tag/button 等通过 children/slot 渲染文本,而非 content) const vnode = children !== undefined ? h(tagName, props, children) : h(tagName, props); return render(vnode, container) as HTMLElement; } // ==================== 图标常量 ==================== /** 机器人头像图标(用于头部、欢迎态、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; } // ==================== 水印生成 ==================== /** 生成水印纹理背景图(Canvas 离屏渲染,平铺用) */ function createWatermarkPattern(config: ResolvedConfig): string { const canvas = document.createElement('canvas'); const ctx = canvas.getContext('2d'); if (!ctx) return ''; // 组装水印文本:配置值 + 日期 时分 const now = new Date(); const y = now.getFullYear(); const m = String(now.getMonth() + 1).padStart(2, '0'); const d = String(now.getDate()).padStart(2, '0'); const h = String(now.getHours()).padStart(2, '0'); const min = String(now.getMinutes()).padStart(2, '0'); const dateStr = `${y}-${m}-${d} ${h}:${min}`; const watermarkText = config.watermark ? `${config.watermark} ${dateStr}` : dateStr; // 一个平铺单元的尺寸 const cellW = 280; const cellH = 160; canvas.width = cellW; canvas.height = cellH; // 按主题选择透明度 const isDark = config.theme === 'dark'; ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.06)'; ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif'; ctx.textAlign = 'center'; ctx.textBaseline = 'middle'; // 倾斜绘制 ctx.save(); ctx.translate(cellW / 2, cellH / 2); ctx.rotate(-22 * Math.PI / 180); ctx.fillText(watermarkText, 0, 0); ctx.restore(); return canvas.toDataURL('image/png'); } // ==================== 聊天弹窗 ==================== /** 创建聊天弹窗完整结构,返回各区域引用 */ export function createChatWindow(config: ResolvedConfig): { window: HTMLElement; messagesContainer: HTMLElement; inputEl: HTMLElement; clearBtn: HTMLElement | null; categorySelect: HTMLElement | null; roleSelect: HTMLElement | null; historyPanel: HTMLElement; welcomeEl: HTMLElement; newMsgBtn: HTMLElement; teaserEl: HTMLElement; resizeHandle: HTMLElement; searchInput: HTMLInputElement | null; ariaLiveEl: HTMLElement; disclaimer: 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'); // === 水印层(仅当配置了 watermark 时创建,绝对定位背景,不拦截交互) === if (config.watermark) { const watermark = document.createElement('div'); watermark.className = 'csk-watermark'; const watermarkBg = createWatermarkPattern(config); if (watermarkBg) { watermark.style.backgroundImage = `url(${watermarkBg})`; } windowEl.appendChild(watermark); } // === 头部 === 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); // === 角色选择器(仅当有多个角色时显示,t-select) === let roleSelect: HTMLElement | null = null; const roles = config.roles; if (roles && roles.length > 1) { // 包装后插入到 headerLeft 之后、actions 之前 const roleWrap = document.createElement('div'); roleWrap.className = 'csk-role-select-wrap'; // on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props roleSelect = createEventful('t-select', { options: roles.map(role => ({ label: role.name || String(role.id), value: String(role.id) })), value: String(config.integrateId), // onChange 触发自定义事件(受控:同步回写选中值) onChange: (value: string | number) => { if (roleSelect) { (roleSelect as unknown as { value: string | number }).value = String(value); } windowEl.dispatchEvent(new CustomEvent('csk:roleChange', { detail: { roleId: String(value) } })); }, }, roleWrap); roleSelect.setAttribute('aria-label', t('role_selector_label')); header.appendChild(headerLeft); header.appendChild(roleWrap); } else { header.appendChild(headerLeft); } 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(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')}
`; // 快捷问题芯片(点击即自动发送,由 t-tag 承载) if (config.quickReplies.length > 0) { const chips = document.createElement('div'); chips.className = 'csk-quick-replies'; for (const reply of config.quickReplies) { // on* 回调(onClick)必须经 omi render 创建;文本经 children 传入(tag 的 children 优先于 content) createEventful('t-tag', { theme: 'primary', variant: 'light-outline', onClick: () => { windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } })); }, }, chips, reply); } 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')); } }); // === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) === let categorySelect: HTMLElement | 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 = '📚'; categoryBar.appendChild(categoryLabel); // on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props categorySelect = createEventful('t-select', { options: [{ label: t('category_all'), value: '' }], value: '', // onChange 触发自定义事件(受控:同步回写选中值) onChange: (value: string | number) => { if (categorySelect) { (categorySelect as unknown as { value: string | number }).value = String(value); } const selectedId = String(value); windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', { detail: { categoryId: selectedId ? Number(selectedId) : undefined } })); }, }, categoryBar); categorySelect.id = 'csk-category-select'; // 插入到 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'; // 输入框 + 发送/停止按钮统一由 t-chat-sender 承担(loading 控制 send/stop 切换,Enter 发送已内置) const inputEl = document.createElement('t-chat-sender'); inputEl.id = 'csk-input'; inputEl.setAttribute('placeholder', t('placeholder')); // 压缩输入框:autosize 为 Object 复杂类型,走 property 赋值(同 dom.ts createChatItemMsg 的 content 处理) (inputEl as unknown as { autosize: { minRows: number } }).autosize = { minRows: 1 }; inputArea.appendChild(inputEl); // === 保密声明脚注(默认折叠为单行,点击展开完整条文) === const disclaimer = document.createElement('div'); disclaimer.className = 'csk-disclaimer'; disclaimer.setAttribute('role', 'button'); disclaimer.setAttribute('tabindex', '0'); disclaimer.setAttribute('aria-expanded', 'false'); const toggleDisclaimer = () => { const expanded = disclaimer.classList.toggle('csk-disclaimer--expanded'); disclaimer.setAttribute('aria-expanded', String(expanded)); }; disclaimer.addEventListener('click', toggleDisclaimer); disclaimer.addEventListener('keydown', (e) => { if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDisclaimer(); } }); if (config.disclaimer !== undefined) { // 用户自定义或传空串隐藏。 // 注意:disclaimer 语义为「受信 HTML」(由宿主开发者或后端管理员配置), // 此处直接 innerHTML 注入属设计内行为,请勿传入不可信用户输入。 disclaimer.innerHTML = config.disclaimer; if (!config.disclaimer) { disclaimer.style.display = 'none'; } } else { // 未传值,等异步从后端拉取,先隐藏 disclaimer.style.display = 'none'; } inputArea.appendChild(disclaimer); 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 = ''; return loadingEl; } const el = document.createElement('t-chat-loading'); el.setAttribute('animation', 'dots'); el.setAttribute('text', t('loading')); 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'); const teaserCloseBtn = document.createElement('button'); teaserCloseBtn.className = 'csk-teaser__close'; teaserCloseBtn.setAttribute('aria-label', t('close')); teaserCloseBtn.type = 'button'; teaserCloseBtn.textContent = '×'; const teaserTextSpan = document.createElement('span'); // 文本语义:teaserText 使用 textContent 注入,避免宿主传入未信任内容时构成 XSS teaserTextSpan.textContent = config.teaserText || t('teaser_text'); teaserEl.appendChild(teaserCloseBtn); teaserEl.appendChild(teaserTextSpan); // === 缩放拖拽手柄(右下角) === const resizeHandle = document.createElement('div'); resizeHandle.className = 'csk-resize-handle'; resizeHandle.setAttribute('aria-label', t('resize')); // 纯 CSS 渲染三条斜线,无需 SVG resizeHandle.innerHTML = ''; windowEl.appendChild(resizeHandle); return { window: windowEl, messagesContainer, inputEl, clearBtn, categorySelect, historyPanel, welcomeEl, newMsgBtn, teaserEl, resizeHandle, searchInput, ariaLiveEl, roleSelect, disclaimer, 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); // 阻止本次拖拽触发 click 事件(mousedown → mousemove → mouseup → click 的浏览器默认链路) const preventClick = (ev: Event): void => { ev.stopPropagation(); launcherEl.removeEventListener('click', preventClick, true); }; launcherEl.addEventListener('click', preventClick, true); // 恢复未读徽章 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); }; } // ==================== 窗口缩放支持 ==================== /** 窗口最小尺寸 */ const MIN_WIDTH = 300; const MIN_HEIGHT = 300; /** * 启用窗口右下角拖拽缩放 * @param handleEl 缩放手柄元素 * @param windowEl 聊天窗口元素 * @param onResizeEnd 缩放结束回调,返回最终宽高用于持久化 * @returns 清理函数 */ export function enableResize( handleEl: HTMLElement, windowEl: HTMLElement, onResizeEnd?: (size: { width: number; height: number }) => void, ): () => void { let resizing = false; let startX = 0; let startY = 0; let startWidth = 0; let startHeight = 0; const onPointerDown = (clientX: number, clientY: number): void => { resizing = true; startX = clientX; startY = clientY; const rect = windowEl.getBoundingClientRect(); startWidth = rect.width; startHeight = rect.height; windowEl.classList.add('csk-window--resizing'); document.addEventListener('mousemove', onMouseMove); document.addEventListener('mouseup', onMouseUp); document.addEventListener('touchmove', onTouchMove, { passive: false }); document.addEventListener('touchend', onTouchEnd); }; const onPointerMove = (clientX: number, clientY: number, e?: Event): void => { if (!resizing) return; if (e) e.preventDefault(); const dx = clientX - startX; const dy = clientY - startY; const newWidth = Math.max(MIN_WIDTH, Math.min(startWidth + dx, window.innerWidth - 24)); const newHeight = Math.max(MIN_HEIGHT, Math.min(startHeight + dy, window.innerHeight - 24)); windowEl.style.width = `${newWidth}px`; windowEl.style.height = `${newHeight}px`; // 同步更新 CSS 变量(供内部布局使用) windowEl.style.setProperty('--csk-window-width', `${newWidth}px`); windowEl.style.setProperty('--csk-window-height', `${newHeight}px`); // 拖拽缩放后清除 bottom/right 定位,避免与固定定位冲突 windowEl.style.bottom = ''; windowEl.style.right = ''; }; const onPointerUp = (): void => { if (!resizing) return; resizing = false; windowEl.classList.remove('csk-window--resizing'); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); const rect = windowEl.getBoundingClientRect(); if (onResizeEnd) { onResizeEnd({ width: rect.width, height: rect.height }); } }; // 鼠标事件(DOM 事件监听器包装) function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); } function onMouseUp(): void { onPointerUp(); } // 鼠标事件 function onMouseDown(e: MouseEvent): void { e.preventDefault(); e.stopPropagation(); onPointerDown(e.clientX, e.clientY); } // 触摸事件 function onTouchStart(e: TouchEvent): void { e.preventDefault(); e.stopPropagation(); 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(); } handleEl.addEventListener('mousedown', onMouseDown); handleEl.addEventListener('touchstart', onTouchStart, { passive: false }); return () => { handleEl.removeEventListener('mousedown', onMouseDown); handleEl.removeEventListener('touchstart', onTouchStart); document.removeEventListener('mousemove', onMouseMove); document.removeEventListener('mouseup', onMouseUp); document.removeEventListener('touchmove', onTouchMove); document.removeEventListener('touchend', onTouchEnd); }; } // ==================== 消息渲染 ==================== /** 渲染用户消息气泡 */ /** 创建一个 t-chat-item 消息元素(TDesign Chat 消息气泡,实际标签为 t-chat-item) */ function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], timestamp: number, msgId?: string): HTMLElement { const msg = document.createElement('t-chat-item'); // omi 组件约定:复杂类型(Array/Object)用 property 赋值;简单类型(String/Boolean)用 setAttribute msg.setAttribute('role', role); msg.setAttribute('datetime', formatTime(timestamp)); if (msgId) msg.setAttribute('id', msgId); (msg as unknown as { content: unknown[] }).content = content; if (role === 'user') { msg.setAttribute('placement', 'right'); msg.setAttribute('variant', 'base'); } else { msg.setAttribute('placement', 'left'); msg.setAttribute('variant', 'text'); } return msg; } export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--user'; wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp)); container.appendChild(wrapper); return wrapper; } /** 渲染错误气泡(发送失败提示,用 t-chat-item 的 error 状态承载) */ export function renderErrorBubble(container: HTMLElement, errMsg: string, timestamp: number): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--ai'; const msg = createChatItemMsg('assistant', [{ type: 'text', data: `⚠ ${errMsg}` }], timestamp); msg.setAttribute('status', 'error'); wrapper.appendChild(msg); container.appendChild(wrapper); return wrapper; } /** 渲染 AI 消息气泡(Markdown 由 t-chat-item 内置 cherry-markdown 渲染) */ export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, msgId?: string, feedback?: 'up' | 'down'): HTMLElement { const wrapper = document.createElement('div'); wrapper.className = 'csk-msg csk-msg--ai'; if (msgId) wrapper.dataset.cskMsgId = msgId; const msg = createChatItemMsg('assistant', [{ type: 'markdown', data: text }], timestamp, msgId); msg.setAttribute('status', 'complete'); // 操作栏投射到 t-chat-item 的 actionbar 插槽 msg.appendChild(createChatAction(wrapper, text, msgId, feedback)); wrapper.appendChild(msg); container.appendChild(wrapper); 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'; if (msgId) wrapper.dataset.cskMsgId = msgId; const msg = createChatItemMsg('assistant', [], timestamp, msgId); msg.setAttribute('status', 'streaming'); // 操作栏投射到 t-chat-item 的 actionbar 插槽(流式期间 copyText 为空,结束由 finalizeAIBubble 补全) msg.appendChild(createChatAction(wrapper, '', msgId)); wrapper.appendChild(msg); container.appendChild(wrapper); return { wrapper, bubble: msg }; } // ==================== AI 消息操作条 + 代码块复制 ==================== /** * 创建 AI 消息操作条(t-chat-action,投射到 t-chat-item 的 actionbar 插槽) * 复制/重试/点赞/点踩均由 TDesign 组件承担;handleAction 回调负责分发 SDK 自定义事件 */ function createChatAction(wrapper: HTMLElement, text: string, msgId?: string, feedback?: 'up' | 'down'): HTMLElement { const actionEl = document.createElement('t-chat-action'); actionEl.setAttribute('slot', 'actionbar'); const el = actionEl as unknown as { actionBar: string[]; handleAction: (action: string, data: { event?: Event; active?: boolean }) => void; }; el.actionBar = ['replay', 'copy', 'good', 'bad']; // copyText/comment 为 String 简单类型,须用 setAttribute(property 赋值在 omi 下静默失效) actionEl.setAttribute('copyText', text); actionEl.setAttribute('comment', feedback === 'up' ? 'good' : feedback === 'down' ? 'bad' : ''); el.handleAction = (action, data) => { const root = wrapper.closest('.csk-window'); if (!root) return; if (action === 'replay') { root.dispatchEvent(new CustomEvent('csk:retry', { detail: { msgId: msgId || '' } })); } else if (action === 'good' || action === 'bad') { root.dispatchEvent(new CustomEvent('csk:feedback', { detail: { msgId: msgId || '', value: action === 'good' ? 'up' : 'down', active: !!data?.active } })); } // copy 由 t-chat-action 内部处理(用 copyText 调 navigator.clipboard),无需分发 }; return actionEl; } /** * 回滚 t-chat-action 的反馈图标状态(重建组件为 comment='')。 * t-chat-action 的 comment 仅在首次挂载时生效,运行时无法外部更新, * 点踩弹原因面板被用户关闭时,通过重建组件来恢复未选中状态。 */ export function resetChatActionFeedback(wrapper: HTMLElement): void { const msg = wrapper.querySelector('t-chat-item'); const actionEl = wrapper.querySelector('t-chat-action'); if (!msg || !actionEl) return; const text = actionEl.getAttribute('copyText') || ''; const msgId = wrapper.dataset.cskMsgId; actionEl.remove(); msg.appendChild(createChatAction(wrapper, text, msgId, undefined)); } /** 标记 AI 气泡流式结束:切换 status 为 complete,并同步复制内容为最终 markdown 原文 */ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void { bubble.setAttribute('status', 'complete'); // 流式期间 copyText 为空,结束后同步为最终原文(t-chat-action 的 copyText 支持运行时更新) const contentArr = (bubble as unknown as { content?: Array<{ data?: string }> }).content; const text = (contentArr?.map(c => c.data || '').join('') || ''); const actionEl = wrapper.querySelector('t-chat-action'); if (actionEl) actionEl.setAttribute('copyText', text); } // ==================== P1: RAG 引用来源渲染 ==================== /** * 渲染 RAG 引用来源卡片(自定义折叠) * 说明:尝试用 替换,但 tdesign-web-components 的 collapse/collapse-panel * 依赖 omi 的 provide/inject 机制,在纯 DOM(createElement + appendChild)下无法建立 * 注入链路(报 `this.injection.getCollapseValue is not a function`),故保留轻量自定义折叠。 */ 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); // 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方 const msgEl = wrapper.querySelector('t-chat-item'); if (msgEl && msgEl.parentNode) { msgEl.parentNode.insertBefore(sourcesEl, msgEl.nextSibling); } else { wrapper.appendChild(sourcesEl); } } // ==================== P2: 会话管理面板渲染 ==================== /** 会话列表项数据 */ export interface HistoryItemData { id: string; chatId?: string; /** 会话所属角色 ID */ roleId?: number; /** 会话所属角色名称 */ roleName?: 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.roleName) metaParts.push(item.roleName); 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; } // ==================== 离线提示横幅 ==================== /** 在消息区顶部展示离线提示横幅(t-alert,theme=warning) */ export function showOfflineBanner(messagesContainer: HTMLElement): void { if (document.getElementById('csk-offline-banner')) return; const banner = document.createElement('t-alert'); banner.id = 'csk-offline-banner'; // message 为复杂类型(Object/Function 等),用 property;theme 为 String 简单类型,用 setAttribute (banner as unknown as { message: string }).message = t('error_offline'); banner.setAttribute('theme', 'warning'); messagesContainer.insertBefore(banner, messagesContainer.firstChild); } /** 隐藏离线提示横幅 */ export function hideOfflineBanner(): void { const el = document.getElementById('csk-offline-banner'); if (el) el.remove(); } // ==================== 建议问题(suggest-message-list) ==================== /** * 在 AI 消息气泡下方渲染推荐问题列表 * @param wrapper AI 消息的 wrapper 元素 * @param suggestions 推荐问题文本数组 * @param onClick 点击回调,传入问题文本 */ export function renderSuggestions( wrapper: HTMLElement, suggestions: string[], onClick: (text: string) => void ): void { // 移除已有的建议区域 removeSuggestions(wrapper); if (!suggestions || suggestions.length === 0) return; const section = document.createElement('div'); section.className = 'csk-suggestions'; const list = document.createElement('div'); list.className = 'csk-suggestions__list'; for (const text of suggestions) { // on* 回调(onClick)必须经 omi render 创建;tag 的 onClick 首参为原生事件,文本经 children 传入 createEventful('t-tag', { theme: 'primary', variant: 'light-outline', onClick: (e: Event) => { e.stopPropagation(); onClick(text); }, }, list, text); } section.appendChild(list); // 直接追加到 wrapper 末尾(wrapper 内即 t-chat-item,无 .csk-msg__content 中间层) wrapper.appendChild(section); } /** * 移除指定 wrapper 中的建议问题列表 */ export function removeSuggestions(wrapper: HTMLElement): void { const existing = wrapper.querySelector('.csk-suggestions'); if (existing) existing.remove(); } // ==================== 确认弹窗 + 反馈原因选择 ==================== /** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */ const REASON_OPTIONS: { key: string; labelKey: string }[] = [ { key: 'inaccurate', labelKey: 'feedback_reason_inaccurate' }, { key: 'irrelevant', labelKey: 'feedback_reason_irrelevant' }, { key: 'incomplete', labelKey: 'feedback_reason_incomplete' }, { key: 'other', labelKey: 'feedback_reason_other' }, ]; /** * 命令式确认弹窗(DialogPlugin.confirm),替代原生 confirm() * @returns 确认返回 true,取消/关闭返回 false */ export function confirmWithDialog(message: string): Promise { return new Promise((resolve) => { let settled = false; const finish = (val: boolean): void => { if (settled) return; settled = true; resolve(val); }; const dlg = DialogPlugin.confirm({ header: t('confirm'), body: message, confirmBtn: t('confirm'), cancelBtn: t('cancel'), onConfirm: () => { finish(true); dlg.destroy(); }, onCancel: () => { finish(false); dlg.destroy(); }, onClose: () => { // ESC / 遮罩点击 / 关闭按钮兜底 finish(false); dlg.destroy(); }, }); }); } /** * 在消息气泡下方展示点踩原因选择面板(内联,按钮用 t-button) * 说明:尝试用 改为模态弹窗,但 dialog 的 visible/confirmBtn/cancelBtn 属性 * 未在 omi propTypes 声明,纯 DOM property 赋值不生效(渲染为 display:none),且复杂 * body 无法通过 DialogPlugin 注入,故保留内联面板形态,仅将按钮升级为 t-button。 */ export function showFeedbackReasonPanel( wrapper: HTMLElement, _msgId: string, onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void, onCancel?: () => void, ): void { // 移除已存在的原因面板 const existing = wrapper.querySelector('.csk-feedback-reason'); if (existing) existing.remove(); const panel = document.createElement('div'); panel.className = 'csk-feedback-reason'; // 标题 const title = document.createElement('div'); title.className = 'csk-feedback-reason__title'; title.textContent = t('feedback_reason_title'); panel.appendChild(title); // 原因选项按钮(t-button,点击即确认) const optionsRow = document.createElement('div'); optionsRow.className = 'csk-feedback-reason__options'; REASON_OPTIONS.forEach((opt) => { const btn = document.createElement('t-button'); // t-button 文本通过 渲染(light DOM children),须用 textContent 而非 content 属性 btn.textContent = t(opt.labelKey); btn.setAttribute('theme', 'default'); btn.setAttribute('variant', 'outline'); // button 的 click 经 eventDispose → fire 派发 CustomEvent('click'),用 addEventListener 监听 btn.addEventListener('click', (e) => { e.stopPropagation(); onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other'); panel.remove(); }); optionsRow.appendChild(btn); }); panel.appendChild(optionsRow); // 补充说明输入框(可选) const commentRow = document.createElement('div'); commentRow.className = 'csk-feedback-reason__comment-row'; const commentInput = document.createElement('input'); commentInput.type = 'text'; commentInput.className = 'csk-feedback-reason__comment'; commentInput.placeholder = t('feedback_reason_comment_placeholder'); commentInput.maxLength = 200; commentRow.appendChild(commentInput); const submitBtn = document.createElement('t-button'); submitBtn.textContent = t('feedback_reason_submit'); submitBtn.setAttribute('theme', 'primary'); submitBtn.setAttribute('variant', 'base'); submitBtn.addEventListener('click', (e) => { e.stopPropagation(); const comment = commentInput.value.trim() || undefined; onConfirm('other', comment); panel.remove(); }); commentRow.appendChild(submitBtn); panel.appendChild(commentRow); // 关闭按钮(右上角,轻量原生 button 承载 X 图标) const closeBtn = document.createElement('button'); closeBtn.type = 'button'; closeBtn.className = 'csk-feedback-reason__close'; closeBtn.setAttribute('aria-label', t('close')); closeBtn.innerHTML = ``; closeBtn.addEventListener('click', (e) => { e.stopPropagation(); panel.remove(); if (onCancel) onCancel(); }); panel.appendChild(closeBtn); wrapper.appendChild(panel); }