(() => { 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, '>') .replace(/"/g, '"') .replace(/'/g, '''); const renderInlineMarkdown = (text) => escapeHtml(text) .replace(/`([^`]+)`/g, '$1') .replace(/\*\*([^*]+)\*\*/g, '$1') .replace(/\*([^*]+)\*/g, '$1') .replace(/\[([^\]]+)\]\((https?:\/\/[^\s)]+)\)/g, '$1'); 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( `
${escapeHtml(code.trim())}
` ); 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 ``; } if (lines.every((line) => /^\d+\.\s+/.test(line))) { return `
    ${lines.map((line) => `
  1. ${renderInlineMarkdown(line.replace(/^\d+\.\s+/, ''))}
  2. `).join('')}
`; } if (lines.length === 1 && /^###\s+/.test(lines[0])) return `

${renderInlineMarkdown(lines[0].replace(/^###\s+/, ''))}

`; if (lines.length === 1 && /^##\s+/.test(lines[0])) return `

${renderInlineMarkdown(lines[0].replace(/^##\s+/, ''))}

`; if (lines.length === 1 && /^#\s+/.test(lines[0])) return `

${renderInlineMarkdown(lines[0].replace(/^#\s+/, ''))}

`; return `

${lines.map((line) => renderInlineMarkdown(line)).join('
')}

`; }).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 = `
客服
FSE TICKET SERVICE DESK 票务服务台 当前页面:${pageName}
`; 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 = ` ${role === 'assistant' ? '服' : '我'}
`; 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 = `
当前页面识别 未识别到票据
提示 客服会在你打开凭证详情、票据详情或输入检索内容后自动读取并辅助解释。
`; 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 = `
当前页面识别 已同步票务上下文
${entries.map(([key, value]) => `
${labelMap[key] || key} ${compact(value, 80)}
`).join('')}
${actionItems.length ? `
${actionItems.map((item, index) => ``).join('')}
` : ''} `; 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(); })();