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;
|
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){
|
async update_data(data){
|
||||||
|
if (this._stopped) return; // 已停止:不再接收/渲染后续 chunk(冻结输出)
|
||||||
this.run_stopped();
|
this.run_stopped();
|
||||||
this.filler.update(data);
|
this.filler.update(data);
|
||||||
if (data.llmusageid) {
|
if (data.llmusageid) {
|
||||||
@ -319,6 +335,7 @@ bricks.AgentIO = class extends bricks.VBox {
|
|||||||
if (!opts.height) opts.height = '100%';
|
if (!opts.height) opts.height = '100%';
|
||||||
super(opts);
|
super(opts);
|
||||||
this.llmmodels = [];
|
this.llmmodels = [];
|
||||||
|
this._running_streams = []; // 在飞的流 [{controller:AbortController, mout:AgentOutput}],「停」统一中断
|
||||||
this.msg_box = new bricks.VScrollPanel({
|
this.msg_box = new bricks.VScrollPanel({
|
||||||
width: '100%',
|
width: '100%',
|
||||||
css: 'filler'
|
css: 'filler'
|
||||||
@ -387,6 +404,27 @@ bricks.AgentIO = class extends bricks.VBox {
|
|||||||
chunk_ended(){
|
chunk_ended(){
|
||||||
console.log('chunk end');
|
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 与
|
// 切换产线(2026-09-07 移动端):重设 chat/menus/model 地址的 pipeline_id 与
|
||||||
// session_id(m_<pid>,每产线独立会话不混历史)并清空会话区。
|
// session_id(m_<pid>,每产线独立会话不混历史)并清空会话区。
|
||||||
// 不重建控件 —— inputw 的未发草稿/附件保留。
|
// 不重建控件 —— inputw 的未发草稿/附件保留。
|
||||||
@ -426,8 +464,17 @@ bricks.AgentIO = class extends bricks.VBox {
|
|||||||
this.msg_box.clear_widgets();
|
this.msg_box.clear_widgets();
|
||||||
}
|
}
|
||||||
async user_inputed(e){
|
async user_inputed(e){
|
||||||
this.show_input(e.params);
|
|
||||||
var params = 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;
|
params.llmid = this.agent_using_llmid;
|
||||||
console.log('params=', params);
|
console.log('params=', params);
|
||||||
/*
|
/*
|
||||||
@ -456,13 +503,40 @@ bricks.AgentIO = class extends bricks.VBox {
|
|||||||
});
|
});
|
||||||
files.forEach(function(f){ send_params.append('file', f); });
|
files.forEach(function(f){ send_params.append('file', f); });
|
||||||
}
|
}
|
||||||
var resp = await hr.post(this.opts.url, {params:send_params});
|
// 每条流一个 AbortController:注册到 _running_streams,「停」指令统一中断
|
||||||
if (! resp) {
|
var controller = window.AbortController ? new AbortController() : null;
|
||||||
mout.run_stopped();
|
var self = this;
|
||||||
return;
|
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){
|
async show_input(params){
|
||||||
var box = new bricks.HBox({width:'100%'});
|
var box = new bricks.HBox({width:'100%'});
|
||||||
|
|||||||
@ -106,13 +106,15 @@ bricks.HttpText = class {
|
|||||||
}
|
}
|
||||||
return Object.assign(this.headers, headers);
|
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);
|
url = this.url_parse(url);
|
||||||
var data = this.add_own_params(params);
|
var data = this.add_own_params(params);
|
||||||
var header = this.add_own_headers(headers);
|
var header = this.add_own_headers(headers);
|
||||||
var _params = {
|
var _params = {
|
||||||
method:method
|
method:method
|
||||||
}
|
}
|
||||||
|
// AbortController signal:调用方可中断在飞的请求(流式 agent「停」指令用)
|
||||||
|
if (signal) _params.signal = signal;
|
||||||
if (data instanceof FormData){
|
if (data instanceof FormData){
|
||||||
method = 'POST';
|
method = 'POST';
|
||||||
_params.body = data;
|
_params.body = data;
|
||||||
@ -130,7 +132,8 @@ bricks.HttpText = class {
|
|||||||
const fetchResult = await this.bricks_fetch(url, {
|
const fetchResult = await this.bricks_fetch(url, {
|
||||||
method: opts.method,
|
method: opts.method,
|
||||||
headers: opts.headers,
|
headers: opts.headers,
|
||||||
params: opts.params});
|
params: opts.params,
|
||||||
|
signal: opts.signal});
|
||||||
if (fetchResult.status == 401 && bricks.app.login_url){
|
if (fetchResult.status == 401 && bricks.app.login_url){
|
||||||
console.log('401 unauthorized, opening login')
|
console.log('401 unauthorized, opening login')
|
||||||
return await this.withLoginInfo(url, opts);
|
return await this.withLoginInfo(url, opts);
|
||||||
@ -248,11 +251,12 @@ bricks.HttpText = class {
|
|||||||
params:params
|
params:params
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
async post(url, {headers=null, params=null}={}){
|
async post(url, {headers=null, params=null, signal=null}={}){
|
||||||
return await this.httpcall(url, {
|
return await this.httpcall(url, {
|
||||||
method:'POST',
|
method:'POST',
|
||||||
headers:headers,
|
headers:headers,
|
||||||
params:params
|
params:params,
|
||||||
|
signal:signal
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -279,18 +283,32 @@ bricks.HttpResponseStream = class extends bricks.HttpResponse {
|
|||||||
async handle_chunk(resp, handler){
|
async handle_chunk(resp, handler){
|
||||||
const reader = resp.body.getReader();
|
const reader = resp.body.getReader();
|
||||||
const decoder = new TextDecoder('utf-8');
|
const decoder = new TextDecoder('utf-8');
|
||||||
let result = await reader.read();
|
|
||||||
var buff_ = '';
|
var buff_ = '';
|
||||||
while (!result.done) {
|
var result;
|
||||||
const text = decoder.decode(result.value);
|
try {
|
||||||
buff_ += text;
|
result = await reader.read();
|
||||||
const lines = buff_.split('\n');
|
while (!result.done) {
|
||||||
for (var i=0;i<lines.length - 1; i++){
|
const text = decoder.decode(result.value);
|
||||||
// console.log('line=', lines[i]);
|
buff_ += text;
|
||||||
handler(lines[i]);
|
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_ != ''){
|
if (buff_ != ''){
|
||||||
handler(buff_);
|
handler(buff_);
|
||||||
|
|||||||
Loading…
x
Reference in New Issue
Block a user