修复聊天异步状态、流中断和报价上下文

This commit is contained in:
2026-10-29 18:41:02 +08:00
parent a081966342
commit 1f66307bba
16 changed files with 565 additions and 116 deletions
+168 -7
View File
@@ -6,8 +6,8 @@ const vm = require('node:vm');
const flush = () => new Promise(setImmediate);
async function customerPage(pageOptions = {}){
const elements = new Map(), listeners = {}, intervals = new Map(), calls = [], hooks = {};
let intervalId = 0, nextConvId = 1;
const elements = new Map(), listeners = {}, intervals = new Map(), timers = new Map(), calls = [], hooks = {};
let intervalId = 0, timerId = 0, nextConvId = 1;
class Element {
constructor(){
this.children = []; this.attributes = {}; this.value = ''; this.style = {};
@@ -25,6 +25,7 @@ async function customerPage(pageOptions = {}){
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(''); }
setAttribute(name, value){ this.attributes[name] = value; }
removeAttribute(name){ delete this.attributes[name]; }
getAttribute(name){ return this.attributes[name]; }
addEventListener(name, callback){ this.listeners[name] = callback; }
click(){ if(this.disabled) return; if(this.onclick) return this.onclick(); if(this.listeners.click) return this.listeners.click({target:this}); }
@@ -61,14 +62,17 @@ async function customerPage(pageOptions = {}){
class TestDate extends Date { constructor(...args){ super(...(args.length ? args : [2026, 9, 29, pageOptions.hour ?? 12])); } }
let state = {status:'active', messages:[]};
const context = vm.createContext({
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder,
document, console, Date:TestDate, navigator:{language:'zh-CN'}, AbortController, TextDecoder, confirm:() => true,
FormData:class { append(){} },
requestAnimationFrame:callback => callback(),
localStorage:{getItem:() => null, setItem(){}},
localStorage:{getItem:key => (pageOptions.storage || {})[key] ?? null, setItem(){}},
window:{innerWidth:1200, addEventListener(){}, matchMedia:() => ({matches:false}), prompt:() => pageOptions.offlineMessage || null},
setInterval: callback => { const id = ++intervalId; intervals.set(id, callback); return id; },
clearInterval:id => intervals.delete(id), setTimeout(){}, clearTimeout(){},
clearInterval:id => intervals.delete(id),
setTimeout:(callback, delay) => { const id=++timerId; timers.set(id,{callback,delay}); return id; },
clearTimeout:id => timers.delete(id),
fetch: async (url, options) => {
calls.push({url, body:options && options.body && JSON.parse(options.body)});
calls.push({url, body:options && options.body && (typeof options.body === 'string' ? JSON.parse(options.body) : options.body)});
if(hooks[url]) return hooks[url](options);
if(url.startsWith('/api/human/poll') && hooks.poll) return hooks.poll(url);
if(url.startsWith('/api/get_messages') && hooks.messages) return hooks.messages(url);
@@ -83,7 +87,7 @@ async function customerPage(pageOptions = {}){
const data = url === '/api/new_chat' ? {id:nextConvId++, welcome:'welcome'}
: url === '/api/human/request' ? {ok:true, session_id:7}
: url.startsWith('/api/human/poll') ? state
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) ? {ok:true}
: ['/api/rate', '/api/human/send', '/api/human/close', '/api/ticket'].includes(url) || url.startsWith('/api/del_history') ? {ok:true}
: url === '/api/get_history' ? (pageOptions.history || []) : [];
return {ok:true, json:async () => data};
},
@@ -97,6 +101,7 @@ async function customerPage(pageOptions = {}){
await flush();
return {context, element, calls, hooks, quickButtons,
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(); },
tick:async () => { Array.from(intervals.values()).forEach(callback => callback()); await flush(); },
click:async button => { await button.click(); await flush(); },
};
@@ -117,6 +122,162 @@ test('the final customer-visible reply is processed before closure and a rating
assert.equal(app.context.window.humanSid, 0);
});
function streamResponse(chunks){
let index=0;
return {ok:true, body:{getReader:() => ({read:async () => index < chunks.length
? {done:false, value:typeof chunks[index] === 'string' ? new TextEncoder().encode(chunks[index++]) : chunks[index++]}
: {done:true}})}};
}
test('AI send preserves a newer draft typed while the conversation is being created', async () => {
const app=await customerPage({human:false});
let resolve;
app.hooks['/api/new_chat']=() => new Promise(done => { resolve=done; });
const sending=app.context.window.fillInput('第一条问题');
app.element('input-text').value='正在补充的下一条问题';
resolve(response({id:18,welcome:'welcome'}));
await sending;
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '第一条问题');
assert.equal(app.element('input-text').value, '正在补充的下一条问题');
});
test('switching to a quick topic discards the previous topic attachment', async () => {
const app=await customerPage({human:false});
app.context.window.__pendingImg='/uploads/20261029100145_6787.txt';
app.context.window.__pendingTag='[文件]';
await app.click(app.quickButtons[1]);
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.msg, '欧洲海运专线报价');
});
test('fragmented CRLF event streams preserve UTF-8 text and complete normally', async () => {
const app=await customerPage({human:false});
const bytes=new TextEncoder().encode('data: {"t":"完整中文回复"}\r\n\r\ndata: {"d":1}\r\n\r\n');
const chunks=Array.from({length:Math.ceil(bytes.length/5)}, (_,index) => bytes.slice(index*5,index*5+5));
app.hooks['/stream_chat']=() => streamResponse(chunks);
await app.context.window.fillInput('请回复');
await flush();
assert.match(app.element('chatDom').textContent, /完整中文回复/);
assert.equal(app.element('chatDom').querySelector('.retry-btn'), null);
assert.equal(app.context.window.__busy, false);
});
test('a stream ending without completion retains partial text and offers retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => streamResponse(['data: {"t":"已经收到的部分回答"}\n\n']);
await app.context.window.fillInput('请继续说明');
await flush();
assert.match(app.element('chatDom').textContent, /已经收到的部分回答/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
assert.equal(app.context.window.__busy, false);
});
test('rate limit responses show the server message and allow retry', async () => {
const app=await customerPage({human:false});
app.hooks['/stream_chat']=() => response({e:'请求太频繁,请稍后再试'},429);
await app.context.window.fillInput('请查单');
await flush();
assert.match(app.element('chatDom').textContent, /请求太频繁/);
assert.ok(app.element('chatDom').querySelector('.retry-btn'));
});
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) => {
if(options.signal) options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'), {name:'AbortError'})));
});
app.context.window.fillInput('请帮我查询');
await app.runTimers(10000);
assert.match(app.element('composer-status').textContent, /会话创建失败/);
assert.equal(app.element('input-text').value, '请帮我查询');
app.hooks['/api/new_chat']=() => response({id:19,welcome:'welcome'});
await app.context.window.fillInput('请帮我查询');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('history refresh failures preserve the last successfully loaded list', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留的对话',ctime:'2026-10-29 12:00'}]});
app.hooks['/api/get_history']=() => response({e:'请求太频繁'},429);
await app.click(app.element('.new-chat-btn'));
await flush();
assert.match(app.element('historyWrap').textContent, /保留的对话/);
});
test('deleting the active conversation aborts its stream and resets the composer', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前对话',ctime:'2026-10-29 12:00'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/stream_chat']=() => ({ok:true,body:{getReader:() => ({read:() => new Promise(() => {})})}});
await app.context.window.fillInput('请等待回复');
await flush();
const remove=app.element('historyWrap').children[0].children[2];
await remove.onclick({stopPropagation(){}});
await flush();
assert.equal(app.context.window.__busy,false);
assert.equal(app.element('send-button').textContent,'发送');
assert.ok(app.calls.find(call => call.url === '/stream_chat'));
});
test('switching topics aborts an upload and does not erase the new conversation status', async () => {
const app=await customerPage({human:false});
let aborted=false;
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => { aborted=true; reject(Object.assign(new Error('aborted'),{name:'AbortError'})); });
});
app.element('upload-input').files=[{name:'old.png'}];
app.element('upload-input').onchange();
await app.click(app.quickButtons[0]);
await flush();
assert.equal(aborted,true);
assert.equal(app.element('upload-btn').disabled,false);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('an upload timeout releases the composer and reports an actionable failure', async () => {
const app=await customerPage({human:false});
app.hooks['/api/upload']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('aborted'),{name:'AbortError'})));
});
app.element('upload-input').files=[{name:'slow.png'}];
app.element('upload-input').onchange();
await app.runTimers(15000);
assert.equal(app.element('upload-btn').disabled,false);
assert.match(app.element('composer-status').textContent,/附件上传失败/);
await app.context.window.fillInput('请继续查单');
await flush();
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('malformed stored pin preferences cannot break the conversation list', async () => {
const app=await customerPage({human:false,storage:{pinned_conv:'{broken'},history:[{id:9,title:'正常对话',ctime:'now'}]});
assert.match(app.element('historyWrap').textContent,/正常对话/);
});
test('clearing history blocks new conversations until its timed request settles', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'保留历史',ctime:'now'}]});
app.hooks['/api/clear_all']=options => new Promise((resolve,reject) => {
options.signal.addEventListener('abort', () => reject(Object.assign(new Error('timeout'),{name:'AbortError'})));
});
app.element('.clear-all-btn').click();
await app.click(app.quickButtons[1]);
await app.context.window.showHuman();
assert.equal(app.calls.filter(call => call.url === '/api/new_chat').length,0);
await app.runTimers(10000);
assert.match(app.element('historyWrap').textContent,/保留历史/);
await app.click(app.quickButtons[0]);
assert.equal(app.calls.filter(call => call.url === '/stream_chat').length,1);
});
test('a failed history deletion preserves the active conversation for the next message', async () => {
const app=await customerPage({human:false,history:[{id:9,title:'当前历史',ctime:'now'}]});
await app.click(app.element('historyWrap').children[0]);
app.hooks['/api/del_history?id=9']=() => response({ok:false},503);
await app.element('historyWrap').children[0].children[2].onclick({stopPropagation(){}});
await app.context.window.fillInput('继续这个问题');
await flush();
assert.equal(app.calls.find(call => call.url === '/stream_chat').body.conv_id,9);
});
test('recalling an earlier reply preserves newer replies in the customer log', async () => {
const app = await customerPage();
app.setState({status:'active', messages:[{id:1, side:'agent', content:'早先的回复'}, {id:2, side:'agent', content:'最新回复'}]});