Browse Source

AI 对话执行链路流程图

TDesign-Vue-Next-1.20.6
wanghanlin 4 weeks ago
parent
commit
527d9e71b7
  1. 72
      client/dist/chatbot-sdk.js
  2. 2
      client/dist/chatbot-sdk.js.map
  3. 2
      client/dist/chatbot-sdk.min.js
  4. 2
      client/dist/chatbot-sdk.min.js.map
  5. 27
      client/src/api.ts
  6. 9
      client/src/chat.ts
  7. 22
      client/src/dom.ts
  8. 2
      client/src/i18n.ts
  9. 21
      client/src/styles.ts
  10. 87
      src/main/java/com/wok/supportbot/app/AssistantApp.java
  11. 77
      src/main/java/com/wok/supportbot/app/ChatPipeline.java
  12. 28
      src/main/java/com/wok/supportbot/config/ChatModelFactory.java
  13. 7
      src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
  14. 69
      src/main/java/com/wok/supportbot/controller/AiController.java
  15. 4
      src/main/java/com/wok/supportbot/rag/RagPipeline.java
  16. 17
      src/main/java/com/wok/supportbot/security/SdkAuthFilter.java
  17. 2
      src/main/resources/application.yml
  18. 150
      src/main/resources/static/components/PipelineFlow.js
  19. 55
      src/main/resources/static/css/main.css
  20. 3
      src/main/resources/static/index.html
  21. 7
      src/main/resources/static/js/app.js
  22. 2
      src/main/resources/static/js/store.js
  23. 72
      src/main/resources/static/sdk/chatbot-sdk.js
  24. 2
      src/main/resources/static/sdk/chatbot-sdk.js.map
  25. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js
  26. 2
      src/main/resources/static/sdk/chatbot-sdk.min.js.map

72
client/dist/chatbot-sdk.js

