feat(agent): 检索工具、模型接入与结构化回复
Agent 按组织与知识源范围调用检索工具,密钥加密保存,回复含来源与转人工意图。
This commit is contained in:
1 parent
c7011040c3
commit
2dd062787d
65 files changed
+33409
No files matched your search
@@ -0,0 +1,119 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
// The live/blocked status is an enterprise component (tested there): stub
|
||||
// the slot so this spec covers the open-source step only.
|
||||
vi.mock('@/composables/useEnterpriseFeatures', async () => {
|
||||
const { defineComponent, h } = await import('vue')
|
||||
const Slot = defineComponent({
|
||||
props: { widgetId: { type: String, default: null } },
|
||||
setup: (props) => () => h('p', { class: 'install-slot' }, props.widgetId),
|
||||
})
|
||||
return {
|
||||
useEnterpriseFeatures: () => ({
|
||||
moduleImports: { widgetInstallStatus: 'install-status' },
|
||||
enterpriseComponent: () => Slot,
|
||||
}),
|
||||
}
|
||||
})
|
||||
vi.mock('vue-sonner', () => ({ toast: { success: vi.fn(), error: vi.fn() } }))
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import StepLaunch from '@/components/aiagent/onboarding/steps/StepLaunch.vue'
|
||||
import { WIDGET_INSTALL_GUIDE_URL } from '@/config/docs'
|
||||
|
||||
const mountStep = async (widgetId: string | null = 'w-1') => {
|
||||
const wrapper = mount(StepLaunch, {
|
||||
props: { widgetId, agentType: 'CUSTOMER_SUPPORT' },
|
||||
global: { stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('StepLaunch', () => {
|
||||
it('offers a mailto with the real snippet and the install guide', async () => {
|
||||
const wrapper = await mountStep()
|
||||
|
||||
const mailto = wrapper.find('a[href^="mailto:"]').attributes('href')!
|
||||
const body = new URLSearchParams(mailto.slice('mailto:?'.length)).get('body')!
|
||||
expect(body).toContain("window.replypierId = 'w-1'")
|
||||
expect(body).toContain(WIDGET_INSTALL_GUIDE_URL)
|
||||
expect(mailto).not.toContain('+') // spaces encoded as %20 for mail clients
|
||||
expect(wrapper.find(`a[href="${WIDGET_INSTALL_GUIDE_URL}"]`).exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('hands the widget to the install status slot', async () => {
|
||||
const wrapper = await mountStep()
|
||||
expect(wrapper.find('.install-slot').text()).toBe('w-1')
|
||||
})
|
||||
|
||||
it('offers no mailto without a widget', async () => {
|
||||
const wrapper = await mountStep(null)
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepLaunch - copying and help', () => {
|
||||
const writeText = vi.fn()
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
writeText.mockReset().mockResolvedValue(undefined)
|
||||
Object.assign(navigator, { clipboard: { writeText } })
|
||||
vi.mocked(toast.error).mockReset()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
const copyButton = (w: any) => w.find('.copy-btn')
|
||||
|
||||
it('copies the snippet and says so on the button, then goes back', async () => {
|
||||
const wrapper = await mountStep()
|
||||
await copyButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(writeText).toHaveBeenCalledWith(expect.stringContaining("window.replypierId = 'w-1'"))
|
||||
expect(copyButton(wrapper).text()).toBe('已复制')
|
||||
await vi.advanceTimersByTimeAsync(2000)
|
||||
expect(copyButton(wrapper).text()).toBe('复制')
|
||||
})
|
||||
|
||||
it('a second copy keeps "Copied" showing for the full time again', async () => {
|
||||
const wrapper = await mountStep()
|
||||
await copyButton(wrapper).trigger('click')
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
await copyButton(wrapper).trigger('click')
|
||||
await vi.advanceTimersByTimeAsync(1500)
|
||||
expect(copyButton(wrapper).text()).toBe('已复制')
|
||||
})
|
||||
|
||||
it('a copy the browser refuses is reported, and the button stays "Copy"', async () => {
|
||||
writeText.mockRejectedValue(new Error('denied'))
|
||||
vi.spyOn(console, 'error').mockImplementation(() => {})
|
||||
const wrapper = await mountStep()
|
||||
await copyButton(wrapper).trigger('click')
|
||||
await flushPromises()
|
||||
expect(toast.error).toHaveBeenCalled()
|
||||
expect(copyButton(wrapper).text()).toBe('复制')
|
||||
})
|
||||
|
||||
it('without a widget there is nothing to copy or email', async () => {
|
||||
const wrapper = await mountStep(null)
|
||||
expect(copyButton(wrapper).attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('a[href^="mailto:"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('the install guide opens in a new tab and says so to screen readers', async () => {
|
||||
const wrapper = await mountStep()
|
||||
const guide = wrapper.find(`a[href="${WIDGET_INSTALL_GUIDE_URL}"]`)
|
||||
expect(guide.attributes('target')).toBe('_blank')
|
||||
expect(guide.attributes('rel')).toContain('noopener')
|
||||
expect(guide.text()).toContain('(在新标签页打开)')
|
||||
})
|
||||
|
||||
it('leaving the step mid-"Copied" leaves no timer behind', async () => {
|
||||
const wrapper = await mountStep()
|
||||
await copyButton(wrapper).trigger('click')
|
||||
wrapper.unmount()
|
||||
expect(vi.getTimerCount()).toBe(0)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,269 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const getOrganization = vi.fn()
|
||||
const getKnowledgeByAgent = vi.fn()
|
||||
const getKnowledgeByOrganization = vi.fn()
|
||||
const getAgentQueueItems = vi.fn()
|
||||
const addUrls = vi.fn()
|
||||
const addText = vi.fn()
|
||||
const planLimits = vi.fn()
|
||||
|
||||
vi.mock('@/services/organization', () => ({
|
||||
organizationService: { getOrganization: (id: string) => getOrganization(id) }
|
||||
}))
|
||||
vi.mock('@/services/knowledge', () => ({
|
||||
knowledgeService: {
|
||||
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
|
||||
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
|
||||
addUrls: (...a: unknown[]) => addUrls(...a),
|
||||
addText: (...a: unknown[]) => addText(...a),
|
||||
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a)
|
||||
}
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
subscriptionStorage: { getPlanLimits: () => planLimits() }
|
||||
}))
|
||||
|
||||
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
|
||||
|
||||
const empty = { knowledge: [], pagination: { total_pages: 0 } }
|
||||
|
||||
const mountStep = async () => {
|
||||
const wrapper = mount(StepTeach, {
|
||||
props: { agentId: 'agent-1', organizationId: 'org-1' },
|
||||
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } }
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const clickContinue = async (wrapper: any) => {
|
||||
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
|
||||
await cont.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const typeAndAdd = async (wrapper: any, url: string) => {
|
||||
await wrapper.find('input[type="text"]').setValue(url)
|
||||
await wrapper.findAll('button').find((b: any) => b.text() === '添加')!.trigger('click')
|
||||
}
|
||||
|
||||
const staged = (wrapper: any) =>
|
||||
wrapper.findAll('.source-list:not(.indexed) .source-name').map((n: any) => n.text())
|
||||
|
||||
describe('StepTeach - only advances when the sources were accepted', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'teeckbox.com' })
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
|
||||
})
|
||||
|
||||
it('stays on the step and shows the server reason when the add is refused', async () => {
|
||||
addUrls.mockRejectedValue(new Error('Cannot add URLs: Maximum number of knowledge sources (1) would be exceeded'))
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(wrapper.find('.step-error').text()).toContain('Maximum number of knowledge sources')
|
||||
// The batch stays staged so the user can fix it.
|
||||
expect(staged(wrapper)).toEqual(['https://teeckbox.com'])
|
||||
})
|
||||
|
||||
it('stays on the step when the URL already exists', async () => {
|
||||
addUrls.mockResolvedValue({ error: 'Some URLs already exist in your knowledge base', duplicate_urls: ['https://teeckbox.com'] })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(wrapper.find('.step-error').text()).toContain('already exist')
|
||||
})
|
||||
|
||||
it('advances once the add is accepted', async () => {
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('advances with nothing staged', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(addUrls).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepTeach - plan source cap', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'teeckbox.com' })
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
})
|
||||
|
||||
it('on a one-source plan, a typed URL replaces the staged signup domain', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await typeAndAdd(wrapper, 'www.teeckbox.com')
|
||||
|
||||
expect(staged(wrapper)).toEqual(['https://www.teeckbox.com'])
|
||||
expect(wrapper.find('.step-error').exists()).toBe(false)
|
||||
await clickContinue(wrapper)
|
||||
expect(addUrls.mock.calls[0][1]).toEqual(['https://www.teeckbox.com'])
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says so when the cap is already used by a source on any agent', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
|
||||
getKnowledgeByOrganization.mockResolvedValue({
|
||||
knowledge: [{ id: 1, name: 'https://elsewhere.com' }],
|
||||
pagination: { total_pages: 1, total_count: 1 }
|
||||
})
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await typeAndAdd(wrapper, 'docs.teeckbox.com')
|
||||
|
||||
expect(staged(wrapper)).toEqual([])
|
||||
expect(wrapper.find('.step-error').text()).toContain('且已在用')
|
||||
})
|
||||
|
||||
it('keeps every staged source when the plan has room', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 5 })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await typeAndAdd(wrapper, 'docs.teeckbox.com')
|
||||
|
||||
expect(staged(wrapper)).toEqual(['https://teeckbox.com', 'https://docs.teeckbox.com'])
|
||||
})
|
||||
it('counts a crawl that is still running against the cap', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
|
||||
getAgentQueueItems.mockResolvedValue({
|
||||
queue_items: [{ id: 9, source: 'https://docs.other.com', source_type: 'website', status: 'processing', created_at: '' }]
|
||||
})
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await typeAndAdd(wrapper, 'blog.teeckbox.com')
|
||||
|
||||
expect(staged(wrapper)).toEqual([])
|
||||
expect(wrapper.find('.step-error').text()).toContain('且已在用')
|
||||
})
|
||||
|
||||
it('back from a later step, does not re-stage a domain that is still crawling', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
|
||||
getAgentQueueItems.mockResolvedValue({
|
||||
queue_items: [{ id: 9, source: 'https://teeckbox.com', source_type: 'website', status: 'pending', created_at: '' }]
|
||||
})
|
||||
const wrapper = await mountStep()
|
||||
|
||||
expect(staged(wrapper)).toEqual([])
|
||||
await clickContinue(wrapper)
|
||||
expect(addUrls).not.toHaveBeenCalled()
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepTeach - a website that fails fast', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
})
|
||||
|
||||
const failedItem = {
|
||||
id: 7, source: 'https://erfanpv.com', source_type: 'website', status: 'failed', created_at: '',
|
||||
error: 'raw', reason: 'This site could not be found. Check the address for typos.',
|
||||
}
|
||||
|
||||
it('stays, explains, and offers to paste text instead', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(wrapper.find('.crawl-failure').text()).toContain("无法读取 https://erfanpv.com。This site could not be found")
|
||||
expect(wrapper.find('.btn-accent').text()).toContain('跳过并继续')
|
||||
|
||||
await wrapper.findAll('button').find((b: any) => b.text().includes('Paste some text instead'))!.trigger('click')
|
||||
addText.mockResolvedValue({ message: 'ok' })
|
||||
await wrapper.find('textarea').setValue('We sell handmade candles and ship across India within five days.')
|
||||
await wrapper.findAll('button').find((b: any) => b.text() === 'Add this text')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), expect.stringContaining('handmade candles'), 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('offers a PDF upload too', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
|
||||
const wrapper = await mountStep()
|
||||
await clickContinue(wrapper)
|
||||
const picker = vi.spyOn(HTMLInputElement.prototype, 'click').mockImplementation(() => {})
|
||||
|
||||
await wrapper.find('.crawl-failure').findAll('button').find((b: any) => b.text() === '上传 PDF')!.trigger('click')
|
||||
|
||||
expect(picker).toHaveBeenCalled()
|
||||
picker.mockRestore()
|
||||
})
|
||||
|
||||
it('lets them continue without it', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [failedItem] })
|
||||
const wrapper = await mountStep()
|
||||
await clickContinue(wrapper)
|
||||
await clickContinue(wrapper)
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('locks the step while the sources are being sent', async () => {
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
|
||||
let accept!: (v: unknown) => void
|
||||
addUrls.mockReturnValue(new Promise((resolve) => (accept = resolve)))
|
||||
const wrapper = await mountStep()
|
||||
await clickContinue(wrapper)
|
||||
|
||||
expect(wrapper.find('.btn-accent').text()).toContain('正在读取网站')
|
||||
expect(wrapper.find('.btn-accent').attributes('disabled')).toBeDefined()
|
||||
for (const label of ['添加', '上传 PDF', '返回']) {
|
||||
expect(wrapper.findAll('button').find((b: any) => b.text() === label)!.attributes('disabled')).toBeDefined()
|
||||
}
|
||||
expect(wrapper.find('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.source-remove').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
|
||||
accept({ message: 'URLs queued for processing' })
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a crawl that is still running does not hold the step: Continue moves on at once', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({
|
||||
queue_items: [{ id: 1, source: 'https://erfanpv.com', source_type: 'website', status: 'pending', created_at: '' }]
|
||||
})
|
||||
const wrapper = await mountStep()
|
||||
await clickContinue(wrapper)
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
expect(wrapper.find('.crawl-failure').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,133 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const getOrganization = vi.fn()
|
||||
const getKnowledgeByAgent = vi.fn()
|
||||
const getKnowledgeByOrganization = vi.fn()
|
||||
const addUrls = vi.fn()
|
||||
|
||||
vi.mock('@/services/organization', () => ({
|
||||
organizationService: { getOrganization: (id: string) => getOrganization(id) }
|
||||
}))
|
||||
vi.mock('@/services/knowledge', () => ({
|
||||
knowledgeService: {
|
||||
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
|
||||
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
|
||||
addUrls: (...a: unknown[]) => addUrls(...a),
|
||||
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] })
|
||||
}
|
||||
}))
|
||||
|
||||
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
|
||||
|
||||
const empty = { knowledge: [], pagination: { total_pages: 0 } }
|
||||
|
||||
const mountStep = async () => {
|
||||
const wrapper = mount(StepTeach, {
|
||||
props: { agentId: 'agent-1', organizationId: 'org-1' },
|
||||
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } }
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const urlInput = (wrapper: any) =>
|
||||
wrapper.find('input[type="text"]').element as HTMLInputElement
|
||||
|
||||
describe('StepTeach - signup domain prefill', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
})
|
||||
|
||||
it('stages the signup domain as a source, scheme added', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
|
||||
// Visible as an added source, not parked in the input where it reads as a
|
||||
// placeholder the user must still press "Add" to accept.
|
||||
expect(wrapper.find('.source-list').text()).toContain('https://testypo.com')
|
||||
expect(urlInput(wrapper).value).toBe('')
|
||||
})
|
||||
|
||||
it('does not suggest a domain the server would refuse', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'localhost' })
|
||||
const wrapper = await mountStep()
|
||||
expect(wrapper.find('.source-list').exists()).toBe(false)
|
||||
expect(urlInput(wrapper).value).toBe('')
|
||||
})
|
||||
|
||||
it('lets the suggestion be removed', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
await wrapper.find('.source-remove').trigger('click')
|
||||
|
||||
expect(wrapper.find('.source-list').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('leaves a domain that already has a scheme alone', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'https://example.com' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
|
||||
expect(wrapper.find('.source-list').text()).toContain('https://example.com')
|
||||
})
|
||||
|
||||
it('does not re-add a domain that is already indexed, or nag about it', async () => {
|
||||
getKnowledgeByAgent.mockResolvedValue({
|
||||
knowledge: [{ id: 1, name: 'https://testypo.com' }],
|
||||
pagination: { total_pages: 1 }
|
||||
})
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'testypo.com' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
|
||||
expect(wrapper.find('.step-error').exists()).toBe(false)
|
||||
// Present once, as the already-indexed entry — not staged a second time.
|
||||
expect(wrapper.findAll('.source-row')).toHaveLength(1)
|
||||
})
|
||||
|
||||
it('renders normally when the org lookup fails', async () => {
|
||||
getOrganization.mockRejectedValue(new Error('boom'))
|
||||
|
||||
const wrapper = await mountStep()
|
||||
|
||||
expect(urlInput(wrapper).value).toBe('')
|
||||
expect(wrapper.find('.source-list').exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('让它了解你的业务')
|
||||
})
|
||||
|
||||
it('stages a typed URL on Continue without needing "Add"', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
await wrapper.find('input[type="text"]').setValue('docs.company.com')
|
||||
|
||||
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
|
||||
await cont.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('does not advance when the typed URL is invalid', async () => {
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
|
||||
|
||||
const wrapper = await mountStep()
|
||||
await wrapper.find('input[type="text"]').setValue('not a url')
|
||||
|
||||
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
|
||||
await cont.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(wrapper.find('.step-error').text()).toContain('请输入有效的')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,407 @@
|
||||
// @vitest-environment jsdom
|
||||
// What the Teach step does after a website couldn't be read: every way out of
|
||||
// the failure panel, and that it never outlives the attempt it describes.
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises, type VueWrapper } from '@vue/test-utils'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const getOrganization = vi.fn()
|
||||
const getKnowledgeByAgent = vi.fn()
|
||||
const getKnowledgeByOrganization = vi.fn()
|
||||
const getAgentQueueItems = vi.fn()
|
||||
const addUrls = vi.fn()
|
||||
const addText = vi.fn()
|
||||
const uploadPdfFiles = vi.fn()
|
||||
const planLimits = vi.fn()
|
||||
|
||||
vi.mock('@/services/organization', () => ({
|
||||
organizationService: { getOrganization: (id: string) => getOrganization(id) }
|
||||
}))
|
||||
vi.mock('@/services/knowledge', () => ({
|
||||
knowledgeService: {
|
||||
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a),
|
||||
getKnowledgeByOrganization: (...a: unknown[]) => getKnowledgeByOrganization(...a),
|
||||
addUrls: (...a: unknown[]) => addUrls(...a),
|
||||
addText: (...a: unknown[]) => addText(...a),
|
||||
uploadPdfFiles: (...a: unknown[]) => uploadPdfFiles(...a),
|
||||
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a)
|
||||
}
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
subscriptionStorage: { getPlanLimits: () => planLimits() }
|
||||
}))
|
||||
|
||||
import StepTeach from '@/components/aiagent/onboarding/steps/StepTeach.vue'
|
||||
|
||||
const empty = { knowledge: [], pagination: { total_pages: 0 } }
|
||||
const failed = (id: number, source: string, reason = 'This site could not be found.') => ({
|
||||
id, source, source_type: 'website', status: 'failed', created_at: '', error: 'raw', reason,
|
||||
})
|
||||
const firstSite = failed(7, 'https://erfanpv.com')
|
||||
|
||||
let wrapper: VueWrapper<any>
|
||||
|
||||
const mountStep = async () => {
|
||||
wrapper = mount(StepTeach, {
|
||||
props: { agentId: 'agent-1', organizationId: 'org-1' },
|
||||
global: { plugins: [createPinia()], stubs: { 'font-awesome-icon': true } },
|
||||
attachTo: document.body,
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const button = (label: string) => wrapper.findAll('button').find((b) => b.text() === label)
|
||||
const primary = () => wrapper.find('.btn-accent')
|
||||
const panel = () => wrapper.find('.crawl-failure')
|
||||
const staged = () => wrapper.findAll('.source-list:not(.indexed) .source-name').map((n) => n.text())
|
||||
|
||||
const clickContinue = async () => {
|
||||
await primary().trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const typeAndAdd = async (url: string) => {
|
||||
await wrapper.find('input[type="text"]').setValue(url)
|
||||
await button('添加')!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
const pickPdfs = async (...names: string[]) => {
|
||||
const input = wrapper.find('input[type="file"]')
|
||||
const picked = names.map((n) => new File(['%PDF'], n, { type: 'application/pdf' }))
|
||||
Object.defineProperty(input.element, 'files', { value: picked, configurable: true })
|
||||
await input.trigger('change')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
// Mount with the signup domain staged, Continue, and have it fail fast.
|
||||
const failFirst = async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
|
||||
await mountStep()
|
||||
await clickContinue()
|
||||
expect(panel().exists()).toBe(true)
|
||||
}
|
||||
|
||||
const openPaste = async () => {
|
||||
await button('改为粘贴文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
}
|
||||
|
||||
describe('StepTeach - after a website could not be read', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
uploadPdfFiles.mockResolvedValue({ message: 'Uploaded' })
|
||||
})
|
||||
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
it('moves focus to the address box so another site can be tried', async () => {
|
||||
await failFirst()
|
||||
expect(document.activeElement).toBe(wrapper.find('input[type="text"]').element)
|
||||
})
|
||||
|
||||
it('staging a new URL clears the panel and brings back Continue, which checks again', async () => {
|
||||
await failFirst()
|
||||
expect(primary().text()).toContain('跳过并继续')
|
||||
|
||||
await typeAndAdd('replypier.site')
|
||||
|
||||
expect(panel().exists()).toBe(false)
|
||||
expect(primary().text()).toMatch(/^继续\s*→$/)
|
||||
expect(staged()).toEqual(['https://replypier.site'])
|
||||
|
||||
await clickContinue()
|
||||
expect(addUrls).toHaveBeenLastCalledWith('org-1', ['https://replypier.site'], 'agent-1', expect.any(Function))
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a URL typed but not added yet already reads as Continue', async () => {
|
||||
await failFirst()
|
||||
await wrapper.find('input[type="text"]').setValue('replypier.site')
|
||||
expect(primary().text()).toMatch(/^继续\s*→$/)
|
||||
|
||||
await clickContinue()
|
||||
expect(addUrls).toHaveBeenLastCalledWith('org-1', ['https://replypier.site'], 'agent-1', expect.any(Function))
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('removing the new source leaves nothing to send, so it moves on without the site', async () => {
|
||||
await failFirst()
|
||||
await typeAndAdd('replypier.site')
|
||||
await wrapper.find('.source-remove').trigger('click')
|
||||
|
||||
expect(staged()).toEqual([])
|
||||
expect(document.activeElement).toBe(wrapper.find('input[type="text"]').element)
|
||||
expect(primary().text()).toMatch(/^继续\s*→$/)
|
||||
await clickContinue()
|
||||
expect(addUrls).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('a second failure replaces the first and starts from the choice again', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
expect(wrapper.find('textarea').exists()).toBe(true)
|
||||
// Leave the paste box open, then retry with another site that also fails.
|
||||
await typeAndAdd('erfanpv.in')
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [failed(8, 'https://erfanpv.in', 'Nothing answered there.'), firstSite] })
|
||||
|
||||
await clickContinue()
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(panel().text()).toContain("无法读取 https://erfanpv.in。Nothing answered there.")
|
||||
expect(panel().text()).not.toContain('erfanpv.com')
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(button('改为粘贴文本')).toBeTruthy()
|
||||
expect(primary().text()).toContain('跳过并继续')
|
||||
})
|
||||
|
||||
it('a URL already staged says so and keeps the panel', async () => {
|
||||
await failFirst()
|
||||
await typeAndAdd('replypier.site')
|
||||
await typeAndAdd('replypier.site')
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('该 URL 已添加到当前批次')
|
||||
expect(staged()).toEqual(['https://replypier.site'])
|
||||
})
|
||||
|
||||
it('a duplicate URL with nothing new staged keeps the failure on screen', async () => {
|
||||
getKnowledgeByOrganization.mockResolvedValue({ knowledge: [{ id: 1, name: 'https://docs.erfanpv.com' }], pagination: { total_pages: 1, total_count: 1 } })
|
||||
await failFirst()
|
||||
await typeAndAdd('docs.erfanpv.com')
|
||||
expect(wrapper.text()).toContain('该 URL 已存在于你的知识库中')
|
||||
expect(panel().exists()).toBe(true)
|
||||
expect(primary().text()).toContain('跳过并继续')
|
||||
})
|
||||
|
||||
it('the plan cap message keeps the failure on screen when nothing could be staged', async () => {
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 1 })
|
||||
getKnowledgeByOrganization.mockResolvedValue({ knowledge: [{ id: 1, name: 'https://elsewhere.com' }], pagination: { total_pages: 1, total_count: 1 } })
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
|
||||
await mountStep()
|
||||
await typeAndAdd('erfanpv.com')
|
||||
// The cap is used up, so nothing was staged and nothing was sent.
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('且已在用')
|
||||
expect(panel().exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('picking a PDF from the panel clears it and Continue uploads it', async () => {
|
||||
await failFirst()
|
||||
await pickPdfs('menu.pdf')
|
||||
|
||||
expect(panel().exists()).toBe(false)
|
||||
expect(staged()).toEqual(['menu.pdf'])
|
||||
expect(primary().text()).toMatch(/^继续\s*→$/)
|
||||
await clickContinue()
|
||||
expect(uploadPdfFiles).toHaveBeenCalled()
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('continue without it moves on and sends nothing', async () => {
|
||||
await failFirst()
|
||||
await clickContinue()
|
||||
expect(addUrls).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('back on the step later, shows the failure again instead of re-staging the same site', async () => {
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [firstSite] })
|
||||
await mountStep()
|
||||
|
||||
expect(staged()).toEqual([])
|
||||
expect(panel().text()).toContain("无法读取 https://erfanpv.com")
|
||||
expect(button('改为粘贴文本')).toBeTruthy()
|
||||
expect(primary().text()).toContain('跳过并继续')
|
||||
})
|
||||
|
||||
it('back on the step with something already indexed, the old failure stays out of the way', async () => {
|
||||
getKnowledgeByAgent.mockResolvedValue({ knowledge: [{ id: 3, name: 'About us' }], pagination: { total_pages: 1 } })
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [firstSite] })
|
||||
await mountStep()
|
||||
expect(panel().exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepTeach - paste text instead', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: 'erfanpv.com' })
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
})
|
||||
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
const longText = 'We sell handmade candles and ship across India within five days.'
|
||||
|
||||
it('opens with the box focused', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
expect(document.activeElement).toBe(wrapper.find('textarea').element)
|
||||
})
|
||||
|
||||
it('can be cancelled back to the choice, focus returned to it', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await button('取消')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(panel().text()).toContain("无法读取")
|
||||
expect(document.activeElement).toBe(button('改为粘贴文本')!.element)
|
||||
expect(primary().text()).toContain('跳过并继续')
|
||||
})
|
||||
|
||||
it('Esc cancels too', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
expect(button('改为粘贴文本')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('Esc cancels from the buttons as well, e.g. after a too-short attempt', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').setValue('Too short.')
|
||||
await button('添加文本')!.trigger('click')
|
||||
await button('添加文本')!.trigger('keydown', { key: 'Escape' })
|
||||
await flushPromises()
|
||||
expect(wrapper.find('textarea').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('adds the text and moves on', async () => {
|
||||
addText.mockResolvedValue({ message: 'ok' })
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').setValue(longText)
|
||||
await button('添加文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), longText, 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
it('says why when the text is too short, and sends nothing', async () => {
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').setValue('We sell candles.')
|
||||
await button('添加文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addText).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.text-fallback [role="alert"]').text()).toContain('请多写几句话')
|
||||
// Typing more clears it.
|
||||
await wrapper.find('textarea').setValue(longText)
|
||||
expect(wrapper.find('.text-fallback [role="alert"]').exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('keeps the text and stays put when the server refuses it', async () => {
|
||||
addText.mockRejectedValue(new Error('A source with this title already exists'))
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').setValue(longText)
|
||||
await button('添加文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
expect(wrapper.find('.text-fallback [role="alert"]').text()).toContain('already exists')
|
||||
expect((wrapper.find('textarea').element as HTMLTextAreaElement).value).toBe(longText)
|
||||
})
|
||||
|
||||
it('holds the rest of the step still while the text is saving', async () => {
|
||||
let finish!: (v: unknown) => void
|
||||
addText.mockReturnValue(new Promise((resolve) => (finish = resolve)))
|
||||
await failFirst()
|
||||
await openPaste()
|
||||
await wrapper.find('textarea').setValue(longText)
|
||||
await button('添加文本')!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(button('添加中…')!.attributes('disabled')).toBeDefined()
|
||||
expect(button('取消')!.attributes('disabled')).toBeDefined()
|
||||
expect(button('返回')!.attributes('disabled')).toBeDefined()
|
||||
expect(primary().attributes('disabled')).toBeDefined()
|
||||
|
||||
finish({ message: 'ok' })
|
||||
await flushPromises()
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
})
|
||||
|
||||
describe('StepTeach - staged PDFs', () => {
|
||||
beforeEach(() => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
getKnowledgeByAgent.mockResolvedValue(empty)
|
||||
getKnowledgeByOrganization.mockResolvedValue(empty)
|
||||
getAgentQueueItems.mockResolvedValue({ queue_items: [] })
|
||||
getOrganization.mockResolvedValue({ id: 'org-1', domain: '' })
|
||||
planLimits.mockReturnValue({ maxKnowledgeSources: 0 })
|
||||
})
|
||||
|
||||
afterEach(() => wrapper?.unmount())
|
||||
|
||||
it('a second pick adds to the list, and the same PDF twice says so', async () => {
|
||||
await mountStep()
|
||||
await pickPdfs('menu.pdf')
|
||||
await pickPdfs('prices.pdf')
|
||||
expect(staged()).toEqual(['menu.pdf', 'prices.pdf'])
|
||||
|
||||
await pickPdfs('menu.pdf')
|
||||
expect(staged()).toEqual(['menu.pdf', 'prices.pdf'])
|
||||
expect(wrapper.find('[role="alert"]').text()).toContain('该 PDF 已在列表中')
|
||||
})
|
||||
|
||||
it('removing a source clears an error about the batch', async () => {
|
||||
addUrls.mockResolvedValue({ error: 'Some URLs already exist in your knowledge base' })
|
||||
await mountStep()
|
||||
await typeAndAdd('acme.com')
|
||||
await clickContinue()
|
||||
expect(wrapper.text()).toContain('Some URLs already exist')
|
||||
|
||||
await wrapper.find('button[aria-label="移除 https://acme.com"]').trigger('click')
|
||||
expect(wrapper.text()).not.toContain('Some URLs already exist')
|
||||
})
|
||||
|
||||
it('a site that failed is still reported when a PDF in the same batch fails', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [firstSite] })
|
||||
uploadPdfFiles.mockRejectedValue(new Error('That PDF is too large.'))
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
await mountStep()
|
||||
await typeAndAdd('https://erfanpv.com')
|
||||
await pickPdfs('menu.pdf')
|
||||
await clickContinue()
|
||||
|
||||
expect(panel().exists()).toBe(true)
|
||||
expect(panel().text()).toContain("无法读取 https://erfanpv.com。")
|
||||
expect(wrapper.text()).toContain('That PDF is too large.')
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
})
|
||||
|
||||
it('a PDF that fails keeps the step even when the site is fine', async () => {
|
||||
const reading = { ...firstSite, status: 'pending', reason: null }
|
||||
getAgentQueueItems.mockResolvedValueOnce({ queue_items: [] }).mockResolvedValue({ queue_items: [reading] })
|
||||
uploadPdfFiles.mockRejectedValue(new Error('That PDF is too large.'))
|
||||
addUrls.mockResolvedValue({ message: 'Queued' })
|
||||
await mountStep()
|
||||
await typeAndAdd('https://erfanpv.com')
|
||||
await pickPdfs('menu.pdf')
|
||||
await clickContinue()
|
||||
|
||||
expect(addUrls).toHaveBeenCalled() // the site went in
|
||||
expect(panel().exists()).toBe(false)
|
||||
expect(wrapper.text()).toContain('That PDF is too large.')
|
||||
expect(wrapper.emitted('next')).toBeFalsy()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,77 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const getAgentQueueItems = vi.fn()
|
||||
const getKnowledgeByAgent = vi.fn()
|
||||
|
||||
vi.mock('@/services/knowledge', () => ({
|
||||
knowledgeService: {
|
||||
getAgentQueueItems: (...a: unknown[]) => getAgentQueueItems(...a),
|
||||
getKnowledgeByAgent: (...a: unknown[]) => getKnowledgeByAgent(...a)
|
||||
}
|
||||
}))
|
||||
vi.mock('@/services/widget', () => ({ widgetService: { createAgentWidget: vi.fn() } }))
|
||||
vi.mock('@/composables/useOnboardingTestChat', () => ({
|
||||
useOnboardingTestChat: () => ({
|
||||
messages: ref([]), loading: ref(false), connecting: ref(false), connectionError: ref(''),
|
||||
start: vi.fn(), send: vi.fn(), cleanup: vi.fn()
|
||||
})
|
||||
}))
|
||||
|
||||
import StepTest from '@/components/aiagent/onboarding/steps/StepTest.vue'
|
||||
|
||||
const SITE = 'https://teeckbox.com'
|
||||
const REASON = 'This site could not be found — the domain does not resolve.'
|
||||
const queue = (status: string) => ({
|
||||
queue_items: [{ id: 1, source: SITE, source_type: 'website', status, created_at: '',
|
||||
reason: status === 'failed' ? REASON : null }]
|
||||
})
|
||||
const knowledgePage = (count: number) => ({ knowledge: [], pagination: { total_count: count } })
|
||||
|
||||
const mountStep = async () => {
|
||||
const wrapper = mount(StepTest, { props: { agentId: 'agent-1', agentName: 'Rex', widgetId: 'w-1' } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('StepTest - what the agent knows', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('a crawl that fails after Teach says why, not "Knowledge indexed"', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce(queue('pending')).mockResolvedValue(queue('failed'))
|
||||
getKnowledgeByAgent.mockResolvedValue(knowledgePage(0))
|
||||
const wrapper = await mountStep()
|
||||
expect(wrapper.text()).toContain('知识库仍在后台建立索引')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).not.toContain('知识库已索引')
|
||||
expect(wrapper.text()).toContain(`无法读取 ${SITE}。${REASON}`)
|
||||
expect(wrapper.text()).toContain('它还完全不了解你的业务')
|
||||
})
|
||||
|
||||
it('a crawl that finishes says it is indexed', async () => {
|
||||
getAgentQueueItems.mockResolvedValueOnce(queue('pending')).mockResolvedValue(queue('completed'))
|
||||
getKnowledgeByAgent.mockResolvedValue(knowledgePage(1))
|
||||
const wrapper = await mountStep()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(5000)
|
||||
await flushPromises()
|
||||
|
||||
expect(wrapper.text()).toContain('知识库已索引')
|
||||
})
|
||||
|
||||
it('arriving after the failure shows it too', async () => {
|
||||
getAgentQueueItems.mockResolvedValue(queue('failed'))
|
||||
getKnowledgeByAgent.mockResolvedValue(knowledgePage(0))
|
||||
const wrapper = await mountStep()
|
||||
expect(wrapper.text()).toContain(`无法读取 ${SITE}。`)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,62 @@
|
||||
/*
|
||||
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, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: { getGuardrailDefault: vi.fn().mockResolvedValue('') },
|
||||
}))
|
||||
|
||||
import AgentInstructionsTab from '@/components/agent/AgentInstructionsTab.vue'
|
||||
|
||||
const mountTab = async (props: Record<string, unknown> = {}) => {
|
||||
const wrapper = mount(AgentInstructionsTab, {
|
||||
props: { instructions: 'Be helpful', transferToHuman: false, askForRating: false, isEditing: true,
|
||||
agent: { id: 'agent-1', name: 'Ava', display_name: 'Ava' }, userGroups: [], selectedGroupIds: [],
|
||||
...props },
|
||||
global: { directives: { tooltip: {} }, stubs: { 'font-awesome-icon': true } },
|
||||
})
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const buttonsSwitch = (wrapper: any) => wrapper.find('input[aria-label="消息渠道上的回复按钮"]')
|
||||
|
||||
describe('AgentInstructionsTab reply buttons', () => {
|
||||
it('is off by default and named for the channels it covers', async () => {
|
||||
const wrapper = await mountTab()
|
||||
expect((buttonsSwitch(wrapper).element as HTMLInputElement).checked).toBe(false)
|
||||
expect(wrapper.text()).toContain('在 WhatsApp、Messenger、Instagram 和 Telegram 上把选项显示为可点击按钮,网页组件不支持。')
|
||||
})
|
||||
|
||||
it('is hidden when the AI does not answer', async () => {
|
||||
const wrapper = await mountTab({ aiRepliesEnabled: false })
|
||||
expect(buttonsSwitch(wrapper).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('saves the choice', async () => {
|
||||
const wrapper = await mountTab()
|
||||
await buttonsSwitch(wrapper).setValue(true)
|
||||
await wrapper.find('.save-button').trigger('click')
|
||||
expect(wrapper.emitted('save-agent')![0][0]).toMatchObject({ channelQuickReplies: true })
|
||||
})
|
||||
|
||||
it('reflects an agent that has it on', async () => {
|
||||
const wrapper = await mountTab({ channelQuickReplies: true })
|
||||
expect((buttonsSwitch(wrapper).element as HTMLInputElement).checked).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,108 @@
|
||||
// @vitest-environment jsdom
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { mount, flushPromises } from '@vue/test-utils'
|
||||
|
||||
const getProgress = vi.fn()
|
||||
const getAgentById = vi.fn()
|
||||
// The enterprise module's setup service; null when the module is absent
|
||||
let enterprise = true
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
moduleImports: { setupService: 'setup-service' },
|
||||
loadModule: async () => (enterprise ? { setupService: { getProgress: () => getProgress() } } : null),
|
||||
})
|
||||
}))
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: { getAgentById: (id: string) => getAgentById(id) }
|
||||
}))
|
||||
const createAgentWidget = vi.fn()
|
||||
vi.mock('@/services/widget', () => ({
|
||||
widgetService: { createAgentWidget: (...a: unknown[]) => createAgentWidget(...a) }
|
||||
}))
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: { getCurrentUser: () => ({ organization_id: 'org-1' }) }
|
||||
}))
|
||||
vi.mock('@/utils/permissions', () => ({ permissionChecks: { canManageAgents: () => true } }))
|
||||
|
||||
const STORAGE_KEY = 'replypier:onboarding:v1'
|
||||
const stubs = {
|
||||
StepCreate: {
|
||||
emits: ['created'],
|
||||
template: `<button class="step-create" @click="$emit('created', { id: 'agent-1', name: 'Tom', agent_type: 'CUSTOMER_SUPPORT' })" />`,
|
||||
},
|
||||
StepTeach: { props: ['agentId'], template: '<div class="step-teach">{{ agentId }}</div>' },
|
||||
StepTest: { props: ['widgetId'], template: '<div class="step-test">{{ widgetId }}</div>' },
|
||||
StepLaunch: { props: ['widgetId'], template: '<div class="step-launch">{{ widgetId }}</div>' },
|
||||
}
|
||||
|
||||
const mountWizard = async () => {
|
||||
vi.resetModules()
|
||||
const { default: AIAgentOnboarding } = await import('@/components/aiagent/onboarding/AIAgentOnboarding.vue')
|
||||
const wrapper = mount(AIAgentOnboarding, { global: { stubs } })
|
||||
await flushPromises()
|
||||
return wrapper
|
||||
}
|
||||
|
||||
describe('AIAgentOnboarding - where it resumes', () => {
|
||||
beforeEach(() => {
|
||||
localStorage.clear()
|
||||
enterprise = true
|
||||
getProgress.mockReset()
|
||||
createAgentWidget.mockReset().mockResolvedValue({ id: 'w-new' })
|
||||
getAgentById.mockReset().mockResolvedValue({ id: 'agent-9', name: 'Tom', agent_type: 'CUSTOMER_SUPPORT' })
|
||||
})
|
||||
|
||||
it('on a fresh device, opens at the step the server reports', async () => {
|
||||
getProgress.mockResolvedValue({ step: 'test', agent_id: 'agent-9', widget_id: 'w-9', hidden: false })
|
||||
const wrapper = await mountWizard()
|
||||
expect(wrapper.find('.step-test').text()).toBe('w-9')
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)['org-1'].agentId).toBe('agent-9')
|
||||
})
|
||||
|
||||
it('opens Launch for an org that has tested but is not live', async () => {
|
||||
getProgress.mockResolvedValue({ step: 'launch', agent_id: 'agent-9', widget_id: 'w-9', hidden: false })
|
||||
const wrapper = await mountWizard()
|
||||
expect(wrapper.find('.step-launch').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('starts at Create when the org has no agent', async () => {
|
||||
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
|
||||
const wrapper = await mountWizard()
|
||||
expect(wrapper.find('.step-create').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it("prefers this browser's own run over the server", async () => {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify({
|
||||
'org-1': { skipped: false, currentStep: 1, completedSteps: [0], agentId: 'agent-9', agentName: 'Tom', widgetId: null, updatedAt: 1 },
|
||||
}))
|
||||
const wrapper = await mountWizard()
|
||||
expect(getProgress).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.step-teach').text()).toBe('agent-9')
|
||||
})
|
||||
|
||||
it('creates the widget with the agent, so it exists if they stop at Teach', async () => {
|
||||
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
|
||||
const wrapper = await mountWizard()
|
||||
await wrapper.find('.step-create').trigger('click')
|
||||
await flushPromises()
|
||||
expect(createAgentWidget).toHaveBeenCalledWith('agent-1', 'Tom')
|
||||
expect(JSON.parse(localStorage.getItem(STORAGE_KEY)!)['org-1'].widgetId).toBe('w-new')
|
||||
expect(wrapper.find('.step-teach').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('still moves on to Teach if the widget could not be created', async () => {
|
||||
getProgress.mockResolvedValue({ step: 'create', agent_id: null, widget_id: null, hidden: false })
|
||||
createAgentWidget.mockRejectedValue(new Error('500'))
|
||||
const wrapper = await mountWizard()
|
||||
await wrapper.find('.step-create').trigger('click')
|
||||
await flushPromises()
|
||||
expect(wrapper.find('.step-teach').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('in open source, resumes only from this browser', async () => {
|
||||
enterprise = false
|
||||
const wrapper = await mountWizard()
|
||||
expect(getProgress).not.toHaveBeenCalled()
|
||||
expect(wrapper.find('.step-create').exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,146 @@
|
||||
/*
|
||||
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, beforeEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import ChatMessage from '../../../components/aiagent/ChatMessage.vue'
|
||||
import robotIcon from '@/assets/robot.png'
|
||||
import userIcon from '@/assets/user.png'
|
||||
|
||||
describe('ChatMessage', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
const createWrapper = (props = {}) => {
|
||||
return mount(ChatMessage, {
|
||||
props: {
|
||||
role: 'user',
|
||||
content: 'Test message',
|
||||
...props
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
it('renders user message properly', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'user',
|
||||
content: 'Hello, world!'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.message').exists()).toBe(true)
|
||||
expect(wrapper.find('.message').classes()).toContain('user')
|
||||
expect(wrapper.find('.bubble').text()).toBe('Hello, world!')
|
||||
expect(wrapper.find('.user-icon').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('renders bot message properly', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: 'I am a bot',
|
||||
agent: 'Test Bot'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.message').exists()).toBe(true)
|
||||
expect(wrapper.find('.robot-icon').exists()).toBe(true)
|
||||
expect(wrapper.find('.bubble').text()).toBe('I am a bot')
|
||||
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('Test Bot')
|
||||
})
|
||||
|
||||
it('renders error message properly', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'error',
|
||||
content: 'An error occurred'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.message').exists()).toBe(true)
|
||||
expect(wrapper.find('.message').classes()).toContain('error')
|
||||
expect(wrapper.find('.bubble').text()).toBe('An error occurred')
|
||||
expect(wrapper.find('.avatar').text()).toBe('⚠️')
|
||||
})
|
||||
|
||||
it('shows loading state for bot messages', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: '',
|
||||
isLoading: true
|
||||
})
|
||||
|
||||
expect(wrapper.find('.typing-indicator').exists()).toBe(true)
|
||||
expect(wrapper.findAll('.typing-indicator span')).toHaveLength(3)
|
||||
})
|
||||
|
||||
it('uses custom avatar for bot when provided', () => {
|
||||
const customAvatar = 'custom-bot.png'
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: 'Custom avatar message',
|
||||
avatar: customAvatar
|
||||
})
|
||||
|
||||
const avatarImg = wrapper.find('.robot-icon')
|
||||
expect(avatarImg.exists()).toBe(true)
|
||||
expect(avatarImg.attributes('src')).toBe(customAvatar)
|
||||
})
|
||||
|
||||
it('uses default robot icon when no avatar is provided for bot', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: 'Default avatar message'
|
||||
})
|
||||
|
||||
const avatarImg = wrapper.find('.robot-icon')
|
||||
expect(avatarImg.exists()).toBe(true)
|
||||
expect(avatarImg.attributes('src')).toBe(robotIcon)
|
||||
})
|
||||
|
||||
it('applies correct styling classes', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'user',
|
||||
content: 'Style test message'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.message').classes()).toContain('user')
|
||||
expect(wrapper.find('.avatar').exists()).toBe(true)
|
||||
expect(wrapper.find('.bubble').exists()).toBe(true)
|
||||
})
|
||||
|
||||
it('handles empty content properly', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'user',
|
||||
content: ''
|
||||
})
|
||||
|
||||
expect(wrapper.find('.bubble').text()).toBe('')
|
||||
})
|
||||
|
||||
it('uses correct alt text for bot avatar', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: 'Bot message',
|
||||
agent: 'Custom Agent'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('Custom Agent')
|
||||
})
|
||||
|
||||
it('uses default alt text for bot avatar when no agent name is provided', () => {
|
||||
wrapper = createWrapper({
|
||||
role: 'bot',
|
||||
content: 'Bot message'
|
||||
})
|
||||
|
||||
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('AI 助手')
|
||||
})
|
||||
})
|
||||
Reference in new issue
Block a user