|
|
@ -6,7 +6,7 @@ |
|
|
* userId → accountId(客户账号 ID) |
|
|
* userId → accountId(客户账号 ID) |
|
|
* chatId → 自动管理(从 /conversation/list 获取或自动生成) |
|
|
* chatId → 自动管理(从 /conversation/list 获取或自动生成) |
|
|
*/ |
|
|
*/ |
|
|
import { ResolvedConfig, ChatMessage, RagSource } from './types'; |
|
|
|
|
|
|
|
|
import { ResolvedConfig, ChatMessage, RagSource, ImageAttachment } from './types'; |
|
|
import { |
|
|
import { |
|
|
chatRequest, |
|
|
chatRequest, |
|
|
chatSSERequest, |
|
|
chatSSERequest, |
|
|
@ -26,6 +26,7 @@ import { |
|
|
getActiveIntegrateId, |
|
|
getActiveIntegrateId, |
|
|
CskError, |
|
|
CskError, |
|
|
fetchSuggestions, |
|
|
fetchSuggestions, |
|
|
|
|
|
uploadAttachment, |
|
|
} from './api'; |
|
|
} from './api'; |
|
|
import { |
|
|
import { |
|
|
renderUserBubble, |
|
|
renderUserBubble, |
|
|
@ -83,6 +84,9 @@ let currentCategoryId: number | undefined; |
|
|
/** 当前是否使用 RAG 对话 */ |
|
|
/** 当前是否使用 RAG 对话 */ |
|
|
let useRag = false; |
|
|
let useRag = false; |
|
|
|
|
|
|
|
|
|
|
|
/** 待发送的图片列表(与 t-chat-sender 的 attachmentsProps.items 同步) */ |
|
|
|
|
|
let pendingImages: ImageAttachment[] = []; |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 初始化对话模块 |
|
|
* 初始化对话模块 |
|
|
*/ |
|
|
*/ |
|
|
@ -195,12 +199,20 @@ async function loadHistoryFromBackend(): Promise<void> { |
|
|
function bindSendEvents(): void { |
|
|
function bindSendEvents(): void { |
|
|
if (!inputEl) return; |
|
|
if (!inputEl) return; |
|
|
|
|
|
|
|
|
// t-chat-sender:send 事件(点击发送/回车)携带 value;stop 事件(loading 态点击)中断流式
|
|
|
|
|
|
|
|
|
// t-chat-sender:send 事件(点击发送/回车)携带 value 与 attachments;stop 事件(loading 态点击)中断流式
|
|
|
inputEl.addEventListener('send', (e) => { |
|
|
inputEl.addEventListener('send', (e) => { |
|
|
const detail = (e as CustomEvent).detail as { value?: string } | undefined; |
|
|
|
|
|
|
|
|
const detail = (e as CustomEvent).detail as { |
|
|
|
|
|
value?: string; |
|
|
|
|
|
attachments?: Array<{ name?: string; url?: string; fileType?: string; size?: number }>; |
|
|
|
|
|
} | undefined; |
|
|
const value = (detail?.value || '').trim(); |
|
|
const value = (detail?.value || '').trim(); |
|
|
if (!value || isSending) return; |
|
|
|
|
|
handleSend(value); |
|
|
|
|
|
|
|
|
// 从附件中提取图片(本次仅图片参与多模态对话)
|
|
|
|
|
|
const images: ImageAttachment[] = (detail?.attachments || []) |
|
|
|
|
|
.filter(a => a.fileType === 'image' && a.url) |
|
|
|
|
|
.map(a => ({ name: a.name || '', url: a.url as string, size: a.size })); |
|
|
|
|
|
|
|
|
|
|
|
if ((!value && images.length === 0) || isSending) return; |
|
|
|
|
|
handleSend(value, images); |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
inputEl.addEventListener('stop', () => { |
|
|
inputEl.addEventListener('stop', () => { |
|
|
@ -209,11 +221,61 @@ function bindSendEvents(): void { |
|
|
} |
|
|
} |
|
|
}); |
|
|
}); |
|
|
|
|
|
|
|
|
|
|
|
// t-chat-sender:fileSelect 事件(选择文件后触发,携带原始 File[])
|
|
|
|
|
|
inputEl.addEventListener('fileSelect', (e) => { |
|
|
|
|
|
const files = (e as CustomEvent<File[]>).detail; |
|
|
|
|
|
if (!files || files.length === 0) return; |
|
|
|
|
|
handleFileSelect(files); |
|
|
|
|
|
}); |
|
|
|
|
|
|
|
|
if (clearBtn) { |
|
|
if (clearBtn) { |
|
|
clearBtn.addEventListener('click', () => handleClear()); |
|
|
clearBtn.addEventListener('click', () => handleClear()); |
|
|
} |
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 处理文件选择:逐个上传到 /attachment/upload,成功后同步到输入区附件预览 |
|
|
|
|
|
*/ |
|
|
|
|
|
async function handleFileSelect(files: File[]): Promise<void> { |
|
|
|
|
|
if (!config) return; |
|
|
|
|
|
|
|
|
|
|
|
for (const file of files) { |
|
|
|
|
|
try { |
|
|
|
|
|
const att = await uploadAttachment(file); |
|
|
|
|
|
pendingImages.push(att); |
|
|
|
|
|
} catch (err) { |
|
|
|
|
|
const msg = err instanceof CskError ? err.message : t('error_upload_failed'); |
|
|
|
|
|
if (messagesContainer) renderErrorBubble(messagesContainer, msg, now()); |
|
|
|
|
|
logger.error('图片上传失败', err); |
|
|
|
|
|
} |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
syncSenderAttachments(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** |
|
|
|
|
|
* 把 pendingImages 同步到 t-chat-sender 的 attachmentsProps.items, |
|
|
|
|
|
* 由 TDesign Chat 组件原生渲染附件预览。 |
|
|
|
|
|
*/ |
|
|
|
|
|
function syncSenderAttachments(): void { |
|
|
|
|
|
if (!inputEl) return; |
|
|
|
|
|
const items = pendingImages.map(img => ({ |
|
|
|
|
|
name: img.name, |
|
|
|
|
|
url: img.url, |
|
|
|
|
|
fileType: 'image' as const, |
|
|
|
|
|
size: img.size, |
|
|
|
|
|
})); |
|
|
|
|
|
(inputEl as unknown as { |
|
|
|
|
|
attachmentsProps: { items: typeof items; overflow: string }; |
|
|
|
|
|
}).attachmentsProps = { items, overflow: 'scrollX' }; |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
/** 清空待发送图片(发送成功后、清空会话、切换角色/会话时调用) */ |
|
|
|
|
|
function clearPendingImages(): void { |
|
|
|
|
|
pendingImages = []; |
|
|
|
|
|
syncSenderAttachments(); |
|
|
|
|
|
} |
|
|
|
|
|
|
|
|
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */ |
|
|
/** 绑定滚动监听:判断是否在底部,控制新消息提示按钮 */ |
|
|
function bindScrollEvents(): void { |
|
|
function bindScrollEvents(): void { |
|
|
if (!messagesContainer) return; |
|
|
if (!messagesContainer) return; |
|
|
@ -404,16 +466,20 @@ function updateEmptyState(): void { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */ |
|
|
/** 处理发送消息(text 来自 t-chat-sender 的 send 事件,或快捷问题/重试的显式调用) */ |
|
|
async function handleSend(text?: string): Promise<void> { |
|
|
|
|
|
|
|
|
async function handleSend(text?: string, images?: ImageAttachment[]): Promise<void> { |
|
|
if (!config || isSending) return; |
|
|
if (!config || isSending) return; |
|
|
|
|
|
|
|
|
const input = (text ?? '').trim(); |
|
|
const input = (text ?? '').trim(); |
|
|
if (input === '') return; |
|
|
|
|
|
|
|
|
const atts = images && images.length > 0 ? images : [...pendingImages]; |
|
|
|
|
|
if (input === '' && atts.length === 0) return; |
|
|
|
|
|
|
|
|
|
|
|
// 清空待发送图片(快照已保存到 atts,发送失败时恢复)
|
|
|
|
|
|
clearPendingImages(); |
|
|
|
|
|
|
|
|
// 1. 渲染用户气泡
|
|
|
// 1. 渲染用户气泡
|
|
|
const userTimestamp = now(); |
|
|
const userTimestamp = now(); |
|
|
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp }; |
|
|
|
|
|
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp); |
|
|
|
|
|
|
|
|
const userMsg: ChatMessage = { id: uuid(), role: 'user', content: input, timestamp: userTimestamp, images: atts }; |
|
|
|
|
|
if (messagesContainer) renderUserBubble(messagesContainer, input, userTimestamp, atts); |
|
|
messages.push(userMsg); |
|
|
messages.push(userMsg); |
|
|
|
|
|
|
|
|
updateEmptyState(); |
|
|
updateEmptyState(); |
|
|
@ -421,15 +487,21 @@ async function handleSend(text?: string): Promise<void> { |
|
|
if (messagesContainer) smartScrollToBottom(); |
|
|
if (messagesContainer) smartScrollToBottom(); |
|
|
|
|
|
|
|
|
// 2. 生成 AI 回复
|
|
|
// 2. 生成 AI 回复
|
|
|
await produceAIReply(input); |
|
|
|
|
|
|
|
|
const ok = await produceAIReply(input, atts); |
|
|
|
|
|
// 发送失败时恢复待发送图片,方便用户重试
|
|
|
|
|
|
if (!ok && atts.length > 0) { |
|
|
|
|
|
pendingImages = atts; |
|
|
|
|
|
syncSenderAttachments(); |
|
|
|
|
|
} |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** |
|
|
/** |
|
|
* 生成 AI 回复(发送请求 + 渲染气泡 + 持久化) |
|
|
* 生成 AI 回复(发送请求 + 渲染气泡 + 持久化) |
|
|
* 复用于:正常发送、重试。调用方负责先渲染用户气泡。 |
|
|
* 复用于:正常发送、重试。调用方负责先渲染用户气泡。 |
|
|
|
|
|
* @returns 是否发送成功(false 时调用方可恢复待发送图片) |
|
|
*/ |
|
|
*/ |
|
|
async function produceAIReply(userText: string): Promise<void> { |
|
|
|
|
|
if (!config || !messagesContainer) return; |
|
|
|
|
|
|
|
|
async function produceAIReply(userText: string, images?: ImageAttachment[]): Promise<boolean> { |
|
|
|
|
|
if (!config || !messagesContainer) return false; |
|
|
|
|
|
|
|
|
isSending = true; |
|
|
isSending = true; |
|
|
setSendButtonMode('stop'); |
|
|
setSendButtonMode('stop'); |
|
|
@ -439,6 +511,9 @@ async function produceAIReply(userText: string): Promise<void> { |
|
|
await initChatId(); |
|
|
await initChatId(); |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
|
|
|
// 提取图片 URL,仅图片参与多模态对话
|
|
|
|
|
|
const imageUrls = (images || []).map(img => img.url); |
|
|
|
|
|
|
|
|
const aiTimestamp = now(); |
|
|
const aiTimestamp = now(); |
|
|
// RAG 启用条件:由 enableRag 控制
|
|
|
// RAG 启用条件:由 enableRag 控制
|
|
|
const shouldUseRag = useRag; |
|
|
const shouldUseRag = useRag; |
|
|
@ -452,9 +527,9 @@ async function produceAIReply(userText: string): Promise<void> { |
|
|
|
|
|
|
|
|
try { |
|
|
try { |
|
|
if (config.streaming) { |
|
|
if (config.streaming) { |
|
|
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId); |
|
|
|
|
|
|
|
|
aiContent = await sendStreamMessage(userText, aiTimestamp, shouldUseRag, aiMsgId, imageUrls); |
|
|
} else { |
|
|
} else { |
|
|
aiContent = await chatRequest(userText); |
|
|
|
|
|
|
|
|
aiContent = await chatRequest(userText, imageUrls); |
|
|
if (hideLoadingFn) hideLoadingFn(); |
|
|
if (hideLoadingFn) hideLoadingFn(); |
|
|
if (messagesContainer) { |
|
|
if (messagesContainer) { |
|
|
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId); |
|
|
renderAIBubble(messagesContainer, aiContent, aiTimestamp, aiMsgId); |
|
|
@ -484,6 +559,7 @@ async function produceAIReply(userText: string): Promise<void> { |
|
|
|
|
|
|
|
|
// 发送成功后清除离线横幅(网络已恢复)
|
|
|
// 发送成功后清除离线横幅(网络已恢复)
|
|
|
hideOfflineBanner(); |
|
|
hideOfflineBanner(); |
|
|
|
|
|
return true; |
|
|
} catch (err) { |
|
|
} catch (err) { |
|
|
if (hideLoadingFn) hideLoadingFn(); |
|
|
if (hideLoadingFn) hideLoadingFn(); |
|
|
|
|
|
|
|
|
@ -492,6 +568,7 @@ async function produceAIReply(userText: string): Promise<void> { |
|
|
renderErrorBubble(messagesContainer, errMsg, now()); |
|
|
renderErrorBubble(messagesContainer, errMsg, now()); |
|
|
} |
|
|
} |
|
|
logger.error(`发送失败 integrateId=${config.integrateId}`, err); |
|
|
logger.error(`发送失败 integrateId=${config.integrateId}`, err); |
|
|
|
|
|
return false; |
|
|
} finally { |
|
|
} finally { |
|
|
isSending = false; |
|
|
isSending = false; |
|
|
abortController = null; |
|
|
abortController = null; |
|
|
@ -500,7 +577,7 @@ async function produceAIReply(userText: string): Promise<void> { |
|
|
} |
|
|
} |
|
|
|
|
|
|
|
|
/** 流式发送消息 */ |
|
|
/** 流式发送消息 */ |
|
|
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string): Promise<string> { |
|
|
|
|
|
|
|
|
async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag: boolean, aiMsgId: string, imageUrls?: string[]): Promise<string> { |
|
|
// 创建中断控制器,供"停止生成"使用
|
|
|
// 创建中断控制器,供"停止生成"使用
|
|
|
abortController = new AbortController(); |
|
|
abortController = new AbortController(); |
|
|
const signal = abortController.signal; |
|
|
const signal = abortController.signal; |
|
|
@ -535,7 +612,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag |
|
|
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
|
|
|
// 无流内容降级为同步请求(须在 wrapperEl/bubbleEl 判断之外:
|
|
|
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
|
|
|
// 二者仅在 onChunk 收到首个 token 时才赋值,否则此分支不可达)
|
|
|
if (!streamStarted && accumulated === '') { |
|
|
if (!streamStarted && accumulated === '') { |
|
|
chatRequest(text).then(resolve).catch(reject); |
|
|
|
|
|
|
|
|
chatRequest(text, imageUrls).then(resolve).catch(reject); |
|
|
return; |
|
|
return; |
|
|
} |
|
|
} |
|
|
if (wrapperEl && bubbleEl) { |
|
|
if (wrapperEl && bubbleEl) { |
|
|
@ -563,6 +640,7 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag |
|
|
}, |
|
|
}, |
|
|
currentCategoryId, |
|
|
currentCategoryId, |
|
|
shouldUseRag, |
|
|
shouldUseRag, |
|
|
|
|
|
imageUrls, |
|
|
signal |
|
|
signal |
|
|
); |
|
|
); |
|
|
}); |
|
|
}); |
|
|
@ -597,7 +675,8 @@ export async function retryFromMessage(msgId: string): Promise<void> { |
|
|
} |
|
|
} |
|
|
if (userIndex < 0) return; |
|
|
if (userIndex < 0) return; |
|
|
const userText = messages[userIndex].content; |
|
|
const userText = messages[userIndex].content; |
|
|
if (!userText) return; |
|
|
|
|
|
|
|
|
const userImages = messages[userIndex].images || []; |
|
|
|
|
|
if (!userText && userImages.length === 0) return; |
|
|
|
|
|
|
|
|
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
|
|
|
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
|
|
|
let userTurn = 0; |
|
|
let userTurn = 0; |
|
|
@ -623,7 +702,7 @@ export async function retryFromMessage(msgId: string): Promise<void> { |
|
|
renderHistory(); |
|
|
renderHistory(); |
|
|
|
|
|
|
|
|
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
|
|
|
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
|
|
|
await handleSend(userText); |
|
|
|
|
|
|
|
|
await handleSend(userText, userImages); |
|
|
} finally { |
|
|
} finally { |
|
|
isRetrying = false; |
|
|
isRetrying = false; |
|
|
} |
|
|
} |
|
|
@ -694,7 +773,7 @@ function renderHistory(): void { |
|
|
|
|
|
|
|
|
for (const msg of messages) { |
|
|
for (const msg of messages) { |
|
|
if (msg.role === 'user') { |
|
|
if (msg.role === 'user') { |
|
|
renderUserBubble(messagesContainer, msg.content, msg.timestamp); |
|
|
|
|
|
|
|
|
renderUserBubble(messagesContainer, msg.content, msg.timestamp, msg.images); |
|
|
} else { |
|
|
} else { |
|
|
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, msg.id, msg.feedback); |
|
|
const wrapper = renderAIBubble(messagesContainer, msg.content, msg.timestamp, msg.id, msg.feedback); |
|
|
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources); |
|
|
if (msg.sources && msg.sources.length > 0) renderSources(wrapper, msg.sources); |
|
|
@ -731,6 +810,7 @@ function handleClear(): void { |
|
|
if (clearBtn) clearBtn.style.display = 'none'; |
|
|
if (clearBtn) clearBtn.style.display = 'none'; |
|
|
updateEmptyState(); |
|
|
updateEmptyState(); |
|
|
clearMessages(config.integrateId); |
|
|
clearMessages(config.integrateId); |
|
|
|
|
|
clearPendingImages(); |
|
|
|
|
|
|
|
|
// 生成新的 chatId,开始新会话
|
|
|
// 生成新的 chatId,开始新会话
|
|
|
const newId = generateNewChatId(); |
|
|
const newId = generateNewChatId(); |
|
|
@ -789,6 +869,7 @@ export async function switchRole(newRoleId: string): Promise<void> { |
|
|
messages = []; |
|
|
messages = []; |
|
|
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); |
|
|
const msgNodes = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); |
|
|
msgNodes.forEach(el => el.remove()); |
|
|
msgNodes.forEach(el => el.remove()); |
|
|
|
|
|
clearPendingImages(); |
|
|
|
|
|
|
|
|
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
|
|
|
// 5. 清空新旧角色的 localStorage 缓存(消息 + chatId),必须在 setActiveRoleId 之前
|
|
|
clearMessages(oldRoleId); |
|
|
clearMessages(oldRoleId); |
|
|
@ -928,6 +1009,7 @@ async function switchToConversation(conversationId: string): Promise<void> { |
|
|
messages = []; |
|
|
messages = []; |
|
|
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); |
|
|
const msgs = messagesContainer.querySelectorAll('.csk-msg, .csk-loading'); |
|
|
msgs.forEach(el => el.remove()); |
|
|
msgs.forEach(el => el.remove()); |
|
|
|
|
|
clearPendingImages(); |
|
|
|
|
|
|
|
|
// 4. 从后端加载该会话的消息
|
|
|
// 4. 从后端加载该会话的消息
|
|
|
try { |
|
|
try { |
|
|
|