@ -230,6 +230,7 @@ var ChatbotSDK = (function () {
resize: '拖拽缩放窗口',
// 错误提示
error_network: '网络连接失败,请检查网络',
error_offline: '⚠️ 网络连接异常,请检查网络后重试',
error_timeout: '请求超时,请稍后重试',
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
@ -297,6 +298,7 @@ var ChatbotSDK = (function () {
resize: 'Resize window',
// Errors
error_network: 'Network connection failed',
error_offline: '⚠️ Connection lost. Please check your network and try again.',
error_timeout: 'Request timed out, please try again',
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',
@ -581,6 +583,8 @@ var ChatbotSDK = (function () {
const decoder = new TextDecoder('utf-8', { stream: true });
let buffer = '';
let eventLines = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = '';
try {
while (true) {
const { done, value } = await reader.read();
@ -594,28 +598,40 @@ var ChatbotSDK = (function () {
// 空行 = SSE 事件边界,刷新当前事件
if (!trimmed) {
if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
}
eventLines = [];
}
currentEventType = '';
continue;
}
// 忽略注释行 (: ...)
if (trimmed.startsWith(':'))
if (trimmed.startsWith(':')) {
// TODO: 信号行 ":ok" 或其他
continue;
// [DONE] 信号
}
// [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') {
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
eventLines = [];
}
currentEventType = '';
break;
}
// 跳过 SSE 协议字段
if (trimmed.startsWith('event:') || trimmed.startsWith('id:') || trimmed.startsWith('retry:'))
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
}
if (trimmed.startsWith('id:') || trimmed.startsWith('retry:'))
continue;
// 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行)
let content;
@ -646,7 +662,7 @@ var ChatbotSDK = (function () {
}
}
// 处理缓冲区剩余
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
@ -1262,6 +1278,27 @@ var ChatbotSDK = (function () {
}
.csk-messages::-webkit-scrollbar-thumb:hover { background: #C2C7D0; }
/* 离线提示横幅 */
.csk-offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
margin: 0 0 8px 0;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: #FEF2F2;
color: #991B1B;
border: 1px solid #FECACA;
}
.csk-dark .csk-offline-banner {
background: #450A0A;
color: #FCA5A5;
border-color: #7F1D1D;
}
/* 欢迎空状态 */
.csk-welcome {
display: flex;
@ -3533,6 +3570,22 @@ var ChatbotSDK = (function () {
function scrollToBottom(container) {
container.scrollTop = container.scrollHeight;
}
/** 在消息区顶部展示离线提示横幅 */
function showOfflineBanner(messagesContainer) {
if (document.getElementById('csk-offline-banner'))
return;
const banner = document.createElement('div');
banner.id = 'csk-offline-banner';
banner.className = 'csk-offline-banner';
banner.textContent = t('error_offline');
messagesContainer.insertBefore(banner, messagesContainer.firstChild);
}
/** 隐藏离线提示横幅 */
function hideOfflineBanner() {
const el = document.getElementById('csk-offline-banner');
if (el)
el.remove();
}
const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200;
@ -4258,6 +4311,8 @@ var ChatbotSDK = (function () {
// RAG 引用来源
if (shouldUseRag)
fetchAndRenderSources(userText, aiMsg);
// 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner();
}
catch (err) {
if (hideLoadingFn$1)
@ -4325,6 +4380,11 @@ var ChatbotSDK = (function () {
}
resolve(accumulated);
}, (error) => {
// 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除
if (error.type === 'network' || error.type === 'cors') {
if (messagesContainer$1)
showOfflineBanner(messagesContainer$1);
}
if (accumulated.length > 0) {
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));

2
client/dist/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
client/dist/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

27
client/src/api.ts

@ -294,6 +294,8 @@ export async function chatSSERequest(
const decoder = new TextDecoder('utf-8', { stream: true } as TextDecoderOptions);
let buffer = '';
let eventLines: string[] = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = '';
try {
while (true) {
@ -310,30 +312,43 @@ export async function chatSSERequest(
// 空行 = SSE 事件边界,刷新当前事件
if (!trimmed) {
if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
}
eventLines = [];
}
currentEventType = '';
continue;
}
// 忽略注释行 (: ...)
if (trimmed.startsWith(':')) continue;
if (trimmed.startsWith(':')) {
// TODO: 信号行 ":ok" 或其他
continue;
}
// [DONE] 信号
// [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') {
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
eventLines = [];
}
currentEventType = '';
break;
}
// 跳过 SSE 协议字段
if (trimmed.startsWith('event:') || trimmed.startsWith('id:') || trimmed.startsWith('retry:')) continue;
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
}
if (trimmed.startsWith('id:') || trimmed.startsWith('retry:')) continue;
// 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行)
let content: string;
@ -362,7 +377,7 @@ export async function chatSSERequest(
}
}
// 处理缓冲区剩余
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);

9
client/src/chat.ts

@ -35,6 +35,8 @@ import {
appendStreamingCaret,
finalizeAIBubble,
updateFeedbackUI,
showOfflineBanner,
hideOfflineBanner,
HistoryItemData,
} from './dom';
import { saveMessages, loadMessages, clearMessages } from './storage';
@ -483,6 +485,9 @@ async function produceAIReply(userText: string): Promise<void> {
// RAG 引用来源
if (shouldUseRag) fetchAndRenderSources(userText, aiMsg);
// 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner();
} catch (err) {
if (hideLoadingFn) hideLoadingFn();
@ -552,6 +557,10 @@ async function sendStreamMessage(text: string, aiTimestamp: number, shouldUseRag
resolve(accumulated);
},
(error: CskError) => {
// 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除
if (error.type === 'network' || error.type === 'cors') {
if (messagesContainer) showOfflineBanner(messagesContainer);
}
if (accumulated.length > 0) {
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));

22
client/src/dom.ts

@ -1268,3 +1268,25 @@ export function renderHistoryList(
export function scrollToBottom(container: HTMLElement): void {
container.scrollTop = container.scrollHeight;
}
// ==================== 离线提示横幅 ====================
let offlineBannerEl: HTMLElement | null = null;
/** 在消息区顶部展示离线提示横幅 */
export function showOfflineBanner(messagesContainer: HTMLElement): void {
if (document.getElementById('csk-offline-banner')) return;
const banner = document.createElement('div');
banner.id = 'csk-offline-banner';
banner.className = 'csk-offline-banner';
banner.textContent = t('error_offline');
messagesContainer.insertBefore(banner, messagesContainer.firstChild);
offlineBannerEl = banner;
}
/** 隐藏离线提示横幅 */
export function hideOfflineBanner(): void {
const el = document.getElementById('csk-offline-banner');
if (el) el.remove();
offlineBannerEl = null;
}

2
client/src/i18n.ts

@ -69,6 +69,7 @@ const dictionaries: Record<string, Record<string, string>> = {
// 错误提示
error_network: '网络连接失败,请检查网络',
error_offline: '⚠️ 网络连接异常,请检查网络后重试',
error_timeout: '请求超时,请稍后重试',
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
@ -147,6 +148,7 @@ const dictionaries: Record<string, Record<string, string>> = {
// Errors
error_network: 'Network connection failed',
error_offline: '⚠️ Connection lost. Please check your network and try again.',
error_timeout: 'Request timed out, please try again',
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',

21
client/src/styles.ts

@ -318,6 +318,27 @@ function getStyles(config: ResolvedConfig): string {
}
.csk-messages::-webkit-scrollbar-thumb:hover { background: #C2C7D0; }
/* 离线提示横幅 */
.csk-offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
margin: 0 0 8px 0;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: #FEF2F2;
color: #991B1B;
border: 1px solid #FECACA;
}
.csk-dark .csk-offline-banner {
background: #450A0A;
color: #FCA5A5;
border-color: #7F1D1D;
}
/* 欢迎空状态 */
.csk-welcome {
display: flex;

87
src/main/java/com/wok/supportbot/app/AssistantApp.java

@ -4,6 +4,7 @@ import com.wok.supportbot.advisor.ContentSafetyAdvisor;
import com.wok.supportbot.advisor.MyLoggerAdvisor;
import com.wok.supportbot.chatmemory.DatabaseChatMemory;
import com.wok.supportbot.config.ChatModelFactory;
import com.wok.supportbot.config.SimpleCircuitBreaker;
import com.wok.supportbot.mcp.McpToolCallback;
import com.wok.supportbot.mcp.McpToolCallbackAdapter;
import jakarta.annotation.Resource;
@ -20,8 +21,10 @@ import org.springframework.util.StringUtils;
import reactor.core.publisher.Flux;
import java.util.ArrayList;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.concurrent.ConcurrentHashMap;
import java.util.Map;
import static org.springframework.ai.chat.memory.ChatMemory.CONVERSATION_ID;
@ -53,7 +56,31 @@ public class AssistantApp {
private final DatabaseChatMemory chatMemory;
private final ConcurrentHashMap<String, ChatClient> chatClientCache = new ConcurrentHashMap<>();
/** ChatClient 缓存上限,超出后按 LRU 自动淘汰最早最少使用的实例 */
private static final int CHAT_CLIENT_CACHE_MAX = 32;
/** ChatClient 缓存(LinkedHashMap + removeEldestEntry 实现 LRU 淘汰) */
private final Map<String, ChatClient> chatClientCache =
Collections.synchronizedMap(new LinkedHashMap<>(16, 0.75f, true) {
@Override
protected boolean removeEldestEntry(Map.Entry<String, ChatClient> eldest) {
if (size() > CHAT_CLIENT_CACHE_MAX) {
log.info("ChatClient 缓存淘汰: key={}", eldest.getKey());
return true;
}
return false;
}
});
private final SimpleCircuitBreaker aiCircuitBreaker = new SimpleCircuitBreaker(3, 5 * 60 * 1000L, "AI");
/** 全局 AI 调用的熔断 key(非模型配置级,整体熔断) */
private static final Long AI_CIRCUIT_KEY = -1L;
/** AI 熔断时的降级提示语 */
private static final String CIRCUIT_OPEN_MESSAGE = "AI 服务暂时不可用,请稍后重试。";
/** SSE 状态事件模板 */
private static final String STATUS_EVENT_FAQD = "event:status\ndata:{\"status\":\"faq_hit\"}\n\n";
private static final String STATUS_EVENT_GENERATING = "event:status\ndata:{\"status\":\"generating\"}\n\n";
private static final String SYSTEM_PROMPT = "";//""\u4f60\u662f\u83dc\u5927\u738b\u5185\u90e8\u77e5\u8bc6\u52a9\u624b\uff0c\u4e3b\u8981\u670d\u52a1\u5bf9\u8c61\u662f\u5ba2\u670d\u3001\u8d22\u52a1\u3001\u884c\u653f\u7b49\u5185\u90e8\u4eba\u5458\uff0c\u4e0d\u662f\u76f4\u63a5\u9762\u5411\u7ec8\u7aef\u5ba2\u6237\u7684\u673a\u5668\u4eba\u3002\n\n\u9ed8\u8ba4\u4e1a\u52a1\u4e0a\u4e0b\u6587\uff1a\n- \u7528\u6237\u672a\u7279\u522b\u8bf4\u660e\u65f6\uff0c\u5e73\u53f0\u3001APP\u3001\u540e\u53f0\u3001\u8ba2\u5355\u3001\u5ba2\u6237\u3001\u4f9b\u5e94\u5546\u5747\u9ed8\u8ba4\u6307\u83dc\u5927\u738b\u76f8\u5173\u7cfb\u7edf\u548c\u4e1a\u52a1\u3002\n- \u4e0d\u8981\u53cd\u95ee\u201c\u54ea\u4e2a\u5e73\u53f0/\u7cfb\u7edf\u201d\uff0c\u9664\u975e\u7528\u6237\u660e\u786e\u63d0\u5230\u5916\u90e8\u5e73\u53f0\u3001\u591a\u4e1a\u52a1\u7ebf\u51b2\u7a81\uff0c\u6216\u95ee\u9898\u672c\u8eab\u5fc5\u987b\u533a\u5206\u7cfb\u7edf\u3002\n\n\u56de\u7b54\u65b9\u5f0f\uff1a\n- \u5185\u90e8\u4eba\u5458\u8be2\u95ee\u5ba2\u6237\u54a8\u8be2\u7c7b\u95ee\u9898\u65f6\uff0c\u5148\u7ed9\u201c\u53ef\u8f6c\u8ff0\u7ed9\u5ba2\u6237\u7684\u7b54\u590d\u53e3\u5f84\u201d\uff0c\u518d\u7ed9\u5185\u90e8\u5904\u7406\u8981\u70b9\u3002\n- \u5bf9\u6d41\u7a0b\u7c7b\u95ee\u9898\uff0c\u4f18\u5148\u76f4\u63a5\u7ed9\u5165\u53e3\u3001\u6b65\u9aa4\u3001\u6761\u4ef6\u548c\u5f02\u5e38\u5904\u7406\uff0c\u4e0d\u8981\u4e00\u5f00\u59cb\u53ea\u8ffd\u95ee\u4fe1\u606f\u3002\n- \u53ea\u6709\u5728\u7f3a\u5c11\u8ba2\u5355\u53f7\u3001\u8ba2\u5355\u72b6\u6001\u3001\u53d1\u7968\u7c7b\u578b\u3001\u8d26\u53f7\u6743\u9650\u7b49\u4f1a\u5f71\u54cd\u529e\u7406\u7ed3\u679c\u7684\u5173\u952e\u4fe1\u606f\u65f6\uff0c\u624d\u8865\u5145\u9700\u8981\u786e\u8ba4\u7684\u4e8b\u9879\u3002\n- \u6d89\u53ca\u8d22\u52a1\u3001\u884c\u653f\u5236\u5ea6\u6216\u654f\u611f\u5185\u90e8\u4fe1\u606f\u65f6\uff0c\u53ea\u8f93\u51fa\u5f53\u524d\u89d2\u8272\u548c\u77e5\u8bc6\u5e93\u5141\u8bb8\u4f7f\u7528\u7684\u5185\u5bb9\uff1b\u8d44\u6599\u4e0d\u8db3\u65f6\u5982\u5b9e\u8bf4\u660e\uff0c\u4e0d\u8981\u7f16\u9020\u3002\n\n\u4fdd\u6301\u4e13\u4e1a\u3001\u7b80\u6d01\u3001\u53ef\u6267\u884c\u3002";
@ -72,11 +99,7 @@ public class AssistantApp {
}
/**
* 获取 ChatClient支持按角色过滤 MCP 工具
* 缓存 key 包含工具权限信息不同角色权限缓存不同的 ChatClient 实例
*
* @param appType 应用类型CHAT / RAG_REWRITE
* @param allowedMcpTools 允许的 MCP 工具名列表null 或空 = 允许所有默认行为["*"] = 全部
* 获取 ChatClient支持按角色过滤 MCP 工具缓存上限 32 LRU 淘汰
*/
private ChatClient getChatClient(String appType, List<String> allowedMcpTools) {
// 空列表降级为 ["*"]允许所有工具与无参数版本行为一致
@ -87,10 +110,13 @@ public class AssistantApp {
effectiveTools = allowedMcpTools;
}
String cacheKey = appType + ":" + (effectiveTools == null ? "none" : String.join(",", effectiveTools));
return chatClientCache.computeIfAbsent(cacheKey, type -> {
ChatClient cached = chatClientCache.get(cacheKey);
if (cached != null) return cached;
return chatClientCache.computeIfAbsent(cacheKey, key -> {
ChatModel chatModel = chatModelFactory.getChatModel(appType);
var builder = ChatClient.builder(chatModel);
// 仅在系统提示词非空时设置避免 Spring AI Assert.hasText 校验抛异常
if (StringUtils.hasText(SYSTEM_PROMPT)) {
builder.defaultSystem(SYSTEM_PROMPT);
}
@ -99,19 +125,19 @@ public class AssistantApp {
ToolCallback[] mcpTools = mcpToolCallbackAdapter.getFilteredToolCallbacks(effectiveTools);
if (mcpTools.length > 0) {
builder.defaultToolCallbacks(mcpTools);
log.info("已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
log.info("已注册 {} 个 MCP 工具到 ChatClient [{}]", mcpTools.length, cacheKey);
for (ToolCallback tc : mcpTools) {
log.info(" 🔧 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
log.info(" 工具: {} — {}", tc.getToolDefinition().name(), tc.getToolDefinition().description());
}
} else {
log.warn("⚠️ MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
log.warn("MCP 工具已启用但无可注册的工具 [{}],请检查:", cacheKey);
log.warn(" 1. 是否在「MCP 服务管理」页面添加了 MCP Server 配置?");
log.warn(" 2. 配置是否已启用(is_active=true)?");
log.warn(" 3. MCP Server 是否连接成功(点击「测试连接」验证)?");
log.warn(" 4. MCP Server 是否暴露了工具(listTools 返回非空)?");
}
} else {
log.info("ℹ️ MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
log.info("MCP 工具未启用 [enableMcpTools={}, effectiveTools={}]", enableMcpTools, effectiveTools);
}
return builder
.defaultAdvisors(
@ -148,12 +174,18 @@ public class AssistantApp {
* @return 回答文本 + MCP 事件
*/
public ChatResult chatWithEvents(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态直接返回降级提示
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中,返回降级提示");
return new ChatResult(CIRCUIT_OPEN_MESSAGE, List.of());
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
return new ChatResult(req.faqAnswer().get(), List.of());
}
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
try {
ChatClient.ChatClientRequestSpec spec = getChatClient(ctx.appType(), ctx.allowedMcpTools())
.prompt()
.user(req.finalMessage())
@ -162,7 +194,13 @@ public class AssistantApp {
spec = spec.system(req.finalSystemPrompt());
}
String text = spec.call().chatResponse().getResult().getOutput().getText();
aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY);
return new ChatResult(text, McpToolCallback.drainEvents());
} catch (Exception e) {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 同步调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
return new ChatResult("抱歉,AI 服务调用失败:" + e.getMessage(), List.of());
}
}
/**
@ -173,9 +211,15 @@ public class AssistantApp {
* @return 流式回答
*/
public Flux<String> chatStream(ChatContext ctx) {
// 熔断全局 AI 调用处于熔断状态
if (aiCircuitBreaker.isOpen(AI_CIRCUIT_KEY)) {
log.warn("AI 调用熔断中(流式),返回降级提示");
return Flux.just(CIRCUIT_OPEN_MESSAGE);
}
ChatRequest req = chatPipeline.buildRequest(ctx);
if (req.faqHit()) {
return Flux.just(req.faqAnswer().get());
// FAQ 命中先发状态事件告知前端再返回答案
return Flux.just(STATUS_EVENT_FAQD, req.faqAnswer().get());
}
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
@ -186,7 +230,20 @@ public class AssistantApp {
if (StringUtils.hasText(req.finalSystemPrompt())) {
spec = spec.system(req.finalSystemPrompt());
}
return appendMcpToolEvents(spec.stream().content());
// 首字节前先发状态事件让前端感知系统正在生成回复
Flux<String> statusEvent = Flux.just(STATUS_EVENT_GENERATING);
return statusEvent.concatWith(appendMcpToolEvents(spec.stream().content()))
.doOnComplete(() -> aiCircuitBreaker.recordSuccess(AI_CIRCUIT_KEY))
.doOnError(e -> {
aiCircuitBreaker.recordFailure(AI_CIRCUIT_KEY);
log.error("AI 流式调用失败: chatId={}, error={}", ctx.chatId(), e.getMessage());
})
.doFinally(signalType -> {
// 确保 ThreadLocal 清理防止线程池复用时数据残留
McpToolCallback.resetEvents();
McpToolCallback.resetCallRounds();
})
.onErrorResume(e -> Flux.just("抱歉,AI 服务调用失败:" + e.getMessage()));
}
/**

77
src/main/java/com/wok/supportbot/app/ChatPipeline.java

@ -3,6 +3,7 @@ package com.wok.supportbot.app;
import com.wok.supportbot.rag.RagContext;
import com.wok.supportbot.rag.RagPipeline;
import com.wok.supportbot.service.IntentRouter;
import com.wok.supportbot.service.SystemConfigService;
import jakarta.annotation.Resource;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.document.Document;
@ -34,12 +35,18 @@ public class ChatPipeline {
/** IntentRouter 判为 CHITCHAT 的置信度阈值,低于此值视为不确定,继续走 RAG */
private static final double CHITCHAT_CONFIDENCE_THRESHOLD = 0.6;
/** FAQ 意图高置信度阈值:IntentRouter 返回 FAQ 且高于此值时,仅走 FAQ 匹配,不降级 RAG */
private static final double FAQ_HIGH_CONFIDENCE_THRESHOLD = 0.8;
@Resource
private IntentRouter intentRouter;
@Resource
private RagPipeline ragPipeline;
@Resource
private SystemConfigService systemConfigService;
/**
* 编排一次对话请求产出执行决策
* <p>
@ -62,8 +69,25 @@ public class ChatPipeline {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty());
}
// 意图路由寒暄/闲聊跳过 KB 检索避免召回"最相似但实际无关"的片段
if (shouldBypassRag(ctx.message())) {
// 意图路由先用 IntentRouter 做细粒度分类
IntentRouter.IntentResult intent = routeIntent(ctx.message());
// FAQ 高置信度仅走 FAQ 三级匹配不降级 RAG避免 LLM 代为生成不准确的答案
if (intent != null && "FAQ".equals(intent.getIntent())
&& intent.getConfidence() >= FAQ_HIGH_CONFIDENCE_THRESHOLD) {
Optional<String> faqAnswer = ragPipeline.tryFaqMatch(ctx.message());
if (faqAnswer.isPresent()) {
log.info("FAQ 高置信({}),命中标准答案: chatId={}", intent.getConfidence(), ctx.chatId());
return new ChatRequest(ctx, ctx.message(), baseSystem, faqAnswer);
}
log.info("FAQ 意图高置信({})但未命中,返回兜底提示: chatId={}", intent.getConfidence(), ctx.chatId());
return new ChatRequest(ctx, ctx.message(), baseSystem,
Optional.of("抱歉,我未能找到与您问题精确匹配的答案。建议您换个方式描述您的问题,或联系人工客服获取帮助。"));
}
// 寒暄/闲聊IntentRouter 判定 CHITCHAT 高置信跳过 KB 检索
if (intent != null && "CHITCHAT".equals(intent.getIntent())
&& intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
return new ChatRequest(ctx, ctx.message(), baseSystem, Optional.empty());
}
@ -79,10 +103,24 @@ public class ChatPipeline {
}
/**
* 判断是否跳过 KB 检索
* <p>
* 先用寒暄词列表做快速路径 LLM 开销同时作为 IntentRouter 失败的兜底
* 未命中再调 {@link IntentRouter} 做细粒度意图分类
* 意图路由先用寒暄词列表做快速路径未命中再调 IntentRouter LLM 分类
* 异常时返回 null调用方默认走 RAG 检索
*/
private IntentRouter.IntentResult routeIntent(String message) {
// 寒暄词快速路径 LLM 开销
if (isChitchat(message)) {
return new IntentRouter.IntentResult("CHITCHAT", 1.0);
}
try {
return intentRouter.route(message);
} catch (Exception e) {
log.debug("意图路由异常,沿用 RAG 检索: {}", e.getMessage());
return null;
}
}
/**
* 判断是否跳过 KB 检索保留旧方法签名供 retrieveSources 等使用
*/
private boolean shouldBypassRag(String message) {
if (isChitchat(message)) {
@ -90,9 +128,7 @@ public class ChatPipeline {
}
try {
IntentRouter.IntentResult intent = intentRouter.route(message);
if ("CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD) {
return true;
}
return "CHITCHAT".equals(intent.getIntent()) && intent.getConfidence() >= CHITCHAT_CONFIDENCE_THRESHOLD;
} catch (Exception e) {
log.debug("意图路由异常,沿用 RAG: {}", e.getMessage());
}
@ -127,15 +163,26 @@ public class ChatPipeline {
}
/**
* 组合系统提示词基础客服提示词 + 角色人设
* {@code AssistantApp.effectiveSystem} 保持一致阶段四统一为单一实现
* 当前基础提示词为空串仅有角色人设时返回 {@code "\n\n【当前角色设定】\n" + rolePrompt}
* 组合系统提示词全局系统提示词 system_config 动态读取 + 角色人设
* 修改 system_config config_key = 'ai_system_prompt' 的配置后即时生效无需重启
*/
private String effectiveSystem(String rolePrompt) {
if (!StringUtils.hasText(rolePrompt)) {
return "";
StringBuilder sb = new StringBuilder();
// 全局系统提示词 DB system_config 表动态读取
String globalPrompt = systemConfigService.getValueByKey("ai_system_prompt");
if (StringUtils.hasText(globalPrompt)) {
sb.append(globalPrompt);
}
// 角色人设
if (StringUtils.hasText(rolePrompt)) {
if (sb.length() > 0) {
sb.append("\n\n");
}
sb.append("【当前角色设定】\n").append(rolePrompt);
}
return "\n\n【当前角色设定】\n" + rolePrompt;
return sb.toString();
}
/**

28
src/main/java/com/wok/supportbot/config/ChatModelFactory.java

@ -15,8 +15,12 @@ import org.springframework.ai.openai.OpenAiChatModel;
import org.springframework.ai.openai.OpenAiChatOptions;
import org.springframework.ai.openai.api.OpenAiApi;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.http.client.JdkClientHttpRequestFactory;
import org.springframework.stereotype.Component;
import org.springframework.web.client.RestClient;
import java.net.http.HttpClient;
import java.time.Duration;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
@ -60,6 +64,24 @@ public class ChatModelFactory {
"openai", "https://api.openai.com"
);
/** AI 模型调用连接超时 */
private static final Duration AI_CONNECT_TIMEOUT = Duration.ofSeconds(10);
/** AI 模型调用读取超时,含流式生成时间 */
private static final Duration AI_READ_TIMEOUT = Duration.ofSeconds(120);
/**
* 构建带超时配置的 RestClient.Builder DashScope OpenAI 兼容路径共用
* 通过 JDK HttpClient 注入 connectTimeout同时设置 JdkClientHttpRequestFactory readTimeout
*/
private static RestClient.Builder timeoutRestClientBuilder() {
var httpClient = HttpClient.newBuilder()
.connectTimeout(AI_CONNECT_TIMEOUT)
.build();
var requestFactory = new JdkClientHttpRequestFactory(httpClient);
requestFactory.setReadTimeout(AI_READ_TIMEOUT);
return RestClient.builder().requestFactory(requestFactory);
}
/**
* 各提供商的 Chat 完成路径默认 /v1/chat/completions
* baseUrl 已含版本路径段的厂商moonshot/volcengine/zhipu需设为 /chat/completions
@ -127,7 +149,8 @@ public class ChatModelFactory {
config.getTemperature(), config.getMaxTokens(), baseUrl);
DashScopeApi.Builder apiBuilder = DashScopeApi.builder()
.apiKey(config.getApiKey());
.apiKey(config.getApiKey())
.restClientBuilder(timeoutRestClientBuilder());
// 团队版/私有化部署使用自定义 baseUrl
if (baseUrl != null && !baseUrl.isBlank()) {
apiBuilder.baseUrl(baseUrl);
@ -159,7 +182,8 @@ public class ChatModelFactory {
var apiBuilder = OpenAiApi.builder()
.apiKey(config.getApiKey())
.baseUrl(baseUrl);
.baseUrl(baseUrl)
.restClientBuilder(timeoutRestClientBuilder());
// 按提供商设置 Chat 完成路径默认 /v1/chat/completionsbaseUrl 已含版本段的厂商用 /chat/completions
if (completionsPath != null) {
apiBuilder.completionsPath(completionsPath);

7
src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java

@ -1135,6 +1135,13 @@ public class DatabaseInitConfig {
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "disclaimer", defaultDisclaimer, "SDK 聊天窗口底部的保密声明");
// AI 对话全局系统提示词种子数据为空则不注入
jdbcTemplate.update("""
INSERT INTO system_config (config_key, config_value, description)
VALUES (?, ?, ?)
ON CONFLICT (config_key) DO NOTHING
""", "ai_system_prompt", "", "AI 对话全局系统提示词为空则不注入修改后即时生效无需重启");
}
/**

69
src/main/java/com/wok/supportbot/controller/AiController.java

@ -298,6 +298,75 @@ public class AiController {
private record AccountRoleContext(String accountId, Long roleId) {
}
// ==================== 标准端点 ====================
/**
* 同步对话标准路径
* 根据 enableRag 参数自动选择普通对话或 RAG 增强对话
*/
@GetMapping("/chat")
public String chatSync(
@RequestParam String message,
@RequestParam(required = false) String chatId,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) String systemPrompt,
@RequestParam(required = false) Boolean enableRag,
@RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds) {
ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
categoryId, categoryIds, systemPrompt);
} else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
}
return assistantApp.chat(ctx);
}
/**
* SSE 流式对话标准路径
* 统一 SSE 入口通过 enableRag 控制是否启用知识库增强
*/
@GetMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<String> chatStream(
@RequestParam String message,
@RequestParam(required = false) String chatId,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) String systemPrompt,
@RequestParam(required = false) Boolean enableRag,
@RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds) {
ChatContext ctx;
if (Boolean.TRUE.equals(enableRag)) {
ctx = buildRagChatContext(message, chatId, rewriteStrategy, roleId, accountId,
categoryId, categoryIds, systemPrompt);
} else {
ctx = buildChatContext(message, chatId, roleId, accountId, systemPrompt);
}
ctx = new ChatContext(ctx.message(), ctx.chatId(), ctx.appType(), ctx.systemPrompt(),
ctx.allowedMcpTools(), ctx.categoryIds(), ctx.rewriteStrategy(), ctx.enableRag(), true);
return assistantApp.chatStream(ctx);
}
/**
* RAG 引用来源标准路径
*/
@GetMapping("/chat/sources")
public Map<String, Object> chatSources(
@RequestParam String message,
@RequestParam(required = false) String chatId,
@RequestParam(required = false) String rewriteStrategy,
@RequestParam(required = false) Long roleId,
@RequestParam(required = false) String accountId,
@RequestParam(required = false) Long categoryId,
@RequestParam(required = false) String categoryIds) {
return getRagSources(message, chatId, rewriteStrategy, roleId, accountId, categoryId, categoryIds);
}
// ==================== SDK 公开接口 ====================
/**

4
src/main/java/com/wok/supportbot/rag/RagPipeline.java

@ -159,9 +159,9 @@ public class RagPipeline {
/**
* 尝试 FAQ 三级匹配精确关键词语义命中返回标准答案
* 异常时降级为未命中继续走 RAG 检索
* 异常时降级为未命中 ChatPipeline 等调用方直接获取 FAQ 匹配结果
*/
private Optional<String> tryFaqMatch(String message) {
public Optional<String> tryFaqMatch(String message) {
try {
return faqMatchEngine.match(message)
.map(result -> result.getFaq().getAnswer());

17
src/main/java/com/wok/supportbot/security/SdkAuthFilter.java

@ -9,6 +9,7 @@ import jakarta.servlet.ServletException;
import jakarta.servlet.http.HttpServletRequest;
import jakarta.servlet.http.HttpServletResponse;
import lombok.extern.slf4j.Slf4j;
import org.slf4j.MDC;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.core.Ordered;
import org.springframework.core.annotation.Order;
@ -19,6 +20,8 @@ import java.io.IOException;
import java.util.Map;
import java.util.Set;
import java.util.UUID;
/**
* SDK 鉴权过滤器
* 拦截 /ai/** 请求校验 SDK JWT Token
@ -52,6 +55,11 @@ public class SdkAuthFilter extends OncePerRequestFilter {
protected void doFilterInternal(HttpServletRequest request,
HttpServletResponse response,
FilterChain chain) throws ServletException, IOException {
// 生成全链路 Trace ID注入 MDC 和响应头
String traceId = generateTraceId();
MDC.put("traceId", traceId);
response.setHeader("X-Trace-Id", traceId);
try {
// 提取 Authorization: Bearer {token}
String authHeader = request.getHeader("Authorization");
@ -98,8 +106,15 @@ public class SdkAuthFilter extends OncePerRequestFilter {
chain.doFilter(request, response);
} finally {
// 预留清理点
MDC.clear();
}
}
/**
* 生成简短 Trace ID UUID 12 兼顾可读性和唯一性
*/
private static String generateTraceId() {
return UUID.randomUUID().toString().replace("-", "").substring(0, 12);
}
/**

2
src/main/resources/application.yml

@ -105,7 +105,7 @@ knife4j:
# 具体的 logging.level 在各环境 yml 中配置
logging:
pattern:
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] %-5level %logger{36} - %msg%n"
console: "%d{yyyy-MM-dd HH:mm:ss} [%thread] [%X{traceId}] %-5level %logger{36} - %msg%n"
# ==================== JWT 认证配置 ====================
jwt:

150
src/main/resources/static/components/PipelineFlow.js

@ -0,0 +1,150 @@
/**
* AI 对话执行链路流程图组件
* 使用 Mermaid.js 渲染从用户请求到 AI 回复的完整处理流程
*/
import { ref, onMounted, nextTick } from 'vue'
import mermaid from 'mermaid'
// 初始化 Mermaid 主题,匹配后台管理 UI 风格
mermaid.initialize({
startOnLoad: false,
theme: 'base',
themeVariables: {
primaryColor: '#f3f4f6',
primaryTextColor: '#1f2937',
primaryBorderColor: '#d1d5db',
lineColor: '#6b7280',
secondaryColor: '#dbeafe',
tertiaryColor: '#fef3c7',
fontSize: '13px'
},
flowchart: {
useMaxWidth: true,
htmlLabels: true,
curve: 'basis'
}
})
// Mermaid 流程图 DSL 定义
// 节点类型: [矩形]=处理步骤, {菱形}=决策分支, subgraph=子系统
const GRAPH_DEFINITION = `
flowchart TD
A["<b>用户请求</b><br/>message + roleId + accountId + chatId"]
A --> B{"<b>Controller</b><br/>鉴权 / 角色解析<br/>构建 ChatContext"}
B --> C["<b>ChatPipeline.buildRequest</b><br/>编排决策入口"]
C --> D{"enableRag ?"}
D -- "❌ false" --> E["<b>模式: 纯对话</b><br/>systemPrompt(角色人设)<br/>不检索知识库"]
D -- "✅ true" --> F["<b>IntentRouter</b><br/>LLM 意图识别<br/>FAQ / RAG / CHITCHAT"]
F --> G{"意图分类结果"}
G -- "FAQ<br/>confidence ≧ 0.8" --> H["<b>FaqMatchEngine</b><br/>三级匹配策略<br/>精确 → 关键词 → 向量语义"]
G -- "CHITCHAT<br/>confidence ≧ 0.6" --> I["<b>模式: 纯对话</b><br/>跳过知识库检索<br/>不注入资料块"]
G -- "RAG / 降级<br/>其余情况" --> J["<b>RagPipeline.retrieve</b><br/>RAG 检索流水线入口"]
subgraph RAG["📚 RAG 检索流水线"]
J --> K["<b>1. FAQ 优先匹配</b><br/>FaqMatchEngine 三级匹配<br/>命中则短路返回"]
K --> L["<b>2. 查询重写</b><br/>REWRITE / TRANSLATION<br/>COMPRESSION / MULTI_QUERY"]
L --> M{"<b>3. 检索模式</b>"}
M --> N["<b>VECTOR</b><br/>向量语义检索"]
M --> O["<b>KEYWORD</b><br/>全文关键词检索<br/>PostgreSQL tsvector"]
M --> P["<b>HYBRID</b><br/>向量 + 关键词<br/>双路检索"]
N --> Q["<b>4. RRF 融合排序</b><br/>Reciprocal Rank Fusion<br/>k=60"]
O --> Q
P --> Q
Q --> R["<b>5. Reranker 重排序</b><br/>DashScope / OpenAI 兼容<br/>超时 3s 自动 fallback"]
R --> S["<b>6. 构建资料块</b><br/>拼接检索文档<br/>注入 system prompt 末尾"]
end
H --> T["<b>组装 ChatRequest</b><br/>finalMessage + finalSystemPrompt<br/>+ faqAnswer (可选)"]
I --> T
S --> T
E --> T
T --> U["<b>AssistantApp</b><br/>chat / chatStream<br/>构建 ChatClient + MCP 工具"]
subgraph ADVISOR["🛡️ Advisor 链(环绕 LLM 调用)"]
U --> V["<b>ContentSafetyAdvisor</b><br/>🔽 before: DFA 敏感词检测<br/>用户输入 BLOCK/MASK"]
V --> W["<b>MessageChatMemoryAdvisor</b><br/>注入历史对话记忆<br/>DatabaseChatMemory 持久化"]
W --> X["<b>MyLoggerAdvisor</b><br/>🔽 before: 请求日志<br/>INFO: AI Request: ..."]
X --> Y["<b>🤖 ChatClient.call / stream</b><br/>LLM 大模型调用<br/>通义千问 / 其他提供商"]
Y --> Z["<b>MyLoggerAdvisor</b><br/>🔼 after: 响应日志<br/>INFO: AI Response: ..."]
Z --> AA["<b>ContentSafetyAdvisor</b><br/>🔼 after: AI 输出检测<br/>BLOCK/MASK 违规内容"]
end
AA --> AB["<b>返回 AI 回复</b><br/>SSE 流式输出<br/>+ MCP 工具调用事件"]
style A fill:#dbeafe,stroke:#3b82f6,stroke-width:2px
style AB fill:#d1fae5,stroke:#10b981,stroke-width:2px
style Y fill:#fef3c7,stroke:#f59e0b,stroke-width:2px
`
export default {
template: `
<div>
<!-- 图例卡片 -->
<div class="card">
<h2>AI 对话执行链路</h2>
<p style="font-size:12px;color:var(--sub);margin:4px 0 16px;">
下图展示从用户请求到 AI 回复的完整处理流程包含意图路由RAG 检索和 Advisor
菱形节点 = 决策分支 · 虚线框 = 独立子系统 · /绿/黄色高亮 = 入口/出口/LLM
</p>
<div class="pipeline-legend">
<div class="legend-item"><span class="legend-dot legend-input"></span> 用户请求 / 入口</div>
<div class="legend-item"><span class="legend-dot legend-decision"></span> </div>
<div class="legend-item"><span class="legend-dot legend-process"></span> </div>
<div class="legend-item"><span class="legend-dot legend-rag"></span> RAG </div>
<div class="legend-item"><span class="legend-dot legend-advisor"></span> Advisor </div>
<div class="legend-item"><span class="legend-dot legend-output"></span> </div>
<div class="legend-item"><span class="legend-dot legend-llm"></span> LLM </div>
</div>
</div>
<!-- 流程图卡片 -->
<div class="card">
<div v-if="error" class="pipeline-error">
<p> 图表渲染失败: {{ error }}</p>
<button class="btn btn-outline btn-sm" @click="retry">重试</button>
</div>
<div v-else-if="!svg" class="pipeline-loading">
<p> 正在生成流程图...</p>
</div>
<div v-else class="pipeline-diagram" v-html="svg"></div>
</div>
</div>
`,
setup() {
const svg = ref('')
const error = ref('')
async function render() {
error.value = ''
svg.value = ''
await nextTick()
try {
const { svg: result } = await mermaid.render('pipeline-graph', GRAPH_DEFINITION)
svg.value = result
} catch (e) {
console.error('Mermaid 渲染失败:', e)
error.value = e.message || '未知渲染错误'
}
}
function retry() {
render()
}
onMounted(() => {
render()
})
return { svg, error, retry }
}
}

55
src/main/resources/static/css/main.css

@ -528,3 +528,58 @@ body { font-family:-apple-system,BlinkMacSystemFont,"Segoe UI","Microsoft YaHei"
.tag-suggestions .tag-suggestion-item:hover {
background: #f3f4f6;
}
/* ==================== AI 执行链流程图 ==================== */
.pipeline-diagram {
width: 100%;
overflow-x: auto;
padding: 8px 0;
}
.pipeline-diagram svg {
max-width: 100%;
height: auto;
display: block;
margin: 0 auto;
}
.pipeline-loading {
text-align: center;
padding: 40px;
color: var(--muted);
font-size: 14px;
}
.pipeline-error {
text-align: center;
padding: 24px;
color: var(--danger);
font-size: 14px;
}
.pipeline-error .btn {
margin-top: 12px;
}
/* 图例 */
.pipeline-legend {
display: flex;
flex-wrap: wrap;
gap: 10px 20px;
}
.legend-item {
display: flex;
align-items: center;
gap: 8px;
font-size: 12px;
color: var(--sub);
}
.legend-dot {
width: 14px;
height: 14px;
border-radius: 3px;
flex: none;
}
.legend-input { background: #dbeafe; border: 1px solid #3b82f6; }
.legend-decision { background: #fef3c7; border: 1px solid #f59e0b; transform: rotate(45deg); border-radius: 2px; }
.legend-process { background: #f3f4f6; border: 1px solid #d1d5db; }
.legend-rag { background: #e0e7ff; border: 1px solid #a5b4fc; }
.legend-advisor { background: #fce7f3; border: 1px solid #f9a8d4; }
.legend-output { background: #d1fae5; border: 1px solid #10b981; }
.legend-llm { background: #fef3c7; border: 1px solid #f59e0b; }

3
src/main/resources/static/index.html

@ -11,7 +11,8 @@
"vue": "https://unpkg.com/vue@3/dist/vue.esm-browser.prod.js",
"marked": "https://cdn.jsdelivr.net/npm/marked@12/+esm",
"dompurify": "https://cdn.jsdelivr.net/npm/dompurify@3/+esm",
"chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm"
"chart.js": "https://cdn.jsdelivr.net/npm/chart.js@4/+esm",
"mermaid": "https://cdn.jsdelivr.net/npm/mermaid@11/dist/mermaid.esm.min.mjs"
}
}
</script>

7
src/main/resources/static/js/app.js

@ -29,6 +29,7 @@ import ApiKeyManager from '../components/ApiKeyManager.js'
import WebhookManager from '../components/WebhookManager.js'
import McpServerManager from '../components/McpServerManager.js'
import SystemConfigManager from '../components/SystemConfigManager.js'
import PipelineFlow from '../components/PipelineFlow.js'
const app = createApp({
setup() {
@ -312,6 +313,11 @@ const app = createApp({
<system-config-manager></system-config-manager>
</div>
<!-- AI 执行链 -->
<div v-if="store.activePage === 'pipeline-flow'" class="page-pane" style="animation: fadeIn .3s ease;">
<pipeline-flow></pipeline-flow>
</div>
<!-- 文档详情弹窗 -->
<doc-detail></doc-detail>
</main>
@ -344,5 +350,6 @@ app.component('api-key-manager', ApiKeyManager)
app.component('webhook-manager', WebhookManager)
app.component('mcp-server-manager', McpServerManager)
app.component('system-config-manager', SystemConfigManager)
app.component('pipeline-flow', PipelineFlow)
app.mount('#app')

2
src/main/resources/static/js/store.js

@ -49,6 +49,7 @@ export const MENU_ITEMS = [
{ id: 'api-key', label: 'API Key 管理', icon: '🔑', roles: ['admin'] },
{ id: 'webhook', label: 'Webhook 管理', icon: '🔔', roles: ['admin'] },
{ id: 'mcp-server', label: 'MCP 服务管理', icon: '🔌', roles: ['admin'] },
{ id: 'pipeline-flow',label: 'AI 执行链', icon: '🔀' },
{ id: 'system-config',label: '系统配置', icon: '🔧', roles: ['admin'] }
]
}
@ -248,6 +249,7 @@ export const store = reactive({
case 'webhook':
case 'mcp-server':
case 'system-config':
case 'pipeline-flow':
// 新页面无额外数据预加载
break
}

72
src/main/resources/static/sdk/chatbot-sdk.js

@ -230,6 +230,7 @@ var ChatbotSDK = (function () {
resize: '拖拽缩放窗口',
// 错误提示
error_network: '网络连接失败,请检查网络',
error_offline: '⚠️ 网络连接异常,请检查网络后重试',
error_timeout: '请求超时,请稍后重试',
error_server: '服务器异常,请稍后重试',
error_cors: '跨域请求被拦截,请联系管理员将当前域名加入 API 白名单',
@ -297,6 +298,7 @@ var ChatbotSDK = (function () {
resize: 'Resize window',
// Errors
error_network: 'Network connection failed',
error_offline: '⚠️ Connection lost. Please check your network and try again.',
error_timeout: 'Request timed out, please try again',
error_server: 'Server error, please try again later',
error_cors: 'CORS request blocked. Please contact admin to whitelist your domain',
@ -581,6 +583,8 @@ var ChatbotSDK = (function () {
const decoder = new TextDecoder('utf-8', { stream: true });
let buffer = '';
let eventLines = [];
/** 当前 SSE 事件类型(event:xxx 行声明的类型),空=默认 message 事件 */
let currentEventType = '';
try {
while (true) {
const { done, value } = await reader.read();
@ -594,28 +598,40 @@ var ChatbotSDK = (function () {
// 空行 = SSE 事件边界,刷新当前事件
if (!trimmed) {
if (eventLines.length > 0) {
// 非内容事件(status 等)不回调 onChunk,避免污染 AI 回复渲染
if (currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
}
eventLines = [];
}
currentEventType = '';
continue;
}
// 忽略注释行 (: ...)
if (trimmed.startsWith(':'))
if (trimmed.startsWith(':')) {
// TODO: 信号行 ":ok" 或其他
continue;
// [DONE] 信号
}
// [DONE] 信号(结束整次 SSE 流)
if (trimmed === '[DONE]') {
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
eventLines = [];
}
currentEventType = '';
break;
}
// 跳过 SSE 协议字段
if (trimmed.startsWith('event:') || trimmed.startsWith('id:') || trimmed.startsWith('retry:'))
// SSE 协议字段:event / id / retry
if (trimmed.startsWith('event:')) {
// 记录当前事件类型,用于决定是否将后续 data 行传递给 AI 渲染
currentEventType = trimmed.substring('event:'.length).trim();
continue;
}
if (trimmed.startsWith('id:') || trimmed.startsWith('retry:'))
continue;
// 提取 content(data: 前缀行去掉前缀,裸行为 Flux 元素内换行的续行)
let content;
@ -646,7 +662,7 @@ var ChatbotSDK = (function () {
}
}
// 处理缓冲区剩余
if (eventLines.length > 0) {
if (eventLines.length > 0 && currentEventType !== 'status' && currentEventType !== 'tool_call_result') {
const chunk = eventLines.join('\n');
totalText += chunk;
onChunk(chunk);
@ -1262,6 +1278,27 @@ var ChatbotSDK = (function () {
}
.csk-messages::-webkit-scrollbar-thumb:hover { background: #C2C7D0; }
/* 离线提示横幅 */
.csk-offline-banner {
display: flex;
align-items: center;
justify-content: center;
gap: 6px;
padding: 8px 12px;
margin: 0 0 8px 0;
border-radius: 8px;
font-size: 12px;
line-height: 1.5;
background: #FEF2F2;
color: #991B1B;
border: 1px solid #FECACA;
}
.csk-dark .csk-offline-banner {
background: #450A0A;
color: #FCA5A5;
border-color: #7F1D1D;
}
/* 欢迎空状态 */
.csk-welcome {
display: flex;
@ -3533,6 +3570,22 @@ var ChatbotSDK = (function () {
function scrollToBottom(container) {
container.scrollTop = container.scrollHeight;
}
/** 在消息区顶部展示离线提示横幅 */
function showOfflineBanner(messagesContainer) {
if (document.getElementById('csk-offline-banner'))
return;
const banner = document.createElement('div');
banner.id = 'csk-offline-banner';
banner.className = 'csk-offline-banner';
banner.textContent = t('error_offline');
messagesContainer.insertBefore(banner, messagesContainer.firstChild);
}
/** 隐藏离线提示横幅 */
function hideOfflineBanner() {
const el = document.getElementById('csk-offline-banner');
if (el)
el.remove();
}
const STORAGE_PREFIX = 'csk_history_';
const MAX_MESSAGES = 200;
@ -4258,6 +4311,8 @@ var ChatbotSDK = (function () {
// RAG 引用来源
if (shouldUseRag)
fetchAndRenderSources(userText, aiMsg);
// 发送成功后清除离线横幅(网络已恢复)
hideOfflineBanner();
}
catch (err) {
if (hideLoadingFn$1)
@ -4325,6 +4380,11 @@ var ChatbotSDK = (function () {
}
resolve(accumulated);
}, (error) => {
// 离线检测:网络不可达时展示横幅提示,下次发送成功自动清除
if (error.type === 'network' || error.type === 'cors') {
if (messagesContainer$1)
showOfflineBanner(messagesContainer$1);
}
if (accumulated.length > 0) {
if (bubbleEl) {
bubbleEl.innerHTML = renderMarkdown(accumulated + '\n\n' + t('stream_interrupted'));

2
src/main/resources/static/sdk/chatbot-sdk.js.map
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File

2
src/main/resources/static/sdk/chatbot-sdk.min.js.map
File diff suppressed because it is too large
View File

Loading…
Cancel
Save