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([])
})
})