From f819b3242babf77a353832f69b3576ec086d6360 Mon Sep 17 00:00:00 2001 From: yumoqing Date: Thu, 17 Sep 2026 15:45:48 +0800 Subject: [PATCH] =?UTF-8?q?feat(agent):=20UiText.insertAtCursor=20+=20Agen?= =?UTF-8?q?tIO=20=E6=95=8F=E6=84=9F=E4=BF=A1=E6=81=AF=E9=80=89=E6=8B=A9?= =?UTF-8?q?=E7=AA=97(2026-09-17)?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 通用框架层,不硬编码任何宿主路径或占位符格式(对齐 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。 --- bricks/agent.js | 2 +- bricks/agent_input.js | 135 ++++++++++++++++++++++++++++++++++++- bricks/css/bricks.css | 31 +++++++++ bricks/imgs/secret_key.svg | 4 ++ bricks/input.js | 30 +++++++++ 5 files changed, 200 insertions(+), 2 deletions(-) create mode 100644 bricks/imgs/secret_key.svg diff --git a/bricks/agent.js b/bricks/agent.js index ad578f0..a434a9c 100644 --- a/bricks/agent.js +++ b/bricks/agent.js @@ -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); diff --git a/bricks/agent_input.js b/bricks/agent_input.js index 4d85c9e..6ca9580 100644 --- a/bricks/agent_input.js +++ b/bricks/agent_input.js @@ -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) { diff --git a/bricks/css/bricks.css b/bricks/css/bricks.css index 4bf50dc..da417ac 100755 --- a/bricks/css/bricks.css +++ b/bricks/css/bricks.css @@ -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; +} diff --git a/bricks/imgs/secret_key.svg b/bricks/imgs/secret_key.svg new file mode 100644 index 0000000..1f14018 --- /dev/null +++ b/bricks/imgs/secret_key.svg @@ -0,0 +1,4 @@ + + + + diff --git a/bricks/input.js b/bricks/input.js index d4cf06d..e55c9e8 100644 --- a/bricks/input.js +++ b/bricks/input.js @@ -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':