Переглянути джерело

消息增加ai提问分类

wwh 2 місяців тому
батько
коміт
8ab6583f81

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/AiConsultSendResult.java

@@ -9,6 +9,9 @@ public class AiConsultSendResult
9
 
9
 
10
     private BizConsultMessage aiMessage;
10
     private BizConsultMessage aiMessage;
11
 
11
 
12
+    /** 已写入 biz_consult_session.real_session_id 的大模型会话 ID(可为空) */
13
+    private String realSessionId;
14
+
12
     public BizConsultMessage getUserMessage()
15
     public BizConsultMessage getUserMessage()
13
     {
16
     {
14
         return userMessage;
17
         return userMessage;
@@ -28,4 +31,14 @@ public class AiConsultSendResult
28
     {
31
     {
29
         this.aiMessage = aiMessage;
32
         this.aiMessage = aiMessage;
30
     }
33
     }
34
+
35
+    public String getRealSessionId()
36
+    {
37
+        return realSessionId;
38
+    }
39
+
40
+    public void setRealSessionId(String realSessionId)
41
+    {
42
+        this.realSessionId = realSessionId;
43
+    }
31
 }
44
 }

+ 13 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/BizConsultMessage.java

@@ -27,6 +27,9 @@ public class BizConsultMessage extends BaseEntity
27
 
27
 
28
     private Integer mediaDuration;
28
     private Integer mediaDuration;
29
 
29
 
30
+    /** AI 提问分类,可为空(最长 32 字符) */
31
+    private String aiCategory;
32
+
30
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
33
     @JsonFormat(pattern = "yyyy-MM-dd HH:mm:ss")
31
     private Date sendTime;
34
     private Date sendTime;
32
 
35
 
@@ -110,6 +113,16 @@ public class BizConsultMessage extends BaseEntity
110
         this.mediaDuration = mediaDuration;
113
         this.mediaDuration = mediaDuration;
111
     }
114
     }
112
 
115
 
116
+    public String getAiCategory()
117
+    {
118
+        return aiCategory;
119
+    }
120
+
121
+    public void setAiCategory(String aiCategory)
122
+    {
123
+        this.aiCategory = aiCategory;
124
+    }
125
+
113
     public Date getSendTime()
126
     public Date getSendTime()
114
     {
127
     {
115
         return sendTime;
128
         return sendTime;

+ 19 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/domain/VetConsultSendBody.java

@@ -1,5 +1,7 @@
1
 package com.ruoyi.web.modules.diagnosis.domain;
1
 package com.ruoyi.web.modules.diagnosis.domain;
2
 
2
 
3
+import com.fasterxml.jackson.annotation.JsonAlias;
4
+
3
 /**
5
 /**
4
  * 兽医发送问诊消息请求体。
6
  * 兽医发送问诊消息请求体。
5
  */
7
  */
@@ -19,8 +21,15 @@ public class VetConsultSendBody
19
     /**
21
     /**
20
      * 大模型网关实际会话 ID(/v1/chat/completions 返回的 session_id),可为空。
22
      * 大模型网关实际会话 ID(/v1/chat/completions 返回的 session_id),可为空。
21
      */
23
      */
24
+    @JsonAlias({ "real_session_id", "session_id", "llmSessionId" })
22
     private String realSessionId;
25
     private String realSessionId;
23
 
26
 
27
+    /**
28
+     * AI 提问分类,可为空(写入提问人消息 ai_category,最长 32 字符)。
29
+     */
30
+    @JsonAlias({ "ai_category", "category" })
31
+    private String aiCategory;
32
+
24
     public Integer getMsgType()
33
     public Integer getMsgType()
25
     {
34
     {
26
         return msgType;
35
         return msgType;
@@ -70,4 +79,14 @@ public class VetConsultSendBody
70
     {
79
     {
71
         this.realSessionId = realSessionId;
80
         this.realSessionId = realSessionId;
72
     }
81
     }
82
+
83
+    public String getAiCategory()
84
+    {
85
+        return aiCategory;
86
+    }
87
+
88
+    public void setAiCategory(String aiCategory)
89
+    {
90
+        this.aiCategory = aiCategory;
91
+    }
73
 }
92
 }

+ 3 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImpl.java

@@ -138,6 +138,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
138
         try
138
         try
139
         {
139
         {
140
             VetConsultValidation.validateSendBody(body);
140
             VetConsultValidation.validateSendBody(body);
141
+            AiConsultValidation.validateAiCategory(body.getAiCategory());
141
             String displayName = resolveDisplayName(askerUserId, askerName);
142
             String displayName = resolveDisplayName(askerUserId, askerName);
142
             BizConsultMessage userMessage = persistUserMessage(session, body, askerUserId, displayName);
143
             BizConsultMessage userMessage = persistUserMessage(session, body, askerUserId, displayName);
143
 
144
 
@@ -154,6 +155,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
154
             AiConsultSendResult result = new AiConsultSendResult();
155
             AiConsultSendResult result = new AiConsultSendResult();
155
             result.setUserMessage(userMessage);
156
             result.setUserMessage(userMessage);
156
             result.setAiMessage(aiMessage);
157
             result.setAiMessage(aiMessage);
158
+            result.setRealSessionId(session.getRealSessionId());
157
             return result;
159
             return result;
158
         }
160
         }
159
         finally
161
         finally
@@ -192,6 +194,7 @@ public class BizAiOnlineConsultServiceImpl implements IBizAiOnlineConsultService
192
         message.setMsgType(body.getMsgType());
194
         message.setMsgType(body.getMsgType());
193
         message.setContent(content);
195
         message.setContent(content);
194
         message.setMediaDuration(body.getMediaDuration());
196
         message.setMediaDuration(body.getMediaDuration());
197
+        message.setAiCategory(AiConsultValidation.normalizeAiCategory(body.getAiCategory()));
195
         message.setSendTime(now);
198
         message.setSendTime(now);
196
         message.setCreateTime(now);
199
         message.setCreateTime(now);
197
         bizConsultMessageMapper.insertBizConsultMessage(message);
200
         bizConsultMessageMapper.insertBizConsultMessage(message);

+ 35 - 0
baqing-admin/src/main/java/com/ruoyi/web/modules/diagnosis/support/AiConsultValidation.java

@@ -24,4 +24,39 @@ public final class AiConsultValidation
24
             }
24
             }
