/**
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
*
* 设计要点:
* - 头部含机器人头像 + 在线状态,增强产品感
* - 消息气泡带头像,左右区分清晰
* - 无消息时显示欢迎空状态
* - 输入区采用圆角容器包裹文本框与发送按钮
*/
import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { t } from './i18n';
// ==================== 图标常量 ====================
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
const BOT_ICON = ``;
/** 用户头像图标 */
const USER_ICON = ``;
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
export function createLauncher(config: ResolvedConfig, onClick: () => void): HTMLElement {
const launcher = document.createElement('div');
launcher.id = 'csk-launcher';
// 有玻璃主题时追加 --glass 类名,启用磨砂玻璃质感样式
const glassClass = config.launcherTheme ? ' csk-launcher--glass' : '';
launcher.className = `csk-launcher csk-launcher--${config.position === 'left-bottom' ? 'left' : 'right'}${glassClass}`;
launcher.setAttribute('title', config.title);
launcher.setAttribute('aria-label', config.title);
launcher.setAttribute('role', 'button');
launcher.setAttribute('tabindex', '0');
// 图标内容
launcher.innerHTML = config.launcherIcon;
// 未读徽章(初始隐藏,有未读消息时由 index.ts 显示)
const badge = document.createElement('div');
badge.className = 'csk-launcher__badge csk-launcher__badge--hidden';
launcher.appendChild(badge);
// 点击事件(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;
categorySelect: HTMLSelectElement | null;
historyPanel: HTMLElement;
welcomeEl: HTMLElement;
newMsgBtn: HTMLElement;
teaserEl: HTMLElement;
searchInput: HTMLInputElement | null;
ariaLiveEl: HTMLElement;
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${config.theme === 'dark' ? ' csk-dark' : ''}`;
windowEl.setAttribute('role', 'dialog');
windowEl.setAttribute('aria-label', config.title);
windowEl.setAttribute('aria-modal', 'false');
// === 头部 ===
const header = document.createElement('div');
header.className = 'csk-header';
const headerLeft = document.createElement('div');
headerLeft.className = 'csk-header__left';
const headerAvatar = document.createElement('div');
headerAvatar.className = 'csk-header__avatar';
headerAvatar.innerHTML = BOT_ICON;
const headerInfo = document.createElement('div');
headerInfo.className = 'csk-header__info';
const titleEl = document.createElement('span');
titleEl.className = 'csk-header__title';
titleEl.textContent = config.title;
const statusEl = document.createElement('span');
statusEl.className = 'csk-header__status';
statusEl.innerHTML = `${t('status_online')}`;
headerInfo.appendChild(titleEl);
headerInfo.appendChild(statusEl);
headerLeft.appendChild(headerAvatar);
headerLeft.appendChild(headerInfo);
const actions = document.createElement('div');
actions.className = 'csk-header__actions';
// 历史会话按钮(P2)
const historyBtn = document.createElement('button');
historyBtn.className = 'csk-history-btn';
historyBtn.setAttribute('title', t('history_title'));
historyBtn.innerHTML = ``;
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', t('minimize'));
minimizeBtn.innerHTML = ``;
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', t('close'));
closeBtn.innerHTML = ``;
closeBtn.addEventListener('click', () => {
windowEl.classList.add('csk-window--hidden');
});
actions.appendChild(historyBtn);
actions.appendChild(minimizeBtn);
actions.appendChild(closeBtn);
header.appendChild(headerLeft);
header.appendChild(actions);
// === 消息区 ===
const messagesContainer = document.createElement('div');
messagesContainer.id = 'csk-messages';
messagesContainer.className = 'csk-messages';
// === 欢迎空状态 ===
const welcomeEl = document.createElement('div');
welcomeEl.className = 'csk-welcome';
welcomeEl.innerHTML = `
${BOT_ICON}
${t('welcome_title')}
${t('welcome_desc')}
`;
// 快捷问题芯片(点击即自动发送)
if (config.quickReplies.length > 0) {
const chips = document.createElement('div');
chips.className = 'csk-quick-replies';
for (const reply of config.quickReplies) {
const chip = document.createElement('button');
chip.type = 'button';
chip.className = 'csk-quick-reply';
chip.textContent = reply;
chip.addEventListener('click', () => {
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
});
chips.appendChild(chip);
}
welcomeEl.appendChild(chips);
}
messagesContainer.appendChild(welcomeEl);
// === 新消息提示按钮(用户上滑时新消息到达显示) ===
// 挂到 windowEl 而非 messagesContainer,绝对定位悬浮于输入区上方,避免随内容滚动
const newMsgBtn = document.createElement('button');
newMsgBtn.type = 'button';
newMsgBtn.className = 'csk-newmsg csk-newmsg--hidden';
newMsgBtn.innerHTML = `
${t('new_message')}
`;
windowEl.appendChild(newMsgBtn);
// === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
historyPanel.innerHTML = `
`;
messagesContainer.appendChild(historyPanel);
// 历史面板返回按钮
const backBtn = historyPanel.querySelector('#csk-history-back');
if (backBtn) {
backBtn.addEventListener('click', () => {
historyPanel.classList.add('csk-history-panel--hidden');
});
}
// 历史按钮点击
historyBtn.addEventListener('click', (e) => {
e.stopPropagation();
const isHidden = historyPanel.classList.contains('csk-history-panel--hidden');
historyPanel.classList.toggle('csk-history-panel--hidden');
if (isHidden) {
// 触发自定义事件,通知加载会话列表
windowEl.dispatchEvent(new CustomEvent('csk:loadHistory'));
}
});
// === 知识库分类下拉框(P1) ===
let categorySelect: HTMLSelectElement | null = null;
if (config.showCategorySwitch) {
const categoryBar = document.createElement('div');
categoryBar.className = 'csk-category-bar';
const categoryLabel = document.createElement('span');
categoryLabel.className = 'csk-category-bar__label';
categoryLabel.textContent = '📚';
categorySelect = document.createElement('select');
categorySelect.id = 'csk-category-select';
categorySelect.className = 'csk-category-select';
categorySelect.innerHTML = ``;
// onChange 触发自定义事件
categorySelect.addEventListener('change', () => {
const selectedId = categorySelect!.value;
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
}));
});
categoryBar.appendChild(categoryLabel);
categoryBar.appendChild(categorySelect);
// 插入到 messages 和 inputArea 之间
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
windowEl.appendChild(categoryBar);
} else {
windowEl.appendChild(header);
windowEl.appendChild(messagesContainer);
}
// === 输入区 ===
const inputArea = document.createElement('div');
inputArea.className = 'csk-input-area';
const inputWrap = document.createElement('div');
inputWrap.className = 'csk-input-wrap';
const inputEl = document.createElement('textarea');
inputEl.id = 'csk-input';
inputEl.className = 'csk-input';
inputEl.setAttribute('placeholder', t('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', t('send'));
sendBtn.setAttribute('disabled', 'true');
sendBtn.innerHTML = ``;
inputWrap.appendChild(inputEl);
inputWrap.appendChild(sendBtn);
inputArea.appendChild(inputWrap);
windowEl.appendChild(inputArea);
// 清空按钮(可选)
let clearBtn: HTMLElement | null = null;
if (config.showClear) {
clearBtn = document.createElement('button');
clearBtn.className = 'csk-clear-btn';
clearBtn.textContent = t('clear');
clearBtn.style.display = 'none'; // 初始隐藏,有消息后才显示
// 插入到 categoryBar/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 = `
${BOT_ICON}
`;
messagesContainer.appendChild(el);
loadingEl = el;
return el;
}
function hideLoading(): void {
if (loadingEl && loadingEl.parentNode) {
loadingEl.parentNode.removeChild(loadingEl);
loadingEl = null;
}
}
// === a11y: aria-live 播报区域(视觉隐藏,供屏幕阅读器监听新消息) ===
const ariaLiveEl = document.createElement('div');
ariaLiveEl.className = 'csk-sr-only';
ariaLiveEl.setAttribute('aria-live', 'polite');
ariaLiveEl.setAttribute('aria-atomic', 'true');
windowEl.appendChild(ariaLiveEl);
// === 历史搜索输入框引用 ===
const searchInput = historyPanel.querySelector('#csk-history-search') as HTMLInputElement | null;
// === Launcher 提示气泡(首访引导,初始隐藏,由 index.ts 定时控制) ===
const teaserEl = document.createElement('div');
teaserEl.className = `csk-teaser csk-teaser--${config.position === 'left-bottom' ? 'left' : 'right'} csk-teaser--hidden`;
teaserEl.setAttribute('role', 'status');
teaserEl.innerHTML = `
${config.teaserText || t('teaser_text')}
`;
return {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
welcomeEl,
newMsgBtn,
teaserEl,
searchInput,
ariaLiveEl,
showLoading,
hideLoading,
};
}
// ==================== 拖拽支持 ====================
/** 启用弹窗拖拽,onDragEnd 回调返回最终位置用于持久化 */
export function enableDrag(headerEl: HTMLElement, windowEl: HTMLElement, onDragEnd?: (pos: { x: number; y: number }) => void): () => void {
let dragging = false;
let startX = 0;
let startY = 0;
let offsetX = 0;
let offsetY = 0;
const onMouseDown = (e: MouseEvent) => {
// 忽略头部按钮点击触发的拖拽
const target = e.target as HTMLElement;
if (target.closest('button')) return;
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);
// 拖拽结束回调:返回当前位置供持久化
if (onDragEnd) {
const rect = windowEl.getBoundingClientRect();
onDragEnd({ x: rect.left, y: rect.top });
}
};
headerEl.addEventListener('mousedown', onMouseDown);
// 清理函数
return () => {
headerEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
};
}
/** Launcher 拖拽:垂直方向最小 bottom 值(px),避免贴底被系统导航栏遮挡 */
const LAUNCHER_MIN_BOTTOM = 16;
/** Launcher 拖拽:计算当前视口下最大 bottom 值 */
function launcherMaxBottom(): number {
return window.innerHeight - 76; // 按钮高度 60 + 16 间距
}
/**
* 启用 Launcher 按钮的拖拽功能(边缘吸附式)
*
* 设计要点:
* - 仅允许垂直方向自由移动,水平方向释放时自动吸附到最近的左/右边缘
* - 移动距离 < 5px 视为点击(不干扰原有的 open/close 逻辑)
* - 拖拽中按钮微放大 + 隐藏未读徽章,释放后带缓动吸附动画
* - 支持鼠标 + 触摸事件,触摸拖拽时阻止页面滚动
*/
export function enableLauncherDrag(
launcherEl: HTMLElement,
config: Pick,
onDragEnd?: (pos: { side: 'left' | 'right'; bottom: number }) => void,
): () => void {
let dragging = false;
let startX = 0;
let startY = 0;
let hasMoved = false;
let startBottom = 0;
let startRight = 0;
let startLeft = 0;
// 标记是否正在吸附过渡中,避免过渡期间的 mouseup 再次触发
let isSnapping = false;
/** 获取当前 bottom 值(纯数值) */
function getCurrentBottom(): number {
const val = parseFloat(getComputedStyle(launcherEl).bottom);
return isNaN(val) ? 24 : val;
}
function onPointerDown(clientX: number, clientY: number): void {
if (isSnapping) return;
dragging = true;
hasMoved = false;
startX = clientX;
startY = clientY;
startBottom = getCurrentBottom();
// 记录水平起始位置(用于判断当前在哪一侧)
const style = launcherEl.style;
const cs = getComputedStyle(launcherEl);
startRight = style.right ? parseFloat(style.right) : parseFloat(cs.right);
startLeft = style.left ? parseFloat(style.left) : parseFloat(cs.left);
}
function onPointerMove(clientX: number, clientY: number, e: Event): void {
if (!dragging) return;
const dx = clientX - startX;
const dy = clientY - startY;
if (!hasMoved) {
if (Math.abs(dx) < 5 && Math.abs(dy) < 5) return;
hasMoved = true;
// 进入拖拽态
launcherEl.classList.add('csk-launcher--dragging');
launcherEl.classList.remove('csk-launcher--right', 'csk-launcher--left');
launcherEl.style.transition = 'none';
// 同步当前水平位置(从吸附态的 right/left 转换为固定像素)
if (!isNaN(startRight) && startRight >= 0 && isNaN(startLeft)) {
launcherEl.style.right = `${startRight}px`;
launcherEl.style.left = 'auto';
} else if (!isNaN(startLeft) && startLeft >= 0) {
launcherEl.style.left = `${startLeft}px`;
launcherEl.style.right = 'auto';
}
}
if (hasMoved) {
e.preventDefault(); // 阻止触摸滚动
const newBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(startBottom - dy, launcherMaxBottom()));
launcherEl.style.bottom = `${newBottom}px`;
}
}
function onPointerUp(): void {
if (!dragging) return;
dragging = false;
if (!hasMoved) return; // 移动距离不足 5px,视为点击,不处理
// 隐藏拖拽态(保留当前 bottom)
const currentBottom = parseFloat(launcherEl.style.bottom) || getCurrentBottom();
const clampedBottom = Math.max(LAUNCHER_MIN_BOTTOM, Math.min(currentBottom, launcherMaxBottom()));
// 判断吸附到哪一侧(取离哪边更近)
const rect = launcherEl.getBoundingClientRect();
const center = rect.left + rect.width / 2;
const snapSide: 'left' | 'right' = center <= window.innerWidth / 2 ? 'left' : 'right';
// 切换到吸附过渡动画
launcherEl.classList.remove('csk-launcher--dragging');
launcherEl.classList.add(`csk-launcher--${snapSide}`, 'csk-launcher--snap');
launcherEl.style.left = '';
launcherEl.style.right = '';
launcherEl.style.bottom = `${clampedBottom}px`;
// 过渡结束后清理(保留 bottom,否则 CSS 默认 24px 会覆盖拖拽位置)
isSnapping = true;
const onTransitionEnd = (): void => {
launcherEl.removeEventListener('transitionend', onTransitionEnd);
launcherEl.classList.remove('csk-launcher--snap');
launcherEl.style.transition = '';
// 注意:不清除 style.bottom,保留用户拖拽的垂直位置
isSnapping = false;
};
launcherEl.addEventListener('transitionend', onTransitionEnd);
// 兜底:350ms 后强制清理(transitionend 可能不触发)
setTimeout(() => {
if (isSnapping) onTransitionEnd();
}, 350);
// 恢复未读徽章
const badge = launcherEl.querySelector('.csk-launcher__badge');
if (badge) (badge as HTMLElement).style.display = '';
if (onDragEnd) {
onDragEnd({ side: snapSide, bottom: clampedBottom });
}
}
// --- 鼠标事件 ---
function onMouseDown(e: MouseEvent): void { onPointerDown(e.clientX, e.clientY); }
function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); }
function onMouseUp(): void { onPointerUp(); }
// --- 触摸事件 ---
function onTouchStart(e: TouchEvent): void {
if (e.touches.length === 1) onPointerDown(e.touches[0].clientX, e.touches[0].clientY);
}
function onTouchMove(e: TouchEvent): void {
if (e.touches.length === 1) onPointerMove(e.touches[0].clientX, e.touches[0].clientY, e);
}
function onTouchEnd(): void { onPointerUp(); }
launcherEl.addEventListener('mousedown', onMouseDown);
document.addEventListener('mousemove', onMouseMove);
document.addEventListener('mouseup', onMouseUp);
launcherEl.addEventListener('touchstart', onTouchStart, { passive: true });
document.addEventListener('touchmove', onTouchMove, { passive: false });
document.addEventListener('touchend', onTouchEnd);
return () => {
launcherEl.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('mousemove', onMouseMove);
document.removeEventListener('mouseup', onMouseUp);
launcherEl.removeEventListener('touchstart', onTouchStart);
document.removeEventListener('touchmove', onTouchMove);
document.removeEventListener('touchend', onTouchEnd);
};
}
// ==================== 消息渲染 ====================
/** 渲染用户消息气泡 */
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--user';
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--user';
avatar.innerHTML = USER_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
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);
content.appendChild(bubble);
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return wrapper;
}
/** 渲染 AI 消息气泡(支持 Markdown) */
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, renderMd?: (text: string) => string, msgId?: string): HTMLElement {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai';
if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
const bubble = document.createElement('div');
bubble.className = 'csk-msg__bubble';
// 支持 Markdown 渲染,传入渲染函数则使用,否则纯文本
if (renderMd) {
bubble.innerHTML = renderMd(text);
} else {
bubble.textContent = text;
}
const time = document.createElement('div');
time.className = 'csk-msg__time';
time.textContent = formatTime(timestamp);
content.appendChild(bubble);
// AI 操作条(复制 / 重试),插入到气泡与时间戳之间
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
// 代码块复制按钮
enhanceCodeBlocks(bubble);
return wrapper;
}
/** 创建空的 AI 气泡(流式追加用) */
export function createEmptyAIBubble(container: HTMLElement, timestamp: number, msgId?: string): { wrapper: HTMLElement; bubble: HTMLElement } {
const wrapper = document.createElement('div');
wrapper.className = 'csk-msg csk-msg--ai csk-msg--streaming';
if (msgId) wrapper.dataset.cskMsgId = msgId;
const avatar = document.createElement('div');
avatar.className = 'csk-msg__avatar csk-msg__avatar--ai';
avatar.innerHTML = BOT_ICON;
const content = document.createElement('div');
content.className = 'csk-msg__content';
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);
content.appendChild(bubble);
content.appendChild(buildAIActions(bubble, wrapper));
content.appendChild(time);
wrapper.appendChild(avatar);
wrapper.appendChild(content);
container.appendChild(wrapper);
return { wrapper, bubble };
}
// ==================== AI 消息操作条 + 代码块复制 ====================
/** 复制图标 */
const COPY_ICON = ``;
/** 重试图标 */
const RETRY_ICON = ``;
/** 构建 AI 消息操作条(复制 / 重试 / 反馈) */
function buildAIActions(bubble: HTMLElement, wrapper: HTMLElement): HTMLElement {
const bar = document.createElement('div');
bar.className = 'csk-msg__actions';
// 复制按钮
const copyBtn = document.createElement('button');
copyBtn.type = 'button';
copyBtn.className = 'csk-action-btn';
copyBtn.setAttribute('title', t('copy'));
copyBtn.setAttribute('aria-label', t('copy'));
copyBtn.innerHTML = COPY_ICON;
copyBtn.addEventListener('click', async (e) => {
e.stopPropagation();
const text = bubble.textContent || '';
await copyToClipboard(text, copyBtn);
});
// 重试按钮
const retryBtn = document.createElement('button');
retryBtn.type = 'button';
retryBtn.className = 'csk-action-btn';
retryBtn.setAttribute('title', t('retry'));
retryBtn.setAttribute('aria-label', t('retry'));
retryBtn.innerHTML = RETRY_ICON;
retryBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:retry', {
detail: { msgId: wrapper.dataset.cskMsgId || '' }
}));
}
});
// 反馈按钮(👍 / 👎)
const THUMB_UP = ``;
const THUMB_DOWN = ``;
const upBtn = document.createElement('button');
upBtn.type = 'button';
upBtn.className = 'csk-feedback-btn';
upBtn.setAttribute('title', t('feedback_up'));
upBtn.setAttribute('aria-label', t('feedback_up'));
upBtn.innerHTML = THUMB_UP;
upBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'up' }
}));
}
});
const downBtn = document.createElement('button');
downBtn.type = 'button';
downBtn.className = 'csk-feedback-btn';
downBtn.setAttribute('title', t('feedback_down'));
downBtn.setAttribute('aria-label', t('feedback_down'));
downBtn.innerHTML = THUMB_DOWN;
downBtn.addEventListener('click', (e) => {
e.stopPropagation();
const root = wrapper.closest('.csk-window');
if (root) {
root.dispatchEvent(new CustomEvent('csk:feedback', {
detail: { msgId: wrapper.dataset.cskMsgId || '', value: 'down' }
}));
}
});
bar.appendChild(copyBtn);
bar.appendChild(retryBtn);
bar.appendChild(upBtn);
bar.appendChild(downBtn);
return bar;
}
/**
* 更新反馈按钮的视觉状态(由 chat.ts 调用)
* @param wrapper 消息 wrapper 元素
* @param value 当前反馈值:'up' | 'down' | undefined
*/
export function updateFeedbackUI(wrapper: HTMLElement, value: 'up' | 'down' | undefined): void {
const btns = wrapper.querySelectorAll('.csk-feedback-btn');
if (btns.length < 2) return;
btns.forEach((btn) => btn.classList.remove('csk-feedback-btn--active', 'down'));
if (value === 'up' && btns[0]) btns[0].classList.add('csk-feedback-btn--active');
if (value === 'down' && btns[1]) btns[1].classList.add('csk-feedback-btn--active', 'down');
}
/** 复制文本到剪贴板,带"已复制"反馈 */
async function copyToClipboard(text: string, btn: HTMLElement): Promise {
const original = btn.innerHTML;
try {
if (navigator.clipboard && window.isSecureContext) {
await navigator.clipboard.writeText(text);
} else {
// 降级方案:execCommand
const ta = document.createElement('textarea');
ta.value = text;
ta.style.position = 'fixed';
ta.style.opacity = '0';
document.body.appendChild(ta);
ta.select();
document.execCommand('copy');
document.body.removeChild(ta);
}
btn.innerHTML = ``;
btn.classList.add('csk-action-btn--done');
setTimeout(() => {
btn.innerHTML = original;
btn.classList.remove('csk-action-btn--done');
}, 1500);
} catch {
// 复制失败静默处理
}
}
/** 为气泡内所有代码块添加复制按钮 */
export function enhanceCodeBlocks(bubble: HTMLElement): void {
const blocks = bubble.querySelectorAll('.csk-md-code-block');
blocks.forEach((pre) => {
if (pre.querySelector('.csk-md-code-copy')) return;
const codeEl = pre.querySelector('code');
const btn = document.createElement('button');
btn.type = 'button';
btn.className = 'csk-md-code-copy';
btn.setAttribute('title', t('copy'));
btn.setAttribute('aria-label', t('copy'));
btn.innerHTML = COPY_ICON;
btn.addEventListener('click', async (e) => {
e.stopPropagation();
await copyToClipboard(codeEl?.textContent || '', btn);
});
(pre as HTMLElement).appendChild(btn);
});
}
/** 流式打字光标:追加到气泡末尾(每次 chunk 后调用) */
export function appendStreamingCaret(bubble: HTMLElement): void {
if (bubble.querySelector('.csk-caret')) return;
const caret = document.createElement('span');
caret.className = 'csk-caret';
caret.setAttribute('aria-hidden', 'true');
bubble.appendChild(caret);
}
/** 标记 AI 气泡流式结束:移除流式态、补代码块复制按钮 */
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void {
wrapper.classList.remove('csk-msg--streaming');
const caret = bubble.querySelector('.csk-caret');
if (caret) caret.remove();
enhanceCodeBlocks(bubble);
}
// ==================== P1: RAG 引用来源渲染 ====================
/** 渲染 RAG 引用来源卡片 */
export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void {
// 移除已有的来源卡片
const existing = wrapper.querySelector('.csk-sources');
if (existing) existing.remove();
if (!sources || sources.length === 0) return;
const sourcesEl = document.createElement('div');
sourcesEl.className = 'csk-sources csk-sources--collapsed';
// 头部
const header = document.createElement('div');
header.className = 'csk-sources__header';
const titleSpan = document.createElement('span');
titleSpan.className = 'csk-sources__title';
titleSpan.textContent = `📚 ${t('source_count', { n: sources.length })}`;
const arrow = document.createElement('span');
arrow.className = 'csk-sources__arrow';
arrow.textContent = '▼';
header.appendChild(titleSpan);
header.appendChild(arrow);
// 点击折叠/展开
header.addEventListener('click', () => {
sourcesEl.classList.toggle('csk-sources--collapsed');
});
// 内容
const body = document.createElement('div');
body.className = 'csk-sources__body';
for (const src of sources) {
const item = document.createElement('div');
item.className = 'csk-source-item';
const name = document.createElement('div');
name.className = 'csk-source-item__name';
name.textContent = src.title || src.sourceName || '未知文档';
if (src.snippet) {
const snippet = document.createElement('div');
snippet.className = 'csk-source-item__snippet';
snippet.textContent = src.snippet;
item.appendChild(snippet);
}
const meta = document.createElement('div');
meta.className = 'csk-source-item__meta';
const metaParts: string[] = [];
if (src.sourceName) metaParts.push(src.sourceName);
if (src.chunkIndex !== undefined) metaParts.push(`分块 #${src.chunkIndex}`);
if (src.score !== undefined) metaParts.push(`相关度 ${(src.score * 100).toFixed(0)}%`);
meta.textContent = metaParts.join(' · ');
item.appendChild(name);
item.appendChild(meta);
body.appendChild(item);
}
sourcesEl.appendChild(header);
sourcesEl.appendChild(body);
// 插入到气泡和时间戳之间(时间戳在 content 容器内,故用其父节点插入)
const timeEl = wrapper.querySelector('.csk-msg__time');
if (timeEl && timeEl.parentNode) {
timeEl.parentNode.insertBefore(sourcesEl, timeEl);
} else {
wrapper.appendChild(sourcesEl);
}
}
// ==================== P2: 会话管理面板渲染 ====================
/** 会话列表项数据 */
export interface HistoryItemData {
id: string;
chatId?: string;
messageCount?: number;
lastMessageTime?: string;
lastMessagePreview?: string;
createdAt?: string;
}
/** 按日期分组会话列表:今天 / 昨天 / 本周 / 更早 */
function groupHistoryByDate(items: HistoryItemData[]): { label: string; items: HistoryItemData[] }[] {
const now = new Date();
const todayStart = new Date(now.getFullYear(), now.getMonth(), now.getDate()).getTime();
const yesterdayStart = todayStart - 86400000;
const weekStart = todayStart - (now.getDay() || 7) * 86400000 + 86400000; // 本周一
const groups: Record = {
today: [],
yesterday: [],
week: [],
earlier: [],
};
for (const item of items) {
// 尝试解析时间:优先 lastMessageTime,其次 createdAt
const timeStr = item.lastMessageTime || item.createdAt;
const ts = timeStr ? new Date(timeStr).getTime() : 0;
if (ts >= todayStart) {
groups.today.push(item);
} else if (ts >= yesterdayStart) {
groups.yesterday.push(item);
} else if (ts >= weekStart) {
groups.week.push(item);
} else {
groups.earlier.push(item);
}
}
const result: { label: string; items: HistoryItemData[] }[] = [];
if (groups.today.length) result.push({ label: t('history_group_today'), items: groups.today });
if (groups.yesterday.length) result.push({ label: t('history_group_yesterday'), items: groups.yesterday });
if (groups.week.length) result.push({ label: t('history_group_week'), items: groups.week });
if (groups.earlier.length) result.push({ label: t('history_group_earlier'), items: groups.earlier });
return result;
}
/** 渲染会话列表(带日期分组) */
export function renderHistoryList(
listEl: HTMLElement,
items: HistoryItemData[],
onSelect: (conversationId: string) => void,
onExport: (id: string) => void,
onDelete: (id: string) => void,
activeChatId?: string,
emptyText?: string
): void {
listEl.innerHTML = '';
if (items.length === 0) {
const empty = document.createElement('div');
empty.className = 'csk-history-panel__empty';
empty.innerHTML = `
💬
${emptyText || t('history_empty')}
`;
listEl.appendChild(empty);
return;
}
// 按日期分组渲染
const groups = groupHistoryByDate(items);
for (const group of groups) {
// 分组标题
if (groups.length > 1) {
const groupEl = document.createElement('div');
groupEl.className = 'csk-history-group';
const labelEl = document.createElement('div');
labelEl.className = 'csk-history-group__label';
labelEl.textContent = group.label;
groupEl.appendChild(labelEl);
listEl.appendChild(groupEl);
}
for (const item of group.items) {
const el = document.createElement('div');
el.className = 'csk-history-item';
// 高亮当前活跃会话
const convId = item.chatId || item.id;
if (activeChatId && convId === activeChatId) {
el.classList.add('csk-history-item--active');
}
const info = document.createElement('div');
info.className = 'csk-history-item__info';
const idEl = document.createElement('div');
idEl.className = 'csk-history-item__id';
// 显示最后一条消息预览,没有则显示 chatId
if (item.lastMessagePreview) {
idEl.textContent = item.lastMessagePreview.length > 60
? item.lastMessagePreview.substring(0, 60) + '...'
: item.lastMessagePreview;
} else {
idEl.textContent = convId;
}
const metaEl = document.createElement('div');
metaEl.className = 'csk-history-item__meta';
const metaParts: string[] = [];
if (item.messageCount !== undefined) metaParts.push(`${item.messageCount} 条消息`);
if (item.lastMessageTime) metaParts.push(item.lastMessageTime);
else if (item.createdAt) metaParts.push(item.createdAt);
metaEl.textContent = metaParts.join(' · ');
info.appendChild(idEl);
info.appendChild(metaEl);
const actionsEl = document.createElement('div');
actionsEl.className = 'csk-history-item__actions';
// 导出按钮
const exportBtn = document.createElement('button');
exportBtn.className = 'csk-history-action csk-history-action--export';
exportBtn.setAttribute('title', t('history_export'));
exportBtn.innerHTML = ``;
exportBtn.addEventListener('click', (e) => {
e.stopPropagation();
onExport(item.id);
});
// 删除按钮
const deleteBtn = document.createElement('button');
deleteBtn.className = 'csk-history-action csk-history-action--delete';
deleteBtn.setAttribute('title', t('history_delete'));
deleteBtn.innerHTML = ``;
deleteBtn.addEventListener('click', (e) => {
e.stopPropagation();
onDelete(item.id);
});
actionsEl.appendChild(exportBtn);
actionsEl.appendChild(deleteBtn);
el.appendChild(info);
el.appendChild(actionsEl);
// 点击整行 → 切换到该会话
el.addEventListener('click', () => {
onSelect(convId);
});
listEl.appendChild(el);
}
} // 关闭 group 循环
}
/** 滚动消息区到底部 */
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}`;
}