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:
@@ -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();
|
||||
}
|
||||
|
||||
535
src/components/ai-chat/index.css
Normal file
535
src/components/ai-chat/index.css
Normal 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);
|
||||
}
|
||||
814
src/components/ai-chat/index.ts
Normal file
814
src/components/ai-chat/index.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
146
src/components/ai-chat/types.ts
Normal file
146
src/components/ai-chat/types.ts
Normal 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;
|
||||
}
|
||||
28
src/components/button-group/toolbar/buttons/ai-chat/index.ts
Normal file
28
src/components/button-group/toolbar/buttons/ai-chat/index.ts
Normal 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();
|
||||
}
|
||||
};
|
||||
};
|
||||
@@ -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());
|
||||
|
||||
@@ -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);
|
||||
});
|
||||
}
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -3,9 +3,15 @@
|
||||
*/
|
||||
export interface SectionPlanePanelOptions {
|
||||
/**
|
||||
* 隐藏按钮回调
|
||||
* 初始隐藏状态
|
||||
*/
|
||||
onHide?: () => void;
|
||||
defaultHidden?: boolean;
|
||||
|
||||
/**
|
||||
* 隐藏状态切换回调
|
||||
* @param isHidden 是否隐藏
|
||||
*/
|
||||
onHideToggle?: (isHidden: boolean) => void;
|
||||
|
||||
/**
|
||||
* 反向按钮回调
|
||||
|
||||
@@ -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() {}
|
||||
|
||||
|
||||
@@ -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'
|
||||
}
|
||||
};
|
||||
|
||||
@@ -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;
|
||||
};
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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: '提交'
|
||||
}
|
||||
};
|
||||
|
||||
199
src/managers/ai-chat-manager.ts
Normal file
199
src/managers/ai-chat-manager.ts
Normal 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;
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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,仅输出日志
|
||||
|
||||
@@ -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) => {
|
||||
// 底层暂不支持反向功能
|
||||
|
||||
@@ -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)');
|
||||
|
||||
@@ -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': {};
|
||||
}
|
||||
|
||||
@@ -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>',
|
||||
};
|
||||
|
||||
Reference in New Issue
Block a user