fix: improve maintenance and synchronize live conversations

This commit is contained in:
WangShen007 committed 2026-10-29 09:46:21 +08:00
1 parent bb541bd212
commit ef86a5d3af
297 files changed
+2983 -1274

No files matched your search

+9 -2
View File
@@ -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'],
+2 -1
View File
@@ -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",
-1
View File
@@ -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' })
-1
View File
@@ -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
View File
@@ -1,5 +1,4 @@
import * as fs from 'fs'
import * as path from 'path'
import * as dotenv from 'dotenv'
// Load development environment variables
-1
View File
@@ -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()
})
})
+8 -14
View File
@@ -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)
})
})
})
+10 -4
View File
@@ -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>
+11 -37
View File
@@ -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>
+3 -1
View File
@@ -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
+8 -2
View File
@@ -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)
}
}
}
+1 -1
View File
@@ -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() {
+4 -19
View File
@@ -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
+1 -2
View File
@@ -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
}
}
}
+2 -2
View File
@@ -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
}
}
}
+150 -31
View File
@@ -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,
+1 -1
View File
@@ -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[]>([])
+29 -3
View File
@@ -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
+8 -7
View File
@@ -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
}
}
}
+4 -2
View File
@@ -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)
-2
View File
@@ -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'
+1 -1
View File
@@ -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;
+2 -2
View File
@@ -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
}
}
-5
View File
@@ -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)
+12 -3
View File
@@ -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) {
+1 -1
View File
@@ -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'
+2 -2
View File
@@ -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 })
}
}
+50
View File
@@ -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
+7 -6
View File
@@ -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;
};
}
}
+1 -2
View File
@@ -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
}
+3 -1
View File
@@ -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>
+56 -25
View File
@@ -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"
+1 -1
View File
@@ -264,7 +264,7 @@ const closeForgotPasswordModal = () => {
}
const handleRequestPasswordReset = async () => {
const success = await requestPasswordReset()
await requestPasswordReset()
// Keep modal open to proceed to step 2
}
+1 -1
View File
@@ -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()
}
+1 -2
View File
@@ -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>
+6 -1
View File
@@ -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>
+81 -172
View File
@@ -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">
+8 -15
View File
@@ -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)
-20
View File
@@ -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 : {};
}
+2 -5
View File
@@ -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
+1 -2
View File
@@ -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`