feat(wiki): add Zhipu GLM-V and Volcano Doubao vision providers

This commit is contained in:
matevip 2026-05-02 21:46:15 +08:00
parent af5670f2f7
commit ac654f3957
4 changed files with 356 additions and 180 deletions

View File

@ -1,62 +1,30 @@
package vip.mate.tool.image.vision.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.RequiredArgsConstructor;
import lombok.extern.slf4j.Slf4j;
import org.springframework.stereotype.Component;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.image.ImageCapability;
import vip.mate.tool.image.vision.ImageVisionProvider;
import vip.mate.tool.image.vision.VisionContext;
import vip.mate.tool.image.vision.VisionRequest;
import vip.mate.tool.image.vision.VisionResult;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* DashScope vision provider uses qwen-vl-max via the OpenAI-compatible
* endpoint at {@code /compatible-mode/v1/chat/completions}.
* DashScope vision provider uses {@code qwen-vl-max} via the
* OpenAI-compatible endpoint at {@code /compatible-mode/v1/chat/completions}.
*
* <p>Default for the regional production rollout: API keys are typically
* available (DASHSCOPE_API_KEY is mandatory for the rest of the platform)
* and per-image cost is the lowest of the supported vendors.
*
* <p>Implementation deliberately uses Hutool {@code HttpRequest} rather
* than building a Spring AI {@code ChatClient} the call shape is a
* one-shot request/response that doesn't benefit from the heavyweight
* agent-style observability / retry / cache machinery in
* {@code AgentOpenAiCompatibleChatModelBuilder}.
*
* @author MateClaw Team
* <p>Default for the Chinese cloud rollout: API keys are typically
* available (DASHSCOPE_API_KEY is mandatory for the rest of the
* platform) and per-image cost is the lowest of the supported vendors,
* so this provider sits at the front of the auto-detect chain.
*/
@Slf4j
@Component
@RequiredArgsConstructor
public class DashScopeVisionProvider implements ImageVisionProvider {
public class DashScopeVisionProvider extends OpenAiCompatibleVisionProvider {
private static final String PROVIDER_ID = "dashscope-vision";
private static final String DASHSCOPE_PROVIDER_KEY = "dashscope";
private static final String DEFAULT_MODEL = "qwen-vl-max";
private static final String DEFAULT_BASE_URL = "https://dashscope.aliyuncs.com/compatible-mode/v1";
private static final int CALL_TIMEOUT_MS = 60_000;
private static final Pattern OFF_TOPIC_PREFIX = Pattern.compile("^\\s*\\[OFF-TOPIC\\]\\s*", Pattern.CASE_INSENSITIVE);
private final ModelProviderService modelProviderService;
private final ObjectMapper objectMapper;
public DashScopeVisionProvider(ModelProviderService modelProviderService,
ObjectMapper objectMapper) {
super(modelProviderService, objectMapper);
}
@Override
public String id() {
@ -68,154 +36,23 @@ public class DashScopeVisionProvider implements ImageVisionProvider {
return "DashScope qwen-vl";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public int autoDetectOrder() {
return 10;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.IMAGE_TO_TEXT);
protected String providerKey() {
return DASHSCOPE_PROVIDER_KEY;
}
@Override
public boolean isAvailable(SystemSettingsDTO settings) {
try {
return modelProviderService.isProviderConfigured(DASHSCOPE_PROVIDER_KEY);
} catch (Exception e) {
log.debug("[Vision][dashscope] availability check failed: {}", e.getMessage());
return false;
}
protected String defaultBaseUrl() {
return DEFAULT_BASE_URL;
}
@Override
public VisionResult caption(VisionRequest request, SystemSettingsDTO settings) {
ModelProviderEntity provider;
try {
provider = modelProviderService.getProviderConfig(DASHSCOPE_PROVIDER_KEY);
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.no_provider",
"DashScope provider is not configured");
}
String apiKey = provider.getApiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new MateClawException("err.wiki.vision.no_provider",
"DashScope API key is missing");
}
String baseUrl = (provider.getBaseUrl() == null || provider.getBaseUrl().isBlank())
? DEFAULT_BASE_URL
: provider.getBaseUrl();
String model = (request.getPreferModel() == null || request.getPreferModel().isBlank())
? DEFAULT_MODEL
: request.getPreferModel();
String prompt = buildPrompt(request.getContext());
String imageDataUrl = buildDataUrl(request.getMimeType(), request.getImageBytes());
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("temperature", 0.0);
body.put("max_tokens", 4096);
ArrayNode messages = body.putArray("messages");
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", prompt);
ObjectNode imagePart = content.addObject();
imagePart.put("type", "image_url");
imagePart.putObject("image_url").put("url", imageDataUrl);
long startNanos = System.nanoTime();
HttpResponse response;
try {
response = HttpRequest.post(baseUrl + "/chat/completions")
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(CALL_TIMEOUT_MS)
.execute();
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.provider_failed",
"DashScope vision call failed: " + e.getMessage());
}
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
if (response.getStatus() != 200) {
String errBody = response.body();
log.warn("[Vision][dashscope] HTTP {} body={}", response.getStatus(), truncate(errBody, 400));
throw new MateClawException("err.wiki.vision.provider_failed",
"DashScope vision returned HTTP " + response.getStatus());
}
String text;
try {
JsonNode root = objectMapper.readTree(response.body());
text = root.path("choices").path(0).path("message").path("content").asText("").trim();
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.provider_failed",
"DashScope vision response was unparseable: " + e.getMessage());
}
if (text.isEmpty()) {
throw new MateClawException("err.wiki.vision.provider_failed",
"DashScope vision returned an empty caption");
}
Matcher offTopic = OFF_TOPIC_PREFIX.matcher(text);
boolean isOffTopic = offTopic.find();
String caption = isOffTopic ? offTopic.replaceFirst("").trim() : text;
return VisionResult.builder()
.caption(caption)
.visibleText(extractVisibleTextHeuristic(caption))
.offTopic(isOffTopic)
.providerId(PROVIDER_ID)
.model(model)
.capturedAt(Instant.now())
.durationMs(durationMs)
.build();
}
private String buildPrompt(VisionContext context) {
if (context != null && context.hasContext()) {
String template = PromptLoader.loadPrompt("wiki/vision-caption-context-aware");
String before = (context.getBeforeText() == null || context.getBeforeText().isBlank())
? "(none)" : context.getBeforeText().trim();
String after = (context.getAfterText() == null || context.getAfterText().isBlank())
? "(none)" : context.getAfterText().trim();
return template.replace("{before}", before).replace("{after}", after);
}
return PromptLoader.loadPrompt("wiki/vision-caption-factual");
}
private static String buildDataUrl(String mimeType, byte[] bytes) {
String mime = (mimeType == null || mimeType.isBlank()) ? "image/png" : mimeType;
return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(bytes);
}
/**
* Heuristic: pull obvious quoted strings as "visible text". This is a
* placeholder until a stronger OCR-grade extraction lands; for the
* factual prompt the model already inlines text verbatim into the
* caption, so the cache row's caption alone is enough for retrieval.
*/
private static String extractVisibleTextHeuristic(String caption) {
Matcher m = Pattern.compile("\"([^\"]{3,})\"").matcher(caption);
StringBuilder sb = new StringBuilder();
while (m.find()) {
if (sb.length() > 0) sb.append('\n');
sb.append(m.group(1));
}
return sb.length() == 0 ? null : sb.toString();
}
private static String truncate(String s, int max) {
if (s == null) return "";
return s.length() > max ? s.substring(0, max) + "" : s;
protected String defaultModel() {
return DEFAULT_MODEL;
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.tool.image.vision.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.llm.service.ModelProviderService;
/**
* Volcengine Ark Doubao vision provider uses
* {@code doubao-seed-1-8-251228} (the seeded multimodal flagship)
* via the OpenAI-compatible endpoint at {@code /api/v3/chat/completions}.
*
* <p>Attaches to the {@code volcengine} model provider; the
* {@code volcengine-plan} sibling (coding subscription tier) is
* intentionally not consumed here because the multimodal model
* isn't part of that plan.
*/
@Component
public class DoubaoVisionProvider extends OpenAiCompatibleVisionProvider {
private static final String PROVIDER_ID = "doubao-vision";
private static final String VOLCENGINE_PROVIDER_KEY = "volcengine";
private static final String DEFAULT_MODEL = "doubao-seed-1-8-251228";
private static final String DEFAULT_BASE_URL = "https://ark.cn-beijing.volces.com/api/v3";
public DoubaoVisionProvider(ModelProviderService modelProviderService,
ObjectMapper objectMapper) {
super(modelProviderService, objectMapper);
}
@Override
public String id() {
return PROVIDER_ID;
}
@Override
public String label() {
return "Volcano Doubao Vision";
}
@Override
public int autoDetectOrder() {
return 30;
}
@Override
protected String providerKey() {
return VOLCENGINE_PROVIDER_KEY;
}
@Override
protected String defaultBaseUrl() {
return DEFAULT_BASE_URL;
}
@Override
protected String defaultModel() {
return DEFAULT_MODEL;
}
}

View File

@ -0,0 +1,221 @@
package vip.mate.tool.image.vision.provider;
import cn.hutool.http.HttpRequest;
import cn.hutool.http.HttpResponse;
import com.fasterxml.jackson.databind.JsonNode;
import com.fasterxml.jackson.databind.ObjectMapper;
import com.fasterxml.jackson.databind.node.ArrayNode;
import com.fasterxml.jackson.databind.node.ObjectNode;
import lombok.extern.slf4j.Slf4j;
import vip.mate.agent.prompt.PromptLoader;
import vip.mate.exception.MateClawException;
import vip.mate.llm.model.ModelProviderEntity;
import vip.mate.llm.service.ModelProviderService;
import vip.mate.system.model.SystemSettingsDTO;
import vip.mate.tool.image.ImageCapability;
import vip.mate.tool.image.vision.ImageVisionProvider;
import vip.mate.tool.image.vision.VisionContext;
import vip.mate.tool.image.vision.VisionRequest;
import vip.mate.tool.image.vision.VisionResult;
import java.time.Duration;
import java.time.Instant;
import java.util.Base64;
import java.util.Set;
import java.util.regex.Matcher;
import java.util.regex.Pattern;
/**
* Shared base for vision providers that hit OpenAI-compatible
* {@code /chat/completions} endpoints with image-content blocks.
*
* <p>DashScope (qwen-vl), Zhipu (glm-v) and Volcengine Ark (doubao
* vision) all accept the same request shape a {@code messages} array
* whose user message contains a {@code text} part and an
* {@code image_url} part with a base64 data URL. Extracting that
* shape into one place lets each concrete provider supply only its
* identity and routing constants.
*
* <p>Subclass contract: implement {@link #id()}, {@link #label()},
* {@link #autoDetectOrder()}, {@link #providerKey()},
* {@link #defaultBaseUrl()}, and {@link #defaultModel()}. Override
* {@link #completionsPath()} when the endpoint differs from the
* default {@code /chat/completions}.
*/
@Slf4j
public abstract class OpenAiCompatibleVisionProvider implements ImageVisionProvider {
protected static final int CALL_TIMEOUT_MS = 60_000;
private static final Pattern OFF_TOPIC_PREFIX =
Pattern.compile("^\\s*\\[OFF-TOPIC\\]\\s*", Pattern.CASE_INSENSITIVE);
protected final ModelProviderService modelProviderService;
protected final ObjectMapper objectMapper;
protected OpenAiCompatibleVisionProvider(ModelProviderService modelProviderService,
ObjectMapper objectMapper) {
this.modelProviderService = modelProviderService;
this.objectMapper = objectMapper;
}
/** {@code mate_model_provider.provider_id} this vision provider attaches to. */
protected abstract String providerKey();
protected abstract String defaultBaseUrl();
protected abstract String defaultModel();
/** Override when the endpoint isn't the OpenAI-standard suffix. */
protected String completionsPath() {
return "/chat/completions";
}
@Override
public boolean requiresCredential() {
return true;
}
@Override
public Set<ImageCapability> capabilities() {
return Set.of(ImageCapability.IMAGE_TO_TEXT);
}
@Override
public boolean isAvailable(SystemSettingsDTO settings) {
try {
return modelProviderService.isProviderConfigured(providerKey());
} catch (Exception e) {
log.debug("[Vision][{}] availability check failed: {}", id(), e.getMessage());
return false;
}
}
@Override
public VisionResult caption(VisionRequest request, SystemSettingsDTO settings) {
ModelProviderEntity provider = loadProvider();
String apiKey = provider.getApiKey();
if (apiKey == null || apiKey.isBlank()) {
throw new MateClawException("err.wiki.vision.no_provider",
label() + " API key is missing");
}
String baseUrl = (provider.getBaseUrl() == null || provider.getBaseUrl().isBlank())
? defaultBaseUrl()
: provider.getBaseUrl();
String model = (request.getPreferModel() == null || request.getPreferModel().isBlank())
? defaultModel()
: request.getPreferModel();
String prompt = buildPrompt(request.getContext());
String imageDataUrl = buildDataUrl(request.getMimeType(), request.getImageBytes());
ObjectNode body = objectMapper.createObjectNode();
body.put("model", model);
body.put("temperature", 0.0);
body.put("max_tokens", 4096);
ArrayNode messages = body.putArray("messages");
ObjectNode userMsg = messages.addObject();
userMsg.put("role", "user");
ArrayNode content = userMsg.putArray("content");
content.addObject().put("type", "text").put("text", prompt);
ObjectNode imagePart = content.addObject();
imagePart.put("type", "image_url");
imagePart.putObject("image_url").put("url", imageDataUrl);
long startNanos = System.nanoTime();
HttpResponse response;
try {
response = HttpRequest.post(baseUrl + completionsPath())
.header("Authorization", "Bearer " + apiKey)
.header("Content-Type", "application/json")
.body(body.toString())
.timeout(CALL_TIMEOUT_MS)
.execute();
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.provider_failed",
label() + " vision call failed: " + e.getMessage());
}
long durationMs = Duration.ofNanos(System.nanoTime() - startNanos).toMillis();
if (response.getStatus() != 200) {
String errBody = response.body();
log.warn("[Vision][{}] HTTP {} body={}", id(), response.getStatus(), truncate(errBody, 400));
throw new MateClawException("err.wiki.vision.provider_failed",
label() + " vision returned HTTP " + response.getStatus());
}
String text;
try {
JsonNode root = objectMapper.readTree(response.body());
text = root.path("choices").path(0).path("message").path("content").asText("").trim();
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.provider_failed",
label() + " vision response was unparseable: " + e.getMessage());
}
if (text.isEmpty()) {
throw new MateClawException("err.wiki.vision.provider_failed",
label() + " vision returned an empty caption");
}
Matcher offTopic = OFF_TOPIC_PREFIX.matcher(text);
boolean isOffTopic = offTopic.find();
String caption = isOffTopic ? offTopic.replaceFirst("").trim() : text;
return VisionResult.builder()
.caption(caption)
.visibleText(extractVisibleTextHeuristic(caption))
.offTopic(isOffTopic)
.providerId(id())
.model(model)
.capturedAt(Instant.now())
.durationMs(durationMs)
.build();
}
private ModelProviderEntity loadProvider() {
try {
return modelProviderService.getProviderConfig(providerKey());
} catch (Exception e) {
throw new MateClawException("err.wiki.vision.no_provider",
label() + " provider is not configured");
}
}
private String buildPrompt(VisionContext context) {
if (context != null && context.hasContext()) {
String template = PromptLoader.loadPrompt("wiki/vision-caption-context-aware");
String before = (context.getBeforeText() == null || context.getBeforeText().isBlank())
? "(none)" : context.getBeforeText().trim();
String after = (context.getAfterText() == null || context.getAfterText().isBlank())
? "(none)" : context.getAfterText().trim();
return template.replace("{before}", before).replace("{after}", after);
}
return PromptLoader.loadPrompt("wiki/vision-caption-factual");
}
private static String buildDataUrl(String mimeType, byte[] bytes) {
String mime = (mimeType == null || mimeType.isBlank()) ? "image/png" : mimeType;
return "data:" + mime + ";base64," + Base64.getEncoder().encodeToString(bytes);
}
/**
* Heuristic: pull obvious quoted strings as "visible text". This is a
* placeholder until a stronger OCR-grade extraction lands; for the
* factual prompt the model already inlines text verbatim into the
* caption, so the cache row's caption alone is enough for retrieval.
*/
private static String extractVisibleTextHeuristic(String caption) {
Matcher m = Pattern.compile("\"([^\"]{3,})\"").matcher(caption);
StringBuilder sb = new StringBuilder();
while (m.find()) {
if (sb.length() > 0) sb.append('\n');
sb.append(m.group(1));
}
return sb.length() == 0 ? null : sb.toString();
}
private static String truncate(String s, int max) {
if (s == null) return "";
return s.length() > max ? s.substring(0, max) + "" : s;
}
}

View File

@ -0,0 +1,59 @@
package vip.mate.tool.image.vision.provider;
import com.fasterxml.jackson.databind.ObjectMapper;
import org.springframework.stereotype.Component;
import vip.mate.llm.service.ModelProviderService;
/**
* Zhipu vision provider uses {@code glm-5v-turbo} via the
* OpenAI-compatible endpoint at {@code /api/paas/v4/chat/completions}.
*
* <p>Attaches to the {@code zhipu-cn} model provider. Operators with
* the international tier should configure the {@code zhipu-cn} entry
* with the international base URL or wait for an explicit intl
* sibling provider the seeded {@code zhipu-intl} differs only in
* base URL.
*/
@Component
public class ZhipuVisionProvider extends OpenAiCompatibleVisionProvider {
private static final String PROVIDER_ID = "zhipu-vision";
private static final String ZHIPU_PROVIDER_KEY = "zhipu-cn";
private static final String DEFAULT_MODEL = "glm-5v-turbo";
private static final String DEFAULT_BASE_URL = "https://open.bigmodel.cn/api/paas/v4";
public ZhipuVisionProvider(ModelProviderService modelProviderService,
ObjectMapper objectMapper) {
super(modelProviderService, objectMapper);
}
@Override
public String id() {
return PROVIDER_ID;
}
@Override
public String label() {
return "Zhipu GLM-V";
}
@Override
public int autoDetectOrder() {
return 20;
}
@Override
protected String providerKey() {
return ZHIPU_PROVIDER_KEY;
}
@Override
protected String defaultBaseUrl() {
return DEFAULT_BASE_URL;
}
@Override
protected String defaultModel() {
return DEFAULT_MODEL;
}
}