feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入

支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
WangShen007 committed 2026-09-29 20:35:00 +08:00
1 parent 4ff934ad98
commit 7937de5d7d
23 files changed
+7545

No files matched your search

@@ -0,0 +1,597 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import type { AddSourcePayload, CrawlScope } from '@/composables/useKnowledgeExplorer'
type SourceKind = 'website' | 'sitemap' | 'pdf' | 'text'
const props = withDefaults(
defineProps<{
submitting?: boolean
}>(),
{ submitting: false },
)
const emit = defineEmits<{
(e: 'close'): void
(e: 'submit', payload: AddSourcePayload): void
}>()
const kind = ref<SourceKind>('website')
const url = ref('')
const scope = ref<CrawlScope>('host')
const files = ref<File[]>([])
const title = ref('')
const content = ref('')
const dragOver = ref(false)
const fileInput = ref<HTMLInputElement | null>(null)
const types: { key: SourceKind; title: string; sub: string }[] = [
{ key: 'website', title: '网站', sub: '抓取单个页面或整个站点' },
{ key: 'sitemap', title: '站点地图', sub: '索引其中列出的所有页面' },
{ key: 'pdf', title: '文档', sub: 'PDF 文件,最大 25 MB' },
{ key: 'text', title: '文本', sub: '直接粘贴内容' },
]
const willQueue = computed(() => kind.value !== 'text')
const submitLabel = computed(() => (kind.value === 'text' ? '添加页面' : '加入抓取队列'))
const canSubmit = computed(() => {
if (props.submitting) return false
if (kind.value === 'website' || kind.value === 'sitemap') return url.value.trim().length > 0
if (kind.value === 'pdf') return files.value.length > 0
return title.value.trim().length > 0 && content.value.trim().length > 0
})
function normalizeUrl(raw: string): string {
const v = raw.trim()
// Leave an explicit scheme alone; otherwise assume https.
return v.includes('://') ? v : `https://${v}`
}
// Parsed form of what's typed so far, used to name the host/section in the
// scope options. Null while the URL is empty or not yet valid.
const parsedUrl = computed<URL | null>(() => {
if (!url.value.trim()) return null
try {
return new URL(normalizeUrl(url.value))
} catch {
return null
}
})
const seedHost = computed(() => parsedUrl.value?.hostname ?? '')
// The section is the URL's own path. Empty for a homepage URL — there is no
// section to speak of, and "pages under /" would just be "This site" again.
const seedSection = computed(() => parsedUrl.value?.pathname.replace(/\/$/, '') ?? '')
const scopes = computed<{ key: CrawlScope; title: string; sub: string }[]>(() => {
const host = seedHost.value
const section = seedSection.value
return [
{ key: 'page', title: '仅此页面', sub: '仅索引上方这个 URL。' },
...(section
? [
{
key: 'path' as CrawlScope,
title: '本栏目',
sub: `仅 ${host} 上 ${section}/ 下的页面。`,
},
]
: []),
{
key: 'host',
title: '整个站点',
sub: host ? `${host} 上的所有页面。` : '同一主机上的所有页面。',
},
{
key: 'domain',
// No domain name in the copy on purpose: working out that
// paywithatoa.co.uk is one registrable domain (not 'co.uk') needs the
// ccTLD list the backend keeps, and a second copy of it would drift.
title: '整个域名',
sub: '也包括其他子域名,如 blog、docs、app。',
},
]
})
// Editing the URL down to a homepage removes the section option; don't leave a
// scope selected that is no longer on screen.
watch(scopes, (options) => {
if (!options.some((option) => option.key === scope.value)) scope.value = 'host'
})
function pickFiles() {
fileInput.value?.click()
}
const isPdf = (file: File) => file.type === 'application/pdf' || /\.pdf$/i.test(file.name)
function onFilesSelected(event: Event) {
const input = event.target as HTMLInputElement
if (input.files) files.value = Array.from(input.files).filter(isPdf)
}
function onDrop(event: DragEvent) {
dragOver.value = false
const dropped = event.dataTransfer?.files
if (!dropped) return
// Only accept PDFs — the backend upload endpoint validates PDF magic bytes.
const pdfs = Array.from(dropped).filter(isPdf)
if (pdfs.length) files.value = pdfs
}
function submit() {
if (!canSubmit.value) return
if (kind.value === 'website') {
emit('submit', { type: 'website', url: normalizeUrl(url.value), scope: scope.value })
} else if (kind.value === 'sitemap') {
emit('submit', { type: 'sitemap', url: normalizeUrl(url.value) })
} else if (kind.value === 'pdf') {
emit('submit', { type: 'pdf', files: files.value })
} else {
emit('submit', { type: 'text', title: title.value.trim(), content: content.value })
}
}
</script>
<template>
<div class="scrim" @click.self="emit('close')">
<div class="modal" role="dialog" aria-modal="true" aria-label="添加知识来源">
<div class="modal__head">
<div>
<h3 class="modal__title">添加知识来源</h3>
<p class="modal__sub">选择来源类型,我们会在后台抓取并建立索引。</p>
</div>
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="types">
<button
v-for="t in types"
:key="t.key"
type="button"
class="type"
:class="{ 'type--active': kind === t.key }"
@click="kind = t.key"
>
<span class="type__title">{{ t.title }}</span>
<span class="type__sub">{{ t.sub }}</span>
</button>
</div>
<div class="body">
<!-- website -->
<template v-if="kind === 'website'">
<label class="field-label" for="kb-add-url">页面 URL</label>
<input id="kb-add-url" v-model="url" class="text-input" type="text"
placeholder="https://docs.yourcompany.com/help" @keyup.enter="submit" />
<label class="field-label">抓取范围</label>
<div class="scope">
<button v-for="opt in scopes" :key="opt.key" type="button" class="scope__opt"
:class="{ 'scope__opt--active': scope === opt.key }" @click="scope = opt.key">
<span class="radio" :class="{ 'radio--on': scope === opt.key }"></span>
<span>
<span class="scope__title">{{ opt.title }}</span>
<span class="scope__sub">{{ opt.sub }}</span>
</span>
</button>
</div>
<p v-if="scope !== 'page'" class="scope__hint">
系统会发现并排队抓取关联页面,数量以套餐上限为准。
</p>
</template>
<!-- sitemap -->
<template v-else-if="kind === 'sitemap'">
<label class="field-label" for="kb-add-sitemap">站点地图 URL</label>
<input id="kb-add-sitemap" v-model="url" class="text-input" type="text"
placeholder="https://yourcompany.com/sitemap.xml" @keyup.enter="submit" />
<div class="note note--teal">
我们会读取站点地图,并将其中列出的每个页面加入抓取队列。较大的站点地图会分批处理。
</div>
</template>
<!-- document -->
<template v-else-if="kind === 'pdf'">
<div
class="drop"
:class="{ 'drop--over': dragOver }"
@click="pickFiles"
@dragover.prevent="dragOver = true"
@dragleave.prevent="dragOver = false"
@drop.prevent="onDrop"
>
<input ref="fileInput" type="file" accept="application/pdf" multiple class="hidden-file"
@change="onFilesSelected" />
<span v-if="files.length" class="drop__name">{{ files.map((f) => f.name).join(', ') }}</span>
<span v-else class="drop__name">拖入 PDF 或点击选择文件</span>
<span class="drop__hint">PDF 文件 · 每个最大 25 MB</span>
</div>
<div class="note note--coral">
我们会提取文本、切分成多个章节,并逐一建立索引。扫描版 PDF 会经过 OCR 处理。
</div>
</template>
<!-- text -->
<template v-else>
<label class="field-label" for="kb-add-title">标题</label>
<input id="kb-add-title" v-model="title" class="text-input" type="text" placeholder="例如:退款政策" />
<label class="field-label" for="kb-add-content">内容</label>
<textarea id="kb-add-content" v-model="content" class="textarea"
placeholder="粘贴或输入希望智能体学习的内容…"></textarea>
</template>
</div>
<div class="foot">
<span class="foot__note">
<span class="foot__dot" :class="willQueue ? 'foot__dot--queue' : 'foot__dot--instant'"></span>
<template v-if="willQueue">已加入抓取队列,索引完成后会通知你。</template>
<template v-else>立即添加,无需抓取。</template>
</span>
<div class="foot__actions">
<button class="btn btn--ghost" type="button" :disabled="submitting" @click="emit('close')">取消</button>
<button class="btn btn--primary" type="button" :disabled="!canSubmit" @click="submit">
{{ submitting ? '添加中…' : submitLabel }}
</button>
</div>
</div>
</div>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 20px;
overflow-y: auto;
}
.modal {
width: 560px;
max-width: 100%;
background: var(--bg2);
border: 1px solid var(--o12);
border-radius: 20px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
overflow: hidden;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o08);
}
.modal__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
color: var(--text);
margin: 0 0 4px;
}
.modal__sub {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.icon-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--o08);
color: var(--text2);
}
.types {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 9px;
padding: 18px 24px 4px;
}
.type {
display: flex;
flex-direction: column;
gap: 2px;
padding: 12px 13px;
border-radius: 11px;
cursor: pointer;
text-align: left;
background: transparent;
border: 1.5px solid var(--o10);
}
.type:hover {
background: var(--o04);
}
.type--active {
background: var(--accent-bg-06);
border-color: var(--accent-solid);
}
.type__title {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.type__sub {
font-size: 11.5px;
color: var(--muted2);
line-height: 1.3;
}
.body {
padding: 16px 24px 4px;
min-height: 172px;
}
.field-label {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.07em;
color: var(--muted2);
margin-bottom: 8px;
}
.field-label:not(:first-child) {
margin-top: 16px;
}
.text-input,
.textarea {
width: 100%;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
padding: 12px 14px;
font-size: 14px;
color: var(--text);
outline: none;
font-family: var(--font-sans);
}
.textarea {
min-height: 120px;
line-height: 1.6;
color: var(--text3);
resize: vertical;
}
.text-input:focus,
.textarea:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.scope {
display: flex;
flex-direction: column;
gap: 8px;
}
.scope__opt {
display: flex;
align-items: flex-start;
gap: 11px;
width: 100%;
padding: 12px 13px;
border-radius: 11px;
cursor: pointer;
text-align: left;
background: transparent;
border: 1.5px solid var(--o10);
}
.scope__opt--active {
background: var(--accent-bg-06);
border-color: var(--accent-solid);
}
.radio {
width: 16px;
height: 16px;
flex-shrink: 0;
margin-top: 1px;
border-radius: 50%;
border: 2px solid var(--o20);
}
.radio--on {
border-color: var(--accent-solid);
background: radial-gradient(circle, var(--accent-solid) 0 45%, transparent 48%);
}
.scope__title {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.scope__sub {
display: block;
font-size: 12px;
color: var(--muted2);
margin-top: 1px;
overflow-wrap: anywhere;
}
.scope__hint {
margin: 8px 2px 0;
font-size: 12px;
color: var(--muted2);
}
.drop {
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
gap: 8px;
padding: 30px 20px;
background: var(--bg);
border: 1.5px dashed var(--o20);
border-radius: 14px;
cursor: pointer;
text-align: center;
}
.drop--over {
border-color: var(--c-coral);
background: var(--coral-bg);
}
.hidden-file {
display: none;
}
.drop__name {
font-size: 14px;
font-weight: 600;
color: var(--text2);
word-break: break-word;
}
.drop__hint {
font-size: 12px;
color: var(--muted2);
}
.note {
display: flex;
gap: 10px;
align-items: flex-start;
padding: 12px 14px;
border-radius: 11px;
font-size: 12.5px;
color: var(--text3);
line-height: 1.5;
margin-top: 14px;
}
.note--teal {
background: var(--teal-bg);
border: 1px solid var(--teal-border);
}
.note--coral {
background: var(--coral-bg);
border: 1px solid var(--coral-border);
}
.foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 14px;
padding: 16px 24px;
border-top: 1px solid var(--o08);
background: var(--surface);
}
.foot__note {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 12.5px;
color: var(--muted);
line-height: 1.4;
}
.foot__dot {
width: 7px;
height: 7px;
border-radius: 50%;
flex-shrink: 0;
}
.foot__dot--queue {
background: var(--c-purple);
}
.foot__dot--instant {
background: var(--c-teal);
}
.foot__actions {
display: flex;
gap: 9px;
flex-shrink: 0;
}
.btn {
padding: 10px 18px;
border-radius: 10px;
font-size: 13.5px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) {
background: var(--o05);
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) {
filter: brightness(1.05);
}
</style>
@@ -0,0 +1,505 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
useKnowledgeExplorer,
type ExplorerSource,
type AddSourcePayload,
} from '@/composables/useKnowledgeExplorer'
import KnowledgeSourceTree from './KnowledgeSourceTree.vue'
import KnowledgePageDetail from './KnowledgePageDetail.vue'
import KnowledgePageEditor from './KnowledgePageEditor.vue'
import KnowledgePlanMeters from './KnowledgePlanMeters.vue'
import KnowledgeAddSourceModal from './KnowledgeAddSourceModal.vue'
import KnowledgeLinkModal from './KnowledgeLinkModal.vue'
const props = withDefaults(
defineProps<{
mode: 'agent' | 'org'
organizationId: string
agentId?: string
showPlanMeters?: boolean
title?: string
description?: string
variant?: 'page' | 'section'
}>(),
{
agentId: undefined,
showPlanMeters: false,
title: '',
description: '',
variant: 'section',
},
)
const ex = useKnowledgeExplorer(props.mode, props.agentId, props.organizationId)
// Add-source modal state.
const addOpen = ref(false)
const isSubmittingSource = ref(false)
// A single, reusable confirm dialog for the destructive actions.
interface ConfirmState {
title: string
message: string
actionLabel: string
busyLabel: string
action: () => Promise<void>
}
const confirmState = ref<ConfirmState | null>(null)
const largestSource = computed(() =>
ex.sources.value.reduce<ExplorerSource | null>((max, s) => {
if (!max || (s.pages ?? s.pageStubs).length > (max.pages ?? max.pageStubs).length) return s
return max
}, null),
)
const largestSubpageCount = computed(() => {
const s = largestSource.value
return s ? (s.pages ?? s.pageStubs).length : 0
})
function openAdd() {
addOpen.value = true
}
async function onSubmitSource(payload: AddSourcePayload) {
if (isSubmittingSource.value) return
isSubmittingSource.value = true
try {
const ok = await ex.submitSource(payload)
if (ok) addOpen.value = false
} finally {
isSubmittingSource.value = false
}
}
function askDeleteSource(source: ExplorerSource) {
if (source.queued && source.queuedStatus === 'error') {
// A failed crawl — not an in-flight one to "cancel".
confirmState.value = {
title: '忽略来源',
message: `忽略失败的来源“${source.name}”?`,
actionLabel: '忽略',
busyLabel: '正在忽略…',
action: () => ex.deleteSource(source),
}
} else if (source.queued) {
confirmState.value = {
title: '取消抓取',
message: `取消对“${source.name}”的排队抓取?`,
actionLabel: '取消抓取',
busyLabel: '正在取消…',
action: () => ex.deleteSource(source),
}
} else if (props.mode === 'agent') {
// In an agent's tab, "Remove" detaches the source from this agent but keeps
// it in the organization's knowledge — it does not delete the source.
confirmState.value = {
title: '从智能体移除',
message: `从该智能体移除“${source.name}”?它仍会保留在组织的知识库中。`,
actionLabel: '移除',
busyLabel: '正在移除…',
action: () => ex.unlinkSource(source.id),
}
} else {
confirmState.value = {
title: '删除来源',
message: `删除“${source.name}”及其所有页面?此操作无法撤销。`,
actionLabel: '删除',
busyLabel: '正在删除…',
action: () => ex.deleteSource(source),
}
}
}
function askDeletePage() {
const page = ex.selectedPage.value
if (!page) return
confirmState.value = {
title: '删除页面',
message: `从该来源删除“${page.title}”?此操作无法撤销。`,
actionLabel: '删除',
busyLabel: '正在删除…',
action: () => ex.deletePage(),
}
}
async function runConfirm() {
const state = confirmState.value
if (!state) return
await state.action()
confirmState.value = null
}
onMounted(() => {
ex.refresh()
ex.startPolling()
})
onUnmounted(() => {
ex.stopPolling()
})
</script>
<template>
<div class="explorer">
<header class="explorer__header">
<div class="explorer__heading">
<component :is="variant === 'page' ? 'h1' : 'h3'" class="explorer__title" :class="`explorer__title--${variant}`">
{{ title }}
</component>
<p v-if="description" class="explorer__desc">{{ description }}</p>
</div>
<div class="explorer__actions">
<slot name="actions" />
<button v-if="mode === 'agent'" class="btn btn--ghost" type="button" @click="ex.openLinkPicker">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M10 13a5 5 0 0 0 7 0l2-2a5 5 0 0 0-7-7l-1 1M14 11a5 5 0 0 0-7 0l-2 2a5 5 0 0 0 7 7l1-1" /></svg>
关联已有
</button>
<button class="btn btn--primary" type="button" @click="openAdd">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
添加来源
</button>
</div>
</header>
<KnowledgePlanMeters
v-if="showPlanMeters"
class="explorer__meters"
:source-count="ex.sources.value.length"
:largest-source-name="largestSource?.name ?? null"
:largest-subpage-count="largestSubpageCount"
/>
<div v-if="ex.error.value" class="banner" role="alert">
{{ ex.error.value }}
<button class="banner__close" type="button" aria-label="关闭" @click="ex.error.value = null">×</button>
</div>
<div class="grid">
<KnowledgeSourceTree
class="grid__tree"
:sources="ex.filteredSources.value"
:selected-page-id="ex.selectedPageId.value"
:selected-source-id="ex.selectedSourceId.value"
:query="ex.query.value"
:status-of="ex.sourceStatus"
:page-rows-of="ex.pageRows"
@update:query="ex.query.value = $event"
@toggle="ex.toggleSource"
@select="(source, pageId) => ex.selectPage(source, pageId)"
@delete-source="askDeleteSource"
@add-page="ex.startAddPage"
/>
<div class="grid__detail">
<KnowledgePageEditor
v-if="ex.editing.value && (ex.selectedPage.value || ex.isAddingPage.value)"
:title="ex.draftTitle.value"
:content="ex.draftContent.value"
:saving="ex.isSaving.value"
:submit-label="ex.isAddingPage.value ? '添加子页面' : '保存页面'"
:url="ex.draftUrl.value"
:show-url="ex.isAddingPage.value"
@update:title="ex.draftTitle.value = $event"
@update:content="ex.draftContent.value = $event"
@update:url="ex.draftUrl.value = $event"
@save="ex.savePage"
@cancel="ex.cancelEdit"
/>
<KnowledgePageDetail
v-else-if="ex.selectedPage.value && ex.selectedSource.value"
:page="ex.selectedPage.value"
:source-name="ex.selectedSource.value.name"
:source-type="ex.selectedSource.value.type"
:agents="ex.selectedSource.value.agents"
:status="ex.sourceStatus(ex.selectedSource.value)"
:deleting="ex.isDeleting.value"
@edit="ex.startEdit"
@delete="askDeletePage"
/>
<div v-else class="empty">
<div class="empty__orb"></div>
<div class="empty__title">选择一个页面查看</div>
<p class="empty__text">
在左侧选择任一已提取的页面,即可查看内容、编辑或移除,也可以添加自己的来源。
</p>
</div>
</div>
</div>
<div v-if="confirmState" class="confirm" role="dialog" aria-modal="true">
<div class="confirm__card">
<h3 class="confirm__title">{{ confirmState.title }}</h3>
<p class="confirm__msg">{{ confirmState.message }}</p>
<div class="confirm__actions">
<button class="btn btn--ghost" type="button" :disabled="ex.isDeleting.value" @click="confirmState = null">取消</button>
<button class="btn btn--danger-solid" type="button" :disabled="ex.isDeleting.value" @click="runConfirm">
{{ ex.isDeleting.value ? confirmState.busyLabel : confirmState.actionLabel }}
</button>
</div>
</div>
</div>
<KnowledgeAddSourceModal
v-if="addOpen"
:submitting="isSubmittingSource"
@close="addOpen = false"
@submit="onSubmitSource"
/>
<KnowledgeLinkModal
v-if="ex.linkPickerOpen.value"
:sources="ex.orgSources.value"
:linked-ids="ex.linkedSourceIds.value"
:busy-ids="ex.linkingIds.value"
:loading="ex.isLoadingOrgSources.value"
:error="ex.orgSourcesError.value"
@close="ex.linkPickerOpen.value = false"
@link="ex.linkSource"
@unlink="ex.unlinkSource"
/>
</div>
</template>
<style scoped>
.explorer {
display: flex;
flex-direction: column;
min-width: 0;
}
.explorer__header {
display: flex;
align-items: flex-end;
justify-content: space-between;
gap: 20px;
margin-bottom: 20px;
flex-wrap: wrap;
}
.explorer__heading {
min-width: 0;
}
.explorer__title {
font-family: var(--font-display);
font-weight: 700;
color: var(--text);
margin: 0 0 6px;
}
.explorer__title--page {
font-size: 30px;
letter-spacing: -0.02em;
}
.explorer__title--section {
font-size: 20px;
}
.explorer__desc {
font-size: 14px;
color: var(--muted);
margin: 0;
max-width: 560px;
line-height: 1.55;
}
.explorer__actions {
display: flex;
align-items: center;
gap: 10px;
flex-shrink: 0;
}
.explorer__meters {
margin-bottom: 14px;
}
.btn {
display: inline-flex;
align-items: center;
gap: 8px;
height: 47px;
padding: 0 18px;
border-radius: 11px;
font-size: 14px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
.btn--ghost {
background: var(--o05);
border-color: var(--o12);
color: var(--text2);
}
.btn--ghost:hover:not(:disabled) { background: var(--o08); }
.btn--danger-solid {
background: var(--c-coral);
color: var(--on-accent-solid);
}
.btn--danger-solid:hover:not(:disabled) { filter: brightness(1.05); }
.banner {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 14px;
margin-bottom: 16px;
background: var(--error-bg);
border: 1px solid var(--coral-border);
border-radius: 11px;
color: var(--c-coral);
font-size: 13.5px;
}
.banner__close {
background: transparent;
border: none;
color: inherit;
font-size: 18px;
cursor: pointer;
line-height: 1;
}
.grid {
display: grid;
grid-template-columns: 344px 1fr;
gap: 18px;
align-items: stretch;
}
@media (max-width: 860px) {
.grid {
grid-template-columns: 1fr;
}
}
.grid__tree,
.grid__detail {
height: 640px;
}
.grid__detail {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
overflow: hidden;
display: flex;
flex-direction: column;
min-width: 0;
}
.empty {
flex: 1;
display: flex;
flex-direction: column;
align-items: center;
justify-content: center;
text-align: center;
padding: 40px;
}
.empty__orb {
width: 52px;
height: 52px;
margin-bottom: 18px;
border-radius: 50%;
background: conic-gradient(from 0deg, var(--c-lime), var(--c-purple), var(--c-teal), var(--c-coral), var(--c-lime));
opacity: 0.85;
}
.empty__title {
font-family: var(--font-display);
font-weight: 600;
font-size: 18px;
color: var(--text2);
margin-bottom: 8px;
}
.empty__text {
font-size: 14px;
color: var(--muted);
max-width: 320px;
line-height: 1.55;
margin: 0;
}
.confirm {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: center;
justify-content: center;
padding: 20px;
}
.confirm__card {
background: var(--surface);
border: 1px solid var(--o12);
border-radius: 16px;
padding: 24px;
max-width: 400px;
width: 100%;
}
.confirm__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
margin: 0 0 8px;
}
.confirm__msg {
font-size: 14px;
color: var(--muted);
line-height: 1.55;
margin: 0 0 20px;
}
.confirm__actions {
display: flex;
justify-content: flex-end;
gap: 10px;
}
</style>
@@ -0,0 +1,308 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { KnowledgeItem } from '@/types/knowledge'
const props = defineProps<{
sources: KnowledgeItem[]
linkedIds: Set<number>
busyIds: Set<number>
loading: boolean
error: string | null
}>()
const emit = defineEmits<{
(e: 'close'): void
(e: 'link', knowledgeId: number): void
(e: 'unlink', knowledgeId: number): void
}>()
const query = ref('')
const filteredSources = computed(() => {
const q = query.value.trim().toLowerCase()
if (!q) return props.sources
return props.sources.filter(
(s) => s.name.toLowerCase().includes(q) || s.type.toLowerCase().includes(q),
)
})
</script>
<template>
<div class="scrim" @click.self="emit('close')">
<div class="modal" role="dialog" aria-modal="true" aria-label="关联已有知识">
<div class="modal__head">
<div>
<h3 class="modal__title">关联已有知识</h3>
<p class="modal__sub">把组织中的知识关联到该智能体,供其使用。</p>
</div>
<button class="icon-btn" type="button" aria-label="关闭" @click="emit('close')">
<svg viewBox="0 0 24 24" width="16" height="16" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round"><path d="M6 6l12 12M18 6L6 18" /></svg>
</button>
</div>
<div class="search">
<input v-model="query" class="text-input" type="text" placeholder="搜索知识来源…" />
</div>
<div class="body">
<div v-if="loading" class="state">正在加载知识来源…</div>
<div v-else-if="error" class="state state--error">{{ error }}</div>
<div v-else-if="!filteredSources.length" class="state">
{{ query ? '没有匹配的来源。' : '组织中还没有可关联的知识。' }}
</div>
<ul v-else class="list">
<li v-for="item in filteredSources" :key="item.id" class="row">
<div class="row__main">
<span class="row__name">{{ item.name }}</span>
<span class="row__type">{{ item.type }}</span>
</div>
<button
v-if="linkedIds.has(item.id)"
class="btn btn--linked"
type="button"
:disabled="busyIds.has(item.id)"
@click="emit('unlink', item.id)"
>
{{ busyIds.has(item.id) ? '正在移除…' : '取消关联' }}
</button>
<button
v-else
class="btn btn--primary"
type="button"
:disabled="busyIds.has(item.id)"
@click="emit('link', item.id)"
>
{{ busyIds.has(item.id) ? '正在关联…' : '关联' }}
</button>
</li>
</ul>
</div>
<div class="foot">
<button class="btn btn--ghost" type="button" @click="emit('close')">完成</button>
</div>
</div>
</div>
</template>
<style scoped>
.scrim {
position: fixed;
inset: 0;
z-index: 100;
background: var(--scrim);
display: flex;
align-items: flex-start;
justify-content: center;
padding: 60px 20px;
overflow-y: auto;
}
.modal {
width: 560px;
max-width: 100%;
background: var(--bg2);
border: 1px solid var(--o12);
border-radius: 20px;
box-shadow: 0 30px 80px rgba(0, 0, 0, 0.4);
overflow: hidden;
display: flex;
flex-direction: column;
}
.modal__head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 16px;
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o08);
}
.modal__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 19px;
color: var(--text);
margin: 0 0 4px;
}
.modal__sub {
font-size: 13px;
color: var(--muted);
margin: 0;
}
.icon-btn {
width: 34px;
height: 34px;
flex-shrink: 0;
border-radius: 9px;
background: var(--o05);
border: 1px solid var(--o10);
color: var(--muted);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
}
.icon-btn:hover {
background: var(--o08);
color: var(--text2);
}
.search {
padding: 16px 24px 4px;
}
.text-input {
width: 100%;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 11px;
padding: 12px 14px;
font-size: 14px;
color: var(--text);
outline: none;
font-family: var(--font-sans);
}
.text-input:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.body {
padding: 12px 24px 4px;
min-height: 180px;
max-height: 340px;
overflow-y: auto;
}
.state {
display: flex;
align-items: center;
justify-content: center;
min-height: 160px;
font-size: 13.5px;
color: var(--muted);
text-align: center;
}
.state--error {
color: var(--c-coral);
}
.list {
list-style: none;
margin: 0;
padding: 0;
display: flex;
flex-direction: column;
gap: 8px;
}
.row {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 11px 13px;
border-radius: 11px;
border: 1px solid var(--o10);
background: var(--o04);
}
.row__main {
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.row__name {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
word-break: break-word;
}
.row__type {
font-size: 11.5px;
color: var(--muted2);
text-transform: capitalize;
}
.foot {
display: flex;
justify-content: flex-end;
gap: 9px;
padding: 16px 24px;
border-top: 1px solid var(--o08);
background: var(--surface);
}
.btn {
padding: 8px 16px;
border-radius: 10px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
white-space: nowrap;
flex-shrink: 0;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) {
filter: brightness(1.05);
}
.btn--linked {
background: var(--o05);
border-color: var(--o12);
color: var(--muted);
}
.btn--linked:hover:not(:disabled) {
background: var(--coral-bg);
border-color: var(--coral-border);
color: var(--c-coral);
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) {
background: var(--o05);
}
</style>
@@ -0,0 +1,377 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { KnowledgeLinkedAgent, KnowledgeSubPage } from '@/types/knowledge'
import type { SourceStatus } from '@/composables/useKnowledgeExplorer'
const props = defineProps<{
page: KnowledgeSubPage
sourceName: string
sourceType: string
agents?: KnowledgeLinkedAgent[]
status: SourceStatus
deleting: boolean
}>()
// First-letter avatar for an agent chip.
const initial = (name: string) => (name.trim()[0] || '?').toUpperCase()
defineEmits<{
(e: 'edit'): void
(e: 'delete'): void
}>()
const statusText: Record<SourceStatus, string> = {
queued: '等待中',
crawling: '抓取中',
synced: '已同步',
error: '需要同步',
}
const paragraphs = computed(() =>
(props.page.content || '')
.split(/\n{2,}/)
.map((p) => p.trim())
.filter(Boolean),
)
const href = computed(() => {
const url = props.page.url || ''
return /^https?:/i.test(url) ? url : `https://${url}`
})
const updatedLabel = computed(() => {
if (!props.page.updated_at) return '未知'
const d = new Date(props.page.updated_at)
return Number.isNaN(d.getTime()) ? '未知' : d.toLocaleDateString()
})
</script>
<template>
<div class="detail">
<div class="detail__head">
<div class="crumbs">
<span class="crumbs__src" :title="sourceName">{{ sourceName }}</span>
<span class="crumbs__sep">/</span>
<span class="crumbs__type">{{ sourceType }}</span>
</div>
<div class="detail__title-row">
<div class="detail__title-wrap">
<h2 class="detail__title">{{ page.title }}</h2>
<a class="detail__url" :href="href" target="_blank" rel="noopener noreferrer">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6" />
<path d="M15 3h6v6" /><path d="M10 14 21 3" />
</svg>
<span class="detail__url-text">{{ page.url }}</span>
</a>
</div>
<div class="detail__actions">
<button class="btn btn--ghost" type="button" @click="$emit('edit')">
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M12 20h9" /><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z" />
</svg>
编辑
</button>
<button class="btn btn--danger" type="button" :disabled="deleting" title="删除页面"
@click="$emit('delete')">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
</button>
</div>
</div>
<div class="meta">
<span class="meta__item">{{ page.word_count }} 词</span>
<span class="meta__sep"></span>
<span class="meta__item">更新于 {{ updatedLabel }}</span>
<span class="meta__sep"></span>
<span class="pill" :class="`pill--${status}`">
<span class="pill__dot"></span>{{ statusText[status] }}
</span>
</div>
<div v-if="agents && agents.length" class="usedby">
<span class="usedby__label">使用方</span>
<span v-for="agent in agents" :key="agent.id" class="agent-chip" :title="agent.name">
<span class="agent-chip__avatar">{{ initial(agent.name) }}</span>
<span class="agent-chip__name">{{ agent.name }}</span>
</span>
</div>
</div>
<div class="doc">
<p v-for="(para, i) in paragraphs" :key="i">{{ para }}</p>
<div v-if="paragraphs.length === 0" class="doc__empty">
该页面还没有内容,点击 <strong>编辑</strong> 添加内容。
</div>
</div>
</div>
</template>
<style scoped>
.detail {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.detail__head {
padding: 20px 24px 16px;
border-bottom: 1px solid var(--o07);
flex-shrink: 0;
}
.crumbs {
display: flex;
align-items: center;
gap: 8px;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
margin-bottom: 12px;
min-width: 0;
}
.crumbs__src {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
max-width: 60%;
}
.crumbs__sep { color: var(--faint); }
.crumbs__type { color: var(--text3); }
.detail__title-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 16px;
}
.detail__title-wrap {
flex: 1;
min-width: 0;
}
.detail__title {
font-family: var(--font-display);
font-weight: 700;
font-size: 22px;
letter-spacing: -0.01em;
color: var(--text);
margin: 0 0 6px;
word-break: break-word;
}
.detail__url {
display: inline-flex;
align-items: center;
gap: 6px;
font-family: var(--font-mono);
font-size: 12px;
color: var(--c-teal);
text-decoration: none;
max-width: 100%;
}
.detail__url-text {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.detail__actions {
display: flex;
align-items: center;
gap: 8px;
flex-shrink: 0;
}
.btn {
display: inline-flex;
align-items: center;
gap: 7px;
padding: 8px 14px;
border-radius: 9px;
font-size: 13px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid var(--o12);
background: var(--o05);
color: var(--text2);
}
.btn:hover { background: var(--o08); }
.btn--danger {
width: 34px;
padding: 0;
justify-content: center;
height: 34px;
color: var(--muted);
}
.btn--danger:hover:not(:disabled) {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.meta {
display: flex;
align-items: center;
gap: 12px;
margin-top: 14px;
flex-wrap: wrap;
font-family: var(--font-mono);
font-size: 11px;
color: var(--muted2);
}
.meta__sep {
width: 3px;
height: 3px;
border-radius: 50%;
background: var(--faint);
}
.pill {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 4px 10px;
border-radius: 999px;
font-size: 11.5px;
font-weight: 600;
border: 1px solid var(--teal-border);
background: var(--teal-bg);
color: var(--c-teal);
}
.pill__dot {
width: 6px;
height: 6px;
border-radius: 50%;
background: currentColor;
}
.usedby {
display: flex;
align-items: center;
gap: 8px;
flex-wrap: wrap;
margin-top: 14px;
}
.usedby__label {
font-family: var(--font-mono);
font-size: 10.5px;
font-weight: 600;
letter-spacing: 0.06em;
text-transform: uppercase;
color: var(--muted2);
}
.agent-chip {
display: inline-flex;
align-items: center;
gap: 6px;
padding: 3px 10px 3px 3px;
border-radius: 999px;
border: 1px solid var(--o12);
background: var(--o05);
font-size: 12px;
font-weight: 600;
color: var(--text2);
max-width: 180px;
}
.agent-chip__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--teal-bg);
color: var(--c-teal);
font-size: 10.5px;
font-weight: 700;
flex-shrink: 0;
}
.agent-chip__name {
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.pill--crawling {
border-color: var(--warning-border, var(--o12));
background: var(--warning-bg);
color: var(--warning-color);
}
.pill--queued {
border-color: var(--purple-border, var(--o12));
background: var(--purple-bg, var(--o06));
color: var(--c-purple);
}
.pill--error {
border-color: var(--coral-border);
background: var(--coral-bg);
color: var(--c-coral);
}
.doc {
flex: 1;
overflow-y: auto;
padding: 24px 28px;
color: var(--text3);
font-size: 15px;
line-height: 1.7;
font-family: var(--font-sans);
}
.doc p {
margin: 0 0 14px;
white-space: pre-wrap;
}
.doc__empty {
text-align: center;
color: var(--muted2);
font-size: 14px;
padding: 40px 0;
}
</style>
@@ -0,0 +1,216 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
const props = withDefaults(
defineProps<{
title: string
content: string
saving: boolean
submitLabel?: string
url?: string
showUrl?: boolean
}>(),
{ submitLabel: '保存页面', url: '', showUrl: false },
)
const emit = defineEmits<{
(e: 'update:title', value: string): void
(e: 'update:content', value: string): void
(e: 'update:url', value: string): void
(e: 'save'): void
(e: 'cancel'): void
}>()
const canSave = computed(() => props.content.trim().length > 0 && !props.saving)
</script>
<template>
<div class="editor">
<div class="editor__body">
<label class="editor__label" for="kb-page-title">页面标题</label>
<input
id="kb-page-title"
class="editor__title"
:value="title"
type="text"
placeholder="页面标题"
@input="emit('update:title', ($event.target as HTMLInputElement).value)"
/>
<template v-if="showUrl">
<label class="editor__label" for="kb-page-url">来源 URL(可选)</label>
<input
id="kb-page-url"
class="editor__title editor__url"
:value="url"
type="url"
placeholder="https://example.com/page"
@input="emit('update:url', ($event.target as HTMLInputElement).value)"
/>
</template>
<label class="editor__label" for="kb-page-content">页面内容</label>
<textarea
id="kb-page-content"
class="editor__content"
:value="content"
placeholder="在此输入页面内容,段落之间留一个空行。"
@input="emit('update:content', ($event.target as HTMLTextAreaElement).value)"
></textarea>
</div>
<div class="editor__foot">
<span class="editor__status">
<span class="editor__status-dot"></span>编辑中——保存后重新生成向量
</span>
<div class="editor__buttons">
<button class="btn btn--ghost" type="button" :disabled="saving" @click="emit('cancel')">取消</button>
<button class="btn btn--primary" type="button" :disabled="!canSave" @click="emit('save')">
{{ saving ? '保存中…' : submitLabel }}
</button>
</div>
</div>
</div>
</template>
<style scoped>
.editor {
display: flex;
flex-direction: column;
height: 100%;
min-height: 0;
}
.editor__body {
flex: 1;
overflow-y: auto;
padding: 18px 24px;
display: flex;
flex-direction: column;
}
.editor__label {
font-family: var(--font-mono);
font-size: 10.5px;
letter-spacing: 0.08em;
color: var(--muted2);
margin-bottom: 8px;
}
.editor__title {
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 9px;
outline: none;
font-family: var(--font-display);
font-weight: 700;
font-size: 18px;
color: var(--text);
padding: 10px 12px;
margin-bottom: 16px;
}
.editor__url {
font-family: var(--font-mono);
font-weight: 400;
font-size: 13px;
color: var(--c-teal);
}
.editor__title:focus,
.editor__content:focus {
border-color: var(--accent-border, var(--accent-ink));
}
.editor__content {
flex: 1;
min-height: 320px;
background: var(--bg);
border: 1px solid var(--o12);
border-radius: 12px;
padding: 16px 18px;
font-size: 15px;
line-height: 1.7;
color: var(--text3);
outline: none;
resize: vertical;
font-family: var(--font-sans);
}
.editor__foot {
display: flex;
align-items: center;
justify-content: space-between;
gap: 12px;
padding: 12px 18px;
border-top: 1px solid var(--o10);
background: var(--bg2);
flex-shrink: 0;
}
.editor__status {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 13px;
color: var(--muted);
}
.editor__status-dot {
width: 7px;
height: 7px;
border-radius: 50%;
background: var(--c-coral);
}
.editor__buttons {
display: flex;
align-items: center;
gap: 9px;
}
.btn {
padding: 9px 18px;
border-radius: 9px;
font-size: 13.5px;
font-weight: 600;
font-family: var(--font-sans);
cursor: pointer;
border: 1px solid transparent;
}
.btn:disabled {
opacity: 0.55;
cursor: not-allowed;
}
.btn--ghost {
background: transparent;
border-color: var(--o12);
color: var(--muted);
}
.btn--ghost:hover:not(:disabled) { background: var(--o05); }
.btn--primary {
background: var(--accent-solid);
color: var(--on-accent-solid);
}
.btn--primary:hover:not(:disabled) { filter: brightness(1.05); }
</style>
@@ -0,0 +1,171 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed, onMounted } from 'vue'
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
const props = defineProps<{
sourceCount: number
largestSourceName: string | null
largestSubpageCount: number
}>()
const { hasEnterpriseModule, subscriptionStore, initializeSubscriptionStore } =
useEnterpriseFeatures()
onMounted(async () => {
if (!hasEnterpriseModule) return
await initializeSubscriptionStore()
if (!subscriptionStore.value.currentPlan) {
await subscriptionStore.value.fetchCurrentPlan()
}
})
const plan = computed(() => subscriptionStore.value.currentPlan?.plan ?? null)
const maxSources = computed<number | null>(() => plan.value?.max_knowledge_sources ?? null)
const maxSubpages = computed<number | null>(() => plan.value?.max_sub_pages ?? null)
// Clamp a usage ratio to a readable meter width (min 6% so a tiny bar still shows).
function pct(used: number, limit: number | null): number {
if (!limit || limit <= 0) return 0
return Math.max(6, Math.min(100, Math.round((used / limit) * 100)))
}
const sourcesOver = computed(() => maxSources.value !== null && props.sourceCount > maxSources.value)
const subpagesOver = computed(
() => maxSubpages.value !== null && props.largestSubpageCount > maxSubpages.value,
)
</script>
<template>
<div class="meters">
<div class="meter" :class="{ 'meter--over': sourcesOver }">
<div class="meter__head">
<span class="meter__label">知识来源</span>
<span class="meter__value">
{{ sourceCount }}<template v-if="maxSources !== null"> / {{ maxSources }}</template>
</span>
</div>
<div v-if="maxSources !== null" class="meter__track">
<div class="meter__fill" :style="{ width: pct(sourceCount, maxSources) + '%' }"></div>
</div>
<div class="meter__note">
<template v-if="sourcesOver">已超出套餐的来源数量上限,升级后可关联更多来源。</template>
<template v-else-if="maxSources !== null">已关联 {{ sourceCount }} / {{ maxSources }} 个来源。</template>
<template v-else>已关联 {{ sourceCount }} 个来源。</template>
</div>
</div>
<div class="meter" :class="{ 'meter--over': subpagesOver }">
<div class="meter__head">
<span class="meter__label">每个来源的子页面</span>
<span class="meter__value">
<template v-if="maxSubpages !== null">最多 {{ maxSubpages }}</template>
<template v-else>—</template>
</span>
</div>
<div v-if="maxSubpages !== null" class="meter__track">
<div class="meter__fill meter__fill--alt"
:style="{ width: pct(largestSubpageCount, maxSubpages) + '%' }"></div>
</div>
<div class="meter__note">
<template v-if="largestSourceName">
最大来源:{{ largestSourceName }} — {{ largestSubpageCount }}<template v-if="maxSubpages !== null"> / {{ maxSubpages }}</template> 个子页面
</template>
<template v-else>还没有来源。</template>
</div>
</div>
</div>
</template>
<style scoped>
.meters {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 14px;
}
@media (max-width: 640px) {
.meters {
grid-template-columns: 1fr;
}
}
.meter {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 14px;
padding: 16px 18px;
}
.meter__head {
display: flex;
align-items: baseline;
justify-content: space-between;
gap: 10px;
margin-bottom: 11px;
}
.meter__label {
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
}
.meter__value {
font-family: var(--font-mono);
font-size: 13px;
font-weight: 600;
color: var(--text3);
}
.meter--over .meter__value {
color: var(--c-coral);
}
.meter__track {
height: 7px;
border-radius: 999px;
background: var(--o07);
overflow: hidden;
}
.meter__fill {
height: 100%;
border-radius: 999px;
background: var(--c-teal);
transition: width 0.3s ease;
}
.meter__fill--alt {
background: var(--c-purple);
}
.meter--over .meter__fill {
background: var(--c-coral);
}
.meter__note {
font-size: 12.5px;
color: var(--muted);
margin-top: 10px;
line-height: 1.5;
}
.meter--over .meter__note {
color: var(--c-coral);
}
</style>
@@ -0,0 +1,501 @@
<!--
Copyright 2024-2026 ReplyPier
Licensed under the Apache License, Version 2.0 (the "License");
you may not use this file except in compliance with the License.
You may obtain a copy of the License at
http://www.apache.org/licenses/LICENSE-2.0
Unless required by applicable law or agreed to in writing, software
distributed under the License is distributed on an "AS IS" BASIS,
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
See the License for the specific language governing permissions and
limitations under the License.
-->
<script setup lang="ts">
import { computed } from 'vue'
import type { ExplorerSource, SourceStatus } from '@/composables/useKnowledgeExplorer'
interface PageRow {
page_id: string
title: string
words: number | null
}
const props = defineProps<{
sources: ExplorerSource[]
selectedPageId: string | null
selectedSourceId: number | null
query: string
statusOf: (source: ExplorerSource) => SourceStatus
pageRowsOf: (source: ExplorerSource) => PageRow[]
}>()
// Group page rows once per render (the template reads them twice per source).
const rowsBySource = computed(() => {
const map = new Map<number, PageRow[]>()
for (const source of props.sources) map.set(source.id, props.pageRowsOf(source))
return map
})
const emit = defineEmits<{
(e: 'update:query', value: string): void
(e: 'toggle', source: ExplorerSource): void
(e: 'select', source: ExplorerSource, pageId: string): void
(e: 'delete-source', source: ExplorerSource): void
(e: 'add-page', source: ExplorerSource): void
}>()
const statusLabel: Record<SourceStatus, string> = {
queued: '等待中',
crawling: '抓取中',
synced: '已同步',
error: '需要同步',
}
// A queued placeholder's error state is a failed crawl, not a source that
// merely "needs sync".
function queuedLabel(source: ExplorerSource): string {
const status = props.statusOf(source)
return status === 'error' ? '失败' : statusLabel[status]
}
function sourceGlyph(type: string): string {
const t = (type || '').toLowerCase()
if (t.includes('pdf') || t === 'file') return 'P'
if (t.includes('site')) return 'S'
if (t.includes('web')) return 'W'
return 'T'
}
// First-letter avatar for a linked agent.
const agentInitial = (name: string) => (name.trim()[0] || '?').toUpperCase()
// Show at most this many agent avatars on a source row; the rest fold into "+N".
const MAX_AVATARS = 3
</script>
<template>
<div class="tree">
<div class="tree__search">
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="2"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<circle cx="11" cy="11" r="7" />
<line x1="21" y1="21" x2="16.65" y2="16.65" />
</svg>
<input
:value="query"
type="search"
placeholder="搜索页面…"
aria-label="搜索页面"
@input="emit('update:query', ($event.target as HTMLInputElement).value)"
/>
</div>
<div class="tree__body">
<div v-for="source in sources" :key="source.id" class="src">
<div class="src__row">
<button
class="src__toggle"
type="button"
:aria-expanded="source.queued ? undefined : source.expanded"
:disabled="source.queued"
@click="!source.queued && emit('toggle', source)"
>
<svg v-if="!source.queued" class="chev" :class="{ 'chev--open': source.expanded }" viewBox="0 0 24 24"
width="14" height="14" fill="none" stroke="currentColor" stroke-width="2.4" stroke-linecap="round"
stroke-linejoin="round" aria-hidden="true">
<path d="M9 6l6 6-6 6" />
</svg>
<span v-else class="chev-spacer"></span>
<span class="src__glyph" :class="`src__glyph--${sourceGlyph(source.type).toLowerCase()}`">{{ sourceGlyph(source.type) }}</span>
<span class="src__meta">
<span class="src__name" :title="source.name">{{ source.name }}</span>
<span class="src__count" :class="{ 'src__count--error': source.queued && statusOf(source) === 'error' }">
{{ source.queued ? queuedLabel(source) : (source.pages ?? source.pageStubs).length + ' 个子页面' }}
</span>
</span>
</button>
<span
v-if="!source.queued && source.agents.length"
class="agents"
:title="`使用方:${source.agents.map((a) => a.name).join('、')}`"
>
<span
v-for="agent in source.agents.slice(0, MAX_AVATARS)"
:key="agent.id"
class="agents__avatar"
>{{ agentInitial(agent.name) }}</span>
<span v-if="source.agents.length > MAX_AVATARS" class="agents__more">
+{{ source.agents.length - MAX_AVATARS }}
</span>
</span>
<span class="dot" :class="`dot--${statusOf(source)}`" :title="statusLabel[statusOf(source)]"></span>
<button
v-if="!source.queued"
class="src__add"
type="button"
title="为该来源添加子页面"
@click="emit('add-page', source)"
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.4"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
</button>
<button
class="src__del"
type="button"
:title="!source.queued ? '删除此来源' : (statusOf(source) === 'error' ? '忽略失败的来源' : '取消抓取')"
@click="emit('delete-source', source)"
>
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="1.9"
stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">
<path d="M4 7h16" /><path d="M9 7V5a1 1 0 0 1 1-1h4a1 1 0 0 1 1 1v2" />
<path d="M6 7l1 13a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1l1-13" />
</svg>
</button>
</div>
<div v-if="source.queued && source.queuedError" class="src__error">
{{ source.queuedError }}
</div>
<div v-if="source.expanded && !source.queued" class="src__pages">
<div v-if="source.loadingContent" class="src__hint">正在加载页面…</div>
<div v-else-if="source.contentError" class="src__hint src__hint--error">{{ source.contentError }}</div>
<template v-else>
<button
v-for="row in (rowsBySource.get(source.id) || [])"
:key="row.page_id"
type="button"
class="pg"
:class="{ 'pg--active': source.id === selectedSourceId && row.page_id === selectedPageId }"
@click="emit('select', source, row.page_id)"
>
<span class="pg__dot"></span>
<span class="pg__title" :title="row.title">{{ row.title }}</span>
<span v-if="row.words !== null" class="pg__words">{{ row.words }} 词</span>
</button>
<div v-if="(rowsBySource.get(source.id) || []).length === 0" class="src__hint">尚未提取任何页面。</div>
<button type="button" class="pg pg--add" @click="emit('add-page', source)">
<svg viewBox="0 0 24 24" width="13" height="13" fill="none" stroke="currentColor" stroke-width="2.2"
stroke-linecap="round" aria-hidden="true"><path d="M12 5v14M5 12h14" /></svg>
添加子页面
</button>
</template>
</div>
</div>
<div v-if="sources.length === 0" class="tree__empty">
<template v-if="query">没有匹配“{{ query }}”的页面。</template>
<template v-else>还没有知识来源。</template>
</div>
</div>
</div>
</template>
<style scoped>
.tree {
background: var(--surface);
border: 1px solid var(--o08);
border-radius: 16px;
display: flex;
flex-direction: column;
overflow: hidden;
height: 100%;
min-height: 0;
}
.tree__search {
display: flex;
align-items: center;
gap: 9px;
margin: 14px;
padding: 0 12px;
background: var(--bg);
border: 1px solid var(--o10);
border-radius: 10px;
color: var(--muted2);
flex-shrink: 0;
}
.tree__search input {
flex: 1;
min-width: 0;
background: transparent;
border: none;
outline: none;
color: var(--text);
font-size: 13.5px;
padding: 10px 0;
}
.tree__body {
flex: 1;
overflow-y: auto;
padding: 8px;
}
.src {
margin-bottom: 4px;
}
.src__row {
display: flex;
align-items: center;
gap: 4px;
border-radius: 9px;
padding-right: 6px;
}
.src__row:hover {
background: var(--o04);
}
.src__toggle {
flex: 1;
min-width: 0;
display: flex;
align-items: center;
gap: 10px;
padding: 10px;
cursor: pointer;
background: transparent;
border: none;
text-align: left;
color: inherit;
}
.chev {
flex-shrink: 0;
color: var(--muted2);
transition: transform 0.18s ease;
}
.chev-spacer {
width: 14px;
flex-shrink: 0;
}
.src__toggle:disabled {
cursor: default;
}
.chev--open {
transform: rotate(90deg);
}
.src__glyph {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 8px;
display: flex;
align-items: center;
justify-content: center;
font-family: var(--font-display);
font-weight: 700;
font-size: 12px;
background: var(--accent-bg-12);
color: var(--accent-ink);
}
.src__glyph--p { background: var(--coral-bg); color: var(--c-coral); }
.src__glyph--s { background: var(--teal-bg); color: var(--c-teal); }
.src__glyph--t { background: var(--purple-bg, var(--o08)); color: var(--c-purple); }
.src__meta {
flex: 1;
min-width: 0;
}
.src__name {
display: block;
font-size: 13.5px;
font-weight: 600;
color: var(--text2);
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.src__count {
display: block;
font-family: var(--font-mono);
font-size: 10.5px;
color: var(--muted2);
margin-top: 2px;
}
.src__count--error {
color: var(--c-coral);
}
.agents {
display: inline-flex;
align-items: center;
flex-shrink: 0;
}
.agents__avatar {
display: inline-flex;
align-items: center;
justify-content: center;
width: 20px;
height: 20px;
border-radius: 50%;
background: var(--teal-bg);
color: var(--c-teal);
font-size: 10px;
font-weight: 700;
border: 1.5px solid var(--surface);
margin-left: -6px;
}
.agents__avatar:first-child {
margin-left: 0;
}
.agents__more {
margin-left: 4px;
font-family: var(--font-mono);
font-size: 10px;
color: var(--muted2);
}
.dot {
width: 7px;
height: 7px;
flex-shrink: 0;
border-radius: 50%;
background: var(--muted2);
}
.dot--synced { background: var(--c-teal); box-shadow: 0 0 6px var(--c-teal); }
.dot--crawling { background: var(--warning-color); }
.dot--queued { background: var(--c-purple); }
.dot--error { background: var(--c-coral); }
.src__add,
.src__del {
width: 26px;
height: 26px;
flex-shrink: 0;
border-radius: 7px;
background: transparent;
border: 1px solid transparent;
color: var(--muted2);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
opacity: 0;
transition: opacity 0.15s ease;
}
.src__row:hover .src__add,
.src__row:hover .src__del { opacity: 1; }
.src__add:hover {
background: var(--accent-bg-08);
color: var(--accent-ink);
border-color: var(--accent-border, var(--o12));
}
.src__del:hover {
background: var(--coral-bg);
color: var(--c-coral);
border-color: var(--coral-border);
}
.src__pages {
padding: 2px 0 6px 18px;
}
.pg {
display: flex;
align-items: center;
gap: 9px;
width: 100%;
padding: 8px 10px;
margin: 1px 0;
border: none;
cursor: pointer;
text-align: left;
border-radius: 8px;
background: transparent;
color: inherit;
}
.pg:hover { background: var(--o04); }
.pg--active { background: var(--accent-bg-08); }
.pg--add {
gap: 8px;
font-size: 12.5px;
font-weight: 600;
font-family: var(--font-sans);
color: var(--muted2);
}
.pg--add:hover {
color: var(--accent-ink);
}
.pg__dot {
width: 6px;
height: 6px;
flex-shrink: 0;
border-radius: 50%;
background: var(--c-teal);
}
.pg__title {
flex: 1;
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
font-size: 13px;
font-weight: 500;
color: var(--text3);
}
.pg--active .pg__title {
color: var(--text);
font-weight: 600;
}
.pg__words {
flex-shrink: 0;
font-family: var(--font-mono);
font-size: 10px;
color: var(--faint);
}
.src__hint {
padding: 8px 10px;
font-size: 12.5px;
color: var(--muted2);
}
.src__hint--error { color: var(--c-coral); }
.src__error {
margin: 2px 10px 6px 44px;
padding: 8px 10px;
font-size: 12px;
line-height: 1.45;
color: var(--c-coral);
background: var(--coral-bg);
border: 1px solid var(--coral-border);
border-radius: 8px;
}
.tree__empty {
padding: 40px 20px;
text-align: center;
font-size: 13px;
color: var(--muted2);
}
</style>