mirror of
https://gitcode.com/ageerle/ruoyi-ai.git
synced 2026-11-15 07:20:40 +00:00
feat: enhance coding harness, model security and system features
This commit is contained in:
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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");
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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";
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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) {
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -41,11 +41,6 @@ public class QueryVectorBo {
|
||||
*/
|
||||
private String embeddingModelName;
|
||||
|
||||
/**
|
||||
* 请求key
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 请求地址
|
||||
*/
|
||||
|
||||
@@ -47,11 +47,6 @@ public class StoreEmbeddingBo {
|
||||
*/
|
||||
private String embeddingModelName;
|
||||
|
||||
/**
|
||||
* 请求key
|
||||
*/
|
||||
private String apiKey;
|
||||
|
||||
/**
|
||||
* 请求地址
|
||||
*/
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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;
|
||||
|
||||
/**
|
||||
|
||||
@@ -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"),
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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()));
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)) {
|
||||
|
||||
@@ -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());
|
||||
// }
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,11 @@ public class StreamingOutputWrapper implements StreamingChatModel, ChatModel {
|
||||
|
||||
@Override
|
||||
public ChatResponse chat(ChatRequest request) {
|
||||
log.info("【StreamingOutputWrapper】chat() 被调用,开始流式处理");
|
||||
long startedAt = System.nanoTime();
|
||||
log.info("streaming_chat status=STARTED messageCount={} toolCount={}",
|
||||
request == null || request.messages() == null ? 0 : request.messages().size(),
|
||||
request == null || request.toolSpecifications() == null
|
||||
? 0 : request.toolSpecifications().size());
|
||||
// 用于收集完整响应
|
||||
AtomicReference<ChatResponse> responseRef = new AtomicReference<>();
|
||||
CompletableFuture<Void> future = new CompletableFuture<>();
|
||||
@@ -70,19 +74,16 @@ public class StreamingOutputWrapper implements StreamingChatModel, ChatModel {
|
||||
public void onPartialResponse(String token) {
|
||||
// 推送到 channel
|
||||
channel.send(token);
|
||||
log.debug("【流式Token】{}", token);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPartialResponse(PartialResponse pr, PartialResponseContext ctx) {
|
||||
channel.send(pr.text());
|
||||
log.debug("【流式PartialResponse】{}", pr.text());
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onPartialThinking(PartialThinking thinking) {
|
||||
channel.send("[思考] " + thinking.text());
|
||||
log.debug("【流式思考】{}", thinking.text());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -108,16 +109,20 @@ public class StreamingOutputWrapper implements StreamingChatModel, ChatModel {
|
||||
// channel.send("\n[Token统计] input=" + usage.inputTokenCount()
|
||||
// + " output=" + usage.outputTokenCount());
|
||||
}
|
||||
log.info("【StreamingOutputWrapper】流式处理完成");
|
||||
log.info("streaming_chat status=COMPLETED elapsedMs={} inputTokens={} outputTokens={}",
|
||||
Math.max(0L, (System.nanoTime() - startedAt) / 1_000_000L),
|
||||
response.tokenUsage() == null ? null : response.tokenUsage().inputTokenCount(),
|
||||
response.tokenUsage() == null ? null : response.tokenUsage().outputTokenCount());
|
||||
future.complete(null);
|
||||
}
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
channel.send("\n[错误] " + error.getMessage());
|
||||
channel.completeWithError(error);
|
||||
future.completeExceptionally(error);
|
||||
log.error("【StreamingOutputWrapper】流式处理出错", error);
|
||||
log.error("streaming_chat status=FAILED elapsedMs={} errorType={}",
|
||||
Math.max(0L, (System.nanoTime() - startedAt) / 1_000_000L),
|
||||
error == null ? "unknown" : error.getClass().getName());
|
||||
}
|
||||
});
|
||||
|
||||
@@ -187,7 +192,6 @@ public class StreamingOutputWrapper implements StreamingChatModel, ChatModel {
|
||||
|
||||
@Override
|
||||
public void onError(Throwable error) {
|
||||
channel.send("\n[错误] " + error.getMessage());
|
||||
channel.completeWithError(error);
|
||||
original.onError(error);
|
||||
}
|
||||
|
||||
@@ -5,8 +5,6 @@ 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;
|
||||
@@ -41,8 +39,9 @@ public class SupervisorStreamListener implements dev.langchain4j.agentic.observa
|
||||
AgentInstance agent = agentRequest.agent();
|
||||
AgenticScope scope = agentRequest.agenticScope();
|
||||
Map<String, Object> inputs = agentRequest.inputs();
|
||||
// 只记录日志,不推送输入信息(避免干扰流式输出)
|
||||
log.info("[Agent开始] {} 输入: {}", agent.name(), inputs);
|
||||
log.info("supervisor_agent agentType={} status=STARTED inputCount={} priorInvocationCount={}",
|
||||
agent.type().getName(), inputs == null ? 0 : inputs.size(),
|
||||
scope.agentInvocations() == null ? 0 : scope.agentInvocations().size());
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -50,12 +49,10 @@ public class SupervisorStreamListener implements dev.langchain4j.agentic.observa
|
||||
AgentInstance agent = agentResponse.agent();
|
||||
Map<String, Object> inputs = agentResponse.inputs();
|
||||
Object output = agentResponse.output();
|
||||
String outputStr = output != null ? output.toString() : "";
|
||||
|
||||
// 只记录日志,不推送输出信息
|
||||
// 流式输出由 StreamingOutputWrapper 处理
|
||||
// 当无子Agent被调用时,由 ChatServiceFacade 用 plannerModel 生成回复
|
||||
log.info("[Agent完成] {} 输出长度: {}", agent.name(), outputStr.length());
|
||||
int outputChars = output instanceof CharSequence text ? text.length() : -1;
|
||||
log.info("supervisor_agent agentType={} status=COMPLETED inputCount={} outputType={} outputChars={}",
|
||||
agent.type().getName(), inputs == null ? 0 : inputs.size(),
|
||||
output == null ? "none" : output.getClass().getName(), outputChars);
|
||||
}
|
||||
|
||||
@Override
|
||||
@@ -64,41 +61,26 @@ public class SupervisorStreamListener implements dev.langchain4j.agentic.observa
|
||||
Map<String, Object> inputs = error.inputs();
|
||||
Throwable throwable = error.error();
|
||||
|
||||
channel.send("\n[Agent错误] " + agent.name()
|
||||
+ " 异常: " + throwable.getMessage());
|
||||
log.error("[Agent错误] {} 异常: {}", agent.name(), throwable.getMessage(), throwable);
|
||||
channel.send("\n[Agent错误] 执行失败");
|
||||
log.error("supervisor_agent 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("supervisor_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("supervisor_scope status=DESTROYED invocationCount={}",
|
||||
agenticScope.agentInvocations() == null ? 0 : agenticScope.agentInvocations().size());
|
||||
}
|
||||
|
||||
// ==================== 工具执行生命周期 ====================
|
||||
|
||||
// @Override
|
||||
// public void beforeToolExecution(BeforeToolExecution beforeToolExecution) {
|
||||
// var toolRequest = beforeToolExecution.request();
|
||||
//// channel.send("\n[工具即将执行] " + toolRequest.name()
|
||||
//// + " 参数: " + truncate(toolRequest.arguments(), 150));
|
||||
// log.info("[工具即将执行] {} 参数: {}", toolRequest.name(), toolRequest.arguments());
|
||||
// }
|
||||
|
||||
// @Override
|
||||
// public void afterToolExecution(ToolExecution toolExecution) {
|
||||
// var toolRequest = toolExecution.request();
|
||||
//// channel.send("\n[工具执行完成] " + toolRequest.name()
|
||||
//// + " 结果: " + truncate(String.valueOf(toolExecution.result()), 300));
|
||||
// log.info("[工具执行完成] {} 结果: {}", toolRequest.name(), toolExecution.result());
|
||||
// }
|
||||
|
||||
// ==================== 继承机制 ====================
|
||||
|
||||
/**
|
||||
@@ -109,12 +91,4 @@ public class SupervisorStreamListener implements dev.langchain4j.agentic.observa
|
||||
return true;
|
||||
}
|
||||
|
||||
// ==================== 辅助方法 ====================
|
||||
|
||||
private String truncate(String s, int maxLen) {
|
||||
if (s == null) {
|
||||
return "null";
|
||||
}
|
||||
return s.length() > maxLen ? s.substring(0, maxLen) + "..." : s;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -75,7 +75,7 @@ public class AtlasAudioGenerationServiceImpl extends AbstractAudioGenerationServ
|
||||
|
||||
Request request = new Request.Builder()
|
||||
.url(AtlasMediaSupport.endpoint(model.getApiHost(), "/model/generateAudio"))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(payload.toString(), AtlasMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -72,7 +72,7 @@ public class OpenAiAudioGenerationServiceImpl extends AbstractAudioGenerationSer
|
||||
private byte[] postBytes(ChatModelVo model, String path, String jsonBody) throws IOException {
|
||||
Request request = new Request.Builder()
|
||||
.url(OpenAiMediaSupport.endpoint(model.getApiHost(), path))
|
||||
.addHeader("Authorization", "Bearer " + model.getApiKey())
|
||||
.addHeader("Authorization", "Bearer " + model.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.addHeader("Content-Type", "application/json")
|
||||
.post(RequestBody.create(jsonBody, OpenAiMediaSupport.JSON))
|
||||
.build();
|
||||
|
||||
@@ -36,7 +36,7 @@ public interface AbstractChatService {
|
||||
default ChatModel buildChatModel(ChatModelVo chatModelVo) {
|
||||
return OpenAiChatModel.builder()
|
||||
.baseUrl(chatModelVo.getApiHost())
|
||||
.apiKey(chatModelVo.getApiKey())
|
||||
.apiKey(chatModelVo.resolveApiKeyForConfiguredEndpoint(getProviderName()))
|
||||
.modelName(chatModelVo.getModelName())
|
||||
.timeout(Duration.ofSeconds(120))
|
||||
.build();
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user