feat(web-ai): support qwen image search safely
This commit is contained in:
+10
@@ -10,6 +10,7 @@ public final class AihrCommunityPolicy {
|
|||||||
private static final Set<String> MODERATOR_ROLES = Set.of(
|
private static final Set<String> MODERATOR_ROLES = Set.of(
|
||||||
"supervisor", "manager", "project_manager", "admin", "superadmin", "hr_operator"
|
"supervisor", "manager", "project_manager", "admin", "superadmin", "hr_operator"
|
||||||
);
|
);
|
||||||
|
private static final Set<String> WEB_SEARCH_PROVIDER_ROLES = Set.of("admin", "superadmin", "hr_operator");
|
||||||
|
|
||||||
private AihrCommunityPolicy() {
|
private AihrCommunityPolicy() {
|
||||||
}
|
}
|
||||||
@@ -32,6 +33,15 @@ public final class AihrCommunityPolicy {
|
|||||||
.anyMatch(MODERATOR_ROLES::contains);
|
.anyMatch(MODERATOR_ROLES::contains);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
public static boolean canManageWebSearchProviders(AihrKnowledgePrincipal principal) {
|
||||||
|
if (principal == null) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
return principal.roles().stream()
|
||||||
|
.map(role -> role == null ? "" : role.trim().toLowerCase(Locale.ROOT))
|
||||||
|
.anyMatch(WEB_SEARCH_PROVIDER_ROLES::contains);
|
||||||
|
}
|
||||||
|
|
||||||
public static String moderatedStatus(String decision) {
|
public static String moderatedStatus(String decision) {
|
||||||
String normalized = decision == null ? "" : decision.trim().toLowerCase(Locale.ROOT);
|
String normalized = decision == null ? "" : decision.trim().toLowerCase(Locale.ROOT);
|
||||||
return switch (normalized) {
|
return switch (normalized) {
|
||||||
|
|||||||
+11
@@ -6,11 +6,15 @@ import org.dromara.aihr.webai.AihrWebAiDto.QueryRequest;
|
|||||||
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
|
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
|
||||||
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
|
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
|
||||||
import org.dromara.common.core.domain.R;
|
import org.dromara.common.core.domain.R;
|
||||||
|
import org.springframework.http.MediaType;
|
||||||
import org.springframework.web.bind.annotation.PostMapping;
|
import org.springframework.web.bind.annotation.PostMapping;
|
||||||
import org.springframework.web.bind.annotation.GetMapping;
|
import org.springframework.web.bind.annotation.GetMapping;
|
||||||
import org.springframework.web.bind.annotation.RequestBody;
|
import org.springframework.web.bind.annotation.RequestBody;
|
||||||
import org.springframework.web.bind.annotation.RequestMapping;
|
import org.springframework.web.bind.annotation.RequestMapping;
|
||||||
|
import org.springframework.web.bind.annotation.RequestParam;
|
||||||
|
import org.springframework.web.bind.annotation.RequestPart;
|
||||||
import org.springframework.web.bind.annotation.RestController;
|
import org.springframework.web.bind.annotation.RestController;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
@RestController
|
@RestController
|
||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
@@ -29,4 +33,11 @@ public class AihrWebAiController {
|
|||||||
public R<QueryResponse> query(@RequestBody QueryRequest request) {
|
public R<QueryResponse> query(@RequestBody QueryRequest request) {
|
||||||
return R.ok(webAiService.query(request));
|
return R.ok(webAiService.query(request));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@PostMapping(value = "/query-image", consumes = MediaType.MULTIPART_FORM_DATA_VALUE)
|
||||||
|
public R<QueryResponse> queryImage(@RequestPart("file") MultipartFile file,
|
||||||
|
@RequestParam("question") String question,
|
||||||
|
@RequestParam("consent") Boolean consent) {
|
||||||
|
return R.ok(webAiService.queryImage(new QueryRequest(question, consent), file));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+1
@@ -21,6 +21,7 @@ public final class AihrWebAiDto {
|
|||||||
}
|
}
|
||||||
|
|
||||||
public record CapabilitiesResponse(boolean enabled,
|
public record CapabilitiesResponse(boolean enabled,
|
||||||
|
boolean supportsImage,
|
||||||
String provider,
|
String provider,
|
||||||
boolean requiresConsent,
|
boolean requiresConsent,
|
||||||
int maxQuestionLength,
|
int maxQuestionLength,
|
||||||
|
|||||||
+148
@@ -0,0 +1,148 @@
|
|||||||
|
package org.dromara.aihr.webai;
|
||||||
|
|
||||||
|
import jakarta.servlet.FilterChain;
|
||||||
|
import jakarta.servlet.ReadListener;
|
||||||
|
import jakarta.servlet.ServletException;
|
||||||
|
import jakarta.servlet.ServletInputStream;
|
||||||
|
import jakarta.servlet.http.HttpServletRequest;
|
||||||
|
import jakarta.servlet.http.HttpServletRequestWrapper;
|
||||||
|
import jakarta.servlet.http.HttpServletResponse;
|
||||||
|
import org.springframework.core.Ordered;
|
||||||
|
import org.springframework.core.annotation.Order;
|
||||||
|
import org.springframework.stereotype.Component;
|
||||||
|
import org.springframework.web.filter.OncePerRequestFilter;
|
||||||
|
|
||||||
|
import java.io.BufferedReader;
|
||||||
|
import java.io.IOException;
|
||||||
|
import java.io.InputStreamReader;
|
||||||
|
import java.nio.charset.StandardCharsets;
|
||||||
|
|
||||||
|
/** Limits this high-cost multipart route before Spring can spool the upload to disk. */
|
||||||
|
@Component
|
||||||
|
@Order(Ordered.HIGHEST_PRECEDENCE + 20)
|
||||||
|
public class AihrWebAiImageUploadLimitFilter extends OncePerRequestFilter {
|
||||||
|
|
||||||
|
static final long MAX_FILE_BYTES = 4L * 1024 * 1024;
|
||||||
|
private static final long MAX_REQUEST_BYTES = MAX_FILE_BYTES + 64L * 1024;
|
||||||
|
private static final String ROUTE = "/api/aihr/web-ai/query-image";
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected boolean shouldNotFilter(HttpServletRequest request) {
|
||||||
|
return !"POST".equalsIgnoreCase(request.getMethod()) || !request.getRequestURI().endsWith(ROUTE);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
protected void doFilterInternal(HttpServletRequest request, HttpServletResponse response, FilterChain filterChain)
|
||||||
|
throws ServletException, IOException {
|
||||||
|
if (request.getContentLengthLong() > MAX_REQUEST_BYTES) {
|
||||||
|
tooLarge(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
filterChain.doFilter(new LimitedRequest(request, MAX_REQUEST_BYTES), response);
|
||||||
|
} catch (ServletException | RuntimeException error) {
|
||||||
|
if (causedByLimit(error)) {
|
||||||
|
tooLarge(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
} catch (IOException error) {
|
||||||
|
if (causedByLimit(error)) {
|
||||||
|
tooLarge(response);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static boolean causedByLimit(Throwable error) {
|
||||||
|
Throwable current = error;
|
||||||
|
while (current != null) {
|
||||||
|
if (current instanceof RequestTooLargeException) {
|
||||||
|
return true;
|
||||||
|
}
|
||||||
|
current = current.getCause();
|
||||||
|
}
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void tooLarge(HttpServletResponse response) throws IOException {
|
||||||
|
if (response.isCommitted()) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
response.resetBuffer();
|
||||||
|
response.setStatus(HttpServletResponse.SC_REQUEST_ENTITY_TOO_LARGE);
|
||||||
|
response.setContentType("application/json;charset=UTF-8");
|
||||||
|
response.getWriter().write("{\"code\":413,\"msg\":\"图片不能超过 4MB\"}");
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class LimitedRequest extends HttpServletRequestWrapper {
|
||||||
|
private final long maxBytes;
|
||||||
|
|
||||||
|
private LimitedRequest(HttpServletRequest request, long maxBytes) {
|
||||||
|
super(request);
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public ServletInputStream getInputStream() throws IOException {
|
||||||
|
return new LimitedInputStream(super.getInputStream(), maxBytes);
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public BufferedReader getReader() throws IOException {
|
||||||
|
String charset = getCharacterEncoding();
|
||||||
|
return new BufferedReader(new InputStreamReader(getInputStream(), charset == null ? StandardCharsets.UTF_8 : java.nio.charset.Charset.forName(charset)));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class LimitedInputStream extends ServletInputStream {
|
||||||
|
private final ServletInputStream delegate;
|
||||||
|
private final long maxBytes;
|
||||||
|
private long bytesRead;
|
||||||
|
|
||||||
|
private LimitedInputStream(ServletInputStream delegate, long maxBytes) {
|
||||||
|
this.delegate = delegate;
|
||||||
|
this.maxBytes = maxBytes;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read() throws IOException {
|
||||||
|
int value = delegate.read();
|
||||||
|
if (value >= 0) count(1);
|
||||||
|
return value;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public int read(byte[] bytes, int offset, int length) throws IOException {
|
||||||
|
int read = delegate.read(bytes, offset, length);
|
||||||
|
if (read > 0) count(read);
|
||||||
|
return read;
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isFinished() {
|
||||||
|
return delegate.isFinished();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public boolean isReady() {
|
||||||
|
return delegate.isReady();
|
||||||
|
}
|
||||||
|
|
||||||
|
@Override
|
||||||
|
public void setReadListener(ReadListener readListener) {
|
||||||
|
delegate.setReadListener(readListener);
|
||||||
|
}
|
||||||
|
|
||||||
|
private void count(int amount) throws RequestTooLargeException {
|
||||||
|
bytesRead += amount;
|
||||||
|
if (bytesRead > maxBytes) {
|
||||||
|
throw new RequestTooLargeException();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static final class RequestTooLargeException extends IOException {
|
||||||
|
}
|
||||||
|
}
|
||||||
+54
-8
@@ -7,8 +7,12 @@ 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.AihrWebAiDto.QueryResponse;
|
import org.dromara.aihr.webai.AihrWebAiDto.QueryResponse;
|
||||||
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
|
import org.dromara.aihr.webai.AihrWebAiDto.CapabilitiesResponse;
|
||||||
|
import org.dromara.common.core.exception.ServiceException;
|
||||||
|
import org.dromara.common.redis.utils.RedisUtils;
|
||||||
|
import org.redisson.api.RateType;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
|
|
||||||
@@ -25,6 +29,14 @@ public class AihrWebAiService {
|
|||||||
private volatile boolean auditTableReady;
|
private volatile boolean auditTableReady;
|
||||||
|
|
||||||
public QueryResponse query(QueryRequest request) {
|
public QueryResponse query(QueryRequest request) {
|
||||||
|
return query(request, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
public QueryResponse queryImage(QueryRequest request, MultipartFile image) {
|
||||||
|
return query(request, image);
|
||||||
|
}
|
||||||
|
|
||||||
|
private QueryResponse query(QueryRequest request, MultipartFile image) {
|
||||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||||
String rawQuestion = normalizeQuestion(request == null ? null : request.question());
|
String rawQuestion = normalizeQuestion(request == null ? null : request.question());
|
||||||
String question = AihrWebPrivacy.forExternalSearch(rawQuestion);
|
String question = AihrWebPrivacy.forExternalSearch(rawQuestion);
|
||||||
@@ -32,33 +44,50 @@ public class AihrWebAiService {
|
|||||||
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
|
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
|
||||||
String provider = runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name();
|
String provider = runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name();
|
||||||
if (request == null || !Boolean.TRUE.equals(request.consent())) {
|
if (request == null || !Boolean.TRUE.equals(request.consent())) {
|
||||||
audit(principal, question, provider, "CONSENT_REQUIRED", 0);
|
audit(principal, question, provider, auditStatus(image, "CONSENT_REQUIRED"), 0);
|
||||||
return new QueryResponse("web", "CONSENT_REQUIRED", "", List.of(), provider,
|
return new QueryResponse("web", "CONSENT_REQUIRED", "", List.of(), provider,
|
||||||
"请确认同意将脱敏后的问题发送到外部检索服务", redacted, true);
|
"请确认同意将脱敏后的问题发送到外部检索服务", redacted, true);
|
||||||
}
|
}
|
||||||
if (runtime == null) {
|
if (runtime == null) {
|
||||||
audit(principal, question, provider, "DISABLED", 0);
|
audit(principal, question, provider, auditStatus(image, "DISABLED"), 0);
|
||||||
return new QueryResponse("web", "DISABLED", "", List.of(), provider,
|
return new QueryResponse("web", "DISABLED", "", List.of(), provider,
|
||||||
"全网检索默认关闭,请由管理员配置合规的外部检索服务后启用", redacted, true);
|
"全网检索默认关闭,请由管理员配置合规的外部检索服务后启用", redacted, true);
|
||||||
}
|
}
|
||||||
|
if (image != null && !"qwen".equalsIgnoreCase(runtime.code())) {
|
||||||
|
audit(principal, question, provider, "IMAGE_UNSUPPORTED", 0);
|
||||||
|
return new QueryResponse("web", "IMAGE_UNSUPPORTED", "", List.of(), provider,
|
||||||
|
"当前全网检索提供方不支持图片识图,请切换至千问联网检索", redacted, true);
|
||||||
|
}
|
||||||
|
if (image != null) {
|
||||||
|
enforceImageRateLimit(principal);
|
||||||
|
}
|
||||||
|
AihrWebSearchClient.ImageInput imageInput;
|
||||||
try {
|
try {
|
||||||
AihrWebSearchClient.SearchResult result = searchClient.search(runtime, question);
|
imageInput = image == null ? null : searchClient.imageInput(image);
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
audit(principal, question, provider, "IMAGE_REJECTED", 0);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
AihrWebSearchClient.SearchResult result = imageInput == null
|
||||||
|
? searchClient.search(runtime, question)
|
||||||
|
: searchClient.searchWithImage(runtime, question, imageInput);
|
||||||
if (result.sources().isEmpty()) {
|
if (result.sources().isEmpty()) {
|
||||||
audit(principal, question, provider, "NO_SOURCES", 0);
|
audit(principal, question, provider, auditStatus(image, "NO_SOURCES"), 0);
|
||||||
return new QueryResponse("web", "NO_SOURCES", "", List.of(), provider,
|
return new QueryResponse("web", "NO_SOURCES", "", List.of(), provider,
|
||||||
"没有找到可公开核验的来源,本次不生成答案", redacted, true);
|
"没有找到可公开核验的来源,本次不生成答案", redacted, true);
|
||||||
}
|
}
|
||||||
if (result.answer() == null || result.answer().isBlank()) {
|
if (result.answer() == null || result.answer().isBlank()) {
|
||||||
audit(principal, question, provider, "NO_ANSWER", result.sources().size());
|
audit(principal, question, provider, auditStatus(image, "NO_ANSWER"), result.sources().size());
|
||||||
return new QueryResponse("web", "NO_ANSWER", "", result.sources(), provider,
|
return new QueryResponse("web", "NO_ANSWER", "", result.sources(), provider,
|
||||||
"已找到公开来源,但外部服务未返回可核验答案;请直接查看来源", redacted, true);
|
"已找到公开来源,但外部服务未返回可核验答案;请直接查看来源", redacted, true);
|
||||||
}
|
}
|
||||||
audit(principal, question, provider, "OK", result.sources().size());
|
audit(principal, question, provider, auditStatus(image, "OK"), result.sources().size());
|
||||||
return new QueryResponse("web", "OK", result.answer(), result.sources(), provider,
|
return new QueryResponse("web", "OK", result.answer(), result.sources(), provider,
|
||||||
"回答来自全网公开资料,不代表企业内部 SOP;请核对下方来源", redacted, true);
|
"回答来自全网公开资料,不代表企业内部 SOP;请核对下方来源", redacted, true);
|
||||||
} catch (RuntimeException error) {
|
} catch (RuntimeException error) {
|
||||||
log.warn("web AI query failed provider={} (details hidden)", provider);
|
log.warn("web AI query failed provider={} (details hidden)", provider);
|
||||||
audit(principal, question, provider, "FAILED", 0);
|
audit(principal, question, provider, auditStatus(image, "FAILED"), 0);
|
||||||
return new QueryResponse("web", "FAILED", "", List.of(), provider,
|
return new QueryResponse("web", "FAILED", "", List.of(), provider,
|
||||||
"全网检索暂时不可用,本次未生成答案", redacted, true);
|
"全网检索暂时不可用,本次未生成答案", redacted, true);
|
||||||
}
|
}
|
||||||
@@ -67,7 +96,7 @@ public class AihrWebAiService {
|
|||||||
public CapabilitiesResponse capabilities() {
|
public CapabilitiesResponse capabilities() {
|
||||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||||
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
|
AihrWebSearchProviderService.RuntimeProvider runtime = searchClient.runtime(principal);
|
||||||
return new CapabilitiesResponse(runtime != null,
|
return new CapabilitiesResponse(runtime != null, runtime != null && "qwen".equalsIgnoreCase(runtime.code()),
|
||||||
runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(), true, 500,
|
runtime == null ? clean(properties.getProvider(), "tavily") : runtime.name(), true, 500,
|
||||||
runtime == null
|
runtime == null
|
||||||
? "全网检索未配置,企业内部问师傅不受影响"
|
? "全网检索未配置,企业内部问师傅不受影响"
|
||||||
@@ -134,4 +163,21 @@ public class AihrWebAiService {
|
|||||||
String provider = clean(value, "unknown");
|
String provider = clean(value, "unknown");
|
||||||
return provider.length() <= 40 ? provider : provider.substring(0, 40);
|
return provider.length() <= 40 ? provider : provider.substring(0, 40);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static String auditStatus(MultipartFile image, String status) {
|
||||||
|
return image == null ? status : "IMAGE_" + status;
|
||||||
|
}
|
||||||
|
|
||||||
|
private static void enforceImageRateLimit(AihrKnowledgePrincipal principal) {
|
||||||
|
try {
|
||||||
|
String key = "web-ai:image:" + principal.tenantId() + ":" + principal.userId();
|
||||||
|
if (RedisUtils.rateLimiter(key, RateType.OVERALL, 6, 60) < 0) {
|
||||||
|
throw new ServiceException("图片联网识图请求过于频繁,请稍后重试", 429);
|
||||||
|
}
|
||||||
|
} catch (ServiceException error) {
|
||||||
|
throw error;
|
||||||
|
} catch (RuntimeException error) {
|
||||||
|
throw new ServiceException("图片联网识图限流服务暂不可用", 503);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+87
-1
@@ -8,8 +8,17 @@ import org.dromara.aihr.knowledge.domain.AihrKnowledgePrincipal;
|
|||||||
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.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
|
import org.springframework.web.multipart.MultipartFile;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.Color;
|
||||||
|
import java.awt.Graphics2D;
|
||||||
|
import java.awt.RenderingHints;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
import java.io.BufferedReader;
|
import java.io.BufferedReader;
|
||||||
|
import java.io.ByteArrayInputStream;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
|
import java.io.IOException;
|
||||||
import java.io.StringReader;
|
import java.io.StringReader;
|
||||||
import java.net.Inet4Address;
|
import java.net.Inet4Address;
|
||||||
import java.net.Inet6Address;
|
import java.net.Inet6Address;
|
||||||
@@ -20,6 +29,7 @@ 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.Base64;
|
||||||
import java.util.LinkedHashMap;
|
import java.util.LinkedHashMap;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -34,6 +44,9 @@ 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 Duration QWEN_REQUEST_TIMEOUT = Duration.ofSeconds(40);
|
||||||
|
private static final long QWEN_IMAGE_MAX_BYTES = 4L * 1024 * 1024;
|
||||||
|
private static final long QWEN_IMAGE_MAX_PIXELS = 12_000_000L;
|
||||||
|
private static final int QWEN_IMAGE_MAX_EDGE = 2048;
|
||||||
private static final Pattern PUBLIC_URL = Pattern.compile("https?://[^\\s\\]\\)\\\"'<>]+");
|
private static final Pattern PUBLIC_URL = Pattern.compile("https?://[^\\s\\]\\)\\\"'<>]+");
|
||||||
|
|
||||||
private final AihrWebAiProperties properties;
|
private final AihrWebAiProperties properties;
|
||||||
@@ -51,7 +64,7 @@ public class AihrWebSearchClient {
|
|||||||
SearchResult search(RuntimeProvider runtime, String question) {
|
SearchResult search(RuntimeProvider runtime, String question) {
|
||||||
String endpoint = clean(runtime.endpoint());
|
String endpoint = clean(runtime.endpoint());
|
||||||
String apiKey = clean(runtime.apiKey());
|
String apiKey = clean(runtime.apiKey());
|
||||||
if (!isPublicHttpsUrl(endpoint)) {
|
if (!AihrWebSearchProviderService.isAllowedProviderEndpoint(runtime.code(), endpoint)) {
|
||||||
throw new ServiceException("全网检索地址未配置或不安全");
|
throw new ServiceException("全网检索地址未配置或不安全");
|
||||||
}
|
}
|
||||||
if (apiKey.isBlank()) {
|
if (apiKey.isBlank()) {
|
||||||
@@ -67,6 +80,21 @@ public class AihrWebSearchClient {
|
|||||||
return searchTavily(runtime, question);
|
return searchTavily(runtime, question);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
SearchResult searchWithImage(RuntimeProvider runtime, String question, ImageInput image) {
|
||||||
|
String endpoint = clean(runtime.endpoint());
|
||||||
|
String apiKey = clean(runtime.apiKey());
|
||||||
|
if (!AihrWebSearchProviderService.isAllowedProviderEndpoint(runtime.code(), endpoint)) {
|
||||||
|
throw new ServiceException("全网检索地址未配置或不安全");
|
||||||
|
}
|
||||||
|
if (apiKey.isBlank()) {
|
||||||
|
throw new ServiceException("全网检索密钥未配置");
|
||||||
|
}
|
||||||
|
if (!"qwen".equalsIgnoreCase(clean(runtime.code()))) {
|
||||||
|
throw new ServiceException("当前全网检索提供方不支持图片识图");
|
||||||
|
}
|
||||||
|
return searchQwen(runtime, question, image);
|
||||||
|
}
|
||||||
|
|
||||||
private SearchResult searchTavily(RuntimeProvider runtime, String question) {
|
private SearchResult searchTavily(RuntimeProvider runtime, String question) {
|
||||||
try {
|
try {
|
||||||
ObjectNode body = objectMapper.createObjectNode();
|
ObjectNode body = objectMapper.createObjectNode();
|
||||||
@@ -101,6 +129,10 @@ public class AihrWebSearchClient {
|
|||||||
}
|
}
|
||||||
|
|
||||||
private SearchResult searchQwen(RuntimeProvider runtime, String question) {
|
private SearchResult searchQwen(RuntimeProvider runtime, String question) {
|
||||||
|
return searchQwen(runtime, question, null);
|
||||||
|
}
|
||||||
|
|
||||||
|
private SearchResult searchQwen(RuntimeProvider runtime, String question, ImageInput image) {
|
||||||
String agentId = clean(runtime.agentId());
|
String agentId = clean(runtime.agentId());
|
||||||
String agentVersion = clean(runtime.agentVersion());
|
String agentVersion = clean(runtime.agentVersion());
|
||||||
if (agentId.isBlank() || agentVersion.isBlank()) {
|
if (agentId.isBlank() || agentVersion.isBlank()) {
|
||||||
@@ -111,7 +143,15 @@ public class AihrWebSearchClient {
|
|||||||
ObjectNode input = body.putObject("input");
|
ObjectNode input = body.putObject("input");
|
||||||
ObjectNode message = input.putArray("messages").addObject();
|
ObjectNode message = input.putArray("messages").addObject();
|
||||||
message.put("role", "user");
|
message.put("role", "user");
|
||||||
|
if (image == null) {
|
||||||
message.put("content", question);
|
message.put("content", question);
|
||||||
|
} else {
|
||||||
|
var content = message.putArray("content");
|
||||||
|
content.addObject().put("type", "image_url")
|
||||||
|
.putObject("image_url")
|
||||||
|
.put("url", "data:" + image.mimeType() + ";base64," + Base64.getEncoder().encodeToString(image.bytes()));
|
||||||
|
content.addObject().put("type", "text").put("text", question);
|
||||||
|
}
|
||||||
ObjectNode options = body.putObject("parameters").putObject("agent_options");
|
ObjectNode options = body.putObject("parameters").putObject("agent_options");
|
||||||
options.put("agent_id", agentId);
|
options.put("agent_id", agentId);
|
||||||
options.put("agent_version", agentVersion);
|
options.put("agent_version", agentVersion);
|
||||||
@@ -162,6 +202,49 @@ public class AihrWebSearchClient {
|
|||||||
clean(properties.getEndpoint()), clean(properties.getApiKey()), "", "", true, true, null);
|
clean(properties.getEndpoint()), clean(properties.getApiKey()), "", "", true, true, null);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static ImageInput imageInput(MultipartFile file) {
|
||||||
|
if (file == null || file.isEmpty()) {
|
||||||
|
throw new ServiceException("请选择图片");
|
||||||
|
}
|
||||||
|
if (file.getSize() > QWEN_IMAGE_MAX_BYTES) {
|
||||||
|
throw new ServiceException("图片不能超过 4MB");
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
BufferedImage source = ImageIO.read(new ByteArrayInputStream(file.getBytes()));
|
||||||
|
if (source == null || source.getWidth() <= 0 || source.getHeight() <= 0) {
|
||||||
|
throw new ServiceException("仅支持 JPG、PNG 或 BMP 图片");
|
||||||
|
}
|
||||||
|
if ((long) source.getWidth() * source.getHeight() > QWEN_IMAGE_MAX_PIXELS) {
|
||||||
|
throw new ServiceException("图片像素不能超过1200万");
|
||||||
|
}
|
||||||
|
BufferedImage normalized = normalizeImage(source);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
if (!ImageIO.write(normalized, "jpg", output) || output.size() > QWEN_IMAGE_MAX_BYTES) {
|
||||||
|
throw new ServiceException("图片内容过于复杂,请压缩后重试");
|
||||||
|
}
|
||||||
|
return new ImageInput(output.toByteArray(), "image/jpeg");
|
||||||
|
} catch (IOException error) {
|
||||||
|
throw new ServiceException("图片读取失败,请重新选择");
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private static BufferedImage normalizeImage(BufferedImage source) {
|
||||||
|
double scale = Math.min(1d, QWEN_IMAGE_MAX_EDGE / (double) Math.max(source.getWidth(), source.getHeight()));
|
||||||
|
int width = Math.max(1, (int) Math.round(source.getWidth() * scale));
|
||||||
|
int height = Math.max(1, (int) Math.round(source.getHeight() * scale));
|
||||||
|
BufferedImage target = new BufferedImage(width, height, BufferedImage.TYPE_INT_RGB);
|
||||||
|
Graphics2D graphics = target.createGraphics();
|
||||||
|
try {
|
||||||
|
graphics.setColor(Color.WHITE);
|
||||||
|
graphics.fillRect(0, 0, width, height);
|
||||||
|
graphics.setRenderingHint(RenderingHints.KEY_INTERPOLATION, RenderingHints.VALUE_INTERPOLATION_BICUBIC);
|
||||||
|
graphics.drawImage(source, 0, 0, width, height, null);
|
||||||
|
return target;
|
||||||
|
} finally {
|
||||||
|
graphics.dispose();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception {
|
static SearchResult parseTavilyResponse(ObjectMapper mapper, String body, int limit) throws Exception {
|
||||||
JsonNode root = mapper.readTree(body == null ? "{}" : body);
|
JsonNode root = mapper.readTree(body == null ? "{}" : body);
|
||||||
List<WebSource> sources = new ArrayList<>();
|
List<WebSource> sources = new ArrayList<>();
|
||||||
@@ -374,4 +457,7 @@ public class AihrWebSearchClient {
|
|||||||
|
|
||||||
public record SearchResult(String answer, List<WebSource> sources) {
|
public record SearchResult(String answer, List<WebSource> sources) {
|
||||||
}
|
}
|
||||||
|
|
||||||
|
record ImageInput(byte[] bytes, String mimeType) {
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+2
-2
@@ -70,8 +70,8 @@ public class AihrWebSearchProviderAdminController {
|
|||||||
|
|
||||||
private AihrKnowledgePrincipal requireModerator() {
|
private AihrKnowledgePrincipal requireModerator() {
|
||||||
AihrKnowledgePrincipal principal = principalResolver.current();
|
AihrKnowledgePrincipal principal = principalResolver.current();
|
||||||
if (!AihrCommunityPolicy.canModerate(principal)) {
|
if (!AihrCommunityPolicy.canManageWebSearchProviders(principal)) {
|
||||||
throw new ServiceException("仅主管或管理员可管理全网检索提供方", 403);
|
throw new ServiceException("仅系统管理员可管理全网检索提供方", 403);
|
||||||
}
|
}
|
||||||
return principal;
|
return principal;
|
||||||
}
|
}
|
||||||
|
|||||||
+27
-3
@@ -12,6 +12,7 @@ import org.springframework.transaction.annotation.Transactional;
|
|||||||
|
|
||||||
import java.sql.PreparedStatement;
|
import java.sql.PreparedStatement;
|
||||||
import java.sql.Statement;
|
import java.sql.Statement;
|
||||||
|
import java.net.URI;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
import java.util.List;
|
import java.util.List;
|
||||||
import java.util.Locale;
|
import java.util.Locale;
|
||||||
@@ -88,7 +89,7 @@ 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 (!isAllowedProviderEndpoint(selected.code(), selected.endpoint()) || !hasText(selected.apiKey())
|
||||||
|| ("qwen".equals(selected.code())
|
|| ("qwen".equals(selected.code())
|
||||||
&& (!hasText(selected.agentId()) || !hasText(selected.agentVersion())))) {
|
&& (!hasText(selected.agentId()) || !hasText(selected.agentVersion())))) {
|
||||||
throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥");
|
throw new IllegalArgumentException("请先配置安全的公开接口和访问密钥");
|
||||||
@@ -185,8 +186,8 @@ public class AihrWebSearchProviderService {
|
|||||||
if (!Set.of("tavily", "qwen").contains(code)) {
|
if (!Set.of("tavily", "qwen").contains(code)) {
|
||||||
throw new IllegalArgumentException("当前仅支持 Tavily 或千问联网检索");
|
throw new IllegalArgumentException("当前仅支持 Tavily 或千问联网检索");
|
||||||
}
|
}
|
||||||
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
|
if (!isAllowedProviderEndpoint(code, endpoint)) {
|
||||||
throw new IllegalArgumentException("检索地址必须是可公开访问的 HTTPS 地址");
|
throw new IllegalArgumentException("检索地址与提供方不匹配或不受信任");
|
||||||
}
|
}
|
||||||
String apiKey = clean(request.apiKey());
|
String apiKey = clean(request.apiKey());
|
||||||
if (!idRequired && apiKey.isBlank()) {
|
if (!idRequired && apiKey.isBlank()) {
|
||||||
@@ -242,6 +243,29 @@ public class AihrWebSearchProviderService {
|
|||||||
return value != null && !value.isBlank();
|
return value != null && !value.isBlank();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static boolean isAllowedProviderEndpoint(String providerCode, String endpoint) {
|
||||||
|
String code = clean(providerCode).toLowerCase(Locale.ROOT);
|
||||||
|
if (!AihrWebSearchClient.isPublicHttpsUrl(endpoint)) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
URI uri = URI.create(endpoint.trim());
|
||||||
|
String host = uri.getHost() == null ? "" : uri.getHost().toLowerCase(Locale.ROOT);
|
||||||
|
String path = uri.getPath() == null ? "" : uri.getPath();
|
||||||
|
return switch (code) {
|
||||||
|
case "qwen" -> "dashscope.aliyuncs.com".equals(host)
|
||||||
|
&& "/api/v2/apps/web-search-agent/chat/completions".equals(path)
|
||||||
|
&& uri.getQuery() == null;
|
||||||
|
case "tavily" -> "api.tavily.com".equals(host)
|
||||||
|
&& "/search".equals(path)
|
||||||
|
&& uri.getQuery() == null;
|
||||||
|
default -> false;
|
||||||
|
};
|
||||||
|
} catch (IllegalArgumentException error) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
private static String clean(String value) {
|
private static String clean(String value) {
|
||||||
return value == null ? "" : value.trim();
|
return value == null ? "" : value.trim();
|
||||||
}
|
}
|
||||||
|
|||||||
+32
@@ -13,7 +13,11 @@ import org.dromara.common.core.exception.ServiceException;
|
|||||||
import org.junit.jupiter.api.Tag;
|
import org.junit.jupiter.api.Tag;
|
||||||
import org.junit.jupiter.api.Test;
|
import org.junit.jupiter.api.Test;
|
||||||
import org.springframework.jdbc.core.JdbcTemplate;
|
import org.springframework.jdbc.core.JdbcTemplate;
|
||||||
|
import org.springframework.mock.web.MockMultipartFile;
|
||||||
|
|
||||||
|
import javax.imageio.ImageIO;
|
||||||
|
import java.awt.image.BufferedImage;
|
||||||
|
import java.io.ByteArrayOutputStream;
|
||||||
import java.nio.file.Files;
|
import java.nio.file.Files;
|
||||||
import java.nio.file.Path;
|
import java.nio.file.Path;
|
||||||
import java.time.LocalDateTime;
|
import java.time.LocalDateTime;
|
||||||
@@ -214,6 +218,34 @@ class AihrWebAiSecurityTest {
|
|||||||
assertTrue(safe.contains("已脱敏"));
|
assertTrue(safe.contains("已脱敏"));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Tag("dev")
|
||||||
|
void qwenImageInputReencodesOnlyDecodableImages() throws Exception {
|
||||||
|
BufferedImage source = new BufferedImage(1, 1, BufferedImage.TYPE_INT_ARGB);
|
||||||
|
ByteArrayOutputStream output = new ByteArrayOutputStream();
|
||||||
|
ImageIO.write(source, "png", output);
|
||||||
|
var png = new MockMultipartFile("file", "photo.png", "image/png", output.toByteArray());
|
||||||
|
|
||||||
|
AihrWebSearchClient.ImageInput image = AihrWebSearchClient.imageInput(png);
|
||||||
|
assertEquals("image/jpeg", image.mimeType());
|
||||||
|
assertTrue(image.bytes().length > 3);
|
||||||
|
assertEquals((byte) 0xff, image.bytes()[0]);
|
||||||
|
assertEquals((byte) 0xd8, image.bytes()[1]);
|
||||||
|
assertThrows(ServiceException.class, () -> AihrWebSearchClient.imageInput(
|
||||||
|
new MockMultipartFile("file", "not-an-image.jpg", "image/jpeg",
|
||||||
|
new byte[]{(byte) 0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a})
|
||||||
|
));
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
@Tag("dev")
|
||||||
|
void providersCannotSendSecretsToArbitraryPublicHosts() {
|
||||||
|
assertTrue(AihrWebSearchProviderService.isAllowedProviderEndpoint("qwen",
|
||||||
|
"https://dashscope.aliyuncs.com/api/v2/apps/web-search-agent/chat/completions"));
|
||||||
|
assertFalse(AihrWebSearchProviderService.isAllowedProviderEndpoint("qwen", "https://example.com/collect"));
|
||||||
|
assertFalse(AihrWebSearchProviderService.isAllowedProviderEndpoint("tavily", "https://api.tavily.com/other"));
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
@Tag("dev")
|
@Tag("dev")
|
||||||
void databaseProviderSecretUsesAuthenticatedEncryption() {
|
void databaseProviderSecretUsesAuthenticatedEncryption() {
|
||||||
|
|||||||
@@ -42,6 +42,7 @@
|
|||||||
<view v-if="msg.role === 'user'" class="bubble-row right">
|
<view v-if="msg.role === 'user'" class="bubble-row right">
|
||||||
<view class="bubble user">
|
<view class="bubble user">
|
||||||
<view v-if="msg.voice" class="bubble-tag">🎤 语音转文字</view>
|
<view v-if="msg.voice" class="bubble-tag">🎤 语音转文字</view>
|
||||||
|
<view v-if="msg.image" class="bubble-tag">📷 图片识图{{ msg.imageName ? ` · ${msg.imageName}` : '' }}</view>
|
||||||
<text class="bubble-text">{{ msg.text }}</text>
|
<text class="bubble-text">{{ msg.text }}</text>
|
||||||
</view>
|
</view>
|
||||||
</view>
|
</view>
|
||||||
@@ -103,10 +104,16 @@
|
|||||||
:transcribing="voiceTranscribing"
|
:transcribing="voiceTranscribing"
|
||||||
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
placeholder="例如:最近物业服务行业有哪些官方新规?"
|
||||||
:maxlength="capabilities?.maxQuestionLength || 500"
|
:maxlength="capabilities?.maxQuestionLength || 500"
|
||||||
:panel-items="[{ key: 'audio', icon: 'sound', label: '选音频转文字' }]"
|
:media-name="selectedImage?.name || ''"
|
||||||
|
media-kind="image"
|
||||||
|
:panel-items="[
|
||||||
|
{ key: 'image', icon: 'image', label: '图片识图', disabled: !capabilities?.supportsImage },
|
||||||
|
{ key: 'audio', icon: 'sound', label: '选音频转文字' }
|
||||||
|
]"
|
||||||
@send="handleComposerSend"
|
@send="handleComposerSend"
|
||||||
@voice-blob="handleVoiceBlob"
|
@voice-blob="handleVoiceBlob"
|
||||||
@panel="handlePanelAction"
|
@panel="handlePanelAction"
|
||||||
|
@clear-media="clearImage"
|
||||||
/>
|
/>
|
||||||
</view>
|
</view>
|
||||||
</template>
|
</template>
|
||||||
@@ -114,11 +121,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { nextTick, ref } from 'vue';
|
import { nextTick, ref } from 'vue';
|
||||||
import { onShow, onUnload } from '@dcloudio/uni-app';
|
import { onShow, onUnload } from '@dcloudio/uni-app';
|
||||||
import type { SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
import type { KnowledgeMediaSelectedFile, SpeechPlaybackStatus, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||||
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
import ChatComposer from '@/components/chat/ChatComposer.vue';
|
||||||
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
import { isLoggedIn, rememberLoginRedirect } from '@/services/auth';
|
||||||
|
import { chooseKnowledgeMedia } from '@/services/knowledge';
|
||||||
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
import { chooseSpeechAudio, createSpeechPlaybackController, MENTOR_VOICE_PROFILE, transcribeSpeechBlob, transcribeSpeechFile } from '@/services/speech';
|
||||||
import { getWebAiCapabilities, queryWebAi } from '@/services/web-ai';
|
import { getWebAiCapabilities, queryWebAi, queryWebAiWithImage } from '@/services/web-ai';
|
||||||
import { renderMarkdown } from '@/utils/markdown';
|
import { renderMarkdown } from '@/utils/markdown';
|
||||||
|
|
||||||
const path = '/pages/user/web-ai/index';
|
const path = '/pages/user/web-ai/index';
|
||||||
@@ -128,6 +136,8 @@ interface UserMsg {
|
|||||||
role: 'user';
|
role: 'user';
|
||||||
text: string;
|
text: string;
|
||||||
voice?: boolean;
|
voice?: boolean;
|
||||||
|
image?: boolean;
|
||||||
|
imageName?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
interface AiMsg {
|
interface AiMsg {
|
||||||
@@ -157,8 +167,10 @@ const threadScrollTop = ref(0);
|
|||||||
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
const composerRef = ref<InstanceType<typeof ChatComposer> | null>(null);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
const voiceTranscribing = ref(false);
|
const voiceTranscribing = ref(false);
|
||||||
|
const selectedImage = ref<KnowledgeMediaSelectedFile | null>(null);
|
||||||
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
const speechState = ref<{ key: string; status: SpeechPlaybackStatus }>({ key: '', status: 'idle' });
|
||||||
const consented = ref(false);
|
const consented = ref(false);
|
||||||
|
const imageConsented = ref(false);
|
||||||
let msgSeq = 0;
|
let msgSeq = 0;
|
||||||
let voiceTranscriptionGeneration = 0;
|
let voiceTranscriptionGeneration = 0;
|
||||||
let abortVoiceTranscription: (() => void) | null = null;
|
let abortVoiceTranscription: (() => void) | null = null;
|
||||||
@@ -208,19 +220,24 @@ const loadCapabilities = async () => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
/** WeChat-style consent: confirm once per session before the first outbound query. */
|
/** WeChat-style consent: confirm once per session before the first outbound query. */
|
||||||
const ensureConsent = () =>
|
const ensureConsent = (hasImage = false) =>
|
||||||
new Promise<boolean>((resolve) => {
|
new Promise<boolean>((resolve) => {
|
||||||
if (consented.value || !capabilities.value?.requiresConsent) {
|
if ((hasImage ? imageConsented.value : consented.value) || !capabilities.value?.requiresConsent) {
|
||||||
resolve(true);
|
resolve(true);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
uni.showModal({
|
uni.showModal({
|
||||||
title: '发送前确认',
|
title: '发送前确认',
|
||||||
content: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
content: hasImage
|
||||||
|
? '图片和问题会发送给外部公开检索与 AI 服务,图片不会保存到企业知识库。请勿上传含个人信息、门禁或未公开资料的图片。是否继续?'
|
||||||
|
: '问题会先脱敏,再发送给外部公开检索与 AI 服务。是否继续?',
|
||||||
confirmText: '同意并发送',
|
confirmText: '同意并发送',
|
||||||
cancelText: '取消',
|
cancelText: '取消',
|
||||||
success: (res) => {
|
success: (res) => {
|
||||||
if (res.confirm) consented.value = true;
|
if (res.confirm) {
|
||||||
|
if (hasImage) imageConsented.value = true;
|
||||||
|
else consented.value = true;
|
||||||
|
}
|
||||||
resolve(Boolean(res.confirm));
|
resolve(Boolean(res.confirm));
|
||||||
},
|
},
|
||||||
fail: () => resolve(false)
|
fail: () => resolve(false)
|
||||||
@@ -232,7 +249,8 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
|||||||
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
uni.showToast({ title: '上一条还在检索中,请稍候', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
const text = rawText.trim();
|
const image = selectedImage.value;
|
||||||
|
const text = rawText.trim() || (image ? '请识别图片内容,并联网检索相关公开资料。' : '');
|
||||||
if (!text) {
|
if (!text) {
|
||||||
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
uni.showToast({ title: '请先输入问题', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
@@ -245,12 +263,17 @@ const sendQuestion = async (rawText: string, options: { voice?: boolean } = {})
|
|||||||
uni.showToast({ title: '全网 AI 尚未由管理员启用', icon: 'none' });
|
uni.showToast({ title: '全网 AI 尚未由管理员启用', icon: 'none' });
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
if (!(await ensureConsent())) return;
|
if (image && !capabilities.value?.supportsImage) {
|
||||||
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice });
|
uni.showToast({ title: '当前全网检索未启用图片识图', icon: 'none' });
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
if (!(await ensureConsent(Boolean(image)))) return;
|
||||||
|
pushMsg<UserMsg>({ role: 'user', text, voice: options.voice, image: Boolean(image), imageName: image?.name });
|
||||||
|
selectedImage.value = null;
|
||||||
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
const pending = pushMsg<PendingMsg>({ role: 'pending' });
|
||||||
loading.value = true;
|
loading.value = true;
|
||||||
try {
|
try {
|
||||||
const result = await queryWebAi(text, true);
|
const result = image ? await queryWebAiWithImage(image, text, true) : await queryWebAi(text, true);
|
||||||
replaceMsg(pending.id, { id: pending.id, role: 'ai', result, citationsOpen: false });
|
replaceMsg(pending.id, { id: pending.id, role: 'ai', result, citationsOpen: false });
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
replaceMsg(pending.id, null);
|
replaceMsg(pending.id, null);
|
||||||
@@ -291,6 +314,23 @@ const handleVoiceBlob = async (blob: Blob) => {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handlePanelAction = async (key: string) => {
|
const handlePanelAction = async (key: string) => {
|
||||||
|
if (key === 'image') {
|
||||||
|
if (loading.value || !capabilities.value?.supportsImage) return;
|
||||||
|
try {
|
||||||
|
const image = await chooseKnowledgeMedia();
|
||||||
|
if (image.kind !== 'image' || !/\.(jpe?g|png|bmp)$/i.test(image.name)) {
|
||||||
|
throw new Error('仅支持 JPG、PNG 或 BMP 图片');
|
||||||
|
}
|
||||||
|
if (image.size > 4 * 1024 * 1024) throw new Error('图片不能超过 4MB');
|
||||||
|
selectedImage.value = image;
|
||||||
|
composerRef.value?.setDraft('');
|
||||||
|
uni.showToast({ title: '已选择图片,发送时将联网识图', icon: 'none' });
|
||||||
|
} catch (error) {
|
||||||
|
const text = error instanceof Error ? error.message : '图片选择失败';
|
||||||
|
if (text !== '未选择附件') uni.showToast({ title: text, icon: 'none' });
|
||||||
|
}
|
||||||
|
return;
|
||||||
|
}
|
||||||
if (key !== 'audio' || voiceTranscribing.value) return;
|
if (key !== 'audio' || voiceTranscribing.value) return;
|
||||||
const generation = ++voiceTranscriptionGeneration;
|
const generation = ++voiceTranscriptionGeneration;
|
||||||
voiceTranscribing.value = true;
|
voiceTranscribing.value = true;
|
||||||
@@ -319,6 +359,10 @@ const handlePanelAction = async (key: string) => {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const clearImage = () => {
|
||||||
|
if (!loading.value) selectedImage.value = null;
|
||||||
|
};
|
||||||
|
|
||||||
const speechButtonLabel = (key: string) => {
|
const speechButtonLabel = (key: string) => {
|
||||||
if (speechState.value.key !== key) return '播报';
|
if (speechState.value.key !== key) return '播报';
|
||||||
if (speechState.value.status === 'loading') return '生成语音中';
|
if (speechState.value.status === 'loading') return '生成语音中';
|
||||||
@@ -335,7 +379,9 @@ onShow(() => {
|
|||||||
loggedIn.value = isLoggedIn();
|
loggedIn.value = isLoggedIn();
|
||||||
if (!loggedIn.value) {
|
if (!loggedIn.value) {
|
||||||
messages.value = [];
|
messages.value = [];
|
||||||
|
selectedImage.value = null;
|
||||||
consented.value = false;
|
consented.value = false;
|
||||||
|
imageConsented.value = false;
|
||||||
}
|
}
|
||||||
capabilities.value = null;
|
capabilities.value = null;
|
||||||
void loadCapabilities();
|
void loadCapabilities();
|
||||||
|
|||||||
@@ -1,8 +1,9 @@
|
|||||||
import type { WebAiCapabilities, WebAiResponse } from '@/types/api';
|
import type { KnowledgeMediaSelectedFile, WebAiCapabilities, WebAiResponse } from '@/types/api';
|
||||||
import { apiRequest } from './api';
|
import { apiRequest, apiUrl, authHeaders, readTextPayload } from './api';
|
||||||
|
|
||||||
type RawCapabilities = {
|
type RawCapabilities = {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
supportsImage?: boolean;
|
||||||
provider: string;
|
provider: string;
|
||||||
requiresConsent: boolean;
|
requiresConsent: boolean;
|
||||||
maxQuestionLength: number;
|
maxQuestionLength: number;
|
||||||
@@ -24,6 +25,22 @@ const domainOf = (value: string) => {
|
|||||||
try { return new URL(value).hostname; } catch { return ''; }
|
try { return new URL(value).hostname; } catch { return ''; }
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const normalizeResponse = (raw: RawWebAiResponse): WebAiResponse => ({
|
||||||
|
requestId: `web:${Date.now()}`,
|
||||||
|
status: raw.status,
|
||||||
|
provider: raw.provider,
|
||||||
|
answer: raw.answer || '',
|
||||||
|
citations: (raw.sources || []).map((source) => ({
|
||||||
|
title: source.title,
|
||||||
|
url: source.url,
|
||||||
|
domain: domainOf(source.url),
|
||||||
|
snippet: source.snippet
|
||||||
|
})),
|
||||||
|
noEvidence: raw.status !== 'OK',
|
||||||
|
warning: raw.notice,
|
||||||
|
redacted: raw.redacted
|
||||||
|
});
|
||||||
|
|
||||||
export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
||||||
const raw = await apiRequest<RawCapabilities>({
|
const raw = await apiRequest<RawCapabilities>({
|
||||||
url: '/api/aihr/web-ai/capabilities',
|
url: '/api/aihr/web-ai/capabilities',
|
||||||
@@ -32,6 +49,7 @@ export const getWebAiCapabilities = async (): Promise<WebAiCapabilities> => {
|
|||||||
});
|
});
|
||||||
return {
|
return {
|
||||||
enabled: raw.enabled,
|
enabled: raw.enabled,
|
||||||
|
supportsImage: raw.supportsImage,
|
||||||
requiresConsent: raw.requiresConsent,
|
requiresConsent: raw.requiresConsent,
|
||||||
providerName: raw.provider,
|
providerName: raw.provider,
|
||||||
maxQuestionLength: raw.maxQuestionLength,
|
maxQuestionLength: raw.maxQuestionLength,
|
||||||
@@ -46,19 +64,25 @@ export const queryWebAi = async (question: string, consent: boolean): Promise<We
|
|||||||
data: { question, consent },
|
data: { question, consent },
|
||||||
timeout: 45000
|
timeout: 45000
|
||||||
});
|
});
|
||||||
return {
|
return normalizeResponse(raw);
|
||||||
requestId: `web:${Date.now()}`,
|
|
||||||
status: raw.status,
|
|
||||||
provider: raw.provider,
|
|
||||||
answer: raw.answer || '',
|
|
||||||
citations: (raw.sources || []).map((source) => ({
|
|
||||||
title: source.title,
|
|
||||||
url: source.url,
|
|
||||||
domain: domainOf(source.url),
|
|
||||||
snippet: source.snippet
|
|
||||||
})),
|
|
||||||
noEvidence: raw.status !== 'OK',
|
|
||||||
warning: raw.notice,
|
|
||||||
redacted: raw.redacted
|
|
||||||
};
|
|
||||||
};
|
};
|
||||||
|
|
||||||
|
export const queryWebAiWithImage = (file: KnowledgeMediaSelectedFile, question: string, consent: boolean) =>
|
||||||
|
new Promise<WebAiResponse>((resolve, reject) => {
|
||||||
|
uni.uploadFile({
|
||||||
|
url: apiUrl('/api/aihr/web-ai/query-image'),
|
||||||
|
filePath: file.path,
|
||||||
|
name: 'file',
|
||||||
|
header: authHeaders(false),
|
||||||
|
formData: { question, consent: String(consent) },
|
||||||
|
timeout: 60000,
|
||||||
|
success: (response) => {
|
||||||
|
try {
|
||||||
|
resolve(normalizeResponse(readTextPayload<RawWebAiResponse>(response.statusCode, response.data)));
|
||||||
|
} catch (error) {
|
||||||
|
reject(error);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
fail: () => reject(new Error('图片上传或联网识图失败'))
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -581,6 +581,7 @@ export interface DailyDrillAnswerResponse {
|
|||||||
|
|
||||||
export interface WebAiCapabilities {
|
export interface WebAiCapabilities {
|
||||||
enabled: boolean;
|
enabled: boolean;
|
||||||
|
supportsImage?: boolean;
|
||||||
requiresConsent: boolean;
|
requiresConsent: boolean;
|
||||||
providerName?: string;
|
providerName?: string;
|
||||||
maxQuestionLength: number;
|
maxQuestionLength: number;
|
||||||
|
|||||||
Reference in New Issue
Block a user