mirror of
https://gitee.com/mateos/mateclaw.git
synced 2026-09-13 03:13:41 +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.
|
||||
}
|
||||
|
||||
/**
|
||||
* 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
|
||||
* 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.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.
|
||||
*
|
||||
* <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
|
||||
* {@code [图 P{n}#{m}]: <caption>} 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.
|
||||
*
|
||||
* <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) {
|
||||
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<String> snippets = pdfImageExtractor.captionInlineImages(
|
||||
java.nio.file.Paths.get(entity.getSourcePath()));
|
||||
List<String> 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);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@ -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<FeatureFlag[]>('/feature-flags'),
|
||||
update: (flagKey: string, data: FeatureFlagUpdate) =>
|
||||
http.put(`/feature-flags/${flagKey}`, data),
|
||||
}
|
||||
|
||||
@ -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',
|
||||
|
||||
@ -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: '系统设置',
|
||||
|
||||
@ -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',
|
||||
|
||||
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'),
|
||||
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',
|
||||
path: '/settings/about',
|
||||
|
||||
Loading…
Reference in New Issue
Block a user