本地 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.
 
 
 
 
 

877 lines
33 KiB

/**
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
*
* 设计要点:
* - 头部含机器人头像 + 在线状态,增强产品感
* - 消息气泡带头像,左右区分清晰
* - 无消息时显示欢迎空状态
* - 输入区采用圆角容器包裹文本框与发送按钮
*/
import { ResolvedConfig, RagSource } from './types';
import { debounce } from './utils';
import { t } from './i18n';
// ==================== 图标常量 ====================
/** 机器人头像图标(用于头部、欢迎态、AI 气泡、Loading) */
const BOT_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M12 8V4H8"/><rect width="12" height="8" x="6" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M9 14v2"/><path d="M15 14v2"/><path d="M18 13v3a4 4 0 0 1-4 4h-4a4 4 0 0 1-4-4v-3"/></svg>`;
/** 用户头像图标 */
const USER_ICON = `<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M19 21v-2a4 4 0 0 0-4-4H9a4 4 0 0 0-4 4v2"/><circle cx="12" cy="7" r="4"/></svg>`;
// ==================== 悬浮按钮 ====================
/** 创建悬浮按钮 */
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;
// 未读徽章(初始隐藏,有未读消息时由 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 = `<i class="csk-status-dot"></i>${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 = `<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"><circle cx="12" cy="12" r="10"/><polyline points="12 6 12 12 16 14"/></svg>`;
// 最小化按钮
const minimizeBtn = document.createElement('button');
minimizeBtn.className = 'csk-header__btn csk-header__btn--minimize';
minimizeBtn.setAttribute('title', t('minimize'));
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', t('close'));
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(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 = `
<div class="csk-welcome__avatar">${BOT_ICON}</div>
<div class="csk-welcome__title">${t('welcome_title')}</div>
<div class="csk-welcome__desc">${t('welcome_desc')}</div>
`;
// 快捷问题芯片(点击即自动发送)
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 = `
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><line x1="12" y1="5" x2="12" y2="19"/><polyline points="19 12 12 19 5 12"/></svg>
<span>${t('new_message')}</span>
`;
windowEl.appendChild(newMsgBtn);
// === 会话管理面板(P2,默认隐藏) ===
const historyPanel = document.createElement('div');
historyPanel.className = 'csk-history-panel csk-history-panel--hidden';
historyPanel.innerHTML = `
<div class="csk-history-panel__header">
<span class="csk-history-panel__title">${t('history_title')}</span>
<button class="csk-history-panel__back" id="csk-history-back">
<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><line x1="19" y1="12" x2="5" y2="12"/><polyline points="12 19 5 12 12 5"/></svg>
${t('close')}
</button>
</div>
<div class="csk-history-panel__search-wrap">
<input type="text" class="csk-history-panel__search" id="csk-history-search" placeholder="${t('history_search')}" />
</div>
<div class="csk-history-panel__list" id="csk-history-list"></div>
`;
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 = `<option value="">${t('category_all')}</option>`;
// 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 = `<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>`;
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 = `
<div class="csk-loading__avatar">${BOT_ICON}</div>
<div class="csk-loading__bubble">
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
<div class="csk-loading__dot"></div>
</div>
`;
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 = `
<button class="csk-teaser__close" aria-label="${t('close')}" type="button">&times;</button>
<span>${config.teaserText || t('teaser_text')}</span>
`;
return {
window: windowEl,
messagesContainer,
inputEl,
sendBtn,
clearBtn,
categorySelect,
historyPanel,
welcomeEl,
newMsgBtn,
teaserEl,
searchInput,
ariaLiveEl,
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) => {
// 忽略头部按钮点击触发的拖拽
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);
};
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 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><rect x="9" y="9" width="13" height="13" rx="2" ry="2"/><path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1"/></svg>`;
/** 重试图标 */
const RETRY_ICON = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="23 4 23 10 17 10"/><path d="M20.49 15a9 9 0 1 1-2.12-9.36L23 10"/></svg>`;
/** 构建 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M14 9V5a3 3 0 0 0-3-3l-4 9v11h11.28a2 2 0 0 0 2-1.7l1.38-9a2 2 0 0 0-2-2.3zM7 22H4a2 2 0 0 1-2-2v-7a2 2 0 0 1 2-2h3"/></svg>`;
const THUMB_DOWN = `<svg xmlns="http://www.w3.org/2000/svg" width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M10 15v4a3 3 0 0 0 3 3l4-9V2H5.72a2 2 0 0 0-2 1.7l-1.38 9a2 2 0 0 0 2 2.3zm7-13h2.67A2.31 2.31 0 0 1 22 4v7a2.31 2.31 0 0 1-2.33 2H17"/></svg>`;
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<void> {
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2.5" stroke-linecap="round" stroke-linejoin="round"><polyline points="20 6 9 17 4 12"/></svg>`;
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;
}
/** 渲染会话列表 */
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 = `
<div class="csk-history-panel__empty-icon">💬</div>
<div>${emptyText || t('history_empty')}</div>
`;
listEl.appendChild(empty);
return;
}
for (const item of 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 = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4"/><polyline points="7 10 12 15 17 10"/><line x1="12" y1="15" x2="12" y2="3"/></svg>`;
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 = `<svg xmlns="http://www.w3.org/2000/svg" width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><polyline points="3 6 5 6 21 6"/><path d="M19 6v14a2 2 0 0 1-2 2H7a2 2 0 0 1-2-2V6m3 0V4a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2"/></svg>`;
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);
}
}
/** 滚动消息区到底部 */
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}`;
}