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.
1408 lines
54 KiB
1408 lines
54 KiB
/**
|
|
* DOM 构建模块 - 悬浮按钮 + 聊天弹窗容器
|
|
*
|
|
* 设计要点:
|
|
* - 头部含机器人头像 + 在线状态,增强产品感
|
|
* - 消息气泡带头像,左右区分清晰
|
|
* - 无消息时显示欢迎空状态
|
|
* - 输入区采用圆角容器包裹文本框与发送按钮
|
|
*/
|
|
import { ResolvedConfig, RagSource } from './types';
|
|
import { debounce, formatTime } from './utils';
|
|
import { t } from './i18n';
|
|
import { DialogPlugin } from 'tdesign-web-components/lib/dialog/index.js';
|
|
// omi 的 render/h 用于创建带 on* 函数回调的组件(见 createEventful 说明)
|
|
import { render, h } from 'omi';
|
|
|
|
/**
|
|
* 用 omi render 创建带函数事件回调的 TDesign 组件。
|
|
*
|
|
* 为什么不能直接 createElement + property 赋值:
|
|
* omi(tdesign-web-components 底层框架)的 isComplexType 对 /^on|children/ 前缀返回 false,
|
|
* 纯 DOM 下 on* 回调(onChange/onClick 等)既不走 handleComplexProps 的 accessor,也无法从
|
|
* attribute 读取,导致 `el.onChange = fn` 静默失效;而 select/tag 内部是直接
|
|
* `this.props.onChange.call()`,没有 fire 的 dispatchEvent 兜底,必须借助 omi render 把
|
|
* 函数作为 vnode attributes 同步进 props。
|
|
*/
|
|
function createEventful(
|
|
tagName: string,
|
|
props: Record<string, unknown>,
|
|
container: HTMLElement,
|
|
children?: string | unknown[],
|
|
): HTMLElement {
|
|
// children 作为 vnode 的 children 传入(tag/button 等通过 children/slot 渲染文本,而非 content)
|
|
const vnode = children !== undefined ? h(tagName, props, children) : h(tagName, props);
|
|
return render(vnode, container) as HTMLElement;
|
|
}
|
|
|
|
// ==================== 图标常量 ====================
|
|
|
|
/** 机器人头像图标(用于头部、欢迎态、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;
|
|
}
|
|
|
|
// ==================== 水印生成 ====================
|
|
|
|
/** 生成水印纹理背景图(Canvas 离屏渲染,平铺用) */
|
|
function createWatermarkPattern(config: ResolvedConfig): string {
|
|
const canvas = document.createElement('canvas');
|
|
const ctx = canvas.getContext('2d');
|
|
if (!ctx) return '';
|
|
|
|
// 组装水印文本:配置值 + 日期 时分
|
|
const now = new Date();
|
|
const y = now.getFullYear();
|
|
const m = String(now.getMonth() + 1).padStart(2, '0');
|
|
const d = String(now.getDate()).padStart(2, '0');
|
|
const h = String(now.getHours()).padStart(2, '0');
|
|
const min = String(now.getMinutes()).padStart(2, '0');
|
|
const dateStr = `${y}-${m}-${d} ${h}:${min}`;
|
|
const watermarkText = config.watermark ? `${config.watermark} ${dateStr}` : dateStr;
|
|
|
|
// 一个平铺单元的尺寸
|
|
const cellW = 280;
|
|
const cellH = 160;
|
|
canvas.width = cellW;
|
|
canvas.height = cellH;
|
|
|
|
// 按主题选择透明度
|
|
const isDark = config.theme === 'dark';
|
|
ctx.fillStyle = isDark ? 'rgba(255, 255, 255, 0.05)' : 'rgba(0, 0, 0, 0.06)';
|
|
ctx.font = '13px -apple-system, BlinkMacSystemFont, "Segoe UI", Roboto, "PingFang SC", "Microsoft YaHei", sans-serif';
|
|
ctx.textAlign = 'center';
|
|
ctx.textBaseline = 'middle';
|
|
|
|
// 倾斜绘制
|
|
ctx.save();
|
|
ctx.translate(cellW / 2, cellH / 2);
|
|
ctx.rotate(-22 * Math.PI / 180);
|
|
ctx.fillText(watermarkText, 0, 0);
|
|
ctx.restore();
|
|
|
|
return canvas.toDataURL('image/png');
|
|
}
|
|
|
|
// ==================== 聊天弹窗 ====================
|
|
|
|
/** 创建聊天弹窗完整结构,返回各区域引用 */
|
|
export function createChatWindow(config: ResolvedConfig): {
|
|
window: HTMLElement;
|
|
messagesContainer: HTMLElement;
|
|
inputEl: HTMLElement;
|
|
clearBtn: HTMLElement | null;
|
|
categorySelect: HTMLElement | null;
|
|
roleSelect: HTMLElement | null;
|
|
historyPanel: HTMLElement;
|
|
welcomeEl: HTMLElement;
|
|
newMsgBtn: HTMLElement;
|
|
teaserEl: HTMLElement;
|
|
resizeHandle: HTMLElement;
|
|
searchInput: HTMLInputElement | null;
|
|
ariaLiveEl: HTMLElement;
|
|
disclaimer: 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');
|
|
|
|
// === 水印层(仅当配置了 watermark 时创建,绝对定位背景,不拦截交互) ===
|
|
if (config.watermark) {
|
|
const watermark = document.createElement('div');
|
|
watermark.className = 'csk-watermark';
|
|
const watermarkBg = createWatermarkPattern(config);
|
|
if (watermarkBg) {
|
|
watermark.style.backgroundImage = `url(${watermarkBg})`;
|
|
}
|
|
windowEl.appendChild(watermark);
|
|
}
|
|
|
|
// === 头部 ===
|
|
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);
|
|
|
|
// === 角色选择器(仅当有多个角色时显示,t-select) ===
|
|
let roleSelect: HTMLElement | null = null;
|
|
const roles = config.roles;
|
|
if (roles && roles.length > 1) {
|
|
// 包装后插入到 headerLeft 之后、actions 之前
|
|
const roleWrap = document.createElement('div');
|
|
roleWrap.className = 'csk-role-select-wrap';
|
|
|
|
// on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props
|
|
roleSelect = createEventful('t-select', {
|
|
options: roles.map(role => ({ label: role.name || String(role.id), value: String(role.id) })),
|
|
value: String(config.integrateId),
|
|
// onChange 触发自定义事件(受控:同步回写选中值)
|
|
onChange: (value: string | number) => {
|
|
if (roleSelect) {
|
|
(roleSelect as unknown as { value: string | number }).value = String(value);
|
|
}
|
|
windowEl.dispatchEvent(new CustomEvent('csk:roleChange', {
|
|
detail: { roleId: String(value) }
|
|
}));
|
|
},
|
|
}, roleWrap);
|
|
roleSelect.setAttribute('aria-label', t('role_selector_label'));
|
|
|
|
header.appendChild(headerLeft);
|
|
header.appendChild(roleWrap);
|
|
} else {
|
|
header.appendChild(headerLeft);
|
|
}
|
|
|
|
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(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>
|
|
`;
|
|
|
|
// 快捷问题芯片(点击即自动发送,由 t-tag 承载)
|
|
if (config.quickReplies.length > 0) {
|
|
const chips = document.createElement('div');
|
|
chips.className = 'csk-quick-replies';
|
|
for (const reply of config.quickReplies) {
|
|
// on* 回调(onClick)必须经 omi render 创建;文本经 children 传入(tag 的 children 优先于 content)
|
|
createEventful('t-tag', {
|
|
theme: 'primary',
|
|
variant: 'light-outline',
|
|
onClick: () => {
|
|
windowEl.dispatchEvent(new CustomEvent('csk:quickReply', { detail: { text: reply } }));
|
|
},
|
|
}, chips, reply);
|
|
}
|
|
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'));
|
|
}
|
|
});
|
|
|
|
// === 知识库分类下拉框(t-select,选项由 loadCategories 异步填充) ===
|
|
let categorySelect: HTMLElement | 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 = '📚';
|
|
categoryBar.appendChild(categoryLabel);
|
|
|
|
// on* 回调(onChange)必须经 omi render 创建,纯 DOM property 赋值无法传入 props
|
|
categorySelect = createEventful('t-select', {
|
|
options: [{ label: t('category_all'), value: '' }],
|
|
value: '',
|
|
// onChange 触发自定义事件(受控:同步回写选中值)
|
|
onChange: (value: string | number) => {
|
|
if (categorySelect) {
|
|
(categorySelect as unknown as { value: string | number }).value = String(value);
|
|
}
|
|
const selectedId = String(value);
|
|
windowEl.dispatchEvent(new CustomEvent('csk:categoryChange', {
|
|
detail: { categoryId: selectedId ? Number(selectedId) : undefined }
|
|
}));
|
|
},
|
|
}, categoryBar);
|
|
categorySelect.id = 'csk-category-select';
|
|
|
|
// 插入到 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';
|
|
|
|
// 输入框 + 发送/停止按钮统一由 t-chat-sender 承担(loading 控制 send/stop 切换,Enter 发送已内置)
|
|
const inputEl = document.createElement('t-chat-sender');
|
|
inputEl.id = 'csk-input';
|
|
inputEl.setAttribute('placeholder', t('placeholder'));
|
|
// 压缩输入框:autosize 为 Object 复杂类型,走 property 赋值(同 dom.ts createChatItemMsg 的 content 处理)
|
|
(inputEl as unknown as { autosize: { minRows: number } }).autosize = { minRows: 1 };
|
|
inputArea.appendChild(inputEl);
|
|
|
|
// === 保密声明脚注(默认折叠为单行,点击展开完整条文) ===
|
|
const disclaimer = document.createElement('div');
|
|
disclaimer.className = 'csk-disclaimer';
|
|
disclaimer.setAttribute('role', 'button');
|
|
disclaimer.setAttribute('tabindex', '0');
|
|
disclaimer.setAttribute('aria-expanded', 'false');
|
|
const toggleDisclaimer = () => {
|
|
const expanded = disclaimer.classList.toggle('csk-disclaimer--expanded');
|
|
disclaimer.setAttribute('aria-expanded', String(expanded));
|
|
};
|
|
disclaimer.addEventListener('click', toggleDisclaimer);
|
|
disclaimer.addEventListener('keydown', (e) => {
|
|
if (e.key === 'Enter' || e.key === ' ') { e.preventDefault(); toggleDisclaimer(); }
|
|
});
|
|
if (config.disclaimer !== undefined) {
|
|
// 用户自定义或传空串隐藏。
|
|
// 注意:disclaimer 语义为「受信 HTML」(由宿主开发者或后端管理员配置),
|
|
// 此处直接 innerHTML 注入属设计内行为,请勿传入不可信用户输入。
|
|
disclaimer.innerHTML = config.disclaimer;
|
|
if (!config.disclaimer) {
|
|
disclaimer.style.display = 'none';
|
|
}
|
|
} else {
|
|
// 未传值,等异步从后端拉取,先隐藏
|
|
disclaimer.style.display = 'none';
|
|
}
|
|
inputArea.appendChild(disclaimer);
|
|
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 = '';
|
|
return loadingEl;
|
|
}
|
|
const el = document.createElement('t-chat-loading');
|
|
el.setAttribute('animation', 'dots');
|
|
el.setAttribute('text', t('loading'));
|
|
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');
|
|
const teaserCloseBtn = document.createElement('button');
|
|
teaserCloseBtn.className = 'csk-teaser__close';
|
|
teaserCloseBtn.setAttribute('aria-label', t('close'));
|
|
teaserCloseBtn.type = 'button';
|
|
teaserCloseBtn.textContent = '×';
|
|
const teaserTextSpan = document.createElement('span');
|
|
// 文本语义:teaserText 使用 textContent 注入,避免宿主传入未信任内容时构成 XSS
|
|
teaserTextSpan.textContent = config.teaserText || t('teaser_text');
|
|
teaserEl.appendChild(teaserCloseBtn);
|
|
teaserEl.appendChild(teaserTextSpan);
|
|
|
|
// === 缩放拖拽手柄(右下角) ===
|
|
const resizeHandle = document.createElement('div');
|
|
resizeHandle.className = 'csk-resize-handle';
|
|
resizeHandle.setAttribute('aria-label', t('resize'));
|
|
// 纯 CSS 渲染三条斜线,无需 SVG
|
|
resizeHandle.innerHTML = '<span class="csk-resize-grip"></span>';
|
|
windowEl.appendChild(resizeHandle);
|
|
|
|
return {
|
|
window: windowEl,
|
|
messagesContainer,
|
|
inputEl,
|
|
clearBtn,
|
|
categorySelect,
|
|
historyPanel,
|
|
welcomeEl,
|
|
newMsgBtn,
|
|
teaserEl,
|
|
resizeHandle,
|
|
searchInput,
|
|
ariaLiveEl,
|
|
roleSelect,
|
|
disclaimer,
|
|
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<ResolvedConfig, 'position'>,
|
|
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);
|
|
|
|
// 阻止本次拖拽触发 click 事件(mousedown → mousemove → mouseup → click 的浏览器默认链路)
|
|
const preventClick = (ev: Event): void => {
|
|
ev.stopPropagation();
|
|
launcherEl.removeEventListener('click', preventClick, true);
|
|
};
|
|
launcherEl.addEventListener('click', preventClick, true);
|
|
|
|
// 恢复未读徽章
|
|
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);
|
|
};
|
|
}
|
|
|
|
// ==================== 窗口缩放支持 ====================
|
|
|
|
/** 窗口最小尺寸 */
|
|
const MIN_WIDTH = 300;
|
|
const MIN_HEIGHT = 300;
|
|
|
|
/**
|
|
* 启用窗口右下角拖拽缩放
|
|
* @param handleEl 缩放手柄元素
|
|
* @param windowEl 聊天窗口元素
|
|
* @param onResizeEnd 缩放结束回调,返回最终宽高用于持久化
|
|
* @returns 清理函数
|
|
*/
|
|
export function enableResize(
|
|
handleEl: HTMLElement,
|
|
windowEl: HTMLElement,
|
|
onResizeEnd?: (size: { width: number; height: number }) => void,
|
|
): () => void {
|
|
let resizing = false;
|
|
let startX = 0;
|
|
let startY = 0;
|
|
let startWidth = 0;
|
|
let startHeight = 0;
|
|
|
|
const onPointerDown = (clientX: number, clientY: number): void => {
|
|
resizing = true;
|
|
startX = clientX;
|
|
startY = clientY;
|
|
const rect = windowEl.getBoundingClientRect();
|
|
startWidth = rect.width;
|
|
startHeight = rect.height;
|
|
windowEl.classList.add('csk-window--resizing');
|
|
document.addEventListener('mousemove', onMouseMove);
|
|
document.addEventListener('mouseup', onMouseUp);
|
|
document.addEventListener('touchmove', onTouchMove, { passive: false });
|
|
document.addEventListener('touchend', onTouchEnd);
|
|
};
|
|
|
|
const onPointerMove = (clientX: number, clientY: number, e?: Event): void => {
|
|
if (!resizing) return;
|
|
if (e) e.preventDefault();
|
|
|
|
const dx = clientX - startX;
|
|
const dy = clientY - startY;
|
|
const newWidth = Math.max(MIN_WIDTH, Math.min(startWidth + dx, window.innerWidth - 24));
|
|
const newHeight = Math.max(MIN_HEIGHT, Math.min(startHeight + dy, window.innerHeight - 24));
|
|
|
|
windowEl.style.width = `${newWidth}px`;
|
|
windowEl.style.height = `${newHeight}px`;
|
|
// 同步更新 CSS 变量(供内部布局使用)
|
|
windowEl.style.setProperty('--csk-window-width', `${newWidth}px`);
|
|
windowEl.style.setProperty('--csk-window-height', `${newHeight}px`);
|
|
// 拖拽缩放后清除 bottom/right 定位,避免与固定定位冲突
|
|
windowEl.style.bottom = '';
|
|
windowEl.style.right = '';
|
|
};
|
|
|
|
const onPointerUp = (): void => {
|
|
if (!resizing) return;
|
|
resizing = false;
|
|
windowEl.classList.remove('csk-window--resizing');
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
|
|
const rect = windowEl.getBoundingClientRect();
|
|
if (onResizeEnd) {
|
|
onResizeEnd({ width: rect.width, height: rect.height });
|
|
}
|
|
};
|
|
|
|
// 鼠标事件(DOM 事件监听器包装)
|
|
function onMouseMove(e: MouseEvent): void { onPointerMove(e.clientX, e.clientY, e); }
|
|
function onMouseUp(): void { onPointerUp(); }
|
|
|
|
// 鼠标事件
|
|
function onMouseDown(e: MouseEvent): void {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
onPointerDown(e.clientX, e.clientY);
|
|
}
|
|
|
|
// 触摸事件
|
|
function onTouchStart(e: TouchEvent): void {
|
|
e.preventDefault();
|
|
e.stopPropagation();
|
|
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(); }
|
|
|
|
handleEl.addEventListener('mousedown', onMouseDown);
|
|
handleEl.addEventListener('touchstart', onTouchStart, { passive: false });
|
|
|
|
return () => {
|
|
handleEl.removeEventListener('mousedown', onMouseDown);
|
|
handleEl.removeEventListener('touchstart', onTouchStart);
|
|
document.removeEventListener('mousemove', onMouseMove);
|
|
document.removeEventListener('mouseup', onMouseUp);
|
|
document.removeEventListener('touchmove', onTouchMove);
|
|
document.removeEventListener('touchend', onTouchEnd);
|
|
};
|
|
}
|
|
|
|
// ==================== 消息渲染 ====================
|
|
|
|
/** 渲染用户消息气泡 */
|
|
/** 创建一个 t-chat-item 消息元素(TDesign Chat 消息气泡,实际标签为 t-chat-item) */
|
|
function createChatItemMsg(role: 'user' | 'assistant', content: unknown[], timestamp: number, msgId?: string): HTMLElement {
|
|
const msg = document.createElement('t-chat-item');
|
|
// omi 组件约定:复杂类型(Array/Object)用 property 赋值;简单类型(String/Boolean)用 setAttribute
|
|
msg.setAttribute('role', role);
|
|
msg.setAttribute('datetime', formatTime(timestamp));
|
|
if (msgId) msg.setAttribute('id', msgId);
|
|
(msg as unknown as { content: unknown[] }).content = content;
|
|
if (role === 'user') {
|
|
msg.setAttribute('placement', 'right');
|
|
msg.setAttribute('variant', 'base');
|
|
} else {
|
|
msg.setAttribute('placement', 'left');
|
|
msg.setAttribute('variant', 'text');
|
|
}
|
|
return msg;
|
|
}
|
|
|
|
export function renderUserBubble(container: HTMLElement, text: string, timestamp: number): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--user';
|
|
|
|
wrapper.appendChild(createChatItemMsg('user', [{ type: 'text', data: text }], timestamp));
|
|
|
|
container.appendChild(wrapper);
|
|
|
|
return wrapper;
|
|
}
|
|
|
|
/** 渲染错误气泡(发送失败提示,用 t-chat-item 的 error 状态承载) */
|
|
export function renderErrorBubble(container: HTMLElement, errMsg: string, timestamp: number): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai';
|
|
const msg = createChatItemMsg('assistant', [{ type: 'text', data: `⚠ ${errMsg}` }], timestamp);
|
|
msg.setAttribute('status', 'error');
|
|
wrapper.appendChild(msg);
|
|
container.appendChild(wrapper);
|
|
return wrapper;
|
|
}
|
|
|
|
/** 渲染 AI 消息气泡(Markdown 由 t-chat-item 内置 cherry-markdown 渲染) */
|
|
export function renderAIBubble(container: HTMLElement, text: string, timestamp: number, msgId?: string, feedback?: 'up' | 'down'): HTMLElement {
|
|
const wrapper = document.createElement('div');
|
|
wrapper.className = 'csk-msg csk-msg--ai';
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId;
|
|
|
|
const msg = createChatItemMsg('assistant', [{ type: 'markdown', data: text }], timestamp, msgId);
|
|
msg.setAttribute('status', 'complete');
|
|
// 操作栏投射到 t-chat-item 的 actionbar 插槽
|
|
msg.appendChild(createChatAction(wrapper, text, msgId, feedback));
|
|
wrapper.appendChild(msg);
|
|
|
|
container.appendChild(wrapper);
|
|
|
|
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';
|
|
if (msgId) wrapper.dataset.cskMsgId = msgId;
|
|
|
|
const msg = createChatItemMsg('assistant', [], timestamp, msgId);
|
|
msg.setAttribute('status', 'streaming');
|
|
// 操作栏投射到 t-chat-item 的 actionbar 插槽(流式期间 copyText 为空,结束由 finalizeAIBubble 补全)
|
|
msg.appendChild(createChatAction(wrapper, '', msgId));
|
|
wrapper.appendChild(msg);
|
|
|
|
container.appendChild(wrapper);
|
|
|
|
return { wrapper, bubble: msg };
|
|
}
|
|
|
|
// ==================== AI 消息操作条 + 代码块复制 ====================
|
|
|
|
/**
|
|
* 创建 AI 消息操作条(t-chat-action,投射到 t-chat-item 的 actionbar 插槽)
|
|
* 复制/重试/点赞/点踩均由 TDesign 组件承担;handleAction 回调负责分发 SDK 自定义事件
|
|
*/
|
|
function createChatAction(wrapper: HTMLElement, text: string, msgId?: string, feedback?: 'up' | 'down'): HTMLElement {
|
|
const actionEl = document.createElement('t-chat-action');
|
|
actionEl.setAttribute('slot', 'actionbar');
|
|
const el = actionEl as unknown as {
|
|
actionBar: string[];
|
|
handleAction: (action: string, data: { event?: Event; active?: boolean }) => void;
|
|
};
|
|
el.actionBar = ['replay', 'copy', 'good', 'bad'];
|
|
// copyText/comment 为 String 简单类型,须用 setAttribute(property 赋值在 omi 下静默失效)
|
|
actionEl.setAttribute('copyText', text);
|
|
actionEl.setAttribute('comment', feedback === 'up' ? 'good' : feedback === 'down' ? 'bad' : '');
|
|
el.handleAction = (action, data) => {
|
|
const root = wrapper.closest('.csk-window');
|
|
if (!root) return;
|
|
if (action === 'replay') {
|
|
root.dispatchEvent(new CustomEvent('csk:retry', { detail: { msgId: msgId || '' } }));
|
|
} else if (action === 'good' || action === 'bad') {
|
|
root.dispatchEvent(new CustomEvent('csk:feedback', {
|
|
detail: { msgId: msgId || '', value: action === 'good' ? 'up' : 'down', active: !!data?.active }
|
|
}));
|
|
}
|
|
// copy 由 t-chat-action 内部处理(用 copyText 调 navigator.clipboard),无需分发
|
|
};
|
|
return actionEl;
|
|
}
|
|
|
|
/**
|
|
* 回滚 t-chat-action 的反馈图标状态(重建组件为 comment='')。
|
|
* t-chat-action 的 comment 仅在首次挂载时生效,运行时无法外部更新,
|
|
* 点踩弹原因面板被用户关闭时,通过重建组件来恢复未选中状态。
|
|
*/
|
|
export function resetChatActionFeedback(wrapper: HTMLElement): void {
|
|
const msg = wrapper.querySelector('t-chat-item');
|
|
const actionEl = wrapper.querySelector('t-chat-action');
|
|
if (!msg || !actionEl) return;
|
|
const text = actionEl.getAttribute('copyText') || '';
|
|
const msgId = wrapper.dataset.cskMsgId;
|
|
actionEl.remove();
|
|
msg.appendChild(createChatAction(wrapper, text, msgId, undefined));
|
|
}
|
|
|
|
/** 标记 AI 气泡流式结束:切换 status 为 complete,并同步复制内容为最终 markdown 原文 */
|
|
export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): void {
|
|
bubble.setAttribute('status', 'complete');
|
|
// 流式期间 copyText 为空,结束后同步为最终原文(t-chat-action 的 copyText 支持运行时更新)
|
|
const contentArr = (bubble as unknown as { content?: Array<{ data?: string }> }).content;
|
|
const text = (contentArr?.map(c => c.data || '').join('') || '');
|
|
const actionEl = wrapper.querySelector('t-chat-action');
|
|
if (actionEl) actionEl.setAttribute('copyText', text);
|
|
}
|
|
|
|
// ==================== P1: RAG 引用来源渲染 ====================
|
|
|
|
/**
|
|
* 渲染 RAG 引用来源卡片(自定义折叠)
|
|
* 说明:尝试用 <t-collapse> 替换,但 tdesign-web-components 的 collapse/collapse-panel
|
|
* 依赖 omi 的 provide/inject 机制,在纯 DOM(createElement + appendChild)下无法建立
|
|
* 注入链路(报 `this.injection.getCollapseValue is not a function`),故保留轻量自定义折叠。
|
|
*/
|
|
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);
|
|
|
|
// 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方
|
|
const msgEl = wrapper.querySelector('t-chat-item');
|
|
if (msgEl && msgEl.parentNode) {
|
|
msgEl.parentNode.insertBefore(sourcesEl, msgEl.nextSibling);
|
|
} else {
|
|
wrapper.appendChild(sourcesEl);
|
|
}
|
|
}
|
|
|
|
// ==================== P2: 会话管理面板渲染 ====================
|
|
|
|
/** 会话列表项数据 */
|
|
export interface HistoryItemData {
|
|
id: string;
|
|
chatId?: string;
|
|
/** 会话所属角色 ID */
|
|
roleId?: number;
|
|
/** 会话所属角色名称 */
|
|
roleName?: 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<string, HistoryItemData[]> = {
|
|
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 = `
|
|
<div class="csk-history-panel__empty-icon">💬</div>
|
|
<div>${emptyText || t('history_empty')}</div>
|
|
`;
|
|
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.roleName) metaParts.push(item.roleName);
|
|
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);
|
|
}
|
|
} // 关闭 group 循环
|
|
}
|
|
|
|
/** 滚动消息区到底部 */
|
|
export function scrollToBottom(container: HTMLElement): void {
|
|
container.scrollTop = container.scrollHeight;
|
|
}
|
|
|
|
// ==================== 离线提示横幅 ====================
|
|
|
|
/** 在消息区顶部展示离线提示横幅(t-alert,theme=warning) */
|
|
export function showOfflineBanner(messagesContainer: HTMLElement): void {
|
|
if (document.getElementById('csk-offline-banner')) return;
|
|
const banner = document.createElement('t-alert');
|
|
banner.id = 'csk-offline-banner';
|
|
// message 为复杂类型(Object/Function 等),用 property;theme 为 String 简单类型,用 setAttribute
|
|
(banner as unknown as { message: string }).message = t('error_offline');
|
|
banner.setAttribute('theme', 'warning');
|
|
messagesContainer.insertBefore(banner, messagesContainer.firstChild);
|
|
}
|
|
|
|
/** 隐藏离线提示横幅 */
|
|
export function hideOfflineBanner(): void {
|
|
const el = document.getElementById('csk-offline-banner');
|
|
if (el) el.remove();
|
|
}
|
|
|
|
// ==================== 建议问题(suggest-message-list) ====================
|
|
|
|
/**
|
|
* 在 AI 消息气泡下方渲染推荐问题列表
|
|
* @param wrapper AI 消息的 wrapper 元素
|
|
* @param suggestions 推荐问题文本数组
|
|
* @param onClick 点击回调,传入问题文本
|
|
*/
|
|
export function renderSuggestions(
|
|
wrapper: HTMLElement,
|
|
suggestions: string[],
|
|
onClick: (text: string) => void
|
|
): void {
|
|
// 移除已有的建议区域
|
|
removeSuggestions(wrapper);
|
|
|
|
if (!suggestions || suggestions.length === 0) return;
|
|
|
|
const section = document.createElement('div');
|
|
section.className = 'csk-suggestions';
|
|
|
|
const list = document.createElement('div');
|
|
list.className = 'csk-suggestions__list';
|
|
|
|
for (const text of suggestions) {
|
|
// on* 回调(onClick)必须经 omi render 创建;tag 的 onClick 首参为原生事件,文本经 children 传入
|
|
createEventful('t-tag', {
|
|
theme: 'primary',
|
|
variant: 'light-outline',
|
|
onClick: (e: Event) => {
|
|
e.stopPropagation();
|
|
onClick(text);
|
|
},
|
|
}, list, text);
|
|
}
|
|
|
|
section.appendChild(list);
|
|
// 直接追加到 wrapper 末尾(wrapper 内即 t-chat-item,无 .csk-msg__content 中间层)
|
|
wrapper.appendChild(section);
|
|
}
|
|
|
|
/**
|
|
* 移除指定 wrapper 中的建议问题列表
|
|
*/
|
|
export function removeSuggestions(wrapper: HTMLElement): void {
|
|
const existing = wrapper.querySelector('.csk-suggestions');
|
|
if (existing) existing.remove();
|
|
}
|
|
|
|
// ==================== 确认弹窗 + 反馈原因选择 ====================
|
|
|
|
/** 点踩原因选项(与后端 MessageFeedback.reasonCategory 枚举一致) */
|
|
const REASON_OPTIONS: { key: string; labelKey: string }[] = [
|
|
{ key: 'inaccurate', labelKey: 'feedback_reason_inaccurate' },
|
|
{ key: 'irrelevant', labelKey: 'feedback_reason_irrelevant' },
|
|
{ key: 'incomplete', labelKey: 'feedback_reason_incomplete' },
|
|
{ key: 'other', labelKey: 'feedback_reason_other' },
|
|
];
|
|
|
|
/**
|
|
* 命令式确认弹窗(DialogPlugin.confirm),替代原生 confirm()
|
|
* @returns 确认返回 true,取消/关闭返回 false
|
|
*/
|
|
export function confirmWithDialog(message: string): Promise<boolean> {
|
|
return new Promise((resolve) => {
|
|
let settled = false;
|
|
const finish = (val: boolean): void => {
|
|
if (settled) return;
|
|
settled = true;
|
|
resolve(val);
|
|
};
|
|
const dlg = DialogPlugin.confirm({
|
|
header: t('confirm'),
|
|
body: message,
|
|
confirmBtn: t('confirm'),
|
|
cancelBtn: t('cancel'),
|
|
onConfirm: () => {
|
|
finish(true);
|
|
dlg.destroy();
|
|
},
|
|
onCancel: () => {
|
|
finish(false);
|
|
dlg.destroy();
|
|
},
|
|
onClose: () => {
|
|
// ESC / 遮罩点击 / 关闭按钮兜底
|
|
finish(false);
|
|
dlg.destroy();
|
|
},
|
|
});
|
|
});
|
|
}
|
|
|
|
/**
|
|
* 在消息气泡下方展示点踩原因选择面板(内联,按钮用 t-button)
|
|
* 说明:尝试用 <t-dialog> 改为模态弹窗,但 dialog 的 visible/confirmBtn/cancelBtn 属性
|
|
* 未在 omi propTypes 声明,纯 DOM property 赋值不生效(渲染为 display:none),且复杂
|
|
* body 无法通过 DialogPlugin 注入,故保留内联面板形态,仅将按钮升级为 t-button。
|
|
*/
|
|
export function showFeedbackReasonPanel(
|
|
wrapper: HTMLElement,
|
|
_msgId: string,
|
|
onConfirm: (reason: 'inaccurate' | 'irrelevant' | 'incomplete' | 'other', comment?: string) => void,
|
|
onCancel?: () => void,
|
|
): void {
|
|
// 移除已存在的原因面板
|
|
const existing = wrapper.querySelector('.csk-feedback-reason');
|
|
if (existing) existing.remove();
|
|
|
|
const panel = document.createElement('div');
|
|
panel.className = 'csk-feedback-reason';
|
|
|
|
// 标题
|
|
const title = document.createElement('div');
|
|
title.className = 'csk-feedback-reason__title';
|
|
title.textContent = t('feedback_reason_title');
|
|
panel.appendChild(title);
|
|
|
|
// 原因选项按钮(t-button,点击即确认)
|
|
const optionsRow = document.createElement('div');
|
|
optionsRow.className = 'csk-feedback-reason__options';
|
|
REASON_OPTIONS.forEach((opt) => {
|
|
const btn = document.createElement('t-button');
|
|
// t-button 文本通过 <slot> 渲染(light DOM children),须用 textContent 而非 content 属性
|
|
btn.textContent = t(opt.labelKey);
|
|
btn.setAttribute('theme', 'default');
|
|
btn.setAttribute('variant', 'outline');
|
|
// button 的 click 经 eventDispose → fire 派发 CustomEvent('click'),用 addEventListener 监听
|
|
btn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
onConfirm(opt.key as 'inaccurate' | 'irrelevant' | 'incomplete' | 'other');
|
|
panel.remove();
|
|
});
|
|
optionsRow.appendChild(btn);
|
|
});
|
|
panel.appendChild(optionsRow);
|
|
|
|
// 补充说明输入框(可选)
|
|
const commentRow = document.createElement('div');
|
|
commentRow.className = 'csk-feedback-reason__comment-row';
|
|
const commentInput = document.createElement('input');
|
|
commentInput.type = 'text';
|
|
commentInput.className = 'csk-feedback-reason__comment';
|
|
commentInput.placeholder = t('feedback_reason_comment_placeholder');
|
|
commentInput.maxLength = 200;
|
|
commentRow.appendChild(commentInput);
|
|
|
|
const submitBtn = document.createElement('t-button');
|
|
submitBtn.textContent = t('feedback_reason_submit');
|
|
submitBtn.setAttribute('theme', 'primary');
|
|
submitBtn.setAttribute('variant', 'base');
|
|
submitBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
const comment = commentInput.value.trim() || undefined;
|
|
onConfirm('other', comment);
|
|
panel.remove();
|
|
});
|
|
commentRow.appendChild(submitBtn);
|
|
panel.appendChild(commentRow);
|
|
|
|
// 关闭按钮(右上角,轻量原生 button 承载 X 图标)
|
|
const closeBtn = document.createElement('button');
|
|
closeBtn.type = 'button';
|
|
closeBtn.className = 'csk-feedback-reason__close';
|
|
closeBtn.setAttribute('aria-label', t('close'));
|
|
closeBtn.innerHTML = `<svg xmlns="http://www.w3.org/2000/svg" width="12" height="12" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><line x1="18" y1="6" x2="6" y2="18"/><line x1="6" y1="6" x2="18" y2="18"/></svg>`;
|
|
closeBtn.addEventListener('click', (e) => {
|
|
e.stopPropagation();
|
|
panel.remove();
|
|
if (onCancel) onCancel();
|
|
});
|
|
panel.appendChild(closeBtn);
|
|
|
|
wrapper.appendChild(panel);
|
|
}
|