Browse Source

fix(client): 修复重新生成清空历史,优化引用来源/推荐问题与历史滚动

- 重新生成改为调用后端会话截断接口,同步前后端数据,避免历史回复被清空
- 引用来源折叠开关与推荐问题改用 t-button,风格对齐 ChatActionbar 操作栏
- 历史消息渲染后延迟滚动到底部,修复停在滚动条中间的问题
master
wanghanlin 5 days ago
parent
commit
da031c6ff0
  1. 34
      client/src/api.ts
  2. 68
      client/src/chat.ts
  3. 53
      client/src/dom.ts
  4. 41
      client/src/styles.ts

34
client/src/api.ts

@ -652,6 +652,40 @@ export function getConversationExportUrl(conversationId: string): string {
return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`); return buildUrl(`/ai/sdk/conversation/${conversationId}/export?${params.toString()}`);
} }
/**
* SDK
* userTurn
* DatabaseChatMemory
*/
export async function truncateConversation(conversationId: string, userTurn: number): Promise<boolean> {
if (!currentConfig) return false;
const url = buildUrl(`/ai/sdk/conversation/${conversationId}/truncate`);
try {
const body: Record<string, unknown> = { 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 自动初始化 ==================== // ==================== chatId 自动初始化 ====================
/** /**

68
client/src/chat.ts

@ -16,6 +16,7 @@ import {
fetchConversationMessages, fetchConversationMessages,
deleteConversation, deleteConversation,
getConversationExportUrl, getConversationExportUrl,
truncateConversation,
initChatId, initChatId,
updateChatId, updateChatId,
getChatId, getChatId,
@ -64,6 +65,8 @@ let ariaLiveEl: HTMLElement | null = null;
let showLoadingFn: (() => HTMLElement) | null = null; let showLoadingFn: (() => HTMLElement) | null = null;
let hideLoadingFn: (() => void) | null = null; let hideLoadingFn: (() => void) | null = null;
let isSending = false; let isSending = false;
/** 是否正在「重新生成」中(独立于 isSending,覆盖截断后端会话的异步窗口,防止重复触发) */
let isRetrying = false;
/** 当前流式请求的中断控制器(用于"停止生成") */ /** 当前流式请求的中断控制器(用于"停止生成") */
let abortController: AbortController | null = null; let abortController: AbortController | null = null;
@ -576,36 +579,55 @@ export function sendQuickReply(text: string): Promise<void> {
/** /**
* AI ID * AI ID
* AI AI
* ChatPanel
* AI
*
* "复活"
*/ */
export async function retryFromMessage(msgId: string): Promise<void> { 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'); const aiIndex = messages.findIndex(m => m.id === msgId && m.role === 'ai');
if (aiIndex < 0) return; if (aiIndex < 0) return;
// 找到上一条用户消息文本
let userText = '';
// 找到该 AI 回复所属的那轮用户提问
let userIndex = -1;
for (let i = aiIndex - 1; i >= 0; i--) { for (let i = aiIndex - 1; i >= 0; i--) {
if (messages[i].role === 'user') { userText = messages[i].content; break; }
if (messages[i].role === 'user') { userIndex = i; break; }
} }
if (userIndex < 0) return;
const userText = messages[userIndex].content;
if (!userText) return; 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();
});
// 计算 userTurn(1-based,仅统计 USER 消息):该轮提问是第几条用户消息
let userTurn = 0;
for (let i = 0; i <= userIndex; i++) {
if (messages[i].role === 'user') userTurn++;
}
// 1. 先截断后端会话(删除该轮提问及其之后的所有消息),失败则不触碰本地数据
const chatId = getChatId();
if (chatId) {
const ok = await truncateConversation(chatId, userTurn);
if (!ok) {
logger.error(`重新生成失败:后端会话截断失败 chatId=${chatId} userTurn=${userTurn}`);
return;
}
}
// 2. 本地截断到该轮提问之前(删除该轮提问、被点击的回复及其后的所有消息)
messages = messages.slice(0, userIndex);
saveMessages(config.integrateId, messages); saveMessages(config.integrateId, messages);
updateEmptyState();
// 重新生成(不重复渲染用户气泡)
await produceAIReply(userText);
// 3. 重新渲染历史(清除 DOM 中被删除的消息),保证界面与数据一致
renderHistory();
// 4. 重新发送该轮提问(重新渲染用户气泡 + 生成新回复)
await handleSend(userText);
} finally {
isRetrying = false;
}
} }
/** 获取并渲染 RAG 引用来源 */ /** 获取并渲染 RAG 引用来源 */
@ -678,7 +700,15 @@ function renderHistory(): void {
} }
isNearBottom = true; 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'; if (clearBtn && messages.length > 0) clearBtn.style.display = 'inline-flex';
updateEmptyState(); updateEmptyState();

53
client/src/dom.ts

@ -963,10 +963,11 @@ export function finalizeAIBubble(wrapper: HTMLElement, bubble: HTMLElement): voi
// ==================== P1: RAG 引用来源渲染 ==================== // ==================== P1: RAG 引用来源渲染 ====================
/** /**
* RAG
* RAG t-button +
* <t-collapse> tdesign-web-components collapse/collapse-panel * <t-collapse> tdesign-web-components collapse/collapse-panel
* omi provide/inject DOMcreateElement + appendChild * omi provide/inject DOMcreateElement + 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 { 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'); const sourcesEl = document.createElement('div');
sourcesEl.className = 'csk-sources csk-sources--collapsed'; 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);
// 折叠开关: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.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'); sourcesEl.classList.toggle('csk-sources--collapsed');
updateToggleLabel();
}); });
// 内容 // 内容
@ -1030,7 +1029,7 @@ export function renderSources(wrapper: HTMLElement, sources: RagSource[]): void
body.appendChild(item); body.appendChild(item);
} }
sourcesEl.appendChild(header);
sourcesEl.appendChild(toggle);
sourcesEl.appendChild(body); sourcesEl.appendChild(body);
// 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方 // 插入到消息气泡(t-chat-item)之后,作为来源卡片紧随气泡下方
@ -1256,15 +1255,17 @@ export function renderSuggestions(
list.className = 'csk-suggestions__list'; list.className = 'csk-suggestions__list';
for (const text of suggestions) { for (const text of suggestions) {
// on* 回调(onClick)必须经 omi render 创建;tag 的 onClick 首参为原生事件,文本经 children 传入
createEventful('t-tag', {
theme: 'primary',
variant: 'light-outline',
onClick: (e: Event) => {
// 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(); e.stopPropagation();
onClick(text); onClick(text);
},
}, list, text);
});
list.appendChild(btn);
} }
section.appendChild(list); section.appendChild(list);

41
client/src/styles.ts

@ -446,41 +446,16 @@ function getStyles(config: ResolvedConfig): string {
/* ========== P1: RAG 引用来源卡片 ========== */ /* ========== P1: RAG 引用来源卡片 ========== */
.csk-sources { .csk-sources {
margin-top: 8px; margin-top: 8px;
max-width: 100%;
font-size: 12px;
}
.csk-sources__body {
margin-top: 6px;
border: 1px solid var(--csk-border); border: 1px solid var(--csk-border);
border-radius: 8px; border-radius: 8px;
overflow: hidden; overflow: hidden;
font-size: 12px;
max-width: 100%;
background: #fff; 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-sources--collapsed .csk-sources__body { display: none; }
.csk-source-item { .csk-source-item {
padding: 6px 10px; padding: 6px 10px;
@ -661,7 +636,7 @@ function getStyles(config: ResolvedConfig): string {
.csk-suggestions__list { .csk-suggestions__list {
display: flex; display: flex;
flex-wrap: wrap; 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-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-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-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:hover { background: var(--csk-bg-ai); }
.csk-dark .csk-source-item__name { color: #E2E8F0; } .csk-dark .csk-source-item__name { color: #E2E8F0; }
.csk-dark .csk-source-item__meta { color: #6B7280; } .csk-dark .csk-source-item__meta { color: #6B7280; }

Loading…
Cancel
Save