feat: enhance coding harness, model security and system features

This commit is contained in:
ageerle
2026-09-08 23:33:29 +08:00
parent d4ed2b8672
commit bcff4fd9e3
259 changed files with 15183 additions and 2713 deletions

View File

@@ -153,6 +153,12 @@
<scope>compile</scope>
</dependency>
<dependency>
<groupId>org.springframework.boot</groupId>
<artifactId>spring-boot-starter-test</artifactId>
<scope>test</scope>
</dependency>
</dependencies>
</project>

View File

@@ -60,6 +60,11 @@ public class WorkflowMessageUtil {
* @param message 消息
*/
public static void saveWorkflowMessage(WfState wfState, String message) {
// Input validation may fail before WfState is created. Do not mask the
// original error and prevent WorkflowEngine from completing the SSE.
if (wfState == null) {
return;
}
Long sessionId = wfState.getSessionId();
Long userId = wfState.getUserId();

View File

@@ -88,7 +88,7 @@ public class WorkflowUtil{
public void streamingInvokeLLM(WfState wfState, WfNodeState state, WorkflowNode node, String modelName,
String prompt, String nodeMessageTemplate) {
log.info("stream invoke, modelName: {}", modelName);
log.info("workflow_llm operation=STREAM_INVOKE status=STARTED");
// 获取用户信息和Token以及SSe连接对象(对话接口需要使用)
Long sessionId = wfState.getSessionId();
@@ -103,7 +103,7 @@ public class WorkflowUtil{
StreamingChatGenerator<AgentState> streamingGenerator = StreamingChatGenerator.builder()
.mapResult(response -> {
String responseTxt = response.aiMessage().text();
log.info("llm response:{}", responseTxt);
logCompletionMetadata(responseTxt);
// 传递所有输入数据 + 添加 LLM 输出
wfState.getNodeStateByNodeUuid(node.getUuid()).ifPresent(item -> {
List<NodeIOData> outputs = new ArrayList<>(item.getInputs());
@@ -158,7 +158,7 @@ public class WorkflowUtil{
* @return
*/
private UserMessage getMessage(String role, String value) {
log.info("Creating message with role: {}, content: {}", role, value);
logMessageMetadata(role, value);
return new UserMessage(value);
}
@@ -177,7 +177,7 @@ public class WorkflowUtil{
* 调用LLM 根据文字生成图片
*/
public String buildTextToImage(String modelName, String prompt, String size, Integer seed){
log.info("Generate image invoke, modelName: {}", modelName);
log.info("workflow_image operation=GENERATE status=STARTED");
// 根据模型名称查询模型信息
ChatModelVo chatModelVo = chatModelService.selectModelByName(modelName);
if (chatModelVo == null) {
@@ -197,4 +197,19 @@ public class WorkflowUtil{
// 调用LLM 生成图片
return imageService.generateImage(imageContext);
}
static void logCompletionMetadata(String responseText) {
log.info("workflow_llm operation=STREAM_INVOKE status=COMPLETED outputChars={}",
textLength(responseText));
}
static void logMessageMetadata(String role, String content) {
String roleType = "user".equalsIgnoreCase(role) ? "USER" : "OTHER";
log.info("workflow_message status=CREATED roleType={} contentChars={}",
roleType, textLength(content));
}
private static int textLength(String value) {
return value == null ? 0 : value.length();
}
}

View File

@@ -7,6 +7,8 @@ import ai.z.openapi.service.web_search.WebSearchResponse;
import lombok.RequiredArgsConstructor;
import org.apache.commons.lang3.StringUtils;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.chat.security.ChatModelCredentialPolicy;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.springframework.stereotype.Component;
@@ -78,21 +80,25 @@ public class ZhipuWebSearchClient {
}
private Credential resolveCredential() {
if (isUsableApiKey(properties.getApiKey())) {
return new Credential(normalizeBaseUrl(properties.getBaseUrl()), properties.getApiKey().trim());
}
ChatModelBo query = new ChatModelBo();
query.setProviderCode(ZHIPU_PROVIDER_CODE);
return chatModelService.queryList(query).stream()
return chatModelService.queryAvailableList(query).stream()
.filter(model -> isUsableApiKey(model.getApiKey()))
.findFirst()
.map(model -> new Credential(normalizeBaseUrl(model.getApiHost()), model.getApiKey().trim()))
.map(this::credentialForConfiguredEndpoint)
.orElseThrow(() -> new IllegalStateException(
"未配置智谱 API Key,请设置环境变量 ZAI_API_KEY,或在模型管理中配置 zhipu 厂商密钥"
"智谱凭据未进入受信 provider/model/endpoint 注册表,当前拒绝解析"
));
}
private Credential credentialForConfiguredEndpoint(ChatModelVo model) {
String finalBaseUrl = normalizeBaseUrl(model.getApiHost());
String apiKey = ChatModelCredentialPolicy.resolveApiKeyForUse(
ZHIPU_PROVIDER_CODE, model.getProviderCode(), model.getModelName(), finalBaseUrl,
model.getApiKey());
return new Credential(finalBaseUrl, apiKey);
}
private boolean isUsableApiKey(String apiKey) {
return StringUtils.isNotBlank(apiKey)
&& !"sk_xx".equalsIgnoreCase(apiKey.trim())

View File

@@ -236,7 +236,6 @@ public class KnowledgeRetrievalNode extends AbstractWfNode {
? config.getSimilarityThreshold() : kb.getSimilarityThreshold());
bo.setEmbeddingModelName(kb.getEmbeddingModel());
bo.setVectorModelName(kb.getVectorModel());
bo.setApiKey(embModel.getApiKey());
bo.setBaseUrl(embModel.getApiHost());
String mode = config.getRetrievalMode() != null ? config.getRetrievalMode().toLowerCase() : "vector";

View File

@@ -10,8 +10,13 @@ public interface ChartGenerationAgent {
@SystemMessage("""
You are a chart generation specialist. Your only task is to generate Apache ECharts
chart configurations. Respond with ONLY the ECharts configuration in ```echarts markdown
code block format. Do not include any explanations, descriptions, or other content.
chart configurations. On success respond with ONLY the ECharts configuration in ```echarts
markdown code block format, without surrounding explanations.
Use only the exact data supplied by the user or the preceding SqlAgent tool results.
You have no database tools. If values, units or categories are missing, or the source is
truncated/failed, explain what is missing instead of inventing a chart. Preserve row order,
numeric values and nulls; do not turn unknown values into zero. Include metric, unit, time
range and relevant filters in title/subtext. Output valid JSON without JavaScript functions.
""")
@UserMessage("""
Generate an Apache ECharts chart configuration for: {{query}}
@@ -19,6 +24,6 @@ public interface ChartGenerationAgent {
{valid JSON ECharts configuration}
```
""")
@Agent("Generate Apache ECharts chart configurations only.")
@Agent("Draw ECharts from data already supplied by the user or SqlAgent. Has no database access. Requires exact rows, units and filters; never invents query results.")
String generateChart(@V("query") String query);
}

View File

@@ -17,24 +17,26 @@ public interface EchartsAgent {
CRITICAL OUTPUT REQUIREMENTS:
- Return Echarts JSON wrapped in markdown code block
- Use this exact format: ```json\n{...}\n```
- Use this exact format: ```echarts\n{...}\n```
- The JSON inside must be valid Echarts configuration
- Frontend expects markdown format for proper parsing
Your workflow:
1. Use MCP tools to query the database and get data
2. The MCP tool returns data in this structure:
{"data": [{"dict_type": "value1", "count": 10}, {"dict_type": "value2", "count": 20}, ...]}
3. Transform this data into Echarts configuration
4. Return ONLY the Echarts JSON
1. Call queryAllTables first. If no tables are available, explain the configuration problem and stop
2. Call queryTableSchema for the required allowed tables, then executeSql with an aggregated SELECT
3. executeSql returns a Markdown table with column aliases and a row count, NOT a JSON data array
It displays at most 10 rows and 8 columns. Aggregate/filter and ORDER BY in SQL before charting
4. Transform only the returned rows into Echarts configuration. Never invent, extrapolate or silently fill missing values
5. If a tool fails, returns no rows or truncates the requested data, explain the limitation instead of drawing a misleading chart
6. On success return ONLY the Echarts JSON code block. Include metric, unit, period and relevant filters in title/subtext
Data transformation rules:
- Extract array elements into xAxis categories and series data
- For the example above: xAxis.data = ["value1", "value2"], series.data = [10, 20]
- Keep categories in query order and numeric series aligned with the corresponding rows
- Choose chart type based on request: bar (default), line, pie, etc.
Expected output format (bar chart example):
```json
```echarts
{
"title": {
"text": "Dict Type Distribution",
@@ -75,15 +77,15 @@ public interface EchartsAgent {
}
REMEMBER:
- Always wrap JSON in ```json and ``` markers
- Always wrap JSON in ```echarts and ``` markers; no functions, JavaScript, HTML or external resources
- Use proper formatting with indentation
- This is the expected format for frontend parsing
""")
@UserMessage("""
Generate an Echarts chart for: {{query}}
IMPORTANT: Return the Echarts configuration JSON wrapped in markdown code block (```json...```).
IMPORTANT: On success return valid Echarts JSON in an echarts markdown code block. On missing or failed data explain the problem.
""")
@Agent("Data visualization assistant that returns Echarts JSON configurations for frontend rendering")
@Agent("Query allowed database tables and draw ECharts in one task. Discovers tables, inspects schemas, executes SQL, then renders real results. For already supplied verified data, use ChartGenerationAgent instead.")
String search(@V("query") String query);
}

View File

@@ -14,7 +14,7 @@ import dev.langchain4j.service.V;
public interface SqlAgent {
@SystemMessage("""
This agent is designed for MySQL 5.7
Generate MySQL-compatible SELECT queries. Do not assume a table or column exists.
You are an intelligent database query assistant. Your responsibility is to:
1. Query all tables in the database to understand the database structure
2. Understand the user's natural language question
@@ -22,7 +22,7 @@ public interface SqlAgent {
4. Provide accurate and helpful answers
Available tools:
- queryAllTables: Query all tables in the database
- queryAllTables: List only configured allowed tables
- queryTableSchema: Query the table structure and CREATE SQL for a specified table
- executeSql: Execute a SELECT SQL query and return results
@@ -33,10 +33,15 @@ public interface SqlAgent {
- If queryAllTables returns NO tables or an empty list, you MUST NOT call executeSql or queryTableSchema
- When no tables are available, inform the user: "当前未配置可查询的数据库表,请联系管理员配置"
- NEVER attempt to execute any SQL query (including SELECT * FROM xxx) without first confirming available tables
- Inspect queryTableSchema for each required table before constructing SQL; use explicit columns and aliases
- executeSql returns a Markdown table, not JSON. It displays at most 10 rows and 8 columns
- Aggregate in SQL, include ORDER BY and an appropriate LIMIT; never compute a complete report from a truncated preview
- On tool errors or empty results, report the limitation and do not invent values or replace missing data with zero
- For a chart handoff, preserve the SQL, filters, units, column aliases, all displayed rows and truncation status
""")
@UserMessage("""
Answer the following question: {{query}}
""")
@Agent("Intelligent database query assistant that MUST check database tables first, then query table structures and execute SQL queries")
@Agent("Query real database data: discover allowed tables, inspect schemas, execute SELECT, and return SQL, filters, units and exact rows for analysis or a subsequent ChartGenerationAgent. Does not draw charts.")
String getData(@V("query") String query);
}

View File

@@ -44,7 +44,7 @@ public class TableSchemaManager {
@Autowired(required = false)
private DataSource agentDataSource;
@Value("${AGENT_ALLOWED_TABLES}")
@Value("${AGENT_ALLOWED_TABLES:}")
private String allowedTables;
/**
@@ -121,6 +121,8 @@ public class TableSchemaManager {
if (tableRs.next()) {
table.setTableComment(tableRs.getString("REMARKS"));
table.setTableType(tableRs.getString("TABLE_TYPE"));
} else {
return null;
}
}
@@ -231,7 +233,7 @@ public class TableSchemaManager {
if (identifier == null || identifier.isEmpty()) {
return false;
}
return identifier.matches("^[a-zA-Z0-9_\\.]+$");
return identifier.matches("^[a-zA-Z0-9_]+$");
}
/**
@@ -242,8 +244,7 @@ public class TableSchemaManager {
if (allowedTables == null || allowedTables.trim().isEmpty()) {
return false;
}
Set<String> tables = new HashSet<>(Arrays.asList(allowedTables.split(",")));
return tables.contains(tableName.trim());
return getAllowedTableNames().contains(tableName.trim());
}
/**

View File

@@ -0,0 +1,76 @@
package org.ruoyi.agent.tool;
import java.util.List;
import java.util.Locale;
import java.util.Set;
import java.util.stream.Collectors;
import net.sf.jsqlparser.JSQLParserException;
import net.sf.jsqlparser.expression.Function;
import net.sf.jsqlparser.parser.CCJSqlParserUtil;
import net.sf.jsqlparser.schema.Table;
import net.sf.jsqlparser.statement.Statement;
import net.sf.jsqlparser.statement.select.PlainSelect;
import net.sf.jsqlparser.statement.select.Select;
import net.sf.jsqlparser.util.TablesNamesFinder;
/** SQL syntax and table checks are an additional guard, not a replacement for a read-only DB account. */
final class AgentSqlValidator {
private AgentSqlValidator() {}
static void validate(String sql, List<String> allowedTables) {
// Reject comments conservatively, including MySQL executable comments that a generic parser ignores.
if (sql.contains("/*") || sql.contains("--") || sql.contains("#")) {
throw new IllegalArgumentException("Error: SQL comments are not supported");
}
Set<String> allowed = allowedTables.stream().map(String::trim)
.map(name -> name.toLowerCase(Locale.ROOT)).collect(Collectors.toSet());
try {
var statements = CCJSqlParserUtil.parseStatements(sql).getStatements();
if (statements.size() != 1) {
throw new IllegalArgumentException("Error: Only a single SELECT is supported");
}
Statement statement = statements.get(0);
if (!(statement instanceof Select select) || select.getWithItemsList() != null) {
throw new IllegalArgumentException("Error: Only a single SELECT without WITH is supported");
}
Set<String> tables = new TablesNamesFinder<Void>() {
@Override
public <S> Void visit(Table table, S context) {
String name = table.getFullyQualifiedName().replace("`", "");
// No cross-database references, even when the unqualified name is allowed.
if (!name.matches("[a-zA-Z0-9_]+")
|| !allowed.contains(name.toLowerCase(Locale.ROOT))) {
throw new IllegalArgumentException("Error: SQL references a table outside AGENT_ALLOWED_TABLES");
}
return super.visit(table, context);
}
@Override
public <S> Void visit(PlainSelect select, S context) {
if (select.getIntoTables() != null || select.getIntoTempTable() != null
|| select.getForMode() != null || select.getForClause() != null
|| select.getWithItemsList() != null) {
throw new IllegalArgumentException("Error: SELECT INTO, locking and WITH are not supported");
}
return super.visit(select, context);
}
@Override
public <S> Void visit(Function function, S context) {
String name = function.getName().toUpperCase(Locale.ROOT);
if (name.contains(".") || Set.of("LOAD_FILE", "SLEEP", "BENCHMARK", "GET_LOCK",
"RELEASE_LOCK", "RELEASE_ALL_LOCKS").contains(name)) {
throw new IllegalArgumentException("Error: SQL function is not allowed");
}
return super.visit(function, context);
}
}.getTables(statement);
if (tables.isEmpty()) {
throw new IllegalArgumentException("Error: SELECT must reference an allowed table");
}
} catch (JSQLParserException e) {
throw new IllegalArgumentException("Error: Invalid or unsupported SELECT SQL");
}
}
}

View File

@@ -8,10 +8,7 @@ import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List;
import java.util.Map;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
import java.util.stream.Collectors;
import java.util.Locale;
import javax.sql.DataSource;
@@ -34,10 +31,14 @@ import org.ruoyi.mcp.service.core.BuiltinToolProvider;
public class ExecuteSqlQueryTool implements BuiltinToolProvider {
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
private DataSource getDataSource() {
DataSource getDataSource() {
return SpringUtils.getBean(DataSource.class);
}
TableSchemaManager getTableSchemaManager() {
return SpringUtils.getBean(TableSchemaManager.class);
}
/**
* 执行 SELECT SQL 查询
* 只允许执行 SELECT 查询,防止恶意的数据修改操作
@@ -45,101 +46,87 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider {
* @param sql 要执行的 SELECT SQL 语句,例如:SELECT * FROM sys_user
* @return 包含查询结果的字符串
*/
@Tool("Execute a SELECT SQL query and return the results. Example: SELECT * FROM sys_user")
@Tool("Execute one read-only SELECT on allowed tables. Returns a Markdown table (at most 10 rows and 8 columns), not JSON. Aggregate and LIMIT in SQL; use explicit column aliases. Never infer omitted rows.")
public String executeSql(String sql) {
// 2. 手动推入数据源上下文
// DynamicDataSourceContextHolder.push("agent");
if (sql == null || sql.trim().isEmpty()) {
return "Error: SQL query cannot be empty";
}
// 只允许执行 SELECT 查询,防止恶意操作
String upperSql = sql.trim().toUpperCase();
String upperSql = sql.trim().toUpperCase(Locale.ROOT);
if (!upperSql.startsWith("SELECT")) {
return "Error: Only SELECT queries are allowed for security reasons";
}
// 校验表白名单:未配置表时直接拒绝,已配置则校验 SQL 中引用的表
TableSchemaManager schemaManager = SpringUtils.getBean(TableSchemaManager.class);
List<String> allowedTables = schemaManager.getAllowedTableNames();
if (allowedTables.isEmpty()) {
return "Error: 当前未配置可查询的数据库表,无法执行任何SQL查询。请联系管理员配置 AGENT_ALLOWED_TABLES";
}
Set<String> allowedSet = allowedTables.stream()
.map(String::toLowerCase)
.collect(Collectors.toSet());
Set<String> referencedTables = extractTableNames(upperSql);
for (String table : referencedTables) {
if (!allowedSet.contains(table.toLowerCase())) {
return "Error: 表 " + table + " 不在允许查询的表列表中。允许查询的表: " + String.join(", ", allowedTables);
}
}
boolean routed = false;
try {
// 校验表白名单:未配置表时直接拒绝,已配置则校验 SQL 中引用的表
TableSchemaManager schemaManager = getTableSchemaManager();
List<String> allowedTables = schemaManager.getAllowedTableNames();
if (allowedTables.isEmpty()) {
return "Error: 当前未配置可查询的数据库表,无法执行任何SQL查询。请联系管理员配置 AGENT_ALLOWED_TABLES";
}
try {
AgentSqlValidator.validate(sql, allowedTables);
} catch (IllegalArgumentException e) {
return e.getMessage();
}
DynamicDataSourceContextHolder.push("agent");
routed = true;
DataSource dataSource = getDataSource();
if (dataSource == null) {
return "Error: Database datasource not configured";
}
try (Connection connection = dataSource.getConnection()) {
try (PreparedStatement preparedStatement = connection.prepareStatement(sql);
ResultSet resultSet = preparedStatement.executeQuery()) {
try (PreparedStatement preparedStatement = connection.prepareStatement(sql)) {
preparedStatement.setQueryTimeout(30);
preparedStatement.setMaxRows(1001);
try (ResultSet resultSet = preparedStatement.executeQuery()) {
ResultSetMetaData metaData = resultSet.getMetaData();
ResultSetMetaData metaData = resultSet.getMetaData();
List<Map<String, Object>> results = new ArrayList<>();
int columnCount = metaData.getColumnCount();
List<Map<String, Object>> results = new ArrayList<>();
int columnCount = metaData.getColumnCount();
// 获取列名
List<String> columnNames = new ArrayList<>();
for (int i = 1; i <= columnCount; i++) {
columnNames.add(metaData.getColumnName(i));
}
// 获取数据行,限制最多1000行以防止内存溢出
int maxRows = 1000;
while (resultSet.next() && results.size() < maxRows) {
Map<String, Object> row = new LinkedHashMap<>();
// 获取列名
List<String> columnNames = new ArrayList<>();
for (int i = 1; i <= columnCount; i++) {
row.put(columnNames.get(i - 1), resultSet.getObject(i));
columnNames.add(metaData.getColumnLabel(i));
}
results.add(row);
}
return formatResults(results, columnNames);
// 获取数据行,限制最多1000行以防止内存溢出
int maxRows = 1000;
while (results.size() < maxRows && resultSet.next()) {
Map<String, Object> row = new LinkedHashMap<>();
for (int i = 1; i <= columnCount; i++) {
row.put(columnNames.get(i - 1), resultSet.getObject(i));
}
results.add(row);
}
boolean truncated = results.size() == maxRows && resultSet.next();
return formatResults(results, columnNames, truncated);
}
}
}
} catch (Exception e) {
log.error("Error executing SQL: {}", sql, e);
// 3. 必须在 finally 中清除上下文,防止污染其他请求
DynamicDataSourceContextHolder.clear();
return "Error: " + e.getMessage();
log.error("sql_tool operation=EXECUTE_SELECT status=FAILED errorType={}",
e.getClass().getName());
return "Error: Database query failed";
} finally {
// 3. 必须在 finally 中清除上下文,防止污染其他请求
DynamicDataSourceContextHolder.clear();
// Restore the caller's route instead of clearing its entire datasource stack.
if (routed) {
DynamicDataSourceContextHolder.poll();
}
}
}
/**
* 从 SQL 中提取引用的表名(FROM / JOIN 后的标识符)
* 覆盖 FROM t1, t2 / FROM t1 JOIN t2 / FROM `t1` 等常见写法
*/
private Set<String> extractTableNames(String upperSql) {
Set<String> tables = new java.util.HashSet<>();
// 匹配 FROM 或 JOIN 后面的表名(支持反引号包裹)
Pattern pattern = Pattern.compile("(?:FROM|JOIN)\\s+`?([A-Z0-9_]+)`?", Pattern.CASE_INSENSITIVE);
Matcher matcher = pattern.matcher(upperSql);
while (matcher.find()) {
tables.add(matcher.group(1));
}
return tables;
}
/**
* 格式化查询结果
* 返回清晰的表格格式,展示关键数据
*/
private String formatResults(List<Map<String, Object>> results, List<String> columnNames) {
private String formatResults(List<Map<String, Object>> results, List<String> columnNames, boolean truncated) {
if (results.isEmpty()) {
return "Query executed successfully, but no results returned";
}
@@ -179,13 +166,17 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider {
}
// 统计信息
result.append("\n").append("Total: ").append(results.size()).append(" rows");
result.append("\n").append(truncated ? "Read at least: " : "Total: ")
.append(results.size()).append(" rows");
if (displayRows < results.size()) {
result.append(" (displayed ").append(displayRows).append(" rows)");
}
if (displayCols < columnNames.size()) {
result.append("\nColumns: ").append(displayCols).append(" / ").append(columnNames.size());
}
if (truncated || displayRows < results.size() || displayCols < columnNames.size()) {
result.append("\nResult truncated. Aggregate/filter in SQL before charting; do not infer omitted data.");
}
log.info("Successfully executed SQL query, returned {} rows", results.size());
return result.toString();
@@ -195,12 +186,7 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider {
* 格式化列名,使其更易读
*/
private String formatColumnName(String columnName) {
// 将下划线替换为空格,首字母大写
String formatted = columnName.replace("_", " ");
if (formatted.length() > 15) {
return formatted.substring(0, 12) + "...";
}
return formatted;
return escapeCell(columnName);
}
/**
@@ -213,9 +199,13 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider {
String str = value.toString();
// 限制列宽以保持表格整洁,长文本截断
if (str.length() > 20) {
return str.substring(0, 17) + "...";
return escapeCell(str.substring(0, 17) + "...");
}
return str;
return escapeCell(str);
}
private String escapeCell(String text) {
return text.replace("|", "\\|").replace("\r", " ").replace("\n", " ");
}
@Override
@@ -230,6 +220,6 @@ public class ExecuteSqlQueryTool implements BuiltinToolProvider {
@Override
public String getDescription() {
return "Execute a SELECT SQL query and return the results. Example: SELECT * FROM sys_user";
return "Execute a read-only SELECT on allowed tables and return a bounded Markdown table";
}
}

View File

@@ -20,7 +20,7 @@ import org.ruoyi.mcp.service.core.BuiltinToolProvider;
public class QueryAllTablesTool implements BuiltinToolProvider {
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
private TableSchemaManager getTableSchemaManager() {
TableSchemaManager getTableSchemaManager() {
return SpringUtils.getBean(TableSchemaManager.class);
}
@@ -59,8 +59,9 @@ public class QueryAllTablesTool implements BuiltinToolProvider {
return result.toString();
} catch (Exception e) {
log.error("Error retrieving tables from cache", e);
return "Error: " + e.getMessage();
log.error("sql_tool operation=QUERY_ALL_TABLES status=FAILED errorType={}",
e.getClass().getName());
return "Error: Database table metadata query failed";
}
}

View File

@@ -6,6 +6,7 @@ import java.sql.ResultSet;
import javax.sql.DataSource;
import org.ruoyi.agent.manager.TableSchemaManager;
import org.ruoyi.common.core.utils.SpringUtils;
import org.springframework.stereotype.Component;
@@ -20,14 +21,16 @@ import org.ruoyi.mcp.service.core.BuiltinToolProvider;
public class QueryTableSchemaTool implements BuiltinToolProvider {
// 使用延迟初始化,避免在构造函数中调用 SpringUtils.getBean()
private DataSource getDataSource() {
DataSource getDataSource() {
return SpringUtils.getBean(DataSource.class);
}
TableSchemaManager getTableSchemaManager() {
return SpringUtils.getBean(TableSchemaManager.class);
}
@Tool("Query the CREATE TABLE statement (DDL) for a specific table by table name")
public String queryTableSchema(String tableName) {
// 2. 手动推入数据源上下文
DynamicDataSourceContextHolder.push("agent");
if (tableName == null || tableName.trim().isEmpty()) {
return "Error: Table name cannot be empty";
}
@@ -36,7 +39,18 @@ public class QueryTableSchemaTool implements BuiltinToolProvider {
return "Error: Invalid table name format";
}
try {
if (getTableSchemaManager().getAllowedTableNames().stream()
.noneMatch(allowed -> allowed.equalsIgnoreCase(tableName))) {
return "Error: Table is not in AGENT_ALLOWED_TABLES";
}
} catch (Exception e) {
log.error("sql_tool operation=QUERY_TABLE_SCHEMA status=FAILED errorType={}", e.getClass().getName());
return "Error: Database schema query failed";
}
String sql = "SHOW CREATE TABLE `" + tableName + "`";
// 2. 只在进入受 finally 保护的数据库区段后推入数据源上下文。
DynamicDataSourceContextHolder.push("agent");
try (Connection connection = getDataSource().getConnection();
PreparedStatement ps = connection.prepareStatement(sql);
@@ -48,13 +62,12 @@ public class QueryTableSchemaTool implements BuiltinToolProvider {
return "Table not found: " + tableName;
} catch (Exception e) {
// 3. 必须在 finally 中清除上下文,防止污染其他请求
DynamicDataSourceContextHolder.clear();
log.error("Error querying table schema: {}", tableName, e);
return "Error: " + e.getMessage();
log.error("sql_tool operation=QUERY_TABLE_SCHEMA status=FAILED errorType={}",
e.getClass().getName());
return "Error: Database schema query failed";
} finally {
// 3. 必须在 finally 中清除上下文,防止污染其他请求
DynamicDataSourceContextHolder.clear();
DynamicDataSourceContextHolder.poll();
}
}

View File

@@ -0,0 +1,46 @@
package org.ruoyi.common.process;
import java.io.IOException;
import java.util.Map;
import java.util.Objects;
/**
* Prevents provider credentials owned by the backend from crossing into ordinary child processes.
*/
public final class ChildProcessSecretSanitizer {
public static final String DEEPSEEK_API_KEY = "DEEPSEEK_API_KEY";
private static final Map<String, String> EMPTY_PROVIDER_SECRET_OVERRIDE =
Map.of(DEEPSEEK_API_KEY, "");
private ChildProcessSecretSanitizer() {
}
/**
* Removes every case variant before the process is created. Environment keys are case-insensitive
* on Windows, while removing variants is also safe on case-sensitive platforms.
*/
public static void sanitize(ProcessBuilder builder) {
Objects.requireNonNull(builder, "builder");
builder.environment().keySet().removeIf(
name -> DEEPSEEK_API_KEY.equalsIgnoreCase(name)
);
}
/**
* Keeps sanitization adjacent to {@link ProcessBuilder#start()} so a later edit cannot accidentally
* reintroduce the inherited provider credential between configuration and process creation.
*/
public static Process start(ProcessBuilder builder) throws IOException {
sanitize(builder);
return builder.start();
}
/**
* LangChain4j's stdio transport only overlays its environment map on the inherited environment.
* An explicit empty value therefore masks the backend's real credential in the MCP child.
*/
public static Map<String, String> emptyProviderSecretOverride() {
return EMPTY_PROVIDER_SECRET_OVERRIDE;
}
}

View File

@@ -6,9 +6,12 @@ import lombok.RequiredArgsConstructor;
import jakarta.servlet.http.HttpServletResponse;
import jakarta.validation.constraints.*;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import org.ruoyi.common.chat.service.chat.IChatModelService;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelBo;
import org.ruoyi.common.chat.domain.bo.chat.ChatModelSelectQuery;
import org.ruoyi.common.chat.domain.bo.chat.ModelBatchKeyBo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelSelectVo;
import org.ruoyi.common.chat.domain.vo.chat.ChatModelVo;
import org.ruoyi.common.core.utils.StringUtils;
import org.ruoyi.enums.ChatModeType;
@@ -54,12 +57,18 @@ public class ChatModelController extends BaseController {
/**
* 查询用户聊天模型列表
*/
@SaCheckPermission(value = {"system:model:list", "coding:harness:use"}, mode = SaMode.OR)
@GetMapping("/modelList")
public R<List<ChatModelVo>> modelList(ChatModelBo bo) {
if (StringUtils.isBlank(bo.getCategory())) {
bo.setCategory(ModelType.CHAT.getKey());
public R<List<ChatModelSelectVo>> modelList(ChatModelSelectQuery query) {
ChatModelBo internalQuery = new ChatModelBo();
if (query == null || StringUtils.isBlank(query.getCategory())) {
internalQuery.setCategory(ModelType.CHAT.getKey());
} else {
internalQuery.setCategory(query.getCategory());
}
return R.ok(chatModelService.queryList(bo));
return R.ok(chatModelService.queryAvailableList(internalQuery).stream()
.map(ChatModelSelectVo::from)
.toList());
}
/**
@@ -81,7 +90,8 @@ public class ChatModelController extends BaseController {
* 导出模型管理列表
*/
@SaCheckPermission("system:model:export")
@Log(title = "模型管理", businessType = BusinessType.EXPORT)
@Log(title = "模型管理", businessType = BusinessType.EXPORT,
excludeParamNames = {"apiKey"})
@PostMapping("/export")
public void export(ChatModelBo bo, HttpServletResponse response) {
List<ChatModelVo> list = chatModelService.queryList(bo);
@@ -104,7 +114,8 @@ public class ChatModelController extends BaseController {
* 新增模型管理
*/
@SaCheckPermission("system:model:add")
@Log(title = "模型管理", businessType = BusinessType.INSERT)
@Log(title = "模型管理", businessType = BusinessType.INSERT,
excludeParamNames = {"apiKey"})
@RepeatSubmit()
@PostMapping()
public R<Void> add(@Validated(AddGroup.class) @RequestBody ChatModelBo bo) {
@@ -115,7 +126,8 @@ public class ChatModelController extends BaseController {
* 修改模型管理
*/
@SaCheckPermission("system:model:edit")
@Log(title = "模型管理", businessType = BusinessType.UPDATE)
@Log(title = "模型管理", businessType = BusinessType.UPDATE,
excludeParamNames = {"apiKey"})
@RepeatSubmit()
@PutMapping()
public R<Void> edit(@Validated(EditGroup.class) @RequestBody ChatModelBo bo) {
@@ -126,7 +138,8 @@ public class ChatModelController extends BaseController {
* 按厂商批量更新密钥
*/
@SaCheckPermission("system:model:edit")
@Log(title = "模型管理", businessType = BusinessType.UPDATE)
@Log(title = "模型管理", businessType = BusinessType.UPDATE,
excludeParamNames = {"apiKey"})
@RepeatSubmit()
@PutMapping("/batchKeyByProvider")
public R<Void> batchKeyByProvider(@Validated @RequestBody ModelBatchKeyBo bo) {

View File

@@ -81,7 +81,7 @@ public class CodingController {
// 编程对话只能用聊天模型,按 category=chat 过滤
ChatModelBo bo = new ChatModelBo();
bo.setCategory(ModelType.CHAT.getKey());
List<ModelOption> models = chatModelService.queryList(bo).stream()
List<ModelOption> models = chatModelService.queryAvailableList(bo).stream()
.map(model -> new ModelOption(model.getId(), model.getModelName(), model.getProviderCode()))
.toList();
return R.ok(models);

View File

@@ -13,6 +13,7 @@ import org.ruoyi.common.satoken.utils.LoginHelper;
import org.ruoyi.service.coding.harness.app.CodingHarnessApplicationService;
import org.ruoyi.service.coding.harness.app.CreateHarnessRunCommand;
import org.ruoyi.service.coding.harness.app.CreateHarnessSessionCommand;
import org.ruoyi.service.coding.harness.app.HarnessImageInput;
import org.ruoyi.service.coding.harness.app.QueueHarnessInputCommand;
import org.ruoyi.service.coding.harness.approval.ApprovalDecision;
import org.ruoyi.service.coding.harness.event.HarnessEventHub;
@@ -26,10 +27,15 @@ import org.ruoyi.service.coding.harness.model.HarnessOwner;
import org.ruoyi.service.coding.harness.model.HarnessPermissionMode;
import org.ruoyi.service.coding.harness.model.HarnessRunState;
import org.ruoyi.service.coding.harness.model.HarnessSessionState;
import org.ruoyi.service.coding.harness.model.HarnessThinkingLevel;
import org.ruoyi.service.coding.harness.model.HarnessVerificationMode;
import org.ruoyi.service.coding.harness.model.WorkspaceManifest;
import org.ruoyi.service.coding.harness.modelruntime.HarnessModelRegistry;
import org.springframework.http.MediaType;
import org.springframework.validation.annotation.Validated;
import org.springframework.web.bind.annotation.GetMapping;
import org.springframework.web.bind.annotation.DeleteMapping;
import org.springframework.web.bind.annotation.PutMapping;
import org.springframework.web.bind.annotation.PathVariable;
import org.springframework.web.bind.annotation.PostMapping;
import org.springframework.web.bind.annotation.RequestBody;
@@ -72,10 +78,14 @@ public class CodingHarnessController {
// Permission mode is a requested ceiling, never a client-granted authority.
StpUtil.checkPermission("coding:harness:write");
}
modelRegistry.requireConfigured(request.model());
modelRegistry.requireSessionModelReady(request.model());
HarnessThinkingLevel thinkingLevel = HarnessThinkingLevel.fromWire(request.thinkingLevel());
HarnessVerificationMode verificationMode = request.verificationMode() == null
? HarnessVerificationMode.DEFAULT : request.verificationMode();
return R.ok(applicationService.createSession(owner(), new CreateHarnessSessionCommand(
request.workspacePath(), request.model(), request.permissionMode(),
request.approvalPolicy(), request.title(), request.idempotencyKey())));
request.approvalPolicy(), request.title(), request.idempotencyKey(),
request.workspaceManifest(), thinkingLevel, verificationMode)));
}
@GetMapping("/sessions")
@@ -88,6 +98,25 @@ public class CodingHarnessController {
return R.ok(applicationService.getSession(owner(), sessionId));
}
@PutMapping("/sessions/{sessionId}/pin")
public R<HarnessSessionState> setSessionPinned(@PathVariable String sessionId,
@Valid @RequestBody PinSessionRequest request) {
return R.ok(applicationService.setSessionPinned(owner(), sessionId, request.pinned()));
}
@DeleteMapping("/sessions/{sessionId}")
public R<Void> deleteSession(@PathVariable String sessionId) {
applicationService.deleteSession(owner(), sessionId);
return R.ok();
}
@PostMapping("/sessions/{sessionId}/restore")
public R<HarnessSessionState> restoreSession(@PathVariable String sessionId) {
return R.ok(applicationService.restoreSession(owner(), sessionId));
}
public record PinSessionRequest(@NotNull Boolean pinned) { }
@GetMapping("/sessions/{sessionId}/messages")
public R<List<HarnessMessage>> messages(
@PathVariable String sessionId,
@@ -102,7 +131,7 @@ public class CodingHarnessController {
requireCurrentPermissionCeiling(sessionId);
return R.ok(applicationService.createRun(owner(), sessionId,
new CreateHarnessRunCommand(request.requirement(), request.budget(),
request.idempotencyKey())));
request.idempotencyKey(), request.images())));
}
@GetMapping("/sessions/{sessionId}/runs")
@@ -270,13 +299,28 @@ public class CodingHarnessController {
@NotNull HarnessPermissionMode permissionMode,
HarnessApprovalPolicy approvalPolicy,
@Size(max = 200) String title,
@NotBlank @Size(max = 256) String idempotencyKey
) { }
@NotBlank @Size(max = 256) String idempotencyKey,
@Valid WorkspaceManifest workspaceManifest,
/** Doubao 思考等级(none/minimal/low/medium/high/xhigh/max);非 Doubao 模型忽略。 */
@Size(max = 16) String thinkingLevel,
/** 验证归属:AGENT(默认)或 EXTERNAL(独立外部验收)。 */
HarnessVerificationMode verificationMode
) {
/** Backward-compatible constructor for direct callers predating workspace manifests. */
public CreateSessionRequest(String workspacePath, String model,
HarnessPermissionMode permissionMode,
HarnessApprovalPolicy approvalPolicy, String title,
String idempotencyKey) {
this(workspacePath, model, permissionMode, approvalPolicy, title, idempotencyKey,
null, null, null);
}
}
public record CreateRunRequest(
@NotBlank @Size(max = 200_000) String requirement,
@Valid HarnessBudget budget,
@NotBlank @Size(max = 256) String idempotencyKey
@NotBlank @Size(max = 256) String idempotencyKey,
@Valid @Size(max = 5) List<HarnessImageInput> images
) { }
public record QueueInputRequest(

View File

@@ -50,17 +50,18 @@ public class McpMarketController extends BaseController {
*/
@SaCheckPermission("mcp:market:list")
@GetMapping("/all")
public McpMarketListResult listAll(
public R<McpMarketListResult> listAll(
@RequestParam(required = false) String keyword,
@RequestParam(required = false) String status) {
return mcpMarketService.listMarkets(keyword, status);
return R.ok(mcpMarketService.listMarkets(keyword, status));
}
/**
* 导出 MCP 市场列表
*/
@SaCheckPermission("mcp:market:export")
@Log(title = "MCP市场管理", businessType = BusinessType.EXPORT)
@Log(title = "MCP市场管理", businessType = BusinessType.EXPORT,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@PostMapping("/export")
public void export(McpMarketBo bo, HttpServletResponse response) {
List<McpMarketVo> list = mcpMarketService.queryList(bo);
@@ -82,7 +83,8 @@ public class McpMarketController extends BaseController {
* 新增市场
*/
@SaCheckPermission("mcp:market:add")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@RepeatSubmit()
@PostMapping
public R<Void> add(@Validated @RequestBody McpMarketBo bo) {
@@ -94,7 +96,8 @@ public class McpMarketController extends BaseController {
* 修改市场
*/
@SaCheckPermission("mcp:market:edit")
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@RepeatSubmit()
@PutMapping
public R<Void> edit(@Validated @RequestBody McpMarketBo bo) {
@@ -131,17 +134,17 @@ public class McpMarketController extends BaseController {
*/
@SaCheckPermission("mcp:market:query")
@GetMapping("/{marketId}/tools")
public McpMarketToolListResult getMarketTools(
public R<McpMarketToolListResult> getMarketTools(
@PathVariable Long marketId,
@RequestParam(defaultValue = "1") int page,
@RequestParam(defaultValue = "10") int size) {
return mcpMarketService.getMarketTools(marketId, page, size);
return R.ok(mcpMarketService.getMarketTools(marketId, page, size));
}
/**
* 刷新市场工具列表
*/
@SaCheckPermission("mcp:market:edit")
@SaCheckPermission("mcp:market:refresh")
@Log(title = "MCP市场管理", businessType = BusinessType.UPDATE)
@PostMapping("/{marketId}/refresh")
public R<McpMarketRefreshResult> refreshMarketTools(@PathVariable Long marketId) {
@@ -151,7 +154,7 @@ public class McpMarketController extends BaseController {
/**
* 加载单个工具到本地
*/
@SaCheckPermission("mcp:market:add")
@SaCheckPermission("mcp:market:load")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@PostMapping("/tools/{toolId}/load")
public R<Void> loadToolToLocal(@PathVariable Long toolId) {
@@ -162,7 +165,7 @@ public class McpMarketController extends BaseController {
/**
* 批量加载工具到本地
*/
@SaCheckPermission("mcp:market:add")
@SaCheckPermission("mcp:market:load")
@Log(title = "MCP市场管理", businessType = BusinessType.INSERT)
@PostMapping("/tools/batch-load")
public R<Map<String, Object>> batchLoadTools(@RequestBody List<Long> toolIds) {

View File

@@ -1,6 +1,7 @@
package org.ruoyi.controller.mcp;
import cn.dev33.satoken.annotation.SaCheckPermission;
import cn.dev33.satoken.annotation.SaMode;
import jakarta.servlet.http.HttpServletResponse;
import lombok.RequiredArgsConstructor;
import org.ruoyi.common.core.domain.R;
@@ -55,11 +56,23 @@ public class McpToolController extends BaseController {
return R.ok(mcpToolService.listTools(keyword, type, status));
}
/**
* 查询智能体表单可选的已启用 MCP 工具。
*/
@SaCheckPermission(value = {
"agent:agent:list", "agent:agent:add", "agent:agent:edit"
}, mode = SaMode.OR)
@GetMapping("/options")
public R<McpToolListResult> options() {
return R.ok(mcpToolService.listEnabledOptions());
}
/**
* 导出 MCP 工具列表
*/
@SaCheckPermission("mcp:tool:export")
@Log(title = "MCP工具管理", businessType = BusinessType.EXPORT)
@Log(title = "MCP工具管理", businessType = BusinessType.EXPORT,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@PostMapping("/export")
public void export(McpToolBo bo, HttpServletResponse response) {
List<McpToolVo> list = mcpToolService.queryList(bo);
@@ -81,7 +94,8 @@ public class McpToolController extends BaseController {
* 新增 MCP 工具
*/
@SaCheckPermission("mcp:tool:add")
@Log(title = "MCP工具管理", businessType = BusinessType.INSERT)
@Log(title = "MCP工具管理", businessType = BusinessType.INSERT,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@RepeatSubmit()
@PostMapping
public R<Void> add(@Validated @RequestBody McpToolBo bo) {
@@ -93,7 +107,8 @@ public class McpToolController extends BaseController {
* 修改 MCP 工具
*/
@SaCheckPermission("mcp:tool:edit")
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE)
@Log(title = "MCP工具管理", businessType = BusinessType.UPDATE,
excludeParamNames = {"authConfig", "configJson", "toolMetadata"})
@RepeatSubmit()
@PutMapping
public R<Void> edit(@Validated @RequestBody McpToolBo bo) {
@@ -128,7 +143,7 @@ public class McpToolController extends BaseController {
/**
* 测试工具连接
*/
@SaCheckPermission("mcp:tool:query")
@SaCheckPermission("mcp:tool:test")
@PostMapping("/{id}/test")
public R<McpToolTestResult> testTool(@PathVariable Long id) {
return R.ok(mcpToolService.testTool(id));

View File

@@ -41,11 +41,6 @@ public class QueryVectorBo {
*/
private String embeddingModelName;
/**
* 请求key
*/
private String apiKey;
/**
* 请求地址
*/

View File

@@ -47,11 +47,6 @@ public class StoreEmbeddingBo {
*/
private String embeddingModelName;
/**
* 请求key
*/
private String apiKey;
/**
* 请求地址
*/

View File

@@ -0,0 +1,23 @@
package org.ruoyi.domain.dto.mcp;
import org.ruoyi.domain.entity.mcp.McpMarket;
import java.util.Date;
/** Public MCP market projection. Authentication configuration is deliberately write-only. */
public record McpMarketListItem(
Long id,
String name,
String url,
String description,
String status,
Date createTime,
Date updateTime
) {
public static McpMarketListItem from(McpMarket market) {
return new McpMarketListItem(market.getId(), market.getName(), market.getUrl(),
market.getDescription(), market.getStatus(), market.getCreateTime(),
market.getUpdateTime());
}
}

View File

@@ -27,7 +27,7 @@ public class McpMarketListResult {
/**
* 市场列表
*/
private List<McpMarket> data;
private List<McpMarketListItem> data;
/**
* 总数
@@ -35,10 +35,13 @@ public class McpMarketListResult {
private int total;
public static McpMarketListResult of(List<McpMarket> data) {
List<McpMarketListItem> publicData = data == null
? List.of()
: data.stream().map(McpMarketListItem::from).toList();
return McpMarketListResult.builder()
.success(true)
.data(data)
.total(data != null ? data.size() : 0)
.data(publicData)
.total(publicData.size())
.build();
}
}

View File

@@ -0,0 +1,25 @@
package org.ruoyi.domain.dto.mcp;
import org.ruoyi.domain.entity.mcp.McpMarketTool;
import java.util.Date;
/** Public market-tool projection. Provider metadata is deliberately excluded. */
public record McpMarketToolListItem(
Long id,
Long marketId,
String toolName,
String toolDescription,
String toolVersion,
Boolean isLoaded,
Long localToolId,
Date createTime,
Date updateTime
) {
public static McpMarketToolListItem from(McpMarketTool tool) {
return new McpMarketToolListItem(tool.getId(), tool.getMarketId(), tool.getToolName(),
tool.getToolDescription(), tool.getToolVersion(), tool.getIsLoaded(),
tool.getLocalToolId(), tool.getCreateTime(), tool.getUpdateTime());
}
}

View File

@@ -27,7 +27,7 @@ public class McpMarketToolListResult {
/**
* 工具列表
*/
private List<McpMarketTool> data;
private List<McpMarketToolListItem> data;
/**
* 总数
@@ -51,9 +51,12 @@ public class McpMarketToolListResult {
public static McpMarketToolListResult of(List<McpMarketTool> data, long total, int page, int size) {
long pages = (total + size - 1) / size;
List<McpMarketToolListItem> publicData = data == null
? List.of()
: data.stream().map(McpMarketToolListItem::from).toList();
return McpMarketToolListResult.builder()
.success(true)
.data(data)
.data(publicData)
.total(total)
.page(page)
.size(size)

View File

@@ -0,0 +1,22 @@
package org.ruoyi.domain.dto.mcp;
import org.ruoyi.domain.entity.mcp.McpTool;
import java.util.Date;
/** Public MCP tool projection. Connection configuration is deliberately write-only. */
public record McpToolListItem(
Long id,
String name,
String description,
String type,
String status,
Date createTime,
Date updateTime
) {
public static McpToolListItem from(McpTool tool) {
return new McpToolListItem(tool.getId(), tool.getName(), tool.getDescription(),
tool.getType(), tool.getStatus(), tool.getCreateTime(), tool.getUpdateTime());
}
}

View File

@@ -27,7 +27,7 @@ public class McpToolListResult {
/**
* 工具列表
*/
private List<McpTool> data;
private List<McpToolListItem> data;
/**
* 总数
@@ -35,10 +35,13 @@ public class McpToolListResult {
private int total;
public static McpToolListResult of(List<McpTool> data) {
List<McpToolListItem> publicData = data == null
? List.of()
: data.stream().map(McpToolListItem::from).toList();
return McpToolListResult.builder()
.success(true)
.data(data)
.total(data != null ? data.size() : 0)
.data(publicData)
.total(publicData.size())
.build();
}
}

View File

@@ -5,7 +5,7 @@ import com.baomidou.mybatisplus.annotation.TableId;
import com.baomidou.mybatisplus.annotation.TableName;
import lombok.Data;
import lombok.EqualsAndHashCode;
import org.ruoyi.common.mybatis.core.domain.BaseEntity;
import org.ruoyi.common.tenant.core.TenantEntity;
/**
* MCP 市场工具关联实体
@@ -15,7 +15,7 @@ import org.ruoyi.common.mybatis.core.domain.BaseEntity;
@Data
@EqualsAndHashCode(callSuper = true)
@TableName("mcp_market_tool")
public class McpMarketTool extends BaseEntity {
public class McpMarketTool extends TenantEntity {
/**
* ID

View File

@@ -2,6 +2,7 @@ package org.ruoyi.domain.vo.mcp;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.mcp.McpMarket;
@@ -48,9 +49,9 @@ public class McpMarketVo implements Serializable {
private String description;
/**
* 认证配置
* 认证配置(只写)
*/
@ExcelProperty(value = "认证配置")
@JsonIgnore
private String authConfig;
/**

View File

@@ -2,6 +2,7 @@ package org.ruoyi.domain.vo.mcp;
import cn.idev.excel.annotation.ExcelIgnoreUnannotated;
import cn.idev.excel.annotation.ExcelProperty;
import com.fasterxml.jackson.annotation.JsonIgnore;
import io.github.linpeilie.annotations.AutoMapper;
import lombok.Data;
import org.ruoyi.domain.entity.mcp.McpTool;
@@ -52,7 +53,7 @@ public class McpToolVo implements Serializable {
/**
* 配置信息
*/
@ExcelProperty(value = "配置信息")
@JsonIgnore
private String configJson;
/**

View File

@@ -13,10 +13,12 @@ public enum ChatModeType {
OLLAMA("ollama", "ollama本地部署模型"),
ZHI_PU("zhipu", "智谱清言"),
DEEP_SEEK("deepseek", "深度求索"),
PPIO("ppio", "PPIO 派欧云"),
QIAN_WEN("qianwen", "通义千问"),
OPEN_AI("openai", "openai"),
ATLAS("atlas", "Atlas Cloud"),
CUSTOM_API("custom_api", "自定义API"),
CUSTOM_API("custom_api", "自定义 OpenAI"),
CUSTOM_ANTHROPIC("custom_anthropic", "自定义 Anthropic"),
MINIMAX("minimax", "MiniMax"),
DIFY("dify", "Dify"),
COZE("coze", "Coze"),

View File

@@ -39,7 +39,9 @@ public class EmbeddingModelFactory {
private final EmbeddingModelListenerProvider embeddingModelListenerProvider;
// 模型缓存,使用ConcurrentHashMap保证线程安全
private final Map<String, BaseEmbedModelService> modelCache = new ConcurrentHashMap<>();
private final Map<ModelCacheKey, BaseEmbedModelService> modelCache = new ConcurrentHashMap<>();
private record ModelCacheKey(Long id, String modelName, String providerCode) { }
/**
* 创建嵌入模型实例
@@ -48,14 +50,15 @@ public class EmbeddingModelFactory {
* @param embeddingModelName 嵌入模型名称
*/
public BaseEmbedModelService createModel(String embeddingModelName) {
return modelCache.computeIfAbsent(embeddingModelName, name -> {
ChatModelVo modelConfig = chatModelService.selectModelByName(embeddingModelName);
if (modelConfig == null) {
throw new IllegalArgumentException("未找到模型配置,name=" + name);
}
return createModelInstance(modelConfig.getProviderCode(), modelConfig);
});
// 即使命中缓存,也先读取当前配置并校验厂商状态。
ChatModelVo modelConfig = chatModelService.selectModelByName(embeddingModelName);
if (modelConfig == null) {
throw new IllegalArgumentException("未找到模型配置,name=" + embeddingModelName);
}
ModelCacheKey key = new ModelCacheKey(modelConfig.getId(),
modelConfig.getModelName(), modelConfig.getProviderCode());
return modelCache.computeIfAbsent(key,
ignored -> createModelInstance(modelConfig.getProviderCode(), modelConfig));
}
/**
@@ -92,7 +95,7 @@ public class EmbeddingModelFactory {
public void refreshModel(Long embeddingModelId) {
ChatModelVo modelConfig = chatModelService.queryById(embeddingModelId);
if (modelConfig != null) {
modelCache.remove(modelConfig.getModelName());
modelCache.keySet().removeIf(key -> embeddingModelId.equals(key.id()));
}
}
@@ -100,7 +103,7 @@ public class EmbeddingModelFactory {
* 按模型名称刷新缓存
*/
public void refreshModelByName(String embeddingModelName) {
modelCache.remove(embeddingModelName);
modelCache.keySet().removeIf(key -> embeddingModelName.equals(key.modelName()));
}
/**

View File

@@ -34,7 +34,9 @@ public class RerankModelFactory {
/**
* 模型缓存,使用ConcurrentHashMap保证线程安全
*/
private final Map<String, RerankModelService> modelCache = new ConcurrentHashMap<>();
private final Map<ModelCacheKey, RerankModelService> modelCache = new ConcurrentHashMap<>();
private record ModelCacheKey(Long id, String modelName, String providerCode) { }
/**
* 创建重排序模型实例
@@ -43,14 +45,15 @@ public class RerankModelFactory {
* @param rerankModelName 重排序模型名称
*/
public RerankModelService createModel(String rerankModelName) {
return modelCache.computeIfAbsent(rerankModelName, name -> {
ChatModelVo modelConfig = chatModelService.selectModelByName(rerankModelName);
if (modelConfig == null) {
throw new IllegalArgumentException("未找到重排序模型配置,name=" + name);
}
return createModelInstance(modelConfig.getProviderCode(), modelConfig);
});
// 即使命中缓存,也先读取当前配置并校验厂商状态。
ChatModelVo modelConfig = chatModelService.selectModelByName(rerankModelName);
if (modelConfig == null) {
throw new IllegalArgumentException("未找到重排序模型配置,name=" + rerankModelName);
}
ModelCacheKey key = new ModelCacheKey(modelConfig.getId(),
modelConfig.getModelName(), modelConfig.getProviderCode());
return modelCache.computeIfAbsent(key,
ignored -> createModelInstance(modelConfig.getProviderCode(), modelConfig));
}
/**
@@ -62,7 +65,7 @@ public class RerankModelFactory {
public void refreshModel(Long modelId) {
ChatModelVo modelConfig = chatModelService.queryById(modelId);
if (modelConfig != null) {
modelCache.remove(modelConfig.getModelName());
modelCache.keySet().removeIf(key -> modelId.equals(key.id()));
}
}
@@ -70,7 +73,7 @@ public class RerankModelFactory {
* 按模型名称刷新缓存
*/
public void refreshModelByName(String modelName) {
modelCache.remove(modelName);
modelCache.keySet().removeIf(key -> modelName.equals(key.modelName()));
}
/**

View File

@@ -1,6 +1,10 @@
package org.ruoyi.mapper.mcp;
import com.baomidou.mybatisplus.annotation.InterceptorIgnore;
import com.baomidou.mybatisplus.extension.plugins.pagination.Page;
import org.apache.ibatis.annotations.Mapper;
import org.apache.ibatis.annotations.Param;
import org.apache.ibatis.annotations.Select;
import org.ruoyi.common.mybatis.core.mapper.BaseMapperPlus;
import org.ruoyi.domain.entity.mcp.McpMarketTool;
@@ -11,4 +15,46 @@ import org.ruoyi.domain.entity.mcp.McpMarketTool;
*/
@Mapper
public interface McpMarketToolMapper extends BaseMapperPlus<McpMarketTool, Void> {
/**
* Atomically resolves a market tool through an owning market in the authenticated tenant.
* Tenant interception is intentionally bypassed because both tenant predicates are explicit;
* this remains fail-closed even when the global tenant plugin is disabled or reconfigured.
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT t.id, t.market_id, t.tool_name, t.tool_description, t.tool_version,
t.tool_metadata, t.is_loaded, t.local_tool_id, t.tenant_id,
t.create_dept, t.create_by, t.create_time, t.update_by, t.update_time
FROM mcp_market_tool t
INNER JOIN mcp_market_info m ON m.id = t.market_id
WHERE t.id = #{toolId}
AND t.tenant_id = #{tenantId}
AND m.tenant_id = #{tenantId}
AND m.del_flag = '0'
LIMIT 1
""")
McpMarketTool selectOwnedById(@Param("toolId") Long toolId,
@Param("tenantId") String tenantId);
/**
* Lists public market-tool fields only when both the child row and its owning market
* belong to the authenticated tenant. The predicates are intentionally independent
* from the tenant interceptor so disabling that plugin cannot broaden this query.
*/
@InterceptorIgnore(tenantLine = "true")
@Select("""
SELECT t.id, t.market_id, t.tool_name, t.tool_description, t.tool_version,
t.is_loaded, t.local_tool_id, t.create_time, t.update_time
FROM mcp_market_tool t
INNER JOIN mcp_market_info m ON m.id = t.market_id
WHERE t.market_id = #{marketId}
AND t.tenant_id = #{tenantId}
AND m.tenant_id = #{tenantId}
AND m.del_flag = '0'
ORDER BY t.create_time DESC
""")
Page<McpMarketTool> selectOwnedPage(@Param("page") Page<McpMarketTool> page,
@Param("marketId") Long marketId,
@Param("tenantId") String tenantId);
}

View File

@@ -143,7 +143,8 @@ public class BuiltinToolRegistry {
toolInstances.add(instance);
log.debug("创建工具实例: {}", entry.getKey());
} catch (Exception e) {
log.error("创建工具实例失败: {} - {}", entry.getKey(), e.getMessage());
log.error("mcp_builtin operation=CREATE_INSTANCE status=FAILED errorType={}",
e.getClass().getName());
}
}
@@ -167,7 +168,8 @@ public class BuiltinToolRegistry {
// 使用无参构造函数创建新实例,保留 @Tool 注解
return toolClass.getDeclaredConstructor().newInstance();
} catch (Exception e) {
log.error("创建工具实例失败: {} - {}", toolName, e.getMessage());
log.error("mcp_builtin operation=CREATE_INSTANCE status=FAILED errorType={}",
e.getClass().getName());
return null;
}
}

View File

@@ -6,6 +6,7 @@ import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.mcp.McpToolProvider;
import dev.langchain4j.mcp.client.DefaultMcpClient;
import dev.langchain4j.mcp.client.McpClient;
import dev.langchain4j.mcp.client.logging.McpLogMessageHandler;
import dev.langchain4j.mcp.client.transport.McpTransport;
import dev.langchain4j.mcp.client.transport.http.StreamableHttpMcpTransport;
import dev.langchain4j.mcp.client.transport.stdio.StdioMcpTransport;
@@ -19,6 +20,7 @@ import lombok.extern.slf4j.Slf4j;
import org.ruoyi.domain.entity.mcp.McpTool;
import org.ruoyi.enums.McpToolStatus;
import org.ruoyi.mapper.mcp.McpToolMapper;
import org.ruoyi.common.process.ChildProcessSecretSanitizer;
import org.springframework.stereotype.Service;
import java.util.ArrayList;
@@ -39,6 +41,11 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor
public class LangChain4jMcpToolProviderService {
static final boolean TRAFFIC_LOGGING_ENABLED = false;
static final McpLogMessageHandler SAFE_NO_OP_LOG_HANDLER = ignored -> {
// MCP server log notifications may contain tool arguments, results or credentials.
};
/**
* 最大失败次数,超过此次数将暂时禁用工具
*/
@@ -171,7 +178,7 @@ public class LangChain4jMcpToolProviderService {
clients.add(client);
}
} catch (Exception e) {
log.error("Failed to create MCP client for tool {}: {}", tool.getId(), e.getMessage());
log.error("mcp_client operation=CREATE status=FAILED errorType={}", errorType(e));
}
}
@@ -194,7 +201,7 @@ public class LangChain4jMcpToolProviderService {
builtinTools.addAll(discoveredTools);
log.debug("Added builtin tool: {}", tool.getName());
} catch (RuntimeException e) {
log.error("Failed to register builtin tool '{}': {}", tool.getName(), e.getMessage());
log.error("mcp_builtin operation=REGISTER status=FAILED errorType={}", errorType(e));
}
}
@@ -283,7 +290,7 @@ public class LangChain4jMcpToolProviderService {
log.info("Successfully created LangChain4j MCP client for tool: {}", tool.getName());
return client;
} catch (Exception e) {
log.error("Failed to create MCP client for tool {}: {}", tool.getName(), e.getMessage());
log.error("mcp_client operation=CREATE status=FAILED errorType={}", errorType(e));
// 记录失败并可能禁用工具
handleToolFailure(id);
return null;
@@ -373,7 +380,7 @@ public class LangChain4jMcpToolProviderService {
}
return false;
} catch (Exception e) {
log.error("Health check failed for tool {}: {}", tool.getName(), e.getMessage());
log.error("mcp_client operation=HEALTH_CHECK status=FAILED errorType={}", errorType(e));
handleToolFailure(toolId);
return false;
}
@@ -461,17 +468,19 @@ public class LangChain4jMcpToolProviderService {
fullCommand.add(command);
fullCommand.addAll(args);
log.info("Creating STDIO MCP client for tool: {}, command: {}", tool.getName(), fullCommand);
log.info("mcp_client transport=STDIO status=CREATING argumentCount={}", fullCommand.size());
// 创建传输层
McpTransport transport = StdioMcpTransport.builder()
.command(fullCommand)
.logEvents(true)
.environment(ChildProcessSecretSanitizer.emptyProviderSecretOverride())
.logEvents(TRAFFIC_LOGGING_ENABLED)
.build();
// 创建客户端
return new DefaultMcpClient.Builder()
.transport(transport)
.logHandler(SAFE_NO_OP_LOG_HANDLER)
.build();
}
@@ -482,7 +491,7 @@ public class LangChain4jMcpToolProviderService {
try {
ProcessBuilder pb = new ProcessBuilder(command, "--version");
pb.redirectErrorStream(true);
Process process = pb.start();
Process process = ChildProcessSecretSanitizer.start(pb);
boolean finished = process.waitFor(5, java.util.concurrent.TimeUnit.SECONDS);
if (!finished) {
process.destroyForcibly();
@@ -493,7 +502,7 @@ public class LangChain4jMcpToolProviderService {
// 如果进程能启动并退出(无论退出码是什么),我们认为命令可用
return true;
} catch (Exception e) {
log.debug("Command '{}' is not available: {}", command, e.getMessage());
log.debug("mcp_command_check status=UNAVAILABLE errorType={}", errorType(e));
return false;
}
}
@@ -514,17 +523,18 @@ public class LangChain4jMcpToolProviderService {
}
String baseUrl = configNode.get("baseUrl").asText();
log.info("Creating HTTP/SSE MCP client for tool: {}, baseUrl: {}", tool.getName(), baseUrl);
log.info("mcp_client transport=HTTP status=CREATING");
// 创建 HTTP/SSE 传输层
McpTransport transport = StreamableHttpMcpTransport.builder()
.url(baseUrl)
.logRequests(true)
.logRequests(TRAFFIC_LOGGING_ENABLED)
.build();
// 创建客户端
return new DefaultMcpClient.Builder()
.transport(transport)
.logHandler(SAFE_NO_OP_LOG_HANDLER)
.build();
}
@@ -545,7 +555,7 @@ public class LangChain4jMcpToolProviderService {
lowerCommand.equals("yarn") || lowerCommand.equals("uvx") ||
lowerCommand.equals("uv")) {
String resolvedCommand = command + ".cmd";
log.debug("Windows detected, resolved command: {} -> {}", command, resolvedCommand);
log.debug("mcp_command_resolution platform=WINDOWS status=RESOLVED");
return resolvedCommand;
}
}
@@ -571,7 +581,7 @@ public class LangChain4jMcpToolProviderService {
// LangChain4j McpClient 没有 close 方法,直接移除即可
log.info("Removed MCP client for tool: {}", toolId);
} catch (Exception e) {
log.warn("Error closing MCP client for tool {}: {}", toolId, e.getMessage());
log.warn("mcp_client operation=CLOSE status=FAILED errorType={}", errorType(e));
}
}
}
@@ -591,4 +601,8 @@ public class LangChain4jMcpToolProviderService {
public int getActiveClientCount() {
return activeClients.size();
}
private static String errorType(Throwable error) {
return error == null ? "unknown" : error.getClass().getName();
}
}

View File

@@ -1,6 +1,7 @@
package org.ruoyi.mcp.tools;
import dev.langchain4j.agent.tool.Tool;
import org.ruoyi.common.process.ChildProcessSecretSanitizer;
import org.ruoyi.mcp.service.core.BuiltinToolProvider;
import org.ruoyi.service.coding.CodingEventChannel;
import org.ruoyi.service.coding.CodingSseEvent;
@@ -125,7 +126,7 @@ public class ExecuteCommandTool implements BuiltinToolProvider {
builder.directory(rootDirectory.toFile());
builder.redirectErrorStream(true);
builder.redirectOutput(logFile.toFile());
process = builder.start();
process = ChildProcessSecretSanitizer.start(builder);
String result;
if (!process.waitFor(TIMEOUT_SECONDS, TimeUnit.SECONDS)) {

View File

@@ -5,12 +5,9 @@ import dev.langchain4j.agentic.observability.AgentRequest;
import dev.langchain4j.agentic.observability.AgentResponse;
import dev.langchain4j.agentic.planner.AgentInstance;
import dev.langchain4j.agentic.scope.AgenticScope;
import dev.langchain4j.service.tool.BeforeToolExecution;
import dev.langchain4j.service.tool.ToolExecution;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.atomic.AtomicReference;
/**
* 自定义的 AgentListener 的监听器。
@@ -27,13 +24,6 @@ import java.util.concurrent.atomic.AtomicReference;
@Slf4j
public class MyAgentListener implements dev.langchain4j.agentic.observability.AgentListener {
/** 最终捕获到的思考结果(主 Agent 完成后写入,供外部获取) */
private final AtomicReference<String> sharedOutputRef = new AtomicReference<>();
public String getCapturedResult() {
return sharedOutputRef.get();
}
// ==================== Agent 调用生命周期 ====================
@Override
@@ -42,37 +32,13 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag
AgenticScope scope = agentRequest.agenticScope();
Map<String, Object> inputs = agentRequest.inputs();
log.info("【Agent调用前】Agent名称: {}", agent.name());
log.info("【Agent调用前】Agent ID: {}", agent.agentId());
log.info("【Agent调用前】Agent类型: {}", agent.type().getName());
log.info("【Agent调用前】Agent描述: {}", agent.description());
log.info("【Agent调用前】Planner类型: {}", agent.plannerType());
log.info("【Agent调用前】输出类型: {}", agent.outputType());
log.info("【Agent调用前】输出Key: {}", agent.outputKey());
log.info("【Agent调用前】是否为异步: {}", agent.async());
log.info("【Agent调用前】是否为叶子节点: {}", agent.leaf());
log.info("【Agent调用前】Agent参数列表:");
for (var arg : agent.arguments()) {
log.info(" - 参数名: {}, 类型: {}, 默认值: {}",
arg.name(), arg.rawType().getName(), arg.defaultValue());
}
log.info("【Agent调用前】Agent输入参数: {}", inputs);
log.info("【Agent调用前】AgenticScope memoryId: {}", scope.memoryId());
log.info("【Agent调用前】AgenticScope当前状态: {}", scope.state());
log.info("【Agent调用前】Agent调用历史记录数: {}", scope.agentInvocations().size());
// 打印嵌套的子Agent信息
if (!agent.subagents().isEmpty()) {
log.info("【Agent调用前】子Agent列表:");
for (AgentInstance sub : agent.subagents()) {
log.info(" - 子Agent: {} ({})", sub.name(), sub.type().getName());
}
}
// 打印父Agent信息
if (agent.parent() != null) {
log.info("【Agent调用前】父Agent: {}", agent.parent().name());
}
log.info("agent_invocation agentType={} status=STARTED inputCount={} argumentCount={} "
+ "subagentCount={} priorInvocationCount={} async={} leaf={}",
agent.type().getName(), inputs == null ? 0 : inputs.size(),
agent.arguments() == null ? 0 : agent.arguments().size(),
agent.subagents() == null ? 0 : agent.subagents().size(),
scope.agentInvocations() == null ? 0 : scope.agentInvocations().size(),
agent.async(), agent.leaf());
}
@Override
@@ -80,19 +46,10 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag
AgentInstance agent = agentResponse.agent();
Map<String, Object> inputs = agentResponse.inputs();
Object output = agentResponse.output();
String outputStr = output != null ? output.toString() : "";
log.info("【Agent调用后】Agent名称: {}", agent.name());
log.info("【Agent调用后】Agent ID: {}", agent.agentId());
log.info("【Agent调用后】Agent输入参数: {}", inputs);
log.info("【Agent调用后】Agent输出结果: {}", output);
log.info("【Agent调用后】是否为叶子节点: {}", agent.leaf());
// 捕获主 Agent 的最终输出,供外部获取
if ("invoke".equals(agent.agentId()) && !outputStr.isEmpty()) {
sharedOutputRef.set(outputStr);
log.info("【Agent调用后】已捕获主Agent输出: {}", outputStr);
}
log.info("agent_invocation agentType={} status=COMPLETED inputCount={} outputType={} leaf={}",
agent.type().getName(), inputs == null ? 0 : inputs.size(),
output == null ? "none" : output.getClass().getName(), agent.leaf());
}
@Override
@@ -101,45 +58,23 @@ public class MyAgentListener implements dev.langchain4j.agentic.observability.Ag
Map<String, Object> inputs = error.inputs();
Throwable throwable = error.error();
log.error("【Agent执行错误】Agent名称: {}", agent.name());
log.error("【Agent执行错误】Agent ID: {}", agent.agentId());
log.error("【Agent执行错误】Agent类型: {}", agent.type().getName());
log.error("【Agent执行错误】Agent输入参数: {}", inputs);
log.error("【Agent执行错误】错误类型: {}", throwable.getClass().getName());
log.error("【Agent执行错误】错误信息: {}", throwable.getMessage(), throwable);
log.error("agent_invocation agentType={} status=FAILED inputCount={} errorType={}",
agent.type().getName(), inputs == null ? 0 : inputs.size(),
throwable == null ? "unknown" : throwable.getClass().getName());
}
// ==================== AgenticScope 生命周期 ====================
@Override
public void afterAgenticScopeCreated(AgenticScope agenticScope) {
log.info("【AgenticScope已创建】memoryId: {}", agenticScope.memoryId());
log.info("【AgenticScope已创建】初始状态: {}", agenticScope.state());
log.info("agentic_scope status=CREATED invocationCount={}",
agenticScope.agentInvocations() == null ? 0 : agenticScope.agentInvocations().size());
}
@Override
public void beforeAgenticScopeDestroyed(AgenticScope agenticScope) {
log.info("【AgenticScope即将销毁】memoryId: {}", agenticScope.memoryId());
log.info("【AgenticScope即将销毁】最终状态: {}", agenticScope.state());
log.info("【AgenticScope即将销毁】总调用次数: {}", agenticScope.agentInvocations().size());
log.info("agentic_scope status=DESTROYED invocationCount={}",
agenticScope.agentInvocations() == null ? 0 : agenticScope.agentInvocations().size());
}
// ==================== 工具执行生命周期 ====================
//
// @Override
// public void beforeToolExecution(BeforeToolExecution beforeToolExecution) {
// var toolRequest = beforeToolExecution.request();
// log.info("【工具执行前】工具请求ID: {}", toolRequest.id());
// log.info("【工具执行前】工具名称: {}", toolRequest.name());
// log.info("【工具执行前】工具参数: {}", toolRequest.arguments());
// }
//
// @Override
// public void afterToolExecution(ToolExecution toolExecution) {
// var toolRequest = toolExecution.request();
// log.info("【工具执行后】工具请求ID: {}", toolRequest.id());
// log.info("【工具执行后】工具名称: {}", toolRequest.name());
// log.info("【工具执行后】工具执行结果: {}", toolExecution.result());
// log.info("【工具执行后】工具执行是否失败: {}", toolExecution.hasFailed());
// }
}

View File

@@ -30,12 +30,11 @@ public class MyAiServiceCompletedListener implements AiServiceCompletedListener
Object chatMemoryId = invocationContext.chatMemoryId();
Instant eventTimestamp = invocationContext.timestamp();
log.info("【AI服务完成】调用唯一标识符: {}", invocationId);
log.info("【AI服务完成】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【AI服务完成】调用的方法名: {}", aiServiceMethodName);
log.info("【AI服务完成】AI服务方法参数: {}", aiServiceMethodArgs);
log.info("【AI服务完成】聊天记忆ID: {}", chatMemoryId);
log.info("【AI服务完成】调用发生的时间: {}", eventTimestamp);
log.info("【AI服务完成】调用结果: {}", result);
long elapsedMillis = Math.max(0L, java.time.Duration.between(eventTimestamp, Instant.now()).toMillis());
String resultType = result.map(value -> value.getClass().getName()).orElse("none");
log.info("ai_service_completed invocationId={} interfaceType={} method={} status=COMPLETED "
+ "argumentCount={} resultType={} elapsedMs={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName,
aiServiceMethodArgs == null ? 0 : aiServiceMethodArgs.size(), resultType, elapsedMillis);
}
}

View File

@@ -24,10 +24,8 @@ public class MyAiServiceErrorListener implements AiServiceErrorListener {
String aiServiceMethodName = invocationContext.methodName();
Throwable error = event.error();
log.error("【AI服务错误】调用唯一标识符: {}", invocationId);
log.error("【AI服务错误】AI服务接口名: {}", aiServiceInterfaceName);
log.error("【AI服务错误】调用的方法名: {}", aiServiceMethodName);
log.error("【AI服务错误】错误类型: {}", error.getClass().getName());
log.error("【AI服务错误】错误信息: {}", error.getMessage(), error);
log.error("ai_service_failed invocationId={} interfaceType={} method={} status=FAILED errorType={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName,
error == null ? "unknown" : error.getClass().getName());
}
}

View File

@@ -25,9 +25,11 @@ public class MyAiServiceRequestIssuedListener implements AiServiceRequestIssuedL
String aiServiceMethodName = invocationContext.methodName();
ChatRequest request = event.request();
log.info("【请求已发出】调用唯一标识符: {}", invocationId);
log.info("【请求已发出】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【请求已发出】调用的方法名: {}", aiServiceMethodName);
log.info("【请求已发出】发送给LLM的请求: {}", request);
int messageCount = request == null || request.messages() == null ? 0 : request.messages().size();
int toolCount = request == null || request.toolSpecifications() == null
? 0 : request.toolSpecifications().size();
log.info("ai_service_request_issued invocationId={} interfaceType={} method={} status=ISSUED "
+ "messageCount={} toolCount={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName, messageCount, toolCount);
}
}

View File

@@ -28,10 +28,15 @@ public class MyAiServiceResponseReceivedListener implements AiServiceResponseRec
ChatRequest request = event.request();
ChatResponse response = event.response();
log.info("【响应已接收】调用唯一标识符: {}", invocationId);
log.info("【响应已接收】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【响应已接收】调用的方法名: {}", aiServiceMethodName);
log.info("【响应已接收】发送给LLM的请求: {}", request);
log.info("【响应已接收】从LLM收到的响应: {}", response);
int messageCount = request == null || request.messages() == null ? 0 : request.messages().size();
int toolCount = request == null || request.toolSpecifications() == null
? 0 : request.toolSpecifications().size();
var usage = response == null ? null : response.tokenUsage();
log.info("ai_service_response_received invocationId={} interfaceType={} method={} status=RECEIVED "
+ "messageCount={} toolCount={} inputTokens={} outputTokens={} finishReason={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName, messageCount, toolCount,
usage == null ? null : usage.inputTokenCount(),
usage == null ? null : usage.outputTokenCount(),
response == null ? null : response.finishReason());
}
}

View File

@@ -29,10 +29,8 @@ public class MyAiServiceStartedListener implements AiServiceStartedListener {
Optional<SystemMessage> systemMessage = event.systemMessage();
UserMessage userMessage = event.userMessage();
log.info("【AI服务启动】调用唯一标识符: {}", invocationId);
log.info("【AI服务启动】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【AI服务启动】调用的方法名: {}", aiServiceMethodName);
log.info("【AI服务启动】系统消息: {}", systemMessage.orElse(null));
log.info("【AI服务启动】用户消息: {}", userMessage);
int messageCount = (systemMessage.isPresent() ? 1 : 0) + (userMessage == null ? 0 : 1);
log.info("ai_service_started invocationId={} interfaceType={} method={} status=STARTED messageCount={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName, messageCount);
}
}

View File

@@ -17,27 +17,53 @@ import lombok.extern.slf4j.Slf4j;
@Slf4j
public class MyChatModelListener implements ChatModelListener {
private static final String START_NANOS_ATTRIBUTE =
MyChatModelListener.class.getName() + ".startNanos";
@Override
public void onRequest(ChatModelRequestContext requestContext) {
ChatRequest request = requestContext.chatRequest();
log.info("【ChatModel请求】发送给模型的请求: {}", request);
log.info("【ChatModel请求】模型提供商: {}", requestContext.modelProvider());
requestContext.attributes().put(START_NANOS_ATTRIBUTE, System.nanoTime());
log.info("【ChatModel请求】提供商[{}] 模型[{}] 消息数[{}] 工具数[{}] 最大输出Token[{}]",
requestContext.modelProvider(), request.modelName(), messageCount(request),
toolCount(request), request.maxOutputTokens());
}
@Override
public void onResponse(ChatModelResponseContext responseContext) {
ChatRequest request = responseContext.chatRequest();
ChatResponse response = responseContext.chatResponse();
log.info("【ChatModel响应】原始请求: {}", request);
log.info("【ChatModel响应】收到的响应: {}", response);
log.info("【ChatModel响应】模型提供商: {}", responseContext.modelProvider());
var usage = response.tokenUsage();
log.info("【ChatModel响应】提供商[{}] 模型[{}] 消息数[{}] 工具数[{}] "
+ "输入Token[{}] 输出Token[{}] 完成原因[{}] 耗时[{}]毫秒",
responseContext.modelProvider(), response.modelName(), messageCount(request),
toolCount(request), usage == null ? null : usage.inputTokenCount(),
usage == null ? null : usage.outputTokenCount(), response.finishReason(),
elapsedMillis(responseContext.attributes().get(START_NANOS_ATTRIBUTE)));
}
@Override
public void onError(ChatModelErrorContext errorContext) {
log.error("【ChatModel错误】错误类型: {}", errorContext.error().getClass().getName());
log.error("【ChatModel错误】错误信息: {}", errorContext.error().getMessage());
log.error("【ChatModel错误】原始请求: {}", errorContext.chatRequest());
log.error("【ChatModel错误】模型提供商: {}", errorContext.modelProvider());
ChatRequest request = errorContext.chatRequest();
log.error("【ChatModel错误】提供商[{}] 模型[{}] 消息数[{}] 工具数[{}] "
+ "状态[FAILED] 错误类型[{}] 耗时[{}]毫秒",
errorContext.modelProvider(), request == null ? null : request.modelName(),
messageCount(request), toolCount(request), errorContext.error().getClass().getName(),
elapsedMillis(errorContext.attributes().get(START_NANOS_ATTRIBUTE)));
}
private int messageCount(ChatRequest request) {
return request == null || request.messages() == null ? 0 : request.messages().size();
}
private int toolCount(ChatRequest request) {
return request == null || request.toolSpecifications() == null
? 0 : request.toolSpecifications().size();
}
private long elapsedMillis(Object startNanos) {
return startNanos instanceof Number start
? Math.max(0L, (System.nanoTime() - start.longValue()) / 1_000_000L)
: -1L;
}
}

View File

@@ -23,25 +23,32 @@ public class MyEmbeddingModelListener implements EmbeddingModelListener {
@Override
public void onRequest(EmbeddingModelRequestContext requestContext) {
log.info("【EmbeddingModel请求】输入文本段落数量: {}", requestContext.textSegments().size());
log.info("【EmbeddingModel请求】嵌入模型: {}", requestContext.embeddingModel());
log.info("embedding_model_requested status=STARTED segmentCount={} modelType={}",
requestContext.textSegments() == null ? 0 : requestContext.textSegments().size(),
modelType(requestContext.embeddingModel()));
}
@Override
public void onResponse(EmbeddingModelResponseContext responseContext) {
Response<List<Embedding>> response = responseContext.response();
List<Embedding> embeddings = response.content();
log.info("【EmbeddingModel响应】嵌入向量数量: {}", embeddings.size());
log.info("【EmbeddingModel响应】嵌入维度: {}", embeddings.isEmpty() ? 0 : embeddings.get(0).dimension());
log.info("【EmbeddingModel响应】嵌入模型: {}", responseContext.embeddingModel());
log.info("【EmbeddingModel响应】输入文本段落: {}", responseContext.textSegments());
log.info("embedding_model_completed status=COMPLETED segmentCount={} embeddingCount={} "
+ "dimension={} modelType={}",
responseContext.textSegments() == null ? 0 : responseContext.textSegments().size(),
embeddings == null ? 0 : embeddings.size(),
embeddings == null || embeddings.isEmpty() ? 0 : embeddings.get(0).dimension(),
modelType(responseContext.embeddingModel()));
}
@Override
public void onError(EmbeddingModelErrorContext errorContext) {
log.error("【EmbeddingModel错误】错误类型: {}", errorContext.error().getClass().getName());
log.error("【EmbeddingModel错误】错误信息: {}", errorContext.error().getMessage());
log.error("【EmbeddingModel错误】输入文本段落数量: {}", errorContext.textSegments().size());
log.error("【EmbeddingModel错误】嵌入模型: {}", errorContext.embeddingModel());
log.error("embedding_model_failed status=FAILED segmentCount={} modelType={} errorType={}",
errorContext.textSegments() == null ? 0 : errorContext.textSegments().size(),
modelType(errorContext.embeddingModel()),
errorContext.error() == null ? "unknown" : errorContext.error().getClass().getName());
}
private static String modelType(Object model) {
return model == null ? "unknown" : model.getClass().getName();
}
}

View File

@@ -33,13 +33,10 @@ public class MyInputGuardrailExecutedListener implements InputGuardrailExecutedL
Duration duration = event.duration();
UserMessage rewrittenUserMessage = event.rewrittenUserMessage();
log.info("【输入Guardrail已执行】调用唯一标识符: {}", invocationId);
log.info("【输入Guardrail已执行】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【输入Guardrail已执行】调用的方法名: {}", aiServiceMethodName);
log.info("【输入Guardrail已执行】Guardrail类名: {}", guardrailClass.getName());
log.info("【输入Guardrail已执行】输入Guardrail请求: {}", request);
log.info("【输入Guardrail已执行】输入Guardrail结果: {}", result);
log.info("【输入Guardrail已执行】重写后的用户消息: {}", rewrittenUserMessage);
log.info("【输入Guardrail已执行】执行耗时: {}ms", duration.toMillis());
log.info("input_guardrail_executed invocationId={} interfaceType={} method={} status=COMPLETED "
+ "guardrailType={} resultType={} rewrittenMessageCount={} elapsedMs={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName, guardrailClass.getName(),
result == null ? "none" : result.getClass().getName(),
rewrittenUserMessage == null ? 0 : 1, duration == null ? -1L : duration.toMillis());
}
}