25
         }
25
         }
26
     }
26
     }
27
+
28
+    /**
29
+     * AI 提问分类(可为空);非空时长度不超过 32,且须为 1~4 或对应文案编码。
30
+     */
31
+    public static void validateAiCategory(String aiCategory)
32
+    {
33
+        if (StringUtils.isEmpty(aiCategory))
34
+        {
35
+            return;
36
+        }
37
+        String trimmed = aiCategory.trim();
38
+        if (trimmed.length() > 32)
39
+        {
40
+            throw new ServiceException("AI提问分类长度不能超过32个字符");
41
+        }
42
+        if (trimmed.matches("^\\d+$"))
43
+        {
44
+            int code = Integer.parseInt(trimmed);
45
+            if (code < AiConsultConstants.CATEGORY_BREEDING
46
+                    || code > AiConsultConstants.CATEGORY_EQUIPMENT)
47
+            {
48
+                throw new ServiceException("AI提问分类无效");
49
+            }
50
+        }
51
+    }
52
+
53
+    public static String normalizeAiCategory(String aiCategory)
54
+    {
55
+        if (StringUtils.isEmpty(aiCategory))
56
+        {
57
+            return null;
58
+        }
59
+        String trimmed = aiCategory.trim();
60
+        return trimmed.isEmpty() ? null : trimmed;
61
+    }
27
 }
62
 }

+ 5 - 3
baqing-admin/src/main/resources/mapper/diagnosis/BizConsultMessageMapper.xml

@@ -11,13 +11,14 @@
11
         <result property="msgType"       column="msg_type"/>
11
         <result property="msgType"       column="msg_type"/>
12
         <result property="content"       column="content"/>
12
         <result property="content"       column="content"/>
13
         <result property="mediaDuration" column="media_duration"/>
13
         <result property="mediaDuration" column="media_duration"/>
14
+        <result property="aiCategory"    column="ai_category"/>
14
         <result property="sendTime"      column="send_time"/>
15
         <result property="sendTime"      column="send_time"/>
15
         <result property="createTime"    column="create_time"/>
16
         <result property="createTime"    column="create_time"/>
16
     </resultMap>
17
     </resultMap>
17
 
18
 
18
     <select id="selectMessagesBeforeId" resultMap="BizConsultMessageResult">
19
     <select id="selectMessagesBeforeId" resultMap="BizConsultMessageResult">
19
         select id, session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration,
20
         select id, session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration,
20
-               send_time, create_time
21
+               ai_category, send_time, create_time
21
         from biz_consult_message
22
         from biz_consult_message
22
         where session_id = #{sessionId}
23
         where session_id = #{sessionId}
23
         <if test="beforeId != null">
24
         <if test="beforeId != null">
@@ -30,10 +31,11 @@
30
     <insert id="insertBizConsultMessage" parameterType="com.ruoyi.web.modules.diagnosis.domain.BizConsultMessage"
31
     <insert id="insertBizConsultMessage" parameterType="com.ruoyi.web.modules.diagnosis.domain.BizConsultMessage"
31
             useGeneratedKeys="true" keyProperty="id">
32
             useGeneratedKeys="true" keyProperty="id">
32
         insert into biz_consult_message (
33
         insert into biz_consult_message (
33
-            session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration, send_time, create_time
34
+            session_id, sender_role, sender_user_id, sender_name, msg_type, content, media_duration, ai_category,
35
+            send_time, create_time
34
         ) values (
36
         ) values (
35
             #{sessionId}, #{senderRole}, #{senderUserId}, #{senderName}, #{msgType}, #{content}, #{mediaDuration},
37
             #{sessionId}, #{senderRole}, #{senderUserId}, #{senderName}, #{msgType}, #{content}, #{mediaDuration},
36
-            #{sendTime}, #{createTime}
38
+            #{aiCategory}, #{sendTime}, #{createTime}
37
         )
39
         )
38
     </insert>
40
     </insert>
39
 </mapper>
41
 </mapper>

+ 18 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/diagnosis/service/impl/BizAiOnlineConsultServiceImplTest.java

@@ -150,6 +150,24 @@ class BizAiOnlineConsultServiceImplTest
150
         verify(sessionMapper).updateRealSessionId(AiOnlineConsultTestSamples.SESSION_ID, "gateway-session-001");
150
         verify(sessionMapper).updateRealSessionId(AiOnlineConsultTestSamples.SESSION_ID, "gateway-session-001");
151
     }
151
     }
152
 
152
 
153
+    @Test
154
+    @DisplayName("提问消息保存 ai_category")
155
+    void sendWithAiCategory()
156
+    {
157
+        when(sessionMapper.selectAiSessionById(AiOnlineConsultTestSamples.SESSION_ID))
158
+                .thenReturn(AiOnlineConsultTestSamples.activeAiSession());
159
+        VetConsultSendBody body = new VetConsultSendBody();
160
+        body.setMsgType(ConsultSessionRules.MSG_TYPE_TEXT);
161
+        body.setContent("牛不吃草");
162
+        body.setAiReplyContent("请先观察体温");
163
+        body.setAiCategory("2");
164
+        service.sendAiMessage(AiOnlineConsultTestSamples.SESSION_ID, body,
165
+                AiOnlineConsultTestSamples.VA_USER_ID, "甲");
166
+        verify(messageMapper).insertBizConsultMessage(argThat(m -> m != null
167
+                && "2".equals(m.getAiCategory())
168
+                && Integer.valueOf(ConsultSessionRules.SENDER_ROLE_ASKER).equals(m.getSenderRole())));
169
+    }
170
+
153
     @Test
