Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions framework/fel/java/fel-community/pom.xml
Original file line number Diff line number Diff line change
Expand Up @@ -17,5 +17,6 @@
<modules>
<module>model-openai</module>
<module>tokenizer-hanlp</module>
<module>tool-youcom-search</module>
</modules>
</project>
126 changes: 126 additions & 0 deletions framework/fel/java/fel-community/tool-youcom-search/pom.xml
Original file line number Diff line number Diff line change
@@ -0,0 +1,126 @@
<?xml version="1.0" encoding="UTF-8"?>
<project xmlns="http://maven.apache.org/POM/4.0.0" xmlns:xsi="http://www.w3.org/2001/XMLSchema-instance"
xsi:schemaLocation="http://maven.apache.org/POM/4.0.0 http://maven.apache.org/xsd/maven-4.0.0.xsd">
<modelVersion>4.0.0</modelVersion>

<parent>
<groupId>org.fitframework.fel</groupId>
<artifactId>fel-community-parent</artifactId>
<version>3.7.0-SNAPSHOT</version>
</parent>

<artifactId>fel-tool-youcom-search-plugin</artifactId>

<name>FEL Tool You.com Search</name>

<dependencies>
<!-- FIT -->
<dependency>
<groupId>org.fitframework</groupId>
<artifactId>fit-api</artifactId>
</dependency>
<dependency>
<groupId>org.fitframework</groupId>
<artifactId>fit-util</artifactId>
</dependency>
<dependency>
<groupId>org.fitframework.service</groupId>
<artifactId>fit-http-classic</artifactId>
</dependency>
<dependency>
<groupId>org.fitframework.service</groupId>
<artifactId>fit-http-protocol</artifactId>
</dependency>

<!-- FEL -->
<dependency>
<groupId>org.fitframework.fel</groupId>
<artifactId>tool-service</artifactId>
</dependency>

