Files
2026-06-21 10:00:13 +08:00

901 lines
30 KiB
JavaScript
Raw Permalink 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.
(() => {
if (window.__tmAiAssistantLoaded) return;
window.__tmAiAssistantLoaded = true;
const pathname = window.location.pathname || '/';
const body = document.body;
const isAssistantEligiblePage = (targetBody) => {
if (!targetBody) return false;
if (targetBody.classList.contains('jr-admin-page') || targetBody.classList.contains('jr-admin-login-page')) return false;
return targetBody.classList.contains('jr-public-page')
|| targetBody.classList.contains('public-search')
|| targetBody.classList.contains('jr-order-page')
|| targetBody.classList.contains('jr-ticket-board-page');
};
if (!isAssistantEligiblePage(body)) return;
const pageMap = {
'/': '首页',
'/home.html': '首页',
'/order': '线上预定',
'/ticket-order.html': '线上预定',
'/search': '车票查询',
'/ticket-search.html': '车票查询',
'/ticket-board.html': '车票详情',
'/token': '订单凭证',
'/token.html': '订单凭证',
'/ic-card/order': 'IC 卡线上购卡',
'/ic-card-order.html': 'IC 卡线上购卡',
'/ic-card/search': 'IC 卡查询',
'/ic-card-search.html': 'IC 卡查询'
};
const suggestionMap = {
'首页': ['这个网站可以做什么?', '如何在线订票?', '凭证码有什么用?'],
'线上预定': ['如何在线订票?', '订票后怎么兑票?', '车型和乘次数量怎么选?'],
'车票查询': ['怎么查我的车票?', '票据状态怎么看?', '查不到票据怎么办?'],
'车票详情': ['帮我解释当前票号。', '这张票现在还能使用吗?', '这些状态字段分别代表什么?'],
'订单凭证': ['凭证码怎么使用?', '这个凭证现在能兑票吗?', '怎么确认是否已经兑票?'],
'IC 卡线上购卡': ['怎么在线购买 IC 卡?', '购卡后怎么领卡?', '持卡人姓名有什么要求?'],
'IC 卡查询': ['怎么查询 IC 卡余额?', '输入什么可以查到 IC 卡?', '卡片状态代表什么?']
};
const resolvePageName = () => {
if (pageMap[pathname]) return pageMap[pathname];
if (body.classList.contains('jr-ticket-board-page')) return '车票详情';
if (body.classList.contains('jr-order-page')) return '线上预定';
if (document.querySelector('#vCode, #vCodeTop, .jr-redeem-code-value')) return '订单凭证';
if (document.querySelector('.jr-ticket-id, .jr-route-board')) return '车票详情';
if (document.querySelector('#q, #searchBtn')) return '车票查询';
return document.title || '当前页面';
};
const pageName = resolvePageName();
const history = [];
let sending = false;
let lastContextSignature = '';
const textOf = (selector) => {
const el = document.querySelector(selector);
return el ? String(el.textContent || '').trim() : '';
};
const valueOf = (selector) => {
const el = document.querySelector(selector);
return el ? String(el.value || '').trim() : '';
};
const compact = (value, maxLength = 120) => {
const text = String(value || '').trim().replace(/\s+/g, ' ');
if (!text) return '';
return text.length > maxLength ? `${text.slice(0, maxLength)}...` : text;
};
const toContextSignature = (context) => JSON.stringify(context || {});
const escapeHtml = (value) => String(value || '')
.replace(/&/g, '&')
.replace(/</g, '&lt;')
.replace(/>/g, '&gt;')
.replace(/"/g, '&quot;')
.replace(/'/g, '&#39;');
const renderInlineMarkdown = (text) => escapeHtml(text)
.replace(/`([^`]+)`/g, '<code>$1</code>')
.replace(/\*\*([^*]+)\*\*/g, '<strong>$1</strong>')
.replace(/\*([^*]+)\*/g, '<em>$1</em>')
.replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '<a href="$2" target="_blank" rel="noreferrer">$1</a>');
const markdownToHtml = (source) => {
const normalized = String(source || '').replace(/\r\n/g, '\n').trim();
if (!normalized) return '';
const codeBlocks = [];
const withPlaceholders = normalized.replace(/```([\w-]*)\n([\s\S]*?)```/g, (_, lang, code) => {
const token = `__TM_CODE_BLOCK_${codeBlocks.length}__`;
codeBlocks.push(
`<pre class="tm-ai-pre"><code${lang ? ` class="language-${escapeHtml(lang)}"` : ''}>${escapeHtml(code.trim())}</code></pre>`
);
return token;
});
const blocks = withPlaceholders.split(/\n{2,}/).map((block) => block.trim()).filter(Boolean);
const html = blocks.map((block) => {
if (/^__TM_CODE_BLOCK_\d+__$/.test(block)) {
const index = Number(block.replace(/\D/g, ''));
return codeBlocks[index] || '';
}
const lines = block.split('\n').map((line) => line.trimEnd());
if (lines.every((line) => /^[-*]\s+/.test(line))) {
return `<ul>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^[-*]\s+/, ''))}</li>`).join('')}</ul>`;
}
if (lines.every((line) => /^\d+\.\s+/.test(line))) {
return `<ol>${lines.map((line) => `<li>${renderInlineMarkdown(line.replace(/^\d+\.\s+/, ''))}</li>`).join('')}</ol>`;
}
if (lines.length === 1 && /^###\s+/.test(lines[0])) return `<h3>${renderInlineMarkdown(lines[0].replace(/^###\s+/, ''))}</h3>`;
if (lines.length === 1 && /^##\s+/.test(lines[0])) return `<h2>${renderInlineMarkdown(lines[0].replace(/^##\s+/, ''))}</h2>`;
if (lines.length === 1 && /^#\s+/.test(lines[0])) return `<h1>${renderInlineMarkdown(lines[0].replace(/^#\s+/, ''))}</h1>`;
return `<p>${lines.map((line) => renderInlineMarkdown(line)).join('<br />')}</p>`;
}).join('');
return html.replace(/__TM_CODE_BLOCK_(\d+)__/g, (_, index) => codeBlocks[Number(index)] || '');
};
const buildContextActions = (context) => {
const actions = [];
if (context.voucher_code) {
actions.push({
label: '解释当前凭证',
prompt: '请结合当前页面识别到的凭证信息,解释这个凭证现在的状态、如何使用,以及我接下来应该做什么。'
});
}
if (context.ticket_id) {
actions.push({
label: '解释当前票号',
prompt: '请结合当前页面识别到的票号与车票信息,解释这张票当前状态、还能不能使用,以及各字段分别代表什么。'
});
}
if (context.query_keyword && !context.ticket_id && !context.voucher_code) {
actions.push({
label: '解释当前检索',
prompt: `我当前检索的是“${context.query_keyword}”,请告诉我应该如何判断它是票号、凭证码还是其他查询关键词。`
});
}
return actions;
};
const collectPageContext = () => {
const params = new URLSearchParams(window.location.search);
const pathSegments = pathname.split('/').filter(Boolean);
const trailingSegment = decodeURIComponent(pathSegments[pathSegments.length - 1] || '');
const context = {
page_name: pageName
};
const fromInput = valueOf('#from');
const toInput = valueOf('#to');
const trips = valueOf('#trips');
const voucherCode = textOf('#vCode') || textOf('#vCodeTop') || textOf('.voucher-code');
const ticketId = compact(textOf('.jr-ticket-id')) || compact(textOf('.jr-panel-headline .mono')) || compact(params.get('id'));
const searchKeyword = valueOf('#q') || valueOf('#queryInput') || compact(params.get('q'));
const status = textOf('#vStatusTop') || textOf('#vStatusTag') || textOf('.jr-status-pill');
const startName = textOf('.vStartName') || textOf('.jr-route-board .jr-station-block:first-child .jr-station-title');
const terminalName = textOf('.vTermName') || textOf('.jr-route-board .jr-station-block.is-end .jr-station-title');
if (voucherCode) context.voucher_code = voucherCode;
if (ticketId) context.ticket_id = ticketId.replace(/\s+<.*$/, '').trim();
if (!context.ticket_id && body.classList.contains('jr-ticket-board-page') && trailingSegment && trailingSegment !== 'search' && trailingSegment !== 'ticket-board.html') {
context.ticket_id = trailingSegment;
}
if (searchKeyword) context.query_keyword = searchKeyword;
if (status) context.status = status;
if (startName) context.start = startName;
if (terminalName) context.terminal = terminalName;
if (fromInput) context.selected_start = fromInput;
if (toInput) context.selected_terminal = toInput;
if (trips) context.trips = trips;
if (pathname === '/order' || pathname === '/ticket-order.html') {
const typeEl = document.querySelector('input[name="trainType"]:checked');
const typeCardTitle = typeEl ? compact(typeEl.nextElementSibling?.querySelector('.type-title')?.textContent || typeEl.value) : '';
if (typeCardTitle) context.train_type = typeCardTitle;
const totalPrice = compact(textOf('.jr-total-amount'));
if (totalPrice) context.price = totalPrice;
}
if (pathname === '/token' || pathname === '/token.html') {
const rideDate = textOf('#vDateTop');
const type = textOf('#vTypeTop');
const price = textOf('#vPriceTop');
const voucherHint = textOf('.jr-redeem-copy');
if (rideDate) context.ride_date = rideDate;
if (type) context.train_type = type;
if (price) context.price = price;
if (voucherHint) context.redeem_tip = compact(voucherHint, 180);
}
if (pathname === '/search' || pathname === '/ticket-search.html' || body.classList.contains('jr-ticket-board-page')) {
const metaItems = Array.from(document.querySelectorAll('.jr-meta-item')).slice(0, 6);
metaItems.forEach((item) => {
const key = compact(item.querySelector('span')?.textContent || '', 40);
const value = compact(item.querySelector('strong')?.textContent || '', 80);
if (!key || !value) return;
if (key.includes('车型')) context.train_type = value;
if (key.includes('票价')) context.price = value;
if (key.includes('乘次')) context.trips_summary = value;
if (key.includes('更新')) context.last_update = value;
});
const metaValues = metaItems.map((item) => compact(item.querySelector('strong')?.textContent || '', 80));
if (!context.train_type && metaValues[0]) context.train_type = metaValues[0];
if (!context.price && metaValues[1]) context.price = metaValues[1];
if (!context.trips_summary && metaValues[2]) context.trips_summary = metaValues[2];
if (!context.last_update && metaValues[3]) context.last_update = metaValues[3];
}
const availableKeys = Object.keys(context).filter((key) => compact(context[key]));
return availableKeys.length > 1 ? context : { page_name: pageName };
};
const ensureStyle = () => {
if (document.getElementById('tm-ai-assistant-style')) return;
const style = document.createElement('style');
style.id = 'tm-ai-assistant-style';
style.textContent = `
.tm-ai-assistant {
position: fixed;
right: 22px;
bottom: 22px;
z-index: 4200;
font-family: "Segoe UI Variable Text", "Segoe UI", "PingFang SC", "Microsoft YaHei", sans-serif;
}
.tm-ai-toggle {
min-width: 138px;
min-height: 50px;
padding: 9px 12px;
border: 1px solid #d7e0d3;
border-top: 3px solid #2b8a57;
border-radius: 0;
background: #fbfdf9;
color: #183525;
cursor: pointer;
display: inline-flex;
align-items: center;
gap: 10px;
justify-content: flex-start;
box-shadow: 0 12px 28px rgba(36, 74, 50, 0.1);
transition: transform 0.18s ease, box-shadow 0.18s ease, border-color 0.18s ease;
}
.tm-ai-toggle:hover {
transform: translateY(-2px);
box-shadow: 0 20px 38px rgba(36, 74, 50, 0.14);
border-color: #cadecd;
}
.tm-ai-toggle-badge {
width: 28px;
height: 28px;
border-radius: 0;
background: #2b8a57;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 12px;
font-weight: 800;
color: #ffffff;
letter-spacing: 0.08em;
}
.tm-ai-toggle-copy {
display: flex;
flex-direction: column;
align-items: flex-start;
gap: 2px;
}
.tm-ai-toggle-copy strong {
font-size: 13px;
line-height: 1;
color: #183525;
}
.tm-ai-toggle-copy span {
font-size: 10px;
color: #607064;
}
.tm-ai-panel {
width: min(520px, calc(100vw - 24px));
height: min(780px, calc(100vh - 72px));
display: none;
flex-direction: column;
margin-top: 10px;
border: 1px solid #d7e0d3;
border-top: 3px solid #2b8a57;
border-radius: 0;
overflow: hidden;
background: #f9fcf7;
box-shadow: 0 24px 56px rgba(36, 74, 50, 0.12);
}
.tm-ai-assistant.is-open .tm-ai-panel {
display: flex;
}
.tm-ai-header {
padding: 12px 14px 8px;
color: #173321;
background: #fbfdf9;
border-bottom: 1px solid #dfe8dd;
}
.tm-ai-header-row {
display: flex;
align-items: flex-start;
justify-content: space-between;
gap: 12px;
}
.tm-ai-agent {
display: flex;
gap: 10px;
align-items: center;
}
.tm-ai-agent-avatar {
width: 26px;
height: 26px;
border-radius: 0;
background: #2b8a57;
display: inline-flex;
align-items: center;
justify-content: center;
font-weight: 800;
font-size: 11px;
color: #ffffff;
}
.tm-ai-header-title {
display: flex;
flex-direction: column;
gap: 1px;
}
.tm-ai-kicker {
font-size: 9px;
font-weight: 800;
letter-spacing: 0.14em;
color: #2b8a57;
}
.tm-ai-title {
font-size: 15px;
font-weight: 800;
color: #183525;
}
.tm-ai-subtitle,
.tm-ai-header-meta {
font-size: 11px;
color: #607064;
}
.tm-ai-header-meta {
display: none;
}
.tm-ai-close {
width: 28px;
height: 28px;
border: 1px solid #d7e0d3;
border-radius: 0;
background: #ffffff;
color: #385042;
cursor: pointer;
transition: background-color 0.18s ease, border-color 0.18s ease, color 0.18s ease;
}
.tm-ai-close:hover {
background: #f3f8f1;
border-color: #c6d7c1;
color: #183525;
}
.tm-ai-context {
margin-top: 8px;
padding: 8px;
border-radius: 0;
background: #f7fbf5;
border: 1px solid #dbe7d8;
}
.tm-ai-context-head {
display: flex;
align-items: center;
justify-content: space-between;
gap: 10px;
margin-bottom: 6px;
}
.tm-ai-context-title {
font-size: 11px;
font-weight: 700;
}
.tm-ai-context-tag {
padding: 2px 6px;
border-radius: 0;
background: #edf6ee;
color: #2b8a57;
font-size: 10px;
}
.tm-ai-context-grid {
display: grid;
grid-template-columns: repeat(3, minmax(0, 1fr));
gap: 6px;
}
.tm-ai-context-item {
padding: 6px 8px;
border-radius: 0;
background: #ffffff;
border: 1px solid #e0e9dd;
}
.tm-ai-context-item span {
display: block;
font-size: 9px;
letter-spacing: 0.06em;
color: #6b7c6e;
}
.tm-ai-context-item strong {
display: block;
margin-top: 2px;
font-size: 12px;
line-height: 1.35;
word-break: break-word;
color: #173321;
}
.tm-ai-context-actions,
.tm-ai-suggestions {
display: flex;
flex-wrap: wrap;
gap: 6px;
margin-top: 8px;
}
.tm-ai-context-btn,
.tm-ai-suggestion {
padding: 6px 10px;
border-radius: 0;
border: 1px solid #d5e1d2;
background: #ffffff;
color: #355040;
font-size: 11px;
cursor: pointer;
transition: border-color 0.18s ease, background-color 0.18s ease, color 0.18s ease, transform 0.18s ease;
}
.tm-ai-context-btn:hover,
.tm-ai-suggestion:hover {
background: #f2f8f1;
border-color: #bfd3ba;
color: #2b8a57;
transform: translateY(-1px);
}
.tm-ai-suggestions {
display: none;
}
.tm-ai-body {
flex: 1;
overflow: auto;
padding: 14px;
background: #fcfefb;
}
.tm-ai-list {
display: flex;
flex-direction: column;
gap: 10px;
}
.tm-ai-row {
display: flex;
gap: 10px;
align-items: flex-end;
}
.tm-ai-row-user {
justify-content: flex-end;
}
.tm-ai-bubble-avatar {
width: 22px;
height: 22px;
border-radius: 0;
display: inline-flex;
align-items: center;
justify-content: center;
font-size: 10px;
font-weight: 800;
flex-shrink: 0;
}
.tm-ai-row-assistant .tm-ai-bubble-avatar {
background: #edf6ee;
color: #2b8a57;
}
.tm-ai-row-user .tm-ai-bubble-avatar {
background: #7ca986;
color: #ffffff;
order: 2;
}
.tm-ai-message {
max-width: 88%;
border-radius: 0;
padding: 10px 12px;
line-height: 1.68;
white-space: pre-wrap;
word-break: break-word;
font-size: 13px;
}
.tm-ai-message-assistant {
background: #ffffff;
color: #223428;
border: 1px solid #dbe6d8;
}
.tm-ai-message-user {
background: #eef6ee;
color: #284233;
border: 1px solid #dbe7d8;
}
.tm-ai-message h1,
.tm-ai-message h2,
.tm-ai-message h3 {
margin: 0 0 8px;
color: inherit;
line-height: 1.45;
}
.tm-ai-message h1 { font-size: 16px; }
.tm-ai-message h2 { font-size: 15px; }
.tm-ai-message h3 { font-size: 14px; }
.tm-ai-message p {
margin: 0 0 8px;
}
.tm-ai-message p:last-child,
.tm-ai-message ul:last-child,
.tm-ai-message ol:last-child,
.tm-ai-message pre:last-child {
margin-bottom: 0;
}
.tm-ai-message ul,
.tm-ai-message ol {
margin: 0 0 8px;
padding-left: 18px;
}
.tm-ai-message li + li {
margin-top: 4px;
}
.tm-ai-message a {
color: #2f7d55;
text-decoration: underline;
}
.tm-ai-message strong {
font-weight: 700;
}
.tm-ai-message code {
padding: 1px 5px;
background: #f1f5ef;
border: 1px solid #dde7da;
font-family: Consolas, "Courier New", monospace;
font-size: 12px;
}
.tm-ai-pre {
margin: 0 0 8px;
padding: 10px 12px;
overflow: auto;
background: #f4f8f2;
border: 1px solid #dbe6d8;
}
.tm-ai-pre code {
padding: 0;
border: none;
background: transparent;
display: block;
line-height: 1.55;
}
.tm-ai-footer {
padding: 10px 12px 12px;
border-top: 1px solid #dfe8dd;
background: #fbfdf9;
}
.tm-ai-status {
min-height: 14px;
margin-bottom: 6px;
color: #647266;
font-size: 11px;
}
.tm-ai-composer {
border: 1px solid #d6e1d3;
border-radius: 0;
background: #ffffff;
overflow: hidden;
}
.tm-ai-input {
width: 100%;
min-height: 72px;
resize: none;
padding: 10px 12px;
border: none;
background: transparent;
color: #173324;
outline: none;
font-size: 13px;
line-height: 1.6;
}
.tm-ai-composer:focus-within {
border-color: #2b8a57;
box-shadow: 0 0 0 3px rgba(43, 138, 87, 0.1);
}
.tm-ai-actions {
display: flex;
align-items: center;
justify-content: flex-end;
gap: 12px;
padding: 0 10px 10px;
}
.tm-ai-hint {
display: none;
}
.tm-ai-send {
min-width: 86px;
height: 34px;
border: none;
border-radius: 0;
background: #4a9969;
color: #fff;
cursor: pointer;
font-weight: 700;
}
.tm-ai-send[disabled],
.tm-ai-input[disabled] {
opacity: 0.65;
cursor: not-allowed;
}
@media (max-width: 768px) {
.tm-ai-assistant {
right: 12px;
left: 12px;
bottom: 12px;
}
.tm-ai-toggle {
width: 100%;
justify-content: center;
}
.tm-ai-panel {
width: 100%;
height: min(76vh, 720px);
}
.tm-ai-context-grid {
grid-template-columns: repeat(2, minmax(0, 1fr));
}
.tm-ai-actions {
flex-direction: row;
justify-content: flex-end;
}
.tm-ai-send {
width: auto;
}
}
`;
document.head.appendChild(style);
};
const root = document.createElement('section');
root.className = 'tm-ai-assistant';
root.innerHTML = `
<button type="button" class="tm-ai-toggle" aria-label="打开在线客服">
<span class="tm-ai-toggle-badge">客</span>
<span class="tm-ai-toggle-copy">
<strong>票务客服台</strong>
<span>咨询订票、票号与凭证</span>
</span>
</button>
<div class="tm-ai-panel" aria-live="polite">
<div class="tm-ai-header">
<div class="tm-ai-header-row">
<div class="tm-ai-agent">
<div class="tm-ai-agent-avatar">客服</div>
<div class="tm-ai-header-title">
<span class="tm-ai-kicker">FSE TICKET SERVICE DESK</span>
<strong class="tm-ai-title">票务服务台</strong>
<span class="tm-ai-subtitle">当前页面:${pageName}</span>
</div>
</div>
<button type="button" class="tm-ai-close" aria-label="关闭在线客服">×</button>
</div>
<div class="tm-ai-context"></div>
<div class="tm-ai-suggestions"></div>
</div>
<div class="tm-ai-body">
<div class="tm-ai-list"></div>
</div>
<div class="tm-ai-footer">
<div class="tm-ai-status"></div>
<div class="tm-ai-composer">
<textarea class="tm-ai-input" placeholder="例如:帮我解释当前凭证为什么显示可使用?"></textarea>
<div class="tm-ai-actions">
<div class="tm-ai-hint">客服会自动读取当前页面中的票号、凭证码和主要票务信息。</div>
<button type="button" class="tm-ai-send">发送</button>
</div>
</div>
</div>
</div>
`;
ensureStyle();
body.appendChild(root);
const toggleButton = root.querySelector('.tm-ai-toggle');
const closeButton = root.querySelector('.tm-ai-close');
const list = root.querySelector('.tm-ai-list');
const suggestions = root.querySelector('.tm-ai-suggestions');
const contextBox = root.querySelector('.tm-ai-context');
const input = root.querySelector('.tm-ai-input');
const sendButton = root.querySelector('.tm-ai-send');
const statusText = root.querySelector('.tm-ai-status');
const bodyBox = root.querySelector('.tm-ai-body');
const renderMessage = (role, text) => {
const row = document.createElement('div');
row.className = `tm-ai-row tm-ai-row-${role}`;
row.innerHTML = `
<span class="tm-ai-bubble-avatar">${role === 'assistant' ? '服' : '我'}</span>
<div class="tm-ai-message tm-ai-message-${role}"></div>
`;
const messageBox = row.querySelector('.tm-ai-message');
if (role === 'assistant') messageBox.innerHTML = markdownToHtml(text);
else messageBox.textContent = text;
list.appendChild(row);
bodyBox.scrollTop = bodyBox.scrollHeight;
};
const setStatus = (text) => {
statusText.textContent = text || '';
};
const pushHistory = (role, text) => {
history.push({ role, content: text });
if (history.length > 12) history.splice(0, history.length - 12);
};
const openPanel = () => {
root.classList.add('is-open');
refreshContextUI();
window.setTimeout(() => input.focus(), 20);
};
const closePanel = () => {
root.classList.remove('is-open');
};
const setSending = (value) => {
sending = value;
input.disabled = value;
sendButton.disabled = value;
setStatus(value ? '客服正在整理当前页面信息并生成答复...' : '');
};
const sendPreset = (prompt) => {
input.value = prompt;
openPanel();
input.focus();
};
const renderSuggestions = () => {
const items = suggestionMap[pageName] || suggestionMap['首页'];
suggestions.innerHTML = '';
items.forEach((text) => {
const button = document.createElement('button');
button.type = 'button';
button.className = 'tm-ai-suggestion';
button.textContent = text;
button.addEventListener('click', () => sendPreset(text));
suggestions.appendChild(button);
});
};
const renderContext = (context) => {
const entries = Object.entries(context || {})
.filter(([key, value]) => key !== 'page_name' && compact(value))
.slice(0, 6);
const actionItems = buildContextActions(context);
if (!entries.length) {
contextBox.innerHTML = `
<div class="tm-ai-context-head">
<span class="tm-ai-context-title">当前页面识别</span>
<span class="tm-ai-context-tag">未识别到票据</span>
</div>
<div class="tm-ai-context-item">
<span>提示</span>
<strong>客服会在你打开凭证详情、票据详情或输入检索内容后自动读取并辅助解释。</strong>
</div>
`;
return;
}
const labelMap = {
voucher_code: '凭证码',
ticket_id: '票号',
query_keyword: '检索内容',
status: '当前状态',
start: '起点',
terminal: '终点',
selected_start: '所选起点',
selected_terminal: '所选终点',
trips: '乘次数量',
train_type: '车型',
price: '票价',
ride_date: '乘车日期',
trips_summary: '乘次信息',
last_update: '最近更新',
redeem_tip: '兑票说明'
};
contextBox.innerHTML = `
<div class="tm-ai-context-head">
<span class="tm-ai-context-title">当前页面识别</span>
<span class="tm-ai-context-tag">已同步票务上下文</span>
</div>
<div class="tm-ai-context-grid">
${entries.map(([key, value]) => `
<div class="tm-ai-context-item">
<span>${labelMap[key] || key}</span>
<strong>${compact(value, 80)}</strong>
</div>
`).join('')}
</div>
${actionItems.length ? `
<div class="tm-ai-context-actions">
${actionItems.map((item, index) => `<button type="button" class="tm-ai-context-btn" data-context-action="${index}">${item.label}</button>`).join('')}
</div>
` : ''}
`;
Array.from(contextBox.querySelectorAll('[data-context-action]')).forEach((button) => {
button.addEventListener('click', () => {
const idx = Number(button.getAttribute('data-context-action'));
const item = actionItems[idx];
if (item) sendPreset(item.prompt);
});
});
};
const refreshContextUI = () => {
const context = collectPageContext();
const signature = toContextSignature(context);
if (signature === lastContextSignature && contextBox.innerHTML) return;
lastContextSignature = signature;
renderContext(context);
};
const sendMessage = async () => {
const message = String(input.value || '').trim();
if (!message || sending) return;
const requestHistory = history.slice();
const context = collectPageContext();
renderMessage('user', message);
pushHistory('user', message);
input.value = '';
setSending(true);
try {
const response = await fetch('/api/ai-assistant', {
method: 'POST',
headers: {
'Content-Type': 'application/json'
},
body: JSON.stringify({
message,
history: requestHistory,
page: pageName,
context
})
});
const data = await response.json().catch(() => ({}));
if (!response.ok || !data.ok || !data.reply) {
throw new Error(data.error || '在线客服暂时无法回答,请稍后重试。');
}
renderMessage('assistant', data.reply);
pushHistory('assistant', data.reply);
} catch (error) {
const fallback = error?.message || '在线客服暂时不可用,请稍后重试。';
renderMessage('assistant', fallback);
pushHistory('assistant', fallback);
} finally {
setSending(false);
refreshContextUI();
input.focus();
}
};
toggleButton.addEventListener('click', () => {
if (root.classList.contains('is-open')) closePanel();
else openPanel();
});
closeButton.addEventListener('click', closePanel);
sendButton.addEventListener('click', sendMessage);
input.addEventListener('keydown', (event) => {
if (event.key === 'Enter' && !event.shiftKey) {
event.preventDefault();
sendMessage();
} else if (event.key === 'Escape') {
closePanel();
}
});
const observer = new MutationObserver(() => {
window.clearTimeout(observer.__tmTimer);
observer.__tmTimer = window.setTimeout(refreshContextUI, 180);
});
observer.observe(document.body, { childList: true, subtree: true, characterData: true });
renderMessage('assistant', `你好,这里是 FSE 票务在线客服。你现在位于“${pageName}”页面,我会自动读取当前票号、凭证码或检索内容,帮你解释状态、使用方法和下一步操作。`);
pushHistory('assistant', `你好,这里是 FSE 票务在线客服。你现在位于“${pageName}”页面,我会自动读取当前票号、凭证码或检索内容,帮你解释状态、使用方法和下一步操作。`);
renderSuggestions();
refreshContextUI();
})();