View File

@@ -2,7 +2,6 @@ package org.ruoyi.observability;
import com.fasterxml.jackson.core.JsonProcessingException;
import com.fasterxml.jackson.databind.ObjectMapper;
import dev.langchain4j.invocation.InvocationContext;
import dev.langchain4j.mcp.client.McpCallContext;
import dev.langchain4j.mcp.client.McpClientListener;
import dev.langchain4j.mcp.client.McpGetPromptResult;
@@ -25,7 +24,7 @@ import java.util.Map;
* <pre>
* {
* "event": "mcp",
* "content": "{\"name\":\"工具名称\",\"status\":\"pending|success|error\",\"result\":\"执行结果\"}"
* "content": "{\"name\":\"tool|resource|prompt\",\"status\":\"pending|success|error\",\"result\":null}"
* }
* </pre>
* <b>前端区分方式:</b>
@@ -56,10 +55,9 @@ public class MyMcpClientListener implements McpClientListener {
public void beforeExecuteTool(McpCallContext context) {
McpClientRequest message = (McpClientRequest) context.message();
McpClientParams params = message.getParams();
if (params instanceof McpCallToolParams callToolParams) {
String name = callToolParams.getName();
log.info("工具调用之前:{}",name);
pushMcpEvent(name, "pending", null);
if (params instanceof McpCallToolParams) {
log.info("mcp_operation operationType=TOOL status=PENDING");
pushMcpEvent("tool", "pending", null);
}
}
@@ -68,86 +66,72 @@ public class MyMcpClientListener implements McpClientListener {
public void afterExecuteTool(McpCallContext context, ToolExecutionResult result, Map<String, Object> rawResult) {
McpClientRequest message = (McpClientRequest) context.message();
McpClientParams params = message.getParams();
if (params instanceof McpCallToolParams callToolParams) {
String name = callToolParams.getName();
String resultText = result != null ? result.toString() : "";
log.info("工具调用之后:{},返回结果{}",name,result);
pushMcpEvent(name, "success", truncate(resultText, 500));
if (params instanceof McpCallToolParams) {
int resultItemCount = rawResult == null ? 0 : rawResult.size();
log.info("mcp_operation operationType=TOOL status=SUCCESS resultItemCount={}", resultItemCount);
pushMcpEvent("tool", "success", null);
}
}
@Override
public void onExecuteToolError(McpCallContext context, Throwable error) {
String toolName = getMethodName(context);
log.error("【MCP工具执行错误】工具: {}, 错误: {}", toolName, error.getMessage());
pushMcpEvent(toolName, "error", error.getMessage());
log.error("mcp_operation operationType=TOOL status=FAILED errorType={}",
error == null ? "unknown" : error.getClass().getName());
pushMcpEvent("tool", "error", null);
}
// ==================== 资源读取 ====================
@Override
public void beforeResourceGet(McpCallContext context) {
String name = getMethodName(context);
log.info("【MCP资源读取前】资源: {}", name);
pushMcpEvent(name, "pending", null);
log.info("mcp_operation operationType=RESOURCE status=PENDING");
pushMcpEvent("resource", "pending", null);
}
@Override
public void afterResourceGet(McpCallContext context, McpReadResourceResult result, Map<String, Object> rawResult) {
String name = getMethodName(context);
int count = result.contents() != null ? result.contents().size() : 0;
log.info("【MCP资源读取后】资源: {}, 数量: {}", name, count);
pushMcpEvent(name, "success", "读取 " + count + " 条资源");
int count = result != null && result.contents() != null ? result.contents().size() : 0;
log.info("mcp_operation operationType=RESOURCE status=SUCCESS itemCount={}", count);
pushMcpEvent("resource", "success", null);
}
@Override
public void onResourceGetError(McpCallContext context, Throwable error) {
String name = getMethodName(context);
log.error("【MCP资源读取错误】资源: {}, 错误: {}", name, error.getMessage());
pushMcpEvent(name, "error", error.getMessage());
log.error("mcp_operation operationType=RESOURCE status=FAILED errorType={}",
error == null ? "unknown" : error.getClass().getName());
pushMcpEvent("resource", "error", null);
}
// ==================== 提示词获取 ====================
@Override
public void beforePromptGet(McpCallContext context) {
String name = getMethodName(context);
log.info("【MCP提示词获取前】提示词: {}", name);
pushMcpEvent(name, "pending", null);
log.info("mcp_operation operationType=PROMPT status=PENDING");
pushMcpEvent("prompt", "pending", null);
}
@Override
public void afterPromptGet(McpCallContext context, McpGetPromptResult result, Map<String, Object> rawResult) {
String name = getMethodName(context);
int count = result.messages() != null ? result.messages().size() : 0;
log.info("【MCP提示词获取后】提示词: {}, 消息数: {}", name, count);
pushMcpEvent(name, "success", "获取 " + count + " 条消息");
int count = result != null && result.messages() != null ? result.messages().size() : 0;
log.info("mcp_operation operationType=PROMPT status=SUCCESS messageCount={}", count);
pushMcpEvent("prompt", "success", null);
}
@Override
public void onPromptGetError(McpCallContext context, Throwable error) {
String name = getMethodName(context);
log.error("【MCP提示词获取错误】提示词: {}, 错误: {}", name, error.getMessage());
pushMcpEvent(name, "error", error.getMessage());
log.error("mcp_operation operationType=PROMPT status=FAILED errorType={}",
error == null ? "unknown" : error.getClass().getName());
pushMcpEvent("prompt", "error", null);
}
// ==================== 辅助方法 ====================
private String getMethodName(McpCallContext context) {
try {
McpClientMessage message = context.message();
return message.method != null ? message.method.toString() : "unknown";
} catch (Exception e) {
return "unknown";
}
}
/**
* 推送 MCP 事件到前端
*/
private void pushMcpEvent(String name, String status, String result) {
if (sessionId == null) {
log.warn("sessionId 为空,无法推送 MCP 事件");
log.warn("mcp_event_delivery status=SKIPPED reason=SESSION_ID_MISSING");
return;
}
try {
@@ -162,12 +146,8 @@ public class MyMcpClientListener implements McpClientListener {
.content(json)
.build());
} catch (JsonProcessingException e) {
log.error("序列化 MCP 事件失败: {}", e.getMessage());
log.error("mcp_event_delivery status=FAILED errorType={}", e.getClass().getName());
}
}
private String truncate(String str, int maxLen) {
if (str == null) return null;
return str.length() > maxLen ? str.substring(0, maxLen) + "..." : str;
}
}

View File

@@ -31,12 +31,10 @@ public class MyOutputGuardrailExecutedListener implements OutputGuardrailExecute
Class<OutputGuardrail> guardrailClass = event.guardrailClass();
Duration duration = event.duration();
log.info("【输出Guardrail已执行】调用唯一标识符: {}", invocationId);
log.info("【输出Guardrail已执行】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【输出Guardrail已执行】调用的方法名: {}", aiServiceMethodName);
log.info("【输出Guardrail已执行】Guardrail类名: {}", guardrailClass.getName());
log.info("【输出Guardrail已执行】输出Guardrail请求: {}", request);
log.info("【输出Guardrail已执行】输出Guardrail结果: {}", result);
log.info("【输出Guardrail已执行】执行耗时: {}ms", duration.toMillis());
log.info("output_guardrail_executed invocationId={} interfaceType={} method={} status=COMPLETED "
+ "guardrailType={} resultType={} elapsedMs={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName, guardrailClass.getName(),
result == null ? "none" : result.getClass().getName(),
duration == null ? -1L : duration.toMillis());
}
}

View File

@@ -27,12 +27,11 @@ public class MyToolExecutedEventListener implements ToolExecutedEventListener {
ToolExecutionRequest request = event.request();
String resultText = event.resultText();
log.info("【工具已执行】调用唯一标识符: {}", invocationId);
log.info("【工具已执行】AI服务接口名: {}", aiServiceInterfaceName);
log.info("【工具已执行】调用的方法名: {}", aiServiceMethodName);
log.info("【工具已执行】工具执行请求 ID: {}", request.id());
log.info("【工具已执行】工具名称: {}", request.name());
log.info("【工具已执行】工具参数: {}", request.arguments());
log.info("【工具已执行】工具执行结果: {}", resultText);
log.info("tool_executed invocationId={} interfaceType={} method={} status=COMPLETED "
+ "toolType={} argumentChars={} resultChars={}",
invocationId, aiServiceInterfaceName, aiServiceMethodName,
request == null ? "unknown" : request.name(),
request == null || request.arguments() == null ? 0 : request.arguments().length(),
resultText == null ? 0 : resultText.length());
}
}