171
     @Test
154
     @DisplayName("处理中拒发")
172
     @DisplayName("处理中拒发")
155
     void processingLock()
173
     void processingLock()

+ 9 - 0
baqing-admin/src/test/java/com/ruoyi/web/modules/diagnosis/support/AiConsultValidationTest.java

@@ -22,6 +22,15 @@ class AiConsultValidationTest
22
         assertEquals("请输入筛选条件", ex.getMessage());
22
         assertEquals("请输入筛选条件", ex.getMessage());
23
     }
23
     }
24
 
24
 
25
+    @Test
26
+    @DisplayName("AI提问分类非法")
27
+    void invalidAiCategory()
28
+    {
29
+        ServiceException ex = assertThrows(ServiceException.class,
30
+                () -> AiConsultValidation.validateAiCategory("9"));
31
+        assertEquals("AI提问分类无效", ex.getMessage());
32
+    }
33
+
25
     @Test
34
     @Test
26
     @DisplayName("发送校验复用")
35
     @DisplayName("发送校验复用")
27
     void sendValidation()
36
     void sendValidation()

+ 19 - 16
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)前端技术方案.md

@@ -1,6 +1,6 @@
1
 # AI 诊断(兽医、机构)— 前端技术方案
1
 # AI 诊断(兽医、机构)— 前端技术方案
2
 
2
 
3
-> 依据:`AI诊断(兽医、机构)功能需求.md`、`AI诊断(兽医、机构)技术方案.md`(v1.1);实现文件 `ruoyi-ui/src/views/diseaseTreatment/aiDiagnosis/index.vue`;布局参考同目录在线接诊(左右分栏 IM)。
3
+> 依据:`AI诊断(兽医、机构)功能需求.md`、`AI诊断(兽医、机构)技术方案.md`(**v1.5**);实现文件 `ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue`;布局参考同目录在线接诊(左右分栏 IM)。
4
 
4
 
5
 ---
5
 ---
6
 
6
 
@@ -8,10 +8,10 @@
8
 
8
 
9
 | 项 | 说明 |
9
 | 项 | 说明 |
10
 | --- | --- |
10
 | --- | --- |
11
-| **Vue 路径** | `ruoyi-ui/src/views/diseaseTreatment/aiDiagnosis/index.vue` |
12
-| **组件名** | `AiDiagnosis` |
13
-| **菜单配置(示例)** | 组件路径:`diseaseTreatment/aiDiagnosis/index`(与后端方案 `onlineConsult/ai/index` 二选一,以若依菜单实际配置为准) |
14
-| **权限前缀** | `diseaseTreatment:aiOnlineConsult:list`、`query`、`add`(发送走大模型直连,**未使用** `send` 按钮权限) |
11
+| **Vue 路径** | `ruoyi-ui/src/views/diseaseTreatment/onlineConsult/ai/index.vue` |
12
+| **组件名** | 以若依菜单 `component` 配置为准 |
13
+| **菜单配置(示例)** | 组件路径:`diseaseTreatment/onlineConsult/ai/index` |
14
+| **权限前缀** | `diseaseTreatment:aiOnlineConsult:list`、`query`、`add`(发送走大模型直连 + 落库,**未使用** `send` 按钮权限) |
15
 
15
 
16
 **准入**:列表/新建/历史消息仅需登录与 `diseaseTreatment:aiOnlineConsult:*` 权限;后端**不校验**是否绑定兽医/机构医疗资源。
16
 **准入**:列表/新建/历史消息仅需登录与 `diseaseTreatment:aiOnlineConsult:*` 权限;后端**不校验**是否绑定兽医/机构医疗资源。
17
 
17
 
@@ -26,12 +26,12 @@
26
 | 会话列表 | 若依 `request` → `GET /diseaseTreatment/onlineConsult/ai/session/list` | 含 `disclaimer` |
26
 | 会话列表 | 若依 `request` → `GET /diseaseTreatment/onlineConsult/ai/session/list` | 含 `disclaimer` |
27
 | 新建会话 | `POST .../session` | 返回 `id`/`sessionId`、`disclaimer` |
27
 | 新建会话 | `POST .../session` | 返回 `id`/`sessionId`、`disclaimer` |
28
 | 历史消息 | `GET .../session/{id}/messages` | 打开会话、上拉加载更早 |
28
 | 历史消息 | `GET .../session/{id}/messages` | 打开会话、上拉加载更早 |
29
-| **提问与 AI 回复** | **`axios` 直连** `{VUE_APP_LLM_BASE_URL}/v1/chat/completions` | 对话框问答 |
30
-| **会话落库** | `POST .../session/{id}/message`,Body 含 `aiReplyContent` | 大模型返回后落库,**不**再调后端 AI 网关 |
29
+| **提问与 AI 回复** | **`streamChatMessage`**(`utils/llmStreamChat.js`)→ `{VUE_APP_LLM_BASE_URL}/v1/chat/completions` | 对话框问答,支持 SSE 流式;失败可回退非流式 |
30
+| **会话落库** | `POST .../session/{id}/message` | Body:`msgType`、`content`、`aiReplyContent`、`realSessionId?`、`aiCategory?`;大模型返回后落库,**不**再调后端 AI 网关 |
31
 | 媒体上传 | `POST /common/upload` | 先上传再随提问送 LLM |
31
 | 媒体上传 | `POST /common/upload` | 先上传再随提问送 LLM |
32
 
32
 
