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:
parent
7c1661312e
commit
f819b3242b
@ -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);
|
||||
|
||||
@ -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) {
|
||||
|
||||
@ -1272,3 +1272,34 @@ hr {
|
||||
top: 0;
|
||||
bottom: 0;
|
||||
}
|
||||
|
||||
/* ══════════ 敏感信息选择窗(2026-09-17,AgentIO 钥匙图标) ══════════
|
||||
* 样式走全局 class(bricks 的内联嵌套 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;
|
||||
}
|
||||
|
||||
4
bricks/imgs/secret_key.svg
Normal file
4
bricks/imgs/secret_key.svg
Normal 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 |
@ -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':
|
||||
|
||||
Loading…
x
Reference in New Issue
Block a user