|
|
|
@ -16,6 +16,7 @@ import { |
|
|
|
fetchConversationMessages, |
|
|
|
deleteConversation, |
|
|
|
getConversationExportUrl, |
|
|
|
truncateConversation, |
|
|
|
initChatId, |
|
|
|
updateChatId, |
|
|
|
getChatId, |
|
|
|
@ -64,6 +65,8 @@ let ariaLiveEl: HTMLElement | null = null; |
|
|
|
let showLoadingFn: (() => HTMLElement) | null = null; |
|
|
|
let hideLoadingFn: (() => void) | null = null; |
|
|
|
let isSending = false; |
|
|
|
/** 是否正在「重新生成」中(独立于 isSending,覆盖截断后端会话的异步窗口,防止重复触发) */ |
|
|
|
let isRetrying = false; |
|
|
|
|
|
|
|
/** 当前流式请求的中断控制器(用于"停止生成") */ |
|
|
|
let abortController: AbortController | null = null; |
|
|
|
@ -576,36 +579,55 @@ export function sendQuickReply(text: string): Promise<void> { |
|
|
|
|
|
|
|
/** |
|
|
|
* 重试:根据 AI 消息 ID 重新生成该回复 |
|
|
|
* 找到该 AI 消息及其上一条用户消息,移除该 AI 消息(及之后的所有消息)后重新请求 |
|
|
|
* 采用「从该轮重新开始」语义(与后台管理端 ChatPanel 一致): |
|
|
|
* 定位该 AI 回复所属的那轮用户提问,先调用后端截断接口删除该提问及其后的全部消息, |
|
|
|
* 再本地截断到该提问之前、重新渲染历史,最后重新发送该提问。 |
|
|
|
* 这样不会只删前端而留下后端旧消息,避免刷新后旧回复"复活"导致的数据错乱。 |
|
|
|
*/ |
|
|
|
export async function retryFromMessage(msgId: string): Promise<void> { |
|
|
|
if (!config || !messagesContainer || isSending) return; |
|
|
|
if (!config || !messagesContainer || isSending || isRetrying) return; |
|
|
|
isRetrying = true; |
|
|
|
try { |
|
|
|
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai'); |
|
|
|
if (aiIndex < 0) return; |
|
|
|
|
|
|
|
const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai'); |
|
|
|
if (aiIndex < 0) return; |
|
|
|
// 找到该 AI 回复所属的那轮用户提问
|
|
|
|
let userIndex = -1; |
|
|
|
for (let i = aiIndex - 1; i >= 0; i--) { |
|
|
|
if (messages[i].role === 'user') { userIndex = i; break; } |
|
|
|
} |
|
|
|
if (userIndex < 0) return; |
|
|
|
const userText = messages[userIndex].content; |
|
|
|
if (!userText) return; |
|
|
|
|
|
|
|
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
|
|
|
|
let userTurn = 0; |
|
|
|
for (let i = 0; i <= userIndex; i++) { |
|
|
|
if (messages[i].role === 'user') userTurn++; |
|
|
|
} |
|
|
|
|
|
|
|
// 找到上一条用户消息文本
|
|
|
|
let userText = ''; |
|
|
|
for (let i = aiIndex - 1; i >= 0; i--) { |
|
|
|
if (messages[i].role === 'user') { userText = messages[i].content; break; } |
|
|
|
} |
|
|
|
if (!userText) return; |
|
|
|
|
|
|
|
// 移除该 AI 消息及其后所有消息(本地 + DOM)
|
|
|
|
const removed = messages.splice(aiIndex); |
|
|
|
const removedIds = new Set(removed.map(m => m.id)); |
|
|
|
const nodes = messagesContainer.querySelectorAll('.csk-msg'); |
|
|
|
nodes.forEach(node => { |
|
|
|
const id = (node as HTMLElement).dataset.cskMsgId; |
|
|
|
// 无 id 的(如错误气泡、被中断的)按顺序兜底:此处仅按 id 精确移除
|
|
|
|
if (id && removedIds.has(id)) node.remove(); |
|
|
|
}); |
|
|
|
// 1. 先截断后端会话(删除该轮提问及其之后的所有消息),失败则不触碰本地数据
|
|
|
|
const chatId = getChatId(); |
|
|
|
if (chatId) { |
|
|
|
const ok = await truncateConversation(chatId, userTurn); |
|
|
|
if (!ok) { |
|
|
|
logger.error(`重新生成失败:后端会话截断失败 chatId=${chatId} userTurn=${userTurn}`); |
|
|
|
return; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
saveMessages(config.integrateId, messages); |
|
|
|
updateEmptyState(); |
|
|
|
// 2. 本地截断到该轮提问之前(删除该轮提问、被点击的回复及其后的所有消息)
|
|
|
|
messages = messages.slice(0, userIndex); |
|
|
|
saveMessages(config.integrateId, messages); |
|
|
|
|
|
|
|
// 重新生成(不重复渲染用户气泡)
|
|
|
|
await produceAIReply(userText); |
|
|
|
// 3. 重新渲染历史(清除 DOM 中被删除的消息),保证界面与数据一致
|
|
|
|
renderHistory(); |
|
|
|
|
|
|
|
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
|
|
|
|
await handleSend(userText); |
|
|
|
} finally { |
|
|
|
isRetrying = false; |
|
|
|
} |
|
|
|
} |
|
|
|
|
|
|
|
/** 获取并渲染 RAG 引用来源 */ |
|
|
|
@ -678,7 +700,15 @@ function renderHistory(): void { |
|
|
|
} |
|
|
|
|
|
|
|
isNearBottom = true; |
|
|
|
scrollToBottom(messagesContainer); |
|
|
|
// 延迟滚动到底部:t-chat-item 的 shadow DOM(含 cherry-markdown)异步渲染,
|
|
|
|
// 若同步设置 scrollTop,此时 scrollHeight 尚未更新到最终值,历史会话会停在滚动条中间。
|
|
|
|
// 用双 rAF 等待组件渲染与布局稳定后再滚到底部。
|
|
|
|
const container = messagesContainer; |
|
|
|
requestAnimationFrame(() => { |
|
|
|
requestAnimationFrame(() => { |
|
|
|
if (container && container.isConnected) scrollToBottom(container); |
|
|
|
}); |
|
|
|
}); |
|
|
|
if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex'; |
|
|
|
updateEmptyState(); |
|
|
|
|
|
|
|
|