feat(chat): cut over to single SSE endpoint

This commit is contained in:
zhuyongxin
2026-07-22 10:01:12 +08:00
parent f8809cb7dd
commit bc36248cd8
36 changed files with 2187 additions and 945 deletions
+10
View File
@@ -204,5 +204,15 @@ logging:
# Agent-facing MySQL Tool uses independent logical datasources only.
# Production entries are supplied by a dedicated profile and Secret injection.
harness:
chat:
worker-core-pool-size: 2
worker-max-pool-size: 8
worker-queue-capacity: 64
model-core-pool-size: 4
model-max-pool-size: 8
model-queue-capacity: 32
run-timeout: 5m
sse-timeout: 5m
canonical-ttl: 2h
mysql-tools:
data-sources: {}
+119 -294
View File
@@ -2,7 +2,6 @@
class SuperBizAgentApp {
constructor() {
this.apiBaseUrl = 'http://localhost:9900/api';
this.currentMode = 'quick'; // 'quick' 或 'stream'
this.sessionId = this.generateSessionId();
this.isStreaming = false;
this.currentChatHistory = []; // 当前对话的消息历史
@@ -108,9 +107,6 @@ class SuperBizAgentApp {
this.toolsBtn = document.getElementById('toolsBtn');
this.toolsMenu = document.getElementById('toolsMenu');
this.uploadFileItem = document.getElementById('uploadFileItem');
this.modeSelectorBtn = document.getElementById('modeSelectorBtn');
this.modeDropdown = document.getElementById('modeDropdown');
this.currentModeText = document.getElementById('currentModeText');
this.fileInput = document.getElementById('fileInput');
// 聊天区域元素
@@ -137,32 +133,6 @@ class SuperBizAgentApp {
this.aiOpsSidebarBtn.addEventListener('click', () => this.triggerAIOps());
}
// 模式选择下拉菜单
if (this.modeSelectorBtn) {
this.modeSelectorBtn.addEventListener('click', (e) => {
e.stopPropagation();
this.toggleModeDropdown();
});
}
// 下拉菜单项点击
const dropdownItems = document.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
item.addEventListener('click', (e) => {
const mode = item.getAttribute('data-mode');
this.selectMode(mode);
this.closeModeDropdown();
});
});
// 点击外部关闭下拉菜单
document.addEventListener('click', (e) => {
if (!this.modeSelectorBtn.contains(e.target) &&
!this.modeDropdown.contains(e.target)) {
this.closeModeDropdown();
}
});
// 发送消息
if (this.sendButton) {
this.sendButton.addEventListener('click', () => this.sendMessage());
@@ -271,8 +241,6 @@ class SuperBizAgentApp {
this.sessionId = this.generateSessionId();
this.updateTraceWorkbenchLink();
// 重置模式为快速
this.currentMode = 'quick';
this.updateUI();
// 重新设置居中样式(确保对话框居中显示)
@@ -552,8 +520,10 @@ class SuperBizAgentApp {
return;
}
const payload = sseMessage.data && typeof sseMessage.data === 'object' ? sseMessage.data : {};
const sessionId = sseMessage.sessionId || payload.sessionId;
const runId = sseMessage.runId || payload.runId;
const sessionId = sseMessage.session_id || sseMessage.sessionId
|| payload.session_id || payload.sessionId;
const runId = sseMessage.run_id || sseMessage.runId
|| payload.run_id || payload.runId;
if (!sessionId) {
return;
}
@@ -562,66 +532,8 @@ class SuperBizAgentApp {
this.rememberTraceTarget(sessionId, runId || null);
}
// 切换模式下拉菜单
toggleModeDropdown() {
if (this.modeSelectorBtn && this.modeDropdown) {
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
if (wrapper) {
wrapper.classList.toggle('active');
}
}
}
// 关闭模式下拉菜单
closeModeDropdown() {
if (this.modeSelectorBtn && this.modeDropdown) {
const wrapper = this.modeSelectorBtn.closest('.mode-selector-wrapper');
if (wrapper) {
wrapper.classList.remove('active');
}
}
}
// 选择模式
selectMode(mode) {
if (this.isStreaming) {
this.showNotification('请等待当前对话完成后再切换模式', 'warning');
return;
}
this.currentMode = mode;
this.updateUI();
const modeNames = {
'quick': '快速',
'stream': '流式'
};
this.showNotification(`已切换到${modeNames[mode]}模式`, 'info');
}
// 更新UI
updateUI() {
// 更新模式选择器显示
if (this.currentModeText) {
const modeNames = {
'quick': '快速',
'stream': '流式'
};
this.currentModeText.textContent = modeNames[this.currentMode] || '快速';
}
// 更新下拉菜单选中状态
const dropdownItems = document.querySelectorAll('.dropdown-item');
dropdownItems.forEach(item => {
const mode = item.getAttribute('data-mode');
if (mode === this.currentMode) {
item.classList.add('active');
} else {
item.classList.remove('active');
}
});
// 更新发送按钮状态
if (this.sendButton) {
this.sendButton.disabled = this.isStreaming;
@@ -669,11 +581,7 @@ class SuperBizAgentApp {
this.updateUI();
try {
if (this.currentMode === 'quick') {
await this.sendQuickMessage(message);
} else if (this.currentMode === 'stream') {
await this.sendStreamMessage(message);
}
await this.sendChatMessage(message);
} catch (error) {
console.error('发送消息失败:', error);
this.addMessage('assistant', '抱歉,发送消息时出现错误:' + error.message);
@@ -689,214 +597,131 @@ class SuperBizAgentApp {
}
}
// 发送快速消息(普通对话)
async sendQuickMessage(message) {
// 添加等待提示消息
const loadingMessage = this.addLoadingMessage('正在思考...');
try {
const response = await fetch(`${this.apiBaseUrl}/chat`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
Id: this.sessionId,
Question: message
})
});
async sendChatMessage(message) {
const loadingMessage = this.addLoadingMessage('正在处理...');
const assistantMessageElement = this.addMessage('assistant', '', true);
const response = await fetch(`${this.apiBaseUrl}/chat`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ Id: this.sessionId, Question: message })
});
if (!response.ok || !response.body) {
throw new Error(`HTTP错误: ${response.status}`);
}
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
const state = { metadata: false, content: false, failure: false, done: false };
let fullResponse = '';
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
const renderContent = (event) => {
if (state.content || state.failure || !state.metadata
|| !event.content_type || !event.payload) {
throw new Error('非法 content 事件');
}
state.content = true;
fullResponse = this.renderChatPayload(event.content_type, event.payload);
const contentElement = assistantMessageElement.querySelector('.message-content');
contentElement.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(contentElement);
this.scrollToBottom();
};
const data = await response.json();
console.log('[sendQuickMessage] 响应数据:', JSON.stringify(data));
// 移除等待提示消息
if (loadingMessage && loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
const handleEvent = (frame) => {
const lines = frame.split(/\r?\n/);
const eventName = lines.find(line => line.startsWith('event:'))?.slice(6).trim();
const dataLines = lines.filter(line => line.startsWith('data:'))
.map(line => line.slice(5).trim());
if (!eventName || dataLines.length === 0) {
throw new Error('非法 SSE frame');
}
// 统一响应格式:检查 data.code 或 data.message 判断请求是否成功
if (data.code === 200 || data.message === 'success') {
// data.data 是 ChatResponse 对象
const chatResponse = data.data;
if (chatResponse && chatResponse.success) {
// 保存后端返回的 sessionId/runId,用于 feedback 提交和 Trace 精确定位
if (chatResponse.sessionId) {
this.lastSessionId = chatResponse.sessionId;
this.lastRunId = chatResponse.runId || '';
this.rememberTraceTarget(chatResponse.sessionId, chatResponse.runId || null);
const event = JSON.parse(dataLines.join('\n'));
switch (eventName) {
case 'metadata':
if (state.metadata || state.content || state.failure || state.done
|| !event.session_id || !event.run_id) {
throw new Error('非法 metadata 事件');
}
// 成功:添加实际响应消息(即使 answer 为空也显示)
const answer = chatResponse.answer || '(无回复内容)';
this.addMessage('assistant', answer);
} else if (chatResponse && chatResponse.errorMessage) {
// 业务错误
throw new Error(chatResponse.errorMessage);
} else {
// 兜底:尝试显示任何可用内容
const fallbackAnswer = chatResponse?.answer || chatResponse?.errorMessage || '服务返回了空内容';
this.addMessage('assistant', fallbackAnswer);
}
} else {
// HTTP 成功但业务失败
throw new Error(data.message || '请求失败');
state.metadata = true;
this.rememberRunMetadata(event);
break;
case 'status':
if (!state.metadata || state.content || state.failure || state.done
|| !event.code || !event.message) {
throw new Error('非法 status 事件');
}
const statusText = loadingMessage?.querySelector('.loading-message-content span');
if (statusText) statusText.textContent = event.message;
break;
case 'content':
renderContent(event);
break;
case 'failure':
if (state.failure || state.content || state.done || !state.metadata
|| !event.code || !event.message) {
throw new Error('非法 failure 事件');
}
state.failure = true;
fullResponse = event.message;
assistantMessageElement.querySelector('.message-content').textContent = event.message;
break;
case 'done':
if (state.done || !state.metadata || (!state.content && !state.failure)
|| !['SUCCESS', 'FALLBACK', 'FAILED'].includes(event.outcome)) {
throw new Error('非法 done 事件');
}
state.done = true;
if (event.outcome === 'FAILED') {
if (!state.failure) throw new Error('FAILED 缺少 failure');
} else if (!state.content) {
throw new Error('成功终态缺少 content');
}
break;
default:
throw new Error(`未知 SSE 事件: ${eventName}`);
}
} catch (error) {
// 出错时也要移除等待提示消息
if (loadingMessage && loadingMessage.parentNode) {
loadingMessage.parentNode.removeChild(loadingMessage);
};
try {
while (true) {
const { done, value } = await reader.read();
if (done) break;
buffer += decoder.decode(value, { stream: true });
const frames = buffer.split(/\r?\n\r?\n/);
buffer = frames.pop() || '';
frames.filter(Boolean).forEach(handleEvent);
}
throw error;
buffer += decoder.decode();
if (buffer.trim()) handleEvent(buffer);
if (!state.done) throw new Error('SSE 缺少 done 终态');
this.handleStreamComplete(assistantMessageElement, fullResponse);
} finally {
reader.releaseLock();
if (loadingMessage && loadingMessage.parentNode) loadingMessage.parentNode.removeChild(loadingMessage);
}
}
// 发送流式消息
async sendStreamMessage(message) {
try {
const response = await fetch(`${this.apiBaseUrl}/chat_stream`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify({
Id: this.sessionId,
Question: message
})
});
if (!response.ok) {
throw new Error(`HTTP错误: ${response.status}`);
}
// 创建助手消息元素
const assistantMessageElement = this.addMessage('assistant', '', true);
let fullResponse = '';
// 处理流式响应
const reader = response.body.getReader();
const decoder = new TextDecoder();
let buffer = '';
let currentEvent = '';
try {
while (true) {
const { done, value } = await reader.read();
if (done) {
// 流结束,使用统一的处理方法
this.handleStreamComplete(assistantMessageElement, fullResponse);
break;
}
// 解码数据并添加到缓冲区
buffer += decoder.decode(value, { stream: true });
// 按行分割处理
const lines = buffer.split('\n');
// 保留最后一行(可能不完整)
buffer = lines.pop() || '';
for (const line of lines) {
if (line.trim() === '') continue;
console.log('[SSE调试] 收到行:', line);
// 解析SSE格式
if (line.startsWith('id:')) {
console.log('[SSE调试] 解析到ID');
continue;
} else if (line.startsWith('event:')) {
// 兼容 "event:message" 和 "event: message" 两种格式
currentEvent = line.substring(6).trim();
console.log('[SSE调试] 解析到事件类型:', currentEvent);
// 注意:后端统一使用 "message" 事件名,真正的类型在 data 的 JSON 中
continue;
} else if (line.startsWith('data:')) {
// 兼容 "data:xxx" 和 "data: xxx" 两种格式
const rawData = line.substring(5).trim();
console.log('[SSE调试] 解析到数据, currentEvent:', currentEvent, ', rawData:', rawData);
// 兼容旧格式 [DONE] 标记
if (rawData === '[DONE]') {
// 流结束标记,将内容转换为Markdown渲染
this.handleStreamComplete(assistantMessageElement, fullResponse);
return;
}
// 处理 SSE 数据
try {
// 尝试解析为 SseMessage 格式的 JSON
const sseMessage = JSON.parse(rawData);
console.log('[SSE调试] 解析JSON成功:', sseMessage);
if (sseMessage && typeof sseMessage.type === 'string') {
if (sseMessage.type === 'metadata') {
this.rememberRunMetadata(sseMessage);
} else if (sseMessage.type === 'content') {
const content = sseMessage.data || '';
fullResponse += content;
console.log('[SSE调试] 添加内容:', content);
// 实时渲染 Markdown
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
// 高亮代码块
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
} else if (sseMessage.type === 'done') {
console.log('[SSE调试] 收到done标记,流结束');
this.handleStreamComplete(assistantMessageElement, fullResponse);
return;
} else if (sseMessage.type === 'error') {
console.error('[SSE调试] 收到错误:', sseMessage.data);
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown('错误: ' + (sseMessage.data || '未知错误'));
}
return;
}
} else {
// 不是标准 SseMessage 格式,尝试兼容处理
console.log('[SSE调试] 非标准格式,尝试兼容处理');
fullResponse += rawData;
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
}
} catch (e) {
// JSON 解析失败,尝试兼容旧格式
console.log('[SSE调试] JSON解析失败,使用兼容模式:', e.message);
if (rawData === '') {
fullResponse += '\n';
} else {
fullResponse += rawData;
}
if (assistantMessageElement) {
const messageContent = assistantMessageElement.querySelector('.message-content');
messageContent.innerHTML = this.renderMarkdown(fullResponse);
this.highlightCodeBlocks(messageContent);
this.scrollToBottom();
}
}
}
}
}
} finally {
reader.releaseLock();
}
} catch (error) {
throw error;
renderChatPayload(contentType, payload) {
if (contentType === 'SYSTEM_CHAT') return payload.answer || '';
if (contentType === 'KNOWLEDGE_ANSWER') {
const references = (payload.references || []).map(ref => `- ${ref.title || ref.document_id}`).join('\n');
return [payload.answer || '', references ? `\n\n参考资料:\n${references}` : ''].join('');
}
if (contentType === 'SAFE_FALLBACK') {
return payload.fallback?.message || '当前证据不足,无法确认根因';
}
if (contentType === 'DIAGNOSIS_REPORT') {
const report = payload.report || {};
const sections = [];
if (report.conclusion?.text) sections.push(`## 结论\n${report.conclusion.text}`);
if (report.analysis?.length) sections.push(`## 分析\n${report.analysis.map(item => `- ${item.text}`).join('\n')}`);
if (report.action_plan?.length) sections.push(`## 行动计划\n${report.action_plan.map(item => `- ${item.action}`).join('\n')}`);
if (report.recommendations?.length) sections.push(`## 建议\n${report.recommendations.map(item => `- ${item.text}`).join('\n')}`);
if (report.limitations?.scope) sections.push(`## 限制\n${report.limitations.scope}`);
return sections.join('\n\n');
}
throw new Error(`未知 content_type: ${contentType}`);
}
// 添加消息到聊天界面
-24
View File
@@ -95,30 +95,6 @@
</div>
</div>
<div class="right-actions">
<div class="mode-selector-wrapper">
<button class="mode-selector-btn" id="modeSelectorBtn">
<span id="currentModeText">快速</span>
<svg class="dropdown-arrow" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M6 9L12 15L18 9" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
</svg>
</button>
<div class="mode-dropdown" id="modeDropdown">
<div class="dropdown-header">选择对话方式</div>
<div class="dropdown-item active" data-mode="quick">
<div class="dropdown-item-main">
<span>快速</span>
<span class="badge-new">新</span>
</div>
<div class="dropdown-item-sub">快速对话</div>
</div>
<div class="dropdown-item" data-mode="stream">
<div class="dropdown-item-main">
<span>流式</span>
</div>
<div class="dropdown-item-sub">流式对话</div>
</div>
</div>
</div>
<button class="send-btn-circle" id="sendButton" title="发送">
<svg viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M22 2L11 13M22 2L15 22L11 13M22 2L2 9L11 13" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
-118
View File
@@ -735,108 +735,6 @@ body {
color: #9aa0a6;
}
.mode-selector-wrapper {
position: relative;
}
.mode-selector-btn {
display: flex;
align-items: center;
gap: 4px;
padding: 0;
background: none;
border: none;
cursor: pointer;
font-size: 14px;
color: #5f6368;
font-weight: 400;
transition: color 0.2s;
white-space: nowrap;
}
.mode-selector-btn:hover {
color: #202124;
}
.dropdown-arrow {
width: 16px;
height: 16px;
transition: transform 0.2s;
}
.mode-selector-wrapper.active .dropdown-arrow {
transform: rotate(180deg);
}
.mode-dropdown {
position: absolute;
bottom: calc(100% + 8px);
right: 0;
background: #ffffff;
border: 1px solid #dadce0;
border-radius: 12px;
box-shadow: 0 2px 10px rgba(60, 64, 67, 0.15);
min-width: 200px;
opacity: 0;
visibility: hidden;
transform: translateY(8px);
transition: all 0.2s;
z-index: 1000;
}
.mode-selector-wrapper.active .mode-dropdown {
opacity: 1;
visibility: visible;
transform: translateY(0);
}
.dropdown-header {
padding: 12px 16px;
font-size: 12px;
font-weight: 600;
color: #5f6368;
text-transform: uppercase;
border-bottom: 1px solid #dadce0;
}
.dropdown-item {
padding: 12px 16px;
cursor: pointer;
transition: background 0.2s;
}
.dropdown-item:hover {
background: #f1f3f4;
}
.dropdown-item.active {
background: rgba(26, 115, 232, 0.1);
}
.dropdown-item-main {
display: flex;
align-items: center;
justify-content: space-between;
font-size: 14px;
font-weight: 500;
color: #202124;
margin-bottom: 4px;
}
.dropdown-item-sub {
font-size: 12px;
color: #5f6368;
}
.badge-new {
background: #1a73e8;
color: #ffffff;
font-size: 10px;
padding: 2px 6px;
border-radius: 4px;
font-weight: 600;
}
.send-btn-circle {
width: 36px;
height: 36px;
@@ -1146,22 +1044,6 @@ body {
flex-wrap: wrap;
}
.mode-selector-wrapper {
order: -1;
width: 100%;
margin-left: 0;
margin-bottom: 8px;
}
.mode-selector-btn {
width: 100%;
justify-content: space-between;
}
.mode-dropdown {
width: 100%;
right: 0;
}
}
/* 反馈栏 */