优化连续追问与回复渐进展示
This commit is contained in:
@@ -12,6 +12,7 @@ Use `am.py` for CLI/WSGI, `logistics_chat/` for business code, and `static/css/`
|
||||
| Customer login / overview | `logistics_chat/customer_service.py`: sessions, default customer bootstrap, order conversation binding; `routes/customer.py`; `static/js/customer.js`: login and logout; `order_listing.py`: `read_overview`, `answer_overview` for chat replies |
|
||||
| Order lookup UI | `static/js/order_lookup.js`: `OrderLookup.create`; `chat.js`: `showOrderLookup`, `fillTrackCard`; `support.js`: `copyText` |
|
||||
| Chat drafts | `static/js/drafts.js`: `ChatDrafts.create`; `chat.js`: `flushDraft`, `restoreDraft`, startup recovery |
|
||||
| Reply presentation | `static/js/reply.js`: `ChatReply.create`; `chat.js`: `doSend`, network completion and display queue cancellation; `tests/frontend/reply.test.cjs` |
|
||||
| Knowledge | `logistics_chat/knowledge.py`: `knowledge_list`, `search_knowledge`; `docs/knowledge.md` |
|
||||
| Pricing | `logistics_chat/pricing.py`: `RATES`, `calc_freight`; `quote_intent.py`: `parse_intent`, `quote_question_text`; `quote_service.py`: `answer_quote`; `quote_engine.py`: `quantity`, `render_rates`, `select_cheapest_rates`; `quote_answers.py`: `timing_liability`; `quote_fees.py`: local business terms and fees |
|
||||
| US quote data | `logistics_chat/quote_workbook.py`: local detail export; `quote_data.py`: versioned SQLite imports; `quote_engine.py`: matching/calculation; `quote_service.py`: conversational reply; `scripts/import_quote_data.py` |
|
||||
@@ -60,7 +61,7 @@ Use four-space Python indentation, `snake_case`, `UPPER_SNAKE_CASE` constants, a
|
||||
|
||||
Use `tests/test_<feature>.py` and `test_<behavior>`. Mock DeepSeek; configure temporary database/upload/backup/log paths before `create_app`. Pytest: `.\.venv\Scripts\python.exe -m pytest -v`; Linux/macOS: `.venv/bin/python -m pytest -v`.
|
||||
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, and `drafts.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
Tests run `tests/frontend/admin.test.cjs`, `chat.test.cjs`, `voice.test.cjs`, `visitor.test.cjs`, `drafts.test.cjs`, and `reply.test.cjs` when Node.js is available; no npm dependencies. Baidu ASR uses FFmpeg on PATH or `FFMPEG_BIN`; provider calls are mocked in tests.
|
||||
|
||||
## Commit & Pull Request Guidelines
|
||||
|
||||
|
||||
@@ -31,6 +31,7 @@ PowerShell 下把目录和 `-g '*.py'` 分开传给 `rg`。报价字典可能整
|
||||
| 查单交互 | `static/js/order_lookup.js`:`OrderLookup.create`;`chat.js`:`showOrderLookup`、`fillTrackCard`;`support.js`:`copyText` | 快捷入口及当前/历史卡片共用查询框;显式输入后才查单,保留聊天草稿和附件;复制共用安全回退 |
|
||||
| AI 与 SSE | `logistics_chat/chat_service.py`:`create_chat_stream`、`sse`;`ai_client.py`:`post_completion`、`compress_answer`、`gen_followups` | `routes/chat.py`:`stream_chat`;客户端 `doSend`、`renderError`、`renderFollowups` |
|
||||
| 人工会话 | `logistics_chat/human_service.py`:`human_*`(含转接、备注、撤回);对应 `routes/human.py` | 输入状态在 `app.extensions['human_typing']`;客户 `startHumanPoll`;客服 `loadQueue`、`poll`、`sendReply`;人工申请每天 8:00–22:00 |
|
||||
| 回复展示 | `static/js/reply.js`:`ChatReply.create`;`chat.js`:`doSend` | 网络完成与文字展示分别结束;收到结束帧后继续显示已接收文字,停止、切换和错误会清理展示队列;减少动态效果时完整显示 |
|
||||
| 附件 | `logistics_chat/file_service.py`:`upload_file`、`cleanup_uploads`;`routes/files.py`:`uploaded_file` | 扩展名/大小/目录由配置提供;客户端 `addMsg`,客服端 `appendMsg` |
|
||||
| 登录、统计、订单、留言、导出 | `logistics_chat/business_service.py`:`admin_login`、`admin_stats`、`my_orders`、`add_ticket`、`list_tickets`、`export_chat` | `routes/admin.py`、`routes/business.py`;登录页 `static/js/admin_login.js` |
|
||||
| 页面 | `templates/index.html`、`templates/admin.html`、`templates/admin_login.html` | 先加载共享 `static/css/theme.css`;客户与客服脚本先加载 `support.js`(表情、通知、评价);客服再加载 `admin_tools.js`(备注、转接、历史搜索),最后加载 `admin.js`;保留全局事件入口 |
|
||||
|
||||
@@ -6,6 +6,7 @@ import re
|
||||
def is_acknowledgement(message):
|
||||
text=re.sub(r'[\s,,。.!!??]','',message).lower()
|
||||
return bool(re.fullmatch(r'(?:好的?|嗯+|明白了?|了解了?|知道了?|收到|谢谢(?:你|您)?|多谢|没问题|'
|
||||
r'(?:就|先)?(?:选|按|用)?(?:这个|这一个|这票)(?:方案)?(?:吧|就行)?|'
|
||||
r'ok(?:ay)?|thanks?|thankyou|gotit|understood)+',text))
|
||||
|
||||
|
||||
|
||||
@@ -83,6 +83,7 @@ def request_selection(text):
|
||||
fastest=fastest or bool(re.search(r'(?:时效|速度)优先|优先(?:时效|速度)|更看重(?:时效|速度)',text)
|
||||
or re.fullmatch(r'(?:我选|选|选择)?(?:时效|速度)[。.!!??\s]*',text))
|
||||
cheapest=cheapest or bool(re.search(r'(?:预算|运费|成本|价格)优先|优先(?:预算|运费|成本|价格)|更看重(?:预算|运费|成本|价格)',text)
|
||||
or re.search(r'(?:想|要|希望|尽量|主要|优先|更在意)(?:能)?(?:省钱|便宜|节省运费|控制成本)',text)
|
||||
or re.fullmatch(r'(?:我选|选|选择)?(?:预算|运费|成本|价格|省钱)[。.!!??\s]*',text))
|
||||
if fastest and cheapest:return 'fastest_cheapest'
|
||||
if re.search(r'差价|相差|差多少|贵多少|便宜多少',text) and (fastest or slowest):return 'speed_compare'
|
||||
|
||||
+10
-1
@@ -202,7 +202,16 @@ body { padding: 20px; height: 100vh; height: 100dvh; overflow: hidden; }
|
||||
.to-bottom { display: none; position: absolute; right: 24px; bottom: calc(var(--composer-height, 150px) + 12px); z-index: 4; width: 38px; height: 38px; border-radius: 50%; border: 1px solid var(--border); background: var(--surface); color: var(--secondary); box-shadow: var(--shadow); }
|
||||
.to-bottom.show { display: block; }
|
||||
.to-bottom:hover { color: var(--brand-text); border-color: var(--brand); }
|
||||
.typing::after { content: " ···"; color: var(--brand-text); }
|
||||
.robot-message.typing { min-width: min(240px, 94%); }
|
||||
.reply-waiting::before { content: ""; display: inline-block; width: 12px; height: 12px; margin-right: 9px; border: 2px solid var(--border); border-top-color: var(--brand-text); border-radius: 50%; vertical-align: -2px; animation: reply-spin .8s linear infinite; }
|
||||
.reply-waiting::after { content: "…"; color: var(--brand-text); animation: reply-pulse 1s ease-in-out infinite; }
|
||||
.reply-ready { animation: reply-enter .18s ease-out; }
|
||||
@keyframes reply-pulse { 50% { opacity: .3; } }
|
||||
@keyframes reply-spin { to { transform: rotate(360deg); } }
|
||||
@keyframes reply-enter { from { opacity: .4; transform: translateY(4px); } to { opacity: 1; transform: translateY(0); } }
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.reply-waiting::after, .reply-waiting::before, .reply-ready { animation: none; }
|
||||
}
|
||||
.sidebar-backdrop { display: none; }
|
||||
.attachment-preview { display: flex; align-items: center; gap: 12px; padding: 10px 12px; margin-bottom: 9px; border: 1px solid var(--border); border-radius: 10px; background: var(--surface-soft); color: var(--text); }
|
||||
.attachment-preview img { height: 42px; width: 42px; object-fit: cover; border: 1px solid var(--border); border-radius: 7px; }
|
||||
|
||||
+55
-31
@@ -266,7 +266,7 @@ const I18N = {
|
||||
confirmDel: "确定要删除这条历史对话吗?",
|
||||
emptySearch: "没有匹配的历史对话",
|
||||
emptyHistory: "暂无历史对话",
|
||||
thinking: "正在思考中...",
|
||||
thinking: "正在处理您的问题",
|
||||
busyRetry: "服务繁忙,请稍后重试",
|
||||
sessionFail: "会话创建失败,请确认服务已启动",
|
||||
autoWelcome: "您好!欢迎咨询柏悦物流智能客服,可以咨询运费、时效或联系人工客服。",
|
||||
@@ -383,7 +383,7 @@ const I18N = {
|
||||
confirmDel: "Delete this conversation?",
|
||||
emptySearch: "No matching conversations",
|
||||
emptyHistory: "No conversations yet",
|
||||
thinking: "Thinking...",
|
||||
thinking: "Preparing your reply",
|
||||
busyRetry: "Service busy, please try again later",
|
||||
sessionFail: "Failed to create session, please check the service is running",
|
||||
autoWelcome: "Welcome to Baiyue Logistics. Ask about rates, transit times or human support.",
|
||||
@@ -1706,13 +1706,30 @@ async function doSend(retryText, showQuestion){
|
||||
}
|
||||
|
||||
const aiBubble = addMsg(t("thinking"), false);
|
||||
aiBubble.classList.add("typing");
|
||||
aiBubble.textContent = t('thinking');
|
||||
aiBubble.classList.add("typing", "reply-waiting");
|
||||
aiBubble.setAttribute('aria-busy', 'true');
|
||||
activeBubble = aiBubble;
|
||||
setBusy(true);
|
||||
|
||||
let es = null;
|
||||
let firstChunk = true;
|
||||
let receivedDone = false;
|
||||
let receivedContent = false;
|
||||
let settled = false; // 已经结束(成功或失败),用它区分"正常关闭"和"真断开"
|
||||
const reply = ChatReply.create({
|
||||
shouldAnimate:function(){
|
||||
return !document.hidden && !window.matchMedia('(prefers-reduced-motion: reduce)').matches;
|
||||
},
|
||||
onText:function(raw){
|
||||
if(settled || version !== conversationVersion) return;
|
||||
aiBubble.classList.remove('reply-waiting');
|
||||
aiBubble.dataset.raw = raw;
|
||||
aiBubble.innerHTML = parseBold(raw);
|
||||
scrollChatToEnd();
|
||||
},
|
||||
onComplete:finish,
|
||||
onError:function(){ fail(t('networkFail')); },
|
||||
});
|
||||
|
||||
const firstTimer = setTimeout(function(){ fail("模型响应超时,请稍后重试"); }, 90000);
|
||||
const totalTimer = setTimeout(function(){ fail("生成时间过长,已中断,请重试"); }, 120000);
|
||||
@@ -1729,11 +1746,13 @@ async function doSend(retryText, showQuestion){
|
||||
}
|
||||
|
||||
function finish(){
|
||||
if(settled) return;
|
||||
if(settled || version !== conversationVersion) return;
|
||||
settled = true;
|
||||
reply.cancel();
|
||||
cleanup();
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
aiBubble.classList.remove("typing", "reply-waiting");
|
||||
aiBubble.setAttribute('aria-busy', 'false');
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
|
||||
let raw = aiBubble.dataset.raw || "";
|
||||
@@ -1755,9 +1774,13 @@ async function doSend(retryText, showQuestion){
|
||||
|
||||
function fail(text){
|
||||
if(settled) return;
|
||||
try{ reply.flush(); }catch(error){}
|
||||
settled = true;
|
||||
reply.cancel();
|
||||
cleanup();
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove('reply-waiting');
|
||||
aiBubble.setAttribute('aria-busy', 'false');
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
renderError(aiBubble, text || t("busyRetry"), function(){
|
||||
return retryAnswer(aiBubble, fullContent);
|
||||
@@ -1768,9 +1791,11 @@ async function doSend(retryText, showQuestion){
|
||||
function stop(){
|
||||
if(settled) return;
|
||||
settled = true;
|
||||
reply.cancel();
|
||||
cleanup();
|
||||
setBusy(false);
|
||||
aiBubble.classList.remove("typing");
|
||||
aiBubble.classList.remove("typing", "reply-waiting");
|
||||
aiBubble.setAttribute('aria-busy', 'false');
|
||||
if(activeBubble === aiBubble) activeBubble = null;
|
||||
const raw = aiBubble.dataset.raw || "";
|
||||
if(raw){
|
||||
@@ -1788,7 +1813,7 @@ async function doSend(retryText, showQuestion){
|
||||
cancelActive = stop;
|
||||
|
||||
function handleFrame(dataText){
|
||||
if(settled || version !== conversationVersion) return;
|
||||
if(settled || receivedDone || version !== conversationVersion) return;
|
||||
let obj;
|
||||
try{
|
||||
obj = JSON.parse(dataText);
|
||||
@@ -1799,38 +1824,37 @@ async function doSend(retryText, showQuestion){
|
||||
if(obj.e){ fail(obj.e); return; }
|
||||
if(obj.login_required) aiBubble.dataset.loginRequired = '1';
|
||||
if(obj.card){
|
||||
receivedContent = true;
|
||||
clearTimeout(firstTimer);
|
||||
aiBubble.classList.add("track-card");
|
||||
fillTrackCard(aiBubble, obj.card);
|
||||
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
|
||||
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
|
||||
firstChunk = false;
|
||||
scrollChatToEnd();
|
||||
reply.card(function(){
|
||||
if(settled || version !== conversationVersion) return;
|
||||
aiBubble.classList.remove('reply-waiting');
|
||||
aiBubble.classList.add('track-card', 'reply-ready');
|
||||
fillTrackCard(aiBubble, obj.card);
|
||||
aiBubble.dataset.raw = obj.card.kind === 'order' ? obj.card.summary : "运单号 " + obj.card.no + "(" + (obj.card.status || "") + ")\n" +
|
||||
(obj.card.nodes || []).map(function(n){ return n.time + " " + n.text + " " + (n.place || ""); }).join("\n");
|
||||
scrollChatToEnd();
|
||||
});
|
||||
return;
|
||||
}
|
||||
if(obj.replace){
|
||||
receivedContent = true;
|
||||
clearTimeout(firstTimer);
|
||||
aiBubble.dataset.raw = obj.replace;
|
||||
aiBubble.innerHTML = parseBold(obj.replace);
|
||||
scrollChatToEnd();
|
||||
reply.replace(obj.replace);
|
||||
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;
|
||||
if(obj.d){
|
||||
if(!receivedContent){ fail(t('networkFail')); return; }
|
||||
receivedDone = true; cleanup(); reply.complete(); return;
|
||||
}
|
||||
const raw = (aiBubble.dataset.raw || "") + obj.t;
|
||||
aiBubble.dataset.raw = raw;
|
||||
aiBubble.innerHTML = parseBold(raw);
|
||||
scrollChatToEnd();
|
||||
if(typeof obj.t !== "string" || !obj.t) return;
|
||||
receivedContent = true;
|
||||
clearTimeout(firstTimer);
|
||||
reply.append(obj.t);
|
||||
}
|
||||
|
||||
// 用 POST + fetch 流式读取:问题放在请求体里,不受 URL 长度限制
|
||||
@@ -1877,18 +1901,18 @@ async function doSend(retryText, showQuestion){
|
||||
if(r.done){
|
||||
buffer += decoder.decode();
|
||||
drain(true);
|
||||
if(!settled) fail(t("networkFail"));
|
||||
if(!settled && !receivedDone) fail(t("networkFail"));
|
||||
return;
|
||||
}
|
||||
buffer += decoder.decode(r.value, {stream: true});
|
||||
drain(false);
|
||||
if(!settled) return pump();
|
||||
if(!settled && !receivedDone) return pump();
|
||||
});
|
||||
}
|
||||
return pump();
|
||||
}).catch(function(err){
|
||||
if(err && err.name === "AbortError") return; // 用户主动停止
|
||||
if(settled) return;
|
||||
if(settled || receivedDone) return;
|
||||
fail(t("networkFail"));
|
||||
});
|
||||
return true;
|
||||
|
||||
@@ -0,0 +1,81 @@
|
||||
// Pace already received text while preserving network completion and cancellation.
|
||||
var ChatReply = (function(){
|
||||
function create(options){
|
||||
const now = options.now || Date.now;
|
||||
const readyAt = now() + 300;
|
||||
const segmenter = typeof Intl !== 'undefined' && Intl.Segmenter
|
||||
? new Intl.Segmenter(undefined, {granularity:'grapheme'}) : null;
|
||||
let text = '', characters = [], visible = 0;
|
||||
let startedAt = null, timer = null, card = null;
|
||||
let complete = false, closed = false;
|
||||
|
||||
function clearTimer(){
|
||||
if(timer !== null) clearTimeout(timer);
|
||||
timer = null;
|
||||
}
|
||||
function schedule(delay){
|
||||
if(timer === null && !closed) timer = setTimeout(function(){
|
||||
try{ draw(false); }
|
||||
catch(error){ closed = true; clearTimer(); options.onError(error); }
|
||||
}, delay);
|
||||
}
|
||||
function draw(force){
|
||||
clearTimer();
|
||||
if(closed) return;
|
||||
const animate = !force && options.shouldAnimate();
|
||||
if(animate && now() < readyAt){ schedule(readyAt - now()); return; }
|
||||
if(card){
|
||||
const show = card;
|
||||
card = null;
|
||||
show();
|
||||
}
|
||||
if(visible < characters.length){
|
||||
if(startedAt === null) startedAt = now();
|
||||
const elapsed = Math.max(0, now() - startedAt);
|
||||
const rate = Math.max(45, characters.length / 4);
|
||||
visible = animate ? Math.min(characters.length, Math.max(visible + 1, Math.floor(elapsed * rate / 1000))) : characters.length;
|
||||
options.onText(characters.slice(0, visible).join(''));
|
||||
}
|
||||
if(visible < characters.length){ schedule(16); return; }
|
||||
if(complete){
|
||||
closed = true;
|
||||
options.onComplete();
|
||||
}
|
||||
}
|
||||
function update(){
|
||||
if(closed) return;
|
||||
if(!options.shouldAnimate()) draw(true);
|
||||
else schedule(Math.max(0, readyAt - now()));
|
||||
}
|
||||
function setText(value){
|
||||
text = value;
|
||||
characters = segmenter ? Array.from(segmenter.segment(text), item => item.segment) : Array.from(text);
|
||||
}
|
||||
return {
|
||||
append:function(value){
|
||||
if(closed) return;
|
||||
card = null;
|
||||
setText(text + value);
|
||||
update();
|
||||
},
|
||||
replace:function(value){
|
||||
if(closed) return;
|
||||
card = null;
|
||||
setText(value);
|
||||
visible = 0;
|
||||
update();
|
||||
},
|
||||
card:function(show){
|
||||
if(closed) return;
|
||||
setText('');
|
||||
visible = 0;
|
||||
card = show;
|
||||
update();
|
||||
},
|
||||
complete:function(){ complete = true; update(); },
|
||||
flush:function(){ draw(true); },
|
||||
cancel:function(){ closed = true; clearTimer(); },
|
||||
};
|
||||
}
|
||||
return {create:create};
|
||||
})();
|
||||
@@ -103,6 +103,7 @@
|
||||
<script src="{{ url_for('static', filename='js/order_lookup.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/drafts.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/voice.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/reply.js') }}"></script>
|
||||
<script src="{{ url_for('static', filename='js/chat.js') }}"></script>
|
||||
</body>
|
||||
</html>
|
||||
|
||||
@@ -15,7 +15,7 @@ async function customerPage(pageOptions = {}){
|
||||
get length(){return Object.keys(values).length;}
|
||||
};}
|
||||
const elements = new Map(), listeners = {}, windowListeners = {}, viewportListeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1;
|
||||
let intervalId = 0, timerId = 0, nextConvId = 1, clock = 0;
|
||||
let voiceOptions;
|
||||
class Element {
|
||||
constructor(){
|
||||
@@ -24,12 +24,12 @@ async function customerPage(pageOptions = {}){
|
||||
this.listeners = {}; this.focusCalls = []; this.isConnected = true;
|
||||
this.classList = {
|
||||
contains: name => this.className.split(' ').includes(name),
|
||||
add: name => { this.className += ' '+name; },
|
||||
remove: name => { this.className = this.className.split(' ').filter(value => value !== name).join(' '); },
|
||||
add: (...names) => { this.className += ' '+names.join(' '); },
|
||||
remove: (...names) => { this.className = this.className.split(' ').filter(value => !names.includes(value)).join(' '); },
|
||||
toggle: (name, on) => { this.classList.remove(name); if(on) this.classList.add(name); },
|
||||
};
|
||||
}
|
||||
set innerHTML(value){ this.html = value; this.children = []; }
|
||||
set innerHTML(value){ this.text = ''; this.html = value; this.children = []; }
|
||||
get innerHTML(){ return this.html || ''; }
|
||||
set textContent(value){ this.text = value; this.html = ''; this.children = []; }
|
||||
get textContent(){ return (this.text || this.innerText || this.html || '')+this.children.map(child => child.textContent).join(''); }
|
||||
@@ -74,7 +74,10 @@ async function customerPage(pageOptions = {}){
|
||||
execCommand: () => pageOptions.copyResult !== false,
|
||||
addEventListener: (name, callback) => { const previous=listeners[name]; listeners[name]=previous ? (...args)=>{previous(...args);callback(...args);} : callback; },
|
||||
};
|
||||
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
|
||||
class TestDate extends Date {
|
||||
constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); }
|
||||
static now(){ return pageOptions.reducedMotion === false ? clock : Date.now(); }
|
||||
}
|
||||
let state = {status:'active', messages:[]};
|
||||
let customerState=pageOptions.customer || null;
|
||||
const context = vm.createContext({
|
||||
@@ -84,13 +87,14 @@ async function customerPage(pageOptions = {}){
|
||||
localStorage:testStorage(storageValues),sessionStorage:testStorage(tabValues),
|
||||
window:{innerWidth:pageOptions.width || 1200, innerHeight:pageOptions.viewportHeight || pageOptions.height || 800,
|
||||
addEventListener:(name,callback) => { (windowListeners[name] ||= []).push(callback); },
|
||||
matchMedia:query => ({matches:context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
matchMedia:query => ({matches:query.includes('prefers-reduced-motion') ? pageOptions.reducedMotion !== false
|
||||
: context.window.innerWidth <= Number((query.match(/max-width:\s*(\d+)px/) || [])[1])}),
|
||||
visualViewport:pageOptions.viewportHeight ? {height:pageOptions.viewportHeight,scale:1,offsetTop:0,
|
||||
addEventListener:(name,callback) => { (viewportListeners[name] ||= []).push(callback); }} : undefined,
|
||||
prompt:() => pageOptions.offlineMessage || null},
|
||||
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
|
||||
clearInterval:id => intervals.delete(id),
|
||||
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
|
||||
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay,at:clock+delay}); return id; },
|
||||
clearTimeout:id => timers.delete(id),
|
||||
fetch: async (url, options) => {
|
||||
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
|
||||
@@ -123,7 +127,7 @@ async function customerPage(pageOptions = {}){
|
||||
return {ok:true, json:async () => data};
|
||||
},
|
||||
});
|
||||
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'chat.js']){
|
||||
for(const file of ['support.js', 'visitor.js', 'customer.js', 'order_lookup.js', 'drafts.js', 'voice.js', 'reply.js', 'chat.js']){
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js', file), 'utf8'), context, {filename:file});
|
||||
if(file === 'voice.js'){
|
||||
const createVoice=context.ChatVoice.create;
|
||||
@@ -156,6 +160,17 @@ async function customerPage(pageOptions = {}){
|
||||
pageHide:async () => { (windowListeners.pagehide || []).forEach(callback => callback()); await flush(); },
|
||||
setState:value => { state=value; },
|
||||
runTimers:async delay => { for(const [id, timer] of Array.from(timers)) if(timer.delay === delay){ timers.delete(id); timer.callback(); } await flush(); },
|
||||
advanceTimers:async milliseconds => {
|
||||
const end=clock+milliseconds;
|
||||
let count=0;
|
||||
while(true){
|
||||
const next=Array.from(timers).filter(([,timer])=>timer.at<=end).sort((a,b)=>a[1].at-b[1].at)[0];
|
||||
if(!next) break;
|
||||
assert.ok(++count<10000,'Timer queue must settle');
|
||||
clock=next[1].at;timers.delete(next[0]);next[1].callback();await flush();
|
||||
}
|
||||
clock=end;await flush();
|
||||
},
|
||||
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
|
||||
click:async button => { await button.click(); await flush(); },
|
||||
};
|
||||
@@ -281,6 +296,110 @@ test('rate limit responses show the server message and allow retry', async () =>
|
||||
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
|
||||
});
|
||||
|
||||
test('a local completed reply keeps waiting feedback then progressively renders before enabling actions', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
assert.match(bubble.textContent,/正在处理/);
|
||||
assert.equal(app.context.window.__busy,true);
|
||||
assert.equal(bubble.querySelector('.msg-actions'),null);
|
||||
await app.advanceTimers(299);
|
||||
assert.match(bubble.textContent,/正在处理/);
|
||||
await app.advanceTimers(1);
|
||||
assert.ok(bubble.dataset.raw.length>0 && bubble.dataset.raw.length<'测试 AI 回复'.length);
|
||||
assert.equal(app.context.window.__busy,true);
|
||||
await app.advanceTimers(2000);
|
||||
assert.equal(bubble.dataset.raw,'测试 AI 回复');
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
assert.ok(bubble.querySelector('.msg-actions'));
|
||||
assert.equal(bubble.getAttribute('aria-busy'),'false');
|
||||
assert.equal(bubble.querySelector('.retry-btn'),null);
|
||||
});
|
||||
|
||||
test('stopping during the waiting stage cancels pending presentation', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
await app.click(app.element('send-button'));
|
||||
const stopped=bubble.textContent;
|
||||
await app.advanceTimers(10000);
|
||||
assert.equal(bubble.textContent,stopped);
|
||||
assert.equal(bubble.dataset.raw,undefined);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
});
|
||||
|
||||
test('stopping mid-reveal freezes the displayed prefix and preserves the next draft', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
const answer='完整的报价内容。'.repeat(100);
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({t:answer})+'\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
await app.advanceTimers(600);
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
const prefix=bubble.dataset.raw;
|
||||
assert.ok(prefix.length<answer.length);
|
||||
app.element('input-text').value='下一条问题';
|
||||
await app.click(app.element('send-button'));
|
||||
await app.advanceTimers(10000);
|
||||
assert.equal(bubble.dataset.raw,prefix);
|
||||
assert.equal(app.element('input-text').value,'下一条问题');
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
});
|
||||
|
||||
test('switching conversations cancels a response whose network stream already completed', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"旧对话的排队回复"}\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
await app.click(app.element('.new-chat-btn'));
|
||||
await app.advanceTimers(10000);
|
||||
assert.doesNotMatch(app.element('chatDom').textContent,/旧对话的排队回复/);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
});
|
||||
|
||||
test('premature stream closure flushes received text and cancels its animation before retry', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
assert.match(bubble.textContent,/已经收到的部分回答/);
|
||||
assert.ok(bubble.querySelector('.retry-btn'));
|
||||
await app.advanceTimers(10000);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
assert.equal(bubble.dataset.raw,'已经收到的部分回答');
|
||||
});
|
||||
|
||||
test('an empty completion releases the waiting indicator and offers retry', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: {"t":""}\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('查询报价');
|
||||
await flush();
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
assert.ok(bubble.querySelector('.retry-btn'));
|
||||
assert.equal(bubble.classList.contains('reply-waiting'),false);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
await app.advanceTimers(10000);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
});
|
||||
|
||||
test('a structured order card is shown after waiting and completes without a false disconnect', async () => {
|
||||
const app=await customerPage({human:false,reducedMotion:false});
|
||||
const card={kind:'order',no:'ORDER12345',status:'履约中',fields:[],nodes:[],summary:'入仓码 IN12345'};
|
||||
app.hooks['/stream_chat']=()=>streamResponse(['data: '+JSON.stringify({card})+'\n\ndata: {"d":1}\n\n']);
|
||||
await app.context.window.fillInput('IN12345');
|
||||
await flush();
|
||||
const bubble=app.element('chatDom').querySelectorAll('.robot-message').at(-1);
|
||||
assert.match(bubble.textContent,/正在处理/);
|
||||
await app.advanceTimers(300);
|
||||
assert.ok(bubble.classList.contains('track-card'));
|
||||
assert.equal(bubble.dataset.raw,card.summary);
|
||||
assert.equal(app.context.window.__busy,false);
|
||||
assert.equal(bubble.querySelector('.retry-btn'),null);
|
||||
});
|
||||
|
||||
test('a stalled conversation creation times out and can be retried without losing the draft', async () => {
|
||||
const app=await customerPage({human:false});
|
||||
app.hooks['/api/new_chat']=options => new Promise((resolve,reject) => {
|
||||
|
||||
@@ -0,0 +1,174 @@
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
const fs = require('node:fs');
|
||||
const path = require('node:path');
|
||||
const vm = require('node:vm');
|
||||
|
||||
function playback(options = {}){
|
||||
let clock = 0, nextId = 0, animate = options.animate !== false, completed = 0;
|
||||
const timers = new Map(), text = [], errors = [];
|
||||
const context = vm.createContext({
|
||||
Intl,
|
||||
setTimeout(callback, delay){ const id = ++nextId; timers.set(id, {callback, at:clock + delay}); return id; },
|
||||
clearTimeout(id){ timers.delete(id); },
|
||||
});
|
||||
vm.runInContext(fs.readFileSync(path.join(__dirname, '../../static/js/reply.js'), 'utf8'), context);
|
||||
const reply = context.ChatReply.create({
|
||||
now:() => clock,
|
||||
shouldAnimate:() => animate,
|
||||
onText:value => text.push(value),
|
||||
onComplete:() => { completed++; },
|
||||
onError:error => errors.push(error),
|
||||
});
|
||||
function advance(milliseconds){
|
||||
const end = clock + milliseconds;
|
||||
let count = 0;
|
||||
while(true){
|
||||
const next = Array.from(timers).filter(([,timer]) => timer.at <= end).sort((a,b) => a[1].at - b[1].at)[0];
|
||||
if(!next) break;
|
||||
assert.ok(++count < 10000, 'Timer queue must settle');
|
||||
clock = next[1].at;
|
||||
timers.delete(next[0]);
|
||||
next[1].callback();
|
||||
}
|
||||
clock = end;
|
||||
}
|
||||
return {reply, advance, text, errors, timers, completed:() => completed, reduceMotion:() => { animate = false; }};
|
||||
}
|
||||
|
||||
test('a complete local response waits briefly and reveals text before finishing', () => {
|
||||
const app = playback();
|
||||
const answer = '这是来自数据库的报价回复。'.repeat(10);
|
||||
app.reply.append(answer);
|
||||
app.reply.complete();
|
||||
app.advance(299);
|
||||
assert.deepEqual(app.text, []);
|
||||
app.advance(1);
|
||||
assert.ok(app.text.at(-1).length < answer.length);
|
||||
assert.equal(app.completed(), 0);
|
||||
app.advance(4200);
|
||||
assert.equal(app.text.at(-1), answer);
|
||||
assert.equal(app.completed(), 1);
|
||||
assert.equal(app.timers.size, 0);
|
||||
});
|
||||
|
||||
test('real streaming chunks keep their full content and finish once', () => {
|
||||
const app = playback();
|
||||
app.advance(1000);
|
||||
app.reply.append('第一段');
|
||||
app.advance(200);
|
||||
app.reply.append(',接着第二段。');
|
||||
app.reply.complete();
|
||||
app.advance(4500);
|
||||
assert.equal(app.text.at(-1), '第一段,接着第二段。');
|
||||
assert.equal(app.completed(), 1);
|
||||
app.reply.complete();
|
||||
app.advance(100);
|
||||
assert.equal(app.completed(), 1);
|
||||
});
|
||||
|
||||
test('replacement supersedes both pending text and a previously displayed prefix', () => {
|
||||
const app = playback();
|
||||
app.reply.append('旧的详细报价'.repeat(20));
|
||||
app.advance(400);
|
||||
assert.ok(app.text.length);
|
||||
app.reply.replace('新的简明报价');
|
||||
app.reply.complete();
|
||||
app.advance(4500);
|
||||
assert.equal(app.text.at(-1), '新的简明报价');
|
||||
assert.equal(app.completed(), 1);
|
||||
});
|
||||
|
||||
test('cancellation before the first reveal prevents late text and completion', () => {
|
||||
const app = playback();
|
||||
app.reply.append('等待中的回复');
|
||||
app.reply.complete();
|
||||
app.reply.cancel();
|
||||
app.advance(10000);
|
||||
assert.deepEqual(app.text, []);
|
||||
assert.equal(app.completed(), 0);
|
||||
assert.equal(app.timers.size, 0);
|
||||
});
|
||||
|
||||
test('cancellation freezes a displayed prefix and ignores later chunks', () => {
|
||||
const app = playback();
|
||||
app.reply.append('已经收到的长回复'.repeat(100));
|
||||
app.advance(500);
|
||||
const prefix = app.text.at(-1);
|
||||
app.reply.cancel();
|
||||
app.reply.append('迟到的内容');
|
||||
app.reply.complete();
|
||||
app.advance(10000);
|
||||
assert.equal(app.text.at(-1), prefix);
|
||||
assert.equal(app.completed(), 0);
|
||||
});
|
||||
|
||||
test('cards are shown intact after the waiting feedback', () => {
|
||||
const app = playback();
|
||||
let shown = 0;
|
||||
app.reply.card(() => { shown++; });
|
||||
app.reply.complete();
|
||||
app.advance(299);
|
||||
assert.equal(shown, 0);
|
||||
app.advance(1);
|
||||
assert.equal(shown, 1);
|
||||
assert.equal(app.completed(), 1);
|
||||
});
|
||||
|
||||
test('reduced motion immediately renders text and cards', () => {
|
||||
const app = playback({animate:false});
|
||||
app.reply.append('快速显示的完整回复');
|
||||
assert.equal(app.text.at(-1), '快速显示的完整回复');
|
||||
app.reply.complete();
|
||||
assert.equal(app.completed(), 1);
|
||||
assert.equal(app.timers.size, 0);
|
||||
const card = playback({animate:false});
|
||||
let shown = false;
|
||||
card.reply.card(() => { shown = true; });
|
||||
card.reply.complete();
|
||||
assert.equal(shown, true);
|
||||
assert.equal(card.completed(), 1);
|
||||
});
|
||||
|
||||
test('a long answer has a bounded presentation duration', () => {
|
||||
const app = playback();
|
||||
const answer = '运费和时效。'.repeat(4000);
|
||||
app.reply.append(answer);
|
||||
app.reply.complete();
|
||||
app.advance(4320);
|
||||
assert.equal(app.text.at(-1), answer);
|
||||
assert.equal(app.completed(), 1);
|
||||
});
|
||||
|
||||
test('grapheme clusters remain intact during progressive display', () => {
|
||||
const app = playback();
|
||||
app.reply.append('👨👩👧👦您好,这是完整字符。');
|
||||
app.advance(300);
|
||||
assert.equal(app.text[0], '👨👩👧👦');
|
||||
app.reply.complete();
|
||||
app.advance(4500);
|
||||
assert.equal(app.text.at(-1), '👨👩👧👦您好,这是完整字符。');
|
||||
});
|
||||
|
||||
test('a hidden or reduced-motion page can flush all received text', () => {
|
||||
const app = playback();
|
||||
const answer = '完整的报价内容'.repeat(50);
|
||||
app.reply.append(answer);
|
||||
app.reply.complete();
|
||||
app.advance(350);
|
||||
app.reduceMotion();
|
||||
app.reply.flush();
|
||||
assert.equal(app.text.at(-1), answer);
|
||||
assert.equal(app.completed(), 1);
|
||||
assert.equal(app.timers.size, 0);
|
||||
});
|
||||
|
||||
test('an asynchronous rendering failure cancels the queue and reports once', () => {
|
||||
const app = playback();
|
||||
app.reply.card(() => { throw new Error('Cannot render card'); });
|
||||
app.reply.complete();
|
||||
app.advance(500);
|
||||
assert.equal(app.errors.length, 1);
|
||||
assert.equal(app.completed(), 0);
|
||||
assert.equal(app.timers.size, 0);
|
||||
});
|
||||
@@ -11,7 +11,7 @@ class FrontendTests(unittest.TestCase):
|
||||
def test_reply_workflows(self):
|
||||
root = Path(__file__).resolve().parents[1]
|
||||
result = subprocess.run(
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs'], cwd=root,
|
||||
['node', '--test', 'tests/frontend/admin.test.cjs', 'tests/frontend/chat.test.cjs', 'tests/frontend/voice.test.cjs', 'tests/frontend/visitor.test.cjs', 'tests/frontend/drafts.test.cjs', 'tests/frontend/reply.test.cjs'], cwd=root,
|
||||
text=True, encoding='utf-8', capture_output=True, timeout=30,
|
||||
)
|
||||
self.assertEqual(result.returncode, 0, result.stdout + result.stderr)
|
||||
|
||||
@@ -196,6 +196,20 @@ class OrderStatisticsTests(unittest.TestCase):
|
||||
self.assertNotIn('请提供',text)
|
||||
self.assertEqual(self.ask('重量呢',cid)['card']['no'],first)
|
||||
|
||||
def test_short_choice_confirmation_preserves_the_selected_order_and_navigation(self):
|
||||
self.login()
|
||||
cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
first=self.ask('第一个呢',cid)['card']['no']
|
||||
second=self.ask('下一票呢',cid)['card']['no']
|
||||
self.ask('就这个',cid)
|
||||
with self.app.app_context():
|
||||
connection=db.connect()
|
||||
reason=connection.execute('SELECT exception_reason FROM order_test_data WHERE order_no=?',(second,)).fetchone()[0]
|
||||
connection.close()
|
||||
self.assertIn(reason,self.ask('异常原因呢',cid)['t'])
|
||||
self.assertEqual(self.ask('上一票呢',cid)['card']['no'],first)
|
||||
|
||||
def test_empty_or_out_of_range_ordinals_never_choose_an_unshown_order(self):
|
||||
self.login();cid=self.client.post('/api/new_chat').json['id']
|
||||
self.ask('我有多少异常订单',cid)
|
||||
|
||||
@@ -79,6 +79,27 @@ class QuoteConversationTests(unittest.TestCase):
|
||||
self.assertIn('3000KG = 30000元',text)
|
||||
self.assertNotIn('普船海派',text)
|
||||
|
||||
def test_natural_cost_preference_selects_the_cheapest_after_recommendation(self):
|
||||
self.ask('美国3000KG义乌美东海派报价')
|
||||
self.ask('请帮我推荐一下')
|
||||
text=self.ask('那我想省钱')
|
||||
self.assertIn('3000KG = 18000元',text)
|
||||
self.assertNotIn('美森正班海派',text)
|
||||
|
||||
def test_short_choice_confirmation_preserves_the_last_selected_quote(self):
|
||||
self.ask('美国3000KG义乌美东海派报价')
|
||||
self.ask('请帮我推荐一下')
|
||||
self.ask('时效优先')
|
||||
self.ask('就选这个')
|
||||
self.assertIn('15天左右提取',self.ask('多久能提取'))
|
||||
self.assertIn('3000KG = 30000元',self.ask('那一共多少钱'))
|
||||
|
||||
def test_quantity_changes_continue_after_a_short_confirmation(self):
|
||||
self.ask('美国3000KG义乌美东美森正班海派报价')
|
||||
self.ask('再加500KG')
|
||||
self.ask('就按这个')
|
||||
self.assertIn('2500KG = 25000元',self.ask('再减一吨'))
|
||||
|
||||
def test_no_book_for_a_named_us_service_does_not_use_legacy_regional_knowledge(self):
|
||||
with patch('logistics_chat.quote_service.load_book',return_value=None), \
|
||||
patch('logistics_chat.quote_data.load_book',return_value=None):
|
||||
|
||||
Reference in New Issue
Block a user