33
-> **与后端技术方案差异**:后端 **§4.4** 约定 `POST /session/{sessionId}/message` 由 `ConsultAiGateway` 落库并返回 `userMessage` + `aiMessage`。前端已封装 `sendAiConsultMessage`(`aiOnlineConsult.js`),**当前页面未调用**;后续可切换为全后端通道以支持落库、推送与 60s 同步策略
34
-> **大模型上下文**:`session.llmSessionId` 存网关返回的 `session_id`,与后端会话主键 `activeSessionId` 分离。
33
+> **与后端技术方案**:后端 **§4.4** 同时支持全后端 `ConsultAiGateway` 通道与前端 `aiReplyContent` 落库通道。当前页面采用后者
34
+> **大模型上下文**:`session.llmSessionId` / `realSessionId` 存网关返回的 `session_id`,与后端会话主键 `activeSessionId` 分离;落库时通过 `realSessionId` 写入 `biz_consult_session.real_session_id`
35
 
35
 
36
 ---
36
 ---
37
 
37
 
@@ -47,7 +47,7 @@
47
 | `listAiConsultSessions` | GET `/session/list` | ✓ |
47
 | `listAiConsultSessions` | GET `/session/list` | ✓ |
48
 | `createAiConsultSession` | POST `/session` | ✓ |
48
 | `createAiConsultSession` | POST `/session` | ✓ |
49
 | `listAiConsultMessages` | GET `/session/{sessionId}/messages` | ✓ |
49
 | `listAiConsultMessages` | GET `/session/{sessionId}/messages` | ✓ |
50
-| `sendAiConsultMessage` | POST `/session/{sessionId}/message`(timeout 65s,`repeatSubmit: false`);Body 含 `msgType`、`content`、`aiReplyContent` | ✓(落库) |
50
+| `sendAiConsultMessage` | POST `/session/{sessionId}/message`(timeout 65s,`repeatSubmit: false`);Body 含 `msgType`、`content`、`aiReplyContent`、`realSessionId?`、`aiCategory?` | ✓(落库) |
51
 
51
 
52
 **列表 Query(小驼峰)**:`pageNum`(默认 1)、`pageSize`(默认 100)、`contentKeyword`、`searchMode`(仅点击搜索为 `true`)。
52
 **列表 Query(小驼峰)**:`pageNum`(默认 1)、`pageSize`(默认 100)、`contentKeyword`、`searchMode`(仅点击搜索为 `true`)。
53
 
53
 
@@ -64,7 +64,8 @@
64
 | **鉴权(直连上游)** | `Authorization: Bearer {VUE_APP_LLM_API_KEY}` |
64
 | **鉴权(直连上游)** | `Authorization: Bearer {VUE_APP_LLM_API_KEY}` |
65
 | **鉴权(经 8010 转发)** | **`Authorization: Bearer {getToken()}`**(若依登录 Token,与 `request.js` 一致);**不能**仅用 `VUE_APP_LLM_API_KEY` |
65
 | **鉴权(经 8010 转发)** | **`Authorization: Bearer {getToken()}`**(若依登录 Token,与 `request.js` 一致);**不能**仅用 `VUE_APP_LLM_API_KEY` |
66
 | **超时** | 120000 ms |
66
 | **超时** | 120000 ms |
67
-| **Body** | `model`、`messages[]`、`stream: false`、`user`;可选 `session_id`(`llmSessionId`) |
67
+| **Body** | `model`、`messages[]`、`stream: true`(优先流式)、`user`;可选 `session_id`(`llmSessionId`) |
68
+| **流式解析** | `utils/sseParse.js`、`utils/llmResponseParse.js`(提取 `session_id`、增量 `content`) |
68
 | **后端转发(可选)** | `VUE_APP_LLM_BASE_URL` → `http://localhost:8010`;`KbOpenAiProxyController` 须登录,详见 `doc/大模型/大模型网关转发接口说明.md` §3、§6 |
69
 | **后端转发(可选)** | `VUE_APP_LLM_BASE_URL` → `http://localhost:8010`;`KbOpenAiProxyController` 须登录,详见 `doc/大模型/大模型网关转发接口说明.md` §3、§6 |
69
 
70
 
70
 **模型选项(前端常量)**
71
 **模型选项(前端常量)**
@@ -99,14 +100,14 @@
99
 | 会话主键 | `resolveSessionId(s)`:`id` 优先,否则 `sessionId` |
100
 | 会话主键 | `resolveSessionId(s)`:`id` 优先,否则 `sessionId` |
100
 | 消息角色 | `senderRole`:`1` 提问人(右)、`3` AI(左) |
101
 | 消息角色 | `senderRole`:`1` 提问人(右)、`3` AI(左) |
101
 | 消息类型 | `msgType`:`1` 文本、`2` 图片、`3` 视频、`4` 语音 |
102
 | 消息类型 | `msgType`:`1` 文本、`2` 图片、`3` 视频、`4` 语音 |
102
-| 发送流程 | 先本地展示提问 → `axios` 调 LLM → 本地展示 AI 回复 → `sendAiConsultMessage`(`aiReplyContent`)落库并合并服务端消息 ID |
103
+| 发送流程 | 先本地展示提问 → `streamChatMessage` 调 LLM → 本地展示 AI 回复 → `sendAiConsultMessage`(`aiReplyContent`、`realSessionId`、`aiCategory`)落库并合并服务端消息 ID |
103
 | 思考中 | `sending` 且最后一条为 `senderRole === 1` 时显示占位气泡 |
104
 | 思考中 | `sending` 且最后一条为 `senderRole === 1` 时显示占位气泡 |
104
 | 上拉历史 | `scrollTop < 40` 时用 `beforeId` 加载更早 50 条 |
105
 | 上拉历史 | `scrollTop < 40` 时用 `beforeId` 加载更早 50 条 |
105
 | 轮询 | 选中会话后 **2s** 仅 `refreshSessionListQuiet`(**不**轮询消息,避免覆盖仅存在本地的 LLM 对话) |
106
 | 轮询 | 选中会话后 **2s** 仅 `refreshSessionListQuiet`(**不**轮询消息,避免覆盖仅存在本地的 LLM 对话) |
106
 | 媒体限制 | 图 jpg/jpeg/png/gif ≤10MB;视频 mp4/mov ≤50MB;语音 mp3/m4a/wav ≤10MB;文件名不含英文逗号 |
