优化连续追问与回复渐进展示

This commit is contained in:
2026-11-06 15:20:39 +08:00
parent a98ae0b8c6
commit 2409bb5c36
13 changed files with 489 additions and 42 deletions
+2 -1
View File
@@ -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
+1
View File
@@ -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`;保留全局事件入口 |
+1
View File
@@ -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))
+1
View File
@@ -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
View File
@@ -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
View File
@@ -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;
+81
View File
@@ -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};
})();
+1
View File
@@ -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>
+127 -8
View File
@@ -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) => {
+174
View File
@@ -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);
});
+1 -1
View File
@@ -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)
+14
View File
@@ -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)
+21
View File
@@ -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):