diff --git a/frontend/src/components/KBInfoPopover.vue b/frontend/src/components/KBInfoPopover.vue new file mode 100644 index 00000000..c87a5e65 --- /dev/null +++ b/frontend/src/components/KBInfoPopover.vue @@ -0,0 +1,510 @@ + + + + + diff --git a/frontend/src/views/knowledge/KnowledgeBase.vue b/frontend/src/views/knowledge/KnowledgeBase.vue index 51de4692..dc30fd78 100644 --- a/frontend/src/views/knowledge/KnowledgeBase.vue +++ b/frontend/src/views/knowledge/KnowledgeBase.vue @@ -5,7 +5,7 @@ import DocContent from "@/components/doc-content.vue"; import useKnowledgeBase from '@/hooks/useKnowledgeBase'; import { useRoute, useRouter } from 'vue-router'; import EmptyKnowledge from '@/components/empty-knowledge.vue'; -import VectorStoreBadge from '@/components/VectorStoreBadge.vue'; +import KBInfoPopover from '@/components/KBInfoPopover.vue'; import { getSessionsList, createSessions, generateSessionsTitle } from "@/api/chat/index"; import { useMenuStore } from '@/stores/menu'; import { useUIStore } from '@/stores/ui'; @@ -180,13 +180,6 @@ const acceptFileTypes = computed(() => [...supportedFileTypes.value].map(t => '.' + t).join(',') ) -// Sorted view of the supported-types set for the info popover. Kept as a -// plain array so the template can use v-for without paying the cost of -// re-sorting on every render. -const supportedFileTypesList = computed(() => - [...supportedFileTypes.value].sort() -) - const unsupportedFileTypes = computed(() => { const engines = parserEngines.value if (!engines.length) return [] @@ -288,157 +281,6 @@ const canMutateKnowledge = computed(() => { // Effective permission: from direct org share list or from GET /knowledge-bases/:id (e.g. agent-visible KB) const effectiveKBPermission = computed(() => orgStore.getKBPermission(kbId.value) || kbInfo.value?.my_permission || ''); -// Display role label: when accessed via share, surface the share role even -// if the user happens to be the original creator — the active context is -// "viewing through a shared space", and write actions will 403 regardless. -const accessRoleLabel = computed(() => { - if (!isViaShare.value && isOwner.value) return t('knowledgeBase.accessInfo.roleOwner'); - const perm = effectiveKBPermission.value; - if (perm) return t(`organization.role.${perm}`); - return '--'; -}); - -// Permission summary text for current role (mirrors accessRoleLabel rule). -const accessPermissionSummary = computed(() => { - if (!isViaShare.value && isOwner.value) return t('knowledgeBase.accessInfo.permissionOwner'); - const perm = effectiveKBPermission.value; - if (perm === 'admin') return t('knowledgeBase.accessInfo.permissionAdmin'); - if (perm === 'editor') return t('knowledgeBase.accessInfo.permissionEditor'); - if (perm === 'viewer') return t('knowledgeBase.accessInfo.permissionViewer'); - return '--'; -}); - -// Last updated time from kbInfo -const kbLastUpdated = computed(() => { - const raw = kbInfo.value?.updated_at; - if (!raw) return null; - return formatStringDate(new Date(raw)); -}); - -// KB.UpdatedAt is auto-bumped by GORM only when the KB row itself is -// touched (rename, config edit, …). For a freshly created KB that -// only had documents uploaded into it, updated_at == created_at and -// surfacing "last updated" alongside "created at" reads as -// duplicated noise. We hide the row in that case and only render it -// once the KB metadata has actually been edited. -const kbHasDistinctUpdate = computed(() => { - const created = kbInfo.value?.created_at; - const updated = kbInfo.value?.updated_at; - if (!updated) return false; - if (!created) return true; - return new Date(updated).getTime() !== new Date(created).getTime(); -}); - -// Enabled capabilities shown in the KB info popover. Only rendered when -// flipped on, so the section stays empty (and the whole block hides) for -// KBs that opted out of everything. -type CapabilityTheme = 'primary' | 'success' | 'warning' | 'default'; -const infoCardCapabilities = computed>(() => { - const kb: any = kbInfo.value; - if (!kb) return []; - const items: Array<{ key: string; label: string; theme: CapabilityTheme }> = []; - if (kb.vlm_config?.enabled) { - items.push({ key: 'vlm', label: 'VLM', theme: 'primary' }); - } - if (kb.asr_config?.enabled) { - items.push({ key: 'asr', label: 'ASR', theme: 'primary' }); - } - if (kb.extract_config?.enabled) { - items.push({ - key: 'kg', - label: t('knowledgeList.features.knowledgeGraph'), - theme: 'success', - }); - } - const idx = kb.indexing_strategy || {}; - if (idx.wiki_enabled) { - items.push({ key: 'wiki', label: 'Wiki', theme: 'warning' }); - } - return items; -}); - -// Document / chunk / share counters for the stats section. We treat -// undefined as "no data" rather than "0" so backends that don't -// populate the field don't get a misleading zero row. -const infoCardStats = computed>(() => { - const kb: any = kbInfo.value; - if (!kb) return []; - const items: Array<{ key: string; label: string; value: number | string }> = []; - if (typeof kb.knowledge_count === 'number') { - items.push({ - key: 'knowledge', - label: kb.type === 'faq' - ? t('knowledgeBase.infoCard.faqCount') - : t('knowledgeBase.infoCard.documentCount'), - value: kb.knowledge_count, - }); - } - return items; -}); - -// Friendly label for the chunking strategy. The backend stores raw -// identifiers ("auto" / "heading" / "heuristic" / "legacy" / "" / …). -// Empty / unknown values fall back to "legacy" semantics, which the -// editor surface labels as "按长度切分". -const chunkingStrategyLabel = computed(() => { - const raw: string = (kbInfo.value?.chunking_config?.strategy || '').toLowerCase(); - const key = (raw === '' || raw === 'recursive') ? 'legacy' : raw; - const path = `knowledgeEditor.chunking.strategies.${key}.label`; - const translated = t(path); - return translated === path ? raw : translated; -}); - -// Compact chunking config rows shown in the info popover. Hidden for -// FAQ KBs since chunking knobs don't drive their indexing path, and -// hidden entirely when chunking_config is absent (defensive — older -// rows on disk may lack the column). -const infoCardChunking = computed>(() => { - const kb: any = kbInfo.value; - if (!kb || kb.type === 'faq') return []; - const cfg = kb.chunking_config; - if (!cfg) return []; - const rows: Array<{ key: string; label: string; value: string }> = []; - if (chunkingStrategyLabel.value) { - rows.push({ - key: 'strategy', - label: t('knowledgeEditor.chunking.strategyLabel'), - value: chunkingStrategyLabel.value, - }); - } - const chars = t('knowledgeEditor.chunking.characters'); - if (typeof cfg.chunk_size === 'number' && cfg.chunk_size > 0) { - rows.push({ - key: 'size', - label: t('knowledgeEditor.chunking.sizeLabel'), - value: `${cfg.chunk_size} ${chars}`, - }); - } - if (typeof cfg.chunk_overlap === 'number') { - rows.push({ - key: 'overlap', - label: t('knowledgeEditor.chunking.overlapLabel'), - value: `${cfg.chunk_overlap} ${chars}`, - }); - } - if (cfg.enable_parent_child) { - const parent = cfg.parent_chunk_size || 4096; - const child = cfg.child_chunk_size || 384; - rows.push({ - key: 'parent-child', - label: t('knowledgeEditor.chunking.parentChildLabel'), - value: `${t('knowledgeBase.infoCard.parentShort')} ${parent} / ${t('knowledgeBase.infoCard.childShort')} ${child}`, - }); - } - if (typeof cfg.token_limit === 'number' && cfg.token_limit > 0) { - rows.push({ - key: 'token-limit', - label: t('knowledgeEditor.chunking.tokenLimitLabel'), - value: String(cfg.token_limit), - }); - } - return rows; -}); - const knowledgeList = ref>([]); let { cardList, total, moreIndex, details, getKnowled, delKnowledge, openMore, onVisibleChange: _onVisibleChange, getCardDetails, getfDetails } = useKnowledgeBase(kbId.value) const onVisibleChange = (visible: boolean) => { @@ -2183,184 +2025,18 @@ async function createNewSession(value: string): Promise { {{ $t('knowledgeEditor.document.title') }} - +
- - - - - - - - -

