@@ -1,916 +0,0 @@
( ( ) => {
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 , '"' )
. replace ( /'/g , ''' ) ;
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 + / . t e s t ( l i n e ) ) ) {
return ` <ul> ${ lines . map ( ( line ) => ` <li> ${ renderInlineMarkdown ( line . replace ( /^[-*]\s+/ , '' ) ) } </li> ` ) . join ( '' ) } </ul> ` ;
}
if ( lines . every ( ( line ) => / ^ \ d + \ . \ s + / . t e s t ( l i n e ) ) ) {
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.is-folded .tm-ai-toggle-copy {
display: none;
}
.tm-ai-toggle.is-folded .tm-ai-fold-btn i {
transform: rotate(180deg);
}
.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>
<span class="tm-ai-fold-btn" title="折叠" style="margin-left: 8px; padding: 4px; color: var(--muted); border: 1px solid var(--border); border-radius: 0;">
<i class="fas fa-chevron-right"></i>
</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 ( ) ;
}
} ;
const foldBtn = root . querySelector ( '.tm-ai-fold-btn' ) ;
foldBtn . addEventListener ( 'click' , ( e ) => {
e . stopPropagation ( ) ;
toggleButton . classList . toggle ( 'is-folded' ) ;
} ) ;
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 ( ) ;
} ) ( ) ;