Files
baiyue-logistics-chat/templates/index.html
T

2636 lines
91 KiB
HTML
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.
<!DOCTYPE html>
<html lang="zh-CN">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>柏悦智能物流客服系统</title>
<link rel="icon" href="{{ url_for('static', filename='fa.jpg') }}">
<style>
* {
margin: 0;
padding: 0;
box-sizing: border-box;
}
body {
font-family: "Microsoft YaHei", sans-serif;
margin: 0;
padding: 40px;
min-height: 100vh;
display: flex;
justify-content: center;
background: linear-gradient(135deg, #0c2042 0%, #12346b 50%, #174894 100%);
background-attachment: fixed;
overflow-x: hidden;
position: relative;
}
.bg-wrap{
position: fixed;
left:0;
top:0;
width:100%;
height:100%;
z-index:-1;
overflow:hidden;
}
.meteor{
position:absolute;
width:180px;
height:2px;
background:linear-gradient(90deg, rgba(255,255,255,0), #b8d0ff, #fff);
border-radius: 100px;
filter: drop-shadow(0 0 4px #72a6ff);
transform: rotate(30deg);
animation: shoot 7s linear infinite;
}
.meteor:nth-child(1){ top:-10%; left:-10%; animation-delay:0s; }
.meteor:nth-child(2){ top:-10%; left:-10%; animation-delay:1.8s; }
.meteor:nth-child(3){ top:-10%; left:-10%; animation-delay:3.2s; }
.meteor:nth-child(4){ top:-10%; left:-10%; animation-delay:4.5s; }
@keyframes shoot {
0% {
transform: rotate(30deg) translateX(0);
opacity:0;
}
10% { opacity: 1; }
90% { opacity: 1; }
100% {
transform: rotate(30deg) translateX(1400px);
opacity:0;
}
}
.ship-silhouette{
position:absolute;
bottom: 0px;
left:0;
width:100%;
height:120px;
opacity:0.22;
}
.chat-container {
width: 1100px;
max-width: 100%;
height: 680px;
display: flex;
box-shadow: 0 8px 32px rgba(0, 0, 0, 0.35);
border-radius:14px;
overflow:hidden;
background: rgba(255,255,255,0.92);
backdrop-filter: blur(8px);
}
.left-panel {
width: 260px;
flex-shrink: 0;
background: linear-gradient(160deg, #12356d 0%, #1a53b3 100%);
color:white;
padding:22px 16px;
display:flex;
flex-direction:column;
max-height:100%;
overflow-y:auto;
}
.new-chat-btn{
width:100%;
padding:10px;
border-radius:10px;
border:none;
background:rgba(255,255,255,0.2);
color:#fff;
cursor:pointer;
margin-bottom:16px;
}
.clear-all-btn{
width:100%;
padding:10px;
border-radius:10px;
border:none;
background:rgba(255,100,100,0.25);
color:#fff;
cursor:pointer;
margin-bottom:16px;
font-size:13px;
}
.clear-all-btn:hover{
background:rgba(255,80,80,0.45);
}
.history-wrap{
flex: 1;
overflow-y: auto;
}
.history-item {
border: 1px solid rgba(255,255,255,.22);
border-radius: 12px;
padding: 11px 13px;
margin-bottom: 8px;
background: rgba(255,255,255,.14);
cursor: pointer;
backdrop-filter: blur(4px);
font-size:13px;
display: flex;
justify-content: space-between;
align-items: flex-start;
gap:8px;
position: relative;
transition: all 0.25s ease;
}
.history-text-wrap{
flex:1;
min-width:0;
white-space: nowrap;
overflow: hidden;
text-overflow: ellipsis;
transition: all 0.25s ease;
}
.history-time{
font-size:11px;
color:rgba(255,255,255,0.72);
margin-top:4px;
display:none;
}
.history-item:hover .history-time{
display:block;
}
.del-history-btn{
width:20px;
height:20px;
flex-shrink:0;
border:none;
background:rgba(255,255,255,0.22);
color:#fff;
border-radius:50%;
font-size:14px;
line-height:1;
cursor:pointer;
padding:0;
}
.del-history-btn:hover{
background:rgba(255,80,80,0.7);
}
/* 删除按钮默认隐藏,鼠标移到这一条上才淡入 */
.del-history-btn{
opacity:0;
pointer-events:none;
transition:opacity 0.15s ease, background 0.2s ease;
}
.history-item:hover .del-history-btn,
.history-item:focus-within .del-history-btn{
opacity:1;
pointer-events:auto;
}
/* 触屏设备没有 hover,保持常显,不然删不掉 */
@media (hover: none){
.del-history-btn{
opacity:0.85;
pointer-events:auto;
}
}
.history-time{
font-size:11px;
color:rgba(255,255,255,0.72);
margin-top:4px;
}
.panel-title {
font-size: 13px;
color: rgba(255,255,255,.85);
margin: 12px 0 8px 4px;
}
/* 历史搜索框 */
.history-search-wrap{
margin-bottom:10px;
}
.history-search{
width:100%;
padding:8px 12px;
border-radius:10px;
border:1px solid rgba(255,255,255,.25);
background:rgba(255,255,255,.14);
color:#fff;
font-size:13px;
outline:none;
transition:all .2s;
}
.history-search::placeholder{
color:rgba(255,255,255,.6);
}
.history-search:focus{
border-color:rgba(255,255,255,.65);
background:rgba(255,255,255,.22);
}
.history-empty{
font-size:12px;
color:rgba(255,255,255,.6);
text-align:center;
padding:14px 0;
}
/* 深色模式切换按钮 */
.theme-toggle{
width:34px;
height:34px;
border-radius:50%;
border:1px solid #dde7f3;
background:#f5f9ff;
font-size:16px;
line-height:1;
cursor:pointer;
transition:all .2s;
}
.title-actions{
display:flex;
align-items:center;
gap:8px;
}
.lang-toggle{
font-size:13px;
font-weight:600;
color:#18479c;
}
body.dark .lang-toggle{
color:#9dc0f5;
}
.theme-toggle:hover{
background:#e8f1ff;
border-color:#2978e8;
}
/* ===== 深色模式 ===== */
body.dark .main-chat{
background:#141a26;
}
body.dark .chat-title{
background:#1b2331;
border-bottom-color:#2a3547;
color:#e6ecf5;
}
body.dark .theme-toggle{
background:#26313f;
border-color:#3a4759;
}
body.dark #chatDom{
background-color:#141a26;
}
body.dark .ai-welcome-card,
body.dark .hot-questions,
body.dark .recent-chat{
background:#1b2331;
box-shadow:0 6px 20px rgba(0,0,0,.35);
}
body.dark .ai-welcome-top h3,
body.dark .hot-questions h4,
body.dark .recent-chat h4{
color:#e6ecf5;
}
body.dark .ai-welcome-top p{
color:#8b9ab0;
}
body.dark .suggest-item{
background:#222c3c;
border-color:#2f3b4e;
color:#9dc0f5;
}
body.dark .suggest-item:hover{
background:#2978e8;
color:#fff;
border-color:#2978e8;
}
body.dark .hot-capsule{
background:#222c3c;
border-color:#2f3b4e;
color:#c3cede;
}
body.dark .hot-capsule:hover{
border-color:#2978e8;
background:#24344f;
}
body.dark .recent-item{
color:#c3cede;
border-bottom-color:#2a3547;
}
body.dark .recent-item:hover{
background:#222c3c;
}
body.dark .robot-message{
background:#1f2937;
color:#dbe4f0;
box-shadow:0 4px 14px rgba(0,0,0,.3);
}
body.dark .robot-message.error-bubble{
background:#2a1d1d;
border-color:#5c3232;
}
body.dark .robot-message.hint-bubble,
body.dark .robot-message.track-card{
background:#1b2331;
border-color:#2f3b4e;
}
body.dark .track-no{
color:#dbe4f0;
}
body.dark .track-node{
color:#a8b5c8;
}
body.dark .track-node.active .track-text{
color:#7fb2ff;
}
body.dark .track-node::before{
background:#2f3b4e;
}
body.dark .track-dot{
background:#1b2331;
border-color:#42536b;
}
body.dark .track-head{
border-bottom-color:#2a3547;
}
body.dark .input-area{
background:#1b2331;
border-top-color:#2a3547;
}
body.dark #input-text{
background:#141a26;
border-color:#2f3b4e;
color:#e6ecf5;
}
body.dark #input-text::placeholder{
color:#6b7a90;
}
body.dark .retry-btn{
color:#9dc0f5;
background:#24344f;
border-color:#3a5578;
}
body.dark .retry-btn:hover{
background:#2978e8;
color:#fff;
}
.main-chat{
flex-grow:1;
display:flex;
flex-direction:column;
background:#eef3fb;
position:relative;
}
.chat-title{
padding:14px 20px;
font-size:18px;
border-bottom:solid 1px #dde7f3;
background:#ffffff;
display:flex;
align-items:center;
justify-content:space-between;
}
.chat-title .ai-icon{
width:28px;
height:28px;
border-radius:50%;
background:linear-gradient(135deg,#18479c,#2978e8);
display:inline-flex;
align-items:center;
justify-content:center;
color:white;
font-size:14px;
margin-right:8px;
}
#chatDom{
position:relative;
flex-grow:1;
padding:30px 24px;
overflow-y:auto;
background-color:#eef3fb;
}
.ai-welcome-card{
background:linear-gradient(135deg,#ffffff 0%,#f0f6ff 100%);
border-radius:18px;
padding:24px;
box-shadow:0 6px 20px rgba(199,216,245,.5);
border-top:4px solid #2978e8;
margin-bottom:22px;
}
.ai-welcome-top{
display:flex;
align-items:center;
margin-bottom:14px;
}
.ai-avatar{
width:46px;
height:46px;
border-radius:50%;
background:linear-gradient(135deg,#18479c,#2978e8);
display:flex;
align-items:center;
justify-content:center;
color:white;
font-size:20px;
margin-right:14px;
}
.ai-welcome-top h3{
font-size:18px;
color:#222;
}
.ai-welcome-top p{
margin:4px 0 0 0;
font-size:13px;
color:#7f8fa6;
}
.suggest-grid{
display:grid;
grid-template-columns:1fr 1fr;
gap:10px;
margin-bottom:18px;
}
.suggest-item{
background:#f5f9ff;
border:1px solid #dce8fb;
border-radius:12px;
padding:12px;
font-size:13px;
color:#18479c;
cursor:pointer;
transition:0.2s;
}
.suggest-item:hover{
background:#18479c;
color:white;
border-color:#18479c;
}
.hot-questions,.recent-chat{
background:#ffffff;
border-radius:18px;
padding:20px;
box-shadow:0 6px 20px rgba(199,216,245,.5);
margin-bottom:22px;
}
.hot-questions h4,.recent-chat h4{
font-size:15px;
color:#222;
margin-bottom:12px;
display:flex;
align-items:center;
}
.hot-questions h4::before,.recent-chat h4::before{
content:"";
width:4px;
height:14px;
background:#2978e8;
border-radius:2px;
margin-right:8px;
}
/* 胶囊标签样式 */
.hot-capsule-wrap{
display:flex;
flex-wrap:wrap;
gap:10px;
}
.hot-capsule{
padding:8px 14px;
background:#f7faff;
border:1px solid #e4efff;
border-radius:999px;
font-size:13px;
color:#333;
cursor:pointer;
transition:0.2s;
}
.hot-capsule:hover{
border-color:#2978e8;
background:#e8f1ff;
}
.hot-bar{
display:flex;
align-items:center;
gap:10px;
flex-wrap:wrap;
padding:10px 20px 0;
}
.send-order-btn{
padding:6px 14px;
border-radius:18px;
background:#fff;
border:1px solid #ffb347;
color:#e0882a;
font-size:13px;
cursor:pointer;
white-space:nowrap;
font-weight:500;
}
.send-order-btn:hover{
background:#fff4e0;
}
.hot-bar-label{
font-size:12px;
color:#9aa7ba;
flex-shrink:0;
}
.hot-bar .hot-capsule{
padding:4px 12px;
font-size:12px;
}
.disclaimer{
text-align:center;
font-size:11px;
color:#aab4c4;
padding:4px 0 6px;
}
.robot-mascot{
position:fixed;
right:20px;
bottom:80px;
width:110px;
height:55px;
overflow:hidden;
z-index:9999;
filter:drop-shadow(0 4px 10px rgba(80,110,200,0.3));
cursor:grab;
transition:height 0.3s ease;
}
.robot-mascot:hover{
height:110px;
}
.robot-mascot svg{
width:110px;
height:110px;
display:block;
}
@keyframes robotFloat{
0%,100%{ transform:translateY(0); }
50%{ transform:translateY(-8px); }
}
.eye-pupils{
animation:eyeLook 4s ease-in-out infinite;
}
@keyframes eyeLook{
0%,100%{ transform:translate(0,0); }
25%{ transform:translate(3px,1px); }
50%{ transform:translate(-3px,1px); }
75%{ transform:translate(0,-2px); }
}
.antenna-light{
animation:blink 1.5s ease-in-out infinite;
}
@keyframes blink{
0%,100%{ opacity:1; }
50%{ opacity:0.3; }
}
.wave-arm{
animation:wave 2s ease-in-out infinite;
}
@keyframes wave{
0%,100%{ transform:rotate(0deg); }
50%{ transform:rotate(-25deg); }
}
.recent-item{
display:flex;
justify-content:space-between;
padding:10px 12px;
border-bottom:1px dashed #e4ebf5;
font-size:13px;
color:#333;
cursor:pointer;
}
.recent-item:last-child{
border-bottom:none;
}
.recent-item:hover{
background:#f7faff;
}
.recent-date{
color:#9aa7bd;
font-size:12px;
}
.msg-row{
margin-bottom:22px;
}
.user-line{
display:flex;
justify-content:flex-end;
}
.robot-line{
display:flex;
justify-content:flex-start;
}
.user-message{
max-width:72%;
background:linear-gradient(135deg,#18479c,#2978e8);
color:white;
padding:14px 18px;
border-radius:14px 14px 0 14px;
box-shadow:0 4px 14px rgba(41,120,232,0.3);
line-height:1.6;
white-space:pre-wrap;
word-break:break-word;
overflow-wrap:anywhere;
}
.robot-message{
max-width:78%;
min-width:0;
background:#ffffff;
padding:14px 18px;
border-radius:14px 14px 14px 0;
box-shadow:0 4px 14px rgba(199,216,245,.4);
color:#333;
line-height:1.6;
/* 关键:长英文/长数字/无空格内容也要换行,不能用 nowrap */
white-space:normal;
word-break:break-word;
overflow-wrap:anywhere;
}
.msg-time{
font-size:11px;
color:#9aa7ba;
margin-top:4px;
opacity:0.7;
}
.user-line .msg-time{
text-align:right;
}
.input-area{
display:flex;
padding:16px 20px;
gap:12px;
border-top:solid 1px #dde7f3;
background:#ffffff;
align-items:flex-end;
position:relative;
}
#input-text:focus{
border-color:#2978e8;
box-shadow:0 0 0 3px rgba(41,120,232,0.15);
}
#send-button{
width:64px;
height:44px;
border:none;
border-radius:22px;
background:linear-gradient(135deg,#18479c,#2978e8);
color:white;
font-size:18px;
cursor:pointer;
transition:0.2s;
display:flex;
align-items:center;
justify-content:center;
}
#upload-btn{
width:44px;
height:44px;
border:none;
border-radius:50%;
background:#eef3fb;
font-size:18px;
cursor:pointer;
flex-shrink:0;
}
#upload-btn:hover{
background:#dde7f5;
}
#send-button:hover{
transform:scale(1.05);
box-shadow:0 4px 12px rgba(41,120,232,0.4);
}
.robot-message.typing::after{
content:"▍";
margin-left:2px;
animation: blink 1s steps(1) infinite;
}
@keyframes blink{
50%{ opacity:0; }
}
.robot-message.error-bubble{
background:#fff7f6;
border:1px solid #f3c9c4;
}
.err-line{
color:#c0392b;
font-size:14px;
line-height:1.6;
word-break:break-word;
}
.retry-btn{
margin-top:10px;
padding:4px 14px;
font-size:12px;
color:#18479c;
background:#eef4ff;
border:1px solid #c9dcff;
border-radius:999px;
cursor:pointer;
transition:0.2s;
}
.retry-btn:hover{
background:#18479c;
color:#fff;
border-color:#18479c;
}
.robot-message.hint-bubble{
background:#f7f9fd;
border:1px dashed #cfd9e8;
color:#6b7a90;
font-size:13px;
}
/* ===== 滚动条美化 ===== */
/* Firefox:只认这两个属性 */
.left-panel, .history-wrap{
scrollbar-width: thin;
scrollbar-color: rgba(255,255,255,0.28) transparent;
}
#chatDom{
scrollbar-width: thin;
scrollbar-color: rgba(24,71,156,0.28) transparent;
}
/* Chrome / Edge:webkit 伪元素 */
::-webkit-scrollbar{
width:8px;
height:8px;
}
::-webkit-scrollbar-track{
background:transparent;
}
::-webkit-scrollbar-thumb{
background:rgba(24,71,156,0.25);
border-radius:10px;
}
::-webkit-scrollbar-thumb:hover{
background:rgba(24,71,156,0.45);
}
.left-panel::-webkit-scrollbar-thumb,
.history-wrap::-webkit-scrollbar-thumb{
background:rgba(255,255,255,0.25);
}
.left-panel::-webkit-scrollbar-thumb:hover,
.history-wrap::-webkit-scrollbar-thumb:hover{
background:rgba(255,255,255,0.45);
}
/* ===== 运单轨迹卡片 ===== */
.robot-message.track-card{
padding:16px 18px;
min-width:320px;
}
.track-head{
display:flex;
align-items:center;
justify-content:space-between;
gap:16px;
padding-bottom:12px;
border-bottom:1px dashed #e4ebf5;
margin-bottom:14px;
}
.track-no{
font-size:14px;
font-weight:600;
color:#2b3a50;
}
.track-no span{
font-weight:400;
font-size:12px;
color:#8a97ab;
margin-right:8px;
}
.track-status{
flex-shrink:0;
font-size:12px;
color:#fff;
background:linear-gradient(135deg,#18479c,#2978e8);
padding:3px 10px;
border-radius:999px;
}
.track-node{
position:relative;
padding-left:22px;
padding-bottom:16px;
font-size:13px;
color:#5b6b80;
line-height:1.5;
}
.track-node:last-child{
padding-bottom:0;
}
.track-node::before{
content:"";
position:absolute;
left:4px;
top:16px;
bottom:0;
width:1px;
background:#e1e8f2;
}
.track-node:last-child::before{
display:none;
}
.track-dot{
position:absolute;
left:0;
top:5px;
width:8px;
height:8px;
border-radius:50%;
background:#fff;
border:1px solid #c3d1e6;
}
.track-node.active .track-dot{
background:#2978e8;
border-color:#2978e8;
box-shadow:0 0 0 3px rgba(41,120,232,.15);
}
.track-node.active .track-text{
color:#1b3f7a;
font-weight:600;
}
.track-time{
color:#9aa7bd;
font-size:12px;
margin-right:10px;
}
.track-place{
color:#9aa7bd;
font-size:12px;
margin-left:8px;
}
/* ===== 多行输入框 ===== */
#input-text{
flex:1;
min-height:48px;
max-height:140px;
padding:13px 18px;
border:1px solid #d5dce6;
border-radius:20px;
font-size:15px;
line-height:1.5;
font-family:inherit;
outline:none;
resize:none;
overflow-y:auto;
transition:border-color .2s, box-shadow .2s;
}
body.dark #input-text{
resize:none;
}
/* 发送按钮:生成中变成停止按钮 */
#send-button.stop{
background:#e5484d;
font-size:14px;
}
#send-button.stop:hover{
background:#d13b40;
}
/* 每条回答的操作条:复制 / 重答 */
.msg-actions{
display:flex;
gap:6px;
margin-top:8px;
opacity:0;
transition:opacity .15s;
}
/* 卡片里的操作条单独占一行 */
.track-card .msg-actions{
clear:both;
padding-top:6px;
border-top:1px dashed #e4ebf5;
}
.robot-line:hover .msg-actions{
opacity:1;
}
.msg-action-btn{
padding:3px 10px;
font-size:12px;
color:#5b6b80;
background:#f4f7fc;
border:1px solid #e1e8f2;
border-radius:999px;
cursor:pointer;
transition:all .2s;
}
.msg-action-btn:hover{
color:#18479c;
border-color:#2978e8;
background:#eef4ff;
}
body.dark .msg-action-btn{
color:#a8b5c8;
background:#222c3c;
border-color:#2f3b4e;
}
body.dark .msg-action-btn:hover{
color:#fff;
background:#2978e8;
border-color:#2978e8;
}
/* 👍/👎 选中态 */
.msg-action-btn.voted{
color:#fff;
background:#2978e8;
border-color:#2978e8;
}
/* 回答后的相关追问 */
.followup-list{
margin-top:10px;
border-top:1px solid #eef2f8;
padding-top:8px;
}
.followup-item{
display:flex;
justify-content:space-between;
align-items:center;
padding:9px 4px;
font-size:13px;
color:#445;
cursor:pointer;
border-bottom:1px solid #f2f5fa;
}
.followup-item:last-child{ border-bottom:none; }
.followup-item:hover{ color:#18479c; }
.followup-item .arrow{ color:#b6c2d4; }
body.dark .followup-item{ color:#c3cede; border-bottom-color:#2a3547; }
body.dark .followup-item:hover{ color:#fff; }
body.dark .followup-list{ border-top-color:#2a3547; }
/* 已停止生成提示 */
.stopped-hint{
margin-top:8px;
font-size:12px;
color:#9aa7bd;
}
.robot-message ul,
.robot-message ol{
margin:6px 0 6px 22px;
padding:0;
}
.robot-message li{
margin:3px 0;
line-height:1.7;
}
.robot-message code{
background:#eef2f8;
padding:1px 5px;
border-radius:4px;
font-family:Consolas, "Courier New", monospace;
font-size:13px;
}
body.dark .robot-message code{
background:#2a3547;
color:#cfe0f7;
}
/* 回到底部 */
.to-bottom{
position:absolute;
right:26px;
bottom:96px;
width:38px;
height:38px;
border-radius:50%;
border:1px solid #dde7f3;
background:#fff;
color:#18479c;
font-size:18px;
line-height:1;
cursor:pointer;
box-shadow:0 4px 14px rgba(24,71,156,.18);
display:none;
z-index:5;
transition:all .2s;
}
.to-bottom.show{
display:block;
}
.to-bottom:hover{
background:#2978e8;
color:#fff;
border-color:#2978e8;
}
body.dark .to-bottom{
background:#1b2331;
border-color:#2f3b4e;
color:#9dc0f5;
}
.toggle-divider{
width:14px;
background:#1a53b3;
color:#fff;
display:flex;
align-items:center;
justify-content:center;
cursor:pointer;
border-radius:0 6px 6px 0;
user-select:none;
}
.left-panel-hide{
width:0 !important;
overflow:hidden;
padding:0 !important;
}
/* ===== 移动端 / 窄屏适配(放在所有基础样式之后,确保覆盖生效) ===== */
@media (max-width: 1180px){
body{
padding: 16px;
}
.chat-container{
width: 100%;
height: calc(100vh - 32px);
}
}
@media (max-width: 900px){
html, body{
width: 100%;
height: 100%;
padding: 0;
margin: 0;
display: block;
overflow: hidden;
}
.chat-container{
width: 100vw;
max-width: 100vw;
height: 100dvh;
border-radius: 0;
position: relative;
overflow: hidden;
}
/* 侧栏改成抽屉:绝对定位,不再占据文档流宽度 */
.chat-container .left-panel{
position: absolute;
left: 0;
top: 0;
bottom: 0;
z-index: 20;
width: 260px;
max-width: 80vw;
max-height: none;
height: 100%;
box-shadow: 6px 0 24px rgba(0,0,0,.35);
transition: transform .25s ease;
}
.chat-container.side-closed .left-panel{
transform: translateX(-100%);
}
.toggle-divider{
position: absolute;
left: 0;
top: 50%;
transform: translateY(-50%);
z-index: 21;
width: 14px;
height: 56px;
border-radius: 0 8px 8px 0;
}
.chat-container .history-wrap{
max-height: none;
flex: 1;
overflow-y: auto;
}
/* 主聊天区占满,且内部元素一律不超出 */
.main-chat{
width: 100%;
max-width: 100%;
min-width: 0;
overflow: hidden;
}
.chat-title{
padding: 12px 14px;
font-size: 16px;
}
.chat-title > span{
min-width: 0;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
#chatDom{
padding: 16px 12px;
width: 100%;
min-width: 0;
overflow-x: hidden;
}
.ai-welcome-card,
.hot-questions,
.recent-chat{
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
.user-message, .robot-message{
max-width: 88%;
min-width: 0;
}
.input-area{
padding: 10px 12px;
gap: 8px;
width: 100%;
max-width: 100%;
box-sizing: border-box;
}
#input-text{
min-width: 0;
flex: 1 1 auto;
}
#send-button{
width: 68px;
flex: 0 0 68px;
border-radius: 18px;
}
.suggest-grid{
grid-template-columns: 1fr;
}
.to-bottom{
right: 14px;
bottom: 84px;
}
.hot-bar{
padding: 8px 10px 0 !important;
gap: 6px;
flex-wrap: wrap;
}
.hot-capsule{
font-size: 12px;
padding: 5px 10px;
}
.robot-mascot{
right: 8px;
bottom:-40px;
transform: scale(0.7);
transform-origin: bottom right;
}
.robot-mascot:hover{
bottom:10px;
}
.ai-welcome-card{
padding: 16px !important;
}
.welcome-title{
font-size: 20px !important;
}
.suggest-grid{
gap: 8px !important;
}
.suggest-item{
padding: 10px 12px !important;
font-size: 13px !important;
}
.disclaimer{
font-size: 11px !important;
padding: 4px 12px !important;
}
}
</style>
</head>
<body>
<div class="bg-wrap">
<div class="meteor"></div>
<div class="meteor"></div>
<div class="meteor"></div>
<div class="meteor"></div>
<svg class="ship-silhouette" viewBox="0 0 1200 120">
<path fill="#184482" d="M0,80 L120,80 L140,60 L260,60 L280,80 L400,80 L420,70 L550,70 L570,80 L700,80 L720,65 L860,65 L880,80 L1020,80 L1040,72 L1150,72 L1170,80 L1200,80 L1200,120 L0,120 Z"/>
</svg>
</div>
<div class="chat-container">
<div class="left-panel">
<button class="new-chat-btn">新建对话</button>
<button class="clear-all-btn">清空全部历史</button>
<div class="panel-title">历史对话</div>
<div class="history-search-wrap">
<input id="history-search" class="history-search" type="text" placeholder="搜索历史对话..." autocomplete="off">
</div>
<div class="history-wrap" id="historyWrap">
</div>
</div>
<div class="toggle-divider">◀</div>
<div class="main-chat">
<div class="chat-title">
<span><span class="ai-icon">🤖</span><span id="app-title">柏悦物流智能助手</span></span>
<span class="title-actions">
<button id="lang-toggle" class="theme-toggle lang-toggle" title="中英文切换(Ctrl+L)">中</button>
<button id="font-toggle" class="theme-toggle" title="字体大小">A</button>
<button id="theme-toggle" class="theme-toggle" title="切换深色/浅色">🌙</button>
</span>
</div>
<div id="chatDom">
<div class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar">🤖</div>
<div>
<h3>您好,我是柏悦物流智能助手</h3>
<p>很高兴为您提供物流查询、专线咨询、异常处理等服务。</p >
</div>
</div>
<p>您可以直接输入问题,也可以点击下面选项快速提问。</p >
<div class="suggest-grid">
<div class="suggest-item" onclick="askNewTopic('帮我查询物流轨迹')">🚚 查询物流轨迹</div>
<div class="suggest-item" onclick="askNewTopic('美国海运专线报价')">💰 美国海运专线</div>
<div class="suggest-item" onclick="askNewTopic('包裹异常处理')">⚠️ 包裹异常处理</div>
<div class="suggest-item" onclick="showHuman('转接人工')">👤 转接人工客服</div>
</div>
</div>
<div class="hot-questions">
<h4>热点问题</h4>
<div class="hot-capsule-wrap">
<div class="hot-capsule" onclick="askNewTopic('如何查询物流轨迹?')">如何查询物流轨迹?</div>
<div class="hot-capsule" onclick="askNewTopic('美国海运专线时效是多久?')">美国海运专线时效是多久?</div>
<div class="hot-capsule" onclick="askNewTopic('包裹显示异常签收怎么办?')">包裹显示异常签收怎么办?</div>
<div class="hot-capsule" onclick="askNewTopic('如何申请理赔?')">如何申请理赔?</div>
</div>
</div>
<div class="recent-chat">
<h4>最近对话记录</h4>
<div id="recentChatWrap"></div>
</div>
</div>
<div class="hot-bar">
<div class="send-order-btn" onclick="sendOrder()">📦 发运单</div>
<div class="hot-capsule" onclick="askNewTopic('怎么查我的物流轨迹?')">物流轨迹查询</div>
<div class="hot-capsule" onclick="askNewTopic('欧洲海运要多久?')">欧洲海运时效</div>
<div class="hot-capsule" onclick="askNewTopic('寄到美国怎么收费?')">美国海运报价</div>
<div class="hot-capsule" onclick="askNewTopic('包裹丢了或破损怎么办?')">异常与理赔</div>
<div class="hot-capsule" onclick="showHuman()">联系人工</div>
</div>
<div class="input-area">
<button id="upload-btn" title="发送图片">📎</button>
<button id="emoji-btn" title="表情" onclick="document.getElementById('emojiPanel').style.display=document.getElementById('emojiPanel').style.display==='block'?'none':'block'">😊</button>
<div id="emojiPanel" style="display:none;position:absolute;bottom:60px;left:10px;background:#fff;border:1px solid #ddd;border-radius:8px;padding:10px;box-shadow:0 2px 8px rgba(0,0,0,0.15);z-index:99;">
<span onclick="insertEmoji('😊')" style="cursor:pointer;font-size:20px;margin:2px;">😊</span>
<span onclick="insertEmoji('😂')" style="cursor:pointer;font-size:20px;margin:2px;">😂</span>
<span onclick="insertEmoji('👍')" style="cursor:pointer;font-size:20px;margin:2px;">👍</span>
<span onclick="insertEmoji('❤️')" style="cursor:pointer;font-size:20px;margin:2px;">❤️</span>
<span onclick="insertEmoji('🙏')" style="cursor:pointer;font-size:20px;margin:2px;">🙏</span>
<span onclick="insertEmoji('😅')" style="cursor:pointer;font-size:20px;margin:2px;">😅</span>
<span onclick="insertEmoji('😢')" style="cursor:pointer;font-size:20px;margin:2px;">😢</span>
<span onclick="insertEmoji('🎉')" style="cursor:pointer;font-size:20px;margin:2px;">🎉</span>
<span onclick="insertEmoji('📦')" style="cursor:pointer;font-size:20px;margin:2px;">📦</span>
<span onclick="insertEmoji('🚚')" style="cursor:pointer;font-size:20px;margin:2px;">🚚</span>
</div>
<input type="file" id="upload-input" accept="image/*,.pdf" hidden>
<textarea id="input-text" rows="1" placeholder="请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)"></textarea>
<button id="send-button">➤</button>
</div>
<div class="disclaimer">AI 生成内容仅供参考,具体以人工客服回复为准</div>
<div class="robot-mascot" title="柏悦物流小助手">
<svg viewBox="0 0 160 160" width="110" height="110">
<!-- 天线 -->
<line x1="80" y1="8" x2="80" y2="22" stroke="#6b8ce0" stroke-width="3"/>
<circle cx="80" cy="7" r="5" fill="#ffd166" class="antenna-light"/>
<!-- 耳朵 -->
<circle cx="34" cy="62" r="9" fill="#8aa5f0"/>
<circle cx="126" cy="62" r="9" fill="#8aa5f0"/>
<circle cx="34" cy="62" r="4" fill="#dfe8ff"/>
<circle cx="126" cy="62" r="4" fill="#dfe8ff"/>
<!-- 头 -->
<rect x="36" y="20" width="88" height="66" rx="30" fill="#8aa5f0"/>
<!-- 脸屏 -->
<rect x="48" y="34" width="64" height="40" rx="16" fill="#2b3a6b"/>
<!-- 眼睛(半圆月牙眼) -->
<g class="eye-pupils">
<path d="M60 52 Q66 46 72 52" stroke="#a8e6cf" stroke-width="3.5" fill="none" stroke-linecap="round"/>
<path d="M88 52 Q94 46 100 52" stroke="#a8e6cf" stroke-width="3.5" fill="none" stroke-linecap="round"/>
</g>
<!-- 嘴(微笑) -->
<path d="M72 62 Q80 68 88 62" stroke="#a8e6cf" stroke-width="2.5" fill="none" stroke-linecap="round"/>
<!-- 腮红 -->
<ellipse cx="56" cy="62" rx="5" ry="3" fill="#ffb3c1" opacity="0.85"/>
<ellipse cx="104" cy="62" rx="5" ry="3" fill="#ffb3c1" opacity="0.85"/>
<!-- 身体 -->
<rect x="48" y="88" width="64" height="44" rx="18" fill="#8aa5f0"/>
<!-- 肚子 -->
<ellipse cx="80" cy="112" rx="20" ry="14" fill="#dfe8ff"/>
<!-- 胸口小箱子图标 -->
<rect x="70" y="106" width="20" height="14" rx="3" fill="#ffb347"/>
<line x1="70" y1="111" x2="90" y2="111" stroke="#e0882a" stroke-width="1.5"/>
<line x1="80" y1="106" x2="80" y2="120" stroke="#e0882a" stroke-width="1.5"/>
<!-- 招手的手(举着箱子) -->
<g class="wave-arm" style="transform-origin:120px 92px;">
<rect x="118" y="86" width="11" height="24" rx="5.5" fill="#8aa5f0"/>
<rect x="114" y="76" width="18" height="16" rx="3" fill="#ffb347"/>
<line x1="114" y1="82" x2="132" y2="82" stroke="#e0882a" stroke-width="1.5"/>
</g>
<!-- 左手 -->
<rect x="32" y="92" width="11" height="22" rx="5.5" fill="#8aa5f0"/>
<circle cx="37" cy="92" r="6" fill="#8aa5f0"/>
<!-- 脚 -->
<rect x="58" y="130" width="14" height="16" rx="6" fill="#6b8ce0"/>
<rect x="88" y="130" width="14" height="16" rx="6" fill="#6b8ce0"/>
</svg>
</div>
<button id="to-bottom" class="to-bottom" title="回到底部">↓</button>
</div>
</div>
<script>
document.addEventListener('DOMContentLoaded', function(){
// 请求浏览器通知权限
if("Notification" in window && Notification.permission === "default"){
Notification.requestPermission();
}
const chatDom = document.getElementById("chatDom");
const inputDom = document.getElementById("input-text");
window.insertEmoji = function(e){
inputDom.value += e;
inputDom.focus();
document.getElementById("emojiPanel").style.display = "none";
};
const sendBtn = document.getElementById("send-button");
const uploadBtn = document.getElementById("upload-btn");
const uploadInput = document.getElementById("upload-input");
uploadBtn.onclick = ()=> uploadInput.click();
uploadInput.onchange = function(){
const f = uploadInput.files[0];
if(!f) return;
const fd = new FormData();
fd.append("file", f);
uploadBtn.textContent = "⏳";
fetch("/api/upload", {method:"POST", body:fd})
.then(r=>r.json())
.then(data=>{
uploadBtn.textContent = "📎";
if(data.ok){
window.__pendingImg = data.url;
window.__pendingTag = data.is_image ? "[图片]" : "[文件]";
var old = document.getElementById("imgPreview");
if(old) old.remove();
var prev = document.createElement("div");
prev.id = "imgPreview";
prev.style.cssText="margin:0 0 8px 50px;";
if(data.is_image){
prev.innerHTML = '<img src="'+data.url+'" style="max-height:60px;border-radius:6px;border:1px solid #ddd;">';
}else{
prev.innerHTML = '<span style="padding:6px 12px;background:#f0f4fa;border-radius:6px;font-size:13px;">📎 '+data.name+'</span>';
}
var area = document.querySelector(".input-area");
area.insertBefore(prev, area.firstChild);
inputDom.focus();
}else{
alert(data.error || "上传失败");
}
})
.catch(()=>{ uploadBtn.textContent = "📎"; alert("上传失败"); });
uploadInput.value = "";
};
const leftHistoryWrap = document.getElementById("historyWrap");
const recentChatWrap = document.getElementById("recentChatWrap");
const clearAllBtn = document.querySelector('.clear-all-btn');
const newChatBtn = document.querySelector('.new-chat-btn');
window.__busy = false; // 生成中状态,用 window 属性避免作用域问题
// 生成中状态(语言切换等会引用,需提前声明)
// ===== 深色模式 =====
(function initTheme(){
const KEY = "wuliu-theme";
const btn = document.getElementById("theme-toggle");
function apply(mode){
document.body.classList.toggle("dark", mode === "dark");
if(btn) btn.textContent = mode === "dark" ? "☀️" : "🌙";
}
let saved = null;
try{ saved = localStorage.getItem(KEY); }catch(e){}
if(!saved){
saved = window.matchMedia && window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
apply(saved);
if(btn){
btn.onclick = function(){
const next = document.body.classList.contains("dark") ? "light" : "dark";
apply(next);
try{ localStorage.setItem(KEY, next); }catch(e){}
}
}
})();
// ===== 字体大小切换 =====
(function(){
var KEY = "wuliu-fontsize";
var SIZES = [14, 16, 18, 20];
var idx = 1;
try{ var s = localStorage.getItem(KEY); if(s) idx = parseInt(s); }catch(e){}
var btn = document.getElementById("font-toggle");
function apply(){
document.body.style.fontSize = SIZES[idx] + "px";
if(btn) btn.textContent = SIZES[idx] + "px";
}
apply();
if(btn){
btn.onclick = function(){
idx = (idx + 1) % SIZES.length;
apply();
try{ localStorage.setItem(KEY, idx); }catch(e){}
};
}
})();
// ===== 中英文切换 =====
const I18N = {
zh: {
appTitle: "柏悦物流智能助手",
newChat: "新建对话",
clearAll: "清空全部历史",
history: "历史对话",
searchPh: "搜索历史对话...",
confirmClear: "确定清空全部历史对话吗?无法恢复!",
confirmDel: "确定要删除这条历史对话吗?",
emptySearch: "没有匹配的历史对话",
emptyHistory: "暂无历史对话",
thinking: "正在思考中...",
busyRetry: "服务繁忙,请稍后重试",
sessionFail: "会话创建失败,请确认服务已启动",
networkFail: "网络连接中断,请检查网络后重试",
stopped: "已停止生成",
stoppedMark: "(已停止生成)",
inputPh: "请输入您的问题或运单号...(Enter 发送,Shift+Enter 换行)",
send: "发送",
stop: "停止",
copy: "复制",
copied: "已复制",
retry: "重答",
clickRetry: "点击重试",
welcomeTitle: "您好,我是柏悦物流智能助手",
welcomeSub: "很高兴为您提供物流查询、专线咨询、异常处理等服务。",
welcomeTip: "您可以直接输入问题,也可以点击下面选项快速提问。",
hot: "热点问题",
recent: "最近对话记录",
sideCollapse: "收起侧栏",
sideExpand: "展开侧栏",
themeTip: "切换深色/浅色",
langTip: "中英文切换(Ctrl+L)",
toBottom: "回到底部",
quick: [
"帮我查询物流轨迹", "欧洲海运专线报价", "包裹异常处理", "转接人工客服"
],
hotList: [
"如何查询物流轨迹?", "欧洲海运时效是多久?", "包裹显示异常签收怎么办?", "如何申请理赔?"
]
},
en: {
appTitle: "Baiyue Logistics AI Assistant",
newChat: "New Chat",
clearAll: "Clear All History",
history: "History",
searchPh: "Search history...",
confirmClear: "Clear all conversation history? This cannot be undone.",
confirmDel: "Delete this conversation?",
emptySearch: "No matching conversations",
emptyHistory: "No conversations yet",
thinking: "Thinking...",
busyRetry: "Service busy, please try again later",
sessionFail: "Failed to create session, please check the service is running",
networkFail: "Connection lost, please check your network and retry",
stopped: "Stopped",
stoppedMark: "(Generation stopped)",
inputPh: "Ask a question or enter a tracking number... (Enter to send, Shift+Enter for newline)",
send: "Send",
stop: "Stop",
copy: "Copy",
copied: "Copied",
retry: "Regenerate",
clickRetry: "Retry",
welcomeTitle: "Hi, I'm the Baiyue Logistics Assistant",
welcomeSub: "I can help with shipment tracking, route quotes, exceptions and more.",
welcomeTip: "Type your question, or pick a quick option below.",
hot: "Popular Questions",
recent: "Recent Conversations",
sideCollapse: "Collapse sidebar",
sideExpand: "Expand sidebar",
themeTip: "Toggle dark/light",
langTip: "Switch language (Ctrl+L)",
toBottom: "Back to bottom",
quick: [
"Track my shipment", "Europe sea freight quote", "Handle a parcel exception", "Talk to a human agent"
],
hotList: [
"How do I track my shipment?", "How long does Europe sea freight take?",
"My parcel shows delivered but I didn't get it", "How do I file a claim?"
]
}
};
let LANG = "zh";
function t(key){
return (I18N[LANG] || I18N.zh)[key];
}
function applyLanguage(lang){
LANG = lang === "en" ? "en" : "zh";
const dict = I18N[LANG];
document.documentElement.lang = LANG === "en" ? "en" : "zh-CN";
try{ localStorage.setItem("wuliu-lang", LANG); }catch(e){}
const titleEl = document.getElementById("app-title");
if(titleEl) titleEl.textContent = dict.appTitle;
const langBtn = document.getElementById("lang-toggle");
if(langBtn){
langBtn.textContent = LANG === "en" ? "EN" : "中";
langBtn.title = dict.langTip;
}
const themeBtn = document.getElementById("theme-toggle");
if(themeBtn) themeBtn.title = dict.themeTip;
document.querySelectorAll(".new-chat-btn").forEach(function(b){ b.textContent = dict.newChat; });
document.querySelectorAll(".clear-all-btn").forEach(function(b){ b.textContent = dict.clearAll; });
document.querySelectorAll(".panel-title").forEach(function(e){ e.textContent = dict.history; });
const hs = document.getElementById("history-search");
if(hs) hs.placeholder = dict.searchPh;
if(window.innerWidth > 900 && inputDom) inputDom.placeholder = dict.inputPh;
sendBtn.textContent = window.__busy ? dict.stop : dict.send;
const tb = document.getElementById("to-bottom");
if(tb) tb.title = dict.toBottom;
const tg = document.querySelector(".toggle-divider");
if(tg) tg.title = document.querySelector('.chat-container').classList.contains('side-closed') ? dict.sideExpand : dict.sideCollapse;
// 欢迎页(若当前显示的是欢迎页则重绘)
if(chatDom.querySelector(".ai-welcome-card")){
const scroll = chatDom.scrollTop;
chatDom.innerHTML = welcomeHTML();
chatDom.scrollTop = scroll;
}
// 历史列表 / 最近对话(空状态与条目都要刷新)
if(typeof renderSideHistory === 'function'){
try{
renderSideHistory();
renderRecentChat();
}catch(e){ /* 列表尚未初始化时忽略 */ }
}
// 已有消息上的复制/重答按钮
document.querySelectorAll(".msg-action-btn").forEach(function(b){
const n = b.dataset.action;
if(n === "copy") b.textContent = dict.copy;
if(n === "retry") b.textContent = dict.retry;
});
document.querySelectorAll(".retry-btn").forEach(function(b){
if(!b.dataset.keep) b.textContent = dict.clickRetry;
});
}
(function initLang(){
let saved = null;
try{ saved = localStorage.getItem("wuliu-lang"); }catch(e){}
if(!saved){
saved = (navigator.language || "zh").toLowerCase().indexOf("zh") === 0 ? "zh" : "en";
}
applyLanguage(saved);
const btn = document.getElementById("lang-toggle");
if(btn){
btn.onclick = function(){ applyLanguage(LANG === "en" ? "zh" : "en"); };
}
// 快捷键:Ctrl+L 切换语言(不占用输入框)
document.addEventListener("keydown", function(e){
if((e.ctrlKey || e.metaKey) && e.key.toLowerCase() === "l"){
e.preventDefault();
applyLanguage(LANG === "en" ? "zh" : "en");
}
});
})();
// ===== 历史搜索 =====
const historySearch = document.getElementById("history-search");
if(historySearch){
historySearch.addEventListener("input", renderSideHistory);
}
let historyList = [];
let sseInstance = null;
let activeBubble = null; // 正在输出的气泡
let cancelActive = null; // 中止当前请求(切换对话/重复发送时用)
let currentConvId = null; // 当前会话 id,null 表示还没建
function formatTime(timeStr) {
const target = new Date(timeStr);
const y = target.getFullYear();
const m = String(target.getMonth() + 1).padStart(2, '0');
const d = String(target.getDate()).padStart(2, '0');
const h = String(target.getHours()).padStart(2, '0');
const min = String(target.getMinutes()).padStart(2, '0');
return `${y}-${m}-${d} ${h}:${min}`;
}
// 先转义再套格式,防止模型输出里的标签被浏览器执行
function escHtml(s){
return String(s).replace(/[&<>"]/g, function(c){
return {"&":"&amp;", "<":"&lt;", ">":"&gt;", '"':"&quot;"}[c];
});
}
// 轻量 Markdown:加粗、行内代码、无序/有序列表、换行
function mdToHtml(text){
const inline = function(s){
return s.replace(/\*\*(.+?)\*\*/g, "<b>$1</b>")
.replace(/`([^`]+)`/g, "<code>$1</code>");
};
const lines = escHtml(text).split("\n");
let html = "";
let list = null;
for (let i = 0; i < lines.length; i++){
const t = lines[i].trim();
let m;
if ((m = t.match(/^[-*•]\s+(.*)$/))){ // 无序列表
if (list !== "ul"){ if (list) html += "</" + list + ">"; html += "<ul>"; list = "ul"; }
html += "<li>" + inline(m[1]) + "</li>";
} else if ((m = t.match(/^\d+[.、)]\s*(.*)$/))){ // 有序列表
if (list !== "ol"){ if (list) html += "</" + list + ">"; html += "<ol>"; list = "ol"; }
html += "<li>" + inline(m[1]) + "</li>";
} else if (!t){ // 空行
if (list){ html += "</" + list + ">"; list = null; }
html += "<br>";
} else { // 普通段落
if (list){ html += "</" + list + ">"; list = null; }
html += inline(t) + "<br>";
}
}
if (list) html += "</" + list + ">";
return html;
}
function parseBold(text){
return mdToHtml(text);
}
// 空会话时的欢迎内容(新建对话 / 打开空会话都要用)
function welcomeHTML(){
const q = t("quick");
const h = t("hotList");
return `
<div class="ai-welcome-card">
<div class="ai-welcome-top">
<div class="ai-avatar">🤖</div>
<div>
<h3>${t("welcomeTitle")}</h3>
<p>${t("welcomeSub")}</p >
</div>
</div>
<p>${t("welcomeTip")}</p >
<div class="suggest-grid">
<div class="suggest-item" onclick="askNewTopic('${q[0]}')">🚚 ${q[0]}</div>
<div class="suggest-item" onclick="askNewTopic('${q[1]}')">💰 ${q[1]}</div>
<div class="suggest-item" onclick="askNewTopic('${q[2]}')">⚠️ ${q[2]}</div>
<div class="suggest-item" onclick="showHuman()">👤 ${q[3]}</div>
</div>
</div>
<div class="hot-questions">
<h4>${t("hot")}</h4>
<div class="hot-capsule-wrap">
<div class="hot-capsule" onclick="askNewTopic('${h[0]}')">${h[0]}</div>
<div class="hot-capsule" onclick="askNewTopic('${h[1]}')">${h[1]}</div>
<div class="hot-capsule" onclick="askNewTopic('${h[2]}')">${h[2]}</div>
<div class="hot-capsule" onclick="askNewTopic('${h[3]}')">${h[3]}</div>
</div>
</div>
<div class="recent-chat">
<h4>${t("recent")}</h4>
<div id="recentChatWrap"></div>
</div>
`;
}
// 清空全部历史
if(clearAllBtn){
clearAllBtn.onclick = function(){
if(!confirm(t("confirmClear"))) return;
if(cancelActive){ cancelActive(); cancelActive = null; }
fetch("/api/clear_all", {method:"DELETE"})
.then(()=>{
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
loadHistory();
});
}
}
// 新建对话:只清界面,等用户开口时再建会话
if(newChatBtn){
newChatBtn.onclick = function(){
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
inputDom.value = "";
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
loadHistory();
}
}
function loadHistory(){
fetch("/api/get_history")
.then(res=>res.json())
.then(data=>{
historyList = data;
renderSideHistory();
renderRecentChat();
})
}
function renderSideHistory(){
leftHistoryWrap.innerHTML = "";
const kw = (document.getElementById("history-search")?.value || "").trim().toLowerCase();
const pinned = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
let list = kw
? historyList.filter(item => (item.title || "").toLowerCase().indexOf(kw) >= 0)
: historyList.slice();
// 置顶的排前面
list.sort((a,b)=>{
const ap = pinned.includes(a.id) ? 0 : 1;
const bp = pinned.includes(b.id) ? 0 : 1;
return ap - bp;
});
if(!list.length){
const empty = document.createElement("div");
empty.className = "history-empty";
empty.innerText = kw ? t("emptySearch") : t("emptyHistory");
leftHistoryWrap.appendChild(empty);
return;
}
list.forEach((item)=>{
let div = document.createElement('div');
div.className = "history-item";
div.dataset.fullText = item.title;
const isPinned = pinned.includes(item.id);
const textWrap = document.createElement("div");
textWrap.className = "history-text-wrap";
const titleSpan = document.createElement("div");
titleSpan.className = "history-title";
titleSpan.innerText = (isPinned ? "📌 " : "") + item.title;
let timeDiv = document.createElement('div');
timeDiv.className = "history-time";
timeDiv.innerText = formatTime(item.utime || item.ctime);
textWrap.appendChild(titleSpan);
textWrap.appendChild(timeDiv);
const pinBtn = document.createElement("button");
pinBtn.className = "del-history-btn";
pinBtn.innerText = isPinned ? "📍" : "📌";
pinBtn.title = isPinned ? "取消置顶" : "置顶";
pinBtn.style.marginRight = "4px";
pinBtn.onclick = (e)=>{
e.stopPropagation();
let p = JSON.parse(localStorage.getItem("pinned_conv") || "[]");
if(p.includes(item.id)){ p = p.filter(x=>x!==item.id); }
else { p.push(item.id); }
localStorage.setItem("pinned_conv", JSON.stringify(p));
renderSideHistory();
};
const delBtn = document.createElement("button");
delBtn.className = "del-history-btn";
delBtn.innerText = "×";
delBtn.onclick = (e)=>{
e.stopPropagation();
if(confirm(t("confirmDel"))){
fetch(`/api/del_history?id=${item.id}`, {method:"DELETE"})
.then(()=>{
if(item.id === currentConvId){
currentConvId = null;
chatDom.innerHTML = welcomeHTML();
}
loadHistory();
})
}
}
div.appendChild(textWrap);
div.appendChild(pinBtn);
div.appendChild(delBtn);
div.onclick = ()=> openConversation(item.id);
leftHistoryWrap.appendChild(div);
})
}
function renderRecentChat(){
const wrap = document.getElementById("recentChatWrap");
if(!wrap) return; // 切过对话后旧节点会失效,每次重新取
wrap.innerHTML = "";
historyList.slice(0,4).forEach(item=>{
const div = document.createElement('div');
div.className = "recent-item";
div.onclick = ()=> openConversation(item.id);
const titleSpan = document.createElement('span');
titleSpan.innerText = item.title;
const timeSpan = document.createElement('span');
timeSpan.className = "recent-date";
timeSpan.innerText = formatTime(item.utime || item.ctime);
div.appendChild(titleSpan);
div.appendChild(timeSpan);
wrap.appendChild(div);
})
}
// 没有会话就先建一个,拿到 conv_id
function ensureConv(){
if(currentConvId) return Promise.resolve(currentConvId);
return fetch("/api/new_chat", {method:"POST"})
.then(res=>res.json())
.then(data=>{
currentConvId = data.id;
return currentConvId;
});
}
// 打开历史会话:还原整段对话,而不是把标题重发一遍
let openingConv = null;
function openConversation(id){
if(openingConv === id) return; // 防连点
openingConv = id;
if(cancelActive){ cancelActive(); cancelActive = null; }
if(sseInstance){ sseInstance.close(); sseInstance = null; }
currentConvId = id;
fetch("/api/get_messages?conv_id=" + id)
.then(res=>res.json())
.then(msgs=>{
openingConv = null;
chatDom.innerHTML = "";
if(!msgs.length){
chatDom.innerHTML = welcomeHTML();
return;
}
msgs.forEach(m=>{
if(m.role === "assistant" && m.content.indexOf(CARD_PREFIX) === 0){
try{
addTrackCard(JSON.parse(m.content.slice(CARD_PREFIX.length)));
}catch(e){
addMsg(m.content, false);
}
}else{
const b = addMsg(m.content, m.role === "user");
if(m.role === "assistant" && b) attachActions(b, m.content, "", false);
}
});
// 等渲染完再滚到底部
requestAnimationFrame(function(){ chatDom.scrollTop = chatDom.scrollHeight; });
})["catch"](function(){ openingConv = null; });
}
const CARD_PREFIX = "__CARD__";
// 把轨迹数据画成时间轴
function fillTrackCard(el, card){
el.innerHTML = "";
const head = document.createElement("div");
head.className = "track-head";
const no = document.createElement("div");
no.className = "track-no";
no.innerHTML = "<span>运单号</span>" + escHtml(card.no);
const st = document.createElement("div");
st.className = "track-status";
st.textContent = card.status;
head.appendChild(no);
head.appendChild(st);
el.appendChild(head);
(card.nodes || []).forEach(function(n, i){
const node = document.createElement("div");
node.className = "track-node" + (i === 0 ? " active" : "");
const dot = document.createElement("span");
dot.className = "track-dot";
const text = document.createElement("div");
text.className = "track-text";
text.innerHTML = '<span class="track-time">' + escHtml(n.time) + "</span>" + escHtml(n.text) +
'<span class="track-place">' + escHtml(n.place || "") + "</span>";
node.appendChild(dot);
node.appendChild(text);
el.appendChild(node);
});
}
function addTrackCard(card){
const row = document.createElement("div");
row.className = "msg-row robot-line";
const bubble = document.createElement("div");
bubble.className = "robot-message track-card";
fillTrackCard(bubble, card);
row.appendChild(bubble);
chatDom.appendChild(row);
chatDom.scrollTop = chatDom.scrollHeight;
return bubble;
}
loadHistory();
window.fillInput = function(words){
inputDom.value = words;
doSend();
}
// 点快捷问题:另起一个会话,不同话题就不会堆进同一条历史
window.askNewTopic = function(words){
if(cancelActive){ cancelActive(); cancelActive = null; }
currentConvId = null; // 下次发送时自动新建会话
// 保留欢迎页(原来就是这个样子);已经在对话里则先回到欢迎页
if(!chatDom.querySelector('.ai-welcome-card')){
chatDom.innerHTML = welcomeHTML();
}
inputDom.value = words;
doSend();
}
window.sendOrder = function(){
const no = prompt("请输入运单号:");
if(no && no.trim()){
inputDom.value = "我的运单号是 " + no.trim() + ",帮我查一下轨迹";
doSend();
}
}
function myOrders(){
var phone = window.prompt("请输入您的手机号:");
if(!phone) return;
addMsg("查询手机号 "+phone+" 的包裹...", false);
fetch("/api/my_orders?phone="+encodeURIComponent(phone))
.then(r=>r.json())
.then(d=>{
if(!d.orders.length){
addMsg("手机号 "+phone+" 下暂无运单记录。", false);
}else{
var txt = "您名下有 "+d.orders.length+" 个包裹:\n";
d.orders.forEach(function(o){ txt += "• "+o.track_no+"("+o.status+")\n"; });
addMsg(txt, false);
}
})
.catch(function(){ addMsg("查询失败,请重试", false); });
}
window.showHuman = async function(){
if(window.humanSid){
addMsg("您已在人工客服会话中,请直接提问。", false);
return;
}
var now = new Date();
var h = now.getHours();
var work = (h >= 8 && h < 22);
if(!work){
addMsg("人工客服工作时间:每天 8:00-22:00\n当前为非工作时间,请留言,工作时间会尽快回复您。", false);
var msg = window.prompt("请输入您的留言:");
if(msg){
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
addMsg("留言已提交,工作时间(8:00-22:00)会尽快回复您。", false);
}
return;
}
addMsg("正在为您转接人工客服...", false);
if(!currentConvId){
try{ await ensureConv(); }catch(e){}
}
fetch("/api/human/request",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({conv_id:currentConvId||0})})
.then(function(r){return r.json()})
.then(function(d){
if(d.ok){
window.humanSid = d.session_id;
addMsg("您已加入人工排队,请稍候...", false);
startHumanPoll();
}else if(d.closed){
addMsg(d.msg || "人工客服当前不在线,请留言。", false);
var msg = window.prompt("请输入您的留言:");
if(msg){
fetch("/api/ticket",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({content:msg, phone:""})});
addMsg("留言已提交,工作时间(8:00-22:00)会尽快回复您。", false);
}
}else{
addMsg("转接失败,请稍后再试。", false);
}
})
.catch(function(){
addMsg("网络错误,请稍后再试。", false);
});
}
window.humanSid = 0;
function startHumanPoll(){
let last=0;
const t=setInterval(()=>{
if(!window.humanSid){ clearInterval(t); return; }
fetch("/api/human/poll?sid="+window.humanSid+"&after="+last)
.then(r=>r.json())
.then(d=>{
if(d.status==="closed"){
clearInterval(t);
var sid = window.humanSid;
window.humanSid=0;
window.__busy=false;
if(typeof setBusy==="function") setBusy(false);
addMsg("人工客服已结束,您可以继续向 AI 提问。", false);
// 满意度评价
if(sid){
var ratingBox = document.createElement("div");
ratingBox.style.cssText = "text-align:center;padding:12px;margin:8px 0;background:#f8fafc;border-radius:10px;";
ratingBox.innerHTML = '<div style="font-size:13px;color:#666;margin-bottom:8px;">本次服务满意吗?</div>' +
'<div style="font-size:24px;cursor:pointer;letter-spacing:4px;">' +
[1,2,3,4,5].map(function(s){return '<span data-star="'+s+'" style="cursor:pointer;color:#ddd;">★</span>'}).join("") +
'</div>';
chatDom.appendChild(ratingBox);
chatDom.scrollTop = chatDom.scrollHeight;
ratingBox.querySelectorAll("[data-star]").forEach(function(star){
star.onmouseover = function(){
var v = parseInt(this.dataset.star);
ratingBox.querySelectorAll("[data-star]").forEach(function(s){
s.style.color = parseInt(s.dataset.star) <= v ? "#fbbf24" : "#ddd";
});
};
star.onclick = function(){
var v = parseInt(this.dataset.star);
fetch("/api/rate",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:sid, stars:v, comment:""})});
ratingBox.innerHTML = '<div style="font-size:13px;color:#666;">感谢您的评价!' + "⭐".repeat(v) + '</div>';
};
});
}
return;
}
// 客服正在输入
var oldT = document.getElementById("human-typing");
if(d.typing && !oldT){
var td = document.createElement("div");
td.id = "human-typing";
td.style.cssText = "font-size:12px;color:#999;padding:4px 0;";
td.textContent = "客服正在输入...";
chatDom.appendChild(td);
chatDom.scrollTop = chatDom.scrollHeight;
}else if(!d.typing && oldT){
oldT.remove();
}
d.messages.forEach(m=>{
if(m.id <= last) return;
last = m.id;
if(m.side==="system"){
// 撤回:删掉最后一条agent消息
var msgs = chatDom.querySelectorAll(".robot-line");
if(msgs.length) msgs[msgs.length-1].remove();
var sysDiv = document.createElement("div");
sysDiv.style.cssText = "text-align:center;font-size:12px;color:#999;padding:4px 0;";
sysDiv.textContent = m.content;
chatDom.appendChild(sysDiv);
chatDom.scrollTop = chatDom.scrollHeight;
} else if(m.side==="agent"){
addMsg(m.content, false);
if(document.hidden && "Notification" in window && Notification.permission === "granted"){
var n = new Notification("客服回复", {body: m.content.substring(0,50)});
n.onclick = function(){ window.focus(); n.close(); };
}
}
var oldT2 = document.getElementById("human-typing");
if(oldT2) oldT2.remove();
});
})
.catch(()=>{});
}, 1000);
}
function addMsg(content, isUser){
const rowDiv = document.createElement("div");
rowDiv.className = "msg-row";
let robotMsg;
// 检测图片附件
let imgUrl = null;
if(content.indexOf("[图片]") >= 0){
const m = content.match(/\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i);
if(m) imgUrl = m[0];
}
// 检测文件附件
let fileUrl = null;
if(content.indexOf("[文件]") >= 0){
const m = content.match(/\/uploads\/\S+\.(?:pdf|txt|csv|doc|docx|xls|xlsx|zip)/i);
if(m) fileUrl = m[0];
}
if(isUser){
rowDiv.classList.add("user-line");
const userMsg = document.createElement('div');
userMsg.className = "user-message";
if(imgUrl){
var txt = content.replace(/\[图片\]\/uploads\/\S+\.(?:jpg|jpeg|png|gif|webp)/i, "").trim();
userMsg.innerHTML = '<img src="' + imgUrl + '" style="max-width:220px;border-radius:10px;display:block;background:transparent;cursor:zoom-in;" onclick="window.open(this.src)">' + (txt ? '<div style="margin-top:6px;">'+txt+'</div>' : '');
}else if(fileUrl){
var fname = fileUrl.split("/").pop();
userMsg.innerHTML = '<a href="'+fileUrl+'" target="_blank" style="color:inherit;text-decoration:none;display:inline-block;padding:8px 12px;background:rgba(255,255,255,0.2);border-radius:8px;">📎 '+fname+'</a>';
}else{
userMsg.innerText = content;
}
rowDiv.appendChild(userMsg);
}else{
rowDiv.classList.add("robot-line");
robotMsg = document.createElement('div');
robotMsg.className = "robot-message";
robotMsg.innerHTML = parseBold(content);
rowDiv.appendChild(robotMsg);
}
// 时间戳
const ts = document.createElement("div");
ts.className = "msg-time";
const d = new Date();
ts.textContent = String(d.getHours()).padStart(2,'0') + ":" + String(d.getMinutes()).padStart(2,'0');
rowDiv.appendChild(ts);
chatDom.appendChild(rowDiv);
chatDom.scrollTop = chatDom.scrollHeight;
if(!isUser){
return robotMsg;
}else{
return rowDiv;
}
}
function renderError(bubble, text, retry){
bubble.classList.remove("typing");
bubble.classList.add("error-bubble");
bubble.innerHTML = "";
const line = document.createElement("div");
line.className = "err-line";
line.textContent = "⚠️ " + text;
const btn = document.createElement("button");
btn.className = "retry-btn";
btn.textContent = "点击重试";
btn.onclick = retry;
bubble.appendChild(line);
bubble.appendChild(btn);
chatDom.scrollTop = chatDom.scrollHeight;
}
// 复制用:把 Markdown 标记去掉,得到干净纯文本
function mdToPlain(md){
if(md.indexOf(CARD_PREFIX) === 0){
try{
const card = JSON.parse(md.slice(CARD_PREFIX.length));
const lines = ["运单号 " + card.no + "(" + (card.status || "") + ")"];
(card.nodes || []).forEach(function(n){
lines.push(n.time + " " + n.text + (n.place ? " " + n.place : ""));
});
return lines.join("\n");
}catch(e){ /* 解析失败就走普通处理 */ }
}
return String(md)
.replace(/`([^`]+)`/g, "$1") // 行内代码
.replace(/\*\*(.+?)\*\*/g, "$1") // 加粗
.replace(/^[-*•]\s+/gm, "· ") // 列表符号换成 ·
.replace(/^\d+[.、)]\s*/gm, "") // 去掉编号前缀
.replace(/“|”/g, '"');
}
// 给一条回答挂上「复制 / 重答」按钮
function attachActions(bubble, rawText, question, showRetry){
const old = bubble.querySelector(".msg-actions");
if(old) old.remove();
if(!rawText) return;
const bar = document.createElement("div");
bar.className = "msg-actions";
const copyBtn = document.createElement("button");
copyBtn.className = "msg-action-btn";
copyBtn.dataset.action = "copy";
copyBtn.textContent = t("copy");
copyBtn.onclick = function(){
const plain = mdToPlain(rawText);
const done = ()=> {
copyBtn.textContent = t("copied");
setTimeout(()=>{ copyBtn.textContent = t("copy"); }, 1200);
};
if(navigator.clipboard && navigator.clipboard.writeText){
navigator.clipboard.writeText(plain).then(done).catch(fallback);
}else{
fallback();
}
function fallback(){
const ta = document.createElement("textarea");
ta.value = plain;
ta.style.position = "fixed";
ta.style.opacity = "0";
document.body.appendChild(ta);
ta.select();
try{ document.execCommand("copy"); }catch(e){}
document.body.removeChild(ta);
done();
}
};
bar.appendChild(copyBtn);
if(showRetry){
const retryBtn = document.createElement("button");
retryBtn.className = "msg-action-btn";
retryBtn.dataset.action = "retry";
retryBtn.textContent = t("retry");
retryBtn.onclick = function(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(question); // 传字符串 = 只重发,不重复插入提问气泡
};
bar.appendChild(retryBtn);
}
// 👍/👎 反馈
function addVote(vote, label){
const b = document.createElement("button");
b.className = "msg-action-btn";
b.textContent = label;
b.onclick = function(){
if(b.classList.contains("voted")) return;
bar.querySelectorAll(".msg-action-btn").forEach(function(x){ x.classList.remove("voted"); });
b.classList.add("voted");
fetch("/api/feedback", {
method:"POST", headers:{"Content-Type":"application/json"},
body: JSON.stringify({question: question, answer: rawText, vote: vote})
}).catch(function(){});
};
bar.appendChild(b);
}
addVote("up", "👍");
addVote("down", "👎");
bubble.appendChild(bar);
}
// 回答下方的"相关追问":点一下就接着问
function renderFollowups(bubble){
let list = [];
try{ list = JSON.parse(bubble.dataset.suggest || "[]"); }catch(e){ list = []; }
if(!list.length) return;
const wrap = document.createElement("div");
wrap.className = "followup-list";
list.forEach(function(q){
const item = document.createElement("div");
item.className = "followup-item";
item.innerHTML = "<span></span><span class='arrow'>›</span>";
item.firstChild.textContent = q;
item.onclick = function(){
wrap.remove();
inputDom.value = q;
doSend();
};
wrap.appendChild(item);
});
bubble.appendChild(wrap);
}
async function doSend(retryText){
const isRetry = typeof retryText === "string";
const sendWord = (isRetry ? retryText : inputDom.value).trim();
var imgUrl = window.__pendingImg || "";
var tag = window.__pendingTag || "[图片]";
if(!sendWord && !imgUrl){
inputDom.focus();
return;
}
// 人工客服模式:直接发给客服,不走AI
if(window.humanSid){
var content = sendWord;
if(imgUrl) content += (content ? " " : "") + tag + imgUrl;
if(!isRetry) addMsg(content, true);
inputDom.value = "";
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
fetch("/api/human/send",{method:"POST",headers:{"Content-Type":"application/json"},body:JSON.stringify({sid:window.humanSid,side:"customer",content:content})});
return;
}
if(window.__busy && !retryText) return; // 防连点
if(cancelActive){ cancelActive(); cancelActive = null; }
if(!isRetry){
var fullContent = sendWord;
if(imgUrl) fullContent += (fullContent ? " " : "") + "[图片]" + imgUrl;
// 新对话:先建会话,再显示自动欢迎语
if(!currentConvId){
try{ await ensureConv(); }catch(err){
const bubble = addMsg("", false);
renderError(bubble, t("sessionFail"), function(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(sendWord);
});
return;
}
addMsg("您好!欢迎咨询柏悦物流智能客服,请问有什么可以帮您?输入运单号可查轨迹,或点击下方快捷选项。", false);
}
addMsg(fullContent, true);
inputDom.value = "";
window.__pendingImg = ""; window.__pendingTag = "[图片]";
var p = document.getElementById("imgPreview"); if(p) p.remove();
inputDom.focus();
}
// 没有会话就先建一个,后面所有消息都挂在它下面
try{
await ensureConv();
}catch(err){
const bubble = addMsg("", false);
renderError(bubble, t("sessionFail"), function(){
const row = bubble.closest(".msg-row");
if(row) row.remove();
doSend(sendWord);
});
return;
}
const aiBubble = addMsg(t("thinking"), false);
aiBubble.classList.add("typing");
activeBubble = aiBubble;
setBusy(true);
let es = null;
let firstChunk = true;
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 15000);
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
function cleanup(){
clearTimeout(firstTimer);
clearTimeout(totalTimer);
const cur = es;
es = null;
if(cur){
cur.close();
if(sseInstance === cur) sseInstance = null;
}
}
function finish(){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
if(activeBubble === aiBubble) activeBubble = null;
inputDom.focus();
let raw = aiBubble.dataset.raw || "";
// 模型有时会把卡片标记原样输出,这里兜底转成时间轴
if(raw.indexOf(CARD_PREFIX) === 0){
try{
const card = JSON.parse(raw.slice(CARD_PREFIX.length));
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, card);
raw = "运单号 " + card.no + "(" + (card.status || "") + ")";
}catch(e){ /* 解析失败就按普通文本显示 */ }
}
attachActions(aiBubble, raw, sendWord, true);
renderFollowups(aiBubble);
updateToBottom();
loadHistory(); // 首条消息会让标题变成问题内容,这里刷新一下
}
function fail(text){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
if(activeBubble === aiBubble) activeBubble = null;
renderError(aiBubble, text || t("busyRetry"), function(){
const row = aiBubble.closest(".msg-row");
if(row) row.remove();
doSend(sendWord);
});
}
// 静默中止(用户点了停止 / 开了新对话 / 问了新问题)
function stop(){
if(settled) return;
settled = true;
cleanup();
setBusy(false);
aiBubble.classList.remove("typing");
if(activeBubble === aiBubble) activeBubble = null;
const raw = aiBubble.dataset.raw || "";
if(raw){
// 已生成的部分保留,并标注已停止
const hint = document.createElement("div");
hint.className = "stopped-hint";
hint.textContent = t("stoppedMark");
aiBubble.appendChild(hint);
attachActions(aiBubble, raw, sendWord, false);
}else{
aiBubble.innerHTML = t("stopped");
aiBubble.classList.add("hint-bubble");
}
}
cancelActive = stop;
function handleFrame(dataText){
if(settled) return;
let obj;
try{
obj = JSON.parse(dataText);
}catch(err){
obj = {t: dataText}; // 兼容旧的纯文本帧
}
if(obj.e){ fail(obj.e); return; }
if(obj.card){
aiBubble.classList.add("track-card");
fillTrackCard(aiBubble, obj.card);
aiBubble.dataset.raw = "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
firstChunk = false;
chatDom.scrollTop = chatDom.scrollHeight;
return;
}
if(obj.replace){
aiBubble.dataset.raw = obj.replace;
aiBubble.innerHTML = parseBold(obj.replace);
chatDom.scrollTop = chatDom.scrollHeight;
return;
}
if(obj.suggest){
aiBubble.dataset.suggest = JSON.stringify(obj.suggest);
return;
}
if(obj.d){ finish(); return; }
if(typeof obj.t !== "string") return;
clearTimeout(firstTimer);
if(firstChunk){
aiBubble.innerHTML = "";
aiBubble.dataset.raw = "";
firstChunk = false;
}
const raw = (aiBubble.dataset.raw || "") + obj.t;
aiBubble.dataset.raw = raw;
aiBubble.innerHTML = parseBold(raw);
chatDom.scrollTop = chatDom.scrollHeight;
}
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
const controller = new AbortController();
es = { close: function(){ try{ controller.abort(); }catch(e){} } };
sseInstance = es;
// 保留 stop 里的 close 调用
const prevStop = cancelActive;
cancelActive = function(){ prevStop && prevStop(); try{ controller.abort(); }catch(e){} };
fetch("/stream_chat", {
method: "POST",
headers: {"Content-Type": "application/json"},
body: JSON.stringify({msg: fullContent, conv_id: currentConvId}),
signal: controller.signal
}).then(function(res){
if(!res.ok){ throw new Error("HTTP " + res.status); }
const reader = res.body.getReader();
const decoder = new TextDecoder("utf-8");
let buffer = "";
function pump(){
return reader.read().then(function(r){
if(r.done){ return; }
buffer += decoder.decode(r.value, {stream: true});
let idx;
// SSE 以空行分隔事件
while((idx = buffer.indexOf("\n\n")) >= 0){
const chunk = buffer.slice(0, idx);
buffer = buffer.slice(idx + 2);
chunk.split("\n").forEach(function(line){
if(line.indexOf("data:") === 0){
handleFrame(line.slice(5).trimStart());
}
});
}
return pump();
});
}
return pump();
}).catch(function(err){
if(err && err.name === "AbortError") return; // 用户主动停止
if(settled) return;
fail(t("networkFail"));
});
}
// ===== 发送 / 停止 按钮状态 =====
// 生成中按钮变成“停止”,点击走 cancelActive
function setBusy(on){
window.__busy = on;
sendBtn.textContent = on ? t("stop") : t("send");
sendBtn.classList.toggle("stop", on);
}
sendBtn.addEventListener("click", function(){
if(window.__busy && cancelActive){
cancelActive();
cancelActive = null;
setBusy(false);
return;
}
doSend();
});
// ===== 多行输入框:自动增高 =====
function autoGrow(){
inputDom.style.height = "auto";
inputDom.style.height = Math.min(inputDom.scrollHeight, 140) + "px";
}
inputDom.addEventListener("input", autoGrow);
inputDom.addEventListener("keydown",(e)=>{
// Enter 发送,Shift+Enter 换行
if(e.key === 'Enter' && !e.shiftKey && !e.isComposing){
e.preventDefault();
if(window.humanSid || !window.__busy) doSend();
}
});
// ===== 回到底部 =====
const toBottomBtn = document.getElementById("to-bottom");
function updateToBottom(){
const gap = chatDom.scrollHeight - chatDom.scrollTop - chatDom.clientHeight;
toBottomBtn.classList.toggle("show", gap > 160);
}
chatDom.addEventListener("scroll", updateToBottom);
toBottomBtn.onclick = ()=>{
chatDom.scrollTo({top: chatDom.scrollHeight, behavior:"smooth"});
};
const sidePanel = document.querySelector('.left-panel');
const toggleBtn = document.querySelector('.toggle-divider');
const chatContainer = document.querySelector('.chat-container');
let sideOpen = true;
function isNarrow(){
return window.matchMedia("(max-width: 900px)").matches;
}
// 窄屏默认收起侧栏
if(isNarrow()){
chatContainer.classList.add('side-closed');
toggleBtn.textContent = '▶';
sideOpen = false;
inputDom.placeholder = "请输入问题或运单号…";
}
toggleBtn.onclick = function(){
sideOpen = !sideOpen;
if(isNarrow()){
// 抽屉模式:只切换整个容器的 class,不动侧栏自身宽度
chatContainer.classList.toggle('side-closed', !sideOpen);
toggleBtn.textContent = sideOpen ? '◀' : '▶';
return;
}
if(sideOpen){
sidePanel.classList.remove('left-panel-hide');
toggleBtn.textContent = '◀';
}else{
sidePanel.classList.add('left-panel-hide');
toggleBtn.textContent = '▶';
}
}
// 从窄屏切到宽屏时,还原侧栏状态,避免卡在隐藏
window.addEventListener("resize", function(){
if(!isNarrow()){
chatContainer.classList.remove('side-closed');
}
});
// ===== 机器人拖拽(限制在聊天白色区域内)=====
const robot = document.querySelector('.robot-mascot');
let dragging = false, offX = 0, offY = 0;
robot.style.cursor = 'grab';
function chatBounds(){
const main = document.querySelector('.main-chat').getBoundingClientRect();
// 右边留出 20px,避免贴到边缘/滚动条
return { left: main.left + 10, top: main.top + 10, right: window.innerWidth - 20, bottom: main.bottom - 10 };
}
robot.addEventListener('mousedown', function(e){
dragging = true;
robot.style.transition = 'none';
robot.style.height = '110px';
robot.style.right = 'auto';
robot.style.bottom = 'auto';
offX = robot.offsetWidth / 2;
offY = robot.offsetHeight / 2;
robot.style.left = (e.clientX - offX) + 'px';
robot.style.top = (e.clientY - offY) + 'px';
e.preventDefault();
});
document.addEventListener('mousemove', function(e){
if(!dragging) return;
const b = chatBounds();
let x = e.clientX - offX;
let y = e.clientY - offY;
x = Math.max(b.left, Math.min(window.innerWidth - robot.offsetWidth - 15, x));
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
robot.style.left = x + 'px';
robot.style.top = y + 'px';
});
document.addEventListener('mouseup', function(){
if(!dragging) return;
dragging = false;
// 松手后再校正一次位置
const b = chatBounds();
let x = parseFloat(robot.style.left) || 0;
let y = parseFloat(robot.style.top) || 0;
x = Math.max(b.left, Math.min(window.innerWidth - robot.offsetWidth - 15, x));
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
robot.style.left = x + 'px';
robot.style.top = y + 'px';
});
robot.addEventListener('touchstart', function(e){
const t = e.touches[0];
const rect = robot.getBoundingClientRect();
dragging = true;
robot.style.transition = 'none';
robot.style.left = rect.left + 'px';
robot.style.top = rect.top + 'px';
robot.style.right = 'auto';
robot.style.bottom = 'auto';
offX = t.clientX - rect.left;
offY = t.clientY - rect.top;
}, {passive:true});
document.addEventListener('touchmove', function(e){
if(!dragging) return;
const t = e.touches[0];
const b = chatBounds();
let x = t.clientX - offX;
let y = t.clientY - offY;
x = Math.max(b.left, Math.min(b.right - robot.offsetWidth - 10, x));
y = Math.max(b.top, Math.min(b.bottom - robot.offsetHeight, y));
robot.style.left = x + 'px';
robot.style.top = y + 'px';
}, {passive:true});
document.addEventListener('touchend', function(){ dragging = false; });
})
</script>
</body>
</html>