fix(agent): 用户输入「停」要真停在输入停之前——旧实现前端无AbortController,停指令被当普通消息再POST一次(后端关键词短路回'已停止'),但上一条流式请求是独立HTTP连接仍持续reader.read()渲染+后端生成器继续跑→'已停止'与旧输出同时刷屏(2026-09-08用户报障);修法:AgentIO每条流注册AbortController到_running_streams,「停」整句精确匹配(绝不子串,防'为什么已取消'误杀)且有在飞流时→abort fetch(服务端连接断→ahserver检测断连break→生成器链下个yield点GeneratorExit终止,实测断连后0个新LLM调用)+冻结旧气泡(update_data见_stopped即return,不再渲染后续chunk,停在输入停之前)+追加'已停止'标记;停指令不上屏不发请求(零LLM费用);jsoncall全链透传signal(bricks_fetch/httpcall/post)+handle_chunk对AbortError静默收尾(reader.cancel的Promise.catch兜底防unhandledrejection);playwright端到端实测:输出冻结chunks_grew=0/服务端确认BrokenPipe断连/停后零多余POST/新消息正常/语义精确匹配全过

This commit is contained in:
yumoqing 2026-09-08 17:25:19 +08:00
parent 1d4c6be71f
commit 44411712d0
2 changed files with 113 additions and 21 deletions

View File

