diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageCapability.java b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageCapability.java
index 93eaa4e4..273e12e4 100644
--- a/mateclaw-server/src/main/java/vip/mate/tool/image/ImageCapability.java
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/ImageCapability.java
@@ -1,7 +1,7 @@
package vip.mate.tool.image;
/**
- * 图片生成能力枚举
+ * 图片相关能力枚举(生成方向 + 解析方向)。
*
* @author MateClaw Team
*/
@@ -11,5 +11,8 @@ public enum ImageCapability {
TEXT_TO_IMAGE,
/** 图片编辑 / 风格转换 */
- IMAGE_EDIT
+ IMAGE_EDIT,
+
+ /** Image to text — vision-in pipeline (factual caption + best-effort visible text). */
+ IMAGE_TO_TEXT
}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionProvider.java
new file mode 100644
index 00000000..3bd04175
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionProvider.java
@@ -0,0 +1,59 @@
+package vip.mate.tool.image.vision;
+
+import vip.mate.system.model.SystemSettingsDTO;
+import vip.mate.tool.image.ImageCapability;
+
+import java.util.Set;
+
+/**
+ * SPI for image-to-text providers.
+ *
+ *
Symmetric counterpart of {@link vip.mate.tool.image.ImageGenerationProvider}.
+ * Implementations are auto-discovered as Spring beans and selected by
+ * {@link ImageVisionService} according to {@link #autoDetectOrder()}
+ * (lower wins) — typically: regional default first, premium fallbacks last.
+ *
+ * @author MateClaw Team
+ */
+public interface ImageVisionProvider {
+
+ /**
+ * Stable identifier for the SPI registry.
+ *
+ *
Convention: {@code -vision} (e.g. {@code dashscope-vision},
+ * {@code openai-vision}, {@code claude-vision}).
+ */
+ String id();
+
+ /** Display label for admin UI. */
+ String label();
+
+ /** Whether the provider needs an API key configured. */
+ boolean requiresCredential();
+
+ /**
+ * Auto-detect priority. Lower wins. Conventional bands:
+ *
+ * - 10–19: regional default (lowest cost, primary path)
+ * - 20–29: universal fallback
+ * - 30+: premium / niche fallback
+ *
+ */
+ int autoDetectOrder();
+
+ /** Capabilities advertised; today only {@link ImageCapability#IMAGE_TO_TEXT}. */
+ Set capabilities();
+
+ /** Reports whether the provider is currently usable (key configured + reachable). */
+ boolean isAvailable(SystemSettingsDTO settings);
+
+ /**
+ * Synchronously caption an image.
+ *
+ * Throws on failure — the caller (typically {@link ImageVisionService})
+ * decides whether to retry / fall back / surface to user.
+ *
+ * @return non-null populated {@link VisionResult}
+ */
+ VisionResult caption(VisionRequest request, SystemSettingsDTO settings);
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionService.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionService.java
new file mode 100644
index 00000000..5477e6bf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/ImageVisionService.java
@@ -0,0 +1,185 @@
+package vip.mate.tool.image.vision;
+
+import lombok.RequiredArgsConstructor;
+import lombok.extern.slf4j.Slf4j;
+import org.springframework.stereotype.Service;
+import vip.mate.exception.MateClawException;
+import vip.mate.system.featureflag.FeatureFlagService;
+import vip.mate.system.model.SystemSettingsDTO;
+import vip.mate.system.service.SystemSettingService;
+import vip.mate.wiki.metrics.WikiMetrics;
+import vip.mate.wiki.model.WikiImageCaptionCacheEntity;
+import vip.mate.wiki.service.WikiImageCaptionCacheService;
+
+import java.security.MessageDigest;
+import java.security.NoSuchAlgorithmException;
+import java.time.Duration;
+import java.time.Instant;
+import java.time.LocalDateTime;
+import java.time.ZoneId;
+import java.util.Comparator;
+import java.util.List;
+import java.util.Optional;
+
+/**
+ * Routing + caching coordinator for the image-to-text pipeline.
+ *
+ *
End-to-end flow:
+ *
+ * - Short-circuit when the {@code wiki.ocr.enabled} feature flag is off.
+ * - Hash the image bytes (SHA-256) and probe the caption cache. A cache
+ * hit returns immediately and bumps the row's {@code hit_count} as a
+ * side effect.
+ * - On miss, walk providers in {@link ImageVisionProvider#autoDetectOrder()}
+ * order. The first available provider that returns a non-null caption
+ * wins. Failures are logged and the next provider is tried.
+ * - Persist the winning caption to the cache (race-tolerant) and emit
+ * success metrics.
+ * - If every provider fails, throw a {@link MateClawException} carrying
+ * the i18n key {@code err.wiki.vision.all_failed}.
+ *
+ *
+ * @author MateClaw Team
+ */
+@Slf4j
+@Service
+@RequiredArgsConstructor
+public class ImageVisionService {
+
+ private static final String FEATURE_FLAG = "wiki.ocr.enabled";
+
+ private final List providers;
+ private final WikiImageCaptionCacheService cacheService;
+ private final SystemSettingService systemSettingService;
+ private final FeatureFlagService featureFlagService;
+ private final WikiMetrics metrics;
+
+ /**
+ * Captions an image, going through cache → provider chain.
+ *
+ * @throws MateClawException with key {@code err.wiki.vision.disabled} when the feature flag is off
+ * @throws MateClawException with key {@code err.wiki.vision.no_provider} when no provider is available
+ * @throws MateClawException with key {@code err.wiki.vision.all_failed} when every provider failed
+ */
+ public VisionResult caption(VisionRequest request) {
+ if (request == null || request.getImageBytes() == null
+ || request.getImageBytes().length == 0) {
+ throw new IllegalArgumentException("VisionRequest with image bytes is required");
+ }
+ if (!featureFlagService.isEnabled(FEATURE_FLAG)) {
+ throw new MateClawException("err.wiki.vision.disabled",
+ "Image vision pipeline is currently disabled");
+ }
+
+ String sha256 = sha256Hex(request.getImageBytes());
+
+ // 1. Cache check.
+ Optional cached = cacheService.lookup(sha256);
+ if (cached.isPresent()) {
+ metrics.recordVisionCacheHit(true);
+ log.debug("[Vision] cache hit sha={}", shortSha(sha256));
+ return fromEntity(cached.get());
+ }
+ metrics.recordVisionCacheHit(false);
+
+ // 2. Provider walk.
+ SystemSettingsDTO settings = systemSettingService.getSettings();
+ List available = providers.stream()
+ .filter(p -> {
+ try {
+ return p.isAvailable(settings);
+ } catch (Exception e) {
+ log.debug("[Vision] availability check threw for provider={}: {}",
+ p.id(), e.getMessage());
+ return false;
+ }
+ })
+ .sorted(Comparator.comparingInt(ImageVisionProvider::autoDetectOrder))
+ .toList();
+ if (available.isEmpty()) {
+ throw new MateClawException("err.wiki.vision.no_provider",
+ "No image vision provider is configured");
+ }
+
+ VisionResult winner = null;
+ Throwable lastError = null;
+ for (ImageVisionProvider provider : available) {
+ long startNanos = System.nanoTime();
+ try {
+ winner = provider.caption(request, settings);
+ metrics.recordVisionCall(provider.id(), true,
+ Duration.ofNanos(System.nanoTime() - startNanos));
+ if (winner != null) {
+ break;
+ }
+ } catch (Exception e) {
+ metrics.recordVisionCall(provider.id(), false,
+ Duration.ofNanos(System.nanoTime() - startNanos));
+ log.warn("[Vision] provider={} failed sha={}: {}",
+ provider.id(), shortSha(sha256), e.getMessage());
+ lastError = e;
+ }
+ }
+
+ if (winner == null) {
+ String why = lastError != null ? lastError.getMessage() : "all providers returned null";
+ throw new MateClawException("err.wiki.vision.all_failed",
+ "All image vision providers failed: " + why);
+ }
+
+ // 3. Persist to cache.
+ cacheService.persist(toEntity(sha256, winner, request.getMimeType()));
+
+ return winner;
+ }
+
+ // ==================== internal ====================
+
+ private static VisionResult fromEntity(WikiImageCaptionCacheEntity row) {
+ Instant captured = row.getCapturedAt() == null
+ ? Instant.now()
+ : row.getCapturedAt().atZone(ZoneId.systemDefault()).toInstant();
+ return VisionResult.builder()
+ .caption(row.getCaption())
+ .visibleText(row.getVisibleText())
+ .providerId(row.getProviderId())
+ .model(row.getCaptureModel())
+ .capturedAt(captured)
+ .durationMs(row.getDurationMs() != null ? row.getDurationMs() : 0L)
+ .build();
+ }
+
+ private static WikiImageCaptionCacheEntity toEntity(String sha256, VisionResult result,
+ String mimeType) {
+ WikiImageCaptionCacheEntity row = new WikiImageCaptionCacheEntity();
+ row.setImageSha256(sha256);
+ row.setCaption(result.getCaption());
+ row.setVisibleText(result.getVisibleText());
+ row.setMimeType(mimeType);
+ row.setCaptureModel(result.getModel());
+ row.setProviderId(result.getProviderId());
+ row.setDurationMs(result.getDurationMs());
+ row.setHitCount(0L);
+ row.setCapturedAt(result.getCapturedAt() != null
+ ? LocalDateTime.ofInstant(result.getCapturedAt(), ZoneId.systemDefault())
+ : LocalDateTime.now());
+ return row;
+ }
+
+ static String sha256Hex(byte[] bytes) {
+ try {
+ byte[] digest = MessageDigest.getInstance("SHA-256").digest(bytes);
+ StringBuilder sb = new StringBuilder(64);
+ for (byte b : digest) {
+ sb.append(String.format("%02x", b));
+ }
+ return sb.toString();
+ } catch (NoSuchAlgorithmException e) {
+ throw new IllegalStateException("SHA-256 unavailable on this JVM", e);
+ }
+ }
+
+ private static String shortSha(String sha) {
+ return sha.substring(0, Math.min(8, sha.length()));
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionContext.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionContext.java
new file mode 100644
index 00000000..cd3f3805
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionContext.java
@@ -0,0 +1,30 @@
+package vip.mate.tool.image.vision;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * Surrounding text fed to the vision provider for context-aware captioning.
+ *
+ * When at least one side is non-blank the provider switches from the
+ * factual-only prompt to the context-aware prompt, which asks the model
+ * to flag images that look unrelated to the surrounding text (so callers
+ * can mark them off-topic in downstream pipelines).
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Builder
+public class VisionContext {
+
+ /** Up to ~500 chars of text that appeared immediately before the image. */
+ private String beforeText;
+
+ /** Up to ~500 chars of text that appeared immediately after the image. */
+ private String afterText;
+
+ public boolean hasContext() {
+ return (beforeText != null && !beforeText.isBlank())
+ || (afterText != null && !afterText.isBlank());
+ }
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionRequest.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionRequest.java
new file mode 100644
index 00000000..c1c09caf
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionRequest.java
@@ -0,0 +1,35 @@
+package vip.mate.tool.image.vision;
+
+import lombok.Builder;
+import lombok.Data;
+
+/**
+ * Input to one image-to-text call.
+ *
+ *
The image is supplied as raw bytes plus a MIME type; callers are
+ * responsible for any format conversion before invoking. The optional
+ * {@link VisionContext} carries surrounding text so the provider can
+ * decide whether the image is on-topic and produce a context-aware
+ * caption.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Builder
+public class VisionRequest {
+
+ /** Raw image bytes (PNG / JPEG / WebP / ...). Required. */
+ private byte[] imageBytes;
+
+ /** MIME type, e.g. {@code image/png}. Required. */
+ private String mimeType;
+
+ /** Optional surrounding-text context for context-aware captioning. */
+ private VisionContext context;
+
+ /**
+ * Caller hint: when set, ask the provider for this specific model name.
+ * Provider may ignore the hint if the model is unsupported.
+ */
+ private String preferModel;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionResult.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionResult.java
new file mode 100644
index 00000000..e5aa558e
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/VisionResult.java
@@ -0,0 +1,45 @@
+package vip.mate.tool.image.vision;
+
+import lombok.Builder;
+import lombok.Data;
+
+import java.time.Instant;
+
+/**
+ * Outcome of one successful image-to-text call.
+ *
+ *
Producers must populate {@link #caption}, {@link #providerId},
+ * {@link #model} and {@link #capturedAt} at minimum. The other fields
+ * are optional and allow callers to render richer UI.
+ *
+ * @author MateClaw Team
+ */
+@Data
+@Builder
+public class VisionResult {
+
+ /** 2-4 sentence factual description of the image (primary output). */
+ private String caption;
+
+ /** Best-effort recovery of any text rendered inside the image; may be null. */
+ private String visibleText;
+
+ /**
+ * True when the context-aware prompt determined the image is unrelated
+ * to the surrounding text. Caller decides what to do with off-topic
+ * captions (downrank / hide / annotate). False (or null) when context
+ * was not supplied.
+ */
+ private boolean offTopic;
+
+ /** SPI-registry id of the provider that produced this result. */
+ private String providerId;
+
+ /** Vendor-specific model identifier (e.g. {@code qwen-vl-max}). */
+ private String model;
+
+ private Instant capturedAt;
+
+ /** Wall-clock duration of the round-trip, in milliseconds. */
+ private long durationMs;
+}
diff --git a/mateclaw-server/src/main/java/vip/mate/tool/image/vision/provider/DashScopeVisionProvider.java b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/provider/DashScopeVisionProvider.java
new file mode 100644
index 00000000..fbf6f805
--- /dev/null
+++ b/mateclaw-server/src/main/java/vip/mate/tool/image/vision/provider/DashScopeVisionProvider.java
@@ -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.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}.
+ *
+ *
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.
+ *
+ *
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
+ */
+@Slf4j
+@Component
+@RequiredArgsConstructor
+public class DashScopeVisionProvider implements ImageVisionProvider {
+
+ 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;
+
+ @Override
+ public String id() {
+ return PROVIDER_ID;
+ }
+
+ @Override
+ public String label() {
+ return "DashScope qwen-vl";
+ }
+
+ @Override
+ public boolean requiresCredential() {
+ return true;
+ }
+
+ @Override
+ public int autoDetectOrder() {
+ return 10;
+ }
+
+ @Override
+ public Set capabilities() {
+ return Set.of(ImageCapability.IMAGE_TO_TEXT);
+ }
+
+ @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;
+ }
+ }
+
+ @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;
+ }
+}
diff --git a/mateclaw-server/src/main/resources/messages.properties b/mateclaw-server/src/main/resources/messages.properties
index d05a471e..51fb2682 100644
--- a/mateclaw-server/src/main/resources/messages.properties
+++ b/mateclaw-server/src/main/resources/messages.properties
@@ -273,3 +273,9 @@ agent.limit_exceeded.empty_context=\uff08\u5c1a\u672a\u6536\u96c6\u5230\u5de5\u5
cron.tasks_conversation.title=📋 定时任务
cron.run_header.scheduled=定时触发
cron.run_header.manual=手动触发
+
+# --- Wiki vision-in pipeline ---
+err.wiki.vision.disabled=图片识别功能未启用
+err.wiki.vision.no_provider=未配置可用的图片识别 provider
+err.wiki.vision.provider_failed=图片识别 provider 调用失败
+err.wiki.vision.all_failed=所有图片识别 provider 调用失败
diff --git a/mateclaw-server/src/main/resources/messages_en.properties b/mateclaw-server/src/main/resources/messages_en.properties
index 69fcb9d9..3802bce9 100644
--- a/mateclaw-server/src/main/resources/messages_en.properties
+++ b/mateclaw-server/src/main/resources/messages_en.properties
@@ -280,3 +280,9 @@ agent.limit_exceeded.empty_context=(No tool call results collected yet.)
cron.tasks_conversation.title=📋 Scheduled Tasks
cron.run_header.scheduled=scheduled
cron.run_header.manual=manual
+
+# --- Wiki vision-in pipeline ---
+err.wiki.vision.disabled=Image vision pipeline is currently disabled
+err.wiki.vision.no_provider=No image vision provider is configured
+err.wiki.vision.provider_failed=Image vision provider call failed
+err.wiki.vision.all_failed=All image vision providers failed
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-context-aware.txt b/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-context-aware.txt
new file mode 100644
index 00000000..64ac2903
--- /dev/null
+++ b/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-context-aware.txt
@@ -0,0 +1,11 @@
+Describe this image factually for a knowledge-base index. Surrounding text below tells you what topic the image relates to.
+
+--- Text before image ---
+{before}
+
+--- Text after image ---
+{after}
+
+--- End surrounding text ---
+
+If the image is clearly NOT relevant to the surrounding text, prepend "[OFF-TOPIC] " to your response. Otherwise, describe factually: visible text verbatim, chart axes and values, diagram structure, key visual elements. 2 to 4 sentences. Plain text only — no markdown, no preamble.
diff --git a/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-factual.txt b/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-factual.txt
new file mode 100644
index 00000000..60b21d79
--- /dev/null
+++ b/mateclaw-server/src/main/resources/prompts/wiki/vision-caption-factual.txt
@@ -0,0 +1 @@
+Describe this image factually for a knowledge-base index. Include: any visible text verbatim, chart axes and values, diagram structure (boxes/arrows/labels), key visual elements. Do NOT speculate or editorialize. 2 to 4 sentences. Output plain text only — no markdown, no preamble.