24 changed files with 1277 additions and 53 deletions
-
160client/dist/chatbot-sdk.js
-
2client/dist/chatbot-sdk.min.js
-
25client/src/api.ts
-
27client/src/chat.ts
-
1client/src/config.ts
-
59client/src/dom.ts
-
40client/src/styles.ts
-
4client/src/types.ts
-
23frontend/src/api/chat.ts
-
26frontend/src/views/ChatPanel.vue
-
20pom.xml
-
9src/main/java/com/wok/supportbot/app/AssistantApp.java
-
12src/main/java/com/wok/supportbot/app/ChatPipeline.java
-
17src/main/java/com/wok/supportbot/app/ChatResult.java
-
201src/main/java/com/wok/supportbot/app/SuggestionGenerator.java
-
285src/main/java/com/wok/supportbot/app/SuggestionResponseParser.java
-
114src/main/java/com/wok/supportbot/cache/SuggestionCache.java
-
27src/main/java/com/wok/supportbot/config/DatabaseInitConfig.java
-
28src/main/java/com/wok/supportbot/controller/AiController.java
-
39src/main/java/com/wok/supportbot/controller/ConversationController.java
-
31src/main/java/com/wok/supportbot/controller/OpenApiController.java
-
10src/main/resources/logback-spring.xml
-
160src/main/resources/static/sdk/chatbot-sdk.js
-
2src/main/resources/static/sdk/chatbot-sdk.min.js
2
client/dist/chatbot-sdk.min.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
@ -0,0 +1,201 @@ |
|||||
|
package com.wok.supportbot.app; |
||||
|
|
||||
|
import com.wok.supportbot.advisor.MyLoggerAdvisor; |
||||
|
import com.wok.supportbot.cache.SuggestionCache; |
||||
|
import com.wok.supportbot.chatmemory.DatabaseChatMemory; |
||||
|
import com.wok.supportbot.config.ChatModelFactory; |
||||
|
import com.wok.supportbot.service.SystemConfigService; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.ai.chat.client.ChatClient; |
||||
|
import org.springframework.ai.chat.messages.Message; |
||||
|
import org.springframework.ai.chat.messages.MessageType; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
import org.springframework.util.StringUtils; |
||||
|
|
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
import java.util.concurrent.CompletableFuture; |
||||
|
import java.util.concurrent.TimeUnit; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 推荐问题异步生成器。 |
||||
|
* <p> |
||||
|
* AI 主回复不再输出 suggestions;前端在 AI 回复结束后调用 suggestions 接口时, |
||||
|
* 本组件基于该会话的历史消息异步调用 LLM 生成 3 条推荐问题。 |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
@Component |
||||
|
public class SuggestionGenerator { |
||||
|
|
||||
|
/** 生成超时时间(秒),超过后返回空列表,避免前端长时间等待 */ |
||||
|
private static final long GENERATE_TIMEOUT_SECONDS = 15; |
||||
|
|
||||
|
/** 读取历史消息条数 */ |
||||
|
private static final int HISTORY_SIZE = 10; |
||||
|
|
||||
|
private final ChatModelFactory chatModelFactory; |
||||
|
private final DatabaseChatMemory chatMemory; |
||||
|
private final SuggestionCache suggestionCache; |
||||
|
private final SystemConfigService systemConfigService; |
||||
|
|
||||
|
/** 懒加载的独立 ChatClient,不带 MCP 工具和内容安全 Advisor */ |
||||
|
private volatile ChatClient chatClient; |
||||
|
|
||||
|
public SuggestionGenerator(ChatModelFactory chatModelFactory, |
||||
|
DatabaseChatMemory chatMemory, |
||||
|
SuggestionCache suggestionCache, |
||||
|
SystemConfigService systemConfigService) { |
||||
|
this.chatModelFactory = chatModelFactory; |
||||
|
this.chatMemory = chatMemory; |
||||
|
this.suggestionCache = suggestionCache; |
||||
|
this.systemConfigService = systemConfigService; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 生成推荐问题列表。 |
||||
|
* <ol> |
||||
|
* <li>若功能未启用,直接返回空列表。</li> |
||||
|
* <li>若缓存命中,直接返回缓存结果。</li> |
||||
|
* <li>若同一 chatId 正在生成,等待已有任务结果。</li> |
||||
|
* <li>否则启动新任务生成,超时后降级为空列表。</li> |
||||
|
* </ol> |
||||
|
* |
||||
|
* @param chatId 会话 ID |
||||
|
* @return 推荐问题列表(最多 3 条) |
||||
|
*/ |
||||
|
public List<String> generate(String chatId) { |
||||
|
if (!StringUtils.hasText(chatId)) { |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
if (!isEnabled()) { |
||||
|
log.debug("推荐问题功能未启用(suggestion_enabled != true),跳过生成: chatId={}", chatId); |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
// 1. 优先读缓存 |
||||
|
List<String> cached = suggestionCache.get(chatId).orElse(null); |
||||
|
if (cached != null) { |
||||
|
return cached; |
||||
|
} |
||||
|
|
||||
|
// 2. 创建生成任务并注册到缓存(避免并发重复生成) |
||||
|
CompletableFuture<List<String>> future = suggestionCache.putIfAbsent(chatId, |
||||
|
CompletableFuture.supplyAsync(() -> doGenerate(chatId))); |
||||
|
|
||||
|
try { |
||||
|
List<String> result = future.get(GENERATE_TIMEOUT_SECONDS, TimeUnit.SECONDS); |
||||
|
return result != null ? result : Collections.emptyList(); |
||||
|
} catch (java.util.concurrent.CancellationException e) { |
||||
|
log.debug("推荐问题生成被取消: chatId={}", chatId); |
||||
|
return Collections.emptyList(); |
||||
|
} catch (Exception e) { |
||||
|
log.warn("推荐问题生成失败或超时: chatId={}", chatId, e); |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 实际执行 LLM 生成。 |
||||
|
*/ |
||||
|
private List<String> doGenerate(String chatId) { |
||||
|
try { |
||||
|
List<Message> history = chatMemory.get(chatId, HISTORY_SIZE); |
||||
|
if (history == null || history.isEmpty()) { |
||||
|
log.info("无历史消息,跳过推荐问题生成: chatId={}", chatId); |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
String historyText = formatHistory(history); |
||||
|
String prompt = buildPrompt(historyText); |
||||
|
|
||||
|
String raw = getChatClient().prompt() |
||||
|
.system(prompt) |
||||
|
.user("请根据历史对话生成推荐问题") |
||||
|
.call() |
||||
|
.chatResponse() |
||||
|
.getResult() |
||||
|
.getOutput() |
||||
|
.getText(); |
||||
|
|
||||
|
List<String> suggestions = SuggestionResponseParser.parseSuggestionsJson(raw.strip()); |
||||
|
log.info("推荐问题生成成功: chatId={}, count={}", chatId, suggestions.size()); |
||||
|
return suggestions; |
||||
|
} catch (Exception e) { |
||||
|
log.warn("推荐问题生成异常: chatId={}", chatId, e); |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 格式化历史消息为纯文本上下文。 |
||||
|
*/ |
||||
|
private String formatHistory(List<Message> history) { |
||||
|
return history.stream() |
||||
|
.map(msg -> { |
||||
|
String role = mapRole(msg.getMessageType()); |
||||
|
String text = msg.getText(); |
||||
|
return role + ": " + text; |
||||
|
}) |
||||
|
.collect(Collectors.joining("\n")); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 将 Spring AI MessageType 映射为中文角色名。 |
||||
|
*/ |
||||
|
private String mapRole(MessageType messageType) { |
||||
|
if (messageType == MessageType.USER) { |
||||
|
return "用户"; |
||||
|
} |
||||
|
if (messageType == MessageType.ASSISTANT) { |
||||
|
return "AI"; |
||||
|
} |
||||
|
if (messageType == MessageType.SYSTEM) { |
||||
|
return "系统"; |
||||
|
} |
||||
|
return "其他"; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 构建生成推荐问题的完整提示词。 |
||||
|
*/ |
||||
|
private String buildPrompt(String historyText) { |
||||
|
String basePrompt = systemConfigService.getValueByKey("suggestion_prompt"); |
||||
|
if (!StringUtils.hasText(basePrompt)) { |
||||
|
basePrompt = "请根据历史对话,生成 3 条用户最可能继续提问的推荐问题。"; |
||||
|
} |
||||
|
|
||||
|
return basePrompt + "\n\n" + |
||||
|
"历史对话:\n" + historyText + "\n\n" + |
||||
|
"输出要求:\n" + |
||||
|
"1. 问题必须与对话主题相关,引导用户深入咨询\n" + |
||||
|
"2. 每条问题用中文,不超过 30 个字\n" + |
||||
|
"3. 只输出 JSON 数组格式,例如:[\"问题1\", \"问题2\", \"问题3\"]\n" + |
||||
|
"4. 不要输出任何其他解释、markdown 代码块或序号"; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 检查 suggestions 功能是否启用。 |
||||
|
*/ |
||||
|
private boolean isEnabled() { |
||||
|
return "true".equals(systemConfigService.getValueByKey("suggestion_enabled")); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取专用于 suggestions 生成的 ChatClient。 |
||||
|
* 不带 MCP 工具、不带内容安全 Advisor,仅注册日志 Advisor。 |
||||
|
*/ |
||||
|
private ChatClient getChatClient() { |
||||
|
if (chatClient == null) { |
||||
|
synchronized (this) { |
||||
|
if (chatClient == null) { |
||||
|
chatClient = ChatClient.builder(chatModelFactory.getChatModel("CHAT")) |
||||
|
.defaultAdvisors(new MyLoggerAdvisor()) |
||||
|
.build(); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
|
return chatClient; |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,285 @@ |
|||||
|
package com.wok.supportbot.app; |
||||
|
|
||||
|
import com.fasterxml.jackson.core.type.TypeReference; |
||||
|
import com.fasterxml.jackson.databind.ObjectMapper; |
||||
|
import com.wok.supportbot.cache.SuggestionCache; |
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import reactor.core.publisher.Flux; |
||||
|
|
||||
|
import java.util.ArrayList; |
||||
|
import java.util.Collections; |
||||
|
import java.util.List; |
||||
|
import java.util.stream.Collectors; |
||||
|
|
||||
|
/** |
||||
|
* 建议问题解析器:从 LLM 原始输出中分离 answer 和 suggestions。 |
||||
|
* <p> |
||||
|
* 哨兵标记为 <code>___SUGGESTIONS___</code>,之后为 JSON 字符串数组。 |
||||
|
* 支持同步路径(直接分割完整文本)和流式路径(滑动窗口检测哨兵)。 |
||||
|
*/ |
||||
|
@Slf4j |
||||
|
public final class SuggestionResponseParser { |
||||
|
|
||||
|
/** 主哨兵:严格匹配提示词要求的下划线分隔标记 */ |
||||
|
private static final String SENTINEL = "___SUGGESTIONS___"; |
||||
|
|
||||
|
/** 兼容哨兵:模型偶尔不按指令输出时的大写变形 */ |
||||
|
private static final String LOOSE_SENTINEL = "SUGGESTIONS"; |
||||
|
|
||||
|
private static final ObjectMapper objectMapper = new ObjectMapper(); |
||||
|
|
||||
|
private SuggestionResponseParser() { |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 查找最佳哨兵位置:优先严格哨兵,其次兼容哨兵。 |
||||
|
* <p> |
||||
|
* 兼容哨兵必须后面紧跟 JSON 数组特征('[' 或空白 + '['), |
||||
|
* 避免正文中出现 "SUGGESTIONS" 普通单词时误触发。 |
||||
|
* |
||||
|
* @param text 要搜索的文本 |
||||
|
* @return 哨兵起始位置,未找到返回 -1 |
||||
|
*/ |
||||
|
private static int findSentinelIndex(String text) { |
||||
|
int strictIdx = text.lastIndexOf(SENTINEL); |
||||
|
if (strictIdx >= 0) { |
||||
|
return strictIdx; |
||||
|
} |
||||
|
|
||||
|
// 兼容模式:从后往前找大写 SUGGESTIONS,且后续需连接 JSON 数组 |
||||
|
int idx = text.lastIndexOf(LOOSE_SENTINEL); |
||||
|
while (idx >= 0) { |
||||
|
int after = idx + LOOSE_SENTINEL.length(); |
||||
|
if (after < text.length()) { |
||||
|
char c = text.charAt(after); |
||||
|
// 允许 SUGGESTIONS[...]、SUGGESTIONS [...]、SUGGESTIONS:\n[...] |
||||
|
if (c == '[' || Character.isWhitespace(c) || c == ':' || c == '-') { |
||||
|
return idx; |
||||
|
} |
||||
|
} |
||||
|
// 继续向前查找更早的兼容哨兵 |
||||
|
idx = text.lastIndexOf(LOOSE_SENTINEL, idx - 1); |
||||
|
} |
||||
|
return -1; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 同步路径:按最后一次出现哨兵的位置分割完整文本。 |
||||
|
* |
||||
|
* @param rawText LLM 原始输出 |
||||
|
* @return 解析结果(answer + suggestions) |
||||
|
*/ |
||||
|
public static ParsedResponse parse(String rawText) { |
||||
|
if (rawText == null || rawText.isEmpty()) { |
||||
|
return new ParsedResponse(rawText != null ? rawText : "", Collections.emptyList()); |
||||
|
} |
||||
|
|
||||
|
int lastIdx = findSentinelIndex(rawText); |
||||
|
if (lastIdx < 0) { |
||||
|
// 无哨兵标记,整段作为 answer |
||||
|
return new ParsedResponse(rawText, Collections.emptyList()); |
||||
|
} |
||||
|
|
||||
|
String answer = rawText.substring(0, lastIdx).trim(); |
||||
|
// 按实际匹配的哨兵长度截取后续内容(严格哨兵或兼容哨兵长度不同) |
||||
|
int matchedSentinelLength = rawText.startsWith(SENTINEL, lastIdx) ? SENTINEL.length() : LOOSE_SENTINEL.length(); |
||||
|
String suggestionsPart = rawText.substring(lastIdx + matchedSentinelLength).trim(); |
||||
|
|
||||
|
List<String> suggestions = parseSuggestionsJson(suggestionsPart); |
||||
|
return new ParsedResponse(answer, suggestions); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 流式路径:从原始 Flux 中分离 answer 和 suggestions。 |
||||
|
* <p> |
||||
|
* 采用「安全缓冲区」策略防止哨兵泄漏到 UI: |
||||
|
* <ol> |
||||
|
* <li>每次 chunk 追加到累积缓冲区</li> |
||||
|
* <li>安全区域 = 缓冲区去掉末尾 SENTINEL.length() 字符(预留哨兵跨 chunk 截断空间)</li> |
||||
|
* <li>在安全区域内检查哨兵:找到则发出哨兵前的剩余内容,后续全进 suggestionsBuffer</li> |
||||
|
* <li>未找到哨兵则发出安全区域内容</li> |
||||
|
* <li>doOnComplete 时:哨兵已发现则解析 suggestions;未发现则发出安全缓冲区内剩余内容</li> |
||||
|
* </ol> |
||||
|
* |
||||
|
* @param rawStream LLM 原始输出流 |
||||
|
* @param cache 建议缓存 |
||||
|
* @param chatId 会话 ID |
||||
|
* @return 纯 answer 的 Flux(不含哨兵及之后的 suggestions) |
||||
|
*/ |
||||
|
/** |
||||
|
* 可变状态容器(供 lambda 内部修改)。 |
||||
|
*/ |
||||
|
private static class StreamState { |
||||
|
final StringBuilder buf = new StringBuilder(); |
||||
|
int emitted = 0; |
||||
|
boolean sentinelFound = false; |
||||
|
} |
||||
|
|
||||
|
public static Flux<String> parseFromStream(Flux<String> rawStream, SuggestionCache cache, String chatId) { |
||||
|
StreamState state = new StreamState(); |
||||
|
|
||||
|
return rawStream |
||||
|
.concatMap(chunk -> Flux.<String>create(sink -> { |
||||
|
if (state.sentinelFound) { |
||||
|
sink.complete(); |
||||
|
return; |
||||
|
} |
||||
|
state.buf.append(chunk); |
||||
|
|
||||
|
int maxSentinelLen = Math.max(SENTINEL.length(), LOOSE_SENTINEL.length()); |
||||
|
int safeEnd = Math.max(0, state.buf.length() - maxSentinelLen); |
||||
|
if (safeEnd <= state.emitted) { |
||||
|
sink.complete(); |
||||
|
return; |
||||
|
} |
||||
|
|
||||
|
String safeRegion = state.buf.substring(state.emitted, safeEnd); |
||||
|
int sentinelIdx = findSentinelIndex(safeRegion); |
||||
|
|
||||
|
if (sentinelIdx >= 0) { |
||||
|
state.sentinelFound = true; |
||||
|
String beforeSentinel = safeRegion.substring(0, sentinelIdx); |
||||
|
if (!beforeSentinel.isEmpty()) { |
||||
|
sink.next(beforeSentinel); |
||||
|
} |
||||
|
// 确定实际匹配到的哨兵长度 |
||||
|
int matchedLen = safeRegion.startsWith(SENTINEL, sentinelIdx) |
||||
|
? SENTINEL.length() |
||||
|
: LOOSE_SENTINEL.length(); |
||||
|
// 哨兵在安全区域内的位置 sentinelIdx,相对全缓冲区即 emitted + sentinelIdx |
||||
|
// emitted 推进到哨兵结束后,后续内容进 suggestions 解析 |
||||
|
state.emitted = state.emitted + sentinelIdx + matchedLen; |
||||
|
log.debug("流式路径检测到哨兵: chatId={}, pos={}, matchedLen={}", chatId, state.emitted, matchedLen); |
||||
|
} else { |
||||
|
if (!safeRegion.isEmpty()) { |
||||
|
sink.next(safeRegion); |
||||
|
} |
||||
|
state.emitted = safeEnd; |
||||
|
} |
||||
|
sink.complete(); |
||||
|
})) |
||||
|
// 流结束后发出安全缓冲区内未发出的残留内容(无哨兵场景) |
||||
|
.concatWith(Flux.defer(() -> { |
||||
|
if (!state.sentinelFound && state.emitted < state.buf.length()) { |
||||
|
String residual = state.buf.substring(state.emitted); |
||||
|
if (!residual.isEmpty()) { |
||||
|
return Flux.just(residual); |
||||
|
} |
||||
|
} |
||||
|
return Flux.empty(); |
||||
|
})) |
||||
|
.doOnComplete(() -> { |
||||
|
if (state.sentinelFound) { |
||||
|
String suggestionsPart = state.buf.length() > state.emitted |
||||
|
? state.buf.substring(state.emitted) : ""; |
||||
|
List<String> suggestions = parseSuggestionsJson(suggestionsPart.strip()); |
||||
|
if (!suggestions.isEmpty()) { |
||||
|
cache.put(chatId, suggestions); |
||||
|
log.info("流式 suggestions 解析成功: chatId={}, count={}", chatId, suggestions.size()); |
||||
|
} else { |
||||
|
log.debug("流式 suggestions 解析为空: chatId={}", chatId); |
||||
|
} |
||||
|
} |
||||
|
}); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 容错解析 suggestions JSON 数组。 |
||||
|
* 先尝试标准 JSON 解析,失败则按行切分取前 3 条非空行。 |
||||
|
*/ |
||||
|
static List<String> parseSuggestionsJson(String jsonPart) { |
||||
|
if (jsonPart == null || jsonPart.isBlank()) { |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
|
||||
|
// 1. 尝试标准 JSON 解析 |
||||
|
String trimmed = jsonPart.strip(); |
||||
|
// 去掉可能的 markdown 代码块包裹 |
||||
|
trimmed = trimCodeBlock(trimmed); |
||||
|
|
||||
|
try { |
||||
|
List<String> parsed = objectMapper.readValue(trimmed, new TypeReference<List<String>>() {}); |
||||
|
List<String> result = new ArrayList<>(); |
||||
|
for (String s : parsed) { |
||||
|
if (s != null && !s.isBlank()) { |
||||
|
result.add(s.strip()); |
||||
|
} |
||||
|
} |
||||
|
if (!result.isEmpty()) { |
||||
|
return result.size() <= 3 ? result : result.subList(0, 3); |
||||
|
} |
||||
|
} catch (Exception e) { |
||||
|
log.debug("标准 JSON 解析 suggestions 失败,尝试按行切分: {}", e.getMessage()); |
||||
|
} |
||||
|
|
||||
|
// 2. 降级:按行切分,取前 3 条 |
||||
|
return parseByLines(trimmed); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 降级解析:按行切分,去掉序号前缀后取前 3 条非空行。 |
||||
|
*/ |
||||
|
private static List<String> parseByLines(String text) { |
||||
|
List<String> lines = text.lines() |
||||
|
.map(String::strip) |
||||
|
.map(SuggestionResponseParser::stripNumberPrefix) |
||||
|
.map(SuggestionResponseParser::stripQuotes) |
||||
|
.filter(s -> !s.isBlank()) |
||||
|
.collect(Collectors.toList()); |
||||
|
|
||||
|
if (lines.isEmpty()) { |
||||
|
return Collections.emptyList(); |
||||
|
} |
||||
|
return lines.size() <= 3 ? lines : lines.subList(0, 3); |
||||
|
} |
||||
|
|
||||
|
/** 去掉可能的 markdown 代码块包裹(支持 3 个及以上反引号) */ |
||||
|
private static String trimCodeBlock(String s) { |
||||
|
if (s.startsWith("```") && s.endsWith("```")) { |
||||
|
// 计算开头的反引号数量 |
||||
|
int openCount = 0; |
||||
|
while (openCount < s.length() && s.charAt(openCount) == '`') openCount++; |
||||
|
if (openCount >= 3 && s.endsWith("`".repeat(openCount))) { |
||||
|
String inner = s.substring(openCount, s.length() - openCount).strip(); |
||||
|
if (inner.startsWith("json")) { |
||||
|
inner = inner.substring(4).strip(); |
||||
|
} else if (inner.startsWith("JSON")) { |
||||
|
inner = inner.substring(4).strip(); |
||||
|
} |
||||
|
return inner; |
||||
|
} |
||||
|
} |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
/** 去掉行首序号,如 "1." "2." "3." "1、" "2、" "3、" */ |
||||
|
private static String stripNumberPrefix(String s) { |
||||
|
if (s.length() >= 2 && Character.isDigit(s.charAt(0)) && |
||||
|
(s.charAt(1) == '.' || s.charAt(1) == '、' || s.charAt(1) == ')')) { |
||||
|
return s.substring(2).strip(); |
||||
|
} |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
/** 去掉首尾引号 */ |
||||
|
private static String stripQuotes(String s) { |
||||
|
if (s.length() >= 2) { |
||||
|
char first = s.charAt(0); |
||||
|
char last = s.charAt(s.length() - 1); |
||||
|
if ((first == '"' && last == '"') || (first == '\'' && last == '\'') || |
||||
|
(first == '“' && last == '”')) { // 中文引号 " " |
||||
|
return s.substring(1, s.length() - 1).strip(); |
||||
|
} |
||||
|
} |
||||
|
return s; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 解析结果值对象。 |
||||
|
* |
||||
|
* @param answer LLM 回答正文(不含哨兵及之后的 suggestions) |
||||
|
* @param suggestions 建议问题列表(0~3 条) |
||||
|
*/ |
||||
|
public record ParsedResponse(String answer, List<String> suggestions) { |
||||
|
} |
||||
|
} |
||||
@ -0,0 +1,114 @@ |
|||||
|
package com.wok.supportbot.cache; |
||||
|
|
||||
|
import lombok.extern.slf4j.Slf4j; |
||||
|
import org.springframework.stereotype.Component; |
||||
|
|
||||
|
import java.util.List; |
||||
|
import java.util.Optional; |
||||
|
import java.util.concurrent.CompletableFuture; |
||||
|
import java.util.concurrent.ConcurrentHashMap; |
||||
|
|
||||
|
/** |
||||
|
* AI 推荐问题(suggest-message-list)的内存缓存。 |
||||
|
* <p> |
||||
|
* key 为 chatId,value 为最近一次生成的 3 条建议问题。 |
||||
|
* 不持久化到数据库——suggestions 是实时生成、即时消费的短生命周期数据。 |
||||
|
* 每次新的生成会覆盖前一次的建议。 |
||||
|
* <p> |
||||
|
* 增加 {@code pending} 映射,避免同一 chatId 的并发请求重复触发 LLM 生成。 |
||||
|
*/ |
||||
|
@Component |
||||
|
@Slf4j |
||||
|
public class SuggestionCache { |
||||
|
|
||||
|
/** 最大缓存条目数,防止无限增长 */ |
||||
|
private static final int MAX_ENTRIES = 1000; |
||||
|
|
||||
|
/** 生成中任务的并发控制:key=chatId,value=正在执行的生成任务 */ |
||||
|
private final ConcurrentHashMap<String, CompletableFuture<List<String>>> pending = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
private final ConcurrentHashMap<String, List<String>> cache = new ConcurrentHashMap<>(); |
||||
|
|
||||
|
/** |
||||
|
* 存入建议问题列表。 |
||||
|
* |
||||
|
* @param chatId 会话 ID |
||||
|
* @param suggestions 建议问题列表 |
||||
|
*/ |
||||
|
public void put(String chatId, List<String> suggestions) { |
||||
|
if (chatId == null || suggestions == null || suggestions.isEmpty()) { |
||||
|
return; |
||||
|
} |
||||
|
// 超过容量上限时清理一半旧条目(简单 LRU 近似) |
||||
|
if (cache.size() >= MAX_ENTRIES) { |
||||
|
int toRemove = MAX_ENTRIES / 2; |
||||
|
var it = cache.keySet().iterator(); |
||||
|
while (it.hasNext() && toRemove > 0) { |
||||
|
it.next(); |
||||
|
it.remove(); |
||||
|
toRemove--; |
||||
|
} |
||||
|
log.info("SuggestionCache 达到上限 {},已清理 {} 条旧记录", MAX_ENTRIES, MAX_ENTRIES / 2); |
||||
|
} |
||||
|
cache.put(chatId, List.copyOf(suggestions)); |
||||
|
log.debug("SuggestionCache 写入: chatId={}, count={}", chatId, suggestions.size()); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 获取建议问题列表。 |
||||
|
* |
||||
|
* @param chatId 会话 ID |
||||
|
* @return 建议问题列表,不存在则返回 Optional.empty() |
||||
|
*/ |
||||
|
public Optional<List<String>> get(String chatId) { |
||||
|
if (chatId == null) { |
||||
|
return Optional.empty(); |
||||
|
} |
||||
|
List<String> suggestions = cache.get(chatId); |
||||
|
return Optional.ofNullable(suggestions); |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 注册一个正在生成中的任务。 |
||||
|
* |
||||
|
* @param chatId 会话 ID |
||||
|
* @param future 生成任务 |
||||
|
* @return 若已存在相同 chatId 的任务,返回已存在的任务;否则返回传入的任务 |
||||
|
*/ |
||||
|
public CompletableFuture<List<String>> putIfAbsent(String chatId, CompletableFuture<List<String>> future) { |
||||
|
if (chatId == null || future == null) { |
||||
|
return future; |
||||
|
} |
||||
|
CompletableFuture<List<String>> existing = pending.putIfAbsent(chatId, future); |
||||
|
if (existing != null) { |
||||
|
return existing; |
||||
|
} |
||||
|
// future 完成后写入缓存并从 pending 移除 |
||||
|
future.whenComplete((result, ex) -> { |
||||
|
pending.remove(chatId, future); |
||||
|
if (ex != null) { |
||||
|
log.warn("SuggestionCache 生成任务异常: chatId={}", chatId, ex); |
||||
|
} else if (result != null && !result.isEmpty()) { |
||||
|
put(chatId, result); |
||||
|
} |
||||
|
}); |
||||
|
return future; |
||||
|
} |
||||
|
|
||||
|
/** |
||||
|
* 清除指定会话的建议缓存(会话删除时调用)。 |
||||
|
* |
||||
|
* @param chatId 会话 ID |
||||
|
*/ |
||||
|
public void evict(String chatId) { |
||||
|
if (chatId != null) { |
||||
|
cache.remove(chatId); |
||||
|
// 同时取消并清理可能正在进行的生成任务 |
||||
|
CompletableFuture<List<String>> future = pending.remove(chatId); |
||||
|
if (future != null && !future.isDone()) { |
||||
|
future.cancel(true); |
||||
|
} |
||||
|
log.debug("SuggestionCache 清除: chatId={}", chatId); |
||||
|
} |
||||
|
} |
||||
|
} |
||||
2
src/main/resources/static/sdk/chatbot-sdk.min.js
File diff suppressed because it is too large
View File
File diff suppressed because it is too large
View File
Write
Preview
Loading…
Cancel
Save
Reference in new issue