feat(web-ai): support qwen web search agent

This commit is contained in:
2026-07-19 21:32:39 +08:00
parent 6853facbe0
commit 7bd2c540fa
7 changed files with 366 additions and 43 deletions
@@ -9,6 +9,8 @@ import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.springframework.stereotype.Service; import org.springframework.stereotype.Service;
import java.io.BufferedReader;
import java.io.StringReader;
import java.net.Inet4Address; import java.net.Inet4Address;
import java.net.Inet6Address; import java.net.Inet6Address;
import java.net.InetAddress; import java.net.InetAddress;
@@ -18,8 +20,12 @@ import java.net.http.HttpRequest;
import java.net.http.HttpResponse; import java.net.http.HttpResponse;
import java.time.Duration; import java.time.Duration;
import java.util.ArrayList; import java.util.ArrayList;
import java.util.LinkedHashMap;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Map;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -27,6 +33,8 @@ public class AihrWebSearchClient {
private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5); private static final Duration CONNECT_TIMEOUT = Duration.ofSeconds(5);
private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(12); private static final Duration REQUEST_TIMEOUT = Duration.ofSeconds(12);
private static final Duration QWEN_REQUEST_TIMEOUT = Duration.ofSeconds(40);
private static final Pattern PUBLIC_URL = Pattern.compile("https?://[^\\s\\]\\)\\\"'<>]+");
private final AihrWebAiProperties properties; private final AihrWebAiProperties properties;
private final AihrWebSearchProviderService providerService; private final AihrWebSearchProviderService providerService;
@@ -41,9 +49,6 @@ public class AihrWebSearchClient {
} }
SearchResult search(RuntimeProvider runtime, String question) { SearchResult search(RuntimeProvider runtime, String question) {
if (!"tavily".equalsIgnoreCase(clean(runtime.code()))) {
throw new ServiceException("暂不支持当前全网检索提供方");
}
String endpoint = clean(runtime.endpoint()); String endpoint = clean(runtime.endpoint());
String apiKey = clean(runtime.apiKey()); String apiKey = clean(runtime.apiKey());
if (!isPublicHttpsUrl(endpoint)) { if (!isPublicHttpsUrl(endpoint)) {
@@ -52,6 +57,17 @@ public class AihrWebSearchClient {
if (apiKey.isBlank()) { if (apiKey.isBlank()) {
throw new ServiceException("全网检索密钥未配置"); throw new ServiceException("全网检索密钥未配置");
} }
String code = clean(runtime.code()).toLowerCase(Locale.ROOT);
if ("qwen".equals(code)) {
return searchQwen(runtime, question);
}
if (!"tavily".equals(code)) {
throw new ServiceException("暂不支持当前全网检索提供方");
}
return searchTavily(runtime, question);
}
private SearchResult searchTavily(RuntimeProvider runtime, String question) {
try { try {
ObjectNode body = objectMapper.createObjectNode(); ObjectNode body = objectMapper.createObjectNode();
body.put("query", question); body.put("query", question);
@@ -59,9 +75,9 @@ public class AihrWebSearchClient {
body.put("include_answer", "basic"); body.put("include_answer", "basic");
body.put("include_raw_content", false); body.put("include_raw_content", false);
body.put("max_results", properties.boundedMaxResults()); body.put("max_results", properties.boundedMaxResults());
HttpRequest request = HttpRequest.newBuilder(URI.create(endpoint)) HttpRequest request = HttpRequest.newBuilder(URI.create(runtime.endpoint()))
.timeout(REQUEST_TIMEOUT) .timeout(REQUEST_TIMEOUT)
.header("Authorization", "Bearer " + apiKey) .header("Authorization", "Bearer " + runtime.apiKey())
.header("Content-Type", "application/json") .header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body))) .POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build(); .build();
@@ -84,6 +100,53 @@ public class AihrWebSearchClient {
} }
} }
private SearchResult searchQwen(RuntimeProvider runtime, String question) {
String agentId = clean(runtime.agentId());
String agentVersion = clean(runtime.agentVersion());
if (agentId.isBlank() || agentVersion.isBlank()) {
throw new ServiceException("千问 Agent 配置不完整");
}
try {
ObjectNode body = objectMapper.createObjectNode();
ObjectNode input = body.putObject("input");
ObjectNode message = input.putArray("messages").addObject();
message.put("role", "user");
message.put("content", question);
ObjectNode options = body.putObject("parameters").putObject("agent_options");
options.put("agent_id", agentId);
options.put("agent_version", agentVersion);
options.put("agent_policy", "standard");
options.put("forced_search", true);
options.put("enable_citation", true);
options.put("enable_text_image_mixed", false);
options.put("related_video", false);
options.put("enable_rec_question", false);
body.put("stream", true);
HttpRequest request = HttpRequest.newBuilder(URI.create(runtime.endpoint()))
.timeout(QWEN_REQUEST_TIMEOUT)
.header("Authorization", "Bearer " + runtime.apiKey())
.header("Content-Type", "application/json")
.POST(HttpRequest.BodyPublishers.ofString(objectMapper.writeValueAsString(body)))
.build();
HttpResponse<String> response = HttpClient.newBuilder()
.connectTimeout(CONNECT_TIMEOUT)
.followRedirects(HttpClient.Redirect.NEVER)
.build()
.send(request, HttpResponse.BodyHandlers.ofString());
if (response.statusCode() < 200 || response.statusCode() >= 300) {
throw new ServiceException("全网检索服务暂时不可用");
}
return parseQwenSseResponse(objectMapper, response.body(), properties.boundedMaxResults());
} catch (InterruptedException error) {
Thread.currentThread().interrupt();
throw new ServiceException("全网检索已中断");
} catch (ServiceException error) {
throw error;
} catch (Exception error) {
throw new ServiceException("全网检索服务暂时不可用");
}
}
RuntimeProvider runtime(AihrKnowledgePrincipal principal) { RuntimeProvider runtime(AihrKnowledgePrincipal principal) {
RuntimeProvider configured = providerService.active(principal); RuntimeProvider configured = providerService.active(principal);
if (configured != null) { if (configured != null) {
@@ -96,7 +159,7 @@ public class AihrWebSearchClient {
return null; return null;
} }
return new RuntimeProvider(-1L, clean(properties.getProvider()), clean(properties.getProvider()), return new RuntimeProvider(-1L, clean(properties.getProvider()), clean(properties.getProvider()),
clean(properties.getEndpoint()), clean(properties.getApiKey()), true, true, null); clean(properties.getEndpoint()), clean(properties.getApiKey()), "", "", true, true, null);
} }
static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception { static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception {
@@ -124,6 +187,125 @@ public class AihrWebSearchClient {
return new SearchResult(answer, List.copyOf(sources)); return new SearchResult(answer, List.copyOf(sources));
} }
static SearchResult parseQwenSseResponse(ObjectMapper mapper, String body, int limit) throws Exception {
StringBuilder answer = new StringBuilder();
Map<String, WebSource> sources = new LinkedHashMap<>();
try (BufferedReader reader = new BufferedReader(new StringReader(body == null ? "" : body))) {
String line;
while ((line = reader.readLine()) != null) {
String chunk = line.trim();
if (!chunk.startsWith("data:")) {
continue;
}
String json = chunk.substring(5).trim();
if (json.isBlank() || "[DONE]".equals(json)) {
continue;
}
JsonNode root = mapper.readTree(json);
int statusCode = root.path("status_code").asInt(200);
String code = clean(root.path("code").asText(""));
if (statusCode >= 400 || (!code.isBlank() && !"200".equals(code) && !"0".equals(code))) {
throw new ServiceException("全网检索服务暂时不可用");
}
JsonNode choices = root.path("output").path("choices");
if (!choices.isArray() || choices.size() == 0) {
continue;
}
JsonNode message = choices.get(0).path("message");
String role = clean(message.path("role").asText(""));
JsonNode content = message.path("content");
if ("assistant".equals(role) && content.isTextual()) {
appendStreamChunk(answer, content.asText(""));
collectTextUrls(content.asText(""), sources, limit);
}
if ("tool".equals(role)) {
collectSources(mapper, content, sources, limit);
}
collectSources(mapper, message.path("additional_kwargs"), sources, limit);
collectSources(mapper, message.path("tool_calls"), sources, limit);
}
}
List<WebSource> safeSources = List.copyOf(sources.values());
return new SearchResult(safeSources.isEmpty() ? "" : truncate(clean(answer.toString()), 4000), safeSources);
}
private static void appendStreamChunk(StringBuilder answer, String chunk) {
if (chunk == null || chunk.isEmpty()) {
return;
}
String current = answer.toString();
if (!current.isEmpty() && chunk.startsWith(current)) {
answer.setLength(0);
}
answer.append(chunk);
}
private static void collectSources(ObjectMapper mapper, JsonNode node, Map<String, WebSource> sources, int limit) {
if (node == null || node.isMissingNode() || node.isNull() || sources.size() >= safeLimit(limit)) {
return;
}
if (node.isTextual()) {
String value = node.asText("").trim();
if ((value.startsWith("{") || value.startsWith("[")) && !value.isBlank()) {
try {
collectSources(mapper, mapper.readTree(value), sources, limit);
} catch (Exception ignored) {
collectTextUrls(value, sources, limit);
}
} else {
collectTextUrls(value, sources, limit);
}
return;
}
if (node.isArray()) {
for (JsonNode item : node) {
collectSources(mapper, item, sources, limit);
if (sources.size() >= safeLimit(limit)) {
break;
}
}
return;
}
if (!node.isObject()) {
return;
}
String url = firstText(node, "url", "link", "source_url", "sourceUrl");
if (isPublicHttpUrl(url)) {
String title = firstText(node, "title", "name", "site_name", "siteName");
sources.putIfAbsent(url, new WebSource(
title.isBlank() ? "公开来源" : title,
url,
truncate(firstText(node, "snippet", "summary", "description", "content"), 600),
node.path("score").asDouble(0D)
));
}
node.fields().forEachRemaining(entry -> collectSources(mapper, entry.getValue(), sources, limit));
}
private static void collectTextUrls(String text, Map<String, WebSource> sources, int limit) {
Matcher matcher = PUBLIC_URL.matcher(text == null ? "" : text);
while (matcher.find() && sources.size() < safeLimit(limit)) {
String url = matcher.group().replaceAll("[.,;:!?,。;:!?]+$", "");
if (isPublicHttpUrl(url)) {
sources.putIfAbsent(url, new WebSource("公开来源", url, "", 0D));
}
}
}
private static String firstText(JsonNode node, String... fields) {
for (String field : fields) {
JsonNode value = node.path(field);
if (value.isTextual() && !value.asText().isBlank()) {
return clean(value.asText());
}
}
return "";
}
private static int safeLimit(int limit) {
return Math.max(1, Math.min(limit, 8));
}
static boolean isPublicHttpUrl(String value) { static boolean isPublicHttpUrl(String value) {
try { try {
URI uri = URI.create(clean(value)); URI uri = URI.create(clean(value));
@@ -1,5 +1,6 @@
package org.dromara.aihr.webai; package org.dromara.aihr.webai;
import com.fasterxml.jackson.annotation.JsonProperty;
import lombok.RequiredArgsConstructor; import lombok.RequiredArgsConstructor;
import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal; import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
@@ -14,6 +15,7 @@ import java.sql.Statement;
import java.time.LocalDateTime; import java.time.LocalDateTime;
import java.util.List; import java.util.List;
import java.util.Locale; import java.util.Locale;
import java.util.Set;
@Service @Service
@RequiredArgsConstructor @RequiredArgsConstructor
@@ -26,11 +28,13 @@ public class AihrWebSearchProviderService {
public List<ProviderResponse> list(AihrKnowledgePrincipal principal) { public List<ProviderResponse> list(AihrKnowledgePrincipal principal) {
ensureTable(); ensureTable();
return jdbcTemplate.query(""" return jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version,
enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? order by id from aihr_web_search_provider where tenant_id = ? order by id
""", (rs, rowNum) -> new ProviderResponse( """, (rs, rowNum) -> new ProviderResponse(
rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), rs.getString("endpoint"), rs.getString("agent_id"), rs.getString("agent_version"),
hasText(rs.getString("api_key")), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)
), principal.tenantId()); ), principal.tenantId());
} }
@@ -42,14 +46,17 @@ public class AihrWebSearchProviderService {
jdbcTemplate.update(connection -> { jdbcTemplate.update(connection -> {
PreparedStatement statement = connection.prepareStatement(""" PreparedStatement statement = connection.prepareStatement("""
insert into aihr_web_search_provider insert into aihr_web_search_provider
(tenant_id, provider_name, provider_code, endpoint, api_key, enabled, create_time, update_time) (tenant_id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version,
values (?, ?, ?, ?, ?, 0, now(), now()) enabled, create_time, update_time)
values (?, ?, ?, ?, ?, ?, ?, 0, now(), now())
""", Statement.RETURN_GENERATED_KEYS); """, Statement.RETURN_GENERATED_KEYS);
statement.setString(1, principal.tenantId()); statement.setString(1, principal.tenantId());
statement.setString(2, data.name()); statement.setString(2, data.name());
statement.setString(3, data.code()); statement.setString(3, data.code());
statement.setString(4, data.endpoint()); statement.setString(4, data.endpoint());
statement.setString(5, secretCodec.encrypt(data.apiKey())); statement.setString(5, secretCodec.encrypt(data.apiKey()));
statement.setString(6, data.agentId());
statement.setString(7, data.agentVersion());
return statement; return statement;
}, keyHolder); }, keyHolder);
Number key = keyHolder.getKey(); Number key = keyHolder.getKey();
@@ -63,11 +70,13 @@ public class AihrWebSearchProviderService {
int updated = jdbcTemplate.update(""" int updated = jdbcTemplate.update("""
update aihr_web_search_provider update aihr_web_search_provider
set provider_name = ?, provider_code = ?, endpoint = ?, set provider_name = ?, provider_code = ?, endpoint = ?,
agent_id = ?, agent_version = ?,
api_key = case when ? is null then api_key else ? end, api_key = case when ? is null then api_key else ? end,
enabled = 0, last_test_ok = 0, last_test_time = null, enabled = 0, last_test_ok = 0, last_test_time = null,
update_time = now() update_time = now()
where tenant_id = ? and id = ? where tenant_id = ? and id = ?
""", data.name(), data.code(), data.endpoint(), apiKey, apiKey, principal.tenantId(), request.id()); """, data.name(), data.code(), data.endpoint(), data.agentId(), data.agentVersion(),
apiKey, apiKey, principal.tenantId(), request.id());
if (updated == 0) { if (updated == 0) {
throw new IllegalArgumentException("全网检索提供方不存在"); throw new IllegalArgumentException("全网检索提供方不存在");
} }
@@ -79,7 +88,9 @@ public class AihrWebSearchProviderService {
ensureTable(); ensureTable();
if (enabled) { if (enabled) {
RuntimeProvider selected = provider(principal, id); RuntimeProvider selected = provider(principal, id);
if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())) { if (!AihrWebSearchClient.isPublicHttpsUrl(selected.endpoint()) || !hasText(selected.apiKey())
|| ("qwen".equals(selected.code())
&& (!hasText(selected.agentId()) || !hasText(selected.agentVersion())))) {
throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥"); throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥");
} }
if (!selected.lastTestOk() || selected.lastTestTime() == null) { if (!selected.lastTestOk() || selected.lastTestTime() == null) {
@@ -112,7 +123,8 @@ public class AihrWebSearchProviderService {
ensureTable(); ensureTable();
try { try {
List<RuntimeProvider> rows = jdbcTemplate.query(""" List<RuntimeProvider> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version,
enabled, last_test_ok, last_test_time
from aihr_web_search_provider from aihr_web_search_provider
where tenant_id = ? and enabled = 1 where tenant_id = ? and enabled = 1
order by id desc limit 1 order by id desc limit 1
@@ -126,7 +138,8 @@ public class AihrWebSearchProviderService {
RuntimeProvider provider(AihrKnowledgePrincipal principal, Long id) { RuntimeProvider provider(AihrKnowledgePrincipal principal, Long id) {
ensureTable(); ensureTable();
List<RuntimeProvider> rows = jdbcTemplate.query(""" List<RuntimeProvider> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version,
enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? and id = ? from aihr_web_search_provider where tenant_id = ? and id = ?
""", (rs, rowNum) -> providerRow(rs), principal.tenantId(), id); """, (rs, rowNum) -> providerRow(rs), principal.tenantId(), id);
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -137,17 +150,20 @@ public class AihrWebSearchProviderService {
private RuntimeProvider providerRow(java.sql.ResultSet rs) throws java.sql.SQLException { private RuntimeProvider providerRow(java.sql.ResultSet rs) throws java.sql.SQLException {
return new RuntimeProvider(rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), return new RuntimeProvider(rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")), rs.getBoolean("enabled"), rs.getString("endpoint"), secretCodec.decrypt(rs.getString("api_key")),
rs.getString("agent_id"), rs.getString("agent_version"), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)); rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class));
} }
private ProviderResponse responseById(AihrKnowledgePrincipal principal, Long id) { private ProviderResponse responseById(AihrKnowledgePrincipal principal, Long id) {
List<ProviderResponse> rows = jdbcTemplate.query(""" List<ProviderResponse> rows = jdbcTemplate.query("""
select id, provider_name, provider_code, endpoint, api_key, enabled, last_test_ok, last_test_time select id, provider_name, provider_code, endpoint, api_key, agent_id, agent_version,
enabled, last_test_ok, last_test_time
from aihr_web_search_provider where tenant_id = ? and id = ? from aihr_web_search_provider where tenant_id = ? and id = ?
""", (rs, rowNum) -> new ProviderResponse( """, (rs, rowNum) -> new ProviderResponse(
rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"), rs.getLong("id"), rs.getString("provider_name"), rs.getString("provider_code"),
rs.getString("endpoint"), hasText(rs.getString("api_key")), rs.getBoolean("enabled"), rs.getString("endpoint"), rs.getString("agent_id"), rs.getString("agent_version"),
hasText(rs.getString("api_key")), rs.getBoolean("enabled"),
rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class) rs.getBoolean("last_test_ok"), rs.getObject("last_test_time", LocalDateTime.class)
), principal.tenantId(), id); ), principal.tenantId(), id);
if (rows.isEmpty()) { if (rows.isEmpty()) {
@@ -166,8 +182,8 @@ public class AihrWebSearchProviderService {
if (name.isBlank() || name.length() > 100) { if (name.isBlank() || name.length() > 100) {
throw new IllegalArgumentException("提供方名称不能为空且不能超过100字"); throw new IllegalArgumentException("提供方名称不能为空且不能超过100字");
} }
if (!"tavily".equals(code)) { if (!Set.of("tavily", "qwen").contains(code)) {
throw new IllegalArgumentException("当前仅支持 tavily"); throw new IllegalArgumentException("当前仅支持 Tavily 或千问联网检索");
} }
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) { if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址"); throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址");
@@ -179,7 +195,20 @@ public class AihrWebSearchProviderService {
if (apiKey.length() > 1000) { if (apiKey.length() > 1000) {
throw new IllegalArgumentException("访问密钥长度超限"); throw new IllegalArgumentException("访问密钥长度超限");
} }
return new ProviderData(name, code, endpoint, apiKey); String agentId = clean(request.agentId());
String agentVersion = clean(request.agentVersion()).toLowerCase(Locale.ROOT);
if ("qwen".equals(code)) {
if (!agentId.startsWith("aid-") || agentId.length() > 100) {
throw new IllegalArgumentException("千问 Agent ID 格式不正确");
}
if (!Set.of("beta", "release").contains(agentVersion)) {
throw new IllegalArgumentException("千问 Agent Version 仅支持 beta 或 release");
}
} else {
agentId = "";
agentVersion = "";
}
return new ProviderData(name, code, endpoint, apiKey, agentId, agentVersion);
} }
private void ensureTable() { private void ensureTable() {
@@ -195,6 +224,7 @@ public class AihrWebSearchProviderService {
`id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL, `id` bigint NOT NULL AUTO_INCREMENT, `tenant_id` varchar(20) NOT NULL,
`provider_name` varchar(100) NOT NULL, `provider_code` varchar(40) NOT NULL DEFAULT 'tavily', `provider_name` varchar(100) NOT NULL, `provider_code` varchar(40) NOT NULL DEFAULT 'tavily',
`endpoint` varchar(500) NOT NULL, `api_key` varchar(1000) DEFAULT NULL, `endpoint` varchar(500) NOT NULL, `api_key` varchar(1000) DEFAULT NULL,
`agent_id` varchar(100) DEFAULT NULL, `agent_version` varchar(20) DEFAULT NULL,
`enabled` tinyint NOT NULL DEFAULT 0, `enabled` tinyint NOT NULL DEFAULT 0,
`last_test_ok` tinyint NOT NULL DEFAULT 0, `last_test_ok` tinyint NOT NULL DEFAULT 0,
`last_test_time` datetime DEFAULT NULL, `last_test_time` datetime DEFAULT NULL,
@@ -216,18 +246,23 @@ public class AihrWebSearchProviderService {
return value == null ? "" : value.trim(); return value == null ? "" : value.trim();
} }
public record ProviderRequest(Long id, String name, String providerCode, String endpoint, String apiKey) { public record ProviderRequest(Long id, String name, String providerCode, String endpoint,
@JsonProperty(access = JsonProperty.Access.WRITE_ONLY) String apiKey,
String agentId, String agentVersion) {
} }
public record ProviderResponse(Long id, String name, String providerCode, String endpoint, public record ProviderResponse(Long id, String name, String providerCode, String endpoint,
String agentId, String agentVersion,
boolean configured, boolean enabled, boolean lastTestOk, boolean configured, boolean enabled, boolean lastTestOk,
LocalDateTime lastTestTime) { LocalDateTime lastTestTime) {
} }
record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey, boolean enabled, record RuntimeProvider(Long id, String name, String code, String endpoint, String apiKey,
boolean lastTestOk, LocalDateTime lastTestTime) { String agentId, String agentVersion, boolean enabled, boolean lastTestOk,
LocalDateTime lastTestTime) {
} }
private record ProviderData(String name, String code, String endpoint, String apiKey) { private record ProviderData(String name, String code, String endpoint, String apiKey,
String agentId, String agentVersion) {
} }
} }
@@ -6,6 +6,7 @@ import org.dromara.aihr.knowledge.service.AihrKnowledgePrincipalResolver;
import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest; import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult; import org.dromara.aihr.webai.AihrWebSearchClient.SearchResult;
import org.dromara.aihr.webai.AihrWebSearchClient.WebSource; import org.dromara.aihr.webai.AihrWebSearchClient.WebSource;
import org.dromara.aihr.webai.AihrWebSearchProviderService.ProviderRequest;
import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider; import org.dromara.aihr.webai.AihrWebSearchProviderService.RuntimeProvider;
import org.dromara.common.core.exception.ServiceException; import org.dromara.common.core.exception.ServiceException;
import org.junit.jupiter.api.Tag; import org.junit.jupiter.api.Tag;
@@ -38,6 +39,21 @@ class AihrWebAiSecurityTest {
assertEquals("tavily", properties.getProvider()); assertEquals("tavily", properties.getProvider());
} }
@Test
@Tag("dev")
void providerRequestSecretIsAcceptedButNeverSerialized() throws Exception {
ObjectMapper mapper = new ObjectMapper();
String json = """
{"name":"千问","providerCode":"qwen","endpoint":"https://8.8.8.8/search",
"apiKey":"must-not-reach-logs","agentId":"aid-test","agentVersion":"release"}
""";
ProviderRequest request = mapper.readValue(json, ProviderRequest.class);
assertEquals("must-not-reach-logs", request.apiKey());
assertFalse(mapper.writeValueAsString(request).contains("must-not-reach-logs"));
}
@Test @Test
@Tag("dev") @Tag("dev")
void searchEndpointRejectsLocalAndPrivateNetworks() { void searchEndpointRejectsLocalAndPrivateNetworks() {
@@ -96,6 +112,25 @@ class AihrWebAiSecurityTest {
assertEquals("https://8.8.8.8/article", result.sources().get(0).url()); assertEquals("https://8.8.8.8/article", result.sources().get(0).url());
} }
@Test
@Tag("dev")
void qwenSseKeepsGeneratedAnswerAndPublicToolSources() throws Exception {
String body = """
data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"tool","content":"{\\"results\\":[{\\"title\\":\\"公开资料\\",\\"url\\":\\"https://8.8.8.8/qwen\\",\\"snippet\\":\\"公开摘要\\"},{\\"title\\":\\"内网\\",\\"url\\":\\"http://127.0.0.1/secret\\"}]}"}}]}}
data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"assistant","content":"根据","additional_kwargs":{}}}]}}
data: {"status_code":200,"code":"","output":{"choices":[{"message":{"role":"assistant","content":"公开资料生成答案。","additional_kwargs":{}}}]}}
""";
SearchResult result = AihrWebSearchClient.parseQwenSseResponse(new ObjectMapper(), body, 5);
assertEquals("根据公开资料生成答案。", result.answer());
assertEquals(1, result.sources().size());
assertEquals("公开资料", result.sources().get(0).title());
assertEquals("https://8.8.8.8/qwen", result.sources().get(0).url());
}
@Test @Test
@Tag("dev") @Tag("dev")
void providerRequestsRequireHttpsEvenForPublicHosts() { void providerRequestsRequireHttpsEvenForPublicHosts() {
@@ -103,7 +138,7 @@ class AihrWebAiSecurityTest {
new AihrWebAiProperties(), mock(AihrWebSearchProviderService.class), new ObjectMapper() new AihrWebAiProperties(), mock(AihrWebSearchProviderService.class), new ObjectMapper()
); );
RuntimeProvider provider = new RuntimeProvider(1L, "test", "tavily", RuntimeProvider provider = new RuntimeProvider(1L, "test", "tavily",
"http://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); "http://8.8.8.8/search", "secret", "", "", true, true, LocalDateTime.now());
assertThrows(ServiceException.class, () -> client.search(provider, "test question")); assertThrows(ServiceException.class, () -> client.search(provider, "test question"));
} }
@@ -119,7 +154,7 @@ class AihrWebAiSecurityTest {
"000000", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile" "000000", 7L, "app_user", "staff-7", Set.of("employee"), Set.of(), "mobile"
); );
RuntimeProvider provider = new RuntimeProvider(1L, "Tavily", "tavily", RuntimeProvider provider = new RuntimeProvider(1L, "Tavily", "tavily",
"https://8.8.8.8/search", "secret", true, true, LocalDateTime.now()); "https://8.8.8.8/search", "secret", "", "", true, true, LocalDateTime.now());
when(resolver.current()).thenReturn(principal); when(resolver.current()).thenReturn(principal);
when(searchClient.runtime(principal)).thenReturn(provider); when(searchClient.runtime(principal)).thenReturn(provider);
when(searchClient.search(provider, "物业行业新规")) when(searchClient.search(provider, "物业行业新规"))
@@ -0,0 +1,24 @@
-- 千问联网检索 Agent 配置;API Key 继续使用 aihr_web_search_provider.api_key 加密保存。
SET @aihr_schema = DATABASE();
SET @web_agent_id_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'agent_id'
);
SET @web_agent_id_ddl = IF(@web_agent_id_exists = 0,
'ALTER TABLE aihr_web_search_provider ADD COLUMN agent_id varchar(100) DEFAULT NULL COMMENT ''千问联网检索 Agent ID'' AFTER api_key',
'SELECT 1');
PREPARE aihr_web_agent_id_stmt FROM @web_agent_id_ddl;
EXECUTE aihr_web_agent_id_stmt;
DEALLOCATE PREPARE aihr_web_agent_id_stmt;
SET @web_agent_version_exists = (
SELECT COUNT(*) FROM information_schema.columns
WHERE table_schema = @aihr_schema AND table_name = 'aihr_web_search_provider' AND column_name = 'agent_version'
);
SET @web_agent_version_ddl = IF(@web_agent_version_exists = 0,
'ALTER TABLE aihr_web_search_provider ADD COLUMN agent_version varchar(20) DEFAULT NULL COMMENT ''千问 Agent 版本:beta/release'' AFTER agent_id',
'SELECT 1');
PREPARE aihr_web_agent_version_stmt FROM @web_agent_version_ddl;
EXECUTE aihr_web_agent_version_stmt;
DEALLOCATE PREPARE aihr_web_agent_version_stmt;
+2
View File
@@ -54,6 +54,7 @@ mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aih
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_service_memory_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_work_report_idempotency_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_ai_database_secret_mysql8.sql
mysql --default-character-set=utf8mb4 "$DB_NAME" < backend/script/sql/update/aihr_20260719_web_search_qwen_mysql8.sql
``` ```
顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。 顺序原因:场景补充依赖三张场景/Rubric 基础表;五维迁移依赖场景和 Rubric;岗位/SOP/任务/资格迁移只补正式数据契约,不写业务行,也不代表岗位适用范围、任务规则或资格标准已经获得 HR 确认。住宅 SOP 和 Prompt 迁移只补内置内容,不代表内容已完成业务复核。知识会话迁移只新增短期上下文表,不生成业务对话。排序规则兼容迁移先对齐本批发布表,全量排序规则迁移再统一历史 `aihr_*` 表;两者都读取目标库 `aihr_knowledge_info.tenant_id` 的实际排序规则。服务记忆迁移创建候选、正式记录和版本留痕三张表;工作上报幂等迁移随后只补原表列与唯一索引。两者同样对齐目标排序规则,兼容历史生产库与全新 MySQL 8 数据库。
@@ -105,6 +106,7 @@ WHERE table_schema = DATABASE()
OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by')) OR (table_name = 'aihr_onboard_task' AND column_name IN ('ext_party_id', 'position_code', 'task_type', 'status', 'assign_by'))
OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru')) OR (table_name = 'aihr_qualification_gate' AND column_name IN ('ext_party_id', 'position_code', 'cert_id', 'passed', 'valid_thru'))
OR (table_name = 'aihr_work_report' AND column_name IN ('request_key', 'request_hash')) OR (table_name = 'aihr_work_report' AND column_name IN ('request_key', 'request_hash'))
OR (table_name = 'aihr_web_search_provider' AND column_name IN ('agent_id', 'agent_version'))
) )
ORDER BY table_name, column_name; ORDER BY table_name, column_name;
+8 -3
View File
@@ -5,8 +5,10 @@ type ApiResult<T> = { code: number; msg: string; data: T };
export interface WebSearchProvider { export interface WebSearchProvider {
id: number; id: number;
name: string; name: string;
providerCode: 'tavily' | string; providerCode: 'tavily' | 'qwen';
endpoint: string; endpoint: string;
agentId?: string;
agentVersion?: string;
configured: boolean; configured: boolean;
enabled: boolean; enabled: boolean;
lastTestOk: boolean; lastTestOk: boolean;
@@ -16,9 +18,11 @@ export interface WebSearchProvider {
export interface WebSearchProviderForm { export interface WebSearchProviderForm {
id?: number; id?: number;
name: string; name: string;
providerCode: 'tavily'; providerCode: 'tavily' | 'qwen';
endpoint: string; endpoint: string;
apiKey: string; apiKey: string;
agentId: string;
agentVersion: 'beta' | 'release';
} }
export const listWebSearchProviders = (): Promise<ApiResult<WebSearchProvider[]>> => request({ export const listWebSearchProviders = (): Promise<ApiResult<WebSearchProvider[]>> => request({
@@ -43,5 +47,6 @@ export const toggleWebSearchProvider = (id: number, enabled: boolean): Promise<A
export const testWebSearchProvider = (id: number): Promise<ApiResult<{ ok: boolean; sourceCount: number; message: string }>> => request({ export const testWebSearchProvider = (id: number): Promise<ApiResult<{ ok: boolean; sourceCount: number; message: string }>> => request({
url: `/api/aihr/web-search/providers/${id}/test`, url: `/api/aihr/web-search/providers/${id}/test`,
method: 'post', method: 'post',
headers: { repeatSubmit: false } headers: { repeatSubmit: false },
timeout: 45000
}); });
+54 -14
View File
@@ -21,12 +21,13 @@
<div class="provider-head"> <div class="provider-head">
<div> <div>
<h2>{{ item.name }}</h2> <h2>{{ item.name }}</h2>
<p>{{ item.providerCode }}</p> <p>{{ providerLabel(item.providerCode) }}</p>
</div> </div>
<el-tag :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '已启用' : '未启用' }}</el-tag> <el-tag :type="item.enabled ? 'success' : 'info'">{{ item.enabled ? '已启用' : '未启用' }}</el-tag>
</div> </div>
<dl> <dl>
<div><dt>接口地址</dt><dd>{{ item.endpoint }}</dd></div> <div><dt>接口地址</dt><dd>{{ item.endpoint }}</dd></div>
<div v-if="item.providerCode === 'qwen'"><dt>Agent</dt><dd>{{ item.agentId }}({{ item.agentVersion }})</dd></div>
<div><dt>访问密钥</dt><dd>{{ item.configured ? '已安全保存' : '未配置' }}</dd></div> <div><dt>访问密钥</dt><dd>{{ item.configured ? '已安全保存' : '未配置' }}</dd></div>
<div> <div>
<dt>连接测试</dt> <dt>连接测试</dt>
@@ -52,11 +53,25 @@
<el-empty v-if="!loading && !providers.length" description="尚未配置全网检索提供方" /> <el-empty v-if="!loading && !providers.length" description="尚未配置全网检索提供方" />
</section> </section>
<el-dialog v-model="editorVisible" :title="form.id ? '编辑提供方' : '新增提供方'" width="560px"> <el-dialog v-model="editorVisible" class="web-search-editor" :title="form.id ? '编辑提供方' : '新增提供方'" width="560px">
<el-form label-position="top"> <el-form label-position="top">
<el-form-item label="名称"><el-input v-model="form.name" maxlength="100" placeholder="例如:Tavily 生产检索" /></el-form-item> <el-form-item label="名称"><el-input v-model="form.name" maxlength="100" placeholder="例如:Tavily 生产检索" /></el-form-item>
<el-form-item label="提供方"><el-input model-value="Tavily" disabled /></el-form-item> <el-form-item label="提供方">
<el-form-item label="HTTPS 接口地址"><el-input v-model="form.endpoint" placeholder="https://api.tavily.com/search" /></el-form-item> <el-select v-model="form.providerCode" style="width:100%" @change="changeProvider">
<el-option label="千问联网检索" value="qwen" />
<el-option label="Tavily" value="tavily" />
</el-select>
</el-form-item>
<el-form-item label="HTTPS 接口地址"><el-input v-model="form.endpoint" /></el-form-item>
<template v-if="form.providerCode === 'qwen'">
<el-form-item label="Agent ID"><el-input v-model="form.agentId" placeholder="aid-..." /></el-form-item>
<el-form-item label="Agent Version">
<el-select v-model="form.agentVersion" style="width:100%">
<el-option label="release(已发布)" value="release" />
<el-option label="beta(测试版)" value="beta" />
</el-select>
</el-form-item>
</template>
<el-form-item :label="form.id ? '访问密钥(留空表示不修改)' : '访问密钥'"> <el-form-item :label="form.id ? '访问密钥(留空表示不修改)' : '访问密钥'">
<el-input v-model="form.apiKey" type="password" show-password autocomplete="new-password" /> <el-input v-model="form.apiKey" type="password" show-password autocomplete="new-password" />
</el-form-item> </el-form-item>
@@ -87,11 +102,19 @@ const saving = ref(false);
const testingId = ref(0); const testingId = ref(0);
const togglingId = ref(0); const togglingId = ref(0);
const editorVisible = ref(false); const editorVisible = ref(false);
const providerDefaults = {
qwen: {
name: '千问联网检索',
endpoint: 'https://dashscope.aliyuncs.com/api/v2/apps/web-search-agent/chat/completions'
},
tavily: { name: 'Tavily 全网检索', endpoint: 'https://api.tavily.com/search' }
} as const;
const emptyForm = (): WebSearchProviderForm => ({ const emptyForm = (): WebSearchProviderForm => ({
name: 'Tavily 全网检索', ...providerDefaults.qwen,
providerCode: 'tavily', providerCode: 'qwen',
endpoint: 'https://api.tavily.com/search', apiKey: '',
apiKey: '' agentId: '',
agentVersion: 'release'
}); });
const form = reactive<WebSearchProviderForm>(emptyForm()); const form = reactive<WebSearchProviderForm>(emptyForm());
@@ -111,21 +134,29 @@ const openEditor = (item?: WebSearchProvider) => {
Object.assign(form, emptyForm(), item ? { Object.assign(form, emptyForm(), item ? {
id: item.id, id: item.id,
name: item.name, name: item.name,
providerCode: 'tavily', providerCode: item.providerCode === 'qwen' ? 'qwen' : 'tavily',
endpoint: item.endpoint, endpoint: item.endpoint,
apiKey: '' apiKey: '',
agentId: item.agentId || '',
agentVersion: item.agentVersion === 'beta' ? 'beta' : 'release'
} : {}); } : {});
editorVisible.value = true; editorVisible.value = true;
}; };
const submit = async () => { const submit = async () => {
if (!form.name.trim() || !form.endpoint.trim() || (!form.id && !form.apiKey.trim())) { const qwenIncomplete = form.providerCode === 'qwen' && (!form.agentId.trim() || !form.agentVersion);
ElMessage.warning('请完整填写名称、接口地址和访问密钥'); if (!form.name.trim() || !form.endpoint.trim() || (!form.id && !form.apiKey.trim()) || qwenIncomplete) {
ElMessage.warning('请完整填写名称、接口地址、访问密钥和 Agent 信息');
return; return;
} }
saving.value = true; saving.value = true;
try { try {
await saveWebSearchProvider({ ...form, name: form.name.trim(), endpoint: form.endpoint.trim() }); await saveWebSearchProvider({
...form,
name: form.name.trim(),
endpoint: form.endpoint.trim(),
agentId: form.agentId.trim()
});
editorVisible.value = false; editorVisible.value = false;
ElMessage.success('配置已保存,启用前请先执行连接测试'); ElMessage.success('配置已保存,启用前请先执行连接测试');
await load(); await load();
@@ -134,6 +165,15 @@ const submit = async () => {
} }
}; };
const changeProvider = (code: 'tavily' | 'qwen') => {
Object.assign(form, providerDefaults[code], {
agentId: code === 'qwen' ? form.agentId : '',
agentVersion: code === 'qwen' ? form.agentVersion : 'release'
});
};
const providerLabel = (code: string) => code === 'qwen' ? '千问联网检索' : 'Tavily';
const testProvider = async (item: WebSearchProvider) => { const testProvider = async (item: WebSearchProvider) => {
testingId.value = item.id; testingId.value = item.id;
try { try {
@@ -162,5 +202,5 @@ onMounted(load);
</script> </script>
<style scoped> <style scoped>
.web-search-page{padding:24px;color:#172033}.page-head,.provider-head,.provider-actions{display:flex;gap:12px;align-items:center}.page-head,.provider-head{justify-content:space-between}.page-head{margin-bottom:18px}.page-head h1,.provider-head h2{margin:0}.page-head h1{font-size:28px}.page-head p,.provider-head p{margin:7px 0 0;color:#667085}.provider-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px;margin-top:18px}.provider-card{padding:20px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}dl{display:grid;gap:12px;margin:20px 0}dl div{display:grid;grid-template-columns:90px minmax(0,1fr);gap:12px}dt{color:#667085}dd{margin:0;overflow-wrap:anywhere}.test-time{margin-left:8px;color:#667085;font-size:12px}.provider-actions{justify-content:flex-end;flex-wrap:wrap}@media(max-width:760px){.web-search-page{padding:16px}.page-head{align-items:flex-start}.provider-grid{grid-template-columns:1fr}.provider-actions{justify-content:flex-start}} .web-search-page{padding:24px;color:#172033}.page-head,.provider-head,.provider-actions{display:flex;gap:12px;align-items:center}.page-head,.provider-head{justify-content:space-between}.page-head{margin-bottom:18px}.page-head h1,.provider-head h2{margin:0}.page-head h1{font-size:28px}.page-head p,.provider-head p{margin:7px 0 0;color:#667085}.provider-grid{display:grid;grid-template-columns:repeat(auto-fit,minmax(360px,1fr));gap:16px;margin-top:18px}.provider-card{padding:20px;border:1px solid #e4e7ec;border-radius:14px;background:#fff;box-shadow:0 4px 18px rgba(16,24,40,.05)}dl{display:grid;gap:12px;margin:20px 0}dl div{display:grid;grid-template-columns:90px minmax(0,1fr);gap:12px}dt{color:#667085}dd{margin:0;overflow-wrap:anywhere}.test-time{margin-left:8px;color:#667085;font-size:12px}.provider-actions{justify-content:flex-end;flex-wrap:wrap}:deep(.web-search-editor .el-dialog__body){max-height:calc(100vh - 180px);overflow-y:auto}@media(max-width:760px){.web-search-page{padding:16px}.page-head{align-items:flex-start}.provider-grid{grid-template-columns:1fr}.provider-actions{justify-content:flex-start}}
</style> </style>