@ -243,7 +243,23 @@ bricks.AgentOutput = class extends bricks.VBox {
this.run = null;
}
}
// 会话被用户「停」中断:冻结已输出内容(停在输入「停」之前的状态),
// 移除"思考中"动画,末尾追加一个轻量「已停止」标记(挂在 AgentOutput 自身,
// 不受 filler 重渲染影响;不新增对话气泡)。
show_stopped(){
if (this._stopped) return;
this._stopped = true;
this.run_stopped();
try {
var t = new bricks.Text({
text: '已停止', otext: '已停止', i18n: true,
halign: 'left', cfontsize: 0.85, color: '#94a3b8'
});
this.add_widget(t);
} catch(e){}
}
async update_data(data){
if (this._stopped) return; // 已停止:不再接收/渲染后续 chunk冻结输出
this.run_stopped();
this.filler.update(data);
if (data.llmusageid) {
@ -319,6 +335,7 @@ bricks.AgentIO = class extends bricks.VBox {
if (!opts.height) opts.height = '100%';
super(opts);
this.llmmodels = [];
this._running_streams = []; // 在飞的流 [{controller:AbortController, mout:AgentOutput}],「停」统一中断
this.msg_box = new bricks.VScrollPanel({
width: '100%',
css: 'filler'
@ -387,6 +404,27 @@ bricks.AgentIO = class extends bricks.VBox {
chunk_ended(){
console.log('chunk end');
}
// 「停」类快捷指令识别2026-09-08整句精确匹配绝不子串匹配——
// 「别停下来继续说」「为什么任务已取消」含停止词但语义是继续/提问,
// 子串匹配会误杀(教训见技能「用户意图识别」节)。
is_stop_command(text){
var t = String(text || '').trim().replace(/[。.!?~\s]+$/,'').toLowerCase();
return ['停','停止','停下','停一下','取消','终止','stop','cancel','abort'].indexOf(t) >= 0;
}
// 中断所有在飞的流式应答abort fetch服务端连接断开 → 生成器在下个
// yield 点收到 GeneratorExit 终止,不再调 LLM/工具)+ 冻结输出气泡
// (不再渲染后续 chunk停在输入「停」之前的状态+ 追加「已停止」标记。
// 返回中断的流数量0 = 当前没有在跑的应答)。
stop_running(){
var running = this._running_streams || [];
var n = running.length;
running.forEach(function(it){
try { it.controller.abort(); } catch(e){}
if (it.mout && it.mout.show_stopped) it.mout.show_stopped();
});
this._running_streams = [];
return n;
}
// 切换产线2026-09-07 移动端):重设 chat/menus/model 地址的 pipeline_id 与
// session_idm_<pid>,每产线独立会话不混历史)并清空会话区。
// 不重建控件 —— inputw 的未发草稿/附件保留。
@ -426,8 +464,17 @@ bricks.AgentIO = class extends bricks.VBox {
this.msg_box.clear_widgets();
}
async user_inputed(e){
this.show_input(e.params);
var params = e.params;
// 「停」快捷指令2026-09-08 修复:用户输入停 → 会话要停在输入停之前,
// 而不是回一句"已停止"、旧流还在继续输出):
// 有在飞的流 → 前端直接中断abort fetch + 冻结旧输出气泡 + 标记已停止),
// 消息不上屏、不发请求(零 LLM 费用);没有在跑的流 → 按普通消息走后端。
if (this.is_stop_command(params.prompt) && (this._running_streams||[]).length > 0){
this.stop_running();
this.scroll_to_bottom();
return;
}
this.show_input(params);
params.llmid = this.agent_using_llmid;
console.log('params=', params);
/*
@ -456,13 +503,40 @@ bricks.AgentIO = class extends bricks.VBox {
});
files.forEach(function(f){ send_params.append('file', f); });
}
var resp = await hr.post(this.opts.url, {params:send_params});
if (! resp) {
mout.run_stopped();
return;
// 每条流一个 AbortController注册到 _running_streams「停」指令统一中断
var controller = window.AbortController ? new AbortController() : null;
var self = this;
var slot = null;
if (controller){
slot = {controller: controller, mout: mout};
if (!this._running_streams) this._running_streams = [];
this._running_streams.push(slot);
}
var _unregister = function(){
if (slot && self._running_streams){
self._running_streams = self._running_streams.filter(function(s){ return s !== slot; });
}
};
try {
var resp = await hr.post(this.opts.url, {params:send_params,
signal: controller ? controller.signal : null});
if (! resp) {
mout.run_stopped();
return;
}
await hr.handle_chunk(resp, this.chunk_response.bind(this, mout));
this.chunk_ended();
} catch(err){
// abort 时 fetch 本身 reject AbortErrorheaders 未回阶段);
// 读流阶段的 AbortError 已在 handle_chunk 内静默。
// stop_running 已冻结气泡+标记,这里只需静默收尾,不弹错误。
if (!(err && (err.name === 'AbortError' || err.code === 20))) {
console.log('agent stream error:', err);
mout.run_stopped();
}
} finally {
_unregister();
}
await hr.handle_chunk(resp, this.chunk_response.bind(this, mout));
this.chunk_ended();
}
async show_input(params){
var box = new bricks.HBox({width:'100%'});

View File

@ -106,13 +106,15 @@ bricks.HttpText = class {
}
return Object.assign(this.headers, headers);
}
async bricks_fetch(url, {method='GET', headers=null, params=null}={}){
async bricks_fetch(url, {method='GET', headers=null, params=null, signal=null}={}){
url = this.url_parse(url);
var data = this.add_own_params(params);
var header = this.add_own_headers(headers);
var _params = {
method:method
}
// AbortController signal调用方可中断在飞的请求流式 agent「停」指令用
if (signal) _params.signal = signal;
if (data instanceof FormData){
method = 'POST';
_params.body = data;
@ -130,7 +132,8 @@ bricks.HttpText = class {
const fetchResult = await this.bricks_fetch(url, {
method: opts.method,
headers: opts.headers,
params: opts.params});
params: opts.params,
signal: opts.signal});
if (fetchResult.status == 401 && bricks.app.login_url){
console.log('401 unauthorized, opening login')
return await this.withLoginInfo(url, opts);
@ -248,11 +251,12 @@ bricks.HttpText = class {
params:params
});
}
async post(url, {headers=null, params=null}={}){
async post(url, {headers=null, params=null, signal=null}={}){
return await this.httpcall(url, {
method:'POST',
headers:headers,
params:params
params:params,
signal:signal
});
}
}
@ -279,18 +283,32 @@ bricks.HttpResponseStream = class extends bricks.HttpResponse {
async handle_chunk(resp, handler){
const reader = resp.body.getReader();
const decoder = new TextDecoder('utf-8');
let result = await reader.read();
var buff_ = '';
while (!result.done) {
const text = decoder.decode(result.value);
buff_ += text;
const lines = buff_.split('\n');
for (var i=0;i<lines.length - 1; i++){
// console.log('line=', lines[i]);
handler(lines[i]);
var result;
try {
result = await reader.read();
while (!result.done) {
const text = decoder.decode(result.value);
buff_ += text;
const lines = buff_.split('\n');
for (var i=0;i<lines.length - 1; i++){
// console.log('line=', lines[i]);
handler(lines[i]);
}
buff_ = lines[lines.length - 1];
result = await reader.read()
}
} catch (e){
// AbortController.abort() → reader.read() 抛 AbortError
// 用户主动「停」是预期中断,静默结束读取循环(不刷错误),
// 保留 buff_ 里已收到的完整行,尾行照常处理。
if (e && (e.name === 'AbortError' || e.code === 20)) {
// cancel() 返回 Promiseabort 后流已处于 error 态可能 reject
// → 必须 .catch() 兜底,否则冒泡成 unhandledrejection
try { Promise.resolve(reader.cancel()).catch(function(){}); } catch(_){}
} else {
throw e;
}
buff_ = lines[lines.length - 1];
result = await reader.read()
}
if (buff_ != ''){
handler(buff_);