refactor(frontend): extract KBInfoPopover and reuse it on FAQ KBs

Move the info popover (intro'd in the previous commit) into a
dedicated `frontend/src/components/KBInfoPopover.vue` component so
both the document KB header and the FAQ KB header can share the
exact same metadata surface.

- KnowledgeBase.vue: replace the inline popover and the now-dead
  helper computeds (kbLastUpdated, kbHasDistinctUpdate,
  infoCardCapabilities, infoCardChunking, infoCardStats,
  chunkingStrategyLabel, supportedFileTypesList, accessRoleLabel,
  accessPermissionSummary) and their CSS with a single
  <KBInfoPopover> tag. The page still fetches parser engines and
  forwards the supported file types so the "Accepted formats" row
  renders for document KBs.
- FAQEntryManager.vue: drop the legacy inline meta strip
  (role tag · share source · last updated) along with the
  accessRoleLabel / accessPermissionSummary / kbLastUpdated /
  effectiveKBPermission computeds it only served, and wire the
  same <KBInfoPopover> into the actions cluster next to the
  settings button. FAQ KBs don't pass supportedFileTypes so the
  format row simply hides itself.
- KBInfoPopover.vue: self-contained — re-derives access role,
  shared-binding and chunking config from kbInfo + the org/auth
  stores. supported-file-types remains an opt-in prop so callers
  decide whether the parser-engine pipeline is relevant.

No user-visible regressions: both pages now render the same
Basic / Access / Capabilities / Chunking / Stats / Storage
Binding sections, with rows hiding when they don't apply.
This commit is contained in:
wizardchen
2026-05-26 10:07:18 +08:00
committed by lyingbug
parent de8ee5ae16
commit f97d588042
3 changed files with 534 additions and 564 deletions

View File

@@ -0,0 +1,510 @@
<template>
<t-popup
v-if="kbInfo"
trigger="click"
placement="bottom-right"
:overlay-style="{ padding: 0 }"
:overlay-inner-style="{ padding: 0 }"
>
<template #content>
<div class="kb-info-card">
<div class="kb-info-card-header">{{ t('knowledgeBase.infoCard.title') }}</div>
<div class="kb-info-card-body">
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.basic') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.type') }}</span>
<span class="kb-info-card-value">
{{ kbInfo.type === 'faq'
? t('knowledgeEditor.basic.typeFAQ')
: t('knowledgeEditor.basic.typeDocument') }}
</span>
</div>
<div v-if="kbInfo.description" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.description') }}</span>
<span class="kb-info-card-value kb-info-card-value-block">{{ kbInfo.description }}</span>
</div>
<div v-if="kbInfo.created_at" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.createdAt') }}</span>
<span class="kb-info-card-value">{{ formatStringDate(new Date(kbInfo.created_at)) }}</span>
</div>
<div v-if="hasDistinctUpdate" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.accessInfo.lastUpdated') }}</span>
<span class="kb-info-card-value">{{ lastUpdatedLabel }}</span>
</div>
<div v-if="supportedFileTypesSorted.length" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.supportedFileTypes') }}</span>
<span class="kb-info-card-value">
<span
v-for="ft in supportedFileTypesSorted"
:key="ft"
class="kb-info-card-ext"
>.{{ ft }}</span>
</span>
</div>
</div>
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.access') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.accessInfo.myRole') }}</span>
<span class="kb-info-card-value">
<t-tag size="small" :theme="roleTagTheme">
{{ accessRoleLabel }}
</t-tag>
<span class="kb-info-card-hint">{{ accessPermissionSummary }}</span>
</span>
</div>
<div v-if="currentSharedKb" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.accessInfo.fromOrg') }}</span>
<span class="kb-info-card-value">
{{ currentSharedKb.org_name }} · {{ t('knowledgeBase.accessInfo.sharedAt') }}
{{ formatStringDate(new Date(currentSharedKb.shared_at)) }}
</span>
</div>
<div v-else-if="effectiveKBPermission" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.source') }}</span>
<span class="kb-info-card-value">{{ t('knowledgeList.detail.sourceTypeAgent') }}</span>
</div>
<div v-if="(kbInfo.share_count ?? 0) > 0" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.sharedTo') }}</span>
<span class="kb-info-card-value">
{{ t('knowledgeList.sharedToOrgs', { count: kbInfo.share_count }) }}
</span>
</div>
</div>
<div v-if="capabilities.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.capabilities') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.enabled') }}</span>
<span class="kb-info-card-value">
<t-tag
v-for="cap in capabilities"
:key="cap.key"
size="small"
variant="light"
:theme="cap.theme"
>
{{ cap.label }}
</t-tag>
</span>
</div>
</div>
<div v-if="chunkingRows.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.chunking') }}
</div>
<div
v-for="row in chunkingRows"
:key="row.key"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ row.label }}</span>
<span class="kb-info-card-value">{{ row.value }}</span>
</div>
</div>
<div v-if="statRows.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.stats') }}
</div>
<div
v-for="stat in statRows"
:key="stat.key"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ stat.label }}</span>
<span class="kb-info-card-value kb-info-card-value-number">{{ stat.value }}</span>
</div>
</div>
<div
v-if="kbInfo.vector_store_source || kbInfo.storage_provider_config?.provider"
class="kb-info-card-section"
>
<div class="kb-info-card-section-title">
{{ t('knowledgeBase.infoCard.binding') }}
</div>
<div v-if="kbInfo.vector_store_source" class="kb-info-card-row">
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.vectorStore') }}</span>
<span class="kb-info-card-value">
<VectorStoreBadge
:source="kbInfo.vector_store_source"
:name="kbInfo.vector_store_name"
:engine-type="kbInfo.vector_store_engine_type"
:status="kbInfo.vector_store_status"
/>
</span>
</div>
<div
v-if="kbInfo.storage_provider_config?.provider"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ t('knowledgeBase.infoCard.fileStorage') }}</span>
<span class="kb-info-card-value kb-info-card-value-mono">
{{ kbInfo.storage_provider_config.provider }}
</span>
</div>
</div>
</div>
</div>
</template>
<t-tooltip :content="t('knowledgeBase.infoCard.tooltip')" placement="top">
<button
type="button"
class="kb-info-button"
:class="{ 'has-warning': kbInfo?.vector_store_status === 'unavailable' }"
>
<t-icon name="info-circle" size="16px" />
</button>
</t-tooltip>
</t-popup>
</template>
<script setup lang="ts">
import { computed } from 'vue'
import { useI18n } from 'vue-i18n'
import VectorStoreBadge from '@/components/VectorStoreBadge.vue'
import { useOrganizationStore } from '@/stores/organization'
import { useAuthStore } from '@/stores/auth'
import { formatStringDate } from '@/utils'
const props = defineProps<{
// The KB detail / list object. Typed as any to avoid coupling to the
// backend's exhaustive shape; the popover reads optional fields and
// falls back gracefully when they are missing.
kbInfo: any
// Optional pre-computed list of supported file extensions (e.g.
// ["pdf", "docx", …]). The popover does not fetch parser engines on
// its own — call sites that already know which formats are reachable
// pass them in; FAQ KBs simply omit the prop.
supportedFileTypes?: string[]
}>()
const { t } = useI18n()
const orgStore = useOrganizationStore()
const authStore = useAuthStore()
// "Owner" here mirrors the per-page guards: the original creator
// (creator_id) — not "in my tenant". creator_id is unset for legacy
// KBs created before that gate existed; those fall through to the
// role/share check.
const isOwner = computed<boolean>(() => {
const kb = props.kbInfo
if (!kb) return false
const creatorId = kb.creator_id || ''
const userId = authStore.user?.id || ''
if (!creatorId) return false
return creatorId === userId
})
const currentSharedKb = computed(() => {
const id = props.kbInfo?.id
if (!id) return null
return orgStore.sharedKnowledgeBases?.find?.((s: any) => s.knowledge_base?.id === id) ?? null
})
const isViaShare = computed<boolean>(() => !!currentSharedKb.value)
const effectiveKBPermission = computed<string>(() => {
const id = props.kbInfo?.id || ''
return orgStore.getKBPermission?.(id) || props.kbInfo?.my_permission || ''
})
const accessRoleLabel = computed<string>(() => {
if (!isViaShare.value && isOwner.value) return t('knowledgeBase.accessInfo.roleOwner')
const perm = effectiveKBPermission.value
if (perm) return t(`organization.role.${perm}`)
return '--'
})
const accessPermissionSummary = computed<string>(() => {
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 '--'
})
type RoleTheme = 'success' | 'primary' | 'warning' | 'default'
const roleTagTheme = computed<RoleTheme>(() => {
if (!isViaShare.value && isOwner.value) return 'success'
const perm = effectiveKBPermission.value
if (perm === 'admin') return 'primary'
if (perm === 'editor') return 'warning'
return 'default'
})
// KB.UpdatedAt is auto-bumped by GORM only when the KB row itself is
// touched (rename, config edit, …). For a freshly created KB whose
// only mutations were document uploads, updated_at == created_at and
// surfacing "last updated" alongside "created at" reads as
// duplicated noise. Hide the row in that case and let it reappear
// once the KB metadata is actually edited.
const hasDistinctUpdate = computed<boolean>(() => {
const created = props.kbInfo?.created_at
const updated = props.kbInfo?.updated_at
if (!updated) return false
if (!created) return true
return new Date(updated).getTime() !== new Date(created).getTime()
})
const lastUpdatedLabel = computed<string>(() => {
const raw = props.kbInfo?.updated_at
return raw ? formatStringDate(new Date(raw)) : ''
})
const supportedFileTypesSorted = computed<string[]>(() => {
if (!props.supportedFileTypes?.length) return []
return [...props.supportedFileTypes].sort()
})
type CapabilityTheme = 'primary' | 'success' | 'warning' | 'default'
const capabilities = computed<Array<{ key: string; label: string; theme: CapabilityTheme }>>(() => {
const kb: any = props.kbInfo
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',
})
}
if (kb.indexing_strategy?.wiki_enabled) {
items.push({ key: 'wiki', label: 'Wiki', theme: 'warning' })
}
return items
})
const chunkingStrategyLabel = computed<string>(() => {
const raw: string = (props.kbInfo?.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
})
const chunkingRows = computed<Array<{ key: string; label: string; value: string }>>(() => {
const kb: any = props.kbInfo
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 statRows = computed<Array<{ key: string; label: string; value: number | string }>>(() => {
const kb: any = props.kbInfo
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
})
</script>
<style scoped lang="less">
.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. */
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;
}
</style>

View File

@@ -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<string[]>(() =>
[...supportedFileTypes.value].sort()
)
const unsupportedFileTypes = computed<string[]>(() => {
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<Array<{ key: string; label: string; theme: CapabilityTheme }>>(() => {
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<Array<{ key: string; label: string; value: number | string }>>(() => {
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<string>(() => {
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<Array<{ key: string; label: string; value: string }>>(() => {
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<Array<{ id: string; name: string; type?: string }>>([]);
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<void> {
</template>
<span v-else class="breadcrumb-current">{{ $t('knowledgeEditor.document.title') }}</span>
</h2>
<!-- 标题行右侧的动作锚点聚拢"信息""设置"两个圆形按钮
通过 margin-left:auto 把它们推到行尾避免散在 breadcrumb 后面 -->
<!-- 标题行右侧的动作锚点聚拢"信息""设置"两个圆形按钮 -->
<div class="kb-title-actions">
<!-- KB 信息卡默认收起点击打开 popover 后分组展示
基础 / 访问 / 能力 / 统计 / 存储绑定绑定不可用时按钮上挂红点提示 -->
<t-popup
v-if="kbInfo && !authStore.isLiteMode"
trigger="click"
placement="bottom-right"
:overlay-style="{ padding: 0 }"
:overlay-inner-style="{ padding: 0 }"
>
<template #content>
<div class="kb-info-card">
<div class="kb-info-card-header">{{ $t('knowledgeBase.infoCard.title') }}</div>
<div class="kb-info-card-body">
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.basic') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.type') }}</span>
<span class="kb-info-card-value">
{{ kbInfo.type === 'faq'
? $t('knowledgeEditor.basic.typeFAQ')
: $t('knowledgeEditor.basic.typeDocument') }}
</span>
</div>
<div v-if="kbInfo.description" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.description') }}</span>
<span class="kb-info-card-value kb-info-card-value-block">{{ kbInfo.description }}</span>
</div>
<div v-if="kbInfo.created_at" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.createdAt') }}</span>
<span class="kb-info-card-value">{{ formatStringDate(new Date(kbInfo.created_at)) }}</span>
</div>
<div v-if="kbHasDistinctUpdate" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.accessInfo.lastUpdated') }}</span>
<span class="kb-info-card-value">{{ kbLastUpdated }}</span>
</div>
<div v-if="supportedFileTypesList.length" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.supportedFileTypes') }}</span>
<span class="kb-info-card-value">
<span
v-for="ft in supportedFileTypesList"
:key="ft"
class="kb-info-card-ext"
>.{{ ft }}</span>
</span>
</div>
</div>
<div class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.access') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.accessInfo.myRole') }}</span>
<span class="kb-info-card-value">
<t-tag size="small"
:theme="(!isViaShare && isOwner) ? 'success' : (effectiveKBPermission === 'admin' ? 'primary' : effectiveKBPermission === 'editor' ? 'warning' : 'default')">
{{ accessRoleLabel }}
</t-tag>
<span class="kb-info-card-hint">{{ accessPermissionSummary }}</span>
</span>
</div>
<div v-if="currentSharedKb" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.accessInfo.fromOrg') }}</span>
<span class="kb-info-card-value">
{{ currentSharedKb.org_name }} · {{ $t('knowledgeBase.accessInfo.sharedAt') }}
{{ formatStringDate(new Date(currentSharedKb.shared_at)) }}
</span>
</div>
<div v-else-if="effectiveKBPermission" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.source') }}</span>
<span class="kb-info-card-value">{{ $t('knowledgeList.detail.sourceTypeAgent') }}</span>
</div>
<div
v-if="((kbInfo as any)?.share_count ?? 0) > 0"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.sharedTo') }}</span>
<span class="kb-info-card-value">
{{ $t('knowledgeList.sharedToOrgs', { count: (kbInfo as any).share_count }) }}
</span>
</div>
</div>
<div v-if="infoCardCapabilities.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.capabilities') }}
</div>
<div class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.enabled') }}</span>
<span class="kb-info-card-value">
<t-tag
v-for="cap in infoCardCapabilities"
:key="cap.key"
size="small"
variant="light"
:theme="cap.theme"
>
{{ cap.label }}
</t-tag>
</span>
</div>
</div>
<div v-if="infoCardChunking.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.chunking') }}
</div>
<div
v-for="row in infoCardChunking"
:key="row.key"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ row.label }}</span>
<span class="kb-info-card-value">{{ row.value }}</span>
</div>
</div>
<div v-if="infoCardStats.length" class="kb-info-card-section">
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.stats') }}
</div>
<div
v-for="stat in infoCardStats"
:key="stat.key"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ stat.label }}</span>
<span class="kb-info-card-value kb-info-card-value-number">{{ stat.value }}</span>
</div>
</div>
<div
v-if="(kbInfo as any)?.vector_store_source || (kbInfo as any)?.storage_provider_config?.provider"
class="kb-info-card-section"
>
<div class="kb-info-card-section-title">
{{ $t('knowledgeBase.infoCard.binding') }}
</div>
<div v-if="(kbInfo as any)?.vector_store_source" class="kb-info-card-row">
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.vectorStore') }}</span>
<span class="kb-info-card-value">
<VectorStoreBadge
:source="(kbInfo as any).vector_store_source"
:name="(kbInfo as any).vector_store_name"
:engine-type="(kbInfo as any).vector_store_engine_type"
:status="(kbInfo as any).vector_store_status"
/>
</span>
</div>
<div
v-if="(kbInfo as any)?.storage_provider_config?.provider"
class="kb-info-card-row"
>
<span class="kb-info-card-label">{{ $t('knowledgeBase.infoCard.fileStorage') }}</span>
<span class="kb-info-card-value kb-info-card-value-mono">
{{ (kbInfo as any).storage_provider_config.provider }}
</span>
</div>
</div>
</div>
</div>
</template>
<t-tooltip :content="$t('knowledgeBase.infoCard.tooltip')" placement="top">
<button
type="button"
class="kb-info-button"
:class="{ 'has-warning': (kbInfo as any)?.vector_store_status === 'unavailable' }"
:disabled="!kbId"
>
<t-icon name="info-circle" size="16px" />
<KBInfoPopover
v-if="kbInfo && !authStore.isLiteMode"
:kb-info="kbInfo"
:supported-file-types="[...supportedFileTypes]"
/>
<t-tooltip v-if="canManage" :content="$t('knowledgeBase.settings')" placement="top">
<button type="button" class="kb-settings-button" :disabled="!kbId" @click="handleOpenKBSettings">
<t-icon name="setting" size="16px" />
</button>
</t-tooltip>
</t-popup>
<t-tooltip v-if="canManage" :content="$t('knowledgeBase.settings')" placement="top">
<button type="button" class="kb-settings-button" :disabled="!kbId" @click="handleOpenKBSettings">
<t-icon name="setting" size="16px" />
</button>
</t-tooltip>
</div>
</div>
<p class="document-subtitle">{{ $t('knowledgeEditor.document.subtitle') }}</p>
@@ -3754,155 +3430,6 @@ async function createNewSession(value: string): Promise<void> {
}
}
.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;

View File

@@ -35,39 +35,19 @@
<t-icon name="chevron-right" class="breadcrumb-separator" />
<span class="breadcrumb-current">{{ $t('knowledgeEditor.faq.title') }}</span>
</h2>
<!-- 身份与最后更新紧凑单行置于标题行右侧悬停显示权限说明 -->
<div v-if="kbInfo && !authStore.isLiteMode" class="faq-access-meta">
<t-tooltip :content="accessPermissionSummary" placement="top">
<span class="faq-access-meta-inner">
<t-tag size="small"
:theme="(!isViaShare && isOwner) ? 'success' : (effectiveKBPermission === 'admin' ? 'primary' : effectiveKBPermission === 'editor' ? 'warning' : 'default')"
class="faq-access-role-tag">
{{ accessRoleLabel }}
</t-tag>
<template v-if="currentSharedKb">
<span class="faq-access-meta-sep">·</span>
<span class="faq-access-meta-text">
{{ $t('knowledgeBase.accessInfo.fromOrg') }}{{ currentSharedKb.org_name }}
{{ $t('knowledgeBase.accessInfo.sharedAt') }} {{ formatImportTime(currentSharedKb.shared_at) }}
</span>
</template>
<template v-else-if="effectiveKBPermission">
<span class="faq-access-meta-sep">·</span>
<span class="faq-access-meta-text">{{ $t('knowledgeList.detail.sourceTypeAgent') }}</span>
</template>
<template v-else-if="kbLastUpdated">
<span class="faq-access-meta-sep">·</span>
<span class="faq-access-meta-text">{{ $t('knowledgeBase.accessInfo.lastUpdated') }} {{ kbLastUpdated
}}</span>
</template>
</span>
<!-- 标题行右侧的动作锚点与文档详情页保持一致的信息 + 设置两个圆形按钮
FAQ 类型知识库不传 supportedFileTypes可上传格式行会自动隐藏 -->
<div class="kb-title-actions">
<KBInfoPopover
v-if="kbInfo && !authStore.isLiteMode"
:kb-info="kbInfo"
/>
<t-tooltip v-if="canManage" :content="$t('knowledgeBase.settings')" placement="top">
<button type="button" class="kb-settings-button" @click="handleOpenKBSettings">
<t-icon name="setting" size="16px" />
</button>
</t-tooltip>
</div>
<t-tooltip v-if="canManage" :content="$t('knowledgeBase.settings')" placement="top">
<button type="button" class="kb-settings-button" @click="handleOpenKBSettings">
<t-icon name="setting" size="16px" />
</button>
</t-tooltip>
</div>
<p class="faq-subtitle">{{ $t('knowledgeEditor.faq.subtitle') }}</p>
</div>
@@ -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 {