/** * AI 聊天对话框组件 * 支持拖拽、消息展示、问答卡片等功能 */ import './index.css'; import type { AiChatOptions, Message, QuestionMessage, QuestionOption, StepStatus } from './types'; import type { ThemeConfig } from '../../themes/types'; import { IBimComponent } from '../../types/component'; import { themeManager } from '../../services/theme'; import { t, localeManager } from '../../services/locale'; import { getIcon } from '../../utils/icon-manager'; /** * AI 聊天组件类 * 实现 IBimComponent 接口,支持主题切换和国际化 */ export class AiChat implements IBimComponent { /** 组件根元素 */ private element: HTMLElement; /** 组件配置选项 */ private options: AiChatOptions; /** 挂载容器 */ private container: HTMLElement; /** 消息列表容器 */ private messagesContainer: HTMLElement | null = null; /** 输入框元素 */ private textarea: HTMLTextAreaElement | null = null; /** 消息数据列表 */ private messages: Message[] = []; /** 是否已销毁 */ private _isDestroyed = false; /** 是否可见 */ private _isVisible = false; /** 主题订阅取消函数 */ private unsubscribeTheme: (() => void) | null = null; /** 语言订阅取消函数 */ private unsubscribeLocale: (() => void) | null = null; /** requestAnimationFrame ID,用于拖拽性能优化 */ private rafId: number | null = null; /** * 构造函数 * @param options 组件配置选项 */ constructor(options: AiChatOptions) { // 合并默认配置 this.options = { width: 440, title: 'aiChat.title', placeholder: 'aiChat.placeholder', quickPrompts: [ { id: 'summarize', label: 'aiChat.quickPrompt.summarize' }, { id: 'explain', label: 'aiChat.quickPrompt.explain' }, { id: 'generate', label: 'aiChat.quickPrompt.generate' } ], ...options }; this.container = options.container; this.element = this.createDom(); this.init(); } /** * 初始化组件 * 挂载到容器并订阅主题/语言变更 */ public init(): void { this.container.appendChild(this.element); this.hide(); // 订阅主题变更 this.unsubscribeTheme = themeManager.subscribe((theme) => { this.setTheme(theme); }); // 订阅语言变更 this.unsubscribeLocale = localeManager.subscribe(() => { this.setLocales(); }); this.setTheme(themeManager.getTheme()); this.setLocales(); } /** * 设置主题 * @param theme 主题配置对象 */ public setTheme(theme: ThemeConfig): void { // 根据主题名称判断深色/浅色模式 const isDark = theme.name === 'dark' || theme.name?.includes('dark'); this.element.classList.toggle('light', !isDark); // 设置 CSS 变量 const style = this.element.style; style.setProperty('--bim-ai-accent', theme.primary); style.setProperty('--bim-ai-text', theme.textPrimary); style.setProperty('--bim-ai-muted', theme.textTertiary); style.setProperty('--bim-ai-border', theme.borderDefault); style.setProperty('--bim-ai-shadow', theme.shadowLg); } /** * 设置国际化文本 * 更新标题、占位符、快捷提示等文本 */ public setLocales(): void { // 更新标题 const titleEl = this.element.querySelector('.bim-ai-chat-title'); if (titleEl && this.options.title) { titleEl.textContent = t(this.options.title); } // 更新输入框占位符 const placeholderEl = this.element.querySelector('.bim-ai-textarea') as HTMLTextAreaElement; if (placeholderEl && this.options.placeholder) { placeholderEl.placeholder = t(this.options.placeholder); } // 更新底部提示文字 const helperL = this.element.querySelector('.bim-ai-helper-left'); if (helperL) helperL.textContent = t('aiChat.helper.newline'); const helperR = this.element.querySelector('.bim-ai-helper-right'); if (helperR) helperR.textContent = t('aiChat.helper.send'); // 更新快捷提示按钮和消息列表 this.updateQuickPrompts(); this.renderMessages(); } /** * 显示对话框 */ public show(): void { this._isVisible = true; this.element.style.display = 'flex'; this.initPosition(); this.scrollToBottom(); } /** * 初始化对话框位置 * 首次显示时定位到右上角(距边缘 50px) */ private initPosition(): void { // 如果已有位置则不重置,保留用户拖拽后的位置 if (this.element.style.left) return; const containerW = this.container.clientWidth; const elW = this.element.offsetWidth; // 计算左边距:容器宽度 - 元素宽度 - 右边距(50px) const left = Math.max(50, containerW - elW - 50); this.element.style.left = `${left}px`; this.element.style.top = '50px'; } /** * 隐藏对话框 */ public hide(): void { this._isVisible = false; this.element.style.display = 'none'; } /** * 切换对话框显示/隐藏状态 */ public toggle(): void { if (this._isVisible) { this.hide(); } else { this.show(); } } /** * 获取对话框是否可见 */ public isVisible(): boolean { return this._isVisible; } /** * 添加消息 * @param message 消息对象 */ public addMessage(message: Message): void { this.messages.push(message); this.renderMessages(); this.scrollToBottom(); } /** * 更新消息 * @param id 消息 ID * @param updates 要更新的字段 */ public updateMessage(id: string, updates: Partial): void { const index = this.messages.findIndex(m => m.id === id); if (index !== -1) { this.messages[index] = { ...this.messages[index], ...updates } as Message; this.renderMessages(); } } /** * 删除消息 * @param id 消息 ID */ public removeMessage(id: string): void { this.messages = this.messages.filter(m => m.id !== id); this.renderMessages(); } /** * 清空所有消息 */ public clearMessages(): void { this.messages = []; this.renderMessages(); } /** * 添加用户消息 * @param content 消息内容 * @returns 消息 ID */ public addUserMessage(content: string): string { const id = `user-${Date.now()}`; this.addMessage({ id, type: 'user', content, timestamp: Date.now() }); return id; } /** * 添加 AI 回复消息 * @param content 消息内容 * @returns 消息 ID */ public addAiMessage(content: string): string { const id = `ai-${Date.now()}`; this.addMessage({ id, type: 'ai', content, timestamp: Date.now() }); return id; } /** * 添加步骤消息(执行中/完成/失败) * @param status 步骤状态 * @param content 步骤描述 * @returns 消息 ID */ public addStepMessage(status: StepStatus, content: string): string { const id = `step-${Date.now()}`; this.addMessage({ id, type: 'step', status, content, timestamp: Date.now() }); return id; } /** * 添加思考中消息 * @returns 消息 ID */ public addThinkingMessage(): string { const id = `thinking-${Date.now()}`; this.addMessage({ id, type: 'thinking', timestamp: Date.now() }); return id; } /** * 添加问答卡片消息 * @param title 问题标题 * @param question 问题内容 * @param options 选项列表 * @returns 消息 ID */ public addQuestionMessage(title: string, question: string, options: QuestionOption[]): string { const id = `question-${Date.now()}`; this.addMessage({ id, type: 'question', title, question, options, answered: false, timestamp: Date.now() }); return id; } /** * 创建 DOM 结构 * @returns 组件根元素 */ private createDom(): HTMLElement { const el = document.createElement('div'); el.className = 'bim-ai-chat'; el.style.width = `${this.options.width}px`; el.innerHTML = `
`; // 获取子元素引用 this.messagesContainer = el.querySelector('.bim-ai-chat-messages'); this.textarea = el.querySelector('.bim-ai-textarea'); // 绑定事件 this.bindEvents(el); this.updateQuickPrompts(el); return el; } /** * 绑定事件监听 * @param el 组件根元素 */ private bindEvents(el: HTMLElement): void { // 顶部操作按钮事件 el.querySelectorAll('.bim-ai-chat-action-btn').forEach(btn => { btn.addEventListener('click', (e) => { const action = (e.currentTarget as HTMLElement).dataset.action; switch (action) { case 'new': this.options.onNewChat?.(); break; case 'history': this.options.onHistory?.(); break; case 'settings': this.options.onSettings?.(); break; case 'close': this.hide(); this.options.onClose?.(); break; } }); }); // 初始化拖拽功能 const header = el.querySelector('.bim-ai-chat-header') as HTMLElement; if (header) { this.initDrag(header); } // 发送按钮点击事件 const sendBtn = el.querySelector('.bim-ai-send-btn'); sendBtn?.addEventListener('click', () => this.handleSend()); // 输入框键盘事件:Enter 发送,Shift+Enter 换行 this.textarea?.addEventListener('keydown', (e) => { if (e.key === 'Enter' && !e.shiftKey) { e.preventDefault(); this.handleSend(); } }); // 输入框自动调整高度 this.textarea?.addEventListener('input', () => { if (this.textarea) { this.textarea.style.height = 'auto'; this.textarea.style.height = Math.min(this.textarea.scrollHeight, 100) + 'px'; } }); // 阻止事件冒泡,避免影响底层 3D 场景 const stopPropagation = (e: Event) => e.stopPropagation(); const events = ['click', 'dblclick', 'mousedown', 'mouseup', 'mousemove', 'wheel', 'touchstart', 'touchend', 'touchmove']; events.forEach(eventType => { el.addEventListener(eventType, stopPropagation, { passive: false }); }); } /** * 处理发送消息 */ private handleSend(): void { if (!this.textarea) return; const content = this.textarea.value.trim(); if (!content) return; // 清空输入框并重置高度 this.textarea.value = ''; this.textarea.style.height = 'auto'; // 触发回调 this.options.onSend?.(content); } /** * 初始化拖拽功能 * 使用 requestAnimationFrame 优化性能,避免拖拽卡顿 * @param header 标题栏元素(拖拽手柄) */ private initDrag(header: HTMLElement): void { // 拖拽状态变量 let startX = 0; // 鼠标起始 X 坐标 let startY = 0; // 鼠标起始 Y 坐标 let startLeft = 0; // 元素起始 left 值 let startTop = 0; // 元素起始 top 值 let containerW = 0; // 容器宽度 let containerH = 0; // 容器高度 let elW = 0; // 元素宽度 let elH = 0; // 元素高度 /** * 鼠标按下事件处理 */ const onMouseDown = (e: MouseEvent) => { // 如果点击的是操作按钮,不触发拖拽 if ((e.target as HTMLElement).closest('.bim-ai-chat-action-btn')) return; e.preventDefault(); e.stopPropagation(); // 记录起始状态 startX = e.clientX; startY = e.clientY; startLeft = this.element.offsetLeft; startTop = this.element.offsetTop; // 记录容器和元素尺寸(用于边界检测) containerW = this.container.clientWidth; containerH = this.container.clientHeight; elW = this.element.offsetWidth; elH = this.element.offsetHeight; // 添加全局鼠标事件监听(使用 capture 确保优先处理) document.addEventListener('mousemove', onMouseMove, { capture: true }); document.addEventListener('mouseup', onMouseUp, { capture: true }); }; /** * 鼠标移动事件处理 * 使用 requestAnimationFrame 节流,提升性能 */ const onMouseMove = (e: MouseEvent) => { e.preventDefault(); e.stopPropagation(); // 如果上一帧还没处理完,跳过本次 if (this.rafId) return; this.rafId = requestAnimationFrame(() => { // 计算鼠标移动的增量 const dx = e.clientX - startX; const dy = e.clientY - startY; // 计算新位置 let newLeft = startLeft + dx; let newTop = startTop + dy; // 边界检测:限制在容器范围内 const maxLeft = containerW - elW; const maxTop = containerH - elH; newLeft = Math.max(0, Math.min(newLeft, maxLeft)); newTop = Math.max(0, Math.min(newTop, maxTop)); // 应用新位置 this.element.style.left = `${newLeft}px`; this.element.style.top = `${newTop}px`; this.rafId = null; }); }; /** * 鼠标释放事件处理 */ const onMouseUp = () => { // 取消未完成的动画帧 if (this.rafId) { cancelAnimationFrame(this.rafId); this.rafId = null; } // 移除全局事件监听 document.removeEventListener('mousemove', onMouseMove, { capture: true }); document.removeEventListener('mouseup', onMouseUp, { capture: true }); }; // 绑定标题栏鼠标按下事件 header.addEventListener('mousedown', onMouseDown); } /** * 更新快捷提示按钮 * @param el 可选的根元素(初始化时使用) */ private updateQuickPrompts(el?: HTMLElement): void { const root = el || this.element; const container = root.querySelector('.bim-ai-quick-prompts'); if (!container || !this.options.quickPrompts) return; // 渲染快捷提示按钮 container.innerHTML = this.options.quickPrompts.map(prompt => ` `).join(''); // 绑定点击事件:点击后填充到输入框 container.querySelectorAll('.bim-ai-quick-prompt').forEach(btn => { btn.addEventListener('click', (e) => { const promptId = (e.currentTarget as HTMLElement).dataset.promptId; const prompt = this.options.quickPrompts?.find(p => p.id === promptId); if (prompt && this.textarea) { this.textarea.value = t(prompt.label); this.textarea.focus(); } }); }); } /** * 渲染消息列表 * 根据消息类型渲染不同的消息卡片 */ private renderMessages(): void { if (!this.messagesContainer) return; this.messagesContainer.innerHTML = this.messages.map(msg => { switch (msg.type) { case 'user': return this.renderUserMessage(msg.content); case 'ai': return this.renderAiMessage(msg.content); case 'step': return this.renderStepMessage(msg.status, msg.content); case 'thinking': return this.renderThinkingMessage(); case 'question': return msg.answered ? this.renderAnsweredQuestion(msg) : this.renderActiveQuestion(msg); default: return ''; } }).join(''); // 重新绑定问答卡片的事件 this.bindQuestionEvents(); } /** * 渲染用户消息气泡 */ private renderUserMessage(content: string): string { return `
${this.escapeHtml(content)}
`; } /** * 渲染 AI 消息气泡 */ private renderAiMessage(content: string): string { return `
${this.escapeHtml(content)}
`; } /** * 渲染步骤消息卡片 * @param status 状态:running/done/error * @param content 步骤描述 */ private renderStepMessage(status: StepStatus, content: string): string { // 状态对应的图标映射 const iconMap: Record = { running: 'loader', done: 'check', error: 'error' }; return `
${getIcon(iconMap[status])} ${this.escapeHtml(content)}
`; } /** * 渲染思考中卡片 */ private renderThinkingMessage(): string { return `
${getIcon('loader')} ${t('aiChat.thinking')}
`; } /** * 渲染未回答的问答卡片 * @param msg 问答消息对象 */ private renderActiveQuestion(msg: QuestionMessage): string { // 渲染选项列表 const optionsHtml = msg.options.map(opt => `
${opt.isOther ? t('aiChat.other') : this.escapeHtml(opt.label)}
${opt.isOther && msg.selectedOptionId === opt.id ? ` ` : ''} `).join(''); return `
${getIcon('bot')}
${this.escapeHtml(msg.title)}
${this.escapeHtml(msg.question)}
${optionsHtml}
`; } /** * 渲染已回答的问答卡片(简洁模式) * @param msg 问答消息对象 */ private renderAnsweredQuestion(msg: QuestionMessage): string { const selectedOption = msg.options.find(o => o.id === msg.selectedOptionId); const answer = selectedOption?.isOther ? msg.customAnswer : selectedOption?.label; return `
Q
${this.escapeHtml(msg.question)}
A
${this.escapeHtml(answer || '')}
`; } /** * 绑定问答卡片的交互事件 * 包括:选项点击、自定义输入、提交按钮 */ private bindQuestionEvents(): void { // 选项点击事件 this.messagesContainer?.querySelectorAll('.bim-ai-option').forEach(option => { option.addEventListener('click', (e) => { const target = e.currentTarget as HTMLElement; const questionId = target.dataset.questionId; const optionId = target.dataset.optionId; if (questionId && optionId) { const msg = this.messages.find(m => m.id === questionId) as QuestionMessage; if (msg && !msg.answered) { msg.selectedOptionId = optionId; this.renderMessages(); } } }); }); // 自定义输入框事件 this.messagesContainer?.querySelectorAll('.bim-ai-option-input').forEach(input => { input.addEventListener('input', (e) => { const target = e.target as HTMLInputElement; const questionId = target.dataset.questionId; if (questionId) { const msg = this.messages.find(m => m.id === questionId) as QuestionMessage; if (msg) { msg.customAnswer = target.value; } } }); }); // 提交按钮事件 this.messagesContainer?.querySelectorAll('.bim-ai-question-submit').forEach(btn => { btn.addEventListener('click', (e) => { const target = e.currentTarget as HTMLElement; const questionId = target.dataset.questionId; if (questionId) { const msg = this.messages.find(m => m.id === questionId) as QuestionMessage; if (msg && msg.selectedOptionId) { msg.answered = true; this.renderMessages(); this.options.onQuestionSubmit?.(questionId, msg.selectedOptionId, msg.customAnswer); } } }); }); } /** * 滚动消息列表到底部 */ private scrollToBottom(): void { if (this.messagesContainer) { this.messagesContainer.scrollTop = this.messagesContainer.scrollHeight; } } /** * HTML 转义,防止 XSS 攻击 * @param str 原始字符串 * @returns 转义后的字符串 */ private escapeHtml(str: string): string { const div = document.createElement('div'); div.textContent = str; return div.innerHTML; } /** * 销毁组件 * 取消订阅并移除 DOM 元素 */ public destroy(): void { if (this._isDestroyed) return; // 取消主题订阅 if (this.unsubscribeTheme) { this.unsubscribeTheme(); this.unsubscribeTheme = null; } // 取消语言订阅 if (this.unsubscribeLocale) { this.unsubscribeLocale(); this.unsubscribeLocale = null; } // 移除 DOM 元素 this.element.remove(); this._isDestroyed = true; } }