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.
This commit is contained in:
yuding
2026-02-02 16:36:17 +08:00
parent 41abd9ed67
commit 4a09d52283
44 changed files with 17877 additions and 10807 deletions

View File

@@ -13,6 +13,7 @@ import { SectionBoxDialogManager } from './managers/section-box-dialog-manager';
import { WalkControlManager } from './managers/walk-control-manager';
import { MapDialogManager } from './managers/map-dialog-manager';
import { ComponentDetailManager } from './managers/component-detail-manager';
import { AiChatManager } from './managers/ai-chat-manager';
import type { EngineOptions, ModelLoadOptions } from './components/engine';
import { localeManager } from './services/locale';
import { themeManager } from './services/theme';
@@ -42,6 +43,7 @@ export class BimEngine {
public walkControl: WalkControlManager | null = null;
public map: MapDialogManager | null = null;
public componentDetail: ComponentDetailManager | null = null;
public aiChat: AiChatManager | null = null;
constructor(
container: HTMLElement | string,
@@ -135,6 +137,10 @@ export class BimEngine {
this.registry.componentDetail = this.componentDetail;
this.componentDetail.init();
this.aiChat = new AiChatManager();
this.registry.aiChat = this.aiChat;
this.aiChat.init();
this.updateTheme(themeManager.getTheme());
themeManager.subscribe((theme) => {
this.updateTheme(theme);
@@ -159,6 +165,7 @@ export class BimEngine {
this.sectionAxis?.destroy();
this.sectionBox?.destroy();
this.walkControl?.destroy();
this.aiChat?.destroy();
this.container.innerHTML = '';
ManagerRegistry.reset();
}

View File

@@ -0,0 +1,535 @@
/* AI 聊天对话框组件样式 */
.bim-ai-chat {
position: absolute;
width: 440px;
height: 600px;
display: flex;
flex-direction: column;
background: var(--bim-ai-panel, rgba(11, 18, 32, 0.95));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
border-radius: 16px;
backdrop-filter: blur(18px);
-webkit-backdrop-filter: blur(18px);
box-shadow: 0 12px 32px var(--bim-ai-shadow, rgba(0, 0, 0, 0.4));
z-index: 1000;
font-family: 'Public Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, sans-serif;
overflow: hidden;
resize: both;
min-width: 360px;
min-height: 400px;
max-width: 90vw;
max-height: 90vh;
}
.bim-ai-chat-header {
display: flex;
align-items: center;
justify-content: space-between;
height: 56px;
padding: 12px 16px;
border-bottom: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
flex-shrink: 0;
cursor: move;
user-select: none;
border-radius: 16px 16px 0 0;
}
.bim-ai-chat-title {
font-size: 18px;
font-weight: 600;
color: var(--bim-ai-text, #E5E7EB);
}
.bim-ai-chat-actions {
display: flex;
gap: 8px;
}
.bim-ai-chat-action-btn {
width: 32px;
height: 32px;
border-radius: 999px;
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
background: transparent;
color: var(--bim-ai-text, #E5E7EB);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: all 0.2s ease;
}
.bim-ai-chat-action-btn:hover {
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
}
.bim-ai-chat-action-btn svg {
width: 18px;
height: 18px;
}
/* 消息区域 */
.bim-ai-chat-messages {
flex: 1;
overflow-y: auto;
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
/* AI 消息气泡 */
.bim-ai-msg-ai {
max-width: 320px;
}
.bim-ai-msg-ai .bim-ai-bubble {
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
border-radius: 16px;
padding: 10px;
}
.bim-ai-msg-ai .bim-ai-bubble-content {
font-size: 13px;
line-height: 1.4;
color: var(--bim-ai-text, #E5E7EB);
}
/* 用户消息气泡 */
.bim-ai-msg-user {
display: flex;
justify-content: flex-end;
}
.bim-ai-msg-user .bim-ai-bubble {
max-width: 300px;
background: var(--bim-ai-user-bubble, rgba(29, 78, 216, 0.8));
border-radius: 16px;
padding: 10px;
box-shadow: 0 10px 26px rgba(0, 0, 0, 0.2);
}
.bim-ai-msg-user .bim-ai-bubble-content {
font-size: 13px;
line-height: 1.4;
color: var(--bim-ai-user-text, #EEF2FF);
}
/* 步骤卡片 */
.bim-ai-step {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 14px;
max-width: 380px;
}
.bim-ai-step-running {
background: var(--bim-ai-info-soft, rgba(56, 189, 248, 0.1));
border: 1px solid rgba(56, 189, 248, 0.33);
}
.bim-ai-step-done {
background: var(--bim-ai-success-soft, rgba(52, 211, 153, 0.1));
border: 1px solid rgba(52, 211, 153, 0.33);
}
.bim-ai-step-error {
background: var(--bim-ai-danger-soft, rgba(239, 68, 68, 0.1));
border: 1px solid var(--bim-ai-danger-stroke, rgba(239, 68, 68, 0.33));
}
.bim-ai-step-icon {
width: 16px;
height: 16px;
flex-shrink: 0;
}
.bim-ai-step-running .bim-ai-step-icon {
color: var(--bim-ai-info, #38BDF8);
}
.bim-ai-step-done .bim-ai-step-icon {
color: var(--bim-ai-success, #34D399);
}
.bim-ai-step-error .bim-ai-step-icon {
color: var(--bim-ai-danger, #EF4444);
}
.bim-ai-step-text {
font-size: 12px;
line-height: 1.4;
color: var(--bim-ai-text, #E5E7EB);
}
/* 思考中卡片 */
.bim-ai-thinking {
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
border-radius: 14px;
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
max-width: 380px;
}
.bim-ai-thinking-icon {
width: 16px;
height: 16px;
color: var(--bim-ai-muted, #94A3B8);
animation: bim-ai-spin 1s linear infinite;
}
.bim-ai-thinking-text {
font-size: 12px;
font-weight: 600;
color: var(--bim-ai-muted, #94A3B8);
}
@keyframes bim-ai-spin {
from { transform: rotate(0deg); }
to { transform: rotate(360deg); }
}
/* 问答卡片 - 未回答状态 */
.bim-ai-question-active {
width: 360px;
border-radius: 14px;
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
border: 1px solid var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
box-shadow: 0 4px 16px var(--bim-ai-shadow, rgba(0, 0, 0, 0.4));
overflow: hidden;
}
.bim-ai-question-header {
display: flex;
align-items: center;
gap: 8px;
padding: 12px;
border-bottom: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
}
.bim-ai-question-icon {
width: 24px;
height: 24px;
border-radius: 6px;
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
display: flex;
align-items: center;
justify-content: center;
color: var(--bim-ai-accent, #3B82F6);
}
.bim-ai-question-icon svg {
width: 14px;
height: 14px;
}
.bim-ai-question-title {
font-size: 13px;
font-weight: 600;
color: var(--bim-ai-text, #E5E7EB);
}
.bim-ai-question-content {
padding: 12px;
display: flex;
flex-direction: column;
gap: 10px;
}
.bim-ai-question-text {
font-size: 13px;
line-height: 1.4;
color: var(--bim-ai-text, #E5E7EB);
}
.bim-ai-question-options {
display: flex;
flex-direction: column;
gap: 6px;
}
.bim-ai-option {
display: flex;
align-items: center;
gap: 8px;
padding: 8px 10px;
border-radius: 8px;
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
cursor: pointer;
transition: all 0.2s ease;
}
.bim-ai-option:hover {
background: var(--bim-ai-subtle-fill-hover, rgba(255, 255, 255, 0.08));
}
.bim-ai-option.selected {
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
}
.bim-ai-option-radio {
width: 16px;
height: 16px;
border-radius: 50%;
border: 1.5px solid var(--bim-ai-muted, #94A3B8);
flex-shrink: 0;
display: flex;
align-items: center;
justify-content: center;
}
.bim-ai-option.selected .bim-ai-option-radio {
background: var(--bim-ai-accent, #3B82F6);
border-color: var(--bim-ai-accent, #3B82F6);
}
.bim-ai-option-radio-dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: var(--bim-ai-accent-ink, #FFFFFF);
display: none;
}
.bim-ai-option.selected .bim-ai-option-radio-dot {
display: block;
}
.bim-ai-option-text {
font-size: 12px;
color: var(--bim-ai-text, #E5E7EB);
}
.bim-ai-option-input {
flex: 1;
background: var(--bim-ai-textbox-fill, rgba(15, 23, 42, 0.6));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
border-radius: 6px;
padding: 6px 8px;
font-size: 12px;
color: var(--bim-ai-text, #E5E7EB);
outline: none;
margin-top: 8px;
width: 100%;
}
.bim-ai-option-input:focus {
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
}
.bim-ai-question-footer {
display: flex;
justify-content: flex-end;
padding: 12px;
border-top: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
}
.bim-ai-question-submit {
display: flex;
align-items: center;
gap: 4px;
padding: 8px 12px;
border-radius: 8px;
background: var(--bim-ai-accent, #3B82F6);
color: var(--bim-ai-accent-ink, #FFFFFF);
border: none;
cursor: pointer;
font-size: 12px;
font-weight: 600;
transition: all 0.2s ease;
}
.bim-ai-question-submit:hover {
filter: brightness(1.1);
}
.bim-ai-question-submit svg {
width: 12px;
height: 12px;
}
/* 问答卡片 - 已回答状态 */
.bim-ai-question-answered {
max-width: 340px;
padding: 10px;
border-radius: 12px;
background: var(--bim-ai-panel2, rgba(15, 23, 42, 0.95));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
display: flex;
flex-direction: column;
gap: 8px;
}
.bim-ai-qa-row {
display: flex;
gap: 8px;
align-items: flex-start;
}
.bim-ai-qa-badge {
width: 18px;
height: 18px;
border-radius: 5px;
display: flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 700;
flex-shrink: 0;
}
.bim-ai-qa-badge-q {
background: var(--bim-ai-accent-soft, rgba(59, 130, 246, 0.1));
color: var(--bim-ai-accent, #3B82F6);
}
.bim-ai-qa-badge-a {
background: var(--bim-ai-success-soft, rgba(52, 211, 153, 0.1));
color: var(--bim-ai-success, #34D399);
}
.bim-ai-qa-question {
font-size: 12px;
line-height: 1.4;
color: var(--bim-ai-muted, #94A3B8);
}
.bim-ai-qa-answer {
font-size: 12px;
line-height: 1.4;
color: var(--bim-ai-text, #E5E7EB);
}
/* 底部输入区域 */
.bim-ai-chat-composer {
padding: 12px;
border-top: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
display: flex;
flex-direction: column;
gap: 8px;
flex-shrink: 0;
}
.bim-ai-quick-prompts {
display: flex;
gap: 8px;
flex-wrap: wrap;
}
.bim-ai-quick-prompt {
padding: 5px 8px;
border-radius: 999px;
background: var(--bim-ai-subtle-fill, rgba(255, 255, 255, 0.05));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
color: var(--bim-ai-text, #E5E7EB);
font-size: 12px;
font-weight: 600;
cursor: pointer;
transition: all 0.2s ease;
}
.bim-ai-quick-prompt:hover {
background: var(--bim-ai-subtle-fill-hover, rgba(255, 255, 255, 0.08));
}
.bim-ai-input-row {
display: flex;
gap: 8px;
align-items: flex-end;
}
.bim-ai-textbox {
flex: 1;
display: flex;
flex-direction: column;
gap: 6px;
padding: 10px;
border-radius: 16px;
background: var(--bim-ai-textbox-fill, rgba(15, 23, 42, 0.6));
border: 1px solid var(--bim-ai-border, rgba(255, 255, 255, 0.1));
}
.bim-ai-textbox:focus-within {
border-color: var(--bim-ai-accent-stroke, rgba(59, 130, 246, 0.33));
}
.bim-ai-textarea {
background: transparent;
border: none;
outline: none;
resize: none;
font-size: 13px;
line-height: 1.4;
color: var(--bim-ai-text, #E5E7EB);
min-height: 20px;
max-height: 100px;
}
.bim-ai-textarea::placeholder {
color: var(--bim-ai-muted, #94A3B8);
}
.bim-ai-helper {
display: flex;
justify-content: space-between;
font-size: 11px;
color: var(--bim-ai-muted, #94A3B8);
}
.bim-ai-send-btn {
width: 40px;
height: 40px;
border-radius: 14px;
background: var(--bim-ai-accent, #3B82F6);
border: none;
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
color: var(--bim-ai-accent-ink, #FFFFFF);
transition: background 0.2s ease;
flex-shrink: 0;
}
.bim-ai-send-btn:hover {
background: var(--bim-ai-accent-hover, #2563EB);
}
.bim-ai-send-btn svg {
width: 18px;
height: 18px;
}
/* 浅色主题覆盖 */
.bim-ai-chat.light {
--bim-ai-panel: rgba(255, 255, 255, 0.95);
--bim-ai-panel2: #FFFFFF;
--bim-ai-border: rgba(15, 23, 42, 0.1);
--bim-ai-text: #0F172A;
--bim-ai-muted: #475569;
--bim-ai-shadow: rgba(11, 18, 32, 0.1);
--bim-ai-subtle-fill: rgba(15, 23, 42, 0.05);
--bim-ai-subtle-fill-hover: rgba(15, 23, 42, 0.08);
--bim-ai-textbox-fill: #F1F5F9;
--bim-ai-user-bubble: #2563EB;
--bim-ai-user-text: #FFFFFF;
--bim-ai-accent: #2563EB;
--bim-ai-accent-soft: rgba(37, 99, 235, 0.08);
--bim-ai-accent-stroke: rgba(37, 99, 235, 0.2);
--bim-ai-success: #059669;
--bim-ai-success-soft: rgba(5, 150, 105, 0.08);
--bim-ai-danger: #DC2626;
--bim-ai-danger-soft: rgba(220, 38, 38, 0.08);
--bim-ai-danger-stroke: rgba(220, 38, 38, 0.2);
--bim-ai-info: #0284C7;
--bim-ai-info-soft: rgba(2, 132, 199, 0.08);
}

View File

@@ -0,0 +1,814 @@
/**
* 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;
}
}

View File

@@ -0,0 +1,146 @@
/**
* AI 聊天组件类型定义
* 包含消息类型、问答卡片、组件配置等
*/
/**
* 消息类型枚举
*/
export type MessageType = 'user' | 'ai' | 'step' | 'thinking' | 'question';
/**
* 步骤状态枚举
*/
export type StepStatus = 'running' | 'done' | 'error';
/**
* 基础消息接口
*/
export interface BaseMessage {
/** 消息唯一标识 */
id: string;
/** 消息类型 */
type: MessageType;
/** 创建时间 */
timestamp: number;
}
/**
* 用户消息
*/
export interface UserMessage extends BaseMessage {
type: 'user';
/** 消息内容 */
content: string;
}
/**
* AI 回复消息
*/
export interface AiMessage extends BaseMessage {
type: 'ai';
/** 消息内容 */
content: string;
}
/**
* 步骤消息(执行中/完成/失败)
*/
export interface StepMessage extends BaseMessage {
type: 'step';
/** 步骤状态 */
status: StepStatus;
/** 步骤描述 */
content: string;
}
/**
* 思考中消息
*/
export interface ThinkingMessage extends BaseMessage {
type: 'thinking';
}
/**
* 问答选项
*/
export interface QuestionOption {
/** 选项唯一标识 */
id: string;
/** 选项文本 */
label: string;
/** 是否为"其他"选项 */
isOther?: boolean;
}
/**
* 问答卡片消息
*/
export interface QuestionMessage extends BaseMessage {
type: 'question';
/** 问题标题 */
title: string;
/** 问题内容 */
question: string;
/** 选项列表 */
options: QuestionOption[];
/** 是否已回答 */
answered: boolean;
/** 选中的选项 ID */
selectedOptionId?: string;
/** 自定义回答(当选择"其他"时) */
customAnswer?: string;
}
/**
* 消息联合类型
*/
export type Message = UserMessage | AiMessage | StepMessage | ThinkingMessage | QuestionMessage;
/**
* 快捷提示配置
*/
export interface QuickPrompt {
/** 唯一标识 */
id: string;
/** 显示文本(翻译键) */
label: string;
}
/**
* AI 聊天组件配置
*/
export interface AiChatOptions {
/** 挂载容器 */
container: HTMLElement;
/** 抽屉宽度,默认 440 */
width?: number;
/** 标题(翻译键) */
title?: string;
/** 输入框占位符(翻译键) */
placeholder?: string;
/** 快捷提示列表 */
quickPrompts?: QuickPrompt[];
/** 发送消息回调 */
onSend?: (message: string) => void;
/** 问答提交回调 */
onQuestionSubmit?: (questionId: string, optionId: string, customAnswer?: string) => void;
/** 新建对话回调 */
onNewChat?: () => void;
/** 打开设置回调 */
onSettings?: () => void;
/** 打开历史回调 */
onHistory?: () => void;
/** 关闭回调 */
onClose?: () => void;
}
/**
* 已回答问答显示数据
*/
export interface AnsweredQuestion {
/** 问题 */
question: string;
/** 回答 */
answer: string;
}

View File

@@ -0,0 +1,28 @@
import type { ButtonConfig } from '../../../index.type';
import { getIcon } from '../../../../../utils/icon-manager';
import { ManagerRegistry } from '../../../../../core/manager-registry';
export const createAiChatButton = (): ButtonConfig => {
const registry = ManagerRegistry.getInstance();
registry.on('aiChat:opened', () => {
registry.toolbar?.setBtnActive('aiChat', true);
});
registry.on('aiChat:closed', () => {
registry.toolbar?.setBtnActive('aiChat', false);
});
return {
id: 'aiChat',
groupId: 'group-2',
type: 'button',
label: 'aiChat.title',
align: 'vertical',
keepActive: true,
icon: getIcon('bot'),
onClick: () => {
registry.aiChat?.toggle();
}
};
};

View File

@@ -17,6 +17,7 @@ export class Toolbar extends BimButtonGroup {
const { createSectionPlaneButton } = await import('./buttons/section/section-plane');
const { createSectionAxisButton } = await import('./buttons/section/section-axis');
const { createSectionBoxButton } = await import('./buttons/section/section-box');
const { createAiChatButton } = await import('./buttons/ai-chat');
this.addGroup('group-1');
@@ -31,6 +32,7 @@ export class Toolbar extends BimButtonGroup {
this.addButton(createMapButton());
this.addButton(createPropertyButton());
this.addGroup('group-2');
this.addButton(createAiChatButton());
this.addButton(createSettingButton());
this.addButton(createInfoButton());
this.addButton(createFullscreenButton());

View File

@@ -261,16 +261,23 @@ export class SectionBoxPanel implements IBimComponent {
const stop = (e: PointerEvent) => {
if (this.dragState.isDragging && this.dragState.pointerId === e.pointerId) {
handle.releasePointerCapture(e.pointerId);
if (handle.hasPointerCapture(e.pointerId)) {
handle.releasePointerCapture(e.pointerId);
}
(handle.closest('.section-box-slider') as HTMLElement).style.zIndex = '';
handle.classList.remove('dragging');
this.dragState.isDragging = false;
this.dragState.pointerId = null;
this.dragState = {
isDragging: false,
axis: null,
handleType: null,
pointerId: null
};
}
};
handle.addEventListener('pointerup', stop);
handle.addEventListener('pointercancel', stop);
handle.addEventListener('lostpointercapture', stop);
});
}

View File

@@ -14,6 +14,8 @@ export class SectionPlanePanel implements IBimComponent {
public element: HTMLElement;
private options: SectionPlanePanelOptions;
private isHidden: boolean = false;
// DOM 引用
private hideBtn!: HTMLButtonElement;
private reverseBtn!: HTMLButtonElement;
@@ -28,9 +30,23 @@ export class SectionPlanePanel implements IBimComponent {
constructor(options: SectionPlanePanelOptions = {}) {
this.options = options;
this.isHidden = options.defaultHidden ?? false;
this.element = this.createDom();
}
public setHiddenState(isHidden: boolean): void {
this.isHidden = isHidden;
this.updateButtonStates();
}
public getHiddenState(): boolean {
return this.isHidden;
}
private updateButtonStates(): void {
this.hideBtn?.classList.toggle('active', this.isHidden);
}
/**
* 初始化组件
*/
@@ -105,9 +121,9 @@ export class SectionPlanePanel implements IBimComponent {
'hide',
getIcon('隐藏'),
() => {
if (this.options.onHide) {
this.options.onHide();
}
this.isHidden = !this.isHidden;
this.updateButtonStates();
this.options.onHideToggle?.(this.isHidden);
}
);

View File

@@ -3,9 +3,15 @@
*/
export interface SectionPlanePanelOptions {
/**
* 隐藏按钮回调
* 初始隐藏状态
*/
onHide?: () => void;
defaultHidden?: boolean;
/**
* 隐藏状态切换回调
* @param isHidden 是否隐藏
*/
onHideToggle?: (isHidden: boolean) => void;
/**
* 反向按钮回调

View File

@@ -21,6 +21,7 @@ import type { SectionBoxDialogManager } from '../managers/section-box-dialog-man
import type { WalkPathDialogManager } from '../managers/walk-path-dialog-manager';
import type { WalkPlanViewDialogManager } from '../managers/walk-plan-view-dialog-manager';
import type { ComponentDetailManager } from '../managers/component-detail-manager';
import type { AiChatManager } from '../managers/ai-chat-manager';
/**
* Manager 注册表 - 单例模式
@@ -68,6 +69,8 @@ export class ManagerRegistry {
public walkPlanView: WalkPlanViewDialogManager | null = null;
/** 构件详情管理器 */
public componentDetail: ComponentDetailManager | null = null;
/** AI 聊天管理器 */
public aiChat: AiChatManager | null = null;
private constructor() {}

View File

@@ -178,5 +178,28 @@ export const enUS: TranslationDictionary = {
},
map: {
dialogTitle: 'Map'
},
aiChat: {
title: 'AI Assistant',
placeholder: 'Ask a question...',
quickPrompt: {
summarize: 'Summarize this model',
explain: 'Explain selected component',
generate: 'Generate report'
},
action: {
new: 'New Chat',
history: 'History',
settings: 'Settings',
close: 'Close'
},
helper: {
newline: 'Shift + Enter for new line',
send: 'Enter to send'
},
thinking: 'Thinking...',
other: 'Other',
otherPlaceholder: 'Enter custom answer',
submit: 'Submit'
}
};

View File

@@ -202,6 +202,29 @@ export interface TranslationDictionary {
map: {
dialogTitle: string;
};
aiChat: {
title: string;
placeholder: string;
quickPrompt: {
summarize: string;
explain: string;
generate: string;
};
action: {
new: string;
history: string;
settings: string;
close: string;
};
helper: {
newline: string;
send: string;
};
thinking: string;
other: string;
otherPlaceholder: string;
submit: string;
};
}
/**

View File

@@ -178,5 +178,28 @@ export const zhCN: TranslationDictionary = {
},
map: {
dialogTitle: '地图'
},
aiChat: {
title: 'AI 助手',
placeholder: '输入你的问题...',
quickPrompt: {
summarize: '总结这个模型',
explain: '解释选中的构件',
generate: '生成报告'
},
action: {
new: '新建对话',
history: '历史记录',
settings: '设置',
close: '关闭'
},
helper: {
newline: 'Shift + Enter 换行',
send: 'Enter 发送'
},
thinking: '正在思考...',
other: '其他',
otherPlaceholder: '请输入自定义答案',
submit: '提交'
}
};

View File

@@ -0,0 +1,199 @@
/**
* AI 聊天管理器
* 负责管理 AI 聊天抽屉的显示、隐藏和消息交互
*/
import { AiChat } from '../components/ai-chat';
import type { Message, QuestionOption, StepStatus } from '../components/ai-chat/types';
import { ManagerRegistry } from '../core/manager-registry';
/**
* AI 聊天管理器
* 管理 AI 聊天组件的生命周期和消息交互
*/
export class AiChatManager {
/** AI 聊天组件实例 */
private aiChat: AiChat | null = null;
/** Manager 注册表 */
private registry: ManagerRegistry;
/** 是否已初始化 */
private initialized = false;
constructor() {
this.registry = ManagerRegistry.getInstance();
}
/**
* 初始化 AI 聊天组件
* 创建 AiChat 实例并注册事件
*/
public init(): void {
if (this.initialized) return;
const wrapper = this.registry.wrapper;
if (!wrapper) {
console.warn('[AiChatManager] wrapper 不存在,无法初始化');
return;
}
this.aiChat = new AiChat({
container: wrapper,
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' }
],
onSend: (message) => {
console.log('[AiChatManager] 用户发送消息:', message);
this.aiChat?.addUserMessage(message);
this.registry.emit('aiChat:message-sent', { message });
},
onQuestionSubmit: (questionId, optionId, customAnswer) => {
console.log('[AiChatManager] 用户回答问题:', { questionId, optionId, customAnswer });
this.registry.emit('aiChat:question-answered', { questionId, optionId, customAnswer });
},
onNewChat: () => {
console.log('[AiChatManager] 新建对话');
this.registry.emit('aiChat:new-chat', {});
},
onHistory: () => {
console.log('[AiChatManager] 打开历史');
this.registry.emit('aiChat:history-opened', {});
},
onSettings: () => {
console.log('[AiChatManager] 打开设置');
this.registry.emit('aiChat:settings-opened', {});
},
onClose: () => {
console.log('[AiChatManager] 关闭 AI 聊天');
this.registry.emit('aiChat:closed', {});
this.registry.toolbar?.setBtnActive('aiChat', false);
}
});
this.initialized = true;
}
/**
* 显示 AI 聊天抽屉
*/
public show(): void {
if (!this.aiChat) {
this.init();
}
this.aiChat?.show();
this.registry.emit('aiChat:opened', {});
this.registry.toolbar?.setBtnActive('aiChat', true);
}
/**
* 隐藏 AI 聊天抽屉
*/
public hide(): void {
this.aiChat?.hide();
this.registry.emit('aiChat:closed', {});
this.registry.toolbar?.setBtnActive('aiChat', false);
}
/**
* 切换 AI 聊天抽屉显示状态
*/
public toggle(): void {
if (this.aiChat?.isVisible()) {
this.hide();
} else {
this.show();
}
}
/**
* 检查 AI 聊天抽屉是否可见
*/
public isVisible(): boolean {
return this.aiChat?.isVisible() ?? false;
}
/**
* 添加用户消息
* @param content 消息内容
* @returns 消息 ID
*/
public addUserMessage(content: string): string {
return this.aiChat?.addUserMessage(content) ?? '';
}
/**
* 添加 AI 消息
* @param content 消息内容
* @returns 消息 ID
*/
public addAiMessage(content: string): string {
return this.aiChat?.addAiMessage(content) ?? '';
}
/**
* 添加步骤消息
* @param status 步骤状态
* @param content 步骤描述
* @returns 消息 ID
*/
public addStepMessage(status: StepStatus, content: string): string {
return this.aiChat?.addStepMessage(status, content) ?? '';
}
/**
* 添加思考中消息
* @returns 消息 ID
*/
public addThinkingMessage(): string {
return this.aiChat?.addThinkingMessage() ?? '';
}
/**
* 添加问答卡片消息
* @param title 问题标题
* @param question 问题内容
* @param options 选项列表
* @returns 消息 ID
*/
public addQuestionMessage(title: string, question: string, options: QuestionOption[]): string {
return this.aiChat?.addQuestionMessage(title, question, options) ?? '';
}
/**
* 更新消息
* @param id 消息 ID
* @param updates 更新内容
*/
public updateMessage(id: string, updates: Partial<Message>): void {
this.aiChat?.updateMessage(id, updates);
}
/**
* 删除消息
* @param id 消息 ID
*/
public removeMessage(id: string): void {
this.aiChat?.removeMessage(id);
}
/**
* 清空所有消息
*/
public clearMessages(): void {
this.aiChat?.clearMessages();
}
/**
* 销毁 AI 聊天管理器
*/
public destroy(): void {
if (this.aiChat) {
this.aiChat.destroy();
this.aiChat = null;
}
this.initialized = false;
}
}

View File

@@ -152,7 +152,7 @@ export class ConstructTreeManagerBtn extends BaseManager {
this.dialog = this.registry.dialog!.create({
title: 'constructTree.title',
minWidth: 320,
height: 420,
height: 600,
content: tabMount,
position: { x: 20, y: 20 },
resizable: false,

View File

@@ -57,8 +57,12 @@ export class SectionAxisDialogManager extends BaseDialogManager {
defaultAxis: 'x',
defaultHidden: false,
onHideToggle: (isHidden) => {
// 隐藏功能:第三方引擎无 API仅输出日志
console.log('[SectionAxisDialogManager] 隐藏切换(暂不支持):', isHidden);
console.log('[SectionAxisDialogManager] 隐藏切换:', isHidden);
if (isHidden) {
this.registry.engine3d?.hideSection();
} else {
this.registry.engine3d?.recoverSection();
}
},
onReverse: () => {
// 反向功能:第三方引擎无 API仅输出日志

View File

@@ -57,8 +57,12 @@ export class SectionBoxDialogManager extends BaseDialogManager {
defaultHidden: false,
defaultReversed: false,
onHideToggle: (isHidden) => {
// 底层暂不支持隐藏功能
console.log('[SectionBoxDialogManager] 隐藏切换(底层暂不支持):', isHidden);
console.log('[SectionBoxDialogManager] 隐藏切换:', isHidden);
if (isHidden) {
this.registry.engine3d?.hideSection();
} else {
this.registry.engine3d?.recoverSection();
}
},
onReverseToggle: (isReversed) => {
// 底层暂不支持反向功能

View File

@@ -47,9 +47,14 @@ export class SectionPlaneDialogManager extends BaseDialogManager {
/** 创建对话框内容 */
protected createContent(): HTMLElement {
this.panel = new SectionPlanePanel({
onHide: () => {
console.log('[SectionPlaneDialogManager] 隐藏剖切面');
this.registry.engine3d?.hideSection();
defaultHidden: false,
onHideToggle: (isHidden) => {
console.log('[SectionPlaneDialogManager] 隐藏切换:', isHidden);
if (isHidden) {
this.registry.engine3d?.hideSection();
} else {
this.registry.engine3d?.recoverSection();
}
},
onReverse: () => {
console.log('[SectionPlaneDialogManager] 反向 (not supported in new API)');

View File

@@ -34,4 +34,13 @@ export interface EngineEvents {
// 构件选中事件
'component:selected': { url: string; id: string };
'component:deselected': {};
// AI 聊天事件
'aiChat:opened': {};
'aiChat:closed': {};
'aiChat:message-sent': { message: string };
'aiChat:question-answered': { questionId: string; optionId: string; customAnswer?: string };
'aiChat:new-chat': {};
'aiChat:history-opened': {};
'aiChat:settings-opened': {};
}

View File

@@ -47,6 +47,7 @@ const ICONS: Record<string, string> = {
plus: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19 13h-6v6h-2v-6H5v-2h6V5h2v6h6v2z"/></svg>',
minus: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M19 13H5v-2h14v2z"/></svg>',
arrowUp: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M7.41 15.41L12 10.83l4.59 4.58L18 14l-6-6l-6 6z"/></svg>',
arrowUpBold: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M4 12l1.41 1.41L11 7.83V20h2V7.83l5.58 5.59L20 12l-8-8l-8 8z"/></svg>',
arrowDown: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M7.41 8.59L12 13.17l4.59-4.58L18 10l-6 6l-6-6z"/></svg>',
arrowLeft: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M15.41 7.41L14 6l-6 6l6 6l1.41-1.41L10.83 12z"/></svg>',
arrowRight: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M10 6L8.59 7.41L13.17 12l-4.58 4.59L10 18l6-6z"/></svg>',
@@ -58,6 +59,12 @@ const ICONS: Record<string, string> = {
expand: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M16.59 8.59L12 13.17L7.41 8.59L6 10l6 6l6-6z"/></svg>',
collapse: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 8l-6 6l1.41 1.41L12 10.83l4.59 4.58L18 14z"/></svg>',
bot: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2a2 2 0 0 1 2 2c0 .74-.4 1.39-1 1.73V7h1a7 7 0 0 1 7 7h1a1 1 0 0 1 1 1v3a1 1 0 0 1-1 1h-1v1a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-1H2a1 1 0 0 1-1-1v-3a1 1 0 0 1 1-1h1a7 7 0 0 1 7-7h1V5.73c-.6-.34-1-.99-1-1.73a2 2 0 0 1 2-2M7.5 13A2.5 2.5 0 0 0 5 15.5A2.5 2.5 0 0 0 7.5 18a2.5 2.5 0 0 0 2.5-2.5A2.5 2.5 0 0 0 7.5 13m9 0a2.5 2.5 0 0 0-2.5 2.5a2.5 2.5 0 0 0 2.5 2.5a2.5 2.5 0 0 0 2.5-2.5a2.5 2.5 0 0 0-2.5-2.5Z"/></svg>',
history: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M13 3a9 9 0 0 0-9 9H1l3.89 3.89l.07.14L9 12H6c0-3.87 3.13-7 7-7s7 3.13 7 7s-3.13 7-7 7c-1.93 0-3.68-.79-4.94-2.06l-1.42 1.42A8.954 8.954 0 0 0 13 21a9 9 0 0 0 0-18zm-1 5v5l4.28 2.54l.72-1.21l-3.5-2.08V8H12z"/></svg>',
settings: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 15.5A3.5 3.5 0 0 1 8.5 12A3.5 3.5 0 0 1 12 8.5a3.5 3.5 0 0 1 3.5 3.5a3.5 3.5 0 0 1-3.5 3.5m7.43-2.53c.04-.32.07-.64.07-.97c0-.33-.03-.66-.07-1l2.11-1.63c.19-.15.24-.42.12-.64l-2-3.46c-.12-.22-.39-.31-.61-.22l-2.49 1c-.52-.39-1.06-.73-1.69-.98l-.37-2.65A.506.506 0 0 0 14 2h-4c-.25 0-.46.18-.5.42l-.37 2.65c-.63.25-1.17.59-1.69.98l-2.49-1c-.22-.09-.49 0-.61.22l-2 3.46c-.13.22-.07.49.12.64L4.57 11c-.04.34-.07.67-.07 1c0 .33.03.65.07.97l-2.11 1.66c-.19.15-.25.42-.12.64l2 3.46c.12.22.39.3.61.22l2.49-1.01c.52.4 1.06.74 1.69.99l.37 2.65c.04.24.25.42.5.42h4c.25 0 .46-.18.5-.42l.37-2.65c.63-.26 1.17-.59 1.69-.99l2.49 1.01c.22.08.49 0 .61-.22l2-3.46c.12-.22.07-.49-.12-.64l-2.11-1.66Z"/></svg>',
loader: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 4V2A10 10 0 0 0 2 12h2a8 8 0 0 1 8-8Z"><animateTransform attributeName="transform" dur="0.75s" repeatCount="indefinite" type="rotate" values="0 12 12;360 12 12"/></path></svg>',
send: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M2 21l21-9L2 3v7l15 2l-15 2v7z"/></svg>',
// ========== 默认图标 ==========
default: '<svg width="24" height="24" viewBox="0 0 24 24"><path fill="currentColor" d="M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10s10-4.48 10-10S17.52 2 12 2zm0 18c-4.41 0-8-3.59-8-8s3.59-8 8-8s8 3.59 8 8s-3.59 8-8 8zm-1-13h2v6h-2zm0 8h2v2h-2z"/></svg>',
};