View File

@@ -1,5 +1,7 @@
package org.ruoyi.observability;
import lombok.extern.slf4j.Slf4j;
import java.util.Map;
import java.util.concurrent.*;
import java.util.concurrent.atomic.AtomicReference;
@@ -23,13 +25,14 @@ import java.util.function.Consumer;
* @author ageerle@163.com
* @date 2025/04/10
*/
@Slf4j
public class OutputChannel {
private static final String DONE = "__DONE__";
private static final Map<String, OutputChannel> REGISTRY = new ConcurrentHashMap<>();
private final BlockingQueue<String> queue = new LinkedBlockingQueue<>(4096);
private final AtomicReference<Throwable> error = new AtomicReference<>();
private final AtomicReference<String> errorType = new AtomicReference<>();
private final CountDownLatch completed = new CountDownLatch(1);
/**
@@ -58,13 +61,13 @@ public class OutputChannel {
/**
* 写入:线程安全,非阻塞,队列满时丢弃
*/
public void send(String text) {
public void send(String text) {
if (text == null || text.isEmpty()) {
return;
}
try {
if (!queue.offer(text, 100, TimeUnit.MILLISECONDS)) {
System.err.println("[OutputChannel] 队列满,丢弃消息: " + truncate(text, 100));
log.warn("output_channel status=DROPPED reason=QUEUE_FULL payloadChars={}", text.length());
}
} catch (InterruptedException e) {
Thread.currentThread().interrupt();
@@ -83,8 +86,11 @@ public void send(String text) {
* 标记错误完成
*/
public void completeWithError(Throwable t) {
error.set(t);
queue.offer("\n[错误] 致命错误: " + t.getMessage());
String safeType = t == null ? "unknown" : t.getClass().getName();
errorType.set(safeType);
queue.offer("\n[错误] Agent 执行失败");
log.error("output_channel status=FAILED errorType={}",
safeType);
queue.offer(DONE);
completed.countDown();
}
@@ -106,9 +112,9 @@ public void send(String text) {
}
}
}
Throwable t = error.get();
if (t != null && !(t instanceof InterruptedException)) {
throw new RuntimeException("Agent 执行出错", t);
String failureType = errorType.get();
if (failureType != null && !InterruptedException.class.getName().equals(failureType)) {
throw new RuntimeException("Agent 执行出错,errorType=" + failureType);
}
}
@@ -119,10 +125,4 @@ public void send(String text) {
return completed.getCount() == 0;
}
private String truncate(String s, int maxLen) {
if (s == null) {
return "null";
}
return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s;
}
}

Some files were not shown because too many files have changed in this diff Show More