mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-15 20:08:18 +08:00
feat(wiki): feature-flag toggle UI + defer extracted_text cache when vision unavailable
This commit is contained in:
parent
bcefe43234
commit
f910d762a3
@ -63,6 +63,54 @@ public class PdfImageExtractor {
|
|||||||
// a mock or a no-op wrapper.
|
// 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.
|
||||||
|
*
|
||||||
|
* <p>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
|
* Walks every page of {@code pdfPath}, captions each qualifying inline
|
||||||
* image, and returns the rendered marker lines in document order.
|
* image, and returns the rendered marker lines in document order.
|
||||||
|
|||||||
@ -8,6 +8,7 @@ import lombok.extern.slf4j.Slf4j;
|
|||||||
import org.springframework.context.ApplicationEventPublisher;
|
import org.springframework.context.ApplicationEventPublisher;
|
||||||
import org.springframework.stereotype.Service;
|
import org.springframework.stereotype.Service;
|
||||||
import org.springframework.transaction.annotation.Transactional;
|
import org.springframework.transaction.annotation.Transactional;
|
||||||
|
import vip.mate.system.featureflag.FeatureFlagService;
|
||||||
import vip.mate.tool.builtin.DocumentExtractTool;
|
import vip.mate.tool.builtin.DocumentExtractTool;
|
||||||
import vip.mate.tool.image.vision.ImageVisionService;
|
import vip.mate.tool.image.vision.ImageVisionService;
|
||||||
import vip.mate.tool.image.vision.VisionRequest;
|
import vip.mate.tool.image.vision.VisionRequest;
|
||||||
@ -34,6 +35,8 @@ import java.util.concurrent.ConcurrentHashMap;
|
|||||||
@RequiredArgsConstructor
|
@RequiredArgsConstructor
|
||||||
public class WikiRawMaterialService {
|
public class WikiRawMaterialService {
|
||||||
|
|
||||||
|
private static final String VISION_FLAG_KEY = "wiki.ocr.enabled";
|
||||||
|
|
||||||
private final WikiRawMaterialMapper rawMapper;
|
private final WikiRawMaterialMapper rawMapper;
|
||||||
private final WikiKnowledgeBaseService kbService;
|
private final WikiKnowledgeBaseService kbService;
|
||||||
private final WikiProperties properties;
|
private final WikiProperties properties;
|
||||||
@ -43,6 +46,7 @@ public class WikiRawMaterialService {
|
|||||||
private final WikiChunkService chunkService;
|
private final WikiChunkService chunkService;
|
||||||
private final ImageVisionService imageVisionService;
|
private final ImageVisionService imageVisionService;
|
||||||
private final PdfImageExtractor pdfImageExtractor;
|
private final PdfImageExtractor pdfImageExtractor;
|
||||||
|
private final FeatureFlagService featureFlagService;
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* RFC-012 follow-up #3:从 partial 状态触发的 reprocess 会在此 set 中打标,
|
* 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
|
// Append inline-image captions for PDFs so chunk-level search
|
||||||
// hits chart/diagram contents that the text extractor missed.
|
// hits chart/diagram contents that the text extractor missed.
|
||||||
// Failures are non-fatal: the body text is still returned.
|
// Failures are non-fatal: the body text is still returned.
|
||||||
String enriched = appendPdfImageCaptions(entity, text);
|
EnrichmentOutcome enrichment = appendPdfImageCaptions(entity, text);
|
||||||
|
|
||||||
if (truncated) {
|
if (truncated) {
|
||||||
// 截断的结果不缓存,避免永久丢失后半内容。返回文本供分块处理使用。
|
// 截断的结果不缓存,避免永久丢失后半内容。返回文本供分块处理使用。
|
||||||
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
|
log.warn("[Wiki] Extracted text truncated at {} chars for: {} (full document may be larger)",
|
||||||
text.length(), entity.getSourcePath());
|
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 {
|
} else {
|
||||||
// Full extraction: cache to avoid re-extracting on subsequent calls.
|
// 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={}",
|
log.info("[Wiki] Extracted text from {}: {} chars (text) → {} chars (enriched), method={}, truncated={}, cached={}",
|
||||||
entity.getSourcePath(), text.length(), enriched.length(), json.getStr("method"), truncated);
|
entity.getSourcePath(), text.length(), enrichment.text().length(),
|
||||||
return enriched;
|
json.getStr("method"), truncated, enrichment.shouldCache() && !truncated);
|
||||||
|
return enrichment.text();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
|
log.warn("[Wiki] Document extraction returned no text for: {}", entity.getSourcePath());
|
||||||
@ -379,35 +390,75 @@ public class WikiRawMaterialService {
|
|||||||
return entity.getOriginalContent();
|
return entity.getOriginalContent();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Enrichment outcome: text + a hint about whether the result is final.
|
||||||
|
*
|
||||||
|
* <p>{@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
|
* For PDF raw materials, walks the inline images and appends a section of
|
||||||
* {@code [图 P{n}#{m}]: <caption>} markers so downstream chunking and
|
* {@code [图 P{n}#{m}]: <caption>} markers so downstream chunking and
|
||||||
* search can index image contents. No-op for non-PDF source types and
|
* search can index image contents.
|
||||||
* when the vision pipeline is unavailable.
|
*
|
||||||
|
* <p>Returns an {@link EnrichmentOutcome} that distinguishes three cases:
|
||||||
|
* <ul>
|
||||||
|
* <li>Non-PDF / no extractor / no images present → text unchanged,
|
||||||
|
* {@code shouldCache=true}.</li>
|
||||||
|
* <li>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.</li>
|
||||||
|
* <li>Vision enabled and at least one image captioned → enriched text,
|
||||||
|
* {@code shouldCache=true}.</li>
|
||||||
|
* <li>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).</li>
|
||||||
|
* </ul>
|
||||||
*/
|
*/
|
||||||
private String appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
|
private EnrichmentOutcome appendPdfImageCaptions(WikiRawMaterialEntity entity, String body) {
|
||||||
if (!"pdf".equals(entity.getSourceType())) {
|
if (!"pdf".equals(entity.getSourceType()) || pdfImageExtractor == null) {
|
||||||
return body;
|
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 {
|
try {
|
||||||
List<String> snippets = pdfImageExtractor.captionInlineImages(
|
List<String> snippets = pdfImageExtractor.captionInlineImages(pdfPath);
|
||||||
java.nio.file.Paths.get(entity.getSourcePath()));
|
|
||||||
if (snippets.isEmpty()) {
|
if (snippets.isEmpty()) {
|
||||||
return body;
|
return new EnrichmentOutcome(body, true);
|
||||||
}
|
}
|
||||||
StringBuilder sb = new StringBuilder(body);
|
StringBuilder sb = new StringBuilder(body);
|
||||||
sb.append("\n\n--- Inline images ---\n");
|
sb.append("\n\n--- Inline images ---\n");
|
||||||
for (String snippet : snippets) {
|
for (String snippet : snippets) {
|
||||||
sb.append(snippet).append('\n');
|
sb.append(snippet).append('\n');
|
||||||
}
|
}
|
||||||
return sb.toString();
|
return new EnrichmentOutcome(sb.toString(), true);
|
||||||
} catch (Exception e) {
|
} catch (Exception e) {
|
||||||
log.warn("[Wiki] PDF inline-image captioning failed for id={}: {}",
|
log.warn("[Wiki] PDF inline-image captioning failed for id={}: {}",
|
||||||
entity.getId(), e.getMessage());
|
entity.getId(), e.getMessage());
|
||||||
return body;
|
return new EnrichmentOutcome(body, true);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@ -626,3 +626,30 @@ export const auditApi = {
|
|||||||
size?: number
|
size?: number
|
||||||
}) => http.get('/audit/events', { params }),
|
}) => 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<FeatureFlag[]>('/feature-flags'),
|
||||||
|
update: (flagKey: string, data: FeatureFlagUpdate) =>
|
||||||
|
http.put(`/feature-flags/${flagKey}`, data),
|
||||||
|
}
|
||||||
|
|||||||
@ -318,9 +318,35 @@ export default {
|
|||||||
music: 'Music Generation',
|
music: 'Music Generation',
|
||||||
video: 'Video Generation',
|
video: 'Video Generation',
|
||||||
model3d: '3D Generation',
|
model3d: '3D Generation',
|
||||||
|
featureFlags: 'Feature Flags',
|
||||||
about: 'About',
|
about: 'About',
|
||||||
advanced: 'Advanced',
|
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',
|
modelTitle: 'Model Management',
|
||||||
modelDesc: 'Manage provider presets and default model selection',
|
modelDesc: 'Manage provider presets and default model selection',
|
||||||
systemTitle: 'System',
|
systemTitle: 'System',
|
||||||
|
|||||||
@ -308,9 +308,35 @@ export default {
|
|||||||
music: '音乐生成',
|
music: '音乐生成',
|
||||||
video: '视频生成',
|
video: '视频生成',
|
||||||
model3d: '3D 生成',
|
model3d: '3D 生成',
|
||||||
|
featureFlags: '功能开关',
|
||||||
about: '关于',
|
about: '关于',
|
||||||
advanced: '高级',
|
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: '模型管理',
|
modelTitle: '模型管理',
|
||||||
modelDesc: '管理模型预设与默认模型选择',
|
modelDesc: '管理模型预设与默认模型选择',
|
||||||
systemTitle: '系统设置',
|
systemTitle: '系统设置',
|
||||||
|
|||||||
@ -189,6 +189,12 @@ const router = createRouter({
|
|||||||
component: () => import('@/views/TokenUsage.vue'),
|
component: () => import('@/views/TokenUsage.vue'),
|
||||||
meta: { title: 'Settings - Token Usage' },
|
meta: { title: 'Settings - Token Usage' },
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'feature-flags',
|
||||||
|
name: 'SettingsFeatureFlags',
|
||||||
|
component: () => import('@/views/Settings/FeatureFlags/index.vue'),
|
||||||
|
meta: { title: 'Settings - Feature Flags' },
|
||||||
|
},
|
||||||
{
|
{
|
||||||
path: 'about',
|
path: 'about',
|
||||||
name: 'SettingsAbout',
|
name: 'SettingsAbout',
|
||||||
|
|||||||
221
mateclaw-ui/src/views/Settings/FeatureFlags/index.vue
Normal file
221
mateclaw-ui/src/views/Settings/FeatureFlags/index.vue
Normal file
@ -0,0 +1,221 @@
|
|||||||
|
<template>
|
||||||
|
<div class="feature-flags-page">
|
||||||
|
<header class="page-header">
|
||||||
|
<div class="mc-page-kicker">{{ t('settings.kicker') }}</div>
|
||||||
|
<h2 class="page-title">{{ t('settings.featureFlags.title') }}</h2>
|
||||||
|
<p class="page-desc">{{ t('settings.featureFlags.description') }}</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div v-if="loading" class="state-row">
|
||||||
|
<el-icon class="is-loading"><Loading /></el-icon>
|
||||||
|
<span>{{ t('common.loading') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-else-if="error" class="state-row state-row--error">
|
||||||
|
<el-icon><WarningFilled /></el-icon>
|
||||||
|
<span>{{ error }}</span>
|
||||||
|
<el-button size="small" @click="load">{{ t('common.retry', 'Retry') }}</el-button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else-if="flags.length > 0" class="flag-list">
|
||||||
|
<li v-for="flag in flags" :key="flag.flagKey" class="flag-row">
|
||||||
|
<div class="flag-text">
|
||||||
|
<div class="flag-key">{{ flag.flagKey }}</div>
|
||||||
|
<div v-if="describe(flag)" class="flag-desc">{{ describe(flag) }}</div>
|
||||||
|
<div v-if="hasScope(flag)" class="flag-scope">
|
||||||
|
<span v-if="flag.whitelistKbIds">
|
||||||
|
{{ t('settings.featureFlags.scope.kb') }}: {{ flag.whitelistKbIds }}
|
||||||
|
</span>
|
||||||
|
<span v-if="flag.whitelistUserIds">
|
||||||
|
{{ t('settings.featureFlags.scope.user') }}: {{ flag.whitelistUserIds }}
|
||||||
|
</span>
|
||||||
|
<span v-if="(flag.rolloutPercent ?? 0) > 0 && (flag.rolloutPercent ?? 0) < 100">
|
||||||
|
{{ t('settings.featureFlags.scope.rollout', { pct: flag.rolloutPercent }) }}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flag-actions">
|
||||||
|
<el-switch
|
||||||
|
:model-value="flag.enabled"
|
||||||
|
:loading="pending[flag.flagKey] === true"
|
||||||
|
:disabled="pending[flag.flagKey] === true"
|
||||||
|
@change="(value: any) => onToggle(flag, !!value)"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
|
||||||
|
<div v-else class="state-row">
|
||||||
|
<span>{{ t('settings.featureFlags.empty') }}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="page-footer">
|
||||||
|
<p>{{ t('settings.featureFlags.footer') }}</p>
|
||||||
|
</footer>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, reactive, ref } from 'vue'
|
||||||
|
import { useI18n } from 'vue-i18n'
|
||||||
|
import { ElButton, ElIcon, ElMessage, ElSwitch } from 'element-plus'
|
||||||
|
import { Loading, WarningFilled } from '@element-plus/icons-vue'
|
||||||
|
import { featureFlagApi, type FeatureFlag } from '@/api/index'
|
||||||
|
|
||||||
|
const { t, te } = useI18n()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Resolves a flag's description from i18n first, falling back to the backend
|
||||||
|
* column when no translation key is registered. The backend value is English-
|
||||||
|
* only by design (it's a stable reference identifier in the DB seed); UI
|
||||||
|
* copy lives next to other strings in the locale files.
|
||||||
|
*/
|
||||||
|
function describe(flag: FeatureFlag): string {
|
||||||
|
const i18nKey = `settings.featureFlags.descriptions.${flag.flagKey}`
|
||||||
|
if (te(i18nKey)) {
|
||||||
|
return t(i18nKey)
|
||||||
|
}
|
||||||
|
return flag.description ?? ''
|
||||||
|
}
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string>('')
|
||||||
|
const flags = ref<FeatureFlag[]>([])
|
||||||
|
const pending = reactive<Record<string, boolean>>({})
|
||||||
|
|
||||||
|
function hasScope(flag: FeatureFlag): boolean {
|
||||||
|
return !!flag.whitelistKbIds
|
||||||
|
|| !!flag.whitelistUserIds
|
||||||
|
|| ((flag.rolloutPercent ?? 0) > 0 && (flag.rolloutPercent ?? 0) < 100)
|
||||||
|
}
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = ''
|
||||||
|
try {
|
||||||
|
const resp: any = await featureFlagApi.list()
|
||||||
|
flags.value = (resp?.data ?? []).slice().sort((a: FeatureFlag, b: FeatureFlag) =>
|
||||||
|
a.flagKey.localeCompare(b.flagKey))
|
||||||
|
} catch (e: any) {
|
||||||
|
error.value = e?.message ?? String(e)
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onToggle(flag: FeatureFlag, next: boolean) {
|
||||||
|
pending[flag.flagKey] = true
|
||||||
|
try {
|
||||||
|
await featureFlagApi.update(flag.flagKey, { enabled: next })
|
||||||
|
flag.enabled = next // optimistic local update
|
||||||
|
ElMessage.success(t(next ? 'settings.featureFlags.enabled' : 'settings.featureFlags.disabled',
|
||||||
|
{ key: flag.flagKey }))
|
||||||
|
} catch (e: any) {
|
||||||
|
ElMessage.error(e?.message ?? t('settings.featureFlags.toggleFailed'))
|
||||||
|
// Revert by refreshing list to true server state.
|
||||||
|
await load()
|
||||||
|
} finally {
|
||||||
|
pending[flag.flagKey] = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style scoped>
|
||||||
|
.feature-flags-page {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 18px;
|
||||||
|
max-width: 880px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-header {
|
||||||
|
border-bottom: 1px solid var(--mc-border-light);
|
||||||
|
padding-bottom: 16px;
|
||||||
|
}
|
||||||
|
.page-title {
|
||||||
|
font-size: 22px;
|
||||||
|
font-weight: 700;
|
||||||
|
margin: 4px 0 8px;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
.page-desc {
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
font-size: 13px;
|
||||||
|
line-height: 1.6;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-list {
|
||||||
|
list-style: none;
|
||||||
|
padding: 0;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1px;
|
||||||
|
background: var(--mc-border-light);
|
||||||
|
border-radius: 10px;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: space-between;
|
||||||
|
gap: 16px;
|
||||||
|
padding: 14px 18px;
|
||||||
|
background: var(--mc-bg-base);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-text {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-key {
|
||||||
|
font-family: var(--mc-font-mono, ui-monospace, SFMono-Regular, Menlo, monospace);
|
||||||
|
font-size: 13px;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--mc-text-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-desc {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
margin-top: 4px;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.flag-scope {
|
||||||
|
font-size: 11px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
margin-top: 4px;
|
||||||
|
display: flex;
|
||||||
|
gap: 12px;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-row {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 8px;
|
||||||
|
padding: 18px;
|
||||||
|
color: var(--mc-text-secondary);
|
||||||
|
background: var(--mc-bg-muted);
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.state-row--error {
|
||||||
|
color: var(--el-color-danger);
|
||||||
|
background: var(--el-color-danger-light-9);
|
||||||
|
}
|
||||||
|
|
||||||
|
.page-footer {
|
||||||
|
font-size: 12px;
|
||||||
|
color: var(--mc-text-tertiary);
|
||||||
|
line-height: 1.5;
|
||||||
|
padding-top: 12px;
|
||||||
|
border-top: 1px solid var(--mc-border-light);
|
||||||
|
}
|
||||||
|
</style>
|
||||||
@ -188,6 +188,12 @@ const sections = computed(() => [
|
|||||||
label: t('nav.tokenUsage'),
|
label: t('nav.tokenUsage'),
|
||||||
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M12 1v22M17 5H9.5a3.5 3.5 0 0 0 0 7h5a3.5 3.5 0 0 1 0 7H6"/></svg>',
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
id: 'feature-flags',
|
||||||
|
path: '/settings/feature-flags',
|
||||||
|
label: t('settings.sections.featureFlags', 'Feature Flags'),
|
||||||
|
icon: '<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><path d="M4 21V4l12 4-12 4"/><path d="M4 12v9"/></svg>',
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'about',
|
id: 'about',
|
||||||
path: '/settings/about',
|
path: '/settings/about',
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user