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 助手')
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,750 @@
|
||||
<!--
|
||||
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 { ref } from 'vue'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: Agent
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'update', agent: Agent): void
|
||||
}>()
|
||||
|
||||
// Create a ref for the agent
|
||||
const agentRef = ref(props.agent)
|
||||
|
||||
// Import the composable
|
||||
import { useAgentAdvanced } from '@/composables/useAgentAdvanced'
|
||||
|
||||
// Use the composable
|
||||
const {
|
||||
localSettings,
|
||||
isLoading,
|
||||
error,
|
||||
hasUnsavedChanges,
|
||||
toggleRateLimiting,
|
||||
updateLocalValue,
|
||||
saveRateLimitSettings
|
||||
} = useAgentAdvanced(agentRef)
|
||||
|
||||
// Handle successful updates
|
||||
const handleUpdate = (updatedAgent: Agent) => {
|
||||
emit('update', updatedAgent)
|
||||
}
|
||||
|
||||
// Handle toggle rate limiting
|
||||
const handleToggleRateLimiting = async () => {
|
||||
try {
|
||||
const updatedAgent = await toggleRateLimiting()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
|
||||
// Handle slider value changes
|
||||
const handleValueChange = (type: 'overallLimitPerIp' | 'requestsPerSec', event: Event) => {
|
||||
const target = event.target as HTMLInputElement
|
||||
if (!target) return
|
||||
|
||||
updateLocalValue(type, target.value)
|
||||
}
|
||||
|
||||
// Handle save settings
|
||||
const handleSaveSettings = async () => {
|
||||
try {
|
||||
const updatedAgent = await saveRateLimitSettings()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
|
||||
// Handle cancel - restore local rate-limit values from the agent reference
|
||||
const handleCancel = () => {
|
||||
localSettings.value = {
|
||||
...localSettings.value,
|
||||
overallLimitPerIp: String(agentRef.value.overall_limit_per_ip || 100),
|
||||
requestsPerSec: String(agentRef.value.requests_per_sec || 1)
|
||||
}
|
||||
}
|
||||
|
||||
// Handle attachments setting toggle
|
||||
const updateAttachmentsSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
// Call API to save the setting
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
allow_attachments: enabled
|
||||
})
|
||||
|
||||
// Update local reference
|
||||
agentRef.value.allow_attachments = updatedAgent.allow_attachments
|
||||
|
||||
// Emit update to parent
|
||||
emit('update', updatedAgent)
|
||||
|
||||
// Show success toast
|
||||
toast.success('附件设置已更新', {
|
||||
duration: 2000
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update attachments setting:', err)
|
||||
toast.error('更新附件设置失败', {
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Allowed file type categories
|
||||
const fileTypeCategories = [
|
||||
{ value: 'images', label: '图片', description: 'JPG, PNG, GIF, WebP' },
|
||||
{ value: 'documents', label: 'PDF 文档', description: 'PDF 文件' },
|
||||
{ value: 'office', label: 'Office 文件', description: 'DOC, DOCX, XLS, XLSX' },
|
||||
{ value: 'text', label: '文本文件', description: 'TXT, CSV' }
|
||||
]
|
||||
|
||||
// Check if a category is selected
|
||||
const isCategorySelected = (category: string): boolean => {
|
||||
const types = agentRef.value.allowed_attachment_types
|
||||
// If null or empty, all types are allowed
|
||||
if (!types || types.length === 0) return true
|
||||
return types.includes(category)
|
||||
}
|
||||
|
||||
// Toggle a file type category
|
||||
const toggleFileTypeCategory = async (category: string) => {
|
||||
try {
|
||||
let currentTypes = agentRef.value.allowed_attachment_types || []
|
||||
|
||||
// If currently empty (all allowed), start with all categories
|
||||
if (currentTypes.length === 0) {
|
||||
currentTypes = fileTypeCategories.map(c => c.value)
|
||||
}
|
||||
|
||||
let newTypes: string[]
|
||||
if (currentTypes.includes(category)) {
|
||||
// Remove category (but ensure at least one remains)
|
||||
newTypes = currentTypes.filter(t => t !== category)
|
||||
if (newTypes.length === 0) {
|
||||
toast.error('至少需要允许一种文件类型', { duration: 2000 })
|
||||
return
|
||||
}
|
||||
} else {
|
||||
// Add category
|
||||
newTypes = [...currentTypes, category]
|
||||
}
|
||||
|
||||
// If all categories selected, set to null (allow all)
|
||||
const finalTypes = newTypes.length === fileTypeCategories.length ? null : newTypes
|
||||
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
allowed_attachment_types: finalTypes
|
||||
})
|
||||
|
||||
agentRef.value.allowed_attachment_types = updatedAgent.allowed_attachment_types
|
||||
emit('update', updatedAgent)
|
||||
|
||||
toast.success('允许的文件类型已更新', { duration: 2000 })
|
||||
} catch (err) {
|
||||
console.error('Failed to update allowed file types:', err)
|
||||
toast.error('更新允许的文件类型失败', { duration: 2000 })
|
||||
}
|
||||
}
|
||||
|
||||
// Handle token authentication setting toggle
|
||||
const updateTokenAuthSetting = async (enabled: boolean) => {
|
||||
try {
|
||||
// Call API to save the setting
|
||||
const updatedAgent = await agentService.updateAgent(agentRef.value.id, {
|
||||
require_token_auth: enabled
|
||||
})
|
||||
|
||||
// Update local reference
|
||||
agentRef.value.require_token_auth = updatedAgent.require_token_auth
|
||||
|
||||
// Emit update to parent
|
||||
emit('update', updatedAgent)
|
||||
|
||||
// Show success toast
|
||||
toast.success(`组件 Token 认证已${enabled ? '开启' : '关闭'}`, {
|
||||
duration: 2000
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to update token auth setting:', err)
|
||||
toast.error('更新 Token 认证设置失败', {
|
||||
duration: 2000
|
||||
})
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="advanced-tab">
|
||||
<div class="page-header">
|
||||
<h3 class="page-title">高级设置</h3>
|
||||
<p class="page-description">
|
||||
配置客服的限流、安全及其他技术选项。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Error message -->
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="settings-grid">
|
||||
<!-- Rate Limiting Card -->
|
||||
<div class="settings-card" :class="{ 'loading': isLoading }">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-info">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 8V12L15 15M21 12C21 16.9706 16.9706 21 12 21C7.02944 21 3 16.9706 3 12C3 7.02944 7.02944 3 12 3C16.9706 3 21 7.02944 21 12Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">限流</div>
|
||||
<div class="card-desc">限制每位用户的请求数,防止客服被滥用。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="localSettings.enableRateLimiting"
|
||||
@change="handleToggleRateLimiting"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': localSettings.enableRateLimiting }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="localSettings.enableRateLimiting" class="card-extra">
|
||||
<div class="num-fields">
|
||||
<div class="num-field">
|
||||
<label class="num-label">最大请求数</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="localSettings.overallLimitPerIp"
|
||||
@input="(e) => handleValueChange('overallLimitPerIp', e)"
|
||||
min="10"
|
||||
max="1000"
|
||||
step="10"
|
||||
class="num-input"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
<div class="num-field">
|
||||
<label class="num-label">时间窗口(秒)</label>
|
||||
<input
|
||||
type="number"
|
||||
v-model="localSettings.requestsPerSec"
|
||||
@input="(e) => handleValueChange('requestsPerSec', e)"
|
||||
min="1"
|
||||
max="10"
|
||||
step="1"
|
||||
class="num-input"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Token Authentication Card -->
|
||||
<div class="settings-card">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-coral">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">组件认证</div>
|
||||
<div class="card-desc">组件访问需通过服务端 Token 认证。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
v-model="agentRef.require_token_auth"
|
||||
@change="(e) => updateTokenAuthSetting((e.target as HTMLInputElement).checked)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': agentRef.require_token_auth }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="card-extra">
|
||||
<div v-if="agentRef.require_token_auth" class="locked-note">
|
||||
<span class="locked-note-icon">
|
||||
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</span>
|
||||
<span>
|
||||
<strong>设置:</strong>在 <strong>设置 → 组件应用</strong> 中创建 API Key,然后从
|
||||
后端调用 <code>/api/v1/generate-token</code>。
|
||||
</span>
|
||||
</div>
|
||||
<div v-else class="info-pill">
|
||||
<span class="info-pill-icon">ⓘ</span>
|
||||
<span>允许匿名访问</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- File Attachments Card -->
|
||||
<div class="settings-card">
|
||||
<div class="card-top">
|
||||
<div class="card-lead">
|
||||
<div class="card-icon icon-teal">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M21.44 11.05L12.25 20.24C11.1242 21.3658 9.59723 21.9983 8.00502 21.9983C6.41282 21.9983 4.88586 21.3658 3.76002 20.24C2.63419 19.1142 2.00171 17.5872 2.00171 15.995C2.00171 14.4028 2.63419 12.8758 3.76002 11.75L12.33 3.18C13.0806 2.42975 14.0991 2.00892 15.16 2.00892C16.221 2.00892 17.2395 2.42975 17.99 3.18C18.7403 3.93053 19.1611 4.94903 19.1611 6.01C19.1611 7.07097 18.7403 8.08947 17.99 8.84L9.41002 17.41C9.03476 17.7853 8.52557 17.9961 7.99502 17.9961C7.46448 17.9961 6.95529 17.7853 6.58002 17.41C6.20476 17.0347 5.99393 16.5256 5.99393 15.995C5.99393 15.4644 6.20476 14.9553 6.58002 14.58L15.07 6.1" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="card-meta">
|
||||
<div class="card-title">文件附件</div>
|
||||
<div class="card-desc">允许用户在与人工客服会话时上传文件。</div>
|
||||
</div>
|
||||
</div>
|
||||
<label class="switch">
|
||||
<input
|
||||
type="checkbox"
|
||||
:checked="agentRef.allow_attachments"
|
||||
@change="(e) => updateAttachmentsSetting((e.target as HTMLInputElement).checked)"
|
||||
:disabled="isLoading"
|
||||
>
|
||||
<span class="slider" :class="{ 'enabled': agentRef.allow_attachments }"></span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div v-if="agentRef.allow_attachments" class="card-extra">
|
||||
<div class="file-types-grid">
|
||||
<button
|
||||
v-for="category in fileTypeCategories"
|
||||
:key="category.value"
|
||||
class="file-type-chip"
|
||||
:class="{ 'selected': isCategorySelected(category.value) }"
|
||||
@click="toggleFileTypeCategory(category.value)"
|
||||
>
|
||||
<svg v-if="isCategorySelected(category.value)" width="14" height="14" viewBox="0 0 24 24" fill="none">
|
||||
<path d="M5 13L9 17L19 7" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
<span>{{ category.label }}</span>
|
||||
</button>
|
||||
</div>
|
||||
<p class="extra-note">
|
||||
人工客服接入后,客户即可上传文件。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div v-else class="card-extra">
|
||||
<p class="extra-note">开启后,人工客服会话中可上传文件。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Save bar -->
|
||||
<div class="save-bar">
|
||||
<button
|
||||
class="btn-ghost"
|
||||
type="button"
|
||||
@click="handleCancel"
|
||||
:disabled="isLoading || !hasUnsavedChanges"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
class="btn-primary"
|
||||
type="button"
|
||||
@click="handleSaveSettings"
|
||||
:disabled="isLoading || !hasUnsavedChanges"
|
||||
>
|
||||
保存修改
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.advanced-tab {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
animation: fadeIn 0.3s ease;
|
||||
}
|
||||
|
||||
/* Page Header */
|
||||
.page-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.page-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px 0;
|
||||
}
|
||||
|
||||
.page-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin: 0;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Settings Grid */
|
||||
.settings-grid {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
/* Settings Card */
|
||||
.settings-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 22px 24px;
|
||||
}
|
||||
|
||||
.settings-card.loading {
|
||||
opacity: 0.6;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
/* Card top row */
|
||||
.card-top {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 18px;
|
||||
}
|
||||
|
||||
.card-lead {
|
||||
display: flex;
|
||||
gap: 14px;
|
||||
}
|
||||
|
||||
.card-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border-radius: var(--radius-btn);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.card-icon.icon-info {
|
||||
background: color-mix(in srgb, var(--c-info) 14%, transparent);
|
||||
color: var(--c-info);
|
||||
}
|
||||
|
||||
.card-icon.icon-coral {
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.card-icon.icon-teal {
|
||||
background: color-mix(in srgb, var(--c-teal) 14%, transparent);
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.card-meta {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.card-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 16.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.card-desc {
|
||||
font-size: 13.5px;
|
||||
color: var(--muted);
|
||||
margin-top: 4px;
|
||||
max-width: 520px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
/* Card extra (revealed area) */
|
||||
.card-extra {
|
||||
margin-top: 18px;
|
||||
padding-top: 18px;
|
||||
border-top: 1px solid var(--o06);
|
||||
}
|
||||
|
||||
/* Toggle Switch (46x26) */
|
||||
.switch {
|
||||
position: relative;
|
||||
display: inline-block;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.switch input {
|
||||
opacity: 0;
|
||||
width: 0;
|
||||
height: 0;
|
||||
}
|
||||
|
||||
.slider {
|
||||
position: absolute;
|
||||
cursor: pointer;
|
||||
inset: 0;
|
||||
background: var(--toggle-track-off);
|
||||
transition: background 0.15s;
|
||||
border-radius: var(--radius-pill);
|
||||
}
|
||||
|
||||
.slider.enabled {
|
||||
background: var(--toggle-on-teal);
|
||||
}
|
||||
|
||||
.slider:before {
|
||||
position: absolute;
|
||||
content: "";
|
||||
height: 20px;
|
||||
width: 20px;
|
||||
left: 3px;
|
||||
top: 3px;
|
||||
background: var(--toggle-knob);
|
||||
transition: transform 0.15s;
|
||||
border-radius: 50%;
|
||||
}
|
||||
|
||||
input:checked + .slider:before {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
/* Number fields (rate limiting) */
|
||||
.num-fields {
|
||||
display: flex;
|
||||
gap: 18px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.num-field {
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.num-label {
|
||||
display: block;
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
margin-bottom: 7px;
|
||||
}
|
||||
|
||||
.num-input {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 11px 13px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-mono);
|
||||
outline: none;
|
||||
}
|
||||
|
||||
.num-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Widget auth - locked note */
|
||||
.locked-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.locked-note-icon {
|
||||
flex-shrink: 0;
|
||||
display: inline-flex;
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.locked-note code {
|
||||
background: var(--o06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Widget auth - info pill */
|
||||
.info-pill {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 7px 13px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
}
|
||||
|
||||
.info-pill-icon {
|
||||
font-size: 13px;
|
||||
line-height: 1;
|
||||
}
|
||||
|
||||
/* File Types Grid */
|
||||
.file-types-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--space-sm);
|
||||
}
|
||||
|
||||
.file-type-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-pill);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.file-type-chip:hover {
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.file-type-chip.selected {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Descriptive extra text */
|
||||
.extra-note {
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
line-height: 1.5;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.file-types-grid + .extra-note {
|
||||
margin-top: var(--space-md);
|
||||
}
|
||||
|
||||
/* Save bar */
|
||||
.save-bar {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 12px;
|
||||
padding: 18px 0 4px;
|
||||
}
|
||||
|
||||
.btn-ghost,
|
||||
.btn-primary {
|
||||
padding: 13px 22px;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-display);
|
||||
font-size: 14.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.btn-primary {
|
||||
padding: 13px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
}
|
||||
|
||||
.btn-primary:hover:not(:disabled),
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-ghost:disabled,
|
||||
.btn-primary:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
/* Error Message */
|
||||
.error-message {
|
||||
padding: var(--space-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
background: var(--coral-bg);
|
||||
color: var(--c-coral);
|
||||
border-radius: var(--radius-md);
|
||||
font-size: 0.9rem;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(8px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
/* Responsive */
|
||||
@media (max-width: 768px) {
|
||||
.advanced-tab {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.settings-card {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.card-top {
|
||||
flex-direction: row;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,437 @@
|
||||
<!--
|
||||
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 { ref, computed, onMounted } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { leadCaptureService } from '@/services/leadCapture'
|
||||
import crmService, { type CrmConnection } from '@/services/crm'
|
||||
import type { LeadCaptureConfig, LeadField } from '@/types/leadCapture'
|
||||
|
||||
const props = defineProps<{ agentId: string }>()
|
||||
|
||||
const loading = ref(true)
|
||||
const saving = ref(false)
|
||||
|
||||
const enabled = ref(false)
|
||||
const requireConsent = ref(true)
|
||||
const guidance = ref('')
|
||||
const fields = ref<LeadField[]>([])
|
||||
const assignmentMode = ref('none')
|
||||
const crmSyncTarget = ref('none')
|
||||
const slackNotifyEnabled = ref(false)
|
||||
|
||||
// Org-level CRM connections, to warn when the chosen target isn't connected.
|
||||
// Loaded best-effort: a 403 (plan) or error just means no warning is shown.
|
||||
const crmConnections = ref<CrmConnection[]>([])
|
||||
const crmTargetUnconnected = computed(() =>
|
||||
crmSyncTarget.value !== 'none' &&
|
||||
!crmConnections.value.some(c => c.provider === crmSyncTarget.value && c.status === 'active')
|
||||
)
|
||||
const crmTargetName = computed(() =>
|
||||
({ hubspot: 'HubSpot', pipedrive: 'Pipedrive' } as Record<string, string>)[crmSyncTarget.value] || crmSyncTarget.value
|
||||
)
|
||||
|
||||
const STANDARD_FIELDS: { key: string; label: string }[] = [
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'phone', label: '电话' },
|
||||
]
|
||||
|
||||
// --- Fields ---
|
||||
function isStandardEnabled(key: string) {
|
||||
return fields.value.some(f => f.standard && f.key === key && f.enabled)
|
||||
}
|
||||
function toggleStandard(key: string) {
|
||||
const existing = fields.value.find(f => f.standard && f.key === key)
|
||||
if (existing) existing.enabled = !existing.enabled
|
||||
else fields.value.push({ key, standard: true, enabled: true, required: key === 'email' })
|
||||
}
|
||||
function isRequired(key: string) {
|
||||
return !!fields.value.find(f => f.key === key)?.required
|
||||
}
|
||||
// Toggle a field's "required" flag. Email is always required (the minimum needed
|
||||
// to record a lead) and cannot be made optional.
|
||||
function toggleRequired(key: string) {
|
||||
if (key === 'email') return
|
||||
const f = fields.value.find(x => x.key === key)
|
||||
if (f) f.required = !f.required
|
||||
}
|
||||
const customFields = computed(() => fields.value.filter(f => !f.standard))
|
||||
const newFieldLabel = ref('')
|
||||
const newFieldOptions = ref('')
|
||||
function addCustomField() {
|
||||
const label = newFieldLabel.value.trim()
|
||||
if (!label) { toast.error('请输入字段名称'); return }
|
||||
// Slug from the label; fall back to an index when the label has no a–z0–9 chars
|
||||
// (e.g. non-Latin scripts) so the key is never a bare "custom_". Ensure uniqueness
|
||||
// since record_lead_capture keys off field.key and duplicates would overwrite.
|
||||
let slug = label.toLowerCase().replace(/[^a-z0-9]+/g, '_').replace(/^_+|_+$/g, '')
|
||||
let key = 'custom_' + (slug || 'field')
|
||||
if (fields.value.some(f => f.key === key)) {
|
||||
let n = 2
|
||||
while (fields.value.some(f => f.key === `${key}_${n}`)) n++
|
||||
key = `${key}_${n}`
|
||||
}
|
||||
const field: LeadField = { key, standard: false, enabled: true, required: false, label }
|
||||
const opts = newFieldOptions.value.split(',').map(s => s.trim()).filter(Boolean)
|
||||
if (opts.length) field.options = opts
|
||||
fields.value.push(field)
|
||||
newFieldLabel.value = ''; newFieldOptions.value = ''
|
||||
}
|
||||
function removeCustomField(key: string) {
|
||||
const i = fields.value.findIndex(f => f.key === key && !f.standard)
|
||||
if (i >= 0) fields.value.splice(i, 1)
|
||||
}
|
||||
function seedDefaultsIfEmpty() {
|
||||
if (fields.value.length === 0) {
|
||||
fields.value = [
|
||||
{ key: 'email', standard: true, enabled: true, required: true },
|
||||
{ key: 'name', standard: true, enabled: true, required: false },
|
||||
{ key: 'company', standard: true, enabled: false, required: false },
|
||||
{ key: 'phone', standard: true, enabled: false, required: false },
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
// --- Preview derivations ---
|
||||
const enabledFieldLabels = computed(() =>
|
||||
fields.value.filter(f => f.enabled).map(f => {
|
||||
if (f.standard) return (STANDARD_FIELDS.find(s => s.key === f.key)?.label) || f.key
|
||||
return f.label || f.key
|
||||
})
|
||||
)
|
||||
const firstAskLabel = computed(() => (enabledFieldLabels.value[0] || 'email').toLowerCase())
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
try {
|
||||
const cfg = await leadCaptureService.getConfig(props.agentId)
|
||||
enabled.value = !!cfg.enabled
|
||||
requireConsent.value = cfg.require_consent !== false
|
||||
guidance.value = cfg.guidance || ''
|
||||
fields.value = cfg.fields || []
|
||||
assignmentMode.value = cfg.assignment_mode || 'none'
|
||||
crmSyncTarget.value = cfg.crm_sync_target || 'none'
|
||||
slackNotifyEnabled.value = !!cfg.slack_notify_enabled
|
||||
seedDefaultsIfEmpty()
|
||||
} catch {
|
||||
toast.error('加载线索收集设置失败')
|
||||
} finally {
|
||||
loading.value = false
|
||||
}
|
||||
try {
|
||||
crmConnections.value = await crmService.listConnections()
|
||||
} catch {
|
||||
crmConnections.value = [] // plan-gated or unavailable — just no warning
|
||||
}
|
||||
}
|
||||
|
||||
async function save() {
|
||||
saving.value = true
|
||||
try {
|
||||
const updated: LeadCaptureConfig = await leadCaptureService.updateConfig(props.agentId, {
|
||||
enabled: enabled.value,
|
||||
require_consent: requireConsent.value,
|
||||
guidance: guidance.value.trim() || null,
|
||||
fields: fields.value,
|
||||
assignment_mode: assignmentMode.value as any,
|
||||
assignment_target_user_id: null,
|
||||
crm_sync_target: crmSyncTarget.value as any,
|
||||
slack_notify_enabled: slackNotifyEnabled.value,
|
||||
})
|
||||
enabled.value = !!updated.enabled
|
||||
toast.success('线索收集设置已保存')
|
||||
} catch {
|
||||
toast.error('保存线索收集设置失败')
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(load)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="lead-capture-tab" v-if="!loading">
|
||||
<div class="lc-header">
|
||||
<div>
|
||||
<h3 class="lc-title">线索收集</h3>
|
||||
<p class="lc-sub">开启后,客服会在对话中收集联系方式——先解决问题,再在合适时机询问。无需表单。</p>
|
||||
</div>
|
||||
<button class="lc-save" :disabled="saving" @click="save">{{ saving ? '保存中…' : '保存修改' }}</button>
|
||||
</div>
|
||||
|
||||
<div class="lc-grid">
|
||||
<!-- LEFT: config -->
|
||||
<div class="lc-left">
|
||||
<!-- Master toggle -->
|
||||
<section class="lc-card">
|
||||
<div class="lc-toggle-row">
|
||||
<div>
|
||||
<div class="lc-toggle-title">启用线索收集</div>
|
||||
<div class="lc-toggle-desc">由客服判断何时开口(类似转人工),并以对话方式收集信息。</div>
|
||||
</div>
|
||||
<button class="lc-switch" :class="{ on: enabled }" @click="enabled = !enabled" :aria-pressed="enabled">
|
||||
<span class="lc-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<template v-if="enabled">
|
||||
<!-- What to collect -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">收集哪些信息</h4>
|
||||
<p class="lc-card-sub">客服会在对话中询问这些信息,并以结构化数据上报。也可以自行添加。</p>
|
||||
<div class="lc-chips">
|
||||
<button
|
||||
v-for="sf in STANDARD_FIELDS" :key="sf.key"
|
||||
class="lc-chip" :class="{ on: isStandardEnabled(sf.key) }"
|
||||
@click="toggleStandard(sf.key)"
|
||||
>
|
||||
<span class="lc-cbox" :class="{ on: isStandardEnabled(sf.key) }">
|
||||
<svg v-if="isStandardEnabled(sf.key)" viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="lc-chip-label">{{ sf.label }}</span>
|
||||
<span v-if="sf.key === 'email'" class="lc-req">必填</span>
|
||||
<span
|
||||
v-else-if="isStandardEnabled(sf.key)"
|
||||
class="lc-req-toggle" :class="{ on: isRequired(sf.key) }"
|
||||
@click.stop="toggleRequired(sf.key)"
|
||||
:title="isRequired(sf.key) ? '必填——点击改为选填' : '选填——点击改为必填'"
|
||||
>{{ isRequired(sf.key) ? '必填' : '选填' }}</span>
|
||||
</button>
|
||||
<span v-for="cf in customFields" :key="cf.key" class="lc-chip on custom">
|
||||
<span class="lc-cbox purple on">
|
||||
<svg viewBox="0 0 24 24" width="12" height="12" fill="none" stroke="currentColor" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"><path d="M20 6 9 17l-5-5" /></svg>
|
||||
</span>
|
||||
<span class="lc-chip-label">{{ cf.label }}</span>
|
||||
<span v-if="cf.options && cf.options.length" class="lc-type" :title="cf.options.join(', ')">{{ cf.options.length }} 个选项</span>
|
||||
<span
|
||||
class="lc-req-toggle" :class="{ on: cf.required }"
|
||||
@click="toggleRequired(cf.key)"
|
||||
:title="cf.required ? '必填——点击改为选填' : '选填——点击改为必填'"
|
||||
>{{ cf.required ? '必填' : '选填' }}</span>
|
||||
<button class="lc-chip-x" title="移除" @click="removeCustomField(cf.key)">✕</button>
|
||||
</span>
|
||||
</div>
|
||||
<p class="lc-chip-hint">点击字段上的 <b>选填 / 必填</b>,控制客服是否必须拿到它才记录线索。邮箱始终必填。</p>
|
||||
<div class="lc-custom-add">
|
||||
<input class="lc-input" v-model="newFieldLabel" placeholder="自定义字段名称(例如:团队规模)" />
|
||||
<input class="lc-input" v-model="newFieldOptions" placeholder="可选值,用逗号分隔(选填)" />
|
||||
<button class="lc-add" @click="addCustomField">+ 添加字段</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Behaviour: consent + guidance -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">如何询问</h4>
|
||||
<div class="lc-toggle-row bordered">
|
||||
<div>
|
||||
<div class="lc-toggle-title">要求用户同意 <span class="lc-badge">GDPR</span></div>
|
||||
<div class="lc-toggle-desc">记录线索前,客服必须获得用户的明确同意。推荐开启。</div>
|
||||
</div>
|
||||
<button class="lc-switch" :class="{ on: requireConsent }" @click="requireConsent = !requireConsent" :aria-pressed="requireConsent">
|
||||
<span class="lc-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<label class="lc-guidance-label">引导说明 <span class="lc-optional">选填</span></label>
|
||||
<textarea
|
||||
class="lc-input lc-textarea"
|
||||
v-model="guidance"
|
||||
rows="3"
|
||||
placeholder="引导何时、如何询问——例如“优先处理定价页访客;非工作时间更主动一些”。"
|
||||
></textarea>
|
||||
</section>
|
||||
|
||||
<!-- When a lead qualifies (CRM sync live; routing coming soon) -->
|
||||
<section class="lc-card">
|
||||
<h4 class="lc-card-title">线索合格后</h4>
|
||||
<p class="lc-card-sub">把合格线索直接推送到你的 CRM。分配与 Slack 提醒将在后续版本推出。</p>
|
||||
<div class="lc-route-row">
|
||||
<span>同步到 CRM</span>
|
||||
<select class="lc-input lc-input-sm" v-model="crmSyncTarget">
|
||||
<option value="none">不同步</option>
|
||||
<option value="hubspot">HubSpot</option>
|
||||
<option value="pipedrive">Pipedrive</option>
|
||||
<option value="salesforce" disabled>Salesforce(即将推出)</option>
|
||||
</select>
|
||||
</div>
|
||||
<p v-if="crmTargetUnconnected" class="lc-crm-warning">
|
||||
{{ crmTargetName }} 尚未连接——线索不会同步,直到你
|
||||
<RouterLink to="/settings/integrations">在集成设置中连接它</RouterLink>。
|
||||
</p>
|
||||
<div class="lc-route-row">
|
||||
<span>分配给 <span class="lc-soon">即将推出</span></span>
|
||||
<select class="lc-input lc-input-sm" v-model="assignmentMode" disabled>
|
||||
<option value="none">不分配——仅记录</option>
|
||||
<option value="sales_team">销售团队</option>
|
||||
<option value="round_robin">轮询分配</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="lc-route-row">
|
||||
<span>Slack 提醒 <span class="lc-soon">即将推出</span></span>
|
||||
<input type="checkbox" v-model="slackNotifyEnabled" disabled />
|
||||
</div>
|
||||
</section>
|
||||
</template>
|
||||
|
||||
<div v-else class="lc-off">
|
||||
线索收集已关闭——该客服只回答问题。开启后即可在对话中收集线索。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- RIGHT: live preview (conversational) -->
|
||||
<div class="lc-right">
|
||||
<div class="lc-preview-head">
|
||||
<span class="lc-preview-tag">实时预览</span>
|
||||
<span class="lc-preview-mode">{{ enabled ? '含线索收集' : '仅回答问题' }}</span>
|
||||
</div>
|
||||
<div class="lc-pv-widget">
|
||||
<div class="lc-pv-topbar">
|
||||
<span class="lc-pv-logo"><i></i><i></i><i></i></span>
|
||||
<span>
|
||||
<span class="lc-pv-name">销售助手</span>
|
||||
<span class="lc-pv-online"><i></i>在线</span>
|
||||
</span>
|
||||
</div>
|
||||
<div class="lc-pv-body">
|
||||
<div class="lc-pv-msg user">你们能对接 Shopify 吗?每周约 800 单。</div>
|
||||
<div class="lc-pv-msg bot">可以——Shopify 一键安装,我还能在聊天里实时查询订单。</div>
|
||||
<template v-if="enabled">
|
||||
<div class="lc-pv-msg bot">很乐意帮你配置店铺——方便留下你的{{ firstAskLabel }},让专员跟进吗?</div>
|
||||
<div class="lc-pv-msg user">好的——jane@acme.com</div>
|
||||
<div v-if="requireConsent" class="lc-pv-msg bot">好的——可以让人通过这个方式联系你吗?</div>
|
||||
<div v-if="requireConsent" class="lc-pv-msg user">可以,没问题。</div>
|
||||
<div class="lc-pv-msg bot">太好了——我会让专员通过 jane@acme.com 联系你。👍</div>
|
||||
</template>
|
||||
<div v-else class="lc-pv-tagline">线索收集关闭 · 仅回答问题</div>
|
||||
</div>
|
||||
<div class="lc-pv-inputbar">
|
||||
<span class="lc-pv-typebox">输入消息…</span>
|
||||
<span class="lc-pv-send">↑</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="enabled" class="lc-pv-captured">
|
||||
<div class="lc-pv-captured-label">已记录为线索 →</div>
|
||||
<div class="lc-pv-chips">
|
||||
<span v-for="l in enabledFieldLabels" :key="l" class="lc-pv-fchip">{{ l }}</span>
|
||||
<span class="lc-pv-fchip summary">+ AI 摘要</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="lc-loading">正在加载线索收集设置…</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.lead-capture-tab { display: flex; flex-direction: column; gap: 18px; padding: 0 var(--space-lg); max-width: 1208px; container-type: inline-size; }
|
||||
.lc-header { display: flex; align-items: flex-start; justify-content: space-between; gap: 16px; }
|
||||
.lc-title { font-size: 20px; font-weight: 600; margin: 0 0 4px; }
|
||||
.lc-sub { font-size: 14px; color: var(--muted); margin: 0; max-width: 560px; line-height: 1.5; }
|
||||
.lc-save { flex-shrink: 0; padding: 10px 18px; background: var(--accent-solid); color: var(--on-accent-solid); border: none; border-radius: 10px; font-size: 14px; font-weight: 600; cursor: pointer; }
|
||||
.lc-save:disabled { opacity: .6; cursor: default; }
|
||||
|
||||
.lc-grid { display: grid; grid-template-columns: 1fr; gap: 24px; align-items: start; }
|
||||
.lc-left { display: flex; flex-direction: column; gap: 18px; min-width: 0; }
|
||||
.lc-right { min-width: 0; }
|
||||
@container (min-width: 880px) {
|
||||
.lc-grid { grid-template-columns: minmax(0, 1fr) 360px; }
|
||||
.lc-right { position: sticky; top: 20px; }
|
||||
}
|
||||
|
||||
.lc-card { background: var(--surface); border: 1px solid var(--border-color); border-radius: 14px; padding: 20px; }
|
||||
.lc-card-title { font-size: 15px; font-weight: 600; margin: 0 0 4px; display: flex; align-items: center; gap: 8px; }
|
||||
.lc-card-sub { font-size: 13px; color: var(--muted); margin: 0 0 14px; }
|
||||
|
||||
/* Toggle rows */
|
||||
.lc-toggle-row { display: flex; align-items: center; justify-content: space-between; gap: 16px; }
|
||||
.lc-toggle-row.bordered { padding-bottom: 16px; margin-bottom: 14px; border-bottom: 1px solid var(--border-color); }
|
||||
.lc-toggle-title { font-weight: 600; font-size: 15px; display: flex; align-items: center; gap: 8px; }
|
||||
.lc-toggle-desc { font-size: 13px; color: var(--muted); margin-top: 3px; max-width: 460px; line-height: 1.45; }
|
||||
.lc-switch { flex-shrink: 0; width: 46px; height: 26px; border-radius: 999px; border: none; background: var(--o12); padding: 3px; cursor: pointer; transition: background .16s ease; }
|
||||
.lc-switch.on { background: var(--accent-solid); }
|
||||
.lc-knob { display: block; width: 20px; height: 20px; border-radius: 50%; background: #fff; transition: transform .16s ease; box-shadow: 0 1px 2px rgba(0,0,0,.3); }
|
||||
.lc-switch.on .lc-knob { transform: translateX(20px); }
|
||||
.lc-badge { font-size: 9px; letter-spacing: .05em; padding: 3px 7px; border-radius: 999px; background: var(--purple-bg); color: var(--c-purple); font-weight: 600; }
|
||||
|
||||
.lc-input { width: 100%; margin-top: 10px; padding: 10px 12px; border: 1px solid var(--border-color); border-radius: 9px; font-size: 14px; background: var(--bg); color: var(--text); }
|
||||
.lc-input-sm { width: auto; min-width: 120px; }
|
||||
.lc-textarea { resize: vertical; line-height: 1.5; }
|
||||
.lc-guidance-label { display: block; font-size: 13px; font-weight: 500; color: var(--text3); margin-top: 4px; }
|
||||
.lc-optional { font-size: 11px; font-weight: 400; color: var(--muted2); margin-left: 4px; }
|
||||
.lc-add { margin-top: 6px; padding: 8px 14px; background: transparent; border: 1px dashed var(--border-color); border-radius: 9px; font-size: 13px; cursor: pointer; color: var(--muted); }
|
||||
|
||||
/* Field chips */
|
||||
.lc-chips { display: flex; flex-wrap: wrap; gap: 8px; }
|
||||
.lc-chip { display: inline-flex; align-items: center; gap: 8px; padding: 8px 13px 8px 9px; border-radius: 12px; border: 1px solid var(--border-color); background: var(--o05); font-size: 13.5px; cursor: pointer; color: var(--text); line-height: 1; }
|
||||
.lc-chip.on { border-color: var(--accent-border); background: var(--accent-bg-08); }
|
||||
.lc-chip.custom { cursor: default; border-color: var(--purple-border, var(--o12)); background: var(--purple-bg); }
|
||||
.lc-cbox { width: 18px; height: 18px; flex-shrink: 0; border-radius: 5px; border: 1.5px solid var(--o25); background: transparent; display: flex; align-items: center; justify-content: center; color: var(--on-accent-solid); }
|
||||
.lc-cbox.on { background: var(--accent-solid); border-color: var(--accent-solid); }
|
||||
.lc-cbox.purple.on { background: var(--c-purple); border-color: var(--c-purple); color: #fff; }
|
||||
.lc-chip-label { font-weight: 500; }
|
||||
.lc-type { font-size: 10px; padding: 2px 6px; border-radius: 5px; background: var(--o08); color: var(--muted); }
|
||||
.lc-req { font-size: 10.5px; color: var(--c-coral); }
|
||||
/* Clickable required/optional pill on each enabled field. */
|
||||
.lc-req-toggle { font-size: 10px; font-weight: 600; letter-spacing: .02em; padding: 2px 7px; border-radius: 999px; cursor: pointer; background: var(--o08); color: var(--muted); border: 1px solid transparent; user-select: none; }
|
||||
.lc-req-toggle:hover { border-color: var(--o25); }
|
||||
.lc-req-toggle.on { background: color-mix(in srgb, var(--c-coral) 16%, transparent); color: var(--c-coral); }
|
||||
.lc-chip-hint { font-size: 12px; color: var(--muted2); margin: 12px 0 0; line-height: 1.45; }
|
||||
.lc-chip-x { border: none; background: none; cursor: pointer; color: var(--muted); padding: 0 2px; font-size: 12px; }
|
||||
.lc-custom-add { display: flex; flex-wrap: wrap; gap: 8px; align-items: center; margin-top: 14px; }
|
||||
.lc-custom-add .lc-input { margin-top: 0; flex: 1; min-width: 160px; }
|
||||
|
||||
.lc-route-row { display: flex; align-items: center; justify-content: space-between; padding: 12px 0; border-bottom: 1px solid var(--border-color); font-size: 14px; }
|
||||
.lc-route-row:last-child { border-bottom: none; }
|
||||
.lc-soon { font-size: 10px; padding: 2px 8px; border-radius: 999px; background: var(--o08); color: var(--muted); font-weight: 500; }
|
||||
.lc-crm-warning { font-size: 12.5px; color: var(--c-coral); background: var(--coral-bg); border: 1px solid var(--coral-border); border-radius: var(--radius-md); padding: 8px 12px; margin: -4px 0 12px; }
|
||||
.lc-crm-warning a { color: inherit; font-weight: 600; }
|
||||
.lc-off { background: var(--o05); border: 1px dashed var(--border-color); border-radius: 14px; padding: 24px; text-align: center; font-size: 14px; color: var(--muted); }
|
||||
.lc-loading { padding: 40px; text-align: center; color: var(--muted); }
|
||||
|
||||
input[type="checkbox"] { appearance: none; -webkit-appearance: none; width: 18px; height: 18px; margin: 0; flex-shrink: 0; border-radius: 5px; border: 1.5px solid var(--o25); background: transparent; cursor: pointer; position: relative; }
|
||||
input[type="checkbox"]:checked { background: var(--accent-solid); border-color: var(--accent-solid); }
|
||||
input[type="checkbox"]:checked::after { content: ''; position: absolute; left: 5px; top: 2px; width: 4px; height: 8px; border: solid var(--on-accent-solid); border-width: 0 2px 2px 0; transform: rotate(45deg); }
|
||||
input[type="checkbox"]:disabled { opacity: .5; cursor: default; }
|
||||
|
||||
/* Live preview */
|
||||
.lc-preview-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 12px; }
|
||||
.lc-preview-tag { font-family: monospace; font-size: 11px; letter-spacing: .07em; color: var(--muted2); }
|
||||
.lc-preview-mode { font-size: 12px; color: var(--muted2); }
|
||||
.lc-pv-widget { background: var(--surface); border: 1px solid var(--border-color); border-radius: 16px; overflow: hidden; display: flex; flex-direction: column; min-height: 380px; }
|
||||
.lc-pv-topbar { display: flex; align-items: center; gap: 11px; padding: 14px 15px; border-bottom: 1px solid var(--border-color); }
|
||||
.lc-pv-logo { width: 32px; height: 32px; border-radius: 10px 10px 10px 3px; background: var(--accent-solid); display: flex; align-items: center; justify-content: center; gap: 2.5px; flex-shrink: 0; }
|
||||
.lc-pv-logo i { width: 3.5px; height: 3.5px; border-radius: 50%; background: var(--on-accent-solid); }
|
||||
.lc-pv-name { display: block; font-weight: 600; font-size: 13.5px; color: var(--text); }
|
||||
.lc-pv-online { display: flex; align-items: center; gap: 6px; font-size: 11px; color: var(--c-teal, #0E8C8C); margin-top: 2px; }
|
||||
.lc-pv-online i { width: 6px; height: 6px; border-radius: 50%; background: currentColor; }
|
||||
.lc-pv-body { flex: 1; padding: 16px 14px; display: flex; flex-direction: column; gap: 10px; overflow-y: auto; }
|
||||
.lc-pv-msg { max-width: 88%; padding: 9px 12px; font-size: 12.5px; line-height: 1.5; }
|
||||
.lc-pv-msg.user { align-self: flex-end; background: var(--o08); border-radius: 14px 14px 4px 14px; color: var(--text2); }
|
||||
.lc-pv-msg.bot { align-self: flex-start; background: var(--o05); border: 1px solid var(--border-color); border-radius: 14px 14px 14px 4px; color: var(--text2); }
|
||||
.lc-pv-tagline { align-self: center; margin-top: 6px; padding: 8px 14px; border-radius: 999px; background: var(--o05); border: 1px solid var(--border-color); font-size: 11.5px; color: var(--muted); }
|
||||
.lc-pv-inputbar { padding: 11px 13px; border-top: 1px solid var(--border-color); display: flex; align-items: center; gap: 9px; }
|
||||
.lc-pv-typebox { flex: 1; padding: 9px 12px; background: var(--bg); border: 1px solid var(--border-color); border-radius: 10px; font-size: 12.5px; color: var(--muted2); }
|
||||
.lc-pv-send { width: 36px; height: 36px; border-radius: 10px; background: var(--accent-bg-12); display: flex; align-items: center; justify-content: center; color: var(--accent-ink); font-size: 15px; flex-shrink: 0; }
|
||||
.lc-pv-captured { margin-top: 14px; background: var(--surface); border: 1px solid var(--border-color); border-radius: 13px; padding: 14px 16px; }
|
||||
.lc-pv-captured-label { font-family: monospace; font-size: 10px; letter-spacing: .07em; color: var(--muted2); margin-bottom: 9px; }
|
||||
.lc-pv-chips { display: flex; flex-wrap: wrap; gap: 7px; }
|
||||
.lc-pv-fchip { padding: 5px 11px; border-radius: 999px; background: var(--accent-bg-08); border: 1px solid var(--accent-border); font-size: 11.5px; color: var(--text3); }
|
||||
.lc-pv-fchip.summary { background: var(--purple-bg); border-color: var(--purple-border, var(--o12)); }
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,655 @@
|
||||
<!--
|
||||
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 { toast } from 'vue-sonner'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { buildWidgetEmbed } from '@/utils/widgetEmbed'
|
||||
|
||||
interface Widget {
|
||||
id: string;
|
||||
[key: string]: any;
|
||||
}
|
||||
|
||||
const props = defineProps({
|
||||
widget: {
|
||||
type: Object as () => Widget | null,
|
||||
required: true
|
||||
},
|
||||
widgetUrl: {
|
||||
type: String,
|
||||
required: true
|
||||
},
|
||||
widgetLoading: {
|
||||
type: Boolean,
|
||||
required: true
|
||||
},
|
||||
agent: {
|
||||
type: Object as () => Agent,
|
||||
required: true
|
||||
}
|
||||
})
|
||||
|
||||
const emit = defineEmits(['copy-widget-code', 'copy-iframe-code', 'copy-backend-code'])
|
||||
|
||||
// Check if token authentication is required
|
||||
const requiresTokenAuth = computed(() => {
|
||||
return props.agent?.require_token_auth ?? false
|
||||
})
|
||||
|
||||
// The exact snippet the Copy button produces (see buildWidgetEmbed) — rendered
|
||||
// in the preview so what you see always matches what you copy.
|
||||
const embedCode = computed(() => buildWidgetEmbed(props.widget?.id ?? '', requiresTokenAuth.value))
|
||||
|
||||
const copyWidgetCode = () => {
|
||||
emit('copy-widget-code')
|
||||
}
|
||||
|
||||
const copyIframeCode = () => {
|
||||
emit('copy-iframe-code')
|
||||
}
|
||||
|
||||
const copyBackendCode = () => {
|
||||
emit('copy-backend-code')
|
||||
}
|
||||
|
||||
// Check if this is an "Ask Anything" style agent
|
||||
const isAskAnythingStyle = computed(() => {
|
||||
return props.agent?.customization?.chat_style === 'ASK_ANYTHING'
|
||||
})
|
||||
|
||||
// Per-page overrides + JS control examples. Placement defaults live in the
|
||||
// Customization tab; these snippets are for developers who need more.
|
||||
const configExampleCode = `<script>
|
||||
window.replypierConfig = {
|
||||
displayMode: 'sidebar-right', // 'floating' | 'sidebar-left' | 'sidebar-right' | 'search-bar'
|
||||
position: { side: 'right', bottom: 20, offset: 20 },
|
||||
width: 400, // floating window size
|
||||
height: 560,
|
||||
launcher: false, // hide the built-in launcher
|
||||
trigger: '#my-chat-button', // your own element opens the chat
|
||||
zIndex: 999999
|
||||
};
|
||||
<\/script>`
|
||||
|
||||
const jsApiExampleCode = `// From your own code:
|
||||
ReplyPier.open(); // open({ message: 'Hi!' }) prefills the input
|
||||
ReplyPier.close();
|
||||
ReplyPier.toggle();
|
||||
ReplyPier.on('unread', (count) => { /* badge your own button */ });
|
||||
ReplyPier.on('ready', () => { /* widget loaded */ });
|
||||
|
||||
// Single-page apps — switch identity without reloading the page:
|
||||
ReplyPier.identify(token); // after login (token from your backend)
|
||||
ReplyPier.logout(); // after logout: back to anonymous
|
||||
ReplyPier.destroy(); // remove the widget from the page
|
||||
ReplyPier.reload(); // rebuild it (also brings back a destroyed widget)
|
||||
|
||||
// Or with no JavaScript at all:
|
||||
<button data-replypier-open>与我们聊天</button>`
|
||||
|
||||
const copyText = (text: string) => {
|
||||
if (!navigator.clipboard) {
|
||||
toast.error('剪贴板不可用——请手动复制代码')
|
||||
return
|
||||
}
|
||||
navigator.clipboard.writeText(text)
|
||||
.then(() => toast.success('已复制到剪贴板'))
|
||||
.catch(() => toast.error('复制失败——请手动复制代码'))
|
||||
}
|
||||
|
||||
// Generate iframe URL
|
||||
const iframeUrl = computed(() => {
|
||||
if (!props.widget?.id) return ''
|
||||
return `${props.widgetUrl}/api/v1/widgets/${props.widget.id}/data`
|
||||
})
|
||||
|
||||
// Generate iframe embed code
|
||||
const iframeEmbedCode = computed(() => {
|
||||
if (!props.widget?.id) return ''
|
||||
return `<iframe src="${iframeUrl.value}" width="100%" height="600" frameborder="0" title="AI 助手" allow="clipboard-write"></iframe>`
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="widget-tab">
|
||||
<section class="detail-section">
|
||||
<h3 class="section-title">组件集成</h3>
|
||||
<p class="section-description">
|
||||
复制下面的代码粘贴到 HTML 中,即可把客服加到任意网站。
|
||||
</p>
|
||||
|
||||
<div class="widget-info">
|
||||
<h4 class="widget-section-title">嵌入代码</h4>
|
||||
<div v-if="widgetLoading" class="loading-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
正在加载组件信息…
|
||||
</div>
|
||||
<div v-else-if="widget" class="widget-code-section">
|
||||
<!-- Token-based authentication (when require_token_auth is enabled) -->
|
||||
<template v-if="requiresTokenAuth">
|
||||
<div class="auth-badge">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 15V17M6 21H18C19.1046 21 20 20.1046 20 19V13C20 11.8954 19.1046 11 18 11H6C4.89543 11 4 11.8954 4 13V19C4 20.1046 4.89543 21 6 21ZM16 11V7C16 4.79086 14.2091 3 12 3C9.79086 3 8 4.79086 8 7V11H16Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
需要 Token 认证
|
||||
</div>
|
||||
|
||||
<p class="code-description">
|
||||
该客服要求 Token 认证。加载组件前,需由你的后端调用 ReplyPier API 生成 Token。
|
||||
</p>
|
||||
|
||||
<!-- Step 1: Get API Key -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">1</span>
|
||||
<h5 class="step-title">获取 API Key</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
前往 <strong>设置 > 组件应用</strong> 创建组件应用并获取 API Key。
|
||||
请妥善保管,只能在服务端使用。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Backend Token Generation -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">2</span>
|
||||
<h5 class="step-title">生成 Token(服务端)</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
创建一个后端接口,调用 ReplyPier API 生成 Token:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>// Your backend endpoint (e.g., /api/chat-token)
|
||||
const response = await fetch('{{ widgetUrl }}/api/v1/generate-token', {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
'Authorization': 'Bearer YOUR_API_KEY' // From Widget Apps
|
||||
},
|
||||
body: JSON.stringify({
|
||||
widget_id: '{{ widget.id }}',
|
||||
customer_email: 'user@example.com', // Optional
|
||||
ttl_seconds: 3600 // Token validity (1 hour)
|
||||
})
|
||||
});
|
||||
|
||||
const { data } = await response.json();
|
||||
// Return data.token to your frontend</code></pre>
|
||||
<button class="copy-button" @click="copyBackendCode" title="复制后端代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Step 3: Frontend Integration -->
|
||||
<div class="setup-step">
|
||||
<div class="step-header">
|
||||
<span class="step-number">3</span>
|
||||
<h5 class="step-title">加入网站(客户端)</h5>
|
||||
</div>
|
||||
<p class="step-description">
|
||||
把这段代码加入 HTML,并将 <code>/api/replypier</code> 换成你的后端接口:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ embedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyWidgetCode" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="info-box warning">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M12 9V13M12 17H12.01M5.07183 19H18.9282C20.4678 19 21.4301 17.3333 20.6603 16L13.7321 4C12.9623 2.66667 11.0377 2.66667 10.2679 4L3.33975 16C2.56995 17.3333 3.53223 19 5.07183 19Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p><strong>安全提示:</strong>切勿在前端代码中暴露 API Key,Token 生成必须在服务端完成。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<!-- Simple integration (no token auth required) -->
|
||||
<template v-else>
|
||||
<p class="code-description">
|
||||
把这段代码加到网站 HTML 中,放在 <code></body></code> 标签之前:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ embedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyWidgetCode" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<div class="info-box">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<circle cx="12" cy="12" r="10" stroke="currentColor" stroke-width="2"/>
|
||||
<path d="M12 16V12M12 8H12.01" stroke="currentColor" stroke-width="2" stroke-linecap="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p>组件会以聊天按钮形式出现在网站右下角,无需任何认证配置。</p>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Per-page overrides + JS API -->
|
||||
<div class="widget-info">
|
||||
<h4 class="widget-section-title">自定义与控制(选填)</h4>
|
||||
<div class="widget-code-section">
|
||||
<p class="code-description">
|
||||
位置、尺寸和启动器显隐在 <strong>自定义</strong> 标签页中设置。
|
||||
如需在特定页面覆盖,请在加载脚本前设置 <code>window.replypierConfig</code>:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ configExampleCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyText(configExampleCode)" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<p class="code-description">
|
||||
用自己的按钮打开、关闭并响应组件:
|
||||
</p>
|
||||
<div class="code-block">
|
||||
<pre><code>{{ jsApiExampleCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyText(jsApiExampleCode)" title="复制到剪贴板">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Iframe Integration Section for Ask Anything Style -->
|
||||
<div v-if="isAskAnythingStyle && !requiresTokenAuth" class="widget-info">
|
||||
<h4 class="widget-section-title">Iframe 集成</h4>
|
||||
<div v-if="widgetLoading" class="loading-indicator">
|
||||
<div class="loading-spinner"></div>
|
||||
正在加载 iframe 信息…
|
||||
</div>
|
||||
<div v-else-if="widget" class="widget-code-section">
|
||||
<p class="code-description">
|
||||
对于“随便问”风格的客服,可以把聊天界面直接嵌入页面:
|
||||
</p>
|
||||
|
||||
<!-- Iframe Preview -->
|
||||
<div class="iframe-preview-section">
|
||||
<h5 class="preview-title">预览</h5>
|
||||
<div class="iframe-preview-container">
|
||||
<iframe
|
||||
:src="iframeUrl"
|
||||
class="iframe-preview"
|
||||
frameborder="0"
|
||||
title="AI 助手预览"
|
||||
allow="clipboard-write"
|
||||
></iframe>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Iframe Code -->
|
||||
<div class="code-block">
|
||||
<pre><code>{{ iframeEmbedCode }}</code></pre>
|
||||
<button class="copy-button" @click="copyIframeCode" title="复制 iframe 代码">
|
||||
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M8 4V16C8 17.1046 8.89543 18 10 18H18C19.1046 18 20 17.1046 20 16V7.41421C20 6.88378 19.7893 6.37507 19.4142 6L16 2.58579C15.6249 2.21071 15.1162 2 14.5858 2H10C8.89543 2 8 2.89543 8 4Z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
<path d="M16 18V20C16 21.1046 15.1046 22 14 22H6C4.89543 22 4 21.1046 4 20V8C4 6.89543 4.89543 6 6 6H8" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="info-box">
|
||||
<div class="info-icon">
|
||||
<svg width="20" height="20" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
|
||||
<path d="M9.663 17h4.673M12 3v1m6.364 1.636l-.707.707M21 12h-1M4 12H3m3.343-5.657l-.707-.707m2.828 9.9a5 5 0 117.072 0l-.548.547A3.374 3.374 0 0014 18.469V19a2 2 0 11-4 0v-.531c0-.895-.356-1.754-.988-2.386l-.548-.547z" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="info-content">
|
||||
<p><strong>提示:</strong>非常适合嵌入帮助中心、联系表单或专属支持页面。</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.widget-tab {
|
||||
max-width: 900px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 20px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
margin-bottom: 22px;
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.widget-section-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-xl);
|
||||
width: 100%;
|
||||
border: 1px solid var(--o08);
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
.widget-code-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: var(--space-lg);
|
||||
}
|
||||
|
||||
/* Auth badge for token authentication */
|
||||
.auth-badge {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
border-radius: var(--radius-chip);
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 500;
|
||||
width: fit-content;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.code-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.6;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.code-description code {
|
||||
background: var(--o06);
|
||||
padding: 2px 6px;
|
||||
border-radius: 4px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 0.9em;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
/* Setup steps */
|
||||
.setup-step {
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-md);
|
||||
padding: var(--space-lg);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.step-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
margin-bottom: var(--space-sm);
|
||||
}
|
||||
|
||||
.step-number {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 50%;
|
||||
font-size: var(--text-sm);
|
||||
font-weight: 600;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-size: 16px;
|
||||
font-weight: 600;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.step-description {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
line-height: 1.6;
|
||||
margin: 0 0 var(--space-md) 0;
|
||||
padding-left: calc(28px + var(--space-md));
|
||||
}
|
||||
|
||||
.step-description strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Code block styling */
|
||||
.code-block {
|
||||
position: relative;
|
||||
background: var(--bg-deep);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 14px;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
margin: 0;
|
||||
padding: 20px;
|
||||
overflow-x: auto;
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.7;
|
||||
color: var(--code);
|
||||
white-space: pre;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.code-block .copy-button {
|
||||
position: absolute;
|
||||
top: var(--space-sm);
|
||||
right: var(--space-sm);
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-chip);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.code-block .copy-button:hover {
|
||||
background: var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.copy-button {
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
padding: var(--space-sm);
|
||||
border-radius: var(--radius-chip);
|
||||
cursor: pointer;
|
||||
color: var(--muted);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.copy-button:hover {
|
||||
background: var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.loading-indicator {
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
padding: var(--space-xl);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
gap: var(--space-sm);
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border: 2px solid var(--o10);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-ink);
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Info box */
|
||||
.info-box {
|
||||
display: flex;
|
||||
background: var(--bg-deep);
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border-radius: var(--radius-md);
|
||||
border-left: 3px solid var(--accent-ink);
|
||||
gap: var(--space-md);
|
||||
align-items: flex-start;
|
||||
}
|
||||
|
||||
.info-box.warning {
|
||||
border-left-color: var(--c-teal);
|
||||
background: var(--teal-bg);
|
||||
}
|
||||
|
||||
.info-box .info-icon {
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.info-box.warning .info-icon {
|
||||
color: var(--c-teal);
|
||||
}
|
||||
|
||||
.info-content p {
|
||||
margin: 0;
|
||||
font-size: var(--text-sm);
|
||||
color: var(--muted);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.info-content strong {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
/* Iframe integration styles */
|
||||
.iframe-preview-section {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
.preview-title {
|
||||
font-size: 0.9rem;
|
||||
font-weight: 600;
|
||||
color: var(--muted);
|
||||
margin-bottom: var(--space-md);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.iframe-preview-container {
|
||||
background: var(--bg-deep);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.iframe-preview {
|
||||
width: 100%;
|
||||
height: 450px;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--bg2);
|
||||
display: block;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
.widget-tab {
|
||||
padding: 0 var(--space-md);
|
||||
}
|
||||
|
||||
.widget-info {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.step-description {
|
||||
padding-left: 0;
|
||||
margin-top: var(--space-sm);
|
||||
}
|
||||
|
||||
.code-block pre {
|
||||
padding: var(--space-md);
|
||||
}
|
||||
|
||||
.code-block code {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.iframe-preview {
|
||||
height: 350px;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,864 @@
|
||||
<!--
|
||||
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 { ref, onMounted, computed } from 'vue'
|
||||
import { useAgentWorkflow } from '@/composables/useAgentWorkflow'
|
||||
import type { AgentWithCustomization } from '@/types/agent'
|
||||
import WorkflowBuilder from '../workflow/WorkflowBuilder.vue'
|
||||
import { workflowCacheStorage } from '@/utils/storage'
|
||||
|
||||
const props = defineProps<{
|
||||
agent: AgentWithCustomization
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
workflow,
|
||||
workflowLoading,
|
||||
workflowError,
|
||||
createWorkflowLoading,
|
||||
hasWorkflow,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow
|
||||
} = useAgentWorkflow(props.agent.id)
|
||||
|
||||
// Create workflow form state
|
||||
const showCreateForm = ref(false)
|
||||
const workflowName = ref('')
|
||||
const workflowDescription = ref('')
|
||||
|
||||
// Edit workflow form state
|
||||
const showEditForm = ref(false)
|
||||
const editWorkflowName = ref('')
|
||||
const editWorkflowDescription = ref('')
|
||||
|
||||
// Workflow builder state
|
||||
const showWorkflowBuilder = ref(false)
|
||||
|
||||
// Computed properties
|
||||
const workflowStatus = computed(() => {
|
||||
if (!workflow.value) return 'No workflow'
|
||||
return workflow.value.status || 'Draft'
|
||||
})
|
||||
|
||||
const workflowStatusColor = computed(() => {
|
||||
const status = workflowStatus.value.toLowerCase()
|
||||
switch (status) {
|
||||
case 'published':
|
||||
return 'success'
|
||||
case 'draft':
|
||||
return 'warning'
|
||||
case 'archived':
|
||||
return 'muted'
|
||||
default:
|
||||
return 'muted'
|
||||
}
|
||||
})
|
||||
|
||||
const formatDate = (dateString: string) => {
|
||||
return new Date(dateString).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit'
|
||||
})
|
||||
}
|
||||
|
||||
const handleCreateWorkflow = async () => {
|
||||
if (!workflowName.value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await createWorkflow({
|
||||
name: workflowName.value.trim(),
|
||||
description: workflowDescription.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
workflowName.value = ''
|
||||
workflowDescription.value = ''
|
||||
showCreateForm.value = false
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
|
||||
const handleEditWorkflow = () => {
|
||||
if (!workflow.value) return
|
||||
|
||||
// Pre-populate form with current values
|
||||
editWorkflowName.value = workflow.value.name
|
||||
editWorkflowDescription.value = workflow.value.description || ''
|
||||
showEditForm.value = true
|
||||
showCreateForm.value = false // Ensure create form is hidden
|
||||
}
|
||||
|
||||
const handleUpdateWorkflow = async () => {
|
||||
if (!workflow.value || !editWorkflowName.value.trim()) {
|
||||
return
|
||||
}
|
||||
|
||||
try {
|
||||
await updateWorkflow(workflow.value.id, {
|
||||
name: editWorkflowName.value.trim(),
|
||||
description: editWorkflowDescription.value.trim() || undefined
|
||||
})
|
||||
|
||||
// Reset form
|
||||
editWorkflowName.value = ''
|
||||
editWorkflowDescription.value = ''
|
||||
showEditForm.value = false
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
|
||||
const cancelEditForm = () => {
|
||||
editWorkflowName.value = ''
|
||||
editWorkflowDescription.value = ''
|
||||
showEditForm.value = false
|
||||
}
|
||||
|
||||
const handleDeleteWorkflow = async () => {
|
||||
if (!workflow.value) return
|
||||
|
||||
if (confirm('确定要删除该工作流吗?此操作无法撤销。')) {
|
||||
try {
|
||||
await deleteWorkflow(workflow.value.id)
|
||||
} catch (error) {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const toggleFullscreen = () => {
|
||||
emit('toggle-fullscreen', true)
|
||||
}
|
||||
|
||||
const openWorkflowBuilder = () => {
|
||||
// Clear existing cache and reload fresh data when opening workflow builder
|
||||
if (workflow.value) {
|
||||
workflowCacheStorage.clearWorkflowCache(workflow.value.id)
|
||||
}
|
||||
showWorkflowBuilder.value = true
|
||||
}
|
||||
|
||||
const closeWorkflowBuilder = () => {
|
||||
showWorkflowBuilder.value = false
|
||||
}
|
||||
|
||||
const handleWorkflowSave = () => {
|
||||
// Refresh workflow data after saving
|
||||
fetchWorkflow()
|
||||
showWorkflowBuilder.value = false
|
||||
}
|
||||
|
||||
const cancelCreateForm = () => {
|
||||
workflowName.value = ''
|
||||
workflowDescription.value = ''
|
||||
showCreateForm.value = false
|
||||
}
|
||||
|
||||
// Fetch workflow on mount
|
||||
onMounted(() => {
|
||||
fetchWorkflow()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="workflow-tab">
|
||||
<section class="detail-section">
|
||||
<div class="wf-header">
|
||||
<h3 class="section-title">工作流编排</h3>
|
||||
<p class="section-description">
|
||||
创建并管理对话工作流,用拖拽式编排器设计自定义对话流程。
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<!-- Loading State -->
|
||||
<div v-if="workflowLoading && !hasWorkflow" class="loading-container">
|
||||
<div class="loading-spinner"></div>
|
||||
<p class="loading-text">正在加载工作流…</p>
|
||||
</div>
|
||||
|
||||
<!-- Error State -->
|
||||
<div v-else-if="workflowError && !hasWorkflow" class="error-container">
|
||||
<div class="error-icon">⚠️</div>
|
||||
<p class="error-message">{{ workflowError }}</p>
|
||||
<button class="retry-button" @click="fetchWorkflow">重试</button>
|
||||
</div>
|
||||
|
||||
<!-- No Workflow State -->
|
||||
<div v-else-if="!hasWorkflow && !showCreateForm && !showEditForm" class="no-workflow-container">
|
||||
<div class="no-workflow-icon">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2">
|
||||
<rect x="3" y="3" width="6" height="6"/>
|
||||
<rect x="15" y="3" width="6" height="6"/>
|
||||
<rect x="9" y="15" width="6" height="6"/>
|
||||
<path d="M6 9v3a3 3 0 0 0 3 3h6a3 3 0 0 0 3-3V9"/>
|
||||
<path d="M12 15v-3"/>
|
||||
</svg>
|
||||
</div>
|
||||
<h4 class="no-workflow-title">尚未创建工作流</h4>
|
||||
<p class="no-workflow-description">
|
||||
创建工作流,用拖拽界面设计自定义对话流程。
|
||||
</p>
|
||||
<button class="create-workflow-button" @click="showCreateForm = true; showEditForm = false">
|
||||
+ 创建工作流
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<!-- Create Workflow Form -->
|
||||
<div v-else-if="showCreateForm" class="create-form-container">
|
||||
<div class="form-header">
|
||||
<h4 class="form-title">创建工作流</h4>
|
||||
<button class="close-form-button" @click="cancelCreateForm" aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleCreateWorkflow" class="create-form">
|
||||
<div class="form-group">
|
||||
<label for="workflow-name" class="form-label">工作流名称 <span class="req">*</span></label>
|
||||
<input
|
||||
id="workflow-name"
|
||||
v-model="workflowName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入工作流名称"
|
||||
required
|
||||
:disabled="createWorkflowLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="workflow-description" class="form-label">描述</label>
|
||||
<textarea
|
||||
id="workflow-description"
|
||||
v-model="workflowDescription"
|
||||
class="form-textarea"
|
||||
placeholder="描述工作流的用途(选填)"
|
||||
rows="3"
|
||||
:disabled="createWorkflowLoading"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="cancelCreateForm"
|
||||
:disabled="createWorkflowLoading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
:disabled="!workflowName.trim() || createWorkflowLoading"
|
||||
>
|
||||
<div v-if="createWorkflowLoading" class="button-spinner"></div>
|
||||
<span v-else>创建工作流</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Edit Workflow Form -->
|
||||
<div v-else-if="showEditForm" class="create-form-container">
|
||||
<div class="form-header">
|
||||
<h4 class="form-title">重命名工作流</h4>
|
||||
<button class="close-form-button" @click="cancelEditForm" aria-label="关闭">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form @submit.prevent="handleUpdateWorkflow" class="create-form">
|
||||
<div class="form-group">
|
||||
<label for="edit-workflow-name" class="form-label">工作流名称 <span class="req">*</span></label>
|
||||
<input
|
||||
id="edit-workflow-name"
|
||||
v-model="editWorkflowName"
|
||||
type="text"
|
||||
class="form-input"
|
||||
placeholder="输入工作流名称"
|
||||
required
|
||||
:disabled="workflowLoading"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group">
|
||||
<label for="edit-workflow-description" class="form-label">描述</label>
|
||||
<textarea
|
||||
id="edit-workflow-description"
|
||||
v-model="editWorkflowDescription"
|
||||
class="form-textarea"
|
||||
placeholder="描述工作流的用途(选填)"
|
||||
rows="3"
|
||||
:disabled="workflowLoading"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="cancelEditForm"
|
||||
:disabled="workflowLoading"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="submit-button"
|
||||
:disabled="!editWorkflowName.trim() || workflowLoading"
|
||||
>
|
||||
<div v-if="workflowLoading" class="button-spinner"></div>
|
||||
<span v-else>保存修改</span>
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
|
||||
<!-- Existing Workflow -->
|
||||
<div v-else-if="hasWorkflow && !showEditForm" class="workflow-container">
|
||||
<div class="workflow-card">
|
||||
<div class="workflow-info">
|
||||
<div class="workflow-main">
|
||||
<span class="workflow-name">{{ workflow!.name }}</span>
|
||||
<span class="workflow-status" :class="`status-${workflowStatusColor}`">
|
||||
{{ workflowStatus }}
|
||||
</span>
|
||||
</div>
|
||||
<p v-if="workflow!.description" class="workflow-description">
|
||||
{{ workflow!.description }}
|
||||
</p>
|
||||
<div class="workflow-meta">
|
||||
<span class="workflow-date">创建于 {{ formatDate(workflow!.created_at) }}</span>
|
||||
<span class="workflow-separator">·</span>
|
||||
<span class="workflow-date">更新于 {{ formatDate(workflow!.updated_at) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="workflow-actions">
|
||||
<button class="action-button primary" @click="openWorkflowBuilder">
|
||||
⛶ 打开编排器
|
||||
</button>
|
||||
|
||||
<button class="action-button secondary" @click="handleEditWorkflow">
|
||||
✎ 重命名
|
||||
</button>
|
||||
|
||||
<button class="action-button danger" @click="handleDeleteWorkflow">
|
||||
删除
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- Workflow Builder -->
|
||||
<WorkflowBuilder
|
||||
v-if="showWorkflowBuilder && workflow"
|
||||
:workflow="workflow"
|
||||
@close="closeWorkflowBuilder"
|
||||
@save="handleWorkflowSave"
|
||||
/>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.workflow-tab {
|
||||
max-width: 1200px;
|
||||
margin: 0 auto;
|
||||
width: 100%;
|
||||
padding: 0 var(--space-lg);
|
||||
}
|
||||
|
||||
.detail-section {
|
||||
margin-bottom: var(--space-xl);
|
||||
}
|
||||
|
||||
/* Header */
|
||||
.wf-header {
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.section-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
margin: 0 0 6px;
|
||||
}
|
||||
|
||||
.section-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
max-width: 600px;
|
||||
line-height: 1.55;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
/* Loading State */
|
||||
.loading-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
background: var(--surface);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.loading-spinner {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
border: 3px solid var(--o10);
|
||||
border-radius: 50%;
|
||||
border-top-color: var(--accent-ink);
|
||||
animation: spin 1s linear infinite;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-text {
|
||||
color: var(--muted);
|
||||
font-size: var(--text-sm);
|
||||
}
|
||||
|
||||
@keyframes spin {
|
||||
to {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
|
||||
/* Error State */
|
||||
.error-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: var(--space-xl);
|
||||
background: var(--coral-bg);
|
||||
border-radius: var(--radius-lg);
|
||||
border: 1px solid var(--error-color);
|
||||
}
|
||||
|
||||
.error-icon {
|
||||
font-size: 2rem;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
color: var(--error-color);
|
||||
font-size: var(--text-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.retry-button {
|
||||
padding: var(--space-sm) var(--space-md);
|
||||
background: var(--error-color);
|
||||
color: var(--on-dark);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
cursor: pointer;
|
||||
font-size: var(--text-sm);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.retry-button:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
/* No Workflow State */
|
||||
.no-workflow-container {
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 64px 30px;
|
||||
background: var(--surface);
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.no-workflow-icon {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
margin: 0 auto 14px;
|
||||
color: var(--muted);
|
||||
opacity: 0.7;
|
||||
}
|
||||
|
||||
.no-workflow-icon svg {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.no-workflow-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 20px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.no-workflow-description {
|
||||
color: var(--muted);
|
||||
font-size: 14px;
|
||||
line-height: 1.5;
|
||||
max-width: 420px;
|
||||
margin: 6px 0 20px;
|
||||
}
|
||||
|
||||
.create-workflow-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--space-sm);
|
||||
padding: 12px 22px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-workflow-button:hover {
|
||||
filter: brightness(1.05);
|
||||
transform: translateY(-1px);
|
||||
}
|
||||
|
||||
/* Create / Rename Form */
|
||||
.create-form-container {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 28px;
|
||||
}
|
||||
|
||||
.form-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-bottom: 22px;
|
||||
}
|
||||
|
||||
.form-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 19px;
|
||||
color: var(--text);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-form-button {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 9px;
|
||||
color: var(--muted);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.close-form-button:hover {
|
||||
background: var(--o10);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.create-form {
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: 18px;
|
||||
}
|
||||
|
||||
.form-group:last-of-type {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
|
||||
.form-label {
|
||||
display: block;
|
||||
font-size: 13.5px;
|
||||
color: var(--text3);
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.form-label .req {
|
||||
color: var(--c-coral);
|
||||
}
|
||||
|
||||
.form-input,
|
||||
.form-textarea {
|
||||
width: 100%;
|
||||
padding: 13px 15px;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-btn);
|
||||
background: var(--bg);
|
||||
color: var(--text);
|
||||
font-size: 14.5px;
|
||||
font-family: var(--font-sans);
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
.form-input:focus,
|
||||
.form-textarea:focus {
|
||||
outline: none;
|
||||
border-color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.form-textarea {
|
||||
resize: vertical;
|
||||
line-height: 1.5;
|
||||
min-height: 80px;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 12px;
|
||||
justify-content: flex-end;
|
||||
margin-top: 22px;
|
||||
}
|
||||
|
||||
.cancel-button,
|
||||
.submit-button {
|
||||
border-radius: var(--radius-btn);
|
||||
font-family: var(--font-sans);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-xs);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: 12px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.submit-button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent-solid);
|
||||
border: none;
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.submit-button:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.submit-button:disabled {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.button-spinner {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border: 2px solid transparent;
|
||||
border-top: 2px solid currentColor;
|
||||
border-radius: 50%;
|
||||
animation: spin 1s linear infinite;
|
||||
}
|
||||
|
||||
/* Workflow Card */
|
||||
.workflow-container {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflow-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-lg);
|
||||
padding: 20px 24px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 20px;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.workflow-info {
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.workflow-main {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.workflow-name {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 17px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.workflow-description {
|
||||
color: var(--muted);
|
||||
font-size: 13px;
|
||||
line-height: 1.4;
|
||||
margin: 6px 0 0;
|
||||
display: -webkit-box;
|
||||
-webkit-line-clamp: 2;
|
||||
-webkit-box-orient: vertical;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.workflow-meta {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
flex-wrap: wrap;
|
||||
margin-top: 6px;
|
||||
}
|
||||
|
||||
.workflow-status {
|
||||
font-family: var(--font-mono);
|
||||
font-size: 10.5px;
|
||||
padding: 3px 9px;
|
||||
border-radius: var(--radius-pill);
|
||||
letter-spacing: 0.06em;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.workflow-status.status-success {
|
||||
background: color-mix(in srgb, var(--success-color) 15%, transparent);
|
||||
color: var(--success-color);
|
||||
}
|
||||
|
||||
.workflow-status.status-warning {
|
||||
background: color-mix(in srgb, var(--warning-color) 15%, transparent);
|
||||
color: var(--warning-color);
|
||||
}
|
||||
|
||||
.workflow-status.status-muted {
|
||||
background: var(--o05);
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.workflow-date {
|
||||
color: var(--muted2);
|
||||
font-size: 12.5px;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.workflow-separator {
|
||||
color: var(--muted2);
|
||||
font-size: 12.5px;
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.action-button {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
border-radius: 10px;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 13.5px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.action-button.primary {
|
||||
padding: 10px 18px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.action-button.primary:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.action-button.secondary {
|
||||
padding: 10px 16px;
|
||||
background: var(--o05);
|
||||
color: var(--text);
|
||||
border: 1px solid var(--o14);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-button.secondary:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.action-button.danger {
|
||||
padding: 10px 16px;
|
||||
background: transparent;
|
||||
color: var(--c-coral);
|
||||
border: 1px solid var(--coral-border);
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.action-button.danger:hover {
|
||||
background: color-mix(in srgb, var(--c-coral) 12%, transparent);
|
||||
}
|
||||
|
||||
/* Responsive design */
|
||||
@media (max-width: 768px) {
|
||||
.workflow-card {
|
||||
flex-direction: column;
|
||||
align-items: flex-start;
|
||||
gap: var(--space-md);
|
||||
}
|
||||
|
||||
.workflow-actions {
|
||||
width: 100%;
|
||||
justify-content: flex-start;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,358 @@
|
||||
<!--
|
||||
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 type { Agent } from '@/types/agent'
|
||||
import { useAgentCreate } from '@/composables/useAgentCreate'
|
||||
|
||||
const {
|
||||
agentName,
|
||||
useWorkflow,
|
||||
isCreating,
|
||||
error,
|
||||
createAgent
|
||||
} = useAgentCreate()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'close'): void
|
||||
(e: 'created', agent: Agent): void
|
||||
(e: 'openWorkflowBuilder', agent: Agent): void
|
||||
}>()
|
||||
|
||||
const handleCreateAgent = async () => {
|
||||
const newAgent = await createAgent()
|
||||
if (newAgent) {
|
||||
emit('created', newAgent)
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="modal-overlay">
|
||||
<div class="modal-content" role="dialog" aria-modal="true" aria-labelledby="create-agent-title">
|
||||
<div class="modal-header">
|
||||
<h3 id="create-agent-title">创建客服</h3>
|
||||
<button type="button" class="close-button" aria-label="关闭" @click="$emit('close')">×</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<form @submit.prevent="handleCreateAgent">
|
||||
<div class="form-group">
|
||||
<label for="agent-name">客服名称</label>
|
||||
<input
|
||||
id="agent-name"
|
||||
type="text"
|
||||
v-model="agentName"
|
||||
placeholder="例如:客户支持客服"
|
||||
:disabled="isCreating"
|
||||
required
|
||||
>
|
||||
<div class="help-text">
|
||||
为客服起个名字,访客会看到这个名称。
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mode-selection">
|
||||
<label>客服模式</label>
|
||||
<div class="mode-options">
|
||||
<div
|
||||
class="mode-option"
|
||||
:class="{ 'selected': !useWorkflow }"
|
||||
@click="useWorkflow = false"
|
||||
>
|
||||
<div class="mode-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<path d="M12 3l1.9 4.6L18.5 9l-4.6 1.4L12 15l-1.9-4.6L5.5 9l4.6-1.4z"/>
|
||||
<path d="M19 14l.7 1.8L21.5 16.5l-1.8.7L19 19l-.7-1.8L16.5 16.5l1.8-.7z"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mode-details">
|
||||
<h4>AI 自动模式</h4>
|
||||
<p>让 AI 依据指令自然应答</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div
|
||||
class="mode-option"
|
||||
:class="{ 'selected': useWorkflow }"
|
||||
@click="useWorkflow = true"
|
||||
>
|
||||
<div class="mode-icon">
|
||||
<svg width="22" height="22" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round">
|
||||
<circle cx="6" cy="6" r="2.4"/>
|
||||
<circle cx="6" cy="18" r="2.4"/>
|
||||
<circle cx="18" cy="9" r="2.4"/>
|
||||
<path d="M8.4 6H13a3 3 0 0 1 3 3M6 8.4v7.2"/>
|
||||
</svg>
|
||||
</div>
|
||||
<div class="mode-details">
|
||||
<h4>工作流编排</h4>
|
||||
<p>拖拽搭建自定义对话流程</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div v-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div class="form-actions">
|
||||
<button
|
||||
type="button"
|
||||
class="cancel-button"
|
||||
@click="$emit('close')"
|
||||
:disabled="isCreating"
|
||||
>
|
||||
取消
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
class="create-button"
|
||||
:disabled="isCreating"
|
||||
>
|
||||
{{ isCreating ? '创建中…' : '创建客服' }}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.modal-overlay {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
background-color: rgba(255, 255, 255, 0.5);
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
z-index: 1000;
|
||||
}
|
||||
|
||||
.modal-content {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: 24px;
|
||||
width: 90%;
|
||||
max-width: 600px;
|
||||
max-height: 90vh;
|
||||
overflow-y: auto;
|
||||
box-shadow: 0 12px 48px rgba(0, 0, 0, 0.2);
|
||||
animation: modal-appear 0.3s ease-out;
|
||||
}
|
||||
|
||||
@keyframes modal-appear {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(20px);
|
||||
}
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.modal-header {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
padding: var(--space-lg) var(--space-lg) var(--space-md);
|
||||
border-bottom: 1px solid var(--border-color);
|
||||
}
|
||||
|
||||
.modal-header h3 {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
color: var(--text-color);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.close-button {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 1.5rem;
|
||||
cursor: pointer;
|
||||
color: var(--text-muted);
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.close-button:hover {
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
.modal-body {
|
||||
padding: var(--space-lg);
|
||||
}
|
||||
|
||||
.form-group {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
|
||||
label {
|
||||
display: block;
|
||||
margin-bottom: var(--space-xs);
|
||||
font-weight: 500;
|
||||
color: var(--text-color);
|
||||
}
|
||||
|
||||
input {
|
||||
width: 100%;
|
||||
padding: var(--space-md);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
color: var(--text-color);
|
||||
font-size: 1rem;
|
||||
transition: border-color 0.2s ease;
|
||||
}
|
||||
|
||||
input:focus {
|
||||
outline: none;
|
||||
border-color: var(--primary-color);
|
||||
box-shadow: 0 0 0 2px var(--primary-soft);
|
||||
}
|
||||
|
||||
.help-text {
|
||||
font-size: 0.875rem;
|
||||
color: var(--text-muted);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
|
||||
.error-message {
|
||||
background-color: var(--error-soft);
|
||||
color: var(--error-color);
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-md);
|
||||
margin-bottom: var(--space-lg);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.mode-options {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-md);
|
||||
border: 2px solid var(--border-color);
|
||||
border-radius: var(--radius-lg);
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-option.selected {
|
||||
border-color: var(--primary-color);
|
||||
background-color: var(--primary-soft);
|
||||
}
|
||||
|
||||
.mode-icon {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 44px;
|
||||
height: 44px;
|
||||
flex-shrink: 0;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o10);
|
||||
color: var(--muted);
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.mode-option.selected .mode-icon {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.mode-details {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.mode-details h4 {
|
||||
margin: 0 0 var(--space-xs);
|
||||
font-size: 1.1rem;
|
||||
}
|
||||
|
||||
.mode-details p {
|
||||
margin: 0;
|
||||
color: var(--text-muted);
|
||||
font-size: 0.875rem;
|
||||
}
|
||||
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: var(--space-md);
|
||||
margin-top: var(--space-xl);
|
||||
}
|
||||
|
||||
.cancel-button {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: 1px solid var(--border-color);
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--background-soft);
|
||||
color: var(--text-color);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.cancel-button:hover {
|
||||
background: var(--background-muted);
|
||||
}
|
||||
|
||||
.create-button {
|
||||
padding: var(--space-md) var(--space-lg);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
}
|
||||
|
||||
.create-button:hover {
|
||||
background: var(--primary-dark);
|
||||
}
|
||||
|
||||
.create-button:disabled,
|
||||
.cancel-button:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
@media (min-width: 768px) {
|
||||
.mode-options {
|
||||
flex-direction: row;
|
||||
}
|
||||
|
||||
.mode-option {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
File diff suppressed because it is too large.
Load diff
File diff suppressed because it is too large.
Load diff
@@ -0,0 +1,227 @@
|
||||
<!--
|
||||
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 { onMounted, ref, watch, defineAsyncComponent } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { notifySetupChanged } from '@/utils/setupEvents'
|
||||
|
||||
// Lazy load components
|
||||
const AISetup = defineAsyncComponent(() => import('../ai/AISetup.vue'))
|
||||
const AgentList = defineAsyncComponent(() => import('../agent/AgentList.vue'))
|
||||
const AIAgentOnboarding = defineAsyncComponent(() => import('./onboarding/AIAgentOnboarding.vue'))
|
||||
|
||||
import { useAgentStorage } from '@/utils/storage'
|
||||
import { aiService } from '@/services/ai'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import { AxiosError } from 'axios'
|
||||
|
||||
const agentStorage = useAgentStorage()
|
||||
|
||||
const error = ref<string | null>(null)
|
||||
const isLoading = ref(false)
|
||||
const isAISetupMode = ref(false)
|
||||
// First-run guided setup: shown when the org has no agents (and didn't skip)
|
||||
const showOnboarding = ref(false)
|
||||
|
||||
const props = defineProps<{
|
||||
model: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
||||
}>()
|
||||
|
||||
const checkAIConfig = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await aiService.getOrganizationConfig()
|
||||
isAISetupMode.value = false
|
||||
await fetchAgents()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
// No AI config found
|
||||
isAISetupMode.value = true
|
||||
} else {
|
||||
error.value = '检查 AI 配置失败'
|
||||
console.error(err)
|
||||
}
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const fetchAgents = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
const agents = await agentService.getOrganizationAgents()
|
||||
if (agents.length > 0) {
|
||||
agentStorage.setAgents(agents)
|
||||
}
|
||||
} catch (err) {
|
||||
error.value = '获取客服列表失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Handle fullscreen toggle from agent list
|
||||
const handleFullscreenToggle = (isFullscreen: boolean) => {
|
||||
emit('toggle-fullscreen', isFullscreen)
|
||||
}
|
||||
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
// Links into setup carry ?setup=1: reopen the wizard where the org left off.
|
||||
const openWizardIfAsked = () => {
|
||||
if (route.query.setup === '1' && permissionChecks.canManageAgents()) showOnboarding.value = true
|
||||
}
|
||||
watch(() => route.query.setup, openWizardIfAsked)
|
||||
|
||||
// Onboarding wizard finished (or was skipped) — refresh and show the list
|
||||
const handleOnboardingComplete = async () => {
|
||||
showOnboarding.value = false
|
||||
isAISetupMode.value = false
|
||||
if (route.query.setup) router.replace({ query: { ...route.query, setup: undefined } })
|
||||
notifySetupChanged()
|
||||
await fetchAgents()
|
||||
}
|
||||
|
||||
const resumeOnboarding = () => {
|
||||
showOnboarding.value = true
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
// Authoritative check: does this org have any agents yet?
|
||||
const agents = await agentService.getOrganizationAgents()
|
||||
if (agents.length > 0) {
|
||||
agentStorage.setAgents(agents)
|
||||
openWizardIfAsked()
|
||||
return
|
||||
}
|
||||
// No agents yet → show the guided first-agent setup, but only to
|
||||
// someone who can actually create one. A view-only role was being
|
||||
// dropped into a creation wizard whose every step would 403.
|
||||
showOnboarding.value = permissionChecks.canManageAgents()
|
||||
} catch (err) {
|
||||
error.value = '加载客服列表失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="chat-window">
|
||||
<div class="messages">
|
||||
<div v-if="isLoading" class="loading-container">
|
||||
<div class="loader"></div>
|
||||
</div>
|
||||
|
||||
<div v-else-if="error" class="error-message">
|
||||
{{ error }}
|
||||
</div>
|
||||
|
||||
<div v-else-if="showOnboarding" class="onboarding-container">
|
||||
<AIAgentOnboarding @complete="handleOnboardingComplete" />
|
||||
</div>
|
||||
|
||||
<div v-else-if="isAISetupMode" class="setup-messages">
|
||||
<div class="ai-provider-setup">
|
||||
<h3>配置 AI 服务商</h3>
|
||||
<div class="ai-setup-container">
|
||||
<AISetup @ai-setup-complete="checkAIConfig" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div v-else class="agent-list-container">
|
||||
<AgentList @toggle-fullscreen="handleFullscreenToggle" @resume-onboarding="resumeOnboarding" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.chat-window {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
margin: 0 auto;
|
||||
background: transparent;
|
||||
}
|
||||
|
||||
.messages {
|
||||
flex: 1;
|
||||
background: transparent;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
min-height: 0;
|
||||
}
|
||||
|
||||
.ai-provider-setup {
|
||||
margin-bottom: var(--space-xl);
|
||||
padding: var(--space-xl);
|
||||
}
|
||||
|
||||
.ai-provider-setup h3 {
|
||||
margin-bottom: var(--space-lg);
|
||||
color: var(--text);
|
||||
font-family: var(--font-display);
|
||||
font-size: var(--text-2xl);
|
||||
font-weight: 700;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.ai-setup-container {
|
||||
margin: 0 auto;
|
||||
}
|
||||
|
||||
.error-message {
|
||||
padding: var(--space-md);
|
||||
background: var(--error-bg);
|
||||
color: var(--error-color);
|
||||
border-radius: var(--radius-lg);
|
||||
text-align: center;
|
||||
margin-bottom: var(--space-md);
|
||||
}
|
||||
|
||||
.loading-container {
|
||||
display: flex;
|
||||
justify-content: center;
|
||||
align-items: center;
|
||||
height: 100%;
|
||||
}
|
||||
|
||||
.loader {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
border: 3px solid var(--o10);
|
||||
border-bottom-color: var(--accent-ink);
|
||||
border-radius: 50%;
|
||||
animation: rotation 1s linear infinite;
|
||||
}
|
||||
|
||||
@keyframes rotation {
|
||||
0% { transform: rotate(0deg) }
|
||||
100% { transform: rotate(360deg) }
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,186 @@
|
||||
<!--
|
||||
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 robotIcon from '@/assets/robot.png'
|
||||
import userIcon from '@/assets/user.png'
|
||||
|
||||
defineProps<{
|
||||
role: 'user' | 'bot' | 'error' | 'agent'
|
||||
content: string
|
||||
agent?: string
|
||||
avatar?: string
|
||||
isLoading?: boolean
|
||||
}>()
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="message" :class="role">
|
||||
<div class="avatar" :class="role">
|
||||
<template v-if="role === 'user'">
|
||||
<img :src="userIcon" alt="用户" class="user-icon" />
|
||||
</template>
|
||||
<template v-else-if="role === 'bot'">
|
||||
<img :src="avatar || robotIcon" :alt="agent || 'AI 助手'" class="robot-icon" />
|
||||
</template>
|
||||
<template v-else>
|
||||
⚠️
|
||||
</template>
|
||||
</div>
|
||||
<div class="bubble">
|
||||
<template v-if="isLoading && role === 'bot'">
|
||||
<div class="typing-indicator">
|
||||
<span></span>
|
||||
<span></span>
|
||||
<span></span>
|
||||
</div>
|
||||
</template>
|
||||
<template v-else>
|
||||
{{ content }}
|
||||
</template>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.message {
|
||||
display: flex;
|
||||
gap: var(--space-md);
|
||||
padding: var(--space-sm);
|
||||
margin-bottom: var(--space-md);
|
||||
animation: fadeIn 0.3s ease-out;
|
||||
}
|
||||
|
||||
.message.user {
|
||||
flex-direction: row-reverse;
|
||||
}
|
||||
|
||||
.avatar {
|
||||
width: 36px;
|
||||
height: 36px;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-full);
|
||||
font-size: 1.2em;
|
||||
}
|
||||
|
||||
.avatar.assistant {
|
||||
background: var(--primary-soft);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.robot-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.avatar.user {
|
||||
background: var(--background-soft);
|
||||
padding: var(--space-xs);
|
||||
}
|
||||
|
||||
.user-icon {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
max-width: 70%;
|
||||
padding: var(--space-md);
|
||||
border-radius: var(--radius-lg);
|
||||
line-height: 1.5;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.message.assistant .bubble {
|
||||
background: #313131;
|
||||
border-top-left-radius: 4px;
|
||||
/* Slightly darker light black background */
|
||||
border-top-left-radius: 4px;
|
||||
}
|
||||
|
||||
.message.user .bubble {
|
||||
background: var(--accent-solid);
|
||||
color: black;
|
||||
border-top-right-radius: 4px;
|
||||
}
|
||||
|
||||
.message.error .bubble {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #dc2626);
|
||||
border: 1px solid var(--error-color, #dc2626);
|
||||
}
|
||||
|
||||
.avatar.error {
|
||||
background: var(--error-soft, #fef2f2);
|
||||
color: var(--error-color, #dc2626);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
font-size: 1.2rem;
|
||||
}
|
||||
|
||||
@keyframes fadeIn {
|
||||
from {
|
||||
opacity: 0;
|
||||
transform: translateY(10px);
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
}
|
||||
}
|
||||
|
||||
.typing-indicator {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
padding: var(--space-xs);
|
||||
opacity: 0.8;
|
||||
}
|
||||
|
||||
.typing-indicator span {
|
||||
width: 8px;
|
||||
height: 8px;
|
||||
background: var(--primary-color, var(--accent-solid));
|
||||
border-radius: 50%;
|
||||
animation: typing 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(1) {
|
||||
animation-delay: -0.32s;
|
||||
}
|
||||
|
||||
.typing-indicator span:nth-child(2) {
|
||||
animation-delay: -0.16s;
|
||||
}
|
||||
|
||||
@keyframes typing {
|
||||
|
||||
0%,
|
||||
80%,
|
||||
100% {
|
||||
transform: scale(0);
|
||||
}
|
||||
|
||||
40% {
|
||||
transform: scale(1.0);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,328 @@
|
||||
<!--
|
||||
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 { onMounted, ref } from 'vue'
|
||||
import { userService } from '@/services/user'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import { useOnboardingState, type OnboardingStepIndex } from '@/composables/useOnboardingState'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import StepCreate from './steps/StepCreate.vue'
|
||||
import StepTeach from './steps/StepTeach.vue'
|
||||
import StepTest from './steps/StepTest.vue'
|
||||
import StepLaunch from './steps/StepLaunch.vue'
|
||||
|
||||
const emit = defineEmits<{
|
||||
// user finished the wizard or skipped — parent refreshes agents and renders the list
|
||||
(e: 'complete'): void
|
||||
}>()
|
||||
|
||||
const onboarding = useOnboardingState()
|
||||
const { loadModule, moduleImports } = useEnterpriseFeatures()
|
||||
|
||||
// The wizard step to reopen at for each server-side stage
|
||||
const WIZARD_STEP_FOR: Record<string, OnboardingStepIndex> = {
|
||||
create: 0, teach: 1, test: 2, launch: 3, live: 3,
|
||||
}
|
||||
|
||||
/** Where the server says the org is, when the enterprise module tracks it. */
|
||||
const serverProgress = async () => {
|
||||
try {
|
||||
const module = await loadModule(moduleImports.setupService)
|
||||
return (await module?.setupService?.getProgress()) ?? null
|
||||
} catch (err) {
|
||||
console.error('Failed to load setup progress:', err)
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* This browser's run if it has one, else where the server says the org is
|
||||
* (enterprise module only): another device, cleared storage, a link.
|
||||
*/
|
||||
const resumePoint = async () => {
|
||||
const record = onboarding.get(orgId)
|
||||
if (record.agentId) return record
|
||||
const server = await serverProgress()
|
||||
if (!server?.agent_id || !(server.step in WIZARD_STEP_FOR)) return record
|
||||
return onboarding.patch(orgId, {
|
||||
agentId: server.agent_id,
|
||||
widgetId: server.widget_id,
|
||||
currentStep: WIZARD_STEP_FOR[server.step],
|
||||
})
|
||||
}
|
||||
|
||||
const orgId = userService.getCurrentUser()?.organization_id || ''
|
||||
const steps = ['创建', '培训', '测试', '上线']
|
||||
|
||||
const currentStep = ref<OnboardingStepIndex>(0)
|
||||
const agentId = ref<string | null>(null)
|
||||
const agentName = ref<string>('')
|
||||
const agentType = ref<string>('customer_support')
|
||||
const widgetId = ref<string | null>(null)
|
||||
// The agent created in this run — so returning to Create edits it instead of
|
||||
// creating a duplicate.
|
||||
const createdAgent = ref<Agent | null>(null)
|
||||
|
||||
onMounted(async () => {
|
||||
// Resume an in-progress run, from this browser or from the server
|
||||
const record = await resumePoint()
|
||||
agentId.value = record.agentId
|
||||
agentName.value = record.agentName || ''
|
||||
widgetId.value = record.widgetId
|
||||
// Can't resume past Create without an agent
|
||||
currentStep.value = record.agentId ? record.currentStep : 0
|
||||
// Restore the agent so Create can prefill + update on a resumed run
|
||||
if (record.agentId) {
|
||||
try {
|
||||
createdAgent.value = await agentService.getAgentById(record.agentId)
|
||||
agentType.value = createdAgent.value?.agent_type || agentType.value
|
||||
agentName.value = agentName.value || createdAgent.value?.display_name || createdAgent.value?.name || ''
|
||||
} catch (err) {
|
||||
// The stored agent no longer exists (e.g. deleted) — start the run fresh
|
||||
// instead of leaving later steps pointing at a dead agent id.
|
||||
console.error('Failed to load onboarding agent, restarting:', err)
|
||||
createdAgent.value = null
|
||||
agentId.value = null
|
||||
currentStep.value = 0
|
||||
onboarding.patch(orgId, { agentId: null, widgetId: null, currentStep: 0, completedSteps: [] })
|
||||
}
|
||||
}
|
||||
})
|
||||
|
||||
const goTo = (step: OnboardingStepIndex) => {
|
||||
currentStep.value = step
|
||||
onboarding.goTo(orgId, step)
|
||||
}
|
||||
|
||||
const handleWidgetCreated = (id: string) => {
|
||||
widgetId.value = id
|
||||
onboarding.patch(orgId, { widgetId: id })
|
||||
}
|
||||
|
||||
// The widget exists from the start, not only once they reach Test: someone
|
||||
// who stops at Teach can still be sent their install code. Test creates it
|
||||
// if this failed.
|
||||
const ensureWidget = async (agent: Agent) => {
|
||||
if (widgetId.value) return
|
||||
try {
|
||||
handleWidgetCreated((await widgetService.createAgentWidget(agent.id, agentName.value)).id)
|
||||
} catch (err) {
|
||||
console.error('Failed to create the widget with the agent:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const handleCreated = async (agent: Agent) => {
|
||||
createdAgent.value = agent
|
||||
agentId.value = agent.id
|
||||
agentName.value = agent.display_name || agent.name
|
||||
agentType.value = agent.agent_type
|
||||
await ensureWidget(agent)
|
||||
onboarding.patch(orgId, {
|
||||
agentId: agent.id,
|
||||
agentName: agentName.value,
|
||||
currentStep: 1,
|
||||
completedSteps: [0],
|
||||
})
|
||||
currentStep.value = 1
|
||||
}
|
||||
|
||||
const completeAndAdvance = (step: OnboardingStepIndex, next: OnboardingStepIndex) => {
|
||||
onboarding.completeStep(orgId, step, next)
|
||||
currentStep.value = next
|
||||
}
|
||||
|
||||
const handleSkip = () => {
|
||||
onboarding.skip(orgId)
|
||||
emit('complete')
|
||||
}
|
||||
|
||||
const handleFinish = () => {
|
||||
onboarding.completeStep(orgId, 3)
|
||||
emit('complete')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="onboarding">
|
||||
<header class="onb-header">
|
||||
<h1 class="onb-title">让你的第一个 AI 客服上线吧</h1>
|
||||
<p class="onb-sub">四步搞定,无需写代码,也无需配置模型——AI 部分交给我们。</p>
|
||||
</header>
|
||||
|
||||
<!-- Stepper -->
|
||||
<ol class="stepper" aria-label="设置进度">
|
||||
<li
|
||||
v-for="(label, i) in steps"
|
||||
:key="label"
|
||||
class="stepper-item"
|
||||
:class="{
|
||||
active: currentStep === i,
|
||||
done: currentStep > i,
|
||||
}"
|
||||
>
|
||||
<span class="stepper-dot">
|
||||
<template v-if="currentStep > i">✓</template>
|
||||
<template v-else>{{ i + 1 }}</template>
|
||||
</span>
|
||||
<span class="stepper-label">{{ label }}</span>
|
||||
<span v-if="i < steps.length - 1" class="stepper-line" aria-hidden="true"></span>
|
||||
</li>
|
||||
</ol>
|
||||
|
||||
<!-- Active step -->
|
||||
<div class="onb-card">
|
||||
<StepCreate
|
||||
v-if="currentStep === 0"
|
||||
:key="createdAgent ? createdAgent.id : 'new'"
|
||||
:existing-agent="createdAgent"
|
||||
@created="handleCreated"
|
||||
@skip="handleSkip"
|
||||
/>
|
||||
<StepTeach
|
||||
v-else-if="currentStep === 1 && agentId"
|
||||
:agent-id="agentId"
|
||||
:organization-id="orgId"
|
||||
@next="completeAndAdvance(1, 2)"
|
||||
@back="goTo(0)"
|
||||
/>
|
||||
<StepTest
|
||||
v-else-if="currentStep === 2 && agentId"
|
||||
:agent-id="agentId"
|
||||
:agent-name="agentName"
|
||||
:widget-id="widgetId"
|
||||
@widget-created="handleWidgetCreated"
|
||||
@next="completeAndAdvance(2, 3)"
|
||||
@back="goTo(1)"
|
||||
/>
|
||||
<StepLaunch
|
||||
v-else-if="currentStep === 3"
|
||||
:widget-id="widgetId"
|
||||
:agent-type="agentType"
|
||||
@finish="handleFinish"
|
||||
@back="goTo(2)"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.onboarding {
|
||||
max-width: 920px;
|
||||
margin: 0 auto;
|
||||
padding: 8px 0 40px;
|
||||
}
|
||||
|
||||
.onb-header {
|
||||
text-align: center;
|
||||
margin-bottom: 30px;
|
||||
}
|
||||
|
||||
.onb-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 700;
|
||||
font-size: 32px;
|
||||
letter-spacing: -0.03em;
|
||||
margin: 0 0 8px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.onb-sub {
|
||||
font-size: 16px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.stepper {
|
||||
list-style: none;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 8px;
|
||||
margin: 0 0 34px;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.stepper-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.stepper-dot {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 1.5px solid var(--o14);
|
||||
background: var(--surface);
|
||||
color: var(--muted2);
|
||||
font-size: 13px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-mono);
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-dot {
|
||||
background: var(--accent-solid);
|
||||
border-color: var(--accent-ink);
|
||||
color: var(--on-accent-solid);
|
||||
}
|
||||
|
||||
.stepper-item.done .stepper-dot {
|
||||
background: var(--accent-bg-12);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.stepper-label {
|
||||
font-size: 14.5px;
|
||||
font-weight: 500;
|
||||
color: var(--muted);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.stepper-item.active .stepper-label {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.stepper-line {
|
||||
flex: 1;
|
||||
height: 1px;
|
||||
background: var(--o12);
|
||||
margin: 0 4px;
|
||||
}
|
||||
|
||||
.onb-card {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-card);
|
||||
padding: 34px;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.stepper-label {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,928 @@
|
||||
<!--
|
||||
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 { ref, computed, defineAsyncComponent, onMounted } from 'vue'
|
||||
import { AxiosError } from 'axios'
|
||||
import { aiService } from '@/services/ai'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { leadCaptureService } from '@/services/leadCapture'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { subscriptionStorage } from '@/utils/storage'
|
||||
import type { Agent } from '@/types/agent'
|
||||
|
||||
// Lazy-load the provider setup form — only needed on OSS builds without ReplyPier AI
|
||||
const AISetup = defineAsyncComponent(() => import('@/components/ai/AISetup.vue'))
|
||||
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
|
||||
// Bundled preset avatars (frontend/src/assets/avatars/avatar-*.png)
|
||||
const avatarModules = import.meta.glob('@/assets/avatars/avatar-*.png', {
|
||||
eager: true,
|
||||
query: '?url',
|
||||
import: 'default',
|
||||
}) as Record<string, string>
|
||||
const presetAvatars = Object.keys(avatarModules).sort().map((k) => avatarModules[k])
|
||||
|
||||
// When set, Create edits this agent (returning to the step) instead of
|
||||
// creating a new one — avoids duplicate agents / plan-limit errors.
|
||||
const props = defineProps<{
|
||||
existingAgent?: Agent | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'created', agent: Agent): void
|
||||
(e: 'skip'): void
|
||||
}>()
|
||||
|
||||
const agentTypes = [
|
||||
{ value: 'customer_support', label: '客户支持' },
|
||||
{ value: 'sales', label: '销售' },
|
||||
{ value: 'tech_support', label: '技术支持' },
|
||||
{ value: 'general', label: '通用' },
|
||||
] as const
|
||||
|
||||
// Sensible starter instructions per agent type — prefilled so setup is quick
|
||||
const TYPE_DEFAULTS: Record<string, string> = {
|
||||
customer_support: '简洁、友好、有同理心。依据知识库回答问题,账单或账户问题转交人工客服。',
|
||||
sales: '热情且乐于助人。突出产品优势,解答价格问题,引导访客预约演示或开始试用。在合适的时机收集线索信息。',
|
||||
tech_support: '清晰且耐心。依据文档引导用户逐步排查问题。必要时索取报错信息或截图,无法解决的问题转交人工。',
|
||||
general: '乐于助人、简洁友好。依据知识库准确回答问题,需要专人处理时转交人工。',
|
||||
}
|
||||
const DEFAULT_VALUES = Object.values(TYPE_DEFAULTS)
|
||||
|
||||
// Prefill from the existing agent when returning to this step
|
||||
const existingInstructions = Array.isArray(props.existingAgent?.instructions)
|
||||
? props.existingAgent!.instructions.join('\n')
|
||||
: ''
|
||||
|
||||
const agentName = ref(props.existingAgent?.display_name || props.existingAgent?.name || '')
|
||||
const selectedType = ref<string>(props.existingAgent?.agent_type || 'customer_support')
|
||||
const instructions = ref<string>(existingInstructions || TYPE_DEFAULTS[selectedType.value] || '')
|
||||
|
||||
const isSubmitting = ref(false)
|
||||
const isGenerating = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
// Lead capture is a Pro-plan feature — hide/disable it where the enterprise module
|
||||
// is present and the plan doesn't include it (mirrors the Lead Capture tab gate).
|
||||
const leadCaptureLocked = computed(() =>
|
||||
hasEnterpriseModule &&
|
||||
(!subscriptionStorage.hasFeature('lead_capture') || !subscriptionStorage.isSubscriptionActive())
|
||||
)
|
||||
|
||||
// Lead capture — a simple on/off here; the agent collects details in conversation.
|
||||
// Refined later in the agent's Lead Capture tab (consent, guidance, custom fields).
|
||||
// Default off when locked so we never try to save it on a non-Pro plan.
|
||||
const leadEnabled = ref(!leadCaptureLocked.value)
|
||||
const LEAD_FIELDS = [
|
||||
{ key: 'name', label: '姓名' },
|
||||
{ key: 'email', label: '邮箱' },
|
||||
{ key: 'company', label: '公司' },
|
||||
{ key: 'phone', label: '电话' },
|
||||
]
|
||||
const leadFields = ref<Record<string, boolean>>({ name: true, email: true, company: true, phone: false })
|
||||
|
||||
// Persist the chosen lead-capture config onto the freshly-created/updated agent.
|
||||
// Non-fatal: the agent is already created and this is fully editable in its tab.
|
||||
const applyLeadCapture = async (agentId: string) => {
|
||||
// Skip entirely on non-Pro plans — the backend gate would 403.
|
||||
if (leadCaptureLocked.value) return
|
||||
try {
|
||||
const fields = LEAD_FIELDS
|
||||
.filter(f => leadFields.value[f.key])
|
||||
.map(f => ({ key: f.key, standard: true, enabled: true, required: f.key === 'email' }))
|
||||
await leadCaptureService.updateConfig(agentId, {
|
||||
enabled: leadEnabled.value,
|
||||
require_consent: true,
|
||||
guidance: null,
|
||||
fields,
|
||||
assignment_mode: 'none',
|
||||
assignment_target_user_id: null,
|
||||
crm_sync_target: 'none',
|
||||
slack_notify_enabled: false,
|
||||
})
|
||||
} catch (err) {
|
||||
console.error('Failed to apply lead capture config:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// When returning to edit an existing agent, prefill from its saved config.
|
||||
onMounted(async () => {
|
||||
if (!props.existingAgent) return
|
||||
try {
|
||||
const cfg = await leadCaptureService.getConfig(props.existingAgent.id)
|
||||
leadEnabled.value = !!cfg.enabled
|
||||
if (cfg.fields && cfg.fields.length) {
|
||||
const on = new Set(cfg.fields.filter(f => f.enabled).map(f => f.key))
|
||||
leadFields.value = {
|
||||
name: on.has('name'), email: on.has('email'),
|
||||
company: on.has('company'), phone: on.has('phone'),
|
||||
}
|
||||
}
|
||||
} catch { /* keep defaults */ }
|
||||
})
|
||||
|
||||
// Avatar: a chosen preset URL, or a custom uploaded file.
|
||||
// For an existing agent, start with nothing selected so we keep its current
|
||||
// photo unless the user actually picks a new one.
|
||||
const selectedAvatar = ref<string | null>(props.existingAgent ? null : (presetAvatars[0] ?? null))
|
||||
const customAvatarFile = ref<File | null>(null)
|
||||
const customAvatarPreview = ref<string | null>(null)
|
||||
const avatarFileInput = ref<HTMLInputElement | null>(null)
|
||||
const avatarTouched = ref(false)
|
||||
|
||||
const selectPreset = (url: string) => {
|
||||
selectedAvatar.value = url
|
||||
avatarTouched.value = true
|
||||
customAvatarFile.value = null
|
||||
if (customAvatarPreview.value) {
|
||||
URL.revokeObjectURL(customAvatarPreview.value)
|
||||
customAvatarPreview.value = null
|
||||
}
|
||||
}
|
||||
|
||||
const triggerAvatarUpload = () => avatarFileInput.value?.click()
|
||||
|
||||
const onAvatarFile = (e: Event) => {
|
||||
const file = (e.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
customAvatarFile.value = file
|
||||
avatarTouched.value = true
|
||||
if (customAvatarPreview.value) URL.revokeObjectURL(customAvatarPreview.value)
|
||||
customAvatarPreview.value = URL.createObjectURL(file)
|
||||
selectedAvatar.value = null
|
||||
}
|
||||
|
||||
// Apply the chosen avatar to the freshly-created agent via the photo endpoint.
|
||||
// Presets are bundled assets, so fetch them into a File first. Non-fatal.
|
||||
const applyAvatar = async (agentId: string) => {
|
||||
try {
|
||||
let file: File | null = customAvatarFile.value
|
||||
if (!file && selectedAvatar.value) {
|
||||
const res = await fetch(selectedAvatar.value)
|
||||
const blob = await res.blob()
|
||||
file = new File([blob], 'avatar.png', { type: blob.type || 'image/png' })
|
||||
}
|
||||
if (file) return await agentService.uploadAgentPhoto(agentId, file)
|
||||
} catch (err) {
|
||||
console.error('Failed to set agent avatar:', err)
|
||||
}
|
||||
return null
|
||||
}
|
||||
|
||||
// Switch type and refresh the prefilled instructions, but never clobber text
|
||||
// the user has actually written/edited.
|
||||
const selectType = (type: string) => {
|
||||
selectedType.value = type
|
||||
const current = instructions.value.trim()
|
||||
if (current === '' || DEFAULT_VALUES.includes(current)) {
|
||||
instructions.value = TYPE_DEFAULTS[type] ?? ''
|
||||
}
|
||||
}
|
||||
|
||||
const generateWithAI = async () => {
|
||||
if (isGenerating.value) return
|
||||
error.value = ''
|
||||
isGenerating.value = true
|
||||
try {
|
||||
const typeLabel = agentTypes.find(t => t.value === selectedType.value)?.label ?? 'support'
|
||||
const prompt = `A ${typeLabel} AI agent${agentName.value.trim() ? ` named "${agentName.value.trim()}"` : ''}. Write clear instructions describing how it should talk to customers and when to hand off to a human.`
|
||||
const generated = await agentService.generateInstructions(prompt, instructions.value.trim() ? [instructions.value.trim()] : undefined)
|
||||
if (generated.length) instructions.value = generated.join('\n')
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '无法生成指令,请重试。'
|
||||
} finally {
|
||||
isGenerating.value = false
|
||||
}
|
||||
}
|
||||
// OSS-only: when ReplyPier AI isn't available we must collect a provider key first
|
||||
const showAiGate = ref(false)
|
||||
|
||||
const ensureAIConfig = async (): Promise<boolean> => {
|
||||
try {
|
||||
await aiService.getOrganizationConfig()
|
||||
return true
|
||||
} catch (err) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
if (hasEnterpriseModule) {
|
||||
// Managed ReplyPier AI — zero setup, backend injects the key
|
||||
await aiService.setupAI({
|
||||
model_type: 'REPLYPIER',
|
||||
model_name: 'replypier',
|
||||
api_key: '',
|
||||
})
|
||||
return true
|
||||
}
|
||||
// OSS: route into the existing provider setup form as a gate
|
||||
showAiGate.value = true
|
||||
return false
|
||||
}
|
||||
throw err
|
||||
}
|
||||
}
|
||||
|
||||
const instructionList = () =>
|
||||
instructions.value.trim()
|
||||
? [instructions.value.trim()]
|
||||
: ['我是 AI 助手,随时为你解答问题。']
|
||||
|
||||
const createAgent = async () => {
|
||||
let agent: Agent
|
||||
if (props.existingAgent) {
|
||||
// Returning to this step — update the existing agent, don't create another
|
||||
agent = await agentService.updateAgent(props.existingAgent.id, {
|
||||
display_name: agentName.value.trim(),
|
||||
instructions: instructionList(),
|
||||
})
|
||||
if (avatarTouched.value) {
|
||||
const customization = await applyAvatar(agent.id)
|
||||
if (customization) agent.customization = customization
|
||||
} else if (props.existingAgent.customization) {
|
||||
agent.customization = props.existingAgent.customization
|
||||
}
|
||||
} else {
|
||||
agent = await agentService.createAgent({
|
||||
name: agentName.value.trim(),
|
||||
display_name: agentName.value.trim(),
|
||||
agent_type: selectedType.value,
|
||||
instructions: instructionList(),
|
||||
is_active: true,
|
||||
use_workflow: false,
|
||||
})
|
||||
const customization = await applyAvatar(agent.id)
|
||||
if (customization) agent.customization = customization
|
||||
}
|
||||
await applyLeadCapture(agent.id)
|
||||
emit('created', agent)
|
||||
}
|
||||
|
||||
const handleContinue = async () => {
|
||||
if (!agentName.value.trim()) {
|
||||
error.value = '请先为客服起个名字。'
|
||||
return
|
||||
}
|
||||
error.value = ''
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
const ready = await ensureAIConfig()
|
||||
if (!ready) return // OSS gate is now showing; createAgent runs after it completes
|
||||
await createAgent()
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '创建客服失败,请重试。'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Fired after the OSS provider form saves a config
|
||||
const onAiConfigured = async () => {
|
||||
showAiGate.value = false
|
||||
isSubmitting.value = true
|
||||
try {
|
||||
await createAgent()
|
||||
} catch (err: any) {
|
||||
error.value = err?.response?.data?.detail || '创建客服失败,请重试。'
|
||||
} finally {
|
||||
isSubmitting.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<!-- OSS provider-key gate -->
|
||||
<div v-if="showAiGate" class="ai-gate">
|
||||
<p class="ai-gate-note">先接入 AI 服务商为客服提供能力,随后即可创建。</p>
|
||||
<AISetup @ai-setup-complete="onAiConfigured" />
|
||||
</div>
|
||||
|
||||
<template v-else>
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">创建你的客服</h2>
|
||||
<p class="step-sub">给它起个名字、设定人设,之后还能随时调整。</p>
|
||||
</header>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label" for="onb-agent-name">客服名称</label>
|
||||
<input
|
||||
id="onb-agent-name"
|
||||
v-model="agentName"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="例如:客户支持客服"
|
||||
:disabled="isSubmitting"
|
||||
@keydown.enter.prevent="handleContinue"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">类型</label>
|
||||
<div class="type-picker">
|
||||
<button
|
||||
v-for="type in agentTypes"
|
||||
:key="type.value"
|
||||
type="button"
|
||||
class="type-chip"
|
||||
:class="{ selected: selectedType === type.value }"
|
||||
:disabled="isSubmitting || !!existingAgent"
|
||||
@click="selectType(type.value)"
|
||||
>
|
||||
{{ type.label }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<label class="field-label">头像</label>
|
||||
<div class="avatar-grid">
|
||||
<button
|
||||
v-for="(url, i) in presetAvatars"
|
||||
:key="i"
|
||||
type="button"
|
||||
class="avatar-thumb"
|
||||
:class="{ selected: selectedAvatar === url }"
|
||||
:disabled="isSubmitting"
|
||||
@click="selectPreset(url)"
|
||||
:aria-label="`头像 ${i + 1}`"
|
||||
>
|
||||
<img :src="url" alt="" />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
class="avatar-thumb upload"
|
||||
:class="{ selected: !!customAvatarPreview }"
|
||||
:disabled="isSubmitting"
|
||||
@click="triggerAvatarUpload"
|
||||
title="上传自定义头像"
|
||||
aria-label="上传自定义头像"
|
||||
>
|
||||
<img v-if="customAvatarPreview" :src="customAvatarPreview" alt="" />
|
||||
<span v-else class="upload-plus">+</span>
|
||||
</button>
|
||||
<input ref="avatarFileInput" type="file" accept="image/*" hidden @change="onAvatarFile" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="field">
|
||||
<div class="field-label-row">
|
||||
<label class="field-label" for="onb-instructions">人设与指令</label>
|
||||
<button
|
||||
type="button"
|
||||
class="ai-generate-btn"
|
||||
:disabled="isSubmitting || isGenerating"
|
||||
@click="generateWithAI"
|
||||
>
|
||||
<span class="ai-icon">✨</span>
|
||||
{{ isGenerating ? '生成中…' : 'AI 生成' }}
|
||||
</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="onb-instructions"
|
||||
v-model="instructions"
|
||||
class="text-input textarea"
|
||||
rows="3"
|
||||
placeholder="简洁友好,账单问题转交人工。"
|
||||
:disabled="isSubmitting"
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="field lead-field">
|
||||
<div class="lead-enable-row">
|
||||
<div>
|
||||
<label class="field-label">线索收集 <span v-if="leadCaptureLocked" class="lead-pro">Pro</span></label>
|
||||
<p class="lead-sub">
|
||||
<template v-if="leadCaptureLocked">在对话中收集并筛选线索,升级到 Pro 即可启用。</template>
|
||||
<template v-else>客服会在对话中收集联系方式——先解决问题,再在合适的时机开口询问。</template>
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
class="lead-switch"
|
||||
:class="{ on: leadEnabled }"
|
||||
:disabled="isSubmitting || leadCaptureLocked"
|
||||
:aria-pressed="leadEnabled"
|
||||
@click="leadEnabled = !leadEnabled"
|
||||
>
|
||||
<span class="lead-knob"></span>
|
||||
</button>
|
||||
</div>
|
||||
<div v-if="leadEnabled && !leadCaptureLocked" class="lead-details">
|
||||
<div class="lead-details-label">收集字段</div>
|
||||
<div class="lead-chips">
|
||||
<button
|
||||
v-for="f in LEAD_FIELDS"
|
||||
:key="f.key"
|
||||
type="button"
|
||||
class="lead-chip"
|
||||
:class="{ on: leadFields[f.key] }"
|
||||
:disabled="isSubmitting"
|
||||
@click="leadFields[f.key] = !leadFields[f.key]"
|
||||
>
|
||||
<span class="lead-check">{{ leadFields[f.key] ? '✔' : '' }}</span>{{ f.label }}
|
||||
<span v-if="f.key === 'email'" class="lead-req">必填</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="ai-note">
|
||||
<svg viewBox="0 0 24 24" width="15" height="15" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="12" cy="12" r="8" /></svg>
|
||||
<span>
|
||||
由 ReplyPier AI 驱动,开箱即用。想用自己的模型?稍后可在
|
||||
<strong>AI 配置 → 高级</strong> 中设置。
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="step-error" role="alert">{{ error }}</p>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-text" :disabled="isSubmitting" @click="emit('skip')">暂时跳过</button>
|
||||
<button type="button" class="btn-accent" :disabled="isSubmitting" @click="handleContinue">
|
||||
{{ isSubmitting ? '创建中…' : '继续' }}
|
||||
<span v-if="!isSubmitting" class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.field {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.field-label {
|
||||
font-size: 13.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.field-label-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.ai-generate-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 6px 12px;
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--accent-ink);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 12.5px;
|
||||
font-weight: 600;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.ai-generate-btn:hover:not(:disabled) {
|
||||
background: var(--accent-bg-12);
|
||||
}
|
||||
|
||||
.ai-generate-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-icon {
|
||||
font-size: 12px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
width: 100%;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.textarea {
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
}
|
||||
|
||||
.type-picker {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.avatar-thumb {
|
||||
width: 48px;
|
||||
height: 48px;
|
||||
padding: 0;
|
||||
border-radius: 50%;
|
||||
overflow: hidden;
|
||||
background: var(--o05);
|
||||
border: 2px solid var(--o12);
|
||||
cursor: pointer;
|
||||
flex-shrink: 0;
|
||||
transition: var(--transition-fast);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
.avatar-thumb img {
|
||||
width: 100%;
|
||||
height: 100%;
|
||||
object-fit: cover;
|
||||
display: block;
|
||||
}
|
||||
|
||||
.avatar-thumb:hover:not(:disabled) {
|
||||
border-color: var(--o25);
|
||||
}
|
||||
|
||||
.avatar-thumb.selected {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: 0 0 0 3px var(--accent-bg-12);
|
||||
}
|
||||
|
||||
.avatar-thumb:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.avatar-thumb.upload {
|
||||
border-style: dashed;
|
||||
background: var(--bg);
|
||||
}
|
||||
|
||||
.upload-plus {
|
||||
font-size: 22px;
|
||||
font-weight: 300;
|
||||
line-height: 1;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.type-chip {
|
||||
padding: 11px 18px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-pill);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.type-chip.selected {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.type-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.ai-note {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 9px;
|
||||
padding: 12px 14px;
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
border-radius: 11px;
|
||||
font-size: 13px;
|
||||
color: var(--c-purple-fg, var(--text3));
|
||||
}
|
||||
|
||||
.ai-note svg {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ai-note strong {
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.step-error {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 14.5px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-accent:disabled,
|
||||
.btn-text:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
|
||||
.ai-gate-note {
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
margin: 0 0 16px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
/* Lead capture */
|
||||
.lead-reco {
|
||||
font-size: 12.5px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.lead-sub {
|
||||
font-size: 13px;
|
||||
color: var(--muted2);
|
||||
margin: -2px 0 4px;
|
||||
line-height: 1.5;
|
||||
max-width: 460px;
|
||||
}
|
||||
|
||||
.lead-enable-row {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
gap: 16px;
|
||||
}
|
||||
|
||||
.lead-switch {
|
||||
flex-shrink: 0;
|
||||
width: 46px;
|
||||
height: 26px;
|
||||
border-radius: 999px;
|
||||
border: none;
|
||||
background: var(--o12);
|
||||
padding: 3px;
|
||||
cursor: pointer;
|
||||
transition: background 0.16s ease;
|
||||
}
|
||||
|
||||
.lead-switch.on {
|
||||
background: var(--accent-solid);
|
||||
}
|
||||
|
||||
.lead-switch:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-knob {
|
||||
display: block;
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
border-radius: 50%;
|
||||
background: #fff;
|
||||
transition: transform 0.16s ease;
|
||||
box-shadow: 0 1px 2px rgba(0, 0, 0, 0.3);
|
||||
}
|
||||
|
||||
.lead-switch.on .lead-knob {
|
||||
transform: translateX(20px);
|
||||
}
|
||||
|
||||
.lead-details {
|
||||
margin-top: 16px;
|
||||
}
|
||||
|
||||
.lead-modes {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(3, 1fr);
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.lead-mode {
|
||||
text-align: left;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
padding: 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-lg, 14px);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.lead-mode.selected {
|
||||
border-color: var(--accent-border);
|
||||
background: var(--accent-bg-08);
|
||||
}
|
||||
|
||||
.lead-mode:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-mode-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 5px;
|
||||
}
|
||||
|
||||
.lead-radio {
|
||||
width: 16px;
|
||||
height: 16px;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o25);
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.lead-radio.on {
|
||||
border-color: var(--accent-ink);
|
||||
background: var(--accent-ink);
|
||||
box-shadow: inset 0 0 0 3px var(--surface, var(--bg));
|
||||
}
|
||||
|
||||
.lead-badge {
|
||||
font-size: 9px;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 3px 7px;
|
||||
border-radius: var(--radius-pill);
|
||||
background: var(--accent-bg-12);
|
||||
color: var(--accent-ink);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.lead-mode-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 14.5px;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.lead-mode-desc {
|
||||
font-size: 12px;
|
||||
color: var(--muted);
|
||||
line-height: 1.45;
|
||||
}
|
||||
|
||||
.lead-details {
|
||||
margin-top: 14px;
|
||||
}
|
||||
|
||||
.lead-details-label {
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
color: var(--text3);
|
||||
margin-bottom: 10px;
|
||||
}
|
||||
|
||||
.lead-chips {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 9px;
|
||||
}
|
||||
|
||||
.lead-chip {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 7px;
|
||||
padding: 9px 14px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-md, 10px);
|
||||
color: var(--text);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.lead-chip.on {
|
||||
background: var(--accent-bg-08);
|
||||
border-color: var(--accent-border);
|
||||
}
|
||||
|
||||
.lead-chip:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.lead-check {
|
||||
width: 12px;
|
||||
font-size: 11px;
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.lead-req {
|
||||
font-size: 10.5px;
|
||||
color: var(--c-coral, #e0653f);
|
||||
margin-left: 2px;
|
||||
}
|
||||
.lead-pro {
|
||||
font-size: 10px;
|
||||
font-weight: 600;
|
||||
letter-spacing: .03em;
|
||||
padding: 2px 7px;
|
||||
border-radius: 999px;
|
||||
margin-left: 6px;
|
||||
background: var(--purple-bg, rgba(124, 58, 237, .14));
|
||||
color: var(--c-purple, #7c3aed);
|
||||
vertical-align: middle;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,286 @@
|
||||
<!--
|
||||
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, onBeforeUnmount, ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { buildWidgetEmbed } from '@/utils/widgetEmbed'
|
||||
import { WIDGET_INSTALL_GUIDE_URL } from '@/config/docs'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
|
||||
const props = defineProps<{
|
||||
widgetId: string | null
|
||||
agentType: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'finish'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const widgetSnippet = computed(() =>
|
||||
props.widgetId ? buildWidgetEmbed(props.widgetId, false) : '',
|
||||
)
|
||||
|
||||
// Where the widget is live, or blocked (enterprise module; nothing in open source)
|
||||
const { enterpriseComponent, moduleImports } = useEnterpriseFeatures()
|
||||
const WidgetInstallStatus = enterpriseComponent(moduleImports.widgetInstallStatus)
|
||||
|
||||
// Built in the browser and handed to the user's own mail app: nothing is sent
|
||||
// from our servers, so it can't be used to mail arbitrary addresses.
|
||||
const developerMailto = computed(() => {
|
||||
if (!widgetSnippet.value) return ''
|
||||
const body = [
|
||||
'你好,',
|
||||
'',
|
||||
'能否把我们的 ReplyPier 聊天组件加到网站上?请在每个页面的 </body> 前粘贴这段代码:',
|
||||
'',
|
||||
widgetSnippet.value,
|
||||
'',
|
||||
`指南:${WIDGET_INSTALL_GUIDE_URL}`,
|
||||
].join('\n')
|
||||
const params = new URLSearchParams({ subject: '把 ReplyPier 聊天组件加到网站', body })
|
||||
return `mailto:?${params.toString().replace(/\+/g, '%20')}`
|
||||
})
|
||||
|
||||
// The button itself says it worked, for a moment; a failure gets a toast.
|
||||
const COPIED_FOR_MS = 2000
|
||||
const copied = ref(false)
|
||||
let copiedTimer: ReturnType<typeof setTimeout> | undefined
|
||||
onBeforeUnmount(() => clearTimeout(copiedTimer))
|
||||
|
||||
const copySnippet = async () => {
|
||||
try {
|
||||
await navigator.clipboard.writeText(widgetSnippet.value)
|
||||
copied.value = true
|
||||
clearTimeout(copiedTimer)
|
||||
copiedTimer = setTimeout(() => (copied.value = false), COPIED_FOR_MS)
|
||||
} catch (err) {
|
||||
console.error('Failed to copy:', err)
|
||||
toast.error('复制到剪贴板失败')
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">正式上线</h2>
|
||||
<p class="step-sub">把这段代码嵌入网站,即可开始与访客聊天。</p>
|
||||
</header>
|
||||
|
||||
<div class="snippet">
|
||||
<pre class="code-block">{{ widgetSnippet }}</pre>
|
||||
<button type="button" class="copy-btn" :disabled="!widgetSnippet" @click="copySnippet">
|
||||
<font-awesome-icon :icon="copied ? 'fa-solid fa-check' : 'fa-regular fa-copy'" aria-hidden="true" />
|
||||
<span aria-live="polite">{{ copied ? '已复制' : '复制' }}</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="snippet-actions">
|
||||
<a v-if="developerMailto" class="action-btn" :href="developerMailto">
|
||||
<font-awesome-icon icon="fa-regular fa-envelope" aria-hidden="true" />
|
||||
邮件发给开发
|
||||
</a>
|
||||
<a class="action-btn" :href="WIDGET_INSTALL_GUIDE_URL" target="_blank" rel="noopener">
|
||||
<font-awesome-icon icon="fa-solid fa-book-open" aria-hidden="true" />
|
||||
安装指南
|
||||
<font-awesome-icon icon="fa-solid fa-arrow-up-right-from-square" class="external" aria-hidden="true" />
|
||||
<span class="sr-only">(在新标签页打开)</span>
|
||||
</a>
|
||||
</div>
|
||||
|
||||
<WidgetInstallStatus :widget-id="widgetId" />
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" @click="emit('finish')">完成并进入后台 ✓</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.snippet {
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.code-block {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-input);
|
||||
/* Room on the right for the Copy button over the first line */
|
||||
padding: 18px 96px 18px 18px;
|
||||
font-family: var(--font-mono);
|
||||
font-size: 13px;
|
||||
line-height: 1.6;
|
||||
color: var(--accent-ink);
|
||||
overflow-x: auto;
|
||||
white-space: pre-wrap;
|
||||
word-break: break-all;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.copy-btn {
|
||||
position: absolute;
|
||||
top: 10px;
|
||||
right: 10px;
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 6px;
|
||||
padding: 7px 12px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: 8px;
|
||||
color: var(--text);
|
||||
font-size: 12.5px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
/* On a phone the code needs every pixel of width: Copy goes above it instead */
|
||||
@media (max-width: 480px) {
|
||||
.code-block {
|
||||
padding: 52px 14px 14px;
|
||||
}
|
||||
}
|
||||
|
||||
.copy-btn:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.copy-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.snippet-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.action-btn {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 11px 16px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
text-decoration: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.action-btn:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.copy-btn:focus-visible,
|
||||
.action-btn:focus-visible {
|
||||
outline: 2px solid var(--accent-ink);
|
||||
outline-offset: 2px;
|
||||
}
|
||||
|
||||
.action-btn .external {
|
||||
font-size: 11px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.sr-only {
|
||||
position: absolute;
|
||||
width: 1px;
|
||||
height: 1px;
|
||||
overflow: hidden;
|
||||
clip: rect(0 0 0 0);
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 28px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,564 @@
|
||||
<!--
|
||||
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, nextTick, onBeforeUnmount, onMounted, ref } from 'vue'
|
||||
import { useKnowledgeManagement } from '@/composables/useKnowledgeManagement'
|
||||
import { useStagedSourceCap } from '@/composables/useStagedSourceCap'
|
||||
import { useCrawlCheck } from '@/composables/useCrawlCheck'
|
||||
import TeachTextFallback from './TeachTextFallback.vue'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
import { organizationService } from '@/services/organization'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
organizationId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'next'): void
|
||||
(e: 'back'): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
newUrl,
|
||||
urls,
|
||||
files,
|
||||
fileInput,
|
||||
isUploading,
|
||||
uploadProgress,
|
||||
uploadError,
|
||||
urlFormError,
|
||||
knowledgeItems,
|
||||
orgTotalCount,
|
||||
queueItems,
|
||||
fetchQueueItems,
|
||||
handleUrlAdd,
|
||||
removeUrl,
|
||||
triggerFileInput,
|
||||
handleUrlUpload,
|
||||
handleFileUpload,
|
||||
fetchKnowledge,
|
||||
withScheme,
|
||||
} = useKnowledgeManagement(props.agentId, props.organizationId)
|
||||
|
||||
const urlInput = ref<HTMLInputElement | null>(null)
|
||||
const pasteButton = ref<HTMLButtonElement | null>(null)
|
||||
|
||||
const advancing = ref(false)
|
||||
// Sending a batch with a website in it: the server checks it can be reached.
|
||||
const reading = ref(false)
|
||||
const textSaving = ref(false)
|
||||
// Sending the sources, or waiting to hear whether they could be read: the
|
||||
// batch is in flight, so nothing on the step may change it.
|
||||
const busy = computed(() => advancing.value || isUploading.value || textSaving.value)
|
||||
|
||||
// A website from the last attempt that failed fast (e.g. a domain that doesn't
|
||||
// resolve), shown with a way round it instead of moving on with nothing learned
|
||||
const crawlFailure = ref<QueueItem | null>(null)
|
||||
const showTextFallback = ref(false)
|
||||
const { firstFailure, cancel: stopCrawlCheck } = useCrawlCheck(fetchQueueItems, queueItems)
|
||||
onBeforeUnmount(stopCrawlCheck)
|
||||
|
||||
const setFailure = (failure: QueueItem | null) => {
|
||||
crawlFailure.value = failure
|
||||
showTextFallback.value = false
|
||||
}
|
||||
|
||||
// Nothing new to send: Continue would move on without the site that failed.
|
||||
const nothingStaged = computed(() => !urls.value.length && !files.value.length && !newUrl.value.trim())
|
||||
const continueLabel = computed(() => {
|
||||
if (reading.value) return '正在读取网站…'
|
||||
if (advancing.value) return '添加中…'
|
||||
return crawlFailure.value && nothingStaged.value ? '跳过并继续' : '继续'
|
||||
})
|
||||
|
||||
// Crawls still running count against the cap too (the server counts them).
|
||||
const crawling = computed(() =>
|
||||
queueItems.value.filter((q) => q.status === 'pending' || q.status === 'processing'),
|
||||
)
|
||||
const inUseCount = computed(() => orgTotalCount.value + crawling.value.length)
|
||||
const { fitToCap } = useStagedSourceCap(inUseCount, urls, files)
|
||||
|
||||
// A newly staged source makes the last failure history: Continue sends it and
|
||||
// checks again. `capError` is set when the plan had no room and nothing stayed.
|
||||
const afterStaging = (capError: string | null) => {
|
||||
urlFormError.value = capError
|
||||
if (capError) return
|
||||
uploadError.value = null
|
||||
setFailure(null)
|
||||
}
|
||||
|
||||
// Stage the typed URL, then keep the batch within the plan's source cap —
|
||||
// over the cap the server rejects the whole batch, signup domain included.
|
||||
const stageUrl = () => {
|
||||
const before = urls.value.length
|
||||
handleUrlAdd()
|
||||
if (urls.value.length > before) afterStaging(fitToCap('urls'))
|
||||
}
|
||||
|
||||
// Picked PDFs join the list like URLs do, rather than replacing the last pick.
|
||||
const stageFiles = (event: Event) => {
|
||||
const input = event.target as HTMLInputElement
|
||||
const picked = Array.from(input.files ?? [])
|
||||
// Cleared so picking the same file again (after removing it) still registers.
|
||||
input.value = ''
|
||||
if (!picked.length) return
|
||||
const fresh = picked.filter((f) => !files.value.some((s) => s.name === f.name && s.size === f.size))
|
||||
if (!fresh.length) {
|
||||
urlFormError.value = '该 PDF 已在列表中。'
|
||||
return
|
||||
}
|
||||
files.value = [...files.value, ...fresh]
|
||||
afterStaging(fitToCap('files', fresh.length))
|
||||
}
|
||||
|
||||
// Errors about the batch no longer describe it once a source is taken out. The
|
||||
// × that had focus is gone with its row, so focus goes back to the address box.
|
||||
const afterUnstaging = () => {
|
||||
urlFormError.value = null
|
||||
uploadError.value = null
|
||||
urlInput.value?.focus()
|
||||
}
|
||||
|
||||
const unstageUrl = (index: number) => {
|
||||
removeUrl(index)
|
||||
afterUnstaging()
|
||||
}
|
||||
|
||||
const unstageFile = (index: number) => {
|
||||
files.value.splice(index, 1)
|
||||
afterUnstaging()
|
||||
}
|
||||
|
||||
// The site they gave us at signup is almost always what they want indexed
|
||||
// first. Stage it as a source rather than dropping it in the input: sitting in
|
||||
// the box it looks like a placeholder, and nothing says you must press
|
||||
// "Add" for it to count. In the list it reads as added, with an × to
|
||||
// drop it.
|
||||
const prefillFromOrgDomain = async () => {
|
||||
try {
|
||||
const org = await organizationService.getOrganization(props.organizationId)
|
||||
if (!org?.domain || newUrl.value || urls.value.length) return
|
||||
const domainUrl = withScheme(org.domain)
|
||||
// Back from a later step, the domain may still be crawling — not indexed
|
||||
// yet, so the dedupe below can't see it. Staging it again would re-send it.
|
||||
// If it already failed, suggesting it again would only fail again. The
|
||||
// queue lists newest first, so this is its latest attempt.
|
||||
const last = queueItems.value.find((q) => q.source === domainUrl)
|
||||
if (last && last.status !== 'completed') return
|
||||
// The server only accepts websites with a full domain (acme.com, not
|
||||
// localhost): don't suggest one it will refuse.
|
||||
if (!new URL(domainUrl).hostname.includes('.')) return
|
||||
|
||||
newUrl.value = domainUrl
|
||||
stageUrl()
|
||||
// stageUrl flags an already-indexed URL, or a plan with no room left. That
|
||||
// is worth saying when a person typed it, but not for a suggestion they
|
||||
// never asked for — it just ends up not staged.
|
||||
urlFormError.value = null
|
||||
} catch {
|
||||
// A missing prefill is not worth blocking or alarming the user over.
|
||||
}
|
||||
}
|
||||
|
||||
// Back on this step with nothing learned yet and the last site unreadable
|
||||
// (e.g. via "Add your website or some text" on the Test step): pick up where
|
||||
// they left off, ways round it included.
|
||||
const restoreLastFailure = () => {
|
||||
if (knowledgeItems.value.length || crawling.value.length || urls.value.length) return
|
||||
const [newest] = queueItems.value
|
||||
if (newest?.status === 'failed' && newest.source_type === 'website') setFailure(newest)
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
// Prefill after the fetch so the dedupe check can see what is already indexed.
|
||||
await fetchKnowledge()
|
||||
await prefillFromOrgDomain()
|
||||
restoreLastFailure()
|
||||
})
|
||||
|
||||
// Flush any staged URLs/files, then advance — but only when the server took
|
||||
// them. Ingestion itself runs async in the background; we don't wait on it.
|
||||
const handleContinue = async () => {
|
||||
// A URL sitting in the box (the prefill, or one they typed without pressing
|
||||
// "Add") reads as accepted. Stage it rather than silently dropping it.
|
||||
if (newUrl.value.trim()) {
|
||||
stageUrl()
|
||||
if (urlFormError.value) return
|
||||
}
|
||||
|
||||
advancing.value = true
|
||||
setFailure(null)
|
||||
let failed: QueueItem | null = null
|
||||
let filesAccepted = true
|
||||
try {
|
||||
const sentUrls = [...urls.value]
|
||||
reading.value = sentUrls.length > 0
|
||||
// On failure the error is on screen and the batch stays staged to fix.
|
||||
if (!(await handleUrlUpload())) return
|
||||
filesAccepted = await handleFileUpload()
|
||||
// The websites went in even if a PDF didn't: say now if one already failed.
|
||||
if (sentUrls.length) failed = await firstFailure(sentUrls)
|
||||
} finally {
|
||||
advancing.value = false
|
||||
reading.value = false
|
||||
}
|
||||
|
||||
if (!failed) {
|
||||
if (filesAccepted) emit('next') // else the PDF error is on screen
|
||||
return
|
||||
}
|
||||
setFailure(failed)
|
||||
// Focus was on the button, disabled while it read the site. Trying another
|
||||
// address is the likeliest next move; the ways round it follow in tab order.
|
||||
await nextTick()
|
||||
urlInput.value?.focus()
|
||||
}
|
||||
|
||||
const openTextFallback = () => {
|
||||
showTextFallback.value = true
|
||||
}
|
||||
|
||||
const closeTextFallback = async () => {
|
||||
showTextFallback.value = false
|
||||
await nextTick()
|
||||
pasteButton.value?.focus()
|
||||
}
|
||||
|
||||
const handleTextAdded = () => {
|
||||
setFailure(null)
|
||||
emit('next')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">让它了解你的业务</h2>
|
||||
<p class="step-sub">放一个网址或 PDF——ReplyPier 会自动读取并建立索引。</p>
|
||||
</header>
|
||||
|
||||
<div class="source-input">
|
||||
<input
|
||||
ref="urlInput"
|
||||
v-model="newUrl"
|
||||
class="text-input mono"
|
||||
type="text"
|
||||
aria-label="网站地址"
|
||||
placeholder="https://docs.yourcompany.com"
|
||||
:disabled="busy"
|
||||
@keydown.enter.prevent="stageUrl"
|
||||
/>
|
||||
<!-- "Add" acts on the URL beside it, so the object is implicit. The PDF
|
||||
button opens a file picker, a different action — it keeps its own
|
||||
label rather than reading as a second way to add the same thing. -->
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="stageUrl">添加</button>
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="triggerFileInput">上传 PDF</button>
|
||||
<input
|
||||
ref="fileInput"
|
||||
type="file"
|
||||
accept="application/pdf"
|
||||
multiple
|
||||
hidden
|
||||
@change="stageFiles"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="urlFormError" class="step-error" role="alert">{{ urlFormError }}</p>
|
||||
|
||||
<!-- Staged sources (this batch) -->
|
||||
<ul v-if="urls.length || files.length" class="source-list" aria-label="待添加">
|
||||
<li v-for="(url, i) in urls" :key="`u-${url}`" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-globe" class="source-icon" aria-hidden="true" />
|
||||
<span class="source-name">{{ url }}</span>
|
||||
<button type="button" class="source-remove" :disabled="busy" :aria-label="`移除 ${url}`" @click="unstageUrl(i)">
|
||||
<font-awesome-icon icon="fa-solid fa-xmark" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
<li v-for="(file, i) in files" :key="`f-${file.name}-${file.size}`" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-file-pdf" class="source-icon" aria-hidden="true" />
|
||||
<span class="source-name">{{ file.name }}</span>
|
||||
<button type="button" class="source-remove" :disabled="busy" :aria-label="`移除 ${file.name}`" @click="unstageFile(i)">
|
||||
<font-awesome-icon icon="fa-solid fa-xmark" aria-hidden="true" />
|
||||
</button>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<!-- Already indexed for this agent -->
|
||||
<ul v-if="knowledgeItems.length" class="source-list indexed" aria-label="已添加">
|
||||
<li v-for="item in knowledgeItems" :key="item.id" class="source-row">
|
||||
<font-awesome-icon icon="fa-solid fa-check" class="source-icon done" aria-hidden="true" />
|
||||
<span class="source-name">{{ item.name }}</span>
|
||||
<span class="source-tag">已索引</span>
|
||||
</li>
|
||||
</ul>
|
||||
|
||||
<div
|
||||
v-if="isUploading"
|
||||
class="progress"
|
||||
role="progressbar"
|
||||
aria-label="上传进度"
|
||||
aria-valuemin="0"
|
||||
aria-valuemax="100"
|
||||
:aria-valuenow="uploadProgress"
|
||||
>
|
||||
<div class="progress-bar" :style="{ width: `${uploadProgress}%` }"></div>
|
||||
</div>
|
||||
|
||||
<p v-if="uploadError" class="step-error" role="alert">{{ uploadError }}</p>
|
||||
|
||||
<div v-if="crawlFailure" class="crawl-failure">
|
||||
<p class="step-error" role="alert">无法读取 {{ crawlFailure.source }}。{{ crawlFailure.reason }}</p>
|
||||
<TeachTextFallback
|
||||
v-if="showTextFallback"
|
||||
:agent-id="agentId"
|
||||
:organization-id="organizationId"
|
||||
@added="handleTextAdded"
|
||||
@cancel="closeTextFallback"
|
||||
@busy="textSaving = $event"
|
||||
/>
|
||||
<div v-else class="failure-actions">
|
||||
<button ref="pasteButton" type="button" class="btn-soft" :disabled="busy" @click="openTextFallback">
|
||||
改为粘贴文本
|
||||
</button>
|
||||
<button type="button" class="btn-soft" :disabled="busy" @click="triggerFileInput">上传 PDF</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" :disabled="busy" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" :disabled="busy" @click="handleContinue">
|
||||
{{ continueLabel }}
|
||||
<span v-if="!advancing" class="arrow" aria-hidden="true">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./step-controls.css"></style>
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.source-input {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
/* On a phone the address gets its own row rather than a sliver beside the buttons */
|
||||
@media (max-width: 520px) {
|
||||
.source-input {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.source-input .text-input {
|
||||
flex-basis: 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input.mono {
|
||||
font-family: var(--font-mono);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.source-list {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.source-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 10px;
|
||||
padding: 11px 14px;
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
|
||||
.source-icon {
|
||||
flex-shrink: 0;
|
||||
width: 14px;
|
||||
font-size: 14px;
|
||||
color: var(--muted);
|
||||
}
|
||||
|
||||
.source-icon.done {
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.source-name {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--text);
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.source-tag {
|
||||
font-size: 11px;
|
||||
font-family: var(--font-mono);
|
||||
color: var(--accent-ink);
|
||||
}
|
||||
|
||||
.source-remove {
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 15px;
|
||||
line-height: 1;
|
||||
cursor: pointer;
|
||||
padding: 2px 4px;
|
||||
}
|
||||
|
||||
.source-remove:hover:not(:disabled) {
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.source-remove:disabled {
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.progress {
|
||||
height: 6px;
|
||||
background: var(--o08);
|
||||
border-radius: var(--radius-pill);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.progress-bar {
|
||||
height: 100%;
|
||||
background: var(--accent-solid);
|
||||
transition: width 0.3s ease;
|
||||
}
|
||||
|
||||
.crawl-failure {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
padding: 14px 16px;
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
}
|
||||
|
||||
.failure-actions {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.btn-accent:disabled,
|
||||
.btn-ghost:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,482 @@
|
||||
<!--
|
||||
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 { nextTick, onBeforeUnmount, onMounted, ref, watch } from 'vue'
|
||||
import { marked } from 'marked'
|
||||
import { sanitizeHtml } from '@/utils/sanitize'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
import { useOnboardingTestChat } from '@/composables/useOnboardingTestChat'
|
||||
import { newestPerSource } from '@/composables/useCrawlCheck'
|
||||
import type { QueueItem } from '@/types/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
agentName: string
|
||||
widgetId: string | null
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'next'): void
|
||||
(e: 'back'): void
|
||||
(e: 'widget-created', widgetId: string): void
|
||||
}>()
|
||||
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
connecting,
|
||||
connectionError,
|
||||
start,
|
||||
send,
|
||||
cleanup,
|
||||
} = useOnboardingTestChat()
|
||||
|
||||
const input = ref('')
|
||||
const setupError = ref('')
|
||||
const scrollEl = ref<HTMLElement | null>(null)
|
||||
|
||||
// Knowledge ingestion runs in the background. While it's queued/processing the
|
||||
// agent can still answer general questions; we poll the queue and let the user
|
||||
// know it'll get smarter once indexing finishes.
|
||||
const knowledgeProcessing = ref(false)
|
||||
const knowledgeJustFinished = ref(false)
|
||||
// Nothing indexed and nothing indexing: answers can only be generic
|
||||
const noKnowledge = ref(false)
|
||||
// The newest source that failed, to say why when nothing was learned. A crawl
|
||||
// often fails long after Teach moved on: this is where the user finds out.
|
||||
const failure = ref<QueueItem | null>(null)
|
||||
let knowledgePoll: ReturnType<typeof setInterval> | null = null
|
||||
|
||||
const checkKnowledgeStatus = async () => {
|
||||
try {
|
||||
const data = await knowledgeService.getAgentQueueItems(props.agentId)
|
||||
const items = (data?.queue_items ?? []) as QueueItem[]
|
||||
const pending = items.filter(i => i.status === 'pending' || i.status === 'processing').length
|
||||
failure.value = newestPerSource(items).find(i => i.status === 'failed') ?? null
|
||||
const wasProcessing = knowledgeProcessing.value
|
||||
knowledgeProcessing.value = pending > 0
|
||||
if (wasProcessing && pending === 0) {
|
||||
stopKnowledgePoll()
|
||||
// Finished is not the same as learned: every source may have failed
|
||||
await checkHasKnowledge()
|
||||
knowledgeJustFinished.value = !noKnowledge.value
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to check knowledge status:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const checkHasKnowledge = async () => {
|
||||
if (knowledgeProcessing.value) return
|
||||
try {
|
||||
const page = await knowledgeService.getKnowledgeByAgent(props.agentId, 1, 1)
|
||||
noKnowledge.value = !(page?.pagination?.total_count || page?.knowledge?.length)
|
||||
} catch (err) {
|
||||
console.error('Failed to check agent knowledge:', err)
|
||||
}
|
||||
}
|
||||
|
||||
const stopKnowledgePoll = () => {
|
||||
if (knowledgePoll) {
|
||||
clearInterval(knowledgePoll)
|
||||
knowledgePoll = null
|
||||
}
|
||||
}
|
||||
|
||||
const ensureWidget = async (): Promise<string | null> => {
|
||||
if (props.widgetId) return props.widgetId
|
||||
try {
|
||||
const widget = await widgetService.createAgentWidget(props.agentId, props.agentName)
|
||||
emit('widget-created', widget.id)
|
||||
return widget.id
|
||||
} catch (err: any) {
|
||||
setupError.value = err?.response?.data?.detail || '测试会话准备失败。'
|
||||
return null
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(async () => {
|
||||
const widgetId = await ensureWidget()
|
||||
if (widgetId) await start(widgetId)
|
||||
// Watch the knowledge queue and poll while anything is still indexing
|
||||
await checkKnowledgeStatus()
|
||||
await checkHasKnowledge()
|
||||
if (knowledgeProcessing.value) {
|
||||
knowledgePoll = setInterval(checkKnowledgeStatus, 5000)
|
||||
}
|
||||
})
|
||||
|
||||
onBeforeUnmount(() => {
|
||||
cleanup()
|
||||
stopKnowledgePoll()
|
||||
})
|
||||
|
||||
const scrollToBottom = () => {
|
||||
nextTick(() => {
|
||||
if (scrollEl.value) scrollEl.value.scrollTop = scrollEl.value.scrollHeight
|
||||
})
|
||||
}
|
||||
|
||||
watch(() => messages.value.length, scrollToBottom)
|
||||
watch(loading, scrollToBottom)
|
||||
|
||||
const handleSend = () => {
|
||||
const text = input.value.trim()
|
||||
if (!text || loading.value) return
|
||||
send(text)
|
||||
input.value = ''
|
||||
}
|
||||
|
||||
const formatMessage = (content: string) => sanitizeHtml(marked(content) as string)
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="step">
|
||||
<header class="step-head">
|
||||
<h2 class="step-title">试一试</h2>
|
||||
<p class="step-sub">像客户一样提问,它的答复就是正式效果。</p>
|
||||
</header>
|
||||
|
||||
<!-- Knowledge indexing status -->
|
||||
<div v-if="knowledgeProcessing" class="kb-note processing">
|
||||
<span class="kb-spinner" aria-hidden="true"></span>
|
||||
<span>
|
||||
知识库仍在后台建立索引。客服现在可以先回答
|
||||
<strong>通用问题</strong>——索引完成后即可基于知识库作答,
|
||||
届时我们会通知你。
|
||||
</span>
|
||||
</div>
|
||||
<div v-else-if="knowledgeJustFinished" class="kb-note done">
|
||||
<span class="kb-check" aria-hidden="true">✓</span>
|
||||
<span>知识库已索引——客服现在可以基于它作答了。</span>
|
||||
</div>
|
||||
<div v-else-if="noKnowledge" class="kb-note empty" role="status">
|
||||
<span>
|
||||
<template v-if="failure">无法读取 {{ failure.source }}。{{ failure.reason }}</template>
|
||||
它还完全不了解你的业务,回答会比较通用。
|
||||
<button type="button" class="kb-link" @click="emit('back')">添加网站或文本</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div class="test-chat">
|
||||
<div v-if="connecting" class="chat-state">正在连接客服…</div>
|
||||
<div v-else-if="setupError || connectionError" class="chat-state error">
|
||||
{{ setupError || connectionError }}
|
||||
</div>
|
||||
|
||||
<div ref="scrollEl" class="chat-scroll">
|
||||
<div
|
||||
v-for="(msg, i) in messages"
|
||||
:key="i"
|
||||
class="bubble-row"
|
||||
:class="msg.message_type === 'user' ? 'from-user' : 'from-bot'"
|
||||
>
|
||||
<div class="bubble" v-html="formatMessage(msg.message || '')"></div>
|
||||
</div>
|
||||
<div v-if="loading" class="bubble-row from-bot">
|
||||
<div class="bubble typing">
|
||||
<span class="dot"></span><span class="dot"></span><span class="dot"></span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="chat-compose">
|
||||
<input
|
||||
v-model="input"
|
||||
class="text-input"
|
||||
type="text"
|
||||
placeholder="向客服提问…"
|
||||
:disabled="connecting || !!setupError"
|
||||
@keydown.enter.prevent="handleSend"
|
||||
/>
|
||||
<button type="button" class="send-btn" :disabled="connecting || loading || !!setupError" @click="handleSend" aria-label="发送">↑</button>
|
||||
</div>
|
||||
|
||||
<div class="step-actions">
|
||||
<button type="button" class="btn-ghost" @click="emit('back')">返回</button>
|
||||
<button type="button" class="btn-accent" @click="emit('next')">
|
||||
看起来不错 <span class="arrow">→</span>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 22px;
|
||||
}
|
||||
|
||||
.step-head {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.step-title {
|
||||
font-family: var(--font-display);
|
||||
font-weight: 600;
|
||||
font-size: 22px;
|
||||
margin: 0;
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.step-sub {
|
||||
font-size: 14.5px;
|
||||
color: var(--muted);
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.kb-note {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
gap: 10px;
|
||||
padding: 12px 14px;
|
||||
border-radius: 11px;
|
||||
font-size: 13px;
|
||||
line-height: 1.5;
|
||||
}
|
||||
|
||||
.kb-note.processing {
|
||||
background: var(--purple-bg);
|
||||
border: 1px solid var(--purple-border);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.kb-note.empty {
|
||||
border-color: var(--c-warn);
|
||||
}
|
||||
|
||||
.kb-link {
|
||||
background: none;
|
||||
border: 0;
|
||||
padding: 0;
|
||||
color: var(--accent-ink);
|
||||
font: inherit;
|
||||
text-decoration: underline;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.kb-note.done {
|
||||
background: var(--accent-bg-08);
|
||||
border: 1px solid var(--accent-border);
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.kb-note strong {
|
||||
color: var(--text);
|
||||
font-weight: 600;
|
||||
}
|
||||
|
||||
.kb-spinner {
|
||||
width: 14px;
|
||||
height: 14px;
|
||||
margin-top: 2px;
|
||||
flex-shrink: 0;
|
||||
border-radius: 50%;
|
||||
border: 2px solid var(--o20);
|
||||
border-top-color: var(--c-purple);
|
||||
animation: kb-spin 0.8s linear infinite;
|
||||
}
|
||||
|
||||
.kb-check {
|
||||
margin-top: 1px;
|
||||
flex-shrink: 0;
|
||||
color: var(--accent-ink);
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@keyframes kb-spin {
|
||||
to { transform: rotate(360deg); }
|
||||
}
|
||||
|
||||
.test-chat {
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o10);
|
||||
border-radius: var(--radius-input);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.chat-state {
|
||||
padding: 12px 16px;
|
||||
font-size: 13px;
|
||||
color: var(--muted);
|
||||
border-bottom: 1px solid var(--o08);
|
||||
}
|
||||
|
||||
.chat-state.error {
|
||||
color: var(--error-color);
|
||||
}
|
||||
|
||||
.chat-scroll {
|
||||
height: 300px;
|
||||
overflow-y: auto;
|
||||
padding: 18px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.bubble-row {
|
||||
display: flex;
|
||||
max-width: 85%;
|
||||
}
|
||||
|
||||
.from-user {
|
||||
align-self: flex-end;
|
||||
}
|
||||
|
||||
.from-bot {
|
||||
align-self: flex-start;
|
||||
}
|
||||
|
||||
.bubble {
|
||||
padding: 10px 14px;
|
||||
font-size: 13.5px;
|
||||
line-height: 1.5;
|
||||
border-radius: 14px 14px 14px 4px;
|
||||
}
|
||||
|
||||
.from-bot .bubble {
|
||||
background: var(--surface);
|
||||
border: 1px solid var(--o08);
|
||||
color: var(--text);
|
||||
}
|
||||
|
||||
.from-user .bubble {
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border-radius: 14px 14px 4px 14px;
|
||||
}
|
||||
|
||||
.typing {
|
||||
display: flex;
|
||||
gap: 4px;
|
||||
}
|
||||
|
||||
.dot {
|
||||
width: 7px;
|
||||
height: 7px;
|
||||
border-radius: 50%;
|
||||
background: var(--muted2);
|
||||
animation: bounce 1.4s infinite ease-in-out;
|
||||
}
|
||||
|
||||
.dot:nth-child(1) { animation-delay: -0.32s; }
|
||||
.dot:nth-child(2) { animation-delay: -0.16s; }
|
||||
|
||||
@keyframes bounce {
|
||||
0%, 80%, 100% { transform: scale(0); }
|
||||
40% { transform: scale(1); }
|
||||
}
|
||||
|
||||
.chat-compose {
|
||||
display: flex;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-input {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-family: var(--font-sans);
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-input:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.send-btn {
|
||||
flex-shrink: 0;
|
||||
width: 50px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-input);
|
||||
font-size: 18px;
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.send-btn:hover:not(:disabled) {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.step-actions {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: center;
|
||||
margin-top: 4px;
|
||||
}
|
||||
|
||||
.btn-ghost {
|
||||
padding: 14px 22px;
|
||||
background: var(--o05);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-btn);
|
||||
color: var(--text);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-ghost:hover {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-accent {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
padding: 14px 26px;
|
||||
background: var(--accent-solid);
|
||||
color: var(--on-accent-solid);
|
||||
border: none;
|
||||
border-radius: var(--radius-btn);
|
||||
font-size: 15px;
|
||||
font-weight: 600;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-accent:hover {
|
||||
filter: brightness(1.05);
|
||||
}
|
||||
|
||||
.send-btn:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.arrow {
|
||||
font-size: 17px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,133 @@
|
||||
<!--
|
||||
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, ref, watch } from 'vue'
|
||||
import { knowledgeService } from '@/services/knowledge'
|
||||
|
||||
const props = defineProps<{
|
||||
agentId: string
|
||||
organizationId: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'added'): void
|
||||
(e: 'cancel'): void
|
||||
// While the text is being saved the step around it must hold still.
|
||||
(e: 'busy', saving: boolean): void
|
||||
}>()
|
||||
|
||||
// Titles must be unique per org, and someone may paste more than once (or
|
||||
// already have an "About us"): stamp it so a second paste isn't refused.
|
||||
const titleNow = () =>
|
||||
`关于我们(${new Date().toLocaleString(undefined, { day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' })})`
|
||||
const MIN_LENGTH = 40 // enough to answer something from; less is a typo
|
||||
|
||||
const textArea = ref<HTMLTextAreaElement | null>(null)
|
||||
const content = ref('')
|
||||
const saving = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const hasText = computed(() => content.value.trim().length > 0)
|
||||
|
||||
watch(saving, (value) => emit('busy', value))
|
||||
// An error is about what was there when they pressed the button.
|
||||
watch(content, () => {
|
||||
error.value = null
|
||||
})
|
||||
onMounted(() => textArea.value?.focus())
|
||||
|
||||
const add = async () => {
|
||||
if (saving.value || !hasText.value) return
|
||||
const text = content.value.trim()
|
||||
// Say why rather than leave the button dead with no reason given.
|
||||
if (text.length < MIN_LENGTH) {
|
||||
error.value = '请多写几句话,让它有内容可答。'
|
||||
return
|
||||
}
|
||||
saving.value = true
|
||||
try {
|
||||
await knowledgeService.addText(props.organizationId, titleNow(), text, props.agentId)
|
||||
content.value = ''
|
||||
emit('added')
|
||||
} catch (err: any) {
|
||||
error.value = err?.message || '无法添加该文本'
|
||||
} finally {
|
||||
saving.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancel = () => {
|
||||
if (!saving.value) emit('cancel')
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<!-- Esc anywhere in the box (textarea or its buttons) backs out of it -->
|
||||
<div class="text-fallback" @keydown.esc.prevent="cancel">
|
||||
<textarea
|
||||
ref="textArea"
|
||||
v-model="content"
|
||||
class="text-area"
|
||||
rows="6"
|
||||
aria-label="关于你业务的内容"
|
||||
placeholder="粘贴客户常问的内容:卖什么、价格、配送、营业时间……"
|
||||
:disabled="saving"
|
||||
></textarea>
|
||||
<p v-if="error" class="step-error" role="alert">{{ error }}</p>
|
||||
<div class="fallback-actions">
|
||||
<button type="button" class="btn-soft" :disabled="!hasText || saving" @click="add">
|
||||
{{ saving ? '添加中…' : '添加文本' }}
|
||||
</button>
|
||||
<button type="button" class="btn-text" :disabled="saving" @click="cancel">取消</button>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<style scoped src="./step-controls.css"></style>
|
||||
<style scoped>
|
||||
.text-fallback {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 10px;
|
||||
}
|
||||
|
||||
.text-area {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
padding: 14px 16px;
|
||||
background: var(--bg);
|
||||
border: 1px solid var(--o12);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
line-height: 1.5;
|
||||
resize: vertical;
|
||||
outline: none;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.text-area:focus {
|
||||
border-color: var(--accent-ink);
|
||||
box-shadow: var(--ring-focus);
|
||||
}
|
||||
|
||||
.fallback-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
</style>
|
||||
@@ -0,0 +1,64 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/* Controls shared by the Teach step and the pieces it is built from. Pulled in
|
||||
with <style scoped src> so each component keeps its own scope. */
|
||||
|
||||
.btn-soft {
|
||||
flex-shrink: 0;
|
||||
padding: 14px 18px;
|
||||
background: var(--o06);
|
||||
border: 1px solid var(--o14);
|
||||
border-radius: var(--radius-input);
|
||||
color: var(--text);
|
||||
font-size: 14px;
|
||||
font-weight: 500;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
white-space: nowrap;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-soft:hover:not(:disabled) {
|
||||
background: var(--o10);
|
||||
}
|
||||
|
||||
.btn-text {
|
||||
padding: 14px 8px;
|
||||
background: none;
|
||||
border: none;
|
||||
color: var(--muted2);
|
||||
font-size: 14px;
|
||||
font-family: var(--font-sans);
|
||||
cursor: pointer;
|
||||
transition: var(--transition-fast);
|
||||
}
|
||||
|
||||
.btn-text:hover:not(:disabled) {
|
||||
color: var(--text3);
|
||||
}
|
||||
|
||||
.btn-soft:disabled,
|
||||
.btn-text:disabled {
|
||||
opacity: 0.6;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.step-error {
|
||||
margin: 0;
|
||||
font-size: 13.5px;
|
||||
color: var(--error-color);
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
/*
|
||||
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, onMounted } from 'vue'
|
||||
import { aiService, type AIConfig } from '@/services/ai'
|
||||
import type { AIProvider } from '@/types/ai'
|
||||
|
||||
export function useAISetup() {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string>('')
|
||||
const setupConfig = ref<{
|
||||
provider: string
|
||||
model: string
|
||||
apiKey: string
|
||||
baseUrl: string
|
||||
}>({
|
||||
provider: '',
|
||||
model: '',
|
||||
apiKey: '',
|
||||
baseUrl: '',
|
||||
})
|
||||
|
||||
const hasExistingConfig = ref(false)
|
||||
|
||||
// Provider catalog is served by the backend (GET /ai/providers) — single source
|
||||
// of truth. Values are normalized to lowercase to match the save/load flow, which
|
||||
// upper-cases on save and lower-cases the stored model_type on load.
|
||||
const providers = ref<AIProvider[]>([])
|
||||
|
||||
const loadProviders = async () => {
|
||||
try {
|
||||
const fetched = await aiService.getProviders()
|
||||
providers.value = fetched.map((p) => ({ ...p, value: p.value.toLowerCase() }))
|
||||
} catch (err: unknown) {
|
||||
const apiError = (err as { response?: { data?: { detail?: { details?: string; error?: string } } } }).response?.data?.detail;
|
||||
error.value = apiError?.details || apiError?.error || '加载服务商失败'
|
||||
}
|
||||
}
|
||||
|
||||
const loadExistingConfig = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = ''
|
||||
const config = await aiService.getOrganizationConfig()
|
||||
setupConfig.value = {
|
||||
provider: config.model_type.toLowerCase(),
|
||||
model: config.model_name,
|
||||
apiKey: config.api_key,
|
||||
baseUrl: (config.settings?.base_url as string) || ''
|
||||
}
|
||||
hasExistingConfig.value = true
|
||||
} catch (err: unknown) {
|
||||
const response = (err as { response?: { status?: number; data?: { detail?: { details?: string; error?: string } } } }).response;
|
||||
if (response?.status !== 404 && response?.data?.detail?.error !== 'AI configuration not found') {
|
||||
error.value = response?.data?.detail?.details || response?.data?.detail?.error || '加载配置失败'
|
||||
}
|
||||
hasExistingConfig.value = false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
const saveAISetup = async (): Promise<boolean> => {
|
||||
try {
|
||||
error.value = ''
|
||||
isLoading.value = true
|
||||
|
||||
if (hasExistingConfig.value) {
|
||||
return await updateAISetup()
|
||||
}
|
||||
|
||||
await aiService.setupAI({
|
||||
model_type: setupConfig.value.provider.toUpperCase(),
|
||||
model_name: setupConfig.value.model,
|
||||
api_key: setupConfig.value.apiKey,
|
||||
base_url: setupConfig.value.baseUrl || undefined,
|
||||
})
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
const apiError = (err as { response?: { data?: { detail?: { details?: string; error?: string } } } }).response?.data?.detail;
|
||||
error.value = apiError?.details || apiError?.error || '设置失败,请重试。'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateAISetup = async (): Promise<boolean> => {
|
||||
try {
|
||||
error.value = ''
|
||||
isLoading.value = true
|
||||
await aiService.updateAI({
|
||||
model_type: setupConfig.value.provider.toUpperCase(),
|
||||
model_name: setupConfig.value.model,
|
||||
api_key: setupConfig.value.apiKey,
|
||||
base_url: setupConfig.value.baseUrl || undefined,
|
||||
})
|
||||
return true
|
||||
} catch (err: unknown) {
|
||||
const apiError = (err as { response?: { data?: { detail?: { details?: string; error?: string } } } }).response?.data?.detail;
|
||||
error.value = apiError?.details || apiError?.error || '更新失败,请重试。'
|
||||
return false
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
loadProviders()
|
||||
loadExistingConfig()
|
||||
})
|
||||
|
||||
return {
|
||||
isLoading,
|
||||
error,
|
||||
setupConfig,
|
||||
providers,
|
||||
saveAISetup,
|
||||
updateAISetup,
|
||||
loadProviders,
|
||||
loadExistingConfig,
|
||||
hasExistingConfig
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,154 @@
|
||||
/*
|
||||
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, watch } from 'vue'
|
||||
import type { Ref } from 'vue'
|
||||
import type { Agent, AgentUpdate } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
|
||||
export function useAgentAdvanced(agent: Ref<Agent>) {
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const hasUnsavedChanges = ref(false)
|
||||
|
||||
// Rate limiting settings - local state
|
||||
const localSettings = ref({
|
||||
enableRateLimiting: agent.value.enable_rate_limiting || false,
|
||||
overallLimitPerIp: String(agent.value.overall_limit_per_ip || 100),
|
||||
requestsPerSec: String(agent.value.requests_per_sec || 1)
|
||||
})
|
||||
|
||||
// Watch for changes in the agent object to update local state
|
||||
watch(() => agent.value, (newAgent) => {
|
||||
localSettings.value = {
|
||||
enableRateLimiting: newAgent.enable_rate_limiting || false,
|
||||
overallLimitPerIp: String(newAgent.overall_limit_per_ip || 100),
|
||||
requestsPerSec: String(newAgent.requests_per_sec || 1)
|
||||
}
|
||||
hasUnsavedChanges.value = false
|
||||
})
|
||||
|
||||
// Tooltip content
|
||||
const rateLimitTooltipContent = () => {
|
||||
return `启用后:\n• 按 IP 限制请求数\n• 防止滥用\n• 控制流量\n• 保护你的 API`
|
||||
}
|
||||
|
||||
const dailyLimitTooltipContent = () => {
|
||||
return `每个 IP 每天允许的最大请求数。\n建议:大多数场景使用 100-500。`
|
||||
}
|
||||
|
||||
const requestsPerSecTooltipContent = () => {
|
||||
return `单个 IP 每秒允许的最大请求数。\n建议使用整数(1-10)以便更好地控制。`
|
||||
}
|
||||
|
||||
// Toggle rate limiting with API call
|
||||
const toggleRateLimiting = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const updatedData: AgentUpdate = {
|
||||
enable_rate_limiting: !localSettings.value.enableRateLimiting,
|
||||
// When enabling, set default values
|
||||
...((!localSettings.value.enableRateLimiting) && {
|
||||
overall_limit_per_ip: 100,
|
||||
requests_per_sec: 1
|
||||
})
|
||||
}
|
||||
|
||||
const updatedAgent = await agentService.updateAgent(agent.value.id, updatedData)
|
||||
|
||||
// Update the agent reference and local settings with the new data
|
||||
agent.value = { ...agent.value, ...updatedAgent }
|
||||
localSettings.value = {
|
||||
enableRateLimiting: updatedAgent.enable_rate_limiting,
|
||||
overallLimitPerIp: String(updatedAgent.overall_limit_per_ip),
|
||||
requestsPerSec: String(updatedAgent.requests_per_sec)
|
||||
}
|
||||
hasUnsavedChanges.value = false
|
||||
|
||||
return updatedAgent
|
||||
} catch (err) {
|
||||
error.value = '更新限流设置失败'
|
||||
console.error('Toggle rate limiting error:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Update local rate limit values
|
||||
const updateLocalValue = (type: 'overallLimitPerIp' | 'requestsPerSec', value: string) => {
|
||||
if (type === 'requestsPerSec') {
|
||||
// Ensure whole numbers for requests per second
|
||||
const numValue = parseInt(value)
|
||||
if (numValue < 1) value = '1'
|
||||
else if (numValue > 10) value = '10'
|
||||
else value = String(numValue)
|
||||
}
|
||||
|
||||
localSettings.value = {
|
||||
...localSettings.value,
|
||||
[type]: value
|
||||
}
|
||||
hasUnsavedChanges.value = true
|
||||
}
|
||||
|
||||
// Save rate limit settings
|
||||
const saveRateLimitSettings = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const updatedData: AgentUpdate = {
|
||||
overall_limit_per_ip: parseInt(localSettings.value.overallLimitPerIp),
|
||||
requests_per_sec: parseInt(localSettings.value.requestsPerSec)
|
||||
}
|
||||
|
||||
const updatedAgent = await agentService.updateAgent(agent.value.id, updatedData)
|
||||
|
||||
// Update the agent reference and local settings with the new data
|
||||
agent.value = { ...agent.value, ...updatedAgent }
|
||||
localSettings.value = {
|
||||
...localSettings.value,
|
||||
overallLimitPerIp: String(updatedAgent.overall_limit_per_ip),
|
||||
requestsPerSec: String(updatedAgent.requests_per_sec)
|
||||
}
|
||||
hasUnsavedChanges.value = false
|
||||
|
||||
return updatedAgent
|
||||
} catch (err) {
|
||||
error.value = '更新限流设置失败'
|
||||
console.error('Update rate limit settings error:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
localSettings,
|
||||
isLoading,
|
||||
error,
|
||||
hasUnsavedChanges,
|
||||
rateLimitTooltipContent,
|
||||
dailyLimitTooltipContent,
|
||||
requestsPerSecTooltipContent,
|
||||
toggleRateLimiting,
|
||||
updateLocalValue,
|
||||
saveRateLimitSettings
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,93 @@
|
||||
/*
|
||||
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 { ChatMessage } from '@/types/agent'
|
||||
|
||||
export function useAgentChat(agentId: string) {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
const currentInput = ref('')
|
||||
|
||||
// Initialize chat with welcome message
|
||||
const initChat = () => {
|
||||
messages.value = [
|
||||
{
|
||||
role: 'bot',
|
||||
content: '你好!有什么可以帮你的吗?',
|
||||
},
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// Handle incoming chat responses
|
||||
const handleChatResponse = (data: unknown) => {
|
||||
isLoading.value = false
|
||||
const response = data as { message: string; type: string }
|
||||
messages.value.push({
|
||||
role: 'bot',
|
||||
content: response.message,
|
||||
})
|
||||
}
|
||||
|
||||
// Handle errors
|
||||
const handleError = (data: unknown) => {
|
||||
isLoading.value = false
|
||||
const errorData = data as { error: string; type: string }
|
||||
error.value = errorData.error
|
||||
}
|
||||
|
||||
// Send message
|
||||
const sendMessage = async (message: string) => {
|
||||
if (!message.trim()) return
|
||||
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
// Add user message to chat
|
||||
messages.value.push({
|
||||
role: 'user',
|
||||
content: message,
|
||||
})
|
||||
|
||||
// Clear input
|
||||
currentInput.value = ''
|
||||
|
||||
|
||||
} catch (err) {
|
||||
error.value = '发送消息失败'
|
||||
console.error('Chat error:', err)
|
||||
}
|
||||
}
|
||||
|
||||
// Cleanup function
|
||||
const cleanup = () => {
|
||||
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
isLoading,
|
||||
error,
|
||||
currentInput,
|
||||
initChat,
|
||||
sendMessage,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
/*
|
||||
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 { Agent } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
|
||||
export function useAgentCreate() {
|
||||
const agentName = ref('')
|
||||
const useWorkflow = ref(false)
|
||||
const isCreating = ref(false)
|
||||
const error = ref('')
|
||||
|
||||
const validateForm = () => {
|
||||
if (!agentName.value.trim()) {
|
||||
error.value = '客服名称必填'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
}
|
||||
|
||||
const createAgent = async () => {
|
||||
if (!validateForm()) return null
|
||||
|
||||
try {
|
||||
isCreating.value = true
|
||||
error.value = ''
|
||||
const newAgent = await agentService.createAgent({
|
||||
name: agentName.value.trim(),
|
||||
display_name: agentName.value.trim(),
|
||||
agent_type: 'custom',
|
||||
instructions: ['I am an AI assistant here to help with questions.'],
|
||||
is_active: true,
|
||||
use_workflow: useWorkflow.value
|
||||
})
|
||||
|
||||
return newAgent
|
||||
} catch (err: any) {
|
||||
error.value = err.response?.data?.detail || '创建客服失败'
|
||||
return null
|
||||
} finally {
|
||||
isCreating.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
agentName,
|
||||
useWorkflow,
|
||||
isCreating,
|
||||
error,
|
||||
createAgent
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,326 @@
|
||||
/*
|
||||
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 { AgentWithCustomization, AgentUpdate } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import type { Widget } from '@/types/widget'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
import { listGroups } from '@/services/groups'
|
||||
import { agentStorage } from '@/utils/storage'
|
||||
import { useJiraIntegration } from './useJiraIntegration'
|
||||
import { useEnterpriseFeatures } from '@/composables/useEnterpriseFeatures'
|
||||
import { buildWidgetEmbed } from '@/utils/widgetEmbed'
|
||||
|
||||
const { hasEnterpriseModule, loadModule, moduleImports } = useEnterpriseFeatures()
|
||||
|
||||
// Lazy load Shopify integration only if enterprise module is available
|
||||
let useShopifyIntegration: any = null
|
||||
if (hasEnterpriseModule) {
|
||||
// Load Shopify integration asynchronously
|
||||
;(async () => {
|
||||
try {
|
||||
const shopifyIntegrationModule = await loadModule(moduleImports.shopifyIntegration)
|
||||
if (shopifyIntegrationModule?.useShopifyIntegration) {
|
||||
useShopifyIntegration = shopifyIntegrationModule.useShopifyIntegration
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load Shopify integration:', error)
|
||||
}
|
||||
})()
|
||||
}
|
||||
|
||||
export function useAgentDetail(agentData: { value: AgentWithCustomization }, emit: (e: 'close') => void) {
|
||||
const fileInput = ref<HTMLInputElement | null>(null)
|
||||
const isUploading = ref(false)
|
||||
const showCropper = ref(false)
|
||||
const cropperImage = ref('')
|
||||
const cropper = ref<any>(null)
|
||||
const widget = ref<Widget | null>(null)
|
||||
const widgetLoading = ref(false)
|
||||
const userGroups = ref<UserGroup[]>([])
|
||||
const selectedGroupIds = ref<string[]>([])
|
||||
const loadingGroups = ref(false)
|
||||
|
||||
// Initialize Jira integration
|
||||
const jiraIntegration = useJiraIntegration(agentData.value.id)
|
||||
|
||||
// Initialize Shopify integration only if enterprise module is available
|
||||
const shopifyIntegration = hasEnterpriseModule && useShopifyIntegration
|
||||
? useShopifyIntegration(agentData.value.id)
|
||||
: {
|
||||
shopifyConnected: ref(false),
|
||||
shopifyShopDomain: ref(''),
|
||||
shopifyLoading: ref(false),
|
||||
shopifyIntegrationEnabled: ref(false),
|
||||
checkShopifyStatus: () => Promise.resolve(),
|
||||
fetchAgentShopifyConfig: () => Promise.resolve(),
|
||||
toggleShopifyIntegration: () => Promise.resolve(),
|
||||
saveShopifyConfig: () => Promise.resolve()
|
||||
}
|
||||
|
||||
const MAX_FILE_SIZE = 5 * 1024 * 1024 // 5MB
|
||||
const ALLOWED_TYPES = ['image/jpeg', 'image/png', 'image/webp']
|
||||
|
||||
const triggerFileUpload = () => {
|
||||
fileInput.value?.click()
|
||||
}
|
||||
|
||||
const handleFileUpload = async (event: Event) => {
|
||||
const file = (event.target as HTMLInputElement).files?.[0]
|
||||
if (!file) return
|
||||
|
||||
// Validate file size
|
||||
if (file.size > MAX_FILE_SIZE) {
|
||||
alert('文件大小不能超过 5MB')
|
||||
return
|
||||
}
|
||||
|
||||
// Validate file type
|
||||
if (!ALLOWED_TYPES.includes(file.type)) {
|
||||
alert('仅支持 JPEG、PNG 和 WebP 图片')
|
||||
return
|
||||
}
|
||||
|
||||
// Show cropper
|
||||
cropperImage.value = URL.createObjectURL(file)
|
||||
showCropper.value = true
|
||||
}
|
||||
|
||||
const handleCrop = async () => {
|
||||
if (!cropper.value) return
|
||||
|
||||
try {
|
||||
isUploading.value = true
|
||||
const { canvas } = cropper.value.getResult()
|
||||
if (!canvas) throw new Error('Canvas not found')
|
||||
|
||||
const blob = await new Promise<Blob>((resolve, reject) => {
|
||||
canvas.toBlob((blob: Blob | null) => {
|
||||
if (blob) resolve(blob)
|
||||
else reject(new Error('Failed to create blob'))
|
||||
}, 'image/png')
|
||||
})
|
||||
|
||||
const croppedFile = new File([blob], 'profile.png', { type: 'image/png' })
|
||||
const updatedCustomization = await agentService.uploadAgentPhoto(agentData.value.id, croppedFile)
|
||||
agentData.value.customization = updatedCustomization
|
||||
|
||||
// A real picture was just uploaded — turn off the aurora orb if it was on.
|
||||
const meta = updatedCustomization?.customization_metadata as Record<string, unknown> | undefined
|
||||
if (meta?.avatar_style === 'orb') {
|
||||
agentData.value.customization = await agentService.updateCustomization(agentData.value.id, {
|
||||
...updatedCustomization,
|
||||
customization_metadata: { ...meta, avatar_style: 'photo' },
|
||||
})
|
||||
}
|
||||
|
||||
showCropper.value = false
|
||||
cropperImage.value = ''
|
||||
} catch (error) {
|
||||
console.error('Failed to upload photo:', error)
|
||||
alert('上传照片失败')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const cancelCrop = () => {
|
||||
showCropper.value = false
|
||||
cropperImage.value = ''
|
||||
}
|
||||
|
||||
// Apply a bundled preset avatar (fetched into a File) via the photo endpoint
|
||||
const applyPresetAvatar = async (url: string) => {
|
||||
try {
|
||||
isUploading.value = true
|
||||
const res = await fetch(url)
|
||||
const blob = await res.blob()
|
||||
const file = new File([blob], 'profile.png', { type: blob.type || 'image/png' })
|
||||
const updatedCustomization = await agentService.uploadAgentPhoto(agentData.value.id, file)
|
||||
agentData.value.customization = updatedCustomization
|
||||
} catch (error) {
|
||||
console.error('Failed to apply preset avatar:', error)
|
||||
alert('更新照片失败')
|
||||
} finally {
|
||||
isUploading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const handleClose = (cleanup: () => void) => {
|
||||
cleanup()
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const initializeWidget = async () => {
|
||||
try {
|
||||
widgetLoading.value = true
|
||||
// Try to fetch existing widget
|
||||
const widgets = await widgetService.getWidgets()
|
||||
const existingWidget = widgets.find((w: Widget) => w.agent_id === agentData.value.id)
|
||||
|
||||
if (existingWidget) {
|
||||
widget.value = existingWidget
|
||||
} else {
|
||||
// Create new widget if none exists
|
||||
const newWidget = await widgetService.createWidget({
|
||||
name: `${agentData.value.name} Widget`,
|
||||
agent_id: agentData.value.id
|
||||
})
|
||||
widget.value = newWidget
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Failed to initialize widget:', error)
|
||||
} finally {
|
||||
widgetLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const copyWidgetCode = (requireTokenAuth?: boolean) => {
|
||||
if (!widget.value) return
|
||||
|
||||
// Single source of truth for the embed snippet (see buildWidgetEmbed) so the
|
||||
// copied code never drifts from the AI Agents list or the on-screen preview.
|
||||
const code = buildWidgetEmbed(widget.value.id, requireTokenAuth)
|
||||
|
||||
navigator.clipboard.writeText(code)
|
||||
.then(() => {
|
||||
toast.success(requireTokenAuth
|
||||
? '已复制带 Token 认证的组件代码!'
|
||||
: '已复制组件代码!',
|
||||
{ duration: 3000 }
|
||||
)
|
||||
})
|
||||
.catch(err => console.error('Failed to copy:', err))
|
||||
}
|
||||
|
||||
const toggleAskForRating = async () => {
|
||||
try {
|
||||
console.log('toggleAskForRating', agentData.value.ask_for_rating)
|
||||
const updatedAgent = await agentService.updateAgent(agentData.value.id, {
|
||||
ask_for_rating: !agentData.value.ask_for_rating
|
||||
})
|
||||
agentData.value = {
|
||||
...agentData.value,
|
||||
ask_for_rating: updatedAgent.ask_for_rating
|
||||
}
|
||||
toast.success(`评分请求${updatedAgent.ask_for_rating ? '已开启' : '已关闭'}`, {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to update rating setting:', error)
|
||||
toast.error('更新评分设置失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const toggleTransferToHuman = async () => {
|
||||
try {
|
||||
const updatedAgent = await agentService.updateAgent(agentData.value.id, {
|
||||
transfer_to_human: !agentData.value.transfer_to_human
|
||||
})
|
||||
agentData.value = {
|
||||
...agentData.value,
|
||||
transfer_to_human: updatedAgent.transfer_to_human
|
||||
}
|
||||
toast.success(`转人工功能${updatedAgent.transfer_to_human ? '已开启' : '已关闭'}`, {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (error) {
|
||||
console.error('Failed to update transfer setting:', error)
|
||||
toast.error('更新转人工设置失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const fetchUserGroups = async () => {
|
||||
try {
|
||||
loadingGroups.value = true
|
||||
// Get all available groups
|
||||
const groups = await listGroups()
|
||||
userGroups.value = groups
|
||||
|
||||
// Get agent's current groups and details
|
||||
const updatedAgent = await agentService.getAgentById(agentData.value.id)
|
||||
|
||||
// Update agent data and storage
|
||||
agentData.value = updatedAgent
|
||||
agentStorage.updateAgent(updatedAgent)
|
||||
|
||||
// Set selected groups
|
||||
selectedGroupIds.value = updatedAgent.groups?.map(g => g.id) || []
|
||||
} catch (error) {
|
||||
console.error('Failed to fetch user groups:', error)
|
||||
toast.error('加载用户组失败')
|
||||
} finally {
|
||||
loadingGroups.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateAgentGroups = async (groupIds: string[]) => {
|
||||
try {
|
||||
const updatedAgent = await agentService.updateAgentGroups(agentData.value.id, groupIds)
|
||||
agentData.value = {
|
||||
...agentData.value,
|
||||
groups: updatedAgent.groups
|
||||
}
|
||||
agentStorage.updateAgent(updatedAgent)
|
||||
toast.success('转接分组已更新')
|
||||
} catch (error) {
|
||||
console.error('Failed to update agent groups:', error)
|
||||
toast.error('更新转接分组失败')
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
fileInput,
|
||||
isUploading,
|
||||
showCropper,
|
||||
cropperImage,
|
||||
cropper,
|
||||
widget,
|
||||
widgetLoading,
|
||||
triggerFileUpload,
|
||||
handleFileUpload,
|
||||
handleCrop,
|
||||
cancelCrop,
|
||||
applyPresetAvatar,
|
||||
handleClose,
|
||||
initializeWidget,
|
||||
copyWidgetCode,
|
||||
toggleAskForRating,
|
||||
toggleTransferToHuman,
|
||||
userGroups,
|
||||
selectedGroupIds,
|
||||
loadingGroups,
|
||||
fetchUserGroups,
|
||||
updateAgentGroups,
|
||||
|
||||
// Jira integration - spread all properties and methods from jiraIntegration
|
||||
...jiraIntegration,
|
||||
|
||||
// Shopify integration - spread all properties and methods from shopifyIntegration
|
||||
...shopifyIntegration
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,100 @@
|
||||
/*
|
||||
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 { Agent } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { useAgentStorage } from '@/utils/storage'
|
||||
|
||||
export function useAgentEdit(agent: Agent) {
|
||||
const agentStorage = useAgentStorage()
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
// Basic state
|
||||
const displayName = ref<string>(agent.display_name || agent.name)
|
||||
const isActive = ref<boolean>(agent.is_active)
|
||||
const instructions = ref<string[]>(
|
||||
Array.isArray(agent.instructions) ? [...agent.instructions] : [agent.instructions || ''],
|
||||
)
|
||||
|
||||
// Instructions handlers
|
||||
const addInstruction = () => {
|
||||
instructions.value.push('')
|
||||
}
|
||||
|
||||
const removeInstruction = (index: number) => {
|
||||
instructions.value.splice(index, 1)
|
||||
}
|
||||
|
||||
// Generate instructions with AI
|
||||
const generateInstructions = async (prompt: string): Promise<string[]> => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const response = await agentService.generateInstructions(prompt, instructions.value)
|
||||
return response
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 429) {
|
||||
error.value = err?.response?.data?.detail || '请求过于频繁,请稍后重试。';
|
||||
} else {
|
||||
error.value = '生成指令失败'
|
||||
}
|
||||
console.error('Generate instructions error:', err)
|
||||
return []
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
// Save handler
|
||||
const handleSave = async () => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
error.value = null
|
||||
|
||||
const updatedData = {
|
||||
display_name: displayName.value,
|
||||
is_active: isActive.value,
|
||||
instructions: instructions.value.filter((i) => i.trim()),
|
||||
}
|
||||
|
||||
const updatedAgent = await agentService.updateAgent(agent.id, updatedData)
|
||||
agentStorage.updateAgent(updatedAgent)
|
||||
|
||||
return updatedAgent
|
||||
} catch (err) {
|
||||
error.value = '更新客服失败'
|
||||
console.error('Save error:', err)
|
||||
throw err
|
||||
} finally {
|
||||
isLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
displayName,
|
||||
isActive,
|
||||
instructions,
|
||||
isLoading,
|
||||
error,
|
||||
addInstruction,
|
||||
removeInstruction,
|
||||
generateInstructions,
|
||||
handleSave,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
/*
|
||||
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, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { workflowService } from '@/services/workflow'
|
||||
import type { WorkflowResponse, WorkflowCreate } from '@/types/workflow'
|
||||
|
||||
export function useAgentWorkflow(agentId: string) {
|
||||
const workflow = ref<WorkflowResponse | null>(null)
|
||||
const workflowLoading = ref(false)
|
||||
const workflowError = ref('')
|
||||
const createWorkflowLoading = ref(false)
|
||||
|
||||
const hasWorkflow = computed(() => workflow.value !== null)
|
||||
|
||||
const fetchWorkflow = async () => {
|
||||
try {
|
||||
workflowLoading.value = true
|
||||
workflowError.value = ''
|
||||
workflow.value = await workflowService.getWorkflowByAgent(agentId)
|
||||
} catch (error: any) {
|
||||
console.error('Error fetching workflow:', error)
|
||||
|
||||
// Don't show error toast for 404 - it means agent doesn't have a workflow yet (normal state)
|
||||
if (error.response?.status !== 404) {
|
||||
workflowError.value = error.response?.data?.detail || '加载工作流失败'
|
||||
toast.error('加载工作流失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} else {
|
||||
// For 404, just log and set workflow to null
|
||||
console.log('No workflow found for agent - agent has no workflow yet')
|
||||
workflow.value = null
|
||||
workflowError.value = ''
|
||||
}
|
||||
} finally {
|
||||
workflowLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const createWorkflow = async (data: Omit<WorkflowCreate, 'agent_id'>) => {
|
||||
try {
|
||||
createWorkflowLoading.value = true
|
||||
workflowError.value = ''
|
||||
|
||||
const workflowData: WorkflowCreate = {
|
||||
...data,
|
||||
agent_id: agentId
|
||||
}
|
||||
|
||||
const newWorkflow = await workflowService.createWorkflow(workflowData)
|
||||
workflow.value = newWorkflow
|
||||
|
||||
toast.success('工作流已创建', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
|
||||
return newWorkflow
|
||||
} catch (error: any) {
|
||||
console.error('Error creating workflow:', error)
|
||||
workflowError.value = error.response?.data?.detail || '创建工作流失败'
|
||||
toast.error('创建工作流失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
createWorkflowLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const updateWorkflow = async (workflowId: string, data: Partial<WorkflowCreate>) => {
|
||||
try {
|
||||
workflowLoading.value = true
|
||||
workflowError.value = ''
|
||||
|
||||
const updatedWorkflow = await workflowService.updateWorkflow(workflowId, data)
|
||||
workflow.value = updatedWorkflow
|
||||
|
||||
toast.success('工作流已更新', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
|
||||
return updatedWorkflow
|
||||
} catch (error: any) {
|
||||
console.error('Error updating workflow:', error)
|
||||
workflowError.value = error.response?.data?.detail || '更新工作流失败'
|
||||
toast.error('更新工作流失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
workflowLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const deleteWorkflow = async (workflowId: string) => {
|
||||
try {
|
||||
workflowLoading.value = true
|
||||
workflowError.value = ''
|
||||
|
||||
await workflowService.deleteWorkflow(workflowId)
|
||||
workflow.value = null
|
||||
|
||||
toast.success('工作流已删除', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
} catch (error: any) {
|
||||
console.error('Error deleting workflow:', error)
|
||||
workflowError.value = error.response?.data?.detail || '删除工作流失败'
|
||||
toast.error('删除工作流失败', {
|
||||
duration: 4000,
|
||||
closeButton: true
|
||||
})
|
||||
throw error
|
||||
} finally {
|
||||
workflowLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
workflow,
|
||||
workflowLoading,
|
||||
workflowError,
|
||||
createWorkflowLoading,
|
||||
hasWorkflow,
|
||||
fetchWorkflow,
|
||||
createWorkflow,
|
||||
updateWorkflow,
|
||||
deleteWorkflow
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
/*
|
||||
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 api from '@/services/api'
|
||||
import { useWidgetSocket } from '@/composables/useWidgetSocket'
|
||||
|
||||
/**
|
||||
* Drives a live test conversation against an agent from the dashboard by
|
||||
* reusing the public widget socket path. The dashboard has no internal
|
||||
* test-chat endpoint, so we mint an anonymous conversation token for the
|
||||
* agent's widget and connect to the `/widget` namespace like the real client.
|
||||
*
|
||||
* Requires the widget's agent to allow anonymous chat (require_token_auth=false,
|
||||
* the default). If token minting fails (401), `connectionError` is surfaced.
|
||||
*/
|
||||
export function useOnboardingTestChat() {
|
||||
const {
|
||||
messages,
|
||||
loading,
|
||||
connectionStatus,
|
||||
sendMessage: socketSendMessage,
|
||||
connect,
|
||||
setToken,
|
||||
cleanup: socketCleanup,
|
||||
} = useWidgetSocket()
|
||||
|
||||
// Older builds persisted the socket's token to localStorage 'rpid'. Nothing writes
|
||||
// it from here any more, but the removal stays: a token left by one of those builds
|
||||
// must not leak into a real widget session on the same origin.
|
||||
const cleanup = () => {
|
||||
socketCleanup()
|
||||
try { localStorage.removeItem('rpid') } catch { /* ignore */ }
|
||||
}
|
||||
|
||||
const connecting = ref(false)
|
||||
const connectionError = ref<string | null>(null)
|
||||
// Synthetic email forces the token endpoint to mint a conversation token
|
||||
// for a plain AI agent (see backend widget.py should_create_customer).
|
||||
const customerEmail = `onboarding+${Date.now()}@noemail.com`
|
||||
|
||||
/**
|
||||
* Fetch an anonymous conversation token for the widget and connect.
|
||||
* Returns true on success.
|
||||
*/
|
||||
const start = async (widgetId: string): Promise<boolean> => {
|
||||
connecting.value = true
|
||||
connectionError.value = null
|
||||
try {
|
||||
const { data } = await api.get(`/widgets/${widgetId}`, {
|
||||
params: { email: customerEmail },
|
||||
})
|
||||
const token: string | undefined = data?.token
|
||||
if (!token) {
|
||||
connectionError.value = '无法为该客服启动测试会话。'
|
||||
return false
|
||||
}
|
||||
setToken(token)
|
||||
const connected = await connect()
|
||||
if (!connected) {
|
||||
connectionError.value = '无法连接客服,请重试。'
|
||||
return false
|
||||
}
|
||||
return true
|
||||
} catch (err: any) {
|
||||
if (err?.response?.status === 401) {
|
||||
connectionError.value =
|
||||
'该客服要求登录后才能对话,请改为在线上组件中测试。'
|
||||
} else {
|
||||
connectionError.value =
|
||||
err?.response?.data?.detail || '无法启动测试会话。'
|
||||
}
|
||||
return false
|
||||
} finally {
|
||||
connecting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
const send = (text: string) => {
|
||||
if (!text.trim()) return
|
||||
socketSendMessage(text, customerEmail, [])
|
||||
}
|
||||
|
||||
return {
|
||||
messages,
|
||||
loading,
|
||||
connecting,
|
||||
connectionStatus,
|
||||
connectionError,
|
||||
start,
|
||||
send,
|
||||
cleanup,
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
/*
|
||||
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.
|
||||
*/
|
||||
|
||||
/**
|
||||
* The footer line disclosing that replies are AI-generated.
|
||||
*
|
||||
* Copy and visibility rule live here, not in each template, so the live widget,
|
||||
* the dashboard preview and the settings description cannot drift apart.
|
||||
*/
|
||||
export const AI_DISCLAIMER_TEXT = 'AI 也可能出错,重要信息请核实。'
|
||||
|
||||
/**
|
||||
* Whether the disclosure should be rendered.
|
||||
*
|
||||
* Two deliberate choices:
|
||||
*
|
||||
* - `!== false` rather than `=== true`. A widget served before this setting
|
||||
* existed sends no value at all, and missing configuration must fail towards
|
||||
* disclosing rather than towards hiding.
|
||||
* - Hidden once a human agent takes the conversation over. From that point the
|
||||
* replies are written by a person, so the line would simply be untrue.
|
||||
*/
|
||||
export function shouldShowAiDisclaimer(
|
||||
enabled: boolean | undefined | null,
|
||||
handedOverToHuman = false,
|
||||
): boolean {
|
||||
return enabled !== false && !handedOverToHuman
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
<!--
|
||||
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 { ref, defineAsyncComponent } from 'vue'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
|
||||
// Lazy load the AIAgentSetup component
|
||||
const AIAgentSetup = defineAsyncComponent(() =>
|
||||
import('@/components/aiagent/AIAgentSetup.vue')
|
||||
)
|
||||
|
||||
const selectedModel = ref('openai')
|
||||
const isWorkflowFullscreen = ref(false)
|
||||
|
||||
// Handle fullscreen toggle from workflow editor
|
||||
const handleWorkflowFullscreenToggle = (isFullscreen: boolean) => {
|
||||
isWorkflowFullscreen.value = isFullscreen
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<DashboardLayout :hide-sidebar="isWorkflowFullscreen" :hide-header="isWorkflowFullscreen">
|
||||
<div class="dashboard-container">
|
||||
<!-- Main Chat Area -->
|
||||
<div class="chat-section">
|
||||
<AIAgentSetup :model="selectedModel" @toggle-fullscreen="handleWorkflowFullscreenToggle" />
|
||||
</div>
|
||||
</div>
|
||||
</DashboardLayout>
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.dashboard-container {
|
||||
display: flex;
|
||||
min-height: 100%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.chat-section {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
</style>
|
||||
Reference in new issue
Block a user