bricks/bricks/agent.js
yumoqing 4815e1e54d fix(AgentIO): 滚底根治——MutationObserver监听内容增长
widget描述符走widgetBuild().then()异步挂载,同步滚总在挂载前执行滚不到新内容;
MutationObserver在挂载落DOM后触发,同步/异步渲染全覆盖,后台tab也生效
2026-09-02 10:57:17 +08:00

437 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

bricks = window.bricks || {}
bricks.LlmMsgAudio = class extends bricks.UpStreaming {
constructor(opts){
super(opts);
this.olddata = '';
this.data = '';
this.cn_p = ["。","","","","\n"];
this.other_p = [".",",","!","?","\n"];
this.audio = AudioPlayer({})
}
detectLanguage(text) {
try {
const detector = new Intl.LocaleDetector();
const locale = detector.detectLocaleOf(text);
return locale.language;
} catch (error) {
console.error('无法检测语言:', error);
return '未知';
}
}
send(data){
var newdata = data.slice(this.olddata.length);
this.olddata = data;
this.data += newdata;
var lang = detectLaguage(this.data);
var parts;
if (lang='zh'){
parts = this.data.split(this.cn_p).filter(part => part.trim()!== '');
} else {
parts = this.data.split(this.oter_p).filter(part => part.trim()!== '');
}
for(var i=0;i<parts.length - 1; i++){
super.send(parts[i]);
}
this.data = parts[parts.length - 1];
}
async go(){
var resp = await super.go();
this.audio.set_source_from_response(resp);
return resp;
}
}
bricks.AgentOut = class extends bricks.VBox {
constructor(opts){
super(opts);
this.rc_w = null;
this.c_w = null;
this.v_w = null;
this.i_w = null;
this.a_w = null;
this.glb_w = null;
this.images = [];
this.reasoning_content = '';
this.content = '';
this.error = '';
this.qa_w = null;
this.bricks_widgets = []; // bricks widget descriptor chunks
}
update(data){
// Handle bricks widget descriptor JSON ({"widgettype": "Text", "options": {...}})
if (data.widgettype){
bricks.widgetBuild(data).then(function(w){
if (w){
this.bricks_widgets.push(w);
if (!(w instanceof bricks.PopupWindow)){
this.add_widget(w);
}
}
}.bind(this));
return;
}
if (data.audio){
var url = data.audio;
if (! data.audio.startsWith('http')){
if (! data.audio.startsWith('data:audio/')){
url = 'data:audio/wav;base64,' + url;
}
}
if (!this.a_w) {
this.a_w = new bricks.AudioPlayer({
width: '100%',
autoplay: true,
url: url,
cheight:2
});
} else {
this.a_w.add_url(url);
}
}
if (data.glb){
this.glb_w = new bricks.GlbViewer({
url:data.glb,
width: '100%'
});
}
if (data.video){
if (!this.v_w){
this.v_w = new bricks.VideoPlayer({
width: '100%',
url: data.video,
autoplay: true
});
} else {
this.v_w.add_url(data.video);
}
}
if (data.error){
this.error += data.error;
}
if (data.reasoning_content){
this.reasoning_content += data.reasoning_content;
}
if (data.content){
this.content += data.content;
}
if (data.image){
if (Array.isArray(data.image)){
this.images.concat(data.image);
} else {
this.images.push(data.image);
}
}
if (data.reply){
var opts = {
dimiss_events: [ 'submit' ],
auto_open: true,
content: {
widgettype: "Form",
options: {
submit_url: this.rply_url,
title: "补充信息",
description: data.reply.question,
fields:[
{
name: 'questionkey',
uitype: 'hide',
value: data.reply.questionkey
},
{
name: ' answer',
uitype: 'text',
required: true
}
]
}
}
};
var win = new bricks.PopupWindow(opts);
}
this.clear_widgets();
if (this.error.length) {
var txt = bricks.escapeSpecialChars(this.error);
this.c_w = new bricks.Text({
text: this.error,
wrap: true,
halign: 'left',
css: 'resp-error',
width: '100%'
});
this.add_widget(this.c_w);
}
if (this.reasoning_content.length) {
var txt = bricks.escapeSpecialChars(this.reasoning_content);
this.rc_w = new bricks.MdWidget({
mdtext: this.reasoning_content,
css: 'thinking-content',
bgcolor: '#f0d0d0',
width: '100%'
});
this.add_widget(this.rc_w);
}
if (this.content.length) {
var txt = bricks.escapeSpecialChars(this.content);
this.c_w = new bricks.MdWidget({
mdtext: this.content,
css: 'resp-content',
width: '100%'
});
this.add_widget(this.c_w);
}
if (this.v_w) {
this.add_widget(this.v_w);
}
if (this.glb_w){
this.add_widget(this.glb_w);
}
if (this.a_w) {
this.add_widget(this.a_w);
}
if (this.images.length){
this.images.forEach( i => {
var w = new bricks.Image({
width: '100%',
url: i
});
this.add_widget(w)
});
}
}
}
bricks.AgentOutput = class extends bricks.VBox {
/* {
icon:
reply_url:
}
完成模型输出的控件的初始化以及获得数据后的更新, 更新是的数据在流模式下,需要使用累积数据
*/
constructor(opts){
if(! opts){
opts = {};
}
opts.width = '100%';
opts.height = 'auto';
super(opts);
var hb = new bricks.HBox({width:'100%', cheight:2});
this.img = new bricks.Svg({
rate:2,
tip:this.opts.modelname,
url:this.icon||bricks_resource('imgs/agent.svg')
});
hb.add_widget(this.img);
this.add_widget(hb);
this.content = new bricks.HBox({width:'100%'});
this.add_widget(this.content);
this.run = new bricks.BaseRunning({target:this, cheight:2, cwidth:2});
this.content.add_widget(this.run);
this.filler = new bricks.AgentOut({width: '100%',
css: 'card',
reply_url: this.reply_url});
this.filler.set_css('filler');
this.content.add_widget(new bricks.BlankIcon({rate:2, flexShrink:0}));
this.content.add_widget(this.filler);
// this.content.add_widget(new bricks.BlankIcon({rate:2, flexShrink:0}));
}
run_stopped(){
if (this.run) {
this.run.stop_timepass();
this.content.remove_widget(this.run);
this.run = null;
}
}
async update_data(data){
this.run_stopped();
this.filler.update(data);
if (data.llmusageid) {
this.llmusageid = data.llmusageid
}
return;
}
finish(){
console.log('finished')
}
}
bricks.AgentInputView = class extends bricks.VBox {
constructor(opts){
super(opts);
this.v_w = null;
this.a_v = null;
this.show_input(this.data);
}
show_input(data){
var mdtext = bricks.escapeSpecialChars(data.prompt) + '\n';
if (data.add_files){
data.add_files.forEach(f =>{
if (f.type.startsWith('video/')) {
var url = URL.createObjectURL(f);
this.v_w = new bricks.VideoPlayer({
url:url,
autoplay:true,
width: '100%'
});
} else if (f.type.startsWith('audio')){
var url = URL.createObjectURL(f);
this.a_w = new bricks.AudioPlayer({
url:url,
autoplay:true,
width: '100%'
});
} else if (f.type.startsWith('image')){
var url = URL.createObjectURL(f);
mdtext += `![${f.name}](${url})`;
} else {
var url = URL.createObjectURL(f);
mdtext += `[${f.name}](${url})`;
}
});
}
this.clear_widgets();
var w = new bricks.MdWidget({
width: '100%',
mdtext:mdtext
});
console.log('mdtext=', mdtext);
this.add_widget(w);
if (this.v_w){
this.add_widget(this.v_w);
}
if (this.a_w){
this.add_widget(this.a_w);
}
}
}
bricks.AgentIO = class extends bricks.VBox {
/*
options:
{
agent_using_llmid: #agent使用的大模型id
reply_url # 补充问题url
url: # agent接受问题的url
}
*/
constructor(opts){
if (!opts.height) opts.height = '100%';
super(opts);
this.llmmodels = [];
this.msg_box = new bricks.VScrollPanel({
width: '100%',
css: 'filler'
});
this.inputw = new bricks.AgentInput({model_dataurl:this.opts.model_dataurl,model_cwidth:this.opts.model_cwidth,model_params:this.opts.model_params,placeholder:this.opts.placeholder});
this.inputw.bind('inputed', this.user_inputed.bind(this));
this.add_widget(this.msg_box);
this.add_widget(this.inputw);
// 用户上翻看历史时暂停跟滚滚回底部恢复scroll_to_bottom 依据 _follow
this.msg_box.bind('scroll', this._track_follow.bind(this));
// 内容增长即跟滚到底2026-09-01 根治agent 输出多为 widget 描述符,
// AgentOut.update 走 widgetBuild().then() 异步挂载——同步滚总在挂载前执行、
// 滚不到新内容。MutationObserver 在挂载落 DOM 后触发,同步/异步渲染全覆盖,
// 后台 tab 也生效rAF 在后台 tab 不触发,已弃用)。
var mo = window.MutationObserver;
if (mo) {
this._mo = new mo(this.scroll_to_bottom.bind(this));
this._mo.observe(this.msg_box.dom_element,
{childList: true, subtree: true, characterData: true});
}
}
// 会话区保持最后输出可见2026-09-01内容超出显示范围时自动滚到底。
// 仅当用户本就停在底部附近才跟滚——用户主动上翻看历史时不强行拉回;
// 滚回底部或发送新消息后恢复跟滚。
// 同步滚:读 scrollHeight 本身强制布局,拿到的是新内容尺寸;
// 不用 rAF后台 tab 不触发 rAF流式更新时不滚
scroll_to_bottom(){
var e = this.msg_box && this.msg_box.dom_element;
if (!e) return;
if (this._follow === false) {
var near_bottom = (e.scrollHeight - e.scrollTop - e.clientHeight) < 80;
if (!near_bottom) return;
}
e.scrollTop = e.scrollHeight;
}
_track_follow(){
var e = this.msg_box && this.msg_box.dom_element;
if (!e) return;
this._follow = (e.scrollHeight - e.scrollTop - e.clientHeight) < 80;
}
chunk_response(mout, l){
l = l.trim();
try {
var d = JSON.parse(l);
} catch(e){
console.log(l, 'is not a json data');
return
}
console.log('l=', l, 'd=', d);
mout.update_data(d);
this.scroll_to_bottom();
}
chunk_ended(){
console.log('chunk end');
}
async user_inputed(e){
this.show_input(e.params);
var params = e.params;
params.llmid = this.agent_using_llmid;
console.log('params=', params);
/*
var agent = new bricks.AgentModel(this, {
url:this.opts.url,
params: params,
method: this.opts.method || 'POST',
reply_url: this.opts.reply_url
});
agent.set_inputed(params);
*/
var mout = new bricks.AgentOutput({
reply_url: this.opts.reply_url
});
this.msg_box.add_widget(mout);
var hr = new bricks.HttpResponseStream();
// 上传文件add_files 是浏览器 File 对象JSON 序列化会丢失内容,改用 FormData
var files = params.add_files || [];
var send_params = params;
if (files.length > 0) {
send_params = new FormData();
Object.keys(params).forEach(function(k){
if (k !== 'add_files' && k !== 'file_names') {
send_params.append(k, params[k]);
}
});
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;
}
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%'});
var data = params;
var w = new bricks.AgentInputView({
width: '100%',
data:data
});
w.set_css(this.msg_css||'user_msg');
w.set_css('filler');
var img = new bricks.Svg({rate:2,url:this.user_icon||bricks_resource('imgs/chat-user.svg')});
// box.add_widget(new bricks.BlankIcon({rate:2, flexShrink:0}));
box.add_widget(w);
box.add_widget(img);
this.msg_box.add_widget(box);
// 用户刚发的消息必须可见:强制跟滚到底
this._follow = true;
this.scroll_to_bottom();
}
}
bricks.Factory.register('AgentIO', bricks.AgentIO);