{{ $t('knowledgeEditor.document.subtitle') }}

@@ -3754,155 +3430,6 @@ async function createNewSession(value: string): Promise { } } -.kb-info-button { - position: relative; - width: 30px; - height: 30px; - border: none; - border-radius: 50%; - background: var(--td-bg-color-secondarycontainer); - display: inline-flex; - align-items: center; - justify-content: center; - color: var(--td-text-color-secondary); - cursor: pointer; - transition: all 0.2s ease; - padding: 0; - - &:hover:not(:disabled) { - background: var(--td-success-color-light); - color: var(--td-brand-color); - } - - &:disabled { - cursor: not-allowed; - opacity: 0.4; - } - - &.has-warning::after { - content: ''; - position: absolute; - top: 4px; - right: 4px; - width: 8px; - height: 8px; - background: var(--td-error-color); - border-radius: 50%; - border: 1.5px solid var(--td-bg-color-secondarycontainer); - } - - :deep(.t-icon) { - font-size: 18px; - } -} - -.kb-info-card { - min-width: 280px; - max-width: 360px; - /* Cap to ~70% of the viewport so the popup never overflows the screen - on shorter laptops. Internal scroll keeps the header pinned via the - sticky rule below. The 32px subtract leaves room for popup padding, - placement gap and rounding so the box stays inside the safe area. */ - max-height: min(70vh, 560px); - display: flex; - flex-direction: column; - padding: 12px 16px; - font-size: 12px; - color: var(--td-text-color-primary); - overflow: hidden; -} - -.kb-info-card-header { - flex: 0 0 auto; - font-size: 13px; - font-weight: 600; - margin-bottom: 8px; - padding-bottom: 8px; - border-bottom: 1px solid var(--td-component-stroke); -} - -/* Scrolling body so the popover never grows past max-height. Negative - side margins line the scrollbar up with the card edge while keeping - the row labels aligned with the header. */ -.kb-info-card-body { - flex: 1 1 auto; - overflow-y: auto; - margin: 0 -16px; - padding: 0 16px; -} - -.kb-info-card-section + .kb-info-card-section { - margin-top: 10px; - padding-top: 10px; - border-top: 1px dashed var(--td-component-stroke); -} - -.kb-info-card-section-title { - font-size: 11px; - font-weight: 500; - color: var(--td-text-color-secondary); - text-transform: uppercase; - letter-spacing: 0.5px; - margin-bottom: 6px; -} - -.kb-info-card-row { - display: flex; - align-items: flex-start; - gap: 8px; - padding: 3px 0; - line-height: 1.6; -} - -.kb-info-card-label { - flex: 0 0 80px; - color: var(--td-text-color-secondary); -} - -.kb-info-card-value { - flex: 1; - display: inline-flex; - flex-wrap: wrap; - align-items: center; - gap: 6px; - color: var(--td-text-color-primary); - word-break: break-word; -} - -.kb-info-card-value-block { - display: block; - color: var(--td-text-color-secondary); - white-space: pre-wrap; -} - -.kb-info-card-value-number { - font-variant-numeric: tabular-nums; - font-weight: 500; -} - -.kb-info-card-value-mono { - font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 11px; - color: var(--td-text-color-secondary); -} - -.kb-info-card-ext { - display: inline-flex; - align-items: center; - padding: 1px 6px; - border-radius: 3px; - background: var(--td-bg-color-component, #f5f7fa); - color: var(--td-text-color-secondary); - font-family: var(--td-font-family-mono, ui-monospace, SFMono-Regular, Menlo, monospace); - font-size: 11px; - line-height: 1.4; -} - -.kb-info-card-hint { - color: var(--td-text-color-placeholder); - font-size: 11px; -} - .tag-filter-bar { display: flex; align-items: center; diff --git a/frontend/src/views/knowledge/components/FAQEntryManager.vue b/frontend/src/views/knowledge/components/FAQEntryManager.vue index 3b47bdec..8e83707f 100644 --- a/frontend/src/views/knowledge/components/FAQEntryManager.vue +++ b/frontend/src/views/knowledge/components/FAQEntryManager.vue @@ -35,39 +35,19 @@ {{ $t('knowledgeEditor.faq.title') }} - -
- - - - {{ accessRoleLabel }} - - - - - + +
+ + +
- - -

{{ $t('knowledgeEditor.faq.subtitle') }}

@@ -963,6 +943,7 @@ import { import * as XLSX from 'xlsx' import Papa from 'papaparse' import FAQTagTooltip from '@/components/FAQTagTooltip.vue' +import KBInfoPopover from '@/components/KBInfoPopover.vue' import { useUIStore } from '@/stores/ui' interface FAQEntry { @@ -1061,36 +1042,6 @@ const canManage = computed(() => { return orgStore.canManageKB(props.kbId, false) }) -// Effective permission: from direct org share list or from GET /knowledge-bases/:id (e.g. agent-visible KB) -const effectiveKBPermission = computed(() => orgStore.getKBPermission(props.kbId) || kbInfo.value?.my_permission || '') - -// Display role label: when accessed via share, surface the share role even -// if the user happens to be the original creator — the active context is -// "viewing through a shared space" and write actions will 403 regardless. -const accessRoleLabel = computed(() => { - if (!isViaShare.value && isOwner.value) return t('knowledgeBase.accessInfo.roleOwner') - const perm = effectiveKBPermission.value - if (perm) return t(`organization.role.${perm}`) - return '--' -}) - -// Permission summary text for current role (mirrors accessRoleLabel rule). -const accessPermissionSummary = computed(() => { - if (!isViaShare.value && isOwner.value) return t('knowledgeBase.accessInfo.permissionOwner') - const perm = effectiveKBPermission.value - if (perm === 'admin') return t('knowledgeBase.accessInfo.permissionAdmin') - if (perm === 'editor') return t('knowledgeBase.accessInfo.permissionEditor') - if (perm === 'viewer') return t('knowledgeBase.accessInfo.permissionViewer') - return '--' -}) - -// Last updated time from kbInfo -const kbLastUpdated = computed(() => { - const raw = kbInfo.value?.updated_at - if (!raw) return null - return formatImportTime(raw) -}) - // FAQ 操作:新建组(新建条目 + 导入) const faqCreateOptions = computed(() => { if (!canEdit.value) return [] @@ -3535,30 +3486,12 @@ watch(() => entries.value.map(e => ({ flex-wrap: wrap; } - .faq-access-meta { - flex-shrink: 0; - } - - .faq-access-meta-inner { + .kb-title-actions { display: inline-flex; align-items: center; gap: 6px; - font-size: 12px; - color: var(--td-text-color-secondary); - cursor: default; - } - - .faq-access-role-tag { flex-shrink: 0; - } - - .faq-access-meta-sep { - color: var(--td-text-color-placeholder); - user-select: none; - } - - .faq-access-meta-text { - white-space: nowrap; + margin-left: 4px; } .faq-breadcrumb {