Commit f60a1007 authored by jiangyz's avatar jiangyz

智能体配置接入

parent 4f8cf0b6
# pms-agent 与 agent-lab-backend API 接口差异清单
基于以下两个项目源码进行对外 REST/SSE 接口契约比对:
- `D:\Code\pms-agent`
- `D:\Code\agent-lab\agent-lab-backend`
比对口径:
- 以控制器与 DTO 源码为准,不依赖 Swagger/OpenAPI 生成结果
-`HTTP 方法 + 完整路径` 作为接口主键
- 比对请求参数、请求体、表单字段、返回 `Result<T>.data` 结构,以及 SSE 事件数据结构
- 仅按 HTTP 契约判断差异;纯 Java 签名差异如果不影响 HTTP 结构,则不单独计为不一致
## 1. 仅 `pms-agent` 存在
| 接口 | `pms-agent` | `agent-lab-backend` | 差异类型 |
|---|---|---|---|
| `GET /api/agent/care/stream` | 查询参数:`sessionId``message`;返回 `text/event-stream`,事件数据为字符串 | 无对应接口 | 仅单边存在 |
## 2. 仅 `agent-lab-backend` 存在
| 接口 | `pms-agent` | `agent-lab-backend` | 差异类型 |
|---|---|---|---|
| `POST /api/chat/sessions/{sessionId}/files` | 无对应接口 | `multipart/form-data`,表单字段:`file`;返回 `Result<ChatSessionFileResponse>` | 仅单边存在 |
| `GET /api/chat/sessions/{sessionId}/files` | 无对应接口 | 返回 `Result<List<ChatSessionFileResponse>>` | 仅单边存在 |
| `DELETE /api/chat/sessions/{sessionId}/files/{fileId}` | 无对应接口 | 返回 `Result<Void>` | 仅单边存在 |
| `GET /api/admin/settings/default-models` | 无对应接口 | 返回 `Result<DefaultModelSettingsResponse>`,含 `defaultModelConfigId``defaultEmbeddingModelConfigId``defaultRerankModelConfigId` 及 3 个默认模型摘要对象 | 仅单边存在 |
| `PUT /api/admin/settings/default-models` | 无对应接口 | 请求体含 `defaultModelConfigId``defaultEmbeddingModelConfigId``defaultRerankModelConfigId`;返回 `Result<DefaultModelSettingsResponse>` | 仅单边存在 |
## 3. 同路径但契约不一致
| 接口 | `pms-agent` | `agent-lab-backend` | 差异类型 |
|---|---|---|---|
| `GET /api/admin/agents` | 返回 `AgentSummaryResponse.modelConfigId: string` | 返回 `AgentSummaryResponse.modelConfigId: number` | 返回参数不一致 |
| `GET /api/admin/agents/{id}` | 返回 `AgentDetailResponse.modelConfigId: string` | 返回 `AgentDetailResponse.modelConfigId: number` | 返回参数不一致 |
| `POST /api/admin/agents` | 请求 `CreateAgentRequest.modelConfigId: string`;返回 `modelConfigId: string` | 请求 `CreateAgentRequest.modelConfigId: number`;返回 `modelConfigId: number` | 请求参数不一致、返回参数不一致 |
| `PUT /api/admin/agents/{id}` | 请求 `UpdateAgentRequest.modelConfigId: string`;返回 `modelConfigId: string` | 请求 `UpdateAgentRequest.modelConfigId: number`;返回 `modelConfigId: number` | 请求参数不一致、返回参数不一致 |
| `PATCH /api/admin/agents/{id}/toggle` | 返回 `modelConfigId: string` | 返回 `modelConfigId: number` | 返回参数不一致 |
| `GET /api/admin/chatbots` | 返回 `ChatbotSummaryResponse.modelConfigId: string` | 返回 `ChatbotSummaryResponse.modelConfigId: number` | 返回参数不一致 |
| `GET /api/admin/chatbots/{id}` | `ChatbotDetailResponse` 继承摘要结构,`modelConfigId: string` | `ChatbotDetailResponse` 继承摘要结构,`modelConfigId: number` | 返回参数不一致 |
| `POST /api/admin/chatbots` | 请求 `CreateChatbotRequest.modelConfigId: string`;返回 `modelConfigId: string` | 请求 `CreateChatbotRequest.modelConfigId: number`;返回 `modelConfigId: number` | 请求参数不一致、返回参数不一致 |
| `PUT /api/admin/chatbots/{id}` | 请求 `UpdateChatbotRequest.modelConfigId: string`;返回 `modelConfigId: string` | 请求 `UpdateChatbotRequest.modelConfigId: number`;返回 `modelConfigId: number` | 请求参数不一致、返回参数不一致 |
| `PATCH /api/admin/chatbots/{id}/toggle` | 返回 `modelConfigId: string` | 返回 `modelConfigId: number` | 返回参数不一致 |
| `GET /api/admin/providers` | 返回 `ProviderResponse.id: string` | 返回 `ProviderResponse.id: number` | 返回参数不一致 |
| `GET /api/admin/providers/{id}` | 返回 `ProviderResponse.id: string` | 返回 `ProviderResponse.id: number` | 返回参数不一致 |
| `POST /api/admin/providers` | 请求体一致;返回 `ProviderResponse.id: string` | 请求体一致;返回 `ProviderResponse.id: number` | 返回参数不一致 |
| `PUT /api/admin/providers/{id}` | 请求体一致;返回 `ProviderResponse.id: string` | 请求体一致;返回 `ProviderResponse.id: number` | 返回参数不一致 |
| `PATCH /api/admin/providers/{id}/toggle` | 返回 `ProviderResponse.id: string` | 返回 `ProviderResponse.id: number` | 返回参数不一致 |
| `GET /api/admin/providers/{providerId}/models` | 返回 `ModelConfigResponse.id: string``providerId: string` | 返回 `ModelConfigResponse.id: number``providerId: number` | 返回参数不一致 |
| `POST /api/admin/providers/{providerId}/models` | 请求体一致;返回 `id: string``providerId: string` | 请求体一致;返回 `id: number``providerId: number` | 返回参数不一致 |
| `PUT /api/admin/providers/{providerId}/models/{mid}` | 请求体一致;返回 `id: string``providerId: string` | 请求体一致;返回 `id: number``providerId: number` | 返回参数不一致 |
| `PATCH /api/admin/providers/{providerId}/models/{mid}/toggle` | 返回 `id: string``providerId: string` | 返回 `id: number``providerId: number` | 返回参数不一致 |
| `GET /api/admin/settings/default-model` | 返回 `DefaultModelResponse.modelConfigId: string` | 返回 `DefaultModelResponse.modelConfigId: number` | 返回参数不一致 |
| `PUT /api/admin/settings/default-model` | 请求体 `{ "modelConfigId": "..." }`;返回 `modelConfigId: string`;空值时报参数错误 | 请求体 `{ "modelConfigId": 123 }`;返回 `modelConfigId: number`;空值时返回成功且 `data` 为空模型 | 请求参数不一致、返回参数不一致 |
## 4. 补充说明
- 本次未列出的接口,按源码比对结果可视为一致。
- 已确认一致的主要接口包括:
- `/api/chat/sessions*` 主聊天链路
- `/api/admin/agents/{agentId}/fields*`
- `/api/admin/chatbots/tools`
- `/api/admin/providers/types`
ALTER TABLE AI_MODEL_CONFIG ADD ENABLE_THINKING NUMBER(1, 0);
ALTER TABLE AI_MODEL_CONFIG ADD MULTI_MODEL NUMBER(1, 0);
COMMENT ON COLUMN AI_MODEL_CONFIG.ENABLE_THINKING IS '是否启用 thinking,仅 LLM 类型有效';
COMMENT ON COLUMN AI_MODEL_CONFIG.MULTI_MODEL IS '是否启用多模态,仅 LLM 类型有效';
...@@ -153,6 +153,10 @@ ...@@ -153,6 +153,10 @@
<groupId>org.springframework.ai</groupId> <groupId>org.springframework.ai</groupId>
<artifactId>spring-ai-starter-model-openai</artifactId> <artifactId>spring-ai-starter-model-openai</artifactId>
</dependency> </dependency>
<dependency>
<groupId>com.alibaba.cloud.ai</groupId>
<artifactId>spring-ai-alibaba-dashscope</artifactId>
</dependency>
</dependencies> </dependencies>
<build> <build>
<plugins> <plugins>
......
package com.infoepoch.pms.agent.common.utils;
import org.apache.commons.lang3.StringUtils;
import java.util.Iterator;
import java.util.LinkedHashMap;
import java.util.Map;
public abstract class AbstractCriteria {
private Integer pageIndex;
private Integer pageSize;
public Integer getPageIndex() {
return pageIndex;
}
public void setPageIndex(Integer pageIndex) {
this.pageIndex = pageIndex;
}
public Integer getPageSize() {
return pageSize;
}
public void setPageSize(Integer pageSize) {
this.pageSize = pageSize;
}
public boolean byPage() {
if (pageIndex == null || pageSize == null)
return false;
if (pageIndex < 0 || pageSize < 1)
return false;
return true;
}
/*** andMap*/
protected Map<String, Object> andMap = new LinkedHashMap<>();
/*** 是否有查询条件*/
public boolean hasCriteria() {
return !andMap.isEmpty();
}
/**
* @Description: 移除andMap内value为null或空字符串
* @Param: []
* @Author: zhangyd
*/
public void removeMapNullOrEmpty() {
Iterator<Map.Entry<String, Object>> it = andMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Object> entry = it.next();
if (entry.getValue() == null) {
it.remove();
continue;
}
//如果是字符串,判断是否是空字符串
if ((entry.getValue() instanceof String) && StringUtils.isBlank(entry.getValue().toString())) {
it.remove();
}
}
}
/**
* @Description: 移除andMap内value为null或空字符串
* @Param: []
* @Author: zhangyd
*/
public void removeMapNull() {
Iterator<Map.Entry<String, Object>> it = andMap.entrySet().iterator();
while (it.hasNext()) {
Map.Entry<String, Object> entry = it.next();
if (entry.getValue() == null) {
it.remove();
}
}
}
}
\ No newline at end of file
package com.infoepoch.pms.agent.common; package com.infoepoch.pms.agent.common.utils;
import org.slf4j.LoggerFactory; import org.slf4j.LoggerFactory;
...@@ -76,6 +76,9 @@ public class LogHelper { ...@@ -76,6 +76,9 @@ public class LogHelper {
public static void error(Object context, String s, Throwable throwable) { public static void error(Object context, String s, Throwable throwable) {
if (throwable.getMessage().contains("Incorrect result size: expected 1, actual 0")){
return;
}
LoggerFactory.getLogger(context.getClass()).error(s, throwable); LoggerFactory.getLogger(context.getClass()).error(s, throwable);
} }
......
package com.infoepoch.pms.agent.common.utils;
import java.util.Map;
import java.util.stream.Collectors;
public class MapTool {
/**
* MAP反转
*
* @param map
* @param <K>
* @param <V>
* @return
*/
public static <K, V> Map<V, K> mapReverse(Map<K, V> map) {
return map.entrySet().stream().collect(Collectors.toMap(Map.Entry::getValue, Map.Entry::getKey));
}
}
package com.infoepoch.pms.agent.common.utils;
/**
* SnowFlake算法Id生成器
*/
public class SnowFlake {
/**
* 起始的时间戳
*/
private final static long START_STMP = 1480166465631L;
/**
* 每一部分占用的位数
*/
private final static long SEQUENCE_BIT = 12; //序列号占用的位数,一个时间戳最多产生4096个序列号
private final static long MACHINE_BIT = 5; //机器标识占用的位数,最大支持32台机器
private final static long DATACENTER_BIT = 5;//数据中心占用的位数,最大支持32个数据中心
/**
* 每一部分的最大值
*/
private final static long MAX_DATACENTER_NUM = -1L ^ (-1L << DATACENTER_BIT);
private final static long MAX_MACHINE_NUM = -1L ^ (-1L << MACHINE_BIT);
private final static long MAX_SEQUENCE = -1L ^ (-1L << SEQUENCE_BIT);
/**
* 每一部分向左的位移
*/
private final static long MACHINE_LEFT = SEQUENCE_BIT;
private final static long DATACENTER_LEFT = SEQUENCE_BIT + MACHINE_BIT;
private final static long TIMESTMP_LEFT = DATACENTER_LEFT + DATACENTER_BIT;
public long datacenterId; //数据中心
public long machineId; //机器标识
private long sequence = 0L; //序列号
private long lastStmp = -1L;//上一次时间戳
public long getDatacenterId() {
return datacenterId;
}
public long getMachineId() {
return machineId;
}
public SnowFlake(long datacenterId, long machineId) {
if (datacenterId > MAX_DATACENTER_NUM || datacenterId < 0) {
throw new IllegalArgumentException("datacenterId can't be greater than MAX_DATACENTER_NUM or less than 0");
}
if (machineId > MAX_MACHINE_NUM || machineId < 0) {
throw new IllegalArgumentException("machineId can't be greater than MAX_MACHINE_NUM or less than 0");
}
this.datacenterId = datacenterId;
this.machineId = machineId;
}
/**
* 产生下一个Id
*/
public synchronized Long nextId() {
long currStmp = getNewstmp();
if (currStmp < lastStmp) {
throw new RuntimeException("Clock moved backwards. Refusing to generate id");
}
if (currStmp == lastStmp) {
//相同毫秒内,序列号自增
sequence = (sequence + 1) & MAX_SEQUENCE;
//同一毫秒的序列数已经达到最大
if (sequence == 0L) {
currStmp = getNextMill();
}
} else {
//不同毫秒内,序列号置为0
sequence = 0L;
}
lastStmp = currStmp;
return (currStmp - START_STMP) << TIMESTMP_LEFT //时间戳部分
| datacenterId << DATACENTER_LEFT //数据中心部分
| machineId << MACHINE_LEFT //机器标识部分
| sequence; //序列号部分
}
private long getNextMill() {
long mill = getNewstmp();
while (mill <= lastStmp) {
mill = getNewstmp();
}
return mill;
}
private long getNewstmp() {
return System.currentTimeMillis();
}
//-------------------------------------------------------------------------
//静态实例
//private static SnowFlake snowFlake = new SnowFlake(0, 0);
private volatile static SnowFlake snowFlake;
/**
* 初始化
*/
public static void init(long datacenterId, long machineId) {
snowFlake = new SnowFlake(datacenterId, machineId);
}
public static SnowFlake instant() {
if (snowFlake == null)
synchronized (SnowFlake.class) {
if (snowFlake == null) {
snowFlake = new SnowFlake(0, 0);
}
}
return snowFlake;
}
}
package com.infoepoch.pms.agent.common.utils;
import org.apache.commons.lang3.StringUtils;
import java.util.Iterator;
import java.util.Objects;
public class StringTool {
public static final String EMPTY = "";
private static final int STRING_BUILDER_SIZE = 256;
public static String replaceAll(String str, String regex, String replacement) {
if(str == null)
return null;
return str.replaceAll(regex, replacement);
}
/**
* 查询单个字符数量
*
* @param str
* @param regex
* @return
*/
public static int searchCharCount(String str, char regex) {
char[] chars = str == null ? new char[0] : str.toCharArray();
int count = 0;
for (char c : chars) {
if (c == regex) {
count++;
}
}
return count;
}
/**
* 获取字符串
*
* @param integer
* @return
*/
public static String getString(Integer integer) {
if (integer == null)
return null;
else
return String.valueOf(integer);
}
/**
* 获取字符串
*
* @param o
* @return
*/
public static String getString(Object o) {
if (o == null)
return null;
else
return String.valueOf(o);
}
/**
* @param o
* @param defaultString
* @return
*/
public static String getString(Object o, String defaultString) {
try {
return String.valueOf(o);
} catch (Exception e) {
return defaultString;
}
}
/**
* @param str
* @param defaultStr
* @return
*/
public static String getString(String str, String defaultStr) {
if (str == null || str.equals("") || str.equals("null")) {
return defaultStr;
} else {
return str;
}
}
/**
* 填充字符串左侧0
*
* @param num
* @param targetLength
* @return
*/
public static String fillLeftZero(Integer num, int targetLength) {
StringBuilder numStr = new StringBuilder(String.valueOf(num));
int length = numStr.length();
if (length >= targetLength) {
return numStr.toString();
} else {
for (int i = 0; i < targetLength - length; i++) {
numStr.insert(0, "0");
}
return numStr.toString();
}
}
/**
* 移除字符串中的html标签
*
* @param str
* @return
*/
public static String removeHtmlTag(String str) {
if(str == null)
return "";
String htmlRegex = "<[^>]+>";
return str.replaceAll(htmlRegex, "")
.replaceAll("&gt;", "“")// 大于
.replaceAll("&lt;", "“")// 小于
.replaceAll("&amp;", "“")// AND符号
.replaceAll("&quot;", "“")// 双引号
.replaceAll("&ldquo;", "“")// 左双引号
.replaceAll("&rdquo;", "”")// 右双引号
.replaceAll("&nbsp;", " ")// 空格
;
}
/**
* 编码自增
*
* @param str
* @param length
* @return
*/
public static String increase(String str, int length) {
int old = Integer.parseInt(str);
return fillLeftZero(old + 1, length);
}
public static String trim(String str) {
return str == null ? null : str.trim();
}
public static String trimToNull(String str) {
String ts = trim(str);
return StringUtils.isEmpty(ts) ? null : ts;
}
public static String trimToEmpty(String str) {
return str == null ? "" : str.trim();
}
public static String join(final Object[] array, final String separator) {
if (array == null) {
return null;
}
return join(array, separator, 0, array.length);
}
public static String join(final Object[] array, String separator, final int startIndex, final int endIndex) {
if (array == null) {
return null;
}
if (separator == null) {
separator = EMPTY;
}
final int noOfItems = endIndex - startIndex;
if (noOfItems <= 0) {
return EMPTY;
}
final StringBuilder buf = newStringBuilder(noOfItems);
for (int i = startIndex; i < endIndex; i++) {
if (i > startIndex) {
buf.append(separator);
}
if (array[i] != null) {
buf.append(array[i]);
}
}
return buf.toString();
}
private static StringBuilder newStringBuilder(final int noOfItems) {
return new StringBuilder(noOfItems * 16);
}
public static String join(final Iterable<?> iterable, final String separator) {
if (iterable == null) {
return null;
}
return join(iterable.iterator(), separator);
}
public static String join(final Iterator<?> iterator, final String separator) {
// handle null, zero and one elements before building a buffer
if (iterator == null) {
return null;
}
if (!iterator.hasNext()) {
return EMPTY;
}
final Object first = iterator.next();
if (!iterator.hasNext()) {
return Objects.toString(first, "");
}
// two or more elements
final StringBuilder buf = new StringBuilder(STRING_BUILDER_SIZE); // Java default is 16, probably too small
if (first != null) {
buf.append(first);
}
while (iterator.hasNext()) {
if (separator != null) {
buf.append(separator);
}
final Object obj = iterator.next();
if (obj != null) {
buf.append(obj);
}
}
return buf.toString();
}
/**
* @Description: 查询字符串中包含指定字符的个数
* @Param: [str, strRes]
* @Author: zhangyd
*/
public static int searchString(String str, String strRes) {
if (null == str || null == strRes || str.isEmpty() || strRes.isEmpty())
return 0;
int n = 0;
int index = 0;
index = str.indexOf(strRes);
while (index != -1) {
n++;
index = str.indexOf(strRes, index + 1);
}
return n;
}
public static String getString(String value){
if (value == null)
return "";
return value;
}
}
...@@ -240,6 +240,17 @@ public class JsonUtils { ...@@ -240,6 +240,17 @@ public class JsonUtils {
return tmpMap; return tmpMap;
} }
public static Map<String, String> jsonToMapString(String jsonStr){
Map<String, String> tmpMap = new HashMap<>();
try {
tmpMap = MAPPER.readValue(jsonStr, new TypeReference<Map<String, String>>() {});
} catch (Exception ex) {
logger.info("JSON转换异常。");
ex.printStackTrace();
}
return tmpMap;
}
public static List<Map<String, Object>> jsonToMapList(String jsonStr) { public static List<Map<String, Object>> jsonToMapList(String jsonStr) {
List<Map<String, Object>> mapList = new ArrayList<>(); List<Map<String, Object>> mapList = new ArrayList<>();
try { try {
......
package com.infoepoch.pms.agent.controller; package com.infoepoch.pms.agent.controller;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.CareAgentService; import com.infoepoch.pms.agent.domain.care.CareAgentService;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
......
package com.infoepoch.pms.agent.domain.care; package com.infoepoch.pms.agent.domain.care;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import com.infoepoch.pms.agent.domain.care.orchestrator.CareRecommendationOrchestrator; import com.infoepoch.pms.agent.domain.care.orchestrator.CareRecommendationOrchestrator;
import org.apache.commons.lang3.StringUtils; import org.apache.commons.lang3.StringUtils;
......
...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState; ...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.OverAllState;
import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.alibaba.cloud.ai.graph.agent.hook.AgentHook; import com.alibaba.cloud.ai.graph.agent.hook.AgentHook;
import com.alibaba.cloud.ai.graph.agent.hook.HookPosition; import com.alibaba.cloud.ai.graph.agent.hook.HookPosition;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import java.util.Collections; import java.util.Collections;
......
...@@ -5,7 +5,7 @@ import com.alibaba.cloud.ai.graph.agent.hook.HookPosition; ...@@ -5,7 +5,7 @@ import com.alibaba.cloud.ai.graph.agent.hook.HookPosition;
import com.alibaba.cloud.ai.graph.agent.hook.messages.AgentCommand; import com.alibaba.cloud.ai.graph.agent.hook.messages.AgentCommand;
import com.alibaba.cloud.ai.graph.agent.hook.messages.MessagesModelHook; import com.alibaba.cloud.ai.graph.agent.hook.messages.MessagesModelHook;
import com.alibaba.cloud.ai.graph.agent.hook.messages.UpdatePolicy; import com.alibaba.cloud.ai.graph.agent.hook.messages.UpdatePolicy;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import org.springframework.ai.chat.messages.Message; import org.springframework.ai.chat.messages.Message;
......
...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.agent.ReactAgent; ...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.agent.ReactAgent;
import com.alibaba.cloud.ai.graph.streaming.OutputType; import com.alibaba.cloud.ai.graph.streaming.OutputType;
import com.alibaba.cloud.ai.graph.streaming.StreamingOutput; import com.alibaba.cloud.ai.graph.streaming.StreamingOutput;
import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import com.infoepoch.pms.agent.domain.care.model.ActivitySummary; import com.infoepoch.pms.agent.domain.care.model.ActivitySummary;
import com.infoepoch.pms.agent.domain.care.model.CareConversationState; import com.infoepoch.pms.agent.domain.care.model.CareConversationState;
......
package com.infoepoch.pms.agent.domain.care.orchestrator; package com.infoepoch.pms.agent.domain.care.orchestrator;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import com.infoepoch.pms.agent.domain.care.model.CareConversationState; import com.infoepoch.pms.agent.domain.care.model.CareConversationState;
import com.infoepoch.pms.agent.domain.care.model.CareQuery; import com.infoepoch.pms.agent.domain.care.model.CareQuery;
......
...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.agent.ReactAgent; ...@@ -4,7 +4,7 @@ import com.alibaba.cloud.ai.graph.agent.ReactAgent;
import com.alibaba.cloud.ai.graph.streaming.OutputType; import com.alibaba.cloud.ai.graph.streaming.OutputType;
import com.alibaba.cloud.ai.graph.streaming.StreamingOutput; import com.alibaba.cloud.ai.graph.streaming.StreamingOutput;
import com.alibaba.cloud.ai.graph.RunnableConfig; import com.alibaba.cloud.ai.graph.RunnableConfig;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import com.infoepoch.pms.agent.domain.care.model.CareQuery; import com.infoepoch.pms.agent.domain.care.model.CareQuery;
import com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler; import com.infoepoch.pms.agent.domain.care.stream.CareStreamingResponseAssembler;
......
package com.infoepoch.pms.agent.domain.care.orchestrator; package com.infoepoch.pms.agent.domain.care.orchestrator;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
import com.infoepoch.pms.agent.domain.care.model.CareConversationState; import com.infoepoch.pms.agent.domain.care.model.CareConversationState;
import com.infoepoch.pms.agent.domain.care.model.CareQuery; import com.infoepoch.pms.agent.domain.care.model.CareQuery;
......
package com.infoepoch.pms.agent.domain.care.state; package com.infoepoch.pms.agent.domain.care.state;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.config.JsonUtils; import com.infoepoch.pms.agent.config.JsonUtils;
import com.infoepoch.pms.agent.domain.care.model.CareConversationState; import com.infoepoch.pms.agent.domain.care.model.CareConversationState;
import com.infoepoch.pms.agent.properties.CareAgentProperties; import com.infoepoch.pms.agent.properties.CareAgentProperties;
......
package com.infoepoch.pms.agent.domain.care.understanding; package com.infoepoch.pms.agent.domain.care.understanding;
import com.fasterxml.jackson.annotation.JsonIgnoreProperties; import com.fasterxml.jackson.annotation.JsonIgnoreProperties;
import com.infoepoch.pms.agent.common.LogHelper; import com.infoepoch.pms.agent.common.utils.LogHelper;
import com.infoepoch.pms.agent.config.JsonUtils; import com.infoepoch.pms.agent.config.JsonUtils;
import com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule; import com.infoepoch.pms.agent.domain.care.model.ActivityMatchUserRule;
import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport; import com.infoepoch.pms.agent.domain.care.log.CareTraceLogSupport;
......
package com.infoepoch.pms.agent.platform.agent.adapter.in.web;
import com.infoepoch.pms.agent.platform.agent.application.AgentService;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentDetailResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSummaryResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.CreateAgentRequest;
import com.infoepoch.pms.agent.platform.agent.application.dto.UpdateAgentRequest;
import com.infoepoch.pms.agent.platform.shared.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 智能体管理控制器。
* <p>
* 提供智能体的增删改查及启用/禁用接口。
* GET 列表返回摘要(不含字段),GET 详情返回含字段列表。
* </p>
*/
@RestController
@RequestMapping("/api/admin/agents")
@RequiredArgsConstructor
public class AgentController {
private final AgentService agentService;
/**
* 查询全部智能体摘要列表。
*
* @return 智能体摘要列表
*/
@GetMapping
public Result<List<AgentSummaryResponse>> listAll() {
return Result.success(agentService.listAll());
}
/**
* 创建智能体。
*
* @param req 创建请求
* @return 创建后的智能体摘要
*/
@PostMapping
public Result<AgentSummaryResponse> create(@Valid @RequestBody CreateAgentRequest req) {
return Result.success(agentService.create(req));
}
/**
* 按 ID 查询智能体详情。
*
* @param id 智能体 ID
* @return 智能体详情
*/
@GetMapping("/{id}")
public Result<AgentDetailResponse> findById(@PathVariable String id) {
return Result.success(agentService.findById(id));
}
/**
* 更新智能体基础信息。
*
* @param id 智能体 ID
* @param req 更新请求
* @return 更新后的智能体摘要
*/
@PutMapping("/{id}")
public Result<AgentSummaryResponse> update(@PathVariable String id,
@Valid @RequestBody UpdateAgentRequest req) {
return Result.success(agentService.update(id, req));
}
/**
* 删除智能体。
*
* @param id 智能体 ID
* @return 空结果
*/
@DeleteMapping("/{id}")
public Result<Void> delete(@PathVariable String id) {
agentService.delete(id);
return Result.success(null);
}
/**
* 切换智能体启用状态。
*
* @param id 智能体 ID
* @return 切换后的智能体摘要
*/
@PatchMapping("/{id}/toggle")
public Result<AgentSummaryResponse> toggle(@PathVariable String id) {
return Result.success(agentService.toggle(id));
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.in.web;
import com.infoepoch.pms.agent.platform.agent.application.AgentFieldService;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentFieldResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.CreateAgentFieldRequest;
import com.infoepoch.pms.agent.platform.agent.application.dto.UpdateAgentFieldRequest;
import com.infoepoch.pms.agent.platform.shared.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.web.bind.annotation.*;
import java.util.List;
/**
* 智能体字段管理控制器。
* <p>
* 提供指定智能体下的字段增删改查接口。
* </p>
*/
@RestController
@RequestMapping("/api/admin/agents/{agentId}/fields")
@RequiredArgsConstructor
public class AgentFieldController {
private final AgentFieldService agentFieldService;
/**
* 查询指定智能体的字段列表。
*
* @param agentId 智能体 ID
* @return 字段列表
*/
@GetMapping
public Result<List<AgentFieldResponse>> listByAgent(@PathVariable String agentId) {
return Result.success(agentFieldService.listByAgent(agentId));
}
/**
* 为指定智能体新增字段。
*
* @param agentId 智能体 ID
* @param req 创建请求
* @return 新增后的字段
*/
@PostMapping
public Result<AgentFieldResponse> create(@PathVariable String agentId,
@Valid @RequestBody CreateAgentFieldRequest req) {
return Result.success(agentFieldService.create(agentId, req));
}
/**
* 更新指定智能体下的字段。
*
* @param agentId 智能体 ID
* @param fieldId 字段 ID
* @param req 更新请求
* @return 更新后的字段
*/
@PutMapping("/{fieldId}")
public Result<AgentFieldResponse> update(@PathVariable String agentId,
@PathVariable String fieldId,
@Valid @RequestBody UpdateAgentFieldRequest req) {
return Result.success(agentFieldService.update(agentId, fieldId, req));
}
/**
* 删除指定智能体下的字段。
*
* @param agentId 智能体 ID
* @param fieldId 字段 ID
* @return 空结果
*/
@DeleteMapping("/{fieldId}")
public Result<Void> delete(@PathVariable String agentId, @PathVariable String fieldId) {
agentFieldService.delete(agentId, fieldId);
return Result.success(null);
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.ai;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelCallHandler;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelInterceptor;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelRequest;
import com.alibaba.cloud.ai.graph.agent.interceptor.ModelResponse;
import org.springframework.ai.chat.messages.Message;
import org.springframework.ai.chat.messages.SystemMessage;
import java.util.ArrayList;
import java.util.List;
/**
* 消息滑动窗口拦截器。
* <p>
* 在每次 LLM 调用前,将消息列表截断为最多 {@code maxMessages} 条:
* 保留第一条 SystemMessage(如有)+ 最后 N-1 条其余消息。
* {@code maxMessages=0} 时透传,不过滤。
* </p>
*/
public class MessageFilterInterceptor extends ModelInterceptor {
private final int maxMessages;
/**
* 构造消息滑动窗口拦截器。
*
* @param maxMessages 最大消息数,0 表示不限制
*/
public MessageFilterInterceptor(int maxMessages) {
this.maxMessages = maxMessages;
}
/**
* 拦截模型调用,过滤消息后再转发给处理链。
*/
@Override
public ModelResponse interceptModel(ModelRequest request, ModelCallHandler handler) {
List<Message> original = request.getMessages();
List<Message> filtered = applyFilter(original);
if (filtered != original) {
request = ModelRequest.builder(request).messages(filtered).build();
}
return handler.call(request);
}
/**
* 返回拦截器名称。
*/
@Override
public String getName() {
return "MessageFilterInterceptor";
}
/**
* 核心过滤逻辑,包可见以便单元测试直接调用。
*/
List<Message> applyFilter(List<Message> messages) {
if (maxMessages == 0 || messages.size() <= maxMessages) {
return messages;
}
List<Message> filtered = new ArrayList<>();
messages.stream()
.filter(m -> m instanceof SystemMessage)
.findFirst()
.ifPresent(filtered::add);
boolean hasSystem = !filtered.isEmpty();
int startIndex = Math.max(0, messages.size() - maxMessages + (hasSystem ? 1 : 0));
filtered.addAll(messages.subList(startIndex, messages.size()));
return filtered;
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.ai;
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallHandler;
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallRequest;
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolCallResponse;
import com.alibaba.cloud.ai.graph.agent.interceptor.ToolInterceptor;
import com.infoepoch.pms.agent.platform.shared.observation.ToolExecutionTraceBuffer;
import com.infoepoch.pms.agent.platform.shared.observation.ToolExecutionTraceEvent;
import lombok.RequiredArgsConstructor;
/**
* 工具执行轨迹拦截器。
*/
@RequiredArgsConstructor
public class ToolExecutionTraceInterceptor extends ToolInterceptor {
private final ToolExecutionTraceBuffer traceBuffer;
private final String requestKey;
/**
* 拦截工具调用并记录 running/finished/failed 三类轨迹事件。
*
* @param request 工具调用请求
* @param handler 工具调用处理器
* @return 工具调用响应
*/
@Override
public ToolCallResponse interceptToolCall(ToolCallRequest request, ToolCallHandler handler) {
String toolDisplayName = request.getToolName();
traceBuffer.append(requestKey, ToolExecutionTraceEvent.running(
request.getToolCallId(), request.getToolName(), toolDisplayName,
request.getArguments(), request.getToolName()));
try {
ToolCallResponse response = handler.call(request);
traceBuffer.append(requestKey, ToolExecutionTraceEvent.finished(
request.getToolCallId(), request.getToolName(), toolDisplayName,
request.getArguments(), response.getResult(), request.getToolName()));
return response;
} catch (Exception ex) {
traceBuffer.append(requestKey, ToolExecutionTraceEvent.failed(
request.getToolCallId(), request.getToolName(), toolDisplayName,
request.getArguments(), ex.getMessage(), request.getToolName()));
throw ex;
}
}
/**
* 返回拦截器名称。
*
* @return 拦截器名称
*/
@Override
public String getName() {
return "ToolExecutionTraceInterceptor";
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.submit;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSubmitCommand;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSubmitResult;
import com.fasterxml.jackson.core.type.TypeReference;
import com.fasterxml.jackson.databind.ObjectMapper;
import lombok.extern.slf4j.Slf4j;
import org.springframework.http.MediaType;
import org.springframework.http.client.ClientHttpResponse;
import org.springframework.stereotype.Component;
import org.springframework.util.StreamUtils;
import org.springframework.util.StringUtils;
import org.springframework.web.client.RestClient;
import java.io.IOException;
import java.nio.charset.StandardCharsets;
import java.util.Collections;
import java.util.LinkedHashMap;
import java.util.Map;
import java.util.UUID;
/**
* 统一的智能体提交策略。
*/
@Slf4j
@Component
public class HttpSubmitStrategy {
/**
* submitUrl 为空时返回的默认成功文案。
*/
private static final String BLANK_URL_MESSAGE = "submit url is blank, skipped remote submit";
private final RestClient restClient;
private final ObjectMapper objectMapper;
public HttpSubmitStrategy(RestClient.Builder restClientBuilder, ObjectMapper objectMapper) {
this.restClient = restClientBuilder.build();
this.objectMapper = objectMapper;
}
/**
* 根据是否配置 submitUrl 执行日志提交或 HTTP 提交。
*
* @param command 提交命令
* @return 提交结果
*/
public AgentSubmitResult submit(AgentSubmitCommand command) {
String requestId = UUID.randomUUID().toString();
if (!StringUtils.hasText(command.getSubmitUrl())) {
log.info("Skip remote submit because submitUrl is blank, agentId={}, agentName={}, requestId={}, fields={}",
command.getAgentId(), command.getAgentName(), requestId, command.getFields());
return new AgentSubmitResult(true, BLANK_URL_MESSAGE, 200, requestId, null);
}
try {
return restClient.post()
.uri(command.getSubmitUrl())
.contentType(MediaType.APPLICATION_JSON)
.body(buildRequestBody(command))
.exchange((request, response) -> buildSubmitResult(response, requestId));
} catch (Exception ex) {
log.error("Agent submit failed, agentId={}, agentName={}, submitUrl={}, requestId={}",
command.getAgentId(), command.getAgentName(), command.getSubmitUrl(), requestId, ex);
return new AgentSubmitResult(false, getExceptionMessage(ex), null, requestId, null);
}
}
/**
* 构建 HTTP 提交请求体。
*
* @param command 提交命令
* @return 请求体 Map
*/
private Map<String, Object> buildRequestBody(AgentSubmitCommand command) {
Map<String, Object> payload = new LinkedHashMap<>();
payload.put("agentId", command.getAgentId());
payload.put("agentName", command.getAgentName());
payload.put("fields", command.getFields());
return payload;
}
/**
* 从响应体中读取字符串字段,不存在时返回默认值。
*
* @param responseBody 响应体
* @param key 字段名
* @param defaultValue 默认值
* @return 读取到的字符串
*/
private String getTextValue(Map<String, Object> responseBody, String key, String defaultValue) {
if (responseBody == null) {
return defaultValue;
}
Object value = responseBody.get(key);
if (value == null) {
return defaultValue;
}
return String.valueOf(value);
}
/**
* 将 HTTP 响应转换为统一提交结果。
*
* @param response HTTP 响应
* @param requestId 本次提交请求 ID
* @return 统一提交结果
* @throws IOException 读取响应体失败时抛出
*/
private AgentSubmitResult buildSubmitResult(ClientHttpResponse response, String requestId) throws IOException {
int statusCode = response.getStatusCode().value();
String responseText = StreamUtils.copyToString(response.getBody(), StandardCharsets.UTF_8);
Map<String, Object> responseBody = parseResponseBody(responseText);
String defaultMessage = StringUtils.hasText(responseText)
? responseText
: response.getStatusCode().toString();
return new AgentSubmitResult(
response.getStatusCode().is2xxSuccessful(),
getTextValue(responseBody, "message", defaultMessage),
statusCode,
getTextValue(responseBody, "requestId", requestId),
getTextValue(responseBody, "externalId", null)
);
}
/**
* 解析响应文本为 Map。
*
* @param responseText 响应文本
* @return 解析后的响应体
*/
private Map<String, Object> parseResponseBody(String responseText) {
if (!StringUtils.hasText(responseText)) {
return Collections.emptyMap();
}
try {
return objectMapper.readValue(responseText, new TypeReference<>() {
});
} catch (Exception ex) {
return Map.of("message", responseText);
}
}
/**
* 提取异常可读信息。
*
* @param ex 异常对象
* @return 异常消息,消息为空时返回异常类名
*/
private String getExceptionMessage(Exception ex) {
if (StringUtils.hasText(ex.getMessage())) {
return ex.getMessage();
}
return ex.getClass().getSimpleName();
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotBuiltInToolDefinition;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotBuiltInToolType;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotWorkspaceRootResolver;
import com.infoepoch.pms.agent.platform.chatbot.application.port.out.ChatbotBuiltInToolQueryPort;
import org.springframework.ai.support.ToolCallbacks;
import org.springframework.ai.tool.ToolCallbackProvider;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.nio.file.Path;
import java.util.*;
/**
* 聊天机器人内置工具注册表。
*/
@Component
public class ChatbotBuiltInToolRegistry implements ChatbotBuiltInToolQueryPort {
public static final String CHATBOT_CONTEXT_KEY = "chatbot";
private final Map<String, ChatbotBuiltInToolDefinition> configurableTools;
private final Map<String, ToolCallbackProvider> toolCallbackProviders;
public ChatbotBuiltInToolRegistry() {
this.configurableTools = createConfigurableTools();
this.toolCallbackProviders = Map.of();
}
/**
* 创建内置工具注册表。
*
* @param getCurrentTimeTool 当前时间工具
* @param webFetchTool 网页抓取工具
* @param globSearchTool 知识库通配搜索工具
* @param grepSearchTool 知识库正则搜索工具
*/
@Autowired
public ChatbotBuiltInToolRegistry(GetCurrentTimeTool getCurrentTimeTool,
WebFetchTool webFetchTool,
GlobSearchTool globSearchTool,
GrepSearchTool grepSearchTool) {
this.configurableTools = createConfigurableTools();
this.toolCallbackProviders = createToolCallbackProviders(getCurrentTimeTool, webFetchTool,
globSearchTool, grepSearchTool);
}
/**
* 查询全部可配置内置工具。
*
* @return 可配置内置工具列表
*/
@Override
public List<ChatbotBuiltInToolDefinition> listConfigurableTools() {
return List.copyOf(configurableTools.values());
}
/**
* 判断指定工具是否为可配置内置工具。
*
* @param toolKey 工具标识
* @return 是否为可配置内置工具
*/
@Override
public boolean isConfigurableTool(String toolKey) {
return StringUtils.hasText(toolKey) && configurableTools.containsKey(toolKey.trim());
}
/**
* 按工具标识查询工具回调提供器。
*
* @param toolKey 工具标识
* @return 工具回调提供器
*/
public Optional<ToolCallbackProvider> findToolCallbackProvider(String toolKey) {
if (!StringUtils.hasText(toolKey)) {
return Optional.empty();
}
return Optional.ofNullable(toolCallbackProviders.get(toolKey.trim()));
}
static Path resolveWorkspaceRootForTesting(String configuredWorkspaceRoot, Path currentPath) {
return ChatbotWorkspaceRootResolver.resolve(configuredWorkspaceRoot, currentPath);
}
private Map<String, ChatbotBuiltInToolDefinition> createConfigurableTools() {
Map<String, ChatbotBuiltInToolDefinition> tools = new LinkedHashMap<>();
for (ChatbotBuiltInToolType type : ChatbotBuiltInToolType.values()) {
tools.put(type.getKey(), new ChatbotBuiltInToolDefinition(type.getKey(), type.getName(), type.getDescription()));
}
return Collections.unmodifiableMap(tools);
}
private Map<String, ToolCallbackProvider> createToolCallbackProviders(GetCurrentTimeTool getCurrentTimeTool,
WebFetchTool webFetchTool,
GlobSearchTool globSearchTool,
GrepSearchTool grepSearchTool) {
Map<String, ToolCallbackProvider> providers = new LinkedHashMap<>();
providers.put(ChatbotBuiltInToolType.GET_CURRENT_TIME.getKey(), ToolCallbackProvider.from(ToolCallbacks.from(getCurrentTimeTool)));
providers.put(ChatbotBuiltInToolType.WEB_FETCH.getKey(), ToolCallbackProvider.from(webFetchTool.createToolCallback()));
providers.put(ChatbotBuiltInToolType.GLOB_SEARCH.getKey(), ToolCallbackProvider.from(globSearchTool.createToolCallback()));
providers.put(ChatbotBuiltInToolType.GREP_SEARCH.getKey(), ToolCallbackProvider.from(grepSearchTool.createToolCallback()));
return Collections.unmodifiableMap(providers);
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.time.ZoneId;
import java.time.ZonedDateTime;
import java.time.format.DateTimeFormatter;
import java.time.format.TextStyle;
import java.util.Locale;
/**
* 系统内置工具:获取当前服务器时间。
* <p>
* 挂载到主 ReactAgent,当用户提到"今天"、"明天"、"下周"等相对日期时,
* 大模型会先调用此工具获取当前时间,再推算具体日期。
* </p>
*/
@Component
public class GetCurrentTimeTool {
/**
* 获取当前上海时区时间。
*
* @return 包含日期、时间、星期的时间结构
*/
@Tool(description = "获取当前服务器时间,当需要处理'今天'、'明天'、'后天'、'下周'等相对日期时必须先调用此工具")
public CurrentTimeResult getCurrentTime() {
ZonedDateTime now = ZonedDateTime.now(ZoneId.of("Asia/Shanghai"));
return new CurrentTimeResult(
now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd")),
now.format(DateTimeFormatter.ofPattern("HH:mm:ss")),
now.format(DateTimeFormatter.ofPattern("yyyy-MM-dd HH:mm:ss")),
now.getDayOfWeek().getDisplayName(TextStyle.FULL, Locale.CHINESE),
"Asia/Shanghai"
);
}
/**
* 当前时间结果值对象。
*
* @param date 日期,格式 yyyy-MM-dd
* @param time 时间,格式 HH:mm:ss
* @param datetime 日期时间,格式 yyyy-MM-dd HH:mm:ss
* @param dayOfWeek 星期几,如"星期一"
* @param timezone 时区名称
*/
public record CurrentTimeResult(
String date,
String time,
String datetime,
String dayOfWeek,
String timezone
) {
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotBuiltInToolType;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotWorkspaceRootResolver;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.GuardedWorkspaceToolCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 工作区文件通配搜索工具。
*/
@Component
public class GlobSearchTool {
private final Path workspaceRoot;
@Autowired
public GlobSearchTool(@Value("${app.chatbot-tools.workspace-root:}") String configuredWorkspaceRoot) {
this.workspaceRoot = ChatbotWorkspaceRootResolver.resolve(configuredWorkspaceRoot,
Paths.get("").toAbsolutePath().normalize());
}
GlobSearchTool(Path workspaceRoot) {
this.workspaceRoot = workspaceRoot.toAbsolutePath().normalize();
}
/**
* 创建工具回调。
*
* @return 工具回调
*/
public ToolCallback createToolCallback() {
ToolCallback delegate = com.alibaba.cloud.ai.graph.agent.tools.GlobSearchTool.builder(workspaceRoot.toString())
.withName(ChatbotBuiltInToolType.GLOB_SEARCH.getKey())
.withDescription(ChatbotBuiltInToolType.GLOB_SEARCH.getDescription())
.build();
return new GuardedWorkspaceToolCallback(workspaceRoot, delegate);
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotBuiltInToolType;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotWorkspaceRootResolver;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.GuardedWorkspaceToolCallback;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.beans.factory.annotation.Autowired;
import org.springframework.beans.factory.annotation.Value;
import org.springframework.stereotype.Component;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 工作区内容正则搜索工具。
*/
@Component
public class GrepSearchTool {
private final Path workspaceRoot;
@Autowired
public GrepSearchTool(@Value("${app.chatbot-tools.workspace-root:}") String configuredWorkspaceRoot) {
this.workspaceRoot = ChatbotWorkspaceRootResolver.resolve(configuredWorkspaceRoot,
Paths.get("").toAbsolutePath().normalize());
}
GrepSearchTool(Path workspaceRoot) {
this.workspaceRoot = workspaceRoot.toAbsolutePath().normalize();
}
/**
* 创建工具回调。
*
* @return 工具回调
*/
public ToolCallback createToolCallback() {
ToolCallback delegate = com.alibaba.cloud.ai.graph.agent.tools.GrepSearchTool.builder(workspaceRoot.toString())
.withName(ChatbotBuiltInToolType.GREP_SEARCH.getKey())
.withDescription(ChatbotBuiltInToolType.GREP_SEARCH.getDescription())
.build();
return new GuardedWorkspaceToolCallback(workspaceRoot, delegate);
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import com.infoepoch.pms.agent.platform.agent.application.AgentSubmitService;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSubmitResult;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import lombok.RequiredArgsConstructor;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.annotation.Tool;
import org.springframework.stereotype.Component;
import java.util.Map;
/**
* 智能体显式提交工具。
*/
@Component
@RequiredArgsConstructor
public class SubmitTool {
public static final String AGENT_CONTEXT_KEY = "agent";
private final AgentSubmitService agentSubmitService;
/**
* 提交当前子智能体已收集的字段数据。
*
* @param fields 待提交字段
* @param toolContext 工具上下文,必须包含当前子智能体
* @return 提交结果
*/
@Tool(description = "提交当前子智能体已收集完成的表单字段。当字段齐全并准备结束当前任务时调用")
public AgentSubmitResult submit(Map<String, Object> fields, ToolContext toolContext) {
return agentSubmitService.submit(extractAgent(toolContext), fields);
}
/**
* 从 ToolContext 中提取当前子智能体。
*
* @param toolContext 工具上下文
* @return 当前子智能体
*/
private Agent extractAgent(ToolContext toolContext) {
Object agent = toolContext.getContext().get(AGENT_CONTEXT_KEY);
if (agent instanceof Agent currentAgent) {
return currentAgent;
}
throw new IllegalArgumentException("ToolContext 中缺少当前智能体");
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool;
import com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support.ChatbotBuiltInToolType;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.function.FunctionToolCallback;
import org.springframework.ai.tool.metadata.ToolMetadata;
import org.springframework.stereotype.Component;
import org.springframework.util.StringUtils;
import java.io.IOException;
import java.net.URI;
import java.net.http.HttpClient;
import java.net.http.HttpRequest;
import java.net.http.HttpResponse;
import java.nio.charset.StandardCharsets;
import java.time.Duration;
import java.util.Optional;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* 聊天机器人网页抓取工具。
*/
@Component
public class WebFetchTool {
private static final int MAX_FETCH_CONTENT_LENGTH = 6000;
private static final Pattern TITLE_PATTERN = Pattern.compile("(?is)<title[^>]*>(.*?)</title>");
private final HttpClient httpClient;
public WebFetchTool() {
this.httpClient = HttpClient.newBuilder()
.followRedirects(HttpClient.Redirect.NORMAL)
.connectTimeout(Duration.ofSeconds(10))
.build();
}
/**
* 创建工具回调。
*
* @return 工具回调
*/
public ToolCallback createToolCallback() {
return FunctionToolCallback
.builder(ChatbotBuiltInToolType.WEB_FETCH.getKey(), (WebFetchRequest request) -> fetchWebPage(request.url()))
.description(ChatbotBuiltInToolType.WEB_FETCH.getDescription())
.inputType(WebFetchRequest.class)
.toolMetadata(ToolMetadata.builder().returnDirect(false).build())
.build();
}
private WebFetchResponse fetchWebPage(String url) {
if (!StringUtils.hasText(url)) {
throw new IllegalArgumentException("URL cannot be empty or null");
}
try {
HttpRequest request = HttpRequest.newBuilder(URI.create(url.trim()))
.timeout(Duration.ofSeconds(20))
.GET()
.build();
HttpResponse<String> response = httpClient.send(request, HttpResponse.BodyHandlers.ofString(StandardCharsets.UTF_8));
String rawHtml = Optional.ofNullable(response.body()).orElse("");
String normalizedContent = normalizeFetchedContent(rawHtml);
boolean truncated = normalizedContent.length() > MAX_FETCH_CONTENT_LENGTH;
return new WebFetchResponse(
response.uri().toString(),
response.statusCode(),
response.headers().firstValue("Content-Type").orElse(""),
extractTitle(rawHtml),
truncated ? normalizedContent.substring(0, MAX_FETCH_CONTENT_LENGTH) : normalizedContent,
truncated
);
}
catch (IOException | IllegalArgumentException ex) {
throw new IllegalStateException("抓取网页内容失败: " + url, ex);
}
catch (InterruptedException ex) {
Thread.currentThread().interrupt();
throw new IllegalStateException("抓取网页内容失败: " + url, ex);
}
}
private String extractTitle(String rawHtml) {
if (!StringUtils.hasText(rawHtml)) {
return null;
}
Matcher matcher = TITLE_PATTERN.matcher(rawHtml);
if (!matcher.find()) {
return null;
}
String normalizedTitle = matcher.group(1)
.replaceAll("\\s+", " ")
.trim();
return normalizedTitle.isEmpty() ? null : normalizedTitle;
}
private String normalizeFetchedContent(String rawContent) {
if (!StringUtils.hasText(rawContent)) {
return "";
}
return rawContent
.replaceAll("(?is)<script.*?>.*?</script>", " ")
.replaceAll("(?is)<style.*?>.*?</style>", " ")
.replaceAll("(?is)<head.*?>.*?</head>", " ")
.replaceAll("(?is)<[^>]+>", " ")
.replaceAll("&nbsp;", " ")
.replaceAll("\\s+", " ")
.trim();
}
/**
* 网页抓取请求。
*
* @param url 目标地址
*/
public record WebFetchRequest(String url) {
}
/**
* 网页抓取结果。
*
* @param finalUrl 实际抓取地址
* @param statusCode HTTP 状态码
* @param contentType 内容类型
* @param title 网页标题
* @param content 提取后的正文
* @param truncated 正文是否被截断
*/
public record WebFetchResponse(String finalUrl,
int statusCode,
String contentType,
String title,
String content,
boolean truncated) {
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support;
/**
* 聊天机器人可配置内置工具定义。
*
* @param key 工具唯一标识
* @param name 工具名称
* @param description 工具说明
*/
public record ChatbotBuiltInToolDefinition(String key, String name, String description) {
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support;
/**
* 聊天机器人内置工具类型枚举。
*/
public enum ChatbotBuiltInToolType {
GET_CURRENT_TIME("get_current_time", "获取当前时间", "返回当前服务器时间"),
WEB_FETCH("web_fetch", "网页抓取", "抓取并总结网页内容"),
GLOB_SEARCH("glob_search", "文件通配搜索", "按通配模式搜索工作区文件"),
GREP_SEARCH("grep_search", "文件内容搜索", "按正则搜索工作区文件内容");
private final String key;
private final String name;
private final String description;
ChatbotBuiltInToolType(String key, String name, String description) {
this.key = key;
this.name = name;
this.description = description;
}
/**
* 返回工具稳定标识。
*
* @return 工具稳定标识
*/
public String getKey() {
return key;
}
/**
* 返回工具名称。
*
* @return 工具名称
*/
public String getName() {
return name;
}
/**
* 返回工具说明。
*
* @return 工具说明
*/
public String getDescription() {
return description;
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support;
import org.springframework.util.StringUtils;
import java.nio.file.Path;
import java.nio.file.Paths;
/**
* 聊天机器人工作区根目录解析器。
*/
public final class ChatbotWorkspaceRootResolver {
private ChatbotWorkspaceRootResolver() {
}
/**
* 解析工作区根目录。
*
* @param configuredWorkspaceRoot 配置中的目录
* @param currentPath 当前工作目录
* @return 归一化后的工作区目录
*/
public static Path resolve(String configuredWorkspaceRoot, Path currentPath) {
Path projectRoot = resolveProjectRoot(currentPath.toAbsolutePath().normalize());
if (StringUtils.hasText(configuredWorkspaceRoot)) {
Path configuredPath = Paths.get(configuredWorkspaceRoot.trim());
if (configuredPath.isAbsolute()) {
return configuredPath.normalize();
}
return projectRoot.resolve(configuredPath).normalize();
}
return projectRoot;
}
private static Path resolveProjectRoot(Path currentPath) {
String currentName = currentPath.getFileName() != null ? currentPath.getFileName().toString() : "";
if ("agent-lab-backend".equals(currentName) || "agent-lab-fronted".equals(currentName)) {
return currentPath.getParent() != null ? currentPath.getParent() : currentPath;
}
return currentPath;
}
}
package com.infoepoch.pms.agent.platform.agent.adapter.out.tool.support;
import org.springframework.ai.chat.model.ToolContext;
import org.springframework.ai.tool.ToolCallback;
import org.springframework.ai.tool.definition.ToolDefinition;
import org.springframework.ai.tool.metadata.ToolMetadata;
import java.nio.file.Files;
import java.nio.file.Path;
/**
* 带工作区目录校验的工具回调装饰器。
*/
public final class GuardedWorkspaceToolCallback implements ToolCallback {
private final Path workspaceRoot;
private final ToolCallback delegate;
public GuardedWorkspaceToolCallback(Path workspaceRoot, ToolCallback delegate) {
this.workspaceRoot = workspaceRoot;
this.delegate = delegate;
}
@Override
public ToolDefinition getToolDefinition() {
return delegate.getToolDefinition();
}
@Override
public ToolMetadata getToolMetadata() {
return delegate.getToolMetadata();
}
@Override
public String call(String toolInput) {
return call(toolInput, null);
}
@Override
public String call(String toolInput, ToolContext toolContext) {
if (!Files.isDirectory(workspaceRoot)) {
return "Error: Workspace root does not exist - " + workspaceRoot;
}
return toolContext == null ? delegate.call(toolInput) : delegate.call(toolInput, toolContext);
}
}
package com.infoepoch.pms.agent.platform.agent.application;
import com.alibaba.cloud.ai.graph.agent.Agent;
import com.alibaba.cloud.ai.graph.checkpoint.BaseCheckpointSaver;
import com.infoepoch.pms.agent.platform.agent.adapter.out.ai.DynamicAgentBuilder;
import com.infoepoch.pms.agent.platform.chatbot.domain.Chatbot;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
/**
* 智能体执行应用服务。
* <p>
* 对外暴露聊天机器人运行时所需的主智能体构建入口,隔离上层模块对底层构建器的直接依赖。
* </p>
*/
@Service
@RequiredArgsConstructor
public class AgentExecutionService {
private final DynamicAgentBuilder dynamicAgentBuilder;
/**
* 根据聊天机器人配置构建可执行的主智能体。
*
* @param chatbot 聊天机器人配置
* @param saver checkpoint saver
* @param requestKey 本次请求的追踪键
* @return 已配置好的主智能体
*/
public Agent buildChatbotAgent(Chatbot chatbot, BaseCheckpointSaver saver, String requestKey) {
return dynamicAgentBuilder.buildChatbotAgent(chatbot, saver, requestKey);
}
}
package com.infoepoch.pms.agent.platform.agent.application;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentFieldResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.CreateAgentFieldRequest;
import com.infoepoch.pms.agent.platform.agent.application.dto.UpdateAgentFieldRequest;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import com.infoepoch.pms.agent.platform.agent.domain.AgentField;
import com.infoepoch.pms.agent.platform.agent.domain.irepository.IAgentFieldRepository;
import com.infoepoch.pms.agent.platform.agent.domain.irepository.IAgentRepository;
import com.infoepoch.pms.agent.platform.shared.exception.BusinessException;
import com.infoepoch.pms.agent.platform.shared.response.ResultCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 智能体字段管理服务。
* <p>
* 负责指定智能体下的字段 CRUD 操作,更新/删除时校验字段归属。
* </p>
*/
@Service
@RequiredArgsConstructor
public class AgentFieldService {
private final IAgentRepository agentRepository;
private final IAgentFieldRepository fieldRepository;
/**
* 查询指定智能体的所有字段,按排序序号升序排列。
*
* @param agentId 智能体 ID
* @return 字段响应列表
* @throws BusinessException AGENT_NOT_FOUND 若智能体不存在
*/
@Transactional(readOnly = true)
public List<AgentFieldResponse> listByAgent(String agentId) {
Agent agent = getAgentOrThrow(agentId);
return fieldRepository.findAllByAgentIdOrderBySortOrderAsc(agentId)
.stream()
.peek(field -> field.setAgent(agent))
.map(AgentFieldResponse::new)
.toList();
}
/**
* 在指定智能体下创建字段。
*
* @param agentId 智能体 ID
* @param req 创建请求 DTO
* @return 字段响应
* @throws BusinessException AGENT_NOT_FOUND 若智能体不存在
*/
@Transactional
public AgentFieldResponse create(String agentId, CreateAgentFieldRequest req) {
Agent agent = getAgentOrThrow(agentId);
AgentField field = new AgentField(agent, req.getFieldName(), req.getFieldLabel(),
req.getFieldType(), req.getDateFormat(), req.getEnumOptions(),
req.getRequired(), req.getDescription(), req.getSortOrder());
return new AgentFieldResponse(fieldRepository.save(field));
}
/**
* 更新字段配置。
*
* @param agentId 智能体 ID(校验归属)
* @param fieldId 字段 ID
* @param req 更新请求 DTO
* @return 更新后的字段响应
* @throws BusinessException AGENT_FIELD_NOT_FOUND 若字段不存在或不属于该智能体
*/
@Transactional
public AgentFieldResponse update(String agentId, String fieldId, UpdateAgentFieldRequest req) {
AgentField field = getFieldOrThrow(fieldId);
if (!field.getAgentId().equals(agentId)) {
throw new BusinessException(ResultCode.AGENT_FIELD_NOT_FOUND,
"字段 ID=" + fieldId + " 不属于智能体 ID=" + agentId);
}
field.modify(req.getFieldName(), req.getFieldLabel(), req.getFieldType(),
req.getDateFormat(), req.getEnumOptions(), req.getRequired(),
req.getDescription(), req.getSortOrder());
return new AgentFieldResponse(fieldRepository.save(field));
}
/**
* 删除字段。
*
* @param agentId 智能体 ID(校验归属)
* @param fieldId 字段 ID
* @throws BusinessException AGENT_FIELD_NOT_FOUND 若字段不存在或不属于该智能体
*/
@Transactional
public void delete(String agentId, String fieldId) {
AgentField field = getFieldOrThrow(fieldId);
if (!field.getAgentId().equals(agentId)) {
throw new BusinessException(ResultCode.AGENT_FIELD_NOT_FOUND,
"字段 ID=" + fieldId + " 不属于智能体 ID=" + agentId);
}
fieldRepository.delete(field.getId());
}
/**
* 查询智能体,不存在时抛出业务异常。
*
* @param agentId 智能体 ID
* @return 智能体实体
*/
private Agent getAgentOrThrow(String agentId) {
return agentRepository.findById(agentId)
.orElseThrow(() -> new BusinessException(ResultCode.AGENT_NOT_FOUND,
"智能体 ID=" + agentId + " 不存在"));
}
/**
* 查询字段,不存在时抛出业务异常。
*
* @param fieldId 字段 ID
* @return 字段实体
*/
private AgentField getFieldOrThrow(String fieldId) {
AgentField field = fieldRepository.findById(fieldId)
.orElseThrow(() -> new BusinessException(ResultCode.AGENT_FIELD_NOT_FOUND,
"字段 ID=" + fieldId + " 不存在"));
if (field.getAgentId() != null) {
field.setAgent(getAgentOrThrow(field.getAgentId()));
}
return field;
}
}
package com.infoepoch.pms.agent.platform.agent.application;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import com.infoepoch.pms.agent.platform.agent.domain.irepository.IAgentRepository;
import com.infoepoch.pms.agent.platform.model.application.ModelQueryService;
import com.infoepoch.pms.agent.platform.shared.exception.BusinessException;
import com.infoepoch.pms.agent.platform.shared.response.ResultCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.*;
/**
* 智能体查询服务。
*/
@Service
@RequiredArgsConstructor
public class AgentQueryService {
private final IAgentRepository agentRepository;
private final ModelQueryService modelQueryService;
/**
* 按 ID 集合查询所有已启用的智能体。
*
* @param ids 智能体 ID 集合
* @return 已启用的智能体列表
* @throws BusinessException PARAM_ERROR 若包含空 ID;AGENT_NOT_FOUND 若存在不存在或已禁用的智能体
*/
@Transactional(readOnly = true)
public List<Agent> findEnabledByIds(Set<String> ids) {
if (ids == null || ids.isEmpty()) {
return Collections.emptyList();
}
if (ids.stream().anyMatch(Objects::isNull)) {
throw new BusinessException(ResultCode.PARAM_ERROR, "子智能体 ID 不能为空");
}
LinkedHashSet<String> uniqueIds = new LinkedHashSet<>(ids);
List<Agent> agents = agentRepository.findByIdInAndEnabledTrue(uniqueIds);
if (agents.size() != uniqueIds.size()) {
throw new BusinessException(ResultCode.AGENT_NOT_FOUND, "存在不存在或已禁用的子智能体");
}
return agents.stream()
.map(this::attachRelations)
.toList();
}
@Transactional(readOnly = true)
public Optional<Agent> findById(String id) {
return agentRepository.findById(id).map(this::attachRelations);
}
private Agent attachRelations(Agent agent) {
if (agent == null) {
return null;
}
agent.setModelConfig(modelQueryService.getModelConfigOrNull(agent.getModelConfigId()));
return agent;
}
}
package com.infoepoch.pms.agent.platform.agent.application;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentDetailResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSummaryResponse;
import com.infoepoch.pms.agent.platform.agent.application.dto.CreateAgentRequest;
import com.infoepoch.pms.agent.platform.agent.application.dto.UpdateAgentRequest;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import com.infoepoch.pms.agent.platform.agent.domain.AgentField;
import com.infoepoch.pms.agent.platform.agent.domain.irepository.IAgentFieldRepository;
import com.infoepoch.pms.agent.platform.agent.domain.irepository.IAgentRepository;
import com.infoepoch.pms.agent.platform.chatbot.domain.irepository.IChatbotAgentsRepository;
import com.infoepoch.pms.agent.platform.model.application.ModelQueryService;
import com.infoepoch.pms.agent.platform.model.domain.AiModelConfig;
import com.infoepoch.pms.agent.platform.shared.exception.BusinessException;
import com.infoepoch.pms.agent.platform.shared.response.ResultCode;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
import java.util.List;
/**
* 智能体管理服务。
* <p>
* 负责智能体的 CRUD 操作,包含名称唯一性校验和可选的模型配置关联。
* </p>
*/
@Service
@RequiredArgsConstructor
public class AgentService {
private final IAgentRepository agentRepository;
private final IAgentFieldRepository agentFieldRepository;
private final IChatbotAgentsRepository chatbotAgentsRepository;
private final ModelQueryService modelQueryService;
/**
* 创建智能体。
*
* @param req 创建请求 DTO
* @return 智能体摘要响应
* @throws BusinessException RESOURCE_ALREADY_EXISTS 若名称已存在;MODEL_CONFIG_NOT_FOUND 若模型配置不存在
*/
@Transactional
public AgentSummaryResponse create(CreateAgentRequest req) {
if (agentRepository.existsByName(req.getName())) {
throw new BusinessException(ResultCode.RESOURCE_ALREADY_EXISTS,
"智能体名称 [" + req.getName() + "] 已存在");
}
AiModelConfig modelConfig = resolveModelConfig(req.getModelConfigId());
Agent agent = new Agent(req.getName(), req.getDescription(),
req.getSystemPrompt(), req.getSubmitUrl(), modelConfig,
req.getContextWindowSize());
return new AgentSummaryResponse(agentRepository.save(agent));
}
/**
* 按 ID 查询智能体详情(含字段列表)。
*
* @param id 智能体 ID
* @return 智能体详情响应
* @throws BusinessException AGENT_NOT_FOUND 若不存在
*/
@Transactional(readOnly = true)
public AgentDetailResponse findById(String id) {
Agent agent = attachRelations(getAgentOrThrow(id));
List<AgentField> fields = agentFieldRepository.findAllByAgentIdOrderBySortOrderAsc(id);
fields.forEach(field -> field.setAgent(agent));
agent.replaceFields(fields);
return new AgentDetailResponse(agent);
}
/**
* 查询所有智能体,按创建时间倒序排列(不含字段列表)。
*
* @return 智能体摘要响应列表
*/
@Transactional(readOnly = true)
public List<AgentSummaryResponse> listAll() {
return agentRepository.findAllByOrderByCreatedAtDesc()
.stream()
.map(AgentSummaryResponse::new)
.toList();
}
/**
* 更新智能体基本信息。
*
* @param id 智能体 ID
* @param req 更新请求 DTO
* @return 更新后的智能体摘要响应
* @throws BusinessException AGENT_NOT_FOUND 若不存在;RESOURCE_ALREADY_EXISTS 若名称已被占用
*/
@Transactional
public AgentSummaryResponse update(String id, UpdateAgentRequest req) {
Agent agent = getAgentOrThrow(id);
if (agentRepository.existsByNameAndIdNot(req.getName(), id)) {
throw new BusinessException(ResultCode.RESOURCE_ALREADY_EXISTS,
"智能体名称 [" + req.getName() + "] 已存在");
}
AiModelConfig modelConfig = resolveModelConfig(req.getModelConfigId());
agent.modify(req.getName(), req.getDescription(),
req.getSystemPrompt(), req.getSubmitUrl(), modelConfig,
req.getContextWindowSize());
return new AgentSummaryResponse(agentRepository.save(agent));
}
/**
* 删除智能体(级联删除其所有字段)。
*
* @param id 智能体 ID
* @throws BusinessException AGENT_NOT_FOUND 若不存在
*/
@Transactional
public void delete(String id) {
Agent agent = getAgentOrThrow(id);
chatbotAgentsRepository.deleteByAgentId(agent.getId());
agentFieldRepository.deleteByAgentId(agent.getId());
agentRepository.delete(agent.getId());
}
/**
* 切换智能体启用/禁用状态。
*
* @param id 智能体 ID
* @return 更新后的智能体摘要响应
* @throws BusinessException AGENT_NOT_FOUND 若不存在
*/
@Transactional
public AgentSummaryResponse toggle(String id) {
Agent agent = getAgentOrThrow(id);
agent.toggleEnabled();
return new AgentSummaryResponse(agentRepository.save(agent));
}
/**
* 按 ID 查询智能体,不存在时抛出业务异常。
*
* @param id 智能体 ID
* @return 智能体实体
*/
private Agent getAgentOrThrow(String id) {
return agentRepository.findById(id)
.orElseThrow(() -> new BusinessException(ResultCode.AGENT_NOT_FOUND,
"智能体 ID=" + id + " 不存在"));
}
private Agent attachRelations(Agent agent) {
if (agent == null) {
return null;
}
agent.setModelConfig(resolveModelConfig(agent.getModelConfigId()));
return agent;
}
/**
* 解析模型配置。
*
* @param modelConfigId 模型配置 ID
* @return 模型配置,传 null 时返回 null
*/
private AiModelConfig resolveModelConfig(String modelConfigId) {
AiModelConfig config = modelQueryService.getModelConfigOrNull(modelConfigId);
if (config != null && !"LLM".equals(config.getModelType())) {
throw new BusinessException(ResultCode.PARAM_ERROR,
"智能体仅支持引用 LLM 类型模型配置");
}
return config;
}
}
package com.infoepoch.pms.agent.platform.agent.application;
import com.infoepoch.pms.agent.platform.agent.adapter.out.submit.HttpSubmitStrategy;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSubmitCommand;
import com.infoepoch.pms.agent.platform.agent.application.dto.AgentSubmitResult;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import lombok.RequiredArgsConstructor;
import org.springframework.stereotype.Service;
import java.util.Map;
/**
* 智能体提交服务。
*/
@Service
@RequiredArgsConstructor
public class AgentSubmitService {
private final HttpSubmitStrategy httpSubmitStrategy;
/**
* 将智能体和表单字段映射为提交命令,并委托给 HTTP 提交策略执行。
*
* @param agent 智能体实体
* @param fields 提交字段
* @return 提交结果
*/
public AgentSubmitResult submit(Agent agent, Map<String, Object> fields) {
AgentSubmitCommand command = new AgentSubmitCommand(
agent.getId(),
agent.getName(),
agent.getSubmitUrl(),
fields
);
return httpSubmitStrategy.submit(command);
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
/**
* 智能体详情响应 DTO(含字段定义列表)。
*/
@Getter
public class AgentDetailResponse {
private final String id;
private final String name;
private final String description;
private final String systemPrompt;
private final String submitUrl;
private final String modelConfigId;
private final boolean enabled;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
private final List<AgentFieldResponse> fields;
public AgentDetailResponse(Agent agent) {
this.id = agent.getId();
this.name = agent.getName();
this.description = agent.getDescription();
this.systemPrompt = agent.getSystemPrompt();
this.submitUrl = agent.getSubmitUrl();
this.modelConfigId = agent.getModelConfig() != null ? agent.getModelConfig().getId() : agent.getModelConfigId();
this.enabled = agent.isEnabled();
this.createdAt = agent.getCreatedAt();
this.updatedAt = agent.getUpdatedAt();
this.fields = agent.getFields().stream()
.map(AgentFieldResponse::new)
.toList();
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import com.infoepoch.pms.agent.platform.agent.domain.AgentField;
import lombok.Getter;
import java.time.LocalDateTime;
import java.util.List;
/**
* 智能体字段响应 DTO。
*/
@Getter
public class AgentFieldResponse {
private final String id;
private final String agentId;
private final String fieldName;
private final String fieldLabel;
private final String fieldType;
private final String dateFormat;
private final List<String> enumOptions;
private final boolean required;
private final String description;
private final int sortOrder;
private final LocalDateTime createdAt;
public AgentFieldResponse(AgentField field) {
this.id = field.getId();
this.agentId = field.getAgent() != null ? field.getAgent().getId() : field.getAgentId();
this.fieldName = field.getFieldName();
this.fieldLabel = field.getFieldLabel();
this.fieldType = field.getFieldType();
this.dateFormat = field.getDateFormat();
this.enumOptions = field.getEnumOptions();
this.required = field.isRequired();
this.description = field.getDescription();
this.sortOrder = field.getSortOrder();
this.createdAt = field.getCreatedAt();
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import lombok.Getter;
import java.util.Map;
/**
* 智能体提交命令 DTO。
*/
@Getter
public class AgentSubmitCommand {
private final String agentId;
private final String agentName;
private final String submitUrl;
private final Map<String, Object> fields;
public AgentSubmitCommand(String agentId, String agentName, String submitUrl,
Map<String, Object> fields) {
this.agentId = agentId;
this.agentName = agentName;
this.submitUrl = submitUrl;
this.fields = fields;
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import lombok.Getter;
/**
* 智能体提交结果 DTO。
*/
@Getter
public class AgentSubmitResult {
private final boolean success;
private final String message;
private final Integer statusCode;
private final String requestId;
private final String externalId;
public AgentSubmitResult(boolean success, String message, Integer statusCode,
String requestId, String externalId) {
this.success = success;
this.message = message;
this.statusCode = statusCode;
this.requestId = requestId;
this.externalId = externalId;
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import lombok.Getter;
import java.time.LocalDateTime;
/**
* 智能体列表响应 DTO(不含字段定义)。
*/
@Getter
public class AgentSummaryResponse {
private final String id;
private final String name;
private final String description;
private final String systemPrompt;
private final String submitUrl;
private final String modelConfigId;
private final boolean enabled;
private final LocalDateTime createdAt;
private final LocalDateTime updatedAt;
public AgentSummaryResponse(Agent agent) {
this.id = agent.getId();
this.name = agent.getName();
this.description = agent.getDescription();
this.systemPrompt = agent.getSystemPrompt();
this.submitUrl = agent.getSubmitUrl();
this.modelConfigId = agent.getModelConfig() != null ? agent.getModelConfig().getId() : agent.getModelConfigId();
this.enabled = agent.isEnabled();
this.createdAt = agent.getCreatedAt();
this.updatedAt = agent.getUpdatedAt();
}
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 创建智能体字段请求 DTO。
*/
@Getter
@Setter
public class CreateAgentFieldRequest {
@NotBlank(message = "字段标识名不能为空")
@Size(max = 100, message = "字段标识名不能超过100个字符")
private String fieldName;
@NotBlank(message = "字段显示标签不能为空")
@Size(max = 100, message = "字段显示标签不能超过100个字符")
private String fieldLabel;
@NotBlank(message = "字段类型不能为空")
@Size(max = 20, message = "字段类型不能超过20个字符")
private String fieldType;
@Size(max = 30, message = "日期格式不能超过30个字符")
private String dateFormat;
private List<String> enumOptions;
@NotNull(message = "是否必填不能为空")
private Boolean required;
private String description;
private int sortOrder = 0;
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 创建智能体请求 DTO。
*/
@Getter
@Setter
public class CreateAgentRequest {
@NotBlank(message = "智能体名称不能为空")
@Size(max = 100, message = "智能体名称不能超过100个字符")
private String name;
@NotBlank(message = "智能体描述不能为空")
private String description;
private String systemPrompt;
@Size(max = 500, message = "提交URL不能超过500个字符")
private String submitUrl;
/**
* 指定模型配置 ID,可选,为 null 时使用全局默认模型。
*/
private String modelConfigId;
/**
* 上下文窗口大小,0 表示不限制,可选,默认 0。
*/
private int contextWindowSize = 0;
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.NotNull;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
import java.util.List;
/**
* 修改智能体字段请求 DTO。
*/
@Getter
@Setter
public class UpdateAgentFieldRequest {
@NotBlank(message = "字段标识名不能为空")
@Size(max = 100, message = "字段标识名不能超过100个字符")
private String fieldName;
@NotBlank(message = "字段显示标签不能为空")
@Size(max = 100, message = "字段显示标签不能超过100个字符")
private String fieldLabel;
@NotBlank(message = "字段类型不能为空")
@Size(max = 20, message = "字段类型不能超过20个字符")
private String fieldType;
@Size(max = 30, message = "日期格式不能超过30个字符")
private String dateFormat;
private List<String> enumOptions;
@NotNull(message = "是否必填不能为空")
private Boolean required;
private String description;
private int sortOrder = 0;
}
package com.infoepoch.pms.agent.platform.agent.application.dto;
import jakarta.validation.constraints.NotBlank;
import jakarta.validation.constraints.Size;
import lombok.Getter;
import lombok.Setter;
/**
* 修改智能体请求 DTO。
*/
@Getter
@Setter
public class UpdateAgentRequest {
@NotBlank(message = "智能体名称不能为空")
@Size(max = 100, message = "智能体名称不能超过100个字符")
private String name;
@NotBlank(message = "智能体描述不能为空")
private String description;
private String systemPrompt;
@Size(max = 500, message = "提交URL不能超过500个字符")
private String submitUrl;
/**
* 指定模型配置 ID,可选,传 null 则清空(使用全局默认)。
*/
private String modelConfigId;
/**
* 上下文窗口大小,0 表示不限制,可选,默认 0。
*/
private int contextWindowSize = 0;
}
package com.infoepoch.pms.agent.platform.agent.domain;
import com.infoepoch.pms.agent.common.utils.SnowFlake;
import com.infoepoch.pms.agent.platform.model.domain.AiModelConfig;
import com.infoepoch.pms.agent.platform.shared.exception.BusinessException;
import com.infoepoch.pms.agent.platform.shared.response.ResultCode;
import lombok.AccessLevel;
import lombok.Getter;
import lombok.NoArgsConstructor;
import org.springframework.util.StringUtils;
import java.time.LocalDateTime;
import java.util.ArrayList;
import java.util.List;
/**
* 智能体实体。
* <p>
* 代表一类业务表单处理流程(如请假、报销等),包含 AI 系统提示词、
* 动态字段定义和提交目标 URL。每个智能体通过 Tool Calling 被主 ReactAgent 触发。
* </p>
*/
@Getter
@NoArgsConstructor(access = AccessLevel.PROTECTED)
public class Agent {
/**
* 智能体名称最大长度。
*/
private static final int NAME_MAX_LENGTH = 100;
/**
* 提交 URL 最大长度。
*/
private static final int SUBMIT_URL_MAX_LENGTH = 500;
private String id;
/**
* 智能体名称,如"请假申请"。
*/
private String name;
/**
* 智能体描述,用于 Tool Calling 时大模型识别意图。
*/
private String description;
/**
* 系统提示词,注入到子 ReactAgent 的 system message,可选。
*/
private String systemPrompt;
/**
* 表单提交目标 URL,可选,留空时使用打桩日志提交。
*/
private String submitUrl;
/**
* 该智能体使用的模型配置,可选,为 null 时使用全局默认模型。
*/
private AiModelConfig modelConfig;
/**
* 该智能体使用的模型配置id
*/
private String modelConfigId;
/**
* 是否启用。禁用后主 ReactAgent 不会加载此智能体。
*/
private boolean enabled = true;
/**
* 记录创建时间。
*/
private LocalDateTime createdAt;
/**
* 记录最后更新时间。
*/
private LocalDateTime updatedAt;
/**
* 上下文窗口大小。0 表示不限制,正整数表示发给 LLM 的最大消息条数。
*/
private int contextWindowSize = 0;
/**
* 智能体的动态字段列表,按 sortOrder 升序排列。
*/
private List<AgentField> fields = new ArrayList<>();
/**
* 仓储还原
*/
public Agent(String id, String name, String description, String systemPrompt, String submitUrl, String modelConfigId, boolean enabled, LocalDateTime createdAt, LocalDateTime updatedAt, int contextWindowSize) {
this.id = id;
this.name = name;
this.description = description;
this.systemPrompt = systemPrompt;
this.submitUrl = submitUrl;
this.modelConfigId = modelConfigId;
this.enabled = enabled;
this.createdAt = createdAt;
this.updatedAt = updatedAt;
this.contextWindowSize = contextWindowSize;
}
/**
* 创建智能体。
*
* @param name 智能体名称,必填,最长 100 字符
* @param description 描述(用于 Tool Calling 意图识别),必填
* @param systemPrompt 系统提示词,可选
* @param submitUrl 提交目标 URL,可选,最长 500 字符
* @param modelConfig 指定模型配置,可选,为 null 使用全局默认
* @param contextWindowSize 上下文窗口大小,0 表示不限制,不能为负数
*/
public Agent(String name, String description, String systemPrompt,
String submitUrl, AiModelConfig modelConfig, int contextWindowSize) {
LocalDateTime now = LocalDateTime.now();
this.id = SnowFlake.instant().nextId().toString();
this.name = normalizeName(name);
this.description = normalizeDescription(description);
this.systemPrompt = systemPrompt == null ? null : systemPrompt.trim();
this.submitUrl = normalizeOptional(submitUrl, SUBMIT_URL_MAX_LENGTH, "提交 URL");
this.modelConfig = modelConfig;
this.modelConfigId = modelConfig == null ? null : modelConfig.getId();
this.contextWindowSize = normalizeContextWindowSize(contextWindowSize);
this.createdAt = now;
this.updatedAt = now;
}
public static Agent restore(String id,
String name,
String description,
String systemPrompt,
String submitUrl,
AiModelConfig modelConfig,
boolean enabled,
LocalDateTime createdAt,
LocalDateTime updatedAt,
int contextWindowSize,
List<AgentField> fields) {
Agent agent = new Agent();
agent.id = id;
agent.name = name;
agent.description = description;
agent.systemPrompt = systemPrompt;
agent.submitUrl = submitUrl;
agent.modelConfig = modelConfig;
agent.modelConfigId = modelConfig == null ? null : modelConfig.getId();
agent.enabled = enabled;
agent.createdAt = createdAt;
agent.updatedAt = updatedAt;
agent.contextWindowSize = contextWindowSize;
agent.fields = fields == null ? new ArrayList<>() : new ArrayList<>(fields);
return agent;
}
public void replaceFields(List<AgentField> fields) {
this.fields = fields == null ? new ArrayList<>() : new ArrayList<>(fields);
}
public void setModelConfig(AiModelConfig modelConfig) {
this.modelConfig = modelConfig;
this.modelConfigId = modelConfig == null ? null : modelConfig.getId();
}
/**
* 修改智能体配置。
*
* @param name 新名称,必填,最长 100 字符
* @param description 新描述,必填
* @param systemPrompt 新系统提示词,可选
* @param submitUrl 新提交 URL,可选,最长 500 字符
* @param modelConfig 新模型配置,可选,传 null 则清空(使用全局默认)
* @param contextWindowSize 上下文窗口大小,0 表示不限制,不能为负数
*/
public void modify(String name, String description, String systemPrompt,
String submitUrl, AiModelConfig modelConfig, int contextWindowSize) {
this.name = normalizeName(name);
this.description = normalizeDescription(description);
this.systemPrompt = systemPrompt == null ? null : systemPrompt.trim();
this.submitUrl = normalizeOptional(submitUrl, SUBMIT_URL_MAX_LENGTH, "提交 URL");
this.modelConfig = modelConfig;
this.modelConfigId = modelConfig == null ? null : modelConfig.getId();
this.contextWindowSize = normalizeContextWindowSize(contextWindowSize);
this.updatedAt = LocalDateTime.now();
}
/**
* 切换启用/禁用状态。
*/
public void toggleEnabled() {
this.enabled = !this.enabled;
this.updatedAt = LocalDateTime.now();
}
/**
* 规范化并校验智能体名称。
*
* @param name 原始名称
* @return 规范化后的名称
*/
private String normalizeName(String name) {
if (!StringUtils.hasText(name)) {
throw new BusinessException(ResultCode.PARAM_ERROR, "智能体名称不能为空");
}
String normalized = name.trim();
if (normalized.length() > NAME_MAX_LENGTH) {
throw new BusinessException(ResultCode.PARAM_ERROR,
"智能体名称长度不能超过 " + NAME_MAX_LENGTH + " 个字符");
}
return normalized;
}
/**
* 规范化并校验智能体描述。
*
* @param description 原始描述
* @return 规范化后的描述
*/
private String normalizeDescription(String description) {
if (!StringUtils.hasText(description)) {
throw new BusinessException(ResultCode.PARAM_ERROR, "智能体描述不能为空");
}
return description.trim();
}
/**
* 规范化并校验可选字符串字段。
*
* @param value 原始值
* @param maxLength 最大长度
* @param fieldName 字段名(用于异常提示)
* @return 规范化结果,空字符串归一化为 null
*/
private String normalizeOptional(String value, int maxLength, String fieldName) {
if (value == null) {
return null;
}
String normalized = value.trim();
if (normalized.length() > maxLength) {
throw new BusinessException(ResultCode.PARAM_ERROR,
fieldName + " 长度不能超过 " + maxLength + " 个字符");
}
return normalized.isEmpty() ? null : normalized;
}
/**
* 校验上下文窗口大小。
*
* @param value 原始窗口大小
* @return 校验后的窗口大小
*/
private int normalizeContextWindowSize(int value) {
if (value < 0) {
throw new BusinessException(ResultCode.PARAM_ERROR, "上下文窗口大小不能为负数");
}
return value;
}
}
package com.infoepoch.pms.agent.platform.agent.domain.irepository;
import com.infoepoch.pms.agent.common.utils.AbstractCriteria;
/**
* @author jiangyz
* @date 2026/4/17 15:49
* 智能体定义表查询条件类
*/
public class AgentCriteria extends AbstractCriteria {
//region 智能体名称
public boolean byName() {
return this.andMap.containsKey("Name");
}
private String name;
public String getName() {
if (byName())
return name;
return null;
}
public void setName(String value) {
this.name = value;
this.andMap.put("Name", value);
}
//endregion
//region 智能体名称模糊查询
public boolean byNameContain() {
return this.andMap.containsKey("NameContain");
}
private String nameContain;
public String getNameContain() {
if (byNameContain())
return nameContain;
return null;
}
public void setNameContain(String value) {
this.nameContain = value;
this.andMap.put("NameContain", value);
}
//endregion
//region 指定模型配置,为空则使用全局默认模型
public boolean byModelConfigId() {
return this.andMap.containsKey("ModelConfigId");
}
private String modelConfigId;
public String getModelConfigId() {
if (byModelConfigId())
return modelConfigId;
return null;
}
public void setModelConfigId(String value) {
this.modelConfigId = value;
this.andMap.put("ModelConfigId", value);
}
//endregion
//region 是否启用
public boolean byEnabled() {
return this.andMap.containsKey("Enabled");
}
private Boolean enabled;
public Boolean getEnabled() {
if (byEnabled())
return enabled;
return null;
}
public void setEnabled(Boolean value) {
this.enabled = value;
this.andMap.put("Enabled", value);
}
//endregion
}
\ No newline at end of file
package com.infoepoch.pms.agent.platform.agent.domain.irepository;
import com.infoepoch.pms.agent.common.utils.AbstractCriteria;
/**
* @author jiangyz
* @date 2026/4/17 16:51
* 智能体字段定义查询条件类
*/
public class AgentFieldCriteria extends AbstractCriteria {
//region 智能体id
public boolean byAgentId() {
return this.andMap.containsKey("AgentId");
}
private String agentId;
public String getAgentId() {
if (byAgentId())
return agentId;
return null;
}
public void setAgentId(String value) {
this.agentId = value;
this.andMap.put("AgentId", value);
}
//endregion
//region 字段英文名,如 leave_start_date
public boolean byFieldName() {
return this.andMap.containsKey("FieldName");
}
private String fieldName;
public String getFieldName() {
if (byFieldName())
return fieldName;
return null;
}
public void setFieldName(String value) {
this.fieldName = value;
this.andMap.put("FieldName", value);
}
//endregion
//region 字段英文名,如 leave_start_date模糊查询
public boolean byFieldNameContain() {
return this.andMap.containsKey("FieldNameContain");
}
private String fieldNameContain;
public String getFieldNameContain() {
if (byFieldNameContain())
return fieldNameContain;
return null;
}
public void setFieldNameContain(String value) {
this.fieldNameContain = value;
this.andMap.put("FieldNameContain", value);
}
//endregion
//region 字段中文名,如 请假开始日期
public boolean byFieldLabel() {
return this.andMap.containsKey("FieldLabel");
}
private String fieldLabel;
public String getFieldLabel() {
if (byFieldLabel())
return fieldLabel;
return null;
}
public void setFieldLabel(String value) {
this.fieldLabel = value;
this.andMap.put("FieldLabel", value);
}
//endregion
//region 字段中文名,如 请假开始日期模糊查询
public boolean byFieldLabelContain() {
return this.andMap.containsKey("FieldLabelContain");
}
private String fieldLabelContain;
public String getFieldLabelContain() {
if (byFieldLabelContain())
return fieldLabelContain;
return null;
}
public void setFieldLabelContain(String value) {
this.fieldLabelContain = value;
this.andMap.put("FieldLabelContain", value);
}
//endregion
//region text / number / date / datetime / enum / boolean
public boolean byFieldType() {
return this.andMap.containsKey("FieldType");
}
private String fieldType;
public String getFieldType() {
if (byFieldType())
return fieldType;
return null;
}
public void setFieldType(String value) {
this.fieldType = value;
this.andMap.put("FieldType", value);
}
//endregion
//region 是否必填
public boolean byRequired() {
return this.andMap.containsKey("Required");
}
private Boolean required;
public Boolean getRequired() {
if (byRequired())
return required;
return null;
}
public void setRequired(Boolean value) {
this.required = value;
this.andMap.put("Required", value);
}
//endregion
}
package com.infoepoch.pms.agent.platform.agent.domain.irepository;
import com.infoepoch.pms.agent.platform.agent.domain.AgentField;
import java.util.List;
import java.util.Optional;
/**
* @author jiangyz
* @date 2026/4/17 16:51
* 智能体字段定义仓储接口
*/
public interface IAgentFieldRepository {
/**
* 新增
* @param: [entity]
*/
boolean insert(AgentField entity);
/**
* 更新
* @param: [entity]
*/
boolean update(AgentField entity);
/**
* 批量新增
* @param: [entitys]
*/
int[] batchInsert(List<AgentField> entitys);
/**
* 批量更新
* @param: [entitys]
*/
int[] batchUpdate(List<AgentField> entitys);
/**
* 删除
* @param: [id]
*/
boolean delete(String id);
// region select
/**
* 根据Id查询
* @param: [id]
*/
AgentField selectById(String id);
/**
* 根据查询条件查询单个对象
* @param: [criteria]
*/
AgentField selectOneByCriteria(AgentFieldCriteria criteria);
/**
* 根据查询条件查询对象集合
* @param: [criteria]
*/
List<AgentField> selectByCriteria(AgentFieldCriteria criteria);
/**
* 根据查询条件分页查询对象结合
* @param: [criteria, pageNum, pageSize]
*/
List<AgentField> selectCriteriaByPage(AgentFieldCriteria criteria, int pageNum, int pageSize);
/**
* 根据条件查询对象总记录数
* @param: [criteria]
*/
Integer selectCountByCriteria(AgentFieldCriteria criteria);
// endregion
Optional<AgentField> findById(String id);
AgentField save(AgentField entity);
List<AgentField> findAllByAgentIdOrderBySortOrderAsc(String agentId);
void deleteByAgentId(String agentId);
}
package com.infoepoch.pms.agent.platform.agent.domain.irepository;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import java.util.List;
import java.util.Optional;
import java.util.Set;
/**
* @author jiangyz
* @date 2026/4/17 15:50
* 智能体定义表仓储接口
*/
public interface IAgentRepository {
/**
* 新增
* @param: [entity]
*/
boolean insert(Agent entity);
/**
* 更新
* @param: [entity]
*/
boolean update(Agent entity);
/**
* 批量新增
* @param: [entitys]
*/
int[] batchInsert(List<Agent> entitys);
/**
* 批量更新
* @param: [entitys]
*/
int[] batchUpdate(List<Agent> entitys);
/**
* 删除
* @param: [id]
*/
boolean delete(String id);
// region select
/**
* 根据Id查询
* @param: [id]
*/
Agent selectById(String id);
/**
* 根据查询条件查询单个对象
* @param: [criteria]
*/
Agent selectOneByCriteria(AgentCriteria criteria);
/**
* 根据查询条件查询对象集合
* @param: [criteria]
*/
List<Agent> selectByCriteria(AgentCriteria criteria);
/**
* 根据查询条件分页查询对象结合
* @param: [criteria, pageNum, pageSize]
*/
List<Agent> selectCriteriaByPage(AgentCriteria criteria, int pageNum, int pageSize);
/**
* 根据条件查询对象总记录数
* @param: [criteria]
*/
Integer selectCountByCriteria(AgentCriteria criteria);
// endregion
Optional<Agent> findById(String id);
Agent save(Agent entity);
boolean existsById(String id);
boolean existsByName(String name);
boolean existsByNameAndIdNot(String name, String id);
boolean existsByModelConfigId(String modelConfigId);
List<Agent> findAllByOrderByCreatedAtDesc();
List<Agent> findByIdInAndEnabledTrue(Set<String> ids);
}
package com.infoepoch.pms.agent.platform.agent.domain.service;
import com.infoepoch.pms.agent.platform.agent.domain.Agent;
import com.infoepoch.pms.agent.platform.agent.domain.AgentField;
import org.springframework.stereotype.Component;
import org.springframework.util.CollectionUtils;
import org.springframework.util.StringUtils;
import java.util.List;
/**
* 动态 System Prompt 生成器。
* <p>
* 若智能体配置了自定义 systemPrompt 则以其为基础;
* 否则根据 Agent 名称、描述和字段列表自动拼装标准提示词。
* 两种路径都会追加 submit 工具使用约束。
* </p>
*/
@Component
public class SystemPromptGenerator {
private static final String SUBMIT_GUIDANCE = "所有必填字段收集完毕后,调用 submit 工具提交申请\n"
+ "严格根据 submit 工具返回结果回复用户:提交成功后告知用户申请已提交,"
+ "提交失败时,根据 submit 工具返回的失败原因向用户说明";
/**
* 生成子 ReactAgent 的 system prompt。
*
* @param agent 智能体实体(需已加载 fields 集合)
* @return 生成的 system prompt 字符串
*/
public String generate(Agent agent) {
String prompt = StringUtils.hasText(agent.getSystemPrompt())
? agent.getSystemPrompt()
: buildDefaultPrompt(agent);
return appendSubmitGuidance(prompt);
}
/**
* 根据智能体信息自动构建默认 system prompt。
*
* @param agent 智能体实体
* @return 默认 system prompt
*/
private String buildDefaultPrompt(Agent agent) {
StringBuilder sb = new StringBuilder();
sb.append("你是「").append(agent.getName()).append("」,");
sb.append(agent.getDescription()).append("\n\n");
sb.append("你需要收集以下信息:\n");
if (!CollectionUtils.isEmpty(agent.getFields())) {
sb.append(buildFieldPrompt(agent.getFields()));
}
return sb.toString();
}
/**
* 根据智能体字段列表构建已填充的 system prompt。
*
* @param fields 智能体附属字段列表
* @return 字段定义段落文本
*/
private String buildFieldPrompt(List<AgentField> fields) {
StringBuilder sb = new StringBuilder();
for (AgentField field : fields) {
sb.append("- ").append(field.getFieldLabel())
.append("(").append(field.getFieldName()).append(")")
.append(":").append(field.getFieldType()).append(",");
sb.append(field.isRequired() ? "必填" : "选填");
if ("date".equals(field.getFieldType()) || "datetime".equals(field.getFieldType())) {
if (StringUtils.hasText(field.getDateFormat())) {
sb.append(",格式:").append(field.getDateFormat());
}
}
if ("enum".equals(field.getFieldType()) && field.getEnumOptions() != null
&& !field.getEnumOptions().isEmpty()) {
sb.append(",可选值:").append(field.getEnumOptions());
}
if (StringUtils.hasText(field.getDescription())) {
sb.append(",说明:").append(field.getDescription());
}
sb.append("\n");
}
sb.append("\n收集规则:\n");
sb.append("1. 缺少必填字段时,每次只追问一个字段,语气自然友好\n");
sb.append("2. 日期类字段需将用户输入转换为指定格式\n");
return sb.toString();
}
/**
* 在系统提示词末尾追加 submit 工具使用约束。
*
* @param prompt 原始提示词
* @return 追加约束后的提示词
*/
private String appendSubmitGuidance(String prompt) {
return prompt + "\n\n" + SUBMIT_GUIDANCE;
}
}
package com.infoepoch.pms.agent.platform.chat.adapter.in.web;
import com.infoepoch.pms.agent.platform.chat.application.ChatService;
import com.infoepoch.pms.agent.platform.chat.application.dto.*;
import com.infoepoch.pms.agent.platform.shared.response.Result;
import jakarta.validation.Valid;
import lombok.RequiredArgsConstructor;
import org.springframework.http.MediaType;
import org.springframework.http.codec.ServerSentEvent;
import org.springframework.web.bind.annotation.*;
import reactor.core.publisher.Flux;
import java.util.List;
/**
* 用户对话接口控制器。
* <p>
* 提供会话管理和消息收发的 REST API。
* </p>
*/
@RestController
@RequestMapping("/api/chat")
@RequiredArgsConstructor
public class ChatController {
private final ChatService chatService;
/**
* 创建新会话。
* <p>
* POST /api/chat/sessions
*/
@PostMapping("/sessions")
public Result<SessionResponse> createSession(@Valid @RequestBody CreateSessionRequest req) {
return Result.success(chatService.createSession(req));
}
/**
* 查询用户会话列表。
* <p>
* GET /api/chat/sessions?userId={userId}
*/
@GetMapping("/sessions")
public Result<List<SessionResponse>> listSessions(@RequestParam String userId) {
return Result.success(chatService.listSessions(userId));
}
/**
* 获取会话信息。
* <p>
* GET /api/chat/sessions/{sessionId}
*/
@GetMapping("/sessions/{sessionId}")
public Result<SessionResponse> getSession(@PathVariable String sessionId) {
return Result.success(chatService.getSession(sessionId));
}
/**
* 删除指定会话。
* <p>
* DELETE /api/chat/sessions/{sessionId}
*/
@DeleteMapping("/sessions/{sessionId}")
public Result<Void> deleteSession(@PathVariable String sessionId) {
chatService.deleteSession(sessionId);
return Result.success(null);
}
/**
* 标记清除上下文待生效。
*/
@PostMapping("/sessions/{sessionId}/context-reset")
public Result<SessionResponse> markContextResetPending(@PathVariable String sessionId) {
return Result.success(chatService.markContextResetPending(sessionId));
}
/**
* 撤销清除上下文待生效。
*/
@DeleteMapping("/sessions/{sessionId}/context-reset")
public Result<SessionResponse> clearContextResetPending(@PathVariable String sessionId) {
return Result.success(chatService.clearContextResetPending(sessionId));
}
/**
* 发送用户消息,获取 AI 回复。
* <p>
* POST /api/chat/sessions/{sessionId}/message
*/
@PostMapping("/sessions/{sessionId}/message")
public Result<MessageResponse> sendMessage(
@PathVariable String sessionId,
@Valid @RequestBody SendMessageRequest req) {
return Result.success(chatService.sendMessage(sessionId, req));
}
/**
* 发送用户消息,并以 SSE 方式流式返回 AI 输出。
* <p>
* POST /api/chat/sessions/{sessionId}/message/stream
*/
@PostMapping(value = "/sessions/{sessionId}/message/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<ChatStreamEventResponse>> streamMessage(
@PathVariable String sessionId,
@Valid @RequestBody SendMessageRequest req) {
return Flux.defer(() -> chatService.streamMessage(sessionId, req))
.map(event -> ServerSentEvent.<ChatStreamEventResponse>builder()
.event(event.getType())
.data(event)
.build())
.onErrorResume(error -> Flux.just(ServerSentEvent.<ChatStreamEventResponse>builder()
.event("error")
.data(new ChatStreamEventResponse("error", error.getMessage(), null, null))
.build()));
}
/**
* 查询当前会话的待审批事项。
* <p>
* GET /api/chat/sessions/{sessionId}/approval/pending
*/
@GetMapping("/sessions/{sessionId}/approval/pending")
public Result<PendingApprovalResponse> getPendingApproval(@PathVariable String sessionId) {
return Result.success(chatService.getPendingApproval(sessionId));
}
/**
* 提交审批决策,并以 SSE 方式流式返回恢复执行过程。
* <p>
* POST /api/chat/sessions/{sessionId}/approval/decision/stream
*/
@PostMapping(value = "/sessions/{sessionId}/approval/decision/stream", produces = MediaType.TEXT_EVENT_STREAM_VALUE)
public Flux<ServerSentEvent<ChatStreamEventResponse>> streamApprovalDecision(
@PathVariable String sessionId,
@Valid @RequestBody ApprovalDecisionRequest req) {
return Flux.defer(() -> chatService.streamApprovalDecision(sessionId, req))
.map(event -> ServerSentEvent.<ChatStreamEventResponse>builder()
.event(event.getType())
.data(event)
.build())
.onErrorResume(error -> Flux.just(ServerSentEvent.<ChatStreamEventResponse>builder()
.event("error")
.data(new ChatStreamEventResponse("error", error.getMessage(), null, null))
.build()));
}
/**
* 获取会话的完整对话历史。
* <p>
* GET /api/chat/sessions/{sessionId}/messages
*/
@GetMapping("/sessions/{sessionId}/messages")
public Result<List<ConversationResponse>> getMessages(@PathVariable String sessionId) {
return Result.success(chatService.getMessages(sessionId));
}
}
package com.infoepoch.pms.agent.platform.chat.application;
/**
* 审批文本意图。
*/
public enum ApprovalIntent {
/**
* 同意当前审批。
*/
APPROVE,
/**
* 拒绝当前审批。
*/
REJECT,
/**
* 修改当前审批内容。
*/
EDIT,
/**
* 拒绝并要求修改当前审批内容。
*/
REJECT_AND_EDIT,
/**
* 无法识别为明确审批操作。
*/
NONE
}
package com.infoepoch.pms.agent.platform.chat.application;
import com.infoepoch.pms.agent.platform.chat.domain.irepository.ISessionRepository;
import com.infoepoch.pms.agent.platform.chat.domain.Session;
import com.infoepoch.pms.agent.platform.model.application.ModelProvisionService;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.ai.chat.messages.SystemMessage;
import org.springframework.ai.chat.messages.UserMessage;
import org.springframework.ai.chat.model.ChatModel;
import org.springframework.ai.chat.prompt.Prompt;
import org.springframework.scheduling.annotation.Async;
import org.springframework.stereotype.Service;
import java.util.List;
/**
* 会话标题生成服务。
*/
@Slf4j
@Service
@RequiredArgsConstructor
public class SessionTitleService {
private final ModelProvisionService modelProvisionService;
private final ISessionRepository sessionRepository;
private static final String PROMPT = "你是一个会话标题生成助手。根据用户的第一条消息,生成一个简短、准确的中文会话标题。\n" +
"要求:\n" +
"- 不超过 15 个字\n" +
"- 不加引号、标点符号结尾\n" +
"- 直接返回标题文本,不要任何前缀或解释";
/**
* 异步为首轮用户消息生成会话标题。
*
* @param sessionId 会话 ID
* @param firstUserMessage 用户首条消息
*/
@Async
public void generateTitleAsync(String sessionId, String firstUserMessage) {
try {
log.info("Generating title for session: {}", sessionId);
ChatModel chatModel = modelProvisionService.create(null);
Prompt prompt = new Prompt(List.of(
new SystemMessage(PROMPT),
new UserMessage(firstUserMessage)
));
String title = chatModel.call(prompt).getResult().getOutput().getText();
log.info("Generated title for session {}: {}", sessionId, title);
Session session = sessionRepository.selectById(sessionId);
if (session != null) {
session.applyTitle(title);
sessionRepository.update(session);
}
} catch (Exception e) {
log.warn("Failed to generate title for session: {}", sessionId, e);
}
}
}
This diff is collapsed.
This diff is collapsed.
This diff is collapsed.
Markdown is supported
0% or
You are about to add 0 people to the discussion. Proceed with caution.
Finish editing this message first!
Please register or to comment