| | |
| | | |
| | | private final AiChatService aiChatService; |
| | | |
| | | /** |
| | | * 返回当前用户在指定 Prompt 场景下的 AI 运行时快照。 |
| | | * 这里不会真正触发模型调用,只负责把当前生效的模型、Prompt、 |
| | | * 已挂载 MCP 以及会话记忆概况一次性返回给前端抽屉初始化使用。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @GetMapping("/ai/chat/runtime") |
| | | public R runtime(@RequestParam(required = false) String promptCode, |
| | |
| | | return R.ok().add(aiChatService.getRuntime(promptCode, sessionId, getLoginUserId(), getTenantId())); |
| | | } |
| | | |
| | | /** |
| | | * 查询当前登录用户在指定 Prompt 下的历史会话列表。 |
| | | * 前端左侧会话栏依赖该接口做会话切换、搜索和刷新。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @GetMapping("/ai/chat/sessions") |
| | | public R sessions(@RequestParam(required = false) String promptCode, |
| | |
| | | return R.ok().add(aiChatService.listSessions(promptCode, keyword, getLoginUserId(), getTenantId())); |
| | | } |
| | | |
| | | /** |
| | | * 软删除单个 AI 会话,同时级联删除会话下的消息记录。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping("/ai/chat/session/remove/{sessionId}") |
| | | public R removeSession(@PathVariable Long sessionId) { |
| | |
| | | return R.ok("Delete Success").add(sessionId); |
| | | } |
| | | |
| | | /** |
| | | * 更新会话标题,供前端重命名会话时调用。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping("/ai/chat/session/rename/{sessionId}") |
| | | public R renameSession(@PathVariable Long sessionId, @RequestBody AiChatSessionRenameRequest request) { |
| | | return R.ok("Update Success").add(aiChatService.renameSession(sessionId, request, getLoginUserId(), getTenantId())); |
| | | } |
| | | |
| | | /** |
| | | * 更新会话置顶状态。 |
| | | * 置顶只影响当前用户的会话排序,不改变会话内容和记忆。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping("/ai/chat/session/pin/{sessionId}") |
| | | public R pinSession(@PathVariable Long sessionId, @RequestBody AiChatSessionPinRequest request) { |
| | | return R.ok("Update Success").add(aiChatService.pinSession(sessionId, request, getLoginUserId(), getTenantId())); |
| | | } |
| | | |
| | | /** |
| | | * 清空指定会话的持久化消息、摘要记忆和事实记忆。 |
| | | * 会话本身保留,便于前端继续在同一个 sessionId 上发起新对话。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping("/ai/chat/session/memory/clear/{sessionId}") |
| | | public R clearSessionMemory(@PathVariable Long sessionId) { |
| | |
| | | return R.ok("Clear Success").add(sessionId); |
| | | } |
| | | |
| | | /** |
| | | * 只保留会话最近一轮问答,用于主动裁剪上下文窗口。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping("/ai/chat/session/memory/retain-latest/{sessionId}") |
| | | public R retainLatestRound(@PathVariable Long sessionId) { |
| | |
| | | return R.ok("Retain Success").add(sessionId); |
| | | } |
| | | |
| | | /** |
| | | * 以 SSE 方式启动 AI 对话。 |
| | | * 控制器只负责生成 requestId、记录入口日志和把鉴权上下文透传给服务层, |
| | | * 真正的流式推理、工具调用和记忆落库都在服务层完成。 |
| | | */ |
| | | @PreAuthorize("isAuthenticated()") |
| | | @PostMapping(value = "/ai/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) |
| | | public SseEmitter stream(@RequestBody AiChatRequest request) { |
| | |
| | | private final AiChatSessionMapper aiChatSessionMapper; |
| | | private final AiChatMessageMapper aiChatMessageMapper; |
| | | |
| | | /** |
| | | * 读取会话记忆快照。 |
| | | * 返回结果同时包含完整落库历史、短期记忆窗口以及摘要/事实记忆, |
| | | * 便于调用方按不同用途选择数据粒度。 |
| | | */ |
| | | @Override |
| | | public AiChatMemoryDto getMemory(Long userId, Long tenantId, String promptCode, Long sessionId) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 查询当前用户在某个 Prompt 下的会话列表。 |
| | | * 列表只返回用于侧边栏展示的摘要信息,不返回完整对话内容。 |
| | | */ |
| | | @Override |
| | | public List<AiChatSessionDto> listSessions(Long userId, Long tenantId, String promptCode, String keyword) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 解析本轮请求应该落到哪个会话。 |
| | | * 如果前端带了 sessionId 则做归属校验并复用;否则自动创建新会话。 |
| | | */ |
| | | @Override |
| | | public AiChatSession resolveSession(Long userId, Long tenantId, String promptCode, Long sessionId, String titleSeed) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | return session; |
| | | } |
| | | |
| | | /** |
| | | * 落库保存一整轮对话。 |
| | | * 这里会顺序写入本轮用户消息和模型回复,并在最后刷新会话标题、最后活跃时间和记忆画像。 |
| | | */ |
| | | @Override |
| | | public void saveRound(AiChatSession session, Long userId, Long tenantId, List<AiChatMessageDto> memoryMessages, String assistantContent) { |
| | | if (session == null || session.getId() == null) { |
| | |
| | | refreshMemoryProfile(session.getId(), userId); |
| | | } |
| | | |
| | | /** 删除整个会话及其消息。 */ |
| | | @Override |
| | | public void removeSession(Long userId, Long tenantId, Long sessionId) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 更新会话标题并返回最新会话摘要。 */ |
| | | @Override |
| | | public AiChatSessionDto renameSession(Long userId, Long tenantId, Long sessionId, AiChatSessionRenameRequest request) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); |
| | | } |
| | | |
| | | /** 更新会话置顶状态。 */ |
| | | @Override |
| | | public AiChatSessionDto pinSession(Long userId, Long tenantId, Long sessionId, AiChatSessionPinRequest request) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | return buildSessionDto(requireOwnedSession(sessionId, userId, tenantId)); |
| | | } |
| | | |
| | | /** 清空某个会话的全部消息和派生记忆字段。 */ |
| | | @Override |
| | | public void clearSessionMemory(Long userId, Long tenantId, Long sessionId) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | .setLastMessageTime(session.getCreateTime())); |
| | | } |
| | | |
| | | /** 只保留最近一轮问答,用于手动裁剪长会话。 */ |
| | | @Override |
| | | public void retainLatestRound(Long userId, Long tenantId, Long sessionId) { |
| | | ensureIdentity(userId, tenantId); |
| | |
| | | } |
| | | |
| | | private List<AiChatMessageDto> normalizeMessages(List<AiChatMessageDto> memoryMessages) { |
| | | /** 清洗前端上传的内存消息,只允许 user/assistant 两类角色落库。 */ |
| | | List<AiChatMessageDto> normalized = new ArrayList<>(); |
| | | if (Cools.isEmpty(memoryMessages)) { |
| | | return normalized; |
| | |
| | | } |
| | | |
| | | private String buildSessionTitle(String titleSeed) { |
| | | /** |
| | | * 把首轮用户问题压缩成适合作为会话标题的短摘要。 |
| | | * 这里会去掉换行、连续空白,并优先在自然语义断点处截断。 |
| | | */ |
| | | if (!StringUtils.hasText(titleSeed)) { |
| | | throw new CoolException("AI 会话标题不能为空"); |
| | | } |
| | |
| | | } |
| | | |
| | | private void refreshMemoryProfile(Long sessionId, Long userId) { |
| | | /** |
| | | * 重新计算会话的摘要记忆和关键事实。 |
| | | * 这是“持久化消息”和“模型上下文治理”之间的桥梁方法。 |
| | | */ |
| | | List<AiChatMessageDto> messages = listMessages(sessionId); |
| | | List<AiChatMessageDto> shortMemoryMessages = tailMessagesByRounds(messages, AiDefaults.MEMORY_RECENT_ROUNDS); |
| | | List<AiChatMessageDto> historyMessages = messages.size() > shortMemoryMessages.size() |
| | |
| | | } |
| | | |
| | | private List<AiChatMessageDto> tailMessagesByRounds(List<AiChatMessageDto> source, int rounds) { |
| | | /** 按“用户发言轮次”裁剪最近消息,而不是简单按条数截断。 */ |
| | | if (Cools.isEmpty(source) || rounds <= 0) { |
| | | return List.of(); |
| | | } |
| | |
| | | } |
| | | |
| | | private String buildMemorySummary(List<AiChatMessageDto> historyMessages) { |
| | | /** 为较早历史生成可直接插入系统消息的文本摘要。 */ |
| | | StringBuilder builder = new StringBuilder("较早对话摘要:\n"); |
| | | for (AiChatMessageDto item : historyMessages) { |
| | | if (item == null || !StringUtils.hasText(item.getContent())) { |
| | |
| | | } |
| | | |
| | | private String buildMemoryFacts(List<AiChatMessageDto> messages) { |
| | | /** 从最近用户关注点中提炼关键事实,作为轻量持久记忆。 */ |
| | | if (Cools.isEmpty(messages)) { |
| | | return null; |
| | | } |
| | |
| | | @Qualifier("aiChatTaskExecutor") |
| | | private final Executor aiChatTaskExecutor; |
| | | |
| | | /** |
| | | * 获取当前对话抽屉初始化所需的运行时数据。 |
| | | * 该方法不会触发模型调用,而是把配置解析结果和会话记忆聚合成前端一次渲染所需的快照。 |
| | | */ |
| | | @Override |
| | | public AiChatRuntimeDto getRuntime(String promptCode, Long sessionId, Long userId, Long tenantId) { |
| | | AiResolvedConfig config = aiConfigResolverService.resolve(promptCode, tenantId); |
| | |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 查询指定 Prompt 场景下的历史会话摘要列表。 |
| | | */ |
| | | @Override |
| | | public List<AiChatSessionDto> listSessions(String promptCode, String keyword, Long userId, Long tenantId) { |
| | | AiResolvedConfig config = aiConfigResolverService.resolve(promptCode, tenantId); |
| | |
| | | aiChatMemoryService.retainLatestRound(userId, tenantId, sessionId); |
| | | } |
| | | |
| | | /** |
| | | * 启动一次新的 SSE 对话流。 |
| | | * 控制线程立即返回 emitter,真正的模型调用与工具执行交给 AI 专用线程池异步处理。 |
| | | */ |
| | | @Override |
| | | public SseEmitter stream(AiChatRequest request, Long userId, Long tenantId) { |
| | | SseEmitter emitter = new SseEmitter(AiDefaults.SSE_TIMEOUT_MS); |
| | |
| | | } |
| | | |
| | | private void doStream(AiChatRequest request, Long userId, Long tenantId, SseEmitter emitter) { |
| | | /** |
| | | * AI 对话的核心执行链路: |
| | | * 1. 校验身份和解析租户配置 |
| | | * 2. 解析或创建会话,加载记忆 |
| | | * 3. 动态挂载 MCP 工具 |
| | | * 4. 发起模型流式/非流式调用 |
| | | * 5. 持久化本轮消息,输出 SSE 事件并记录审计日志 |
| | | */ |
| | | String requestId = request.getRequestId(); |
| | | long startedAt = System.currentTimeMillis(); |
| | | AtomicReference<Long> firstTokenAtRef = new AtomicReference<>(); |
| | |
| | | } |
| | | |
| | | private AiResolvedConfig resolveConfig(AiChatRequest request, Long tenantId) { |
| | | /** 把请求里的 Prompt 场景解析成一份可直接执行的 AI 配置。 */ |
| | | try { |
| | | return aiConfigResolverService.resolve(request.getPromptCode(), tenantId); |
| | | } catch (Exception e) { |
| | |
| | | } |
| | | |
| | | private AiChatSession resolveSession(AiChatRequest request, Long userId, Long tenantId, String promptCode) { |
| | | /** 根据 sessionId 复用历史会话,或在首次提问时创建新会话。 */ |
| | | try { |
| | | return aiChatMemoryService.resolveSession(userId, tenantId, promptCode, request.getSessionId(), resolveTitleSeed(request.getMessages())); |
| | | } catch (Exception e) { |
| | |
| | | } |
| | | |
| | | private AiChatMemoryDto loadMemory(Long userId, Long tenantId, String promptCode, Long sessionId) { |
| | | /** 读取会话的短期记忆、摘要记忆和事实记忆,供模型组装上下文。 */ |
| | | try { |
| | | return aiChatMemoryService.getMemory(userId, tenantId, promptCode, sessionId); |
| | | } catch (Exception e) { |
| | |
| | | } |
| | | |
| | | private McpMountRuntimeFactory.McpMountRuntime createRuntime(AiResolvedConfig config, Long userId) { |
| | | /** 按配置中的 MCP 挂载记录构造本轮对话专属的工具运行时。 */ |
| | | try { |
| | | return mcpMountRuntimeFactory.create(config.getMcpMounts(), userId); |
| | | } catch (Exception e) { |
| | |
| | | |
| | | private OpenAiChatOptions buildChatOptions(AiParam aiParam, ToolCallback[] toolCallbacks, Long userId, Long tenantId, |
| | | String requestId, Long sessionId, Map<String, Object> metadata) { |
| | | /** |
| | | * 组装一次聊天调用的全部模型选项和 Tool Context。 |
| | | * Tool Context 会透传给内置工具和外部 MCP,保证工具在租户和会话范围内执行。 |
| | | */ |
| | | if (userId == null) { |
| | | throw buildAiException("AI_AUTH_USER_MISSING", AiErrorCategory.AUTH, "OPTIONS_BUILD", "当前登录用户不存在", null); |
| | | } |
| | |
| | | Long sessionId, AtomicLong toolCallSequence, |
| | | AtomicLong toolSuccessCount, AtomicLong toolFailureCount, |
| | | Long callLogId, Long userId, Long tenantId) { |
| | | /** 给所有工具回调套上一层可观测包装,用于实时 SSE 轨迹和审计日志落库。 */ |
| | | if (Cools.isEmpty(toolCallbacks)) { |
| | | return toolCallbacks; |
| | | } |
| | |
| | | } |
| | | |
| | | private List<Message> buildPromptMessages(AiChatMemoryDto memory, List<AiChatMessageDto> sourceMessages, AiPrompt aiPrompt, Map<String, Object> metadata) { |
| | | /** |
| | | * 组装最终提交给模型的消息列表。 |
| | | * 顺序上始终是:系统 Prompt -> 历史摘要 -> 关键事实 -> 最近对话 -> 当前用户输入。 |
| | | */ |
| | | if (Cools.isEmpty(sourceMessages)) { |
| | | throw new CoolException("对话消息不能为空"); |
| | | } |
| | |
| | | } |
| | | |
| | | private List<AiChatMessageDto> mergeMessages(List<AiChatMessageDto> persistedMessages, List<AiChatMessageDto> memoryMessages) { |
| | | /** 把落库历史与本轮前端内存增量合并成模型可消费的完整上下文。 */ |
| | | List<AiChatMessageDto> merged = new ArrayList<>(); |
| | | if (!Cools.isEmpty(persistedMessages)) { |
| | | merged.addAll(persistedMessages); |
| | |
| | | } |
| | | |
| | | private void emitDone(SseEmitter emitter, String requestId, ChatResponseMetadata metadata, String fallbackModel, Long sessionId, long startedAt, Long firstTokenAt) { |
| | | /** 输出对话完成事件,统一封装耗时、首包延迟和 token 用量。 */ |
| | | Usage usage = metadata == null ? null : metadata.getUsage(); |
| | | emitStrict(emitter, "done", AiChatDoneDto.builder() |
| | | .requestId(requestId) |
| | |
| | | } |
| | | |
| | | private void emitStrict(SseEmitter emitter, String eventName, Object payload) { |
| | | /** 严格发送 SSE 事件;一旦发送失败,直接上抛为流式输出异常。 */ |
| | | try { |
| | | String data = objectMapper.writeValueAsString(payload); |
| | | emitter.send(SseEmitter.event() |
| | |
| | | } |
| | | |
| | | private void emitSafely(SseEmitter emitter, String eventName, Object payload) { |
| | | /** 尝试发送非关键事件,发送失败只记录日志,不打断主对话流程。 */ |
| | | try { |
| | | emitStrict(emitter, eventName, payload); |
| | | } catch (Exception e) { |
| | |
| | | |
| | | @Override |
| | | public String call(String toolInput, ToolContext toolContext) { |
| | | /** |
| | | * 工具执行观测包装器。 |
| | | * 在真实调用前后分别发送 tool_start / tool_result / tool_error, |
| | | * 同时把调用摘要写入 MCP 调用日志表。 |
| | | */ |
| | | String toolName = delegate.getToolDefinition() == null ? "unknown" : delegate.getToolDefinition().name(); |
| | | String mountName = delegate instanceof MountedToolCallback ? ((MountedToolCallback) delegate).getMountName() : null; |
| | | String toolCallId = requestId + "-tool-" + toolCallSequence.incrementAndGet(); |
| | |
| | | private final AiPromptService aiPromptService; |
| | | private final AiMcpMountService aiMcpMountService; |
| | | |
| | | /** |
| | | * 按租户解析一次完整的 AI 运行配置。 |
| | | * 该方法是对话入口、运行态摘要和配置中心共用的统一解析点, |
| | | * 负责把当前生效的参数、Prompt 和 MCP 挂载聚合成一个不可再拆分的配置对象。 |
| | | */ |
| | | @Override |
| | | public AiResolvedConfig resolve(String promptCode, Long tenantId) { |
| | | if (tenantId == null) { |
| | |
| | | private final McpMountRuntimeFactory mcpMountRuntimeFactory; |
| | | private final ObjectMapper objectMapper; |
| | | |
| | | /** 查询某个租户下当前启用的 MCP 挂载列表。 */ |
| | | @Override |
| | | public List<AiMcpMount> listActiveMounts(Long tenantId) { |
| | | ensureTenantId(tenantId); |
| | |
| | | .orderByAsc(AiMcpMount::getId)); |
| | | } |
| | | |
| | | /** 保存前校验 MCP 挂载草稿,并补全运行时默认值。 */ |
| | | @Override |
| | | public void validateBeforeSave(AiMcpMount aiMcpMount, Long tenantId) { |
| | | ensureTenantId(tenantId); |
| | |
| | | ensureRequiredFields(aiMcpMount, tenantId); |
| | | } |
| | | |
| | | /** 更新前校验并锁定记录所属租户,防止跨租户修改。 */ |
| | | @Override |
| | | public void validateBeforeUpdate(AiMcpMount aiMcpMount, Long tenantId) { |
| | | ensureTenantId(tenantId); |
| | |
| | | ensureRequiredFields(aiMcpMount, tenantId); |
| | | } |
| | | |
| | | /** |
| | | * 预览当前挂载最终会暴露给模型的工具目录。 |
| | | * 对内置 MCP 会额外合并治理目录信息,对外部 MCP 则以实际解析结果为准。 |
| | | */ |
| | | @Override |
| | | public List<AiMcpToolPreviewDto> previewTools(Long mountId, Long userId, Long tenantId) { |
| | | AiMcpMount mount = requireMount(mountId, tenantId); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 对已保存的挂载做真实连通性测试,并把结果回写到运行态字段。 */ |
| | | @Override |
| | | public AiMcpConnectivityTestDto testConnectivity(Long mountId, Long userId, Long tenantId) { |
| | | AiMcpMount mount = requireMount(mountId, tenantId); |
| | |
| | | } |
| | | } |
| | | |
| | | /** 对表单里的草稿配置做临时连通性测试,不落库。 */ |
| | | @Override |
| | | public AiMcpConnectivityTestDto testDraftConnectivity(AiMcpMount mount, Long userId, Long tenantId) { |
| | | ensureTenantId(tenantId); |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 直接执行某一个工具的测试调用。 |
| | | * 该方法主要服务于管理端的“工具测试”面板,不参与正式对话链路。 |
| | | */ |
| | | @Override |
| | | public AiMcpToolTestDto testTool(Long mountId, Long userId, Long tenantId, AiMcpToolTestRequest request) { |
| | | if (userId == null) { |
| | |
| | | } |
| | | |
| | | private void fillDefaults(AiMcpMount aiMcpMount) { |
| | | /** 为挂载草稿补齐统一默认值,保证后续运行时代码不需要重复判断空值。 */ |
| | | if (!StringUtils.hasText(aiMcpMount.getTransportType())) { |
| | | aiMcpMount.setTransportType(AiDefaults.MCP_TRANSPORT_SSE_HTTP); |
| | | } |
| | |
| | | } |
| | | |
| | | private void ensureRequiredFields(AiMcpMount aiMcpMount, Long tenantId) { |
| | | /** |
| | | * 按 transportType 校验挂载必填项。 |
| | | * 这里把“字段合法性”和“跨记录冲突”一起收口,避免校验逻辑分散在 controller 层。 |
| | | */ |
| | | if (!StringUtils.hasText(aiMcpMount.getName())) { |
| | | throw new CoolException("MCP 挂载名称不能为空"); |
| | | } |
| | |
| | | } |
| | | |
| | | private AiMcpMount requireMount(Long mountId, Long tenantId) { |
| | | /** 按租户加载挂载记录,不存在直接抛错。 */ |
| | | ensureTenantId(tenantId); |
| | | if (mountId == null) { |
| | | throw new CoolException("MCP 挂载 ID 不能为空"); |
| | |
| | | } |
| | | |
| | | private void ensureBuiltinConflictFree(AiMcpMount aiMcpMount, Long tenantId) { |
| | | /** 校验同租户下是否存在与当前内置编码互斥的启用挂载。 */ |
| | | if (aiMcpMount.getStatus() == null || aiMcpMount.getStatus() != StatusType.ENABLE.val) { |
| | | return; |
| | | } |
| | |
| | | } |
| | | |
| | | private List<AiMcpToolPreviewDto> buildToolPreviewDtos(ToolCallback[] callbacks, List<AiMcpToolPreviewDto> governedCatalog) { |
| | | /** 把底层 ToolCallback 和治理目录信息拼成前端需要的结构化工具卡片数据。 */ |
| | | List<AiMcpToolPreviewDto> tools = new ArrayList<>(); |
| | | Map<String, AiMcpToolPreviewDto> catalogMap = new java.util.LinkedHashMap<>(); |
| | | for (AiMcpToolPreviewDto item : governedCatalog) { |
| | |
| | | private final GenericApplicationContext applicationContext; |
| | | private final ObservationRegistry observationRegistry; |
| | | |
| | | /** |
| | | * 对一份 AI 参数草稿做真实连通性校验。 |
| | | * 校验方式不是简单判断字段非空,而是直接构造聊天模型并发起一次最小探测调用, |
| | | * 用返回结果和耗时生成前端可展示的校验报告。 |
| | | */ |
| | | public AiParamValidateResultDto validate(AiParam aiParam) { |
| | | long startedAt = System.currentTimeMillis(); |
| | | try { |
| | |
| | | } |
| | | |
| | | private OpenAiChatModel createChatModel(AiParam aiParam) { |
| | | /** |
| | | * 构造仅用于校验的轻量聊天模型。 |
| | | * 这里沿用正式链路的 Observation 和 ToolCalling 依赖, |
| | | * 保证校验结论与真实运行环境尽量一致。 |
| | | */ |
| | | OpenAiApi openAiApi = buildOpenAiApi(aiParam); |
| | | ToolCallingManager toolCallingManager = DefaultToolCallingManager.builder() |
| | | .observationRegistry(observationRegistry) |
| | |
| | | } |
| | | |
| | | private OpenAiApi buildOpenAiApi(AiParam aiParam) { |
| | | /** |
| | | * 根据表单里的 Base URL、API Key 和超时参数构造 OpenAI 兼容客户端。 |
| | | * 该方法被显式拆出来,是为了让“网络连接参数”和“模型选项”职责分离。 |
| | | */ |
| | | int timeoutMs = aiParam.getTimeoutMs() == null ? AiDefaults.DEFAULT_TIMEOUT_MS : aiParam.getTimeoutMs(); |
| | | SimpleClientHttpRequestFactory requestFactory = new SimpleClientHttpRequestFactory(); |
| | | requestFactory.setConnectTimeout(timeoutMs); |
| | |
| | | } |
| | | |
| | | private String formatDate(Date date) { |
| | | /** 统一输出给前端的校验时间格式。 */ |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(date); |
| | | } |
| | | } |
| | |
| | | |
| | | private static final Pattern VARIABLE_PATTERN = Pattern.compile("\\{\\{?([a-zA-Z0-9_.-]+)}}?"); |
| | | |
| | | /** |
| | | * 同时渲染 System Prompt 和 User Prompt,并回传本次解析到的变量清单。 |
| | | * 该方法用于 Prompt 管理页的预览能力,帮助管理员在不真正调用模型的前提下验证模板结果。 |
| | | */ |
| | | public AiPromptPreviewDto render(String systemPrompt, String userPromptTemplate, String input, Map<String, Object> metadata) { |
| | | String finalInput = input == null ? "" : input; |
| | | return AiPromptPreviewDto.builder() |
| | |
| | | .build(); |
| | | } |
| | | |
| | | /** |
| | | * 只渲染用户消息模板。 |
| | | * 如果模板没有消费任何变量,则保留模板原文并把用户输入附加到末尾, |
| | | * 这样可以显式暴露“模板未生效”的问题,而不是静默吞掉输入。 |
| | | */ |
| | | public String renderUserPrompt(String userPromptTemplate, String input, Map<String, Object> metadata) { |
| | | if (!StringUtils.hasText(userPromptTemplate)) { |
| | | return input; |
| | |
| | | } |
| | | |
| | | private String renderTemplate(String template, String input, Map<String, Object> metadata) { |
| | | /** 渲染任意模板片段;空模板保持原样返回。 */ |
| | | if (!StringUtils.hasText(template)) { |
| | | return template; |
| | | } |
| | |
| | | } |
| | | |
| | | private String replaceTemplateVariables(String template, String input, Map<String, Object> metadata) { |
| | | /** |
| | | * 统一处理 `{{input}}`、`{input}` 以及 metadata 里的占位变量替换。 |
| | | * 这里使用朴素替换而不是脚本执行,目的是让模板行为稳定、可预期、易排查。 |
| | | */ |
| | | String rendered = template |
| | | .replace("{{input}}", input) |
| | | .replace("{input}", input); |
| | |
| | | } |
| | | |
| | | private List<String> resolveVariables(String systemPrompt, String userPromptTemplate, Map<String, Object> metadata) { |
| | | /** 收集当前 Prompt 中显式出现过的变量名,用于前端展示。 */ |
| | | LinkedHashSet<String> variables = new LinkedHashSet<>(); |
| | | collectVariables(variables, systemPrompt); |
| | | collectVariables(variables, userPromptTemplate); |
| | |
| | | } |
| | | |
| | | private void collectVariables(LinkedHashSet<String> variables, String template) { |
| | | /** 扫描模板文本中的占位变量并按出现顺序去重。 */ |
| | | if (!StringUtils.hasText(template)) { |
| | | return; |
| | | } |
| | |
| | | private final RsfWmsTaskTools rsfWmsTaskTools; |
| | | private final RsfWmsBaseTools rsfWmsBaseTools; |
| | | |
| | | /** |
| | | * 校验内置 MCP 编码是否合法。 |
| | | * 当前版本只允许使用显式登记在注册表中的编码,未知编码直接拒绝, |
| | | * 这样可以确保“页面可选项”和“运行时可挂载项”始终一致。 |
| | | */ |
| | | @Override |
| | | public void validateBuiltinCode(String builtinCode) { |
| | | if (!StringUtils.hasText(builtinCode)) { |
| | |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 根据挂载记录创建内置工具回调。 |
| | | * 这里不会做任何动态发现,所有工具都必须经过显式注册和治理目录校验后才能暴露给模型。 |
| | | */ |
| | | @Override |
| | | public List<ToolCallback> createToolCallbacks(AiMcpMount mount, Long userId) { |
| | | String builtinCode = mount.getBuiltinCode(); |
| | |
| | | throw new CoolException("不支持的内置 MCP 编码: " + builtinCode); |
| | | } |
| | | |
| | | /** |
| | | * 返回某个内置编码下可预览的工具目录信息。 |
| | | * 该目录比运行时回调多了工具用途、查询边界和示例提问,供管理页展示。 |
| | | */ |
| | | @Override |
| | | public List<AiMcpToolPreviewDto> listBuiltinToolCatalog(String builtinCode) { |
| | | validateBuiltinCode(builtinCode); |
| | |
| | | } |
| | | |
| | | private List<ToolCallback> createValidatedCallbacks(Object toolBean, String builtinCode) { |
| | | /** |
| | | * 把 `@Tool` Bean 转成 Spring AI ToolCallback,并强制校验: |
| | | * 1. 工具名必须符合命名规范 |
| | | * 2. 每个工具都必须出现在治理目录里 |
| | | */ |
| | | List<ToolCallback> callbacks = Arrays.asList(ToolCallbacks.from(toolBean)); |
| | | Map<String, AiMcpToolPreviewDto> catalog = catalogByBuiltinCode(builtinCode); |
| | | for (ToolCallback callback : callbacks) { |
| | |
| | | } |
| | | |
| | | private List<String> supportedBuiltinCodes() { |
| | | /** 当前版本允许挂载的全部内置 MCP 编码。 */ |
| | | return List.of(AiDefaults.MCP_BUILTIN_RSF_WMS); |
| | | } |
| | | |
| | | private Map<String, AiMcpToolPreviewDto> catalogByBuiltinCode(String builtinCode) { |
| | | /** |
| | | * 构造内置工具治理目录。 |
| | | * 这里的目录是运行时校验和管理端预览的共同事实来源,不能与工具实现脱节。 |
| | | */ |
| | | if (AiDefaults.MCP_BUILTIN_RSF_WMS.equals(builtinCode)) { |
| | | Map<String, AiMcpToolPreviewDto> catalog = new LinkedHashMap<>(); |
| | | catalog.put("rsf_query_available_inventory", buildCatalogItem( |
| | |
| | | |
| | | private AiMcpToolPreviewDto buildCatalogItem(String name, String toolGroup, String toolPurpose, |
| | | String queryBoundary, List<String> exampleQuestions) { |
| | | /** 统一创建工具目录条目,避免不同工具组出现字段风格不一致。 */ |
| | | return AiMcpToolPreviewDto.builder() |
| | | .name(name) |
| | | .toolGroup(toolGroup) |
| | |
| | | private final ObjectMapper objectMapper; |
| | | private final BuiltinMcpToolRegistry builtinMcpToolRegistry; |
| | | |
| | | /** |
| | | * 把一组 MCP 挂载记录解析成一次对话可直接使用的运行时对象。 |
| | | * 该方法统一处理内置 MCP、远程 SSE MCP 和本地 STDIO MCP, |
| | | * 同时收集挂载成功项、失败项以及最终暴露给模型的工具回调列表。 |
| | | */ |
| | | @Override |
| | | public McpMountRuntime create(List<AiMcpMount> mounts, Long userId) { |
| | | List<McpSyncClient> clients = new ArrayList<>(); |
| | |
| | | } |
| | | |
| | | private List<ToolCallback> wrapMountedCallbacks(List<ToolCallback> source, String mountName) { |
| | | /** 为每个工具回调补上挂载来源,便于后续审计、观测和前端工具轨迹展示。 */ |
| | | List<ToolCallback> mountedCallbacks = new ArrayList<>(); |
| | | for (ToolCallback callback : source) { |
| | | if (callback == null) { |
| | |
| | | } |
| | | |
| | | private void ensureUniqueToolNames(List<ToolCallback> callbacks) { |
| | | /** 确保多挂载聚合后不会出现同名工具,否则模型侧无法正确分辨工具定义。 */ |
| | | LinkedHashSet<String> duplicateNames = new LinkedHashSet<>(); |
| | | LinkedHashSet<String> seenNames = new LinkedHashSet<>(); |
| | | for (ToolCallback callback : callbacks) { |
| | |
| | | } |
| | | |
| | | private McpSyncClient createClient(AiMcpMount mount) { |
| | | /** |
| | | * 按挂载配置动态创建 MCP Client。 |
| | | * 该方法只负责 transport 层初始化,不负责工具去重和错误聚合。 |
| | | */ |
| | | Duration timeout = Duration.ofMillis(mount.getRequestTimeoutMs() == null |
| | | ? AiDefaults.DEFAULT_TIMEOUT_MS |
| | | : mount.getRequestTimeoutMs()); |
| | |
| | | } |
| | | |
| | | private List<String> readStringList(String json) { |
| | | /** 解析挂载表里的 JSON 数组配置,例如 STDIO args。 */ |
| | | if (!StringUtils.hasText(json)) { |
| | | return Collections.emptyList(); |
| | | } |
| | |
| | | } |
| | | |
| | | private Map<String, String> readStringMap(String json) { |
| | | /** 解析挂载表里的 JSON Map 配置,例如 headers 或环境变量。 */ |
| | | if (!StringUtils.hasText(json)) { |
| | | return Collections.emptyMap(); |
| | | } |
| | |
| | | private final List<String> mountedNames; |
| | | private final List<String> errors; |
| | | |
| | | /** 运行时对象本身只做数据封装和资源释放,不引入额外业务逻辑。 */ |
| | | private DefaultMcpMountRuntime(List<McpSyncClient> clients, ToolCallback[] callbacks, List<String> mountedNames, List<String> errors) { |
| | | this.clients = clients; |
| | | this.callbacks = callbacks; |
| | |
| | | |
| | | @Override |
| | | public void close() { |
| | | /** 统一关闭本次运行时里创建的外部 MCP Client,避免连接泄漏。 */ |
| | | for (McpSyncClient client : clients) { |
| | | try { |
| | | client.close(); |
| | |
| | | private final ToolCallback delegate; |
| | | private final String mountName; |
| | | |
| | | /** 装饰器仅补充挂载来源,不改变底层工具定义和调用行为。 */ |
| | | private MountedToolCallbackImpl(ToolCallback delegate, String mountName) { |
| | | this.delegate = delegate; |
| | | this.mountName = mountName; |
| | |
| | | private BuiltinToolGovernanceSupport() { |
| | | } |
| | | |
| | | /** |
| | | * 把工具入参里的 limit 统一收敛到安全范围内。 |
| | | * 所有内置只读工具都通过该方法限制返回规模,避免模型一次查询过多数据。 |
| | | */ |
| | | public static int normalizeLimit(Integer limit, int defaultValue, int maxValue) { |
| | | if (limit == null) { |
| | | return defaultValue; |
| | |
| | | return limit; |
| | | } |
| | | |
| | | /** |
| | | * 要求多个过滤条件里至少有一个有效值。 |
| | | * 这是防止 AI 工具被模型当成“全表扫描接口”使用的第一道保护。 |
| | | */ |
| | | public static void requireAnyFilter(String message, String... values) { |
| | | if (values == null || values.length == 0) { |
| | | throw new CoolException(message); |
| | |
| | | throw new CoolException(message); |
| | | } |
| | | |
| | | /** |
| | | * 清洗单个文本型查询参数,并限制最大长度。 |
| | | * 这里只做轻量治理,不做模糊兜底或自动纠错,非法输入直接拒绝。 |
| | | */ |
| | | public static String sanitizeQueryText(String value, String fieldLabel, int maxLength) { |
| | | if (!StringUtils.hasText(value)) { |
| | | return null; |
| | |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 清洗字符串数组型参数,常用于站点类型、状态列表等批量过滤条件。 |
| | | * 返回结果会自动剔除空值,但如果最终为空仍然视为非法请求。 |
| | | */ |
| | | public static List<String> sanitizeStringList(List<String> values, String fieldLabel, int maxSize, int maxItemLength) { |
| | | if (values == null || values.isEmpty()) { |
| | | throw new CoolException(fieldLabel + "不能为空"); |
| | |
| | | private final BasStationService basStationService; |
| | | private final DictDataService dictDataService; |
| | | |
| | | /** |
| | | * 查询仓库基础信息。 |
| | | * 该工具面向“按编码/名称定位仓库”的问答场景,不负责提供全量仓库主数据导出能力。 |
| | | */ |
| | | @Tool(name = "rsf_query_warehouses", description = "只读查询工具。按仓库编码或名称查询仓库基础信息。") |
| | | public List<Map<String, Object>> queryWarehouses( |
| | | @ToolParam(description = "仓库编码,可选") String code, |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 查询基础站点信息。 |
| | | * 查询条件允许按站点名称、编号或使用状态组合过滤,返回值只保留 AI 对话需要的字段。 |
| | | */ |
| | | @Tool(name = "rsf_query_bas_stations", description = "只读查询工具。按站点编号、站点名称或使用状态查询基础站点。") |
| | | public List<Map<String, Object>> queryBasStations( |
| | | @ToolParam(description = "站点名称,可选") String stationName, |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 查询字典数据。 |
| | | * 字典类型编码是强制条件,用来确保模型不会越过业务边界直接遍历整张字典表。 |
| | | */ |
| | | @Tool(name = "rsf_query_dict_data", description = "只读查询工具。根据字典类型编码查询字典数据,可按值或标签进一步过滤。") |
| | | public List<Map<String, Object>> queryDictData( |
| | | @ToolParam(required = true, description = "字典类型编码") String dictTypeCode, |
| | |
| | | private final LocItemService locItemService; |
| | | private final DeviceSiteService deviceSiteService; |
| | | |
| | | /** |
| | | * 查询当前可用于出库的库存明细。 |
| | | * 该工具只允许按物料编码或物料名称做定向查询,不允许无条件扫描库存表。 |
| | | */ |
| | | @Tool(name = "rsf_query_available_inventory", description = "只读查询工具。根据物料编码或物料名称查询当前在库且可用于出库的库存明细。") |
| | | public List<Map<String, Object>> queryAvailableInventory( |
| | | @ToolParam(description = "物料编码,优先使用") String matnr, |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 查询指定作业类型可用的设备站点。 |
| | | * 返回的是模型更容易消费的扁平结构,而不是直接暴露完整实体对象。 |
| | | */ |
| | | @Tool(name = "rsf_query_station_list", description = "只读查询工具。根据作业类型列表查询可用站点,返回站点编号、名称、目标位置和状态等信息。") |
| | | public List<Map<String, Object>> queryStationList( |
| | | @ToolParam(required = true, description = "作业类型列表") List<String> types, |
| | |
| | | |
| | | private final TaskService taskService; |
| | | |
| | | /** |
| | | * 查询任务列表。 |
| | | * 方法要求至少带一个过滤条件,避免模型把任务表当作可直接遍历的数据源。 |
| | | */ |
| | | @Tool(name = "rsf_query_task_list", description = "只读查询工具。按任务号、状态、任务类型、源站点、目标站点等条件查询任务列表。") |
| | | public List<Map<String, Object>> queryTaskList( |
| | | @ToolParam(description = "任务号,可模糊查询") String taskCode, |
| | |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 查询单个任务详情。 |
| | | * 与列表查询不同,这里允许返回更丰富的字段,但仍然要求调用方通过任务 ID 或任务号做精确定位。 |
| | | */ |
| | | @Tool(name = "rsf_query_task_detail", description = "只读查询工具。根据任务 ID 或任务号查询任务详情。") |
| | | public Map<String, Object> queryTaskDetail( |
| | | @ToolParam(description = "任务 ID") Long taskId, |
| | |
| | | } |
| | | |
| | | private Map<String, Object> buildTaskSummary(Task task) { |
| | | /** 把任务实体收敛为适合模型阅读和前端展示的摘要结构。 */ |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("id", task.getId()); |
| | | item.put("taskCode", task.getTaskCode()); |