<!-- Test Plugins -->
<dependency>
<groupId>org.fitframework.plugin</groupId>
<artifactId>fit-message-serializer-json-jackson</artifactId>
<version>${fit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.fitframework.plugin</groupId>
<artifactId>fit-http-client-okhttp</artifactId>
<version>${fit.version}</version>
<scope>test</scope>
</dependency>
<dependency>
<groupId>org.fitframework.plugin</groupId>
<artifactId>fit-value-fastjson</artifactId>
<version>${fit.version}</version>
<scope>test</scope>
</dependency>

<!-- Test -->
<dependency>
<groupId>org.junit.jupiter</groupId>
<artifactId>junit-jupiter</artifactId>
</dependency>
<dependency>
<groupId>org.assertj</groupId>
<artifactId>assertj-core</artifactId>
</dependency>
<dependency>
<groupId>org.mockito</groupId>
<artifactId>mockito-core</artifactId>
</dependency>
<dependency>
<groupId>org.fitframework</groupId>
<artifactId>fit-test-framework</artifactId>
</dependency>
</dependencies>

<build>
<plugins>
<plugin>
<groupId>org.fitframework</groupId>
<artifactId>fit-build-maven-plugin</artifactId>
<version>${fit.version}</version>
<configuration>
<category>system</category>
<level>7</level>
</configuration>
<executions>
<execution>
<id>build-plugin</id>
<goals>
<goal>build-plugin</goal>
</goals>
</execution>
<execution>
<id>package-plugin</id>
<goals>
<goal>package-plugin</goal>
</goals>
</execution>
</executions>
</plugin>
<plugin>
<groupId>org.apache.maven.plugins</groupId>
<artifactId>maven-antrun-plugin</artifactId>
<version>${maven.antrun.version}</version>
<executions>
<execution>
<phase>package</phase>
<configuration>
<target>
<copy file="${project.build.directory}/${project.build.finalName}.jar"
todir="../../../../../build/plugins"/>
</target>
</configuration>
<goals>
<goal>run</goal>
</goals>
</execution>
</executions>
</plugin>
</plugins>
</build>
</project>
Original file line number Diff line number Diff line change
@@ -0,0 +1,38 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved.
* This file is a part of the ModelEngine Project.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

package modelengine.fel.community.tool.youcom;

import modelengine.fel.tool.annotation.Group;
import modelengine.fel.tool.annotation.ToolMethod;
import modelengine.fitframework.annotation.Genericable;
import modelengine.fitframework.annotation.Property;

/**
* 表示 You.com 联网搜索工具的接口定义。
* <p>该工具通过 You.com 的搜索接口,为智能体提供联网检索能力,返回格式化后的中文文本结果。</p>
*
* @author Amos Mwangi
* @since 2026-07-08
*/
@Group(name = "youcom_search_service")
public interface YouComSearchService {
/**
* 使用 You.com 检索互联网上的相关信息。
*
* @param query 表示检索关键字的 {@link String}。
* @param count 表示期望返回结果条数的 {@link Integer},默认值为 {@code 5},实际会被限制在
* {@code 1} 到 {@code 20} 之间。
* @param freshness 表示结果时效性过滤条件的 {@link String},可选,取值参考 You.com 官方文档
* (例如 {@code day}、{@code week}、{@code month}、{@code year})。
* @return 表示格式化后的检索结果的 {@link String};当密钥未配置或调用失败时,返回对应的中文提示信息,不会抛出异常。
*/
@ToolMethod(namespace = "youcom", name = "web_search", description = "使用 You.com 联网搜索获取实时信息")
@Genericable("modelengine.fel.community.tool.youcom.web_search")
String webSearch(@Property(description = "检索关键字", required = true) String query,
@Property(description = "期望返回的结果条数,默认 5 条,最多 20 条", defaultValue = "5") Integer count,
@Property(description = "结果时效性,可选,如 day、week、month、year") String freshness);
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,221 @@
/*---------------------------------------------------------------------------------------------
* Copyright (c) 2026 Huawei Technologies Co., Ltd. All rights reserved.
* This file is a part of the ModelEngine Project.
* Licensed under the MIT License. See License.txt in the project root for license information.
*--------------------------------------------------------------------------------------------*/

package modelengine.fel.community.tool.youcom;

import static modelengine.fitframework.inspection.Validation.notNull;

import modelengine.fel.community.tool.youcom.api.YouComSearchApi;
import modelengine.fel.community.tool.youcom.config.YouComSearchConfig;
import modelengine.fel.community.tool.youcom.entity.YouComSearchResponse;
import modelengine.fel.community.tool.youcom.entity.YouComSearchResultItem;
import modelengine.fit.http.client.HttpClassicClient;
import modelengine.fit.http.client.HttpClassicClientFactory;
import modelengine.fit.http.client.HttpClassicClientRequest;
import modelengine.fit.http.client.HttpClassicClientResponse;
import modelengine.fit.http.entity.Entity;
import modelengine.fit.http.entity.ObjectEntity;
import modelengine.fit.http.entity.TextEntity;
import modelengine.fit.http.protocol.HttpRequestMethod;
import modelengine.fitframework.annotation.Component;
import modelengine.fitframework.annotation.Fit;
import modelengine.fitframework.annotation.Fitable;
import modelengine.fitframework.exception.ClientException;
import modelengine.fitframework.exception.TimeoutException;
import modelengine.fitframework.log.Logger;
import modelengine.fitframework.resource.UrlUtils;
import modelengine.fitframework.serialization.ObjectSerializer;
import modelengine.fitframework.serialization.SerializationException;
import modelengine.fitframework.util.CollectionUtils;
import modelengine.fitframework.util.ObjectUtils;
import modelengine.fitframework.util.StringUtils;

import java.io.IOException;
import java.util.ArrayList;
import java.util.List;
import java.util.function.Supplier;

/**
* 表示 {@link YouComSearchService} 的默认实现。
* <p>密钥仅从环境变量 {@value YouComSearchApi#API_KEY_ENV_NAME} 读取;当密钥缺失或请求失败时,
* 均返回中文提示字符串,不会抛出异常,避免影响宿主智能体的正常流程。</p>
*
* @author Amos Mwangi
* @since 2026-07-08
*/
@Component
public class YouComSearchServiceImpl implements YouComSearchService {
private static final Logger log = Logger.get(YouComSearchServiceImpl.class);
private static final int DEFAULT_COUNT = 5;
private static final int MIN_COUNT = 1;
private static final int MAX_COUNT = 20;
private static final String NO_KEY_MESSAGE =
"未检测到 You.com 搜索密钥,请设置环境变量 YDC_API_KEY 后重试。";
private static final String TIMEOUT_MESSAGE = "You.com 搜索请求超时,请稍后重试。";
private static final String NETWORK_ERROR_MESSAGE = "You.com 搜索请求失败,请检查网络连接后重试。";
private static final String PARSE_ERROR_MESSAGE = "You.com 响应解析失败,请稍后重试。";
private static final String EMPTY_RESULT_MESSAGE = "You.com 未检索到与查询相关的结果。";

private final HttpClassicClientFactory httpClientFactory;
private final YouComSearchConfig config;
private final ObjectSerializer serializer;
private final Supplier<String> apiKeyProvider;

/**
* 创建 {@link YouComSearchServiceImpl} 的实例。
*
* @param httpClientFactory 表示 http 客户端工厂的 {@link HttpClassicClientFactory}。
* @param config 表示 You.com 搜索工具可选配置的 {@link YouComSearchConfig}。
* @param serializer 表示对象序列化器的 {@link ObjectSerializer}。
* @throws IllegalArgumentException 当 {@code httpClientFactory}、{@code config}、{@code serializer}
* 为 {@code null} 时。
*/
public YouComSearchServiceImpl(HttpClassicClientFactory httpClientFactory, YouComSearchConfig config,
@Fit(alias = "json") ObjectSerializer serializer) {
this(httpClientFactory, config, serializer, () -> System.getenv(YouComSearchApi.API_KEY_ENV_NAME));
}

/**
* 创建 {@link YouComSearchServiceImpl} 的实例,允许自定义密钥来源,供单元测试使用。
*
* @param httpClientFactory 表示 http 客户端工厂的 {@link HttpClassicClientFactory}。
* @param config 表示 You.com 搜索工具可选配置的 {@link YouComSearchConfig}。
* @param serializer 表示对象序列化器的 {@link ObjectSerializer}。
* @param apiKeyProvider 表示密钥提供者的 {@link Supplier}{@code <}{@link String}{@code >}。
* @throws IllegalArgumentException 当任意参数为 {@code null} 时。
*/
YouComSearchServiceImpl(HttpClassicClientFactory httpClientFactory, YouComSearchConfig config,
ObjectSerializer serializer, Supplier<String> apiKeyProvider) {
this.httpClientFactory = notNull(httpClientFactory, "The http client factory cannot be null.");
this.config = notNull(config, "The config cannot be null.");
this.serializer = notNull(serializer, "The serializer cannot be null.");
this.apiKeyProvider = notNull(apiKeyProvider, "The api key provider cannot be null.");
}

@Override
@Fitable("default")
public String webSearch(String query, Integer count, String freshness) {
notNull(query, "The query cannot be null.");
String apiKey = this.apiKeyProvider.get();
if (StringUtils.isBlank(apiKey)) {
return NO_KEY_MESSAGE;
}
int actualCount = normalizeCount(count);
String url = buildUrl(this.config.getApiBase(), query, actualCount, freshness);
HttpClassicClient client = this.httpClientFactory.create(HttpClassicClientFactory.Config.builder()
.connectTimeout(this.config.getConnectTimeout())
.socketTimeout(this.config.getReadTimeout())
.build());
HttpClassicClientRequest request = client.createRequest(HttpRequestMethod.GET, url);
request.headers().set(YouComSearchApi.API_KEY_HEADER, apiKey);
try (HttpClassicClientResponse<YouComSearchResponse> response =
request.exchange(YouComSearchResponse.class)) {
if (response.statusCode() != 200) {
log.warn("Failed to call You.com search api. [code={}, reason={}]",
response.statusCode(),
response.reasonPhrase());
return mapErrorMessage(response.statusCode());
}
return formatSuccess(this.parseResponse(response), actualCount);
} catch (TimeoutException e) {
log.warn("You.com search request timed out.", e);
return TIMEOUT_MESSAGE;
} catch (SerializationException e) {
log.warn("Failed to parse You.com search response.", e);
return PARSE_ERROR_MESSAGE;
} catch (ClientException | IOException e) {
log.warn("Failed to request You.com search api.", e);
return NETWORK_ERROR_MESSAGE;
} catch (RuntimeException e) {
log.warn("Failed to handle You.com search response.", e);
return PARSE_ERROR_MESSAGE;
}
}

private YouComSearchResponse parseResponse(HttpClassicClientResponse<YouComSearchResponse> response) {
Entity entity = response.entity().orElse(null);
if (entity instanceof ObjectEntity) {
return ObjectUtils.cast(((ObjectEntity<?>) entity).object());
}
if (entity instanceof TextEntity) {
return this.serializer.deserialize(((TextEntity) entity).content(), YouComSearchResponse.class);
}
return null;
}

private static int normalizeCount(Integer count) {
if (count == null || count < MIN_COUNT) {
return DEFAULT_COUNT;
}
return Math.min(count, MAX_COUNT);
}

private static String buildUrl(String apiBase, String query, int count, String freshness) {
StringBuilder url = new StringBuilder(UrlUtils.combine(apiBase, YouComSearchApi.SEARCH_ENDPOINT));
url.append('?')
.append(YouComSearchApi.PARAM_QUERY)
.append('=')
.append(UrlUtils.encodeForm(query))
.append('&')
.append(YouComSearchApi.PARAM_COUNT)
.append('=')
.append(count);
if (StringUtils.isNotBlank(freshness)) {
url.append('&')
.append(YouComSearchApi.PARAM_FRESHNESS)
.append('=')
.append(UrlUtils.encodeForm(freshness));
}
return url.toString();
}

private static String mapErrorMessage(int statusCode) {
switch (statusCode) {
case 401:
return "You.com 鉴权失败,请检查 YDC_API_KEY 是否正确。";
case 403:
return "You.com 请求被拒绝,请检查服务地址或访问权限。";
case 422:
return "You.com 请求参数不合法,请检查查询参数组合。";
case 429:
return "You.com 请求过于频繁,请稍后重试。";
default:
if (statusCode >= 500) {
return "You.com 服务暂时不可用,请稍后重试。";
}
return StringUtils.format("You.com 搜索失败(状态码:{0}),请稍后重试。", statusCode);
}
}

private static String formatSuccess(YouComSearchResponse response, int requestedCount) {
List<YouComSearchResultItem> merged = new ArrayList<>();
if (response != null && response.results() != null) {
if (CollectionUtils.isNotEmpty(response.results().web())) {
merged.addAll(response.results().web());
}
if (CollectionUtils.isNotEmpty(response.results().news())) {
merged.addAll(response.results().news());
}
}
if (merged.isEmpty()) {
return EMPTY_RESULT_MESSAGE;
}
List<YouComSearchResultItem> limited =
merged.size() > requestedCount ? merged.subList(0, requestedCount) : merged;
StringBuilder text = new StringBuilder();
text.append(StringUtils.format("检索完成,共 {0} 条结果:", limited.size()));
for (int i = 0; i < limited.size(); i++) {
YouComSearchResultItem item = limited.get(i);
text.append('\n')
.append(StringUtils.format("{0}. 标题:{1}", i + 1, StringUtils.blankIf(item.title(), "无标题")));
text.append('\n').append(StringUtils.format(" 链接:{0}", StringUtils.blankIf(item.url(), "无")));
if (CollectionUtils.isNotEmpty(item.snippets())) {
text.append('\n').append(" 摘录:").append(String.join(" ", item.snippets()));
}
}
return text.toString();
}
}
Loading
Loading