本地 RAG 知识库
You can not select more than 25 topics Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
 
 
 
 
 

291 lines
9.4 KiB

/**
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
*/
import { ResolvedConfig } from './types';
import { debounce } from './utils';
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
launcher.setAttribute('tabindex', '0');
// 图标内容
launcher.innerHTML = config.launcherIcon;
// 点击事件(300ms 防抖)
const debouncedClick = debounce(onClick, 300);
launcher.addEventListener('click', debouncedClick);
// 键盘支持
launcher.addEventListener('keydown', (e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
debouncedClick();
}
});
return launcher;
}
// ==================== 聊天弹窗 ====================
/** 创建聊天弹窗完整结构,返回各区域引用 */
export function createChatWindow(config: ResolvedConfig): {
window: HTMLElement;
messagesContainer: HTMLElement;
inputEl: HTMLTextAreaElement;
sendBtn: HTMLElement;
clearBtn: HTMLElement | null;
showLoading: () => HTMLElement;
hideLoading: () => void;
} {
// 最外层容器
const windowEl = document.createElement('div');
windowEl.id = 'csk-window';
windowEl.className = `csk-root csk-window csk-window--${config.position === 'left-bottom' ? 'left' : 'right'} csk-window--hidden`;
// === 头部 ===
const header = document.createElement('div');
header.className = 'csk-header';
const titleEl = document.createElement('span');
titleEl.className = 'csk-header__title';
titleEl.textContent = config.title;
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', '最小化');
minimizeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="5" y1="12" x2="19" y2="12"/></svg>`;
minimizeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
// 关闭按钮
const closeBtn = document.createElement('button');
closeBtn.className = 'csk-header__btn csk-header__btn--close';
closeBtn.setAttribute('title', '关闭');
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
closeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(titleEl);
header.appendChild(actions);
// === 消息区 ===
const messagesContainer = document.createElement('div');
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 输入区 ===
const inputArea = document.createElement('div');
inputArea.className = 'csk-input-area';
const inputEl = document.createElement('textarea');
inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
inputEl.setAttribute('placeholder', '输入您的问题...');
inputEl.setAttribute('rows', '1');
inputEl.setAttribute('autofocus', '');
const sendBtn = document.createElement('button');
sendBtn.id = 'csk-send-btn';
sendBtn.className = 'csk-send-btn';
sendBtn.setAttribute('title', '发送');
sendBtn.setAttribute('disabled', 'true');
sendBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="22" y1="2" x2="11" y2="13"/><polygon points="22 2 15 22 11 13 2 9 22 2"/></svg>`;
inputArea.appendChild(inputEl);
inputArea.appendChild(sendBtn);
// === 组装 ===
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
let clearBtn: HTMLElement | null = null;
if (config.showClear) {
clearBtn = document.createElement('button');
clearBtn.className = 'csk-clear-btn';
clearBtn.textContent = '清空对话';
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
// 插入到 messages 之后、inputArea 之前
windowEl.insertBefore(clearBtn, inputArea);
}
// === Loading 动画 ===
let loadingEl: HTMLElement | null = null;
function showLoading(): HTMLElement {
if (loadingEl) {
loadingEl.style.display = 'flex';
return loadingEl;
}
const el = document.createElement('div');
el.className = 'csk-loading';
el.innerHTML = `
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
`;
messagesContainer.appendChild(el);
loadingEl = el;
return el;
}
function hideLoading(): void {
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
loadingEl = null;
}
}
return {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
showLoading,
hideLoading,
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽 */
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement): () => void {
let dragging = false;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
const onMouseDown = (e: MouseEvent) => {
dragging = true;
startX = e.clientX;
startY = e.clientY;
const rect = windowEl.getBoundingClientRect();
offsetX = startX - rect.left;
offsetY = startY - rect.top;
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
};
const onMouseMove = (e: MouseEvent) => {
if (!dragging) return;
const x = e.clientX - offsetX;
const y = e.clientY - offsetY;
// 边界限制,防止拖出视口
const maxX = window.innerWidth - windowEl.offsetWidth;
const maxY = window.innerHeight - windowEl.offsetHeight;
windowEl.style.right = 'auto';
windowEl.style.bottom = 'auto';
windowEl.style.left = `${Math.max(0, Math.min(x, maxX))}px`;
windowEl.style.top = `${Math.max(0, Math.min(y, maxY))}px`;
};
const onMouseUp = () => {
dragging = false;
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
headerEl.addEventListener('mousedown', onMouseDown);
// 清理函数
return () => {
headerEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}
// ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return wrapper;
}
/** 渲染 AI 消息气泡 */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.textContent = text;
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return wrapper;
}
/** 创建空的 AI 气泡(流式追加用) */
export function createEmptyAIBubble(container: HTMLElement, timestamp: number): { wrapper: HTMLElement; bubble: HTMLElement } {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
bubble.innerHTML = '';
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
wrapper.appendChild(bubble);
wrapper.appendChild(time);
container.appendChild(wrapper);
return { wrapper, bubble };
}
/** 滚动消息区到底部 */
export function scrollToBottom(container: HTMLElement): void {
container.scrollTop = container.scrollHeight;
}
/** 格式化时间戳 */
function formatTime(timestamp: number): string {
const d = new Date(timestamp);
const hh = String(d.getHours()).padStart(2, '0');
const mm = String(d.getMinutes()).padStart(2, '0');
return `${hh}:${mm}`;
}