107
 | 媒体限制 | 图 jpg/jpeg/png/gif ≤10MB;视频 mp4/mov ≤50MB;语音 mp3/m4a/wav ≤10MB;文件名不含英文逗号 |
107
 | 本地消息 ID | 前缀 `m_`(`genLocalId`),与服务端数字 ID 区分 |
108
 | 本地消息 ID | 前缀 `m_`(`genLocalId`),与服务端数字 ID 区分 |
108
 
109
 
109
-**数据持久化说明**:大模型返回后调用 `POST .../message` 并传 `aiReplyContent`,服务端仅落库、不调 `ConsultAiGateway`;落库失败时界面仍保留本地气泡并提示「保存会话失败」。
110
+**数据持久化说明**:大模型返回后调用 `POST .../message` 并传 `aiReplyContent`、可选 `realSessionId`(大模型 `session_id`)、`aiCategory`(提问分类);服务端仅落库、不调 `ConsultAiGateway`;响应 `data.realSessionId` 可与本地 `llmSessionId` 对齐。落库失败时界面仍保留本地气泡并提示「保存会话失败」。
110
 
111
 
111
 ---
112
 ---
112
 
113
 
@@ -136,8 +137,9 @@
136
 
137
 
137
 | 路径 | 说明 |
138
 | 路径 | 说明 |
138
 | --- | --- |
139
 | --- | --- |
139
-| `src/views/diseaseTreatment/aiDiagnosis/index.vue` | 主页面(IM + LLM 发送) |
140
-| `src/api/diseaseTreatment/aiOnlineConsult.js` | 若依会话/消息 API(含预留 `sendAiConsultMessage`) |
140
+| `src/views/diseaseTreatment/onlineConsult/ai/index.vue` | 主页面(IM + LLM 流式发送 + 落库) |
141
+| `src/api/diseaseTreatment/aiOnlineConsult.js` | 若依会话/消息 API |
142
+| `src/utils/llmStreamChat.js`、`sseParse.js`、`llmResponseParse.js` | 大模型流式与响应解析 |
141
 | `src/mixins/diseaseTreatmentLocaleMixin.js` | 文案 |
143
 | `src/mixins/diseaseTreatmentLocaleMixin.js` | 文案 |
142
 | `src/lang/zh/diseaseTreatment.js` | 中文 `aiOnlineConsult` |
144
 | `src/lang/zh/diseaseTreatment.js` | 中文 `aiOnlineConsult` |
143
 | `src/lang/bo/diseaseTreatment.js` | 藏文 `aiOnlineConsult` |
145
 | `src/lang/bo/diseaseTreatment.js` | 藏文 `aiOnlineConsult` |
@@ -160,4 +162,5 @@
160
 
162
 
161
 | 版本 | 说明 |
163
 | 版本 | 说明 |
162
 | --- | --- |
164
 | --- | --- |
163
-| 1.0 | 初稿:混合架构(会话后端 + 对话 LLM 直连);对齐 `aiDiagnosis/index.vue` 实现 |
165
+| 1.0 | 初稿:混合架构(会话后端 + 对话 LLM 直连);对齐 `onlineConsult/ai/index.vue` 实现 |
166
+| 1.1 | 落库通道:`sendAiConsultMessage` + `aiReplyContent`;`realSessionId`、`aiCategory`;LLM SSE 流式 |

+ 59 - 15
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)技术方案.md

@@ -1,7 +1,7 @@
1
 # AI 诊断(兽医、机构)— 技术方案
1
 # AI 诊断(兽医、机构)— 技术方案
2
 
2
 
3
 > 依据:`AI诊断(兽医、机构)功能需求.md`(v1.1);关联 `在线接诊(兽医)技术方案.md`(v1.1)、`在线接诊(兽医)功能需求.md`  
3
 > 依据:`AI诊断(兽医、机构)功能需求.md`(v1.1);关联 `在线接诊(兽医)技术方案.md`(v1.1)、`在线接诊(兽医)功能需求.md`  
4
-> **本文档版本:1.4**(管理端隐藏 AI 诊断会话 `POST .../hide`,见 **§4.5**;`/v1` 见 **§1.2**)
4
+> **本文档版本:1.5**(`real_session_id`、`ai_category` 字段与落库接口 Body 同步,见 **§3.1**、**§3.2**、**§4.3**)
5
 
5
 
6
 ---
6
 ---
7
 
7
 
@@ -120,6 +120,7 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
120
 | `receiver_provider_id` | **NULL**(禁止写入兽医/机构资源 ID) |
120
 | `receiver_provider_id` | **NULL**(禁止写入兽医/机构资源 ID) |
121
 | `receiver_name` | **NULL** |
121
 | `receiver_name` | **NULL** |
122
 | `session_title` | 新建时 **「新会话」**;首条有效文本提问后更新为摘要(≤50 字) |
122
 | `session_title` | 新建时 **「新会话」**;首条有效文本提问后更新为摘要(≤50 字) |
123
+| `real_session_id` | **varchar(64)**,可为空;大模型网关 `session_id`,由 `POST .../message` 写入/更新,供多轮 `session_id` 回传 |
123
 | `last_message_time` / `last_message_preview` | 每次提问或 AI 回复后更新 |
124
 | `last_message_time` / `last_message_preview` | 每次提问或 AI 回复后更新 |
124
 | `vet_visible` | 默认 **1**;提问人 `hide` 后置 **0** |
125
 | `vet_visible` | 默认 **1**;提问人 `hide` 后置 **0** |
125
 
126
 
