feat(knowledge): 知识源维护与 TXT/Markdown/PDF 导入
支持文本、Markdown 与非扫描 PDF 上传解析,展示提取文本与切块结果。
This commit is contained in:
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([])
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user