From 2409bb5c3611e41423910a378304a9ae1f0c1a9f Mon Sep 17 00:00:00 2001 From: ouyangxq <775153912@qq.com> Date: Fri, 6 Nov 2026 15:20:39 +0800 Subject: [PATCH] =?UTF-8?q?=E4=BC=98=E5=8C=96=E8=BF=9E=E7=BB=AD=E8=BF=BD?= =?UTF-8?q?=E9=97=AE=E4=B8=8E=E5=9B=9E=E5=A4=8D=E6=B8=90=E8=BF=9B=E5=B1=95?= =?UTF-8?q?=E7=A4=BA?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- AGENTS.md | 3 +- docs/architecture.md | 1 + logistics_chat/customer_faq.py | 1 + logistics_chat/quote_intent.py | 1 + static/css/chat.css | 11 +- static/js/chat.js | 86 +++++++++------ static/js/reply.js | 81 ++++++++++++++ templates/index.html | 1 + tests/frontend/chat.test.cjs | 135 ++++++++++++++++++++++-- tests/frontend/reply.test.cjs | 174 +++++++++++++++++++++++++++++++ tests/test_frontend.py | 2 +- tests/test_order_statistics.py | 14 +++ tests/test_quote_conversation.py | 21 ++++ 13 files changed, 489 insertions(+), 42 deletions(-) create mode 100644 static/js/reply.js create mode 100644 tests/frontend/reply.test.cjs diff --git a/AGENTS.md b/AGENTS.md index 393e807..a50292c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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_.py` and `test_`. 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 diff --git a/docs/architecture.md b/docs/architecture.md index c92c1bd..7dd71cf 100644 --- a/docs/architecture.md +++ b/docs/architecture.md @@ -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`;保留全局事件入口 | diff --git a/logistics_chat/customer_faq.py b/logistics_chat/customer_faq.py index a98207f..9e68d1e 100644 --- a/logistics_chat/customer_faq.py +++ b/logistics_chat/customer_faq.py @@ -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)) diff --git a/logistics_chat/quote_intent.py b/logistics_chat/quote_intent.py index 4c22dc5..c20b3de 100644 --- a/logistics_chat/quote_intent.py +++ b/logistics_chat/quote_intent.py @@ -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' diff --git a/static/css/chat.css b/static/css/chat.css index 26b5684..62ed308 100644 --- a/static/css/chat.css +++ b/static/css/chat.css @@ -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; } diff --git a/static/js/chat.js b/static/js/chat.js index b467668..1de006a 100644 --- a/static/js/chat.js +++ b/static/js/chat.js @@ -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; diff --git a/static/js/reply.js b/static/js/reply.js new file mode 100644 index 0000000..c80eb2e --- /dev/null +++ b/static/js/reply.js @@ -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}; +})(); diff --git a/templates/index.html b/templates/index.html index ed2900f..c3eac30 100644 --- a/templates/index.html +++ b/templates/index.html @@ -103,6 +103,7 @@ + diff --git a/tests/frontend/chat.test.cjs b/tests/frontend/chat.test.cjs index 7df3fcd..23fe9f1 100644 --- a/tests/frontend/chat.test.cjs +++ b/tests/frontend/chat.test.cjs @@ -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 { + 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) => { diff --git a/tests/frontend/reply.test.cjs b/tests/frontend/reply.test.cjs new file mode 100644 index 0000000..821d967 --- /dev/null +++ b/tests/frontend/reply.test.cjs @@ -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); +}); diff --git a/tests/test_frontend.py b/tests/test_frontend.py index 55cdff4..00ab22f 100644 --- a/tests/test_frontend.py +++ b/tests/test_frontend.py @@ -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) diff --git a/tests/test_order_statistics.py b/tests/test_order_statistics.py index deb94f4..0651d18 100644 --- a/tests/test_order_statistics.py +++ b/tests/test_order_statistics.py @@ -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) diff --git a/tests/test_quote_conversation.py b/tests/test_quote_conversation.py index 6b1a72d..426b9ee 100644 --- a/tests/test_quote_conversation.py +++ b/tests/test_quote_conversation.py @@ -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):