@@ -136,6 +137,12 @@ Payload 同在线接诊 **§4.2**(`senderRole`:1 提问人,**3** AI 助手
136
 
137
 
137
 在线接诊使用的 **2**(接诊兽医)在 AI 会话中**不出现**。
138
 在线接诊使用的 **2**(接诊兽医)在 AI 会话中**不出现**。
138
 
139
 
140
+| 字段 | 说明 |
141
+| --- | --- |
142
+| `ai_category` | **varchar(32)**,可为空;仅 **提问人消息**(`sender_role=1`)写入;AI 回复为 `NULL`。纯数字时须为 `1`~`4`(与 `AiConsultConstants` 分类一致),亦可存业务编码字符串 |
143
+
144
+**已有库迁移**:`sql/alter_biz_consult_session_real_session_id.sql`、`sql/alter_biz_consult_message_ai_category.sql`。
145
+
139
 ### 3.3 AI 列表查询语义
146
 ### 3.3 AI 列表查询语义
140
 
147
 
141
 **默认列表**(`searchMode=false`):
148
 **默认列表**(`searchMode=false`):
@@ -208,19 +215,21 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
208
 | 4.1 | 历史会话列表 | GET | `/session/list` | `...:list` | Query:`pageNum`、`pageSize`(默认 20)、`contentKeyword`、`searchMode`;`asker_user_id` 服务端注入 |
215
 | 4.1 | 历史会话列表 | GET | `/session/list` | `...:list` | Query:`pageNum`、`pageSize`(默认 20)、`contentKeyword`、`searchMode`;`asker_user_id` 服务端注入 |
209
 | 4.2 | 新增会话 | POST | `/session` | `...:add` | 创建 `consult_type=2` 空会话;`session_title=新会话`;`last_message_time=create_time`(**§3.5 #1**);返回 `sessionId`、`disclaimer` |
216
 | 4.2 | 新增会话 | POST | `/session` | `...:add` | 创建 `consult_type=2` 空会话;`session_title=新会话`;`last_message_time=create_time`(**§3.5 #1**);返回 `sessionId`、`disclaimer` |
210
 | 4.3 | 历史消息 | GET | `/session/{sessionId}/messages` | `...:query` | 校验 `consult_type=2` + `asker_user_id` + `vet_visible=1`;`beforeId`、`pageSize`(默认 50) |
217
 | 4.3 | 历史消息 | GET | `/session/{sessionId}/messages` | `...:query` | 校验 `consult_type=2` + `asker_user_id` + `vet_visible=1`;`beforeId`、`pageSize`(默认 50) |
211
-| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`;流程见 **§5** |
218
+| 4.4 | 提问(触发 AI) | POST | `/session/{sessionId}/message` | `...:send` | Body:`msgType`、`content`、`mediaDuration?`、`aiReplyContent?`、`realSessionId?`、`aiCategory?`;流程见 **§5** |
212
 | 4.5 | 隐藏会话 | POST | `/session/{sessionId}/hide` | `...:remove` | 管理端隐藏 AI 诊断会话;`vet_visible=0`;仅本人会话;隐藏后列表/消息/提问不可用 |
219
 | 4.5 | 隐藏会话 | POST | `/session/{sessionId}/hide` | `...:remove` | 管理端隐藏 AI 诊断会话;`vet_visible=0`;仅本人会话;隐藏后列表/消息/提问不可用 |
213
 
220
 
214
 ### 4.1 列表 `rows[]` 字段
221
 ### 4.1 列表 `rows[]` 字段
215
 
222
 
216
-`sessionId`、`sessionTitle`、`lastMessagePreview`、`lastMessageTime`(前端格式化为「M月D日」)。
223
+`sessionId`、`sessionTitle`、`lastMessagePreview`、`lastMessageTime`、`realSessionId`(大模型网关会话 ID,可为空)。
217
 
224
 
218
 ### 4.2 消息 `rows[]` / 推送字段
225
 ### 4.2 消息 `rows[]` / 推送字段
219
 
226
 
220
-`messageId`、`sessionId`、`senderRole`(1 提问人 / **3** AI)、`senderName`、`msgType`、`content`、`mediaDuration`、`sendTime`。
227
+`messageId`、`sessionId`、`senderRole`(1 提问人 / **3** AI)、`senderName`、`msgType`、`content`、`mediaDuration`、`aiCategory`(可为空)、`sendTime`。
221
 
228
 
222
 ### 4.3 提问 Body 示例
229
 ### 4.3 提问 Body 示例
223
 
230
 
231
+**服务端调 `ConsultAiGateway`(无 `aiReplyContent`)**
232
+
224
 ```json
233
 ```json
225
 {
234
 {
226
   "msgType": 1,
235
   "msgType": 1,
@@ -228,6 +237,37 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
228
 }
237
 }
229
 ```
238
 ```
230
 
239
 
240
+**前端大模型直连后落库(推荐)**
241
+
242
+```json
243
+{
244
+  "msgType": 1,
245
+  "content": "牛不吃草、精神沉郁,请问可能原因?",
246
+  "aiReplyContent": "可能原因包括…",
247
+  "realSessionId": "gateway-session-001",
248
+  "aiCategory": "2"
249
+}
250
+```
251
+
252
+| Body 字段 | 类型 | 必填 | 说明 |
253
+| --- | --- | --- | --- |
254
+| `msgType` | int | Y | 1 文本 2 图 3 视频 4 语音 |
255
+| `content` | string | 条件 | 文本或媒体 URL |
256
+| `mediaDuration` | int | N | 音视频秒数 |
257
+| `aiReplyContent` | string | N | 非空时服务端**仅落库**,不调 `ConsultAiGateway` |
258
+| `realSessionId` | string | N | 大模型 `session_id`;写入 `biz_consult_session.real_session_id`(别名 `real_session_id`、`session_id`、`llmSessionId`) |
259
+| `aiCategory` | string | N | 写入提问人消息 `ai_category`(别名 `ai_category`、`category`);最长 32 |
260
+
261
+**提问成功 `data`**
262
+
263
+```json
264
+{
265
+  "userMessage": { "id": 1, "senderRole": 1, "aiCategory": "2", "...": "..." },
266
+  "aiMessage": { "id": 2, "senderRole": 3, "...": "..." },
267
+  "realSessionId": "gateway-session-001"
268
+}
269
+```
270
+
231
 ### 4.4 业务错误(`msg` 示例)
271
 ### 4.4 业务错误(`msg` 示例)
232
 
272
 
233
 | 场景 | 文案 |
273
 | 场景 | 文案 |
@@ -241,6 +281,7 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
241
 | AI 超时 | AI 回复超时,请稍后重试 |
281
 | AI 超时 | AI 回复超时,请稍后重试 |
242
 | AI 失败 | AI 回复失败,请稍后重试 |
282
 | AI 失败 | AI 回复失败,请稍后重试 |
243
 | AI 处理中 | AI 正在回复中,请稍候 |
283
 | AI 处理中 | AI 正在回复中,请稍候 |
284
+| AI 提问分类无效 | AI提问分类无效 / AI提问分类长度不能超过32个字符 |
244
 
285
 
245
 ---
286
 ---
246
 
287
 
@@ -249,20 +290,22 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
249
 ```text
290
 ```text
250
 1. 校验会话归属(type=2, asker=当前用户, vet_visible=1)
291
 1. 校验会话归属(type=2, asker=当前用户, vet_visible=1)
251
 2. 若会话 AI 处理中 → 拒绝(§3.5 #7)
292
 2. 若会话 AI 处理中 → 拒绝(§3.5 #7)
252
-3. 校验 Body(VetConsultValidation)
253
-4. 标记会话 processing=1(内存/Redis 锁)
254
-5. INSERT 提问消息(sender_role=1)
255
-6. 更新 session.last_message_*(提问侧摘要)
256
-7. 推送 NEW_MESSAGE(提问)→ 前端立即展示
257
-8. 调用 ConsultAiGateway(≤60s,单轮 §3.5 #6):
293
+3. 校验 Body(VetConsultValidation、AiConsultValidation.validateAiCategory)
294
+4. 若 Body.realSessionId 非空 → 更新 biz_consult_session.real_session_id
295
+5. 标记会话 processing=1(内存/Redis 锁)
296
+6. INSERT 提问消息(sender_role=1,含 ai_category 若有)
297
+7. 更新 session.last_message_*(提问侧摘要)
298
+8. 推送 NEW_MESSAGE(提问)→ 前端立即展示
299
+9a. 若 aiReplyContent 非空:直接作为 AI 文本,跳过 ConsultAiGateway(前端直连大模型落库通道)
300
+9b. 否则调用 ConsultAiGateway(≤60s,单轮 §3.5 #6):
258
    - msg_type=1:文本问答
301
    - msg_type=1:文本问答
259
    - msg_type=2:图文;失败 → §3.5 #4 固定 AI 文案仍入库
302
    - msg_type=2:图文;失败 → §3.5 #4 固定 AI 文案仍入库
260
    - msg_type=3/4:§3.5 #3 固定引导文案入库
303
    - msg_type=3/4:§3.5 #3 固定引导文案入库
261
-9. 成功:INSERT AI 消息(sender_role=3
262
-10. 更新 session.last_message_*;首问更新 session_title
263
-11. 推送 NEW_MESSAGE(AI 回复,落库后 ≤1s)
264
-12. 释放 processing;响应 data:{ userMessage, aiMessage }
265
-13. 网关硬失败(非图片软失败):不写 AI 消息;HTTP 错误 + §4.4 文案
304
+10. 成功:INSERT AI 消息(sender_role=3,ai_category=NULL
305
+11. 更新 session.last_message_*;首问更新 session_title
306
+12. 推送 NEW_MESSAGE(AI 回复,落库后 ≤1s)
307
+13. 释放 processing;响应 data:{ userMessage, aiMessage, realSessionId? }
308
+14. 网关硬失败(非图片软失败且无 aiReplyContent):不写 AI 消息;HTTP 错误 + §4.4 文案
266
 ```
309
 ```
267
 
310
 
268
 ### 5.1 同步、超时与展示策略(v1.1 已确认)
311
 ### 5.1 同步、超时与展示策略(v1.1 已确认)
@@ -379,3 +422,4 @@ ORDER BY COALESCE(last_message_time, create_time) DESC
379
 | 1.2 | 补充 §1.2:`KbOpenAiProxyController` 转发 `/v1/models`、`/v1/chat/completions`;区分对话框直连/转发与 `ConsultAiGateway` 兜底 |
422
 | 1.2 | 补充 §1.2:`KbOpenAiProxyController` 转发 `/v1/models`、`/v1/chat/completions`;区分对话框直连/转发与 `ConsultAiGateway` 兜底 |
380
 | 1.3 | §1.2:`/v1/**` 须若依 Token,取消匿名访问 |
423
 | 1.3 | §1.2:`/v1/**` 须若依 Token,取消匿名访问 |
381
 | 1.4 | 管理端 **§4.5** `POST /session/{id}/hide` 隐藏 AI 诊断会话;权限 `...:remove` |
424
 | 1.4 | 管理端 **§4.5** `POST /session/{id}/hide` 隐藏 AI 诊断会话;权限 `...:remove` |
425
+| 1.5 | **§3.1** `biz_consult_session.real_session_id`;**§3.2** `biz_consult_message.ai_category`(varchar32);**§4.3** 落库 Body/响应字段;**§5** 前端 `aiReplyContent` 落库通道 |

+ 3 - 0
doc/牧业疫病诊疗服务/AI诊断(兽医、机构)/AI诊断(兽医、机构)测试用例.md

@@ -97,6 +97,9 @@
97
 | ZCZX-AIZD-API-038 | 管理端 | 隐藏会话 | 接口测试 | Postman | §4.5、AI-04 | VA;AS0 | `POST /diseaseTreatment/onlineConsult/ai/session/{id}/hide` | `code=200`;列表无该会话 |
97
 | ZCZX-AIZD-API-038 | 管理端 | 隐藏会话 | 接口测试 | Postman | §4.5、AI-04 | VA;AS0 | `POST /diseaseTreatment/onlineConsult/ai/session/{id}/hide` | `code=200`;列表无该会话 |
98
 | ZCZX-AIZD-API-039 | 管理端 | 隐藏越权 | 接口测试 | Postman | §4.1.1 | ASX 属 VB | VA `POST .../hide` | 「会话不存在或已删除」 |
98
 | ZCZX-AIZD-API-039 | 管理端 | 隐藏越权 | 接口测试 | Postman | §4.1.1 | ASX 属 VB | VA `POST .../hide` | 「会话不存在或已删除」 |
99
 | ZCZX-AIZD-API-040 | 权限 | 无 remove | 接口测试 | Postman | §4.5 | 仅 list/query/add | `POST .../hide` | 无权限 |
99
 | ZCZX-AIZD-API-040 | 权限 | 无 remove | 接口测试 | Postman | §4.5 | 仅 list/query/add | `POST .../hide` | 无权限 |
100
+| ZCZX-AIZD-API-041 | 落库 | realSessionId | 接口测试 | Postman | §4.3、§5 | VA;AS0 | `POST .../message` Body 含 `aiReplyContent` + `realSessionId` | `code=200`;`data.realSessionId` 与库 `real_session_id` 一致 |
101
+| ZCZX-AIZD-API-042 | 落库 | aiCategory | 接口测试 | Postman | §3.2、§4.3 | VA;AS0 | `POST .../message` Body `aiCategory=2` + `aiReplyContent` | `userMessage.aiCategory=2`;`aiMessage.aiCategory` 为空 |
102
+| ZCZX-AIZD-API-043 | 校验 | aiCategory 无效 | 接口测试 | Postman | §4.4 | VA;AS0 | `aiCategory=99`(纯数字超范围) | 失败;「AI提问分类无效」 |
100
 
103
 
101
 ---
104
 ---
102
 
105
 

+ 2 - 0
doc/牧业疫病诊疗服务/在线接诊(兽医)/在线接诊(兽医)技术方案.md

@@ -102,6 +102,7 @@ AI 回复消息由异步任务调用大模型后 `INSERT`(`sender_role=AI`)
102
 | `receiver_provider_id` | `bigint(20)` | N | 兽医/专家 `biz_medical_resource.id`(AI 为 NULL) |
102
 | `receiver_provider_id` | `bigint(20)` | N | 兽医/专家 `biz_medical_resource.id`(AI 为 NULL) |
103
 | `receiver_name` | `varchar(64)` | N | 接诊人姓名快照 |
103
 | `receiver_name` | `varchar(64)` | N | 接诊人姓名快照 |
104
 | `session_title` | `varchar(200)` | N | 问题/问答标题(AI 列表主标题) |
104
 | `session_title` | `varchar(200)` | N | 问题/问答标题(AI 列表主标题) |
105
+| `real_session_id` | `varchar(64)` | N | 大模型网关 `session_id`(AI 诊断落库写入,见 AI 诊断方案 §3.1) |
105
 | `last_message_time` | `datetime` | N | 最后一条消息时间(列表排序、3 个月窗) |
106
 | `last_message_time` | `datetime` | N | 最后一条消息时间(列表排序、3 个月窗) |
106
 | `last_message_preview` | `varchar(500)` | N | 列表摘要(文本截断或 `[图片]` 等) |
107
 | `last_message_preview` | `varchar(500)` | N | 列表摘要(文本截断或 `[图片]` 等) |
107
 | `vet_visible` | `tinyint(4)` | Y | **1**兽医可见 **0**牧民删除后对兽医隐藏(仅 `consult_type=1` 使用) |
108
 | `vet_visible` | `tinyint(4)` | Y | **1**兽医可见 **0**牧民删除后对兽医隐藏(仅 `consult_type=1` 使用) |
@@ -130,6 +131,7 @@ AI 回复消息由异步任务调用大模型后 `INSERT`(`sender_role=AI`)
130
 | `msg_type` | `tinyint(4)` | Y | **1**文本 **2**图片 **3**视频 **4**语音 |
131
 | `msg_type` | `tinyint(4)` | Y | **1**文本 **2**图片 **3**视频 **4**语音 |
131
 | `content` | `text` | N | 文本内容或媒体 URL |
132
 | `content` | `text` | N | 文本内容或媒体 URL |
132
 | `media_duration` | `int(11)` | N | 语音/视频时长(秒,可选) |
133
 | `media_duration` | `int(11)` | N | 语音/视频时长(秒,可选) |
134
+| `ai_category` | `varchar(32)` | N | AI 提问分类(仅 AI 诊断提问人消息,见 AI 诊断方案 §3.2) |
133
 | `send_time` | `datetime` | Y | 发送时间 |
135
 | `send_time` | `datetime` | Y | 发送时间 |
134
 | `create_time` | `datetime` | N | 创建时间 |
136
 | `create_time` | `datetime` | N | 创建时间 |
135
 
137
 

+ 1 - 0
sql/biz_consult_message.sql

@@ -8,6 +8,7 @@ CREATE TABLE IF NOT EXISTS `biz_consult_message` (
8
   `msg_type` tinyint(4) NOT NULL COMMENT '1文本2图片3视频4语音',
8
   `msg_type` tinyint(4) NOT NULL COMMENT '1文本2图片3视频4语音',
9
   `content` text COMMENT '文本或媒体URL',
9
   `content` text COMMENT '文本或媒体URL',
10
   `media_duration` int(11) DEFAULT NULL COMMENT '音视频时长秒',
10
   `media_duration` int(11) DEFAULT NULL COMMENT '音视频时长秒',
11
+  `ai_category` varchar(32) DEFAULT NULL COMMENT 'AI提问分类',
11
   `send_time` datetime NOT NULL COMMENT '发送时间',
12
   `send_time` datetime NOT NULL COMMENT '发送时间',
12
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
13
   `create_time` datetime DEFAULT NULL COMMENT '创建时间',
13
   PRIMARY KEY (`id`),
14
   PRIMARY KEY (`id`),