import { apiClient, ApiError } from '/static/portal/common/api-client.js?v=20260913'; import { getAccessToken, getAuthContext } from '/static/portal/common/auth.js?v=20260913'; import { escapeHtml } from '/static/portal/common/formatters.js'; import { saveVisitorToken, visitorToken, visitorTokenExpired } from '/static/portal/common/visitor-token.js'; function mountMarkup(mode) { const isCustomer = mode === 'customer'; document.body.insertAdjacentHTML('beforeend', ` `); } export function mountCustomerServiceWidget(mode = 'public') { if (document.querySelector('[data-cs-widget]')) return; mountMarkup(mode); const root = document.querySelector('[data-cs-widget]'); const launcher = root.querySelector('[data-cs-launcher]'); const panel = root.querySelector('[data-cs-panel]'); const messages = root.querySelector('[data-cs-messages]'); const form = root.querySelector('[data-cs-form]'); const input = form.elements.message; const status = root.querySelector('[data-cs-status]'); const isCustomer = mode === 'customer'; let sessionId = ''; let busy = false; const setOpen = (open) => { panel.hidden = !open; launcher.setAttribute('aria-expanded', String(open)); root.classList.toggle('cs-widget--open', open); if (open) window.setTimeout(() => input.focus(), 60); }; const addMessage = (text, role = 'assistant') => { const item = document.createElement('div'); item.className = `cs-widget__message cs-widget__message--${role}`; item.textContent = text; messages.appendChild(item); messages.scrollTop = messages.scrollHeight; return item; }; const authHeaders = () => { const token = getAccessToken(); return token ? {} : { Authorization: `Bearer ${visitorToken()}` }; }; async function ensureVisitorToken() { if (isCustomer) return ''; let token = visitorToken(); if (!token || visitorTokenExpired(token)) { const response = await apiClient.post('V001'); token = response.data?.access_token || ''; if (token) saveVisitorToken(token); } return token; } async function ensureSession() { if (sessionId) return sessionId; // Visitor tokens intentionally carry only agent:run + knowledge:query. They // use an ephemeral run session, while authenticated customers get a persisted // conversation resource with conversation:create permission. if (!isCustomer) { await ensureVisitorToken(); sessionId = crypto.randomUUID(); return sessionId; } await ensureVisitorToken(); const response = await apiClient.post('C001', { agent_type: 'customer_service' }, { headers: authHeaders() }); sessionId = response.data?.session_id || ''; if (!sessionId) throw new ApiError('客服会话未建立'); return sessionId; } async function waitForRun(runId) { // 实测(本机 + DeepSeek):整条链路「受理 0.05s + 意图分类 + 知识检索 + 落库」 // 约 4.1–4.8 秒。原参数是「350ms 后首次、之后每 700ms 一次、共 14 次」≈ 9.5 秒封顶, // 后端稍一抖动就撞上限;而且平均要多等半个轮询周期(350ms)才看到结果。 // 改为「300ms 后首次、之后每 500ms 一次、共 40 次」≈ 20 秒:感知延迟压到半秒内, // 同时给模型/检索抖动留出余量。 // ⚠️ 若这里仍然超时,先确认 Agent Worker 已启动(`python -m app.worker`)—— // 没有 Worker 时 run 会一直停在 queued,任何轮询预算都不够。 for (let attempt = 0; attempt < 40; attempt += 1) { await new Promise((resolve) => window.setTimeout(resolve, attempt ? 500 : 300)); const response = await apiClient.get('R002', { pathParams: { runId }, headers: authHeaders() }); const snapshot = response.data || {}; if (snapshot.status === 'succeeded') return snapshot.result?.reply || snapshot.result?.content || '暂时没有可展示的回复。'; if (snapshot.status === 'failed' || snapshot.status === 'cancelled') throw new ApiError('客服暂时无法完成回答,请稍后重试。'); } throw new ApiError('客服繁忙,暂时没能给出答复,请稍后重试。'); } form.addEventListener('submit', async (event) => { event.preventDefault(); const message = String(input.value || '').trim(); if (!message || busy) return; busy = true; input.value = ''; input.disabled = true; addMessage(message, 'user'); const pending = addMessage('正在查询已发布资料…', 'assistant'); status.textContent = '正在安全处理'; try { const currentSession = await ensureSession(); const accepted = await apiClient.post('R001', { agent_type: 'customer_service', session_id: currentSession, message, idempotency_key: crypto.randomUUID().replaceAll('-', ''), }, { headers: authHeaders() }); pending.textContent = await waitForRun(accepted.data?.run_id); status.textContent = '已完成回答'; } catch (error) { pending.remove(); if (error.status === 401 || error.code === 'AUTHENTICATION_REQUIRED') { addMessage(isCustomer ? '登录状态已过期,请重新登录后继续。' : '访客服务已过期,请刷新页面后重试。'); } else if (error.code === 'AGENT_PERMISSION_DENIED') { addMessage('当前问题超出该入口的服务范围,建议转人工客服。'); } else { addMessage(error.message || '请求未完成,请稍后重试。'); } status.textContent = '请求未完成'; apiClient.reportError(error); } finally { busy = false; input.disabled = false; input.focus(); } }); launcher.addEventListener('click', () => setOpen(panel.hidden)); root.querySelector('[data-cs-close]').addEventListener('click', () => setOpen(false)); root.querySelector('[data-cs-login]').addEventListener('click', async () => { if (!isCustomer) { window.location.assign(`/portal/customer/login/?next=${encodeURIComponent(window.location.pathname)}`); return; } if (!sessionId) { addMessage('您可以直接输入问题,我会先查询已发布资料。'); input.focus(); return; } try { const response = await apiClient.post('C005', { reason_code: 'user_requested', reason_detail: '客户通过智能客服浮窗申请人工协助' }, { pathParams: { sessionId } }); addMessage(`已记录转人工请求(${response.data?.handover_id || '处理中'}),请稍候由客服人员接入。`); } catch (error) { addMessage(error.message || '转人工请求未完成,请稍后重试。'); apiClient.reportError(error); } }); // Keep the widget identity aligned with an account switch without exposing account data. window.addEventListener('portal:auth-changed', () => { const context = getAuthContext(); if (!context && isCustomer) window.location.reload(); }); }