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:
parent
1d4c6be71f
commit
44411712d0
@ -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_id(m_<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 AbortError(headers 未回阶段);
|
||||
// 读流阶段的 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%'});
|
||||
|
||||
@ -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() 返回 Promise,abort 后流已处于 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_);
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user