Files
group_fqcd_jr/app/static/portal/common/customer-service-widget/widget.js
T

168 lines
9.1 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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';
const VISITOR_TOKEN_KEY = 'portalVisitorToken';
function visitorToken() {
try { return sessionStorage.getItem(VISITOR_TOKEN_KEY) || ''; } catch { return ''; }
}
function saveVisitorToken(token) {
try { sessionStorage.setItem(VISITOR_TOKEN_KEY, token); } catch { /* storage may be blocked */ }
}
function visitorTokenExpired(token) {
try {
const encoded = token.split('.')[1];
if (!encoded) return true;
const normalized = encoded.replace(/-/g, '+').replace(/_/g, '/').padEnd(Math.ceil(encoded.length / 4) * 4, '=');
const payload = JSON.parse(atob(normalized));
return !Number.isFinite(payload.exp) || payload.exp * 1000 <= Date.now() + 15000;
} catch {
return true;
}
}
function mountMarkup(mode) {
const isCustomer = mode === 'customer';
document.body.insertAdjacentHTML('beforeend', `
<aside class="cs-widget" data-cs-widget data-mode="${isCustomer ? 'customer' : 'visitor'}">
<button class="cs-widget__launcher" type="button" aria-label="打开智能客服" aria-expanded="false" data-cs-launcher>
<span class="cs-widget__orb" aria-hidden="true"><span></span></span>
<span class="cs-widget__launcher-copy"><strong>南方小助</strong><small>${isCustomer ? '专属服务' : '基金问答'}</small></span>
</button>
<section class="cs-widget__panel" aria-label="智能客服对话" hidden data-cs-panel>
<header class="cs-widget__header"><div><p class="cs-widget__eyebrow">${isCustomer ? '已登录客户' : '公开服务'}</p><h2>智能客服</h2></div><button class="cs-widget__close" type="button" aria-label="关闭客服" data-cs-close>×</button></header>
<div class="cs-widget__notice">${isCustomer ? '可查询您的测评、账户与基金服务信息。' : '可查询公开基金与服务规则,账户问题请先登录。'}</div>
<div class="cs-widget__messages" role="log" aria-live="polite" data-cs-messages><div class="cs-widget__message cs-widget__message--assistant">您好,我是南方小助。${isCustomer ? '可以帮您查询账户相关服务。' : '可以帮您了解公开基金与服务规则。'}</div></div>
<form class="cs-widget__composer" data-cs-form><input name="message" maxlength="8000" autocomplete="off" placeholder="请输入您的问题" aria-label="输入问题" required><button type="submit" aria-label="发送问题">发送</button></form>
<div class="cs-widget__footer"><button type="button" data-cs-login>${isCustomer ? '转人工客服' : '登录后查询账户'}</button><span data-cs-status>内容来自已发布知识与权限范围</span></div>
</section>
</aside>`);
}
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();
});
}