147 lines
8.5 KiB
JavaScript
147 lines
8.5 KiB
JavaScript
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', `
|
||
<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();
|
||
});
|
||
}
|