diff --git a/client/src/api.ts b/client/src/api.ts index adb7743..1232d60 100644 --- a/client/src/api.ts +++ b/client/src/api.ts @@ -652,6 +652,40 @@ export function getConversationExportUrl(conversationId: string): string { return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); } +/** + * 截断会话(SDK 安全端点) + * 删除「第 userTurn 条用户消息」及其之后的全部消息,用于「重新生成」前同步后端数据, + * 保证重发时 DatabaseChatMemory 取到的历史与前端一致、不被旧回复污染。 + */ +export async function truncateConversation(conversationId: string, userTurn: number): Promise { + if (!currentConfig) return false; + const url = buildUrl(`/ai/sdk/conversation/${conversationId}/truncate`); + try { + const body: Record = { userTurn }; + if (currentConfig.userId) body.accountId = currentConfig.userId; + // 后端按 JSON 数字解析 roleId,须转 Number 传入(空串/非数字不传,避免 0 被误判) + const roleId = getActiveIntegrateId(); + if (roleId) { + const num = Number(roleId); + if (Number.isFinite(num) && num > 0) body.roleId = num; + } + const response = await safeFetch(url, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(body), + }); + if (!response.ok) { + logger.error(`会话截断失败 status=${response.status}`); + return false; + } + const json: ApiResponse = await response.json(); + return json.success === true; + } catch (err) { + logger.error('会话截断异常', err); + return false; + } +} + // ==================== chatId 自动初始化 ==================== /** diff --git a/client/src/chat.ts b/client/src/chat.ts index 41b9902..c848bfc 100644 --- a/client/src/chat.ts +++ b/client/src/chat.ts @@ -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 { /** * 重试:根据 AI 消息 ID 重新生成该回复 - * 找到该 AI 消息及其上一条用户消息,移除该 AI 消息(及之后的所有消息)后重新请求 + * 采用「从该轮重新开始」语义(与后台管理端 ChatPanel 一致): + * 定位该 AI 回复所属的那轮用户提问,先调用后端截断接口删除该提问及其后的全部消息, + * 再本地截断到该提问之前、重新渲染历史,最后重新发送该提问。 + * 这样不会只删前端而留下后端旧消息,避免刷新后旧回复"复活"导致的数据错乱。 */ export async function retryFromMessage(msgId: string): Promise { - 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(); diff --git a/client/src/dom.ts b/client/src/dom.ts index e380c80..0ae5860 100644 --- a/client/src/dom.ts +++ b/client/src/dom.ts @@ -963,10 +963,11 @@ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): voi // ==================== P1: RAG 引用来源渲染 ==================== /** - * 渲染 RAG 引用来源卡片(自定义折叠) + * 渲染 RAG 引用来源卡片(t-button 折叠开关 + 自定义来源列表) * 说明:尝试用 替换,但 tdesign-web-components 的 collapse/collapse-panel * 依赖 omi 的 provide/inject 机制,在纯 DOM(createElement + appendChild)下无法建立 - * 注入链路(报 `this.injection.getCollapseValue is not a function`),故保留轻量自定义折叠。 + * 注入链路(报 `this.injection.getCollapseValue is not a function`),故折叠开关改用 + * t-button(标准组件、纯 DOM 可用),展开后的来源条目保留自定义渲染。 */ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void { // 移除已有的来源卡片 @@ -978,24 +979,22 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void 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 = '▼'; + // 折叠开关:t-button(标准组件,紧凑宽度,默认折叠) + const toggle = document.createElement('t-button'); + toggle.className = 'csk-sources__toggle'; + toggle.setAttribute('theme', 'default'); + toggle.setAttribute('variant', 'text'); + toggle.setAttribute('size', 'small'); - header.appendChild(titleSpan); - header.appendChild(arrow); - - // 点击折叠/展开 - header.addEventListener('click', () => { + const updateToggleLabel = () => { + const collapsed = sourcesEl.classList.contains('csk-sources--collapsed'); + toggle.textContent = `📚 ${t('source_count', { n: sources.length })} ${collapsed ? '▶' : '▼'}`; + }; + updateToggleLabel(); + toggle.addEventListener('click', (e) => { + e.stopPropagation(); sourcesEl.classList.toggle('csk-sources--collapsed'); + updateToggleLabel(); }); // 内容 @@ -1030,7 +1029,7 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void body.appendChild(item); } - sourcesEl.appendChild(header); + sourcesEl.appendChild(toggle); sourcesEl.appendChild(body); // 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方 @@ -1256,15 +1255,17 @@ export function renderSuggestions( 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); + // t-button 走 slot 文本 + addEventListener(内部 fire 派发 CustomEvent),无需 omi render + const btn = document.createElement('t-button'); + btn.textContent = text; + btn.setAttribute('theme', 'default'); + btn.setAttribute('variant', 'text'); + btn.setAttribute('size', 'small'); + btn.addEventListener('click', (e) => { + e.stopPropagation(); + onClick(text); + }); + list.appendChild(btn); } section.appendChild(list); diff --git a/client/src/styles.ts b/client/src/styles.ts index 1f37456..f37fd28 100644 --- a/client/src/styles.ts +++ b/client/src/styles.ts @@ -446,41 +446,16 @@ function getStyles(config: ResolvedConfig): string { /* ========== P1: RAG 引用来源卡片 ========== */ .csk-sources { margin-top: 8px; + max-width: 100%; + font-size: 12px; +} +.csk-sources__body { + margin-top: 6px; border: 1px solid var(--csk-border); border-radius: 8px; overflow: hidden; - font-size: 12px; - max-width: 100%; background: #fff; } -.csk-sources__header { - display: flex; - align-items: center; - justify-content: space-between; - padding: 6px 10px; - background: var(--csk-bg-ai); - cursor: pointer; - user-select: none; - transition: background 0.15s; -} -.csk-sources__header:hover { background: var(--csk-border); } -.csk-sources__title { - display: flex; - align-items: center; - gap: 5px; - font-weight: 500; - color: #374151; -} -.csk-sources__arrow { - transition: transform 0.2s; - color: #9CA3AF; - font-size: 10px; -} -.csk-sources--collapsed .csk-sources__arrow { transform: rotate(-90deg); } -.csk-sources__body { - border-top: 1px solid var(--csk-border); - padding: 0; -} .csk-sources--collapsed .csk-sources__body { display: none; } .csk-source-item { padding: 6px 10px; @@ -661,7 +636,7 @@ function getStyles(config: ResolvedConfig): string { .csk-suggestions__list { display: flex; flex-wrap: wrap; - gap: 4px; + gap: 8px; } /* ========== 新消息提示按钮 ========== */ @@ -809,9 +784,7 @@ function getStyles(config: ResolvedConfig): string { .csk-dark .csk-header::after { background: linear-gradient(to bottom, rgba(0,0,0,0.12), transparent); } .csk-dark .csk-input-area { background: #1A1A2E; border-top-color: var(--csk-border); } .csk-dark .csk-category-bar { background: #1A1A2E; border-top-color: var(--csk-border); } -.csk-dark .csk-sources { background: #1E1E2E; border-color: var(--csk-border); } -.csk-dark .csk-sources__header { background: #25253A; } -.csk-dark .csk-sources__header:hover { background: #2D2D48; } +.csk-dark .csk-sources__body { background: #1E1E2E; border-color: var(--csk-border); } .csk-dark .csk-source-item:hover { background: var(--csk-bg-ai); } .csk-dark .csk-source-item__name { color: #E2E8F0; } .csk-dark .csk-source-item__meta { color: #6B7280; }