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
-
21src/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
-
14src/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