diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java
index 443a8cef..742d3228 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/PdfImageExtractor.java
@@ -63,6 +63,54 @@ public class PdfImageExtractor {
// a mock or a no-op wrapper.
}
+ /**
+ * Quick check: does the PDF contain at least one inline image that
+ * passes the size threshold? Stops scanning at the first qualifying
+ * image so it's cheap on large documents.
+ *
+ *
Used by the upload pipeline to decide whether the absence of
+ * captions is an "actually nothing to caption" outcome (safe to cache)
+ * vs a "vision was unavailable" outcome (defer caching until flag
+ * flips on so the next read retries).
+ */
+ public boolean hasInlineImages(Path pdfPath) {
+ if (pdfPath == null) {
+ return false;
+ }
+ File pdfFile = pdfPath.toFile();
+ if (!pdfFile.isFile()) {
+ return false;
+ }
+ try (PDDocument doc = Loader.loadPDF(pdfFile)) {
+ for (PDPage page : doc.getPages()) {
+ PDResources resources = page.getResources();
+ if (resources == null) continue;
+ for (COSName name : resources.getXObjectNames()) {
+ PDXObject obj;
+ try {
+ obj = resources.getXObject(name);
+ } catch (IOException ignored) {
+ continue;
+ }
+ if (!(obj instanceof PDImageXObject pdImage)) continue;
+ BufferedImage bi;
+ try {
+ bi = pdImage.getImage();
+ } catch (IOException | RuntimeException ignored) {
+ continue;
+ }
+ if (bi.getWidth() >= MIN_IMAGE_SIDE_PX
+ && bi.getHeight() >= MIN_IMAGE_SIDE_PX) {
+ return true;
+ }
+ }
+ }
+ } catch (IOException e) {
+ log.debug("[PdfImage] hasInlineImages probe failed for {}: {}", pdfPath, e.getMessage());
+ }
+ return false;
+ }
+
/**
* Walks every page of {@code pdfPath}, captions each qualifying inline
* image, and returns the rendered marker lines in document order.
diff --git a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java
index d4b1a746..8085b6ad 100644
--- a/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java
+++ b/mateclaw-server/src/main/java/vip/mate/wiki/service/WikiRawMaterialService.java
@@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
import org.springframework.context.ApplicationEventPublisher;
import org.springframework.stereotype.Service;
import org.springframework.transaction.annotation.Transactional;
+import vip.mate.system.featureflag.FeatureFlagService;
import vip.mate.tool.builtin.DocumentExtractTool;
import vip.mate.tool.image.vision.ImageVisionService;
import vip.mate.tool.image.vision.VisionRequest;
@@ -34,6 +35,8 @@ import java.util.concurrent.ConcurrentHashMap;
@RequiredArgsConstructor
public class WikiRawMaterialService {
+ private static final String VISION_FLAG_KEY = "wiki.ocr.enabled";
+
private final WikiRawMaterialMapper rawMapper;
private final WikiKnowledgeBaseService kbService;
private final WikiProperties properties;
@@ -43,6 +46,7 @@ public class WikiRawMaterialService {
private final WikiChunkService chunkService;
private final ImageVisionService imageVisionService;
private final PdfImageExtractor pdfImageExtractor;
+ private final FeatureFlagService featureFlagService;
/**
* RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标,
@@ -356,19 +360,26 @@ public class WikiRawMaterialService {
// Append inline-image captions for PDFs so chunk-level search
// hits chart/diagram contents that the text extractor missed.
// Failures are non-fatal: the body text is still returned.
- String enriched = appendPdfImageCaptions(entity, text);
+ EnrichmentOutcome enrichment = appendPdfImageCaptions(entity, text);
if (truncated) {
// 截断的结果不缓存,避免永久丢失后半内容。返回文本供分块处理使用。
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
text.length(), entity.getSourcePath());
+ } else if (!enrichment.shouldCache()) {
+ // Vision was unavailable but the PDF has images we'd otherwise
+ // caption — leave extracted_text NULL so the next call retries
+ // once the operator enables wiki.ocr.enabled.
+ log.info("[Wiki] PDF id={} captioning deferred (vision disabled or unavailable); "
+ + "extracted_text not cached so next read retries", entity.getId());
} else {
// Full extraction: cache to avoid re-extracting on subsequent calls.
- updateExtractedText(entity.getId(), enriched);
+ updateExtractedText(entity.getId(), enrichment.text());
}
- log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}",
- entity.getSourcePath(), text.length(), enriched.length(), json.getStr("method"), truncated);
- return enriched;
+ log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}, cached={}",
+ entity.getSourcePath(), text.length(), enrichment.text().length(),
+ json.getStr("method"), truncated, enrichment.shouldCache() && !truncated);
+ return enrichment.text();
}
}
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
@@ -379,35 +390,75 @@ public class WikiRawMaterialService {
return entity.getOriginalContent();
}
+ /**
+ * Enrichment outcome: text + a hint about whether the result is final.
+ *
+ *
{@code shouldCache=false} signals the caller to skip the
+ * extracted_text cache write so the next call re-runs PDF image
+ * captioning. This handles the common operator workflow of uploading
+ * a PDF before flipping {@code wiki.ocr.enabled} on — without the
+ * skip, the partial text-only result would block the eventual
+ * caption catch-up.
+ */
+ private record EnrichmentOutcome(String text, boolean shouldCache) { }
+
/**
* For PDF raw materials, walks the inline images and appends a section of
* {@code [图 P{n}#{m}]:
} markers so downstream chunking and
- * search can index image contents. No-op for non-PDF source types and
- * when the vision pipeline is unavailable.
+ * search can index image contents.
+ *
+ *
Returns an {@link EnrichmentOutcome} that distinguishes three cases:
+ *
+ *
Non-PDF / no extractor / no images present → text unchanged,
+ * {@code shouldCache=true}.
+ *
Vision disabled but the PDF has inline images → text unchanged,
+ * {@code shouldCache=false} so the next read retries after the
+ * operator flips {@code wiki.ocr.enabled} on.
+ *
Vision enabled and at least one image captioned → enriched text,
+ * {@code shouldCache=true}.
+ *
Vision enabled but every image's caption call failed → text
+ * unchanged, {@code shouldCache=true} (transient failures should
+ * not block the cache; if the model is wedged, retry happens via
+ * a manual reprocess).
+ *
*/
- private String appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
- if (!"pdf".equals(entity.getSourceType())) {
- return body;
+ private EnrichmentOutcome appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
+ if (!"pdf".equals(entity.getSourceType()) || pdfImageExtractor == null) {
+ return new EnrichmentOutcome(body, true);
}
- if (pdfImageExtractor == null) {
- return body;
+
+ java.nio.file.Path pdfPath = java.nio.file.Paths.get(entity.getSourcePath());
+
+ // Vision-disabled path: only refuse the cache when the PDF actually has
+ // qualifying images. Image-less PDFs can still be cached as text-only.
+ boolean visionEnabled = featureFlagService != null
+ && featureFlagService.isEnabled(VISION_FLAG_KEY);
+ if (!visionEnabled) {
+ boolean hasImages = pdfImageExtractor.hasInlineImages(pdfPath);
+ if (hasImages) {
+ log.info("[Wiki] PDF id={} has inline images but {} is disabled; "
+ + "skipping captioning and deferring extracted_text cache",
+ entity.getId(), VISION_FLAG_KEY);
+ return new EnrichmentOutcome(body, false);
+ }
+ return new EnrichmentOutcome(body, true);
}
+
try {
- List snippets = pdfImageExtractor.captionInlineImages(
- java.nio.file.Paths.get(entity.getSourcePath()));
+ List snippets = pdfImageExtractor.captionInlineImages(pdfPath);
if (snippets.isEmpty()) {
- return body;
+ return new EnrichmentOutcome(body, true);
}
StringBuilder sb = new StringBuilder(body);
sb.append("\n\n--- Inline images ---\n");
for (String snippet : snippets) {
sb.append(snippet).append('\n');
}
- return sb.toString();
+ return new EnrichmentOutcome(sb.toString(), true);
} catch (Exception e) {
log.warn("[Wiki] PDF inline-image captioning failed for id={}: {}",
entity.getId(), e.getMessage());
- return body;
+ return new EnrichmentOutcome(body, true);
}
}
diff --git a/mateclaw-ui/src/api/index.ts b/mateclaw-ui/src/api/index.ts
index 3cfbe384..b101122d 100644
--- a/mateclaw-ui/src/api/index.ts
+++ b/mateclaw-ui/src/api/index.ts
@@ -626,3 +626,30 @@ export const auditApi = {
size?: number
}) => http.get('/audit/events', { params }),
}
+
+// ==================== Feature Flags ====================
+export interface FeatureFlag {
+ id: number
+ flagKey: string
+ enabled: boolean
+ description?: string
+ whitelistKbIds?: string
+ whitelistUserIds?: string
+ rolloutPercent?: number
+ createTime?: string
+ updateTime?: string
+}
+
+export interface FeatureFlagUpdate {
+ enabled?: boolean
+ description?: string
+ whitelistKbIds?: string
+ whitelistUserIds?: string
+ rolloutPercent?: number
+}
+
+export const featureFlagApi = {
+ list: () => http.get('/feature-flags'),
+ update: (flagKey: string, data: FeatureFlagUpdate) =>
+ http.put(`/feature-flags/${flagKey}`, data),
+}
diff --git a/mateclaw-ui/src/i18n/locales/en-US.ts b/mateclaw-ui/src/i18n/locales/en-US.ts
index 3e0a9bf5..f5659a05 100644
--- a/mateclaw-ui/src/i18n/locales/en-US.ts
+++ b/mateclaw-ui/src/i18n/locales/en-US.ts
@@ -318,9 +318,35 @@ export default {
music: 'Music Generation',
video: 'Video Generation',
model3d: '3D Generation',
+ featureFlags: 'Feature Flags',
about: 'About',
advanced: 'Advanced',
},
+ featureFlags: {
+ title: 'Feature Flags',
+ description: 'Runtime-toggleable feature switches. Edits apply immediately on the writing instance and propagate to peers within one cache refresh tick (≈30 s); no server restart required.',
+ empty: 'No feature flags registered.',
+ enabled: 'Enabled {key}',
+ disabled: 'Disabled {key}',
+ toggleFailed: 'Toggle failed, please retry',
+ footer: 'New features ship disabled by default. After enabling, watch the metrics and logs for the relevant module before widening the rollout.',
+ scope: {
+ kb: 'KB whitelist',
+ user: 'User whitelist',
+ rollout: 'Rollout {pct}%',
+ },
+ descriptions: {
+ 'wiki.ocr.enabled': 'Image OCR / vision-in pipeline for wiki uploads — captions images via a vision model on upload',
+ 'wiki.compile.4stage.enabled': 'Four-stage knowledge base compilation pipeline (Summary / Planning / Generation / Index)',
+ 'wiki.compile.cache.enabled': 'Prompt cache layer for the wiki compile pipeline — cuts ~30% input tokens on cache hits',
+ 'wiki.confidence.enabled': 'Confidence taxonomy on wiki relations and pages (EXTRACTED / INFERRED / AMBIGUOUS / UNVERIFIED)',
+ 'wiki.hot_cache.enabled': 'KB-level recent-activity snapshot injected into the agent system prompt',
+ 'wiki.graph.insights.enabled': 'Wiki graph insights panel (surprising connections / gaps / bridges)',
+ 'wiki.graph.adamic_adar.enabled': 'Adamic-Adar topology signal, additive to the existing four relevance signals',
+ 'wiki.graph.boundary.enabled': 'Boundary score for surfacing dangling pages (high out-degree, low in-degree)',
+ 'wiki.relation.cache.enabled': 'Persistent cache for wiki page-to-page relation computation (default on, performance baseline)',
+ },
+ },
modelTitle: 'Model Management',
modelDesc: 'Manage provider presets and default model selection',
systemTitle: 'System',
diff --git a/mateclaw-ui/src/i18n/locales/zh-CN.ts b/mateclaw-ui/src/i18n/locales/zh-CN.ts
index 0ed52cc8..e19f6aca 100644
--- a/mateclaw-ui/src/i18n/locales/zh-CN.ts
+++ b/mateclaw-ui/src/i18n/locales/zh-CN.ts
@@ -308,9 +308,35 @@ export default {
music: '音乐生成',
video: '视频生成',
model3d: '3D 生成',
+ featureFlags: '功能开关',
about: '关于',
advanced: '高级',
},
+ featureFlags: {
+ title: '功能开关',
+ description: '运行时可切换的功能开关。修改后立即在所有节点生效(每实例 30 秒内同步缓存);不需要重启服务。',
+ empty: '当前没有已注册的功能开关。',
+ enabled: '已开启 {key}',
+ disabled: '已关闭 {key}',
+ toggleFailed: '切换失败,请稍后重试',
+ footer: '默认情况下新功能以关闭状态发布。开启后请关注对应模块的指标与日志,确认无回归再扩大灰度。',
+ scope: {
+ kb: '知识库白名单',
+ user: '用户白名单',
+ rollout: '灰度比例 {pct}%',
+ },
+ descriptions: {
+ 'wiki.ocr.enabled': '维基上传的图片 OCR / 视觉理解流水线(开启后图片会调用 vision 模型生成事实性描述)',
+ 'wiki.compile.4stage.enabled': '四阶段知识库编译流水线(摘要 / 规划 / 生成 / 索引)',
+ 'wiki.compile.cache.enabled': '维基编译流水线的提示词缓存层(命中后能省 30%+ token)',
+ 'wiki.confidence.enabled': '维基关系与页面的置信度分类(EXTRACTED / INFERRED / AMBIGUOUS / UNVERIFIED)',
+ 'wiki.hot_cache.enabled': '知识库级"最近活动"快照,注入到 Agent 的系统提示',
+ 'wiki.graph.insights.enabled': '维基图谱洞察面板(意外连接 / 知识缺口 / 桥接节点)',
+ 'wiki.graph.adamic_adar.enabled': 'Adamic-Adar 拓扑相似度信号(叠加在现有四个信号之上)',
+ 'wiki.graph.boundary.enabled': '边界分数:让"悬空"页面(出度大入度小)冒头',
+ 'wiki.relation.cache.enabled': '维基页面间关系计算的持久化缓存(默认开启,性能基线)',
+ },
+ },
modelTitle: '模型管理',
modelDesc: '管理模型预设与默认模型选择',
systemTitle: '系统设置',
diff --git a/mateclaw-ui/src/router/index.ts b/mateclaw-ui/src/router/index.ts
index c36c05a9..31eab3a7 100644
--- a/mateclaw-ui/src/router/index.ts
+++ b/mateclaw-ui/src/router/index.ts
@@ -189,6 +189,12 @@ const router = createRouter({
component: () => import('@/views/TokenUsage.vue'),
meta: { title: 'Settings - Token Usage' },
},
+ {
+ path: 'feature-flags',
+ name: 'SettingsFeatureFlags',
+ component: () => import('@/views/Settings/FeatureFlags/index.vue'),
+ meta: { title: 'Settings - Feature Flags' },
+ },
{
path: 'about',
name: 'SettingsAbout',
diff --git a/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue b/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue
new file mode 100644
index 00000000..67ed4ab1
--- /dev/null
+++ b/mateclaw-ui/src/views/Settings/FeatureFlags/index.vue
@@ -0,0 +1,221 @@
+
+