bricks/bricks/agent.js
yumoqing f819b3242b feat(agent): UiText.insertAtCursor + AgentIO 敏感信息选择窗(2026-09-17)
通用框架层,不硬编码任何宿主路径或占位符格式(对齐 model_dataurl 既有设计):

- input.js UiText.insertAtCursor(text): 光标位置插入(有选区则替换选区)。
  两个时序坑照抄 handle_enter/handle_tab_indent 既有处理:程序改
  dom_element.value 不触发 input 事件(须手动同步 this.value);focus() 后浏览器
  可能重置 selectionStart(schedule_once 0.5ms 重设)。不 dispatch('changed')——
  UiText 也用于 Form,多发一次会触发脏值/校验联动。
- agent_input.js: 钥匙图标(仅宿主传 opts.secret_dataurl 时渲染 → 未配置的宿主
  完全看不到,零影响) + open_secret_picker 弹窗(HttpJson.httpcall 拉元数据列表,
  只显示 name/label/type/prefix4/len/用过几次——够人类辨认但拼不出可用值) +
  insert_secret_placeholder(按 opts.secret_format 生成占位符插入光标处)。
  弹窗 API 用 dismiss()+'dismissed'(PopupWindow 无 close()/closed)。
- agent.js AgentIO: 透传 secret_dataurl/params/format/title/url/tip 六个配置。
- css: .secret-picker-tip/row/meta + .secret-type-tag(走全局 class,
  bricks 内联嵌套 style 对象无效)。
- imgs/secret_key.svg: 24x24 stroke 风格,与 add.svg/submit.svg 一致。

宿主接入示例见 pipeline-core wwwroot/agent/index.ui。
2026-09-17 15:45:48 +08:00

562 lines
18 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;
}
}
// 会话被用户「停」中断:冻结已输出内容(停在输入「停」之前的状态),
// 移除"思考中"动画,末尾追加一个轻量「已停止」标记(挂在 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) {
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._running_streams = []; // 在飞的流 [{controller:AbortController, mout:AgentOutput}],「停」统一中断
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,secret_dataurl:this.opts.secret_dataurl,secret_params:this.opts.secret_params,secret_format:this.opts.secret_format,secret_title:this.opts.secret_title,secret_url:this.opts.secret_url,secret_tip:this.opts.secret_tip});
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;
var st = e.scrollTop;
var near = (e.scrollHeight - st - e.clientHeight) < 80;
if (near) {
// 滚回底部附近 → 恢复跟滚
this._follow = true;
} else if (this._last_st !== undefined && st < this._last_st - 5) {
// scrollTop 减小 = 用户真上翻 → 暂停跟滚。
// 程序滚底只会增大 scrollTop异步 widget 挂载使 scroll 事件滞后时
// gap 会瞬时 >80但 st 不减小——不能误判为用户上翻2026-09-01 根因:
// 旧实现按 gap 判 follow第二条消息后程序滚底被自己的 scroll 事件
// 误关 follow后续输出不再跟滚
this._follow = false;
}
this._last_st = st;
}
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');
}
// 「停」类快捷指令识别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 的未发草稿/附件保留。
// 入参兼容:字符串 pid / 数据对象 {id|pipeline_id} / bind 事件对象
// method handler 收到的是 event数据在 event.params —— ChipBar select
set_pipeline(p){
if (p && p.params) p = p.params;
var pid = (typeof p === 'string') ? p
: ((p && (p.pipeline_id || p.id)) || '');
if (!pid) return;
function _u(base, pl){
if (!base) return base;
var u = base;
function _set(key, val){
if (new RegExp('[?&]' + key + '=').test(u)){
u = u.replace(new RegExp('([?&]' + key + '=)[^&]*'), '$1' + encodeURIComponent(val));
} else {
u += (u.indexOf('?') >= 0 ? '&' : '?') + key + '=' + encodeURIComponent(val);
}
}
_set('pipeline_id', pl);
_set('session_id', 'm_' + pl);
return u;
}
this._base_chat_url = this._base_chat_url || this.opts.url;
this._base_model_url = this._base_model_url || this.opts.model_dataurl;
this.opts.url = _u(this._base_chat_url, pid);
if (this._base_model_url) {
this.opts.model_dataurl = _u(this._base_model_url, pid);
// 模型下拉按产线过滤capabilities 等参数保留在 _base_model_url 里)
if (this.inputw && this.inputw.set_model_dataurl) {
this.inputw.set_model_dataurl(this.opts.model_dataurl,
{pipeline_id: pid, session_id: 'm_' + pid});
}
}
this.pipeline_id = pid;
this.msg_box.clear_widgets();
}
async user_inputed(e){
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);
/*
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); });
}
// 每条流一个 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();
}
}
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);