Files
bim_engine/src/components/ai-chat/index.ts
yuding 4a09d52283 feat(clipping): implement hide/recover toggle for all section dialogs
Update all three section dialogs to support hide/show toggle:

SectionAxisDialogManager:
- onHideToggle now calls hideSection()/recoverSection()

SectionBoxDialogManager:
- onHideToggle now calls hideSection()/recoverSection()

SectionPlanePanel:
- Add isHidden state tracking
- Change onHide to onHideToggle(isHidden)
- Add setHiddenState/getHiddenState methods
- Update button to toggle active state

SectionPlaneDialogManager:
- Switch to onHideToggle callback
- Call hideSection()/recoverSection() based on toggle state

Behavior: Click hide button to hide section, click again to recover.
2026-02-02 16:36:17 +08:00

815 lines
27 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

/**
* 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<Message>): 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 = `
<div class="bim-ai-chat-header">
<span class="bim-ai-chat-title"></span>
<div class="bim-ai-chat-actions">
<button class="bim-ai-chat-action-btn" data-action="new" title="${t('aiChat.action.new')}">
${getIcon('plus')}
</button>
<button class="bim-ai-chat-action-btn" data-action="history" title="${t('aiChat.action.history')}">
${getIcon('history')}
</button>
<button class="bim-ai-chat-action-btn" data-action="settings" title="${t('aiChat.action.settings')}">
${getIcon('settings')}
</button>
<button class="bim-ai-chat-action-btn" data-action="close" title="${t('aiChat.action.close')}">
${getIcon('close')}
</button>
</div>
</div>
<div class="bim-ai-chat-messages"></div>
<div class="bim-ai-chat-composer">
<div class="bim-ai-quick-prompts"></div>
<div class="bim-ai-input-row">
<div class="bim-ai-textbox">
<textarea class="bim-ai-textarea" rows="1"></textarea>
<div class="bim-ai-helper">
<span class="bim-ai-helper-left"></span>
<span class="bim-ai-helper-right"></span>
</div>
</div>
<button class="bim-ai-send-btn">
${getIcon('arrowUpBold')}
</button>
</div>
</div>
`;
// 获取子元素引用
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 => `
<button class="bim-ai-quick-prompt" data-prompt-id="${prompt.id}">
${t(prompt.label)}
</button>
`).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 `
<div class="bim-ai-msg-user">
<div class="bim-ai-bubble">
<div class="bim-ai-bubble-content">${this.escapeHtml(content)}</div>
</div>
</div>
`;
}
/**
* 渲染 AI 消息气泡
*/
private renderAiMessage(content: string): string {
return `
<div class="bim-ai-msg-ai">
<div class="bim-ai-bubble">
<div class="bim-ai-bubble-content">${this.escapeHtml(content)}</div>
</div>
</div>
`;
}
/**
* 渲染步骤消息卡片
* @param status 状态running/done/error
* @param content 步骤描述
*/
private renderStepMessage(status: StepStatus, content: string): string {
// 状态对应的图标映射
const iconMap: Record<StepStatus, string> = {
running: 'loader',
done: 'check',
error: 'error'
};
return `
<div class="bim-ai-step bim-ai-step-${status}">
<span class="bim-ai-step-icon">${getIcon(iconMap[status])}</span>
<span class="bim-ai-step-text">${this.escapeHtml(content)}</span>
</div>
`;
}
/**
* 渲染思考中卡片
*/
private renderThinkingMessage(): string {
return `
<div class="bim-ai-thinking">
<span class="bim-ai-thinking-icon">${getIcon('loader')}</span>
<span class="bim-ai-thinking-text">${t('aiChat.thinking')}</span>
</div>
`;
}
/**
* 渲染未回答的问答卡片
* @param msg 问答消息对象
*/
private renderActiveQuestion(msg: QuestionMessage): string {
// 渲染选项列表
const optionsHtml = msg.options.map(opt => `
<div class="bim-ai-option ${msg.selectedOptionId === opt.id ? 'selected' : ''}"
data-question-id="${msg.id}" data-option-id="${opt.id}" data-is-other="${opt.isOther || false}">
<div class="bim-ai-option-radio">
<div class="bim-ai-option-radio-dot"></div>
</div>
<span class="bim-ai-option-text">${opt.isOther ? t('aiChat.other') : this.escapeHtml(opt.label)}</span>
</div>
${opt.isOther && msg.selectedOptionId === opt.id ? `
<input type="text" class="bim-ai-option-input"
data-question-id="${msg.id}"
placeholder="${t('aiChat.otherPlaceholder')}"
value="${msg.customAnswer || ''}">
` : ''}
`).join('');
return `
<div class="bim-ai-question-active" data-question-id="${msg.id}">
<div class="bim-ai-question-header">
<div class="bim-ai-question-icon">${getIcon('bot')}</div>
<span class="bim-ai-question-title">${this.escapeHtml(msg.title)}</span>
</div>
<div class="bim-ai-question-content">
<div class="bim-ai-question-text">${this.escapeHtml(msg.question)}</div>
<div class="bim-ai-question-options">${optionsHtml}</div>
</div>
<div class="bim-ai-question-footer">
<button class="bim-ai-question-submit" data-question-id="${msg.id}">
<span>${t('aiChat.submit')}</span>
${getIcon('send')}
</button>
</div>
</div>
`;
}
/**
* 渲染已回答的问答卡片(简洁模式)
* @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 `
<div class="bim-ai-question-answered">
<div class="bim-ai-qa-row">
<div class="bim-ai-qa-badge bim-ai-qa-badge-q">Q</div>
<span class="bim-ai-qa-question">${this.escapeHtml(msg.question)}</span>
</div>
<div class="bim-ai-qa-row">
<div class="bim-ai-qa-badge bim-ai-qa-badge-a">A</div>
<span class="bim-ai-qa-answer">${this.escapeHtml(answer || '')}</span>
</div>
</div>
`;
}
/**
* 绑定问答卡片的交互事件
* 包括:选项点击、自定义输入、提交按钮
*/
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;
}
}