fix: improve maintenance and synchronize live conversations
This commit is contained in:
1 parent
bb541bd212
commit
ef86a5d3af
297 files changed
+2983
-1274
No files matched your search
@@ -12,8 +12,15 @@ export default [
|
||||
|
||||
{
|
||||
name: 'app/files-to-ignore',
|
||||
// The enterprise Playwright suite has its own lint config (e2e/eslint.config.js rules).
|
||||
ignores: ['**/dist/**', '**/dist-ssr/**', '**/coverage/**', 'src/modules/enterprise/e2e/**'],
|
||||
// Generated outputs and editor files mirror the frontend .gitignore.
|
||||
// The enterprise Playwright suite has its own lint config.
|
||||
ignores: [
|
||||
'**/node_modules/**', '**/logs/**', '**/dist/**', '**/dist-ssr/**', '**/coverage/**',
|
||||
'**/test-results/**', '**/playwright-report/**', '**/cypress/videos/**',
|
||||
'**/cypress/screenshots/**', '**/.vscode/**', '**/.idea/**',
|
||||
'**/*.local', '**/*.log', '**/*.tsbuildinfo', '**/.desloppify/**',
|
||||
'public/webclient/**', 'src/modules/enterprise/e2e/**',
|
||||
],
|
||||
},
|
||||
|
||||
...pluginVue.configs['flat/essential'],
|
||||
|
||||
@@ -17,7 +17,8 @@
|
||||
"test:unit": "vitest",
|
||||
"build-only": "vite build",
|
||||
"type-check": "vue-tsc --noEmit -p tsconfig.app.json --composite false",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts --fix --ignore-path .gitignore",
|
||||
"lint": "eslint . --ext .vue,.js,.jsx,.cjs,.mjs,.ts,.tsx,.cts,.mts",
|
||||
"lint:fix": "npm run lint -- --fix",
|
||||
"format": "prettier --write src/",
|
||||
"build:webclient": "npm run build:webclient:prod",
|
||||
"build:webclient:dev": "tsx scripts/build-webclient-dev.ts",
|
||||
|
||||
@@ -3,7 +3,6 @@ import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
// Load development environment variables
|
||||
dotenv.config({ path: '.env.dev' })
|
||||
|
||||
@@ -3,7 +3,6 @@ import { resolve, dirname } from 'path'
|
||||
import { fileURLToPath } from 'url'
|
||||
import * as dotenv from 'dotenv'
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
|
||||
// Load production environment variables
|
||||
dotenv.config({ path: '.env.prod' })
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as dotenv from 'dotenv'
|
||||
|
||||
// Load development environment variables
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import * as fs from 'fs'
|
||||
import * as path from 'path'
|
||||
import * as dotenv from 'dotenv'
|
||||
|
||||
// Load production environment variables
|
||||
|
||||
@@ -40,6 +40,9 @@ const mountStep = async () => {
|
||||
return wrapper
|
||||
}
|
||||
|
||||
const buttonByText = (wrapper: ReturnType<typeof mount>, label: string) =>
|
||||
wrapper.findAll('button').find((button) => button.text() === label)!
|
||||
|
||||
const clickContinue = async (wrapper: any) => {
|
||||
const cont = wrapper.findAll('button').find((b: any) => b.text().includes('继续'))!
|
||||
await cont.trigger('click')
|
||||
@@ -48,7 +51,7 @@ const clickContinue = async (wrapper: any) => {
|
||||
|
||||
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')
|
||||
await buttonByText(wrapper, '添加').trigger('click')
|
||||
}
|
||||
|
||||
const staged = (wrapper: any) =>
|
||||
@@ -206,13 +209,13 @@ describe('StepTeach - a website that fails fast', () => {
|
||||
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')
|
||||
await buttonByText(wrapper, '改为粘贴文本').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 buttonByText(wrapper, '添加文本').trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^About us \(/), expect.stringContaining('handmade candles'), 'agent-1')
|
||||
expect(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), expect.stringContaining('handmade candles'), 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
@@ -246,7 +249,7 @@ describe('StepTeach - a website that fails fast', () => {
|
||||
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(buttonByText(wrapper, label).attributes('disabled')).toBeDefined()
|
||||
}
|
||||
expect(wrapper.find('input[type="text"]').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.find('.source-remove').attributes('disabled')).toBeDefined()
|
||||
|
||||
@@ -288,7 +288,7 @@ describe('StepTeach - paste text instead', () => {
|
||||
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(addText).toHaveBeenCalledWith('org-1', expect.stringMatching(/^关于我们(.+\)$/), longText, 'agent-1')
|
||||
expect(wrapper.emitted('next')).toBeTruthy()
|
||||
})
|
||||
|
||||
|
||||
@@ -0,0 +1,123 @@
|
||||
/*
|
||||
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 { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { createMemoryHistory, createRouter } from 'vue-router'
|
||||
|
||||
// Keep the real components and composables; replace only their external edges.
|
||||
vi.mock('webfontloader', () => ({
|
||||
default: { load: ({ active }: { active?: () => void }) => active?.() },
|
||||
}))
|
||||
vi.mock('@/services/widget', () => ({
|
||||
widgetService: { getWidgets: vi.fn().mockResolvedValue([{ id: 'widget-1', agent_id: 'agent-1' }]) },
|
||||
}))
|
||||
vi.mock('@/services/groups', () => ({ listGroups: vi.fn().mockResolvedValue([]) }))
|
||||
vi.mock('@/services/jira', () => ({
|
||||
checkJiraConnection: vi.fn().mockResolvedValue({ connected: false }),
|
||||
getAgentJiraConfig: vi.fn().mockResolvedValue({ enabled: false }),
|
||||
getJiraProjects: vi.fn().mockResolvedValue([]),
|
||||
getJiraIssueTypes: vi.fn().mockResolvedValue([]),
|
||||
saveAgentJiraConfig: vi.fn().mockResolvedValue(undefined),
|
||||
}))
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: { getAgentById: vi.fn(async () => agent) },
|
||||
}))
|
||||
|
||||
import AgentChatPreviewPanel from '@/components/agent/AgentChatPreviewPanel.vue'
|
||||
import AgentDetail from '@/components/agent/AgentDetail.vue'
|
||||
import { useAgentChat } from '@/composables/useAgentChat'
|
||||
|
||||
const agent = {
|
||||
id: 'agent-1', name: '小鲸', display_name: '小鲸', agent_type: 'CUSTOMER_SUPPORT',
|
||||
is_active: true, organization_id: 'org-1', use_workflow: false, require_token_auth: false,
|
||||
instructions: [], groups: [], customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' },
|
||||
}
|
||||
|
||||
const previewProps = {
|
||||
isActive: true, agentId: 'agent-1', agentName: '小鲸', agentType: 'CUSTOMER_SUPPORT',
|
||||
customization: { id: 1, agent_id: 'agent-1', chat_style: 'CHATBOT' as const },
|
||||
}
|
||||
|
||||
beforeEach(() => {
|
||||
window.history.replaceState({}, '', '/')
|
||||
localStorage.clear()
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
describe('agent preview and close', () => {
|
||||
it('keeps sample messages, disabled composer and style-switch restoration', async () => {
|
||||
const wrapper = mount(AgentChatPreviewPanel, { props: previewProps })
|
||||
await flushPromises()
|
||||
expect(wrapper.text()).toContain('你好!有什么可以帮你?')
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
expect(wrapper.text()).toContain('支持——所有商品 30 天内免费退货。')
|
||||
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('.chat-input button').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ customization: { ...previewProps.customization, chat_style: 'ASK_ANYTHING' } })
|
||||
expect(wrapper.text()).toContain('欢迎来到 小鲸')
|
||||
expect(wrapper.text()).not.toContain('你们支持免费退货吗?')
|
||||
expect(wrapper.get('.welcome-message-input input').attributes('disabled')).toBeDefined()
|
||||
expect(wrapper.get('.welcome-message-input button').attributes('disabled')).toBeDefined()
|
||||
|
||||
await wrapper.setProps({ customization: previewProps.customization })
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('still shows the offline state without enabling the preview composer', () => {
|
||||
const wrapper = mount(AgentChatPreviewPanel, { props: { ...previewProps, isActive: false } })
|
||||
expect(wrapper.text()).toContain('客服已离线')
|
||||
expect(wrapper.get('.chat-input input').attributes('disabled')).toBeDefined()
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('preserves the chat composable public initialization, send and cleanup interface', async () => {
|
||||
const chat = useAgentChat('agent-1')
|
||||
chat.initChat()
|
||||
expect(chat.messages.value).toEqual([{ role: 'bot', content: '你好!有什么可以帮你的吗?' }])
|
||||
chat.currentInput.value = '测试消息'
|
||||
await chat.sendMessage('测试消息')
|
||||
expect(chat.messages.value.at(-1)).toEqual({ role: 'user', content: '测试消息' })
|
||||
expect(chat.currentInput.value).toBe('')
|
||||
expect(() => chat.cleanup()).not.toThrow()
|
||||
})
|
||||
|
||||
it('emits close from the detail button and cleans the mounted preview on unmount', async () => {
|
||||
const router = createRouter({ history: createMemoryHistory(), routes: [] })
|
||||
const wrapper = mount(AgentDetail, {
|
||||
props: { agent },
|
||||
shallow: true,
|
||||
global: { plugins: [router], stubs: { AgentChatPreviewPanel: false, 'font-awesome-icon': true } },
|
||||
})
|
||||
await flushPromises()
|
||||
const tab = wrapper.findAll('button').find((button) => button.text() === '聊天自定义')
|
||||
expect(tab).toBeDefined()
|
||||
await tab!.trigger('click')
|
||||
expect(wrapper.findComponent(AgentChatPreviewPanel).exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('你们支持免费退货吗?')
|
||||
expect(document.getElementById('chat-panel-font-agent-1')).not.toBeNull()
|
||||
|
||||
await wrapper.get('button[aria-label="返回客服列表"]').trigger('click')
|
||||
expect(wrapper.emitted('close')).toEqual([[]])
|
||||
wrapper.unmount()
|
||||
expect(document.getElementById('chat-panel-font-agent-1')).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,60 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import AISetup from '@/components/ai/AISetup.vue'
|
||||
|
||||
const { loadModule, fetchPlans, availablePlans } = vi.hoisted(() => ({
|
||||
loadModule: vi.fn(),
|
||||
fetchPlans: vi.fn(),
|
||||
availablePlans: [] as Array<{ id: string; name: string; max_messages: number }>,
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
hasEnterpriseModule: true,
|
||||
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
|
||||
loadModule,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
useSubscriptionStorage: () => ({
|
||||
getCurrentSubscription: () => null,
|
||||
getAvailablePlans: () => availablePlans,
|
||||
isSubscriptionActive: () => false,
|
||||
hasFeature: () => false,
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useAISetup', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return {
|
||||
useAISetup: () => ({
|
||||
isLoading: ref(false),
|
||||
error: ref(''),
|
||||
providers: ref([]),
|
||||
setupConfig: ref({ provider: 'openai', model: 'gpt-4o-mini', apiKey: '', baseUrl: '' }),
|
||||
saveAISetup: vi.fn(),
|
||||
updateAISetup: vi.fn(),
|
||||
hasExistingConfig: ref(false),
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
describe('AISetup subscription plan loading', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
availablePlans.splice(0)
|
||||
fetchPlans.mockImplementation(async () => {
|
||||
availablePlans.push({ id: 'starter', name: 'Starter', max_messages: 500 })
|
||||
})
|
||||
loadModule.mockResolvedValue({ subscriptionStore: { fetchPlans } })
|
||||
})
|
||||
|
||||
it('loads the optional enterprise catalog once during initialization', async () => {
|
||||
const wrapper = mount(AISetup)
|
||||
await flushPromises()
|
||||
|
||||
expect(loadModule).toHaveBeenCalledTimes(1)
|
||||
expect(fetchPlans).toHaveBeenCalledTimes(1)
|
||||
expect(availablePlans).toHaveLength(1)
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,78 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount } from '@vue/test-utils'
|
||||
import { AxiosError } from 'axios'
|
||||
import AIAgentSetup from '@/components/aiagent/AIAgentSetup.vue'
|
||||
|
||||
const { getOrganizationAgents, getOrganizationConfig, setAgents, route } = vi.hoisted(() => ({
|
||||
getOrganizationAgents: vi.fn(),
|
||||
getOrganizationConfig: vi.fn(),
|
||||
setAgents: vi.fn(),
|
||||
route: { query: {} as Record<string, string> },
|
||||
}))
|
||||
|
||||
vi.mock('@/services/agent', () => ({ agentService: { getOrganizationAgents } }))
|
||||
vi.mock('@/services/ai', () => ({
|
||||
aiService: {
|
||||
getOrganizationConfig,
|
||||
getProviders: vi.fn().mockResolvedValue([]),
|
||||
},
|
||||
}))
|
||||
vi.mock('@/utils/storage', () => ({
|
||||
useAgentStorage: () => ({ setAgents }),
|
||||
useSubscriptionStorage: () => ({
|
||||
getCurrentSubscription: () => null,
|
||||
isSubscriptionActive: () => false,
|
||||
hasFeature: () => false,
|
||||
getAvailablePlans: () => [],
|
||||
}),
|
||||
}))
|
||||
vi.mock('@/composables/useEnterpriseFeatures', () => ({
|
||||
useEnterpriseFeatures: () => ({
|
||||
hasEnterpriseModule: false,
|
||||
moduleImports: { subscriptionStore: '/src/modules/enterprise/composables/useSubscriptionStore.ts' },
|
||||
loadModule: vi.fn(),
|
||||
}),
|
||||
}))
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRoute: () => route,
|
||||
useRouter: () => ({ replace: vi.fn() }),
|
||||
}))
|
||||
describe('AIAgentSetup initial configuration check', () => {
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
getOrganizationAgents.mockResolvedValue([{ id: 'agent-1', name: 'Test agent' }])
|
||||
getOrganizationConfig.mockResolvedValue({ provider: 'openai' })
|
||||
})
|
||||
|
||||
it('checks existing agents once and shows the configured agent list', async () => {
|
||||
const wrapper = mount(AIAgentSetup, {
|
||||
props: { model: 'openai' },
|
||||
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
|
||||
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
|
||||
expect(setAgents).toHaveBeenCalledWith([{ id: 'agent-1', name: 'Test agent' }])
|
||||
expect(wrapper.text()).not.toContain('配置 AI 服务商')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('opens AI setup when an existing organization has no AI configuration', async () => {
|
||||
const notFound = new AxiosError('AI configuration not found')
|
||||
Object.defineProperty(notFound, 'response', { value: { status: 404 } })
|
||||
getOrganizationConfig.mockRejectedValue(notFound)
|
||||
|
||||
const wrapper = mount(AIAgentSetup, {
|
||||
props: { model: 'openai' },
|
||||
global: { stubs: { AgentList: true, AISetup: true, AIAgentOnboarding: true } },
|
||||
})
|
||||
await flushPromises()
|
||||
|
||||
expect(getOrganizationAgents).toHaveBeenCalledTimes(1)
|
||||
expect(getOrganizationConfig).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.text()).toContain('配置 AI 服务商')
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -14,11 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach } from 'vitest'
|
||||
import { describe, it, expect } 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
|
||||
@@ -143,4 +142,4 @@ describe('ChatMessage', () => {
|
||||
|
||||
expect(wrapper.find('.robot-icon').attributes('alt')).toBe('AI 助手')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,255 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import FaqWorkspace from '@/components/faq/FaqWorkspace.vue'
|
||||
|
||||
const { updateSettingsMock, settingsSeed, persistedSettings } = vi.hoisted(() => ({
|
||||
updateSettingsMock: vi.fn(),
|
||||
persistedSettings: { value: null as Record<string, unknown> | null },
|
||||
settingsSeed: {
|
||||
enabled: true,
|
||||
slug: 'help-center',
|
||||
title: 'Support',
|
||||
description: null,
|
||||
logo_url: null,
|
||||
favicon_url: null,
|
||||
brand_color: '#000000',
|
||||
header_links: [] as Array<{ label: string; url: string }>,
|
||||
cta_text: 'Contact us',
|
||||
cta_url: '',
|
||||
cta_enabled: true,
|
||||
auto_generate: false,
|
||||
agent_id: null,
|
||||
ai_search_enabled: false,
|
||||
chat_widget_enabled: false,
|
||||
live_url: 'https://support.example.test/help-center',
|
||||
published_count: 0,
|
||||
plan_allowed: true,
|
||||
agents: [],
|
||||
domain: null,
|
||||
},
|
||||
}))
|
||||
|
||||
vi.mock('@/composables/useFaqWorkspace', async () => {
|
||||
const { ref } = await import('vue')
|
||||
const noop = vi.fn()
|
||||
const noopAsync = vi.fn(async () => {})
|
||||
|
||||
return {
|
||||
useFaqWorkspace: () => ({
|
||||
faqs: ref([]),
|
||||
job: ref(null),
|
||||
settings: ref({ ...settingsSeed, header_links: [...settingsSeed.header_links] }),
|
||||
estimate: ref(null),
|
||||
fetchEstimate: noopAsync,
|
||||
sourceCount: ref(0),
|
||||
pageCount: ref(0),
|
||||
phase: ref('empty'),
|
||||
barPhase: ref('idle'),
|
||||
publishedCount: ref(0),
|
||||
groupedFaqs: ref(new Map()),
|
||||
searchQuery: ref(''),
|
||||
categoryFilter: ref(null),
|
||||
statusFilter: ref('all'),
|
||||
categories: ref([]),
|
||||
hasActiveFilters: ref(false),
|
||||
filteredCount: ref(0),
|
||||
resetFilters: noop,
|
||||
toggleCategory: noop,
|
||||
isCategoryOpen: () => true,
|
||||
editingId: ref(null),
|
||||
isNewFaq: ref(false),
|
||||
draftQuestion: ref(''),
|
||||
draftAnswer: ref(''),
|
||||
draftCategory: ref('General'),
|
||||
draftSlug: ref(''),
|
||||
draftUrlPath: ref(''),
|
||||
draftMetaTitle: ref(''),
|
||||
draftMetaDescription: ref(''),
|
||||
isSaving: ref(false),
|
||||
selectedIds: ref(new Set()),
|
||||
selectionActive: ref(false),
|
||||
toggleSelect: noop,
|
||||
setSelected: noop,
|
||||
clearSelection: noop,
|
||||
bulkSetStatus: noopAsync,
|
||||
bulkDelete: noopAsync,
|
||||
refresh: noopAsync,
|
||||
startPolling: noop,
|
||||
stopPolling: noop,
|
||||
startGeneration: noopAsync,
|
||||
submitImport: noopAsync,
|
||||
submitPdfImport: noopAsync,
|
||||
togglePublish: noopAsync,
|
||||
startEdit: noop,
|
||||
startAdd: noop,
|
||||
cancelEdit: noop,
|
||||
saveEdit: noopAsync,
|
||||
deleteFaq: noopAsync,
|
||||
}),
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/faq', () => ({
|
||||
faqService: {
|
||||
updateSettings: updateSettingsMock,
|
||||
uploadLogo: vi.fn(),
|
||||
removeLogo: vi.fn(),
|
||||
uploadFavicon: vi.fn(),
|
||||
removeFavicon: vi.fn(),
|
||||
setDomain: vi.fn(),
|
||||
verifyDomain: vi.fn(),
|
||||
removeDomain: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('Help Center appearance updates', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
persistedSettings.value = { ...settingsSeed, header_links: [] }
|
||||
updateSettingsMock.mockImplementation(async (patch: Record<string, unknown>) => {
|
||||
persistedSettings.value = { ...persistedSettings.value, ...patch }
|
||||
return { ...persistedSettings.value }
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
async function openAppearance() {
|
||||
wrapper = mount(FaqWorkspace, {
|
||||
global: {
|
||||
stubs: {
|
||||
FaqGenerateBar: true,
|
||||
FaqEmptyState: true,
|
||||
FaqGenerationProgress: true,
|
||||
FaqCategoryGroup: true,
|
||||
FaqCard: true,
|
||||
HelpCenterPublic: true,
|
||||
FaqImportModal: true,
|
||||
FaqSourcePicker: true,
|
||||
},
|
||||
},
|
||||
})
|
||||
const customTab = wrapper.findAll('button.tab').find((button) => button.text().includes('自定义'))
|
||||
expect(customTab).toBeDefined()
|
||||
await customTab!.trigger('click')
|
||||
return wrapper
|
||||
}
|
||||
|
||||
it('updates the parent draft immediately and saves text after the existing debounce', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaUrl.setValue('app.example.test')
|
||||
|
||||
expect((ctaUrl.element as HTMLInputElement).value).toBe('app.example.test')
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(799)
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({ cta_url: 'app.example.test' })
|
||||
})
|
||||
|
||||
it('merges text edits made inside one debounce window into the saved patch', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaText.setValue('Talk to our team')
|
||||
await ctaUrl.setValue('app.example.test')
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
cta_text: 'Talk to our team',
|
||||
cta_url: 'app.example.test',
|
||||
})
|
||||
})
|
||||
|
||||
it('flushes pending text edits together with an immediate color save', async () => {
|
||||
await openAppearance()
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
await ctaText.setValue('Talk to our team')
|
||||
await wrapper.findAll('button.swatch')[0].trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
expect(updateSettingsMock).toHaveBeenCalledWith({
|
||||
cta_text: 'Talk to our team',
|
||||
brand_color: '#4338CA',
|
||||
})
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('keeps a newer draft when an earlier save response arrives in flight', async () => {
|
||||
await openAppearance()
|
||||
|
||||
let resolveFirstSave!: (settings: Record<string, unknown>) => void
|
||||
const firstSaveResponse = new Promise<Record<string, unknown>>((resolve) => {
|
||||
resolveFirstSave = resolve
|
||||
})
|
||||
updateSettingsMock.mockImplementationOnce(async (patch: Record<string, unknown>) => {
|
||||
persistedSettings.value = { ...persistedSettings.value, ...patch }
|
||||
const responseSnapshot = { ...persistedSettings.value }
|
||||
await firstSaveResponse
|
||||
return responseSnapshot
|
||||
})
|
||||
|
||||
const ctaText = wrapper.find('input[placeholder="名称"]')
|
||||
const ctaUrl = wrapper.get('input[placeholder="app.yoursite.com"]')
|
||||
await ctaText.setValue('First saved value')
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
expect(updateSettingsMock).toHaveBeenCalledOnce()
|
||||
|
||||
await ctaUrl.setValue('newer.example.test')
|
||||
expect((ctaUrl.element as HTMLInputElement).value).toBe('newer.example.test')
|
||||
resolveFirstSave({ ...persistedSettings.value })
|
||||
await flushPromises()
|
||||
|
||||
expect((wrapper.get('input[placeholder="app.yoursite.com"]').element as HTMLInputElement).value)
|
||||
.toBe('newer.example.test')
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
|
||||
expect(updateSettingsMock).toHaveBeenCalledTimes(2)
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({ cta_url: 'newer.example.test' })
|
||||
expect(persistedSettings.value).toMatchObject({
|
||||
cta_text: 'First saved value',
|
||||
cta_url: 'newer.example.test',
|
||||
})
|
||||
})
|
||||
|
||||
it('saves header-link edits through parent updates and custom colors immediately', async () => {
|
||||
await openAppearance()
|
||||
|
||||
await wrapper.get('button.add-link').trigger('click')
|
||||
const linkLabel = wrapper.get('.link-rows .text-input--label')
|
||||
await linkLabel.setValue('Documentation')
|
||||
expect(updateSettingsMock).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({
|
||||
header_links: [{ label: 'Documentation', url: '' }],
|
||||
})
|
||||
|
||||
await wrapper.findAll('button.swatch')[0].trigger('click')
|
||||
await flushPromises()
|
||||
expect(updateSettingsMock).toHaveBeenLastCalledWith({ brand_color: '#4338CA' })
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,20 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect } from 'vitest'
|
||||
import { beforeEach, describe, it, expect, vi } from 'vitest'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import KnowledgeAddSourceModal from '../../../components/knowledge/KnowledgeAddSourceModal.vue'
|
||||
import { useKnowledgeExplorer, type AddSourcePayload } from '../../../composables/useKnowledgeExplorer'
|
||||
|
||||
const knowledge = vi.hoisted(() => ({
|
||||
addUrls: vi.fn().mockResolvedValue({}),
|
||||
getKnowledgeByOrganization: vi.fn().mockResolvedValue({ knowledge: [] }),
|
||||
getKnowledgeByAgent: vi.fn().mockResolvedValue({ knowledge: [] }),
|
||||
getOrgQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
|
||||
getAgentQueueItems: vi.fn().mockResolvedValue({ queue_items: [] }),
|
||||
}))
|
||||
|
||||
vi.mock('@/services/knowledge', () => ({ knowledgeService: knowledge }))
|
||||
|
||||
const mountModal = async (url: string) => {
|
||||
const wrapper = mount(KnowledgeAddSourceModal)
|
||||
@@ -28,9 +39,15 @@ const scopeTitles = (wrapper: ReturnType<typeof mount>) =>
|
||||
wrapper.findAll('.scope__title').map((node) => node.text())
|
||||
|
||||
const scopeOption = (wrapper: ReturnType<typeof mount>, title: string) =>
|
||||
wrapper.findAll('.scope__opt').find((node) => node.find('.scope__title').text() === title)
|
||||
wrapper.findAll('button[aria-pressed]').find((node) => node.text().startsWith(title))
|
||||
|
||||
const submitWebsite = async (wrapper: ReturnType<typeof mount>) => {
|
||||
await wrapper.findAll('button').find((button) => button.text() === '加入抓取队列')!.trigger('click')
|
||||
}
|
||||
|
||||
describe('KnowledgeAddSourceModal crawl scope', () => {
|
||||
beforeEach(() => vi.clearAllMocks())
|
||||
|
||||
it('offers no section option for a homepage URL', async () => {
|
||||
// "Pages under / " is not a section — it is the whole site, which the next
|
||||
// option already covers.
|
||||
@@ -47,25 +64,39 @@ describe('KnowledgeAddSourceModal crawl scope', () => {
|
||||
)
|
||||
})
|
||||
|
||||
it('falls back to this-site when the section option disappears', async () => {
|
||||
it('falls back to this-site and submits it to the organization knowledge base', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
|
||||
await wrapper.find('#kb-add-url').setValue('https://help.example.com')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
await submitWebsite(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({
|
||||
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
|
||||
expect(payload).toEqual({
|
||||
type: 'website',
|
||||
url: 'https://help.example.com',
|
||||
scope: 'host',
|
||||
})
|
||||
const explorer = useKnowledgeExplorer('org', undefined, 'org-1')
|
||||
await expect(explorer.submitSource(payload)).resolves.toBe(true)
|
||||
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com'], undefined, undefined, undefined, 'host')
|
||||
expect(knowledge.getKnowledgeByOrganization).toHaveBeenCalledWith('org-1', 1, 100)
|
||||
expect(knowledge.getOrgQueueItems).toHaveBeenCalledWith('org-1')
|
||||
expect(knowledge.getKnowledgeByAgent).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('submits the selected scope', async () => {
|
||||
it('submits the selected section for the agent knowledge base', async () => {
|
||||
const wrapper = await mountModal('https://help.example.com/hc/en')
|
||||
await scopeOption(wrapper, '本栏目')!.trigger('click')
|
||||
await wrapper.find('button.btn--primary').trigger('click')
|
||||
await submitWebsite(wrapper)
|
||||
|
||||
expect(wrapper.emitted('submit')![0][0]).toMatchObject({ scope: 'path' })
|
||||
const payload = wrapper.emitted('submit')![0][0] as AddSourcePayload
|
||||
expect(payload).toEqual({ type: 'website', url: 'https://help.example.com/hc/en', scope: 'path' })
|
||||
const explorer = useKnowledgeExplorer('agent', 'agent-1', 'org-1')
|
||||
await expect(explorer.submitSource(payload)).resolves.toBe(true)
|
||||
expect(knowledge.addUrls).toHaveBeenCalledWith('org-1', ['https://help.example.com/hc/en'], 'agent-1', undefined, undefined, 'path')
|
||||
expect(knowledge.getKnowledgeByAgent).toHaveBeenCalledWith('agent-1', 1, 100)
|
||||
expect(knowledge.getAgentQueueItems).toHaveBeenCalledWith('agent-1')
|
||||
expect(knowledge.getKnowledgeByOrganization).not.toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
@@ -32,7 +32,7 @@ vi.mock('@/pwa/register', () => ({
|
||||
|
||||
const setNotificationPermission = (permission: string | null) => {
|
||||
if (permission === null) {
|
||||
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
||||
|
||||
delete (window as any).Notification
|
||||
} else {
|
||||
vi.stubGlobal('Notification', { permission })
|
||||
@@ -53,7 +53,7 @@ describe('EnablePushPrompt', () => {
|
||||
setNotificationPermission('default')
|
||||
const wrapper = await mountPrompt()
|
||||
expect(wrapper.find('.push-prompt').exists()).toBe(true)
|
||||
expect(wrapper.text()).toContain('Enable notifications')
|
||||
expect(wrapper.text()).toContain('开启通知')
|
||||
})
|
||||
|
||||
it('stays hidden when permission is already granted', async () => {
|
||||
@@ -95,7 +95,7 @@ describe('EnablePushPrompt', () => {
|
||||
setNotificationPermission(null)
|
||||
vi.stubGlobal('navigator', { userAgent: 'iPhone Safari' })
|
||||
const wrapper = await mountPrompt()
|
||||
expect(wrapper.text()).toContain('Add ReplyPier to your Home Screen')
|
||||
expect(wrapper.text()).toContain('请先把 ReplyPier 添加到主屏幕')
|
||||
expect(wrapper.find('.primary-btn').exists()).toBe(false)
|
||||
})
|
||||
})
|
||||
@@ -209,6 +209,6 @@ describe('TicketInvestigationPanel guided re-run', () => {
|
||||
const denied = mountPanel({}, { canReinvestigate: true, canEditSettings: false })
|
||||
await denied.find('.rerun-btn').trigger('click')
|
||||
expect(denied.find('.rerun-scope a').exists()).toBe(false)
|
||||
expect(denied.find('.rerun-scope').text()).toContain('Ticketing settings')
|
||||
expect(denied.find('.rerun-scope').text()).toContain('工单设置')
|
||||
})
|
||||
})
|
||||
@@ -61,7 +61,7 @@ describe('TicketSidePanel CSAT card', () => {
|
||||
)
|
||||
expect(card.text()).toContain('4/5')
|
||||
expect(card.findAll('.csat-stars font-awesome-icon-stub')).toHaveLength(5)
|
||||
expect(card.text()).toContain('Rated by the customer')
|
||||
expect(card.text()).toContain('客户已在关联会话中评分')
|
||||
})
|
||||
|
||||
it('shows pending while the ask is out', () => {
|
||||
|
||||
@@ -0,0 +1,169 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { defineComponent, h, reactive } from 'vue'
|
||||
import { mount } from '@vue/test-utils'
|
||||
import type { ChatDetail } from '@/types/chat'
|
||||
|
||||
const mocks = vi.hoisted(() => {
|
||||
const handlers = new Map<string, (value?: any) => void>()
|
||||
const reconnect = new Set<() => void>()
|
||||
const acknowledgements = new Map<string, () => void>()
|
||||
return {
|
||||
handlers, reconnect, acknowledgements,
|
||||
getChatDetail: vi.fn(),
|
||||
socket: {
|
||||
connect: vi.fn(),
|
||||
on: vi.fn((name: string, handler: (value?: any) => void) => handlers.set(name, handler)),
|
||||
off: vi.fn((name: string) => handlers.delete(name)),
|
||||
onReconnect: vi.fn((handler: () => void) => reconnect.add(handler)),
|
||||
offReconnect: vi.fn((handler: () => void) => reconnect.delete(handler)),
|
||||
emit: vi.fn((event: string, value: { session_id: string }, ack?: () => void) => {
|
||||
if (event === 'join_room' && ack) acknowledgements.set(value.session_id, ack)
|
||||
}),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('@/services/socket', () => ({ socketService: mocks.socket }))
|
||||
vi.mock('@/services/chat', () => ({ chatService: { getChatDetail: mocks.getChatDetail } }))
|
||||
vi.mock('@/services/user', () => ({
|
||||
userService: {
|
||||
getUserId: () => 'user-1',
|
||||
getCurrentUser: () => ({ organization_id: 'org-1', role: { permissions: [{ name: 'view_unassigned_chats' }] } }),
|
||||
},
|
||||
}))
|
||||
|
||||
import { useConversationsList } from '@/composables/useConversationsList'
|
||||
|
||||
const detail = (sessionId: string, messages: ChatDetail['messages'] = []): ChatDetail => ({
|
||||
session_id: sessionId,
|
||||
customer: { id: 'customer', email: '' },
|
||||
agent: { id: 'agent', name: 'AI', display_name: null },
|
||||
messages, status: 'open', user_id: null, group_id: null,
|
||||
created_at: '2026-10-29T00:00:00Z', updated_at: '2026-10-29T00:00:00Z',
|
||||
})
|
||||
|
||||
const message = (id: number, text: string) => ({
|
||||
id, message: text, message_type: id % 2 ? 'user' : 'bot',
|
||||
created_at: '2026-10-29T00:00:00Z', session_id: 's1',
|
||||
})
|
||||
|
||||
function setup() {
|
||||
let model!: ReturnType<typeof useConversationsList>
|
||||
const emit = vi.fn()
|
||||
const props = reactive({ conversations: [], loading: false, error: '' })
|
||||
const wrapper = mount(defineComponent({
|
||||
setup() { model = useConversationsList(props, emit); return () => h('div') },
|
||||
}))
|
||||
return { model, emit, wrapper }
|
||||
}
|
||||
|
||||
describe('conversation realtime synchronization', () => {
|
||||
beforeEach(() => {
|
||||
vi.useFakeTimers()
|
||||
vi.clearAllMocks()
|
||||
mocks.handlers.clear()
|
||||
mocks.reconnect.clear()
|
||||
mocks.acknowledgements.clear()
|
||||
})
|
||||
afterEach(() => vi.useRealTimers())
|
||||
|
||||
it('refreshes a selected AI conversation after its authorized watch is acknowledged', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, '你好'), message(2, '您好')]))
|
||||
await model.loadChatDetail('s1')
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('join_room', { session_id: 'chat_watch_s1' }, expect.any(Function))
|
||||
mocks.acknowledgements.get('chat_watch_s1')!()
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['你好', '您好'])
|
||||
expect(emit).toHaveBeenCalledWith('chatUpdated', expect.objectContaining({ session_id: 's1' }))
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('coalesces thousands of updates and performs only one trailing read while a read is in flight', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
let finish!: (value: ChatDetail) => void
|
||||
const delayed = new Promise<ChatDetail>(resolve => { finish = resolve })
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockReturnValueOnce(delayed).mockResolvedValueOnce(detail('s1', [message(1, 'latest')]))
|
||||
await model.loadChatDetail('s1')
|
||||
for (let i = 0; i < 5000; i++) {
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
mocks.handlers.get('inbox_changed')!()
|
||||
}
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
for (let i = 0; i < 5000; i++) mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(800)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
expect(emit.mock.calls.filter(call => call[0] === 'liveRefresh')).toHaveLength(1)
|
||||
finish(detail('s1'))
|
||||
await Promise.resolve()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(3)
|
||||
expect(model.selectedChat.value?.messages[0].message).toBe('latest')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('does not replace a newer selection with a slow older response', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
let finish!: (value: ChatDetail) => void
|
||||
mocks.getChatDetail.mockReturnValueOnce(new Promise<ChatDetail>(resolve => { finish = resolve })).mockResolvedValueOnce(detail('s2'))
|
||||
const first = model.loadChatDetail('s1')
|
||||
await model.loadChatDetail('s2')
|
||||
finish(detail('s1'))
|
||||
await first
|
||||
expect(model.selectedChat.value?.session_id).toBe('s2')
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('rejoins and resynchronizes after a connection drops, then removes its listeners', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
mocks.getChatDetail.mockResolvedValue(detail('s1'))
|
||||
await model.loadChatDetail('s1')
|
||||
for (const callback of mocks.reconnect) callback()
|
||||
mocks.acknowledgements.get('chat_watch_s1')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(mocks.getChatDetail).toHaveBeenCalledTimes(2)
|
||||
wrapper.unmount()
|
||||
expect(mocks.socket.emit).toHaveBeenCalledWith('leave_room', { session_id: 'chat_watch_s1' })
|
||||
expect(mocks.handlers.has('chat_changed')).toBe(false)
|
||||
expect(mocks.reconnect.size).toBe(0)
|
||||
})
|
||||
|
||||
it('eventually reconciles if a broker notification is missed without a disconnect', async () => {
|
||||
const random = vi.spyOn(Math, 'random').mockReturnValue(0)
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockResolvedValueOnce(detail('s1', [message(1, 'recovered')]))
|
||||
await model.loadChatDetail('s1')
|
||||
await vi.advanceTimersByTimeAsync(46000)
|
||||
expect(model.selectedChat.value?.messages[0].message).toBe('recovered')
|
||||
expect(emit).toHaveBeenCalledWith('liveRefresh')
|
||||
wrapper.unmount()
|
||||
random.mockRestore()
|
||||
})
|
||||
|
||||
it('drops a conversation when the server revokes access', async () => {
|
||||
const { model, wrapper, emit } = setup()
|
||||
mocks.getChatDetail.mockResolvedValueOnce(detail('s1')).mockRejectedValueOnce({ response: { status: 404 } })
|
||||
await model.loadChatDetail('s1')
|
||||
mocks.handlers.get('chat_changed')!()
|
||||
await vi.advanceTimersByTimeAsync(200)
|
||||
expect(model.selectedChat.value).toBeNull()
|
||||
expect(emit).toHaveBeenCalledWith('chatAccessLost')
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('keeps two same-tick messages and ignores a duplicate row', async () => {
|
||||
const { model, wrapper } = setup()
|
||||
mocks.getChatDetail.mockResolvedValue(detail('s1'))
|
||||
await model.loadChatDetail('s1')
|
||||
const reply = mocks.handlers.get('chat_reply')!
|
||||
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
reply({ session_id: 's1', message_id: 2, message: 'b', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
reply({ session_id: 's1', message_id: 1, message: 'a', type: 'user', timestamp: '2026-10-29T00:00:00Z' })
|
||||
await Promise.resolve()
|
||||
expect(model.selectedChat.value?.messages.map(m => m.message)).toEqual(['a', 'b'])
|
||||
wrapper.unmount()
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,80 @@
|
||||
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
|
||||
const { io, socket, emitter } = vi.hoisted(() => {
|
||||
const socket = {
|
||||
connected: false,
|
||||
active: true,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
|
||||
return {
|
||||
io: vi.fn(() => socket),
|
||||
socket,
|
||||
emitter: {
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
},
|
||||
}
|
||||
})
|
||||
|
||||
vi.mock('socket.io-client', () => ({ io }))
|
||||
vi.mock('@/utils/emitter', () => ({ default: emitter }))
|
||||
vi.mock('@/config/api', () => ({ getWsUrl: () => 'wss://support.example.test' }))
|
||||
|
||||
import { socketService } from '@/services/socket'
|
||||
|
||||
describe('SocketService connection lifecycle', () => {
|
||||
beforeEach(() => {
|
||||
socketService.disconnect()
|
||||
vi.clearAllMocks()
|
||||
socket.connected = false
|
||||
socket.active = true
|
||||
io.mockReturnValue(socket)
|
||||
})
|
||||
|
||||
it('reuses a pending or reconnecting socket instead of replacing its listeners', () => {
|
||||
socketService.connect()
|
||||
socketService.on('chat_reply', vi.fn())
|
||||
|
||||
socketService.connect()
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
expect(socket.on).toHaveBeenCalledTimes(5)
|
||||
expect(socket.disconnect).not.toHaveBeenCalled()
|
||||
expect(socket.connect).not.toHaveBeenCalled()
|
||||
})
|
||||
|
||||
it('reconnects an existing inactive socket without creating a second instance', () => {
|
||||
socketService.connect()
|
||||
socket.active = false
|
||||
|
||||
socketService.connect()
|
||||
|
||||
expect(io).toHaveBeenCalledTimes(1)
|
||||
expect(socket.connect).toHaveBeenCalledTimes(1)
|
||||
})
|
||||
|
||||
it('allows a fresh socket after an explicit disconnect', () => {
|
||||
const nextSocket = {
|
||||
...socket,
|
||||
on: vi.fn(),
|
||||
off: vi.fn(),
|
||||
emit: vi.fn(),
|
||||
connect: vi.fn(),
|
||||
disconnect: vi.fn(),
|
||||
}
|
||||
io.mockReturnValueOnce(socket).mockReturnValueOnce(nextSocket)
|
||||
|
||||
socketService.connect('/agent')
|
||||
socketService.disconnect()
|
||||
socketService.connect('/agent')
|
||||
|
||||
expect(socket.disconnect).toHaveBeenCalledTimes(1)
|
||||
expect(io).toHaveBeenCalledTimes(2)
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,63 @@
|
||||
import { describe, expect, it } from 'vitest'
|
||||
import { validateWidgetFormField } from '@/utils/widgetFormValidation'
|
||||
|
||||
describe('validateWidgetFormField', () => {
|
||||
it('uses the server-trimmed value for required and text length rules', () => {
|
||||
const field = { name: 'name', label: 'Name', type: 'text', minLength: 2 }
|
||||
|
||||
expect(validateWidgetFormField(field, ' A ')).toBe('Name 至少需要 2 个字符')
|
||||
expect(validateWidgetFormField(field, ' AB ')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, required: true }, ' ')).toBe('Name 为必填项')
|
||||
})
|
||||
|
||||
it('counts text by Unicode code point like Python len()', () => {
|
||||
const field = { name: 'name', label: 'Name', type: 'text' }
|
||||
|
||||
expect(validateWidgetFormField({ ...field, minLength: 2 }, '🙂'))
|
||||
.toBe('Name 至少需要 2 个字符')
|
||||
expect(validateWidgetFormField({ ...field, maxLength: 1 }, '🙂')).toBeNull()
|
||||
})
|
||||
|
||||
it('treats a zero text maxLength as disabled, matching the server contract', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'message', label: 'Message', type: 'text', maxLength: 0 },
|
||||
'hello',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('keeps numeric zero and zero bounds meaningful', () => {
|
||||
const field = { name: 'amount', label: 'Amount', type: 'number', maxLength: 0, required: true }
|
||||
|
||||
expect(validateWidgetFormField(field, 0)).toBeNull()
|
||||
expect(validateWidgetFormField(field, '1')).toBe('Amount 不能大于 0')
|
||||
expect(validateWidgetFormField({ name: 'age', label: 'Age', type: 'number' }, '-1'))
|
||||
.toBe('Age 不能小于 0')
|
||||
})
|
||||
|
||||
it('rejects numeric text that Python float() rejects', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'age', label: 'Age', type: 'number' },
|
||||
'12abc',
|
||||
)).toBe('Age 必须是有效数字')
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'age', label: 'Age', type: 'number', minLength: 18 },
|
||||
'2.5e1',
|
||||
)).toBeNull()
|
||||
})
|
||||
|
||||
it('accepts Python float() decimal forms and preserves explicit null bounds', () => {
|
||||
const field = { name: 'amount', label: 'Amount', type: 'number' }
|
||||
|
||||
expect(validateWidgetFormField(field, '3.')).toBeNull()
|
||||
expect(validateWidgetFormField(field, '1.e3')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, minLength: null }, '-1')).toBeNull()
|
||||
expect(validateWidgetFormField({ ...field, maxLength: null }, '1')).toBeNull()
|
||||
})
|
||||
|
||||
it('trims email values before applying the existing email rule', () => {
|
||||
expect(validateWidgetFormField(
|
||||
{ name: 'email', label: 'Email', type: 'email' },
|
||||
' person@example.test ',
|
||||
)).toBeNull()
|
||||
})
|
||||
})
|
||||
@@ -41,7 +41,8 @@ describe('403 View', () => {
|
||||
history: createWebHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/403', name: 'forbidden', component: ForbiddenView }
|
||||
{ path: '/403', name: 'forbidden', component: ForbiddenView },
|
||||
{ path: '/settings/user', name: 'user-settings', component: { template: '<div>Settings</div>' } }
|
||||
]
|
||||
})
|
||||
|
||||
@@ -54,10 +55,6 @@ describe('403 View', () => {
|
||||
global: {
|
||||
plugins: [router],
|
||||
stubs: {
|
||||
RouterLink: {
|
||||
template: '<a :href="to" class="router-link"><slot /></a>',
|
||||
props: ['to']
|
||||
},
|
||||
DashboardLayout: {
|
||||
template: '<div class="dashboard-layout"><slot /></div>'
|
||||
}
|
||||
@@ -68,8 +65,8 @@ describe('403 View', () => {
|
||||
|
||||
it('renders properly', () => {
|
||||
expect(wrapper.find('.unauthorized').exists()).toBe(true)
|
||||
expect(wrapper.find('h1').text()).toBe('Access Denied')
|
||||
expect(wrapper.find('p').text()).toBe('You don\'t have permission to access this page.')
|
||||
expect(wrapper.find('h1').text()).toBe('访问被拒绝')
|
||||
expect(wrapper.find('p').text()).toBe('你没有权限访问此页面。')
|
||||
})
|
||||
|
||||
it('is wrapped in DashboardLayout', () => {
|
||||
@@ -95,14 +92,11 @@ describe('403 View', () => {
|
||||
expect(backLink.classes()).toContain('back-link')
|
||||
})
|
||||
|
||||
it('navigates to home when clicking the back link', async () => {
|
||||
it('navigates to the accessible page when clicking the back link', async () => {
|
||||
const backLink = wrapper.find('.back-link')
|
||||
await backLink.trigger('click')
|
||||
|
||||
// Simulate router navigation
|
||||
await router.push('/')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/')
|
||||
|
||||
expect(router.currentRoute.value.path).toBe('/settings/user')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -84,12 +84,14 @@ vi.mock('@/services/api', () => ({
|
||||
// Mock agent service
|
||||
vi.mock('@/services/agent', () => ({
|
||||
agentService: {
|
||||
getAgentRoster: vi.fn().mockResolvedValue([]),
|
||||
getOrganizationAgents: vi.fn().mockResolvedValue([])
|
||||
}
|
||||
}))
|
||||
|
||||
// Import the mocked modules
|
||||
import { chatService } from '@/services/chat'
|
||||
import { agentService } from '@/services/agent'
|
||||
|
||||
describe('ConversationsView', () => {
|
||||
let wrapper: VueWrapper
|
||||
@@ -138,6 +140,20 @@ describe('ConversationsView', () => {
|
||||
expect(conversationsList.props('loading')).toBe(false)
|
||||
})
|
||||
|
||||
it('loads the lightweight agent roster used by the inbox filter', async () => {
|
||||
const roster = [{ id: 'agent-1', name: 'Ada', display_name: 'Ada Lovelace' }]
|
||||
vi.mocked(agentService.getAgentRoster).mockResolvedValue(roster)
|
||||
|
||||
wrapper = mount(ConversationsView)
|
||||
|
||||
await vi.waitFor(() => {
|
||||
const filters = wrapper.findComponent({ name: 'ConversationFilters' })
|
||||
expect(filters.props('agents')).toEqual(roster)
|
||||
})
|
||||
|
||||
expect(agentService.getAgentRoster).toHaveBeenCalledOnce()
|
||||
})
|
||||
|
||||
it('handles loading error', async () => {
|
||||
const errorMessage = '加载会话失败'
|
||||
;(chatService.getRecentChats as any).mockRejectedValue(new Error('API Error'))
|
||||
@@ -186,4 +202,4 @@ describe('ConversationsView', () => {
|
||||
|
||||
expect(chatService.getRecentChats).toHaveBeenCalled()
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -15,10 +15,30 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, beforeEach, vi } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { flushPromises, mount, VueWrapper } from '@vue/test-utils'
|
||||
import HumanAgentView from '../../views/HumanAgentView.vue'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
|
||||
const serviceMocks = vi.hoisted(() => ({
|
||||
listUsers: vi.fn(),
|
||||
getTeamOverview: vi.fn(),
|
||||
listGroups: vi.fn(),
|
||||
listRoles: vi.fn()
|
||||
}))
|
||||
|
||||
vi.mock('@/services/users', () => ({
|
||||
listUsers: serviceMocks.listUsers,
|
||||
getTeamOverview: serviceMocks.getTeamOverview
|
||||
}))
|
||||
|
||||
vi.mock('@/services/groups', () => ({
|
||||
listGroups: serviceMocks.listGroups
|
||||
}))
|
||||
|
||||
vi.mock('@/services/roles', () => ({
|
||||
listRoles: serviceMocks.listRoles
|
||||
}))
|
||||
|
||||
// Mock the components
|
||||
vi.mock('@/layouts/DashboardLayout.vue', () => ({
|
||||
default: {
|
||||
@@ -51,9 +71,26 @@ vi.mock('@/components/human-agent/RoleList.vue', () => ({
|
||||
describe('HumanAgentView', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
beforeEach(async () => {
|
||||
setActivePinia(createPinia())
|
||||
vi.clearAllMocks()
|
||||
serviceMocks.listUsers.mockResolvedValue([{
|
||||
id: 'agent-1',
|
||||
email: 'agent@example.test',
|
||||
full_name: 'Test Agent',
|
||||
organization_id: 'org-test',
|
||||
is_online: true,
|
||||
role: { id: 'role-agent', name: 'Agent' }
|
||||
}])
|
||||
serviceMocks.getTeamOverview.mockResolvedValue(null)
|
||||
serviceMocks.listGroups.mockResolvedValue([{
|
||||
id: 'group-1',
|
||||
name: 'Support',
|
||||
organization_id: 'org-test'
|
||||
}])
|
||||
serviceMocks.listRoles.mockResolvedValue([{ id: 'role-agent', name: 'Agent' }])
|
||||
wrapper = mount(HumanAgentView)
|
||||
await flushPromises()
|
||||
})
|
||||
|
||||
it('renders properly', () => {
|
||||
@@ -77,6 +114,15 @@ describe('HumanAgentView', () => {
|
||||
expect(wrapper.findComponent({ name: 'RoleList' }).exists()).toBe(false)
|
||||
})
|
||||
|
||||
it('loads the roster and tab counts through the local service boundary', () => {
|
||||
expect(serviceMocks.listUsers).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.getTeamOverview).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.listGroups).toHaveBeenCalledTimes(1)
|
||||
expect(serviceMocks.listRoles).toHaveBeenCalledTimes(1)
|
||||
expect(wrapper.findAll('.tab-pill .count').map((badge) => badge.text())).toEqual(['1', '1', '1'])
|
||||
expect(wrapper.find('.stat-value').text()).toBe('1')
|
||||
})
|
||||
|
||||
it('switches to GroupList when Groups tab is clicked', async () => {
|
||||
const groupsTab = wrapper.findAll('.tab-pill')[1]
|
||||
await groupsTab.trigger('click')
|
||||
@@ -121,4 +167,4 @@ describe('HumanAgentView', () => {
|
||||
const dashboardLayout = wrapper.findComponent({ name: 'DashboardLayout' })
|
||||
expect(dashboardLayout.exists()).toBe(true)
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -14,9 +14,9 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { describe, it, expect, vi, beforeEach } from 'vitest'
|
||||
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'
|
||||
import { mount, VueWrapper } from '@vue/test-utils'
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import { createRouter, createMemoryHistory } from 'vue-router'
|
||||
import { createPinia, setActivePinia } from 'pinia'
|
||||
import SetupView from '../../views/SetupView.vue'
|
||||
import { nextTick } from 'vue'
|
||||
@@ -58,13 +58,17 @@ describe('SetupView', () => {
|
||||
let wrapper: VueWrapper
|
||||
let router: any
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
})
|
||||
|
||||
beforeEach(async () => {
|
||||
// Create a fresh pinia instance
|
||||
setActivePinia(createPinia())
|
||||
|
||||
// Create router instance
|
||||
router = createRouter({
|
||||
history: createWebHistory(),
|
||||
history: createMemoryHistory(),
|
||||
routes: [
|
||||
{ path: '/', name: 'home', component: { template: '<div>Home</div>' } },
|
||||
{ path: '/ai-agents', name: 'ai-agents', component: { template: '<div>AI Agents</div>' } }
|
||||
@@ -85,6 +89,7 @@ describe('SetupView', () => {
|
||||
})
|
||||
|
||||
// Wait for initial organization check
|
||||
await flushPromises()
|
||||
await nextTick()
|
||||
})
|
||||
|
||||
@@ -102,6 +107,7 @@ describe('SetupView', () => {
|
||||
vi.mocked(getSetupStatus).mockResolvedValueOnce(true)
|
||||
|
||||
// Remount component
|
||||
wrapper.unmount()
|
||||
wrapper = mount(SetupView, {
|
||||
global: {
|
||||
plugins: [router]
|
||||
@@ -288,4 +294,4 @@ describe('SetupView', () => {
|
||||
|
||||
expect(wrapper.find('button[type="submit"]').classes()).toContain('loading')
|
||||
})
|
||||
})
|
||||
})
|
||||
@@ -0,0 +1,85 @@
|
||||
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
||||
import { flushPromises, mount, type VueWrapper } from '@vue/test-utils'
|
||||
import TicketsView from '@/views/TicketsView.vue'
|
||||
import { ticketService } from '@/services/tickets'
|
||||
|
||||
const { router } = vi.hoisted(() => ({
|
||||
router: { push: vi.fn(), replace: vi.fn() },
|
||||
}))
|
||||
|
||||
vi.mock('vue-router', async (importOriginal) => ({
|
||||
...await importOriginal<typeof import('vue-router')>(),
|
||||
useRoute: () => ({ query: {} }),
|
||||
useRouter: () => router,
|
||||
}))
|
||||
vi.mock('@/layouts/DashboardLayout.vue', () => ({
|
||||
default: { name: 'DashboardLayout', template: '<div><slot /></div>' },
|
||||
}))
|
||||
vi.mock('@/composables/useTicketSocket', () => ({ useTicketSocket: vi.fn() }))
|
||||
vi.mock('@/composables/useUsers', async () => {
|
||||
const { ref } = await import('vue')
|
||||
return { useUsers: () => ({ users: ref([]), fetchUsers: vi.fn().mockResolvedValue([]) }) }
|
||||
})
|
||||
vi.mock('@/services/tickets', () => ({
|
||||
ticketService: {
|
||||
listTickets: vi.fn(),
|
||||
getStats: vi.fn(),
|
||||
},
|
||||
}))
|
||||
|
||||
describe('ticket filter updates through the workspace', () => {
|
||||
let wrapper: VueWrapper
|
||||
|
||||
beforeEach(() => {
|
||||
vi.clearAllMocks()
|
||||
vi.useFakeTimers()
|
||||
vi.mocked(ticketService.listTickets).mockResolvedValue({
|
||||
tickets: [],
|
||||
pagination: { page: 1, per_page: 20, total: 0, total_pages: 0 },
|
||||
})
|
||||
vi.mocked(ticketService.getStats).mockResolvedValue({
|
||||
open: 0,
|
||||
awaiting_approval: 0,
|
||||
sla_breaching: 0,
|
||||
ai_resolved_pct_7d: null,
|
||||
csat_avg: null,
|
||||
csat_ai_avg: null,
|
||||
csat_human_avg: null,
|
||||
csat_responses: 0,
|
||||
csat_window_days: 7,
|
||||
})
|
||||
})
|
||||
|
||||
afterEach(() => {
|
||||
wrapper?.unmount()
|
||||
vi.useRealTimers()
|
||||
})
|
||||
|
||||
it('updates parent filters, URL state, and the debounced search request', async () => {
|
||||
wrapper = mount(TicketsView)
|
||||
await flushPromises()
|
||||
vi.mocked(ticketService.listTickets).mockClear()
|
||||
|
||||
const statusButton = wrapper.findAll('button.pill').find((button) => button.text() === '待处理')
|
||||
expect(statusButton).toBeDefined()
|
||||
await statusButton!.trigger('click')
|
||||
await flushPromises()
|
||||
|
||||
expect(router.replace).toHaveBeenCalledWith({ query: { status: 'open' } })
|
||||
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({
|
||||
status: 'open,triaging,investigating,awaiting_approval,in_progress,reopened,resolved_pending_confirmation',
|
||||
}))
|
||||
|
||||
vi.mocked(ticketService.listTickets).mockClear()
|
||||
const search = wrapper.get('input.search-input')
|
||||
await search.setValue('refund')
|
||||
await vi.advanceTimersByTimeAsync(349)
|
||||
expect(ticketService.listTickets).not.toHaveBeenCalled()
|
||||
|
||||
await vi.advanceTimersByTimeAsync(1)
|
||||
await flushPromises()
|
||||
|
||||
expect(router.replace).toHaveBeenLastCalledWith({ query: { status: 'open', search: 'refund' } })
|
||||
expect(ticketService.listTickets).toHaveBeenCalledWith(expect.objectContaining({ search: 'refund' }))
|
||||
})
|
||||
})
|
||||
@@ -95,6 +95,7 @@ describe('the composer while a reply is in flight', () => {
|
||||
|
||||
afterEach(() => {
|
||||
vi.unstubAllGlobals()
|
||||
vi.restoreAllMocks()
|
||||
})
|
||||
|
||||
const composer = (wrapper: any) =>
|
||||
@@ -185,4 +186,51 @@ describe('the composer while a reply is in flight', () => {
|
||||
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('observes dynamic inputs without a runtime Node global', async () => {
|
||||
vi.stubGlobal('Node', undefined)
|
||||
const errors: string[] = []
|
||||
const onWindowError = (event: ErrorEvent) => errors.push(event.message)
|
||||
window.addEventListener('error', onWindowError)
|
||||
|
||||
const wrapper = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const hostInput = document.createElement('input')
|
||||
hostInput.type = 'text'
|
||||
document.body.appendChild(hostInput)
|
||||
await new Promise((resolve) => setTimeout(resolve, 0))
|
||||
|
||||
expect(errors).toEqual([])
|
||||
hostInput.remove()
|
||||
window.removeEventListener('error', onWindowError)
|
||||
wrapper.unmount()
|
||||
})
|
||||
|
||||
it('removes the same host-message listener before remounting', () => {
|
||||
const addListener = vi.spyOn(window, 'addEventListener')
|
||||
const removeListener = vi.spyOn(window, 'removeEventListener')
|
||||
|
||||
const firstMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const messageListeners = () => addListener.mock.calls
|
||||
.filter(([type]) => type === 'message')
|
||||
.map(([, listener]) => listener)
|
||||
|
||||
const firstHostListener = messageListeners()[0]
|
||||
expect(firstHostListener).toBeDefined()
|
||||
firstMount.unmount()
|
||||
expect(removeListener.mock.calls.some(
|
||||
([type, listener]) => type === 'message' && listener === firstHostListener
|
||||
)).toBe(true)
|
||||
|
||||
const secondMount = mount(WidgetBuilder, { props: { widgetId: 'w-1', token: 'tok-1' } })
|
||||
const listenersAfterRemount = messageListeners()
|
||||
const secondHostListener = listenersAfterRemount[2]
|
||||
|
||||
expect(listenersAfterRemount).toHaveLength(4)
|
||||
expect(secondHostListener).not.toBe(firstHostListener)
|
||||
expect(removeListener.mock.calls.some(
|
||||
([type, listener]) => type === 'message' && listener === secondHostListener
|
||||
)).toBe(false)
|
||||
|
||||
secondMount.unmount()
|
||||
})
|
||||
})
|
||||
@@ -55,7 +55,7 @@ const handleToggleRateLimiting = async () => {
|
||||
try {
|
||||
const updatedAgent = await toggleRateLimiting()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
@@ -73,7 +73,7 @@ const handleSaveSettings = async () => {
|
||||
try {
|
||||
const updatedAgent = await saveRateLimitSettings()
|
||||
handleUpdate(updatedAgent)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
// Error is handled in the composable
|
||||
}
|
||||
}
|
||||
@@ -747,4 +747,4 @@ input:checked + .slider:before {
|
||||
gap: var(--space-md);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -40,8 +40,6 @@ const props = defineProps<{
|
||||
}>()
|
||||
console.log('AgentChatPreviewPanel props:', props.customization)
|
||||
const isExpanded = ref(true)
|
||||
const emailInput = ref('')
|
||||
const hasStartedChat = ref(false)
|
||||
|
||||
// Chat initiation message state
|
||||
const showInitiationMessage = ref(false)
|
||||
@@ -139,22 +137,9 @@ const {
|
||||
error,
|
||||
currentInput,
|
||||
initChat,
|
||||
sendMessage,
|
||||
cleanup
|
||||
} = useAgentChat(props.agentId)
|
||||
|
||||
// Handle message submit - dummy for preview only
|
||||
const handleSubmit = () => {
|
||||
// Do nothing - this is just a preview
|
||||
return
|
||||
}
|
||||
|
||||
// Handle enter key - dummy for preview only
|
||||
const handleKeyPress = (event: KeyboardEvent) => {
|
||||
// Do nothing - this is just a preview
|
||||
return
|
||||
}
|
||||
|
||||
// Watch for showInitiationPreview flag
|
||||
watch(() => props.customization.showInitiationPreview, async (newValue) => {
|
||||
// Prevent multiple simultaneous animations
|
||||
@@ -338,17 +323,6 @@ const formatMessage = (content: string) => {
|
||||
return sanitizeHtml(marked(content) as string)
|
||||
}
|
||||
|
||||
// Add this function near the top of the script section with other utility functions
|
||||
const isValidEmail = (email: string) => {
|
||||
const emailRegex = /^[^\s@]+@[^\s@]+\.[^\s@]+$/
|
||||
return emailRegex.test(email)
|
||||
}
|
||||
|
||||
// Update the isMessageInputEnabled computed property
|
||||
const isMessageInputEnabled = computed(() => {
|
||||
return props.isActive && !isLoading.value && isValidEmail(emailInput.value.trim())
|
||||
})
|
||||
|
||||
// Aurora is the new dark ask-me-anything design. It reuses the ASK_ANYTHING
|
||||
// welcome/input flow but with a dark, polished theme and an orb avatar.
|
||||
const isAuroraStyle = computed(() => props.customization.chat_style === 'AURORA')
|
||||
|
||||
@@ -552,30 +552,6 @@ const showInitiationPreview = () => {
|
||||
})
|
||||
}
|
||||
|
||||
const hideInitiationPreview = () => {
|
||||
emit('preview', {
|
||||
...customization.value,
|
||||
showBubblePreview: false,
|
||||
showInitiationPreview: false
|
||||
})
|
||||
}
|
||||
|
||||
// Collapsible sections state
|
||||
const expandedSections = ref<Set<string>>(new Set(['chat-style', 'colors']))
|
||||
|
||||
const toggleSection = (sectionId: string) => {
|
||||
if (expandedSections.value.has(sectionId)) {
|
||||
expandedSections.value.delete(sectionId)
|
||||
} else {
|
||||
expandedSections.value.add(sectionId)
|
||||
}
|
||||
}
|
||||
|
||||
const isSectionExpanded = (sectionId: string) => {
|
||||
return expandedSections.value.has(sectionId)
|
||||
}
|
||||
|
||||
|
||||
</script>
|
||||
|
||||
<template>
|
||||
|
||||
@@ -34,7 +34,6 @@ import AgentMCPToolsTab from './AgentMCPToolsTab.vue'
|
||||
import AgentLeadCaptureTab from './AgentLeadCaptureTab.vue'
|
||||
import { Cropper, CircleStencil } from 'vue-advanced-cropper'
|
||||
import 'vue-advanced-cropper/dist/style.css'
|
||||
import { useAgentChat } from '@/composables/useAgentChat'
|
||||
import { useAgentDetail } from '@/composables/useAgentDetail'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { toast } from 'vue-sonner'
|
||||
@@ -247,7 +246,6 @@ const {
|
||||
handleCrop,
|
||||
cancelCrop,
|
||||
applyPresetAvatar,
|
||||
handleClose: handleCloseAgent,
|
||||
initializeWidget,
|
||||
copyWidgetCode: copyWidgetCodeFn,
|
||||
userGroups,
|
||||
@@ -276,11 +274,10 @@ const {
|
||||
fetchAgentShopifyConfig,
|
||||
toggleShopifyIntegration,
|
||||
saveShopifyConfig,
|
||||
shopifyShopDomain
|
||||
shopifyShopDomain,
|
||||
shopifyLoading
|
||||
} = useAgentDetail(agentData, emit)
|
||||
|
||||
const { cleanup } = useAgentChat(agentData.value.id)
|
||||
|
||||
const handlePreview = (customization: AgentCustomization) => {
|
||||
previewCustomization.value = {
|
||||
...customization,
|
||||
@@ -327,7 +324,7 @@ const photoUrl = computed(() => {
|
||||
})
|
||||
|
||||
const handleClose = () => {
|
||||
handleCloseAgent(cleanup)
|
||||
emit('close')
|
||||
}
|
||||
|
||||
const copyWidgetCode = () => {
|
||||
@@ -1158,6 +1155,7 @@ onMounted(async () => {
|
||||
打开知识库
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"><path d="M18 13v6a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2V8a2 2 0 0 1 2-2h6"></path><path d="M15 3h6v6"></path><path d="M10 14 21 3"></path></svg>
|
||||
</router-link>
|
||||
<button type="button" class="kb-open-link" @click="openTips">知识管理提示</button>
|
||||
</template>
|
||||
</KnowledgeExplorer>
|
||||
</div>
|
||||
@@ -1179,6 +1177,7 @@ onMounted(async () => {
|
||||
:loading-projects="loadingProjects"
|
||||
:loading-issue-types="loadingIssueTypes"
|
||||
:shopify-integration-enabled="shopifyIntegrationEnabled"
|
||||
:shopify-loading="shopifyLoading"
|
||||
:shopify-shop-domain="shopifyShopDomain"
|
||||
@toggle-ticketing="toggleTicketing"
|
||||
@toggle-create-ticket="toggleCreateTicket"
|
||||
|
||||
@@ -103,10 +103,10 @@ const props = defineProps({
|
||||
// Local state for Shopify connection
|
||||
const shopifyConnected = ref(false)
|
||||
const shopifyShopDomain = ref('')
|
||||
const shopifyLoading = ref(true)
|
||||
const shopifyStatusLoading = ref(true)
|
||||
const localShopifyEnabled = ref(props.shopifyIntegrationEnabled)
|
||||
const shopifyToggleInProgress = ref(false)
|
||||
const shopifyError = ref('')
|
||||
const shopifyToggleError = ref('')
|
||||
|
||||
// Local state for messaging-channel routing (Telegram, WhatsApp, Messenger, Instagram)
|
||||
const MESSAGING_CHANNELS = ['telegram', 'whatsapp', 'messenger', 'instagram', 'slack', 'email', 'sms', 'line']
|
||||
@@ -174,13 +174,13 @@ watch(() => props.selectedIssueType, (newValue) => {
|
||||
watch(() => props.shopifyIntegrationEnabled, (newValue) => {
|
||||
localShopifyEnabled.value = newValue
|
||||
// Reset error when parent updates the enabled state successfully
|
||||
shopifyError.value = ''
|
||||
shopifyToggleError.value = ''
|
||||
})
|
||||
|
||||
// Update error state when parent passes error
|
||||
watch(() => props.shopifyError, (newValue) => {
|
||||
if (newValue) {
|
||||
shopifyError.value = newValue
|
||||
shopifyToggleError.value = newValue
|
||||
// Revert the toggle if there's an error
|
||||
localShopifyEnabled.value = props.shopifyIntegrationEnabled
|
||||
}
|
||||
@@ -258,7 +258,7 @@ const saveJiraConfig = () => {
|
||||
// Fetch Shopify connection status
|
||||
const fetchShopifyStatus = async () => {
|
||||
try {
|
||||
shopifyLoading.value = true
|
||||
shopifyStatusLoading.value = true
|
||||
const data = await checkShopifyConnection()
|
||||
shopifyConnected.value = data.connected
|
||||
shopifyShopDomain.value = data.shop_domain || ''
|
||||
@@ -267,7 +267,7 @@ const fetchShopifyStatus = async () => {
|
||||
console.error('Error checking Shopify connection:', error)
|
||||
shopifyConnected.value = false
|
||||
} finally {
|
||||
shopifyLoading.value = false
|
||||
shopifyStatusLoading.value = false
|
||||
}
|
||||
}
|
||||
|
||||
@@ -473,7 +473,7 @@ onMounted(async () => {
|
||||
<input type="checkbox"
|
||||
v-model="localShopifyEnabled"
|
||||
@change="toggleShopifyIntegration"
|
||||
:disabled="shopifyLoading || props.shopifyLoading || shopifyToggleInProgress"
|
||||
:disabled="shopifyStatusLoading || props.shopifyLoading || shopifyToggleInProgress"
|
||||
>
|
||||
<span class="slider" :class="{ 'in-progress': shopifyToggleInProgress }"></span>
|
||||
</label>
|
||||
@@ -482,7 +482,7 @@ onMounted(async () => {
|
||||
<p class="helper-text">为该客服启用 Shopify 商品信息与功能</p>
|
||||
|
||||
<!-- Shopify Connection Status -->
|
||||
<div v-if="shopifyLoading" class="jira-status loading">
|
||||
<div v-if="shopifyStatusLoading" class="jira-status loading">
|
||||
正在检查 Shopify 连接…
|
||||
</div>
|
||||
<div v-else-if="!shopifyConnected" class="jira-status not-connected">
|
||||
@@ -498,9 +498,9 @@ onMounted(async () => {
|
||||
</div>
|
||||
</div>
|
||||
<!-- Add error message display -->
|
||||
<div v-if="shopifyConnected && shopifyError" class="shopify-error">
|
||||
<div v-if="shopifyConnected && shopifyToggleError" class="shopify-error">
|
||||
<span class="error-icon">❌</span>
|
||||
{{ shopifyError }}
|
||||
{{ shopifyToggleError }}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
@@ -1225,4 +1225,4 @@ input:checked + .slider:before {
|
||||
opacity: 0.7;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -79,7 +79,7 @@ function addCustomField() {
|
||||
// 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, '')
|
||||
const 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
|
||||
|
||||
@@ -17,7 +17,6 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useMCPTools } from '@/composables/useMCPTools'
|
||||
import type { MCPTransportType } from '@/types/mcp'
|
||||
import {
|
||||
CONNECTOR_GUIDANCE_EXAMPLE,
|
||||
CONNECTOR_GUIDANCE_HINT,
|
||||
@@ -64,8 +63,7 @@ const {
|
||||
addHeader,
|
||||
removeHeader,
|
||||
isToolLinked,
|
||||
getTransportTypeInfo,
|
||||
formatDate
|
||||
getTransportTypeInfo
|
||||
} = useMCPTools(props.agentId)
|
||||
|
||||
// Form helpers
|
||||
|
||||
@@ -99,7 +99,7 @@ const handleCreateWorkflow = async () => {
|
||||
workflowName.value = ''
|
||||
workflowDescription.value = ''
|
||||
showCreateForm.value = false
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
@@ -129,7 +129,7 @@ const handleUpdateWorkflow = async () => {
|
||||
editWorkflowName.value = ''
|
||||
editWorkflowDescription.value = ''
|
||||
showEditForm.value = false
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// Error handling is done in the composable
|
||||
}
|
||||
}
|
||||
@@ -146,15 +146,12 @@ const handleDeleteWorkflow = async () => {
|
||||
if (confirm('确定要删除该工作流吗?此操作无法撤销。')) {
|
||||
try {
|
||||
await deleteWorkflow(workflow.value.id)
|
||||
} catch (error) {
|
||||
} catch {
|
||||
// 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
|
||||
@@ -162,16 +159,18 @@ const openWorkflowBuilder = () => {
|
||||
workflowCacheStorage.clearWorkflowCache(workflow.value.id)
|
||||
}
|
||||
showWorkflowBuilder.value = true
|
||||
emit('toggle-fullscreen', true)
|
||||
}
|
||||
|
||||
const closeWorkflowBuilder = () => {
|
||||
showWorkflowBuilder.value = false
|
||||
emit('toggle-fullscreen', false)
|
||||
}
|
||||
|
||||
const handleWorkflowSave = () => {
|
||||
// Refresh workflow data after saving
|
||||
fetchWorkflow()
|
||||
showWorkflowBuilder.value = false
|
||||
closeWorkflowBuilder()
|
||||
}
|
||||
|
||||
const cancelCreateForm = () => {
|
||||
@@ -861,4 +860,4 @@ onMounted(() => {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -27,23 +27,11 @@ const emit = defineEmits<{
|
||||
const { hasEnterpriseModule, moduleImports, loadModule } = useEnterpriseFeatures()
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
|
||||
// Only import subscription store if enterprise module is available
|
||||
const subscriptionStore = ref<any>({ fetchPlans: async () => {} })
|
||||
type SubscriptionPlansStore = { fetchPlans?: () => Promise<unknown> }
|
||||
|
||||
// Initialize subscription store if enterprise module is available
|
||||
onMounted(async () => {
|
||||
if (hasEnterpriseModule) {
|
||||
try {
|
||||
const module = await loadModule(moduleImports.subscriptionStore)
|
||||
if (module?.default) {
|
||||
// Access the subscriptionStore export directly
|
||||
subscriptionStore.value = module.default
|
||||
}
|
||||
} catch (error) {
|
||||
console.warn('Failed to load subscription store:', error)
|
||||
}
|
||||
}
|
||||
})
|
||||
const isSubscriptionPlansStore = (value: unknown): value is SubscriptionPlansStore =>
|
||||
typeof value === 'object' && value !== null &&
|
||||
'fetchPlans' in value && typeof value.fetchPlans === 'function'
|
||||
|
||||
// Subscription feature checking
|
||||
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||
@@ -165,18 +153,7 @@ const currentPlanName = computed(() => {
|
||||
})
|
||||
|
||||
// Check available plans and upgrade options
|
||||
const availablePlans = computed(() => {
|
||||
const plans = subscriptionStorage.getAvailablePlans()
|
||||
|
||||
// If plans are empty and we have enterprise module, trigger fetch
|
||||
if (plans.length === 0 && hasEnterpriseModule) {
|
||||
subscriptionStore.value.fetchPlans?.().catch((err: any) => {
|
||||
console.error('Failed to fetch plans:', err)
|
||||
})
|
||||
}
|
||||
|
||||
return plans
|
||||
})
|
||||
const availablePlans = ref(subscriptionStorage.getAvailablePlans())
|
||||
|
||||
// Determine if user can upgrade (not on the highest tier plan)
|
||||
const canUpgrade = computed(() => {
|
||||
@@ -295,15 +272,12 @@ const handleUpgrade = () => {
|
||||
onMounted(async () => {
|
||||
if (hasEnterpriseModule && subscriptionStorage.getAvailablePlans().length === 0) {
|
||||
try {
|
||||
// Initialize the subscription store first
|
||||
const module = await loadModule(moduleImports.subscriptionStore)
|
||||
if (module?.default) {
|
||||
// Access the subscriptionStore export directly
|
||||
subscriptionStore.value = module.default
|
||||
|
||||
// Then fetch plans
|
||||
await subscriptionStore.value.fetchPlans?.()
|
||||
}
|
||||
const store = isSubscriptionPlansStore(module?.subscriptionStore)
|
||||
? module.subscriptionStore
|
||||
: isSubscriptionPlansStore(module?.default) ? module.default : null
|
||||
await store?.fetchPlans?.()
|
||||
availablePlans.value = subscriptionStorage.getAvailablePlans()
|
||||
} catch (err) {
|
||||
console.error('Failed to fetch plans on mount:', err)
|
||||
}
|
||||
@@ -1432,4 +1406,4 @@ const replypierButtonText = computed(() => {
|
||||
font-size: 1rem;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -17,7 +17,6 @@ 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
|
||||
@@ -39,7 +38,7 @@ 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<{
|
||||
defineProps<{
|
||||
model: string
|
||||
}>()
|
||||
|
||||
@@ -47,12 +46,12 @@ const emit = defineEmits<{
|
||||
(e: 'toggle-fullscreen', isFullscreen: boolean): void
|
||||
}>()
|
||||
|
||||
const checkAIConfig = async () => {
|
||||
const checkAIConfig = async (refreshAgents = true) => {
|
||||
try {
|
||||
isLoading.value = true
|
||||
await aiService.getOrganizationConfig()
|
||||
isAISetupMode.value = false
|
||||
await fetchAgents()
|
||||
if (refreshAgents) await fetchAgents()
|
||||
} catch (err: unknown) {
|
||||
if (err instanceof AxiosError && err.response?.status === 404) {
|
||||
// No AI config found
|
||||
@@ -115,6 +114,7 @@ onMounted(async () => {
|
||||
if (agents.length > 0) {
|
||||
agentStorage.setAgents(agents)
|
||||
openWizardIfAsked()
|
||||
if (!showOnboarding.value) await checkAIConfig(false)
|
||||
return
|
||||
}
|
||||
// No agents yet → show the guided first-agent setup, but only to
|
||||
@@ -224,4 +224,4 @@ onMounted(async () => {
|
||||
0% { transform: rotate(0deg) }
|
||||
100% { transform: rotate(360deg) }
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -140,7 +140,7 @@ const props = defineProps<{
|
||||
timeRange: string
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
(e: 'time-range-change', range: string): void
|
||||
}>()
|
||||
|
||||
@@ -170,7 +170,7 @@ const fetchPerformanceData = async () => {
|
||||
}
|
||||
|
||||
// Watch for time range changes from parent
|
||||
watch(() => props.timeRange, (newRange) => {
|
||||
watch(() => props.timeRange, () => {
|
||||
fetchPerformanceData()
|
||||
})
|
||||
|
||||
@@ -300,4 +300,4 @@ onMounted(() => {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -376,7 +376,6 @@ const analyticsData = ref<AnalyticsData | null>(null)
|
||||
// Subscription and analytics feature checking
|
||||
const subscriptionStorage = useSubscriptionStorage()
|
||||
const { hasEnterpriseModule } = useEnterpriseFeatures()
|
||||
const currentSubscription = computed(() => subscriptionStorage.getCurrentSubscription())
|
||||
const isSubscriptionActive = computed(() => subscriptionStorage.isSubscriptionActive())
|
||||
|
||||
// Check if analytics feature is available
|
||||
@@ -394,13 +393,6 @@ const isAnalyticsLocked = computed(() => {
|
||||
})
|
||||
|
||||
// Upgrade modal state
|
||||
const showUpgradeModal = ref(false)
|
||||
|
||||
// Modal functions
|
||||
const closeUpgradeModal = () => {
|
||||
showUpgradeModal.value = false
|
||||
}
|
||||
|
||||
const handleUpgrade = () => {
|
||||
// Only redirect to subscription page if enterprise module exists
|
||||
if (hasEnterpriseModule) {
|
||||
|
||||
@@ -342,7 +342,7 @@ const goToPage = (page: number) => {
|
||||
}
|
||||
|
||||
// Watch for time range changes from parent
|
||||
watch(() => props.timeRange, (newRange) => {
|
||||
watch(() => props.timeRange, () => {
|
||||
// Reset to page 1 when time range changes
|
||||
fetchCustomerData(1)
|
||||
})
|
||||
@@ -886,4 +886,4 @@ onMounted(() => {
|
||||
color: var(--text-muted);
|
||||
font-size: var(--text-md);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -17,6 +17,8 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { useId } from 'vue'
|
||||
|
||||
defineOptions({ name: 'CommonModal' })
|
||||
|
||||
const titleId = useId()
|
||||
const props = withDefaults(defineProps<{
|
||||
title?: string
|
||||
@@ -116,4 +118,4 @@ const onOverlayClick = () => {
|
||||
.modal-body {
|
||||
margin-top: 0;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -40,6 +40,8 @@ const props = defineProps<{
|
||||
|
||||
const emit = defineEmits<{
|
||||
(e: 'refresh'): void
|
||||
(e: 'liveRefresh'): void
|
||||
(e: 'chatAccessLost'): void
|
||||
(e: 'chatUpdated', data: ChatDetail): void
|
||||
(e: 'chatSelected', data: ChatDetail): void
|
||||
(e: 'clearUnread', sessionId: string): void
|
||||
@@ -98,6 +100,7 @@ const {
|
||||
chatLoading,
|
||||
formattedConversations,
|
||||
loadChatDetail,
|
||||
clearSelection,
|
||||
unreadMessages,
|
||||
clearUnread
|
||||
} = useConversationsList(props, emit)
|
||||
@@ -131,8 +134,7 @@ const updateSelectedChat = (updatedChat: ChatDetail) => {
|
||||
|
||||
// Function to clear the selected chat from parent
|
||||
const clearSelectedChat = () => {
|
||||
selectedChat.value = null
|
||||
selectedId.value = null
|
||||
clearSelection()
|
||||
}
|
||||
|
||||
// Expose the function to parent component
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import type { FaqImportMode, FaqItem, FaqStatus } from '@/types/faq'
|
||||
import type { FaqImportMode, FaqItem, FaqStatus, HelpCenterSettingsUpdate } from '@/types/faq'
|
||||
import { useFaqWorkspace } from '@/composables/useFaqWorkspace'
|
||||
import { useHelpCenterSettings } from '@/composables/useHelpCenterSettings'
|
||||
import FaqGenerateBar from './FaqGenerateBar.vue'
|
||||
@@ -102,6 +102,12 @@ const {
|
||||
removeDomain,
|
||||
} = useHelpCenterSettings(settings)
|
||||
|
||||
function updateAppearance(payload: HelpCenterSettingsUpdate) {
|
||||
if (!settings.value) return
|
||||
settings.value = { ...settings.value, ...payload }
|
||||
queueSave(payload)
|
||||
}
|
||||
|
||||
// Surface the server-side plan verdict to the view's lock overlay.
|
||||
watch(
|
||||
() => settings.value?.plan_allowed,
|
||||
@@ -431,7 +437,7 @@ onUnmounted(stopPolling)
|
||||
class="settings-section"
|
||||
:settings="settings"
|
||||
:save-state="saveState"
|
||||
@update="queueSave"
|
||||
@update="updateAppearance"
|
||||
@save-now="saveNow"
|
||||
@upload-logo="uploadLogo"
|
||||
@remove-logo="removeLogo"
|
||||
|
||||
@@ -87,21 +87,30 @@ const saveStateLabel = computed(() => {
|
||||
return ''
|
||||
})
|
||||
|
||||
/** Text fields: mutate in place for instant feedback, emit for debounced save. */
|
||||
/** Text fields emit patches so the parent updates its draft and debounces the save. */
|
||||
function onText(field: 'cta_text' | 'cta_url', event: Event) {
|
||||
const value = (event.target as HTMLInputElement).value
|
||||
props.settings[field] = value
|
||||
emit('update', { [field]: value })
|
||||
}
|
||||
|
||||
// Header link rows are mutated in place — the settings engine deep-watches
|
||||
// header_links and debounce-saves.
|
||||
function updateLink(index: number, field: 'label' | 'url', event: Event) {
|
||||
const current = props.settings.header_links[index]
|
||||
if (!current) return
|
||||
const headerLinks = [...props.settings.header_links]
|
||||
headerLinks[index] = { ...current, [field]: (event.target as HTMLInputElement).value }
|
||||
emit('update', { header_links: headerLinks })
|
||||
}
|
||||
|
||||
function addLink() {
|
||||
props.settings.header_links.push({ label: '新链接', url: '' })
|
||||
emit('update', {
|
||||
header_links: [...props.settings.header_links, { label: '新链接', url: '' }],
|
||||
})
|
||||
}
|
||||
|
||||
function removeLink(index: number) {
|
||||
props.settings.header_links.splice(index, 1)
|
||||
emit('update', {
|
||||
header_links: props.settings.header_links.filter((_, linkIndex) => linkIndex !== index),
|
||||
})
|
||||
}
|
||||
</script>
|
||||
|
||||
@@ -187,10 +196,10 @@ function removeLink(index: number) {
|
||||
<p class="hint hint--tight">显示在帮助中心页头的链接——可指向你的官网。</p>
|
||||
<div class="link-rows">
|
||||
<div v-for="(link, index) in settings.header_links" :key="index" class="link-row">
|
||||
<input v-model="link.label" class="text-input text-input--label" type="text" placeholder="名称" />
|
||||
<input :value="link.label" class="text-input text-input--label" type="text" placeholder="名称" @input="updateLink(index, 'label', $event)" />
|
||||
<div class="url-input">
|
||||
<span class="url-input__prefix">https://</span>
|
||||
<input v-model="link.url" type="text" placeholder="yoursite.com" />
|
||||
<input :value="link.url" type="text" placeholder="yoursite.com" @input="updateLink(index, 'url', $event)" />
|
||||
</div>
|
||||
<button class="remove-btn" type="button" title="移除" @click="removeLink(index)">
|
||||
<svg viewBox="0 0 24 24" width="14" height="14" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round"><path d="M18 6 6 18M6 6l12 12" /></svg>
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { onMounted, ref, computed } from 'vue'
|
||||
import { onMounted, computed } from 'vue'
|
||||
import Modal from '@/components/common/Modal.vue'
|
||||
import RoleForm from './RoleForm.vue'
|
||||
import type { Role } from '@/types/user'
|
||||
@@ -728,4 +728,4 @@ onMounted(() => {
|
||||
margin-bottom: var(--space-lg);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -24,7 +24,7 @@ const props = defineProps<{
|
||||
user: User
|
||||
}>()
|
||||
|
||||
const emit = defineEmits<{
|
||||
defineEmits<{
|
||||
close: []
|
||||
groupToggle: [groupId: string, checked: boolean]
|
||||
}>()
|
||||
@@ -114,4 +114,4 @@ onMounted(fetchGroups)
|
||||
padding: var(--space-xl);
|
||||
opacity: 0.7;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -30,7 +30,6 @@ const emit = defineEmits<{
|
||||
const {
|
||||
jiraConnected,
|
||||
jiraLoading,
|
||||
showTicketModal,
|
||||
jiraProjects,
|
||||
jiraIssueTypes,
|
||||
jiraPriorities,
|
||||
@@ -47,7 +46,6 @@ const {
|
||||
priorityAvailable,
|
||||
checkingPriorityAvailability,
|
||||
isFormValid,
|
||||
checkJiraStatus,
|
||||
handleProjectChange,
|
||||
handleIssueTypeChange,
|
||||
openTicketModal,
|
||||
@@ -435,4 +433,4 @@ initModal()
|
||||
color: var(--warning);
|
||||
font-size: 16px;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -23,7 +23,7 @@ import { conversationSessionUrl } from '@/pwa/pushContract'
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const props = defineProps<{
|
||||
defineProps<{
|
||||
isOpen: boolean
|
||||
}>()
|
||||
|
||||
@@ -577,4 +577,4 @@ onMounted(fetchNotifications)
|
||||
padding-bottom: var(--safe-bottom);
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -17,9 +17,10 @@ limitations under the License.
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { useOrganizationSettings } from '@/composables/useOrganizationSettings'
|
||||
// @ts-ignore
|
||||
import { timezoneOptions } from '@/utils/timezones'
|
||||
|
||||
defineOptions({ name: 'OrganizationsView' })
|
||||
|
||||
const {
|
||||
formData,
|
||||
loading,
|
||||
|
||||
@@ -19,7 +19,14 @@ import { onMounted } from 'vue'
|
||||
import type { TicketListFilters } from '@/types/ticket'
|
||||
import { useUsers } from '@/composables/useUsers'
|
||||
|
||||
defineProps<{ filters: TicketListFilters }>()
|
||||
const props = defineProps<{ filters: TicketListFilters }>()
|
||||
const emit = defineEmits<{
|
||||
'update:filters': [patch: Partial<TicketListFilters>]
|
||||
}>()
|
||||
|
||||
function updateFilter(patch: Partial<TicketListFilters>) {
|
||||
emit('update:filters', patch)
|
||||
}
|
||||
|
||||
const STATUS_PILLS = [
|
||||
{ value: 'all', label: '全部' },
|
||||
@@ -50,14 +57,14 @@ onMounted(() => {
|
||||
v-for="pill in STATUS_PILLS"
|
||||
:key="pill.value"
|
||||
class="pill"
|
||||
:class="{ active: filters.status === pill.value }"
|
||||
@click="filters.status = pill.value"
|
||||
:class="{ active: props.filters.status === pill.value }"
|
||||
@click="updateFilter({ status: pill.value })"
|
||||
>
|
||||
{{ pill.label }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<select v-model="filters.priority" class="filter-select" aria-label="按优先级筛选">
|
||||
<select :value="props.filters.priority" class="filter-select" aria-label="按优先级筛选" @change="updateFilter({ priority: ($event.target as HTMLSelectElement).value })">
|
||||
<option value="all">优先级 · 全部</option>
|
||||
<option value="urgent">紧急</option>
|
||||
<option value="high">高</option>
|
||||
@@ -65,7 +72,7 @@ onMounted(() => {
|
||||
<option value="low">低</option>
|
||||
</select>
|
||||
|
||||
<select v-model="filters.assignee" class="filter-select" aria-label="按负责人筛选">
|
||||
<select :value="props.filters.assignee" class="filter-select" aria-label="按负责人筛选" @change="updateFilter({ assignee: ($event.target as HTMLSelectElement).value })">
|
||||
<option value="all">负责人 · 全部</option>
|
||||
<option value="unassigned">未分配 / AI</option>
|
||||
<option v-for="user in users" :key="user.id" :value="user.id">
|
||||
@@ -78,8 +85,8 @@ onMounted(() => {
|
||||
v-for="pill in AI_PILLS"
|
||||
:key="pill.value"
|
||||
class="pill with-dot"
|
||||
:class="{ active: filters.ai === pill.value }"
|
||||
@click="filters.ai = pill.value"
|
||||
:class="{ active: props.filters.ai === pill.value }"
|
||||
@click="updateFilter({ ai: pill.value })"
|
||||
>
|
||||
<span class="dot" :style="{ background: pill.color }"></span>{{ pill.label }}
|
||||
</button>
|
||||
@@ -87,10 +94,10 @@ onMounted(() => {
|
||||
|
||||
<div class="search-wrap">
|
||||
<span class="search-glyph"></span>
|
||||
<input v-model="filters.search" class="search-input" placeholder="搜索工单…" />
|
||||
<input :value="props.filters.search" class="search-input" placeholder="搜索工单…" @input="updateFilter({ search: ($event.target as HTMLInputElement).value })" />
|
||||
</div>
|
||||
|
||||
<select v-model="filters.sort" class="filter-select" aria-label="工单排序">
|
||||
<select :value="props.filters.sort" class="filter-select" aria-label="工单排序" @change="updateFilter({ sort: ($event.target as HTMLSelectElement).value })">
|
||||
<option value="updated">排序 · 更新时间</option>
|
||||
<option value="created">最新创建</option>
|
||||
<option value="priority">优先级</option>
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, inject, computed, watch } from 'vue'
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { useAuth } from '@/composables/useAuth'
|
||||
import { userService } from '@/services/user'
|
||||
import { meetsPasswordPolicy, validatePassword, type PasswordStrength } from '@/utils/validators'
|
||||
@@ -933,4 +933,4 @@ input:checked + .slider:before {
|
||||
}
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -41,7 +41,7 @@ const copyToClipboard = async () => {
|
||||
setTimeout(() => {
|
||||
copied.value = false
|
||||
}, 2000)
|
||||
} catch (err) {
|
||||
} catch {
|
||||
toast.error('复制失败', {
|
||||
description: '请手动复制',
|
||||
duration: 2000
|
||||
|
||||
@@ -173,7 +173,7 @@ const getNodeTypeName = (type: string | undefined) => {
|
||||
const handleObjectChange = (fieldKey: string, value: string) => {
|
||||
try {
|
||||
formData.value.config[fieldKey] = JSON.parse(value)
|
||||
} catch (e) {
|
||||
} catch {
|
||||
// Keep as string if not valid JSON
|
||||
formData.value.config[fieldKey] = value
|
||||
}
|
||||
@@ -665,4 +665,4 @@ const handleObjectChange = (fieldKey: string, value: string) => {
|
||||
flex: 1;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -135,8 +135,6 @@ const nodeForm = ref({
|
||||
block_message: ''
|
||||
})
|
||||
|
||||
const saving = ref(false)
|
||||
|
||||
// Validation errors state
|
||||
const validationErrors = ref<Record<string, string>>({})
|
||||
|
||||
@@ -554,7 +552,7 @@ const getNodeDataForForm = (node: Node) => {
|
||||
const workflowCache = workflowCacheStorage.getWorkflowCache(props.workflowId)
|
||||
|
||||
// Start with node data
|
||||
let nodeData = { ...node.data }
|
||||
const nodeData = { ...node.data }
|
||||
|
||||
if (workflowCache && workflowCache.nodes) {
|
||||
// Find this node in the cache
|
||||
@@ -2173,4 +2171,4 @@ const handleDelete = () => {
|
||||
color: var(--error-color, #EF4444);
|
||||
}
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -22,7 +22,6 @@ import { Controls } from '@vue-flow/controls'
|
||||
import { MiniMap } from '@vue-flow/minimap'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { workflowNodeService } from '../../services/workflowNode'
|
||||
import { workflowService } from '../../services/workflow'
|
||||
import type { WorkflowResponse } from '@/types/workflow'
|
||||
import { WorkflowStatus, ExitCondition } from '@/types/workflow'
|
||||
import PropertiesPanel from './PropertiesPanel.vue'
|
||||
@@ -61,7 +60,6 @@ const nodeTypes = {}
|
||||
const {
|
||||
validateAllNodes,
|
||||
highlightNodesWithErrors,
|
||||
resetNodeValidationStyle,
|
||||
resetAllNodesValidationStyle,
|
||||
getNodeDataWithCache
|
||||
} = useWorkflowValidation()
|
||||
@@ -209,7 +207,6 @@ const {
|
||||
handleNodesChange,
|
||||
handleDeleteEvent,
|
||||
handleEdgesChange,
|
||||
mapNodeTypeToFrontend,
|
||||
mapNodeTypeToBackend
|
||||
} = useWorkflowManagement({
|
||||
workflowId: props.workflow.id,
|
||||
@@ -1273,4 +1270,4 @@ onUnmounted(() => {
|
||||
|
||||
|
||||
|
||||
</style>
|
||||
</style>
|
||||
@@ -15,8 +15,7 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { computed, ref, watch } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { ref, watch } from 'vue'
|
||||
|
||||
interface ConditionRule {
|
||||
id: string
|
||||
@@ -589,4 +588,4 @@ const updateGroupOperator = (groupId: string, operator: 'AND' | 'OR') => {
|
||||
content: "⚠";
|
||||
font-size: 0.8rem;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -14,15 +14,14 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { App } from 'vue'
|
||||
// TODO: Import node editors when they are created
|
||||
// import MessageNodeEditor from './MessageNodeEditor.vue'
|
||||
// import LlmNodeEditor from './LlmNodeEditor.vue'
|
||||
|
||||
export default {
|
||||
install(app: App) {
|
||||
install() {
|
||||
// TODO: Register node editors when they are created
|
||||
// app.component('message-node-editor', MessageNodeEditor)
|
||||
// app.component('llm-node-editor', LlmNodeEditor)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, onMounted } from 'vue'
|
||||
import { aiService, type AIConfig } from '@/services/ai'
|
||||
import { aiService } from '@/services/ai'
|
||||
import type { AIProvider } from '@/types/ai'
|
||||
|
||||
export function useAISetup() {
|
||||
|
||||
@@ -17,8 +17,9 @@ limitations under the License.
|
||||
import { ref } from 'vue'
|
||||
|
||||
import type { ChatMessage } from '@/types/agent'
|
||||
import { agentStorage } from '@/utils/storage'
|
||||
|
||||
export function useAgentChat(agentId: string) {
|
||||
export function useAgentChat(_agentId: string) {
|
||||
const messages = ref<ChatMessage[]>([])
|
||||
const isLoading = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
@@ -26,32 +27,16 @@ export function useAgentChat(agentId: string) {
|
||||
|
||||
// Initialize chat with welcome message
|
||||
const initChat = () => {
|
||||
const agentName = agentStorage.getAgents().find((agent) => agent.id === _agentId)?.name
|
||||
messages.value = [
|
||||
{
|
||||
role: 'bot',
|
||||
content: '你好!有什么可以帮你的吗?',
|
||||
content: agentName ? `你好,我是${agentName}。有什么可以帮你的吗?` : '你好!有什么可以帮你的吗?',
|
||||
},
|
||||
]
|
||||
|
||||
}
|
||||
|
||||
// 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
|
||||
|
||||
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { Agent } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
|
||||
export function useAgentCreate() {
|
||||
@@ -63,4 +62,4 @@ export function useAgentCreate() {
|
||||
error,
|
||||
createAgent
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import type { AgentWithCustomization, AgentUpdate } from '@/types/agent'
|
||||
import type { AgentWithCustomization } from '@/types/agent'
|
||||
import { agentService } from '@/services/agent'
|
||||
import { widgetService } from '@/services/widget'
|
||||
import type { Widget } from '@/types/widget'
|
||||
@@ -323,4 +323,4 @@ export function useAgentDetail(agentData: { value: AgentWithCustomization }, emi
|
||||
// Shopify integration - spread all properties and methods from shopifyIntegration
|
||||
...shopifyIntegration
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -29,9 +29,6 @@ import { permissionChecks } from '@/utils/permissions'
|
||||
import { findMessage, newClientId } from '@/utils/messageMatch'
|
||||
import { useOrgInboxRoom } from '@/composables/useOrgInboxRoom'
|
||||
|
||||
// Define valid chat statuses
|
||||
type ChatStatus = 'open' | 'closed' | 'transferred'
|
||||
|
||||
/** Emitted by the backend when a reply was saved but never reached the customer. */
|
||||
interface DeliveryErrorEvent {
|
||||
error?: string
|
||||
@@ -446,4 +443,4 @@ export function useConversationChat(
|
||||
templateCanReopen,
|
||||
clearTemplateSuggestion
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -21,6 +21,10 @@ import { zhCN } from 'date-fns/locale'
|
||||
import { chatService } from '@/services/chat'
|
||||
import { socketService } from '@/services/socket'
|
||||
import { userService } from '@/services/user'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
|
||||
const DETAIL_REFRESH_DELAY = 200
|
||||
const INBOX_REFRESH_DELAY = 1000
|
||||
|
||||
export function useConversationsList(props: {
|
||||
conversations: Conversation[]
|
||||
@@ -30,6 +34,8 @@ export function useConversationsList(props: {
|
||||
loadingMore?: boolean
|
||||
}, emit: {
|
||||
(e: 'refresh'): void
|
||||
(e: 'liveRefresh'): void
|
||||
(e: 'chatAccessLost'): void
|
||||
(e: 'chatUpdated', data: ChatDetail): void
|
||||
(e: 'clearUnread', sessionId: string): void
|
||||
(e: 'updateFilter', status: 'open' | 'closed'): void
|
||||
@@ -38,25 +44,119 @@ export function useConversationsList(props: {
|
||||
const selectedChat = ref<ChatDetail | null>(null)
|
||||
const selectedId = ref<string | null>(null)
|
||||
const chatLoading = ref<boolean>(false)
|
||||
const currentUserId = ref(userService.getUserId())
|
||||
const unreadMessages = reactive<Record<string, number>>({})
|
||||
const processedMessages = reactive<Set<string>>(new Set())
|
||||
const orgId = userService.getCurrentUser()?.organization_id
|
||||
const updatesRoom = orgId && permissionChecks.canViewChats() ? `org_chat_updates_${orgId}` : null
|
||||
let watchedRoom: string | null = null
|
||||
let detailTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let inboxTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let reconciliationTimer: ReturnType<typeof setInterval> | null = null
|
||||
let detailInFlight = false
|
||||
let selectionLoadingVersion: number | null = null
|
||||
let detailDirty = false
|
||||
let detailVersion = 0
|
||||
let disposed = false
|
||||
|
||||
// A burst produces at most one in-flight detail request and one trailing
|
||||
// refresh. Only the selected chat joins its watch room.
|
||||
const scheduleDetailRefresh = () => {
|
||||
if (!selectedId.value || disposed) return
|
||||
detailDirty = true
|
||||
if (detailTimer || detailInFlight || selectionLoadingVersion === detailVersion) return
|
||||
detailTimer = setTimeout(async () => {
|
||||
detailTimer = null
|
||||
if (!selectedId.value || disposed) return
|
||||
const sessionId = selectedId.value
|
||||
const version = detailVersion
|
||||
detailDirty = false
|
||||
detailInFlight = true
|
||||
try {
|
||||
const detail = await chatService.getChatDetail(sessionId)
|
||||
if (!disposed && selectedId.value === sessionId && detailVersion === version) {
|
||||
// Preserve locally queued sends if a snapshot races their storage.
|
||||
const pending = (selectedChat.value?.messages || []).filter(message =>
|
||||
message.client_id && !detail.messages.some(saved => saved.id != null && saved.id === message.id)
|
||||
)
|
||||
selectedChat.value = { ...detail, messages: [...detail.messages, ...pending] }
|
||||
emit('chatUpdated', selectedChat.value)
|
||||
}
|
||||
} catch (err) {
|
||||
const status = (err as { response?: { status?: number } })?.response?.status
|
||||
if ((status === 403 || status === 404) && selectedId.value === sessionId && detailVersion === version) {
|
||||
clearSelection()
|
||||
emit('chatAccessLost')
|
||||
scheduleInboxRefresh()
|
||||
} else {
|
||||
console.error('Failed to refresh chat:', err)
|
||||
}
|
||||
} finally {
|
||||
detailInFlight = false
|
||||
if (detailDirty) scheduleDetailRefresh()
|
||||
}
|
||||
}, DETAIL_REFRESH_DELAY)
|
||||
}
|
||||
|
||||
const scheduleInboxRefresh = () => {
|
||||
if (inboxTimer || disposed) return
|
||||
inboxTimer = setTimeout(() => {
|
||||
inboxTimer = null
|
||||
if (!disposed) emit('liveRefresh')
|
||||
}, INBOX_REFRESH_DELAY)
|
||||
}
|
||||
|
||||
const watchChat = (sessionId: string) => {
|
||||
const room = `chat_watch_${sessionId}`
|
||||
if (watchedRoom && watchedRoom !== room) {
|
||||
socketService.emit('leave_room', { session_id: watchedRoom })
|
||||
}
|
||||
watchedRoom = room
|
||||
// The acknowledgement follows the server-side room join. Fetching after
|
||||
// it closes the gap between the initial snapshot and subscription.
|
||||
socketService.emit('join_room', { session_id: room }, () => {
|
||||
if (watchedRoom === room && selectedId.value === sessionId) scheduleDetailRefresh()
|
||||
})
|
||||
}
|
||||
|
||||
const clearSelection = () => {
|
||||
detailVersion++
|
||||
selectionLoadingVersion = null
|
||||
detailDirty = false
|
||||
chatLoading.value = false
|
||||
selectedId.value = null
|
||||
selectedChat.value = null
|
||||
if (watchedRoom) socketService.emit('leave_room', { session_id: watchedRoom })
|
||||
watchedRoom = null
|
||||
}
|
||||
|
||||
const reconcileVisiblePage = () => {
|
||||
if (document.visibilityState !== 'visible') return
|
||||
scheduleDetailRefresh()
|
||||
scheduleInboxRefresh()
|
||||
}
|
||||
|
||||
// Socket event handlers
|
||||
const handleChatReply = (data: {
|
||||
message: string
|
||||
type: string
|
||||
agent_name?: string
|
||||
created_at: string
|
||||
created_at?: string
|
||||
timestamp?: string
|
||||
message_id?: number
|
||||
session_id: string
|
||||
attributes?: any
|
||||
attachments?: any[]
|
||||
}) => {
|
||||
// Create unique message identifier
|
||||
const messageKey = `${data.session_id}-${data.created_at}`;
|
||||
const timestamp = data.created_at || data.timestamp
|
||||
const messageKey = data.message_id != null ? `${data.session_id}-${data.message_id}` : `${data.session_id}-${timestamp}`
|
||||
// Skip if already processed
|
||||
if (processedMessages.has(messageKey)) return;
|
||||
processedMessages.add(messageKey);
|
||||
if (processedMessages.size > 2000) {
|
||||
const oldest = processedMessages.values().next().value
|
||||
if (oldest) processedMessages.delete(oldest)
|
||||
}
|
||||
|
||||
// Add unread counter if not current chat
|
||||
if (selectedId.value !== data.session_id) {
|
||||
@@ -67,11 +167,15 @@ export function useConversationsList(props: {
|
||||
}
|
||||
|
||||
if (selectedChat.value && selectedId.value === data.session_id) {
|
||||
// Ensure created_at is a valid ISO string
|
||||
const created_at = new Date(data.created_at).toISOString()
|
||||
if (!timestamp || Number.isNaN(Date.parse(timestamp))) {
|
||||
scheduleDetailRefresh()
|
||||
return
|
||||
}
|
||||
const created_at = new Date(timestamp).toISOString()
|
||||
|
||||
// Base message structure
|
||||
const newMessage: Message = {
|
||||
id: data.message_id,
|
||||
message: data.message,
|
||||
message_type: data.type === 'agent_message' ? 'agent' : data.type,
|
||||
created_at,
|
||||
@@ -87,29 +191,15 @@ export function useConversationsList(props: {
|
||||
newMessage.shopify_output = data.attributes.shopify_output
|
||||
}
|
||||
|
||||
// Create a completely new chat object with all properties
|
||||
const updatedChat: ChatDetail = {
|
||||
...selectedChat.value,
|
||||
messages: [...(selectedChat.value.messages || []), newMessage],
|
||||
updated_at: created_at,
|
||||
customer: { ...selectedChat.value.customer },
|
||||
agent: { ...selectedChat.value.agent },
|
||||
status: selectedChat.value.status,
|
||||
user_id: selectedChat.value.user_id,
|
||||
user_name: selectedChat.value.user_name,
|
||||
session_id: selectedChat.value.session_id,
|
||||
}
|
||||
|
||||
// Use Promise.resolve().then() to break the reactive chain
|
||||
// This prevents the recursive update error by deferring the state update
|
||||
// Read current state inside the microtask: two messages in the same tick
|
||||
// must not each append to an obsolete copy of the conversation.
|
||||
Promise.resolve().then(() => {
|
||||
// Update the selected chat with the new object
|
||||
const current = selectedChat.value
|
||||
if (!current || current.session_id !== data.session_id) return
|
||||
if (newMessage.id != null && current.messages.some(message => message.id === newMessage.id)) return
|
||||
const updatedChat = { ...current, messages: [...current.messages, newMessage], updated_at: created_at }
|
||||
selectedChat.value = updatedChat
|
||||
|
||||
// Emit chat update event in a separate tick
|
||||
Promise.resolve().then(() => {
|
||||
emit('chatUpdated', updatedChat)
|
||||
})
|
||||
emit('chatUpdated', updatedChat)
|
||||
})
|
||||
}
|
||||
}
|
||||
@@ -123,12 +213,16 @@ export function useConversationsList(props: {
|
||||
|
||||
socketService.on('chat_reply', handleChatReply)
|
||||
socketService.on('room_event', handleRoomEvent)
|
||||
socketService.on('chat_changed', scheduleDetailRefresh)
|
||||
socketService.on('inbox_changed', scheduleInboxRefresh)
|
||||
}
|
||||
|
||||
const cleanupSocketListeners = () => {
|
||||
|
||||
socketService.off('chat_reply', handleChatReply)
|
||||
socketService.off('room_event', handleRoomEvent)
|
||||
socketService.off('chat_changed', scheduleDetailRefresh)
|
||||
socketService.off('inbox_changed', scheduleInboxRefresh)
|
||||
}
|
||||
|
||||
// Handle socket reconnection
|
||||
@@ -143,21 +237,28 @@ export function useConversationsList(props: {
|
||||
if (userId) {
|
||||
socketService.emit('join_room', { session_id: `user_${userId}` })
|
||||
}
|
||||
if (updatesRoom) {
|
||||
socketService.emit('join_room', { session_id: updatesRoom }, scheduleInboxRefresh)
|
||||
}
|
||||
if (selectedId.value) watchChat(selectedId.value)
|
||||
}
|
||||
|
||||
|
||||
|
||||
/**
|
||||
* @param force refetch even when this chat is already open — the Refresh
|
||||
* button. Live socket updates only arrive once a human takes a chat over;
|
||||
* while the AI is answering, this is how an agent pulls the latest turns.
|
||||
* @param force refetch even when this chat is already open.
|
||||
*/
|
||||
const loadChatDetail = async (sessionId: string, force = false) => {
|
||||
let loadVersion: number | null = null
|
||||
try {
|
||||
// Only load full chat detail if it's a new chat or not loaded yet
|
||||
if (force || !selectedChat.value || selectedChat.value.session_id !== sessionId) {
|
||||
const version = ++detailVersion
|
||||
loadVersion = version
|
||||
selectionLoadingVersion = version
|
||||
chatLoading.value = true
|
||||
selectedId.value = sessionId
|
||||
watchChat(sessionId)
|
||||
const detail = await chatService.getChatDetail(sessionId)
|
||||
|
||||
// Debug: Log if messages have attachments
|
||||
@@ -169,6 +270,7 @@ export function useConversationsList(props: {
|
||||
})
|
||||
}
|
||||
|
||||
if (disposed || selectedId.value !== sessionId || detailVersion !== version) return
|
||||
selectedChat.value = detail
|
||||
|
||||
// Clear unread messages for this chat
|
||||
@@ -180,7 +282,11 @@ export function useConversationsList(props: {
|
||||
} catch (err) {
|
||||
console.error('Failed to load chat:', err)
|
||||
} finally {
|
||||
chatLoading.value = false
|
||||
if (loadVersion === detailVersion && selectedId.value === sessionId) chatLoading.value = false
|
||||
if (loadVersion !== null && selectionLoadingVersion === loadVersion) {
|
||||
selectionLoadingVersion = null
|
||||
if (detailDirty) scheduleDetailRefresh()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -223,15 +329,27 @@ export function useConversationsList(props: {
|
||||
if (userId) {
|
||||
socketService.emit('join_room', { session_id: `user_${userId}` })
|
||||
}
|
||||
if (updatesRoom) socketService.emit('join_room', { session_id: updatesRoom }, scheduleInboxRefresh)
|
||||
// Redis pub/sub is not durable. Reconcile occasionally if a notification
|
||||
// was lost without a socket disconnect, with jitter to spread DB load.
|
||||
reconciliationTimer = setInterval(reconcileVisiblePage, 45000 + Math.floor(Math.random() * 15000))
|
||||
document.addEventListener('visibilitychange', reconcileVisiblePage)
|
||||
})
|
||||
|
||||
// Cleanup on unmount
|
||||
onBeforeUnmount(() => {
|
||||
disposed = true
|
||||
if (detailTimer) clearTimeout(detailTimer)
|
||||
if (inboxTimer) clearTimeout(inboxTimer)
|
||||
if (reconciliationTimer) clearInterval(reconciliationTimer)
|
||||
document.removeEventListener('visibilitychange', reconcileVisiblePage)
|
||||
console.log('Disconnecting from socket')
|
||||
const userId = userService.getUserId()
|
||||
if (userId) {
|
||||
socketService.emit('leave_room', { session_id: `user_${userId}` })
|
||||
}
|
||||
if (watchedRoom) socketService.emit('leave_room', { session_id: watchedRoom })
|
||||
if (updatesRoom) socketService.emit('leave_room', { session_id: updatesRoom })
|
||||
cleanupSocketListeners()
|
||||
socketService.offReconnect(handleSocketReconnect)
|
||||
})
|
||||
@@ -247,7 +365,8 @@ export function useConversationsList(props: {
|
||||
chatLoading,
|
||||
formattedConversations,
|
||||
loadChatDetail,
|
||||
clearSelection,
|
||||
unreadMessages,
|
||||
clearUnread
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -25,14 +25,6 @@ const NotAvailableComponent = defineComponent({
|
||||
},
|
||||
})
|
||||
|
||||
// Check for enterprise module
|
||||
const enterpriseModules = import.meta.glob([
|
||||
'/src/modules/enterprise/views/SignupView.vue',
|
||||
'/src/modules/enterprise/composables/useSubscriptionStore.ts',
|
||||
'/src/modules/enterprise/router/guards/subscription.ts',
|
||||
])
|
||||
const hasEnterpriseModule = Object.keys(enterpriseModules).length > 0
|
||||
|
||||
interface Plan {
|
||||
type: string
|
||||
name: string
|
||||
@@ -60,10 +52,6 @@ interface MessageLimitStatus {
|
||||
percentage: number
|
||||
}
|
||||
|
||||
interface SubscriptionGuard {
|
||||
subscriptionGuard: (to: any, from: any, next: any) => void
|
||||
}
|
||||
|
||||
type EnterpriseModule = {
|
||||
default?: Component
|
||||
subscriptionStore?: SubscriptionStore
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { computed, ref, watch, type Ref } from 'vue'
|
||||
import { computed, ref, type Ref } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
|
||||
import { faqService } from '@/services/faq'
|
||||
@@ -34,17 +34,13 @@ export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>)
|
||||
let debounceTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let saveInFlight = false
|
||||
let trailingSave: HelpCenterSettingsUpdate | null = null
|
||||
let suppressWatch = false
|
||||
let debouncedPayload: HelpCenterSettingsUpdate | null = null
|
||||
|
||||
const brandColor = computed(() => settings.value?.brand_color || '#4338CA')
|
||||
|
||||
function applyResponse(updated: HelpCenterSettings): void {
|
||||
suppressWatch = true
|
||||
settings.value = updated
|
||||
// Release after the watcher has seen (and ignored) this assignment.
|
||||
setTimeout(() => {
|
||||
suppressWatch = false
|
||||
}, 0)
|
||||
const pending = { ...(trailingSave || {}), ...(debouncedPayload || {}) }
|
||||
settings.value = { ...updated, ...pending }
|
||||
}
|
||||
|
||||
async function save(payload: HelpCenterSettingsUpdate): Promise<void> {
|
||||
@@ -72,26 +68,25 @@ export function useHelpCenterSettings(settings: Ref<HelpCenterSettings | null>)
|
||||
|
||||
/** Debounced path for text inputs (links, CTA, labels). */
|
||||
function queueSave(payload: HelpCenterSettingsUpdate): void {
|
||||
debouncedPayload = { ...(debouncedPayload || {}), ...payload }
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
debounceTimer = setTimeout(() => void save(payload), AUTOSAVE_DEBOUNCE_MS)
|
||||
debounceTimer = setTimeout(() => {
|
||||
debounceTimer = null
|
||||
const queued = debouncedPayload
|
||||
debouncedPayload = null
|
||||
if (queued) void save(queued)
|
||||
}, AUTOSAVE_DEBOUNCE_MS)
|
||||
}
|
||||
|
||||
/** Immediate path for discrete actions (swatch click, toggles, selects). */
|
||||
async function saveNow(payload: HelpCenterSettingsUpdate): Promise<void> {
|
||||
if (debounceTimer) clearTimeout(debounceTimer)
|
||||
await save(payload)
|
||||
debounceTimer = null
|
||||
const combined = { ...(debouncedPayload || {}), ...payload }
|
||||
debouncedPayload = null
|
||||
await save(combined)
|
||||
}
|
||||
|
||||
// Deep-watch branding fields the inputs mutate in place (header_links rows).
|
||||
watch(
|
||||
() => settings.value?.header_links,
|
||||
(links, previous) => {
|
||||
if (suppressWatch || links === undefined || previous === undefined) return
|
||||
queueSave({ header_links: links })
|
||||
},
|
||||
{ deep: true },
|
||||
)
|
||||
|
||||
async function uploadLogo(file: File): Promise<void> {
|
||||
if (file.size > MAX_LOGO_BYTES) {
|
||||
toast.error('Logo 不能超过 2 MB')
|
||||
|
||||
@@ -80,7 +80,7 @@ export function useKnowledgeManagement(agentId: string, organizationId: string)
|
||||
error.value = null
|
||||
|
||||
// Fetch agent knowledge, org knowledge, and queue items in parallel
|
||||
const [agentResponse, orgResponse, queueResponse] = await Promise.all([
|
||||
const [agentResponse, orgResponse] = await Promise.all([
|
||||
knowledgeService.getKnowledgeByAgent(agentId, currentPage.value, pageSize.value),
|
||||
knowledgeService.getKnowledgeByOrganization(
|
||||
organizationId,
|
||||
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import { ref, computed } from 'vue'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { widgetAppService } from '@/services/widget-app'
|
||||
import type { WidgetApp, WidgetAppCreate, WidgetAppUpdate } from '@/types/widget-app'
|
||||
import type { WidgetApp, WidgetAppCreate } from '@/types/widget-app'
|
||||
|
||||
export function useWidgetApps() {
|
||||
const allApps = ref<WidgetApp[]>([])
|
||||
|
||||
@@ -56,6 +56,14 @@ const MAGIC_BYTES: Record<string, { bytes: number[]; offset: number }[]> = {
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xdb], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xee], offset: 0 }
|
||||
],
|
||||
'image/jpg': [
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe0], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe1], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe2], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xe8], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xdb], offset: 0 },
|
||||
{ bytes: [0xff, 0xd8, 0xff, 0xee], offset: 0 }
|
||||
],
|
||||
'image/png': [{ bytes: [0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a], offset: 0 }],
|
||||
'image/gif': [
|
||||
{ bytes: [0x47, 0x49, 0x46, 0x38, 0x37, 0x61], offset: 0 }, // GIF87a
|
||||
@@ -78,9 +86,10 @@ async function validateMagicBytes(file: File): Promise<{ valid: boolean; error?:
|
||||
if (mimeType.includes('openxmlformats') || mimeType === 'application/msword') {
|
||||
const buffer = await file.slice(0, 4).arrayBuffer()
|
||||
const bytes = new Uint8Array(buffer)
|
||||
// PK signature for ZIP-based or OLE for old Office
|
||||
if ((bytes[0] === 0x50 && bytes[1] === 0x4b) ||
|
||||
(bytes[0] === 0xd0 && bytes[1] === 0xcf)) {
|
||||
// OOXML files are ZIP containers; legacy Office files use OLE.
|
||||
const isZip = bytes[0] === 0x50 && bytes[1] === 0x4b
|
||||
const isOle = bytes[0] === 0xd0 && bytes[1] === 0xcf
|
||||
if (mimeType === 'application/msword' ? isOle : isZip) {
|
||||
return { valid: true }
|
||||
}
|
||||
return { valid: false, error: 'File content does not match Office document format' }
|
||||
@@ -350,6 +359,23 @@ export function useWidgetFiles(token: Ref<string | null>, fileInputRef: Ref<HTML
|
||||
}
|
||||
|
||||
const isImage = file.type.startsWith('image/')
|
||||
if (!ALLOWED_FILE_TYPES.has(file.type)) {
|
||||
alert(`文件“${file.name}”的类型不受支持`)
|
||||
continue
|
||||
}
|
||||
|
||||
const extensionValidation = validateExtension(file.name, file.type)
|
||||
if (!extensionValidation.valid) {
|
||||
alert(extensionValidation.error || `文件“${file.name}”的扩展名与类型不匹配`)
|
||||
continue
|
||||
}
|
||||
|
||||
const contentValidation = await validateMagicBytes(file)
|
||||
if (!contentValidation.valid) {
|
||||
alert(contentValidation.error || `文件“${file.name}”的内容类型无效`)
|
||||
continue
|
||||
}
|
||||
|
||||
const maxSize = isImage ? MAX_FILE_SIZE : MAX_DOCUMENT_SIZE
|
||||
|
||||
// Validate file size before upload
|
||||
|
||||
@@ -15,8 +15,9 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref } from 'vue'
|
||||
import { io, Socket } from 'socket.io-client'
|
||||
import type { HumanAgent, SocketError } from '../types/widget'
|
||||
import { io } from 'socket.io-client'
|
||||
import type { Socket } from 'socket.io-client'
|
||||
import type { HumanAgent, SocketError, WidgetFormData, WidgetMessage } from '../types/widget'
|
||||
import { getErrorMessage } from '../types/widget'
|
||||
import { widgetEnv } from '../webclient/widget-env'
|
||||
import { readTokenClaims } from './useConversationToken'
|
||||
@@ -38,7 +39,7 @@ export interface SessionState {
|
||||
const END_CHAT_TIMEOUT_MS = 5000
|
||||
|
||||
export function useWidgetSocket() {
|
||||
const messages = ref<Message[]>([])
|
||||
const messages = ref<WidgetMessage[]>([])
|
||||
const loading = ref(false)
|
||||
const errorMessage = ref('')
|
||||
const showError = ref(false)
|
||||
@@ -48,7 +49,7 @@ export function useWidgetSocket() {
|
||||
const retryCount = ref(0)
|
||||
const MAX_RETRIES = 5
|
||||
const humanAgent = ref<HumanAgent>({})
|
||||
const currentForm = ref<any>(null)
|
||||
const currentForm = ref<WidgetFormData | null>(null)
|
||||
const currentSessionId = ref<string>('')
|
||||
|
||||
// The typing indicator is driven solely by the server's `bot_typing`
|
||||
@@ -168,7 +169,7 @@ export function useWidgetSocket() {
|
||||
return auth
|
||||
}
|
||||
|
||||
const initializeSocket = (sessionId: string) => {
|
||||
const initializeSocket = () => {
|
||||
socket = io(`${widgetEnv.WS_URL}/widget`, {
|
||||
transports: ['websocket'],
|
||||
reconnection: true,
|
||||
@@ -400,7 +401,7 @@ export function useWidgetSocket() {
|
||||
socket = null
|
||||
}
|
||||
|
||||
socket = initializeSocket('')
|
||||
socket = initializeSocket()
|
||||
|
||||
return new Promise((resolve) => {
|
||||
socket?.on('connect', () => {
|
||||
@@ -808,4 +809,4 @@ export function useWidgetSocket() {
|
||||
setToken,
|
||||
setWidgetId
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import { ref, type Ref } from 'vue'
|
||||
import { type Ref } from 'vue'
|
||||
import type { Node, Edge } from '@vue-flow/core'
|
||||
import { toast } from 'vue-sonner'
|
||||
import { workflowNodeService } from '@/services/workflowNode'
|
||||
@@ -566,4 +566,4 @@ export function useWorkflowManagement(options: WorkflowManagementOptions) {
|
||||
mapNodeTypeToFrontend,
|
||||
mapNodeTypeToBackend
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -17,7 +17,7 @@ limitations under the License.
|
||||
import './assets/styles/main.css'
|
||||
import '@/assets/base.css'
|
||||
|
||||
import { createApp } from 'vue'
|
||||
import { createApp, defineComponent } from 'vue'
|
||||
import { createPinia } from 'pinia'
|
||||
import VueApexCharts from 'vue3-apexcharts'
|
||||
import App from './App.vue'
|
||||
@@ -44,10 +44,12 @@ const resolvedTheme = savedTheme === 'system'
|
||||
document.documentElement.setAttribute('data-theme', resolvedTheme)
|
||||
|
||||
const app = createApp(App)
|
||||
const ApexChart = defineComponent({ name: 'ApexChart', extends: VueApexCharts })
|
||||
const apexChartRegistration = 'apexchart'
|
||||
|
||||
app.use(createPinia())
|
||||
app.use(router)
|
||||
app.component('apexchart', VueApexCharts)
|
||||
app.component(apexChartRegistration, ApexChart)
|
||||
app.component('font-awesome-icon', FontAwesomeIcon)
|
||||
app.use(FloatingVue)
|
||||
|
||||
|
||||
@@ -15,12 +15,10 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import { createRouter, createWebHistory } from 'vue-router'
|
||||
import type { RouteLocationNormalized, NavigationGuardNext } from 'vue-router'
|
||||
import { userService } from '@/services/user'
|
||||
import { getSetupStatus } from '@/services/organization'
|
||||
import { canAccessMatchedPaths } from '@/router/routePermissions'
|
||||
import { resolveLandingRoute } from '@/router/landing'
|
||||
import { getApiUrl } from '@/config/api'
|
||||
import HumanAgentView from '@/views/HumanAgentView.vue'
|
||||
import OrganizationSettings from '@/views/settings/OrganizationSettings.vue'
|
||||
import AIConfigSettings from '@/views/settings/AIConfigSettings.vue'
|
||||
|
||||
@@ -15,7 +15,7 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import api from './api'
|
||||
import type { AIConfigResponse, AISetupResponse, AIProvider } from '@/types/ai'
|
||||
import type { AIProvider } from '@/types/ai'
|
||||
|
||||
export interface AIConfig {
|
||||
model_type: string;
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { User, UserGroup } from '@/types/user'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
import api from './api'
|
||||
|
||||
|
||||
@@ -49,4 +49,4 @@ export async function removeUserFromGroup(groupId: string, userId: string): Prom
|
||||
export async function getGroupUsers(groupId: string): Promise<UserGroup> {
|
||||
const response = await api.get(`user-groups/${groupId}`)
|
||||
return response.data
|
||||
}
|
||||
}
|
||||
@@ -16,15 +16,10 @@ limitations under the License.
|
||||
|
||||
import api from './api'
|
||||
import type { OrganizationCreate, OrganizationResponse, OrganizationUpdate } from '@/types/organization'
|
||||
import type { AxiosError } from 'axios'
|
||||
import { userService } from './user'
|
||||
import type { Organization } from '@/types/organization'
|
||||
|
||||
|
||||
interface ErrorResponse {
|
||||
detail: string
|
||||
}
|
||||
|
||||
export const createOrganization = async (data: OrganizationCreate) => {
|
||||
const response = await api.post<OrganizationResponse>('/organizations', data)
|
||||
|
||||
|
||||
@@ -34,7 +34,15 @@ class SocketService {
|
||||
}
|
||||
|
||||
connect(namespace: string = '/agent') {
|
||||
if (this.socket?.connected) return
|
||||
if (this.socket) {
|
||||
// Socket.IO keeps the same instance while its manager reconnects. Reuse
|
||||
// that instance so existing event subscriptions survive the reconnect
|
||||
// window and repeated callers cannot orphan a pending socket.
|
||||
if (!this.socket.connected && !this.socket.active) {
|
||||
this.socket.connect()
|
||||
}
|
||||
return
|
||||
}
|
||||
|
||||
// Use the runtime-resolved WS URL (window.APP_CONFIG → VITE_WS_URL → localhost),
|
||||
// same as every other consumer. Reading import.meta.env directly baked the
|
||||
@@ -76,12 +84,13 @@ class SocketService {
|
||||
}
|
||||
}
|
||||
|
||||
emit(event: string, data: unknown) {
|
||||
emit(event: string, data: unknown, acknowledge?: () => void) {
|
||||
if (!this.socket) {
|
||||
console.error('Socket not initialized')
|
||||
return
|
||||
}
|
||||
this.socket.emit(event, data)
|
||||
if (acknowledge) this.socket.emit(event, data, acknowledge)
|
||||
else this.socket.emit(event, data)
|
||||
}
|
||||
|
||||
emitPing(event: string) {
|
||||
|
||||
@@ -14,7 +14,7 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Role, User } from '@/types/user'
|
||||
import type { User } from '@/types/user'
|
||||
import api from './api'
|
||||
import { uploadProfilePic } from './users'
|
||||
|
||||
|
||||
@@ -84,7 +84,7 @@ export async function getTeamOverview(): Promise<TeamOverview | null> {
|
||||
try {
|
||||
const response = await api.get('users/team-overview')
|
||||
return response.data
|
||||
} catch (err) {
|
||||
} catch {
|
||||
return null
|
||||
}
|
||||
}
|
||||
@@ -134,4 +134,4 @@ export async function uploadProfilePic(formData: FormData): Promise<User> {
|
||||
|
||||
export async function updateUserStatus(id: string, isOnline: boolean): Promise<void> {
|
||||
await api.post(`/users/${id}/status`, { is_online: isOnline })
|
||||
}
|
||||
}
|
||||
@@ -15,6 +15,56 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { ChatStyle, WidgetPosition } from './agent'
|
||||
import type { Message, ShopifyProductImage } from './chat'
|
||||
|
||||
export interface WidgetShopifyProduct {
|
||||
id: string
|
||||
title: string
|
||||
price: string
|
||||
image?: ShopifyProductImage
|
||||
handle?: string
|
||||
variant_title?: string
|
||||
price_formatted?: string
|
||||
currency?: string
|
||||
}
|
||||
|
||||
export interface WidgetMessage extends Message {
|
||||
isSubmitted?: boolean
|
||||
selectedRating?: number
|
||||
finalRating?: number
|
||||
showFeedback?: boolean
|
||||
feedback?: string
|
||||
finalFeedback?: string
|
||||
userInputValue?: string
|
||||
submittedValue?: string | null
|
||||
shopify_output?: {
|
||||
products: WidgetShopifyProduct[]
|
||||
shop_domain?: string
|
||||
}
|
||||
}
|
||||
|
||||
export interface WidgetFormField {
|
||||
name: string
|
||||
label?: string
|
||||
type?: string
|
||||
placeholder?: string
|
||||
required?: boolean
|
||||
disabled?: boolean
|
||||
minLength?: number | null
|
||||
maxLength?: number | null
|
||||
min?: number | string
|
||||
max?: number | string
|
||||
options?: string[] | string
|
||||
}
|
||||
|
||||
export interface WidgetFormData {
|
||||
form_type?: string
|
||||
form_full_screen?: boolean
|
||||
title?: string
|
||||
description?: string
|
||||
submit_button_text?: string
|
||||
fields?: WidgetFormField[]
|
||||
}
|
||||
|
||||
export interface Widget {
|
||||
id: string
|
||||
|
||||
Vendored
+7
-6
@@ -15,16 +15,17 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
interface Window {
|
||||
process?: { env?: { NODE_ENV?: string } }
|
||||
__INITIAL_DATA__?: {
|
||||
widgetId: string;
|
||||
agentName: string;
|
||||
customization: Record<string, any>;
|
||||
customerId: string;
|
||||
customer: Record<string, any>;
|
||||
widgetId?: string;
|
||||
agentName?: string;
|
||||
customization?: Record<string, unknown>;
|
||||
customerId?: string;
|
||||
customer?: Record<string, unknown>;
|
||||
initialToken?: string;
|
||||
workflow?: boolean;
|
||||
allowAttachments?: boolean;
|
||||
/** How soon the header may claim someone replies. */
|
||||
presence?: import('../utils/widgetPresence').WidgetPresence;
|
||||
};
|
||||
}
|
||||
}
|
||||
@@ -15,7 +15,6 @@ limitations under the License.
|
||||
*/
|
||||
|
||||
import type { Agent } from '@/types/agent'
|
||||
import type { UserGroup } from '@/types/user'
|
||||
|
||||
// Define interfaces locally to avoid direct imports from enterprise module
|
||||
// These will be used as fallbacks when enterprise module isn't available
|
||||
@@ -382,7 +381,7 @@ export function useSubscriptionStorage() {
|
||||
return {
|
||||
...subscriptionStorage,
|
||||
}
|
||||
} catch (error) {
|
||||
} catch {
|
||||
console.warn('Enterprise module not available, using basic subscription storage')
|
||||
return {
|
||||
...subscriptionStorage,
|
||||
|
||||
@@ -0,0 +1,56 @@
|
||||
import { isValidEmail, type WidgetFormField } from '../types/widget'
|
||||
|
||||
/** Validate a widget form field with the same value normalization and bounds
|
||||
* used by backend/app/api/widget_chat.py before accepting a form submission. */
|
||||
export function validateWidgetFormField(field: WidgetFormField, value: unknown): string | null {
|
||||
const label = field.label || field.name
|
||||
const valueString = value === null || value === undefined ? '' : String(value).trim()
|
||||
|
||||
if (field.required && valueString === '') {
|
||||
return `${label} 为必填项`
|
||||
}
|
||||
if (valueString === '') return null
|
||||
|
||||
if (field.type === 'email' && !isValidEmail(valueString)) {
|
||||
return '请输入有效的邮箱地址'
|
||||
}
|
||||
|
||||
if (field.type === 'tel') {
|
||||
const digits = valueString.replace(/\D/g, '')
|
||||
if (digits.length < 7 || digits.length > 15) return '请输入有效的电话号码'
|
||||
}
|
||||
|
||||
if (field.type === 'text' || field.type === 'textarea') {
|
||||
const valueLength = [...valueString].length
|
||||
if (field.minLength != null && valueLength < field.minLength) {
|
||||
return `${label} 至少需要 ${field.minLength} 个字符`
|
||||
}
|
||||
// Match the server's text-field contract: maxLength=0 disables the bound.
|
||||
if (field.maxLength && valueLength > field.maxLength) {
|
||||
return `${label} 不能超过 ${field.maxLength} 个字符`
|
||||
}
|
||||
}
|
||||
|
||||
if (field.type === 'number') {
|
||||
const numberPattern = /^[+-]?(?:(?:\d(?:_?\d)*(?:\.(?:\d(?:_?\d)*)?)?|\.\d(?:_?\d)*)(?:e[+-]?\d(?:_?\d)*)?|inf(?:inity)?|nan)$/i
|
||||
if (!numberPattern.test(valueString)) return `${label} 必须是有效数字`
|
||||
|
||||
const specialNumber = /^[+-]?(?:inf(?:inity)?|nan)$/i.test(valueString)
|
||||
const lowerValue = valueString.toLowerCase()
|
||||
const numberValue = specialNumber
|
||||
? lowerValue.endsWith('nan')
|
||||
? Number.NaN
|
||||
: valueString.startsWith('-') ? Number.NEGATIVE_INFINITY : Number.POSITIVE_INFINITY
|
||||
: Number(valueString.replace(/_/g, ''))
|
||||
const minimum = field.minLength === undefined ? 0 : field.minLength
|
||||
const maximum = field.maxLength === undefined ? null : field.maxLength
|
||||
if (minimum !== null && numberValue < minimum) {
|
||||
return `${label} 不能小于 ${minimum}`
|
||||
}
|
||||
if (maximum !== null && numberValue > maximum) {
|
||||
return `${label} 不能大于 ${maximum}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -18,6 +18,8 @@ limitations under the License.
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import { resolveLandingRoute } from '@/router/landing'
|
||||
|
||||
defineOptions({ name: 'ForbiddenView' })
|
||||
|
||||
// Not '/': that redirects to the landing route, which for a user who was just
|
||||
// denied is often another denial. Resolved once — the component is created
|
||||
// fresh per visit, and permissions do not change mid-render.
|
||||
@@ -48,4 +50,4 @@ const landingRoute = resolveLandingRoute()
|
||||
color: var(--primary-color);
|
||||
text-decoration: none;
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -157,7 +157,55 @@ const tabCount = computed(() =>
|
||||
: `${loadedCount.value}${hasMore.value ? '+' : ''}`
|
||||
)
|
||||
|
||||
let listVersion = 0
|
||||
let liveRefreshInFlight = false
|
||||
let liveRefreshPending = false
|
||||
|
||||
const conversationParams = (skip: number, limit: number) => {
|
||||
const params: any = {
|
||||
skip,
|
||||
limit,
|
||||
status: statusFilter.value === 'open' ? 'open,transferred' : statusFilter.value
|
||||
}
|
||||
if (filterValues.value.customerEmailFilter.trim()) params.customer_email = filterValues.value.customerEmailFilter.trim()
|
||||
if (filterValues.value.agentFilter.trim()) params.agent_id = filterValues.value.agentFilter.trim()
|
||||
if (filterValues.value.userFilter.trim()) params.user_id = filterValues.value.userFilter.trim()
|
||||
if (filterValues.value.dateFromFilter) params.date_from = new Date(filterValues.value.dateFromFilter).toISOString()
|
||||
if (filterValues.value.dateToFilter) params.date_to = new Date(filterValues.value.dateToFilter).toISOString()
|
||||
return params
|
||||
}
|
||||
|
||||
const refreshLiveConversations = async () => {
|
||||
if (liveRefreshInFlight || loading.value) {
|
||||
liveRefreshPending = true
|
||||
return
|
||||
}
|
||||
liveRefreshInFlight = true
|
||||
liveRefreshPending = false
|
||||
const version = listVersion
|
||||
// Keep the query bounded even if the operator has loaded many pages.
|
||||
const limit = Math.min(100, Math.max(pageSize.value, conversations.value.length))
|
||||
try {
|
||||
const recent = await chatService.getRecentChats(conversationParams(0, limit))
|
||||
if (version !== listVersion) return
|
||||
if (conversations.value.length <= 100) {
|
||||
conversations.value = recent
|
||||
hasMore.value = recent.length === limit
|
||||
totalCount.value = recent.length < limit ? recent.length : null
|
||||
} else {
|
||||
const recentIds = new Set(recent.map(item => item.session_id))
|
||||
conversations.value = [...recent, ...conversations.value.filter(item => !recentIds.has(item.session_id))]
|
||||
}
|
||||
} catch (err) {
|
||||
console.error('Failed to refresh conversations:', err)
|
||||
} finally {
|
||||
liveRefreshInFlight = false
|
||||
if (liveRefreshPending && !loading.value) void refreshLiveConversations()
|
||||
}
|
||||
}
|
||||
|
||||
const loadConversations = async (page = 1, loadMore = false) => {
|
||||
const version = ++listVersion
|
||||
error.value = ''
|
||||
|
||||
if (page === 1 || !loadMore) {
|
||||
@@ -169,30 +217,8 @@ const loadConversations = async (page = 1, loadMore = false) => {
|
||||
let newConversations: Conversation[] = []
|
||||
|
||||
try {
|
||||
const params: any = {
|
||||
skip,
|
||||
limit: pageSize.value,
|
||||
status: statusFilter.value === 'open' ? 'open,transferred' : statusFilter.value
|
||||
}
|
||||
|
||||
// Add filters if they have values
|
||||
if (filterValues.value.customerEmailFilter.trim()) {
|
||||
params.customer_email = filterValues.value.customerEmailFilter.trim()
|
||||
}
|
||||
if (filterValues.value.agentFilter.trim()) {
|
||||
params.agent_id = filterValues.value.agentFilter.trim()
|
||||
}
|
||||
if (filterValues.value.userFilter.trim()) {
|
||||
params.user_id = filterValues.value.userFilter.trim()
|
||||
}
|
||||
if (filterValues.value.dateFromFilter) {
|
||||
params.date_from = new Date(filterValues.value.dateFromFilter).toISOString()
|
||||
}
|
||||
if (filterValues.value.dateToFilter) {
|
||||
params.date_to = new Date(filterValues.value.dateToFilter).toISOString()
|
||||
}
|
||||
|
||||
newConversations = await chatService.getRecentChats(params)
|
||||
newConversations = await chatService.getRecentChats(conversationParams(skip, pageSize.value))
|
||||
if (version !== listVersion) return
|
||||
|
||||
// If we're loading more, append to existing conversations
|
||||
if (loadMore && page > 1) {
|
||||
@@ -214,7 +240,10 @@ const loadConversations = async (page = 1, loadMore = false) => {
|
||||
error.value = '加载会话失败'
|
||||
console.error(err)
|
||||
} finally {
|
||||
loading.value = false
|
||||
if (version === listVersion) {
|
||||
loading.value = false
|
||||
if (liveRefreshPending) void refreshLiveConversations()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -489,6 +518,8 @@ const handleChatClosed = () => {
|
||||
:initial-session-id="initialSessionId"
|
||||
:mobile-pane="mobileChatOpen ? 'chat' : 'list'"
|
||||
@refresh="loadConversations(1)"
|
||||
@live-refresh="refreshLiveConversations"
|
||||
@chat-access-lost="selectedChatInfo = null"
|
||||
@update-filter="updateFilter"
|
||||
@load-more="loadMoreConversations"
|
||||
@chat-updated="handleChatUpdated"
|
||||
|
||||
@@ -264,7 +264,7 @@ const closeForgotPasswordModal = () => {
|
||||
}
|
||||
|
||||
const handleRequestPasswordReset = async () => {
|
||||
const success = await requestPasswordReset()
|
||||
await requestPasswordReset()
|
||||
// Keep modal open to proceed to step 2
|
||||
}
|
||||
|
||||
|
||||
@@ -134,7 +134,7 @@ function shortId(p: PersonListItem): string {
|
||||
return `#${String(p.id).slice(0, 8)}`
|
||||
}
|
||||
|
||||
function onPersonUpdated(updatedStage?: string) {
|
||||
function onPersonUpdated() {
|
||||
// A person changed (e.g. marked customer) — refresh list + stats.
|
||||
loadList(); loadStats()
|
||||
}
|
||||
|
||||
@@ -21,7 +21,6 @@ import type { OrganizationCreate } from '@/types/organization'
|
||||
import { createOrganization, getSetupStatus } from '@/services/organization'
|
||||
import { userService } from '@/services/user'
|
||||
import { validatePassword, validateDomain, validateEmail, validateName, validateOrgName, type PasswordStrength } from '@/utils/validators'
|
||||
// @ts-ignore
|
||||
import { defaultTimezone, timezoneOptions } from '@/utils/timezones'
|
||||
import type { BusinessHoursDict } from '@/types/organization'
|
||||
|
||||
@@ -670,4 +669,4 @@ input:checked + .toggle-slider:before {
|
||||
justify-content: space-between;
|
||||
}
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -29,6 +29,7 @@ import TicketFilterBar from '@/components/tickets/TicketFilterBar.vue'
|
||||
import TicketEmptyState from '@/components/tickets/TicketEmptyState.vue'
|
||||
import TicketCreateModal from '@/components/tickets/TicketCreateModal.vue'
|
||||
import { permissionChecks } from '@/utils/permissions'
|
||||
import type { TicketListFilters } from '@/types/ticket'
|
||||
|
||||
const router = useRouter()
|
||||
const {
|
||||
@@ -89,6 +90,10 @@ const timeAgo = (iso?: string | null) =>
|
||||
function openTicket(id: string) {
|
||||
router.push(`/tickets/${id}`)
|
||||
}
|
||||
|
||||
function updateFilters(patch: Partial<TicketListFilters>) {
|
||||
Object.assign(filters, patch)
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
@@ -124,7 +129,7 @@ function openTicket(id: string) {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<TicketFilterBar :filters="filters" />
|
||||
<TicketFilterBar :filters="filters" @update:filters="updateFilters" />
|
||||
|
||||
<div class="ticket-table">
|
||||
<div class="table-scroll" tabindex="0" aria-label="工单列表">
|
||||
|
||||
@@ -15,12 +15,12 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, onMounted, computed, watch } from 'vue'
|
||||
import { ref, onMounted, computed } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
import { toast } from 'vue-sonner'
|
||||
import DashboardLayout from '@/layouts/DashboardLayout.vue'
|
||||
import { checkJiraConnection, getJiraAuthUrl, disconnectJira } from '@/services/jira'
|
||||
import { checkShopifyConnection, getShopifyShops } from '@/services/shopify'
|
||||
import { checkShopifyConnection } from '@/services/shopify'
|
||||
import channelsService, { type ChannelAccount } from '@/services/channels'
|
||||
import { accountToManage, reconnectWarning } from '@/utils/channelAccounts'
|
||||
import crmService, { type CrmConnection, type CrmProvider } from '@/services/crm'
|
||||
@@ -45,14 +45,6 @@ import lineLogo from '@/assets/line-logo.svg'
|
||||
import hubspotLogo from '@/assets/hubspot-logo.svg'
|
||||
import pipedriveLogo from '@/assets/pipedrive-logo.svg'
|
||||
|
||||
// Define interface for Shopify shop
|
||||
interface ShopifyShop {
|
||||
id: string
|
||||
shop_domain: string
|
||||
is_installed: boolean
|
||||
[key: string]: any
|
||||
}
|
||||
|
||||
// Shopify state variables
|
||||
const shopifyConnected = ref(false)
|
||||
const shopifyShopDomain = ref('')
|
||||
@@ -1612,4 +1604,4 @@ onMounted(async () => {
|
||||
color: var(--error-color);
|
||||
margin-top: var(--space-xs);
|
||||
}
|
||||
</style>
|
||||
</style>
|
||||
@@ -15,16 +15,17 @@ limitations under the License.
|
||||
-->
|
||||
|
||||
<script setup lang="ts">
|
||||
// @ts-nocheck
|
||||
import { ref, onMounted, computed, onUnmounted, watch, nextTick } from 'vue'
|
||||
import {
|
||||
isValidEmail} from '../types/widget'
|
||||
import type { WidgetFormData, WidgetMessage, WidgetShopifyProduct } from '../types/widget'
|
||||
import { renderMarkdown } from './markdown'
|
||||
import BrandMark from '../components/common/BrandMark.vue'
|
||||
import AskAiPanel from './AskAiPanel.vue'
|
||||
import NewChatConfirm from './NewChatConfirm.vue'
|
||||
import { resolveOrbStyle } from '../utils/orb'
|
||||
import { isEndChatMessage } from '../utils/endChat'
|
||||
import { validateWidgetFormField as validateFormField } from '../utils/widgetFormValidation'
|
||||
import { AI_DISCLAIMER_TEXT, shouldShowAiDisclaimer } from '../utils/aiDisclaimer'
|
||||
import { presenceLine } from '../utils/widgetPresence'
|
||||
import { widgetEnv, resolveWidgetUploadUrl } from './widget-env'
|
||||
@@ -44,7 +45,6 @@ import {
|
||||
import { themeCssVars } from './widget-theme'
|
||||
import './widget-surface.css'
|
||||
import { useCurrency } from '../composables/useCurrency'
|
||||
import { formatDistanceToNow } from 'date-fns'
|
||||
// Markdown rendering + sanitisation live in ./markdown, shared with the Ask AI
|
||||
// palette so the two surfaces can't drift apart on formatting or escaping.
|
||||
|
||||
@@ -75,7 +75,6 @@ const {
|
||||
hasStartedChat,
|
||||
connectionStatus,
|
||||
sendMessage: socketSendMessage,
|
||||
sendFileAttachments,
|
||||
endChat: socketEndChat,
|
||||
loadChatHistory,
|
||||
connect,
|
||||
@@ -119,6 +118,8 @@ const handleInputSync = (event: Event) => {
|
||||
|
||||
// MutationObserver to detect DOM changes and re-setup listeners
|
||||
let domObserver: MutationObserver | null = null
|
||||
let domObserverTimer: ReturnType<typeof setTimeout> | null = null
|
||||
const isElementNode = (node: Node) => node.nodeType === 1 // Node.ELEMENT_NODE, without a runtime global lookup.
|
||||
|
||||
const setupDOMObserver = () => {
|
||||
if (domObserver) {
|
||||
@@ -133,13 +134,13 @@ const setupDOMObserver = () => {
|
||||
// Check if input fields were added/removed
|
||||
if (mutation.type === 'childList') {
|
||||
const addedInputs = Array.from(mutation.addedNodes).some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
isElementNode(node) &&
|
||||
((node as Element).matches('input, textarea') ||
|
||||
(node as Element).querySelector?.('input, textarea'))
|
||||
)
|
||||
|
||||
const removedInputs = Array.from(mutation.removedNodes).some(node =>
|
||||
node.nodeType === Node.ELEMENT_NODE &&
|
||||
isElementNode(node) &&
|
||||
((node as Element).matches('input, textarea') ||
|
||||
(node as Element).querySelector?.('input, textarea'))
|
||||
)
|
||||
@@ -157,8 +158,9 @@ const setupDOMObserver = () => {
|
||||
|
||||
if (shouldResetup) {
|
||||
// Debounce to avoid excessive calls
|
||||
clearTimeout(setupDOMObserver.timeoutId)
|
||||
setupDOMObserver.timeoutId = setTimeout(() => {
|
||||
if (domObserverTimer) clearTimeout(domObserverTimer)
|
||||
domObserverTimer = setTimeout(() => {
|
||||
domObserverTimer = null
|
||||
setupNativeEventListeners()
|
||||
}, hasNewInputFields ? 50 : 100) // Faster setup for new inputs
|
||||
}
|
||||
@@ -172,9 +174,6 @@ const setupDOMObserver = () => {
|
||||
})
|
||||
}
|
||||
|
||||
// Add timeout ID property to the function for debouncing
|
||||
setupDOMObserver.timeoutId = null
|
||||
|
||||
// Keep track of current input fields for cleanup
|
||||
let currentInputFields: HTMLElement[] = []
|
||||
|
||||
@@ -223,9 +222,9 @@ const setupNativeEventListeners = () => {
|
||||
'input'
|
||||
]
|
||||
|
||||
let inputFields = []
|
||||
let inputFields: HTMLElement[] = []
|
||||
for (const selector of selectors) {
|
||||
const fields = document.querySelectorAll(selector)
|
||||
const fields = document.querySelectorAll<HTMLElement>(selector)
|
||||
if (fields.length > 0) {
|
||||
inputFields = Array.from(fields)
|
||||
break
|
||||
@@ -296,9 +295,8 @@ const handleNativeKeyDown = (event: KeyboardEvent) => {
|
||||
// Close header dropdown menu when clicking outside
|
||||
const closeHeaderMenu = (event: Event) => {
|
||||
const target = event.target as HTMLElement
|
||||
const headerMenuContainer = document.querySelector('.header-menu-container')
|
||||
const headerMenuBtn = document.querySelector('.header-menu-btn')
|
||||
const headerDropdownMenu = document.querySelector('.header-dropdown-menu')
|
||||
const headerMenuContainer = document.querySelector<HTMLElement>('.header-menu-container')
|
||||
const headerDropdownMenu = document.querySelector<HTMLElement>('.header-dropdown-menu')
|
||||
|
||||
// If click is outside the header menu container, close the dropdown
|
||||
if (headerDropdownMenu && !headerMenuContainer?.contains(target)) {
|
||||
@@ -312,7 +310,7 @@ const isInitializing = ref(true)
|
||||
// The conversation token, kept alive by useConversationToken: it rotates before the
|
||||
// token lapses so a page left open for hours doesn't drop an identified visitor.
|
||||
const {
|
||||
token,
|
||||
token: conversationToken,
|
||||
start: startTokenLifecycle,
|
||||
stop: stopTokenLifecycle,
|
||||
setToken: adoptToken,
|
||||
@@ -329,12 +327,12 @@ const {
|
||||
handleIdentityExpired()
|
||||
}
|
||||
})
|
||||
const hasToken = computed(() => !!token.value)
|
||||
|
||||
// Authentication error state
|
||||
const authError = ref<string | null>(null)
|
||||
const showAuthError = ref(false)
|
||||
const isApiKeyAuthRequired = ref(false) // Specific error for missing API key configuration
|
||||
const reloadWidget = () => window.location.reload()
|
||||
|
||||
// Check if there's an initial auth error from widget.ts
|
||||
if (props.initialAuthError) {
|
||||
@@ -348,7 +346,7 @@ initializeFromData()
|
||||
const initialData = window.__INITIAL_DATA__
|
||||
|
||||
startTokenLifecycle(initialData?.widgetId || '', initialData?.initialToken)
|
||||
if (token.value) {
|
||||
if (conversationToken.value) {
|
||||
hasConversationToken.value = true
|
||||
}
|
||||
|
||||
@@ -390,21 +388,12 @@ const {
|
||||
handleDragOver,
|
||||
handleDragLeave,
|
||||
handlePaste,
|
||||
uploadFiles,
|
||||
removeAttachment,
|
||||
openPreview,
|
||||
closePreview,
|
||||
openFilePicker,
|
||||
isImage
|
||||
} = useWidgetFiles(token, fileInputRef)
|
||||
|
||||
// Check if there's an active form being displayed
|
||||
const hasActiveForm = computed(() => {
|
||||
return messages.value.some(message =>
|
||||
message.message_type === 'form' &&
|
||||
(!message.isSubmitted || message.isSubmitted === false)
|
||||
)
|
||||
})
|
||||
} = useWidgetFiles(conversationToken, fileInputRef)
|
||||
|
||||
// Update the computed property for message input enabled state
|
||||
const isMessageInputEnabled = computed(() => {
|
||||
@@ -423,7 +412,7 @@ const isMessageInputEnabled = computed(() => {
|
||||
|
||||
|
||||
return (isValidEmail(emailInput.value.trim()) &&
|
||||
connectionStatus.value === 'connected' && !loading.value) || window.__INITIAL_DATA__?.workflow
|
||||
connectionStatus.value === 'connected' && !loading.value) || !!window.__INITIAL_DATA__?.workflow
|
||||
})
|
||||
|
||||
// Whether the field accepts typing. Same conditions as sending, except that a
|
||||
@@ -547,13 +536,13 @@ const checkAuthorization = async ({ afterRefusal = false } = {}): Promise<boolea
|
||||
url.searchParams.append('email', emailInput.value.trim())
|
||||
}
|
||||
|
||||
const headers = {
|
||||
const headers: Record<string, string> = {
|
||||
'Accept': 'application/json',
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
|
||||
if (token.value) {
|
||||
headers['Authorization'] = `Bearer ${token.value}`
|
||||
if (conversationToken.value) {
|
||||
headers['Authorization'] = `Bearer ${conversationToken.value}`
|
||||
}
|
||||
|
||||
// Never a stored copy: this call is what binds the visitor to a customer,
|
||||
@@ -623,7 +612,7 @@ const checkAuthorization = async ({ afterRefusal = false } = {}): Promise<boolea
|
||||
showAuthError.value = false
|
||||
|
||||
// 🔐 SECURITY: Pass token to WebSocket before connecting
|
||||
setToken(token.value || undefined)
|
||||
setToken(conversationToken.value || undefined)
|
||||
|
||||
// Connect the socket. A failure here is not fatal any more: the socket
|
||||
// keeps retrying in the background and the header shows the connection
|
||||
@@ -658,8 +647,10 @@ const checkAuthorization = async ({ afterRefusal = false } = {}): Promise<boolea
|
||||
|
||||
// Update workflow status in initial data if received from backend
|
||||
if (data.agent?.workflow !== undefined) {
|
||||
window.__INITIAL_DATA__ = window.__INITIAL_DATA__ || {}
|
||||
window.__INITIAL_DATA__.workflow = data.agent.workflow
|
||||
window.__INITIAL_DATA__ = {
|
||||
...window.__INITIAL_DATA__,
|
||||
workflow: data.agent.workflow
|
||||
}
|
||||
}
|
||||
|
||||
// Get workflow state after successful connection
|
||||
@@ -695,7 +686,7 @@ const scrollToBottom = () => {
|
||||
}
|
||||
|
||||
// Add watch effect for messages
|
||||
watch(() => messages.value, (newMessages) => {
|
||||
watch(() => messages.value, () => {
|
||||
// Scroll to bottom when new messages arrive
|
||||
nextTick(() => {
|
||||
scrollToBottom()
|
||||
@@ -759,16 +750,11 @@ const workflowButtonText = ref('开始聊天')
|
||||
|
||||
// Form full screen handling refs
|
||||
const showFullScreenForm = ref(false)
|
||||
const fullScreenFormData = ref<any>(null)
|
||||
const fullScreenFormData = ref<WidgetFormData | null>(null)
|
||||
|
||||
|
||||
|
||||
// Add this after other computed properties
|
||||
const ratingEnabled = computed(() => {
|
||||
const lastMessage = messages.value[messages.value.length - 1]
|
||||
return lastMessage?.attributes?.request_rating || false
|
||||
})
|
||||
|
||||
// Check if we should show "start new conversation" instead of chat input
|
||||
const shouldShowNewConversationOption = computed(() => {
|
||||
// Only in workflow mode
|
||||
@@ -789,14 +775,14 @@ const humanAgentPhotoUrl = computed(() =>
|
||||
)
|
||||
|
||||
// Add this after other methods
|
||||
const handleEndChat = async (message) => {
|
||||
const handleEndChat = async (message: WidgetMessage) => {
|
||||
// Defence in depth: this closes the session server-side, so it must never
|
||||
// run for a message that is not an end-chat, whoever calls it.
|
||||
if (!isEndChatMessage(message)) return
|
||||
|
||||
// Call backend API to acknowledge end_chat and close the session
|
||||
try {
|
||||
if (message.session_id && token.value && widgetId.value) {
|
||||
if (message.session_id && conversationToken.value && widgetId.value) {
|
||||
const url = new URL(`${widgetEnv.API_URL}/widgets/${widgetId.value}/end-chat`)
|
||||
url.searchParams.append('session_id', message.session_id)
|
||||
if (message.attributes?.end_chat_reason) {
|
||||
@@ -809,7 +795,7 @@ const handleEndChat = async (message) => {
|
||||
const response = await fetch(url, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
'Authorization': `Bearer ${token.value}`,
|
||||
'Authorization': `Bearer ${conversationToken.value}`,
|
||||
'Content-Type': 'application/json'
|
||||
}
|
||||
})
|
||||
@@ -834,11 +820,11 @@ const handleEndChat = async (message) => {
|
||||
message: `请评价你与 ${displayAgentName} 的本次聊天`,
|
||||
message_type: 'rating',
|
||||
created_at: new Date().toISOString(),
|
||||
session_id: message.session_id,
|
||||
session_id: message.session_id || currentSessionId.value,
|
||||
agent_name: displayAgentName,
|
||||
showFeedback: false
|
||||
})
|
||||
currentSessionId.value = message.session_id
|
||||
currentSessionId.value = message.session_id || currentSessionId.value
|
||||
}
|
||||
}
|
||||
|
||||
@@ -867,7 +853,12 @@ const handleStarClick = async (rating: number) => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleSubmitRating = async (sessionId: string, rating: number, feedback: string | null = null) => {
|
||||
const displayedRating = (message: WidgetMessage): number =>
|
||||
message.isSubmitted
|
||||
? message.finalRating ?? 0
|
||||
: hoverRating.value || message.selectedRating || 0
|
||||
|
||||
const submitRating = async (rating: number, feedback?: string) => {
|
||||
try {
|
||||
isSubmittingRating.value = true
|
||||
await socketSubmitRating(rating, feedback)
|
||||
@@ -886,33 +877,6 @@ const handleSubmitRating = async (sessionId: string, rating: number, feedback: s
|
||||
}
|
||||
}
|
||||
|
||||
const handleAddToCart = (message) => {
|
||||
const productData = message.shopify_output || {
|
||||
id: message.product_id,
|
||||
title: message.product_title,
|
||||
price: message.product_price,
|
||||
image: message.product_image,
|
||||
vendor: message.product_vendor
|
||||
};
|
||||
|
||||
if (productData) {
|
||||
// Send a message to the parent window (the main shop)
|
||||
postToHost({
|
||||
type: 'ADD_TO_CART',
|
||||
product: productData
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
const handleAddToCartFromCarousel = (product) => {
|
||||
if (product) {
|
||||
postToHost({
|
||||
type: 'ADD_TO_CART',
|
||||
product: product
|
||||
});
|
||||
}
|
||||
};
|
||||
|
||||
// Form validation function
|
||||
const validateForm = (formConfig: any): boolean => {
|
||||
const errors: Record<string, string> = {}
|
||||
@@ -956,7 +920,7 @@ const handleFormSubmit = async (formConfig: any) => {
|
||||
// Remove the form message from messages array
|
||||
const formIndex = messages.value.findIndex(msg =>
|
||||
msg.message_type === 'form' &&
|
||||
(!msg.isSubmitted || msg.isSubmitted === false)
|
||||
!msg.isSubmitted
|
||||
)
|
||||
if (formIndex !== -1) {
|
||||
messages.value.splice(formIndex, 1)
|
||||
@@ -1013,62 +977,6 @@ const handleFieldChange = (fieldName: string, value: any) => {
|
||||
}
|
||||
}
|
||||
|
||||
// Phone number validation function
|
||||
const isValidPhoneNumber = (phone: string): boolean => {
|
||||
// Remove all non-digit characters
|
||||
const cleanPhone = phone.replace(/\D/g, '')
|
||||
// Check if it's between 7 and 15 digits (international standard)
|
||||
return cleanPhone.length >= 7 && cleanPhone.length <= 15
|
||||
}
|
||||
|
||||
// Enhanced form validation function
|
||||
const validateFormField = (field: any, value: any): string | null => {
|
||||
// Required field validation
|
||||
if (field.required && (!value || value.toString().trim() === '')) {
|
||||
return `${field.label} 为必填项`
|
||||
}
|
||||
|
||||
// Skip further validation if field is empty and not required
|
||||
if (!value || value.toString().trim() === '') {
|
||||
return null
|
||||
}
|
||||
|
||||
// Email validation
|
||||
if (field.type === 'email' && !isValidEmail(value)) {
|
||||
return `请输入有效的邮箱地址`
|
||||
}
|
||||
|
||||
// Phone number validation
|
||||
if (field.type === 'tel' && !isValidPhoneNumber(value)) {
|
||||
return `请输入有效的电话号码`
|
||||
}
|
||||
|
||||
// Length validation for text fields
|
||||
if ((field.type === 'text' || field.type === 'textarea') && field.minLength && value.length < field.minLength) {
|
||||
return `${field.label} 至少需要 ${field.minLength} 个字符`
|
||||
}
|
||||
|
||||
if ((field.type === 'text' || field.type === 'textarea') && field.maxLength && value.length > field.maxLength) {
|
||||
return `${field.label} 不能超过 ${field.maxLength} 个字符`
|
||||
}
|
||||
|
||||
// Number validation
|
||||
if (field.type === 'number') {
|
||||
const numValue = parseFloat(value)
|
||||
if (isNaN(numValue)) {
|
||||
return `${field.label} 必须是有效数字`
|
||||
}
|
||||
if (field.minLength && numValue < field.minLength) {
|
||||
return `${field.label} 不能小于 ${field.minLength}`
|
||||
}
|
||||
if (field.maxLength && numValue > field.maxLength) {
|
||||
return `${field.label} 不能大于 ${field.maxLength}`
|
||||
}
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
|
||||
// Handle full screen form submission
|
||||
const submitFullScreenForm = async () => {
|
||||
|
||||
@@ -1117,7 +1025,7 @@ const submitFullScreenForm = async () => {
|
||||
}
|
||||
}
|
||||
|
||||
const handleViewDetails = (product, shopDomain) => {
|
||||
const handleViewDetails = (product: WidgetShopifyProduct, shopDomain?: string) => {
|
||||
console.log('handleViewDetails called with:', { product, shopDomain });
|
||||
|
||||
if (!product) {
|
||||
@@ -1152,7 +1060,7 @@ const handleViewDetails = (product, shopDomain) => {
|
||||
};
|
||||
|
||||
// Add this function in the script section after the other helper functions
|
||||
const removeUrls = (text) => {
|
||||
const removeUrls = (text: string): string => {
|
||||
if (!text) return '';
|
||||
|
||||
|
||||
@@ -1161,8 +1069,8 @@ const removeUrls = (text) => {
|
||||
let processedText = text.replace(/!\[([^\]]*)\]\(([^)]+)\)/g, '');
|
||||
|
||||
// Then, temporarily replace regular markdown links with placeholders to preserve them
|
||||
const markdownLinks = [];
|
||||
processedText = processedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, linkText, url) => {
|
||||
const markdownLinks: string[] = [];
|
||||
processedText = processedText.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match: string) => {
|
||||
const placeholder = `__MARKDOWN_LINK_${markdownLinks.length}__`;
|
||||
console.log('Found markdown link:', match, '-> placeholder:', placeholder);
|
||||
markdownLinks.push(match);
|
||||
@@ -1191,10 +1099,13 @@ const removeUrls = (text) => {
|
||||
return processedText;
|
||||
}
|
||||
|
||||
const formOptions = (options?: string | string[]): string[] =>
|
||||
(Array.isArray(options) ? options : (options || '').split('\n'))
|
||||
.filter(option => option.trim())
|
||||
|
||||
|
||||
// File upload functionality (remaining local state)
|
||||
const isUploading = ref(false)
|
||||
const dragOver = ref(false)
|
||||
|
||||
const maxFiles = 3
|
||||
const acceptTypes = 'image/*,.pdf,.doc,.docx,.txt,.csv,.xlsx,.xls'
|
||||
@@ -1266,7 +1177,7 @@ const handleIdentityExpired = () => {
|
||||
|
||||
/** Drop an identity nobody can renew and carry on as an anonymous visitor. */
|
||||
const continueAnonymously = async () => {
|
||||
if (!token.value) return
|
||||
if (!conversationToken.value) return
|
||||
adoptToken(null)
|
||||
await initializeWidget()
|
||||
}
|
||||
@@ -1274,7 +1185,7 @@ const continueAnonymously = async () => {
|
||||
/** Adopt a token the host page supplied, then re-authorize without a rebuild. */
|
||||
const adoptHostToken = async (hostToken: unknown) => {
|
||||
const next = sanitizeToken(hostToken)
|
||||
if (!next || next === token.value) return
|
||||
if (!next || next === conversationToken.value) return
|
||||
adoptToken(next)
|
||||
await initializeWidget()
|
||||
}
|
||||
@@ -1323,7 +1234,7 @@ const initializeWidget = async () => {
|
||||
// which would race that gap and be dropped.
|
||||
// (The handler bodies run on later macrotasks, so refs declared further down —
|
||||
// parentDisplay — are initialized by the time they're touched.)
|
||||
window.addEventListener('message', (event) => {
|
||||
const handleHostMessage = (event: MessageEvent) => {
|
||||
// Only the embedding page (the loader, or the dashboard preview) may drive the
|
||||
// widget; hostile sibling frames can reach this window via top.frames[i].
|
||||
if (event.source !== window.parent) return
|
||||
@@ -1369,7 +1280,9 @@ window.addEventListener('message', (event) => {
|
||||
input?.focus()
|
||||
})
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
window.addEventListener('message', handleHostMessage)
|
||||
|
||||
// Setup event listeners and callbacks
|
||||
const setupEventListeners = () => {
|
||||
@@ -1414,6 +1327,7 @@ const setupEventListeners = () => {
|
||||
const formMessage = {
|
||||
message: '',
|
||||
message_type: 'form',
|
||||
session_id: currentSessionId.value,
|
||||
attributes: {
|
||||
form_data: data.form_data
|
||||
},
|
||||
@@ -1543,7 +1457,6 @@ const handleStartNewChat = async () => {
|
||||
|
||||
// Handle starting a new conversation
|
||||
const handleStartNewConversation = async () => {
|
||||
shouldShowNewConversationOption.value = false
|
||||
messages.value = [] // Clear messages
|
||||
// Drop any human agent from the previous conversation. humanAgent is only ever
|
||||
// set (on takeover, and from loaded history), never cleared, so without this the
|
||||
@@ -1584,11 +1497,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', (event) => {
|
||||
if (event.data.type === 'SCROLL_TO_BOTTOM') {
|
||||
scrollToBottom()
|
||||
}
|
||||
})
|
||||
window.removeEventListener('message', handleHostMessage)
|
||||
|
||||
// Remove header menu click listener
|
||||
document.removeEventListener('click', closeHeaderMenu)
|
||||
@@ -1600,9 +1509,9 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
// Clear any pending timeouts
|
||||
if (setupDOMObserver.timeoutId) {
|
||||
clearTimeout(setupDOMObserver.timeoutId)
|
||||
setupDOMObserver.timeoutId = null
|
||||
if (domObserverTimer) {
|
||||
clearTimeout(domObserverTimer)
|
||||
domObserverTimer = null
|
||||
}
|
||||
isUnmounted = true
|
||||
cancelPendingListenerSetups()
|
||||
@@ -1935,7 +1844,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
{{ authError }}
|
||||
</p>
|
||||
|
||||
<button class="auth-error-refresh-btn" @click="() => window.location.reload()">
|
||||
<button class="auth-error-refresh-btn" @click="reloadWidget">
|
||||
刷新页面
|
||||
</button>
|
||||
</div>
|
||||
@@ -2134,8 +2043,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
:minlength="field.minLength"
|
||||
:maxlength="field.maxLength"
|
||||
:minlength="field.minLength ?? undefined"
|
||||
:maxlength="field.maxLength ?? undefined"
|
||||
:value="formData[field.name] || ''"
|
||||
@input="handleFieldChange(field.name, ($event.target as HTMLInputElement).value)"
|
||||
@blur="handleFieldChange(field.name, ($event.target as HTMLInputElement).value)"
|
||||
@@ -2152,8 +2061,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
type="number"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
:min="field.minLength"
|
||||
:max="field.maxLength"
|
||||
:min="field.minLength ?? undefined"
|
||||
:max="field.maxLength ?? undefined"
|
||||
:value="formData[field.name] || ''"
|
||||
@input="handleFieldChange(field.name, ($event.target as HTMLInputElement).value)"
|
||||
class="form-input"
|
||||
@@ -2166,8 +2075,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
:id="`fullscreen-form-${field.name}`"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
:minlength="field.minLength"
|
||||
:maxlength="field.maxLength"
|
||||
:minlength="field.minLength ?? undefined"
|
||||
:maxlength="field.maxLength ?? undefined"
|
||||
:value="formData[field.name] || ''"
|
||||
@input="handleFieldChange(field.name, ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
@@ -2187,7 +2096,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
>
|
||||
<option value="">{{ field.placeholder || '请选择…' }}</option>
|
||||
<option
|
||||
v-for="option in (Array.isArray(field.options) ? field.options : field.options?.split('\n') || []).filter(o => o.trim())"
|
||||
v-for="option in formOptions(field.options)"
|
||||
:key="option"
|
||||
:value="option.trim()"
|
||||
>
|
||||
@@ -2217,7 +2126,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
class="radio-group"
|
||||
>
|
||||
<label
|
||||
v-for="option in (Array.isArray(field.options) ? field.options : field.options?.split('\n') || []).filter(o => o.trim())"
|
||||
v-for="option in formOptions(field.options)"
|
||||
:key="option"
|
||||
class="radio-field"
|
||||
>
|
||||
@@ -2432,9 +2341,9 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
:key="star"
|
||||
class="star-button"
|
||||
:class="{
|
||||
'warning': star <= (message.isSubmitted ? message.finalRating : (hoverRating || message.selectedRating)) && (message.isSubmitted ? message.finalRating : (hoverRating || message.selectedRating)) <= 3,
|
||||
'success': star <= (message.isSubmitted ? message.finalRating : (hoverRating || message.selectedRating)) && (message.isSubmitted ? message.finalRating : (hoverRating || message.selectedRating)) > 3,
|
||||
'selected': star <= (message.isSubmitted ? message.finalRating : (hoverRating || message.selectedRating))
|
||||
'warning': star <= displayedRating(message) && displayedRating(message) <= 3,
|
||||
'success': star <= displayedRating(message) && displayedRating(message) > 3,
|
||||
'selected': star <= displayedRating(message)
|
||||
}"
|
||||
@mouseover="!message.isSubmitted && handleStarHover(star)"
|
||||
@mouseleave="!message.isSubmitted && handleStarLeave"
|
||||
@@ -2458,7 +2367,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
<div class="feedback-counter">{{ message.feedback?.length || 0 }}/500</div>
|
||||
</div>
|
||||
<button
|
||||
@click="handleSubmitRating(message.session_id, hoverRating, message.feedback)"
|
||||
@click="submitRating(hoverRating, message.feedback)"
|
||||
:disabled="isSubmittingRating || !hoverRating"
|
||||
class="submit-rating-button"
|
||||
:style="{ backgroundColor: customization.accent_color || 'var(--accent-solid)' }"
|
||||
@@ -2507,8 +2416,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
:type="field.type"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
:minlength="field.minLength"
|
||||
:maxlength="field.maxLength"
|
||||
:minlength="field.minLength ?? undefined"
|
||||
:maxlength="field.maxLength ?? undefined"
|
||||
:value="formData[field.name] || ''"
|
||||
@input="handleFieldChange(field.name, ($event.target as HTMLInputElement).value)"
|
||||
@blur="handleFieldChange(field.name, ($event.target as HTMLInputElement).value)"
|
||||
@@ -2541,8 +2450,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
:id="`form-${field.name}`"
|
||||
:placeholder="field.placeholder || ''"
|
||||
:required="field.required"
|
||||
:minlength="field.minLength"
|
||||
:maxlength="field.maxLength"
|
||||
:minlength="field.minLength ?? undefined"
|
||||
:maxlength="field.maxLength ?? undefined"
|
||||
:value="formData[field.name] || ''"
|
||||
@input="handleFieldChange(field.name, ($event.target as HTMLTextAreaElement).value)"
|
||||
class="form-textarea"
|
||||
@@ -2564,7 +2473,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
>
|
||||
<option value="">{{ field.placeholder || '请选择' }}</option>
|
||||
<option
|
||||
v-for="option in (Array.isArray(field.options) ? field.options : field.options?.split('\n') || []).filter(o => o.trim())"
|
||||
v-for="option in formOptions(field.options)"
|
||||
:key="option.trim()"
|
||||
:value="option.trim()"
|
||||
>
|
||||
@@ -2590,7 +2499,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
<!-- Radio buttons -->
|
||||
<div v-else-if="field.type === 'radio'" class="radio-field">
|
||||
<div
|
||||
v-for="option in (Array.isArray(field.options) ? field.options : field.options?.split('\n') || []).filter(o => o.trim())"
|
||||
v-for="option in formOptions(field.options)"
|
||||
:key="option.trim()"
|
||||
class="radio-option"
|
||||
>
|
||||
@@ -2673,7 +2582,7 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
<template v-else-if="message.shopify_output || message.message_type === 'product'">
|
||||
<div class="product-message-container">
|
||||
<!-- Display the message text, removing images if products are present -->
|
||||
<div v-if="message.message" v-html="renderMarkdown(message.shopify_output?.products?.length > 0 ? removeUrls(message.message) : message.message)" class="product-message-text"></div>
|
||||
<div v-if="message.message" v-html="renderMarkdown(message.shopify_output?.products?.length ? removeUrls(message.message) : message.message)" class="product-message-text"></div>
|
||||
|
||||
<!-- Always use carousel/list display -->
|
||||
<div v-if="message.shopify_output?.products && message.shopify_output.products.length > 0" class="products-carousel">
|
||||
@@ -2716,8 +2625,8 @@ const askAiHotkey = computed(() => parentDisplay.value?.hotkey !== false)
|
||||
<!-- Live replies reveal char-by-char, rendering the partial slice
|
||||
through the same sanitized markdown pipeline as the final text
|
||||
so formatting appears mid-stream (caret rides the last block). -->
|
||||
<div v-if="isStreaming(index)" class="message-streaming" v-html="renderMarkdown(displayText(index, message.message))"></div>
|
||||
<div v-else v-html="renderMarkdown(message.message)"></div>
|
||||
<div v-if="isStreaming(index)" class="message-streaming" v-html="renderMarkdown(displayText(index, message.message || ''))"></div>
|
||||
<div v-else v-html="renderMarkdown(message.message || '')"></div>
|
||||
|
||||
<!-- Display attachments if present -->
|
||||
<div v-if="message.attachments && message.attachments.length > 0" class="message-attachments">
|
||||
|
||||
@@ -2,14 +2,7 @@ import paperBoatSvg from '../assets/logo.svg?raw';
|
||||
|
||||
// @ts-nocheck
|
||||
// TypeScript declarations for global variables
|
||||
/** @type {string} */
|
||||
window.replypierId;
|
||||
/** @type {string} */
|
||||
window.replypierBaseUrl;
|
||||
/** @type {object} Public widget API — see the assignment at the bottom of this file. */
|
||||
window.ReplyPier;
|
||||
/** @type {object|undefined} Declarative options, same shape as ReplyPier.init(options). */
|
||||
window.replypierConfig;
|
||||
// Public globals are initialized at the bottom of this loader.
|
||||
|
||||
;(function () {
|
||||
// Function to validate hex color code
|
||||
@@ -216,7 +209,7 @@ window.replypierConfig;
|
||||
const cbs = listeners[name]
|
||||
if (!cbs || !cbs.length) return
|
||||
cbs.slice().forEach(function (cb) {
|
||||
try { cb(payload) } catch (e) { /* a broken site listener must not break the widget */ }
|
||||
try { cb(payload) } catch { /* a broken site listener must not break the widget */ }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -248,7 +241,7 @@ window.replypierConfig;
|
||||
const btn = document.getElementById(config.buttonId)
|
||||
if (btn) btn.classList.remove('replypier-pending')
|
||||
onRevealCallbacks.splice(0).forEach((cb) => {
|
||||
try { cb() } catch (e) { /* no-op */ }
|
||||
try { cb() } catch { /* no-op */ }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1228,7 +1221,7 @@ window.replypierConfig;
|
||||
try {
|
||||
const claims = JSON.parse(atob(payload.replace(/-/g, '+').replace(/_/g, '/')));
|
||||
return claims.sub || claims.customer_id || null;
|
||||
} catch (e) {
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
@@ -2012,7 +2005,7 @@ window.replypierConfig;
|
||||
destroy: destroyWidget,
|
||||
}
|
||||
pendingCalls.splice(0).forEach(function (fn) {
|
||||
try { fn() } catch (e) { /* keep the widget alive if a queued call throws */ }
|
||||
try { fn() } catch { /* keep the widget alive if a queued call throws */ }
|
||||
})
|
||||
}
|
||||
|
||||
@@ -2058,7 +2051,7 @@ window.replypierConfig;
|
||||
if (!match && config.trigger) {
|
||||
try {
|
||||
match = el.closest(config.trigger)
|
||||
} catch (err) { /* invalid selector supplied by the site — ignore */ }
|
||||
} catch { /* invalid selector supplied by the site — ignore */ }
|
||||
}
|
||||
if (!match) return
|
||||
// Stop in-page anchors used as triggers (<a href="#">) from scrolling the page.
|
||||
@@ -2279,7 +2272,7 @@ window.replypierConfig;
|
||||
keepalive: true,
|
||||
headers: { 'Authorization': `Bearer ${token}` },
|
||||
}).catch(() => { /* the widget is being rebuilt either way */ })
|
||||
} catch (e) {
|
||||
} catch {
|
||||
/* the widget is being rebuilt either way */
|
||||
}
|
||||
}
|
||||
@@ -2393,7 +2386,7 @@ window.replypierConfig;
|
||||
// 'ready' already happened — call straight away so consent-gated / lazy site
|
||||
// code doesn't miss it. It fires only once, so there's nothing to register.
|
||||
if (event === 'ready' && widgetReadyFired) {
|
||||
try { cb() } catch (e) { /* site listener error */ }
|
||||
try { cb() } catch { /* site listener error */ }
|
||||
return
|
||||
}
|
||||
;(listeners[event] = listeners[event] || []).push(cb)
|
||||
|
||||
@@ -14,30 +14,10 @@ See the License for the specific language governing permissions and
|
||||
limitations under the License.
|
||||
*/
|
||||
|
||||
// @ts-nocheck
|
||||
import { buildUploadUrl } from '../utils/avatars';
|
||||
|
||||
export interface InitialData {
|
||||
widgetId: string;
|
||||
agentName: string;
|
||||
customization: Record<string, any>;
|
||||
customerId: string;
|
||||
customer: Record<string, any>;
|
||||
initialToken?: string;
|
||||
}
|
||||
|
||||
// @ts-ignore
|
||||
declare global {
|
||||
// @ts-ignore
|
||||
interface Window {
|
||||
// @ts-ignore
|
||||
__INITIAL_DATA__: InitialData;
|
||||
}
|
||||
}
|
||||
|
||||
// Helper function to get runtime configuration
|
||||
function getRuntimeConfig() {
|
||||
// @ts-ignore - APP_CONFIG might not be available at build time
|
||||
return typeof window !== 'undefined' && window.APP_CONFIG ? window.APP_CONFIG : {};
|
||||
}
|
||||
|
||||
|
||||
@@ -18,15 +18,13 @@ import { createApp } from 'vue'
|
||||
import WidgetBuilder from './WidgetBuilder.vue'
|
||||
import './widget.css'
|
||||
|
||||
// Ensure the environment is defined
|
||||
// @ts-ignore
|
||||
// Keep the compatibility shim available for libraries that still inspect
|
||||
// process.env in the browser bundle.
|
||||
if (!window.process) {
|
||||
// @ts-ignore
|
||||
window.process = { env: { NODE_ENV: 'production' } }
|
||||
}
|
||||
|
||||
// Extract widget ID and token from window or URL
|
||||
// @ts-ignore
|
||||
const initialData = window.__INITIAL_DATA__
|
||||
const url = new URL(window.location.href)
|
||||
const isPreviewMode = url.searchParams.get('preview') === 'true'
|
||||
@@ -47,7 +45,6 @@ const widgetId = isPreviewMode
|
||||
: (initialData?.widgetId) || undefined
|
||||
|
||||
// Get token - prefer initial data, fallback to URL param in preview mode
|
||||
// @ts-ignore
|
||||
const token = isPreviewMode
|
||||
? (initialData?.initialToken) || getURLParam('token') || undefined
|
||||
: (initialData?.initialToken) || undefined
|
||||
|
||||
@@ -14,8 +14,7 @@ import { THEME_COLORS } from './src/config/themeColors'
|
||||
export default defineConfig(({ command, mode }) => {
|
||||
// Load env file based on `mode` in the current working directory.
|
||||
// When mode is 'prod' or 'dev', load .env.prod or .env.dev respectively
|
||||
let envDir = process.cwd()
|
||||
let envFile = '.env'
|
||||
const envDir = process.cwd()
|
||||
|
||||
if (mode === 'prod' || mode === 'production') {
|
||||
// Manually load .env.prod for production builds. Plain `vite build`
|
||||
|
||||
Reference in new issue
Block a user