2个文件已删除
96个文件已添加
2 文件已重命名
351个文件已修改
| New file |
| | |
| | | # WMS AI功能开发文档 |
| | | |
| | | ## 1. 目标说明 |
| | | |
| | | 本文档基于 `zy-wcs-master` 的 AI 巡检思路,对当前 `wms-master` 项目进行了功能映射和实现说明。 |
| | | 本次实现的目标不是照搬 Spring AI + MCP 的完整体系,而是复用当前项目已有的 `rsf-server + rsf-ai-gateway + rsf-admin` 架构,以最小改动补齐以下能力: |
| | | |
| | | - 通用聊天场景与系统诊断场景并存 |
| | | - 一键诊断入口 |
| | | - 基于库存、任务、设备站点的实时诊断上下文 |
| | | - AI会话与消息持久化 |
| | | - 保持现有模型配置中心和网关调用链不变 |
| | | |
| | | ## 2. 与 `zy-wcs-master` 的映射关系 |
| | | |
| | | | `zy-wcs-master` 能力 | 当前项目映射方案 | |
| | | | --- | --- | |
| | | | 一键巡检 / 连续追问 | `AiController` 新增诊断场景,前端增加“一键诊断” | |
| | | | Prompt 场景中心 | 使用 `sceneCode + AiProperties + AiPromptContextProvider` 组合实现 | |
| | | | MCP 工具聚合 | 使用现有上下文提供器直接查询业务表,先实现轻量级数据工具能力 | |
| | | | 会话落库 | 新增 `sys_ai_chat_session`、`sys_ai_chat_message` | |
| | | | LLM 网关 | 继续复用 `rsf-ai-gateway` | |
| | | |
| | | 说明:当前版本没有直接引入 `zy-wcs-master` 的 Prompt 管理后台、MCP 挂载中心、模型路由容灾中心。这些能力保留为下一阶段增强项。 |
| | | |
| | | ## 3. 当前实现架构 |
| | | |
| | | ### 3.1 模块分工 |
| | | |
| | | - `rsf-admin` |
| | | - AI 对话抽屉组件 |
| | | - 新增一键诊断按钮 |
| | | - `rsf-server` |
| | | - AI控制器、场景路由、上下文拼装、会话存储 |
| | | - 直接从 WMS 业务表读取诊断摘要 |
| | | - `rsf-ai-gateway` |
| | | - 统一封装 OpenAI 兼容流式接口 |
| | | |
| | | ### 3.2 调用链路 |
| | | |
| | | ```mermaid |
| | | flowchart LR |
| | | A["rsf-admin AI对话组件"] --> B["rsf-server /ai/chat/stream"] |
| | | A --> C["rsf-server /ai/diagnose/stream"] |
| | | B --> D["AiPromptContextService"] |
| | | C --> D |
| | | D --> E["库存摘要"] |
| | | D --> F["任务摘要"] |
| | | D --> G["设备站点摘要"] |
| | | B --> H["AiSessionService"] |
| | | C --> H |
| | | B --> I["rsf-ai-gateway"] |
| | | C --> I |
| | | I --> J["OpenAI兼容模型接口"] |
| | | ``` |
| | | |
| | | ## 4. 后端实现说明 |
| | | |
| | | ### 4.1 场景定义 |
| | | |
| | | 新增场景编码: |
| | | |
| | | - `general_chat`:普通对话 |
| | | - `system_diagnose`:系统巡检诊断 |
| | | |
| | | 相关位置: |
| | | |
| | | - `rsf-server/src/main/java/com/vincent/rsf/server/ai/constant/AiSceneCode.java` |
| | | - `rsf-server/src/main/java/com/vincent/rsf/server/ai/dto/AiChatStreamRequest.java` |
| | | - `rsf-server/src/main/java/com/vincent/rsf/server/ai/model/AiPromptContext.java` |
| | | |
| | | ### 4.2 控制器能力 |
| | | |
| | | `AiController` 当前提供两条 SSE 能力: |
| | | |
| | | - `/ai/chat/stream` |
| | | - 通用聊天 |
| | | - 支持显式传入 `sceneCode` |
| | | - `/ai/diagnose/stream` |
| | | - 诊断快捷入口 |
| | | - 自动使用 `system_diagnose` |
| | | - 如果前端未传消息,则使用默认巡检提示词 |
| | | |
| | | ### 4.3 Prompt 与上下文策略 |
| | | |
| | | 当前实现采用“基础提示词 + 场景提示词 + 数据摘要提示词”的组合方式: |
| | | |
| | | 1. 基础提示词来自模型配置或 `application.yml` |
| | | 2. 诊断场景追加 `diagnosis-system-prompt` |
| | | 3. `AiPromptContextService` 自动拼接上下文提供器内容 |
| | | |
| | | 上下文提供器如下: |
| | | |
| | | - `AiDiagnosisPromptProvider` |
| | | - 输出诊断流程和回答结构约束 |
| | | - `AiWarehouseSummaryService` |
| | | - 汇总 `man_loc`、`man_loc_item` |
| | | - `AiTaskSummaryService` |
| | | - 汇总 `man_task` |
| | | - `AiDeviceSiteSummaryService` |
| | | - 汇总 `man_device_site` |
| | | |
| | | ### 4.4 诊断数据来源 |
| | | |
| | | 本次实现优先接入实时业务表,不额外引入独立知识库: |
| | | |
| | | - 库存与库位:`man_loc`、`man_loc_item` |
| | | - 任务:`man_task` |
| | | - 设备站点:`man_device_site` |
| | | |
| | | 这使得 AI 更偏向“运行态诊断助手”,而不是通用知识问答机器人。 |
| | | |
| | | ## 5. 会话持久化设计 |
| | | |
| | | ### 5.1 表结构 |
| | | |
| | | 新增脚本:`version/db/20260316_ai_chat_storage.sql` |
| | | |
| | | 涉及两张表: |
| | | |
| | | - `sys_ai_chat_session` |
| | | - `sys_ai_chat_message` |
| | | |
| | | ### 5.2 存储策略 |
| | | |
| | | `AiSessionServiceImpl` 采用“数据库优先,内存兜底”模式: |
| | | |
| | | - 如果检测到两张表存在,则自动启用数据库持久化 |
| | | - 如果未执行迁移脚本,则继续使用原有内存缓存逻辑 |
| | | |
| | | 这样可以降低发布风险,适合分阶段上线。 |
| | | |
| | | ### 5.3 上线建议 |
| | | |
| | | 建议在正式启用前先执行以下脚本: |
| | | |
| | | - `version/db/20260311_ai_param.sql` |
| | | - `version/db/20260316_ai_chat_storage.sql` |
| | | |
| | | 脚本执行完成后重启 `rsf-server`,使其在启动阶段检测到会话表并切换到持久化模式。 |
| | | |
| | | ## 6. 前端交互说明 |
| | | |
| | | `rsf-admin/src/ai/AiChatWidget.jsx` 已增加“一键诊断”入口,入口位置包括: |
| | | |
| | | - 会话顶部工具区 |
| | | - 空白态引导区 |
| | | - 底部输入区状态栏 |
| | | |
| | | 点击后会向 `/ai/diagnose/stream` 发起流式请求,并自动带上: |
| | | |
| | | - `sessionId` |
| | | - `modelCode` |
| | | - `sceneCode=system_diagnose` |
| | | |
| | | ## 7. 配置项说明 |
| | | |
| | | `rsf-server/src/main/resources/application.yml` |
| | | |
| | | 关键配置: |
| | | |
| | | - `ai.system-prompt` |
| | | - `ai.diagnosis-system-prompt` |
| | | - `ai.default-model-code` |
| | | - `ai.max-context-messages` |
| | | |
| | | 模型接入配置仍然优先从 AI 参数管理读取,`application.yml` 主要承担默认兜底配置。 |
| | | |
| | | ## 8. 与 `zy-wcs-master` 的差距 |
| | | |
| | | 当前版本已经具备“可用”的诊断能力,但与 `zy-wcs-master` 相比仍有以下差距: |
| | | |
| | | - 尚未实现独立的 Prompt 配置后台 |
| | | - 尚未实现标准 Spring AI MCP Server / MCP Mount 聚合 |
| | | - 尚未实现多模型路由、失败切换、调用日志中心 |
| | | - 当前诊断依赖数据库摘要,尚未纳入日志流、设备实时线程状态等更深层数据 |
| | | |
| | | ## 9. 下一阶段建议 |
| | | |
| | | 如果继续向 `zy-wcs-master` 靠近,建议按以下顺序演进: |
| | | |
| | | 1. 抽象统一的“AI数据工具层”,替代零散的上下文提供器 |
| | | 2. 新增 Prompt 模板表和发布机制 |
| | | 3. 给 `rsf-ai-gateway` 增加调用日志、失败切换、模型路由 |
| | | 4. 将日志、任务异常、接口失败率纳入诊断上下文 |
| | | 5. 增加独立的 AI 管理页,用于诊断记录、Prompt 调优和模型切换 |
| | |
| | | |
| | | const DRAWER_WIDTH = 720; |
| | | const SESSION_WIDTH = 220; |
| | | const DIAGNOSIS_MESSAGE = '请对当前WMS系统进行一次巡检诊断,结合库存、任务、设备站点数据识别异常并给出处理建议。'; |
| | | |
| | | const parseSseChunk = (chunk, onEvent) => { |
| | | const blocks = chunk.split('\n\n'); |
| | |
| | | } |
| | | }; |
| | | |
| | | const handleSend = async () => { |
| | | if (!draft.trim() || !activeSessionId || sending) { |
| | | const streamChat = async ({ userContent, endpoint, sceneCode }) => { |
| | | if (!userContent || !activeSessionId || sending) { |
| | | return; |
| | | } |
| | | const userContent = draft.trim(); |
| | | const sessionId = activeSessionId; |
| | | const modelCode = activeSession?.modelCode || models[0]?.code; |
| | | setDraft(''); |
| | | setError(''); |
| | | setSending(true); |
| | | setMessagesBySession((prev) => { |
| | |
| | | let receivedDelta = false; |
| | | |
| | | try { |
| | | const response = await fetch(`${PREFIX_BASE_URL}ai/chat/stream`, { |
| | | const response = await fetch(`${PREFIX_BASE_URL}${endpoint}`, { |
| | | method: 'POST', |
| | | headers: { |
| | | 'Content-Type': 'application/json', |
| | |
| | | body: JSON.stringify({ |
| | | sessionId, |
| | | message: userContent, |
| | | modelCode |
| | | modelCode, |
| | | sceneCode |
| | | }), |
| | | signal: controller.signal |
| | | }); |
| | |
| | | setSending(false); |
| | | streamControllerRef.current = null; |
| | | } |
| | | }; |
| | | |
| | | const handleSend = async () => { |
| | | if (!draft.trim() || !activeSessionId || sending) { |
| | | return; |
| | | } |
| | | const userContent = draft.trim(); |
| | | setDraft(''); |
| | | await streamChat({ |
| | | userContent, |
| | | endpoint: 'ai/chat/stream', |
| | | sceneCode: 'general_chat' |
| | | }); |
| | | }; |
| | | |
| | | const handleDiagnose = async () => { |
| | | if (!activeSessionId || sending) { |
| | | return; |
| | | } |
| | | await streamChat({ |
| | | userContent: DIAGNOSIS_MESSAGE, |
| | | endpoint: 'ai/diagnose/stream', |
| | | sceneCode: 'system_diagnose' |
| | | }); |
| | | }; |
| | | |
| | | const assistantReplyText = (messageList) => { |
| | |
| | | </MenuItem> |
| | | ))} |
| | | </Select> |
| | | <Button |
| | | variant="outlined" |
| | | size="small" |
| | | disabled={sending || !activeSessionId} |
| | | onClick={handleDiagnose} |
| | | sx={{ borderRadius: 2, whiteSpace: 'nowrap' }} |
| | | > |
| | | 一键诊断 |
| | | </Button> |
| | | </Stack> |
| | | <Divider /> |
| | | <Box |
| | |
| | | 开始新的智能对话 |
| | | </Typography> |
| | | <Typography variant="body2"> |
| | | 可以直接提问仓储业务问题,或切换模型开始新的会话。 |
| | | 可以直接提问仓储业务问题,或点击一键诊断快速巡检当前WMS状态。 |
| | | </Typography> |
| | | <Button variant="outlined" onClick={handleDiagnose} disabled={sending || !activeSessionId}> |
| | | 一键诊断 |
| | | </Button> |
| | | </Stack> |
| | | ) : ( |
| | | <Stack spacing={2}> |
| | |
| | | label={activeSession?.modelCode || '未选择模型'} |
| | | sx={{ bgcolor: 'rgba(25,118,210,0.08)', color: 'primary.main' }} |
| | | /> |
| | | <Button |
| | | variant="text" |
| | | size="small" |
| | | disabled={sending || !activeSessionId} |
| | | onClick={handleDiagnose} |
| | | sx={{ minWidth: 'auto', px: 1 }} |
| | | > |
| | | 一键诊断 |
| | | </Button> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | `Enter` 发送,`Shift + Enter` 换行 |
| | | </Typography> |
| | |
| | | maxRows={6} |
| | | value={draft} |
| | | onChange={(event) => setDraft(event.target.value)} |
| | | placeholder="输入问题,支持多会话和模型切换" |
| | | placeholder="输入问题,支持多会话、多模型和一键诊断" |
| | | onKeyDown={(event) => { |
| | | if (event.key === 'Enter' && !event.shiftKey) { |
| | | event.preventDefault(); |
| | |
| | | operation: 'Operation', |
| | | config: 'Config', |
| | | aiParam: 'AI Params', |
| | | aiPrompt: 'AI Prompt', |
| | | aiDiagnosis: 'AI Diagnosis', |
| | | aiDiagnosisPlan: 'AI Diagnosis Plan', |
| | | aiCallLog: 'AI Call Log', |
| | | aiRoute: 'AI Route', |
| | | aiToolConfig: 'AI Diagnostic Tool', |
| | | aiMcpMount: 'AI MCP Mount', |
| | | tenant: 'Tenant', |
| | | userLogin: 'Token', |
| | | customer: 'Customer', |
| | |
| | | operation: '操作日志', |
| | | config: '配置参数', |
| | | aiParam: 'AI参数', |
| | | aiPrompt: 'AI提示词', |
| | | aiDiagnosis: 'AI诊断记录', |
| | | aiDiagnosisPlan: 'AI巡检计划', |
| | | aiCallLog: 'AI调用日志', |
| | | aiRoute: 'AI模型路由', |
| | | aiToolConfig: 'AI诊断工具', |
| | | aiMcpMount: 'AI MCP挂载', |
| | | tenant: '租户管理', |
| | | userLogin: '登录日志', |
| | | customer: '客户表', |
| | |
| | | import host from "./system/host"; |
| | | import config from "./system/config"; |
| | | import aiParam from "./system/aiParam"; |
| | | import aiPrompt from "./system/aiPrompt"; |
| | | import aiDiagnosis from "./system/aiDiagnosis"; |
| | | import aiDiagnosisPlan from "./system/aiDiagnosisPlan"; |
| | | import aiCallLog from "./system/aiCallLog"; |
| | | import aiRoute from "./system/aiRoute"; |
| | | import aiToolConfig from "./system/aiToolConfig"; |
| | | import aiMcpMount from "./system/aiMcpMount"; |
| | | import tenant from "./system/tenant"; |
| | | import role from "./system/role"; |
| | | import userLogin from "./system/userLogin"; |
| | |
| | | return config; |
| | | case "aiParam": |
| | | return aiParam; |
| | | case "aiPrompt": |
| | | return aiPrompt; |
| | | case "aiDiagnosis": |
| | | return aiDiagnosis; |
| | | case "aiDiagnosisPlan": |
| | | return aiDiagnosisPlan; |
| | | case "aiCallLog": |
| | | return aiCallLog; |
| | | case "aiRoute": |
| | | return aiRoute; |
| | | case "aiToolConfig": |
| | | return aiToolConfig; |
| | | case "aiMcpMount": |
| | | return aiMcpMount; |
| | | case "tenant": |
| | | return tenant; |
| | | case "role": |
| New file |
| | |
| | | import React from "react"; |
| | | import { alpha } from '@mui/material/styles'; |
| | | import { Box, Grid, Paper, Stack, Typography } from '@mui/material'; |
| | | |
| | | const pageShellSx = { |
| | | mt: 0.5, |
| | | width: '100%', |
| | | }; |
| | | |
| | | export const AiConsoleLayout = ({ title, subtitle, actions, stats, children }) => ( |
| | | <Box sx={pageShellSx}> |
| | | <Paper |
| | | elevation={0} |
| | | sx={{ |
| | | borderRadius: 3, |
| | | px: { xs: 2, md: 2.5 }, |
| | | py: 2, |
| | | overflow: 'hidden', |
| | | backgroundColor: '#fff', |
| | | border: '1px solid #e6ebf2', |
| | | boxShadow: '0 1px 3px rgba(15, 23, 42, 0.06)', |
| | | }} |
| | | > |
| | | <Stack direction={{ xs: 'column', md: 'row' }} justifyContent="space-between" spacing={2}> |
| | | <Box> |
| | | <Typography variant="h6" sx={{ fontWeight: 700, letterSpacing: 0.1 }}> |
| | | {title} |
| | | </Typography> |
| | | {subtitle ? ( |
| | | <Typography variant="body2" color="text.secondary" sx={{ mt: 0.5, maxWidth: 760 }}> |
| | | {subtitle} |
| | | </Typography> |
| | | ) : null} |
| | | </Box> |
| | | {actions ? ( |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" justifyContent="flex-end"> |
| | | {actions} |
| | | </Stack> |
| | | ) : null} |
| | | </Stack> |
| | | {stats?.length ? ( |
| | | <Grid container spacing={1.5} sx={{ mt: 1.5 }}> |
| | | {stats.map((item) => ( |
| | | <Grid item xs={12} sm={6} md={12 / Math.min(stats.length, 4)} key={item.label}> |
| | | <Box |
| | | sx={{ |
| | | minHeight: 76, |
| | | px: 1.5, |
| | | py: 1.25, |
| | | borderRadius: 2, |
| | | border: '1px solid #e7ecf3', |
| | | backgroundColor: '#fafbfd', |
| | | }} |
| | | > |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {item.label} |
| | | </Typography> |
| | | <Typography variant="h5" sx={{ mt: 0.5, fontWeight: 700, lineHeight: 1.1 }}> |
| | | {item.value} |
| | | </Typography> |
| | | {item.helper ? ( |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {item.helper} |
| | | </Typography> |
| | | ) : null} |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | ) : null} |
| | | </Paper> |
| | | <Box sx={{ mt: 1.5 }}> |
| | | {children} |
| | | </Box> |
| | | </Box> |
| | | ); |
| | | |
| | | export const AiConsolePanel = ({ title, subtitle, action, children, minHeight }) => ( |
| | | <Paper |
| | | elevation={0} |
| | | sx={{ |
| | | height: '100%', |
| | | minHeight: minHeight || 240, |
| | | borderRadius: 3, |
| | | border: '1px solid #e6ebf2', |
| | | backgroundColor: '#fff', |
| | | boxShadow: '0 1px 3px rgba(15, 23, 42, 0.06)', |
| | | overflow: 'hidden', |
| | | }} |
| | | > |
| | | <Stack |
| | | direction="row" |
| | | justifyContent="space-between" |
| | | alignItems="flex-start" |
| | | spacing={1} |
| | | sx={{ |
| | | px: 2, |
| | | py: 1.5, |
| | | borderBottom: '1px solid #e6edf5', |
| | | backgroundColor: alpha('#fafbfc', 1), |
| | | }} |
| | | > |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700 }}> |
| | | {title} |
| | | </Typography> |
| | | {subtitle ? ( |
| | | <Typography variant="caption" color="text.secondary" sx={{ display: 'block', mt: 0.5 }}> |
| | | {subtitle} |
| | | </Typography> |
| | | ) : null} |
| | | </Box> |
| | | {action} |
| | | </Stack> |
| | | <Box sx={{ p: 1.5 }}> |
| | | {children} |
| | | </Box> |
| | | </Paper> |
| | | ); |
| | | |
| | | export const aiCardSx = (active) => ({ |
| | | p: 1.5, |
| | | borderRadius: 2.5, |
| | | border: active ? '1px solid #d7e2ef' : '1px solid #e5eaf0', |
| | | backgroundColor: '#fff', |
| | | boxShadow: active |
| | | ? '0 2px 8px rgba(15, 23, 42, 0.08)' |
| | | : '0 1px 4px rgba(15, 23, 42, 0.04)', |
| | | transition: 'box-shadow 0.18s ease, border-color 0.18s ease', |
| | | '&:hover': { |
| | | boxShadow: '0 3px 10px rgba(15, 23, 42, 0.08)', |
| | | }, |
| | | }); |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | |
| | | const AiCallLogEdit = () => ( |
| | | <Edit actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm toolbar={false}> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12}> |
| | | <Typography variant="h6" gutterBottom>调用详情</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="routeCode" label="路由编码" disabled /> |
| | | <TextInput source="modelCode" label="模型编码" disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <NumberInput source="attemptNo" label="尝试序号" disabled /> |
| | | <TextInput source="result$" label="结果" disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <NumberInput source="spendTime" label="耗时(ms)" disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="err" label="错误信息" fullWidth multiline minRows={4} disabled /> |
| | | </Stack> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ); |
| | | |
| | | export default AiCallLogEdit; |
| New file |
| | |
| | | import React, { useEffect, useState } from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useListContext, |
| | | Pagination, |
| | | } from 'react-admin'; |
| | | import { Box, Chip, Grid, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import request from "@/utils/request"; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <TextInput source="modelCode" label="模型编码" />, |
| | | <TextInput source="routeCode" label="路由编码" />, |
| | | <SelectInput source="result" label="结果" choices={[ |
| | | { id: '1', name: '成功' }, |
| | | { id: '0', name: '失败' }, |
| | | ]} />, |
| | | ]; |
| | | |
| | | const resultColor = (result) => Number(result) === 1 ? 'success' : 'error'; |
| | | |
| | | const CallLogBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const [stats, setStats] = useState({}); |
| | | |
| | | useEffect(() => { |
| | | let mounted = true; |
| | | const fetchStats = async () => { |
| | | try { |
| | | const { data: res } = await request.get('/ai/call-log/stats'); |
| | | if (mounted && res?.code === 200) { |
| | | setStats(res.data || {}); |
| | | } |
| | | } catch (error) { |
| | | } |
| | | }; |
| | | fetchStats(); |
| | | return () => { |
| | | mounted = false; |
| | | }; |
| | | }, []); |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI调用日志" |
| | | subtitle="按当前系统后台风格展示模型调用观测,突出成功率、最近 24 小时调用量和平均耗时,便于快速排查路由与上游模型问题。" |
| | | stats={[ |
| | | { label: '调用总数', value: stats.total || 0 }, |
| | | { label: '成功率', value: `${Number(stats.successRate || 0).toFixed(1)}%` }, |
| | | { label: '平均耗时', value: `${stats.avgSpendTime || 0} ms` }, |
| | | { label: '近24小时', value: stats.last24hCount || 0 }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="调用明细" |
| | | subtitle={`已观测模型 ${stats.modelCount || 0} 个,路由 ${stats.routeCount || 0} 条。`} |
| | | minHeight={420} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Box sx={aiCardSx(record.result === 1)}> |
| | | <Box display="flex" justifyContent="space-between" gap={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.modelCode || '未记录模型'} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | {record.routeCode || '未记录路由'} · 第 {record.attemptNo || 1} 次 |
| | | </Typography> |
| | | </Box> |
| | | <Chip size="small" color={resultColor(record.result)} label={record.result$ || '未知'} /> |
| | | </Box> |
| | | <Box sx={{ mt: 1.25, display: 'grid', gridTemplateColumns: 'repeat(2, minmax(0, 1fr))', gap: 1.5 }}> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>耗时</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.spendTime || 0} ms</Typography> |
| | | </Box> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>请求时间</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.requestTime || '-'}</Typography> |
| | | </Box> |
| | | </Box> |
| | | <Box sx={{ mt: 1.25 }}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>错误信息</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.5, minHeight: 72, color: '#31465d' }}> |
| | | {record.err || '无'} |
| | | </Typography> |
| | | </Box> |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | </Box> |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiCallLogList = () => ( |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiCallLog"} |
| | | filters={filters} |
| | | sort={{ field: "createTime", order: "desc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <SelectColumnsButton preferenceKey='aiCallLog' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <CallLogBoard /> |
| | | </List> |
| | | ); |
| | | |
| | | export default AiCallLogList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiCallLogList from "./AiCallLogList"; |
| | | import AiCallLogEdit from "./AiCallLogEdit"; |
| | | |
| | | export default { |
| | | list: AiCallLogList, |
| | | edit: AiCallLogEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.modelCode || record.id || ''}` |
| | | }; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | useNotify, |
| | | useRecordContext, |
| | | } from 'react-admin'; |
| | | import { Box, Button, Chip, Grid, Paper, Stack, Typography } from '@mui/material'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import request from "@/utils/request"; |
| | | |
| | | const ReportExportButton = () => { |
| | | const record = useRecordContext(); |
| | | const notify = useNotify(); |
| | | |
| | | if (!record?.id) { |
| | | return null; |
| | | } |
| | | |
| | | const handleExport = async () => { |
| | | try { |
| | | const res = await request.get(`/ai/diagnosis/report/export/${record.id}`, { |
| | | responseType: 'blob', |
| | | }); |
| | | const blob = new Blob([res.data], { type: 'text/markdown;charset=utf-8' }); |
| | | const link = document.createElement('a'); |
| | | link.href = window.URL.createObjectURL(blob); |
| | | link.setAttribute('download', `${record.diagnosisNo || 'ai-diagnosis-report'}.md`); |
| | | document.body.appendChild(link); |
| | | link.click(); |
| | | link.remove(); |
| | | } catch (error) { |
| | | notify(error.message || '导出失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | return <Button variant="outlined" onClick={handleExport}>导出报告</Button>; |
| | | }; |
| | | |
| | | const ToolTracePanel = () => { |
| | | const record = useRecordContext(); |
| | | |
| | | if (!record?.toolSummary) { |
| | | return ( |
| | | <Typography variant="body2" color="text.secondary"> |
| | | 暂无工具轨迹数据 |
| | | </Typography> |
| | | ); |
| | | } |
| | | |
| | | let tools = []; |
| | | try { |
| | | const parsed = JSON.parse(record.toolSummary); |
| | | if (Array.isArray(parsed)) { |
| | | tools = parsed; |
| | | } |
| | | } catch (error) { |
| | | } |
| | | |
| | | if (!tools.length) { |
| | | return ( |
| | | <TextInput source="toolSummary" label="工具摘要(JSON)" fullWidth multiline minRows={6} disabled /> |
| | | ); |
| | | } |
| | | |
| | | return ( |
| | | <Box sx={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(280px, 1fr))', gap: 2 }}> |
| | | {tools.map((item, index) => ( |
| | | <Paper key={`${item.toolCode || 'tool'}-${index}`} variant="outlined" sx={{ p: 2, borderRadius: 2 }}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle2" sx={{ fontWeight: 700 }}> |
| | | {item.toolName || item.toolCode || `工具${index + 1}`} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {(item.mcpToolName || item.toolCode || '-') + (item.mountCode ? ` · ${item.mountCode}` : '')} |
| | | </Typography> |
| | | </Box> |
| | | <Chip size="small" label={item.severity || 'INFO'} color={item.severity === 'ERROR' ? 'error' : (item.severity === 'WARN' ? 'warning' : 'primary')} /> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1.25, whiteSpace: 'pre-wrap' }}> |
| | | {item.summaryText || '无摘要'} |
| | | </Typography> |
| | | </Paper> |
| | | ))} |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | const AiDiagnosisEdit = () => ( |
| | | <Edit actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm toolbar={false}> |
| | | <Grid container width={{ xs: '100%', xl: '85%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12}> |
| | | <Typography variant="h6" gutterBottom>诊断概览</Typography> |
| | | <Stack direction='row' gap={2} flexWrap="wrap"> |
| | | <TextInput source="diagnosisNo" label="诊断编号" disabled /> |
| | | <TextInput source="sceneCode$" label="场景" disabled /> |
| | | <TextInput source="modelCode" label="模型编码" disabled /> |
| | | <TextInput source="result$" label="结果" disabled /> |
| | | <NumberInput source="spendTime" label="耗时(ms)" disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2} sx={{ mt: 1 }}> |
| | | <ReportExportButton /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="h6" gutterBottom>报告摘要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="reportTitle" label="报告标题" fullWidth disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="question" label="诊断问题" fullWidth multiline minRows={3} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="executiveSummary" label="问题概述" fullWidth multiline minRows={4} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="evidenceSummary" label="关键证据" fullWidth multiline minRows={4} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="actionSummary" label="建议动作" fullWidth multiline minRows={4} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="riskSummary" label="风险评估" fullWidth multiline minRows={4} disabled /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="h6" gutterBottom>原始数据</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="conclusion" label="AI结论" fullWidth multiline minRows={6} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="reportMarkdown" label="报告Markdown" fullWidth multiline minRows={8} disabled /> |
| | | </Stack> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Typography variant="subtitle1" gutterBottom>工具执行轨迹</Typography> |
| | | <ToolTracePanel /> |
| | | </Box> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="err" label="错误信息" fullWidth multiline minRows={3} disabled /> |
| | | </Stack> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ); |
| | | |
| | | export default AiDiagnosisEdit; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useListContext, |
| | | Pagination, |
| | | } from 'react-admin'; |
| | | import { Box, Chip, Grid, Stack, Typography } from '@mui/material'; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <TextInput source="diagnosisNo" label="诊断编号" />, |
| | | <TextInput source="modelCode" label="模型编码" />, |
| | | <SelectInput source="result" label="结果" choices={[ |
| | | { id: '2', name: '运行中' }, |
| | | { id: '1', name: '成功' }, |
| | | { id: '0', name: '失败' }, |
| | | ]} />, |
| | | ]; |
| | | |
| | | const resultColor = (result) => { |
| | | if (result === 1) { |
| | | return 'success'; |
| | | } |
| | | if (result === 0) { |
| | | return 'error'; |
| | | } |
| | | return 'warning'; |
| | | }; |
| | | |
| | | const DiagnosisBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const successCount = records.filter((item) => item.result === 1).length; |
| | | const failedCount = records.filter((item) => item.result === 0).length; |
| | | const runningCount = records.filter((item) => item.result === 2).length; |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI诊断报告" |
| | | subtitle="诊断列表改成报告卡片视图,突出结论标题、处理结果和耗时;详情页继续承接完整报告与 Markdown 导出。" |
| | | stats={[ |
| | | { label: '报告总数', value: records.length }, |
| | | { label: '成功', value: successCount }, |
| | | { label: '失败', value: failedCount }, |
| | | { label: '运行中', value: runningCount }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="报告列表" |
| | | subtitle="点击编辑进入报告详情页,可查看结构化报告、原始结论和导出 Markdown。" |
| | | minHeight={460} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Box sx={aiCardSx(record.result === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.reportTitle || record.diagnosisNo} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | {record.sceneCode$} · {record.modelCode || '未记录模型'} |
| | | </Typography> |
| | | </Box> |
| | | <Chip size="small" color={resultColor(record.result)} label={record.result$ || '未知'} /> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1.25, minHeight: 72, color: '#31465d' }}> |
| | | {record.executiveSummary || record.question || '暂无诊断摘要'} |
| | | </Typography> |
| | | <Stack direction="row" spacing={2} sx={{ mt: 1.25 }}> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>耗时</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.spendTime || 0} ms</Typography> |
| | | </Box> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>开始时间</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.startTime || '-'}</Typography> |
| | | </Box> |
| | | </Stack> |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | </Box> |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiDiagnosisList = () => ( |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiDiagnosis"} |
| | | filters={filters} |
| | | sort={{ field: "createTime", order: "desc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <SelectColumnsButton preferenceKey='aiDiagnosis' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <DiagnosisBoard /> |
| | | </List> |
| | | ); |
| | | |
| | | export default AiDiagnosisList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiDiagnosisList from "./AiDiagnosisList"; |
| | | import AiDiagnosisEdit from "./AiDiagnosisEdit"; |
| | | |
| | | export default { |
| | | list: AiDiagnosisList, |
| | | edit: AiDiagnosisEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.diagnosisNo || record.id || ''}` |
| | | }; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | CreateBase, |
| | | useTranslate, |
| | | TextInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | useNotify, |
| | | Form, |
| | | } from 'react-admin'; |
| | | import { |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Grid, |
| | | Box, |
| | | } from '@mui/material'; |
| | | import DialogCloseButton from "@/page/components/DialogCloseButton"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | ]; |
| | | |
| | | const AiDiagnosisPlanCreate = (props) => { |
| | | const { open, setOpen } = props; |
| | | const translate = useTranslate(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleClose = (event, reason) => { |
| | | if (reason !== "backdropClick") { |
| | | setOpen(false); |
| | | } |
| | | }; |
| | | |
| | | const handleSuccess = async () => { |
| | | setOpen(false); |
| | | notify('common.response.success'); |
| | | }; |
| | | |
| | | const handleError = async (error) => { |
| | | notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } }); |
| | | }; |
| | | |
| | | return ( |
| | | <CreateBase |
| | | record={{ |
| | | sceneCode: 'system_diagnose', |
| | | cronExpr: '0 0/30 * * * ?', |
| | | status: 1, |
| | | }} |
| | | mutationOptions={{ onSuccess: handleSuccess, onError: handleError }} |
| | | > |
| | | <Dialog open={open} onClose={handleClose} fullWidth disableRestoreFocus maxWidth="md"> |
| | | <Form> |
| | | <DialogTitle sx={{ position: 'sticky', top: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | {translate('create.title')} |
| | | <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}> |
| | | <DialogCloseButton onClose={handleClose} /> |
| | | </Box> |
| | | </DialogTitle> |
| | | <DialogContent sx={{ mt: 2 }}> |
| | | <Grid container rowSpacing={2} columnSpacing={2}> |
| | | <Grid item xs={6}><TextInput source="planName" label="计划名称" fullWidth /></Grid> |
| | | <Grid item xs={6}><SelectInput source="sceneCode" label="场景" choices={sceneChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="cronExpr" label="Cron表达式" fullWidth helperText="例如: 0 0/30 * * * ?" /></Grid> |
| | | <Grid item xs={6}><TextInput source="preferredModelCode" label="优先模型编码" fullWidth /></Grid> |
| | | <Grid item xs={12}><TextInput source="prompt" label="巡检提示词" fullWidth multiline minRows={5} /></Grid> |
| | | <Grid item xs={6}><StatusSelectInput fullWidth /></Grid> |
| | | <Grid item xs={12}><MemoInput /></Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | </Toolbar> |
| | | </DialogActions> |
| | | </Form> |
| | | </Dialog> |
| | | </CreateBase> |
| | | ) |
| | | } |
| | | |
| | | export default AiDiagnosisPlanCreate; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import { EDIT_MODE } from '@/config/setting'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | ]; |
| | | |
| | | const FormToolbar = () => ( |
| | | <Toolbar sx={{ justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | <DeleteButton mutationMode="optimistic" /> |
| | | </Toolbar> |
| | | ); |
| | | |
| | | const AiDiagnosisPlanEdit = () => ( |
| | | <Edit redirect="list" mutationMode={EDIT_MODE} actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm shouldUnregister warnWhenUnsavedChanges toolbar={<FormToolbar />} mode="onTouched"> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12} md={8}> |
| | | <Typography variant="h6" gutterBottom>主要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="planName" label="计划名称" fullWidth /> |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="cronExpr" label="Cron表达式" fullWidth /> |
| | | <TextInput source="preferredModelCode" label="优先模型编码" fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="nextRunTime$" label="下次运行时间" disabled fullWidth /> |
| | | <TextInput source="lastRunTime$" label="上次运行时间" disabled fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="lastResult$" label="最近结果" disabled fullWidth /> |
| | | <TextInput source="lastDiagnosisId" label="最近诊断ID" disabled fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="lastMessage" label="最近消息" fullWidth multiline minRows={3} disabled /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="prompt" label="巡检提示词" fullWidth multiline minRows={6} /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="h6" gutterBottom>通用</Typography> |
| | | <StatusSelectInput /> |
| | | <MemoInput /> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ) |
| | | |
| | | export default AiDiagnosisPlanEdit; |
| New file |
| | |
| | | import React, { useState } from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useListContext, |
| | | Pagination, |
| | | useNotify, |
| | | useRefresh, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Box, Button, Chip, Grid, Stack, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import MyCreateButton from "@/page/components/MyCreateButton"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { DEFAULT_PAGE_SIZE, OPERATE_MODE } from '@/config/setting'; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | import AiDiagnosisPlanCreate from "./AiDiagnosisPlanCreate"; |
| | | import request from "@/utils/request"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | ]; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} />, |
| | | <TextInput source="planName" label="计划名称" />, |
| | | <SelectInput source="status" label="状态" choices={[ |
| | | { id: '1', name: '启用' }, |
| | | { id: '0', name: '停用' }, |
| | | ]} />, |
| | | ]; |
| | | |
| | | const RunPlanButton = ({ record }) => { |
| | | const notify = useNotify(); |
| | | const refresh = useRefresh(); |
| | | |
| | | const handleRun = async () => { |
| | | try { |
| | | const { data } = await request.post('/ai/diagnosis-plan/run', { id: record.id }); |
| | | if (data?.code !== 200) { |
| | | throw new Error(data?.msg || '执行失败'); |
| | | } |
| | | notify('已提交执行'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '执行失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <Button size="small" variant="outlined" onClick={handleRun} disabled={record.runningFlag === 1}> |
| | | 立即执行 |
| | | </Button> |
| | | ); |
| | | }; |
| | | |
| | | const PlanBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const enabledCount = records.filter((item) => item.status === 1).length; |
| | | const runningCount = records.filter((item) => item.runningFlag === 1).length; |
| | | const successCount = records.filter((item) => item.lastResult === 1).length; |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI巡检计划" |
| | | subtitle="把一键诊断扩成可计划执行的巡检任务,按 Cron 定时触发,自动生成新的诊断记录和报告。" |
| | | stats={[ |
| | | { label: '计划总数', value: records.length }, |
| | | { label: '启用', value: enabledCount }, |
| | | { label: '运行中', value: runningCount }, |
| | | { label: '最近成功', value: successCount }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="计划列表" |
| | | subtitle="计划执行时会自动写入诊断记录和调用日志;停用后不会继续触发,但仍可手动执行。" |
| | | minHeight={420} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Box sx={aiCardSx(record.status === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.planName || '未命名计划'} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | {record.sceneCode$} · {record.cronExpr} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={record.status === 1 ? 'success' : 'default'} label={record.status === 1 ? '启用' : '停用'} /> |
| | | <Chip size="small" color={record.lastResult === 1 ? 'success' : (record.lastResult === 0 ? 'error' : 'warning')} label={record.lastResult$} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Stack direction="row" spacing={2} sx={{ mt: 1.25 }}> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>优先模型</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.preferredModelCode || '自动路由'}</Typography> |
| | | </Box> |
| | | <Box> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>下次运行</Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}>{record.nextRunTime$ || '-'}</Typography> |
| | | </Box> |
| | | </Stack> |
| | | <Box sx={{ mt: 1.25 }}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>最近消息</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.5, minHeight: 72, color: '#31465d' }}> |
| | | {record.lastMessage || record.prompt || '暂无执行记录'} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={1} sx={{ mt: 1.5 }} flexWrap="wrap"> |
| | | <RunPlanButton record={record} /> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | <DeleteButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} mutationMode={OPERATE_MODE} /> |
| | | </Stack> |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiDiagnosisPlanList = () => { |
| | | const [createDialog, setCreateDialog] = useState(false); |
| | | |
| | | return ( |
| | | <Box display="flex" sx={{ width: '100%' }}> |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiDiagnosisPlan"} |
| | | empty={<EmptyData onClick={() => { setCreateDialog(true) }} />} |
| | | filters={filters} |
| | | sort={{ field: "nextRunTime", order: "asc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <MyCreateButton onClick={() => { setCreateDialog(true) }} /> |
| | | <SelectColumnsButton preferenceKey='aiDiagnosisPlan' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <PlanBoard /> |
| | | </List> |
| | | <AiDiagnosisPlanCreate open={createDialog} setOpen={setCreateDialog} /> |
| | | </Box> |
| | | ) |
| | | } |
| | | |
| | | export default AiDiagnosisPlanList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiDiagnosisPlanList from "./AiDiagnosisPlanList"; |
| | | import AiDiagnosisPlanEdit from "./AiDiagnosisPlanEdit"; |
| | | |
| | | export default { |
| | | list: AiDiagnosisPlanList, |
| | | edit: AiDiagnosisPlanEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.planName || record.cronExpr || ''}` |
| | | }; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | CreateBase, |
| | | useTranslate, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | useNotify, |
| | | Form, |
| | | } from 'react-admin'; |
| | | import { |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Grid, |
| | | Box, |
| | | } from '@mui/material'; |
| | | import DialogCloseButton from "@/page/components/DialogCloseButton"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | |
| | | const transportChoices = [ |
| | | { id: 'INTERNAL', name: '内部工具集' }, |
| | | { id: 'HTTP', name: 'Streamable HTTP' }, |
| | | { id: 'SSE', name: 'SSE MCP' }, |
| | | ]; |
| | | |
| | | const enabledChoices = [ |
| | | { id: 1, name: '启用' }, |
| | | { id: 0, name: '停用' }, |
| | | ]; |
| | | |
| | | const AiMcpMountCreate = (props) => { |
| | | const { open, setOpen } = props; |
| | | const translate = useTranslate(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleClose = (event, reason) => { |
| | | if (reason !== "backdropClick") { |
| | | setOpen(false); |
| | | } |
| | | }; |
| | | |
| | | const handleSuccess = async () => { |
| | | setOpen(false); |
| | | notify('common.response.success'); |
| | | }; |
| | | |
| | | const handleError = async (error) => { |
| | | notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } }); |
| | | }; |
| | | |
| | | return ( |
| | | <CreateBase |
| | | record={{ transportType: 'INTERNAL', enabledFlag: 1, timeoutMs: 10000, status: 1 }} |
| | | mutationOptions={{ onSuccess: handleSuccess, onError: handleError }} |
| | | > |
| | | <Dialog open={open} onClose={handleClose} fullWidth disableRestoreFocus maxWidth="md"> |
| | | <Form> |
| | | <DialogTitle sx={{ position: 'sticky', top: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | {translate('create.title')} |
| | | <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}> |
| | | <DialogCloseButton onClose={handleClose} /> |
| | | </Box> |
| | | </DialogTitle> |
| | | <DialogContent sx={{ mt: 2 }}> |
| | | <Grid container rowSpacing={2} columnSpacing={2}> |
| | | <Grid item xs={6}><TextInput source="name" label="挂载名称" fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="mountCode" label="挂载编码" fullWidth /></Grid> |
| | | <Grid item xs={6}><SelectInput source="transportType" label="传输类型" choices={transportChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><SelectInput source="enabledFlag" label="启用" choices={enabledChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><NumberInput source="timeoutMs" label="超时毫秒" fullWidth /></Grid> |
| | | <Grid item xs={6}><StatusSelectInput fullWidth /></Grid> |
| | | <Grid item xs={12}><TextInput source="url" label="地址" fullWidth helperText="内部工具集会自动写入 /ai/mcp;外部挂载可填写远程 Streamable HTTP 或 SSE MCP 地址" /></Grid> |
| | | <Grid item xs={12}><MemoInput /></Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | </Toolbar> |
| | | </DialogActions> |
| | | </Form> |
| | | </Dialog> |
| | | </CreateBase> |
| | | ) |
| | | } |
| | | |
| | | export default AiMcpMountCreate; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import { EDIT_MODE } from '@/config/setting'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | |
| | | const transportChoices = [ |
| | | { id: 'INTERNAL', name: '内部工具集' }, |
| | | { id: 'HTTP', name: 'Streamable HTTP' }, |
| | | { id: 'SSE', name: 'SSE MCP' }, |
| | | ]; |
| | | |
| | | const enabledChoices = [ |
| | | { id: 1, name: '启用' }, |
| | | { id: 0, name: '停用' }, |
| | | ]; |
| | | |
| | | const FormToolbar = () => ( |
| | | <Toolbar sx={{ justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | <DeleteButton mutationMode="optimistic" /> |
| | | </Toolbar> |
| | | ); |
| | | |
| | | const AiMcpMountEdit = () => ( |
| | | <Edit redirect="list" mutationMode={EDIT_MODE} actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm shouldUnregister warnWhenUnsavedChanges toolbar={<FormToolbar />} mode="onTouched"> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12} md={8}> |
| | | <Typography variant="h6" gutterBottom>主要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="name" label="挂载名称" fullWidth /> |
| | | <TextInput source="mountCode" label="挂载编码" fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <SelectInput source="transportType" label="传输类型" choices={transportChoices} fullWidth /> |
| | | <SelectInput source="enabledFlag" label="启用" choices={enabledChoices} fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="url" label="地址" fullWidth /> |
| | | <NumberInput source="timeoutMs" label="超时毫秒" fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="lastTestResult$" label="最近测试结果" disabled fullWidth /> |
| | | <TextInput source="lastTestTime$" label="最近测试时间" disabled fullWidth /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="lastToolCount" label="最近工具数" disabled fullWidth /> |
| | | <TextInput source="lastTestMessage" label="最近测试消息" disabled fullWidth multiline minRows={2} /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="h6" gutterBottom>通用</Typography> |
| | | <StatusSelectInput /> |
| | | <MemoInput /> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ) |
| | | |
| | | export default AiMcpMountEdit; |
| New file |
| | |
| | | import React, { useEffect, useMemo, useState } from "react"; |
| | | import { |
| | | Accordion, |
| | | AccordionDetails, |
| | | AccordionSummary, |
| | | Alert, |
| | | Box, |
| | | Button, |
| | | Chip, |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | FormControl, |
| | | Grid, |
| | | InputLabel, |
| | | MenuItem, |
| | | Paper, |
| | | Select, |
| | | Stack, |
| | | Switch, |
| | | TextField, |
| | | Typography, |
| | | } from "@mui/material"; |
| | | import ExpandMoreIcon from "@mui/icons-material/ExpandMore"; |
| | | import AddIcon from "@mui/icons-material/Add"; |
| | | import EditIcon from "@mui/icons-material/Edit"; |
| | | import StorageIcon from "@mui/icons-material/Storage"; |
| | | import HubIcon from "@mui/icons-material/Hub"; |
| | | import BuildIcon from "@mui/icons-material/Build"; |
| | | import DeleteOutlineIcon from "@mui/icons-material/DeleteOutline"; |
| | | import { useNotify } from "react-admin"; |
| | | import request from "@/utils/request"; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const usageChoices = [ |
| | | { id: "DIAGNOSE_ONLY", name: "仅诊断使用" }, |
| | | { id: "CHAT_AND_DIAGNOSE", name: "聊天与诊断都可用" }, |
| | | { id: "DISABLED", name: "禁用" }, |
| | | ]; |
| | | |
| | | const transportChoices = [ |
| | | { id: "AUTO", name: "自动识别" }, |
| | | { id: "HTTP", name: "Streamable HTTP" }, |
| | | { id: "SSE", name: "SSE" }, |
| | | ]; |
| | | |
| | | const authChoices = [ |
| | | { id: "NONE", name: "无认证" }, |
| | | { id: "BEARER", name: "Bearer Token" }, |
| | | { id: "API_KEY", name: "X-API-Key" }, |
| | | ]; |
| | | |
| | | const defaultServiceForm = { |
| | | id: null, |
| | | name: "", |
| | | url: "", |
| | | transportType: "AUTO", |
| | | authType: "NONE", |
| | | authValue: "", |
| | | usageScope: "DIAGNOSE_ONLY", |
| | | timeoutMs: 10000, |
| | | enabledFlag: 1, |
| | | memo: "", |
| | | }; |
| | | |
| | | const usageLabel = (usageScope) => { |
| | | const matched = usageChoices.find((item) => item.id === usageScope); |
| | | return matched ? matched.name : "仅诊断使用"; |
| | | }; |
| | | |
| | | const transportLabel = (transportType) => { |
| | | const matched = transportChoices.find((item) => item.id === transportType); |
| | | return matched ? matched.name : transportType || "自动识别"; |
| | | }; |
| | | |
| | | const BuiltInToolCard = ({ tool, onSave }) => { |
| | | const notify = useNotify(); |
| | | const [usageScope, setUsageScope] = useState(tool.usageScope || "DIAGNOSE_ONLY"); |
| | | const [priority, setPriority] = useState(tool.priority || 10); |
| | | const [toolPrompt, setToolPrompt] = useState(tool.toolPrompt || ""); |
| | | const [saving, setSaving] = useState(false); |
| | | |
| | | useEffect(() => { |
| | | setUsageScope(tool.usageScope || "DIAGNOSE_ONLY"); |
| | | setPriority(tool.priority || 10); |
| | | setToolPrompt(tool.toolPrompt || ""); |
| | | }, [tool]); |
| | | |
| | | const handleSave = async () => { |
| | | try { |
| | | setSaving(true); |
| | | const { data: res } = await request.post("/ai/mcp/console/builtin-tool/save", { |
| | | toolCode: tool.toolCode, |
| | | toolName: tool.toolName, |
| | | priority: priority || 10, |
| | | toolPrompt, |
| | | usageScope, |
| | | }); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "保存失败"); |
| | | } |
| | | notify("内置工具策略已更新"); |
| | | onSave?.(res?.data || []); |
| | | } catch (error) { |
| | | notify(error.message || "保存失败", { type: "error" }); |
| | | } finally { |
| | | setSaving(false); |
| | | } |
| | | }; |
| | | |
| | | const enabled = usageScope !== "DISABLED"; |
| | | |
| | | return ( |
| | | <Box sx={aiCardSx(enabled)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: "#284059" }}> |
| | | {tool.toolName} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: "#8093a8" }}> |
| | | {tool.toolCode} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={enabled ? "success" : "default"} label={enabled ? "启用" : "停用"} /> |
| | | <Chip size="small" color="primary" label={usageLabel(usageScope)} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1.5, minHeight: 44, color: "#31465d" }}> |
| | | {tool.description || "系统内置工具,默认由平台托管。"} |
| | | </Typography> |
| | | <FormControl size="small" fullWidth sx={{ mt: 1.5 }}> |
| | | <InputLabel>用途预设</InputLabel> |
| | | <Select value={usageScope} label="用途预设" onChange={(event) => setUsageScope(event.target.value)}> |
| | | {usageChoices.map((item) => ( |
| | | <MenuItem key={item.id} value={item.id}>{item.name}</MenuItem> |
| | | ))} |
| | | </Select> |
| | | </FormControl> |
| | | <Accordion elevation={0} disableGutters sx={{ mt: 1.5, borderRadius: 2, border: "1px solid #dbe5f1" }}> |
| | | <AccordionSummary expandIcon={<ExpandMoreIcon />}> |
| | | <Typography variant="body2" sx={{ fontWeight: 600 }}>高级设置</Typography> |
| | | </AccordionSummary> |
| | | <AccordionDetails> |
| | | <Stack spacing={1.5}> |
| | | <TextField |
| | | label="执行优先级" |
| | | size="small" |
| | | type="number" |
| | | value={priority} |
| | | onChange={(event) => setPriority(Number(event.target.value || 10))} |
| | | /> |
| | | <TextField |
| | | label="附加规则" |
| | | size="small" |
| | | value={toolPrompt} |
| | | multiline |
| | | minRows={3} |
| | | onChange={(event) => setToolPrompt(event.target.value)} |
| | | helperText="这里只在需要覆盖默认工具说明时填写。" |
| | | /> |
| | | </Stack> |
| | | </AccordionDetails> |
| | | </Accordion> |
| | | <Stack direction="row" justifyContent="flex-end" sx={{ mt: 1.5 }}> |
| | | <Button variant="contained" size="small" onClick={handleSave} disabled={saving}> |
| | | {saving ? "保存中..." : "保存"} |
| | | </Button> |
| | | </Stack> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | const ServiceDialog = ({ open, record, onClose, onSaved }) => { |
| | | const notify = useNotify(); |
| | | const [form, setForm] = useState(defaultServiceForm); |
| | | const [saving, setSaving] = useState(false); |
| | | |
| | | useEffect(() => { |
| | | if (open) { |
| | | setForm(record ? { |
| | | ...defaultServiceForm, |
| | | ...record, |
| | | authType: record.authType || "NONE", |
| | | transportType: record.transportType || "AUTO", |
| | | usageScope: record.usageScope || "DIAGNOSE_ONLY", |
| | | timeoutMs: record.timeoutMs || 10000, |
| | | } : defaultServiceForm); |
| | | } |
| | | }, [open, record]); |
| | | |
| | | const handleChange = (field, value) => { |
| | | setForm((prev) => ({ ...prev, [field]: value })); |
| | | }; |
| | | |
| | | const handleSave = async () => { |
| | | try { |
| | | setSaving(true); |
| | | const payload = { ...form }; |
| | | if (payload.authType === "NONE") { |
| | | payload.authValue = ""; |
| | | } |
| | | const { data: res } = await request.post("/ai/mcp/console/service/save", payload); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "保存失败"); |
| | | } |
| | | notify("外部 MCP 服务已保存"); |
| | | onSaved?.(res?.data); |
| | | } catch (error) { |
| | | notify(error.message || "保存失败", { type: "error" }); |
| | | } finally { |
| | | setSaving(false); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <Dialog open={open} onClose={onClose} fullWidth maxWidth="md"> |
| | | <DialogTitle>{form.id ? "编辑外部 MCP 服务" : "新增外部 MCP 服务"}</DialogTitle> |
| | | <DialogContent dividers> |
| | | <Grid container spacing={2} sx={{ mt: 0.25 }}> |
| | | <Grid item xs={12} md={6}> |
| | | <TextField label="服务名称" fullWidth size="small" value={form.name} onChange={(event) => handleChange("name", event.target.value)} /> |
| | | </Grid> |
| | | <Grid item xs={12} md={6}> |
| | | <FormControl fullWidth size="small"> |
| | | <InputLabel>连接方式</InputLabel> |
| | | <Select value={form.transportType} label="连接方式" onChange={(event) => handleChange("transportType", event.target.value)}> |
| | | {transportChoices.map((item) => ( |
| | | <MenuItem key={item.id} value={item.id}>{item.name}</MenuItem> |
| | | ))} |
| | | </Select> |
| | | </FormControl> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <TextField |
| | | label="服务地址" |
| | | fullWidth |
| | | size="small" |
| | | value={form.url} |
| | | onChange={(event) => handleChange("url", event.target.value)} |
| | | helperText="直接填写远程 MCP 服务地址;系统会优先按自动识别协议连接。" |
| | | /> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <FormControl fullWidth size="small"> |
| | | <InputLabel>认证方式</InputLabel> |
| | | <Select value={form.authType} label="认证方式" onChange={(event) => handleChange("authType", event.target.value)}> |
| | | {authChoices.map((item) => ( |
| | | <MenuItem key={item.id} value={item.id}>{item.name}</MenuItem> |
| | | ))} |
| | | </Select> |
| | | </FormControl> |
| | | </Grid> |
| | | <Grid item xs={12} md={8}> |
| | | <TextField |
| | | label="认证信息" |
| | | fullWidth |
| | | size="small" |
| | | type={form.authType === "NONE" ? "text" : "password"} |
| | | value={form.authValue} |
| | | disabled={form.authType === "NONE"} |
| | | onChange={(event) => handleChange("authValue", event.target.value)} |
| | | /> |
| | | </Grid> |
| | | <Grid item xs={12} md={6}> |
| | | <FormControl fullWidth size="small"> |
| | | <InputLabel>用途预设</InputLabel> |
| | | <Select value={form.usageScope} label="用途预设" onChange={(event) => handleChange("usageScope", event.target.value)}> |
| | | {usageChoices.filter((item) => item.id !== "DISABLED").map((item) => ( |
| | | <MenuItem key={item.id} value={item.id}>{item.name}</MenuItem> |
| | | ))} |
| | | </Select> |
| | | </FormControl> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <TextField |
| | | label="超时毫秒" |
| | | type="number" |
| | | size="small" |
| | | fullWidth |
| | | value={form.timeoutMs} |
| | | onChange={(event) => handleChange("timeoutMs", Number(event.target.value || 10000))} |
| | | /> |
| | | </Grid> |
| | | <Grid item xs={12} md={3}> |
| | | <Stack direction="row" alignItems="center" spacing={1} sx={{ height: "100%" }}> |
| | | <Switch checked={Number(form.enabledFlag) === 1} onChange={(event) => handleChange("enabledFlag", event.target.checked ? 1 : 0)} /> |
| | | <Typography variant="body2">启用服务</Typography> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <TextField |
| | | label="备注" |
| | | fullWidth |
| | | size="small" |
| | | value={form.memo} |
| | | multiline |
| | | minRows={2} |
| | | onChange={(event) => handleChange("memo", event.target.value)} |
| | | /> |
| | | </Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions> |
| | | <Button onClick={onClose}>取消</Button> |
| | | <Button variant="contained" onClick={handleSave} disabled={saving}>{saving ? "保存中..." : "保存"}</Button> |
| | | </DialogActions> |
| | | </Dialog> |
| | | ); |
| | | }; |
| | | |
| | | const ExternalServiceCard = ({ service, onEdit, onTest, onInspect, onRemove }) => ( |
| | | <Box sx={aiCardSx(service.enabledFlag === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: "#284059" }}> |
| | | {service.name} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: "#8093a8" }}> |
| | | {transportLabel(service.transportType)} · {usageLabel(service.usageScope)} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={service.enabledFlag === 1 ? "success" : "default"} label={service.enabledFlag === 1 ? "启用" : "停用"} /> |
| | | <Chip size="small" color={service.lastTestResult === 1 ? "success" : "default"} label={service.lastTestResult$ || "未测试"} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1.25, color: "#31465d", minHeight: 42 }}> |
| | | {service.url} |
| | | </Typography> |
| | | {service.lastTestMessage ? ( |
| | | <Alert severity={service.lastTestResult === 1 ? "success" : "warning"} sx={{ mt: 1.25 }}> |
| | | {service.lastTestMessage} |
| | | </Alert> |
| | | ) : null} |
| | | <Typography variant="caption" display="block" sx={{ mt: 1.25, color: "#70839a" }}> |
| | | {service.lastTestTime ? `最近测试: ${service.lastTestTime}` : "最近测试: -"} |
| | | </Typography> |
| | | <Typography variant="caption" display="block" sx={{ color: "#70839a" }}> |
| | | {service.lastToolCount !== null && service.lastToolCount !== undefined ? `已发现工具: ${service.lastToolCount}` : "已发现工具: -"} |
| | | </Typography> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1.5 }}> |
| | | <Button size="small" variant="outlined" onClick={() => onInspect(service)}>查看工具</Button> |
| | | <Button size="small" variant="outlined" onClick={() => onTest(service)}>连接测试</Button> |
| | | <Button size="small" startIcon={<EditIcon />} onClick={() => onEdit(service)}>编辑</Button> |
| | | <Button size="small" color="error" startIcon={<DeleteOutlineIcon />} onClick={() => onRemove(service)}>删除</Button> |
| | | </Stack> |
| | | </Box> |
| | | ); |
| | | |
| | | const ExternalToolsPanel = ({ selectedService, tools, preview, onPreview }) => ( |
| | | <Grid container spacing={2}> |
| | | <Grid item xs={12} lg={7}> |
| | | <AiConsolePanel |
| | | title="外部服务工具目录" |
| | | subtitle={selectedService ? `当前服务:${selectedService.name}` : "选中任一外部服务后,会展示最新发现的工具目录。"} |
| | | minHeight={320} |
| | | > |
| | | <Box sx={{ display: "grid", gap: 1.5 }}> |
| | | {tools.map((tool) => ( |
| | | <Paper key={tool.mcpToolName} variant="outlined" sx={{ p: 1.5, borderRadius: 2 }}> |
| | | <Stack direction="row" justifyContent="space-between" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle2" sx={{ fontWeight: 700 }}> |
| | | {tool.toolName || tool.mcpToolName} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {tool.mcpToolName} |
| | | </Typography> |
| | | </Box> |
| | | <Button size="small" variant="text" onClick={() => onPreview(tool)}>执行预览</Button> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1 }}> |
| | | {tool.description || tool.toolPrompt || "暂无说明"} |
| | | </Typography> |
| | | </Paper> |
| | | ))} |
| | | {!tools.length ? <EmptyData /> : null} |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </Grid> |
| | | <Grid item xs={12} lg={5}> |
| | | <AiConsolePanel |
| | | title="工具预览" |
| | | subtitle="用于快速确认远程工具是否真的能返回结果。" |
| | | minHeight={320} |
| | | > |
| | | {preview ? ( |
| | | <Box> |
| | | <Typography variant="subtitle2" sx={{ fontWeight: 700 }}> |
| | | {preview.toolName || preview.mcpToolName || preview.toolCode} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {preview.severity || "INFO"} |
| | | </Typography> |
| | | <Typography variant="body2" sx={{ mt: 1.5, whiteSpace: "pre-wrap" }}> |
| | | {preview.summaryText || "暂无摘要"} |
| | | </Typography> |
| | | </Box> |
| | | ) : ( |
| | | <EmptyData /> |
| | | )} |
| | | </AiConsolePanel> |
| | | </Grid> |
| | | </Grid> |
| | | ); |
| | | |
| | | const AiMcpMountList = () => { |
| | | const notify = useNotify(); |
| | | const [overview, setOverview] = useState({ builtInMount: null, builtInTools: [], externalServices: [] }); |
| | | const [serviceDialogOpen, setServiceDialogOpen] = useState(false); |
| | | const [editingService, setEditingService] = useState(null); |
| | | const [selectedService, setSelectedService] = useState(null); |
| | | const [serviceTools, setServiceTools] = useState([]); |
| | | const [preview, setPreview] = useState(null); |
| | | |
| | | const loadOverview = async () => { |
| | | try { |
| | | const { data: res } = await request.get("/ai/mcp/console/overview"); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "加载失败"); |
| | | } |
| | | setOverview(res.data || { builtInMount: null, builtInTools: [], externalServices: [] }); |
| | | } catch (error) { |
| | | notify(error.message || "加载失败", { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | useEffect(() => { |
| | | loadOverview(); |
| | | }, []); |
| | | |
| | | const stats = useMemo(() => { |
| | | const builtInEnabled = (overview.builtInTools || []).filter((item) => item.usageScope !== "DISABLED").length; |
| | | const externalEnabled = (overview.externalServices || []).filter((item) => item.enabledFlag === 1).length; |
| | | const successCount = (overview.externalServices || []).filter((item) => item.lastTestResult === 1).length; |
| | | const discoveredTools = (overview.externalServices || []).reduce((sum, item) => sum + (item.lastToolCount || 0), 0); |
| | | return [ |
| | | { label: "内置工具启用", value: builtInEnabled }, |
| | | { label: "外部服务", value: (overview.externalServices || []).length }, |
| | | { label: "最近测试成功", value: successCount }, |
| | | { label: "发现外部工具", value: discoveredTools }, |
| | | ]; |
| | | }, [overview]); |
| | | |
| | | const handleBuiltInSaved = (tools) => { |
| | | setOverview((prev) => ({ ...prev, builtInTools: tools })); |
| | | }; |
| | | |
| | | const handleServiceSaved = async () => { |
| | | setServiceDialogOpen(false); |
| | | setEditingService(null); |
| | | await loadOverview(); |
| | | }; |
| | | |
| | | const handleTestService = async (service) => { |
| | | try { |
| | | const { data: res } = await request.post("/ai/mcp/console/service/test", { id: service.id }, { timeout: (service.timeoutMs || 10000) + 5000 }); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "测试失败"); |
| | | } |
| | | notify(res?.data?.message || "连接测试完成"); |
| | | await loadOverview(); |
| | | setSelectedService(res?.data?.service || service); |
| | | setServiceTools(res?.data?.tools || []); |
| | | setPreview(null); |
| | | } catch (error) { |
| | | notify(error.message || "测试失败", { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handleInspectService = async (service) => { |
| | | try { |
| | | const { data: res } = await request.get("/ai/mcp/mount/toolList", { params: { mountId: service.id } }); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "加载工具失败"); |
| | | } |
| | | setSelectedService(service); |
| | | setServiceTools(res.data || []); |
| | | setPreview(null); |
| | | } catch (error) { |
| | | notify(error.message || "加载工具失败", { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handlePreviewTool = async (tool) => { |
| | | try { |
| | | const { data: res } = await request.post("/ai/mcp/mount/toolPreview", { |
| | | mountCode: tool.mountCode, |
| | | toolCode: tool.toolCode, |
| | | sceneCode: tool.sceneCode, |
| | | question: "请返回该工具当前的摘要预览结果", |
| | | }); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "预览失败"); |
| | | } |
| | | setPreview(res.data); |
| | | } catch (error) { |
| | | notify(error.message || "预览失败", { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | const handleRemoveService = async (service) => { |
| | | try { |
| | | const { data: res } = await request.post(`/ai/mcp/console/service/remove/${service.id}`); |
| | | if (res?.code !== 200) { |
| | | throw new Error(res?.msg || "删除失败"); |
| | | } |
| | | notify("服务已删除"); |
| | | if (selectedService?.id === service.id) { |
| | | setSelectedService(null); |
| | | setServiceTools([]); |
| | | setPreview(null); |
| | | } |
| | | await loadOverview(); |
| | | } catch (error) { |
| | | notify(error.message || "删除失败", { type: "error" }); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <Box sx={{ width: "100%" }}> |
| | | <AiConsoleLayout |
| | | title="MCP中心" |
| | | subtitle="内置工具由系统自动托管;外部 MCP 服务只保留接入地址、认证和用途预设,复杂协议细节由系统自动处理。" |
| | | stats={stats} |
| | | > |
| | | <AiConsolePanel |
| | | title="内置工具" |
| | | subtitle="这些工具直接连接当前 WMS 内部数据,默认开箱即用,不需要手动配置挂载地址或协议。" |
| | | action={( |
| | | <Chip |
| | | icon={<StorageIcon />} |
| | | label={overview.builtInMount ? "系统托管" : "待初始化"} |
| | | color={overview.builtInMount ? "success" : "default"} |
| | | variant="outlined" |
| | | /> |
| | | )} |
| | | minHeight={260} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {(overview.builtInTools || []).map((tool) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={tool.toolCode}> |
| | | <BuiltInToolCard tool={tool} onSave={handleBuiltInSaved} /> |
| | | </Grid> |
| | | ))} |
| | | {!overview?.builtInTools?.length ? <EmptyData /> : null} |
| | | </Grid> |
| | | </AiConsolePanel> |
| | | |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <AiConsolePanel |
| | | title="外部 MCP 服务" |
| | | subtitle="默认只需要录入服务名称、地址和用途。系统会优先自动识别协议,连接成功后再展示发现到的工具。" |
| | | action={( |
| | | <Button variant="contained" startIcon={<AddIcon />} onClick={() => { setEditingService(null); setServiceDialogOpen(true); }}> |
| | | 新增服务 |
| | | </Button> |
| | | )} |
| | | minHeight={280} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {(overview.externalServices || []).map((service) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={service.id}> |
| | | <ExternalServiceCard |
| | | service={service} |
| | | onEdit={(item) => { setEditingService(item); setServiceDialogOpen(true); }} |
| | | onTest={handleTestService} |
| | | onInspect={handleInspectService} |
| | | onRemove={handleRemoveService} |
| | | /> |
| | | </Grid> |
| | | ))} |
| | | {!overview?.externalServices?.length ? <EmptyData /> : null} |
| | | </Grid> |
| | | </AiConsolePanel> |
| | | </Box> |
| | | |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <AiConsolePanel |
| | | title="工具使用策略" |
| | | subtitle="内置工具按用途预设自动参与聊天或诊断;外部服务在连接成功后可以查看其实际工具目录并做调用预览。" |
| | | minHeight={120} |
| | | > |
| | | <Stack direction="row" spacing={1} flexWrap="wrap"> |
| | | <Chip icon={<BuildIcon />} label="内置工具默认自动参与诊断" color="primary" variant="outlined" /> |
| | | <Chip icon={<HubIcon />} label="外部服务默认按用途预设参与运行时工具选择" color="primary" variant="outlined" /> |
| | | <Chip label="高级规则已折叠到各卡片内部" variant="outlined" /> |
| | | </Stack> |
| | | </AiConsolePanel> |
| | | </Box> |
| | | |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <ExternalToolsPanel |
| | | selectedService={selectedService} |
| | | tools={serviceTools} |
| | | preview={preview} |
| | | onPreview={handlePreviewTool} |
| | | /> |
| | | </Box> |
| | | </AiConsoleLayout> |
| | | |
| | | <ServiceDialog |
| | | open={serviceDialogOpen} |
| | | record={editingService} |
| | | onClose={() => { |
| | | setServiceDialogOpen(false); |
| | | setEditingService(null); |
| | | }} |
| | | onSaved={handleServiceSaved} |
| | | /> |
| | | </Box> |
| | | ); |
| | | }; |
| | | |
| | | export default AiMcpMountList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiMcpMountList from "./AiMcpMountList"; |
| | | import AiMcpMountEdit from "./AiMcpMountEdit"; |
| | | |
| | | export default { |
| | | list: AiMcpMountList, |
| | | edit: AiMcpMountEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.name || record.mountCode || ''}` |
| | | }; |
| | |
| | | import React, { useState } from "react"; |
| | | import { |
| | | List, |
| | | DatagridConfigurable, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | BulkDeleteButton, |
| | | WrapperField, |
| | | TextField, |
| | | NumberField, |
| | | DateField, |
| | | BooleanField, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useListContext, |
| | | Pagination, |
| | | EditButton, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Box } from '@mui/material'; |
| | | import { styled } from '@mui/material/styles'; |
| | | import { Box, Chip, Grid, Stack, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import MyCreateButton from "@/page/components/MyCreateButton"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { OPERATE_MODE, DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import AiParamCreate from "./AiParamCreate"; |
| | | |
| | | const StyledDatagrid = styled(DatagridConfigurable)(({ theme }) => ({ |
| | | '& .css-1vooibu-MuiSvgIcon-root': { |
| | | height: '.9em' |
| | | }, |
| | | '& .RaDatagrid-row': { |
| | | cursor: 'auto' |
| | | }, |
| | | '& .opt': { |
| | | width: 200 |
| | | }, |
| | | })); |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | |
| | | { id: '0', name: 'common.enums.statusFalse' }, |
| | | ]} |
| | | />, |
| | | ] |
| | | ]; |
| | | |
| | | const providerLabel = (provider) => { |
| | | if (provider === 'openai') { |
| | | return 'OpenAI Compatible'; |
| | | } |
| | | if (provider === 'mock') { |
| | | return 'Mock'; |
| | | } |
| | | return provider || '未配置'; |
| | | }; |
| | | |
| | | const AiParamBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const enabledCount = records.filter((item) => item.status === 1).length; |
| | | const defaultCount = records.filter((item) => item.defaultFlag === 1).length; |
| | | const openaiCount = records.filter((item) => item.provider === 'openai').length; |
| | | const mockCount = records.filter((item) => item.provider === 'mock').length; |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI参数" |
| | | subtitle="保持当前系统后台的白底卡片和轻边框风格,用卡片方式展示模型配置概况,方便和其他 AI 页面统一查看。" |
| | | stats={[ |
| | | { label: '参数总数', value: records.length }, |
| | | { label: '启用', value: enabledCount }, |
| | | { label: '默认模型', value: defaultCount }, |
| | | { label: 'OpenAI / Mock', value: `${openaiCount} / ${mockCount}` }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="模型配置" |
| | | subtitle="展示模型编码、供应商、上下文轮数和默认状态;创建、编辑、删除仍沿用原系统的弹窗与编辑页。" |
| | | minHeight={460} |
| | | > |
| | | <Box |
| | | sx={{ |
| | | display: 'grid', |
| | | gridTemplateColumns: 'repeat(auto-fit, minmax(320px, 1fr))', |
| | | gap: 2, |
| | | }} |
| | | > |
| | | {records.map((record) => ( |
| | | <Box key={record.id}> |
| | | <Box sx={aiCardSx(record.defaultFlag === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700 }}> |
| | | {record.name || record.modelCode || record.uuid} |
| | | </Typography> |
| | | <Typography variant="caption" color="text.secondary"> |
| | | {record.modelCode || '未填写模型编码'} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={record.status === 1 ? 'success' : 'default'} label={record.status === 1 ? '启用' : '停用'} /> |
| | | {record.defaultFlag === 1 ? <Chip size="small" color="primary" label="默认" /> : null} |
| | | </Stack> |
| | | </Stack> |
| | | <Grid container spacing={1.25} sx={{ mt: 1 }}> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" color="text.secondary">供应商</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25 }}>{providerLabel(record.provider)}</Typography> |
| | | </Grid> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" color="text.secondary">模型名</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25 }}>{record.modelName || '-'}</Typography> |
| | | </Grid> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" color="text.secondary">上下文轮数</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25 }}>{record.maxContextMessages || 0}</Typography> |
| | | </Grid> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" color="text.secondary">排序</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25 }}>{record.sort || 0}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="caption" color="text.secondary">聊天地址</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25, wordBreak: 'break-all' }}> |
| | | {record.chatUrl || '未配置'} |
| | | </Typography> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="caption" color="text.secondary">备注</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25, minHeight: 42 }}> |
| | | {record.memo || '未填写备注'} |
| | | </Typography> |
| | | </Grid> |
| | | </Grid> |
| | | <Stack direction="row" spacing={1} sx={{ mt: 1.5 }}> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | <DeleteButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} mutationMode={OPERATE_MODE} /> |
| | | </Stack> |
| | | </Box> |
| | | </Box> |
| | | ))} |
| | | </Box> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiParamList = () => { |
| | | const [createDialog, setCreateDialog] = useState(false); |
| | | |
| | | return ( |
| | | <Box display="flex"> |
| | | <Box display="flex" sx={{ width: '100%' }}> |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiParam"} |
| | | empty={<EmptyData onClick={() => { setCreateDialog(true) }} />} |
| | | filters={filters} |
| | |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <StyledDatagrid |
| | | preferenceKey='aiParam' |
| | | bulkActionButtons={() => <BulkDeleteButton mutationMode={OPERATE_MODE} />} |
| | | rowClick={false} |
| | | omit={['id', 'createTime', 'memo', 'statusBool', 'defaultFlagBool']} |
| | | > |
| | | <NumberField source="id" /> |
| | | <TextField source="uuid" label="table.field.aiParam.uuid" /> |
| | | <TextField source="name" label="table.field.aiParam.name" /> |
| | | <TextField source="modelCode" label="table.field.aiParam.modelCode" /> |
| | | <TextField source="provider" label="table.field.aiParam.provider" /> |
| | | <TextField source="modelName" label="table.field.aiParam.modelName" /> |
| | | <NumberField source="maxContextMessages" label="table.field.aiParam.maxContextMessages" /> |
| | | <NumberField source="sort" label="table.field.aiParam.sort" /> |
| | | <BooleanField source="defaultFlagBool" label="table.field.aiParam.defaultFlag" sortable={false} /> |
| | | <BooleanField source="statusBool" label="common.field.status" sortable={false} /> |
| | | <DateField source="updateTime" label="common.field.updateTime" showTime /> |
| | | <TextField source="memo" label="common.field.memo" sortable={false} /> |
| | | <WrapperField cellClassName="opt" label="common.field.opt"> |
| | | <EditButton sx={{ padding: '1px', fontSize: '.75rem' }} /> |
| | | <DeleteButton sx={{ padding: '1px', fontSize: '.75rem' }} mutationMode={OPERATE_MODE} /> |
| | | </WrapperField> |
| | | </StyledDatagrid> |
| | | <AiParamBoard /> |
| | | </List> |
| | | <AiParamCreate |
| | | open={createDialog} |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | CreateBase, |
| | | useTranslate, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | useNotify, |
| | | Form, |
| | | } from 'react-admin'; |
| | | import { |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Stack, |
| | | Grid, |
| | | Box, |
| | | } from '@mui/material'; |
| | | import DialogCloseButton from "@/page/components/DialogCloseButton"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const AiPromptCreate = (props) => { |
| | | const { open, setOpen } = props; |
| | | const translate = useTranslate(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleClose = (event, reason) => { |
| | | if (reason !== "backdropClick") { |
| | | setOpen(false); |
| | | } |
| | | }; |
| | | |
| | | const handleSuccess = async () => { |
| | | setOpen(false); |
| | | notify('common.response.success'); |
| | | }; |
| | | |
| | | const handleError = async (error) => { |
| | | notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } }); |
| | | }; |
| | | |
| | | return ( |
| | | <CreateBase |
| | | record={{ sceneCode: 'system_diagnose', status: 1, publishedFlag: 0 }} |
| | | mutationOptions={{ onSuccess: handleSuccess, onError: handleError }} |
| | | > |
| | | <Dialog open={open} onClose={handleClose} fullWidth disableRestoreFocus maxWidth="md"> |
| | | <Form> |
| | | <DialogTitle sx={{ position: 'sticky', top: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | {translate('create.title')} |
| | | <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}> |
| | | <DialogCloseButton onClose={handleClose} /> |
| | | </Box> |
| | | </DialogTitle> |
| | | <DialogContent sx={{ mt: 2 }}> |
| | | <Grid container rowSpacing={2} columnSpacing={2}> |
| | | <Grid item xs={6}><SelectInput source="sceneCode" label="场景" choices={sceneChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="templateName" label="模板名称" fullWidth /></Grid> |
| | | <Grid item xs={12}><TextInput source="basePrompt" label="基础提示词" fullWidth multiline minRows={4} /></Grid> |
| | | <Grid item xs={12}><TextInput source="toolPrompt" label="工具提示词" fullWidth multiline minRows={4} /></Grid> |
| | | <Grid item xs={12}><TextInput source="outputPrompt" label="输出提示词" fullWidth multiline minRows={4} /></Grid> |
| | | <Grid item xs={6}><NumberInput source="versionNo" label="版本号" fullWidth /></Grid> |
| | | <Grid item xs={6}><StatusSelectInput fullWidth /></Grid> |
| | | <Grid item xs={12}><Stack direction="column" spacing={1} width={'100%'}><MemoInput /></Stack></Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | </Toolbar> |
| | | </DialogActions> |
| | | </Form> |
| | | </Dialog> |
| | | </CreateBase> |
| | | ) |
| | | } |
| | | |
| | | export default AiPromptCreate; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import { EDIT_MODE } from '@/config/setting'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const FormToolbar = () => ( |
| | | <Toolbar sx={{ justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | <DeleteButton mutationMode="optimistic" /> |
| | | </Toolbar> |
| | | ); |
| | | |
| | | const AiPromptEdit = () => ( |
| | | <Edit redirect="list" mutationMode={EDIT_MODE} actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm shouldUnregister warnWhenUnsavedChanges toolbar={<FormToolbar />} mode="onTouched"> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12} md={8}> |
| | | <Typography variant="h6" gutterBottom>主要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} /> |
| | | <TextInput source="templateName" label="模板名称" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <NumberInput source="versionNo" label="版本号" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="basePrompt" label="基础提示词" fullWidth multiline minRows={4} /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="toolPrompt" label="工具提示词" fullWidth multiline minRows={4} /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="outputPrompt" label="输出提示词" fullWidth multiline minRows={4} /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="h6" gutterBottom>通用</Typography> |
| | | <StatusSelectInput /> |
| | | <MemoInput /> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ) |
| | | |
| | | export default AiPromptEdit; |
| New file |
| | |
| | | import React, { useEffect, useMemo, useState } from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useNotify, |
| | | useRefresh, |
| | | useListContext, |
| | | Pagination, |
| | | EditButton, |
| | | } from 'react-admin'; |
| | | import { Box, Button, Chip, Dialog, DialogActions, DialogContent, DialogTitle, FormControl, Grid, InputLabel, MenuItem, Select, Stack, TextField as MuiTextField, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import MyCreateButton from "@/page/components/MyCreateButton"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import AiPromptCreate from "./AiPromptCreate"; |
| | | import request from "@/utils/request"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const sceneLabels = { |
| | | general_chat: '通用对话', |
| | | system_diagnose: '系统诊断', |
| | | }; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} />, |
| | | <TextInput source="templateName" label="模板名称" />, |
| | | <SelectInput |
| | | source="publishedFlag" |
| | | label="发布状态" |
| | | choices={[ |
| | | { id: '1', name: '已发布' }, |
| | | { id: '0', name: '草稿' }, |
| | | ]} |
| | | />, |
| | | <SelectInput |
| | | label="common.field.status" |
| | | source="status" |
| | | choices={[ |
| | | { id: '1', name: 'common.enums.statusTrue' }, |
| | | { id: '0', name: 'common.enums.statusFalse' }, |
| | | ]} |
| | | />, |
| | | ]; |
| | | |
| | | const PromptBoard = ({ onCreateDraft }) => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const refresh = useRefresh(); |
| | | const notify = useNotify(); |
| | | const [selectedScene, setSelectedScene] = useState(''); |
| | | const [versionDialog, setVersionDialog] = useState(false); |
| | | const [activeRecord, setActiveRecord] = useState(null); |
| | | const [logs, setLogs] = useState([]); |
| | | const [compareId, setCompareId] = useState(''); |
| | | const [compareData, setCompareData] = useState(null); |
| | | |
| | | const groupedScenes = useMemo(() => { |
| | | const map = {}; |
| | | records.forEach((item) => { |
| | | const code = item.sceneCode || 'unknown'; |
| | | if (!map[code]) { |
| | | map[code] = []; |
| | | } |
| | | map[code].push(item); |
| | | }); |
| | | return map; |
| | | }, [records]); |
| | | |
| | | const sceneCodes = Object.keys(groupedScenes).sort(); |
| | | const selectedRecords = groupedScenes[selectedScene] || []; |
| | | const publishedCount = records.filter((item) => item.publishedFlag === 1).length; |
| | | const draftCount = records.filter((item) => item.publishedFlag !== 1).length; |
| | | |
| | | useEffect(() => { |
| | | if (!selectedScene && sceneCodes.length) { |
| | | setSelectedScene(sceneCodes[0]); |
| | | } |
| | | if (selectedScene && !groupedScenes[selectedScene] && sceneCodes.length) { |
| | | setSelectedScene(sceneCodes[0]); |
| | | } |
| | | }, [selectedScene, sceneCodes, groupedScenes]); |
| | | |
| | | const openVersionDialog = async (record) => { |
| | | try { |
| | | const logRes = await request.get(`/ai/prompt/publish-log/list?sceneCode=${record.sceneCode}`); |
| | | setActiveRecord(record); |
| | | setLogs(logRes.data?.data || []); |
| | | setCompareId(''); |
| | | setCompareData(null); |
| | | setVersionDialog(true); |
| | | } catch (error) { |
| | | notify(error.message || '加载版本日志失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | const handlePublish = async (record) => { |
| | | try { |
| | | await request.post('/ai/prompt/publish', { id: record.id }); |
| | | notify('common.response.success'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '操作失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | const handleRollback = async (record) => { |
| | | try { |
| | | await request.post('/ai/prompt/rollback', { id: record.id }); |
| | | notify('common.response.success'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '操作失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | const handleCopy = async (record) => { |
| | | try { |
| | | await request.post('/ai/prompt/copy', { id: record.id }); |
| | | notify('common.response.success'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '操作失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | const handleCompare = async () => { |
| | | if (!activeRecord || !compareId) { |
| | | return; |
| | | } |
| | | try { |
| | | const res = await request.get(`/ai/prompt/compare?leftId=${activeRecord.id}&rightId=${compareId}`); |
| | | setCompareData(res.data?.data || null); |
| | | } catch (error) { |
| | | notify(error.message || '加载对比失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData onClick={onCreateDraft} />; |
| | | } |
| | | |
| | | return ( |
| | | <> |
| | | <AiConsoleLayout |
| | | title="Prompt 配置中心" |
| | | subtitle="页面结构参考 zy-wcs-master 的 Prompt 中心,保留原系统的按钮、编辑页和权限模型,重点增强场景感知、版本阅读和运营动作展示。" |
| | | actions={[ |
| | | <Button key="draft" variant="contained" onClick={onCreateDraft}>新建草稿</Button>, |
| | | ]} |
| | | stats={[ |
| | | { label: '场景数', value: sceneCodes.length }, |
| | | { label: '版本总数', value: records.length }, |
| | | { label: '已发布', value: publishedCount }, |
| | | { label: '草稿', value: draftCount }, |
| | | ]} |
| | | > |
| | | <Grid container spacing={2}> |
| | | <Grid item xs={12} md={3}> |
| | | <AiConsolePanel |
| | | title="场景" |
| | | subtitle="每个场景只会有一个已发布版本,诊断和聊天运行时会直接读取它。" |
| | | minHeight={520} |
| | | > |
| | | <Stack spacing={1.5}> |
| | | {sceneCodes.map((code) => { |
| | | const list = groupedScenes[code] || []; |
| | | const published = list.find((item) => item.publishedFlag === 1); |
| | | const drafts = list.filter((item) => item.publishedFlag !== 1).length; |
| | | return ( |
| | | <Box key={code} sx={{ ...aiCardSx(selectedScene === code), cursor: 'pointer' }} onClick={() => setSelectedScene(code)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {sceneLabels[code] || code} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8aa0b7' }}>{code}</Typography> |
| | | </Box> |
| | | <Chip size="small" color={published ? 'success' : 'default'} label={published ? `v${published.versionNo}` : '未发布'} /> |
| | | </Stack> |
| | | <Grid container spacing={1} sx={{ mt: 1 }}> |
| | | <Grid item xs={6}> |
| | | <Box sx={{ p: 1, borderRadius: 2, border: '1px solid #e7eef7', backgroundColor: '#fff' }}> |
| | | <Typography variant="caption" sx={{ color: '#7f92a8' }}>版本数</Typography> |
| | | <Typography variant="h6" sx={{ mt: 0.25, color: '#2a3e55' }}>{list.length}</Typography> |
| | | </Box> |
| | | </Grid> |
| | | <Grid item xs={6}> |
| | | <Box sx={{ p: 1, borderRadius: 2, border: '1px solid #e7eef7', backgroundColor: '#fff' }}> |
| | | <Typography variant="caption" sx={{ color: '#7f92a8' }}>草稿数</Typography> |
| | | <Typography variant="h6" sx={{ mt: 0.25, color: '#2a3e55' }}>{drafts}</Typography> |
| | | </Box> |
| | | </Grid> |
| | | </Grid> |
| | | </Box> |
| | | ); |
| | | })} |
| | | </Stack> |
| | | </AiConsolePanel> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <AiConsolePanel |
| | | title="版本列表" |
| | | subtitle={`当前场景:${sceneLabels[selectedScene] || selectedScene || '未选择场景'}`} |
| | | action={<Button size="small" variant="outlined" onClick={onCreateDraft}>新建草稿</Button>} |
| | | minHeight={520} |
| | | > |
| | | <Stack spacing={1.5}> |
| | | {selectedRecords.map((record) => ( |
| | | <Box key={record.id} sx={aiCardSx(record.publishedFlag === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.templateName || `${sceneLabels[record.sceneCode] || record.sceneCode} v${record.versionNo}`} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | 版本 v{record.versionNo} · 更新时间 {record.updateTime || '-'} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75}> |
| | | <Chip size="small" color={record.publishedFlag === 1 ? 'warning' : 'default'} label={record.publishedFlag === 1 ? '已发布' : '草稿'} /> |
| | | <Chip size="small" color={record.status === 1 ? 'success' : 'default'} label={record.status === 1 ? '启用' : '停用'} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Typography variant="body2" sx={{ mt: 1.25, minHeight: 48, color: '#31465d' }}> |
| | | {record.memo || '未填写版本备注'} |
| | | </Typography> |
| | | <Stack direction="row" spacing={1} flexWrap="wrap" sx={{ mt: 1.5 }}> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | <Button size="small" variant="outlined" onClick={() => openVersionDialog(record)}>版本</Button> |
| | | <Button size="small" variant="outlined" onClick={() => handleCopy(record)}>复制</Button> |
| | | <Button size="small" variant="outlined" onClick={() => handlePublish(record)}>发布</Button> |
| | | <Button size="small" variant="outlined" onClick={() => handleRollback(record)}>回滚</Button> |
| | | </Stack> |
| | | </Box> |
| | | ))} |
| | | </Stack> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </Grid> |
| | | <Grid item xs={12} md={5}> |
| | | <AiConsolePanel |
| | | title="运营提示" |
| | | subtitle="当前版本编辑仍进入原有编辑页,这里主要承接版本阅读、对比和发布日志预览。" |
| | | minHeight={520} |
| | | > |
| | | <Box sx={{ ...aiCardSx(false), minHeight: 140 }}> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}>使用建议</Typography> |
| | | <Typography variant="body2" sx={{ mt: 1, color: '#31465d', lineHeight: 1.8 }}> |
| | | 先在左侧切换场景,再在中间挑选草稿或线上版本。需要深度查看发布轨迹、做两版 Prompt 文本对比时,点击“版本”进入运营弹窗。 |
| | | </Typography> |
| | | </Box> |
| | | <Box sx={{ ...aiCardSx(true), mt: 1.5, minHeight: 230 }}> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}>当前场景摘要</Typography> |
| | | <Stack spacing={1} sx={{ mt: 1.25 }}> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}> |
| | | 场景编码:{selectedScene || '-'} |
| | | </Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}> |
| | | 发布版本:{(selectedRecords.find((item) => item.publishedFlag === 1)?.versionNo) ? `v${selectedRecords.find((item) => item.publishedFlag === 1)?.versionNo}` : '暂无'} |
| | | </Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}> |
| | | 草稿数量:{selectedRecords.filter((item) => item.publishedFlag !== 1).length} |
| | | </Typography> |
| | | <Typography variant="body2" sx={{ color: '#31465d' }}> |
| | | 启用版本:{selectedRecords.filter((item) => item.status === 1).length} |
| | | </Typography> |
| | | </Stack> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </Grid> |
| | | </Grid> |
| | | </AiConsoleLayout> |
| | | |
| | | <Dialog open={versionDialog} onClose={() => setVersionDialog(false)} fullWidth maxWidth="lg"> |
| | | <DialogTitle>Prompt 版本运营</DialogTitle> |
| | | <DialogContent> |
| | | <Stack spacing={3}> |
| | | <Typography variant="body2"> |
| | | 当前模板:{activeRecord?.templateName || '-'} / 版本 {activeRecord?.versionNo || '-'} |
| | | </Typography> |
| | | <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}> |
| | | <Box flex={1}> |
| | | <Typography variant="subtitle1" gutterBottom>发布日志</Typography> |
| | | {logs.map((item) => ( |
| | | <Box key={item.id} sx={{ borderBottom: '1px solid #eee', pb: 1, mb: 1 }}> |
| | | <Typography variant="body2">{item.actionType} / v{item.versionNo} / {item.templateName}</Typography> |
| | | <Typography variant="caption" color="text.secondary">{item.actionDesc} / {item.createTime}</Typography> |
| | | </Box> |
| | | ))} |
| | | </Box> |
| | | <Box flex={1}> |
| | | <Typography variant="subtitle1" gutterBottom>文本对比</Typography> |
| | | <Stack spacing={1.5}> |
| | | <FormControl sx={{ minWidth: 220 }}> |
| | | <InputLabel id="prompt-compare-label">对比版本</InputLabel> |
| | | <Select |
| | | labelId="prompt-compare-label" |
| | | label="对比版本" |
| | | value={compareId} |
| | | onChange={(event) => setCompareId(event.target.value)} |
| | | > |
| | | {selectedRecords.filter((item) => item.id !== activeRecord?.id).map((item) => ( |
| | | <MenuItem key={item.id} value={item.id}>v{item.versionNo} / {item.templateName}</MenuItem> |
| | | ))} |
| | | </Select> |
| | | </FormControl> |
| | | <Button variant="outlined" onClick={handleCompare}>加载对比</Button> |
| | | </Stack> |
| | | </Box> |
| | | </Stack> |
| | | {compareData ? ( |
| | | <Stack direction={{ xs: 'column', md: 'row' }} spacing={2}> |
| | | <Box flex={1}> |
| | | <Typography variant="subtitle1" gutterBottom>当前版本</Typography> |
| | | <MuiTextField label="基础提示词" fullWidth multiline minRows={5} value={compareData.left?.basePrompt || ''} disabled margin="dense" /> |
| | | <MuiTextField label="工具提示词" fullWidth multiline minRows={5} value={compareData.left?.toolPrompt || ''} disabled margin="dense" /> |
| | | <MuiTextField label="输出提示词" fullWidth multiline minRows={5} value={compareData.left?.outputPrompt || ''} disabled margin="dense" /> |
| | | </Box> |
| | | <Box flex={1}> |
| | | <Typography variant="subtitle1" gutterBottom>对比版本</Typography> |
| | | <MuiTextField label="基础提示词" fullWidth multiline minRows={5} value={compareData.right?.basePrompt || ''} disabled margin="dense" /> |
| | | <MuiTextField label="工具提示词" fullWidth multiline minRows={5} value={compareData.right?.toolPrompt || ''} disabled margin="dense" /> |
| | | <MuiTextField label="输出提示词" fullWidth multiline minRows={5} value={compareData.right?.outputPrompt || ''} disabled margin="dense" /> |
| | | </Box> |
| | | </Stack> |
| | | ) : null} |
| | | </Stack> |
| | | </DialogContent> |
| | | <DialogActions> |
| | | <Button onClick={() => setVersionDialog(false)}>关闭</Button> |
| | | </DialogActions> |
| | | </Dialog> |
| | | </> |
| | | ); |
| | | }; |
| | | |
| | | const AiPromptList = () => { |
| | | const [createDialog, setCreateDialog] = useState(false); |
| | | |
| | | return ( |
| | | <Box display="flex" sx={{ width: '100%' }}> |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiPrompt"} |
| | | empty={<EmptyData onClick={() => { setCreateDialog(true) }} />} |
| | | filters={filters} |
| | | sort={{ field: "updateTime", order: "desc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <MyCreateButton onClick={() => { setCreateDialog(true) }} /> |
| | | <SelectColumnsButton preferenceKey='aiPrompt' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <PromptBoard onCreateDraft={() => setCreateDialog(true)} /> |
| | | </List> |
| | | <AiPromptCreate open={createDialog} setOpen={setCreateDialog} /> |
| | | </Box> |
| | | ) |
| | | } |
| | | |
| | | export default AiPromptList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiPromptList from "./AiPromptList"; |
| | | import AiPromptEdit from "./AiPromptEdit"; |
| | | |
| | | export default { |
| | | list: AiPromptList, |
| | | edit: AiPromptEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.templateName || record.sceneCode || ''}` |
| | | }; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | CreateBase, |
| | | useTranslate, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | useNotify, |
| | | Form, |
| | | } from 'react-admin'; |
| | | import { |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Stack, |
| | | Grid, |
| | | Box, |
| | | } from '@mui/material'; |
| | | import DialogCloseButton from "@/page/components/DialogCloseButton"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | |
| | | const routeChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const AiRouteCreate = (props) => { |
| | | const { open, setOpen } = props; |
| | | const translate = useTranslate(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleClose = (event, reason) => { |
| | | if (reason !== "backdropClick") { |
| | | setOpen(false); |
| | | } |
| | | }; |
| | | |
| | | const handleSuccess = async () => { |
| | | setOpen(false); |
| | | notify('common.response.success'); |
| | | }; |
| | | |
| | | const handleError = async (error) => { |
| | | notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } }); |
| | | }; |
| | | |
| | | return ( |
| | | <CreateBase |
| | | record={{ routeCode: 'general_chat', priority: 1, failCount: 0, successCount: 0, status: 1 }} |
| | | mutationOptions={{ onSuccess: handleSuccess, onError: handleError }} |
| | | > |
| | | <Dialog open={open} onClose={handleClose} fullWidth disableRestoreFocus maxWidth="md"> |
| | | <Form> |
| | | <DialogTitle sx={{ position: 'sticky', top: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | {translate('create.title')} |
| | | <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}> |
| | | <DialogCloseButton onClose={handleClose} /> |
| | | </Box> |
| | | </DialogTitle> |
| | | <DialogContent sx={{ mt: 2 }}> |
| | | <Grid container rowSpacing={2} columnSpacing={2}> |
| | | <Grid item xs={6}><SelectInput source="routeCode" label="路由编码" choices={routeChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="modelCode" label="模型编码" fullWidth /></Grid> |
| | | <Grid item xs={6}><NumberInput source="priority" label="优先级" fullWidth /></Grid> |
| | | <Grid item xs={6}><StatusSelectInput fullWidth /></Grid> |
| | | <Grid item xs={12}><Stack direction="column" spacing={1} width={'100%'}><MemoInput /></Stack></Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | </Toolbar> |
| | | </DialogActions> |
| | | </Form> |
| | | </Dialog> |
| | | </CreateBase> |
| | | ) |
| | | } |
| | | |
| | | export default AiRouteCreate; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import { EDIT_MODE } from '@/config/setting'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | |
| | | const routeChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const FormToolbar = () => ( |
| | | <Toolbar sx={{ justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | <DeleteButton mutationMode="optimistic" /> |
| | | </Toolbar> |
| | | ); |
| | | |
| | | const AiRouteEdit = () => ( |
| | | <Edit redirect="list" mutationMode={EDIT_MODE} actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm shouldUnregister warnWhenUnsavedChanges toolbar={<FormToolbar />} mode="onTouched"> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12} md={8}> |
| | | <Typography variant="h6" gutterBottom>主要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <SelectInput source="routeCode" label="路由编码" choices={routeChoices} /> |
| | | <TextInput source="modelCode" label="模型编码" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <NumberInput source="priority" label="优先级" /> |
| | | <NumberInput source="failCount" label="失败次数" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <NumberInput source="successCount" label="成功次数" /> |
| | | <TextInput source="cooldownUntil$" label="冷却截止" disabled /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="h6" gutterBottom>通用</Typography> |
| | | <StatusSelectInput /> |
| | | <MemoInput /> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ) |
| | | |
| | | export default AiRouteEdit; |
| New file |
| | |
| | | import React, { useState } from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useNotify, |
| | | useRefresh, |
| | | useListContext, |
| | | Pagination, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Box, Button, Chip, Grid, Stack, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import MyCreateButton from "@/page/components/MyCreateButton"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { OPERATE_MODE, DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import AiRouteCreate from "./AiRouteCreate"; |
| | | import request from "@/utils/request"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const routeChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <SelectInput source="routeCode" label="路由编码" choices={routeChoices} />, |
| | | <TextInput source="modelCode" label="模型编码" />, |
| | | <SelectInput label="common.field.status" source="status" choices={[ |
| | | { id: '1', name: 'common.enums.statusTrue' }, |
| | | { id: '0', name: 'common.enums.statusFalse' }, |
| | | ]} />, |
| | | ]; |
| | | |
| | | const RouteCardActions = ({ record }) => { |
| | | const refresh = useRefresh(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleToggle = async () => { |
| | | try { |
| | | await request.post('/ai/route/toggle', { id: record.id, status: record.status === 1 ? 0 : 1 }); |
| | | notify('common.response.success'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '操作失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | const handleReset = async () => { |
| | | try { |
| | | await request.post('/ai/route/reset', { id: record.id }); |
| | | notify('common.response.success'); |
| | | refresh(); |
| | | } catch (error) { |
| | | notify(error.message || '操作失败', { type: 'error' }); |
| | | } |
| | | }; |
| | | |
| | | return ( |
| | | <Stack direction="row" spacing={1} flexWrap="wrap"> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | <DeleteButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} mutationMode={OPERATE_MODE} /> |
| | | <Button size="small" variant="outlined" onClick={handleToggle}>{record.status === 1 ? '停用' : '启用'}</Button> |
| | | <Button size="small" variant="outlined" onClick={handleReset}>重置</Button> |
| | | </Stack> |
| | | ); |
| | | }; |
| | | |
| | | const RouteBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const enabledCount = records.filter((item) => item.status === 1).length; |
| | | const coolingCount = records.filter((item) => item.cooldownUntil).length; |
| | | const failSwitchCount = records.filter((item) => (item.failCount || 0) > 0).length; |
| | | const successCount = records.reduce((sum, item) => sum + (item.successCount || 0), 0); |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI模型路由" |
| | | subtitle="参考 zy-wcs-master 的 LLM 控制台信息层次,突出路由状态、冷却与成功失败计数,但保留现有系统的按钮、筛选和表单风格。" |
| | | stats={[ |
| | | { label: '总路由', value: records.length }, |
| | | { label: '启用', value: enabledCount }, |
| | | { label: '冷却中', value: coolingCount }, |
| | | { label: '累计成功', value: successCount, helper: `已有失败记录 ${failSwitchCount} 条` }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="路由卡片" |
| | | subtitle="每张卡片代表一条模型候选,优先级越小越先命中;启停、重置会立即作用于后续请求。" |
| | | minHeight={460} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Box sx={aiCardSx(record.status === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.modelCode} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | {record.routeCode} · 优先级 {record.priority} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={record.status === 1 ? 'success' : 'default'} label={record.status === 1 ? '启用' : '停用'} /> |
| | | {record.cooldownUntil ? <Chip size="small" color="warning" label="冷却中" /> : null} |
| | | </Stack> |
| | | </Stack> |
| | | <Grid container spacing={1.25} sx={{ mt: 1 }}> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>失败次数</Typography> |
| | | <Typography variant="h6" sx={{ color: '#2f455c', mt: 0.25 }}>{record.failCount || 0}</Typography> |
| | | </Grid> |
| | | <Grid item xs={6}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>成功次数</Typography> |
| | | <Typography variant="h6" sx={{ color: '#2f455c', mt: 0.25 }}>{record.successCount || 0}</Typography> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>冷却截止</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25, color: '#31465d' }}> |
| | | {record.cooldownUntil$ || '未进入冷却'} |
| | | </Typography> |
| | | </Grid> |
| | | <Grid item xs={12}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>备注</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.25, color: '#31465d', minHeight: 42 }}> |
| | | {record.memo || '未填写备注'} |
| | | </Typography> |
| | | </Grid> |
| | | </Grid> |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <RouteCardActions record={record} /> |
| | | </Box> |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiRouteList = () => { |
| | | const [createDialog, setCreateDialog] = useState(false); |
| | | |
| | | return ( |
| | | <Box display="flex" sx={{ width: '100%' }}> |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiRoute"} |
| | | empty={<EmptyData onClick={() => { setCreateDialog(true) }} />} |
| | | filters={filters} |
| | | sort={{ field: "priority", order: "asc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <MyCreateButton onClick={() => { setCreateDialog(true) }} /> |
| | | <SelectColumnsButton preferenceKey='aiRoute' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <RouteBoard /> |
| | | </List> |
| | | <AiRouteCreate open={createDialog} setOpen={setCreateDialog} /> |
| | | </Box> |
| | | ); |
| | | } |
| | | |
| | | export default AiRouteList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiRouteList from "./AiRouteList"; |
| | | import AiRouteEdit from "./AiRouteEdit"; |
| | | |
| | | export default { |
| | | list: AiRouteList, |
| | | edit: AiRouteEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.routeCode || record.modelCode || ''}` |
| | | }; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | CreateBase, |
| | | useTranslate, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | useNotify, |
| | | Form, |
| | | } from 'react-admin'; |
| | | import { |
| | | Dialog, |
| | | DialogActions, |
| | | DialogContent, |
| | | DialogTitle, |
| | | Stack, |
| | | Grid, |
| | | Box, |
| | | } from '@mui/material'; |
| | | import DialogCloseButton from "@/page/components/DialogCloseButton"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const enabledChoices = [ |
| | | { id: 1, name: '启用' }, |
| | | { id: 0, name: '停用' }, |
| | | ]; |
| | | |
| | | const AiToolConfigCreate = (props) => { |
| | | const { open, setOpen } = props; |
| | | const translate = useTranslate(); |
| | | const notify = useNotify(); |
| | | |
| | | const handleClose = (event, reason) => { |
| | | if (reason !== "backdropClick") { |
| | | setOpen(false); |
| | | } |
| | | }; |
| | | |
| | | const handleSuccess = async () => { |
| | | setOpen(false); |
| | | notify('common.response.success'); |
| | | }; |
| | | |
| | | const handleError = async (error) => { |
| | | notify(error.message || 'common.response.fail', { type: 'error', messageArgs: { _: error.message } }); |
| | | }; |
| | | |
| | | return ( |
| | | <CreateBase |
| | | record={{ sceneCode: 'system_diagnose', priority: 10, enabledFlag: 1, status: 1 }} |
| | | mutationOptions={{ onSuccess: handleSuccess, onError: handleError }} |
| | | > |
| | | <Dialog open={open} onClose={handleClose} fullWidth disableRestoreFocus maxWidth="md"> |
| | | <Form> |
| | | <DialogTitle sx={{ position: 'sticky', top: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | {translate('create.title')} |
| | | <Box sx={{ position: 'absolute', top: 8, right: 8, zIndex: 1001 }}> |
| | | <DialogCloseButton onClose={handleClose} /> |
| | | </Box> |
| | | </DialogTitle> |
| | | <DialogContent sx={{ mt: 2 }}> |
| | | <Grid container rowSpacing={2} columnSpacing={2}> |
| | | <Grid item xs={6}><SelectInput source="sceneCode" label="场景" choices={sceneChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="toolCode" label="工具编码" fullWidth /></Grid> |
| | | <Grid item xs={6}><TextInput source="toolName" label="工具名称" fullWidth /></Grid> |
| | | <Grid item xs={6}><NumberInput source="priority" label="优先级" fullWidth /></Grid> |
| | | <Grid item xs={6}><SelectInput source="enabledFlag" label="启用" choices={enabledChoices} fullWidth /></Grid> |
| | | <Grid item xs={6}><StatusSelectInput fullWidth /></Grid> |
| | | <Grid item xs={12}><TextInput source="toolPrompt" label="工具提示词" fullWidth multiline minRows={4} /></Grid> |
| | | <Grid item xs={12}><Stack direction="column" spacing={1} width={'100%'}><MemoInput /></Stack></Grid> |
| | | </Grid> |
| | | </DialogContent> |
| | | <DialogActions sx={{ position: 'sticky', bottom: 0, backgroundColor: 'background.paper', zIndex: 1000 }}> |
| | | <Toolbar sx={{ width: '100%', justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | </Toolbar> |
| | | </DialogActions> |
| | | </Form> |
| | | </Dialog> |
| | | </CreateBase> |
| | | ) |
| | | } |
| | | |
| | | export default AiToolConfigCreate; |
| New file |
| | |
| | | import React from "react"; |
| | | import { |
| | | Edit, |
| | | SimpleForm, |
| | | TextInput, |
| | | NumberInput, |
| | | SaveButton, |
| | | SelectInput, |
| | | Toolbar, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Stack, Grid, Typography } from '@mui/material'; |
| | | import { EDIT_MODE } from '@/config/setting'; |
| | | import EditBaseAside from "@/page/components/EditBaseAside"; |
| | | import CustomerTopToolBar from "@/page/components/EditTopToolBar"; |
| | | import MemoInput from "@/page/components/MemoInput"; |
| | | import StatusSelectInput from "@/page/components/StatusSelectInput"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const enabledChoices = [ |
| | | { id: 1, name: '启用' }, |
| | | { id: 0, name: '停用' }, |
| | | ]; |
| | | |
| | | const FormToolbar = () => ( |
| | | <Toolbar sx={{ justifyContent: 'space-between' }}> |
| | | <SaveButton /> |
| | | <DeleteButton mutationMode="optimistic" /> |
| | | </Toolbar> |
| | | ); |
| | | |
| | | const AiToolConfigEdit = () => ( |
| | | <Edit redirect="list" mutationMode={EDIT_MODE} actions={<CustomerTopToolBar />} aside={<EditBaseAside />}> |
| | | <SimpleForm shouldUnregister warnWhenUnsavedChanges toolbar={<FormToolbar />} mode="onTouched"> |
| | | <Grid container width={{ xs: '100%', xl: '80%' }} rowSpacing={3} columnSpacing={3}> |
| | | <Grid item xs={12} md={8}> |
| | | <Typography variant="h6" gutterBottom>主要</Typography> |
| | | <Stack direction='row' gap={2}> |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} /> |
| | | <TextInput source="toolCode" label="工具编码" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="toolName" label="工具名称" /> |
| | | <NumberInput source="priority" label="优先级" /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <SelectInput source="enabledFlag" label="启用" choices={enabledChoices} /> |
| | | </Stack> |
| | | <Stack direction='row' gap={2}> |
| | | <TextInput source="toolPrompt" label="工具提示词" fullWidth multiline minRows={4} /> |
| | | </Stack> |
| | | </Grid> |
| | | <Grid item xs={12} md={4}> |
| | | <Typography variant="h6" gutterBottom>通用</Typography> |
| | | <StatusSelectInput /> |
| | | <MemoInput /> |
| | | </Grid> |
| | | </Grid> |
| | | </SimpleForm> |
| | | </Edit> |
| | | ) |
| | | |
| | | export default AiToolConfigEdit; |
| New file |
| | |
| | | import React, { useState } from "react"; |
| | | import { |
| | | List, |
| | | SearchInput, |
| | | TopToolbar, |
| | | SelectColumnsButton, |
| | | EditButton, |
| | | FilterButton, |
| | | TextInput, |
| | | DateInput, |
| | | SelectInput, |
| | | useListContext, |
| | | Pagination, |
| | | DeleteButton, |
| | | } from 'react-admin'; |
| | | import { Box, Button, Chip, Grid, Stack, Typography } from '@mui/material'; |
| | | import EmptyData from "@/page/components/EmptyData"; |
| | | import MyCreateButton from "@/page/components/MyCreateButton"; |
| | | import MyExportButton from '@/page/components/MyExportButton'; |
| | | import { OPERATE_MODE, DEFAULT_PAGE_SIZE } from '@/config/setting'; |
| | | import AiToolConfigCreate from "./AiToolConfigCreate"; |
| | | import { AiConsoleLayout, AiConsolePanel, aiCardSx } from "@/page/components/AiConsoleLayout"; |
| | | |
| | | const sceneChoices = [ |
| | | { id: 'general_chat', name: '通用对话' }, |
| | | { id: 'system_diagnose', name: '系统诊断' }, |
| | | ]; |
| | | |
| | | const filters = [ |
| | | <SearchInput source="condition" alwaysOn />, |
| | | <DateInput label='common.time.after' source="timeStart" alwaysOn />, |
| | | <DateInput label='common.time.before' source="timeEnd" alwaysOn />, |
| | | <SelectInput source="sceneCode" label="场景" choices={sceneChoices} />, |
| | | <TextInput source="toolCode" label="工具编码" />, |
| | | <TextInput source="toolName" label="工具名称" />, |
| | | ]; |
| | | |
| | | const ToolConfigBoard = () => { |
| | | const { data, isLoading } = useListContext(); |
| | | const records = data || []; |
| | | const enabledCount = records.filter((item) => item.enabledFlag === 1).length; |
| | | const diagnoseCount = records.filter((item) => item.sceneCode === 'system_diagnose').length; |
| | | const chatCount = records.filter((item) => item.sceneCode === 'general_chat').length; |
| | | |
| | | if (!isLoading && !records.length) { |
| | | return <EmptyData />; |
| | | } |
| | | |
| | | return ( |
| | | <AiConsoleLayout |
| | | title="AI诊断工具中心" |
| | | subtitle="参考 zy-wcs-master 的工作区布局,提供场景化工具编排、启停和提示词增强,但控件与交互仍保持当前系统的 react-admin 风格。" |
| | | stats={[ |
| | | { label: '工具总数', value: records.length }, |
| | | { label: '已启用', value: enabledCount }, |
| | | { label: '诊断场景', value: diagnoseCount }, |
| | | { label: '通用对话', value: chatCount }, |
| | | ]} |
| | | > |
| | | <AiConsolePanel |
| | | title="工具编排" |
| | | subtitle="同一场景会按优先级执行,启用状态和工具提示词会直接进入诊断运行时。" |
| | | minHeight={420} |
| | | > |
| | | <Grid container spacing={2}> |
| | | {records.map((record) => ( |
| | | <Grid item xs={12} md={6} xl={4} key={record.id}> |
| | | <Box sx={aiCardSx(record.enabledFlag === 1)}> |
| | | <Stack direction="row" justifyContent="space-between" alignItems="flex-start" spacing={1}> |
| | | <Box> |
| | | <Typography variant="subtitle1" sx={{ fontWeight: 700, color: '#284059' }}> |
| | | {record.toolName || record.toolCode} |
| | | </Typography> |
| | | <Typography variant="caption" sx={{ color: '#8093a8' }}> |
| | | {record.sceneCode} · {record.toolCode} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={0.75} flexWrap="wrap" justifyContent="flex-end"> |
| | | <Chip size="small" color={record.enabledFlag === 1 ? 'success' : 'default'} label={record.enabledFlag === 1 ? '启用' : '停用'} /> |
| | | <Chip size="small" color={record.status === 1 ? 'primary' : 'default'} label={`P${record.priority || 0}`} /> |
| | | </Stack> |
| | | </Stack> |
| | | <Box sx={{ mt: 1.5 }}> |
| | | <Typography variant="caption" sx={{ color: '#70839a' }}>工具提示词</Typography> |
| | | <Typography variant="body2" sx={{ mt: 0.5, color: '#31465d', minHeight: 72 }}> |
| | | {record.toolPrompt || '未配置附加提示词,将只使用默认工具摘要。'} |
| | | </Typography> |
| | | </Box> |
| | | <Stack direction="row" spacing={1} sx={{ mt: 1.5 }}> |
| | | <EditButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} /> |
| | | <DeleteButton record={record} sx={{ px: 1.25, py: 0.5, minWidth: 64 }} mutationMode={OPERATE_MODE} /> |
| | | </Stack> |
| | | </Box> |
| | | </Grid> |
| | | ))} |
| | | </Grid> |
| | | <Box sx={{ mt: 2 }}> |
| | | <Pagination rowsPerPageOptions={[DEFAULT_PAGE_SIZE, 25, 50]} /> |
| | | </Box> |
| | | </AiConsolePanel> |
| | | </AiConsoleLayout> |
| | | ); |
| | | }; |
| | | |
| | | const AiToolConfigList = () => { |
| | | const [createDialog, setCreateDialog] = useState(false); |
| | | |
| | | return ( |
| | | <Box display="flex" sx={{ width: '100%' }}> |
| | | <List |
| | | sx={{ width: '100%', flexGrow: 1 }} |
| | | title={"menu.aiToolConfig"} |
| | | empty={<EmptyData onClick={() => { setCreateDialog(true) }} />} |
| | | filters={filters} |
| | | sort={{ field: "priority", order: "asc" }} |
| | | actions={( |
| | | <TopToolbar> |
| | | <FilterButton /> |
| | | <MyCreateButton onClick={() => { setCreateDialog(true) }} /> |
| | | <SelectColumnsButton preferenceKey='aiToolConfig' /> |
| | | <MyExportButton /> |
| | | </TopToolbar> |
| | | )} |
| | | perPage={DEFAULT_PAGE_SIZE} |
| | | pagination={false} |
| | | > |
| | | <ToolConfigBoard /> |
| | | </List> |
| | | <AiToolConfigCreate open={createDialog} setOpen={setCreateDialog} /> |
| | | </Box> |
| | | ) |
| | | } |
| | | |
| | | export default AiToolConfigList; |
| New file |
| | |
| | | import { |
| | | ShowGuesser, |
| | | } from "react-admin"; |
| | | |
| | | import AiToolConfigList from "./AiToolConfigList"; |
| | | import AiToolConfigEdit from "./AiToolConfigEdit"; |
| | | |
| | | export default { |
| | | list: AiToolConfigList, |
| | | edit: AiToolConfigEdit, |
| | | show: ShowGuesser, |
| | | recordRepresentation: (record) => `${record.toolName || record.toolCode || ''}` |
| | | }; |
| | |
| | | @ConfigurationProperties(prefix = "gateway.ai") |
| | | public class AiGatewayProperties { |
| | | |
| | | private String defaultModelCode = "mock-general"; |
| | | private String defaultModelCode = "deepseek-ai/DeepSeek-V3.2"; |
| | | |
| | | private Integer connectTimeoutMillis = 10000; |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.ai.gateway.config; |
| | | |
| | | import org.springframework.context.annotation.Configuration; |
| | | import org.springframework.web.servlet.config.annotation.AsyncSupportConfigurer; |
| | | import org.springframework.web.servlet.config.annotation.WebMvcConfigurer; |
| | | |
| | | @Configuration |
| | | public class WebAsyncConfig implements WebMvcConfigurer { |
| | | |
| | | @Override |
| | | public void configureAsyncSupport(AsyncSupportConfigurer configurer) { |
| | | // StreamingResponseBody is used for long-lived model streams; disable the MVC async timeout. |
| | | configurer.setDefaultTimeout(0L); |
| | | } |
| | | } |
| | |
| | | import com.vincent.rsf.ai.gateway.service.AiGatewayService; |
| | | import com.vincent.rsf.ai.gateway.service.GatewayStreamEvent; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.IOException; |
| | | import java.io.InterruptedIOException; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.concurrent.atomic.AtomicBoolean; |
| | | |
| | | @RestController |
| | | @RequestMapping("/internal/chat") |
| | | public class AiGatewayController { |
| | | |
| | | private static final Logger logger = LoggerFactory.getLogger(AiGatewayController.class); |
| | | |
| | | @Resource |
| | | private AiGatewayService aiGatewayService; |
| | |
| | | @PostMapping(value = "/stream", produces = "application/x-ndjson") |
| | | public StreamingResponseBody stream(@RequestBody GatewayChatRequest request) { |
| | | return outputStream -> { |
| | | logger.info("AI gateway controller stream opened: sessionId={}, routeCode={}, attemptNo={}, modelCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode()); |
| | | AtomicBoolean streaming = new AtomicBoolean(true); |
| | | Object writeLock = new Object(); |
| | | Thread heartbeatThread = new Thread(() -> { |
| | | while (streaming.get()) { |
| | | try { |
| | | Thread.sleep(10000L); |
| | | if (!streaming.get()) { |
| | | break; |
| | | } |
| | | String json = objectMapper.writeValueAsString(new GatewayStreamEvent() |
| | | .setType("ping") |
| | | .setModelCode(request.getModelCode()) |
| | | .setResponseTime(System.currentTimeMillis())) + "\n"; |
| | | synchronized (writeLock) { |
| | | outputStream.write(json.getBytes(StandardCharsets.UTF_8)); |
| | | outputStream.flush(); |
| | | } |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | logger.info("AI gateway heartbeat interrupted: sessionId={}, routeCode={}, attemptNo={}, modelCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode()); |
| | | break; |
| | | } catch (Exception e) { |
| | | logger.warn("AI gateway heartbeat write failed: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, message={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode(), |
| | | e.getMessage()); |
| | | break; |
| | | } |
| | | } |
| | | }, "ai-gateway-heartbeat-" + (request.getSessionId() == null ? "unknown" : request.getSessionId())); |
| | | heartbeatThread.setDaemon(true); |
| | | heartbeatThread.start(); |
| | | try { |
| | | aiGatewayService.stream(request, event -> { |
| | | String json = objectMapper.writeValueAsString(event) + "\n"; |
| | | outputStream.write(json.getBytes(StandardCharsets.UTF_8)); |
| | | outputStream.flush(); |
| | | synchronized (writeLock) { |
| | | outputStream.write(json.getBytes(StandardCharsets.UTF_8)); |
| | | outputStream.flush(); |
| | | } |
| | | }); |
| | | } catch (Exception e) { |
| | | if (isInterruptedError(e)) { |
| | | logger.warn("AI gateway controller stream interrupted: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, message={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode(), |
| | | e.getMessage()); |
| | | return; |
| | | } |
| | | logger.error("AI gateway controller stream failed: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, message={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode(), |
| | | e.getMessage(), |
| | | e); |
| | | throw new IOException(e); |
| | | } finally { |
| | | streaming.set(false); |
| | | heartbeatThread.interrupt(); |
| | | logger.info("AI gateway controller stream closed: sessionId={}, routeCode={}, attemptNo={}, modelCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode()); |
| | | } |
| | | }; |
| | | } |
| | | |
| | | private boolean isInterruptedError(Throwable throwable) { |
| | | Throwable current = throwable; |
| | | while (current != null) { |
| | | if (current instanceof InterruptedException || current instanceof InterruptedIOException) { |
| | | return true; |
| | | } |
| | | String message = current.getMessage(); |
| | | if (message != null) { |
| | | String normalized = message.toLowerCase(); |
| | | if (normalized.contains("interrupted") |
| | | || normalized.contains("broken pipe") |
| | | || normalized.contains("connection reset") |
| | | || normalized.contains("forcibly closed")) { |
| | | return true; |
| | | } |
| | | } |
| | | current = current.getCause(); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | private String modelCode; |
| | | |
| | | private String routeCode; |
| | | |
| | | private Integer attemptNo; |
| | | |
| | | private String systemPrompt; |
| | | |
| | | private String chatUrl; |
| | |
| | | import com.vincent.rsf.ai.gateway.dto.GatewayChatRequest; |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.BufferedReader; |
| | | import java.io.InputStream; |
| | | import java.io.InputStreamReader; |
| | | import java.io.InterruptedIOException; |
| | | import java.io.OutputStream; |
| | | import java.net.HttpURLConnection; |
| | | import java.net.URL; |
| | |
| | | @Service |
| | | public class AiGatewayService { |
| | | |
| | | private static final Logger logger = LoggerFactory.getLogger(AiGatewayService.class); |
| | | |
| | | @Resource |
| | | private AiGatewayProperties aiGatewayProperties; |
| | | @Resource |
| | |
| | | |
| | | public void stream(GatewayChatRequest request, EventConsumer consumer) throws Exception { |
| | | AiGatewayProperties.ModelConfig modelConfig = resolveModel(request); |
| | | logger.info("AI gateway stream start: sessionId={}, routeCode={}, attemptNo={}, requestModelCode={}, resolvedModelCode={}, provider={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | request.getModelCode(), |
| | | modelConfig == null ? null : modelConfig.getCode(), |
| | | modelConfig == null ? null : modelConfig.getProvider()); |
| | | if (modelConfig == null || modelConfig.getChatUrl() == null || modelConfig.getChatUrl().trim().isEmpty()) { |
| | | logger.info("AI gateway use mock stream: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, provider={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig == null ? request.getModelCode() : modelConfig.getCode(), |
| | | modelConfig == null ? "mock" : modelConfig.getProvider()); |
| | | mockStream(request, modelConfig, consumer); |
| | | return; |
| | | } |
| | |
| | | |
| | | private void mockStream(GatewayChatRequest request, AiGatewayProperties.ModelConfig modelConfig, |
| | | EventConsumer consumer) throws Exception { |
| | | long requestTime = System.currentTimeMillis(); |
| | | String modelCode = modelConfig == null ? aiGatewayProperties.getDefaultModelCode() : modelConfig.getCode(); |
| | | String lastQuestion = ""; |
| | | List<GatewayChatMessage> messages = request.getMessages(); |
| | |
| | | } |
| | | } |
| | | String answer = "当前为演示模式,模型[" + modelCode + "]已收到你的问题:" + lastQuestion; |
| | | logger.info("AI gateway mock stream emitting response: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, answerLength={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelCode, |
| | | answer.length()); |
| | | for (char c : answer.toCharArray()) { |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("delta") |
| | | .setModelCode(modelCode) |
| | | .setContent(String.valueOf(c))); |
| | | Thread.sleep(20L); |
| | | try { |
| | | Thread.sleep(20L); |
| | | } catch (InterruptedException e) { |
| | | Thread.currentThread().interrupt(); |
| | | return; |
| | | } |
| | | } |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("done") |
| | | .setModelCode(modelCode)); |
| | | .setModelCode(modelCode) |
| | | .setSuccess(true) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(System.currentTimeMillis()) |
| | | .setDurationMs(System.currentTimeMillis() - requestTime)); |
| | | logger.info("AI gateway mock stream completed: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, durationMs={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelCode, |
| | | System.currentTimeMillis() - requestTime); |
| | | } |
| | | |
| | | private void openAiCompatibleStream(GatewayChatRequest request, AiGatewayProperties.ModelConfig modelConfig, |
| | | EventConsumer consumer) throws Exception { |
| | | HttpURLConnection connection = null; |
| | | long requestTime = System.currentTimeMillis(); |
| | | boolean terminalEventSent = false; |
| | | int eventLineCount = 0; |
| | | int deltaCount = 0; |
| | | int contentChars = 0; |
| | | boolean firstDeltaLogged = false; |
| | | String normalizedUrl = modelConfig == null ? null : modelConfig.getChatUrl(); |
| | | try { |
| | | logger.info("AI gateway opening upstream stream: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, provider={}, url={}, modelName={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig == null ? null : modelConfig.getCode(), |
| | | modelConfig == null ? null : modelConfig.getProvider(), |
| | | normalizedUrl, |
| | | modelConfig == null ? null : modelConfig.getModelName()); |
| | | connection = (HttpURLConnection) new URL(modelConfig.getChatUrl()).openConnection(); |
| | | connection.setConnectTimeout(aiGatewayProperties.getConnectTimeoutMillis()); |
| | | connection.setReadTimeout(aiGatewayProperties.getReadTimeoutMillis()); |
| | |
| | | } |
| | | |
| | | int statusCode = connection.getResponseCode(); |
| | | logger.info("AI gateway upstream response received: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, statusCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | statusCode); |
| | | InputStream inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); |
| | | if (inputStream == null) { |
| | | logger.warn("AI gateway upstream returned empty stream: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, url={}, statusCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | normalizedUrl, |
| | | statusCode); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("error") |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setMessage("模型服务无响应")); |
| | | .setMessage("模型服务无响应") |
| | | .setSuccess(false) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(System.currentTimeMillis()) |
| | | .setDurationMs(System.currentTimeMillis() - requestTime)); |
| | | terminalEventSent = true; |
| | | return; |
| | | } |
| | | if (statusCode >= 400) { |
| | | logger.warn("AI gateway upstream http error: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, url={}, statusCode={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | normalizedUrl, |
| | | statusCode); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("error") |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setMessage(readErrorMessage(inputStream, statusCode))); |
| | | .setMessage(readErrorMessage(inputStream, statusCode)) |
| | | .setSuccess(false) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(System.currentTimeMillis()) |
| | | .setDurationMs(System.currentTimeMillis() - requestTime)); |
| | | terminalEventSent = true; |
| | | return; |
| | | } |
| | | |
| | |
| | | if (line.trim().isEmpty() || !line.startsWith("data:")) { |
| | | continue; |
| | | } |
| | | eventLineCount++; |
| | | String payload = line.substring(5).trim(); |
| | | if ("[DONE]".equals(payload)) { |
| | | long responseTime = System.currentTimeMillis(); |
| | | logger.info("AI gateway upstream done marker received: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, eventLines={}, deltaCount={}, contentChars={}, durationMs={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | eventLineCount, |
| | | deltaCount, |
| | | contentChars, |
| | | responseTime - requestTime); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("done") |
| | | .setModelCode(modelConfig.getCode())); |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setSuccess(true) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(responseTime) |
| | | .setDurationMs(responseTime - requestTime)); |
| | | terminalEventSent = true; |
| | | break; |
| | | } |
| | | JsonNode root = objectMapper.readTree(payload); |
| | |
| | | JsonNode delta = choice.path("delta"); |
| | | JsonNode contentNode = delta.path("content"); |
| | | if (!contentNode.isMissingNode() && !contentNode.isNull()) { |
| | | String content = contentNode.asText(); |
| | | deltaCount++; |
| | | contentChars += content.length(); |
| | | if (!firstDeltaLogged) { |
| | | logger.info("AI gateway upstream first delta received: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, afterMs={}, sampleLength={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | System.currentTimeMillis() - requestTime, |
| | | content.length()); |
| | | firstDeltaLogged = true; |
| | | } |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("delta") |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setContent(contentNode.asText())); |
| | | .setContent(content)); |
| | | } |
| | | JsonNode finishReason = choice.path("finish_reason"); |
| | | if (!finishReason.isMissingNode() && !finishReason.isNull()) { |
| | | long responseTime = System.currentTimeMillis(); |
| | | logger.info("AI gateway upstream finish_reason received: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, finishReason={}, eventLines={}, deltaCount={}, contentChars={}, durationMs={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | finishReason.asText(), |
| | | eventLineCount, |
| | | deltaCount, |
| | | contentChars, |
| | | responseTime - requestTime); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("done") |
| | | .setModelCode(modelConfig.getCode())); |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setSuccess(true) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(responseTime) |
| | | .setDurationMs(responseTime - requestTime)); |
| | | terminalEventSent = true; |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | if (!terminalEventSent) { |
| | | long responseTime = System.currentTimeMillis(); |
| | | logger.warn("AI gateway upstream ended without terminal event: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, url={}, eventLines={}, deltaCount={}, contentChars={}, durationMs={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig.getCode(), |
| | | normalizedUrl, |
| | | eventLineCount, |
| | | deltaCount, |
| | | contentChars, |
| | | responseTime - requestTime); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("error") |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setMessage("模型流异常中断") |
| | | .setSuccess(false) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(responseTime) |
| | | .setDurationMs(responseTime - requestTime)); |
| | | } |
| | | } catch (Exception e) { |
| | | if (isInterruptedError(e)) { |
| | | logger.warn("AI gateway upstream interrupted: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, url={}, stage={}, message={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig == null ? null : modelConfig.getCode(), |
| | | normalizedUrl, |
| | | terminalEventSent ? "after_terminal" : "streaming", |
| | | e.getMessage()); |
| | | if (e instanceof InterruptedException || e instanceof InterruptedIOException) { |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | return; |
| | | } |
| | | logger.error("AI gateway upstream exception: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, url={}, eventLines={}, deltaCount={}, contentChars={}, message={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig == null ? null : modelConfig.getCode(), |
| | | normalizedUrl, |
| | | eventLineCount, |
| | | deltaCount, |
| | | contentChars, |
| | | e.getMessage(), |
| | | e); |
| | | consumer.accept(new GatewayStreamEvent() |
| | | .setType("error") |
| | | .setModelCode(modelConfig.getCode()) |
| | | .setMessage(e.getMessage())); |
| | | .setMessage(e.getMessage()) |
| | | .setSuccess(false) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(System.currentTimeMillis()) |
| | | .setDurationMs(System.currentTimeMillis() - requestTime)); |
| | | } finally { |
| | | if (connection != null) { |
| | | connection.disconnect(); |
| | | } |
| | | logger.info("AI gateway upstream stream closed: sessionId={}, routeCode={}, attemptNo={}, modelCode={}, terminalEventSent={}, eventLines={}, deltaCount={}, contentChars={}", |
| | | request.getSessionId(), |
| | | request.getRouteCode(), |
| | | request.getAttemptNo(), |
| | | modelConfig == null ? null : modelConfig.getCode(), |
| | | terminalEventSent, |
| | | eventLineCount, |
| | | deltaCount, |
| | | contentChars); |
| | | } |
| | | } |
| | | |
| | |
| | | } |
| | | } |
| | | |
| | | private boolean isInterruptedError(Throwable throwable) { |
| | | Throwable current = throwable; |
| | | while (current != null) { |
| | | if (current instanceof InterruptedException || current instanceof InterruptedIOException) { |
| | | return true; |
| | | } |
| | | String message = current.getMessage(); |
| | | if (message != null) { |
| | | String normalized = message.toLowerCase(); |
| | | if (normalized.contains("interrupted") |
| | | || normalized.contains("broken pipe") |
| | | || normalized.contains("connection reset") |
| | | || normalized.contains("forcibly closed")) { |
| | | return true; |
| | | } |
| | | } |
| | | current = current.getCause(); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | } |
| | |
| | | |
| | | private String modelCode; |
| | | |
| | | private Boolean success; |
| | | |
| | | private Long requestTime; |
| | | |
| | | private Long responseTime; |
| | | |
| | | private Long durationMs; |
| | | |
| | | } |
| | |
| | | |
| | | gateway: |
| | | ai: |
| | | default-model-code: mock-general |
| | | default-model-code: deepseek-ai/DeepSeek-V3.2 |
| | | connect-timeout-millis: 10000 |
| | | read-timeout-millis: 0 |
| | | models: |
| | | - code: mock-general |
| | | name: Mock General |
| | | provider: mock |
| | | model-name: mock-general |
| | | enabled: true |
| | | - code: mock-creative |
| | | name: Mock Creative |
| | | provider: mock |
| | | model-name: mock-creative |
| | | - code: deepseek-ai/DeepSeek-V3.2 |
| | | name: DEEPSEEK |
| | | provider: openai |
| | | chat-url: https://api.siliconflow.cn |
| | | api-key: |
| | | model-name: deepseek-ai/DeepSeek-V3.2 |
| | | enabled: true |
| | |
| | | package com.vincent.rsf.server.ai.config; |
| | | |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import lombok.Data; |
| | | import org.springframework.boot.context.properties.ConfigurationProperties; |
| | | import org.springframework.context.annotation.Configuration; |
| | |
| | | |
| | | private String systemPrompt = "你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。"; |
| | | |
| | | private String defaultModelCode = "mock-general"; |
| | | private String diagnosisSystemPrompt = "你是一名资深WMS智能诊断助手,目标是结合当前系统上下文对仓库运行情况做巡检分析。" |
| | | + "回答时禁止凭空猜测,必须优先依据提供的实时摘要进行判断。" |
| | | + "请优先按以下顺序分析:先总结库存、任务、设备站点的实时状态,指出是否存在明显异常;" |
| | | + "如果发现异常,请给出异常现象、可能原因、影响范围、建议处理步骤;" |
| | | + "如果数据正常,请明确说明当前未发现明显异常,并提醒仍需人工结合现场状态复核。" |
| | | + "回答尽量引用你拿到的实时数据,不要编造未查询到的设备状态或业务事实。" |
| | | + "请按“问题概述、关键证据、可能原因、建议动作、风险评估”的结构输出,并优先给出可执行建议。"; |
| | | |
| | | private Integer routeFailThreshold = 3; |
| | | |
| | | private Integer routeCooldownMinutes = 10; |
| | | |
| | | private Integer diagnosticLogWindowHours = 24; |
| | | |
| | | private Integer apiFailureWindowHours = 24; |
| | | |
| | | private String defaultModelCode = "deepseek-ai/DeepSeek-V3.2"; |
| | | |
| | | private List<ModelConfig> models = new ArrayList<>(); |
| | | |
| | |
| | | if (defaultModelCode != null && !defaultModelCode.trim().isEmpty()) { |
| | | return defaultModelCode; |
| | | } |
| | | return getEnabledModels().isEmpty() ? "mock-general" : getEnabledModels().get(0).getCode(); |
| | | return getEnabledModels().isEmpty() ? "deepseek-ai/DeepSeek-V3.2" : getEnabledModels().get(0).getCode(); |
| | | } |
| | | |
| | | public String buildScenePrompt(String sceneCode, String basePrompt) { |
| | | String prompt = basePrompt == null ? null : basePrompt.trim(); |
| | | if (AiSceneCode.SYSTEM_DIAGNOSE.equals(sceneCode)) { |
| | | if (prompt == null || prompt.isEmpty()) { |
| | | return diagnosisSystemPrompt; |
| | | } |
| | | return prompt + "\n\n" + diagnosisSystemPrompt; |
| | | } |
| | | return prompt; |
| | | } |
| | | |
| | | @Data |
| | |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.config; |
| | | |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.PostConstruct; |
| | | import javax.annotation.Resource; |
| | | import javax.sql.DataSource; |
| | | import java.sql.Connection; |
| | | import java.sql.ResultSet; |
| | | import java.util.Arrays; |
| | | import java.util.List; |
| | | |
| | | @Component |
| | | public class AiSchemaGuard { |
| | | |
| | | private static final List<String> REQUIRED_TABLES = Arrays.asList( |
| | | "sys_ai_chat_session", |
| | | "sys_ai_chat_message", |
| | | "sys_ai_prompt_template", |
| | | "sys_ai_prompt_publish_log", |
| | | "sys_ai_diagnosis_record", |
| | | "sys_ai_diagnosis_plan", |
| | | "sys_ai_call_log", |
| | | "sys_ai_mcp_mount", |
| | | "sys_ai_model_route", |
| | | "sys_ai_diagnostic_tool_config" |
| | | ); |
| | | |
| | | @Resource |
| | | private DataSource dataSource; |
| | | |
| | | @PostConstruct |
| | | public void validate() { |
| | | try (Connection connection = dataSource.getConnection()) { |
| | | for (String table : REQUIRED_TABLES) { |
| | | if (!tableExists(connection, table)) { |
| | | throw new IllegalStateException("AI feature table missing: " + table + ",请先执行 AI 迁移脚本"); |
| | | } |
| | | } |
| | | } catch (IllegalStateException e) { |
| | | throw e; |
| | | } catch (Exception e) { |
| | | throw new IllegalStateException("AI feature schema validation failed: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | |
| | | private boolean tableExists(Connection connection, String tableName) throws Exception { |
| | | try (ResultSet resultSet = connection.getMetaData().getTables(connection.getCatalog(), null, tableName, null)) { |
| | | if (resultSet.next()) { |
| | | return true; |
| | | } |
| | | } |
| | | try (ResultSet resultSet = connection.getMetaData().getTables(connection.getCatalog(), null, tableName.toUpperCase(), null)) { |
| | | if (resultSet.next()) { |
| | | return true; |
| | | } |
| | | } |
| | | try (ResultSet resultSet = connection.getMetaData().getTables(connection.getCatalog(), null, tableName.toLowerCase(), null)) { |
| | | return resultSet.next(); |
| | | } |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.constant; |
| | | |
| | | public class AiMcpConstants { |
| | | |
| | | public static final String DEFAULT_LOCAL_MOUNT_CODE = "wms_local"; |
| | | public static final String DEFAULT_LOCAL_MOUNT_NAME = "WMS本地MCP"; |
| | | public static final String TRANSPORT_AUTO = "AUTO"; |
| | | public static final String TRANSPORT_INTERNAL = "INTERNAL"; |
| | | public static final String TRANSPORT_HTTP = "HTTP"; |
| | | public static final String TRANSPORT_SSE = "SSE"; |
| | | public static final String USAGE_SCOPE_DIAGNOSE_ONLY = "DIAGNOSE_ONLY"; |
| | | public static final String USAGE_SCOPE_CHAT_AND_DIAGNOSE = "CHAT_AND_DIAGNOSE"; |
| | | public static final String USAGE_SCOPE_DISABLED = "DISABLED"; |
| | | public static final String AUTH_TYPE_NONE = "NONE"; |
| | | public static final String AUTH_TYPE_BEARER = "BEARER"; |
| | | public static final String AUTH_TYPE_API_KEY = "API_KEY"; |
| | | public static final String PROTOCOL_VERSION = "2025-03-26"; |
| | | public static final String SERVER_NAME = "wms-rsf-mcp"; |
| | | public static final String SERVER_VERSION = "1.0.0"; |
| | | |
| | | private AiMcpConstants() { |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.constant; |
| | | |
| | | public final class AiSceneCode { |
| | | |
| | | public static final String GENERAL_CHAT = "general_chat"; |
| | | |
| | | public static final String SYSTEM_DIAGNOSE = "system_diagnose"; |
| | | |
| | | private AiSceneCode() { |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.server.ai.config.AiProperties; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.dto.AiChatStreamRequest; |
| | | import com.vincent.rsf.server.ai.dto.AiSessionCreateRequest; |
| | | import com.vincent.rsf.server.ai.dto.AiSessionRenameRequest; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatMessage; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatRequest; |
| | | import com.vincent.rsf.server.ai.model.AiChatMessage; |
| | | import com.vincent.rsf.server.ai.model.AiChatSession; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.AiGatewayClient; |
| | | import com.vincent.rsf.server.ai.service.AiPromptContextService; |
| | | import com.vincent.rsf.server.ai.service.diagnosis.AiChatStreamOrchestrator; |
| | | import com.vincent.rsf.server.ai.service.diagnosis.AiDiagnosisRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiModelRouteRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiRuntimeConfigService; |
| | | import com.vincent.rsf.server.ai.service.AiSessionService; |
| | | import com.vincent.rsf.server.system.controller.BaseController; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.web.bind.annotation.*; |
| | | import org.springframework.web.bind.annotation.GetMapping; |
| | | import org.springframework.web.bind.annotation.PathVariable; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.IOException; |
| | | import java.util.Date; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | |
| | | @Resource |
| | | private AiSessionService aiSessionService; |
| | | @Resource |
| | | private AiProperties aiProperties; |
| | | @Resource |
| | | private AiGatewayClient aiGatewayClient; |
| | | @Resource |
| | | private AiRuntimeConfigService aiRuntimeConfigService; |
| | | @Resource |
| | | private AiPromptContextService aiPromptContextService; |
| | | private AiModelRouteRuntimeService aiModelRouteRuntimeService; |
| | | @Resource |
| | | private AiDiagnosisRuntimeService aiDiagnosisRuntimeService; |
| | | @Resource |
| | | private AiChatStreamOrchestrator aiChatStreamOrchestrator; |
| | | |
| | | @GetMapping("/model/list") |
| | | public R modelList() { |
| | | List<Map<String, Object>> models = new java.util.ArrayList<>(); |
| | | List<Map<String, Object>> models = new ArrayList<>(); |
| | | for (AiRuntimeConfigService.ModelRuntimeConfig model : aiRuntimeConfigService.listEnabledModels()) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("code", model.getCode()); |
| | |
| | | |
| | | @PostMapping(value = "/chat/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) |
| | | public SseEmitter chatStream(@RequestBody AiChatStreamRequest request) { |
| | | return doChatStream(normalizeRequest(request)); |
| | | } |
| | | |
| | | @PostMapping(value = "/diagnose/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE) |
| | | public SseEmitter diagnoseStream(@RequestBody(required = false) AiChatStreamRequest request) { |
| | | AiChatStreamRequest diagnosisRequest = normalizeRequest(request); |
| | | diagnosisRequest.setSceneCode(AiSceneCode.SYSTEM_DIAGNOSE); |
| | | if (diagnosisRequest.getMessage() == null || diagnosisRequest.getMessage().trim().isEmpty()) { |
| | | diagnosisRequest.setMessage("请对当前WMS系统进行一次巡检诊断,结合库存、任务、设备站点数据识别异常并给出处理建议。"); |
| | | } |
| | | return doChatStream(diagnosisRequest); |
| | | } |
| | | |
| | | private SseEmitter doChatStream(AiChatStreamRequest request) { |
| | | SseEmitter emitter = new SseEmitter(0L); |
| | | Long tenantId = getTenantId(); |
| | | Long userId = getLoginUserId(); |
| | |
| | | completeWithError(emitter, "消息内容不能为空"); |
| | | return emitter; |
| | | } |
| | | |
| | | AiChatSession session = aiSessionService.ensureSession(tenantId, userId, request.getSessionId(), request.getModelCode()); |
| | | aiSessionService.clearStopFlag(session.getId()); |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "user", request.getMessage(), session.getModelCode()); |
| | | AiRuntimeConfigService.ModelRuntimeConfig modelRuntimeConfig = aiRuntimeConfigService.resolveModel(session.getModelCode()); |
| | | |
| | | AiPromptContext promptContext = new AiPromptContext() |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setSessionId(session.getId()) |
| | | .setModelCode(session.getModelCode()) |
| | | .setQuestion(request.getMessage()) |
| | | .setSceneCode(request.getSceneCode()); |
| | | |
| | | List<AiModelRouteRuntimeService.RouteCandidate> candidates = aiModelRouteRuntimeService.resolveCandidates( |
| | | tenantId, |
| | | request.getSceneCode(), |
| | | session.getModelCode() |
| | | ); |
| | | if (candidates.isEmpty()) { |
| | | completeWithError(emitter, "未找到可用的AI模型配置"); |
| | | return emitter; |
| | | } |
| | | int maxContextMessages = resolveContextSize(candidates); |
| | | List<AiChatMessage> contextMessages = aiSessionService.listContextMessages( |
| | | tenantId, |
| | | userId, |
| | | session.getId(), |
| | | modelRuntimeConfig.getMaxContextMessages() |
| | | maxContextMessages |
| | | ); |
| | | AiDiagnosisRecord diagnosisRecord = AiSceneCode.SYSTEM_DIAGNOSE.equals(request.getSceneCode()) |
| | | ? aiDiagnosisRuntimeService.startDiagnosis(tenantId, userId, session.getId(), request.getSceneCode(), request.getMessage()) |
| | | : null; |
| | | |
| | | Thread thread = new Thread(() -> { |
| | | StringBuilder assistantReply = new StringBuilder(); |
| | | boolean doneSent = false; |
| | | try { |
| | | emitter.send(SseEmitter.event().name("session").data(buildSessionPayload(session), MediaType.APPLICATION_JSON)); |
| | | GatewayChatRequest gatewayChatRequest = buildGatewayRequest( |
| | | tenantId, |
| | | userId, |
| | | session, |
| | | contextMessages, |
| | | modelRuntimeConfig, |
| | | request.getMessage() |
| | | ); |
| | | aiGatewayClient.stream(gatewayChatRequest, event -> handleGatewayEvent( |
| | | emitter, |
| | | event, |
| | | session, |
| | | assistantReply |
| | | )); |
| | | if (aiSessionService.isStopRequested(session.getId())) { |
| | | if (assistantReply.length() > 0) { |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), session.getModelCode()); |
| | | } |
| | | emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, true), MediaType.APPLICATION_JSON)); |
| | | doneSent = true; |
| | | } |
| | | } catch (Exception e) { |
| | | try { |
| | | emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(e.getMessage()), MediaType.APPLICATION_JSON)); |
| | | } catch (IOException ignore) { |
| | | } |
| | | } finally { |
| | | if (!doneSent && assistantReply.length() > 0) { |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), session.getModelCode()); |
| | | try { |
| | | emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, false), MediaType.APPLICATION_JSON)); |
| | | } catch (IOException ignore) { |
| | | } |
| | | } |
| | | emitter.complete(); |
| | | aiSessionService.clearStopFlag(session.getId()); |
| | | } |
| | | }, "ai-chat-stream-" + session.getId()); |
| | | Thread thread = new Thread(() -> executeStream( |
| | | emitter, tenantId, userId, session, request, promptContext, contextMessages, diagnosisRecord, candidates |
| | | ), "ai-chat-stream-" + session.getId()); |
| | | thread.setDaemon(true); |
| | | thread.start(); |
| | | return emitter; |
| | | } |
| | | |
| | | private boolean handleGatewayEvent(SseEmitter emitter, JsonNode event, AiChatSession session, |
| | | StringBuilder assistantReply) throws Exception { |
| | | if (aiSessionService.isStopRequested(session.getId())) { |
| | | return false; |
| | | } |
| | | String type = event.path("type").asText(); |
| | | if ("delta".equals(type)) { |
| | | String content = event.path("content").asText(""); |
| | | assistantReply.append(content); |
| | | emitter.send(SseEmitter.event().name("delta").data(buildDeltaPayload(session, content), MediaType.APPLICATION_JSON)); |
| | | return true; |
| | | } |
| | | if ("error".equals(type)) { |
| | | emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(event.path("message").asText("模型调用失败")), MediaType.APPLICATION_JSON)); |
| | | return false; |
| | | } |
| | | if ("done".equals(type)) { |
| | | return false; |
| | | } |
| | | return true; |
| | | private void executeStream(SseEmitter emitter, |
| | | Long tenantId, |
| | | Long userId, |
| | | AiChatSession session, |
| | | AiChatStreamRequest request, |
| | | AiPromptContext promptContext, |
| | | List<AiChatMessage> contextMessages, |
| | | AiDiagnosisRecord diagnosisRecord, |
| | | List<AiModelRouteRuntimeService.RouteCandidate> candidates) { |
| | | aiChatStreamOrchestrator.executeStream( |
| | | emitter, |
| | | tenantId, |
| | | userId, |
| | | session, |
| | | request, |
| | | promptContext, |
| | | contextMessages, |
| | | diagnosisRecord, |
| | | candidates |
| | | ); |
| | | } |
| | | |
| | | private GatewayChatRequest buildGatewayRequest(Long tenantId, Long userId, AiChatSession session, List<AiChatMessage> contextMessages, |
| | | AiRuntimeConfigService.ModelRuntimeConfig modelRuntimeConfig, |
| | | String latestQuestion) { |
| | | GatewayChatRequest request = new GatewayChatRequest(); |
| | | request.setSessionId(session.getId()); |
| | | request.setModelCode(session.getModelCode()); |
| | | request.setSystemPrompt(aiPromptContextService.buildSystemPrompt( |
| | | modelRuntimeConfig.getSystemPrompt(), |
| | | new AiPromptContext() |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setSessionId(session.getId()) |
| | | .setModelCode(session.getModelCode()) |
| | | .setQuestion(latestQuestion) |
| | | )); |
| | | request.setChatUrl(modelRuntimeConfig.getChatUrl()); |
| | | request.setApiKey(modelRuntimeConfig.getApiKey()); |
| | | request.setModelName(modelRuntimeConfig.getModelName()); |
| | | for (AiChatMessage contextMessage : contextMessages) { |
| | | GatewayChatMessage item = new GatewayChatMessage(); |
| | | item.setRole(contextMessage.getRole()); |
| | | item.setContent(contextMessage.getContent()); |
| | | request.getMessages().add(item); |
| | | private int resolveContextSize(List<AiModelRouteRuntimeService.RouteCandidate> candidates) { |
| | | return aiChatStreamOrchestrator.resolveContextSize(candidates); |
| | | } |
| | | |
| | | private AiChatStreamRequest normalizeRequest(AiChatStreamRequest request) { |
| | | AiChatStreamRequest normalized = request == null ? new AiChatStreamRequest() : request; |
| | | if (normalized.getSceneCode() == null || normalized.getSceneCode().trim().isEmpty()) { |
| | | normalized.setSceneCode(AiSceneCode.GENERAL_CHAT); |
| | | } |
| | | return request; |
| | | } |
| | | |
| | | private Map<String, Object> buildSessionPayload(AiChatSession session) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("title", session.getTitle()); |
| | | payload.put("modelCode", session.getModelCode()); |
| | | return payload; |
| | | } |
| | | |
| | | private Map<String, Object> buildDeltaPayload(AiChatSession session, String content) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("modelCode", session.getModelCode()); |
| | | payload.put("content", content); |
| | | payload.put("timestamp", new Date().getTime()); |
| | | return payload; |
| | | } |
| | | |
| | | private Map<String, Object> buildDonePayload(AiChatSession session, boolean stopped) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("modelCode", session.getModelCode()); |
| | | payload.put("stopped", stopped); |
| | | return payload; |
| | | return normalized; |
| | | } |
| | | |
| | | private Map<String, Object> buildErrorPayload(String message) { |
| | |
| | | } |
| | | emitter.complete(); |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | private String modelCode; |
| | | |
| | | private String sceneCode; |
| | | |
| | | } |
| | | |
| | |
| | | private String modelCode; |
| | | |
| | | } |
| | | |
| | |
| | | private String content; |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | private String modelCode; |
| | | |
| | | private String routeCode; |
| | | |
| | | private Integer attemptNo; |
| | | |
| | | private String systemPrompt; |
| | | |
| | | private String chatUrl; |
| | |
| | | private List<GatewayChatMessage> messages = new ArrayList<>(); |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.ai.model.AiChatMessage; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiChatMessageMapper extends BaseMapper<AiChatMessage> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.ai.model.AiChatSession; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiChatSessionMapper extends BaseMapper<AiChatSession> { |
| | | |
| | | } |
| | | |
| | |
| | | package com.vincent.rsf.server.ai.model; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | |
| | |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_chat_message") |
| | | public class AiChatMessage implements Serializable { |
| | | |
| | | @TableId(value = "id", type = IdType.INPUT) |
| | | private String id; |
| | | |
| | | private Long tenantId; |
| | | |
| | | private Long userId; |
| | | |
| | | private String sessionId; |
| | | |
| | |
| | | |
| | | private Date createTime; |
| | | |
| | | private Integer status; |
| | | |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | } |
| | | |
| | |
| | | package com.vincent.rsf.server.ai.model; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | |
| | |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_chat_session") |
| | | public class AiChatSession implements Serializable { |
| | | |
| | | @TableId(value = "id", type = IdType.INPUT) |
| | | private String id; |
| | | |
| | | private Long tenantId; |
| | | |
| | | private Long userId; |
| | | |
| | | private String title; |
| | | |
| | |
| | | |
| | | private Date updateTime; |
| | | |
| | | private Integer status; |
| | | |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.model; |
| | | |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Map; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | public class AiDiagnosticToolResult implements Serializable { |
| | | |
| | | private String toolCode; |
| | | |
| | | private String mountCode; |
| | | |
| | | private String mcpToolName; |
| | | |
| | | private String toolName; |
| | | |
| | | private String severity; |
| | | |
| | | private String summaryText; |
| | | |
| | | private Map<String, Object> rawMeta; |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.model; |
| | | |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Map; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | public class AiMcpToolDescriptor implements Serializable { |
| | | |
| | | private String mountCode; |
| | | |
| | | private String mountName; |
| | | |
| | | private String toolCode; |
| | | |
| | | private String mcpToolName; |
| | | |
| | | private String toolName; |
| | | |
| | | private String sceneCode; |
| | | |
| | | private String description; |
| | | |
| | | private Integer enabledFlag; |
| | | |
| | | private Integer priority; |
| | | |
| | | private String toolPrompt; |
| | | |
| | | private String usageScope; |
| | | |
| | | private String transportType; |
| | | |
| | | private Map<String, Object> inputSchema; |
| | | } |
| | | |
| | |
| | | private String modelCode; |
| | | |
| | | private String question; |
| | | |
| | | private String sceneCode; |
| | | } |
| | | |
| | |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.BufferedReader; |
| | | import java.io.IOException; |
| | | import java.io.InputStream; |
| | | import java.io.InputStreamReader; |
| | | import java.io.OutputStream; |
| | |
| | | private ObjectMapper objectMapper; |
| | | |
| | | public interface StreamCallback { |
| | | /** |
| | | * 处理网关返回的一条 NDJSON 事件。 |
| | | * 返回 true 表示继续消费后续事件,返回 false 表示主动停止本次流读取。 |
| | | */ |
| | | boolean handle(JsonNode event) throws Exception; |
| | | } |
| | | |
| | | /** |
| | | * 调用 AI 网关的内部流式接口,并将网关返回的事件逐条回调给上层编排逻辑。 |
| | | * 这里屏蔽了 HTTP 细节,调用方只需要关注 delta / done / error 事件本身。 |
| | | */ |
| | | public void stream(GatewayChatRequest request, StreamCallback callback) throws Exception { |
| | | HttpURLConnection connection = null; |
| | | boolean terminalEventReceived = false; |
| | | try { |
| | | String url = aiProperties.getGatewayBaseUrl() + "/internal/chat/stream"; |
| | | connection = (HttpURLConnection) new URL(url).openConnection(); |
| | |
| | | continue; |
| | | } |
| | | JsonNode event = objectMapper.readTree(line); |
| | | String type = event.path("type").asText(); |
| | | if ("done".equals(type) || "error".equals(type)) { |
| | | terminalEventReceived = true; |
| | | } |
| | | if (!callback.handle(event)) { |
| | | break; |
| | | } |
| | | } |
| | | } |
| | | if (!terminalEventReceived) { |
| | | throw new IOException("AI网关流异常中断"); |
| | | } |
| | | } finally { |
| | | if (connection != null) { |
| | |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.system.entity.AiModelRoute; |
| | | import com.vincent.rsf.server.system.service.AiModelRouteService; |
| | | import lombok.Data; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Set; |
| | | |
| | | @Service |
| | | public class AiModelRouteRuntimeService { |
| | | |
| | | @Resource |
| | | private AiRuntimeConfigService aiRuntimeConfigService; |
| | | @Resource |
| | | private AiModelRouteService aiModelRouteService; |
| | | @Resource |
| | | private com.vincent.rsf.server.ai.config.AiProperties aiProperties; |
| | | |
| | | /** |
| | | * 为一次聊天/诊断请求解析候选模型列表。 |
| | | * 顺序是:会话首选模型 -> 当前场景可用路由 -> 默认模型。 |
| | | */ |
| | | public List<RouteCandidate> resolveCandidates(Long tenantId, String sceneCode, String preferredModelCode) { |
| | | List<RouteCandidate> output = new ArrayList<>(); |
| | | Set<String> seen = new LinkedHashSet<>(); |
| | | if (preferredModelCode != null && !preferredModelCode.trim().isEmpty()) { |
| | | RouteCandidate preferredCandidate = toCandidate(null, preferredModelCode, seen); |
| | | if (preferredCandidate != null) { |
| | | output.add(preferredCandidate); |
| | | } |
| | | } |
| | | String routeCode = resolveRouteCode(sceneCode); |
| | | for (AiModelRoute route : aiModelRouteService.listAvailableRoutes(tenantId, routeCode)) { |
| | | RouteCandidate candidate = toCandidate(route, route.getModelCode(), seen); |
| | | if (candidate != null) { |
| | | output.add(candidate); |
| | | } |
| | | } |
| | | if (output.isEmpty()) { |
| | | RouteCandidate defaultCandidate = toCandidate(null, aiRuntimeConfigService.resolveDefaultModelCode(), seen); |
| | | if (defaultCandidate != null) { |
| | | output.add(defaultCandidate); |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 标记某条模型路由本次调用成功,并清空失败计数和冷却状态。 |
| | | */ |
| | | public void markSuccess(Long routeId) { |
| | | if (routeId == null) { |
| | | return; |
| | | } |
| | | aiModelRouteService.update(new LambdaUpdateWrapper<AiModelRoute>() |
| | | .setSql("success_count = IFNULL(success_count, 0) + 1") |
| | | .set(AiModelRoute::getFailCount, 0) |
| | | .set(AiModelRoute::getCooldownUntil, null) |
| | | .eq(AiModelRoute::getId, routeId)); |
| | | } |
| | | |
| | | /** |
| | | * 标记某条模型路由本次调用失败。 |
| | | * 连续失败达到阈值后会进入冷却期,避免短时间内持续命中故障模型。 |
| | | */ |
| | | public void markFailure(Long routeId) { |
| | | if (routeId == null) { |
| | | return; |
| | | } |
| | | AiModelRoute route = aiModelRouteService.getById(routeId); |
| | | if (route == null) { |
| | | return; |
| | | } |
| | | int nextFailCount = (route.getFailCount() == null ? 0 : route.getFailCount()) + 1; |
| | | Date cooldownUntil = route.getCooldownUntil(); |
| | | if (nextFailCount >= aiProperties.getRouteFailThreshold()) { |
| | | cooldownUntil = new Date(System.currentTimeMillis() + aiProperties.getRouteCooldownMinutes() * 60_000L); |
| | | } |
| | | route.setFailCount(nextFailCount); |
| | | route.setCooldownUntil(cooldownUntil); |
| | | aiModelRouteService.updateById(route); |
| | | } |
| | | |
| | | /** |
| | | * 手动重置一条路由的成功/失败统计和冷却时间。 |
| | | */ |
| | | public void resetRoute(Long routeId) { |
| | | if (routeId == null) { |
| | | return; |
| | | } |
| | | aiModelRouteService.update(new LambdaUpdateWrapper<AiModelRoute>() |
| | | .set(AiModelRoute::getFailCount, 0) |
| | | .set(AiModelRoute::getSuccessCount, 0) |
| | | .set(AiModelRoute::getCooldownUntil, null) |
| | | .eq(AiModelRoute::getId, routeId)); |
| | | } |
| | | |
| | | /** |
| | | * 将聊天场景统一映射成路由组编码。 |
| | | */ |
| | | private String resolveRouteCode(String sceneCode) { |
| | | if (AiSceneCode.SYSTEM_DIAGNOSE.equals(sceneCode)) { |
| | | return AiSceneCode.SYSTEM_DIAGNOSE; |
| | | } |
| | | return AiSceneCode.GENERAL_CHAT; |
| | | } |
| | | |
| | | /** |
| | | * 基于模型编码和路由记录组装运行时候选项,并负责去重与可用性校验。 |
| | | */ |
| | | private RouteCandidate toCandidate(AiModelRoute route, String modelCode, Set<String> seen) { |
| | | if (modelCode == null || modelCode.trim().isEmpty() || seen.contains(modelCode)) { |
| | | return null; |
| | | } |
| | | AiRuntimeConfigService.ModelRuntimeConfig runtimeConfig = aiRuntimeConfigService.resolveModel(modelCode); |
| | | if (runtimeConfig == null || !Boolean.TRUE.equals(runtimeConfig.getEnabled())) { |
| | | return null; |
| | | } |
| | | seen.add(modelCode); |
| | | RouteCandidate candidate = new RouteCandidate(); |
| | | candidate.setRouteId(route == null ? null : route.getId()); |
| | | candidate.setRouteCode(route == null ? null : route.getRouteCode()); |
| | | candidate.setAttemptModelCode(runtimeConfig.getCode()); |
| | | candidate.setRuntimeConfig(runtimeConfig); |
| | | return candidate; |
| | | } |
| | | |
| | | @Data |
| | | public static class RouteCandidate { |
| | | private Long routeId; |
| | | private String routeCode; |
| | | private String attemptModelCode; |
| | | private AiRuntimeConfigService.ModelRuntimeConfig runtimeConfig; |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | public interface AiPromptContextProvider { |
| | | |
| | | /** |
| | | * 判断当前上下文是否需要由该提供器补充系统提示词。 |
| | | */ |
| | | boolean supports(AiPromptContext context); |
| | | |
| | | /** |
| | | * 根据当前请求上下文生成一段可附加到系统 Prompt 的业务背景描述。 |
| | | */ |
| | | String buildContext(AiPromptContext context); |
| | | } |
| | | |
| | |
| | | this.providers = providers == null ? new ArrayList<>() : providers; |
| | | } |
| | | |
| | | /** |
| | | * 将基础 Prompt 与所有命中的上下文提供器结果拼装成最终系统提示词。 |
| | | * 普通聊天场景主要依赖这条链补充业务背景,诊断场景则在此基础上叠加工具摘要。 |
| | | */ |
| | | public String buildSystemPrompt(String basePrompt, AiPromptContext context) { |
| | | List<String> promptParts = new ArrayList<>(); |
| | | if (basePrompt != null && !basePrompt.trim().isEmpty()) { |
| | |
| | | return String.join("\n\n", promptParts); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import com.vincent.rsf.server.ai.config.AiProperties; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.diagnosis.AiDiagnosticToolService; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | import com.vincent.rsf.server.system.service.AiPromptTemplateService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.List; |
| | | |
| | | @Service |
| | | public class AiPromptRuntimeService { |
| | | |
| | | @Resource |
| | | private AiProperties aiProperties; |
| | | @Resource |
| | | private AiPromptContextService aiPromptContextService; |
| | | @Resource |
| | | private AiPromptTemplateService aiPromptTemplateService; |
| | | @Resource |
| | | private AiDiagnosticToolService aiDiagnosticToolService; |
| | | |
| | | /** |
| | | * 构造指定场景的系统 Prompt。 |
| | | * 当未显式传入诊断工具结果时,诊断场景会在内部主动收集一次工具摘要。 |
| | | */ |
| | | public String buildSystemPrompt(String sceneCode, String fallbackBasePrompt, AiPromptContext context) { |
| | | return buildSystemPrompt(sceneCode, fallbackBasePrompt, context, null); |
| | | } |
| | | |
| | | /** |
| | | * 构造最终系统 Prompt,按“已发布模板/默认模板 + 上下文 + 工具摘要”的顺序拼装。 |
| | | */ |
| | | public String buildSystemPrompt(String sceneCode, String fallbackBasePrompt, AiPromptContext context, |
| | | List<AiDiagnosticToolResult> diagnosticResults) { |
| | | String basePrompt = resolveBasePrompt(sceneCode, fallbackBasePrompt, context.getTenantId()); |
| | | List<AiDiagnosticToolResult> results = diagnosticResults; |
| | | if (results == null && AiSceneCode.SYSTEM_DIAGNOSE.equals(sceneCode)) { |
| | | results = aiDiagnosticToolService.collect(context); |
| | | } |
| | | if (results != null && !results.isEmpty()) { |
| | | List<String> parts = new ArrayList<>(); |
| | | String contextPrompt = aiPromptContextService.buildSystemPrompt(basePrompt, context); |
| | | if (contextPrompt != null && !contextPrompt.trim().isEmpty()) { |
| | | parts.add(contextPrompt.trim()); |
| | | } |
| | | String diagnosticPrompt = aiDiagnosticToolService.buildPrompt(context.getTenantId(), sceneCode, results); |
| | | if (!diagnosticPrompt.trim().isEmpty()) { |
| | | parts.add(diagnosticPrompt); |
| | | } |
| | | return String.join("\n\n", parts); |
| | | } |
| | | return aiPromptContextService.buildSystemPrompt(basePrompt, context); |
| | | } |
| | | |
| | | /** |
| | | * 解析当前场景的基础 Prompt。 |
| | | * 优先使用已发布的 Prompt 模板;若模板不存在或为空,则回退到配置文件中的默认提示词。 |
| | | */ |
| | | private String resolveBasePrompt(String sceneCode, String fallbackBasePrompt, Long tenantId) { |
| | | AiPromptTemplate publishedTemplate = aiPromptTemplateService.getPublishedTemplate(tenantId, sceneCode); |
| | | if (publishedTemplate == null) { |
| | | return aiProperties.buildScenePrompt(sceneCode, fallbackBasePrompt); |
| | | } |
| | | List<String> parts = new ArrayList<>(); |
| | | if (publishedTemplate.getBasePrompt() != null && !publishedTemplate.getBasePrompt().trim().isEmpty()) { |
| | | parts.add(publishedTemplate.getBasePrompt().trim()); |
| | | } |
| | | if (publishedTemplate.getToolPrompt() != null && !publishedTemplate.getToolPrompt().trim().isEmpty()) { |
| | | parts.add(publishedTemplate.getToolPrompt().trim()); |
| | | } |
| | | if (publishedTemplate.getOutputPrompt() != null && !publishedTemplate.getOutputPrompt().trim().isEmpty()) { |
| | | parts.add(publishedTemplate.getOutputPrompt().trim()); |
| | | } |
| | | if (parts.isEmpty()) { |
| | | return aiProperties.buildScenePrompt(sceneCode, fallbackBasePrompt); |
| | | } |
| | | return String.join("\n\n", parts); |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | @Resource |
| | | private AiParamService aiParamService; |
| | | |
| | | /** |
| | | * 枚举当前可用的模型运行时配置。 |
| | | * 优先从数据库读取租户可运营的模型参数;数据库不可用时回退到 application 配置。 |
| | | */ |
| | | public List<ModelRuntimeConfig> listEnabledModels() { |
| | | List<ModelRuntimeConfig> output = new ArrayList<>(); |
| | | try { |
| | |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 解析指定模型编码对应的运行时配置。 |
| | | * 如果未指定模型编码,则返回当前默认模型;如果数据库无记录,则回退到静态配置。 |
| | | */ |
| | | public ModelRuntimeConfig resolveModel(String modelCode) { |
| | | try { |
| | | AiParam aiParam; |
| | |
| | | return config; |
| | | } |
| | | |
| | | /** |
| | | * 获取系统当前默认模型编码。 |
| | | */ |
| | | public String resolveDefaultModelCode() { |
| | | return resolveModel(null).getCode(); |
| | | } |
| | | |
| | | /** |
| | | * 将数据库中的 AI 参数实体转换为运行时统一使用的模型配置对象。 |
| | | */ |
| | | private ModelRuntimeConfig toRuntimeConfig(AiParam aiParam) { |
| | | ModelRuntimeConfig config = new ModelRuntimeConfig(); |
| | | config.setCode(aiParam.getModelCode()); |
| | |
| | | private Boolean enabled; |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | public interface AiSessionService { |
| | | |
| | | /** |
| | | * 查询当前用户可见的 AI 会话列表,按最近更新时间倒序返回。 |
| | | */ |
| | | List<AiChatSession> listSessions(Long tenantId, Long userId); |
| | | |
| | | /** |
| | | * 显式创建一个新会话,并根据传入模型或默认模型初始化会话元数据。 |
| | | */ |
| | | AiChatSession createSession(Long tenantId, Long userId, String title, String modelCode); |
| | | |
| | | /** |
| | | * 确保指定会话存在;如果会话不存在则自动创建,存在时可顺带更新模型偏好。 |
| | | */ |
| | | AiChatSession ensureSession(Long tenantId, Long userId, String sessionId, String modelCode); |
| | | |
| | | /** |
| | | * 按租户、用户和会话 ID 精确读取会话,避免跨租户/跨用户串会话。 |
| | | */ |
| | | AiChatSession getSession(Long tenantId, Long userId, String sessionId); |
| | | |
| | | /** |
| | | * 重命名会话标题。 |
| | | */ |
| | | AiChatSession renameSession(Long tenantId, Long userId, String sessionId, String title); |
| | | |
| | | /** |
| | | * 删除会话及其聊天消息。 |
| | | */ |
| | | void removeSession(Long tenantId, Long userId, String sessionId); |
| | | |
| | | /** |
| | | * 查询会话下的完整消息列表。 |
| | | */ |
| | | List<AiChatMessage> listMessages(Long tenantId, Long userId, String sessionId); |
| | | |
| | | /** |
| | | * 查询构造上下文所需的最近若干条消息。 |
| | | */ |
| | | List<AiChatMessage> listContextMessages(Long tenantId, Long userId, String sessionId, int maxCount); |
| | | |
| | | /** |
| | | * 追加一条聊天消息,并同步刷新会话最后消息、最后活跃时间和模型信息。 |
| | | */ |
| | | AiChatMessage appendMessage(Long tenantId, Long userId, String sessionId, String role, String content, String modelCode); |
| | | |
| | | /** |
| | | * 清除会话的“停止生成”标记,通常在一次流式对话收尾时调用。 |
| | | */ |
| | | void clearStopFlag(String sessionId); |
| | | |
| | | /** |
| | | * 标记会话需要停止生成,供流式编排线程轮询消费。 |
| | | */ |
| | | void requestStop(String sessionId); |
| | | |
| | | /** |
| | | * 判断当前会话是否已收到停止生成请求。 |
| | | */ |
| | | boolean isStopRequested(String sessionId); |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatRequest; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | |
| | | @Service |
| | | public class AiTextCompletionService { |
| | | |
| | | @Resource |
| | | private AiGatewayClient aiGatewayClient; |
| | | |
| | | /** |
| | | * 用流式网关接口模拟一次“同步补全文本”调用。 |
| | | * 适合工具规划、结构化 JSON 选择这类短文本任务,内部仍然复用统一的流式网关能力。 |
| | | */ |
| | | public String complete(GatewayChatRequest request) throws Exception { |
| | | final StringBuilder output = new StringBuilder(); |
| | | aiGatewayClient.stream(request, event -> handleEvent(event, output)); |
| | | return output.toString().trim(); |
| | | } |
| | | |
| | | /** |
| | | * 只收集 delta 文本,并把 error / done 事件转换成同步调用语义。 |
| | | */ |
| | | private boolean handleEvent(JsonNode event, StringBuilder output) { |
| | | String type = event.path("type").asText(""); |
| | | if ("delta".equals(type)) { |
| | | output.append(event.path("content").asText("")); |
| | | return true; |
| | | } |
| | | if ("error".equals(type)) { |
| | | throw new IllegalStateException(event.path("message").asText("模型调用失败")); |
| | | } |
| | | return !"done".equals(type); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.dto.AiChatStreamRequest; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatMessage; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatRequest; |
| | | import com.vincent.rsf.server.ai.model.AiChatMessage; |
| | | import com.vincent.rsf.server.ai.model.AiChatSession; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.AiGatewayClient; |
| | | import com.vincent.rsf.server.ai.service.AiModelRouteRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiPromptRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiSessionService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import org.springframework.http.MediaType; |
| | | import org.springframework.stereotype.Service; |
| | | import org.springframework.web.servlet.mvc.method.annotation.SseEmitter; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.IOException; |
| | | import java.io.InterruptedIOException; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiChatStreamOrchestrator { |
| | | |
| | | @Resource |
| | | private AiSessionService aiSessionService; |
| | | @Resource |
| | | private AiGatewayClient aiGatewayClient; |
| | | @Resource |
| | | private AiPromptRuntimeService aiPromptRuntimeService; |
| | | @Resource |
| | | private AiDiagnosticToolService aiDiagnosticToolService; |
| | | @Resource |
| | | private AiModelRouteRuntimeService aiModelRouteRuntimeService; |
| | | @Resource |
| | | private AiDiagnosisRuntimeService aiDiagnosisRuntimeService; |
| | | @Resource |
| | | private AiDiagnosisMcpRuntimeService aiDiagnosisMcpRuntimeService; |
| | | |
| | | /** |
| | | * 从候选模型列表中挑出最大的上下文窗口,用于提前截断会话历史。 |
| | | */ |
| | | public int resolveContextSize(List<AiModelRouteRuntimeService.RouteCandidate> candidates) { |
| | | int max = 12; |
| | | for (AiModelRouteRuntimeService.RouteCandidate item : candidates) { |
| | | if (item != null && item.getRuntimeConfig() != null |
| | | && item.getRuntimeConfig().getMaxContextMessages() != null |
| | | && item.getRuntimeConfig().getMaxContextMessages() > max) { |
| | | max = item.getRuntimeConfig().getMaxContextMessages(); |
| | | } |
| | | } |
| | | return max; |
| | | } |
| | | |
| | | /** |
| | | * 执行一次完整的流式聊天/诊断编排。 |
| | | * 这里统一负责 MCP 结果准备、模型重试、事件转发、调用日志和诊断收尾。 |
| | | */ |
| | | public void executeStream(SseEmitter emitter, |
| | | Long tenantId, |
| | | Long userId, |
| | | AiChatSession session, |
| | | AiChatStreamRequest request, |
| | | AiPromptContext promptContext, |
| | | List<AiChatMessage> contextMessages, |
| | | AiDiagnosisRecord diagnosisRecord, |
| | | List<AiModelRouteRuntimeService.RouteCandidate> candidates) { |
| | | StringBuilder assistantReply = new StringBuilder(); |
| | | String finalModelCode = session.getModelCode(); |
| | | String finalErrorMessage = null; |
| | | boolean stopped = false; |
| | | boolean success = false; |
| | | boolean assistantSaved = false; |
| | | boolean errorSent = false; |
| | | List<AiDiagnosticToolResult> runtimeDiagnosticResults = new ArrayList<>(); |
| | | String toolSummary = "[]"; |
| | | try { |
| | | emitter.send(SseEmitter.event().name("session").data(buildSessionPayload(session), MediaType.APPLICATION_JSON)); |
| | | if (aiDiagnosisMcpRuntimeService.shouldUseMcp(promptContext)) { |
| | | runtimeDiagnosticResults = aiDiagnosisMcpRuntimeService.resolveToolResults( |
| | | tenantId, |
| | | promptContext, |
| | | contextMessages, |
| | | candidates.isEmpty() ? null : candidates.get(0) |
| | | ); |
| | | toolSummary = aiDiagnosticToolService.serializeResults(runtimeDiagnosticResults); |
| | | } |
| | | int attemptNo = 1; |
| | | for (AiModelRouteRuntimeService.RouteCandidate candidate : candidates) { |
| | | AttemptState attemptState = new AttemptState(); |
| | | Date requestTime = new Date(); |
| | | try { |
| | | GatewayChatRequest gatewayChatRequest = buildGatewayRequest( |
| | | session, |
| | | contextMessages, |
| | | candidate, |
| | | request, |
| | | promptContext, |
| | | runtimeDiagnosticResults, |
| | | attemptNo |
| | | ); |
| | | aiGatewayClient.stream(gatewayChatRequest, event -> handleGatewayEvent( |
| | | emitter, |
| | | event, |
| | | session, |
| | | assistantReply, |
| | | attemptState |
| | | )); |
| | | } catch (Exception e) { |
| | | attemptState.setSuccess(false); |
| | | attemptState.setErrorMessage(e.getMessage()); |
| | | attemptState.setInterrupted(isInterruptedError(e)); |
| | | attemptState.setResponseTime(new Date()); |
| | | } |
| | | if (attemptState.getResponseTime() == null) { |
| | | attemptState.setResponseTime(new Date()); |
| | | } |
| | | String actualModelCode = attemptState.getActualModelCode() == null |
| | | ? candidate.getAttemptModelCode() |
| | | : attemptState.getActualModelCode(); |
| | | finalModelCode = actualModelCode; |
| | | aiDiagnosisRuntimeService.saveCallLog( |
| | | tenantId, |
| | | userId, |
| | | session.getId(), |
| | | diagnosisRecord == null ? null : diagnosisRecord.getId(), |
| | | resolveRouteCode(candidate, request), |
| | | actualModelCode, |
| | | attemptNo, |
| | | requestTime, |
| | | attemptState.getResponseTime(), |
| | | Boolean.TRUE.equals(attemptState.getSuccess()) ? 1 : 0, |
| | | attemptState.getErrorMessage() |
| | | ); |
| | | if (Boolean.TRUE.equals(attemptState.getSuccess())) { |
| | | aiModelRouteRuntimeService.markSuccess(candidate.getRouteId()); |
| | | success = assistantReply.length() > 0; |
| | | if (!success) { |
| | | finalErrorMessage = "模型未返回有效内容"; |
| | | } |
| | | break; |
| | | } |
| | | if (attemptState.isStopped() || aiSessionService.isStopRequested(session.getId())) { |
| | | stopped = true; |
| | | break; |
| | | } |
| | | if (!attemptState.isInterrupted()) { |
| | | aiModelRouteRuntimeService.markFailure(candidate.getRouteId()); |
| | | } |
| | | finalErrorMessage = attemptState.getErrorMessage(); |
| | | if (attemptState.isReceivedDelta() || attemptNo >= candidates.size()) { |
| | | if (!attemptState.isInterrupted() && finalErrorMessage != null && !finalErrorMessage.trim().isEmpty()) { |
| | | emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(finalErrorMessage), MediaType.APPLICATION_JSON)); |
| | | errorSent = true; |
| | | } |
| | | break; |
| | | } |
| | | attemptNo++; |
| | | } |
| | | |
| | | if (aiSessionService.isStopRequested(session.getId())) { |
| | | stopped = true; |
| | | } |
| | | |
| | | if (stopped) { |
| | | if (assistantReply.length() > 0) { |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), finalModelCode); |
| | | assistantSaved = true; |
| | | } |
| | | emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, finalModelCode, true), MediaType.APPLICATION_JSON)); |
| | | if (diagnosisRecord != null) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisFailure(diagnosisRecord, assistantReply.toString(), "用户已停止生成", toolSummary); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | if (success) { |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), finalModelCode); |
| | | assistantSaved = true; |
| | | emitter.send(SseEmitter.event().name("done").data(buildDonePayload(session, finalModelCode, false), MediaType.APPLICATION_JSON)); |
| | | if (diagnosisRecord != null) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisSuccess(diagnosisRecord, assistantReply.toString(), finalModelCode, toolSummary); |
| | | } |
| | | return; |
| | | } |
| | | |
| | | if (assistantReply.length() > 0 && !assistantSaved) { |
| | | aiSessionService.appendMessage(tenantId, userId, session.getId(), "assistant", assistantReply.toString(), finalModelCode); |
| | | assistantSaved = true; |
| | | } |
| | | if (diagnosisRecord != null) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisFailure(diagnosisRecord, assistantReply.toString(), finalErrorMessage, toolSummary); |
| | | } |
| | | if (!errorSent && finalErrorMessage != null && !finalErrorMessage.trim().isEmpty()) { |
| | | emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(finalErrorMessage), MediaType.APPLICATION_JSON)); |
| | | } |
| | | } catch (Exception e) { |
| | | if (diagnosisRecord != null) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisFailure(diagnosisRecord, assistantReply.toString(), e.getMessage(), toolSummary); |
| | | } |
| | | if (!isInterruptedError(e)) { |
| | | try { |
| | | emitter.send(SseEmitter.event().name("error").data(buildErrorPayload(e.getMessage()), MediaType.APPLICATION_JSON)); |
| | | } catch (IOException ignore) { |
| | | } |
| | | } else { |
| | | Thread.currentThread().interrupt(); |
| | | } |
| | | } finally { |
| | | emitter.complete(); |
| | | aiSessionService.clearStopFlag(session.getId()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 消费网关返回的单条流式事件,并把状态写回本次尝试上下文。 |
| | | */ |
| | | private boolean handleGatewayEvent(SseEmitter emitter, JsonNode event, AiChatSession session, |
| | | StringBuilder assistantReply, AttemptState attemptState) throws Exception { |
| | | if (aiSessionService.isStopRequested(session.getId())) { |
| | | attemptState.setStopped(true); |
| | | attemptState.setResponseTime(new Date()); |
| | | return false; |
| | | } |
| | | String type = event.path("type").asText(); |
| | | String modelCode = event.path("modelCode").asText(session.getModelCode()); |
| | | if ("delta".equals(type)) { |
| | | String content = event.path("content").asText(""); |
| | | assistantReply.append(content); |
| | | attemptState.setReceivedDelta(true); |
| | | attemptState.setActualModelCode(modelCode); |
| | | emitter.send(SseEmitter.event().name("delta").data(buildDeltaPayload(session, modelCode, content), MediaType.APPLICATION_JSON)); |
| | | return true; |
| | | } |
| | | if ("error".equals(type)) { |
| | | String message = event.path("message").asText("模型调用失败"); |
| | | attemptState.setSuccess(false); |
| | | attemptState.setErrorMessage(message); |
| | | attemptState.setActualModelCode(modelCode); |
| | | attemptState.setResponseTime(parseResponseTime(event)); |
| | | attemptState.setInterrupted(isInterruptedMessage(message)); |
| | | return false; |
| | | } |
| | | if ("done".equals(type)) { |
| | | attemptState.setSuccess(true); |
| | | attemptState.setActualModelCode(modelCode); |
| | | attemptState.setResponseTime(parseResponseTime(event)); |
| | | return false; |
| | | } |
| | | if ("ping".equals(type)) { |
| | | emitter.send(SseEmitter.event().name("ping").data(buildPingPayload(modelCode), MediaType.APPLICATION_JSON)); |
| | | return true; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 将当前尝试需要的上下文、系统 Prompt 和模型信息组装成网关请求。 |
| | | */ |
| | | private GatewayChatRequest buildGatewayRequest(AiChatSession session, |
| | | List<AiChatMessage> contextMessages, |
| | | AiModelRouteRuntimeService.RouteCandidate candidate, |
| | | AiChatStreamRequest chatRequest, |
| | | AiPromptContext promptContext, |
| | | List<AiDiagnosticToolResult> diagnosticResults, |
| | | Integer attemptNo) { |
| | | GatewayChatRequest request = new GatewayChatRequest(); |
| | | request.setSessionId(session.getId()); |
| | | request.setModelCode(candidate.getAttemptModelCode()); |
| | | request.setRouteCode(resolveRouteCode(candidate, chatRequest)); |
| | | request.setAttemptNo(attemptNo); |
| | | request.setSystemPrompt(aiPromptRuntimeService.buildSystemPrompt( |
| | | chatRequest.getSceneCode(), |
| | | candidate.getRuntimeConfig().getSystemPrompt(), |
| | | promptContext, |
| | | diagnosticResults |
| | | )); |
| | | request.setChatUrl(candidate.getRuntimeConfig().getChatUrl()); |
| | | request.setApiKey(candidate.getRuntimeConfig().getApiKey()); |
| | | request.setModelName(candidate.getRuntimeConfig().getModelName()); |
| | | for (AiChatMessage contextMessage : contextMessages) { |
| | | GatewayChatMessage item = new GatewayChatMessage(); |
| | | item.setRole(contextMessage.getRole()); |
| | | item.setContent(contextMessage.getContent()); |
| | | request.getMessages().add(item); |
| | | } |
| | | return request; |
| | | } |
| | | |
| | | /** |
| | | * 解析当前尝试应落到哪个路由编码,优先使用路由候选自带的 routeCode。 |
| | | */ |
| | | private String resolveRouteCode(AiModelRouteRuntimeService.RouteCandidate candidate, AiChatStreamRequest request) { |
| | | if (candidate != null && candidate.getRouteCode() != null && !candidate.getRouteCode().trim().isEmpty()) { |
| | | return candidate.getRouteCode(); |
| | | } |
| | | return AiSceneCode.SYSTEM_DIAGNOSE.equals(request.getSceneCode()) |
| | | ? AiSceneCode.SYSTEM_DIAGNOSE |
| | | : AiSceneCode.GENERAL_CHAT; |
| | | } |
| | | |
| | | /** |
| | | * 从网关事件中解析响应时间,缺省时回退为当前时间。 |
| | | */ |
| | | private Date parseResponseTime(JsonNode event) { |
| | | long millis = event.path("responseTime").asLong(0L); |
| | | return millis <= 0L ? new Date() : new Date(millis); |
| | | } |
| | | |
| | | /** |
| | | * 构造会话初始化事件给前端。 |
| | | */ |
| | | private Map<String, Object> buildSessionPayload(AiChatSession session) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("title", session.getTitle()); |
| | | payload.put("modelCode", session.getModelCode()); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 构造增量输出事件给前端。 |
| | | */ |
| | | private Map<String, Object> buildDeltaPayload(AiChatSession session, String modelCode, String content) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("modelCode", modelCode); |
| | | payload.put("content", content); |
| | | payload.put("timestamp", new Date().getTime()); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 构造流式完成事件给前端。 |
| | | */ |
| | | private Map<String, Object> buildDonePayload(AiChatSession session, String modelCode, boolean stopped) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("sessionId", session.getId()); |
| | | payload.put("modelCode", modelCode); |
| | | payload.put("stopped", stopped); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 构造错误事件给前端。 |
| | | */ |
| | | private Map<String, Object> buildErrorPayload(String message) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("message", message == null ? "AI服务异常" : message); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 构造心跳事件给前端,用于维持长连接活性。 |
| | | */ |
| | | private Map<String, Object> buildPingPayload(String modelCode) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("modelCode", modelCode); |
| | | payload.put("timestamp", new Date().getTime()); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 判断异常链中是否包含线程中断类错误。 |
| | | */ |
| | | private boolean isInterruptedError(Throwable throwable) { |
| | | Throwable current = throwable; |
| | | while (current != null) { |
| | | if (current instanceof InterruptedException || current instanceof InterruptedIOException) { |
| | | return true; |
| | | } |
| | | if (isInterruptedMessage(current.getMessage())) { |
| | | return true; |
| | | } |
| | | current = current.getCause(); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | private boolean isInterruptedMessage(String message) { |
| | | if (message == null || message.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | String normalized = message.toLowerCase(); |
| | | return normalized.contains("interrupted") |
| | | || normalized.contains("broken pipe") |
| | | || normalized.contains("connection reset") |
| | | || normalized.contains("forcibly closed"); |
| | | } |
| | | |
| | | private static class AttemptState { |
| | | private Boolean success; |
| | | private String actualModelCode; |
| | | private String errorMessage; |
| | | private boolean receivedDelta; |
| | | private boolean interrupted; |
| | | private boolean stopped; |
| | | private Date responseTime; |
| | | |
| | | public Boolean getSuccess() { |
| | | return success; |
| | | } |
| | | |
| | | public void setSuccess(Boolean success) { |
| | | this.success = success; |
| | | } |
| | | |
| | | public String getActualModelCode() { |
| | | return actualModelCode; |
| | | } |
| | | |
| | | public void setActualModelCode(String actualModelCode) { |
| | | this.actualModelCode = actualModelCode; |
| | | } |
| | | |
| | | public String getErrorMessage() { |
| | | return errorMessage; |
| | | } |
| | | |
| | | public void setErrorMessage(String errorMessage) { |
| | | this.errorMessage = errorMessage; |
| | | } |
| | | |
| | | public boolean isReceivedDelta() { |
| | | return receivedDelta; |
| | | } |
| | | |
| | | public void setReceivedDelta(boolean receivedDelta) { |
| | | this.receivedDelta = receivedDelta; |
| | | } |
| | | |
| | | public boolean isInterrupted() { |
| | | return interrupted; |
| | | } |
| | | |
| | | public void setInterrupted(boolean interrupted) { |
| | | this.interrupted = interrupted; |
| | | } |
| | | |
| | | public boolean isStopped() { |
| | | return stopped; |
| | | } |
| | | |
| | | public void setStopped(boolean stopped) { |
| | | this.stopped = stopped; |
| | | } |
| | | |
| | | public Date getResponseTime() { |
| | | return responseTime; |
| | | } |
| | | |
| | | public void setResponseTime(Date responseTime) { |
| | | this.responseTime = responseTime; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatMessage; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatRequest; |
| | | import com.vincent.rsf.server.ai.model.AiChatMessage; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.AiModelRouteRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiTextCompletionService; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpRegistryService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Comparator; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.LinkedHashSet; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.Set; |
| | | |
| | | @Service |
| | | public class AiDiagnosisMcpRuntimeService { |
| | | |
| | | @Resource |
| | | private AiMcpRegistryService aiMcpRegistryService; |
| | | @Resource |
| | | private AiDiagnosticToolService aiDiagnosticToolService; |
| | | @Resource |
| | | private AiTextCompletionService aiTextCompletionService; |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | |
| | | /** |
| | | * 根据当前问题、上下文消息和可用模型,解析出本轮真正要执行的 MCP 工具结果。 |
| | | * 先做工具过滤,再做启发式或模型规划选择,最后顺序执行工具。 |
| | | */ |
| | | public List<AiDiagnosticToolResult> resolveToolResults(Long tenantId, |
| | | AiPromptContext context, |
| | | List<AiChatMessage> contextMessages, |
| | | AiModelRouteRuntimeService.RouteCandidate plannerCandidate) { |
| | | List<AiMcpToolDescriptor> tools = filterTools(aiMcpRegistryService.listTools(tenantId, null), context.getSceneCode()); |
| | | if (tools.isEmpty()) { |
| | | return fallbackResults(context); |
| | | } |
| | | List<AiMcpToolDescriptor> selectedTools = selectTools(context, contextMessages, plannerCandidate, tools); |
| | | if (selectedTools.isEmpty()) { |
| | | return fallbackResults(context); |
| | | } |
| | | List<AiDiagnosticToolResult> output = new ArrayList<>(); |
| | | for (AiMcpToolDescriptor tool : selectedTools) { |
| | | try { |
| | | AiDiagnosticToolResult result = aiMcpRegistryService.executeTool(tenantId, tool, context); |
| | | if (result != null && result.getSummaryText() != null && !result.getSummaryText().trim().isEmpty()) { |
| | | output.add(result); |
| | | } |
| | | } catch (Exception e) { |
| | | output.add(new AiDiagnosticToolResult() |
| | | .setToolCode(tool.getToolCode()) |
| | | .setMountCode(tool.getMountCode()) |
| | | .setMcpToolName(tool.getMcpToolName()) |
| | | .setToolName(tool.getToolName()) |
| | | .setSeverity("WARN") |
| | | .setSummaryText("MCP工具执行失败:" + e.getMessage())); |
| | | } |
| | | } |
| | | if (output.isEmpty()) { |
| | | return fallbackResults(context); |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 判断当前请求是否值得启用 MCP。 |
| | | * 诊断场景默认启用,普通聊天则按关键词和业务问题特征触发。 |
| | | */ |
| | | public boolean shouldUseMcp(AiPromptContext context) { |
| | | if (context == null) { |
| | | return false; |
| | | } |
| | | if (AiSceneCode.SYSTEM_DIAGNOSE.equals(context.getSceneCode())) { |
| | | return true; |
| | | } |
| | | String question = context.getQuestion(); |
| | | if (question == null || question.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | String normalized = question.toLowerCase(); |
| | | return normalized.contains("mcp") |
| | | || normalized.contains("工具") |
| | | || normalized.contains("license") |
| | | || question.contains("许可证") |
| | | || normalized.contains("task") |
| | | || normalized.contains("device") |
| | | || normalized.contains("site") |
| | | || normalized.contains("loc") |
| | | || question.contains("库存") |
| | | || question.contains("库位") |
| | | || question.contains("货位") |
| | | || question.contains("物料") |
| | | || question.contains("任务") |
| | | || question.contains("设备") |
| | | || question.contains("站点") |
| | | || question.contains("巷道"); |
| | | } |
| | | |
| | | /** |
| | | * 诊断场景在没有选出具体工具时,退回到内置工具全量收集模式; |
| | | * 普通聊天则直接返回空结果,避免无关工具污染回答。 |
| | | */ |
| | | private List<AiDiagnosticToolResult> fallbackResults(AiPromptContext context) { |
| | | if (context != null && AiSceneCode.SYSTEM_DIAGNOSE.equals(context.getSceneCode())) { |
| | | return aiDiagnosticToolService.collect(context); |
| | | } |
| | | return new ArrayList<>(); |
| | | } |
| | | |
| | | /** |
| | | * 按场景和启用状态过滤可用工具目录。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> filterTools(List<AiMcpToolDescriptor> tools, String sceneCode) { |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | for (AiMcpToolDescriptor tool : tools) { |
| | | if (tool == null || !Integer.valueOf(1).equals(tool.getEnabledFlag())) { |
| | | continue; |
| | | } |
| | | if (sceneCode != null && tool.getSceneCode() != null && !tool.getSceneCode().trim().isEmpty() |
| | | && !sceneCode.equals(tool.getSceneCode())) { |
| | | continue; |
| | | } |
| | | output.add(tool); |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 普通聊天优先使用启发式工具选择,诊断场景优先尝试模型规划。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> selectTools(AiPromptContext context, |
| | | List<AiChatMessage> contextMessages, |
| | | AiModelRouteRuntimeService.RouteCandidate plannerCandidate, |
| | | List<AiMcpToolDescriptor> tools) { |
| | | List<AiMcpToolDescriptor> heuristic = heuristicSelect(tools, context == null ? null : context.getQuestion()); |
| | | if (context != null && !AiSceneCode.SYSTEM_DIAGNOSE.equals(context.getSceneCode()) && !heuristic.isEmpty()) { |
| | | return heuristic; |
| | | } |
| | | List<AiMcpToolDescriptor> byModel = selectToolsByModel(context, contextMessages, plannerCandidate, tools); |
| | | if (!byModel.isEmpty()) { |
| | | return byModel; |
| | | } |
| | | return heuristic; |
| | | } |
| | | |
| | | /** |
| | | * 使用一个轻量规划请求,让模型在现有工具目录中选出最需要调用的工具。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> selectToolsByModel(AiPromptContext context, |
| | | List<AiChatMessage> contextMessages, |
| | | AiModelRouteRuntimeService.RouteCandidate plannerCandidate, |
| | | List<AiMcpToolDescriptor> tools) { |
| | | if (plannerCandidate == null || plannerCandidate.getRuntimeConfig() == null) { |
| | | return new ArrayList<>(); |
| | | } |
| | | try { |
| | | GatewayChatRequest request = new GatewayChatRequest(); |
| | | request.setSessionId(context == null ? null : context.getSessionId()); |
| | | request.setModelCode(plannerCandidate.getAttemptModelCode()); |
| | | request.setRouteCode("system_diagnose_planner"); |
| | | request.setAttemptNo(0); |
| | | request.setChatUrl(plannerCandidate.getRuntimeConfig().getChatUrl()); |
| | | request.setApiKey(plannerCandidate.getRuntimeConfig().getApiKey()); |
| | | request.setModelName(plannerCandidate.getRuntimeConfig().getModelName()); |
| | | request.setSystemPrompt(buildPlannerSystemPrompt()); |
| | | for (AiChatMessage item : contextMessages) { |
| | | GatewayChatMessage message = new GatewayChatMessage(); |
| | | message.setRole(item.getRole()); |
| | | message.setContent(item.getContent()); |
| | | request.getMessages().add(message); |
| | | } |
| | | GatewayChatMessage plannerMessage = new GatewayChatMessage(); |
| | | plannerMessage.setRole("user"); |
| | | plannerMessage.setContent(buildPlannerUserPrompt(context, tools)); |
| | | request.getMessages().add(plannerMessage); |
| | | String response = aiTextCompletionService.complete(request); |
| | | return mapSelection(parseToolNames(response), tools); |
| | | } catch (Exception ignore) { |
| | | return new ArrayList<>(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 生成专用于工具规划的 system prompt。 |
| | | */ |
| | | private String buildPlannerSystemPrompt() { |
| | | return "你是WMS诊断工具调度器。你只能从提供的 MCP 工具目录中选择最需要调用的工具。" |
| | | + "只输出 JSON,不要 markdown,不要解释。输出格式必须是 " |
| | | + "{\"tools\":[{\"name\":\"工具名\",\"reason\":\"简短原因\"}]}" |
| | | + "。工具名必须来自目录,最多选择4个;如果无需工具,返回 {\"tools\":[]}。"; |
| | | } |
| | | |
| | | /** |
| | | * 将问题和工具目录整理成规划模型可直接消费的输入。 |
| | | */ |
| | | private String buildPlannerUserPrompt(AiPromptContext context, List<AiMcpToolDescriptor> tools) { |
| | | List<String> parts = new ArrayList<>(); |
| | | parts.add("问题:"); |
| | | parts.add(context == null || context.getQuestion() == null ? "" : context.getQuestion()); |
| | | parts.add(""); |
| | | parts.add("可用工具目录:"); |
| | | for (AiMcpToolDescriptor tool : tools) { |
| | | parts.add("- " + tool.getMcpToolName() |
| | | + " | " + safe(tool.getToolName()) |
| | | + " | " + safe(tool.getDescription()) |
| | | + " | " + safe(tool.getToolPrompt())); |
| | | } |
| | | return String.join("\n", parts); |
| | | } |
| | | |
| | | /** |
| | | * 解析规划模型返回的 JSON,提取被选中的工具名列表。 |
| | | */ |
| | | private List<String> parseToolNames(String response) { |
| | | List<String> output = new ArrayList<>(); |
| | | if (response == null || response.trim().isEmpty()) { |
| | | return output; |
| | | } |
| | | String normalized = unwrapJson(response); |
| | | try { |
| | | JsonNode root = objectMapper.readTree(normalized); |
| | | JsonNode toolsNode = root.path("tools"); |
| | | if (!toolsNode.isArray()) { |
| | | return output; |
| | | } |
| | | for (JsonNode item : toolsNode) { |
| | | String name = item.path("name").asText(""); |
| | | if (!name.trim().isEmpty()) { |
| | | output.add(name.trim()); |
| | | } |
| | | } |
| | | } catch (Exception ignore) { |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 将模型返回的工具名映射回本地工具描述符,并按返回顺序去重截断。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> mapSelection(List<String> names, List<AiMcpToolDescriptor> tools) { |
| | | Map<String, AiMcpToolDescriptor> byName = new LinkedHashMap<>(); |
| | | for (AiMcpToolDescriptor tool : tools) { |
| | | byName.put(tool.getMcpToolName(), tool); |
| | | } |
| | | Set<String> seen = new LinkedHashSet<>(); |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | for (String name : names) { |
| | | if (seen.contains(name)) { |
| | | continue; |
| | | } |
| | | AiMcpToolDescriptor tool = byName.get(name); |
| | | if (tool != null) { |
| | | output.add(tool); |
| | | seen.add(name); |
| | | } |
| | | if (output.size() >= 4) { |
| | | break; |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 基于中文业务关键词与工具描述打分,选出最可能命中的工具。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> heuristicSelect(List<AiMcpToolDescriptor> tools, String question) { |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | if (tools.isEmpty()) { |
| | | return output; |
| | | } |
| | | String normalized = normalize(question); |
| | | List<ScoredTool> scoredTools = new ArrayList<>(); |
| | | for (AiMcpToolDescriptor tool : tools) { |
| | | int score = matchScore(tool, normalized); |
| | | if (score > 0) { |
| | | scoredTools.add(new ScoredTool(tool, score)); |
| | | } |
| | | } |
| | | scoredTools.sort(Comparator.comparingInt(ScoredTool::getScore).reversed()); |
| | | for (ScoredTool scoredTool : scoredTools) { |
| | | output.add(scoredTool.getTool()); |
| | | if (output.size() >= 4) { |
| | | return output; |
| | | } |
| | | } |
| | | for (AiMcpToolDescriptor tool : tools) { |
| | | if (!output.contains(tool)) { |
| | | output.add(tool); |
| | | } |
| | | if (output.size() >= 3) { |
| | | break; |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 计算单个工具与当前问题的命中分数。 |
| | | */ |
| | | private int matchScore(AiMcpToolDescriptor tool, String question) { |
| | | if (question == null || question.trim().isEmpty()) { |
| | | return 0; |
| | | } |
| | | return countMatches(question, tool.getToolCode(), tool.getToolName(), tool.getDescription(), tool.getToolPrompt()); |
| | | } |
| | | |
| | | /** |
| | | * 统计问题文本与一组候选字段的片段命中次数。 |
| | | */ |
| | | private int countMatches(String question, String... values) { |
| | | int score = 0; |
| | | for (String value : values) { |
| | | for (String piece : buildMatchFragments(value)) { |
| | | if (piece.length() >= 2 && question.contains(piece)) { |
| | | score++; |
| | | } |
| | | } |
| | | } |
| | | return score; |
| | | } |
| | | |
| | | /** |
| | | * 将工具名、描述等文本拆成适合中文业务查询匹配的片段集合。 |
| | | */ |
| | | private List<String> buildMatchFragments(String value) { |
| | | List<String> fragments = new ArrayList<>(); |
| | | if (value == null || value.trim().isEmpty()) { |
| | | return fragments; |
| | | } |
| | | String[] pieces = normalize(value).split("[^a-z0-9\\u4e00-\\u9fa5]+"); |
| | | for (String piece : pieces) { |
| | | if (piece.length() < 2) { |
| | | continue; |
| | | } |
| | | fragments.add(piece); |
| | | if (piece.matches("[\\u4e00-\\u9fa5]+")) { |
| | | if (piece.endsWith("摘要") || piece.endsWith("概况") || piece.endsWith("概览") || piece.endsWith("状态")) { |
| | | fragments.add(piece.substring(0, piece.length() - 2)); |
| | | } |
| | | for (int size = 2; size <= Math.min(4, piece.length()); size++) { |
| | | for (int i = 0; i <= piece.length() - size; i++) { |
| | | fragments.add(piece.substring(i, i + size)); |
| | | } |
| | | } |
| | | } |
| | | } |
| | | return fragments; |
| | | } |
| | | |
| | | /** |
| | | * 对比对文本做小写归一化,便于统一匹配。 |
| | | */ |
| | | private String normalize(String value) { |
| | | return value == null ? "" : value.toLowerCase(); |
| | | } |
| | | |
| | | /** |
| | | * 尽量从模型输出中剥离出可解析的 JSON 片段。 |
| | | */ |
| | | private String unwrapJson(String response) { |
| | | String normalized = response.trim(); |
| | | if (normalized.startsWith("```")) { |
| | | int firstBrace = normalized.indexOf('{'); |
| | | int lastBrace = normalized.lastIndexOf('}'); |
| | | if (firstBrace >= 0 && lastBrace > firstBrace) { |
| | | return normalized.substring(firstBrace, lastBrace + 1); |
| | | } |
| | | } |
| | | int firstBrace = normalized.indexOf('{'); |
| | | int lastBrace = normalized.lastIndexOf('}'); |
| | | if (firstBrace >= 0 && lastBrace > firstBrace) { |
| | | return normalized.substring(firstBrace, lastBrace + 1); |
| | | } |
| | | return normalized; |
| | | } |
| | | |
| | | /** |
| | | * 为 prompt 组装阶段提供空值安全字符串。 |
| | | */ |
| | | private String safe(String value) { |
| | | return value == null ? "" : value; |
| | | } |
| | | |
| | | private static class ScoredTool { |
| | | private final AiMcpToolDescriptor tool; |
| | | private final int score; |
| | | |
| | | /** |
| | | * 保存工具及其匹配得分。 |
| | | */ |
| | | private ScoredTool(AiMcpToolDescriptor tool, int score) { |
| | | this.tool = tool; |
| | | this.score = score; |
| | | } |
| | | |
| | | /** |
| | | * 返回被打分的工具。 |
| | | */ |
| | | public AiMcpToolDescriptor getTool() { |
| | | return tool; |
| | | } |
| | | |
| | | /** |
| | | * 返回工具匹配得分。 |
| | | */ |
| | | public int getScore() { |
| | | return score; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatMessage; |
| | | import com.vincent.rsf.server.ai.dto.GatewayChatRequest; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.AiGatewayClient; |
| | | import com.vincent.rsf.server.ai.service.AiModelRouteRuntimeService; |
| | | import com.vincent.rsf.server.ai.service.AiPromptRuntimeService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisPlanService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.InterruptedIOException; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Service |
| | | public class AiDiagnosisPlanRunnerService { |
| | | |
| | | @Resource |
| | | private AiDiagnosisPlanService aiDiagnosisPlanService; |
| | | @Resource |
| | | private AiDiagnosticToolService aiDiagnosticToolService; |
| | | @Resource |
| | | private AiModelRouteRuntimeService aiModelRouteRuntimeService; |
| | | @Resource |
| | | private AiPromptRuntimeService aiPromptRuntimeService; |
| | | @Resource |
| | | private AiGatewayClient aiGatewayClient; |
| | | @Resource |
| | | private AiDiagnosisRuntimeService aiDiagnosisRuntimeService; |
| | | |
| | | /** |
| | | * 执行一次巡检计划。 |
| | | * 计划执行本质上复用诊断主链路,只是输入来源从人工提问变成了计划配置。 |
| | | */ |
| | | public void runPlan(Long planId, boolean manualTrigger) { |
| | | AiDiagnosisPlan plan = aiDiagnosisPlanService.getById(planId); |
| | | if (plan == null) { |
| | | return; |
| | | } |
| | | Date finishTime = new Date(); |
| | | Date nextRunTime = Integer.valueOf(1).equals(plan.getStatus()) |
| | | ? aiDiagnosisPlanService.calculateNextRunTime(plan.getCronExpr(), finishTime) |
| | | : null; |
| | | Long userId = plan.getUpdateBy() == null ? plan.getCreateBy() : plan.getUpdateBy(); |
| | | String sessionId = "plan-" + plan.getId() + "-" + System.currentTimeMillis(); |
| | | String question = plan.getPrompt(); |
| | | if (question == null || question.trim().isEmpty()) { |
| | | question = "请对当前WMS系统进行一次巡检诊断,结合库存、任务、设备站点数据识别异常并给出处理建议。"; |
| | | } |
| | | |
| | | AiPromptContext promptContext = new AiPromptContext() |
| | | .setTenantId(plan.getTenantId()) |
| | | .setUserId(userId) |
| | | .setSessionId(sessionId) |
| | | .setModelCode(plan.getPreferredModelCode()) |
| | | .setQuestion(question) |
| | | .setSceneCode(plan.getSceneCode() == null || plan.getSceneCode().trim().isEmpty() |
| | | ? AiSceneCode.SYSTEM_DIAGNOSE |
| | | : plan.getSceneCode()); |
| | | |
| | | List<AiDiagnosticToolResult> diagnosticResults = aiDiagnosticToolService.collect(promptContext); |
| | | String toolSummary = aiDiagnosticToolService.serializeResults(diagnosticResults); |
| | | AiDiagnosisRecord diagnosisRecord = aiDiagnosisRuntimeService.startDiagnosis( |
| | | plan.getTenantId(), |
| | | userId, |
| | | sessionId, |
| | | promptContext.getSceneCode(), |
| | | question |
| | | ); |
| | | |
| | | StringBuilder assistantReply = new StringBuilder(); |
| | | String finalModelCode = plan.getPreferredModelCode(); |
| | | String finalErrorMessage = null; |
| | | boolean success = false; |
| | | |
| | | try { |
| | | List<AiModelRouteRuntimeService.RouteCandidate> candidates = aiModelRouteRuntimeService.resolveCandidates( |
| | | plan.getTenantId(), |
| | | promptContext.getSceneCode(), |
| | | plan.getPreferredModelCode() |
| | | ); |
| | | if (candidates.isEmpty()) { |
| | | finalErrorMessage = "未找到可用的AI模型配置"; |
| | | } else { |
| | | int attemptNo = 1; |
| | | for (AiModelRouteRuntimeService.RouteCandidate candidate : candidates) { |
| | | AttemptState attemptState = new AttemptState(); |
| | | Date requestTime = new Date(); |
| | | try { |
| | | GatewayChatRequest gatewayChatRequest = buildGatewayRequest( |
| | | sessionId, |
| | | question, |
| | | candidate, |
| | | promptContext, |
| | | diagnosticResults, |
| | | attemptNo |
| | | ); |
| | | aiGatewayClient.stream(gatewayChatRequest, event -> handleGatewayEvent(event, assistantReply, attemptState)); |
| | | } catch (Exception e) { |
| | | attemptState.setSuccess(false); |
| | | attemptState.setErrorMessage(e.getMessage()); |
| | | attemptState.setInterrupted(isInterruptedError(e)); |
| | | attemptState.setResponseTime(new Date()); |
| | | } |
| | | if (attemptState.getResponseTime() == null) { |
| | | attemptState.setResponseTime(new Date()); |
| | | } |
| | | String actualModelCode = attemptState.getActualModelCode() == null |
| | | ? candidate.getAttemptModelCode() |
| | | : attemptState.getActualModelCode(); |
| | | finalModelCode = actualModelCode; |
| | | aiDiagnosisRuntimeService.saveCallLog( |
| | | plan.getTenantId(), |
| | | userId, |
| | | sessionId, |
| | | diagnosisRecord.getId(), |
| | | candidate.getRouteCode(), |
| | | actualModelCode, |
| | | attemptNo, |
| | | requestTime, |
| | | attemptState.getResponseTime(), |
| | | Boolean.TRUE.equals(attemptState.getSuccess()) ? 1 : 0, |
| | | attemptState.getErrorMessage() |
| | | ); |
| | | if (Boolean.TRUE.equals(attemptState.getSuccess())) { |
| | | aiModelRouteRuntimeService.markSuccess(candidate.getRouteId()); |
| | | success = assistantReply.length() > 0; |
| | | if (!success) { |
| | | finalErrorMessage = "模型未返回有效内容"; |
| | | } |
| | | break; |
| | | } |
| | | if (!attemptState.isInterrupted()) { |
| | | aiModelRouteRuntimeService.markFailure(candidate.getRouteId()); |
| | | } |
| | | finalErrorMessage = attemptState.getErrorMessage(); |
| | | if (attemptState.isReceivedDelta() || attemptNo >= candidates.size()) { |
| | | break; |
| | | } |
| | | attemptNo++; |
| | | } |
| | | } |
| | | |
| | | if (success) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisSuccess(diagnosisRecord, assistantReply.toString(), finalModelCode, toolSummary); |
| | | aiDiagnosisPlanService.finishExecution( |
| | | plan.getId(), |
| | | 1, |
| | | diagnosisRecord.getId(), |
| | | buildPlanMessage(assistantReply.toString(), manualTrigger ? "手动执行成功" : "计划执行成功"), |
| | | new Date(), |
| | | nextRunTime |
| | | ); |
| | | return; |
| | | } |
| | | aiDiagnosisRuntimeService.finishDiagnosisFailure(diagnosisRecord, assistantReply.toString(), finalErrorMessage, toolSummary); |
| | | aiDiagnosisPlanService.finishExecution( |
| | | plan.getId(), |
| | | 0, |
| | | diagnosisRecord.getId(), |
| | | buildPlanMessage(finalErrorMessage, manualTrigger ? "手动执行失败" : "计划执行失败"), |
| | | new Date(), |
| | | nextRunTime |
| | | ); |
| | | } catch (Exception e) { |
| | | aiDiagnosisRuntimeService.finishDiagnosisFailure(diagnosisRecord, assistantReply.toString(), e.getMessage(), toolSummary); |
| | | aiDiagnosisPlanService.finishExecution( |
| | | plan.getId(), |
| | | 0, |
| | | diagnosisRecord.getId(), |
| | | buildPlanMessage(e.getMessage(), manualTrigger ? "手动执行失败" : "计划执行失败"), |
| | | new Date(), |
| | | nextRunTime |
| | | ); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 为巡检计划组装网关请求。 |
| | | */ |
| | | private GatewayChatRequest buildGatewayRequest(String sessionId, |
| | | String question, |
| | | AiModelRouteRuntimeService.RouteCandidate candidate, |
| | | AiPromptContext promptContext, |
| | | List<AiDiagnosticToolResult> diagnosticResults, |
| | | Integer attemptNo) { |
| | | GatewayChatRequest request = new GatewayChatRequest(); |
| | | request.setSessionId(sessionId); |
| | | request.setModelCode(candidate.getAttemptModelCode()); |
| | | request.setRouteCode(candidate.getRouteCode()); |
| | | request.setAttemptNo(attemptNo); |
| | | request.setSystemPrompt(aiPromptRuntimeService.buildSystemPrompt( |
| | | promptContext.getSceneCode(), |
| | | candidate.getRuntimeConfig().getSystemPrompt(), |
| | | promptContext, |
| | | diagnosticResults |
| | | )); |
| | | request.setChatUrl(candidate.getRuntimeConfig().getChatUrl()); |
| | | request.setApiKey(candidate.getRuntimeConfig().getApiKey()); |
| | | request.setModelName(candidate.getRuntimeConfig().getModelName()); |
| | | |
| | | GatewayChatMessage message = new GatewayChatMessage(); |
| | | message.setRole("user"); |
| | | message.setContent(question); |
| | | request.getMessages().add(message); |
| | | return request; |
| | | } |
| | | |
| | | /** |
| | | * 消费计划执行时的流式网关事件。 |
| | | */ |
| | | private boolean handleGatewayEvent(JsonNode event, StringBuilder assistantReply, AttemptState attemptState) { |
| | | String type = event.path("type").asText(); |
| | | String modelCode = event.path("modelCode").asText(); |
| | | if ("delta".equals(type)) { |
| | | String content = event.path("content").asText(""); |
| | | assistantReply.append(content); |
| | | attemptState.setReceivedDelta(true); |
| | | attemptState.setActualModelCode(modelCode); |
| | | return true; |
| | | } |
| | | if ("error".equals(type)) { |
| | | attemptState.setSuccess(false); |
| | | attemptState.setActualModelCode(modelCode); |
| | | attemptState.setErrorMessage(event.path("message").asText("模型调用失败")); |
| | | attemptState.setResponseTime(parseResponseTime(event)); |
| | | attemptState.setInterrupted(isInterruptedMessage(attemptState.getErrorMessage())); |
| | | return false; |
| | | } |
| | | if ("done".equals(type)) { |
| | | attemptState.setSuccess(true); |
| | | attemptState.setActualModelCode(modelCode); |
| | | attemptState.setResponseTime(parseResponseTime(event)); |
| | | return false; |
| | | } |
| | | return true; |
| | | } |
| | | |
| | | /** |
| | | * 从网关事件中提取响应时间。 |
| | | */ |
| | | private Date parseResponseTime(JsonNode event) { |
| | | long millis = event.path("responseTime").asLong(0L); |
| | | return millis <= 0L ? new Date() : new Date(millis); |
| | | } |
| | | |
| | | /** |
| | | * 将计划执行结果压缩成适合回写到计划记录的短消息。 |
| | | */ |
| | | private String buildPlanMessage(String text, String fallback) { |
| | | String source = text == null ? "" : text.trim(); |
| | | if (source.isEmpty()) { |
| | | return fallback; |
| | | } |
| | | return source.length() > 120 ? source.substring(0, 120) : source; |
| | | } |
| | | |
| | | /** |
| | | * 判断异常链中是否包含中断类错误。 |
| | | */ |
| | | private boolean isInterruptedError(Throwable throwable) { |
| | | Throwable current = throwable; |
| | | while (current != null) { |
| | | if (current instanceof InterruptedException || current instanceof InterruptedIOException) { |
| | | return true; |
| | | } |
| | | if (isInterruptedMessage(current.getMessage())) { |
| | | return true; |
| | | } |
| | | current = current.getCause(); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | /** |
| | | * 根据异常消息文本判断是否属于连接中断类错误。 |
| | | */ |
| | | private boolean isInterruptedMessage(String message) { |
| | | if (message == null || message.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | String normalized = message.toLowerCase(); |
| | | return normalized.contains("interrupted") |
| | | || normalized.contains("broken pipe") |
| | | || normalized.contains("connection reset") |
| | | || normalized.contains("forcibly closed"); |
| | | } |
| | | |
| | | private static class AttemptState { |
| | | private Boolean success; |
| | | private String actualModelCode; |
| | | private String errorMessage; |
| | | private boolean receivedDelta; |
| | | private boolean interrupted; |
| | | private Date responseTime; |
| | | |
| | | public Boolean getSuccess() { |
| | | return success; |
| | | } |
| | | |
| | | public void setSuccess(Boolean success) { |
| | | this.success = success; |
| | | } |
| | | |
| | | public String getActualModelCode() { |
| | | return actualModelCode; |
| | | } |
| | | |
| | | public void setActualModelCode(String actualModelCode) { |
| | | this.actualModelCode = actualModelCode; |
| | | } |
| | | |
| | | public String getErrorMessage() { |
| | | return errorMessage; |
| | | } |
| | | |
| | | public void setErrorMessage(String errorMessage) { |
| | | this.errorMessage = errorMessage; |
| | | } |
| | | |
| | | public boolean isReceivedDelta() { |
| | | return receivedDelta; |
| | | } |
| | | |
| | | public void setReceivedDelta(boolean receivedDelta) { |
| | | this.receivedDelta = receivedDelta; |
| | | } |
| | | |
| | | public boolean isInterrupted() { |
| | | return interrupted; |
| | | } |
| | | |
| | | public void setInterrupted(boolean interrupted) { |
| | | this.interrupted = interrupted; |
| | | } |
| | | |
| | | public Date getResponseTime() { |
| | | return responseTime; |
| | | } |
| | | |
| | | public void setResponseTime(Date responseTime) { |
| | | this.responseTime = responseTime; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisPlanService; |
| | | import org.springframework.scheduling.annotation.Scheduled; |
| | | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Component |
| | | public class AiDiagnosisPlanScheduler { |
| | | |
| | | @Resource |
| | | private AiDiagnosisPlanService aiDiagnosisPlanService; |
| | | @Resource |
| | | private AiDiagnosisPlanRunnerService aiDiagnosisPlanRunnerService; |
| | | @Resource |
| | | private ThreadPoolTaskScheduler taskScheduler; |
| | | |
| | | /** |
| | | * 每 30 秒扫描一次到期巡检计划,并异步分发执行。 |
| | | */ |
| | | @Scheduled(cron = "0/30 * * * * ?") |
| | | public void dispatchDuePlans() { |
| | | Date now = new Date(); |
| | | List<AiDiagnosisPlan> plans = aiDiagnosisPlanService.listDuePlans(now); |
| | | for (AiDiagnosisPlan plan : plans) { |
| | | Long operatorId = plan.getUpdateBy() == null ? plan.getCreateBy() : plan.getUpdateBy(); |
| | | Date nextRunTime = aiDiagnosisPlanService.calculateNextRunTime(plan.getCronExpr(), now); |
| | | boolean acquired = aiDiagnosisPlanService.acquireForExecution( |
| | | plan.getId(), |
| | | operatorId, |
| | | "计划执行中", |
| | | nextRunTime |
| | | ); |
| | | if (!acquired) { |
| | | continue; |
| | | } |
| | | taskScheduler.execute(() -> aiDiagnosisPlanRunnerService.runPlan(plan.getId(), false)); |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiDiagnosisReportService { |
| | | |
| | | /** |
| | | * 基于模型结论和工具摘要回填诊断报告结构化字段与 Markdown 内容。 |
| | | */ |
| | | public void fillReport(AiDiagnosisRecord record, String conclusion, String toolSummary) { |
| | | if (record == null) { |
| | | return; |
| | | } |
| | | Map<String, String> sections = extractSections(conclusion); |
| | | String executiveSummary = firstNonBlank( |
| | | sections.get("问题概述"), |
| | | sections.get("执行摘要"), |
| | | safeText(conclusion) |
| | | ); |
| | | String evidenceSummary = firstNonBlank( |
| | | sections.get("关键证据"), |
| | | sections.get("证据摘要"), |
| | | safeText(toolSummary) |
| | | ); |
| | | String actionSummary = firstNonBlank( |
| | | sections.get("建议动作"), |
| | | sections.get("处置建议"), |
| | | "请结合现场流程继续确认。" |
| | | ); |
| | | String riskSummary = firstNonBlank( |
| | | sections.get("风险评估"), |
| | | sections.get("潜在风险"), |
| | | "如未及时处理,可能继续影响当前业务运行。" |
| | | ); |
| | | record.setReportTitle(buildTitle(record)); |
| | | record.setExecutiveSummary(executiveSummary); |
| | | record.setEvidenceSummary(evidenceSummary); |
| | | record.setActionSummary(actionSummary); |
| | | record.setRiskSummary(riskSummary); |
| | | record.setReportMarkdown(buildMarkdown(record, executiveSummary, evidenceSummary, actionSummary, riskSummary, conclusion)); |
| | | } |
| | | |
| | | /** |
| | | * 尝试从原始结论文本中提取“问题概述/关键证据/建议动作/风险评估”等章节。 |
| | | */ |
| | | private Map<String, String> extractSections(String conclusion) { |
| | | Map<String, String> sections = new LinkedHashMap<>(); |
| | | if (conclusion == null || conclusion.trim().isEmpty()) { |
| | | return sections; |
| | | } |
| | | String[] lines = conclusion.replace("\r", "").split("\n"); |
| | | String currentTitle = "问题概述"; |
| | | StringBuilder currentBody = new StringBuilder(); |
| | | for (String rawLine : lines) { |
| | | String line = rawLine == null ? "" : rawLine.trim(); |
| | | String title = normalizeTitle(line); |
| | | if (title != null) { |
| | | saveSection(sections, currentTitle, currentBody); |
| | | currentTitle = title; |
| | | currentBody = new StringBuilder(); |
| | | String remainder = line.substring(line.indexOf(title) + title.length()).trim(); |
| | | if (remainder.startsWith(":") || remainder.startsWith(":")) { |
| | | remainder = remainder.substring(1).trim(); |
| | | } |
| | | if (!remainder.isEmpty()) { |
| | | currentBody.append(remainder); |
| | | } |
| | | continue; |
| | | } |
| | | if (currentBody.length() > 0) { |
| | | currentBody.append("\n"); |
| | | } |
| | | currentBody.append(rawLine == null ? "" : rawLine); |
| | | } |
| | | saveSection(sections, currentTitle, currentBody); |
| | | return sections; |
| | | } |
| | | |
| | | /** |
| | | * 将当前章节内容写入 section map。 |
| | | */ |
| | | private void saveSection(Map<String, String> sections, String title, StringBuilder body) { |
| | | String content = body == null ? "" : body.toString().trim(); |
| | | if (content.isEmpty()) { |
| | | return; |
| | | } |
| | | sections.put(title, content); |
| | | } |
| | | |
| | | /** |
| | | * 判断一行文本是否可视为报告章节标题。 |
| | | */ |
| | | private String normalizeTitle(String line) { |
| | | if (line == null) { |
| | | return null; |
| | | } |
| | | String normalized = line.replace("#", "").replace(":", "").replace(":", "").trim(); |
| | | List<String> titles = new ArrayList<>(); |
| | | titles.add("问题概述"); |
| | | titles.add("执行摘要"); |
| | | titles.add("关键证据"); |
| | | titles.add("证据摘要"); |
| | | titles.add("可能原因"); |
| | | titles.add("建议动作"); |
| | | titles.add("处置建议"); |
| | | titles.add("风险评估"); |
| | | titles.add("潜在风险"); |
| | | for (String title : titles) { |
| | | if (normalized.startsWith(title)) { |
| | | return title; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 构造诊断报告标题。 |
| | | */ |
| | | private String buildTitle(AiDiagnosisRecord record) { |
| | | String diagnosisNo = record.getDiagnosisNo() == null ? "-" : record.getDiagnosisNo(); |
| | | return "WMS诊断报告-" + diagnosisNo; |
| | | } |
| | | |
| | | /** |
| | | * 生成最终报告 Markdown。 |
| | | */ |
| | | private String buildMarkdown(AiDiagnosisRecord record, String executiveSummary, String evidenceSummary, |
| | | String actionSummary, String riskSummary, String conclusion) { |
| | | List<String> parts = new ArrayList<>(); |
| | | parts.add("# " + buildTitle(record)); |
| | | parts.add(""); |
| | | parts.add("## 问题概述"); |
| | | parts.add(safeText(executiveSummary)); |
| | | parts.add(""); |
| | | parts.add("## 关键证据"); |
| | | parts.add(safeText(evidenceSummary)); |
| | | parts.add(""); |
| | | parts.add("## 建议动作"); |
| | | parts.add(safeText(actionSummary)); |
| | | parts.add(""); |
| | | parts.add("## 风险评估"); |
| | | parts.add(safeText(riskSummary)); |
| | | if (conclusion != null && !conclusion.trim().isEmpty()) { |
| | | parts.add(""); |
| | | parts.add("## 原始结论"); |
| | | parts.add(conclusion.trim()); |
| | | } |
| | | return String.join("\n", parts); |
| | | } |
| | | |
| | | /** |
| | | * 返回第一个非空文本。 |
| | | */ |
| | | private String firstNonBlank(String... values) { |
| | | if (values == null) { |
| | | return ""; |
| | | } |
| | | for (String value : values) { |
| | | if (value != null && !value.trim().isEmpty()) { |
| | | return value.trim(); |
| | | } |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** |
| | | * 对展示文本做空值兜底。 |
| | | */ |
| | | private String safeText(String value) { |
| | | return value == null || value.trim().isEmpty() ? "暂无内容" : value.trim(); |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import com.vincent.rsf.server.system.service.AiCallLogService; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisRecordService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.Date; |
| | | |
| | | @Service |
| | | public class AiDiagnosisRuntimeService { |
| | | |
| | | @Resource |
| | | private AiDiagnosisRecordService aiDiagnosisRecordService; |
| | | @Resource |
| | | private AiCallLogService aiCallLogService; |
| | | @Resource |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | @Resource |
| | | private AiDiagnosisReportService aiDiagnosisReportService; |
| | | |
| | | /** |
| | | * 在诊断开始时创建一条诊断记录,并标记为“进行中”。 |
| | | */ |
| | | public AiDiagnosisRecord startDiagnosis(Long tenantId, Long userId, String sessionId, String sceneCode, String question) { |
| | | AiDiagnosisRecord record = new AiDiagnosisRecord() |
| | | .setDiagnosisNo(String.valueOf(snowflakeIdWorker.nextId()).substring(3)) |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setSessionId(sessionId) |
| | | .setSceneCode(sceneCode) |
| | | .setQuestion(question) |
| | | .setResult(2) |
| | | .setStatus(1) |
| | | .setDeleted(0) |
| | | .setStartTime(new Date()) |
| | | .setCreateTime(new Date()) |
| | | .setUpdateTime(new Date()); |
| | | aiDiagnosisRecordService.save(record); |
| | | return record; |
| | | } |
| | | |
| | | /** |
| | | * 将诊断记录标记为成功,并补齐报告内容、模型信息和耗时。 |
| | | */ |
| | | public void finishDiagnosisSuccess(AiDiagnosisRecord record, String conclusion, String modelCode, String toolSummary) { |
| | | if (record == null) { |
| | | return; |
| | | } |
| | | Date now = new Date(); |
| | | record.setConclusion(conclusion); |
| | | aiDiagnosisReportService.fillReport(record, conclusion, toolSummary); |
| | | record.setModelCode(modelCode); |
| | | record.setToolSummary(toolSummary); |
| | | record.setResult(1); |
| | | record.setEndTime(now); |
| | | record.setSpendTime(now.getTime() - record.getStartTime().getTime()); |
| | | record.setUpdateTime(now); |
| | | aiDiagnosisRecordService.updateById(record); |
| | | } |
| | | |
| | | /** |
| | | * 将诊断记录标记为失败,并保留已有结论、错误信息和工具轨迹。 |
| | | */ |
| | | public void finishDiagnosisFailure(AiDiagnosisRecord record, String conclusion, String err, String toolSummary) { |
| | | if (record == null) { |
| | | return; |
| | | } |
| | | Date now = new Date(); |
| | | record.setConclusion(conclusion); |
| | | aiDiagnosisReportService.fillReport(record, conclusion, toolSummary); |
| | | record.setToolSummary(toolSummary); |
| | | record.setErr(err); |
| | | record.setResult(0); |
| | | record.setEndTime(now); |
| | | record.setSpendTime(record.getStartTime() == null ? null : now.getTime() - record.getStartTime().getTime()); |
| | | record.setUpdateTime(now); |
| | | aiDiagnosisRecordService.updateById(record); |
| | | } |
| | | |
| | | /** |
| | | * 保存一次模型调用日志,供审计、统计和故障定位使用。 |
| | | */ |
| | | public AiCallLog saveCallLog(Long tenantId, Long userId, String sessionId, Long diagnosisId, String routeCode, |
| | | String modelCode, Integer attemptNo, Date requestTime, Date responseTime, |
| | | Integer result, String err) { |
| | | AiCallLog log = new AiCallLog() |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setSessionId(sessionId) |
| | | .setDiagnosisId(diagnosisId) |
| | | .setRouteCode(routeCode) |
| | | .setModelCode(modelCode) |
| | | .setAttemptNo(attemptNo) |
| | | .setRequestTime(requestTime) |
| | | .setResponseTime(responseTime) |
| | | .setSpendTime(requestTime == null || responseTime == null ? null : responseTime.getTime() - requestTime.getTime()) |
| | | .setResult(result) |
| | | .setErr(err) |
| | | .setStatus(1) |
| | | .setDeleted(0) |
| | | .setCreateTime(responseTime == null ? new Date() : responseTime); |
| | | aiCallLogService.save(log); |
| | | return log; |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.diagnosis; |
| | | |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpPayloadMapper; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpRegistryService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosticToolConfigService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Collection; |
| | | import java.util.Comparator; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiDiagnosticToolService { |
| | | |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | @Resource |
| | | private AiDiagnosticToolConfigService aiDiagnosticToolConfigService; |
| | | @Resource |
| | | private AiMcpRegistryService aiMcpRegistryService; |
| | | @Resource |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | /** |
| | | * 收集当前租户的所有内置诊断工具结果。 |
| | | * 这是诊断场景的兜底路径,也用于在模型规划失败时强制聚合一轮内部数据。 |
| | | */ |
| | | public List<AiDiagnosticToolResult> collect(AiPromptContext context) { |
| | | List<AiDiagnosticToolResult> output = new ArrayList<>(); |
| | | for (AiMcpToolDescriptor descriptor : resolveInternalTools(context)) { |
| | | try { |
| | | AiDiagnosticToolResult result = aiMcpRegistryService.executeTool(context.getTenantId(), descriptor, context); |
| | | if (result != null && result.getSummaryText() != null && !result.getSummaryText().trim().isEmpty()) { |
| | | output.add(result); |
| | | } |
| | | } catch (Exception e) { |
| | | output.add(new AiDiagnosticToolResult() |
| | | .setToolCode(descriptor.getToolCode()) |
| | | .setMountCode(descriptor.getMountCode() == null ? AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE : descriptor.getMountCode()) |
| | | .setMcpToolName(descriptor.getMcpToolName()) |
| | | .setToolName(descriptor.getToolName()) |
| | | .setSeverity("WARN") |
| | | .setSummaryText("工具执行失败:" + e.getMessage())); |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 将工具结果列表转换为可直接拼进系统 Prompt 的摘要文本。 |
| | | */ |
| | | public String buildPrompt(List<AiDiagnosticToolResult> results) { |
| | | return buildPrompt(null, null, results); |
| | | } |
| | | |
| | | /** |
| | | * 根据当前场景补充工具级附加规则,并生成最终工具摘要 Prompt。 |
| | | */ |
| | | public String buildPrompt(Long tenantId, String sceneCode, List<AiDiagnosticToolResult> results) { |
| | | if (results == null || results.isEmpty()) { |
| | | return ""; |
| | | } |
| | | Map<String, AiDiagnosticToolConfig> configMap = buildConfigMap(tenantId, sceneCode); |
| | | List<String> parts = new ArrayList<>(); |
| | | parts.add("以下是诊断工具返回的实时摘要,请优先依据这些结果判断:"); |
| | | for (AiDiagnosticToolResult item : results) { |
| | | AiDiagnosticToolConfig config = configMap.get(item.getToolCode()); |
| | | if (config != null && config.getToolPrompt() != null && !config.getToolPrompt().trim().isEmpty()) { |
| | | parts.add("[" + safeValue(item.getToolName()) + "][指令] " + safeValue(config.getToolPrompt())); |
| | | } |
| | | parts.add("[" + safeValue(item.getToolName()) + "][" + safeValue(item.getSeverity()) + "] " + safeValue(item.getSummaryText())); |
| | | } |
| | | return String.join("\n", parts); |
| | | } |
| | | |
| | | /** |
| | | * 将工具结果序列化成 JSON,便于诊断记录与工具轨迹落库。 |
| | | */ |
| | | public String serializeResults(List<AiDiagnosticToolResult> results) { |
| | | if (results == null || results.isEmpty()) { |
| | | return "[]"; |
| | | } |
| | | try { |
| | | return objectMapper.writeValueAsString(results); |
| | | } catch (Exception e) { |
| | | return "[]"; |
| | | } |
| | | } |
| | | |
| | | private String safeValue(String value) { |
| | | return value == null ? "" : value; |
| | | } |
| | | |
| | | /** |
| | | * 按当前场景解析可以参与执行的内部工具列表,并按优先级排序。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> resolveInternalTools(AiPromptContext context) { |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | if (context == null || context.getTenantId() == null) { |
| | | return output; |
| | | } |
| | | for (AiMcpToolDescriptor descriptor : aiMcpRegistryService.listInternalTools(context.getTenantId())) { |
| | | if (descriptor == null || !Integer.valueOf(1).equals(descriptor.getEnabledFlag())) { |
| | | continue; |
| | | } |
| | | if (context.getSceneCode() != null |
| | | && descriptor.getSceneCode() != null |
| | | && !descriptor.getSceneCode().trim().isEmpty() |
| | | && !context.getSceneCode().equals(descriptor.getSceneCode())) { |
| | | continue; |
| | | } |
| | | output.add(descriptor); |
| | | } |
| | | output.sort(Comparator.comparing( |
| | | item -> item.getPriority() == null ? Integer.MAX_VALUE : item.getPriority() |
| | | )); |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 为当前场景构建工具配置索引。 |
| | | * 当工具用途为“聊天与诊断都可用”时,会允许在多个场景下复用同一条配置。 |
| | | */ |
| | | private Map<String, AiDiagnosticToolConfig> buildConfigMap(Long tenantId, String sceneCode) { |
| | | Map<String, AiDiagnosticToolConfig> map = new LinkedHashMap<>(); |
| | | if (tenantId == null || sceneCode == null || sceneCode.trim().isEmpty()) { |
| | | return map; |
| | | } |
| | | List<AiDiagnosticToolConfig> configs = aiDiagnosticToolConfigService.listTenantConfigs(tenantId); |
| | | for (AiDiagnosticToolConfig config : emptyList(configs)) { |
| | | if (!Integer.valueOf(1).equals(config.getStatus())) { |
| | | continue; |
| | | } |
| | | String usageScope = aiMcpPayloadMapper.resolveUsageScope(config.getSceneCode(), config.getEnabledFlag(), config.getUsageScope()); |
| | | if (AiMcpConstants.USAGE_SCOPE_DISABLED.equals(usageScope)) { |
| | | continue; |
| | | } |
| | | if (AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY.equals(usageScope) |
| | | && !sceneCode.equals(config.getSceneCode())) { |
| | | continue; |
| | | } |
| | | AiDiagnosticToolConfig existed = map.get(config.getToolCode()); |
| | | if (existed == null || preferConfig(config, existed, sceneCode)) { |
| | | map.put(config.getToolCode(), config); |
| | | } |
| | | } |
| | | return map; |
| | | } |
| | | |
| | | /** |
| | | * 冲突时优先选择与当前场景精确匹配的配置,其次比较优先级。 |
| | | */ |
| | | private boolean preferConfig(AiDiagnosticToolConfig candidate, AiDiagnosticToolConfig current, String sceneCode) { |
| | | boolean candidateExact = sceneCode.equals(candidate.getSceneCode()); |
| | | boolean currentExact = sceneCode.equals(current.getSceneCode()); |
| | | if (candidateExact != currentExact) { |
| | | return candidateExact; |
| | | } |
| | | Integer candidatePriority = candidate.getPriority() == null ? Integer.MAX_VALUE : candidate.getPriority(); |
| | | Integer currentPriority = current.getPriority() == null ? Integer.MAX_VALUE : current.getPriority(); |
| | | return candidatePriority < currentPriority; |
| | | } |
| | | |
| | | private <T> Collection<T> emptyList(List<T> list) { |
| | | return list == null ? new ArrayList<>() : list; |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| | |
| | | package com.vincent.rsf.server.ai.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.mapper.AiChatMessageMapper; |
| | | import com.vincent.rsf.server.ai.mapper.AiChatSessionMapper; |
| | | import com.vincent.rsf.server.ai.model.AiChatMessage; |
| | | import com.vincent.rsf.server.ai.model.AiChatSession; |
| | | import com.vincent.rsf.server.ai.service.AiRuntimeConfigService; |
| | | import com.vincent.rsf.server.ai.service.AiSessionService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.PostConstruct; |
| | | import javax.annotation.Resource; |
| | | import javax.sql.DataSource; |
| | | import java.sql.Connection; |
| | | import java.sql.ResultSet; |
| | | import java.util.ArrayList; |
| | | import java.util.Comparator; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import java.util.Objects; |
| | | import java.util.UUID; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | import java.util.concurrent.ConcurrentMap; |
| | |
| | | private static final ConcurrentMap<String, List<AiChatSession>> LOCAL_SESSION_CACHE = new ConcurrentHashMap<>(); |
| | | private static final ConcurrentMap<String, List<AiChatMessage>> LOCAL_MESSAGE_CACHE = new ConcurrentHashMap<>(); |
| | | private static final ConcurrentMap<String, String> LOCAL_STOP_CACHE = new ConcurrentHashMap<>(); |
| | | private static final String SESSION_TABLE_NAME = "sys_ai_chat_session"; |
| | | private static final String MESSAGE_TABLE_NAME = "sys_ai_chat_message"; |
| | | |
| | | @Resource |
| | | private AiRuntimeConfigService aiRuntimeConfigService; |
| | | @Resource |
| | | private AiChatSessionMapper aiChatSessionMapper; |
| | | @Resource |
| | | private AiChatMessageMapper aiChatMessageMapper; |
| | | @Resource |
| | | private DataSource dataSource; |
| | | |
| | | private volatile boolean storageReady; |
| | | |
| | | @PostConstruct |
| | | /** |
| | | * 启动时探测聊天存储表是否已创建。 |
| | | * 如果表存在则走数据库持久化,否则回退到本地内存缓存,保证开发和缺表场景可继续运行。 |
| | | */ |
| | | public void initStorageMode() { |
| | | storageReady = detectStorageTables(); |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 读取用户会话列表。 |
| | | * 数据库存储模式直接查表,内存模式则从本地缓存取出并按最近更新时间排序。 |
| | | */ |
| | | public synchronized List<AiChatSession> listSessions(Long tenantId, Long userId) { |
| | | if (useDatabaseStorage()) { |
| | | return aiChatSessionMapper.selectList(new LambdaQueryWrapper<AiChatSession>() |
| | | .eq(AiChatSession::getTenantId, tenantId) |
| | | .eq(AiChatSession::getUserId, userId) |
| | | .orderByDesc(AiChatSession::getUpdateTime, AiChatSession::getCreateTime)); |
| | | } |
| | | List<AiChatSession> sessions = getSessions(tenantId, userId); |
| | | sessions.sort(Comparator.comparing(AiChatSession::getUpdateTime, Comparator.nullsLast(Date::compareTo)).reversed()); |
| | | return sessions; |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 创建新会话,并初始化标题、模型和时间戳。 |
| | | */ |
| | | public synchronized AiChatSession createSession(Long tenantId, Long userId, String title, String modelCode) { |
| | | List<AiChatSession> sessions = getSessions(tenantId, userId); |
| | | List<AiChatSession> sessions = useDatabaseStorage() ? listSessions(tenantId, userId) : getSessions(tenantId, userId); |
| | | Date now = new Date(); |
| | | AiChatSession session = new AiChatSession() |
| | | .setId(UUID.randomUUID().toString().replace("-", "")) |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setTitle(resolveTitle(title, sessions.size() + 1)) |
| | | .setModelCode(resolveModelCode(modelCode)) |
| | | .setCreateTime(now) |
| | | .setUpdateTime(now) |
| | | .setLastMessageAt(now); |
| | | .setLastMessageAt(now) |
| | | .setStatus(1) |
| | | .setDeleted(0); |
| | | if (useDatabaseStorage()) { |
| | | aiChatSessionMapper.insert(session); |
| | | return session; |
| | | } |
| | | sessions.add(0, session); |
| | | saveSessions(tenantId, userId, sessions); |
| | | saveMessages(session.getId(), new ArrayList<>()); |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 确保会话存在;如果会话已存在但模型发生变化,会同步更新会话记录。 |
| | | */ |
| | | public synchronized AiChatSession ensureSession(Long tenantId, Long userId, String sessionId, String modelCode) { |
| | | AiChatSession session = getSession(tenantId, userId, sessionId); |
| | | if (session == null) { |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 安全读取会话,并校验租户与用户归属。 |
| | | */ |
| | | public synchronized AiChatSession getSession(Long tenantId, Long userId, String sessionId) { |
| | | if (sessionId == null || sessionId.trim().isEmpty()) { |
| | | return null; |
| | | } |
| | | if (useDatabaseStorage()) { |
| | | AiChatSession session = aiChatSessionMapper.selectById(sessionId); |
| | | if (session == null) { |
| | | return null; |
| | | } |
| | | if (!Objects.equals(tenantId, session.getTenantId()) || !Objects.equals(userId, session.getUserId())) { |
| | | return null; |
| | | } |
| | | return session; |
| | | } |
| | | for (AiChatSession session : getSessions(tenantId, userId)) { |
| | | if (sessionId.equals(session.getId())) { |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 更新会话标题。 |
| | | */ |
| | | public synchronized AiChatSession renameSession(Long tenantId, Long userId, String sessionId, String title) { |
| | | AiChatSession session = getSession(tenantId, userId, sessionId); |
| | | if (session == null) { |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 删除会话及其关联消息,同时清理停止标记缓存。 |
| | | */ |
| | | public synchronized void removeSession(Long tenantId, Long userId, String sessionId) { |
| | | if (useDatabaseStorage()) { |
| | | AiChatSession session = getSession(tenantId, userId, sessionId); |
| | | if (session != null) { |
| | | aiChatMessageMapper.delete(new LambdaQueryWrapper<AiChatMessage>() |
| | | .eq(AiChatMessage::getSessionId, sessionId)); |
| | | aiChatSessionMapper.deleteById(sessionId); |
| | | } |
| | | LOCAL_STOP_CACHE.remove(sessionId); |
| | | return; |
| | | } |
| | | List<AiChatSession> sessions = getSessions(tenantId, userId); |
| | | sessions.removeIf(session -> sessionId.equals(session.getId())); |
| | | saveSessions(tenantId, userId, sessions); |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 查询会话的完整消息历史。 |
| | | */ |
| | | public synchronized List<AiChatMessage> listMessages(Long tenantId, Long userId, String sessionId) { |
| | | AiChatSession session = getSession(tenantId, userId, sessionId); |
| | | if (session == null) { |
| | | return new ArrayList<>(); |
| | | } |
| | | if (useDatabaseStorage()) { |
| | | return aiChatMessageMapper.selectList(new LambdaQueryWrapper<AiChatMessage>() |
| | | .eq(AiChatMessage::getSessionId, sessionId) |
| | | .orderByAsc(AiChatMessage::getCreateTime, AiChatMessage::getId)); |
| | | } |
| | | return getMessages(sessionId); |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 截取最近若干条消息作为模型上下文,避免每次都把完整历史发送给模型。 |
| | | */ |
| | | public synchronized List<AiChatMessage> listContextMessages(Long tenantId, Long userId, String sessionId, int maxCount) { |
| | | List<AiChatMessage> messages = listMessages(tenantId, userId, sessionId); |
| | | if (messages.size() <= maxCount) { |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 追加一条消息,并同步刷新会话摘要、活跃时间和默认标题。 |
| | | */ |
| | | public synchronized AiChatMessage appendMessage(Long tenantId, Long userId, String sessionId, String role, String content, String modelCode) { |
| | | AiChatSession session = getSession(tenantId, userId, sessionId); |
| | | if (session == null) { |
| | |
| | | List<AiChatMessage> messages = getMessages(sessionId); |
| | | AiChatMessage message = new AiChatMessage() |
| | | .setId(UUID.randomUUID().toString().replace("-", "")) |
| | | .setTenantId(tenantId) |
| | | .setUserId(userId) |
| | | .setSessionId(sessionId) |
| | | .setRole(role) |
| | | .setContent(content) |
| | | .setModelCode(resolveModelCode(modelCode)) |
| | | .setCreateTime(new Date()); |
| | | messages.add(message); |
| | | saveMessages(sessionId, messages); |
| | | .setCreateTime(new Date()) |
| | | .setStatus(1) |
| | | .setDeleted(0); |
| | | if (useDatabaseStorage()) { |
| | | aiChatMessageMapper.insert(message); |
| | | } else { |
| | | messages.add(message); |
| | | saveMessages(sessionId, messages); |
| | | } |
| | | session.setLastMessage(buildPreview(content)); |
| | | session.setLastMessageAt(message.getCreateTime()); |
| | | session.setUpdateTime(message.getCreateTime()); |
| | |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 清除停止生成标记。 |
| | | */ |
| | | public void clearStopFlag(String sessionId) { |
| | | LOCAL_STOP_CACHE.remove(sessionId); |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 标记会话需要停止生成。 |
| | | */ |
| | | public void requestStop(String sessionId) { |
| | | LOCAL_STOP_CACHE.put(sessionId, "1"); |
| | | } |
| | | |
| | | @Override |
| | | /** |
| | | * 读取停止生成标记。 |
| | | */ |
| | | public boolean isStopRequested(String sessionId) { |
| | | String stopFlag = LOCAL_STOP_CACHE.get(sessionId); |
| | | return "1".equals(stopFlag); |
| | | } |
| | | |
| | | /** |
| | | * 从内存缓存中读取当前用户的会话列表。 |
| | | */ |
| | | private List<AiChatSession> getSessions(Long tenantId, Long userId) { |
| | | String ownerKey = buildOwnerKey(tenantId, userId); |
| | | List<AiChatSession> sessions = LOCAL_SESSION_CACHE.get(ownerKey); |
| | | return sessions == null ? new ArrayList<>() : new ArrayList<>(sessions); |
| | | } |
| | | |
| | | /** |
| | | * 将会话列表写回本地缓存。 |
| | | */ |
| | | private void saveSessions(Long tenantId, Long userId, List<AiChatSession> sessions) { |
| | | String ownerKey = buildOwnerKey(tenantId, userId); |
| | | List<AiChatSession> cachedSessions = new ArrayList<>(sessions); |
| | | LOCAL_SESSION_CACHE.put(ownerKey, cachedSessions); |
| | | } |
| | | |
| | | /** |
| | | * 从内存缓存中读取指定会话的消息列表。 |
| | | */ |
| | | private List<AiChatMessage> getMessages(String sessionId) { |
| | | List<AiChatMessage> messages = LOCAL_MESSAGE_CACHE.get(sessionId); |
| | | return messages == null ? new ArrayList<>() : new ArrayList<>(messages); |
| | | } |
| | | |
| | | /** |
| | | * 将消息列表写回本地缓存。 |
| | | */ |
| | | private void saveMessages(String sessionId, List<AiChatMessage> messages) { |
| | | List<AiChatMessage> cachedMessages = new ArrayList<>(messages); |
| | | LOCAL_MESSAGE_CACHE.put(sessionId, cachedMessages); |
| | | } |
| | | |
| | | /** |
| | | * 按存储模式刷新单个会话记录。 |
| | | */ |
| | | private void refreshSession(Long tenantId, Long userId, AiChatSession target) { |
| | | if (useDatabaseStorage()) { |
| | | aiChatSessionMapper.updateById(target); |
| | | return; |
| | | } |
| | | List<AiChatSession> sessions = getSessions(tenantId, userId); |
| | | for (int i = 0; i < sessions.size(); i++) { |
| | | if (target.getId().equals(sessions.get(i).getId())) { |
| | |
| | | saveSessions(tenantId, userId, sessions); |
| | | } |
| | | |
| | | /** |
| | | * 组装租户与用户维度的本地缓存 key。 |
| | | */ |
| | | private String buildOwnerKey(Long tenantId, Long userId) { |
| | | return String.valueOf(tenantId) + ":" + String.valueOf(userId); |
| | | } |
| | | |
| | | /** |
| | | * 解析本次消息使用的模型编码;为空时回退到系统默认模型。 |
| | | */ |
| | | private String resolveModelCode(String modelCode) { |
| | | return modelCode == null || modelCode.trim().isEmpty() ? aiRuntimeConfigService.resolveDefaultModelCode() : modelCode; |
| | | } |
| | | |
| | | /** |
| | | * 生成会话标题,未显式传标题时使用“新对话 N”。 |
| | | */ |
| | | private String resolveTitle(String title, int index) { |
| | | if (title == null || title.trim().isEmpty()) { |
| | | return "新对话 " + index; |
| | |
| | | return buildPreview(title); |
| | | } |
| | | |
| | | /** |
| | | * 将用户输入压缩成适合作为标题或最后消息预览的短文本。 |
| | | */ |
| | | private String buildPreview(String content) { |
| | | if (content == null || content.trim().isEmpty()) { |
| | | return "新对话"; |
| | |
| | | return normalized.length() > 24 ? normalized.substring(0, 24) : normalized; |
| | | } |
| | | |
| | | /** |
| | | * 判断当前是否可以使用数据库持久化聊天数据。 |
| | | */ |
| | | private boolean useDatabaseStorage() { |
| | | return storageReady || (storageReady = detectStorageTables()); |
| | | } |
| | | |
| | | /** |
| | | * 检查聊天存储所需表是否已经存在。 |
| | | */ |
| | | private boolean detectStorageTables() { |
| | | try (Connection connection = dataSource.getConnection()) { |
| | | return tableExists(connection, SESSION_TABLE_NAME) && tableExists(connection, MESSAGE_TABLE_NAME); |
| | | } catch (Exception ignore) { |
| | | return false; |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断指定表名是否在当前数据库中存在。 |
| | | */ |
| | | private boolean tableExists(Connection connection, String tableName) throws Exception { |
| | | if (tableName == null || tableName.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | String[] candidates = new String[]{tableName, tableName.toUpperCase(), tableName.toLowerCase()}; |
| | | for (String candidate : candidates) { |
| | | try (ResultSet resultSet = connection.getMetaData().getTables(connection.getCatalog(), null, candidate, null)) { |
| | | if (resultSet.next()) { |
| | | return true; |
| | | } |
| | | } |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.mcp; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.BufferedReader; |
| | | import java.io.InputStream; |
| | | import java.io.InputStreamReader; |
| | | import java.io.OutputStream; |
| | | import java.net.HttpURLConnection; |
| | | import java.net.URL; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Component |
| | | public class AiMcpHttpClient { |
| | | |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | @Resource |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | /** |
| | | * 通过 Streamable HTTP 协议加载远程 MCP 工具目录。 |
| | | */ |
| | | public List<AiMcpToolDescriptor> listTools(AiMcpMount mount) { |
| | | initialize(mount); |
| | | JsonNode result = sendRequest(mount, "tools/list", new LinkedHashMap<String, Object>(), true); |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | JsonNode toolsNode = result.path("tools"); |
| | | if (!toolsNode.isArray()) { |
| | | return output; |
| | | } |
| | | for (JsonNode item : toolsNode) { |
| | | AiMcpToolDescriptor descriptor = aiMcpPayloadMapper.toExternalToolDescriptor(mount, item); |
| | | if (descriptor != null) { |
| | | output.add(descriptor); |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 通过 Streamable HTTP 协议执行一次远程工具调用。 |
| | | */ |
| | | public AiDiagnosticToolResult callTool(AiMcpMount mount, String toolName, Map<String, Object> arguments) { |
| | | initialize(mount); |
| | | Map<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("name", toolName); |
| | | params.put("arguments", arguments == null ? new LinkedHashMap<String, Object>() : arguments); |
| | | JsonNode result = sendRequest(mount, "tools/call", params, true); |
| | | return aiMcpPayloadMapper.toExternalToolResult(mount, toolName, result); |
| | | } |
| | | |
| | | /** |
| | | * 执行 MCP initialize + notifications/initialized 握手。 |
| | | */ |
| | | private void initialize(AiMcpMount mount) { |
| | | Map<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("protocolVersion", AiMcpConstants.PROTOCOL_VERSION); |
| | | params.put("capabilities", new LinkedHashMap<String, Object>()); |
| | | Map<String, Object> clientInfo = new LinkedHashMap<>(); |
| | | clientInfo.put("name", "rsf-server"); |
| | | clientInfo.put("version", AiMcpConstants.SERVER_VERSION); |
| | | params.put("clientInfo", clientInfo); |
| | | sendRequest(mount, "initialize", params, true); |
| | | sendRequest(mount, "notifications/initialized", new LinkedHashMap<String, Object>(), false); |
| | | } |
| | | |
| | | /** |
| | | * 发送一条 JSON-RPC 请求到远程 MCP HTTP 端点。 |
| | | */ |
| | | private JsonNode sendRequest(AiMcpMount mount, String method, Object params, boolean expectResponse) { |
| | | HttpURLConnection connection = null; |
| | | try { |
| | | connection = (HttpURLConnection) new URL(mount.getUrl()).openConnection(); |
| | | connection.setRequestMethod("POST"); |
| | | connection.setDoOutput(true); |
| | | connection.setConnectTimeout(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | connection.setReadTimeout(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | connection.setRequestProperty("Content-Type", "application/json"); |
| | | connection.setRequestProperty("Accept", "application/json"); |
| | | applyAuthHeaders(connection, mount); |
| | | |
| | | Map<String, Object> body = new LinkedHashMap<>(); |
| | | body.put("jsonrpc", "2.0"); |
| | | if (expectResponse) { |
| | | body.put("id", String.valueOf(System.currentTimeMillis())); |
| | | } |
| | | body.put("method", method); |
| | | body.put("params", params == null ? new LinkedHashMap<String, Object>() : params); |
| | | |
| | | try (OutputStream outputStream = connection.getOutputStream()) { |
| | | outputStream.write(objectMapper.writeValueAsBytes(body)); |
| | | outputStream.flush(); |
| | | } |
| | | |
| | | int statusCode = connection.getResponseCode(); |
| | | InputStream inputStream = statusCode >= 400 ? connection.getErrorStream() : connection.getInputStream(); |
| | | if (!expectResponse) { |
| | | return null; |
| | | } |
| | | if (inputStream == null) { |
| | | throw new IllegalStateException("MCP服务返回空响应"); |
| | | } |
| | | String payload = readPayload(inputStream); |
| | | JsonNode root = objectMapper.readTree(payload); |
| | | if (root.has("error") && !root.get("error").isNull()) { |
| | | throw new IllegalStateException(root.path("error").path("message").asText("MCP调用失败")); |
| | | } |
| | | return root.path("result"); |
| | | } catch (Exception e) { |
| | | throw new IllegalStateException("MCP请求失败: " + e.getMessage(), e); |
| | | } finally { |
| | | if (connection != null) { |
| | | connection.disconnect(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 按挂载配置写入鉴权请求头。 |
| | | */ |
| | | private void applyAuthHeaders(HttpURLConnection connection, AiMcpMount mount) { |
| | | if (mount == null || mount.getAuthType() == null || mount.getAuthValue() == null || mount.getAuthValue().trim().isEmpty()) { |
| | | return; |
| | | } |
| | | String authType = mount.getAuthType().trim().toUpperCase(); |
| | | if (AiMcpConstants.AUTH_TYPE_BEARER.equals(authType)) { |
| | | connection.setRequestProperty("Authorization", "Bearer " + mount.getAuthValue().trim()); |
| | | } else if (AiMcpConstants.AUTH_TYPE_API_KEY.equals(authType)) { |
| | | connection.setRequestProperty("X-API-Key", mount.getAuthValue().trim()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 读取 HTTP 响应体全文。 |
| | | */ |
| | | private String readPayload(InputStream inputStream) throws Exception { |
| | | StringBuilder output = new StringBuilder(); |
| | | try (BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8))) { |
| | | String line; |
| | | while ((line = reader.readLine()) != null) { |
| | | output.append(line); |
| | | } |
| | | } |
| | | return output.toString(); |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.mcp; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Iterator; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Component |
| | | public class AiMcpPayloadMapper { |
| | | |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | |
| | | /** |
| | | * 将远程 MCP tools/list 返回的单个工具定义转换成本系统统一的工具描述符。 |
| | | */ |
| | | public AiMcpToolDescriptor toExternalToolDescriptor(AiMcpMount mount, JsonNode item) { |
| | | String remoteName = item.path("name").asText(""); |
| | | if (remoteName.trim().isEmpty()) { |
| | | return null; |
| | | } |
| | | return new AiMcpToolDescriptor() |
| | | .setMountCode(mount.getMountCode()) |
| | | .setMountName(mount.getName()) |
| | | .setToolCode(remoteName) |
| | | .setMcpToolName(buildMcpToolName(mount.getMountCode(), remoteName)) |
| | | .setToolName(item.path("title").asText(remoteName)) |
| | | .setSceneCode(resolveSceneCode(mount.getUsageScope())) |
| | | .setDescription(item.path("description").asText("")) |
| | | .setEnabledFlag(mount.getEnabledFlag()) |
| | | .setPriority(999) |
| | | .setToolPrompt(item.path("description").asText("")) |
| | | .setUsageScope(normalizeUsageScope(mount.getUsageScope())) |
| | | .setTransportType(mount.getTransportType()) |
| | | .setInputSchema(readInputSchema(item.path("inputSchema"), true)); |
| | | } |
| | | |
| | | /** |
| | | * 将远程 MCP tools/call 返回的结果转换为系统内部统一的工具结果模型。 |
| | | */ |
| | | public AiDiagnosticToolResult toExternalToolResult(AiMcpMount mount, String toolName, JsonNode result) { |
| | | boolean isError = result.path("isError").asBoolean(false); |
| | | Map<String, Object> rawMeta = new LinkedHashMap<>(); |
| | | if (result.has("structuredContent") && !result.get("structuredContent").isNull()) { |
| | | rawMeta.put("structuredContent", objectMapper.convertValue(result.get("structuredContent"), Map.class)); |
| | | } |
| | | if (result.has("content") && !result.get("content").isNull()) { |
| | | rawMeta.put("content", objectMapper.convertValue(result.get("content"), Object.class)); |
| | | } |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(toolName) |
| | | .setMountCode(mount.getMountCode()) |
| | | .setMcpToolName(buildMcpToolName(mount.getMountCode(), toolName)) |
| | | .setToolName(toolName) |
| | | .setSeverity(isError ? "WARN" : "INFO") |
| | | .setSummaryText(extractContentText(result)) |
| | | .setRawMeta(rawMeta); |
| | | } |
| | | |
| | | /** |
| | | * 将本地工具描述符转换为 MCP 协议 tools/list 所需的数据结构。 |
| | | */ |
| | | public Map<String, Object> toProtocolTool(AiMcpToolDescriptor descriptor) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("name", descriptor.getMcpToolName()); |
| | | item.put("title", descriptor.getToolName()); |
| | | item.put("description", descriptor.getDescription() == null || descriptor.getDescription().trim().isEmpty() |
| | | ? descriptor.getToolPrompt() |
| | | : descriptor.getDescription()); |
| | | item.put("inputSchema", descriptor.getInputSchema() == null || descriptor.getInputSchema().isEmpty() |
| | | ? defaultInputSchema(false) |
| | | : descriptor.getInputSchema()); |
| | | return item; |
| | | } |
| | | |
| | | /** |
| | | * 读取远程工具声明中的 inputSchema;缺省时回退到系统默认输入结构。 |
| | | */ |
| | | public Map<String, Object> readInputSchema(JsonNode schemaNode, boolean includeTenantId) { |
| | | if (schemaNode == null || schemaNode.isMissingNode() || schemaNode.isNull()) { |
| | | return defaultInputSchema(includeTenantId); |
| | | } |
| | | return objectMapper.convertValue(schemaNode, Map.class); |
| | | } |
| | | |
| | | /** |
| | | * 生成系统默认的工具输入 schema。 |
| | | */ |
| | | public Map<String, Object> defaultInputSchema(boolean includeTenantId) { |
| | | Map<String, Object> schema = new LinkedHashMap<>(); |
| | | schema.put("type", "object"); |
| | | Map<String, Object> properties = new LinkedHashMap<>(); |
| | | properties.put("question", fieldSchema("string", "当前诊断问题或调用目的")); |
| | | properties.put("sceneCode", fieldSchema("string", "诊断场景编码")); |
| | | if (includeTenantId) { |
| | | properties.put("tenantId", fieldSchema("integer", "租户ID")); |
| | | } |
| | | schema.put("properties", properties); |
| | | return schema; |
| | | } |
| | | |
| | | /** |
| | | * 从 MCP tools/call 结果中提取可直接给模型看的文本摘要。 |
| | | */ |
| | | public String extractContentText(JsonNode result) { |
| | | List<String> parts = new ArrayList<>(); |
| | | JsonNode contentNode = result.path("content"); |
| | | if (contentNode.isArray()) { |
| | | for (Iterator<JsonNode> it = contentNode.elements(); it.hasNext(); ) { |
| | | JsonNode item = it.next(); |
| | | if ("text".equals(item.path("type").asText(""))) { |
| | | parts.add(item.path("text").asText("")); |
| | | } else if (item.isObject() || item.isArray()) { |
| | | parts.add(item.toString()); |
| | | } else { |
| | | parts.add(item.asText("")); |
| | | } |
| | | } |
| | | } |
| | | if (!parts.isEmpty()) { |
| | | return String.join("\n", parts).trim(); |
| | | } |
| | | if (result.has("structuredContent") && !result.get("structuredContent").isNull()) { |
| | | return result.get("structuredContent").toString(); |
| | | } |
| | | return result.toString(); |
| | | } |
| | | |
| | | /** |
| | | * 构造系统统一的 MCP 工具全名。 |
| | | */ |
| | | public String buildMcpToolName(String mountCode, String toolCode) { |
| | | return (mountCode == null ? AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE : mountCode) + "_" + toolCode; |
| | | } |
| | | |
| | | /** |
| | | * 将用途预设转换成运行时 sceneCode。 |
| | | */ |
| | | public String resolveSceneCode(String usageScope) { |
| | | if (AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY.equalsIgnoreCase(usageScope)) { |
| | | return AiSceneCode.SYSTEM_DIAGNOSE; |
| | | } |
| | | return ""; |
| | | } |
| | | |
| | | /** |
| | | * 标准化用途范围字段,统一成系统支持的三个枚举值。 |
| | | */ |
| | | public String normalizeUsageScope(String usageScope) { |
| | | if (usageScope == null || usageScope.trim().isEmpty()) { |
| | | return AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY; |
| | | } |
| | | String normalized = usageScope.trim().toUpperCase(); |
| | | if (AiMcpConstants.USAGE_SCOPE_CHAT_AND_DIAGNOSE.equals(normalized) |
| | | || AiMcpConstants.USAGE_SCOPE_DISABLED.equals(normalized)) { |
| | | return normalized; |
| | | } |
| | | return AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY; |
| | | } |
| | | |
| | | /** |
| | | * 根据 sceneCode、enabledFlag 和 usageScope 推断最终用途范围。 |
| | | */ |
| | | public String resolveUsageScope(String sceneCode, Integer enabledFlag, String usageScope) { |
| | | String normalized = normalizeUsageScope(usageScope); |
| | | if (AiMcpConstants.USAGE_SCOPE_DISABLED.equals(normalized)) { |
| | | return normalized; |
| | | } |
| | | if (enabledFlag != null && !Integer.valueOf(1).equals(enabledFlag)) { |
| | | return AiMcpConstants.USAGE_SCOPE_DISABLED; |
| | | } |
| | | if (AiMcpConstants.USAGE_SCOPE_CHAT_AND_DIAGNOSE.equals(normalized)) { |
| | | return normalized; |
| | | } |
| | | if (sceneCode == null || sceneCode.trim().isEmpty()) { |
| | | return AiMcpConstants.USAGE_SCOPE_CHAT_AND_DIAGNOSE; |
| | | } |
| | | return AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY; |
| | | } |
| | | |
| | | /** |
| | | * 构造单个字段的 schema 定义。 |
| | | */ |
| | | private Map<String, Object> fieldSchema(String type, String description) { |
| | | Map<String, Object> field = new LinkedHashMap<>(); |
| | | field.put("type", type); |
| | | field.put("description", description); |
| | | return field; |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.mcp; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiMcpProtocolService { |
| | | |
| | | @Resource |
| | | private AiMcpRegistryService aiMcpRegistryService; |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | @Resource |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | /** |
| | | * 处理 MCP 协议入口请求,兼容批量与单条 JSON-RPC 负载。 |
| | | */ |
| | | public Object handle(Long tenantId, JsonNode body) { |
| | | if (body != null && body.isArray()) { |
| | | List<Object> responses = new ArrayList<>(); |
| | | for (JsonNode item : body) { |
| | | Object response = handleSingle(tenantId, item); |
| | | if (response != null) { |
| | | responses.add(response); |
| | | } |
| | | } |
| | | return responses; |
| | | } |
| | | return handleSingle(tenantId, body); |
| | | } |
| | | |
| | | /** |
| | | * 处理单条 JSON-RPC MCP 请求。 |
| | | */ |
| | | public Object handleSingle(Long tenantId, JsonNode request) { |
| | | if (request == null || request.isMissingNode() || request.isNull()) { |
| | | return error(null, -32600, "invalid request"); |
| | | } |
| | | JsonNode idNode = request.get("id"); |
| | | String method = request.path("method").asText(""); |
| | | try { |
| | | if ("initialize".equals(method)) { |
| | | return success(idNode, buildInitializeResult()); |
| | | } |
| | | if ("notifications/initialized".equals(method)) { |
| | | return idNode == null || idNode.isNull() ? null : success(idNode, new LinkedHashMap<String, Object>()); |
| | | } |
| | | if ("ping".equals(method)) { |
| | | return success(idNode, new LinkedHashMap<String, Object>()); |
| | | } |
| | | if ("tools/list".equals(method)) { |
| | | return success(idNode, buildToolsListResult(tenantId)); |
| | | } |
| | | if ("tools/call".equals(method)) { |
| | | return success(idNode, buildToolCallResult(tenantId, request.path("params"))); |
| | | } |
| | | return error(idNode, -32601, "method not found"); |
| | | } catch (Exception e) { |
| | | return error(idNode, -32000, e.getMessage()); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 构造 initialize 方法的标准返回体。 |
| | | */ |
| | | private Map<String, Object> buildInitializeResult() { |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("protocolVersion", AiMcpConstants.PROTOCOL_VERSION); |
| | | Map<String, Object> capabilities = new LinkedHashMap<>(); |
| | | Map<String, Object> tools = new LinkedHashMap<>(); |
| | | tools.put("listChanged", false); |
| | | capabilities.put("tools", tools); |
| | | result.put("capabilities", capabilities); |
| | | Map<String, Object> serverInfo = new LinkedHashMap<>(); |
| | | serverInfo.put("name", AiMcpConstants.SERVER_NAME); |
| | | serverInfo.put("version", AiMcpConstants.SERVER_VERSION); |
| | | result.put("serverInfo", serverInfo); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 构造 tools/list 方法返回体。 |
| | | */ |
| | | private Map<String, Object> buildToolsListResult(Long tenantId) { |
| | | List<Map<String, Object>> tools = new ArrayList<>(); |
| | | for (AiMcpToolDescriptor descriptor : aiMcpRegistryService.listTools(tenantId, null)) { |
| | | if (descriptor == null || !Integer.valueOf(1).equals(descriptor.getEnabledFlag())) { |
| | | continue; |
| | | } |
| | | tools.add(aiMcpPayloadMapper.toProtocolTool(descriptor)); |
| | | } |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("tools", tools); |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 执行 tools/call,并把内部工具结果转换成 MCP 协议输出。 |
| | | */ |
| | | private Map<String, Object> buildToolCallResult(Long tenantId, JsonNode paramsNode) { |
| | | String name = paramsNode.path("name").asText(""); |
| | | Map<String, Object> arguments = paramsNode.has("arguments") && !paramsNode.get("arguments").isNull() |
| | | ? objectMapper.convertValue(paramsNode.get("arguments"), Map.class) |
| | | : new LinkedHashMap<String, Object>(); |
| | | AiPromptContext context = new AiPromptContext() |
| | | .setTenantId(tenantId) |
| | | .setSceneCode(arguments.get("sceneCode") == null ? "system_diagnose" : String.valueOf(arguments.get("sceneCode"))) |
| | | .setQuestion(arguments.get("question") == null ? "请执行一次MCP工具调用" : String.valueOf(arguments.get("question"))); |
| | | AiDiagnosticToolResult result = aiMcpRegistryService.executeTool(tenantId, name, context, arguments); |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | List<Map<String, Object>> content = new ArrayList<>(); |
| | | Map<String, Object> text = new LinkedHashMap<>(); |
| | | text.put("type", "text"); |
| | | text.put("text", result == null || result.getSummaryText() == null ? "" : result.getSummaryText()); |
| | | content.add(text); |
| | | payload.put("content", content); |
| | | payload.put("isError", result != null && "WARN".equalsIgnoreCase(result.getSeverity())); |
| | | if (result != null && result.getRawMeta() != null && !result.getRawMeta().isEmpty()) { |
| | | payload.put("structuredContent", result.getRawMeta()); |
| | | } |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 生成 JSON-RPC 成功响应。 |
| | | */ |
| | | public Map<String, Object> success(JsonNode idNode, Object result) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("jsonrpc", "2.0"); |
| | | payload.put("id", idNode == null || idNode.isNull() ? null : objectMapper.convertValue(idNode, Object.class)); |
| | | payload.put("result", result == null ? new LinkedHashMap<String, Object>() : result); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 生成 JSON-RPC 错误响应。 |
| | | */ |
| | | public Map<String, Object> error(JsonNode idNode, int code, String message) { |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("jsonrpc", "2.0"); |
| | | payload.put("id", idNode == null || idNode.isNull() ? null : objectMapper.convertValue(idNode, Object.class)); |
| | | Map<String, Object> error = new LinkedHashMap<>(); |
| | | error.put("code", code); |
| | | error.put("message", message == null ? "unknown error" : message); |
| | | payload.put("error", error); |
| | | return payload; |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.mcp; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.ai.service.provider.AiDiagnosticDataProvider; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosticToolConfigService; |
| | | import com.vincent.rsf.server.system.service.AiMcpMountService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Comparator; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.concurrent.ConcurrentHashMap; |
| | | |
| | | @Service |
| | | public class AiMcpRegistryService { |
| | | |
| | | private static final long EXTERNAL_TOOL_CACHE_TTL_MS = 30000L; |
| | | |
| | | private final List<AiDiagnosticDataProvider> providers; |
| | | private final Map<String, CachedTools> externalToolCache = new ConcurrentHashMap<>(); |
| | | |
| | | @Resource |
| | | private AiMcpMountService aiMcpMountService; |
| | | @Resource |
| | | private AiDiagnosticToolConfigService aiDiagnosticToolConfigService; |
| | | @Resource |
| | | private AiMcpHttpClient aiMcpHttpClient; |
| | | @Resource |
| | | private AiMcpSseClient aiMcpSseClient; |
| | | @Resource |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | public AiMcpRegistryService(List<AiDiagnosticDataProvider> providers) { |
| | | this.providers = providers == null ? new ArrayList<>() : providers; |
| | | } |
| | | |
| | | /** |
| | | * 枚举租户下可见的 MCP 工具目录。 |
| | | * 包括内部工具和所有启用中的外部挂载工具。 |
| | | */ |
| | | public List<AiMcpToolDescriptor> listTools(Long tenantId, Long mountId) { |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | List<AiMcpMount> mounts; |
| | | if (mountId == null) { |
| | | mounts = aiMcpMountService.list(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, tenantId) |
| | | .eq(AiMcpMount::getEnabledFlag, 1) |
| | | .eq(AiMcpMount::getStatus, 1) |
| | | .orderByAsc(AiMcpMount::getMountCode, AiMcpMount::getId)); |
| | | } else { |
| | | mounts = new ArrayList<>(); |
| | | AiMcpMount mount = aiMcpMountService.getTenantMount(tenantId, mountId); |
| | | if (mount != null && Integer.valueOf(1).equals(mount.getEnabledFlag()) && Integer.valueOf(1).equals(mount.getStatus())) { |
| | | mounts.add(mount); |
| | | } |
| | | } |
| | | for (AiMcpMount mount : mounts) { |
| | | try { |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType())) { |
| | | output.addAll(buildInternalTools(tenantId, mount)); |
| | | } else if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(mount.getTransportType())) { |
| | | output.addAll(loadCachedExternalTools(tenantId, mount)); |
| | | } else if (AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(mount.getTransportType())) { |
| | | output.addAll(loadCachedExternalTools(tenantId, mount)); |
| | | } |
| | | } catch (Exception ignore) { |
| | | } |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 仅返回系统内置工具目录。 |
| | | */ |
| | | public List<AiMcpToolDescriptor> listInternalTools(Long tenantId) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMountByCode(tenantId, AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE); |
| | | if (mount == null || !Integer.valueOf(1).equals(mount.getEnabledFlag()) || !Integer.valueOf(1).equals(mount.getStatus())) { |
| | | return new ArrayList<>(); |
| | | } |
| | | return buildInternalTools(tenantId, mount); |
| | | } |
| | | |
| | | /** |
| | | * 测试指定挂载的连通性与工具发现能力,并把结果回写到挂载测试状态字段。 |
| | | */ |
| | | public Map<String, Object> testMount(Long tenantId, Long mountId) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMount(tenantId, mountId); |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("MCP挂载不存在"); |
| | | } |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("mountCode", mount.getMountCode()); |
| | | payload.put("transportType", mount.getTransportType()); |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType())) { |
| | | List<AiMcpToolDescriptor> tools = buildInternalTools(tenantId, mount); |
| | | payload.put("success", true); |
| | | payload.put("toolCount", tools.size()); |
| | | payload.put("tools", tools); |
| | | updateTestState(mount, 1, "内部工具挂载正常", tools.size()); |
| | | return payload; |
| | | } |
| | | ExternalToolsResult testResult = loadExternalToolsWithTransport(mount); |
| | | List<AiMcpToolDescriptor> tools = testResult.tools; |
| | | payload.put("success", true); |
| | | payload.put("toolCount", tools.size()); |
| | | payload.put("tools", tools); |
| | | payload.put("resolvedTransportType", testResult.transportType); |
| | | payload.put("recommendedTransportType", testResult.transportType); |
| | | payload.put("message", buildExternalSuccessMessage(testResult.transportType, tools.size())); |
| | | updateTestState(mount, 1, String.valueOf(payload.get("message")), tools.size()); |
| | | return payload; |
| | | } |
| | | |
| | | /** |
| | | * 以“预览”模式执行一次工具调用,便于后台页面调试工具返回内容。 |
| | | */ |
| | | public AiDiagnosticToolResult previewTool(Long tenantId, String mountCode, String toolCode, String sceneCode, String question) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMountByCode(tenantId, mountCode); |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("MCP挂载不存在"); |
| | | } |
| | | AiPromptContext context = new AiPromptContext() |
| | | .setTenantId(tenantId) |
| | | .setSceneCode(sceneCode == null || sceneCode.trim().isEmpty() ? AiSceneCode.SYSTEM_DIAGNOSE : sceneCode) |
| | | .setQuestion(question == null || question.trim().isEmpty() ? "请执行一次MCP工具预览" : question); |
| | | if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(mount.getTransportType()) |
| | | || AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(mount.getTransportType())) { |
| | | return executeExternalTool(mount, toolCode, context, buildToolArguments(context, null)); |
| | | } |
| | | return executeInternalTool(mountCode, toolCode, context); |
| | | } |
| | | |
| | | /** |
| | | * 根据工具描述符执行一次工具调用。 |
| | | */ |
| | | public AiDiagnosticToolResult executeTool(Long tenantId, AiMcpToolDescriptor descriptor, AiPromptContext context) { |
| | | if (descriptor == null) { |
| | | throw new IllegalArgumentException("MCP工具不存在"); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(descriptor.getTransportType()) |
| | | || AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(descriptor.getTransportType())) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMountByCode(tenantId, descriptor.getMountCode()); |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("MCP挂载不存在"); |
| | | } |
| | | return executeExternalTool(mount, descriptor.getToolCode(), context, buildToolArguments(context, descriptor)); |
| | | } |
| | | return executeInternalTool(descriptor.getMountCode(), descriptor.getToolCode(), context); |
| | | } |
| | | |
| | | /** |
| | | * 根据完整 MCP 工具名执行一次工具调用,通常供协议层直接使用。 |
| | | */ |
| | | public AiDiagnosticToolResult executeTool(Long tenantId, String mcpToolName, AiPromptContext context, Map<String, Object> arguments) { |
| | | AiMcpToolDescriptor descriptor = findDescriptor(tenantId, mcpToolName); |
| | | if (descriptor == null) { |
| | | throw new IllegalArgumentException("MCP工具不存在"); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(descriptor.getTransportType()) |
| | | || AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(descriptor.getTransportType())) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMountByCode(tenantId, descriptor.getMountCode()); |
| | | if (mount == null) { |
| | | throw new IllegalArgumentException("MCP挂载不存在"); |
| | | } |
| | | return executeExternalTool(mount, descriptor.getToolCode(), context, arguments); |
| | | } |
| | | return executeInternalTool(descriptor.getMountCode(), descriptor.getToolCode(), context); |
| | | } |
| | | |
| | | /** |
| | | * 确保租户存在默认本地 MCP 挂载。 |
| | | */ |
| | | public void ensureDefaultMount(Long tenantId, Long userId) { |
| | | if (aiMcpMountService.getTenantMountByCode(tenantId, AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE) != null) { |
| | | return; |
| | | } |
| | | Date now = new Date(); |
| | | AiMcpMount mount = new AiMcpMount() |
| | | .setUuid(String.valueOf(System.currentTimeMillis())) |
| | | .setName(AiMcpConstants.DEFAULT_LOCAL_MOUNT_NAME) |
| | | .setMountCode(AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE) |
| | | .setTransportType(AiMcpConstants.TRANSPORT_INTERNAL) |
| | | .setUrl("/ai/mcp") |
| | | .setEnabledFlag(1) |
| | | .setTimeoutMs(10000) |
| | | .setStatus(1) |
| | | .setDeleted(0) |
| | | .setTenantId(tenantId) |
| | | .setCreateBy(userId) |
| | | .setCreateTime(now) |
| | | .setUpdateBy(userId) |
| | | .setUpdateTime(now) |
| | | .setMemo("默认挂载当前 WMS AI 内置工具集合"); |
| | | aiMcpMountService.save(mount); |
| | | } |
| | | |
| | | /** |
| | | * 为内部工具结果补齐挂载编码和标准 MCP 工具名。 |
| | | */ |
| | | public AiDiagnosticToolResult decorateResult(AiDiagnosticToolResult result) { |
| | | if (result == null) { |
| | | return null; |
| | | } |
| | | return decorateResult(result, AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE, findProvider(result.getToolCode())); |
| | | } |
| | | |
| | | private AiDiagnosticToolResult decorateResult(AiDiagnosticToolResult result, String mountCode, AiDiagnosticDataProvider provider) { |
| | | String actualMountCode = mountCode == null || mountCode.trim().isEmpty() ? AiMcpConstants.DEFAULT_LOCAL_MOUNT_CODE : mountCode; |
| | | result.setMountCode(actualMountCode); |
| | | result.setMcpToolName(aiMcpPayloadMapper.buildMcpToolName(actualMountCode, result.getToolCode())); |
| | | if ((result.getToolName() == null || result.getToolName().trim().isEmpty()) && provider != null) { |
| | | result.setToolName(provider.getToolName()); |
| | | } |
| | | return result; |
| | | } |
| | | |
| | | /** |
| | | * 按当前租户的工具配置生成内部 MCP 工具目录。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> buildInternalTools(Long tenantId, AiMcpMount mount) { |
| | | Map<String, AiDiagnosticToolConfig> configMap = buildInternalConfigMap(tenantId); |
| | | List<AiDiagnosticDataProvider> sortedProviders = new ArrayList<>(providers); |
| | | sortedProviders.sort(Comparator.comparingInt(AiDiagnosticDataProvider::getOrder)); |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | for (AiDiagnosticDataProvider provider : sortedProviders) { |
| | | AiDiagnosticToolConfig config = configMap.get(provider.getToolCode()); |
| | | String usageScope = aiMcpPayloadMapper.resolveUsageScope( |
| | | config == null ? null : config.getSceneCode(), |
| | | config == null ? 1 : config.getEnabledFlag(), |
| | | config == null ? null : config.getUsageScope() |
| | | ); |
| | | output.add(new AiMcpToolDescriptor() |
| | | .setMountCode(mount.getMountCode()) |
| | | .setMountName(mount.getName()) |
| | | .setToolCode(provider.getToolCode()) |
| | | .setMcpToolName(aiMcpPayloadMapper.buildMcpToolName(mount.getMountCode(), provider.getToolCode())) |
| | | .setToolName(provider.getToolName()) |
| | | .setSceneCode(aiMcpPayloadMapper.resolveSceneCode(usageScope)) |
| | | .setDescription(provider.getDefaultToolPrompt()) |
| | | .setEnabledFlag(AiMcpConstants.USAGE_SCOPE_DISABLED.equals(usageScope) ? 0 : (config == null ? 1 : config.getEnabledFlag())) |
| | | .setPriority(config == null ? provider.getOrder() : config.getPriority()) |
| | | .setToolPrompt(config == null ? provider.getDefaultToolPrompt() : config.getToolPrompt()) |
| | | .setUsageScope(usageScope) |
| | | .setTransportType(mount.getTransportType()) |
| | | .setInputSchema(aiMcpPayloadMapper.defaultInputSchema(true))); |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | /** |
| | | * 为内置工具目录选择一条最合适的有效配置。 |
| | | * 目录层只有一条工具描述,因此这里优先保留启用且状态正常的配置, |
| | | * 并优先选择“聊天与诊断都可用”的配置,再退回到“仅诊断”配置。 |
| | | */ |
| | | private Map<String, AiDiagnosticToolConfig> buildInternalConfigMap(Long tenantId) { |
| | | Map<String, AiDiagnosticToolConfig> configMap = new LinkedHashMap<>(); |
| | | for (AiDiagnosticToolConfig item : aiDiagnosticToolConfigService.listTenantConfigs(tenantId)) { |
| | | if (item == null || !Integer.valueOf(1).equals(item.getStatus())) { |
| | | continue; |
| | | } |
| | | AiDiagnosticToolConfig existed = configMap.get(item.getToolCode()); |
| | | if (existed == null || preferInternalConfig(item, existed)) { |
| | | configMap.put(item.getToolCode(), item); |
| | | } |
| | | } |
| | | return configMap; |
| | | } |
| | | |
| | | /** |
| | | * 内置工具目录优先展示用途更广的配置,其次比较优先级。 |
| | | */ |
| | | private boolean preferInternalConfig(AiDiagnosticToolConfig candidate, AiDiagnosticToolConfig current) { |
| | | String candidateUsageScope = aiMcpPayloadMapper.resolveUsageScope( |
| | | candidate.getSceneCode(), |
| | | candidate.getEnabledFlag(), |
| | | candidate.getUsageScope() |
| | | ); |
| | | String currentUsageScope = aiMcpPayloadMapper.resolveUsageScope( |
| | | current.getSceneCode(), |
| | | current.getEnabledFlag(), |
| | | current.getUsageScope() |
| | | ); |
| | | int candidateRank = usageScopeRank(candidateUsageScope); |
| | | int currentRank = usageScopeRank(currentUsageScope); |
| | | if (candidateRank != currentRank) { |
| | | return candidateRank < currentRank; |
| | | } |
| | | Integer candidatePriority = candidate.getPriority() == null ? Integer.MAX_VALUE : candidate.getPriority(); |
| | | Integer currentPriority = current.getPriority() == null ? Integer.MAX_VALUE : current.getPriority(); |
| | | return candidatePriority < currentPriority; |
| | | } |
| | | |
| | | private int usageScopeRank(String usageScope) { |
| | | if (AiMcpConstants.USAGE_SCOPE_CHAT_AND_DIAGNOSE.equals(usageScope)) { |
| | | return 0; |
| | | } |
| | | if (AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY.equals(usageScope)) { |
| | | return 1; |
| | | } |
| | | return 2; |
| | | } |
| | | |
| | | /** |
| | | * 按工具编码定位内部工具实现。 |
| | | */ |
| | | private AiDiagnosticDataProvider findProvider(String toolCode) { |
| | | for (AiDiagnosticDataProvider provider : providers) { |
| | | if (provider.getToolCode().equals(toolCode)) { |
| | | return provider; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 更新挂载测试结果和工具数量,并清理缓存。 |
| | | */ |
| | | private void updateTestState(AiMcpMount mount, Integer result, String message, Integer toolCount) { |
| | | mount.setLastTestResult(result); |
| | | mount.setLastTestMessage(message); |
| | | mount.setLastToolCount(toolCount); |
| | | mount.setLastTestTime(new Date()); |
| | | mount.setUpdateTime(new Date()); |
| | | aiMcpMountService.updateById(mount); |
| | | if (mount.getId() != null) { |
| | | externalToolCache.remove(buildCacheKey(mount.getTenantId(), mount)); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 执行内部 MCP 工具。 |
| | | */ |
| | | private AiDiagnosticToolResult executeInternalTool(String mountCode, String toolCode, AiPromptContext context) { |
| | | AiDiagnosticDataProvider provider = findProvider(toolCode); |
| | | if (provider == null) { |
| | | throw new IllegalArgumentException("MCP工具不存在"); |
| | | } |
| | | AiDiagnosticToolResult result = provider.buildDiagnosticData(context); |
| | | if (result == null) { |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(toolCode) |
| | | .setMountCode(mountCode) |
| | | .setMcpToolName(aiMcpPayloadMapper.buildMcpToolName(mountCode, toolCode)) |
| | | .setToolName(provider.getToolName()) |
| | | .setSeverity("WARN") |
| | | .setSummaryText("工具没有返回结果"); |
| | | } |
| | | return decorateResult(result, mountCode, provider); |
| | | } |
| | | |
| | | /** |
| | | * 执行外部 MCP 工具,并补齐本地系统所需的统一字段。 |
| | | */ |
| | | private AiDiagnosticToolResult executeExternalTool(AiMcpMount mount, String toolCode, |
| | | AiPromptContext context, Map<String, Object> arguments) { |
| | | ExternalToolCallResult callResult = callExternalTool(mount, toolCode, arguments); |
| | | AiDiagnosticToolResult result = callResult.result; |
| | | return result |
| | | .setToolCode(toolCode) |
| | | .setMountCode(mount.getMountCode()) |
| | | .setMcpToolName(aiMcpPayloadMapper.buildMcpToolName(mount.getMountCode(), toolCode)) |
| | | .setToolName(result.getToolName() == null || result.getToolName().trim().isEmpty() ? toolCode : result.getToolName()) |
| | | .setRawMeta(result.getRawMeta() == null ? new LinkedHashMap<String, Object>() : result.getRawMeta()); |
| | | } |
| | | |
| | | /** |
| | | * 真正加载外部工具目录,不带缓存。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> loadExternalTools(AiMcpMount mount) { |
| | | return loadExternalToolsWithTransport(mount).tools; |
| | | } |
| | | |
| | | /** |
| | | * 带短期缓存地加载外部工具目录,避免同一挂载在短时间内重复握手。 |
| | | */ |
| | | private List<AiMcpToolDescriptor> loadCachedExternalTools(Long tenantId, AiMcpMount mount) { |
| | | String cacheKey = buildCacheKey(tenantId, mount); |
| | | CachedTools cached = externalToolCache.get(cacheKey); |
| | | long now = System.currentTimeMillis(); |
| | | if (cached != null && cached.expireAt > now) { |
| | | return new ArrayList<>(cached.tools); |
| | | } |
| | | List<AiMcpToolDescriptor> tools = loadExternalTools(mount); |
| | | externalToolCache.put(cacheKey, new CachedTools(new ArrayList<>(tools), now + EXTERNAL_TOOL_CACHE_TTL_MS)); |
| | | return tools; |
| | | } |
| | | |
| | | /** |
| | | * 生成外部工具目录缓存 key。 |
| | | */ |
| | | private String buildCacheKey(Long tenantId, AiMcpMount mount) { |
| | | return String.valueOf(tenantId) + ":" + mount.getId() + ":" + (mount.getUpdateTime() == null ? 0L : mount.getUpdateTime().getTime()); |
| | | } |
| | | |
| | | /** |
| | | * 按完整 MCP 工具名反查工具描述符。 |
| | | */ |
| | | private AiMcpToolDescriptor findDescriptor(Long tenantId, String mcpToolName) { |
| | | for (AiMcpToolDescriptor descriptor : listTools(tenantId, null)) { |
| | | if (descriptor != null && mcpToolName.equals(descriptor.getMcpToolName())) { |
| | | return descriptor; |
| | | } |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | /** |
| | | * 根据上下文和工具信息构造远程调用参数。 |
| | | */ |
| | | private Map<String, Object> buildToolArguments(AiPromptContext context, AiMcpToolDescriptor descriptor) { |
| | | Map<String, Object> arguments = new LinkedHashMap<>(); |
| | | if (context != null) { |
| | | arguments.put("tenantId", context.getTenantId()); |
| | | arguments.put("sceneCode", context.getSceneCode()); |
| | | arguments.put("question", context.getQuestion()); |
| | | arguments.put("sessionId", context.getSessionId()); |
| | | } |
| | | if (descriptor != null) { |
| | | arguments.put("toolName", descriptor.getToolName()); |
| | | arguments.put("mountCode", descriptor.getMountCode()); |
| | | } |
| | | return arguments; |
| | | } |
| | | |
| | | /** |
| | | * 生成外部挂载测试成功文案。 |
| | | */ |
| | | private String buildExternalSuccessMessage(String transportType, int toolCount) { |
| | | String prefix = AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(transportType) |
| | | ? "远程SSE MCP工具加载成功" |
| | | : "远程Streamable HTTP MCP工具加载成功"; |
| | | return prefix + ",发现 " + toolCount + " 个工具"; |
| | | } |
| | | |
| | | /** |
| | | * 根据挂载配置选择 HTTP / SSE 客户端去加载外部工具目录。 |
| | | * AUTO 模式会依次尝试 Streamable HTTP 和 SSE。 |
| | | */ |
| | | private ExternalToolsResult loadExternalToolsWithTransport(AiMcpMount mount) { |
| | | if (AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(mount.getTransportType())) { |
| | | return new ExternalToolsResult(AiMcpConstants.TRANSPORT_SSE, aiMcpSseClient.listTools(mount)); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(mount.getTransportType())) { |
| | | return new ExternalToolsResult(AiMcpConstants.TRANSPORT_HTTP, aiMcpHttpClient.listTools(mount)); |
| | | } |
| | | List<String> errors = new ArrayList<>(); |
| | | try { |
| | | return new ExternalToolsResult(AiMcpConstants.TRANSPORT_HTTP, aiMcpHttpClient.listTools(copyMountWithTransport(mount, AiMcpConstants.TRANSPORT_HTTP))); |
| | | } catch (Exception e) { |
| | | errors.add("HTTP: " + e.getMessage()); |
| | | } |
| | | try { |
| | | return new ExternalToolsResult(AiMcpConstants.TRANSPORT_SSE, aiMcpSseClient.listTools(copyMountWithTransport(mount, AiMcpConstants.TRANSPORT_SSE))); |
| | | } catch (Exception e) { |
| | | errors.add("SSE: " + e.getMessage()); |
| | | } |
| | | throw new IllegalStateException(String.join(";", errors)); |
| | | } |
| | | |
| | | /** |
| | | * 根据挂载配置选择 HTTP / SSE 客户端执行远程工具。 |
| | | */ |
| | | private ExternalToolCallResult callExternalTool(AiMcpMount mount, String toolCode, Map<String, Object> arguments) { |
| | | if (AiMcpConstants.TRANSPORT_SSE.equalsIgnoreCase(mount.getTransportType())) { |
| | | return new ExternalToolCallResult(AiMcpConstants.TRANSPORT_SSE, aiMcpSseClient.callTool(mount, toolCode, arguments)); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_HTTP.equalsIgnoreCase(mount.getTransportType())) { |
| | | return new ExternalToolCallResult(AiMcpConstants.TRANSPORT_HTTP, aiMcpHttpClient.callTool(mount, toolCode, arguments)); |
| | | } |
| | | List<String> errors = new ArrayList<>(); |
| | | try { |
| | | return new ExternalToolCallResult( |
| | | AiMcpConstants.TRANSPORT_HTTP, |
| | | aiMcpHttpClient.callTool(copyMountWithTransport(mount, AiMcpConstants.TRANSPORT_HTTP), toolCode, arguments) |
| | | ); |
| | | } catch (Exception e) { |
| | | errors.add("HTTP: " + e.getMessage()); |
| | | } |
| | | try { |
| | | return new ExternalToolCallResult( |
| | | AiMcpConstants.TRANSPORT_SSE, |
| | | aiMcpSseClient.callTool(copyMountWithTransport(mount, AiMcpConstants.TRANSPORT_SSE), toolCode, arguments) |
| | | ); |
| | | } catch (Exception e) { |
| | | errors.add("SSE: " + e.getMessage()); |
| | | } |
| | | throw new IllegalStateException(String.join(";", errors)); |
| | | } |
| | | |
| | | /** |
| | | * 复制一份挂载对象,并覆盖指定传输协议,供 AUTO 探测流程使用。 |
| | | */ |
| | | private AiMcpMount copyMountWithTransport(AiMcpMount mount, String transportType) { |
| | | return new AiMcpMount() |
| | | .setId(mount.getId()) |
| | | .setUuid(mount.getUuid()) |
| | | .setName(mount.getName()) |
| | | .setMountCode(mount.getMountCode()) |
| | | .setTransportType(transportType) |
| | | .setUrl(mount.getUrl()) |
| | | .setAuthType(mount.getAuthType()) |
| | | .setAuthValue(mount.getAuthValue()) |
| | | .setUsageScope(mount.getUsageScope()) |
| | | .setEnabledFlag(mount.getEnabledFlag()) |
| | | .setTimeoutMs(mount.getTimeoutMs()) |
| | | .setStatus(mount.getStatus()) |
| | | .setTenantId(mount.getTenantId()) |
| | | .setCreateBy(mount.getCreateBy()) |
| | | .setCreateTime(mount.getCreateTime()) |
| | | .setUpdateBy(mount.getUpdateBy()) |
| | | .setUpdateTime(mount.getUpdateTime()) |
| | | .setMemo(mount.getMemo()); |
| | | } |
| | | |
| | | private static class CachedTools { |
| | | private final List<AiMcpToolDescriptor> tools; |
| | | private final long expireAt; |
| | | |
| | | /** |
| | | * 保存一次外部工具目录缓存内容及其失效时间。 |
| | | */ |
| | | private CachedTools(List<AiMcpToolDescriptor> tools, long expireAt) { |
| | | this.tools = tools; |
| | | this.expireAt = expireAt; |
| | | } |
| | | } |
| | | |
| | | private static class ExternalToolsResult { |
| | | private final String transportType; |
| | | private final List<AiMcpToolDescriptor> tools; |
| | | |
| | | /** |
| | | * 保存一次外部工具目录加载结果及实际命中的传输协议。 |
| | | */ |
| | | private ExternalToolsResult(String transportType, List<AiMcpToolDescriptor> tools) { |
| | | this.transportType = transportType; |
| | | this.tools = tools; |
| | | } |
| | | } |
| | | |
| | | private static class ExternalToolCallResult { |
| | | private final String transportType; |
| | | private final AiDiagnosticToolResult result; |
| | | |
| | | /** |
| | | * 保存一次外部工具调用结果及实际命中的传输协议。 |
| | | */ |
| | | private ExternalToolCallResult(String transportType, AiDiagnosticToolResult result) { |
| | | this.transportType = transportType; |
| | | this.result = result; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.mcp; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.fasterxml.jackson.databind.ObjectMapper; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiMcpToolDescriptor; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import org.slf4j.Logger; |
| | | import org.slf4j.LoggerFactory; |
| | | import org.springframework.stereotype.Component; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.io.BufferedReader; |
| | | import java.io.InputStream; |
| | | import java.io.InputStreamReader; |
| | | import java.io.OutputStream; |
| | | import java.net.HttpURLConnection; |
| | | import java.net.URI; |
| | | import java.net.URL; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | import java.util.UUID; |
| | | import java.util.concurrent.BlockingQueue; |
| | | import java.util.concurrent.LinkedBlockingQueue; |
| | | import java.util.concurrent.TimeUnit; |
| | | |
| | | @Component |
| | | public class AiMcpSseClient { |
| | | |
| | | private static final Logger logger = LoggerFactory.getLogger(AiMcpSseClient.class); |
| | | |
| | | @Resource |
| | | private ObjectMapper objectMapper; |
| | | @Resource |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | /** |
| | | * 通过 SSE + message endpoint 协议加载远程 MCP 工具目录。 |
| | | */ |
| | | public List<AiMcpToolDescriptor> listTools(AiMcpMount mount) { |
| | | try (SseSession session = openSession(mount)) { |
| | | logger.info("AI MCP SSE listTools start: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | session.initialize(); |
| | | JsonNode result = session.request("tools/list", new LinkedHashMap<String, Object>()); |
| | | List<AiMcpToolDescriptor> output = new ArrayList<>(); |
| | | JsonNode toolsNode = result.path("tools"); |
| | | if (!toolsNode.isArray()) { |
| | | logger.warn("AI MCP SSE listTools no tools array: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | return output; |
| | | } |
| | | for (JsonNode item : toolsNode) { |
| | | AiMcpToolDescriptor descriptor = aiMcpPayloadMapper.toExternalToolDescriptor(mount, item); |
| | | if (descriptor != null) { |
| | | output.add(descriptor); |
| | | } |
| | | } |
| | | logger.info("AI MCP SSE listTools success: mountCode={}, url={}, toolCount={}", |
| | | mount.getMountCode(), mount.getUrl(), output.size()); |
| | | return output; |
| | | } catch (Exception e) { |
| | | logger.warn("AI MCP SSE listTools failed: mountCode={}, url={}, message={}", |
| | | mount.getMountCode(), mount.getUrl(), e.getMessage()); |
| | | throw new IllegalStateException("SSE MCP工具加载失败: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 通过 SSE + message endpoint 协议执行远程 MCP 工具。 |
| | | */ |
| | | public AiDiagnosticToolResult callTool(AiMcpMount mount, String toolName, Map<String, Object> arguments) { |
| | | try (SseSession session = openSession(mount)) { |
| | | logger.info("AI MCP SSE callTool start: mountCode={}, url={}, toolName={}", |
| | | mount.getMountCode(), mount.getUrl(), toolName); |
| | | session.initialize(); |
| | | Map<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("name", toolName); |
| | | params.put("arguments", arguments == null ? new LinkedHashMap<String, Object>() : arguments); |
| | | JsonNode result = session.request("tools/call", params); |
| | | AiDiagnosticToolResult toolResult = aiMcpPayloadMapper.toExternalToolResult(mount, toolName, result); |
| | | logger.info("AI MCP SSE callTool success: mountCode={}, url={}, toolName={}, isError={}, summaryLength={}", |
| | | mount.getMountCode(), mount.getUrl(), toolName, |
| | | "WARN".equalsIgnoreCase(toolResult.getSeverity()), |
| | | toolResult.getSummaryText() == null ? 0 : toolResult.getSummaryText().length()); |
| | | return toolResult; |
| | | } catch (Exception e) { |
| | | logger.warn("AI MCP SSE callTool failed: mountCode={}, url={}, toolName={}, message={}", |
| | | mount.getMountCode(), mount.getUrl(), toolName, e.getMessage()); |
| | | throw new IllegalStateException("SSE MCP工具调用失败: " + e.getMessage(), e); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 打开远程 SSE 流并创建会话包装对象。 |
| | | */ |
| | | private SseSession openSession(AiMcpMount mount) throws Exception { |
| | | logger.info("AI MCP SSE opening stream: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | int timeoutMs = mount.getTimeoutMs() == null || mount.getTimeoutMs() <= 0 ? 10000 : mount.getTimeoutMs(); |
| | | HttpURLConnection connection = (HttpURLConnection) new URL(mount.getUrl()).openConnection(); |
| | | connection.setRequestMethod("GET"); |
| | | connection.setDoInput(true); |
| | | connection.setConnectTimeout(timeoutMs); |
| | | connection.setReadTimeout(timeoutMs); |
| | | connection.setRequestProperty("Accept", "text/event-stream"); |
| | | applyAuthHeaders(connection, mount); |
| | | InputStream inputStream = connection.getInputStream(); |
| | | logger.info("AI MCP SSE stream connected: mountCode={}, url={}, responseCode={}", |
| | | mount.getMountCode(), mount.getUrl(), connection.getResponseCode()); |
| | | BufferedReader reader = new BufferedReader(new InputStreamReader(inputStream, StandardCharsets.UTF_8)); |
| | | SseSession session = new SseSession(mount, connection, reader); |
| | | session.start(); |
| | | return session; |
| | | } |
| | | |
| | | private class SseSession implements AutoCloseable { |
| | | private final AiMcpMount mount; |
| | | private final HttpURLConnection connection; |
| | | private final BufferedReader reader; |
| | | private final BlockingQueue<SseEvent> events = new LinkedBlockingQueue<>(); |
| | | private volatile boolean closed; |
| | | private Thread worker; |
| | | private String messageEndpoint; |
| | | |
| | | private SseSession(AiMcpMount mount, HttpURLConnection connection, BufferedReader reader) { |
| | | this.mount = mount; |
| | | this.connection = connection; |
| | | this.reader = reader; |
| | | } |
| | | |
| | | /** |
| | | * 启动后台读取线程,并等待远程服务返回 endpoint 事件。 |
| | | */ |
| | | private void start() throws Exception { |
| | | worker = new Thread(this::readLoop, "ai-mcp-sse-client-" + mount.getMountCode()); |
| | | worker.setDaemon(true); |
| | | worker.start(); |
| | | logger.info("AI MCP SSE waiting endpoint event: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | SseEvent endpointEvent = waitEvent("endpoint"); |
| | | messageEndpoint = resolveEndpoint(endpointEvent == null ? null : endpointEvent.getData()); |
| | | logger.info("AI MCP SSE endpoint event received: mountCode={}, url={}, rawEndpoint={}, resolvedEndpoint={}", |
| | | mount.getMountCode(), mount.getUrl(), |
| | | endpointEvent == null ? null : endpointEvent.getData(), |
| | | messageEndpoint); |
| | | if (messageEndpoint == null || messageEndpoint.trim().isEmpty()) { |
| | | throw new IllegalStateException("SSE MCP未返回 message endpoint"); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 完成一次 MCP initialize 握手。 |
| | | */ |
| | | private void initialize() throws Exception { |
| | | logger.info("AI MCP SSE initialize start: mountCode={}, url={}, messageEndpoint={}", |
| | | mount.getMountCode(), mount.getUrl(), messageEndpoint); |
| | | Map<String, Object> params = new LinkedHashMap<>(); |
| | | params.put("protocolVersion", AiMcpConstants.PROTOCOL_VERSION); |
| | | params.put("capabilities", new LinkedHashMap<String, Object>()); |
| | | Map<String, Object> clientInfo = new LinkedHashMap<>(); |
| | | clientInfo.put("name", "rsf-server"); |
| | | clientInfo.put("version", AiMcpConstants.SERVER_VERSION); |
| | | params.put("clientInfo", clientInfo); |
| | | request("initialize", params); |
| | | notifyInitialized(); |
| | | logger.info("AI MCP SSE initialize success: mountCode={}, url={}, messageEndpoint={}", |
| | | mount.getMountCode(), mount.getUrl(), messageEndpoint); |
| | | } |
| | | |
| | | /** |
| | | * 向远程 MCP 发送 initialized 通知。 |
| | | */ |
| | | private void notifyInitialized() throws Exception { |
| | | Map<String, Object> body = new LinkedHashMap<>(); |
| | | body.put("jsonrpc", "2.0"); |
| | | body.put("method", "notifications/initialized"); |
| | | body.put("params", new LinkedHashMap<String, Object>()); |
| | | postMessage(body, false); |
| | | logger.info("AI MCP SSE initialized notification sent: mountCode={}, messageEndpoint={}", |
| | | mount.getMountCode(), messageEndpoint); |
| | | } |
| | | |
| | | /** |
| | | * 通过 message endpoint 发送一次 JSON-RPC 请求,并等待对应 message 事件响应。 |
| | | */ |
| | | private JsonNode request(String method, Object params) throws Exception { |
| | | String id = UUID.randomUUID().toString().replace("-", ""); |
| | | Map<String, Object> body = new LinkedHashMap<>(); |
| | | body.put("jsonrpc", "2.0"); |
| | | body.put("id", id); |
| | | body.put("method", method); |
| | | body.put("params", params == null ? new LinkedHashMap<String, Object>() : params); |
| | | logger.info("AI MCP SSE request send: mountCode={}, method={}, requestId={}, messageEndpoint={}", |
| | | mount.getMountCode(), method, id, messageEndpoint); |
| | | postMessage(body, true); |
| | | SseEvent response = waitEvent("message"); |
| | | if (response == null || response.getData() == null || response.getData().trim().isEmpty()) { |
| | | throw new IllegalStateException("SSE MCP未返回响应消息"); |
| | | } |
| | | logger.info("AI MCP SSE response received: mountCode={}, method={}, requestId={}, dataLength={}", |
| | | mount.getMountCode(), method, id, response.getData().length()); |
| | | JsonNode root = objectMapper.readTree(response.getData()); |
| | | if (!id.equals(root.path("id").asText(""))) { |
| | | logger.warn("AI MCP SSE response id mismatch: mountCode={}, method={}, requestId={}, responseId={}", |
| | | mount.getMountCode(), method, id, root.path("id").asText("")); |
| | | throw new IllegalStateException("SSE MCP响应ID不匹配"); |
| | | } |
| | | if (root.has("error") && !root.get("error").isNull()) { |
| | | logger.warn("AI MCP SSE response error: mountCode={}, method={}, requestId={}, message={}", |
| | | mount.getMountCode(), method, id, root.path("error").path("message").asText("")); |
| | | throw new IllegalStateException(root.path("error").path("message").asText("MCP调用失败")); |
| | | } |
| | | return root.path("result"); |
| | | } |
| | | |
| | | /** |
| | | * 向 message endpoint 提交一条 HTTP POST 消息。 |
| | | */ |
| | | private void postMessage(Map<String, Object> body, boolean expectSuccess) throws Exception { |
| | | HttpURLConnection post = null; |
| | | try { |
| | | post = (HttpURLConnection) new URL(messageEndpoint).openConnection(); |
| | | post.setRequestMethod("POST"); |
| | | post.setDoOutput(true); |
| | | post.setConnectTimeout(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | post.setReadTimeout(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | post.setRequestProperty("Content-Type", "application/json"); |
| | | post.setRequestProperty("Accept", "application/json"); |
| | | applyAuthHeaders(post, mount); |
| | | logger.info("AI MCP SSE post message: mountCode={}, endpoint={}, method={}", |
| | | mount.getMountCode(), messageEndpoint, body.get("method")); |
| | | try (OutputStream outputStream = post.getOutputStream()) { |
| | | outputStream.write(objectMapper.writeValueAsBytes(body)); |
| | | outputStream.flush(); |
| | | } |
| | | int statusCode = post.getResponseCode(); |
| | | logger.info("AI MCP SSE post response: mountCode={}, endpoint={}, method={}, statusCode={}", |
| | | mount.getMountCode(), messageEndpoint, body.get("method"), statusCode); |
| | | if (expectSuccess && statusCode >= 400) { |
| | | throw new IllegalStateException("SSE MCP消息提交失败,状态码=" + statusCode); |
| | | } |
| | | } finally { |
| | | if (post != null) { |
| | | post.disconnect(); |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 在限定时间内等待某类 SSE 事件。 |
| | | */ |
| | | private SseEvent waitEvent(String targetName) throws Exception { |
| | | long timeoutMs = mount.getTimeoutMs() == null ? 10000L : mount.getTimeoutMs().longValue(); |
| | | long deadline = System.currentTimeMillis() + timeoutMs; |
| | | while (System.currentTimeMillis() < deadline) { |
| | | long remain = deadline - System.currentTimeMillis(); |
| | | SseEvent event = events.poll(remain <= 0 ? 1L : remain, TimeUnit.MILLISECONDS); |
| | | if (event == null) { |
| | | continue; |
| | | } |
| | | logger.info("AI MCP SSE event dequeued: mountCode={}, target={}, actual={}, dataLength={}", |
| | | mount.getMountCode(), targetName, event.getName(), event.getData() == null ? 0 : event.getData().length()); |
| | | if ("error".equals(event.getName())) { |
| | | throw new IllegalStateException("SSE MCP事件读取失败: " + event.getData()); |
| | | } |
| | | if (targetName.equals(event.getName())) { |
| | | return event; |
| | | } |
| | | } |
| | | logger.warn("AI MCP SSE wait event timeout: mountCode={}, target={}, timeoutMs={}", |
| | | mount.getMountCode(), targetName, timeoutMs); |
| | | throw new IllegalStateException("等待SSE事件超时: " + targetName); |
| | | } |
| | | |
| | | /** |
| | | * 后台持续读取 SSE 流,并把事件转发到队列供主线程消费。 |
| | | */ |
| | | private void readLoop() { |
| | | String eventName = "message"; |
| | | StringBuilder dataBuilder = new StringBuilder(); |
| | | try { |
| | | String line; |
| | | while (!closed && (line = reader.readLine()) != null) { |
| | | if (line.startsWith("event:")) { |
| | | eventName = line.substring(6).trim(); |
| | | continue; |
| | | } |
| | | if (line.startsWith("data:")) { |
| | | dataBuilder.append(line.substring(5).trim()).append('\n'); |
| | | continue; |
| | | } |
| | | if (line.trim().isEmpty()) { |
| | | if (dataBuilder.length() > 0) { |
| | | logger.info("AI MCP SSE raw event read: mountCode={}, event={}, dataLength={}", |
| | | mount.getMountCode(), eventName, dataBuilder.length()); |
| | | events.offer(new SseEvent(eventName, dataBuilder.toString().trim())); |
| | | } |
| | | eventName = "message"; |
| | | dataBuilder.setLength(0); |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | if (!closed) { |
| | | logger.warn("AI MCP SSE read loop failed: mountCode={}, url={}, message={}", |
| | | mount.getMountCode(), mount.getUrl(), e.getMessage()); |
| | | events.offer(new SseEvent("error", e.getMessage())); |
| | | } |
| | | } finally { |
| | | try { |
| | | reader.close(); |
| | | } catch (Exception ignore) { |
| | | } |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 解析远程返回的 message endpoint。 |
| | | * 当对方错误返回回环地址时,会重写成当前挂载 URL 的主机地址。 |
| | | */ |
| | | private String resolveEndpoint(String rawEndpoint) { |
| | | if (rawEndpoint == null || rawEndpoint.trim().isEmpty()) { |
| | | return null; |
| | | } |
| | | try { |
| | | URI baseUri = new URI(mount.getUrl()); |
| | | URI endpointUri = baseUri.resolve(rawEndpoint.trim()); |
| | | String host = endpointUri.getHost(); |
| | | if (isLoopbackHost(host) && !sameHost(host, baseUri.getHost())) { |
| | | endpointUri = new URI( |
| | | baseUri.getScheme(), |
| | | endpointUri.getUserInfo(), |
| | | baseUri.getHost(), |
| | | endpointUri.getPort() > 0 ? endpointUri.getPort() : baseUri.getPort(), |
| | | endpointUri.getPath(), |
| | | endpointUri.getQuery(), |
| | | endpointUri.getFragment()); |
| | | logger.info("AI MCP SSE endpoint rewritten: mountCode={}, rawEndpoint={}, rewrittenEndpoint={}", |
| | | mount.getMountCode(), rawEndpoint, endpointUri); |
| | | } |
| | | return endpointUri.toString(); |
| | | } catch (Exception e) { |
| | | return rawEndpoint.trim(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 判断地址是否为本机回环地址。 |
| | | */ |
| | | private boolean isLoopbackHost(String host) { |
| | | return "127.0.0.1".equals(host) || "localhost".equalsIgnoreCase(host) || "::1".equals(host); |
| | | } |
| | | |
| | | /** |
| | | * 判断两个 host 是否等价。 |
| | | */ |
| | | private boolean sameHost(String left, String right) { |
| | | if (left == null || right == null) { |
| | | return false; |
| | | } |
| | | return left.equalsIgnoreCase(right); |
| | | } |
| | | |
| | | /** |
| | | * 关闭 SSE 会话,并异步清理底层连接资源。 |
| | | */ |
| | | @Override |
| | | public void close() throws Exception { |
| | | closed = true; |
| | | logger.info("AI MCP SSE closing session: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | if (worker != null) { |
| | | worker.interrupt(); |
| | | } |
| | | Thread cleanup = new Thread(() -> { |
| | | try { |
| | | connection.disconnect(); |
| | | } catch (Exception ignore) { |
| | | } |
| | | try { |
| | | reader.close(); |
| | | } catch (Exception ignore) { |
| | | } |
| | | logger.info("AI MCP SSE cleanup finished: mountCode={}, url={}", mount.getMountCode(), mount.getUrl()); |
| | | }, "ai-mcp-sse-cleanup-" + mount.getMountCode()); |
| | | cleanup.setDaemon(true); |
| | | cleanup.start(); |
| | | } |
| | | } |
| | | |
| | | /** |
| | | * 按挂载配置写入鉴权请求头。 |
| | | */ |
| | | private void applyAuthHeaders(HttpURLConnection connection, AiMcpMount mount) { |
| | | if (mount == null || mount.getAuthType() == null || mount.getAuthValue() == null || mount.getAuthValue().trim().isEmpty()) { |
| | | return; |
| | | } |
| | | String authType = mount.getAuthType().trim().toUpperCase(); |
| | | if (AiMcpConstants.AUTH_TYPE_BEARER.equals(authType)) { |
| | | connection.setRequestProperty("Authorization", "Bearer " + mount.getAuthValue().trim()); |
| | | } else if (AiMcpConstants.AUTH_TYPE_API_KEY.equals(authType)) { |
| | | connection.setRequestProperty("X-API-Key", mount.getAuthValue().trim()); |
| | | } |
| | | } |
| | | |
| | | private static class SseEvent { |
| | | private final String name; |
| | | private final String data; |
| | | |
| | | /** |
| | | * 封装一条 SSE 事件。 |
| | | */ |
| | | private SseEvent(String name, String data) { |
| | | this.name = name; |
| | | this.data = data; |
| | | } |
| | | |
| | | /** |
| | | * 返回事件名。 |
| | | */ |
| | | public String getName() { |
| | | return name; |
| | | } |
| | | |
| | | /** |
| | | * 返回事件数据文本。 |
| | | */ |
| | | public String getData() { |
| | | return data; |
| | | } |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | import com.vincent.rsf.server.system.service.AiCallLogService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiApiFailureSummaryService implements AiDiagnosticDataProvider { |
| | | |
| | | private static final String TOOL_CODE = "ai_call_failure"; |
| | | private static final String TOOL_NAME = "AI调用失败"; |
| | | |
| | | @Resource |
| | | private AiCallLogService aiCallLogService; |
| | | @Resource |
| | | private com.vincent.rsf.server.ai.config.AiProperties aiProperties; |
| | | |
| | | /** |
| | | * 返回 AI 调用失败工具默认顺序。 |
| | | */ |
| | | @Override |
| | | public int getOrder() { |
| | | return 50; |
| | | } |
| | | |
| | | /** |
| | | * 返回 AI 调用失败工具编码。 |
| | | */ |
| | | @Override |
| | | public String getToolCode() { |
| | | return TOOL_CODE; |
| | | } |
| | | |
| | | /** |
| | | * 返回 AI 调用失败工具展示名。 |
| | | */ |
| | | @Override |
| | | public String getToolName() { |
| | | return TOOL_NAME; |
| | | } |
| | | |
| | | /** |
| | | * 返回 AI 调用失败工具默认说明。 |
| | | */ |
| | | @Override |
| | | public String getDefaultToolPrompt() { |
| | | return "重点识别最近 AI 调用失败的模型、错误类型和时间窗口。"; |
| | | } |
| | | |
| | | /** |
| | | * 汇总最近一段时间内的 AI 调用失败记录。 |
| | | */ |
| | | @Override |
| | | public AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context) { |
| | | Date start = new Date(System.currentTimeMillis() - aiProperties.getApiFailureWindowHours() * 3600_000L); |
| | | List<AiCallLog> records = aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>() |
| | | .eq(AiCallLog::getTenantId, context.getTenantId()) |
| | | .eq(AiCallLog::getResult, 0) |
| | | .ge(AiCallLog::getCreateTime, start) |
| | | .orderByDesc(AiCallLog::getCreateTime) |
| | | .last("limit 10")); |
| | | Map<String, Object> meta = new LinkedHashMap<>(); |
| | | meta.put("count", records.size()); |
| | | if (records.isEmpty()) { |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("INFO") |
| | | .setSummaryText("最近 " + aiProperties.getApiFailureWindowHours() + " 小时未发现 AI 调用失败记录。") |
| | | .setRawMeta(meta); |
| | | } |
| | | List<String> parts = new ArrayList<>(); |
| | | for (AiCallLog item : records) { |
| | | parts.add((item.getModelCode() == null ? "未知模型" : item.getModelCode()) |
| | | + "(" |
| | | + (item.getErr() == null ? "无异常描述" : item.getErr()) |
| | | + ")"); |
| | | } |
| | | meta.put("latestErrors", parts); |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("WARN") |
| | | .setSummaryText("最近 " + aiProperties.getApiFailureWindowHours() + " 小时发现 " + records.size() + " 条 AI 调用失败记录,最近失败包括:" + String.join(";", parts)) |
| | | .setRawMeta(meta); |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.manager.entity.DeviceSite; |
| | | import com.vincent.rsf.server.manager.mapper.DeviceSiteMapper; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiDeviceSiteSummaryService implements AiDiagnosticDataProvider { |
| | | |
| | | private static final String TOOL_CODE = "device_site_summary"; |
| | | private static final String TOOL_NAME = "设备站点摘要"; |
| | | |
| | | @Resource |
| | | private DeviceSiteMapper deviceSiteMapper; |
| | | |
| | | /** |
| | | * 返回设备站点工具默认顺序。 |
| | | */ |
| | | @Override |
| | | public int getOrder() { |
| | | return 30; |
| | | } |
| | | |
| | | /** |
| | | * 返回设备站点工具编码。 |
| | | */ |
| | | @Override |
| | | public String getToolCode() { |
| | | return TOOL_CODE; |
| | | } |
| | | |
| | | /** |
| | | * 返回设备站点工具展示名。 |
| | | */ |
| | | @Override |
| | | public String getToolName() { |
| | | return TOOL_NAME; |
| | | } |
| | | |
| | | /** |
| | | * 返回设备站点工具默认说明。 |
| | | */ |
| | | @Override |
| | | public String getDefaultToolPrompt() { |
| | | return "结合设备站点摘要判断设备状态、巷道分布和最近更新站点。"; |
| | | } |
| | | |
| | | /** |
| | | * 汇总站点状态、设备分布和最近更新站点,生成设备站点摘要。 |
| | | */ |
| | | @Override |
| | | public AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context) { |
| | | String summary = buildDeviceSiteSummary(context); |
| | | String severity = summary.contains("未查询到") ? "WARN" : "INFO"; |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity(severity) |
| | | .setSummaryText(summary); |
| | | } |
| | | |
| | | /** |
| | | * 基于 man_device_site 生成设备站点总览。 |
| | | */ |
| | | private String buildDeviceSiteSummary(AiPromptContext context) { |
| | | List<DeviceSite> deviceSites = deviceSiteMapper.selectList(new LambdaQueryWrapper<DeviceSite>() |
| | | .select(DeviceSite::getSite, DeviceSite::getName, DeviceSite::getType, DeviceSite::getDevice, |
| | | DeviceSite::getDeviceCode, DeviceSite::getDeviceSite, DeviceSite::getStatus, |
| | | DeviceSite::getChannel, DeviceSite::getUpdateTime) |
| | | .eq(DeviceSite::getTenantId, context.getTenantId()) |
| | | .orderByDesc(DeviceSite::getUpdateTime) |
| | | .last("limit 50")); |
| | | |
| | | if (deviceSites.isEmpty()) { |
| | | return "当前未查询到 man_device_site 设备站点数据,请提示用户核对基础数据或租户配置。"; |
| | | } |
| | | |
| | | Map<String, Long> statusCounters = new LinkedHashMap<>(); |
| | | Map<String, Long> deviceCounters = new LinkedHashMap<>(); |
| | | Map<String, Long> channelCounters = new LinkedHashMap<>(); |
| | | for (DeviceSite deviceSite : deviceSites) { |
| | | String status = resolveStatus(deviceSite.getStatus()); |
| | | String device = emptyToDefault(deviceSite.getDevice$(), "未配置设备类型"); |
| | | String channel = deviceSite.getChannel() == null ? "未配置巷道" : "巷道" + deviceSite.getChannel(); |
| | | statusCounters.put(status, statusCounters.getOrDefault(status, 0L) + 1); |
| | | deviceCounters.put(device, deviceCounters.getOrDefault(device, 0L) + 1); |
| | | channelCounters.put(channel, channelCounters.getOrDefault(channel, 0L) + 1); |
| | | } |
| | | |
| | | StringBuilder summary = new StringBuilder(); |
| | | summary.append("以下是基于 man_device_site 的实时设备站点摘要,请结合任务和库存数据综合判断:"); |
| | | summary.append("\n设备站点总览:共 ").append(deviceSites.size()).append(" 条有效站点记录。"); |
| | | summary.append("\n站点状态分布:").append(formatCounter(statusCounters)).append("。"); |
| | | summary.append("\n设备类型分布:").append(formatCounter(deviceCounters)).append("。"); |
| | | summary.append("\n巷道分布:").append(formatCounter(channelCounters)).append("。"); |
| | | summary.append("\n最近更新站点 TOP5:").append(formatLatestSites(deviceSites.subList(0, Math.min(deviceSites.size(), 5)))).append("。"); |
| | | return summary.toString(); |
| | | } |
| | | |
| | | /** |
| | | * 格式化计数类摘要。 |
| | | */ |
| | | private String formatCounter(Map<String, Long> counter) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (Map.Entry<String, Long> item : counter.entrySet()) { |
| | | parts.add(item.getKey() + " " + item.getValue() + " 条"); |
| | | } |
| | | return String.join(",", parts); |
| | | } |
| | | |
| | | /** |
| | | * 格式化最近更新站点列表。 |
| | | */ |
| | | private String formatLatestSites(List<DeviceSite> deviceSites) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (DeviceSite deviceSite : deviceSites) { |
| | | parts.add(emptyToDefault(deviceSite.getName(), emptyToDefault(deviceSite.getSite(), "未命名站点")) |
| | | + "(" |
| | | + resolveStatus(deviceSite.getStatus()) |
| | | + ",设备 " |
| | | + emptyToDefault(deviceSite.getDevice$(), "-") |
| | | + ",接驳位 " |
| | | + emptyToDefault(deviceSite.getDeviceCode(), "-") |
| | | + ",巷道 " |
| | | + (deviceSite.getChannel() == null ? "-" : deviceSite.getChannel()) |
| | | + ")"); |
| | | } |
| | | return String.join(";", parts); |
| | | } |
| | | |
| | | /** |
| | | * 将站点状态码转成可读文本。 |
| | | */ |
| | | private String resolveStatus(Integer status) { |
| | | if (status == null) { |
| | | return "未知状态"; |
| | | } |
| | | return status == 1 ? "正常" : status == 0 ? "冻结" : "状态" + status; |
| | | } |
| | | |
| | | /** |
| | | * 统一处理空值展示。 |
| | | */ |
| | | private String emptyToDefault(String value, String defaultValue) { |
| | | return value == null || value.trim().isEmpty() ? defaultValue : value; |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | |
| | | public interface AiDiagnosticDataProvider { |
| | | |
| | | /** |
| | | * 返回内部工具编码,作为本地 MCP 工具名后缀和工具配置主键。 |
| | | */ |
| | | String getToolCode(); |
| | | |
| | | /** |
| | | * 返回工具展示名称。 |
| | | */ |
| | | String getToolName(); |
| | | |
| | | /** |
| | | * 返回工具默认说明,用于工具目录展示和默认 Prompt 引导。 |
| | | */ |
| | | default String getDefaultToolPrompt() { |
| | | return ""; |
| | | } |
| | | |
| | | /** |
| | | * 返回工具默认顺序。 |
| | | */ |
| | | int getOrder(); |
| | | |
| | | /** |
| | | * 执行内部工具的真实业务查询,并返回摘要化结果。 |
| | | */ |
| | | AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context); |
| | | |
| | | } |
| | | |
| | | |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.system.entity.OperationRecord; |
| | | import com.vincent.rsf.server.system.service.OperationRecordService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.ArrayList; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @Service |
| | | public class AiOperationRecordSummaryService implements AiDiagnosticDataProvider { |
| | | |
| | | private static final String TOOL_CODE = "operation_record"; |
| | | private static final String TOOL_NAME = "异常操作日志"; |
| | | |
| | | @Resource |
| | | private OperationRecordService operationRecordService; |
| | | @Resource |
| | | private com.vincent.rsf.server.ai.config.AiProperties aiProperties; |
| | | |
| | | /** |
| | | * 返回异常操作日志工具默认顺序。 |
| | | */ |
| | | @Override |
| | | public int getOrder() { |
| | | return 40; |
| | | } |
| | | |
| | | /** |
| | | * 返回异常操作日志工具编码。 |
| | | */ |
| | | @Override |
| | | public String getToolCode() { |
| | | return TOOL_CODE; |
| | | } |
| | | |
| | | /** |
| | | * 返回异常操作日志工具展示名。 |
| | | */ |
| | | @Override |
| | | public String getToolName() { |
| | | return TOOL_NAME; |
| | | } |
| | | |
| | | /** |
| | | * 返回异常操作日志工具默认说明。 |
| | | */ |
| | | @Override |
| | | public String getDefaultToolPrompt() { |
| | | return "重点识别最近失败操作和高频异常。"; |
| | | } |
| | | |
| | | /** |
| | | * 汇总最近一段时间内的失败操作记录。 |
| | | */ |
| | | @Override |
| | | public AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context) { |
| | | Date start = new Date(System.currentTimeMillis() - aiProperties.getDiagnosticLogWindowHours() * 3600_000L); |
| | | List<OperationRecord> records = operationRecordService.list(new LambdaQueryWrapper<OperationRecord>() |
| | | .eq(OperationRecord::getTenantId, context.getTenantId()) |
| | | .eq(OperationRecord::getResult, 0) |
| | | .ge(OperationRecord::getCreateTime, start) |
| | | .orderByDesc(OperationRecord::getCreateTime) |
| | | .last("limit 10")); |
| | | Map<String, Object> meta = new LinkedHashMap<>(); |
| | | meta.put("count", records.size()); |
| | | if (records.isEmpty()) { |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("INFO") |
| | | .setSummaryText("最近 " + aiProperties.getDiagnosticLogWindowHours() + " 小时未发现操作日志失败记录。") |
| | | .setRawMeta(meta); |
| | | } |
| | | List<String> parts = new ArrayList<>(); |
| | | for (OperationRecord item : records) { |
| | | parts.add((item.getNamespace() == null ? "未知操作" : item.getNamespace()) |
| | | + "(" |
| | | + (item.getErr() == null ? "无异常描述" : item.getErr()) |
| | | + ")"); |
| | | } |
| | | meta.put("latestErrors", parts); |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("WARN") |
| | | .setSummaryText("最近 " + aiProperties.getDiagnosticLogWindowHours() + " 小时发现 " + records.size() + " 条失败操作记录,最近异常包括:" + String.join(";", parts)) |
| | | .setRawMeta(meta); |
| | | } |
| | | |
| | | } |
| | | |
| | | |
| | | |
| File was renamed from rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiTaskSummaryService.java |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.manager.entity.Task; |
| | | import com.vincent.rsf.server.manager.enums.TaskStsType; |
| | |
| | | import java.util.*; |
| | | |
| | | @Service |
| | | public class AiTaskSummaryService implements AiPromptContextProvider { |
| | | public class AiTaskSummaryService implements AiDiagnosticDataProvider { |
| | | |
| | | private static final String TOOL_CODE = "task_summary"; |
| | | private static final String TOOL_NAME = "任务摘要"; |
| | | |
| | | @Resource |
| | | private TaskMapper taskMapper; |
| | | |
| | | /** |
| | | * 返回任务工具默认顺序。 |
| | | */ |
| | | @Override |
| | | public boolean supports(AiPromptContext context) { |
| | | if (context == null || context.getQuestion() == null) { |
| | | return false; |
| | | } |
| | | String normalized = context.getQuestion().toLowerCase(Locale.ROOT); |
| | | return normalized.contains("task") |
| | | || normalized.contains("任务") |
| | | || normalized.contains("出库任务") |
| | | || normalized.contains("入库任务") |
| | | || normalized.contains("移库任务") |
| | | || normalized.contains("备货任务"); |
| | | public int getOrder() { |
| | | return 20; |
| | | } |
| | | |
| | | /** |
| | | * 返回任务工具编码。 |
| | | */ |
| | | @Override |
| | | public String buildContext(AiPromptContext context) { |
| | | public String getToolCode() { |
| | | return TOOL_CODE; |
| | | } |
| | | |
| | | /** |
| | | * 返回任务工具展示名。 |
| | | */ |
| | | @Override |
| | | public String getToolName() { |
| | | return TOOL_NAME; |
| | | } |
| | | |
| | | /** |
| | | * 返回任务工具默认说明。 |
| | | */ |
| | | @Override |
| | | public String getDefaultToolPrompt() { |
| | | return "结合任务摘要识别积压、异常状态和最近变更任务。"; |
| | | } |
| | | |
| | | /** |
| | | * 汇总任务状态和最近变更任务,生成任务摘要工具结果。 |
| | | */ |
| | | @Override |
| | | public AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context) { |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("INFO") |
| | | .setSummaryText(buildTaskSummary()); |
| | | } |
| | | |
| | | /** |
| | | * 基于 man_task 生成任务总览、状态分布、类型分布和最近任务摘要。 |
| | | */ |
| | | private String buildTaskSummary() { |
| | | List<Task> activeTasks = taskMapper.selectList(new LambdaQueryWrapper<Task>() |
| | | .select(Task::getTaskCode, Task::getTaskStatus, Task::getTaskType, Task::getOrgLoc, Task::getTargLoc, Task::getUpdateTime) |
| | | .eq(Task::getStatus, 1)); |
| | |
| | | return summary.toString(); |
| | | } |
| | | |
| | | /** |
| | | * 格式化任务状态统计。 |
| | | */ |
| | | private String formatStatuses(Map<Integer, Long> rows) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (Map.Entry<Integer, Long> row : rows.entrySet()) { |
| | |
| | | return String.join(",", parts); |
| | | } |
| | | |
| | | /** |
| | | * 格式化任务类型统计。 |
| | | */ |
| | | private String formatTypes(Map<Integer, Long> rows) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (Map.Entry<Integer, Long> row : rows.entrySet()) { |
| | |
| | | return String.join(",", parts); |
| | | } |
| | | |
| | | /** |
| | | * 格式化最近更新任务列表。 |
| | | */ |
| | | private String formatLatestTasks(List<Task> tasks) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (Task task : tasks) { |
| | |
| | | return String.join(";", parts); |
| | | } |
| | | |
| | | /** |
| | | * 将任务状态编码转换为可读文案。 |
| | | */ |
| | | private String resolveTaskStatus(Integer taskStatus) { |
| | | if (taskStatus == null) { |
| | | return "未知状态"; |
| | |
| | | return "状态" + taskStatus; |
| | | } |
| | | |
| | | /** |
| | | * 将任务类型编码转换为可读文案。 |
| | | */ |
| | | private String resolveTaskType(Integer taskType) { |
| | | if (taskType == null) { |
| | | return "未知类型"; |
| | |
| | | return "类型" + taskType; |
| | | } |
| | | |
| | | /** |
| | | * 统一处理空字符串显示。 |
| | | */ |
| | | private String emptyToDash(String value) { |
| | | return value == null || value.trim().isEmpty() ? "-" : value; |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| File was renamed from rsf-server/src/main/java/com/vincent/rsf/server/ai/service/AiWarehouseSummaryService.java |
| | |
| | | package com.vincent.rsf.server.ai.service; |
| | | package com.vincent.rsf.server.ai.service.provider; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.server.ai.model.AiDiagnosticToolResult; |
| | | import com.vincent.rsf.server.ai.model.AiPromptContext; |
| | | import com.vincent.rsf.server.manager.entity.Loc; |
| | | import com.vincent.rsf.server.manager.entity.LocItem; |
| | |
| | | import java.util.*; |
| | | |
| | | @Service |
| | | public class AiWarehouseSummaryService implements AiPromptContextProvider { |
| | | public class AiWarehouseSummaryService implements AiDiagnosticDataProvider { |
| | | |
| | | private static final String TOOL_CODE = "warehouse_summary"; |
| | | private static final String TOOL_NAME = "库存摘要"; |
| | | |
| | | private static final Map<String, String> LOC_STATUS_LABELS = new LinkedHashMap<>(); |
| | | |
| | |
| | | @Resource |
| | | private LocItemMapper locItemMapper; |
| | | |
| | | /** |
| | | * 返回库存类内部工具的默认顺序。 |
| | | */ |
| | | @Override |
| | | public boolean supports(AiPromptContext context) { |
| | | return context != null && shouldSummarize(context.getQuestion()); |
| | | public int getOrder() { |
| | | return 10; |
| | | } |
| | | |
| | | /** |
| | | * 返回库存工具编码。 |
| | | */ |
| | | @Override |
| | | public String buildContext(AiPromptContext context) { |
| | | if (!supports(context)) { |
| | | return ""; |
| | | } |
| | | public String getToolCode() { |
| | | return TOOL_CODE; |
| | | } |
| | | |
| | | /** |
| | | * 返回库存工具展示名。 |
| | | */ |
| | | @Override |
| | | public String getToolName() { |
| | | return TOOL_NAME; |
| | | } |
| | | |
| | | /** |
| | | * 返回库存工具默认说明。 |
| | | */ |
| | | @Override |
| | | public String getDefaultToolPrompt() { |
| | | return "结合库存摘要判断库位状态、库存结构与重点物料分布。"; |
| | | } |
| | | |
| | | /** |
| | | * 汇总库位与库存明细,生成库存摘要工具结果。 |
| | | */ |
| | | @Override |
| | | public AiDiagnosticToolResult buildDiagnosticData(AiPromptContext context) { |
| | | return new AiDiagnosticToolResult() |
| | | .setToolCode(getToolCode()) |
| | | .setToolName(getToolName()) |
| | | .setSeverity("INFO") |
| | | .setSummaryText(buildWarehouseSummary(context)); |
| | | } |
| | | |
| | | /** |
| | | * 基于 man_loc 和 man_loc_item 生成库存概览、库位状态分布和 TOP 统计。 |
| | | */ |
| | | private String buildWarehouseSummary(AiPromptContext context) { |
| | | |
| | | List<Loc> activeLocs = locMapper.selectList(new LambdaQueryWrapper<Loc>() |
| | | .select(Loc::getUseStatus) |
| | |
| | | return summary.toString(); |
| | | } |
| | | |
| | | private boolean shouldSummarize(String question) { |
| | | if (question == null || question.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | String normalized = question.toLowerCase(Locale.ROOT); |
| | | return normalized.contains("loc") |
| | | || normalized.contains("库位") |
| | | || normalized.contains("货位") |
| | | || normalized.contains("库区") |
| | | || normalized.contains("库存") |
| | | || normalized.contains("物料") |
| | | || normalized.contains("巷道") |
| | | || normalized.contains("储位"); |
| | | } |
| | | |
| | | /** |
| | | * 将库位状态计数格式化为可读文本。 |
| | | */ |
| | | private String formatLocStatuses(Map<String, Long> counters) { |
| | | if (counters == null || counters.isEmpty()) { |
| | | return "暂无数据"; |
| | |
| | | return String.join(",", parts); |
| | | } |
| | | |
| | | /** |
| | | * 格式化库存最多的库位列表。 |
| | | */ |
| | | private String formatTopLocs(List<Map.Entry<String, LocAggregate>> rows) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (Map.Entry<String, LocAggregate> row : rows) { |
| | |
| | | return String.join(";", parts); |
| | | } |
| | | |
| | | /** |
| | | * 格式化库存最多的物料列表。 |
| | | */ |
| | | private String formatTopMaterials(List<MaterialAggregate> rows) { |
| | | List<String> parts = new ArrayList<>(); |
| | | for (MaterialAggregate row : rows) { |
| | |
| | | return String.join(";", parts); |
| | | } |
| | | |
| | | /** |
| | | * 统一格式化数量值。 |
| | | */ |
| | | private String formatDecimal(Object value) { |
| | | BigDecimal decimal = toDecimal(value); |
| | | return decimal.stripTrailingZeros().toPlainString(); |
| | | } |
| | | |
| | | /** |
| | | * 将不同类型的数量字段统一转换为 BigDecimal。 |
| | | */ |
| | | private BigDecimal toDecimal(Object value) { |
| | | if (value == null) { |
| | | return BigDecimal.ZERO; |
| | |
| | | private final Set<String> locCodes = new HashSet<>(); |
| | | } |
| | | } |
| | | |
| | | |
| | | |
| | |
| | | return this.host + ":" + this.port + "/" + this.prePath; |
| | | } |
| | | } |
| | | |
| | |
| | | return new RestTemplate(); |
| | | } |
| | | } |
| | | |
| | |
| | | public class BaseSyncParams { |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("物料编码") |
| | | private String matnrCode; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("单据明细") |
| | | private List<WaitPakinItem> itemList; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("物料编码") |
| | | private String matnrCode; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty(value = "单据明细") |
| | | private List<OrderItem> children; |
| | | } |
| | | |
| | |
| | | private List<WkOrderItem> itemList; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty(value = "库区标识", required = true) |
| | | private Long whAreaId; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("备注说明") |
| | | private String MemoDtl; |
| | | } |
| | | |
| | |
| | | private List<ReportDataParam> Data; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("差异单明细") |
| | | private List<CheckDiffItem> checkDiffItems; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("单据名称列表") |
| | | private List<SyncReviseItems> reviseItems; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty(value= "状态 1: 正常 0: 冻结 ") |
| | | private Integer status; |
| | | } |
| | | |
| | |
| | | private List<SyncOrdersItem> items; |
| | | |
| | | } |
| | | |
| | |
| | | private String projectCode; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("调拔单明细") |
| | | private List<SyncTransferItems> items; |
| | | } |
| | | |
| | |
| | | // private Integer locType2; //库位类型 |
| | | // private Integer locType3; //库位类型 |
| | | } |
| | | |
| | |
| | | private String latitude; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("差异单明细") |
| | | List<CheckDiffItem> items; |
| | | } |
| | | |
| | |
| | | |
| | | private List<TransferItem> items; |
| | | } |
| | | |
| | |
| | | |
| | | private List<WkOrderItem> orderItems; |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.api.controller.mcp; |
| | | |
| | | import com.fasterxml.jackson.databind.JsonNode; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpProtocolService; |
| | | import com.vincent.rsf.server.system.controller.BaseController; |
| | | import org.springframework.web.bind.annotation.PostMapping; |
| | | import org.springframework.web.bind.annotation.RequestBody; |
| | | import org.springframework.web.bind.annotation.RequestHeader; |
| | | import org.springframework.web.bind.annotation.RequestMapping; |
| | | import org.springframework.web.bind.annotation.RequestParam; |
| | | import org.springframework.web.bind.annotation.RestController; |
| | | |
| | | import javax.annotation.Resource; |
| | | @RestController |
| | | @RequestMapping("/ai/mcp") |
| | | public class AiMcpProtocolController extends BaseController { |
| | | |
| | | @Resource |
| | | private AiMcpProtocolService aiMcpProtocolService; |
| | | |
| | | @PostMapping |
| | | public Object handle(@RequestBody JsonNode body, |
| | | @RequestHeader(value = "X-Tenant-Id", required = false) String tenantHeader, |
| | | @RequestParam(value = "tenantId", required = false) Long tenantParam) { |
| | | Long tenantId = resolveTenantId(tenantHeader, tenantParam); |
| | | return aiMcpProtocolService.handle(tenantId, body); |
| | | } |
| | | |
| | | private Long resolveTenantId(String tenantHeader, Long tenantParam) { |
| | | if (tenantParam != null) { |
| | | return tenantParam; |
| | | } |
| | | if (tenantHeader != null && !tenantHeader.trim().isEmpty()) { |
| | | try { |
| | | return Long.valueOf(tenantHeader.trim()); |
| | | } catch (Exception ignore) { |
| | | } |
| | | } |
| | | Long loginTenantId = getTenantId(); |
| | | return loginTenantId == null ? 1L : loginTenantId; |
| | | } |
| | | } |
| | | |
| | |
| | | return agvService.AGVBindAndInTaskStartT(param, getLoginUserId()); |
| | | } |
| | | } |
| | | |
| | |
| | | return mobileService.generateTask(map, getLoginUserId()); |
| | | } |
| | | } |
| | | |
| | |
| | | |
| | | @ApiModelProperty("响应结果") |
| | | private Object data; |
| | | |
| | | } |
| | | } |
| | |
| | | //待下发任务发送至中转站 |
| | | public static String MISSION_TRANSFER_STATION = "/rsf-open-api/mission/task/master/control"; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("备注") |
| | | private String memo; |
| | | } |
| | | |
| | |
| | | private List<WkOrderItem> wkOrderItems; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("供应商编码") |
| | | private String suplierCode; |
| | | } |
| | | |
| | |
| | | |
| | | private String status$; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("库位信息") |
| | | private List<Loc> locs; |
| | | } |
| | | |
| | |
| | | private TaskLocAreaDto locArea; |
| | | |
| | | } |
| | | |
| | |
| | | public Short type; |
| | | public String val; |
| | | } |
| | | |
| | |
| | | private Integer locType1; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("条目数") |
| | | private Integer pageSize; |
| | | } |
| | | |
| | |
| | | |
| | | private List<ContainerWaveDto> containerWaveDtos; |
| | | } |
| | | |
| | |
| | | private Integer locType1; |
| | | |
| | | } |
| | | |
| | |
| | | */ |
| | | public Double anfme; |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("容器码") |
| | | private String zpallet; |
| | | } |
| | | |
| | |
| | | private Integer taskPri; |
| | | |
| | | } |
| | | |
| | |
| | | this.retryTimes = flowStepInstance.getRetryTimes(); |
| | | } |
| | | } |
| | | |
| | |
| | | private Integer locType1; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("优先级") |
| | | private Integer priority; |
| | | } |
| | | |
| | |
| | | private Integer type = 0; |
| | | |
| | | } |
| | | |
| | |
| | | @ApiModelProperty("任务明细") |
| | | private List<TaskItemParam> taskList; |
| | | } |
| | | |
| | |
| | | R AGVBindAndInTaskStart(String barcode); |
| | | boolean AGVBindAndInTaskStart(String barcode, String sta); |
| | | } |
| | | |
| | |
| | | |
| | | R generateTask(Map<String, Object> map, Long loginUserId); |
| | | } |
| | | |
| | |
| | | |
| | | R getInOutHistories(Map<String, Object> param); |
| | | } |
| | | |
| | |
| | | |
| | | R getCheckTaskItemList2(String barcode); |
| | | } |
| | | |
| | |
| | | |
| | | R locOperate(PdaGeneralParam generalParam, User loginUser); |
| | | } |
| | | |
| | |
| | | |
| | | R taskItemList(PdaGeneralParam param, Long loginUserId); |
| | | } |
| | | |
| | |
| | | */ |
| | | R erpQueryInventorySummary(InventoryQueryConditionParam condition); |
| | | } |
| | | |
| | |
| | | |
| | | R uploadCheckOrder(ReportParams params); |
| | | } |
| | | |
| | |
| | | R wcsReassignLoc(ReassignLocParam params); |
| | | R wcsChangeLoc(ChangeLocParam params); |
| | | } |
| | | |
| | |
| | | return R.ok(Cools.add("checkDiffItems", checkDiffItems).add("checkDiff", checkDiff)); |
| | | } |
| | | } |
| | | |
| | |
| | | item -> new BigDecimal(item.getAnfme().toString()).equals(new BigDecimal(item.getQty().toString()))); |
| | | } |
| | | } |
| | | |
| | |
| | | // return R.ok(JSONObject.toJSONString(params)); |
| | | } |
| | | } |
| | | |
| | |
| | | private int groupCount; |
| | | |
| | | } |
| | | |
| | |
| | | return date.getTime(); |
| | | } |
| | | } |
| | | |
| | |
| | | boolean result() default true; |
| | | |
| | | } |
| | | |
| | |
| | | public static final Integer TASK_SORT_MIN_VALUE = 0; |
| | | |
| | | } |
| | | |
| | |
| | | public final static String EMAIL_EXIT = "10006 - Email address already exist"; |
| | | |
| | | } |
| | | |
| | |
| | | @Data |
| | | public class QueueTask { |
| | | } |
| | | |
| | |
| | | public String type; |
| | | public String desc; |
| | | } |
| | | |
| | |
| | | "/wcs/**", |
| | | "/monitor/**", |
| | | "/mcp/**", |
| | | "/ai/mcp", |
| | | "/mes/**" |
| | | }; |
| | | |
| | |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | List<String> getDataFieldSort(); |
| | | |
| | | } |
| | | |
| | |
| | | String SYSTEM_20001 = "20001-许可证已失效";
|
| | |
|
| | | }
|
| | |
|
| | |
| | | /**库存调整*/ |
| | | public final static String SYS_STOCK_REVISE_TYPE = "sys_stock_revise_type"; |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | import com.vincent.rsf.server.system.service.AiCallLogService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiCallLogController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiCallLogService aiCallLogService; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiCallLog:list')") |
| | | @PostMapping("/aiCallLog/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiCallLog, BaseParam> pageParam = new PageParam<>(baseParam, AiCallLog.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiCallLog> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiCallLogService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiCallLog:list')") |
| | | @GetMapping("/aiCallLog/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiCallLog callLog = getTenantRecord(id); |
| | | if (callLog == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok().add(callLog); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiCallLog:list')") |
| | | @PostMapping("/aiCallLog/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>() |
| | | .eq(AiCallLog::getTenantId, getTenantId())), AiCallLog.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiCallLog:list')") |
| | | @GetMapping("/ai/call-log/list") |
| | | public R customList() { |
| | | return R.ok().add(aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>() |
| | | .eq(AiCallLog::getTenantId, getTenantId()) |
| | | .orderByDesc(AiCallLog::getCreateTime, AiCallLog::getId))); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiCallLog:list')") |
| | | @GetMapping("/ai/call-log/stats") |
| | | public R stats() { |
| | | List<AiCallLog> logs = aiCallLogService.list(new LambdaQueryWrapper<AiCallLog>() |
| | | .eq(AiCallLog::getTenantId, getTenantId()) |
| | | .orderByDesc(AiCallLog::getCreateTime, AiCallLog::getId)); |
| | | long total = logs.size(); |
| | | long successCount = logs.stream().filter(item -> Integer.valueOf(1).equals(item.getResult())).count(); |
| | | long failCount = logs.stream().filter(item -> Integer.valueOf(0).equals(item.getResult())).count(); |
| | | long modelCount = logs.stream().map(AiCallLog::getModelCode).filter(item -> item != null && !item.trim().isEmpty()).distinct().count(); |
| | | long routeCount = logs.stream().map(AiCallLog::getRouteCode).filter(item -> item != null && !item.trim().isEmpty()).distinct().count(); |
| | | long totalSpend = logs.stream().map(AiCallLog::getSpendTime).filter(item -> item != null && item > 0L).mapToLong(Long::longValue).sum(); |
| | | long spendCount = logs.stream().map(AiCallLog::getSpendTime).filter(item -> item != null && item > 0L).count(); |
| | | Date now = new Date(); |
| | | long last24hCount = logs.stream() |
| | | .map(AiCallLog::getCreateTime) |
| | | .filter(item -> item != null && now.getTime() - item.getTime() <= 24L * 60L * 60L * 1000L) |
| | | .count(); |
| | | |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("total", total); |
| | | payload.put("successCount", successCount); |
| | | payload.put("failCount", failCount); |
| | | payload.put("successRate", total <= 0 ? 0D : (successCount * 100D) / total); |
| | | payload.put("avgSpendTime", spendCount <= 0 ? 0L : totalSpend / spendCount); |
| | | payload.put("modelCount", modelCount); |
| | | payload.put("routeCount", routeCount); |
| | | payload.put("last24hCount", last24hCount); |
| | | return R.ok().add(payload); |
| | | } |
| | | |
| | | private AiCallLog getTenantRecord(Long id) { |
| | | if (id == null) { |
| | | return null; |
| | | } |
| | | return aiCallLogService.getOne(new LambdaQueryWrapper<AiCallLog>() |
| | | .eq(AiCallLog::getTenantId, getTenantId()) |
| | | .eq(AiCallLog::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisRecordService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.net.URLEncoder; |
| | | import java.nio.charset.StandardCharsets; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiDiagnosisController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiDiagnosisRecordService aiDiagnosisRecordService; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @PostMapping("/aiDiagnosis/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiDiagnosisRecord, BaseParam> pageParam = new PageParam<>(baseParam, AiDiagnosisRecord.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiDiagnosisRecord> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiDiagnosisRecordService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @GetMapping("/aiDiagnosis/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiDiagnosisRecord record = getTenantRecord(id); |
| | | if (record == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok().add(record); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @PostMapping("/aiDiagnosis/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiDiagnosisRecordService.list(new LambdaQueryWrapper<AiDiagnosisRecord>() |
| | | .eq(AiDiagnosisRecord::getTenantId, getTenantId())), AiDiagnosisRecord.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @GetMapping("/ai/diagnosis/list") |
| | | public R diagnosisList() { |
| | | return R.ok().add(aiDiagnosisRecordService.list(new LambdaQueryWrapper<AiDiagnosisRecord>() |
| | | .eq(AiDiagnosisRecord::getTenantId, getTenantId()) |
| | | .orderByDesc(AiDiagnosisRecord::getCreateTime, AiDiagnosisRecord::getId))); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @GetMapping("/ai/diagnosis/detail/{id}") |
| | | public R detail(@PathVariable("id") Long id) { |
| | | AiDiagnosisRecord record = getTenantRecord(id); |
| | | if (record == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok().add(record); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosis:list')") |
| | | @GetMapping("/ai/diagnosis/report/export/{id}") |
| | | public void exportReport(@PathVariable("id") Long id, HttpServletResponse response) throws Exception { |
| | | AiDiagnosisRecord record = getTenantRecord(id); |
| | | if (record == null) { |
| | | response.setStatus(404); |
| | | return; |
| | | } |
| | | String fileName = URLEncoder.encode((record.getDiagnosisNo() == null ? "ai-diagnosis-report" : record.getDiagnosisNo()) + ".md", StandardCharsets.UTF_8.name()); |
| | | response.setCharacterEncoding(StandardCharsets.UTF_8.name()); |
| | | response.setContentType("text/markdown; charset=utf-8"); |
| | | response.setHeader("Content-Disposition", "attachment; filename=" + fileName); |
| | | response.getWriter().write(record.getReportMarkdown() == null ? "" : record.getReportMarkdown()); |
| | | response.getWriter().flush(); |
| | | } |
| | | |
| | | private AiDiagnosisRecord getTenantRecord(Long id) { |
| | | if (id == null) { |
| | | return null; |
| | | } |
| | | return aiDiagnosisRecordService.getOne(new LambdaQueryWrapper<AiDiagnosisRecord>() |
| | | .eq(AiDiagnosisRecord::getTenantId, getTenantId()) |
| | | .eq(AiDiagnosisRecord::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.ai.service.diagnosis.AiDiagnosisPlanRunnerService; |
| | | import com.vincent.rsf.server.common.annotation.OperationLog; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisPlanService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.scheduling.concurrent.ThreadPoolTaskScheduler; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiDiagnosisPlanController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiDiagnosisPlanService aiDiagnosisPlanService; |
| | | @Autowired |
| | | private AiDiagnosisPlanRunnerService aiDiagnosisPlanRunnerService; |
| | | @Autowired |
| | | private ThreadPoolTaskScheduler taskScheduler; |
| | | @Autowired |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:list')") |
| | | @PostMapping("/aiDiagnosisPlan/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiDiagnosisPlan, BaseParam> pageParam = new PageParam<>(baseParam, AiDiagnosisPlan.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiDiagnosisPlan> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiDiagnosisPlanService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:list')") |
| | | @GetMapping("/aiDiagnosisPlan/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiDiagnosisPlan plan = aiDiagnosisPlanService.getTenantPlan(getTenantId(), id); |
| | | if (plan == null) { |
| | | return R.error("plan not found"); |
| | | } |
| | | return R.ok().add(plan); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:save')") |
| | | @OperationLog("Create AiDiagnosisPlan") |
| | | @PostMapping("/aiDiagnosisPlan/save") |
| | | public R save(@RequestBody AiDiagnosisPlan plan) { |
| | | if (Cools.isEmpty(plan.getPlanName()) || Cools.isEmpty(plan.getCronExpr())) { |
| | | return R.error("计划名称和Cron表达式不能为空"); |
| | | } |
| | | if (!aiDiagnosisPlanService.validateCron(plan.getCronExpr())) { |
| | | return R.error("Cron表达式不合法"); |
| | | } |
| | | Date now = new Date(); |
| | | plan.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | plan.setTenantId(getTenantId()); |
| | | plan.setSceneCode(Cools.isEmpty(plan.getSceneCode()) ? "system_diagnose" : plan.getSceneCode()); |
| | | plan.setRunningFlag(0); |
| | | plan.setStatus(plan.getStatus() == null ? 1 : plan.getStatus()); |
| | | plan.setNextRunTime(Integer.valueOf(1).equals(plan.getStatus()) |
| | | ? aiDiagnosisPlanService.calculateNextRunTime(plan.getCronExpr(), now) |
| | | : null); |
| | | plan.setCreateBy(getLoginUserId()); |
| | | plan.setCreateTime(now); |
| | | plan.setUpdateBy(getLoginUserId()); |
| | | plan.setUpdateTime(now); |
| | | if (!aiDiagnosisPlanService.save(plan)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | return R.ok("Save Success").add(plan); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:update')") |
| | | @OperationLog("Update AiDiagnosisPlan") |
| | | @PostMapping("/aiDiagnosisPlan/update") |
| | | public R update(@RequestBody AiDiagnosisPlan plan) { |
| | | AiDiagnosisPlan existed = aiDiagnosisPlanService.getTenantPlan(getTenantId(), plan.getId()); |
| | | if (existed == null) { |
| | | return R.error("plan not found"); |
| | | } |
| | | if (Cools.isEmpty(plan.getPlanName()) || Cools.isEmpty(plan.getCronExpr())) { |
| | | return R.error("计划名称和Cron表达式不能为空"); |
| | | } |
| | | if (!aiDiagnosisPlanService.validateCron(plan.getCronExpr())) { |
| | | return R.error("Cron表达式不合法"); |
| | | } |
| | | plan.setTenantId(getTenantId()); |
| | | plan.setSceneCode(Cools.isEmpty(plan.getSceneCode()) ? existed.getSceneCode() : plan.getSceneCode()); |
| | | plan.setRunningFlag(existed.getRunningFlag()); |
| | | plan.setLastResult(existed.getLastResult()); |
| | | plan.setLastDiagnosisId(existed.getLastDiagnosisId()); |
| | | plan.setLastRunTime(existed.getLastRunTime()); |
| | | plan.setLastMessage(existed.getLastMessage()); |
| | | plan.setNextRunTime(Integer.valueOf(1).equals(plan.getStatus()) |
| | | ? aiDiagnosisPlanService.calculateNextRunTime(plan.getCronExpr(), new Date()) |
| | | : null); |
| | | plan.setCreateBy(existed.getCreateBy()); |
| | | plan.setCreateTime(existed.getCreateTime()); |
| | | plan.setUpdateBy(getLoginUserId()); |
| | | plan.setUpdateTime(new Date()); |
| | | if (!aiDiagnosisPlanService.updateById(plan)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok("Update Success").add(plan); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:remove')") |
| | | @OperationLog("Delete AiDiagnosisPlan") |
| | | @PostMapping("/aiDiagnosisPlan/remove/{ids}") |
| | | public R remove(@PathVariable Long[] ids) { |
| | | List<Long> idList = Arrays.asList(ids); |
| | | List<AiDiagnosisPlan> plans = aiDiagnosisPlanService.list(new LambdaQueryWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getTenantId, getTenantId()) |
| | | .in(AiDiagnosisPlan::getId, idList)); |
| | | if (plans.size() != idList.size() || !aiDiagnosisPlanService.removeByIds(idList)) { |
| | | return R.error("Delete Fail"); |
| | | } |
| | | return R.ok("Delete Success").add(ids); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:list')") |
| | | @PostMapping("/aiDiagnosisPlan/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiDiagnosisPlanService.list(new LambdaQueryWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getTenantId, getTenantId())), AiDiagnosisPlan.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiDiagnosisPlan:update')") |
| | | @OperationLog("Run AiDiagnosisPlan") |
| | | @PostMapping("/ai/diagnosis-plan/run") |
| | | public R run(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | AiDiagnosisPlan plan = aiDiagnosisPlanService.getTenantPlan(getTenantId(), id); |
| | | if (plan == null) { |
| | | return R.error("plan not found"); |
| | | } |
| | | if (Integer.valueOf(1).equals(plan.getRunningFlag())) { |
| | | return R.error("计划正在执行中"); |
| | | } |
| | | Date nextRunTime = Integer.valueOf(1).equals(plan.getStatus()) |
| | | ? aiDiagnosisPlanService.calculateNextRunTime(plan.getCronExpr(), new Date()) |
| | | : null; |
| | | boolean acquired = aiDiagnosisPlanService.acquireForExecution( |
| | | plan.getId(), |
| | | getLoginUserId(), |
| | | "手动执行中", |
| | | nextRunTime |
| | | ); |
| | | if (!acquired) { |
| | | return R.error("计划正在执行中"); |
| | | } |
| | | taskScheduler.execute(() -> aiDiagnosisPlanRunnerService.runPlan(plan.getId(), true)); |
| | | return R.ok(); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.common.annotation.OperationLog; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosticToolConfigService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiDiagnosticToolConfigController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiDiagnosticToolConfigService aiDiagnosticToolConfigService; |
| | | @Autowired |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:list')") |
| | | @PostMapping("/aiToolConfig/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiDiagnosticToolConfig, BaseParam> pageParam = new PageParam<>(baseParam, AiDiagnosticToolConfig.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiDiagnosticToolConfig> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiDiagnosticToolConfigService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:list')") |
| | | @GetMapping("/aiToolConfig/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiDiagnosticToolConfig config = aiDiagnosticToolConfigService.getTenantConfig(getTenantId(), id); |
| | | if (config == null) { |
| | | return R.error("config not found"); |
| | | } |
| | | return R.ok().add(config); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:save')") |
| | | @OperationLog("Create AiDiagnosticToolConfig") |
| | | @PostMapping("/aiToolConfig/save") |
| | | public R save(@RequestBody AiDiagnosticToolConfig config) { |
| | | if (Cools.isEmpty(config.getSceneCode()) || Cools.isEmpty(config.getToolCode())) { |
| | | return R.error("场景编码和工具编码不能为空"); |
| | | } |
| | | Date now = new Date(); |
| | | config.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | config.setTenantId(getTenantId()); |
| | | config.setCreateBy(getLoginUserId()); |
| | | config.setCreateTime(now); |
| | | config.setUpdateBy(getLoginUserId()); |
| | | config.setUpdateTime(now); |
| | | if (config.getEnabledFlag() == null) { |
| | | config.setEnabledFlag(1); |
| | | } |
| | | if (config.getPriority() == null) { |
| | | config.setPriority(1); |
| | | } |
| | | if (config.getStatus() == null) { |
| | | config.setStatus(1); |
| | | } |
| | | if (!aiDiagnosticToolConfigService.save(config)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | return R.ok("Save Success").add(config); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:update')") |
| | | @OperationLog("Update AiDiagnosticToolConfig") |
| | | @PostMapping("/aiToolConfig/update") |
| | | public R update(@RequestBody AiDiagnosticToolConfig config) { |
| | | AiDiagnosticToolConfig existed = aiDiagnosticToolConfigService.getTenantConfig(getTenantId(), config.getId()); |
| | | if (existed == null) { |
| | | return R.error("config not found"); |
| | | } |
| | | config.setTenantId(getTenantId()); |
| | | config.setUpdateBy(getLoginUserId()); |
| | | config.setUpdateTime(new Date()); |
| | | config.setCreateBy(existed.getCreateBy()); |
| | | config.setCreateTime(existed.getCreateTime()); |
| | | if (config.getEnabledFlag() == null) { |
| | | config.setEnabledFlag(existed.getEnabledFlag()); |
| | | } |
| | | if (config.getPriority() == null) { |
| | | config.setPriority(existed.getPriority()); |
| | | } |
| | | if (config.getStatus() == null) { |
| | | config.setStatus(existed.getStatus()); |
| | | } |
| | | if (!aiDiagnosticToolConfigService.updateById(config)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok("Update Success").add(config); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:remove')") |
| | | @OperationLog("Delete AiDiagnosticToolConfig") |
| | | @PostMapping("/aiToolConfig/remove/{ids}") |
| | | public R remove(@PathVariable Long[] ids) { |
| | | List<Long> idList = Arrays.asList(ids); |
| | | List<AiDiagnosticToolConfig> configs = aiDiagnosticToolConfigService.list(new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, getTenantId()) |
| | | .in(AiDiagnosticToolConfig::getId, idList)); |
| | | if (configs.size() != idList.size() || !aiDiagnosticToolConfigService.removeByIds(idList)) { |
| | | return R.error("Delete Fail"); |
| | | } |
| | | return R.ok("Delete Success").add(ids); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:list')") |
| | | @PostMapping("/aiToolConfig/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiDiagnosticToolConfigService.list(new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, getTenantId())), AiDiagnosticToolConfig.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiToolConfig:list')") |
| | | @GetMapping("/ai/tool-config/list") |
| | | public R customList(@RequestParam(required = false) String sceneCode) { |
| | | LambdaQueryWrapper<AiDiagnosticToolConfig> wrapper = new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, getTenantId()) |
| | | .orderByAsc(AiDiagnosticToolConfig::getSceneCode, AiDiagnosticToolConfig::getPriority, AiDiagnosticToolConfig::getId); |
| | | if (!Cools.isEmpty(sceneCode)) { |
| | | wrapper.eq(AiDiagnosticToolConfig::getSceneCode, sceneCode); |
| | | } |
| | | return R.ok().add(aiDiagnosticToolConfigService.list(wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiToolConfig:save','system:aiToolConfig:update')") |
| | | @PostMapping("/ai/tool-config/save") |
| | | public R customSave(@RequestBody AiDiagnosticToolConfig config) { |
| | | if (config.getId() == null) { |
| | | if (!hasAuthority("system:aiToolConfig:save")) { |
| | | return R.error("无新增权限"); |
| | | } |
| | | return save(config); |
| | | } |
| | | if (!hasAuthority("system:aiToolConfig:update")) { |
| | | return R.error("无更新权限"); |
| | | } |
| | | return update(config); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.ai.constant.AiMcpConstants; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpPayloadMapper; |
| | | import com.vincent.rsf.server.ai.service.mcp.AiMcpRegistryService; |
| | | import com.vincent.rsf.server.common.annotation.OperationLog; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosticToolConfigService; |
| | | import com.vincent.rsf.server.system.service.AiMcpMountService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiMcpMountController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiMcpMountService aiMcpMountService; |
| | | @Autowired |
| | | private AiMcpRegistryService aiMcpRegistryService; |
| | | @Autowired |
| | | private AiDiagnosticToolConfigService aiDiagnosticToolConfigService; |
| | | @Autowired |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | @Autowired |
| | | private AiMcpPayloadMapper aiMcpPayloadMapper; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @PostMapping("/aiMcpMount/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiMcpMount, BaseParam> pageParam = new PageParam<>(baseParam, AiMcpMount.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiMcpMount> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiMcpMountService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @GetMapping("/aiMcpMount/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMount(getTenantId(), id); |
| | | if (mount == null) { |
| | | return R.error("mount not found"); |
| | | } |
| | | return R.ok().add(mount); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:save')") |
| | | @OperationLog("Create AiMcpMount") |
| | | @PostMapping("/aiMcpMount/save") |
| | | public R save(@RequestBody AiMcpMount mount) { |
| | | if (Cools.isEmpty(mount.getName()) || Cools.isEmpty(mount.getMountCode()) || Cools.isEmpty(mount.getTransportType())) { |
| | | return R.error("名称、挂载编码和传输类型不能为空"); |
| | | } |
| | | Date now = new Date(); |
| | | mount.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | mount.setEnabledFlag(mount.getEnabledFlag() == null ? 1 : mount.getEnabledFlag()); |
| | | mount.setTimeoutMs(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | mount.setStatus(mount.getStatus() == null ? 1 : mount.getStatus()); |
| | | mount.setTransportType(mount.getTransportType().toUpperCase()); |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equals(mount.getTransportType())) { |
| | | mount.setUrl("/ai/mcp"); |
| | | } |
| | | mount.setTenantId(getTenantId()); |
| | | mount.setCreateBy(getLoginUserId()); |
| | | mount.setCreateTime(now); |
| | | mount.setUpdateBy(getLoginUserId()); |
| | | mount.setUpdateTime(now); |
| | | if (!aiMcpMountService.save(mount)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | return R.ok("Save Success").add(mount); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @OperationLog("Update AiMcpMount") |
| | | @PostMapping("/aiMcpMount/update") |
| | | public R update(@RequestBody AiMcpMount mount) { |
| | | AiMcpMount existed = aiMcpMountService.getTenantMount(getTenantId(), mount.getId()); |
| | | if (existed == null) { |
| | | return R.error("mount not found"); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(existed.getTransportType())) { |
| | | return R.error("内置挂载由系统托管,不支持直接编辑"); |
| | | } |
| | | if (Cools.isEmpty(mount.getName()) || Cools.isEmpty(mount.getMountCode()) || Cools.isEmpty(mount.getTransportType())) { |
| | | return R.error("名称、挂载编码和传输类型不能为空"); |
| | | } |
| | | mount.setTransportType(mount.getTransportType().toUpperCase()); |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equals(mount.getTransportType())) { |
| | | mount.setUrl("/ai/mcp"); |
| | | } |
| | | mount.setTenantId(getTenantId()); |
| | | mount.setCreateBy(existed.getCreateBy()); |
| | | mount.setCreateTime(existed.getCreateTime()); |
| | | mount.setLastTestResult(existed.getLastTestResult()); |
| | | mount.setLastTestTime(existed.getLastTestTime()); |
| | | mount.setLastTestMessage(existed.getLastTestMessage()); |
| | | mount.setLastToolCount(existed.getLastToolCount()); |
| | | mount.setUpdateBy(getLoginUserId()); |
| | | mount.setUpdateTime(new Date()); |
| | | if (!aiMcpMountService.updateById(mount)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok("Update Success").add(mount); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:remove')") |
| | | @OperationLog("Delete AiMcpMount") |
| | | @PostMapping("/aiMcpMount/remove/{ids}") |
| | | public R remove(@PathVariable Long[] ids) { |
| | | List<Long> idList = Arrays.asList(ids); |
| | | List<AiMcpMount> mounts = aiMcpMountService.list(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, getTenantId()) |
| | | .in(AiMcpMount::getId, idList)); |
| | | for (AiMcpMount mount : mounts) { |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType())) { |
| | | return R.error("内置挂载由系统托管,不支持删除"); |
| | | } |
| | | } |
| | | if (mounts.size() != idList.size() || !aiMcpMountService.removeByIds(idList)) { |
| | | return R.error("Delete Fail"); |
| | | } |
| | | return R.ok("Delete Success").add(ids); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @PostMapping("/aiMcpMount/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiMcpMountService.list(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, getTenantId())), AiMcpMount.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @GetMapping("/ai/mcp/mount/list") |
| | | public R list() { |
| | | aiMcpRegistryService.ensureDefaultMount(getTenantId(), getLoginUserId()); |
| | | return R.ok().add(aiMcpMountService.listTenantMounts(getTenantId())); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiMcpMount:save','system:aiMcpMount:update')") |
| | | @PostMapping("/ai/mcp/mount/save") |
| | | public R customSave(@RequestBody AiMcpMount mount) { |
| | | if (mount.getId() == null) { |
| | | return save(mount); |
| | | } |
| | | return update(mount); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @PostMapping("/ai/mcp/mount/initDefaults") |
| | | public R initDefaults() { |
| | | aiMcpRegistryService.ensureDefaultMount(getTenantId(), getLoginUserId()); |
| | | return R.ok(); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @GetMapping("/ai/mcp/mount/toolList") |
| | | public R toolList(@RequestParam(required = false) Long mountId) { |
| | | return R.ok().add(aiMcpRegistryService.listTools(getTenantId(), mountId)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @PostMapping("/ai/mcp/mount/test") |
| | | public R test(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | return R.ok().add(aiMcpRegistryService.testMount(getTenantId(), id)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @PostMapping("/ai/mcp/mount/toolPreview") |
| | | public R toolPreview(@RequestBody Map<String, Object> map) { |
| | | return R.ok().add(aiMcpRegistryService.previewTool( |
| | | getTenantId(), |
| | | String.valueOf(map.get("mountCode")), |
| | | String.valueOf(map.get("toolCode")), |
| | | map.get("sceneCode") == null ? null : String.valueOf(map.get("sceneCode")), |
| | | map.get("question") == null ? null : String.valueOf(map.get("question")) |
| | | )); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:list')") |
| | | @GetMapping("/ai/mcp/console/overview") |
| | | public R consoleOverview() { |
| | | aiMcpRegistryService.ensureDefaultMount(getTenantId(), getLoginUserId()); |
| | | Map<String, Object> payload = new LinkedHashMap<>(); |
| | | payload.put("builtInMount", null); |
| | | payload.put("builtInTools", new java.util.ArrayList<>()); |
| | | payload.put("externalServices", new java.util.ArrayList<>()); |
| | | for (AiMcpMount mount : aiMcpMountService.listTenantMounts(getTenantId())) { |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType())) { |
| | | payload.put("builtInMount", buildMountView(mount, true)); |
| | | payload.put("builtInTools", buildBuiltInToolViews()); |
| | | } else { |
| | | ((List<Object>) payload.get("externalServices")).add(buildMountView(mount, false)); |
| | | } |
| | | } |
| | | return R.ok().add(payload); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiMcpMount:save','system:aiMcpMount:update')") |
| | | @PostMapping("/ai/mcp/console/service/save") |
| | | public R consoleSaveService(@RequestBody AiMcpMount mount) { |
| | | if (Cools.isEmpty(mount.getName()) || Cools.isEmpty(mount.getUrl())) { |
| | | return R.error("名称和地址不能为空"); |
| | | } |
| | | Date now = new Date(); |
| | | AiMcpMount existed = mount.getId() == null ? null : aiMcpMountService.getTenantMount(getTenantId(), mount.getId()); |
| | | if (existed != null && AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(existed.getTransportType())) { |
| | | return R.error("内置挂载由系统托管,不支持编辑"); |
| | | } |
| | | String transportType = Cools.isEmpty(mount.getTransportType()) ? AiMcpConstants.TRANSPORT_AUTO : mount.getTransportType().toUpperCase(); |
| | | String usageScope = normalizeUsageScope(mount.getUsageScope()); |
| | | AiMcpMount target = existed == null ? new AiMcpMount() : existed; |
| | | target.setName(mount.getName()); |
| | | target.setUrl(mount.getUrl()); |
| | | target.setTransportType(transportType); |
| | | target.setAuthType(Cools.isEmpty(mount.getAuthType()) ? AiMcpConstants.AUTH_TYPE_NONE : mount.getAuthType().toUpperCase()); |
| | | target.setAuthValue(mount.getAuthValue()); |
| | | target.setUsageScope(usageScope); |
| | | target.setEnabledFlag(mount.getEnabledFlag() == null ? 1 : mount.getEnabledFlag()); |
| | | target.setTimeoutMs(mount.getTimeoutMs() == null ? 10000 : mount.getTimeoutMs()); |
| | | target.setStatus(mount.getStatus() == null ? 1 : mount.getStatus()); |
| | | target.setMemo(mount.getMemo()); |
| | | target.setTenantId(getTenantId()); |
| | | target.setUpdateBy(getLoginUserId()); |
| | | target.setUpdateTime(now); |
| | | if (existed == null) { |
| | | target.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | target.setMountCode(generateMountCode(target.getName())); |
| | | target.setCreateBy(getLoginUserId()); |
| | | target.setCreateTime(now); |
| | | if (!aiMcpMountService.save(target)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | } else if (!aiMcpMountService.updateById(target)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok().add(buildMountView(target, false)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:update')") |
| | | @PostMapping("/ai/mcp/console/service/test") |
| | | public R consoleTestService(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | Map<String, Object> result = (Map<String, Object>) aiMcpRegistryService.testMount(getTenantId(), id); |
| | | AiMcpMount mount = aiMcpMountService.getTenantMount(getTenantId(), id); |
| | | if (mount == null) { |
| | | return R.error("mount not found"); |
| | | } |
| | | result.put("service", buildMountView(mount, false)); |
| | | return R.ok().add(result); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiMcpMount:remove')") |
| | | @PostMapping("/ai/mcp/console/service/remove/{id}") |
| | | public R consoleRemoveService(@PathVariable("id") Long id) { |
| | | AiMcpMount mount = aiMcpMountService.getTenantMount(getTenantId(), id); |
| | | if (mount == null) { |
| | | return R.error("mount not found"); |
| | | } |
| | | if (AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType())) { |
| | | return R.error("内置挂载由系统托管,不支持删除"); |
| | | } |
| | | return aiMcpMountService.removeById(id) ? R.ok() : R.error("Delete Fail"); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiToolConfig:save','system:aiToolConfig:update')") |
| | | @PostMapping("/ai/mcp/console/builtin-tool/save") |
| | | public R consoleSaveBuiltInTool(@RequestBody AiDiagnosticToolConfig config) { |
| | | if (Cools.isEmpty(config.getToolCode())) { |
| | | return R.error("工具编码不能为空"); |
| | | } |
| | | String usageScope = aiMcpPayloadMapper.normalizeUsageScope(config.getUsageScope()); |
| | | String sceneCode = resolveSceneCodeByUsageScope(usageScope); |
| | | LambdaQueryWrapper<AiDiagnosticToolConfig> wrapper = new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, getTenantId()) |
| | | .eq(AiDiagnosticToolConfig::getToolCode, config.getToolCode()); |
| | | if (Cools.isEmpty(sceneCode)) { |
| | | wrapper.and(w -> w.isNull(AiDiagnosticToolConfig::getSceneCode).or().eq(AiDiagnosticToolConfig::getSceneCode, "")); |
| | | } else { |
| | | wrapper.eq(AiDiagnosticToolConfig::getSceneCode, sceneCode); |
| | | } |
| | | AiDiagnosticToolConfig existed = aiDiagnosticToolConfigService.getOne(wrapper.last("limit 1")); |
| | | Date now = new Date(); |
| | | AiDiagnosticToolConfig target = existed == null ? new AiDiagnosticToolConfig() : existed; |
| | | if (existed == null) { |
| | | target.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | target.setCreateBy(getLoginUserId()); |
| | | target.setCreateTime(now); |
| | | } |
| | | target |
| | | .setToolCode(config.getToolCode()) |
| | | .setToolName(config.getToolName()) |
| | | .setSceneCode(sceneCode) |
| | | .setUsageScope(usageScope) |
| | | .setEnabledFlag(AiMcpConstants.USAGE_SCOPE_DISABLED.equals(usageScope) ? 0 : (config.getEnabledFlag() == null ? 1 : config.getEnabledFlag())) |
| | | .setPriority(config.getPriority() == null ? 10 : config.getPriority()) |
| | | .setToolPrompt(config.getToolPrompt()) |
| | | .setStatus(config.getStatus() == null ? 1 : config.getStatus()) |
| | | .setTenantId(getTenantId()) |
| | | .setUpdateBy(getLoginUserId()) |
| | | .setUpdateTime(now) |
| | | .setMemo(config.getMemo()); |
| | | if (existed == null) { |
| | | aiDiagnosticToolConfigService.save(target); |
| | | } else { |
| | | aiDiagnosticToolConfigService.updateById(target); |
| | | } |
| | | return R.ok().add(buildBuiltInToolViews()); |
| | | } |
| | | |
| | | private List<Map<String, Object>> buildBuiltInToolViews() { |
| | | List<Map<String, Object>> output = new java.util.ArrayList<>(); |
| | | for (com.vincent.rsf.server.ai.model.AiMcpToolDescriptor descriptor : aiMcpRegistryService.listInternalTools(getTenantId())) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("toolCode", descriptor.getToolCode()); |
| | | item.put("toolName", descriptor.getToolName()); |
| | | item.put("description", descriptor.getDescription()); |
| | | item.put("enabledFlag", descriptor.getEnabledFlag()); |
| | | item.put("priority", descriptor.getPriority()); |
| | | item.put("toolPrompt", descriptor.getToolPrompt()); |
| | | item.put("usageScope", aiMcpPayloadMapper.resolveUsageScope(descriptor.getSceneCode(), descriptor.getEnabledFlag(), descriptor.getUsageScope())); |
| | | item.put("advanced", buildAdvancedFlag(descriptor)); |
| | | output.add(item); |
| | | } |
| | | return output; |
| | | } |
| | | |
| | | private Map<String, Object> buildMountView(AiMcpMount mount, boolean internalManaged) { |
| | | Map<String, Object> item = new LinkedHashMap<>(); |
| | | item.put("id", mount.getId()); |
| | | item.put("name", mount.getName()); |
| | | item.put("mountCode", mount.getMountCode()); |
| | | item.put("transportType", mount.getTransportType()); |
| | | item.put("displayTransportType", AiMcpConstants.TRANSPORT_INTERNAL.equalsIgnoreCase(mount.getTransportType()) ? "内置" : mount.getTransportType()); |
| | | item.put("url", mount.getUrl()); |
| | | item.put("enabledFlag", mount.getEnabledFlag()); |
| | | item.put("timeoutMs", mount.getTimeoutMs()); |
| | | item.put("lastTestResult", mount.getLastTestResult()); |
| | | item.put("lastTestResult$", mount.getLastTestResult$()); |
| | | item.put("lastTestTime", mount.getLastTestTime$()); |
| | | item.put("lastTestMessage", mount.getLastTestMessage()); |
| | | item.put("lastToolCount", mount.getLastToolCount()); |
| | | item.put("authType", mount.getAuthType()); |
| | | item.put("hasAuth", !Cools.isEmpty(mount.getAuthValue())); |
| | | item.put("usageScope", normalizeUsageScope(mount.getUsageScope())); |
| | | item.put("internalManaged", internalManaged); |
| | | return item; |
| | | } |
| | | |
| | | private boolean buildAdvancedFlag(com.vincent.rsf.server.ai.model.AiMcpToolDescriptor descriptor) { |
| | | return descriptor.getPriority() != null && descriptor.getPriority() != 10 |
| | | || (descriptor.getToolPrompt() != null && !descriptor.getToolPrompt().trim().isEmpty()); |
| | | } |
| | | |
| | | private String normalizeUsageScope(String usageScope) { |
| | | return aiMcpPayloadMapper.normalizeUsageScope(usageScope); |
| | | } |
| | | |
| | | private String resolveUsageScope(String sceneCode, Integer enabledFlag) { |
| | | return aiMcpPayloadMapper.resolveUsageScope(sceneCode, enabledFlag, null); |
| | | } |
| | | |
| | | private String resolveSceneCodeByUsageScope(String usageScope) { |
| | | return AiMcpConstants.USAGE_SCOPE_DIAGNOSE_ONLY.equals(usageScope) ? AiSceneCode.SYSTEM_DIAGNOSE : ""; |
| | | } |
| | | |
| | | private String generateMountCode(String name) { |
| | | String normalized = name == null ? "remote_mcp" : name.toLowerCase().replaceAll("[^a-z0-9]+", "_"); |
| | | normalized = normalized.replaceAll("^_+|_+$", ""); |
| | | if (normalized.isEmpty()) { |
| | | normalized = "remote_mcp"; |
| | | } |
| | | return normalized + "_" + String.valueOf(System.currentTimeMillis()).substring(7); |
| | | } |
| | | } |
| | | |
| | |
| | | ExcelUtil.build(ExcelUtil.create(aiParamService.list(), AiParam.class), response); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.common.annotation.OperationLog; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiPromptPublishLog; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | import com.vincent.rsf.server.system.service.AiPromptPublishLogService; |
| | | import com.vincent.rsf.server.system.service.AiPromptTemplateService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.LinkedHashMap; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiPromptController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiPromptTemplateService aiPromptTemplateService; |
| | | @Autowired |
| | | private AiPromptPublishLogService aiPromptPublishLogService; |
| | | @Autowired |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @PostMapping("/aiPrompt/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiPromptTemplate, BaseParam> pageParam = new PageParam<>(baseParam, AiPromptTemplate.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiPromptTemplate> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiPromptTemplateService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @PostMapping("/aiPrompt/list") |
| | | public R list(@RequestBody(required = false) Map<String, Object> map) { |
| | | return R.ok().add(aiPromptTemplateService.list(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, getTenantId()) |
| | | .orderByAsc(AiPromptTemplate::getSceneCode) |
| | | .orderByDesc(AiPromptTemplate::getVersionNo, AiPromptTemplate::getId))); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @PostMapping({"/aiPrompt/many/{ids}", "/aiPrompts/many/{ids}"}) |
| | | public R many(@PathVariable Long[] ids) { |
| | | return R.ok().add(aiPromptTemplateService.list(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, getTenantId()) |
| | | .in(AiPromptTemplate::getId, Arrays.asList(ids)))); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @GetMapping("/aiPrompt/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiPromptTemplate promptTemplate = aiPromptTemplateService.getTenantTemplate(getTenantId(), id); |
| | | if (promptTemplate == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok().add(promptTemplate); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:save')") |
| | | @OperationLog("Create AiPrompt") |
| | | @PostMapping("/aiPrompt/save") |
| | | public R save(@RequestBody AiPromptTemplate aiPromptTemplate) { |
| | | if (Cools.isEmpty(aiPromptTemplate.getSceneCode())) { |
| | | return R.error("场景编码不能为空"); |
| | | } |
| | | Date now = new Date(); |
| | | aiPromptTemplate.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | aiPromptTemplate.setTenantId(getTenantId()); |
| | | aiPromptTemplate.setCreateBy(getLoginUserId()); |
| | | aiPromptTemplate.setCreateTime(now); |
| | | aiPromptTemplate.setUpdateBy(getLoginUserId()); |
| | | aiPromptTemplate.setUpdateTime(now); |
| | | aiPromptTemplate.setVersionNo(aiPromptTemplate.getVersionNo() == null |
| | | ? aiPromptTemplateService.nextVersionNo(getTenantId(), aiPromptTemplate.getSceneCode()) |
| | | : aiPromptTemplate.getVersionNo()); |
| | | if (aiPromptTemplate.getPublishedFlag() == null) { |
| | | aiPromptTemplate.setPublishedFlag(0); |
| | | } |
| | | if (aiPromptTemplate.getStatus() == null) { |
| | | aiPromptTemplate.setStatus(1); |
| | | } |
| | | if (!aiPromptTemplateService.save(aiPromptTemplate)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | return R.ok("Save Success").add(aiPromptTemplate); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:update')") |
| | | @OperationLog("Update AiPrompt") |
| | | @PostMapping("/aiPrompt/update") |
| | | public R update(@RequestBody AiPromptTemplate aiPromptTemplate) { |
| | | AiPromptTemplate existed = aiPromptTemplateService.getTenantTemplate(getTenantId(), aiPromptTemplate.getId()); |
| | | if (existed == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | aiPromptTemplate.setTenantId(getTenantId()); |
| | | aiPromptTemplate.setUpdateBy(getLoginUserId()); |
| | | aiPromptTemplate.setUpdateTime(new Date()); |
| | | aiPromptTemplate.setCreateBy(existed.getCreateBy()); |
| | | aiPromptTemplate.setCreateTime(existed.getCreateTime()); |
| | | if (!aiPromptTemplateService.updateById(aiPromptTemplate)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok("Update Success").add(aiPromptTemplate); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:remove')") |
| | | @OperationLog("Delete AiPrompt") |
| | | @PostMapping("/aiPrompt/remove/{ids}") |
| | | public R remove(@PathVariable Long[] ids) { |
| | | List<Long> idList = Arrays.asList(ids); |
| | | List<AiPromptTemplate> records = aiPromptTemplateService.list(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, getTenantId()) |
| | | .in(AiPromptTemplate::getId, idList)); |
| | | if (records.size() != idList.size() || !aiPromptTemplateService.removeByIds(idList)) { |
| | | return R.error("Delete Fail"); |
| | | } |
| | | return R.ok("Delete Success").add(ids); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @PostMapping("/aiPrompt/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiPromptTemplateService.list(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, getTenantId())), AiPromptTemplate.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @GetMapping("/ai/prompt/list") |
| | | public R customList(@RequestParam(required = false) String sceneCode) { |
| | | LambdaQueryWrapper<AiPromptTemplate> wrapper = new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, getTenantId()) |
| | | .orderByAsc(AiPromptTemplate::getSceneCode) |
| | | .orderByDesc(AiPromptTemplate::getVersionNo, AiPromptTemplate::getId); |
| | | if (!Cools.isEmpty(sceneCode)) { |
| | | wrapper.eq(AiPromptTemplate::getSceneCode, sceneCode); |
| | | } |
| | | return R.ok().add(aiPromptTemplateService.list(wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiPrompt:save','system:aiPrompt:update')") |
| | | @PostMapping("/ai/prompt/save") |
| | | public R customSave(@RequestBody AiPromptTemplate aiPromptTemplate) { |
| | | if (aiPromptTemplate.getId() == null) { |
| | | if (!hasAuthority("system:aiPrompt:save")) { |
| | | return R.error("无新增权限"); |
| | | } |
| | | return save(aiPromptTemplate); |
| | | } |
| | | if (!hasAuthority("system:aiPrompt:update")) { |
| | | return R.error("无更新权限"); |
| | | } |
| | | return update(aiPromptTemplate); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:publish')") |
| | | @OperationLog("Publish AiPrompt") |
| | | @PostMapping("/ai/prompt/publish") |
| | | public R publish(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | if (!aiPromptTemplateService.publishTemplate(getTenantId(), id, getLoginUserId())) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok(); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @GetMapping("/ai/prompt/version/list") |
| | | public R versionList(@RequestParam String sceneCode) { |
| | | List<AiPromptTemplate> list = aiPromptTemplateService.listVersions(getTenantId(), sceneCode); |
| | | return R.ok().add(list); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @GetMapping("/ai/prompt/publish-log/list") |
| | | public R publishLogList(@RequestParam(required = false) String sceneCode) { |
| | | List<AiPromptPublishLog> list = aiPromptPublishLogService.listSceneLogs(getTenantId(), sceneCode); |
| | | return R.ok().add(list); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:save')") |
| | | @OperationLog("Copy AiPrompt") |
| | | @PostMapping("/ai/prompt/copy") |
| | | public R copy(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | AiPromptTemplate copied = aiPromptTemplateService.copyTemplate(getTenantId(), id, getLoginUserId()); |
| | | if (copied == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok().add(copied); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:list')") |
| | | @GetMapping("/ai/prompt/compare") |
| | | public R compare(@RequestParam Long leftId, @RequestParam Long rightId) { |
| | | AiPromptTemplate left = aiPromptTemplateService.getTenantTemplate(getTenantId(), leftId); |
| | | AiPromptTemplate right = aiPromptTemplateService.getTenantTemplate(getTenantId(), rightId); |
| | | if (left == null || right == null) { |
| | | return R.error("record not found"); |
| | | } |
| | | Map<String, Object> result = new LinkedHashMap<>(); |
| | | result.put("left", left); |
| | | result.put("right", right); |
| | | return R.ok().add(result); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiPrompt:publish')") |
| | | @OperationLog("Rollback AiPrompt") |
| | | @PostMapping("/ai/prompt/rollback") |
| | | public R rollback(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | if (!aiPromptTemplateService.rollbackTemplate(getTenantId(), id, getLoginUserId())) { |
| | | return R.error("record not found"); |
| | | } |
| | | return R.ok(); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.controller; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.R; |
| | | import com.vincent.rsf.framework.common.SnowflakeIdWorker; |
| | | import com.vincent.rsf.server.ai.service.AiModelRouteRuntimeService; |
| | | import com.vincent.rsf.server.common.annotation.OperationLog; |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.common.domain.PageParam; |
| | | import com.vincent.rsf.server.common.utils.ExcelUtil; |
| | | import com.vincent.rsf.server.system.entity.AiModelRoute; |
| | | import com.vincent.rsf.server.system.service.AiModelRouteService; |
| | | import org.springframework.beans.factory.annotation.Autowired; |
| | | import org.springframework.security.access.prepost.PreAuthorize; |
| | | import org.springframework.web.bind.annotation.*; |
| | | |
| | | import javax.servlet.http.HttpServletResponse; |
| | | import java.util.Arrays; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | import java.util.Map; |
| | | |
| | | @RestController |
| | | public class AiRouteController extends BaseController { |
| | | |
| | | @Autowired |
| | | private AiModelRouteService aiModelRouteService; |
| | | @Autowired |
| | | private AiModelRouteRuntimeService aiModelRouteRuntimeService; |
| | | @Autowired |
| | | private SnowflakeIdWorker snowflakeIdWorker; |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:list')") |
| | | @PostMapping("/aiRoute/page") |
| | | public R page(@RequestBody Map<String, Object> map) { |
| | | BaseParam baseParam = buildParam(map, BaseParam.class); |
| | | PageParam<AiModelRoute, BaseParam> pageParam = new PageParam<>(baseParam, AiModelRoute.class); |
| | | com.baomidou.mybatisplus.core.conditions.query.QueryWrapper<AiModelRoute> wrapper = pageParam.buildWrapper(true); |
| | | wrapper.eq("tenant_id", getTenantId()); |
| | | return R.ok().add(aiModelRouteService.page(pageParam, wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:list')") |
| | | @GetMapping("/aiRoute/{id}") |
| | | public R get(@PathVariable("id") Long id) { |
| | | AiModelRoute route = aiModelRouteService.getTenantRoute(getTenantId(), id); |
| | | if (route == null) { |
| | | return R.error("route not found"); |
| | | } |
| | | return R.ok().add(route); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:save')") |
| | | @OperationLog("Create AiRoute") |
| | | @PostMapping("/aiRoute/save") |
| | | public R save(@RequestBody AiModelRoute aiModelRoute) { |
| | | if (Cools.isEmpty(aiModelRoute.getRouteCode()) || Cools.isEmpty(aiModelRoute.getModelCode())) { |
| | | return R.error("路由编码和模型编码不能为空"); |
| | | } |
| | | Date now = new Date(); |
| | | aiModelRoute.setUuid(String.valueOf(snowflakeIdWorker.nextId()).substring(3)); |
| | | aiModelRoute.setTenantId(getTenantId()); |
| | | aiModelRoute.setCreateBy(getLoginUserId()); |
| | | aiModelRoute.setCreateTime(now); |
| | | aiModelRoute.setUpdateBy(getLoginUserId()); |
| | | aiModelRoute.setUpdateTime(now); |
| | | if (aiModelRoute.getPriority() == null) { |
| | | aiModelRoute.setPriority(1); |
| | | } |
| | | if (aiModelRoute.getStatus() == null) { |
| | | aiModelRoute.setStatus(1); |
| | | } |
| | | if (aiModelRoute.getFailCount() == null) { |
| | | aiModelRoute.setFailCount(0); |
| | | } |
| | | if (aiModelRoute.getSuccessCount() == null) { |
| | | aiModelRoute.setSuccessCount(0); |
| | | } |
| | | if (!aiModelRouteService.save(aiModelRoute)) { |
| | | return R.error("Save Fail"); |
| | | } |
| | | return R.ok("Save Success").add(aiModelRoute); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:update')") |
| | | @OperationLog("Update AiRoute") |
| | | @PostMapping("/aiRoute/update") |
| | | public R update(@RequestBody AiModelRoute aiModelRoute) { |
| | | AiModelRoute existed = aiModelRouteService.getTenantRoute(getTenantId(), aiModelRoute.getId()); |
| | | if (existed == null) { |
| | | return R.error("route not found"); |
| | | } |
| | | aiModelRoute.setTenantId(getTenantId()); |
| | | aiModelRoute.setUpdateBy(getLoginUserId()); |
| | | aiModelRoute.setUpdateTime(new Date()); |
| | | aiModelRoute.setCreateBy(existed.getCreateBy()); |
| | | aiModelRoute.setCreateTime(existed.getCreateTime()); |
| | | if (!aiModelRouteService.updateById(aiModelRoute)) { |
| | | return R.error("Update Fail"); |
| | | } |
| | | return R.ok("Update Success").add(aiModelRoute); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:remove')") |
| | | @OperationLog("Delete AiRoute") |
| | | @PostMapping("/aiRoute/remove/{ids}") |
| | | public R remove(@PathVariable Long[] ids) { |
| | | List<Long> idList = Arrays.asList(ids); |
| | | List<AiModelRoute> routes = aiModelRouteService.list(new LambdaQueryWrapper<AiModelRoute>() |
| | | .eq(AiModelRoute::getTenantId, getTenantId()) |
| | | .in(AiModelRoute::getId, idList)); |
| | | if (routes.size() != idList.size() || !aiModelRouteService.removeByIds(idList)) { |
| | | return R.error("Delete Fail"); |
| | | } |
| | | return R.ok("Delete Success").add(ids); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:list')") |
| | | @PostMapping("/aiRoute/export") |
| | | public void export(@RequestBody Map<String, Object> map, HttpServletResponse response) throws Exception { |
| | | ExcelUtil.build(ExcelUtil.create(aiModelRouteService.list(new LambdaQueryWrapper<AiModelRoute>() |
| | | .eq(AiModelRoute::getTenantId, getTenantId())), AiModelRoute.class), response); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:list')") |
| | | @GetMapping("/ai/route/list") |
| | | public R customList(@RequestParam(required = false) String routeCode) { |
| | | LambdaQueryWrapper<AiModelRoute> wrapper = new LambdaQueryWrapper<AiModelRoute>() |
| | | .eq(AiModelRoute::getTenantId, getTenantId()) |
| | | .orderByAsc(AiModelRoute::getRouteCode, AiModelRoute::getPriority, AiModelRoute::getId); |
| | | if (!Cools.isEmpty(routeCode)) { |
| | | wrapper.eq(AiModelRoute::getRouteCode, routeCode); |
| | | } |
| | | return R.ok().add(aiModelRouteService.list(wrapper)); |
| | | } |
| | | |
| | | @PreAuthorize("hasAnyAuthority('system:aiRoute:save','system:aiRoute:update')") |
| | | @PostMapping("/ai/route/save") |
| | | public R customSave(@RequestBody AiModelRoute aiModelRoute) { |
| | | if (aiModelRoute.getId() == null) { |
| | | if (!hasAuthority("system:aiRoute:save")) { |
| | | return R.error("无新增权限"); |
| | | } |
| | | return save(aiModelRoute); |
| | | } |
| | | if (!hasAuthority("system:aiRoute:update")) { |
| | | return R.error("无更新权限"); |
| | | } |
| | | return update(aiModelRoute); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:update')") |
| | | @OperationLog("Toggle AiRoute") |
| | | @PostMapping("/ai/route/toggle") |
| | | public R toggle(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | Integer status = Integer.valueOf(String.valueOf(map.get("status"))); |
| | | AiModelRoute route = aiModelRouteService.getTenantRoute(getTenantId(), id); |
| | | if (route == null) { |
| | | return R.error("route not found"); |
| | | } |
| | | route.setStatus(status); |
| | | route.setUpdateBy(getLoginUserId()); |
| | | route.setUpdateTime(new Date()); |
| | | aiModelRouteService.updateById(route); |
| | | return R.ok().add(route); |
| | | } |
| | | |
| | | @PreAuthorize("hasAuthority('system:aiRoute:update')") |
| | | @OperationLog("Reset AiRoute") |
| | | @PostMapping("/ai/route/reset") |
| | | public R reset(@RequestBody Map<String, Object> map) { |
| | | Long id = Long.valueOf(String.valueOf(map.get("id"))); |
| | | if (aiModelRouteService.getTenantRoute(getTenantId(), id) == null) { |
| | | return R.error("route not found"); |
| | | } |
| | | aiModelRouteRuntimeService.resetRoute(id); |
| | | return R.ok(); |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | import com.vincent.rsf.server.common.domain.BaseParam; |
| | | import com.vincent.rsf.server.system.entity.User; |
| | | import org.springframework.security.core.Authentication; |
| | | import org.springframework.security.core.GrantedAuthority; |
| | | import org.springframework.security.core.context.SecurityContextHolder; |
| | | |
| | | import java.util.List; |
| | |
| | | public Long getTenantId() { |
| | | User loginUser = getLoginUser(); |
| | | return loginUser == null ? null : loginUser.getTenantId(); |
| | | } |
| | | |
| | | public boolean hasAuthority(String authority) { |
| | | if (authority == null || authority.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | try { |
| | | Authentication authentication = SecurityContextHolder.getContext().getAuthentication(); |
| | | if (authentication == null || authentication.getAuthorities() == null) { |
| | | return false; |
| | | } |
| | | for (GrantedAuthority grantedAuthority : authentication.getAuthorities()) { |
| | | if (grantedAuthority != null && authority.equals(grantedAuthority.getAuthority())) { |
| | | return true; |
| | | } |
| | | } |
| | | } catch (Exception e) { |
| | | System.out.println(e.getMessage()); |
| | | } |
| | | return false; |
| | | } |
| | | |
| | | public <T extends BaseParam> T buildParam(Map<String, Object> map, Class<T> clz) { |
| | |
| | | } |
| | | |
| | | } |
| | | |
| | |
| | | private String newPassword; |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.SpringUtils; |
| | | import com.vincent.rsf.server.system.service.TenantService; |
| | | import com.vincent.rsf.server.system.service.UserService; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_call_log") |
| | | public class AiCallLog implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "ID") |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty(value = "会话ID") |
| | | private String sessionId; |
| | | |
| | | @ApiModelProperty(value = "诊断记录ID") |
| | | private Long diagnosisId; |
| | | |
| | | @ApiModelProperty(value = "路由编码") |
| | | private String routeCode; |
| | | |
| | | @ApiModelProperty(value = "模型编码") |
| | | private String modelCode; |
| | | |
| | | @ApiModelProperty(value = "尝试序号") |
| | | private Integer attemptNo; |
| | | |
| | | @ApiModelProperty(value = "请求时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date requestTime; |
| | | |
| | | @ApiModelProperty(value = "响应时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date responseTime; |
| | | |
| | | @ApiModelProperty(value = "耗时毫秒") |
| | | private Long spendTime; |
| | | |
| | | @ApiModelProperty(value = "结果 1:成功 0:失败") |
| | | private Integer result; |
| | | |
| | | @ApiModelProperty(value = "错误信息") |
| | | private String err; |
| | | |
| | | @ApiModelProperty(value = "状态 1:正常 0:冻结") |
| | | private Integer status; |
| | | |
| | | @ApiModelProperty(value = "是否删除 1:是 0:否") |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | @ApiModelProperty(value = "租户") |
| | | private Long tenantId; |
| | | |
| | | @ApiModelProperty(value = "用户") |
| | | private Long userId; |
| | | |
| | | @ApiModelProperty(value = "创建时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | public String getResult$() { |
| | | if (this.result == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.result) ? "成功" : "失败"; |
| | | } |
| | | |
| | | public String getUserId$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.userId); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getTenantId$() { |
| | | TenantService service = SpringUtils.getBean(TenantService.class); |
| | | Tenant tenant = service.getById(this.tenantId); |
| | | if (!Cools.isEmpty(tenant)) { |
| | | return String.valueOf(tenant.getName()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getRequestTime$() { |
| | | if (Cools.isEmpty(this.requestTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.requestTime); |
| | | } |
| | | |
| | | public String getResponseTime$() { |
| | | if (Cools.isEmpty(this.responseTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.responseTime); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_diagnosis_plan") |
| | | public class AiDiagnosisPlan implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty("编号") |
| | | private String uuid; |
| | | |
| | | @ApiModelProperty("计划名称") |
| | | private String planName; |
| | | |
| | | @ApiModelProperty("场景编码") |
| | | private String sceneCode; |
| | | |
| | | @ApiModelProperty("Cron表达式") |
| | | private String cronExpr; |
| | | |
| | | @ApiModelProperty("巡检提示词") |
| | | private String prompt; |
| | | |
| | | @ApiModelProperty("优先模型编码") |
| | | private String preferredModelCode; |
| | | |
| | | @ApiModelProperty("运行中 1:是 0:否") |
| | | private Integer runningFlag; |
| | | |
| | | @ApiModelProperty("上次结果 2:运行中 1:成功 0:失败") |
| | | private Integer lastResult; |
| | | |
| | | @ApiModelProperty("上次诊断记录ID") |
| | | private Long lastDiagnosisId; |
| | | |
| | | @ApiModelProperty("上次运行时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date lastRunTime; |
| | | |
| | | @ApiModelProperty("下次运行时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date nextRunTime; |
| | | |
| | | @ApiModelProperty("最近消息") |
| | | private String lastMessage; |
| | | |
| | | @ApiModelProperty("状态 1:正常 0:冻结") |
| | | private Integer status; |
| | | |
| | | @TableLogic |
| | | @ApiModelProperty("是否删除 1:是 0:否") |
| | | private Integer deleted; |
| | | |
| | | private Long tenantId; |
| | | private Long createBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | private Long updateBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | private String memo; |
| | | |
| | | public Boolean getStatusBool() { |
| | | if (status == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(status); |
| | | } |
| | | |
| | | public Boolean getRunningFlagBool() { |
| | | if (runningFlag == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(runningFlag); |
| | | } |
| | | |
| | | public String getLastResult$() { |
| | | if (lastResult == null) { |
| | | return "未运行"; |
| | | } |
| | | if (Integer.valueOf(2).equals(lastResult)) { |
| | | return "运行中"; |
| | | } |
| | | if (Integer.valueOf(1).equals(lastResult)) { |
| | | return "成功"; |
| | | } |
| | | if (Integer.valueOf(0).equals(lastResult)) { |
| | | return "失败"; |
| | | } |
| | | return String.valueOf(lastResult); |
| | | } |
| | | |
| | | public String getSceneCode$() { |
| | | if ("system_diagnose".equals(sceneCode)) { |
| | | return "系统诊断"; |
| | | } |
| | | if ("general_chat".equals(sceneCode)) { |
| | | return "通用对话"; |
| | | } |
| | | return sceneCode; |
| | | } |
| | | |
| | | public String getLastRunTime$() { |
| | | if (Cools.isEmpty(lastRunTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(lastRunTime); |
| | | } |
| | | |
| | | public String getNextRunTime$() { |
| | | if (Cools.isEmpty(nextRunTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(nextRunTime); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.SpringUtils; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.system.service.TenantService; |
| | | import com.vincent.rsf.server.system.service.UserService; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_diagnosis_record") |
| | | public class AiDiagnosisRecord implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "ID") |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty(value = "诊断编号") |
| | | private String diagnosisNo; |
| | | |
| | | @ApiModelProperty(value = "会话ID") |
| | | private String sessionId; |
| | | |
| | | @ApiModelProperty(value = "场景编码") |
| | | private String sceneCode; |
| | | |
| | | @ApiModelProperty(value = "问题") |
| | | private String question; |
| | | |
| | | @ApiModelProperty(value = "结论") |
| | | private String conclusion; |
| | | |
| | | @ApiModelProperty(value = "报告标题") |
| | | private String reportTitle; |
| | | |
| | | @ApiModelProperty(value = "执行摘要") |
| | | private String executiveSummary; |
| | | |
| | | @ApiModelProperty(value = "证据摘要") |
| | | private String evidenceSummary; |
| | | |
| | | @ApiModelProperty(value = "建议动作") |
| | | private String actionSummary; |
| | | |
| | | @ApiModelProperty(value = "风险评估") |
| | | private String riskSummary; |
| | | |
| | | @ApiModelProperty(value = "报告Markdown") |
| | | private String reportMarkdown; |
| | | |
| | | @ApiModelProperty(value = "工具摘要") |
| | | private String toolSummary; |
| | | |
| | | @ApiModelProperty(value = "模型编码") |
| | | private String modelCode; |
| | | |
| | | @ApiModelProperty(value = "结果 2:运行中 1:成功 0:失败") |
| | | private Integer result; |
| | | |
| | | @ApiModelProperty(value = "错误信息") |
| | | private String err; |
| | | |
| | | @ApiModelProperty(value = "开始时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date startTime; |
| | | |
| | | @ApiModelProperty(value = "结束时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date endTime; |
| | | |
| | | @ApiModelProperty(value = "耗时毫秒") |
| | | private Long spendTime; |
| | | |
| | | @ApiModelProperty(value = "状态 1:正常 0:冻结") |
| | | private Integer status; |
| | | |
| | | @ApiModelProperty(value = "是否删除 1:是 0:否") |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | @ApiModelProperty(value = "租户") |
| | | private Long tenantId; |
| | | |
| | | @ApiModelProperty(value = "用户") |
| | | private Long userId; |
| | | |
| | | @ApiModelProperty(value = "创建时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | @ApiModelProperty(value = "更新时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | public String getSceneCode$() { |
| | | if (AiSceneCode.SYSTEM_DIAGNOSE.equals(this.sceneCode)) { |
| | | return "系统诊断"; |
| | | } |
| | | if (AiSceneCode.GENERAL_CHAT.equals(this.sceneCode)) { |
| | | return "通用对话"; |
| | | } |
| | | return this.sceneCode; |
| | | } |
| | | |
| | | public String getResult$() { |
| | | if (this.result == null) { |
| | | return null; |
| | | } |
| | | if (Integer.valueOf(2).equals(this.result)) { |
| | | return "运行中"; |
| | | } |
| | | if (Integer.valueOf(1).equals(this.result)) { |
| | | return "成功"; |
| | | } |
| | | if (Integer.valueOf(0).equals(this.result)) { |
| | | return "失败"; |
| | | } |
| | | return String.valueOf(this.result); |
| | | } |
| | | |
| | | public String getUserId$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.userId); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getTenantId$() { |
| | | TenantService service = SpringUtils.getBean(TenantService.class); |
| | | Tenant tenant = service.getById(this.tenantId); |
| | | if (!Cools.isEmpty(tenant)) { |
| | | return String.valueOf(tenant.getName()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getStartTime$() { |
| | | if (Cools.isEmpty(this.startTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.startTime); |
| | | } |
| | | |
| | | public String getEndTime$() { |
| | | if (Cools.isEmpty(this.endTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.endTime); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_diagnostic_tool_config") |
| | | public class AiDiagnosticToolConfig implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty("编号") |
| | | private String uuid; |
| | | |
| | | @ApiModelProperty("场景编码") |
| | | private String sceneCode; |
| | | |
| | | @ApiModelProperty("工具编码") |
| | | private String toolCode; |
| | | |
| | | @ApiModelProperty("工具名称") |
| | | private String toolName; |
| | | |
| | | @ApiModelProperty("启用 1:是 0:否") |
| | | private Integer enabledFlag; |
| | | |
| | | @ApiModelProperty("优先级") |
| | | private Integer priority; |
| | | |
| | | @ApiModelProperty("附加提示词") |
| | | private String toolPrompt; |
| | | |
| | | @ApiModelProperty("用途范围") |
| | | private String usageScope; |
| | | |
| | | @ApiModelProperty("状态") |
| | | private Integer status; |
| | | |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | private Long tenantId; |
| | | private Long createBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | private Long updateBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | private String memo; |
| | | |
| | | public Boolean getEnabledFlagBool() { |
| | | if (enabledFlag == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(enabledFlag); |
| | | } |
| | | |
| | | public Boolean getStatusBool() { |
| | | if (status == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(status); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_mcp_mount") |
| | | public class AiMcpMount implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty("编号") |
| | | private String uuid; |
| | | |
| | | @ApiModelProperty("名称") |
| | | private String name; |
| | | |
| | | @ApiModelProperty("挂载编码") |
| | | private String mountCode; |
| | | |
| | | @ApiModelProperty("传输类型") |
| | | private String transportType; |
| | | |
| | | @ApiModelProperty("地址") |
| | | private String url; |
| | | |
| | | @ApiModelProperty("认证方式") |
| | | private String authType; |
| | | |
| | | @ApiModelProperty("认证值") |
| | | private String authValue; |
| | | |
| | | @ApiModelProperty("用途范围") |
| | | private String usageScope; |
| | | |
| | | @ApiModelProperty("启用 1:是 0:否") |
| | | private Integer enabledFlag; |
| | | |
| | | @ApiModelProperty("超时毫秒") |
| | | private Integer timeoutMs; |
| | | |
| | | @ApiModelProperty("上次测试结果 1:成功 0:失败") |
| | | private Integer lastTestResult; |
| | | |
| | | @ApiModelProperty("上次测试时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date lastTestTime; |
| | | |
| | | @ApiModelProperty("上次测试消息") |
| | | private String lastTestMessage; |
| | | |
| | | @ApiModelProperty("上次工具数") |
| | | private Integer lastToolCount; |
| | | |
| | | @ApiModelProperty("状态 1:正常 0:冻结") |
| | | private Integer status; |
| | | |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | private Long tenantId; |
| | | private Long createBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | private Long updateBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | private String memo; |
| | | |
| | | public Boolean getEnabledFlagBool() { |
| | | if (enabledFlag == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(enabledFlag); |
| | | } |
| | | |
| | | public String getLastTestResult$() { |
| | | if (lastTestResult == null) { |
| | | return "未测试"; |
| | | } |
| | | return Integer.valueOf(1).equals(lastTestResult) ? "成功" : "失败"; |
| | | } |
| | | |
| | | public String getLastTestTime$() { |
| | | if (Cools.isEmpty(lastTestTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(lastTestTime); |
| | | } |
| | | |
| | | public Boolean getInternalManaged() { |
| | | return "INTERNAL".equalsIgnoreCase(transportType); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.SpringUtils; |
| | | import com.vincent.rsf.server.system.service.TenantService; |
| | | import com.vincent.rsf.server.system.service.UserService; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_model_route") |
| | | public class AiModelRoute implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "ID") |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty(value = "编号") |
| | | private String uuid; |
| | | |
| | | @ApiModelProperty(value = "路由编码") |
| | | private String routeCode; |
| | | |
| | | @ApiModelProperty(value = "模型编码") |
| | | private String modelCode; |
| | | |
| | | @ApiModelProperty(value = "优先级") |
| | | private Integer priority; |
| | | |
| | | @ApiModelProperty(value = "冷却截止时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date cooldownUntil; |
| | | |
| | | @ApiModelProperty(value = "失败次数") |
| | | private Integer failCount; |
| | | |
| | | @ApiModelProperty(value = "成功次数") |
| | | private Integer successCount; |
| | | |
| | | @ApiModelProperty(value = "状态 1:启用 0:停用") |
| | | private Integer status; |
| | | |
| | | @ApiModelProperty(value = "是否删除 1:是 0:否") |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | @ApiModelProperty(value = "租户") |
| | | private Long tenantId; |
| | | |
| | | @ApiModelProperty(value = "添加人员") |
| | | private Long createBy; |
| | | |
| | | @ApiModelProperty(value = "添加时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | @ApiModelProperty(value = "修改人员") |
| | | private Long updateBy; |
| | | |
| | | @ApiModelProperty(value = "修改时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | @ApiModelProperty(value = "备注") |
| | | private String memo; |
| | | |
| | | public Boolean getStatusBool() { |
| | | if (this.status == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.status); |
| | | } |
| | | |
| | | public String getCooldownUntil$() { |
| | | if (Cools.isEmpty(this.cooldownUntil)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.cooldownUntil); |
| | | } |
| | | |
| | | public String getTenantId$() { |
| | | TenantService service = SpringUtils.getBean(TenantService.class); |
| | | Tenant tenant = service.getById(this.tenantId); |
| | | if (!Cools.isEmpty(tenant)) { |
| | | return String.valueOf(tenant.getName()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getCreateBy$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.createBy); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getCreateTime$() { |
| | | if (Cools.isEmpty(this.createTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime); |
| | | } |
| | | |
| | | public String getUpdateBy$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.updateBy); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getUpdateTime$() { |
| | | if (Cools.isEmpty(this.updateTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_prompt_publish_log") |
| | | public class AiPromptPublishLog implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty("Prompt模板ID") |
| | | private Long promptTemplateId; |
| | | |
| | | @ApiModelProperty("场景编码") |
| | | private String sceneCode; |
| | | |
| | | @ApiModelProperty("模板名称") |
| | | private String templateName; |
| | | |
| | | @ApiModelProperty("版本号") |
| | | private Integer versionNo; |
| | | |
| | | @ApiModelProperty("动作") |
| | | private String actionType; |
| | | |
| | | @ApiModelProperty("动作描述") |
| | | private String actionDesc; |
| | | |
| | | private Long tenantId; |
| | | private Long createBy; |
| | | |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.entity; |
| | | |
| | | import com.baomidou.mybatisplus.annotation.IdType; |
| | | import com.baomidou.mybatisplus.annotation.TableId; |
| | | import com.baomidou.mybatisplus.annotation.TableLogic; |
| | | import com.baomidou.mybatisplus.annotation.TableName; |
| | | import com.fasterxml.jackson.annotation.JsonFormat; |
| | | import com.vincent.rsf.framework.common.Cools; |
| | | import com.vincent.rsf.framework.common.SpringUtils; |
| | | import com.vincent.rsf.server.ai.constant.AiSceneCode; |
| | | import com.vincent.rsf.server.system.service.TenantService; |
| | | import com.vincent.rsf.server.system.service.UserService; |
| | | import io.swagger.annotations.ApiModelProperty; |
| | | import lombok.Data; |
| | | import lombok.experimental.Accessors; |
| | | import org.springframework.format.annotation.DateTimeFormat; |
| | | |
| | | import java.io.Serializable; |
| | | import java.text.SimpleDateFormat; |
| | | import java.util.Date; |
| | | |
| | | @Data |
| | | @Accessors(chain = true) |
| | | @TableName("sys_ai_prompt_template") |
| | | public class AiPromptTemplate implements Serializable { |
| | | |
| | | private static final long serialVersionUID = 1L; |
| | | |
| | | @ApiModelProperty(value = "ID") |
| | | @TableId(value = "id", type = IdType.AUTO) |
| | | private Long id; |
| | | |
| | | @ApiModelProperty(value = "编号") |
| | | private String uuid; |
| | | |
| | | @ApiModelProperty(value = "场景编码") |
| | | private String sceneCode; |
| | | |
| | | @ApiModelProperty(value = "模板名称") |
| | | private String templateName; |
| | | |
| | | @ApiModelProperty(value = "基础提示词") |
| | | private String basePrompt; |
| | | |
| | | @ApiModelProperty(value = "工具提示词") |
| | | private String toolPrompt; |
| | | |
| | | @ApiModelProperty(value = "输出提示词") |
| | | private String outputPrompt; |
| | | |
| | | @ApiModelProperty(value = "版本号") |
| | | private Integer versionNo; |
| | | |
| | | @ApiModelProperty(value = "已发布 1:是 0:否") |
| | | private Integer publishedFlag; |
| | | |
| | | @ApiModelProperty(value = "状态 1:正常 0:冻结") |
| | | private Integer status; |
| | | |
| | | @ApiModelProperty(value = "是否删除 1:是 0:否") |
| | | @TableLogic |
| | | private Integer deleted; |
| | | |
| | | @ApiModelProperty(value = "租户") |
| | | private Long tenantId; |
| | | |
| | | @ApiModelProperty(value = "添加人员") |
| | | private Long createBy; |
| | | |
| | | @ApiModelProperty(value = "添加时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date createTime; |
| | | |
| | | @ApiModelProperty(value = "修改人员") |
| | | private Long updateBy; |
| | | |
| | | @ApiModelProperty(value = "修改时间") |
| | | @DateTimeFormat(pattern = "yyyy-MM-dd HH:mm:ss") |
| | | @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss", timezone = "GMT+8") |
| | | private Date updateTime; |
| | | |
| | | @ApiModelProperty(value = "备注") |
| | | private String memo; |
| | | |
| | | public String getSceneCode$() { |
| | | if (AiSceneCode.SYSTEM_DIAGNOSE.equals(this.sceneCode)) { |
| | | return "系统诊断"; |
| | | } |
| | | if (AiSceneCode.GENERAL_CHAT.equals(this.sceneCode)) { |
| | | return "通用对话"; |
| | | } |
| | | return this.sceneCode; |
| | | } |
| | | |
| | | public Boolean getPublishedFlagBool() { |
| | | if (this.publishedFlag == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.publishedFlag); |
| | | } |
| | | |
| | | public String getPublishedFlag$() { |
| | | if (this.publishedFlag == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.publishedFlag) ? "已发布" : "草稿"; |
| | | } |
| | | |
| | | public Boolean getStatusBool() { |
| | | if (this.status == null) { |
| | | return null; |
| | | } |
| | | return Integer.valueOf(1).equals(this.status); |
| | | } |
| | | |
| | | public String getTenantId$() { |
| | | TenantService service = SpringUtils.getBean(TenantService.class); |
| | | Tenant tenant = service.getById(this.tenantId); |
| | | if (!Cools.isEmpty(tenant)) { |
| | | return String.valueOf(tenant.getName()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getCreateBy$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.createBy); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getCreateTime$() { |
| | | if (Cools.isEmpty(this.createTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.createTime); |
| | | } |
| | | |
| | | public String getUpdateBy$() { |
| | | UserService service = SpringUtils.getBean(UserService.class); |
| | | User user = service.getById(this.updateBy); |
| | | if (!Cools.isEmpty(user)) { |
| | | return String.valueOf(user.getNickname()); |
| | | } |
| | | return null; |
| | | } |
| | | |
| | | public String getUpdateTime$() { |
| | | if (Cools.isEmpty(this.updateTime)) { |
| | | return ""; |
| | | } |
| | | return new SimpleDateFormat("yyyy-MM-dd HH:mm:ss").format(this.updateTime); |
| | | } |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiCallLogMapper extends BaseMapper<AiCallLog> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AiDiagnosisPlanMapper extends BaseMapper<AiDiagnosisPlan> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiDiagnosisRecordMapper extends BaseMapper<AiDiagnosisRecord> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AiDiagnosticToolConfigMapper extends BaseMapper<AiDiagnosticToolConfig> { |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AiMcpMountMapper extends BaseMapper<AiMcpMount> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiModelRoute; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiModelRouteMapper extends BaseMapper<AiModelRoute> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface AiParamMapper extends BaseMapper<AiParam> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiPromptPublishLog; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | |
| | | @Mapper |
| | | public interface AiPromptPublishLogMapper extends BaseMapper<AiPromptPublishLog> { |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.mapper; |
| | | |
| | | import com.baomidou.mybatisplus.core.mapper.BaseMapper; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | import org.apache.ibatis.annotations.Mapper; |
| | | import org.springframework.stereotype.Repository; |
| | | |
| | | @Mapper |
| | | @Repository |
| | | public interface AiPromptTemplateMapper extends BaseMapper<AiPromptTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface DeptMapper extends BaseMapper<Dept> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface DictDataMapper extends BaseMapper<DictData> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface DictTypeMapper extends BaseMapper<DictType> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FieldsItemMapper extends BaseMapper<FieldsItem> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FieldsMapper extends BaseMapper<Fields> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowInstanceMapper extends BaseMapper<FlowInstance> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowStepInstanceMapper extends BaseMapper<FlowStepInstance> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowStepLogMapper extends BaseMapper<FlowStepLog> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowStepTemplateMapper extends BaseMapper<FlowStepTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface HostMapper extends BaseMapper<Host> { |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(Long roleId); |
| | | } |
| | | |
| | |
| | | public interface MenuMapper extends BaseMapper<Menu> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface OperationRecordMapper extends BaseMapper<OperationRecord> { |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(@Param("roleId") Long roleId); |
| | | } |
| | | |
| | |
| | | public interface RoleMapper extends BaseMapper<Role> { |
| | | |
| | | } |
| | | |
| | |
| | | List<Long> listStrictlyMenuByRoleId(@Param("roleId") Long roleId); |
| | | |
| | | } |
| | | |
| | |
| | | public interface SerialRuleItemMapper extends BaseMapper<SerialRuleItem> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface SerialRuleMapper extends BaseMapper<SerialRule> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface SubsystemFlowTemplateMapper extends BaseMapper<SubsystemFlowTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskInstanceMapper extends BaseMapper<TaskInstance> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskInstanceNodeMapper extends BaseMapper<TaskInstanceNode> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskPathTemplateMapper extends BaseMapper<TaskPathTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskPathTemplateMergeMapper extends BaseMapper<TaskPathTemplateMerge> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskPathTemplateNodeMapper extends BaseMapper<TaskPathTemplateNode> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface UserLoginMapper extends BaseMapper<UserLogin> { |
| | | |
| | | } |
| | | |
| | |
| | | User selectByEmailWithoutTenant(@Param("email") String email, @Param("tenantId") Long tenantId); |
| | | |
| | | } |
| | | |
| | |
| | | List<Role> selectByUserId(@Param("userId") Long userId); |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(@Param("roleId") Long roleId); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | |
| | | public interface AiCallLogService extends IService<AiCallLog> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | public interface AiDiagnosisPlanService extends IService<AiDiagnosisPlan> { |
| | | |
| | | AiDiagnosisPlan getTenantPlan(Long tenantId, Long id); |
| | | |
| | | List<AiDiagnosisPlan> listDuePlans(Date now); |
| | | |
| | | Date calculateNextRunTime(String cronExpr, Date after); |
| | | |
| | | boolean validateCron(String cronExpr); |
| | | |
| | | boolean acquireForExecution(Long id, Long operatorId, String lastMessage, Date nextRunTime); |
| | | |
| | | void finishExecution(Long id, Integer lastResult, Long lastDiagnosisId, String lastMessage, Date finishTime, Date nextRunTime); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | |
| | | public interface AiDiagnosisRecordService extends IService<AiDiagnosisRecord> { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | |
| | | import java.util.List; |
| | | |
| | | public interface AiDiagnosticToolConfigService extends IService<AiDiagnosticToolConfig> { |
| | | |
| | | List<AiDiagnosticToolConfig> listTenantConfigs(Long tenantId); |
| | | |
| | | List<AiDiagnosticToolConfig> listSceneConfigs(Long tenantId, String sceneCode); |
| | | |
| | | AiDiagnosticToolConfig getTenantConfig(Long tenantId, Long id); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | |
| | | import java.util.List; |
| | | |
| | | public interface AiMcpMountService extends IService<AiMcpMount> { |
| | | |
| | | List<AiMcpMount> listTenantMounts(Long tenantId); |
| | | |
| | | AiMcpMount getTenantMount(Long tenantId, Long id); |
| | | |
| | | AiMcpMount getTenantMountByCode(Long tenantId, String mountCode); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiModelRoute; |
| | | |
| | | import java.util.List; |
| | | |
| | | public interface AiModelRouteService extends IService<AiModelRoute> { |
| | | |
| | | AiModelRoute getTenantRoute(Long tenantId, Long id); |
| | | |
| | | List<AiModelRoute> listAvailableRoutes(Long tenantId, String routeCode); |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | AiParam getDefaultModel(); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiPromptPublishLog; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | |
| | | import java.util.List; |
| | | |
| | | public interface AiPromptPublishLogService extends IService<AiPromptPublishLog> { |
| | | |
| | | void saveLog(Long tenantId, Long userId, AiPromptTemplate template, String actionType, String actionDesc); |
| | | |
| | | List<AiPromptPublishLog> listSceneLogs(Long tenantId, String sceneCode); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.IService; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | |
| | | import java.util.List; |
| | | |
| | | public interface AiPromptTemplateService extends IService<AiPromptTemplate> { |
| | | |
| | | AiPromptTemplate getTenantTemplate(Long tenantId, Long id); |
| | | |
| | | AiPromptTemplate getPublishedTemplate(Long tenantId, String sceneCode); |
| | | |
| | | List<AiPromptTemplate> listVersions(Long tenantId, String sceneCode); |
| | | |
| | | boolean publishTemplate(Long tenantId, Long id, Long userId); |
| | | |
| | | boolean rollbackTemplate(Long tenantId, Long id, Long userId); |
| | | |
| | | int nextVersionNo(Long tenantId, String sceneCode); |
| | | |
| | | AiPromptTemplate copyTemplate(Long tenantId, Long id, Long userId); |
| | | } |
| | | |
| | |
| | | |
| | | R modiftyStatus(Config config); |
| | | } |
| | | |
| | |
| | | public interface DeptService extends IService<Dept> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface DictDataService extends IService<DictData> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface DictTypeService extends IService<DictType> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FieldsItemService extends IService<FieldsItem> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FieldsService extends IService<Fields> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowInstanceService extends IService<FlowInstance> { |
| | | |
| | | } |
| | | |
| | |
| | | boolean jumpCurrent(Long id); |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowStepLogService extends IService<FlowStepLog> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface FlowStepTemplateService extends IService<FlowStepTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface HostService extends IService<Host> { |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(Long roleId); |
| | | } |
| | | |
| | |
| | | public interface MenuService extends IService<Menu> { |
| | | |
| | | } |
| | | |
| | |
| | | void saveAsync(OperationRecord operationRecord); |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(Long roleId); |
| | | } |
| | | |
| | |
| | | List<Long> listStrictlyMenuByRoleId(Long roleId); |
| | | |
| | | } |
| | | |
| | |
| | | public interface RoleService extends IService<Role> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface SerialRuleItemService extends IService<SerialRuleItem> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface SerialRuleService extends IService<SerialRule> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface SubsystemFlowTemplateService extends IService<SubsystemFlowTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskInstanceNodeService extends IService<TaskInstanceNode> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskInstanceService extends IService<TaskInstance> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskPathTemplateNodeService extends IService<TaskPathTemplateNode> { |
| | | |
| | | } |
| | | |
| | |
| | | public interface TaskPathTemplateService extends IService<TaskPathTemplate> { |
| | | |
| | | } |
| | | |
| | |
| | | Long initTenant(TenantInitParam param); |
| | | |
| | | } |
| | | |
| | |
| | | void saveAsync(Long userId, String token, Integer type, Long tenantId, String memo, HttpServletRequest request); |
| | | |
| | | } |
| | | |
| | |
| | | List<Role> listByUserId(Long userId); |
| | | |
| | | } |
| | | |
| | |
| | | |
| | | User selectByUsernameWithoutTenant(String username, Long tenantId); |
| | | } |
| | | |
| | |
| | | |
| | | List<Long> listStrictlyMenuByRoleId(Long roleId); |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiCallLog; |
| | | import com.vincent.rsf.server.system.mapper.AiCallLogMapper; |
| | | import com.vincent.rsf.server.system.service.AiCallLogService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | @Service("aiCallLogService") |
| | | public class AiCallLogServiceImpl extends ServiceImpl<AiCallLogMapper, AiCallLog> implements AiCallLogService { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisPlan; |
| | | import com.vincent.rsf.server.system.mapper.AiDiagnosisPlanMapper; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisPlanService; |
| | | import org.springframework.scheduling.support.CronSequenceGenerator; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Service("aiDiagnosisPlanService") |
| | | public class AiDiagnosisPlanServiceImpl extends ServiceImpl<AiDiagnosisPlanMapper, AiDiagnosisPlan> implements AiDiagnosisPlanService { |
| | | |
| | | @Override |
| | | public AiDiagnosisPlan getTenantPlan(Long tenantId, Long id) { |
| | | if (tenantId == null || id == null) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getTenantId, tenantId) |
| | | .eq(AiDiagnosisPlan::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiDiagnosisPlan> listDuePlans(Date now) { |
| | | Date target = now == null ? new Date() : now; |
| | | return this.list(new LambdaQueryWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getStatus, 1) |
| | | .eq(AiDiagnosisPlan::getRunningFlag, 0) |
| | | .isNotNull(AiDiagnosisPlan::getNextRunTime) |
| | | .le(AiDiagnosisPlan::getNextRunTime, target) |
| | | .orderByAsc(AiDiagnosisPlan::getNextRunTime, AiDiagnosisPlan::getId)); |
| | | } |
| | | |
| | | @Override |
| | | public Date calculateNextRunTime(String cronExpr, Date after) { |
| | | if (!validateCron(cronExpr)) { |
| | | return null; |
| | | } |
| | | return new CronSequenceGenerator(cronExpr.trim()).next(after == null ? new Date() : after); |
| | | } |
| | | |
| | | @Override |
| | | public boolean validateCron(String cronExpr) { |
| | | if (cronExpr == null || cronExpr.trim().isEmpty()) { |
| | | return false; |
| | | } |
| | | return CronSequenceGenerator.isValidExpression(cronExpr.trim()); |
| | | } |
| | | |
| | | @Override |
| | | public boolean acquireForExecution(Long id, Long operatorId, String lastMessage, Date nextRunTime) { |
| | | if (id == null) { |
| | | return false; |
| | | } |
| | | Date now = new Date(); |
| | | return this.update(new LambdaUpdateWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getId, id) |
| | | .eq(AiDiagnosisPlan::getRunningFlag, 0) |
| | | .set(AiDiagnosisPlan::getRunningFlag, 1) |
| | | .set(AiDiagnosisPlan::getLastResult, 2) |
| | | .set(AiDiagnosisPlan::getLastMessage, lastMessage) |
| | | .set(AiDiagnosisPlan::getNextRunTime, nextRunTime) |
| | | .set(AiDiagnosisPlan::getUpdateBy, operatorId) |
| | | .set(AiDiagnosisPlan::getUpdateTime, now)); |
| | | } |
| | | |
| | | @Override |
| | | public void finishExecution(Long id, Integer lastResult, Long lastDiagnosisId, String lastMessage, Date finishTime, Date nextRunTime) { |
| | | if (id == null) { |
| | | return; |
| | | } |
| | | Date now = finishTime == null ? new Date() : finishTime; |
| | | this.update(new LambdaUpdateWrapper<AiDiagnosisPlan>() |
| | | .eq(AiDiagnosisPlan::getId, id) |
| | | .set(AiDiagnosisPlan::getRunningFlag, 0) |
| | | .set(AiDiagnosisPlan::getLastResult, lastResult) |
| | | .set(AiDiagnosisPlan::getLastDiagnosisId, lastDiagnosisId) |
| | | .set(AiDiagnosisPlan::getLastRunTime, now) |
| | | .set(AiDiagnosisPlan::getNextRunTime, nextRunTime) |
| | | .set(AiDiagnosisPlan::getLastMessage, lastMessage) |
| | | .set(AiDiagnosisPlan::getUpdateTime, now)); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosisRecord; |
| | | import com.vincent.rsf.server.system.mapper.AiDiagnosisRecordMapper; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosisRecordService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | @Service("aiDiagnosisRecordService") |
| | | public class AiDiagnosisRecordServiceImpl extends ServiceImpl<AiDiagnosisRecordMapper, AiDiagnosisRecord> implements AiDiagnosisRecordService { |
| | | |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiDiagnosticToolConfig; |
| | | import com.vincent.rsf.server.system.mapper.AiDiagnosticToolConfigMapper; |
| | | import com.vincent.rsf.server.system.service.AiDiagnosticToolConfigService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.List; |
| | | |
| | | @Service("aiDiagnosticToolConfigService") |
| | | public class AiDiagnosticToolConfigServiceImpl extends ServiceImpl<AiDiagnosticToolConfigMapper, AiDiagnosticToolConfig> implements AiDiagnosticToolConfigService { |
| | | |
| | | @Override |
| | | public List<AiDiagnosticToolConfig> listTenantConfigs(Long tenantId) { |
| | | return this.list(new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, tenantId) |
| | | .orderByAsc(AiDiagnosticToolConfig::getSceneCode, AiDiagnosticToolConfig::getPriority, AiDiagnosticToolConfig::getId)); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiDiagnosticToolConfig> listSceneConfigs(Long tenantId, String sceneCode) { |
| | | return this.list(new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, tenantId) |
| | | .eq(AiDiagnosticToolConfig::getSceneCode, sceneCode) |
| | | .eq(AiDiagnosticToolConfig::getStatus, 1) |
| | | .orderByAsc(AiDiagnosticToolConfig::getPriority, AiDiagnosticToolConfig::getId)); |
| | | } |
| | | |
| | | @Override |
| | | public AiDiagnosticToolConfig getTenantConfig(Long tenantId, Long id) { |
| | | if (tenantId == null || id == null) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiDiagnosticToolConfig>() |
| | | .eq(AiDiagnosticToolConfig::getTenantId, tenantId) |
| | | .eq(AiDiagnosticToolConfig::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiMcpMount; |
| | | import com.vincent.rsf.server.system.mapper.AiMcpMountMapper; |
| | | import com.vincent.rsf.server.system.service.AiMcpMountService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.List; |
| | | |
| | | @Service("aiMcpMountService") |
| | | public class AiMcpMountServiceImpl extends ServiceImpl<AiMcpMountMapper, AiMcpMount> implements AiMcpMountService { |
| | | |
| | | @Override |
| | | public List<AiMcpMount> listTenantMounts(Long tenantId) { |
| | | return this.list(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, tenantId) |
| | | .orderByAsc(AiMcpMount::getMountCode, AiMcpMount::getId)); |
| | | } |
| | | |
| | | @Override |
| | | public AiMcpMount getTenantMount(Long tenantId, Long id) { |
| | | if (tenantId == null || id == null) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, tenantId) |
| | | .eq(AiMcpMount::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public AiMcpMount getTenantMountByCode(Long tenantId, String mountCode) { |
| | | if (tenantId == null || mountCode == null || mountCode.trim().isEmpty()) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiMcpMount>() |
| | | .eq(AiMcpMount::getTenantId, tenantId) |
| | | .eq(AiMcpMount::getMountCode, mountCode) |
| | | .last("limit 1")); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiModelRoute; |
| | | import com.vincent.rsf.server.system.mapper.AiModelRouteMapper; |
| | | import com.vincent.rsf.server.system.service.AiModelRouteService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Service("aiModelRouteService") |
| | | public class AiModelRouteServiceImpl extends ServiceImpl<AiModelRouteMapper, AiModelRoute> implements AiModelRouteService { |
| | | |
| | | @Override |
| | | public AiModelRoute getTenantRoute(Long tenantId, Long id) { |
| | | if (tenantId == null || id == null) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiModelRoute>() |
| | | .eq(AiModelRoute::getTenantId, tenantId) |
| | | .eq(AiModelRoute::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiModelRoute> listAvailableRoutes(Long tenantId, String routeCode) { |
| | | Date now = new Date(); |
| | | return this.list(new LambdaQueryWrapper<AiModelRoute>() |
| | | .eq(AiModelRoute::getTenantId, tenantId) |
| | | .eq(AiModelRoute::getRouteCode, routeCode) |
| | | .eq(AiModelRoute::getStatus, 1) |
| | | .and(wrapper -> wrapper.isNull(AiModelRoute::getCooldownUntil).or().le(AiModelRoute::getCooldownUntil, now)) |
| | | .orderByAsc(AiModelRoute::getPriority, AiModelRoute::getId)); |
| | | } |
| | | } |
| | | |
| | |
| | | return list.isEmpty() ? null : list.get(0); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiPromptPublishLog; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | import com.vincent.rsf.server.system.mapper.AiPromptPublishLogMapper; |
| | | import com.vincent.rsf.server.system.service.AiPromptPublishLogService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Service("aiPromptPublishLogService") |
| | | public class AiPromptPublishLogServiceImpl extends ServiceImpl<AiPromptPublishLogMapper, AiPromptPublishLog> implements AiPromptPublishLogService { |
| | | |
| | | @Override |
| | | public void saveLog(Long tenantId, Long userId, AiPromptTemplate template, String actionType, String actionDesc) { |
| | | if (tenantId == null || template == null) { |
| | | return; |
| | | } |
| | | this.save(new AiPromptPublishLog() |
| | | .setPromptTemplateId(template.getId()) |
| | | .setSceneCode(template.getSceneCode()) |
| | | .setTemplateName(template.getTemplateName()) |
| | | .setVersionNo(template.getVersionNo()) |
| | | .setActionType(actionType) |
| | | .setActionDesc(actionDesc) |
| | | .setTenantId(tenantId) |
| | | .setCreateBy(userId) |
| | | .setCreateTime(new Date())); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiPromptPublishLog> listSceneLogs(Long tenantId, String sceneCode) { |
| | | return this.list(new LambdaQueryWrapper<AiPromptPublishLog>() |
| | | .eq(AiPromptPublishLog::getTenantId, tenantId) |
| | | .eq(sceneCode != null && !sceneCode.trim().isEmpty(), AiPromptPublishLog::getSceneCode, sceneCode) |
| | | .orderByDesc(AiPromptPublishLog::getCreateTime, AiPromptPublishLog::getId)); |
| | | } |
| | | } |
| | | |
| New file |
| | |
| | | package com.vincent.rsf.server.system.service.impl; |
| | | |
| | | import com.baomidou.mybatisplus.core.conditions.query.LambdaQueryWrapper; |
| | | import com.baomidou.mybatisplus.core.conditions.update.LambdaUpdateWrapper; |
| | | import com.baomidou.mybatisplus.extension.service.impl.ServiceImpl; |
| | | import com.vincent.rsf.server.system.entity.AiPromptTemplate; |
| | | import com.vincent.rsf.server.system.mapper.AiPromptTemplateMapper; |
| | | import com.vincent.rsf.server.system.service.AiPromptPublishLogService; |
| | | import com.vincent.rsf.server.system.service.AiPromptTemplateService; |
| | | import org.springframework.stereotype.Service; |
| | | |
| | | import javax.annotation.Resource; |
| | | import java.util.Date; |
| | | import java.util.List; |
| | | |
| | | @Service("aiPromptTemplateService") |
| | | public class AiPromptTemplateServiceImpl extends ServiceImpl<AiPromptTemplateMapper, AiPromptTemplate> implements AiPromptTemplateService { |
| | | |
| | | @Resource |
| | | private AiPromptPublishLogService aiPromptPublishLogService; |
| | | |
| | | @Override |
| | | public AiPromptTemplate getTenantTemplate(Long tenantId, Long id) { |
| | | if (tenantId == null || id == null) { |
| | | return null; |
| | | } |
| | | return this.getOne(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, tenantId) |
| | | .eq(AiPromptTemplate::getId, id) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public AiPromptTemplate getPublishedTemplate(Long tenantId, String sceneCode) { |
| | | return this.getOne(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, tenantId) |
| | | .eq(AiPromptTemplate::getSceneCode, sceneCode) |
| | | .eq(AiPromptTemplate::getPublishedFlag, 1) |
| | | .eq(AiPromptTemplate::getStatus, 1) |
| | | .orderByDesc(AiPromptTemplate::getVersionNo, AiPromptTemplate::getId) |
| | | .last("limit 1")); |
| | | } |
| | | |
| | | @Override |
| | | public List<AiPromptTemplate> listVersions(Long tenantId, String sceneCode) { |
| | | return this.list(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, tenantId) |
| | | .eq(AiPromptTemplate::getSceneCode, sceneCode) |
| | | .orderByDesc(AiPromptTemplate::getVersionNo, AiPromptTemplate::getId)); |
| | | } |
| | | |
| | | @Override |
| | | public boolean publishTemplate(Long tenantId, Long id, Long userId) { |
| | | AiPromptTemplate target = getTenantTemplate(tenantId, id); |
| | | if (target == null) { |
| | | return false; |
| | | } |
| | | boolean updated = switchPublishedTemplate(tenantId, userId, target); |
| | | if (updated) { |
| | | aiPromptPublishLogService.saveLog(tenantId, userId, target, "publish", "发布 Prompt 版本"); |
| | | } |
| | | return updated; |
| | | } |
| | | |
| | | @Override |
| | | public boolean rollbackTemplate(Long tenantId, Long id, Long userId) { |
| | | AiPromptTemplate target = getTenantTemplate(tenantId, id); |
| | | if (target == null) { |
| | | return false; |
| | | } |
| | | boolean updated = switchPublishedTemplate(tenantId, userId, target); |
| | | if (updated) { |
| | | aiPromptPublishLogService.saveLog(tenantId, userId, target, "rollback", "回滚到指定 Prompt 版本"); |
| | | } |
| | | return updated; |
| | | } |
| | | |
| | | private boolean switchPublishedTemplate(Long tenantId, Long userId, AiPromptTemplate target) { |
| | | this.update(new LambdaUpdateWrapper<AiPromptTemplate>() |
| | | .set(AiPromptTemplate::getPublishedFlag, 0) |
| | | .eq(AiPromptTemplate::getTenantId, tenantId) |
| | | .eq(AiPromptTemplate::getSceneCode, target.getSceneCode()) |
| | | .eq(AiPromptTemplate::getPublishedFlag, 1)); |
| | | target.setPublishedFlag(1); |
| | | target.setUpdateBy(userId); |
| | | target.setUpdateTime(new Date()); |
| | | return this.updateById(target); |
| | | } |
| | | |
| | | @Override |
| | | public int nextVersionNo(Long tenantId, String sceneCode) { |
| | | AiPromptTemplate latest = this.getOne(new LambdaQueryWrapper<AiPromptTemplate>() |
| | | .eq(AiPromptTemplate::getTenantId, tenantId) |
| | | .eq(AiPromptTemplate::getSceneCode, sceneCode) |
| | | .orderByDesc(AiPromptTemplate::getVersionNo, AiPromptTemplate::getId) |
| | | .last("limit 1")); |
| | | return latest == null || latest.getVersionNo() == null ? 1 : latest.getVersionNo() + 1; |
| | | } |
| | | |
| | | @Override |
| | | public AiPromptTemplate copyTemplate(Long tenantId, Long id, Long userId) { |
| | | AiPromptTemplate source = getTenantTemplate(tenantId, id); |
| | | if (source == null) { |
| | | return null; |
| | | } |
| | | Date now = new Date(); |
| | | AiPromptTemplate copied = new AiPromptTemplate() |
| | | .setUuid(String.valueOf(System.currentTimeMillis())) |
| | | .setSceneCode(source.getSceneCode()) |
| | | .setTemplateName((source.getTemplateName() == null ? source.getSceneCode() : source.getTemplateName()) + "-副本") |
| | | .setBasePrompt(source.getBasePrompt()) |
| | | .setToolPrompt(source.getToolPrompt()) |
| | | .setOutputPrompt(source.getOutputPrompt()) |
| | | .setVersionNo(nextVersionNo(tenantId, source.getSceneCode())) |
| | | .setPublishedFlag(0) |
| | | .setStatus(source.getStatus()) |
| | | .setDeleted(0) |
| | | .setTenantId(tenantId) |
| | | .setCreateBy(userId) |
| | | .setCreateTime(now) |
| | | .setUpdateBy(userId) |
| | | .setUpdateTime(now) |
| | | .setMemo(source.getMemo()); |
| | | if (!this.save(copied)) { |
| | | return null; |
| | | } |
| | | aiPromptPublishLogService.saveLog(tenantId, userId, copied, "copy", "复制 Prompt 版本为新草稿"); |
| | | return copied; |
| | | } |
| | | } |
| | | |
| | |
| | | public class DeptServiceImpl extends ServiceImpl<DeptMapper, Dept> implements DeptService { |
| | | |
| | | } |
| | | |
| | |
| | | public class DictDataServiceImpl extends ServiceImpl<DictDataMapper, DictData> implements DictDataService { |
| | | |
| | | } |
| | | |
| | |
| | | public class DictTypeServiceImpl extends ServiceImpl<DictTypeMapper, DictType> implements DictTypeService { |
| | | |
| | | } |
| | | |
| | |
| | | public class FieldsItemServiceImpl extends ServiceImpl<FieldsItemMapper, FieldsItem> implements FieldsItemService { |
| | | |
| | | } |
| | | |
| | |
| | | public class FieldsServiceImpl extends ServiceImpl<FieldsMapper, Fields> implements FieldsService { |
| | | |
| | | } |
| | | |
| | |
| | | public class FlowInstanceServiceImpl extends ServiceImpl<FlowInstanceMapper, FlowInstance> implements FlowInstanceService { |
| | | |
| | | } |
| | | |
| | |
| | | public class FlowStepLogServiceImpl extends ServiceImpl<FlowStepLogMapper, FlowStepLog> implements FlowStepLogService { |
| | | |
| | | } |
| | | |
| | |
| | | public class FlowStepTemplateServiceImpl extends ServiceImpl<FlowStepTemplateMapper, FlowStepTemplate> implements FlowStepTemplateService { |
| | | |
| | | } |
| | | |
| | |
| | | public class HostServiceImpl extends ServiceImpl<HostMapper, Host> implements HostService { |
| | | |
| | | } |
| | | |
| | |
| | | return baseMapper.listStrictlyMenuByRoleId(roleId); |
| | | } |
| | | } |
| | | |
| | |
| | | public class MenuServiceImpl extends ServiceImpl<MenuMapper, Menu> implements MenuService { |
| | | |
| | | } |
| | | |
| | |
| | | return baseMapper.listStrictlyMenuByRoleId(roleId); |
| | | } |
| | | } |
| | | |
| | |
| | | public class RoleServiceImpl extends ServiceImpl<RoleMapper, Role> implements RoleService { |
| | | |
| | | } |
| | | |
| | |
| | | public class SerialRuleItemServiceImpl extends ServiceImpl<SerialRuleItemMapper, SerialRuleItem> implements SerialRuleItemService { |
| | | |
| | | } |
| | | |
| | |
| | | public class SerialRuleServiceImpl extends ServiceImpl<SerialRuleMapper, SerialRule> implements SerialRuleService { |
| | | |
| | | } |
| | | |
| | |
| | | public class SubsystemFlowTemplateServiceImpl extends ServiceImpl<SubsystemFlowTemplateMapper, SubsystemFlowTemplate> implements SubsystemFlowTemplateService { |
| | | |
| | | } |
| | | |
| | |
| | | public class TaskInstanceNodeServiceImpl extends ServiceImpl<TaskInstanceNodeMapper, TaskInstanceNode> implements TaskInstanceNodeService { |
| | | |
| | | } |
| | | |
| | |
| | | public class TaskInstanceServiceImpl extends ServiceImpl<TaskInstanceMapper, TaskInstance> implements TaskInstanceService { |
| | | |
| | | } |
| | | |
| | |
| | | public class TaskPathTemplateNodeServiceImpl extends ServiceImpl<TaskPathTemplateNodeMapper, TaskPathTemplateNode> implements TaskPathTemplateNodeService { |
| | | |
| | | } |
| | | |
| | |
| | | public class TaskPathTemplateServiceImpl extends ServiceImpl<TaskPathTemplateMapper, TaskPathTemplate> implements TaskPathTemplateService { |
| | | |
| | | } |
| | | |
| | |
| | | return this.baseMapper.listStrictlyMenuByRoleId(roleId); |
| | | } |
| | | } |
| | | |
| | |
| | | ai:
|
| | | session-ttl-seconds: 86400
|
| | | max-context-messages: 12
|
| | | default-model-code: mock-general
|
| | | default-model-code: deepseek-ai/DeepSeek-V3.2
|
| | | system-prompt: 你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。
|
| | | diagnosis-system-prompt: 你是一名资深WMS智能诊断助手,目标是结合当前系统上下文对仓库运行情况做巡检分析。回答时禁止凭空猜测,必须优先依据提供的实时摘要进行判断。请按“问题概述、关键证据、可能原因、建议动作、风险评估”的结构输出,并优先给出可执行建议。
|
| | | route-fail-threshold: 3
|
| | | route-cooldown-minutes: 10
|
| | | diagnostic-log-window-hours: 24
|
| | | api-failure-window-hours: 24
|
| | | models:
|
| | | - code: mock-general
|
| | | name: Mock General
|
| | | provider: mock
|
| | | enabled: true
|
| | | - code: mock-creative
|
| | | name: Mock Creative
|
| | | provider: mock
|
| | | - code: deepseek-ai/DeepSeek-V3.2
|
| | | name: DEEPSEEK
|
| | | provider: openai
|
| | | enabled: true
|
| | |
|
| | | # 下位机配置
|
| New file |
| | |
| | | -- AI 全量迁移聚合脚本 |
| | | -- 该文件聚合了原有全部 AI 相关迁移 SQL。 |
| | | -- 若已执行过部分脚本,可重复执行;各段脚本均保持幂等或兼容更新逻辑。 |
| | | |
| | | -- >>> 20260311_ai_param.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_param` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(255) DEFAULT NULL COMMENT '编号', |
| | | `name` varchar(255) DEFAULT NULL COMMENT '名称', |
| | | `model_code` varchar(255) DEFAULT NULL COMMENT '模型编码', |
| | | `provider` varchar(255) DEFAULT NULL COMMENT '供应商', |
| | | `chat_url` varchar(512) DEFAULT NULL COMMENT '聊天地址', |
| | | `api_key` varchar(512) DEFAULT NULL COMMENT 'API密钥', |
| | | `model_name` varchar(255) DEFAULT NULL COMMENT '模型名称', |
| | | `system_prompt` text COMMENT '系统提示词', |
| | | `max_context_messages` int(11) DEFAULT NULL COMMENT '上下文轮数', |
| | | `default_flag` int(1) NOT NULL DEFAULT '0' COMMENT '默认模型{1:是,0:否}', |
| | | `sort` int(11) DEFAULT NULL COMMENT '排序', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户[sys_tenant]', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '添加人员[sys_user]', |
| | | `create_time` timestamp NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '添加时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '修改人员[sys_user]', |
| | | `update_time` timestamp NULL DEFAULT CURRENT_TIMESTAMP COMMENT '修改时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_param_model_code` (`model_code`), |
| | | KEY `idx_ai_param_deleted_code` (`deleted`,`model_code`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8; |
| | | |
| | | INSERT INTO `sys_ai_param` |
| | | (`uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514305', '通用助手', 'mock-general', 'mock', NULL, NULL, 'mock-general', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', 12, 1, 1, 1, 0, 1, 2, NOW(), 2, NOW(), '默认演示模型' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_ai_param` |
| | | WHERE `model_code` = 'mock-general' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_param` |
| | | (`uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514306', '创意助手', 'mock-creative', 'mock', NULL, NULL, 'mock-creative', '你是WMS系统内的智能助手,回答时可以更灵活地组织表达,但结论必须准确。', 12, 0, 2, 1, 0, 1, 2, NOW(), 2, NOW(), '演示创意模型' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_ai_param` |
| | | WHERE `model_code` = 'mock-creative' |
| | | ); |
| | | |
| | | SET @ai_parent_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `component` = 'aiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 9, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_parent_menu_id IS NULL; |
| | | |
| | | SET @ai_parent_menu_id := COALESCE( |
| | | @ai_parent_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `component` = 'aiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_query_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Query AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_query_menu_id IS NULL; |
| | | |
| | | SET @ai_query_menu_id := COALESCE( |
| | | @ai_query_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Query AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_create_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Create AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_create_menu_id IS NULL; |
| | | |
| | | SET @ai_create_menu_id := COALESCE( |
| | | @ai_create_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Create AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_update_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Update AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_update_menu_id IS NULL; |
| | | |
| | | SET @ai_update_menu_id := COALESCE( |
| | | @ai_update_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Update AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_delete_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Delete AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_delete_menu_id IS NULL; |
| | | |
| | | SET @ai_delete_menu_id := COALESCE( |
| | | @ai_delete_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Delete AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_export_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Export AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Export AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 4, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_export_menu_id IS NULL; |
| | | |
| | | SET @ai_export_menu_id := COALESCE( |
| | | @ai_export_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Export AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_parent_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_parent_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_parent_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_query_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_query_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_query_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_create_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_create_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_create_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_update_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_update_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_update_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_delete_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_delete_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_delete_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_export_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_export_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_export_menu_id |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260311_ai_param_menu.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | SET @ai_parent_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `component` = 'aiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiParam', 1, 'menu.system', '1', 'menu.system', '/system/aiParam', 'aiParam', NULL, NULL, 0, NULL, 'SmartToy', 9, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_parent_menu_id IS NULL; |
| | | |
| | | SET @ai_parent_menu_id := COALESCE( |
| | | @ai_parent_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `component` = 'aiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_query_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Query AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_query_menu_id IS NULL; |
| | | |
| | | SET @ai_query_menu_id := COALESCE( |
| | | @ai_query_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Query AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_create_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Create AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_create_menu_id IS NULL; |
| | | |
| | | SET @ai_create_menu_id := COALESCE( |
| | | @ai_create_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Create AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_update_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Update AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_update_menu_id IS NULL; |
| | | |
| | | SET @ai_update_menu_id := COALESCE( |
| | | @ai_update_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Update AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_delete_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Delete AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_delete_menu_id IS NULL; |
| | | |
| | | SET @ai_delete_menu_id := COALESCE( |
| | | @ai_delete_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Delete AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | SET @ai_export_menu_id := ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Export AiParam' |
| | | LIMIT 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Export AiParam', @ai_parent_menu_id, NULL, CONCAT('1,', @ai_parent_menu_id), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiParam:list', NULL, 4, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL |
| | | WHERE @ai_export_menu_id IS NULL; |
| | | |
| | | SET @ai_export_menu_id := COALESCE( |
| | | @ai_export_menu_id, |
| | | LAST_INSERT_ID(), |
| | | ( |
| | | SELECT `id` |
| | | FROM `sys_menu` |
| | | WHERE `parent_id` = @ai_parent_menu_id |
| | | AND `name` = 'Export AiParam' |
| | | LIMIT 1 |
| | | ) |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_parent_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_parent_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_parent_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_query_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_query_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_query_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_create_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_create_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_create_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_update_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_update_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_update_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_delete_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_delete_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_delete_menu_id |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, @ai_export_menu_id |
| | | FROM DUAL |
| | | WHERE @ai_export_menu_id IS NOT NULL |
| | | AND NOT EXISTS ( |
| | | SELECT 1 |
| | | FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 |
| | | AND `menu_id` = @ai_export_menu_id |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_chat_storage.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_chat_session` ( |
| | | `id` varchar(64) NOT NULL COMMENT '会话ID', |
| | | `title` varchar(255) DEFAULT NULL COMMENT '会话标题', |
| | | `model_code` varchar(128) DEFAULT NULL COMMENT '模型编码', |
| | | `last_message` varchar(255) DEFAULT NULL COMMENT '最近消息摘要', |
| | | `last_message_at` datetime DEFAULT NULL COMMENT '最近消息时间', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_chat_session_owner` (`tenant_id`,`user_id`,`deleted`), |
| | | KEY `idx_ai_chat_session_update` (`update_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI会话表'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_chat_message` ( |
| | | `id` varchar(64) NOT NULL COMMENT '消息ID', |
| | | `session_id` varchar(64) NOT NULL COMMENT '会话ID', |
| | | `role` varchar(32) DEFAULT NULL COMMENT '角色', |
| | | `content` longtext COMMENT '消息内容', |
| | | `model_code` varchar(128) DEFAULT NULL COMMENT '模型编码', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_chat_message_session` (`session_id`,`deleted`,`create_time`), |
| | | KEY `idx_ai_chat_message_owner` (`tenant_id`,`user_id`,`deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI消息表'; |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase2.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_prompt_template` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(64) DEFAULT NULL COMMENT '编号', |
| | | `scene_code` varchar(64) NOT NULL COMMENT '场景编码', |
| | | `template_name` varchar(255) DEFAULT NULL COMMENT '模板名称', |
| | | `base_prompt` longtext COMMENT '基础提示词', |
| | | `tool_prompt` longtext COMMENT '工具提示词', |
| | | `output_prompt` longtext COMMENT '输出提示词', |
| | | `version_no` int(11) DEFAULT NULL COMMENT '版本号', |
| | | `published_flag` int(1) NOT NULL DEFAULT '0' COMMENT '已发布{1:是,0:否}', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_prompt_scene` (`tenant_id`,`scene_code`,`published_flag`,`deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI提示词模板'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_diagnosis_record` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `diagnosis_no` varchar(64) DEFAULT NULL COMMENT '诊断编号', |
| | | `session_id` varchar(64) DEFAULT NULL COMMENT '会话ID', |
| | | `scene_code` varchar(64) DEFAULT NULL COMMENT '场景编码', |
| | | `question` longtext COMMENT '问题', |
| | | `conclusion` longtext COMMENT '结论', |
| | | `tool_summary` longtext COMMENT '工具摘要', |
| | | `model_code` varchar(128) DEFAULT NULL COMMENT '模型编码', |
| | | `result` int(1) NOT NULL DEFAULT '2' COMMENT '结果{2:运行中,1:成功,0:失败}', |
| | | `err` varchar(1000) DEFAULT NULL COMMENT '错误信息', |
| | | `start_time` datetime DEFAULT NULL COMMENT '开始时间', |
| | | `end_time` datetime DEFAULT NULL COMMENT '结束时间', |
| | | `spend_time` bigint(20) DEFAULT NULL COMMENT '耗时毫秒', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_diag_owner` (`tenant_id`,`scene_code`,`deleted`,`create_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI诊断记录'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_call_log` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `session_id` varchar(64) DEFAULT NULL COMMENT '会话ID', |
| | | `diagnosis_id` bigint(20) DEFAULT NULL COMMENT '诊断记录ID', |
| | | `route_code` varchar(64) DEFAULT NULL COMMENT '路由编码', |
| | | `model_code` varchar(128) DEFAULT NULL COMMENT '模型编码', |
| | | `attempt_no` int(11) DEFAULT NULL COMMENT '尝试序号', |
| | | `request_time` datetime DEFAULT NULL COMMENT '请求时间', |
| | | `response_time` datetime DEFAULT NULL COMMENT '响应时间', |
| | | `spend_time` bigint(20) DEFAULT NULL COMMENT '耗时毫秒', |
| | | `result` int(1) NOT NULL DEFAULT '0' COMMENT '结果{1:成功,0:失败}', |
| | | `err` varchar(1000) DEFAULT NULL COMMENT '错误信息', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `user_id` bigint(20) DEFAULT NULL COMMENT '用户ID', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_call_owner` (`tenant_id`,`route_code`,`deleted`,`create_time`), |
| | | KEY `idx_ai_call_diagnosis` (`diagnosis_id`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI调用日志'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_model_route` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(64) DEFAULT NULL COMMENT '编号', |
| | | `route_code` varchar(64) NOT NULL COMMENT '路由编码', |
| | | `model_code` varchar(128) NOT NULL COMMENT '模型编码', |
| | | `priority` int(11) NOT NULL DEFAULT '1' COMMENT '优先级', |
| | | `cooldown_until` datetime DEFAULT NULL COMMENT '冷却截止时间', |
| | | `fail_count` int(11) NOT NULL DEFAULT '0' COMMENT '失败次数', |
| | | `success_count` int(11) NOT NULL DEFAULT '0' COMMENT '成功次数', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:启用,0:停用}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_route_code` (`tenant_id`,`route_code`,`status`,`deleted`,`priority`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI模型路由'; |
| | | |
| | | INSERT INTO `sys_ai_prompt_template` |
| | | (`uuid`, `scene_code`, `template_name`, `base_prompt`, `tool_prompt`, `output_prompt`, `version_no`, `published_flag`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514310', 'general_chat', '通用对话默认模板', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', '如存在可用上下文,请优先引用实时数据回答。', '回答请直接给出结论,必要时补充说明。', 1, 1, 1, 0, 1, 2, NOW(), 2, NOW(), '默认模板' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_prompt_template` WHERE `tenant_id` = 1 AND `scene_code` = 'general_chat' AND `version_no` = 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_prompt_template` |
| | | (`uuid`, `scene_code`, `template_name`, `base_prompt`, `tool_prompt`, `output_prompt`, `version_no`, `published_flag`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514311', 'system_diagnose', '系统诊断默认模板', '你是一名资深WMS智能诊断助手,目标是结合当前系统上下文对仓库运行情况做巡检分析。', '请先汇总库存、任务、设备站点、异常日志与AI调用失败信息,再判断是否存在异常。', '请按“问题概述、关键证据、可能原因、建议动作、风险评估”的结构输出。', 1, 1, 1, 0, 1, 2, NOW(), 2, NOW(), '默认模板' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_prompt_template` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `version_no` = 1 |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_model_route` |
| | | (`uuid`, `route_code`, `model_code`, `priority`, `cooldown_until`, `fail_count`, `success_count`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514312', 'general_chat', 'deepseek-ai/DeepSeek-V3.2', 1, NULL, 0, 0, 1, 0, 1, 2, NOW(), 2, NOW(), '默认通用对话模型' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_model_route` WHERE `tenant_id` = 1 AND `route_code` = 'general_chat' AND `model_code` = 'deepseek-ai/DeepSeek-V3.2' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_model_route` |
| | | (`uuid`, `route_code`, `model_code`, `priority`, `cooldown_until`, `fail_count`, `success_count`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514314', 'system_diagnose', 'deepseek-ai/DeepSeek-V3.2', 1, NULL, 0, 0, 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断模型' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_model_route` WHERE `tenant_id` = 1 AND `route_code` = 'system_diagnose' AND `model_code` = 'deepseek-ai/DeepSeek-V3.2' |
| | | ); |
| | | |
| | | SET @system_menu_ai_prompt := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiPrompt' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiPrompt', 1, 'menu.system', '1', 'menu.system', '/system/aiPrompt', 'aiPrompt', NULL, NULL, 0, NULL, 'Psychology', 10, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_prompt IS NULL; |
| | | SET @system_menu_ai_prompt := COALESCE(@system_menu_ai_prompt, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiPrompt' LIMIT 1)); |
| | | |
| | | SET @system_menu_ai_diagnosis := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiDiagnosis' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiDiagnosis', 1, 'menu.system', '1', 'menu.system', '/system/aiDiagnosis', 'aiDiagnosis', NULL, NULL, 0, NULL, 'FactCheck', 11, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_diagnosis IS NULL; |
| | | SET @system_menu_ai_diagnosis := COALESCE(@system_menu_ai_diagnosis, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiDiagnosis' LIMIT 1)); |
| | | |
| | | SET @system_menu_ai_call_log := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiCallLog' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiCallLog', 1, 'menu.system', '1', 'menu.system', '/system/aiCallLog', 'aiCallLog', NULL, NULL, 0, NULL, 'History', 12, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_call_log IS NULL; |
| | | SET @system_menu_ai_call_log := COALESCE(@system_menu_ai_call_log, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiCallLog' LIMIT 1)); |
| | | |
| | | SET @system_menu_ai_route := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiRoute' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiRoute', 1, 'menu.system', '1', 'menu.system', '/system/aiRoute', 'aiRoute', NULL, NULL, 0, NULL, 'Route', 13, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_route IS NULL; |
| | | SET @system_menu_ai_route := COALESCE(@system_menu_ai_route, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiRoute' LIMIT 1)); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiPrompt', @system_menu_ai_prompt, NULL, CONCAT('1,', @system_menu_ai_prompt), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_prompt AND `authority` = 'system:aiPrompt:list'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiPrompt', @system_menu_ai_prompt, NULL, CONCAT('1,', @system_menu_ai_prompt), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_prompt AND `authority` = 'system:aiPrompt:save'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiPrompt', @system_menu_ai_prompt, NULL, CONCAT('1,', @system_menu_ai_prompt), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_prompt AND `authority` = 'system:aiPrompt:update'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiPrompt', @system_menu_ai_prompt, NULL, CONCAT('1,', @system_menu_ai_prompt), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_prompt AND `authority` = 'system:aiPrompt:remove'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Publish AiPrompt', @system_menu_ai_prompt, NULL, CONCAT('1,', @system_menu_ai_prompt), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiPrompt:publish', NULL, 4, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_prompt AND `authority` = 'system:aiPrompt:publish'); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiDiagnosis', @system_menu_ai_diagnosis, NULL, CONCAT('1,', @system_menu_ai_diagnosis), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiDiagnosis:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_diagnosis AND `authority` = 'system:aiDiagnosis:list'); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiCallLog', @system_menu_ai_call_log, NULL, CONCAT('1,', @system_menu_ai_call_log), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiCallLog:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_call_log AND `authority` = 'system:aiCallLog:list'); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiRoute', @system_menu_ai_route, NULL, CONCAT('1,', @system_menu_ai_route), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiRoute:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_route AND `authority` = 'system:aiRoute:list'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiRoute', @system_menu_ai_route, NULL, CONCAT('1,', @system_menu_ai_route), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiRoute:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_route AND `authority` = 'system:aiRoute:save'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiRoute', @system_menu_ai_route, NULL, CONCAT('1,', @system_menu_ai_route), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiRoute:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_route AND `authority` = 'system:aiRoute:update'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiRoute', @system_menu_ai_route, NULL, CONCAT('1,', @system_menu_ai_route), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiRoute:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_route AND `authority` = 'system:aiRoute:remove'); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `authority` IN ( |
| | | 'system:aiPrompt:list', 'system:aiPrompt:save', 'system:aiPrompt:update', 'system:aiPrompt:remove', 'system:aiPrompt:publish', |
| | | 'system:aiDiagnosis:list', 'system:aiCallLog:list', |
| | | 'system:aiRoute:list', 'system:aiRoute:save', 'system:aiRoute:update', 'system:aiRoute:remove' |
| | | ) |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `component` IN ('aiPrompt', 'aiDiagnosis', 'aiCallLog', 'aiRoute') |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase3.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_diagnostic_tool_config` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(64) DEFAULT NULL COMMENT '编号', |
| | | `scene_code` varchar(64) NOT NULL COMMENT '场景编码', |
| | | `tool_code` varchar(64) NOT NULL COMMENT '工具编码', |
| | | `tool_name` varchar(128) DEFAULT NULL COMMENT '工具名称', |
| | | `enabled_flag` int(1) NOT NULL DEFAULT '1' COMMENT '启用{1:是,0:否}', |
| | | `priority` int(11) NOT NULL DEFAULT '1' COMMENT '优先级', |
| | | `tool_prompt` longtext COMMENT '工具提示词', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_tool_scene` (`tenant_id`,`scene_code`,`status`,`deleted`,`priority`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI诊断工具配置'; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_prompt_publish_log` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `prompt_template_id` bigint(20) DEFAULT NULL COMMENT 'Prompt模板ID', |
| | | `scene_code` varchar(64) DEFAULT NULL COMMENT '场景编码', |
| | | `template_name` varchar(255) DEFAULT NULL COMMENT '模板名称', |
| | | `version_no` int(11) DEFAULT NULL COMMENT '版本号', |
| | | `action_type` varchar(64) DEFAULT NULL COMMENT '动作类型', |
| | | `action_desc` varchar(255) DEFAULT NULL COMMENT '动作描述', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_prompt_publish_log` (`tenant_id`,`scene_code`,`create_time`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI提示词发布日志'; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'report_title'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `report_title` varchar(255) DEFAULT NULL COMMENT ''报告标题'' AFTER `conclusion`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'executive_summary'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `executive_summary` longtext COMMENT ''执行摘要'' AFTER `report_title`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'evidence_summary'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `evidence_summary` longtext COMMENT ''证据摘要'' AFTER `executive_summary`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'action_summary'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `action_summary` longtext COMMENT ''建议动作'' AFTER `evidence_summary`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'risk_summary'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `risk_summary` longtext COMMENT ''风险评估'' AFTER `action_summary`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @sql := IF( |
| | | EXISTS(SELECT 1 FROM information_schema.COLUMNS WHERE TABLE_SCHEMA = DATABASE() AND TABLE_NAME = 'sys_ai_diagnosis_record' AND COLUMN_NAME = 'report_markdown'), |
| | | 'SELECT 1', |
| | | 'ALTER TABLE `sys_ai_diagnosis_record` ADD COLUMN `report_markdown` longtext COMMENT ''报告Markdown'' AFTER `risk_summary`' |
| | | ); |
| | | PREPARE stmt FROM @sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | INSERT INTO `sys_ai_diagnostic_tool_config` |
| | | (`uuid`, `scene_code`, `tool_code`, `tool_name`, `enabled_flag`, `priority`, `tool_prompt`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514315', 'system_diagnose', 'warehouse_summary', '库存摘要', 1, 10, '结合库存摘要判断库位状态、库存结构与重点物料分布。', 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断工具' |
| | | FROM DUAL WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_diagnostic_tool_config` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `tool_code` = 'warehouse_summary' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_diagnostic_tool_config` |
| | | (`uuid`, `scene_code`, `tool_code`, `tool_name`, `enabled_flag`, `priority`, `tool_prompt`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514316', 'system_diagnose', 'task_summary', '任务摘要', 1, 20, '结合任务摘要识别积压、异常状态和最近变更任务。', 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断工具' |
| | | FROM DUAL WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_diagnostic_tool_config` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `tool_code` = 'task_summary' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_diagnostic_tool_config` |
| | | (`uuid`, `scene_code`, `tool_code`, `tool_name`, `enabled_flag`, `priority`, `tool_prompt`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514317', 'system_diagnose', 'device_site_summary', '设备站点摘要', 1, 30, '结合设备站点摘要判断设备状态、巷道分布和最近更新站点。', 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断工具' |
| | | FROM DUAL WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_diagnostic_tool_config` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `tool_code` = 'device_site_summary' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_diagnostic_tool_config` |
| | | (`uuid`, `scene_code`, `tool_code`, `tool_name`, `enabled_flag`, `priority`, `tool_prompt`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514318', 'system_diagnose', 'operation_record', '异常操作日志', 1, 40, '重点识别最近失败操作和高频异常。', 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断工具' |
| | | FROM DUAL WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_diagnostic_tool_config` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `tool_code` = 'operation_record' |
| | | ); |
| | | |
| | | INSERT INTO `sys_ai_diagnostic_tool_config` |
| | | (`uuid`, `scene_code`, `tool_code`, `tool_name`, `enabled_flag`, `priority`, `tool_prompt`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514319', 'system_diagnose', 'ai_call_failure', 'AI调用失败', 1, 50, '重点识别最近 AI 调用失败的模型、错误类型和时间窗口。', 1, 0, 1, 2, NOW(), 2, NOW(), '默认诊断工具' |
| | | FROM DUAL WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_diagnostic_tool_config` WHERE `tenant_id` = 1 AND `scene_code` = 'system_diagnose' AND `tool_code` = 'ai_call_failure' |
| | | ); |
| | | |
| | | SET @system_menu_ai_tool_config := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiToolConfig' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiToolConfig', 1, 'menu.system', '1', 'menu.system', '/system/aiToolConfig', 'aiToolConfig', NULL, NULL, 0, NULL, 'BuildCircle', 14, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_tool_config IS NULL; |
| | | SET @system_menu_ai_tool_config := COALESCE(@system_menu_ai_tool_config, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiToolConfig' LIMIT 1)); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiToolConfig', @system_menu_ai_tool_config, NULL, CONCAT('1,', @system_menu_ai_tool_config), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiToolConfig:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_tool_config AND `authority` = 'system:aiToolConfig:list'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiToolConfig', @system_menu_ai_tool_config, NULL, CONCAT('1,', @system_menu_ai_tool_config), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiToolConfig:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_tool_config AND `authority` = 'system:aiToolConfig:save'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiToolConfig', @system_menu_ai_tool_config, NULL, CONCAT('1,', @system_menu_ai_tool_config), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiToolConfig:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_tool_config AND `authority` = 'system:aiToolConfig:update'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiToolConfig', @system_menu_ai_tool_config, NULL, CONCAT('1,', @system_menu_ai_tool_config), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiToolConfig:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_tool_config AND `authority` = 'system:aiToolConfig:remove'); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `authority` IN ('system:aiToolConfig:list', 'system:aiToolConfig:save', 'system:aiToolConfig:update', 'system:aiToolConfig:remove') |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `component` IN ('aiToolConfig') |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase4.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_diagnosis_plan` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(64) DEFAULT NULL COMMENT '编号', |
| | | `plan_name` varchar(255) DEFAULT NULL COMMENT '计划名称', |
| | | `scene_code` varchar(64) DEFAULT NULL COMMENT '场景编码', |
| | | `cron_expr` varchar(128) DEFAULT NULL COMMENT 'Cron表达式', |
| | | `prompt` longtext COMMENT '巡检提示词', |
| | | `preferred_model_code` varchar(128) DEFAULT NULL COMMENT '优先模型编码', |
| | | `running_flag` int(1) NOT NULL DEFAULT '0' COMMENT '运行中{1:是,0:否}', |
| | | `last_result` int(1) DEFAULT NULL COMMENT '上次结果{2:运行中,1:成功,0:失败}', |
| | | `last_diagnosis_id` bigint(20) DEFAULT NULL COMMENT '上次诊断记录ID', |
| | | `last_run_time` datetime DEFAULT NULL COMMENT '上次运行时间', |
| | | `next_run_time` datetime DEFAULT NULL COMMENT '下次运行时间', |
| | | `last_message` varchar(1000) DEFAULT NULL COMMENT '最近消息', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_diag_plan_due` (`tenant_id`,`status`,`running_flag`,`next_run_time`,`deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI诊断巡检计划'; |
| | | |
| | | SET @system_menu_ai_plan := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiDiagnosisPlan' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiDiagnosisPlan', 1, 'menu.system', '1', 'menu.system', '/system/aiDiagnosisPlan', 'aiDiagnosisPlan', NULL, NULL, 0, NULL, 'Schedule', 14, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_plan IS NULL; |
| | | SET @system_menu_ai_plan := COALESCE(@system_menu_ai_plan, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiDiagnosisPlan' LIMIT 1)); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiDiagnosisPlan', @system_menu_ai_plan, NULL, CONCAT('1,', @system_menu_ai_plan), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiDiagnosisPlan:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_plan AND `authority` = 'system:aiDiagnosisPlan:list'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiDiagnosisPlan', @system_menu_ai_plan, NULL, CONCAT('1,', @system_menu_ai_plan), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiDiagnosisPlan:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_plan AND `authority` = 'system:aiDiagnosisPlan:save'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiDiagnosisPlan', @system_menu_ai_plan, NULL, CONCAT('1,', @system_menu_ai_plan), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiDiagnosisPlan:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_plan AND `authority` = 'system:aiDiagnosisPlan:update'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiDiagnosisPlan', @system_menu_ai_plan, NULL, CONCAT('1,', @system_menu_ai_plan), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiDiagnosisPlan:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_plan AND `authority` = 'system:aiDiagnosisPlan:remove'); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `authority` IN ( |
| | | 'system:aiDiagnosisPlan:list', 'system:aiDiagnosisPlan:save', 'system:aiDiagnosisPlan:update', 'system:aiDiagnosisPlan:remove' |
| | | ) |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `component` IN ('aiDiagnosisPlan') |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase5.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | CREATE TABLE IF NOT EXISTS `sys_ai_mcp_mount` ( |
| | | `id` bigint(20) unsigned NOT NULL AUTO_INCREMENT COMMENT 'ID', |
| | | `uuid` varchar(64) DEFAULT NULL COMMENT '编号', |
| | | `name` varchar(255) DEFAULT NULL COMMENT '名称', |
| | | `mount_code` varchar(128) DEFAULT NULL COMMENT '挂载编码', |
| | | `transport_type` varchar(32) DEFAULT NULL COMMENT '传输类型', |
| | | `url` varchar(512) DEFAULT NULL COMMENT '地址', |
| | | `enabled_flag` int(1) NOT NULL DEFAULT '1' COMMENT '启用{1:是,0:否}', |
| | | `timeout_ms` int(11) DEFAULT NULL COMMENT '超时毫秒', |
| | | `last_test_result` int(1) DEFAULT NULL COMMENT '上次测试结果{1:成功,0:失败}', |
| | | `last_test_time` datetime DEFAULT NULL COMMENT '上次测试时间', |
| | | `last_test_message` varchar(1000) DEFAULT NULL COMMENT '上次测试消息', |
| | | `last_tool_count` int(11) DEFAULT NULL COMMENT '上次工具数', |
| | | `status` int(1) NOT NULL DEFAULT '1' COMMENT '状态{1:正常,0:冻结}', |
| | | `deleted` int(1) NOT NULL DEFAULT '0' COMMENT '是否删除{1:是,0:否}', |
| | | `tenant_id` bigint(20) DEFAULT NULL COMMENT '租户ID', |
| | | `create_by` bigint(20) DEFAULT NULL COMMENT '创建人', |
| | | `create_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '创建时间', |
| | | `update_by` bigint(20) DEFAULT NULL COMMENT '更新人', |
| | | `update_time` datetime NOT NULL DEFAULT CURRENT_TIMESTAMP COMMENT '更新时间', |
| | | `memo` varchar(255) DEFAULT NULL COMMENT '备注', |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_mcp_mount` (`tenant_id`,`mount_code`,`status`,`deleted`) |
| | | ) ENGINE=InnoDB DEFAULT CHARSET=utf8mb4 COMMENT='AI MCP挂载'; |
| | | |
| | | INSERT INTO `sys_ai_mcp_mount` |
| | | (`uuid`, `name`, `mount_code`, `transport_type`, `url`, `enabled_flag`, `timeout_ms`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) |
| | | SELECT '6702082748514325', 'WMS本地MCP', 'wms_local', 'INTERNAL', '/ai/mcp', 1, 10000, 1, 0, 1, 2, NOW(), 2, NOW(), '默认挂载当前 WMS AI 内置工具集合' |
| | | FROM DUAL |
| | | WHERE NOT EXISTS ( |
| | | SELECT 1 FROM `sys_ai_mcp_mount` WHERE `tenant_id` = 1 AND `mount_code` = 'wms_local' |
| | | ); |
| | | |
| | | SET @system_menu_ai_mcp_mount := ( |
| | | SELECT `id` FROM `sys_menu` WHERE `component` = 'aiMcpMount' LIMIT 1 |
| | | ); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'menu.aiMcpMount', 1, 'menu.system', '1', 'menu.system', '/system/aiMcpMount', 'aiMcpMount', NULL, NULL, 0, NULL, 'Hub', 15, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE @system_menu_ai_mcp_mount IS NULL; |
| | | SET @system_menu_ai_mcp_mount := COALESCE(@system_menu_ai_mcp_mount, LAST_INSERT_ID(), (SELECT `id` FROM `sys_menu` WHERE `component` = 'aiMcpMount' LIMIT 1)); |
| | | |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Query AiMcpMount', @system_menu_ai_mcp_mount, NULL, CONCAT('1,', @system_menu_ai_mcp_mount), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:list', NULL, 0, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_mcp_mount AND `authority` = 'system:aiMcpMount:list'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Create AiMcpMount', @system_menu_ai_mcp_mount, NULL, CONCAT('1,', @system_menu_ai_mcp_mount), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:save', NULL, 1, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_mcp_mount AND `authority` = 'system:aiMcpMount:save'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Update AiMcpMount', @system_menu_ai_mcp_mount, NULL, CONCAT('1,', @system_menu_ai_mcp_mount), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:update', NULL, 2, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_mcp_mount AND `authority` = 'system:aiMcpMount:update'); |
| | | INSERT INTO `sys_menu` |
| | | (`name`, `parent_id`, `parent_name`, `path`, `path_name`, `route`, `component`, `brief`, `code`, `type`, `authority`, `icon`, `sort`, `meta`, `tenant_id`, `status`, `deleted`, `create_time`, `create_by`, `update_time`, `update_by`, `memo`) |
| | | SELECT 'Delete AiMcpMount', @system_menu_ai_mcp_mount, NULL, CONCAT('1,', @system_menu_ai_mcp_mount), NULL, NULL, NULL, NULL, NULL, 1, 'system:aiMcpMount:remove', NULL, 3, NULL, 1, 1, 0, NOW(), 2, NOW(), 2, NULL |
| | | FROM DUAL WHERE NOT EXISTS (SELECT 1 FROM `sys_menu` WHERE `parent_id` = @system_menu_ai_mcp_mount AND `authority` = 'system:aiMcpMount:remove'); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `authority` IN ( |
| | | 'system:aiMcpMount:list', 'system:aiMcpMount:save', 'system:aiMcpMount:update', 'system:aiMcpMount:remove' |
| | | ) |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | INSERT INTO `sys_role_menu` (`role_id`, `menu_id`) |
| | | SELECT 1, `id` FROM `sys_menu` |
| | | WHERE `component` IN ('aiMcpMount') |
| | | AND NOT EXISTS ( |
| | | SELECT 1 FROM `sys_role_menu` |
| | | WHERE `role_id` = 1 AND `menu_id` = `sys_menu`.`id` |
| | | ); |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase6_mcp_console.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | SET @table_schema = DATABASE(); |
| | | SET @add_auth_type_sql = ( |
| | | SELECT IF( |
| | | COUNT(*) = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `auth_type` varchar(32) DEFAULT ''NONE'' COMMENT ''认证方式'' AFTER `url`', |
| | | 'SELECT 1' |
| | | ) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @table_schema |
| | | AND TABLE_NAME = 'sys_ai_mcp_mount' |
| | | AND COLUMN_NAME = 'auth_type' |
| | | ); |
| | | PREPARE stmt FROM @add_auth_type_sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @add_auth_value_sql = ( |
| | | SELECT IF( |
| | | COUNT(*) = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `auth_value` varchar(512) DEFAULT NULL COMMENT ''认证信息'' AFTER `auth_type`', |
| | | 'SELECT 1' |
| | | ) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @table_schema |
| | | AND TABLE_NAME = 'sys_ai_mcp_mount' |
| | | AND COLUMN_NAME = 'auth_value' |
| | | ); |
| | | PREPARE stmt FROM @add_auth_value_sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | SET @add_usage_scope_sql = ( |
| | | SELECT IF( |
| | | COUNT(*) = 0, |
| | | 'ALTER TABLE `sys_ai_mcp_mount` ADD COLUMN `usage_scope` varchar(32) DEFAULT ''DIAGNOSE_ONLY'' COMMENT ''用途范围'' AFTER `auth_value`', |
| | | 'SELECT 1' |
| | | ) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @table_schema |
| | | AND TABLE_NAME = 'sys_ai_mcp_mount' |
| | | AND COLUMN_NAME = 'usage_scope' |
| | | ); |
| | | PREPARE stmt FROM @add_usage_scope_sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | UPDATE `sys_ai_mcp_mount` |
| | | SET `auth_type` = COALESCE(NULLIF(`auth_type`, ''), 'NONE'), |
| | | `usage_scope` = CASE |
| | | WHEN `transport_type` = 'INTERNAL' THEN 'CHAT_AND_DIAGNOSE' |
| | | ELSE COALESCE(NULLIF(`usage_scope`, ''), 'DIAGNOSE_ONLY') |
| | | END |
| | | WHERE 1 = 1; |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | | -- >>> 20260316_ai_phase7_tool_usage_scope.sql |
| | | SET NAMES utf8mb4; |
| | | SET FOREIGN_KEY_CHECKS = 0; |
| | | |
| | | SET @table_schema = DATABASE(); |
| | | SET @add_usage_scope_sql = ( |
| | | SELECT IF( |
| | | COUNT(*) = 0, |
| | | 'ALTER TABLE `sys_ai_diagnostic_tool_config` ADD COLUMN `usage_scope` varchar(32) DEFAULT ''DIAGNOSE_ONLY'' COMMENT ''用途范围'' AFTER `tool_prompt`', |
| | | 'SELECT 1' |
| | | ) |
| | | FROM information_schema.COLUMNS |
| | | WHERE TABLE_SCHEMA = @table_schema |
| | | AND TABLE_NAME = 'sys_ai_diagnostic_tool_config' |
| | | AND COLUMN_NAME = 'usage_scope' |
| | | ); |
| | | PREPARE stmt FROM @add_usage_scope_sql; |
| | | EXECUTE stmt; |
| | | DEALLOCATE PREPARE stmt; |
| | | |
| | | UPDATE `sys_ai_diagnostic_tool_config` |
| | | SET `usage_scope` = CASE |
| | | WHEN `enabled_flag` IS NOT NULL AND `enabled_flag` <> 1 THEN 'DISABLED' |
| | | WHEN `scene_code` IS NULL OR TRIM(`scene_code`) = '' THEN 'CHAT_AND_DIAGNOSE' |
| | | ELSE 'DIAGNOSE_ONLY' |
| | | END |
| | | WHERE `usage_scope` IS NULL OR TRIM(`usage_scope`) = ''; |
| | | |
| | | SET FOREIGN_KEY_CHECKS = 1; |
| | | |
| | |
| | | PRIMARY KEY (`id`), |
| | | KEY `idx_ai_param_model_code` (`model_code`), |
| | | KEY `idx_ai_param_deleted_code` (`deleted`,`model_code`) |
| | | ) ENGINE=InnoDB AUTO_INCREMENT=3 DEFAULT CHARSET=utf8; |
| | | ) ENGINE=InnoDB AUTO_INCREMENT=2 DEFAULT CHARSET=utf8; |
| | | |
| | | -- ---------------------------- |
| | | -- Records of sys_ai_param |
| | | -- ---------------------------- |
| | | BEGIN; |
| | | INSERT INTO `sys_ai_param` (`id`, `uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) VALUES (1, '6702082748514305', '通用助手', 'mock-general', 'mock', NULL, NULL, 'mock-general', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', 12, 1, 1, 1, 0, 1, 2, '2025-02-05 14:16:51', 2, '2025-02-05 14:16:51', '默认演示模型'); |
| | | INSERT INTO `sys_ai_param` (`id`, `uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) VALUES (2, '6702082748514306', '创意助手', 'mock-creative', 'mock', NULL, NULL, 'mock-creative', '你是WMS系统内的智能助手,回答时可以更灵活地组织表达,但结论必须准确。', 12, 0, 2, 1, 0, 1, 2, '2025-02-05 14:16:51', 2, '2025-02-05 14:16:51', '演示创意模型'); |
| | | INSERT INTO `sys_ai_param` (`id`, `uuid`, `name`, `model_code`, `provider`, `chat_url`, `api_key`, `model_name`, `system_prompt`, `max_context_messages`, `default_flag`, `sort`, `status`, `deleted`, `tenant_id`, `create_by`, `create_time`, `update_by`, `update_time`, `memo`) VALUES (1, '6702082748514305', 'DEEPSEEK', 'deepseek-ai/DeepSeek-V3.2', 'openai', 'https://api.siliconflow.cn', NULL, 'deepseek-ai/DeepSeek-V3.2', '你是WMS系统内的智能助手,回答时优先保持准确、简洁,并结合上下文帮助用户理解仓储业务。', 12, 1, 1, 1, 0, 1, 2, '2026-03-11 14:13:22', 2, '2026-03-11 15:03:30', '默认演示模型'); |
| | | COMMIT; |
| | | |
| | | -- ---------------------------- |