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。
This commit is contained in:
yumoqing 2026-09-17 15:45:48 +08:00
parent 7c1661312e
commit f819b3242b
5 changed files with 200 additions and 2 deletions

View File

@ -340,7 +340,7 @@ bricks.AgentIO = class extends bricks.VBox {
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 = 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);

View File

@ -56,10 +56,24 @@ bricks.AgentInput = class extends bricks.VBox {
});
inputw.bind('click', this.input_finished.bind(this));
// button row: [+add] [model_selector] [filler] [submit]
// secret picker button (2026-09-17) — 仅在宿主提供 secret_dataurl 时渲染,
// 未配置的宿主/应用完全看不到这个图标bricks 通用性:不硬编码宿主路径)
var secretw = null;
if (opts.secret_dataurl) {
secretw = new bricks.Svg({
cwidth: 1.5, cheight: 1.5,
url: opts.secret_url || bricks_resource('imgs/secret_key.svg'),
// tip 由 widget.js 统一做 i18n._() 包装,这里传原始 key勿预翻译会二次包装
tip: opts.secret_tip || 'Insert secret'
});
secretw.bind('click', this.open_secret_picker.bind(this));
}
// button row: [+add] [model_selector] [🔑secret?] [filler] [submit]
var hbox = new bricks.HBox({cheight: 1.5, gap: '4px', alignItems: 'center'});
hbox.add_widget(addfilew);
hbox.add_widget(this.model_selector);
if (secretw) hbox.add_widget(secretw);
hbox.add_widget(new bricks.VBox({css: 'filler'}));
hbox.add_widget(inputw);
@ -122,6 +136,125 @@ bricks.AgentInput = class extends bricks.VBox {
this.inputfilew.show();
}
// ══════════════════════════════════════════════════════════════
// 敏感信息插入2026-09-17
//
// 通用性约束bricks 是多应用共用框架,**不得硬编码任何宿主的路径或占位符格式**
// (对齐 model_dataurl 的既有设计)。宿主通过 opts 注入:
// secret_dataurl — 列表数据源(后端只返回元数据,绝不返回值本身)
// secret_params — 附加查询参数(如 session_id/pipeline_id
// secret_format — 占位符格式,{name} 为替换位(如 '@@sec:{name}@@'
// secret_title — 弹窗标题
// 未传 secret_dataurl 时钥匙图标根本不渲染 → 其他应用零影响。
// ══════════════════════════════════════════════════════════════
async open_secret_picker() {
if (!this.opts.secret_dataurl) return;
if (this._secret_win) { // 防重复弹窗
try { this._secret_win.open(); } catch (e) {}
return;
}
var self = this;
var win = new bricks.PopupWindow({
title: this.opts.secret_title || bricks.app.i18n._('Secrets'),
auto_open: true,
width: '460px',
height: '420px',
archor: 'cc'
});
this._secret_win = win;
var body = new bricks.VBox({width: '100%', height: '100%', gap: '4px'});
win.content_w.add_widget(body);
var tip = new bricks.Text({
text: bricks.app.i18n._(
'Click an item to insert a placeholder at the cursor. The real value is never shown.'),
css: 'secret-picker-tip',
wrap: true, width: '100%'
});
body.add_widget(tip);
var listw = new bricks.VScrollPanel({width: '100%', height: '100%'});
body.add_widget(listw);
var rows = new bricks.VBox({width: '100%', gap: '2px'});
listw.add_widget(rows);
var loading = new bricks.Text({text: '…', width: '100%'});
rows.add_widget(loading);
try {
var jc = new bricks.HttpJson();
var params = bricks.extend({}, this.opts.secret_params || {});
var data = await jc.httpcall(this.opts.secret_dataurl,
{method: 'GET', params: params});
rows.clear_widgets();
var items = (data && (data.secrets || data.rows || data.data)) || [];
if (!items.length) {
rows.add_widget(new bricks.Text({
text: bricks.app.i18n._('No secrets saved yet.'),
width: '100%', wrap: true
}));
}
items.forEach(function (it) {
var name = it.name || '';
if (!name) return;
var row = new bricks.HBox({width: '100%', gap: '6px', alignItems: 'center'});
row.set_css('secret-picker-row');
// 主行:名称(加粗)+ 类型徽标
var label = new bricks.Text({
text: (it.label && it.label !== name) ? (name + ' · ' + it.label) : name,
cfontsize: 0.9, width: 'auto'
});
row.add_widget(label);
row.add_widget(new bricks.VBox({css: 'filler'}));
if (it.secret_type) {
row.add_widget(new bricks.Text({text: it.secret_type, cfontsize: 0.75, css: 'secret-type-tag'}));
}
rows.add_widget(row);
// 元数据行:前缀/长度/用过几次——够人类辨认是哪个凭据,但拼不出可用值
var meta = [];
if (it.prefix_hint) meta.push('prefix ' + it.prefix_hint);
if (it.length_hint) meta.push('len ' + it.length_hint);
if (it.use_count) meta.push('used ' + it.use_count + 'x');
if (it.remark) meta.push(it.remark);
if (meta.length) {
rows.add_widget(new bricks.Text({
text: meta.join(' · '), cfontsize: 0.72, css: 'secret-picker-meta', width: '100%'
}));
}
row.bind('click', function () {
self.insert_secret_placeholder(name);
});
});
} catch (e) {
rows.clear_widgets();
rows.add_widget(new bricks.Text({
text: bricks.app.i18n._('Failed to load secrets: ') + String(e).slice(0, 120),
css: 'resp-error', width: '100%', wrap: true
}));
}
// 弹窗关闭/最小化后清引用Popup 的事件是 'dismissed',无 'closed'
// X 按钮走 destroy() → dismiss() 同样触发)——下次打开重新拉最新列表
win.bind('dismissed', function () { self._secret_win = null; });
}
insert_secret_placeholder(name) {
var fmt = this.opts.secret_format || '@@sec:{name}@@';
var text = String(fmt).split('{name}').join(name);
if (this.textw && this.textw.insertAtCursor) {
this.textw.insertAtCursor(text);
} else {
// UiText 不支持时的降级:追加到末尾(不静默失败)
var cur = (this.textw && this.textw.dom_element) ? (this.textw.dom_element.value || '') : '';
this.textw.setValue(cur + text);
}
if (this._secret_win) {
// PopupWindow 无 close()——正确 API 是 dismiss()(隐藏并 dispatch 'dismissed'
try { this._secret_win.dismiss(); } catch (e) {}
this._secret_win = null;
}
}
// 重设模型下拉数据源2026-09-07 移动端切产线用):销毁重建 UiCode
// 保持其在按钮行中的位置与样式不变。
set_model_dataurl(url, params) {

View File

@ -1272,3 +1272,34 @@ hr {
top: 0;
bottom: 0;
}
/* 敏感信息选择窗2026-09-17AgentIO 钥匙图标
* 样式走全局 classbricks 的内联嵌套 style 对象无效只能用平铺 options css */
.secret-picker-tip {
font-size: 0.75rem;
opacity: 0.72;
padding: 2px 4px 8px 4px;
line-height: 1.4;
}
.secret-picker-row {
cursor: pointer;
padding: 5px 6px;
border-radius: 4px;
transition: background-color 0.12s ease;
}
.secret-picker-row:hover {
background-color: rgba(127, 127, 127, 0.16);
}
.secret-picker-meta {
font-size: 0.7rem;
opacity: 0.6;
padding: 0 6px 6px 6px;
}
.secret-type-tag {
font-size: 0.68rem;
opacity: 0.7;
padding: 1px 6px;
border: 1px solid rgba(127, 127, 127, 0.35);
border-radius: 8px;
white-space: nowrap;
}

View File

@ -0,0 +1,4 @@
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" xmlns="http://www.w3.org/2000/svg">
<circle cx="8.5" cy="8.5" r="4.5" stroke="currentColor" stroke-width="1.5"/>
<path d="M11.8 11.8L20 20M17 17l2-2M14.5 14.5l2-2" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/>
</svg>

After

Width:  |  Height:  |  Size: 301 B

View File

@ -1057,6 +1057,36 @@ bricks.UiText =class extends bricks.UiType {
var v = this.opts.value || this.opts.defaultvalue||'';
this.setValue(v);
}
/**
* 在当前光标位置插入文本有选区则替换选区返回插入后的光标位置
* 2026-09-17 AgentIO插入敏感信息占位符新增
*
* 两个时序坑照抄 handle_enter/handle_tab_indent 的既有处理
* - 程序直接改 dom_element.value **不触发 input 事件** set_value_from_input
* 不会跑必须手动同步 this.value否则外层 getValue() 读到旧值
* - focus() 后浏览器可能重置 selectionStart schedule_once 二次设置光标
* handle_enter 同款做法0.5ms 后重设
*/
insertAtCursor(text){
text = (text === undefined || text === null) ? '' : String(text);
var editor = this.dom_element;
if (! editor) return;
var value = editor.value || '';
var start = (typeof editor.selectionStart === 'number') ? editor.selectionStart : value.length;
var end = (typeof editor.selectionEnd === 'number') ? editor.selectionEnd : start;
editor.value = value.substring(0, start) + text + value.substring(end);
this.value = editor.value;
var pos = start + text.length;
editor.selectionStart = editor.selectionEnd = pos;
editor.focus();
this.handleInput(); // 高度自适应textarea auto-resize
schedule_once(this.set_editor_focus.bind(this, editor, pos), 0.5);
// 不 dispatch('changed'):与 handle_enter / handle_tab_indent 保持一致。
// UiText 也用于 Form程序化插入后多发一次 changed 会触发 Form 的脏值/校验
// 联动(意外副作用);取值路径 getValue()→resultValue() 直接读 dom_element.value
// 上面已同步 this.value外层AgentInput.input_finished能拿到最新文本。
return pos;
}
key_handle(e){
switch(e.key){
case 'Tab':