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,71 @@
/*
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.
*/
import { describe, it, expect } from 'vitest'
import { mount } from '@vue/test-utils'
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
const mountModal = async (url: string) => {
const wrapper = mount(KnowledgeAddSourceModal)
await wrapper.find('#kb-add-url').setValue(url)
return wrapper
}
const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
wrapper.findAll('.scope__title').map((node) => node.text())
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
describe('KnowledgeAddSourceModal crawl scope', () => {
it('offers no section option for a homepage URL', async () => {
// "Pages under / " is not a section — it is the whole site, which the next
// option already covers.
const wrapper = await mountModal('https://paywithatoa.co.uk')
expect(scopeTitles(wrapper)).toEqual(['仅此页面', '整个站点', '整个域名'])
expect(scopeOption(wrapper, '整个站点')!.text()).toContain('paywithatoa.co.uk 上的所有页面')
})
it('names the section when the URL has a path', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
expect(scopeTitles(wrapper)).toContain('本栏目')
expect(scopeOption(wrapper, '本栏目')!.text()).toContain(
'/hc/en/ 下的页面',
)
})
it('falls back to this-site when the section option disappears', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
await wrapper.find('button.btn--primary').trigger('click')
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
type: 'website',
url: 'https://help.example.com',
scope: 'host',
})
})
it('submits the selected scope', async () => {
const wrapper = await mountModal('https://help.example.com/hc/en')
await scopeOption(wrapper, '本栏目')!.trigger('click')
await wrapper.find('button.btn--primary').trigger('click')
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
})
})
@@ -0,0 +1,116 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { setActivePinia, createPinia } from 'pinia'
vi.mock('@/services/knowledge', () => ({
knowledgeService: {
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
getKnowledgeByOrganization: vi
.fn()
.mockResolvedValue({ knowledge: [], pagination: { total_pages: 0 } }),
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] })
}
}))
import { useKnowledgeManagement } from '@/composables/useKnowledgeManagement'
const setup = () => useKnowledgeManagement('agent-1', 'org-1')
describe('useKnowledgeManagement - URL handling', () => {
beforeEach(() => {
setActivePinia(createPinia())
vi.clearAllMocks()
})
describe('withScheme', () => {
const { withScheme } = setup()
it.each([
['docs.company.com', 'https://docs.company.com'],
['company.co.uk', 'https://company.co.uk'],
[' spaced.com ', 'https://spaced.com'],
['//protocol-relative.com', 'https://protocol-relative.com']
])('defaults the scheme: %s -> %s', (input, expected) => {
expect(withScheme(input)).toBe(expected)
})
it.each([
'https://already.com',
'http://plain.com',
'HTTPS://shouty.com'
])('leaves an existing scheme alone: %s', (input) => {
expect(withScheme(input)).toBe(input)
})
it('leaves an empty value empty', () => {
expect(withScheme(' ')).toBe('')
})
})
describe('isValidUrl', () => {
const { isValidUrl } = setup()
it.each(['https://docs.company.com', 'http://a.co', 'https://x.com/path?q=1'])(
'accepts %s',
(url) => expect(isValidUrl(url)).toBe(true)
)
it.each(['mailto:someone@x.com', 'foo:bar', 'not a url', 'https://'])(
'rejects %s',
(url) => expect(isValidUrl(url)).toBe(false)
)
it.each(['https://localhost', 'https://wiki', 'http://intranet:8080'])(
'accepts the intranet hostnames a self-hosted install indexes: %s',
(url) => expect(isValidUrl(url)).toBe(true)
)
})
describe('handleUrlAdd', () => {
it('stages a scheme-less URL instead of rejecting it', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'docs.company.com'
handleUrlAdd()
expect(urls.value).toEqual(['https://docs.company.com'])
expect(urlFormError.value).toBeNull()
expect(newUrl.value).toBe('')
})
it('still rejects input that is not a URL at all', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'not a url'
handleUrlAdd()
expect(urls.value).toEqual([])
expect(urlFormError.value).toBe('请输入有效的 URL')
})
it('clears a previous error when a later attempt succeeds', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'not a url'
handleUrlAdd()
expect(urlFormError.value).toBe('请输入有效的 URL')
newUrl.value = 'docs.company.com'
handleUrlAdd()
expect(urlFormError.value).toBeNull()
expect(urls.value).toEqual(['https://docs.company.com'])
})
it('does not stage the same URL twice', () => {
const { newUrl, urls, urlFormError, handleUrlAdd } = setup()
newUrl.value = 'docs.company.com'
handleUrlAdd()
newUrl.value = 'https://docs.company.com'
handleUrlAdd()
expect(urls.value).toEqual(['https://docs.company.com'])
expect(urlFormError.value).toBe('该 URL 已添加到当前批次')
})
})
})
@@ -0,0 +1,55 @@
import { describe, it, expect, vi, beforeEach } from 'vitest'
import { ref } from 'vue'
const planLimits = vi.fn()
vi.mock('@/utils/storage', () => ({
subscriptionStorage: { getPlanLimits: () => planLimits() }
}))
import { useStagedSourceCap } from '@/composables/useStagedSourceCap'
const file = (name: string) => new File(['x'], name, { type: 'application/pdf' })
const setup = (cap: number, indexed: number, urls: string[], files: File[]) => {
planLimits.mockReturnValue({ maxKnowledgeSources: cap })
const u = ref(urls)
const f = ref(files)
return { u, f, ...useStagedSourceCap(ref(indexed), u, f) }
}
describe('useStagedSourceCap', () => {
beforeEach(() => planLimits.mockReset())
it('does nothing when the plan sets no cap', () => {
const { u, f, fitToCap } = setup(0, 3, ['a', 'b'], [file('x.pdf')])
expect(fitToCap('urls')).toBeNull()
expect(u.value).toEqual(['a', 'b'])
expect(f.value).toHaveLength(1)
})
it('drops staged files before older URLs, oldest first', () => {
const { u, f, fitToCap } = setup(2, 0, ['a', 'b'], [file('x.pdf')])
expect(fitToCap('urls')).toBeNull()
expect(f.value).toEqual([])
expect(u.value).toEqual(['a', 'b'])
})
it('drops the oldest URL when only URLs are staged', () => {
const { u, fitToCap } = setup(2, 0, ['a', 'b', 'c'], [])
expect(fitToCap('urls')).toBeNull()
expect(u.value).toEqual(['b', 'c'])
})
it('unstages new files that cannot fit and says how many can', () => {
const { u, f, fitToCap } = setup(3, 1, ['a'], [file('x.pdf'), file('y.pdf'), file('z.pdf')])
expect(fitToCap('files', 3)).toBe('你的套餐包含 3 个知识库来源,最多可选 2 个。')
expect(f.value).toEqual([])
expect(u.value).toEqual(['a'])
})
it('reports a cap already filled by indexed sources', () => {
const { u, fitToCap } = setup(1, 1, ['a'], [])
expect(fitToCap('urls')).toBe('你的套餐包含 1 个知识库来源,且已在用。')
expect(u.value).toEqual([])
})
})
@@ -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>
@@ -0,0 +1,669 @@
/*
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.
*/
import { computed, ref } from 'vue'
import { toast } from 'vue-sonner'
import { showPlanLimitError } from '@/utils/planLimitToast'
import type {
KnowledgeItem,
KnowledgeLinkedAgent,
KnowledgePage,
KnowledgeSubPage,
QueueItem,
} from '@/types/knowledge'
import { knowledgeService } from '@/services/knowledge'
import { basePageId, groupChunksIntoPages, titleFromId } from '@/utils/knowledgePages'
export type ExplorerMode = 'agent' | 'org'
export type SourceStatus = 'queued' | 'crawling' | 'synced' | 'error'
// How far a website crawl may follow links from the URL it starts at.
// 'page' indexes only that URL; the rest map to the backend's crawl scopes.
export type CrawlScope = 'page' | 'path' | 'host' | 'domain'
// What the add-source modal submits; discriminated by `type`.
export type AddSourcePayload =
| { type: 'website'; url: string; scope: CrawlScope }
| { type: 'sitemap'; url: string }
| { type: 'pdf'; files: File[] }
| { type: 'text'; title: string; content: string }
/** A knowledge source enriched with lazily-loaded, grouped sub-page content. */
export interface ExplorerSource {
id: number
name: string
type: string
// Agents this source is linked to (for the "Used by" chips).
agents: KnowledgeLinkedAgent[]
pageStubs: KnowledgePage[]
expanded: boolean
loadingContent: boolean
contentError: string | null
pages: KnowledgeSubPage[] | null
// True for a placeholder built from an in-flight queue item that has not yet
// produced a real Knowledge source (id is the negated queue-item id).
queued?: boolean
// Status carried from the queue item for a placeholder (its display name may
// differ from the queue `source`, so it can't be re-derived by name lookup).
queuedStatus?: SourceStatus
// Failure reason for a failed queue item (e.g. blocked by bot protection).
queuedError?: string | null
}
const POLL_INTERVAL_MS = 10000
/**
* State engine for the master-detail knowledge explorer, shared by the
* agent-editor tab (`mode: 'agent'`) and the dedicated knowledge page
* (`mode: 'org'`). Source list is fetched eagerly; each source's sub-page
* content is grouped from its chunks lazily on first expand/selection.
*/
export function useKnowledgeExplorer(
mode: ExplorerMode,
agentId: string | undefined,
organizationId: string,
) {
const sources = ref<ExplorerSource[]>([])
const queueItems = ref<QueueItem[]>([])
const isLoading = ref(false)
const error = ref<string | null>(null)
const query = ref('')
const selectedSourceId = ref<number | null>(null)
const selectedPageId = ref<string | null>(null)
const editing = ref(false)
const isAddingPage = ref(false)
const draftTitle = ref('')
const draftContent = ref('')
const draftUrl = ref('')
const isSaving = ref(false)
const isDeleting = ref(false)
let pollTimer: ReturnType<typeof setInterval> | null = null
// Source names that were crawling on the previous poll tick, so we can force a
// final content refresh once a crawl finishes.
let crawlingNames = new Set<string>()
// Queue-item ids seen on the previous tick, so we detect a crawl finishing
// (an item leaving the queue) even if it was never observed as active.
let lastQueueIds = new Set<number>()
const queueIds = () => new Set(queueItems.value.map((q) => q.id))
const activeCrawlNames = () =>
new Set(
queueItems.value
.filter((q) => q.status === 'pending' || q.status === 'processing')
.map((q) => q.source),
)
const setsDiffer = (a: Set<number>, b: Set<number>) =>
a.size !== b.size || [...a].some((id) => !b.has(id))
const findSource = (id: number) => sources.value.find((s) => s.id === id)
const toExplorerSource = (item: KnowledgeItem): ExplorerSource => ({
id: item.id,
name: item.name,
type: item.type,
agents: item.agents || [],
pageStubs: item.pages || [],
expanded: false,
loadingContent: false,
contentError: null,
pages: null,
})
// `background` refetches (from the crawl poll) skip the loading flag so any
// bound spinner doesn't flicker every tick.
const fetchSources = async (background = false) => {
if (!background) isLoading.value = true
error.value = null
try {
const response =
mode === 'agent' && agentId
? await knowledgeService.getKnowledgeByAgent(agentId, 1, 100)
: await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
const items: KnowledgeItem[] = response.knowledge || []
// Preserve expand/loaded state across refreshes so a running crawl poll
// does not collapse the tree or drop already-fetched content.
const previous = new Map(sources.value.map((s) => [s.id, s]))
sources.value = items.map((item) => {
const prev = previous.get(item.id)
const next = toExplorerSource(item)
if (prev) {
next.expanded = prev.expanded
next.pages = prev.pages
}
return next
})
} catch (err) {
console.error('Failed to load knowledge sources:', err)
error.value = '加载知识库来源失败'
} finally {
if (!background) isLoading.value = false
}
}
const fetchQueue = async () => {
try {
const response =
mode === 'agent' && agentId
? await knowledgeService.getAgentQueueItems(agentId)
: await knowledgeService.getOrgQueueItems(organizationId)
queueItems.value = response.queue_items || []
} catch (err) {
console.error('Failed to load knowledge queue:', err)
}
}
// A queue item's source_type ('website' | 'pdf_url' | 'pdf_file') mapped to the
// tree's source-kind glyph.
const queueKind = (sourceType: string): string => {
if (sourceType.includes('pdf')) return 'pdf'
if (sourceType.includes('site')) return 'sitemap'
if (sourceType.includes('web')) return 'web'
return 'custom'
}
// A queue item's `source` is a URL for websites but an internal temp path or
// S3 signed URL for PDFs — show a readable filename for the placeholder.
const queueSourceName = (item: QueueItem): string => {
if (!item.source_type.includes('pdf')) return item.source
const path = item.source.split(/[?#]/)[0]
const base = decodeURIComponent(path.slice(path.lastIndexOf('/') + 1))
.replace(/\.[^.]+$/, '') // drop extension
.replace(/^[0-9a-f-]{8,}[_-]/i, '') // drop a leading uuid_ prefix
return base || item.source
}
const queueStatusToSource = (status: string): SourceStatus =>
status === 'failed' ? 'error' : status === 'processing' ? 'crawling' : 'queued'
// Placeholder source for an in-flight queue item (negative id = queue-item id).
const toSyntheticSource = (item: QueueItem): ExplorerSource => ({
id: -item.id,
name: queueSourceName(item),
type: queueKind(item.source_type),
agents: [],
pageStubs: [],
expanded: false,
loadingContent: false,
contentError: null,
pages: [],
queued: true,
queuedStatus: queueStatusToSource(item.status),
queuedError: item.status === 'failed' ? item.error ?? null : null,
})
// Real sources plus a placeholder for every in-flight queue item that has not
// yet produced a real source — so a just-queued crawl shows immediately and is
// replaced by the real source once indexing completes and the poll refetches.
const allSources = computed<ExplorerSource[]>(() => {
const realNames = new Set(sources.value.map((s) => s.name))
const seen = new Set<string>()
const placeholders: ExplorerSource[] = []
for (const item of queueItems.value) {
if (realNames.has(item.source) || seen.has(item.source)) continue
seen.add(item.source)
placeholders.push(toSyntheticSource(item))
}
return [...placeholders, ...sources.value]
})
// Resolve the source by id on every mutation. A concurrent (poll-driven)
// fetchSources() replaces the objects in `sources.value`, so writing to the
// originally-captured `source` would be lost — always re-find the live one.
const loadSourceContent = async (sourceId: number, force = false) => {
const source = findSource(sourceId)
if (!source || source.loadingContent) return
if (source.pages && !force) return
source.loadingContent = true
source.contentError = null
try {
const content = await knowledgeService.getKnowledgeContent(sourceId)
const pages = groupChunksIntoPages(content.chunks || [])
const live = findSource(sourceId)
if (live) live.pages = pages
} catch (err) {
console.error(`Failed to load content for source ${sourceId}:`, err)
const live = findSource(sourceId)
if (live) {
live.contentError = '加载页面失败'
live.pages = []
}
} finally {
const live = findSource(sourceId)
if (live) live.loadingContent = false
}
}
const toggleSource = async (source: ExplorerSource) => {
source.expanded = !source.expanded
if (source.expanded && source.pages === null) {
await loadSourceContent(source.id)
}
}
const selectPage = async (source: ExplorerSource, pageId: string) => {
selectedSourceId.value = source.id
selectedPageId.value = pageId
editing.value = false
isAddingPage.value = false
if (source.pages === null) {
await loadSourceContent(source.id)
}
}
const selectedSource = computed(
() => sources.value.find((s) => s.id === selectedSourceId.value) ?? null,
)
const selectedPage = computed<KnowledgeSubPage | null>(() => {
const source = selectedSource.value
if (!source || !source.pages || selectedPageId.value === null) return null
return source.pages.find((p) => p.page_id === selectedPageId.value) ?? null
})
const sourceStatus = (source: ExplorerSource): SourceStatus => {
// Placeholders carry their own status (their display name differs from the
// queue source, so a name lookup wouldn't find the item).
if (source.queued) return source.queuedStatus ?? 'queued'
const item = queueItems.value.find((q) => q.source === source.name)
if (item?.status === 'failed') return 'error'
if (item?.status === 'processing') return 'crawling'
if (item?.status === 'pending') return 'queued'
return 'synced'
}
// Page rows for the tree: grouped pages once loaded, else lightweight stubs.
const pageRows = (source: ExplorerSource) => {
if (source.pages) {
return source.pages.map((p) => ({
page_id: p.page_id,
title: p.title,
words: p.word_count,
}))
}
return source.pageStubs.map((stub: KnowledgePage) => ({
page_id: stub.subpage,
title: titleFromId(stub.subpage),
words: null as number | null,
}))
}
const normalizedQuery = computed(() => query.value.trim().toLowerCase())
const filteredSources = computed(() => {
const q = normalizedQuery.value
if (!q) return allSources.value
return allSources.value.filter((source) => {
if (source.name.toLowerCase().includes(q)) return true
const rows = pageRows(source)
return rows.some((r) => r.title.toLowerCase().includes(q) || r.page_id.toLowerCase().includes(q))
})
})
const startEdit = () => {
const page = selectedPage.value
if (!page) return
draftTitle.value = page.title
draftContent.value = page.content
isAddingPage.value = false
editing.value = true
}
// Open the editor with empty fields to add a new sub-page to `source`.
const startAddPage = async (source: ExplorerSource) => {
if (source.queued) return
selectedSourceId.value = source.id
selectedPageId.value = null
draftTitle.value = ''
draftContent.value = ''
draftUrl.value = ''
isAddingPage.value = true
editing.value = true
source.expanded = true
if (source.pages === null) await loadSourceContent(source.id)
}
const cancelEdit = () => {
editing.value = false
isAddingPage.value = false
}
const addNewPage = async (source: ExplorerSource) => {
const title = draftTitle.value.trim()
const content = draftContent.value.trim()
if (!title) {
error.value = '请为子页面填写标题'
toast.error('请为子页面填写标题')
return
}
if (!content) {
error.value = '子页面内容不能为空'
return
}
isSaving.value = true
error.value = null
try {
await knowledgeService.addSubpage(source.id, title, content, draftUrl.value.trim() || undefined)
await loadSourceContent(source.id, true)
selectedPageId.value = basePageId(title)
isAddingPage.value = false
editing.value = false
toast.success('子页面已添加')
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : '添加子页面失败'
console.error('Failed to add sub-page:', err)
error.value = msg
void showPlanLimitError('无法添加子页面', msg)
} finally {
isSaving.value = false
}
}
const savePage = async () => {
const source = selectedSource.value
if (!source) return
if (isAddingPage.value) return addNewPage(source)
const page = selectedPage.value
if (!page) return
const content = draftContent.value.trim()
if (!content) {
error.value = '页面内容不能为空'
return
}
isSaving.value = true
error.value = null
try {
await knowledgeService.updatePage(source.id, page.page_id, content, draftTitle.value.trim())
await loadSourceContent(source.id, true)
editing.value = false
} catch (err: unknown) {
console.error('Failed to save page:', err)
error.value = err instanceof Error ? err.message : '保存页面失败'
} finally {
isSaving.value = false
}
}
const deletePage = async () => {
const source = selectedSource.value
const page = selectedPage.value
if (!source || !page) return
isDeleting.value = true
error.value = null
try {
await knowledgeService.deletePage(source.id, page.page_id)
selectedPageId.value = null
await loadSourceContent(source.id, true)
} catch (err: unknown) {
console.error('Failed to delete page:', err)
error.value = err instanceof Error ? err.message : '删除页面失败'
} finally {
isDeleting.value = false
}
}
const deleteSource = async (source: ExplorerSource) => {
isDeleting.value = true
error.value = null
try {
// A queued placeholder isn't a real source yet — cancel its queue item.
if (source.queued) {
await knowledgeService.deleteQueueItem(-source.id)
} else {
await knowledgeService.deleteKnowledge(source.id)
}
if (selectedSourceId.value === source.id) {
selectedSourceId.value = null
selectedPageId.value = null
}
await Promise.all([fetchSources(), fetchQueue()])
} catch (err: unknown) {
console.error('Failed to delete source:', err)
error.value = err instanceof Error ? err.message : '删除来源失败'
} finally {
isDeleting.value = false
}
}
// ---- Link existing org-level knowledge to this agent (agent mode only) ----
// Org sources are created with agent_id=NULL, so they never auto-link to an
// agent; this picker restores the ability to attach them from the agent tab.
const linkPickerOpen = ref(false)
const orgSources = ref<KnowledgeItem[]>([])
const isLoadingOrgSources = ref(false)
const orgSourcesError = ref<string | null>(null)
// Knowledge ids with a link/unlink request in flight (per-row busy state).
const linkingIds = ref<Set<number>>(new Set())
// Ids already linked to this agent — drives the Link/Unlink toggle per row.
const linkedSourceIds = computed(() => new Set(sources.value.map((s) => s.id)))
const loadOrgSources = async () => {
isLoadingOrgSources.value = true
orgSourcesError.value = null
try {
const response = await knowledgeService.getKnowledgeByOrganization(organizationId, 1, 100)
orgSources.value = response.knowledge || []
} catch (err) {
console.error('Failed to load organization knowledge:', err)
orgSourcesError.value = '加载组织知识库失败'
} finally {
isLoadingOrgSources.value = false
}
}
const openLinkPicker = async () => {
if (mode !== 'agent' || !agentId) return
linkPickerOpen.value = true
await loadOrgSources()
}
// Shared link/unlink runner: guards concurrent clicks per row, keeps the
// busy-state Set in sync, refetches the agent's linked sources, and toasts.
const runLinkOp = async (
knowledgeId: number,
op: (id: number, agentId: string) => Promise<unknown>,
{ successMsg, failMsg, onSuccess }: {
successMsg: string
failMsg: string
onSuccess?: () => void
},
) => {
if (mode !== 'agent' || !agentId || linkingIds.value.has(knowledgeId)) return
linkingIds.value.add(knowledgeId)
try {
await op(knowledgeId, agentId)
onSuccess?.()
await fetchSources()
toast.success(successMsg)
} catch (err: unknown) {
const msg = err instanceof Error ? err.message : failMsg
console.error(`${failMsg}:`, err)
toast.error(failMsg, { description: msg })
} finally {
linkingIds.value.delete(knowledgeId)
}
}
const linkSource = (knowledgeId: number) =>
runLinkOp(knowledgeId, (id, aid) => knowledgeService.linkToAgent(id, aid), {
successMsg: '知识已关联到此客服',
failMsg: '无法关联知识',
})
const unlinkSource = (knowledgeId: number) =>
runLinkOp(knowledgeId, (id, aid) => knowledgeService.unlinkFromAgent(id, aid), {
successMsg: '知识已从此客服移除',
failMsg: '无法移除知识',
// Clear the detail pane if the removed source was selected.
onSuccess: () => {
if (selectedSourceId.value === knowledgeId) {
selectedSourceId.value = null
selectedPageId.value = null
}
},
})
const linkedAgentId = mode === 'agent' ? agentId : undefined
// Handle add/urls responses that report duplicates instead of throwing.
const throwIfError = (res: { error?: string } | undefined) => {
if (res?.error) throw new Error(res.error)
}
// Submit a new source from the add-source modal. Resolves true on success.
// Crawl sources (website/sitemap/pdf) are queued and indexed in the
// background; text is indexed immediately. Toasts announce the outcome.
const submitSource = async (payload: AddSourcePayload): Promise<boolean> => {
error.value = null
try {
if (payload.type === 'website') {
throwIfError(
await knowledgeService.addUrls(
organizationId,
[payload.url],
linkedAgentId,
undefined,
// "This page only" is a one-page cap; the others are crawl scopes.
payload.scope === 'page' ? 1 : undefined,
payload.scope === 'page' ? undefined : payload.scope,
),
)
toast.success('已加入抓取队列', {
description: `${payload.url},索引完成后我们会通知你。`,
})
} else if (payload.type === 'sitemap') {
throwIfError(await knowledgeService.addSitemap(organizationId, payload.url, linkedAgentId))
toast.success('已加入抓取队列', {
description: `${payload.url},正在从站点地图中发现页面。`,
})
} else if (payload.type === 'pdf') {
await knowledgeService.uploadPdfFiles(payload.files, organizationId, linkedAgentId)
toast.success('已加入抓取队列', {
description: '文档正在解析和索引中。',
})
} else {
await knowledgeService.addText(organizationId, payload.title, payload.content, linkedAgentId)
toast.success('页面已添加', {
description: `${payload.title} 已加入知识库。`,
})
}
await Promise.all([fetchSources(), fetchQueue()])
return true
} catch (err: unknown) {
// Surface add-source failures via toast only (the inline error banner is
// reserved for the read/edit pane), so the user doesn't see it twice.
const msg = err instanceof Error ? err.message : '添加来源失败'
console.error('Failed to add source:', err)
// Plan caps get the offer toast when one applies; other failures the plain one
void showPlanLimitError('无法添加来源', msg)
return false
}
}
const refresh = async () => {
await Promise.all([fetchSources(), fetchQueue()])
// Baseline the queue snapshot so the first poll can detect a crawl that
// finishes before it is ever observed as active.
lastQueueIds = queueIds()
crawlingNames = activeCrawlNames()
}
const startPolling = () => {
if (pollTimer) return
pollTimer = setInterval(async () => {
await fetchQueue()
const active = activeCrawlNames()
const currentIds = queueIds()
// Reconcile when a crawl is in flight OR the queue changed since the last
// tick (an item was added or finished/removed) — the latter catches a
// crawl that completed within a single poll window.
if (active.size || setsDiffer(currentIds, lastQueueIds)) {
await fetchSources(true)
// Force-refresh the content of any expanded source that is (or just was)
// crawling, so newly-extracted pages appear without a manual re-expand.
const touched = new Set<string>([...active, ...crawlingNames])
for (const source of sources.value) {
if (source.expanded && touched.has(source.name)) {
await loadSourceContent(source.id, true)
}
}
}
crawlingNames = active
lastQueueIds = currentIds
}, POLL_INTERVAL_MS)
}
const stopPolling = () => {
if (pollTimer) {
clearInterval(pollTimer)
pollTimer = null
}
}
return {
// state
sources,
queueItems,
isLoading,
error,
query,
selectedSourceId,
selectedPageId,
editing,
isAddingPage,
draftTitle,
draftContent,
draftUrl,
isSaving,
isDeleting,
linkPickerOpen,
orgSources,
isLoadingOrgSources,
orgSourcesError,
linkingIds,
// computed
selectedSource,
selectedPage,
filteredSources,
linkedSourceIds,
// helpers for the view
sourceStatus,
pageRows,
// actions
fetchSources,
fetchQueue,
refresh,
toggleSource,
selectPage,
startEdit,
startAddPage,
cancelEdit,
savePage,
deletePage,
deleteSource,
submitSource,
openLinkPicker,
linkSource,
unlinkSource,
startPolling,
stopPolling,
}
}
@@ -0,0 +1,506 @@
/*
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.
*/
import { ref } from 'vue'
import type { KnowledgeItem, KnowledgePage, QueueItem, KnowledgeContent } from '@/types/knowledge'
import { knowledgeService } from '@/services/knowledge'
export function useKnowledgeManagement(agentId: string, organizationId: string) {
// Knowledge list state
const knowledgeItems = ref<KnowledgeItem[]>([])
const currentPage = ref(1)
const pageSize = ref(10)
const totalPages = ref(0)
const isLoading = ref(false)
const error = ref<string | null>(null)
// Queue state
const queueItems = ref<QueueItem[]>([])
const isLoadingQueue = ref(false)
// Content viewing/editing state
const selectedKnowledge = ref<number | null>(null)
const knowledgeContent = ref<KnowledgeContent | null>(null)
const isLoadingContent = ref(false)
const isEditingContent = ref(false)
const editedContent = ref('')
const isSavingContent = ref(false)
const showContentModal = ref(false)
// Modal and upload state
const showKnowledgeModal = ref(false)
const activeTab = ref('pdf')
const files = ref<File[]>([])
const urls = ref<string[]>([])
const newUrl = ref('')
const isUploading = ref(false)
const uploadProgress = ref(0)
const successMessage = ref('')
const fileInput = ref<HTMLInputElement | null>(null)
// Link state
const showLinkModal = ref(false)
const orgKnowledgeItems = ref<KnowledgeItem[]>([])
const orgCurrentPage = ref(1)
const orgTotalPages = ref(0)
// Every source the org has indexed (all pages), for plan-cap checks.
const orgTotalCount = ref(0)
const isLoadingOrg = ref(false)
// Delete state
const showDeleteConfirm = ref(false)
const knowledgeToDelete = ref<number | null>(null)
// Add new state for URL form errors
const urlFormError = ref<string | null>(null)
// Add upload error state
const uploadError = ref<string | null>(null)
// Fetch knowledge data
const fetchKnowledge = async () => {
try {
isLoading.value = true
isLoadingOrg.value = true
error.value = null
// Fetch agent knowledge, org knowledge, and queue items in parallel
const [agentResponse, orgResponse, queueResponse] = await Promise.all([
knowledgeService.getKnowledgeByAgent(agentId, currentPage.value, pageSize.value),
knowledgeService.getKnowledgeByOrganization(
organizationId,
orgCurrentPage.value,
pageSize.value,
),
fetchQueueItems(),
])
// Default to empty: a partial response used to leave these undefined,
// and every later .some()/.length on them threw.
knowledgeItems.value = agentResponse?.knowledge || []
totalPages.value = agentResponse?.pagination?.total_pages || 0
// Update org knowledge
orgKnowledgeItems.value = orgResponse?.knowledge || []
orgTotalPages.value = orgResponse?.pagination?.total_pages || 0
orgTotalCount.value = orgResponse?.pagination?.total_count || 0
} catch (err) {
error.value = '加载知识库来源失败'
console.error(err)
} finally {
isLoading.value = false
isLoadingOrg.value = false
}
}
// Fetch queue items
const fetchQueueItems = async () => {
try {
isLoadingQueue.value = true
const response = await knowledgeService.getAgentQueueItems(agentId)
queueItems.value = response.queue_items || []
return response
} catch (err) {
console.error('Failed to load queue items:', err)
queueItems.value = []
return { queue_items: [] }
} finally {
isLoadingQueue.value = false
}
}
const deleteQueueItem = async (queueId: number) => {
try {
await knowledgeService.deleteQueueItem(queueId)
await fetchQueueItems() // Refresh queue
} catch (err) {
console.error('Failed to delete queue item:', err)
error.value = '删除队列项失败'
}
}
// Pagination handler
const handlePageChange = (page: number) => {
currentPage.value = page
fetchKnowledge()
}
// Date formatting
const formatDate = (dateString: string | null): string => {
if (!dateString) return 'N/A'
return new Date(dateString).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
// Get earliest creation date
const getFirstCreated = (pages: KnowledgePage[]): string | null => {
return pages.reduce(
(earliest, page) => {
if (!page.created_at) return earliest
if (!earliest) return page.created_at
return page.created_at < earliest ? page.created_at : earliest
},
null as string | null,
)
}
// People type "docs.company.com", not "https://docs.company.com". Without a
// scheme new URL() throws and they get "Please enter a valid URL" for an
// address that is perfectly fine, so default the scheme before validating.
const withScheme = (url: string): string => {
const trimmed = url.trim()
if (!trimmed) return trimmed
if (/^[a-z][a-z0-9+.-]*:\/\//i.test(trimmed)) return trimmed
// Leading slashes would produce https:////host — drop them first.
return `https://${trimmed.replace(/^\/+/, '')}`
}
// URL validation
const isValidUrl = (url: string): boolean => {
try {
const parsed = new URL(url)
// new URL() accepts things like "mailto:x" and "foo:bar"; a crawlable
// source has to be http(s). Deliberately not requiring a dot in the host:
// self-hosted installs index intranet names like https://wiki.
return /^https?:$/.test(parsed.protocol) && !!parsed.hostname
} catch {
return false
}
}
// File handling methods
const triggerFileInput = () => {
fileInput.value?.click()
}
const handleFileSelect = (event: Event) => {
const input = event.target as HTMLInputElement
if (input.files) {
files.value = Array.from(input.files)
}
}
// Resolves true when the batch was accepted (or there was nothing to send),
// so a caller like the onboarding wizard can stop instead of moving on.
const handleFileUpload = async (): Promise<boolean> => {
if (!files.value.length) return true
try {
isUploading.value = true
uploadError.value = null // Clear previous errors
const response = await knowledgeService.uploadPdfFiles(
files.value,
organizationId,
agentId,
(progress) => {
uploadProgress.value = progress
},
)
if (response.message) {
successMessage.value = response.message
await fetchKnowledge() // Refresh knowledge list
}
files.value = []
if (fileInput.value) fileInput.value.value = ''
return true
} catch (error: any) {
console.error('Upload failed:', error)
uploadError.value = error.message || '上传文件失败'
return false
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
// URL handling methods
const handleUrlAdd = () => {
if (!newUrl.value) return
// Start clean so the outcome of this attempt is the only thing on screen,
// and so callers can read urlFormError to tell whether it succeeded.
urlFormError.value = null
// Clean the URL, defaulting the scheme when the user left it off
const cleanUrl = withScheme(newUrl.value)
// Basic URL validation
if (!isValidUrl(cleanUrl)) {
urlFormError.value = '请输入有效的 URL'
return
}
// Check if URL already exists in knowledgeItems or orgKnowledgeItems
const urlExistsInAgent = knowledgeItems.value.some((item) => item.name === cleanUrl)
const urlExistsInOrg = orgKnowledgeItems.value.some((item) => item.name === cleanUrl)
if (urlExistsInAgent || urlExistsInOrg) {
urlFormError.value = '该 URL 已存在于你的知识库中'
newUrl.value = ''
return
}
// Check if URL is already in the current batch
if (urls.value.includes(cleanUrl)) {
urlFormError.value = '该 URL 已添加到当前批次'
newUrl.value = ''
return
}
// Add URL to batch
urls.value.push(cleanUrl)
newUrl.value = ''
urlFormError.value = null
}
const removeUrl = (index: number) => {
urls.value.splice(index, 1)
}
// Same contract as handleFileUpload: true when the batch was accepted.
const handleUrlUpload = async (): Promise<boolean> => {
if (!urls.value.length) return true
try {
isUploading.value = true
uploadError.value = null // Clear previous errors
const response = await knowledgeService.addUrls(
organizationId,
urls.value,
agentId,
(progress) => {
uploadProgress.value = progress
},
)
// A duplicate comes back as a 200 with `error` and nothing queued; keep
// the batch staged so the user sees which URL to drop.
if (response.error) {
uploadError.value = response.error
return false
}
if (response.message) {
successMessage.value = response.message
await fetchKnowledge() // Refresh knowledge list
}
urls.value = []
return true
} catch (error: any) {
console.error('URL upload failed:', error)
uploadError.value = error.message || '上传 URL 失败'
return false
} finally {
isUploading.value = false
uploadProgress.value = 0
}
}
const fetchOrgKnowledge = async () => {
try {
isLoadingOrg.value = true
const response = await knowledgeService.getKnowledgeByOrganization(
organizationId,
orgCurrentPage.value,
pageSize.value,
)
orgKnowledgeItems.value = response.knowledge
orgTotalPages.value = response.pagination.total_pages
} catch (err) {
console.error(err)
} finally {
isLoadingOrg.value = false
}
}
const handleOrgPageChange = (page: number) => {
orgCurrentPage.value = page
fetchOrgKnowledge()
}
const linkKnowledge = async (knowledgeId: number) => {
try {
await knowledgeService.linkToAgent(knowledgeId, agentId)
await fetchKnowledge() // Refresh agent knowledge
} catch (error) {
console.error('Error linking knowledge:', error)
}
}
const unlinkKnowledge = async (knowledgeId: number) => {
try {
await knowledgeService.unlinkFromAgent(knowledgeId, agentId)
await fetchKnowledge() // Refresh agent knowledge
} catch (error) {
console.error('Error unlinking knowledge:', error)
}
}
// Delete methods
const confirmDelete = (knowledgeId: number) => {
knowledgeToDelete.value = knowledgeId
showDeleteConfirm.value = true
}
const handleDelete = async () => {
if (!knowledgeToDelete.value) return
try {
await knowledgeService.deleteKnowledge(knowledgeToDelete.value)
await fetchKnowledge() // Refresh the list
showDeleteConfirm.value = false
knowledgeToDelete.value = null
} catch (err) {
console.error('Error deleting knowledge:', err)
error.value = '删除知识来源失败'
}
}
const cancelDelete = () => {
showDeleteConfirm.value = false
knowledgeToDelete.value = null
}
// Content management methods
const viewKnowledgeContent = async (knowledgeId: number) => {
try {
selectedKnowledge.value = knowledgeId
isLoadingContent.value = true
showContentModal.value = true
const response = await knowledgeService.getKnowledgeContent(knowledgeId)
knowledgeContent.value = response
// Do not combine chunks - keep them separate for individual editing
editedContent.value = ''
} catch (err) {
console.error('Error loading knowledge content:', err)
error.value = '加载知识内容失败'
} finally {
isLoadingContent.value = false
}
}
const enableContentEditing = () => {
isEditingContent.value = true
}
const cancelContentEditing = () => {
isEditingContent.value = false
}
const saveChunkContent = async (chunkId: string, content: string) => {
if (!selectedKnowledge.value) return
try {
isSavingContent.value = true
await knowledgeService.updateChunkContent(selectedKnowledge.value, chunkId, content)
successMessage.value = '内容块已更新'
// Reload the content to show updated chunk
await viewKnowledgeContent(selectedKnowledge.value)
} catch (err: any) {
console.error('Error saving chunk content:', err)
error.value = err.message || '保存内容块失败'
} finally {
isSavingContent.value = false
}
}
const closeContentModal = () => {
showContentModal.value = false
selectedKnowledge.value = null
knowledgeContent.value = null
isEditingContent.value = false
editedContent.value = ''
}
return {
// State
knowledgeItems,
currentPage,
pageSize,
totalPages,
isLoading,
error,
showKnowledgeModal,
activeTab,
files,
urls,
newUrl,
isUploading,
uploadProgress,
successMessage,
fileInput,
showLinkModal,
orgKnowledgeItems,
orgCurrentPage,
orgTotalPages,
orgTotalCount,
isLoadingOrg,
showDeleteConfirm,
knowledgeToDelete,
urlFormError,
uploadError,
queueItems,
isLoadingQueue,
selectedKnowledge,
knowledgeContent,
isLoadingContent,
isEditingContent,
editedContent,
isSavingContent,
showContentModal,
// Methods
fetchKnowledge,
fetchQueueItems,
deleteQueueItem,
handlePageChange,
formatDate,
getFirstCreated,
isValidUrl,
withScheme,
triggerFileInput,
handleFileSelect,
handleFileUpload,
handleUrlAdd,
removeUrl,
handleUrlUpload,
fetchOrgKnowledge,
handleOrgPageChange,
linkKnowledge,
unlinkKnowledge,
confirmDelete,
handleDelete,
cancelDelete,
viewKnowledgeContent,
enableContentEditing,
cancelContentEditing,
saveChunkContent,
closeContentModal,
}
}
@@ -0,0 +1,69 @@
/*
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.
*/
import type { Ref } from 'vue'
import { subscriptionStorage } from '@/utils/storage'
/**
* Keeps a staged batch of knowledge sources within the plan's source cap.
*
* The server rejects a whole batch that would go over the cap, so staging one
* source too many loses all of them. On a one-source plan the onboarding wizard
* pre-stages the signup domain; a second add then swaps the staged source
* rather than queuing a batch that is bound to fail.
*
* `inUseCount` is every source already counting against the cap (indexed or
* still crawling), mirroring the server's count.
*
* A cap of 0 means the plan sets none we know of (self-hosted, or limits not
* loaded yet) and the server stays the only check.
*/
export function useStagedSourceCap(
inUseCount: Ref<number>,
urls: Ref<string[]>,
files: Ref<File[]>,
) {
// Read on every call: the subscription can land in storage after mount.
const cap = () => subscriptionStorage.getPlanLimits().maxKnowledgeSources
const room = () => cap() - inUseCount.value
const capMessage = (detail: string) =>
`你的套餐包含 ${cap()} 个知识库来源${detail}`
/**
* Call right after staging `added` sources onto the end of `newest`. Older
* staged sources (the other list first, then the front of `newest`) are
* dropped until the batch fits the cap. If the new sources can't fit on
* their own they are unstaged instead and the message to show is returned;
* null means the batch fits.
*/
const fitToCap = (newest: 'urls' | 'files', added = 1): string | null => {
if (cap() <= 0) return null
const latest: Ref<unknown[]> = newest === 'urls' ? urls : files
const other: Ref<unknown[]> = newest === 'urls' ? files : urls
if (added > room()) {
latest.value = latest.value.slice(0, -added)
return room() > 0 ? capMessage(`,最多可选 ${room()} 个。`) : capMessage(',且已在用。')
}
let overflow = latest.value.length + other.value.length - room()
const fromOther = Math.min(Math.max(overflow, 0), other.value.length)
other.value = other.value.slice(fromOther)
overflow -= fromOther
if (overflow > 0) latest.value = latest.value.slice(overflow)
return null
}
return { fitToCap }
}
+112
View File
@@ -0,0 +1,112 @@
/*
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.
*/
import type { KnowledgeContentChunk, KnowledgeSubPage } from '@/types/knowledge'
/** `<page>::<n>` — chunk ids written by app/knowledge/chunking.py. */
const CHUNK_ID_SUFFIX = /::\d+$/
/** `<page>_<n>` — chunk ids agno's PDF reader used to write. */
const LEGACY_CHUNK_ID_SUFFIX = /_\d+$/
/**
* Map a chunk id back to its page id by stripping the trailing chunk suffix:
* `<page>::<n>` (app/knowledge/chunking.py) or the legacy `<page>_<n>` that
* agno's PDF reader produced.
*
* Only a numeric suffix is stripped, so real underscores in a page name (e.g.
* `getting_started`) are preserved. Mirrors the backend `PAGE_ID_EXPR`
* (app/knowledge/page_editor.py) exactly, so the id derived here is the same
* `page_id` the backend expects for edit/delete and the `subpage` value it
* returns when listing sources — keep the two in sync.
*/
export function basePageId(id: string): string {
const chunked = id.replace(CHUNK_ID_SUFFIX, '')
if (chunked !== id) return chunked
return id.replace(LEGACY_CHUNK_ID_SUFFIX, '')
}
/**
* What goes between a chunk and the one before it when a page is rebuilt.
* `::n` chunks (app/knowledge/chunking.py) are verbatim slices of the page, so
* nothing: inserting a break would change the text that gets saved back.
* Legacy `_n` chunks were cut on whitespace that was dropped, so a paragraph
* break is the closest reconstruction.
*/
function chunkJoiner(chunkId: string): string {
return CHUNK_ID_SUFFIX.test(chunkId) ? '' : '\n\n'
}
function countWords(text: string): number {
const trimmed = text.trim()
return trimmed ? trimmed.split(/\s+/).length : 0
}
function laterIso(a: string | null, b: string | null): string | null {
if (!a) return b
if (!b) return a
return a > b ? a : b
}
/**
* Group a source's content chunks into sub-pages. Chunks are concatenated in
* their existing order (the backend returns them ordered by `created_at`), and
* page-level url/title/updated_at are taken from the chunk metadata.
*/
export function groupChunksIntoPages(chunks: KnowledgeContentChunk[]): KnowledgeSubPage[] {
const byPage = new Map<string, KnowledgeSubPage>()
const order: string[] = []
for (const chunk of chunks) {
const pageId = basePageId(chunk.id)
const meta = chunk.metadata || {}
const content = chunk.content || ''
let page = byPage.get(pageId)
if (!page) {
page = {
page_id: pageId,
url: typeof meta.url === 'string' && meta.url ? meta.url : pageId,
title: typeof meta.title === 'string' && meta.title ? meta.title : titleFromId(pageId),
content: '',
word_count: 0,
chunk_count: 0,
updated_at: chunk.created_at ?? null,
chunk_ids: [],
}
byPage.set(pageId, page)
order.push(pageId)
}
page.content = page.content ? `${page.content}${chunkJoiner(chunk.id)}${content}` : content
page.chunk_count += 1
page.chunk_ids.push(chunk.id)
page.updated_at = laterIso(page.updated_at, chunk.created_at ?? null)
}
for (const pageId of order) {
const page = byPage.get(pageId)!
page.word_count = countWords(page.content)
}
return order.map((pageId) => byPage.get(pageId)!)
}
/** Human-ish title fallback: last non-empty URL/path segment of the page id. */
export function titleFromId(pageId: string): string {
const cleaned = pageId.replace(/[#?].*$/, '').replace(/\/+$/, '')
const segment = cleaned.split('/').filter(Boolean).pop()
return segment || pageId
}
+51
View File
@@ -0,0 +1,51 @@
<!--
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 DashboardLayout from '@/layouts/DashboardLayout.vue'
import KnowledgeExplorer from '@/components/knowledge/KnowledgeExplorer.vue'
import { userService } from '@/services/user'
const organizationId = computed(() => userService.getCurrentUser()?.organization_id ?? '')
const description =
'这里汇集 AI 智能体学习的所有页面。你可以查看并编辑已提取的内容、添加自己的页面,' +
'并决定哪些内容保留在知识库中。'
</script>
<template>
<DashboardLayout>
<div class="knowledge-view">
<KnowledgeExplorer
v-if="organizationId"
mode="org"
variant="page"
title="知识库"
:description="description"
:organization-id="organizationId"
:show-plan-meters="true"
/>
</div>
</DashboardLayout>
</template>
<style scoped>
.knowledge-view {
max-width: 1240px;
margin: 0 auto;
padding: 32px 24px 60px;
}
</style>