@jesonliu/lark-claudecode-bridge 1.4.3 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (56) hide show
  1. package/assets/web/css/main.css +774 -574
  2. package/assets/web/index.html +37 -17
  3. package/assets/web/js/auth.js +218 -0
  4. package/assets/web/js/bootstrap.js +36 -34
  5. package/assets/web/js/core.js +4 -3
  6. package/assets/web/js/main.js +79 -20
  7. package/assets/web/js/markdown.js +64 -0
  8. package/assets/web/js/pages/apps.js +338 -251
  9. package/assets/web/js/pages/chats.js +481 -0
  10. package/assets/web/js/pages/claude.js +243 -180
  11. package/assets/web/js/pages/mcp.js +59 -43
  12. package/assets/web/js/pages/memory.js +43 -46
  13. package/assets/web/js/pages/overview.js +426 -88
  14. package/assets/web/js/pages/permissions.js +121 -34
  15. package/assets/web/js/pages/plugins.js +51 -37
  16. package/assets/web/js/pages/skills.js +68 -92
  17. package/assets/web/js/pages/slash.js +31 -23
  18. package/assets/web/js/pages/workspaces.js +114 -73
  19. package/assets/web/js/theme.js +21 -18
  20. package/assets/web/js/ui.js +75 -32
  21. package/dist/access/access-control.d.ts +18 -0
  22. package/dist/access/access-control.js +29 -0
  23. package/dist/access/access-control.js.map +1 -1
  24. package/dist/bin/lcb.js +15 -0
  25. package/dist/bin/lcb.js.map +1 -1
  26. package/dist/feishu/name-cache.d.ts +15 -0
  27. package/dist/feishu/name-cache.js +82 -0
  28. package/dist/feishu/name-cache.js.map +1 -0
  29. package/dist/feishu/sibling-names.d.ts +1 -0
  30. package/dist/feishu/sibling-names.js +63 -0
  31. package/dist/feishu/sibling-names.js.map +1 -0
  32. package/dist/gateway/feishu-gateway.d.ts +24 -0
  33. package/dist/gateway/feishu-gateway.js +132 -0
  34. package/dist/gateway/feishu-gateway.js.map +1 -1
  35. package/dist/gateway/gateway-registry.d.ts +18 -0
  36. package/dist/gateway/gateway-registry.js +14 -0
  37. package/dist/gateway/gateway-registry.js.map +1 -0
  38. package/dist/index.d.ts +4 -0
  39. package/dist/index.js +52 -9
  40. package/dist/index.js.map +1 -1
  41. package/dist/types.d.ts +2 -0
  42. package/dist/web/chats-api.d.ts +93 -0
  43. package/dist/web/chats-api.js +603 -0
  44. package/dist/web/chats-api.js.map +1 -0
  45. package/dist/web/lifecycle.d.ts +21 -0
  46. package/dist/web/lifecycle.js +80 -3
  47. package/dist/web/lifecycle.js.map +1 -1
  48. package/dist/web/logs-api.d.ts +37 -0
  49. package/dist/web/logs-api.js +141 -0
  50. package/dist/web/logs-api.js.map +1 -0
  51. package/dist/web/server.js +196 -13
  52. package/dist/web/server.js.map +1 -1
  53. package/dist/web/webauth.d.ts +44 -0
  54. package/dist/web/webauth.js +221 -0
  55. package/dist/web/webauth.js.map +1 -0
  56. package/package.json +1 -1
@@ -1,7 +1,21 @@
1
- // ============ 应用(列表 + 抽屉编辑;输入实时写回 S.doc.apps,抽屉「保存」一次落盘) ============
1
+ // ============ 飞书应用(卡片列表 + 抽屉横向 Tab 编辑) ============
2
+ // 抽屉顶部横向 Tab:基本信息(身份/Secret/域名/工作区/并发/人格)/ 机器人角色(主/分身 +
3
+ // 分身三项白名单)/ 模型 / 触发词 / 环境变量。输入实时写回 S.doc.apps,抽屉「保存」一次落盘。
4
+ // Secret 已脱敏:留空 = 保持不变;带「显示」切换。主机器人不渲染分身白名单区。
2
5
  import { S, $, esc, toast, api, snapDoc, saveDoc } from '../core.js';
3
6
  import { openDrawer, closeDrawer, cancelDrawer, confirmDialog, multiSelectField, singleSelectField } from '../ui.js';
4
7
 
8
+ const svg = (inner, s = 12) => `<svg viewBox="0 0 24 24" width="${s}" height="${s}" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true">${inner}</svg>`;
9
+ const IC = {
10
+ bot: '<rect x="4" y="8" width="16" height="12" rx="2"/><path d="M12 8V4M8 4h8"/><path d="M9 13v2M15 13v2"/>',
11
+ globe: '<circle cx="12" cy="12" r="9"/><path d="M3 12h18"/><path d="M12 3a14 14 0 0 1 0 18a14 14 0 0 1 0-18"/>',
12
+ folder: '<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z"/>',
13
+ claude: '<circle cx="7.5" cy="15.5" r="5.5"/><path d="m21 2-9.6 9.6"/><path d="m15.5 7.5 3 3L22 7l-3-3"/>',
14
+ slash: '<path d="m4 17 6-6-6-6"/><path d="M12 19h8"/>',
15
+ plus: '<path d="M12 5v14"/><path d="M5 12h14"/>',
16
+ info: '<circle cx="12" cy="12" r="9"/><path d="M12 8h.01"/><path d="M12 12v4"/>',
17
+ };
18
+
5
19
  // ---- 分身白名单多选候选(/api/skills + /api/plugins;60s TTL + in-flight 去重 + stale-if-error) ----
6
20
  /** 纯函数(可测):技能清单 → 多选候选。插件来源 value 用 `plugin:skill` 限定格式
7
21
  * (SDK Options.skills 契约,sdk.d.ts:插件技能须限定名),裸技能用目录名;按 value 去重首胜 */
@@ -74,273 +88,347 @@ function loadDeputyCandidates() {
74
88
 
75
89
  function renderApps(el) {
76
90
  const apps = S.doc.apps || (S.doc.apps = []);
77
- el.innerHTML = `
78
- <div class="card">
79
- <h3>飞书应用(机器人)</h3>
80
- <div class="desc">每个应用一条独立长连接与会话池。点击行或「编辑」在抽屉中配置;App Secret 已脱敏,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 角色改动需重启 lcb start;触发词与环境变量改动下一条消息热生效。</div>
81
- <div class="list-toolbar">
82
- <button class="btn primary" id="addApp">+ 新增应用</button>
83
- </div>
84
- <table>
85
- <thead><tr><th>名称</th><th>App ID</th><th style="width:130px">域名</th><th>默认工作区</th><th style="width:130px">状态</th><th style="width:64px"></th></tr></thead>
86
- <tbody id="appsBody"></tbody>
87
- </table>
88
- <div class="footer-note">⚠️ 应用段改动需重启 lcb start 后生效(触发词与环境变量除外,下一条消息自动生效)。</div>
89
- </div>`;
90
- const statusTag = (app) => {
91
- const st = S.status?.apps?.find((s) => s.name === (app.name || app.app_id))?.started;
92
- return st === true ? '<span class="tag ok">运行中</span>'
93
- : st === false ? '<span class="tag err">启动失败</span>'
94
- : '<span class="tag off">未随本页启动</span>';
95
- };
91
+ const statusOf = (app) => S.status?.apps?.find((s) => s.name === (app.name || app.app_id))?.started;
96
92
  // 该应用实际启用的模型:profile_model 覆盖优先,其次档案默认 model(与后端 resolveAppProfileModel 展示口径一致)
97
93
  const profiles = (S.doc.claude && S.doc.claude.profiles) || [];
98
94
  const profileModelOf = (app) =>
99
95
  app.profile_model || (profiles.find((p) => p.name === app.profile)?.model || '');
100
- $('#appsBody').innerHTML = apps.map((app, i) => {
101
- const model = profileModelOf(app);
102
- return `
103
- <tr data-i="${i}" style="cursor:pointer">
104
- <td><b>${esc(app.name || app.app_id || '(未命名)')}</b>${app.role === 'deputy' ? ' <span class="tag off">分身</span>' : ''}${app.profile ? ` <span class="tag warn">${esc(model ? `${app.profile} · ${model}` : app.profile)}</span>` : ''}</td>
105
- <td><code>${esc(app.app_id || '')}</code></td>
106
- <td>${esc(app.domain === 'lark' ? 'lark(国际)' : 'feishu(国内)')}</td>
107
- <td>${esc(app.default_workspace || '(全局默认)')}</td>
108
- <td>${statusTag(app)}</td>
109
- <td><button class="btn sm">编辑</button></td>
110
- </tr>`;
111
- }).join('');
112
- $('#appsBody').querySelectorAll('tr').forEach((tr) =>
113
- tr.onclick = () => openAppDrawer(apps, Number(tr.dataset.i), el));
114
- $('#addApp').onclick = () => {
96
+ el.innerHTML = `
97
+ <div class="cardgrid">
98
+ ${apps.map((app, i) => {
99
+ const started = statusOf(app);
100
+ const model = profileModelOf(app);
101
+ return `
102
+ <div class="acard" data-i="${i}" tabindex="0" role="button" aria-label="编辑 ${esc(app.name || app.app_id || '应用')}">
103
+ <div class="acard-head"><b>${esc(app.name || app.app_id || '(未命名)')}</b>
104
+ ${app.role === 'deputy' ? '<span class="tag info">分身</span>' : ''}
105
+ <span class="dot ${started === true ? 'ok' : started === false ? 'err' : 'off'}" title="${started === true ? '运行中' : started === false ? '启动失败' : '未随本页启动'}"></span></div>
106
+ <div class="acard-meta">
107
+ <span title="App ID">${svg(IC.bot)}<span class="mono">${esc(app.app_id || '(待填写)')}</span></span>
108
+ <span>${svg(IC.globe)}${app.domain === 'lark' ? 'lark 国际' : 'feishu 国内'}</span>
109
+ <span>${svg(IC.folder)}${esc(app.default_workspace || '全局默认')}</span>
110
+ </div>
111
+ <div class="acard-meta">
112
+ <span>${svg(IC.claude)}${esc(app.profile ? `${app.profile}${model ? ` · ${model}` : ''}` : '跟随全局')}</span>
113
+ <span>${svg(IC.slash)}触发词 ${(app.triggers || []).length}</span>
114
+ </div>
115
+ <div class="acard-foot">
116
+ <button class="btn sm" data-edit="${i}">编辑</button>
117
+ <button class="btn sm danger" data-del="${i}">删除</button>
118
+ <span class="mut" style="font-size:11px;align-self:center;margin-left:auto">${app.role === 'deputy' ? '艾特即用' : '配对码准入'}</span>
119
+ </div>
120
+ </div>`;
121
+ }).join('')}
122
+ <div class="acard newcard" id="addApp" tabindex="0" role="button">${svg(IC.plus, 18)}<span>新增应用</span></div>
123
+ </div>
124
+ <div class="mini-note" style="margin-top:10px">每个应用一条独立长连接与会话池。App Secret 已脱敏保存,留空 = 保持不变。凭证 / 名称 / 域名 / 默认工作区 / 并发 / 人格 / 角色改动需重启 lcb start;触发词与环境变量改动下一条消息热生效。</div>`;
125
+ $('#pageActions').innerHTML = `<button class="btn primary" id="addAppTop">${svg(IC.plus, 14)} 新增应用</button>`;
126
+ const openEdit = (i) => {
127
+ const snap = snapDoc(); // 快照先于改动:取消时回滚(含新增未保存行)
128
+ openAppDrawer(apps, i, snap);
129
+ };
130
+ el.querySelectorAll('.acard[data-i]').forEach((c) => {
131
+ c.onclick = (e) => { if (e.target.closest('button')) return; openEdit(Number(c.dataset.i)); };
132
+ c.onkeydown = (e) => { if (e.key === 'Enter') openEdit(Number(c.dataset.i)); };
133
+ });
134
+ el.querySelectorAll('[data-edit]').forEach((b) => b.onclick = () => openEdit(Number(b.dataset.edit)));
135
+ el.querySelectorAll('[data-del]').forEach((b) => b.onclick = async () => {
136
+ if (apps.length <= 1) return toast('至少保留一个应用', true);
137
+ const app = apps[Number(b.dataset.del)];
138
+ if (!(await confirmDialog({ title: '删除应用', message: `确认删除应用「${esc(app.name || app.app_id || '未命名')}」?删除将立即保存生效。`, danger: true, confirmText: '删除' }))) return;
139
+ apps.splice(Number(b.dataset.del), 1);
140
+ // saveDoc 内部 refresh() 会整页重渲染;失败时 toast(内存态与磁盘不一致由下一轮 refresh 校正前保持)
141
+ if (!(await saveDoc())) toast('删除失败,请重试', true);
142
+ });
143
+ const add = () => {
115
144
  const snap = snapDoc(); // 快照先于 push:取消时连同新增空行一起回滚
116
145
  apps.push({ app_id: '', app_secret: '' });
117
- renderApps(el);
118
- openAppDrawer(apps, apps.length - 1, el, snap);
146
+ openAppDrawer(apps, apps.length - 1, snap);
119
147
  };
148
+ $('#addApp').onclick = add;
149
+ $('#addAppTop').onclick = add;
120
150
  }
121
151
 
122
152
  /**
123
- * 应用编辑抽屉:输入实时 mutate app 对象,「保存」PUT 落盘(applySecrets / 注文保护链路零改动)。
124
- * secret 语义逐字保留:非空 = 明文写入;空 = 保持脱敏对象(PUT 端按 app_id 回填磁盘现值)。
125
- * 「取消」/ X / ESC = 回滚到打开前快照(新增未保存的应用行一并撤销)。
153
+ * 应用编辑抽屉(横向 Tab)。输入实时 mutate app 对象,「保存」PUT 落盘(applySecrets /
154
+ * 注文保护链路零改动)。secret 语义逐字保留:非空 = 明文写入;空 = 保持脱敏对象(PUT 端
155
+ * 按 app_id 回填磁盘现值)。「取消」/ X / ESC = 回滚到打开前快照(新增未保存行一并撤销)。
126
156
  */
127
- function openAppDrawer(apps, idx, el, snap) {
157
+ function openAppDrawer(apps, idx, snap) {
128
158
  const app = apps[idx];
129
159
  const secret = app.app_secret || {};
160
+ const isNew = !app.app_id && !app.name;
130
161
  const wsNames = (S.doc.workspaces || []).map((w) => w.name || '').filter(Boolean);
131
162
  const globalDef = S.doc.defaults?.workspace || '';
132
163
  const profiles = (S.doc.claude && S.doc.claude.profiles) || [];
133
- // 当前选中档案(drawer 打开时的初值;切换 select 时刷新模型 datalist)
134
164
  const profileByName = (n) => profiles.find((p) => p.name === n);
135
- openDrawer({
136
- title: `应用:${app.name || app.app_id || '(新应用)'}`,
137
- snap: snap ?? snapDoc(),
138
- bodyHtml: `
139
- <div class="row">
140
- <div><label>应用名字(概览页与飞书 /status 显示用,缺省取 App ID)</label>
141
- <input type="text" data-f="name" value="${esc(app.name || '')}" placeholder="素材收集"></div>
142
- <div><label>App ID</label>
143
- <input type="text" data-f="app_id" value="${esc(app.app_id || '')}" placeholder="cli_…"></div>
144
- </div>
145
- <label>App Secret ${secret.secretSet ? `(已设置 ${esc(secret.secretHint)},留空不改)` : ''}</label>
165
+ const started = S.status?.apps?.find((s) => s.name === (app.name || app.app_id))?.started;
166
+ const secs = [
167
+ ['base', '基本信息'],
168
+ ['role', '机器人角色'],
169
+ ['model', '模型'],
170
+ ['trig', '触发词'],
171
+ ['env', '环境变量'],
172
+ ];
173
+ let cur = 'base';
174
+
175
+ const paneBase = () => `
176
+ <div class="sub-title" style="margin-bottom:2px">应用身份
177
+ ${started === true ? '<span class="tag ok" style="margin-left:auto">已连接</span>' : started === false ? '<span class="tag err" style="margin-left:auto">启动失败</span>' : '<span class="tag off" style="margin-left:auto">未连接</span>'}
178
+ </div>
179
+ <div class="mut" style="font-size:12px;margin-bottom:8px">配置机器人连接信息与默认行为。</div>
180
+ <div class="grid2">
181
+ <div><label class="fld">应用名称 <span class="tip" data-tip="概览页与飞书 /status 显示用,缺省取 App ID">${svg(IC.info, 13)}</span></label>
182
+ <input type="text" data-f="name" value="${esc(app.name || '')}" placeholder="素材收集"></div>
183
+ <div><label class="fld">App ID</label>
184
+ <input type="text" data-f="app_id" value="${esc(app.app_id || '')}" placeholder="cli_…"></div>
185
+ </div>
186
+ <label class="fld">App Secret ${secret.secretSet ? `<span class="tag off">已设置 ${esc(secret.secretHint)}</span>` : ''} <span class="tip" data-tip="已脱敏保存;留空 = 保持不变,输入 = 覆盖">${svg(IC.info, 13)}</span></label>
187
+ <div class="input-btn">
146
188
  <input type="password" data-f="app_secret" placeholder="${secret.secretSet ? '••••••••(留空保持不变)' : '未设置'}">
147
- <div class="row">
148
- <div><label>域名</label><select data-f="domain">
149
- <option value="" ${!app.domain || app.domain === 'feishu' ? 'selected' : ''}>feishu(国内)</option>
150
- <option value="lark" ${app.domain === 'lark' ? 'selected' : ''}>lark(国际)</option>
189
+ <button class="btn" type="button" id="dwSecretShow">显示</button>
190
+ </div>
191
+ <div class="mini-note" style="margin-top:5px">密钥已保存。保存时留空或不修改,将继续使用现有密钥。</div>
192
+ <div class="grid3" style="margin-top:6px">
193
+ <div><label class="fld">域名</label><select data-f="domain">
194
+ <option value="" ${!app.domain || app.domain === 'feishu' ? 'selected' : ''}>feishu(国内)</option>
195
+ <option value="lark" ${app.domain === 'lark' ? 'selected' : ''}>lark(国际)</option></select></div>
196
+ <div><label class="fld">默认工作区</label><select data-f="default_workspace">
197
+ <option value="" ${!app.default_workspace ? 'selected' : ''}>(全局默认${globalDef ? `:${esc(globalDef)}` : ''})</option>
198
+ ${wsNames.map((n) => `<option value="${esc(n)}" ${app.default_workspace === n ? 'selected' : ''}>${esc(n)}</option>`).join('')}
199
+ </select></div>
200
+ <div><label class="fld">并发上限 <span class="tag warn">需重启</span></label>
201
+ <input type="number" data-f="concurrency" min="1" max="100" value="${esc(app.concurrency ?? '')}" placeholder="缺省用全局"></div>
202
+ </div>
203
+ <label class="fld">人格补充 <span class="tag off">可选</span> <span class="tag warn">需重启</span> <span class="tip" data-tip="追加到该机器人每个会话的 system prompt,用于多机器人差异化定位">${svg(IC.info, 13)}</span></label>
204
+ <textarea data-f="append_system_prompt" style="min-height:76px" class="plain" placeholder="你是我的超级助手,擅长代码开发、文案编写…(回复须带「主人」称谓等要求写在这里)">${esc(app.append_system_prompt || '')}</textarea>
205
+ <div class="mini-note" style="margin-top:6px">${(app.append_system_prompt || '').length} 字 · 建议只写角色差异与表达偏好,避免重复全局指令。</div>`;
206
+
207
+ const paneRole = () => {
208
+ const deputy = app.role === 'deputy';
209
+ return `
210
+ <label class="fld">机器人角色 <span class="tip" data-tip="主机器人=全权限、配对码准入;分身机器人=限定能力、群内艾特即用">${svg(IC.info, 13)}</span></label>
211
+ <div class="rcards">
212
+ <label class="rcard ${!deputy ? 'on' : ''}"><input type="radio" name="dwRole" value="primary" ${!deputy ? 'checked' : ''}><span><b>主机器人</b><span class="hint">全权限,配对码准入</span></span></label>
213
+ <label class="rcard ${deputy ? 'on' : ''}"><input type="radio" name="dwRole" value="deputy" ${deputy ? 'checked' : ''}><span><b>分身机器人</b><span class="hint">限定能力,供他人在群里艾特使用</span></span></label>
214
+ </div>
215
+ ${deputy ? `
216
+ <div class="hint" style="margin:8px 0 2px">分身必填「允许的技能」与「允许的工作区」;未列入白名单的技能 / 插件不加载,任务锁定在第一个允许的工作区;管理命令在分身里一律不可用。改动需重启生效。</div>
217
+ <label class="fld">允许的技能(必填)</label><div data-msel="allowed_skills"></div>
218
+ <label class="fld">允许的工作区(必填,任务锁定第一个)</label><div data-msel="allowed_workspaces"></div>
219
+ <label class="fld">允许的插件(可选)</label><div data-msel="allowed_plugins"></div>` : `
220
+ <div class="mini-note" style="margin-top:12px">主机器人 = 全权限(不限制技能 / 工作区 / 插件),首次使用需配对码准入。</div>`}`;
221
+ };
222
+
223
+ const paneModel = () => `
224
+ <div class="hint" style="margin-bottom:6px">未配置 = 跟随全局(「Claude 认证」页当前生效配置)。档案须先在「Claude 认证」页创建;认证经独立 settings 文件注入(优先级高于全局),模型经 --model 参数注入。改动下一条消息热生效。</div>
225
+ <div class="grid2" style="align-items:end">
226
+ <div><label class="fld">厂商档案</label>
227
+ <select data-f="profile">
228
+ <option value="" ${!app.profile ? 'selected' : ''}>(跟随全局)</option>
229
+ ${profiles.map((p) => `<option value="${esc(p.name)}" ${app.profile === p.name ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
151
230
  </select></div>
152
- <div><label>默认工作区${globalDef ? `(缺省用全局默认:${esc(globalDef)})` : '(缺省用全局默认)'}</label>
153
- <select data-f="default_workspace">
154
- <option value="" ${!app.default_workspace ? 'selected' : ''}>(全局默认)</option>
155
- ${wsNames.map((n) => `<option value="${esc(n)}" ${app.default_workspace === n ? 'selected' : ''}>${esc(n)}</option>`).join('')}
156
- </select></div>
157
- <div><label>并发上限(1-100)</label>
158
- <input type="number" data-f="concurrency" min="1" max="100" value="${esc(app.concurrency ?? '')}" placeholder="缺省用全局"></div>
159
- </div>
160
- <label>角色(主机器人 = 全权限、首次使用需配对;分身机器人 = 限定能力、艾特即用供他人使用)</label>
161
- <select data-f="role">
162
- <option value="primary" ${app.role !== 'deputy' ? 'selected' : ''}>主机器人(全权限,配对码准入)</option>
163
- <option value="deputy" ${app.role === 'deputy' ? 'selected' : ''}>分身机器人(限定技能 / 插件 / 工作区,艾特即用)</option>
164
- </select>
165
- <h4 style="margin:18px 0 2px;font-size:13.5px">模型配置(该机器人专用的模型厂商与模型;改动下一条消息热生效)</h4>
166
- <div class="hint" style="margin-bottom:6px">未配置 = 跟随全局(「Claude 认证」页当前生效配置)。档案须先在「Claude 认证」页创建;认证经独立 settings 文件注入(优先级高于全局),模型经 --model 参数注入。</div>
167
- <div class="row" style="align-items:end">
168
- <div><label>档案</label>
169
- <select data-f="profile">
170
- <option value="" ${!app.profile ? 'selected' : ''}>(跟随全局)</option>
171
- ${profiles.map((p) => `<option value="${esc(p.name)}" ${app.profile === p.name ? 'selected' : ''}>${esc(p.name)}</option>`).join('')}
172
- </select></div>
173
- <div id="profileModelBox" style="flex:1;${app.profile ? '' : 'display:none'}"><label>模型(从该档案候选中选择;留空 = 档案默认模型)</label>
174
- <div id="profileModelMount"></div></div>
175
- </div>
176
- <div id="deputyFields" style="${app.role === 'deputy' ? '' : 'display:none'}">
177
- <div class="hint" style="margin:8px 0 2px">分身必填「允许的技能」与「允许的工作区」;未列入白名单的技能 / 插件不加载,任务锁定在第一个允许的工作区;管理命令在分身里一律不可用。改动需重启生效。</div>
178
- <label>允许的技能(必填;输入关键词搜索选择,未列出的技能对模型不可见)</label>
179
- <div data-msel="allowed_skills"></div>
180
- <label>允许的工作区(必填;取值须在工作区列表内,任务锁定第一个)</label>
181
- <div data-msel="allowed_workspaces"></div>
182
- <label>允许的插件(可选;按名称过滤自动发现的插件,不配 = 不加载任何自动发现插件)</label>
183
- <div data-msel="allowed_plugins"></div>
184
- </div>
185
- <label>人格补充(追加到该机器人每个会话的 system prompt,多机器人差异化定位)<span class="tag warn" style="margin-left:6px">改动需重启生效</span></label>
186
- <textarea data-f="append_system_prompt" placeholder="你是我的超级助手,擅长代码开发、文案编写…(回复须带「主人」称谓等要求写在这里)">${esc(app.append_system_prompt || '')}</textarea>
187
- <h4 style="margin:18px 0 2px;font-size:13.5px">触发词(命中即改写消息;rewrite 指向本地命令时由 bridge 执行、其余照旧入队;下一条消息热生效)</h4>
188
- <div class="hint" style="margin-bottom:6px">match:以 <code>/</code> 开头 或 关键词 = 消息首词精确匹配;</br>rewrite:可用 <code>{text}</code>=原文全文、<code>{args}</code>=首词后的参数;</br>补充追问:命中先追问补充内容,你的下一条消息与原文合并后再改写入队(斜杠 / 等命令消息除外)。</br>PS. 可指向 bridge 本地命令(如 match=新会话、rewrite=/new,飞书菜单场景常用——bridge 本地执行,不透传给 Claude Code);本地命令原文(/stop 等)不被劫持;按序首个命中生效。</div>
189
- <table><thead><tr><th style="width:38%">match</th><th>rewrite</th><th style="width:76px" title="开启后命中先追问补充内容,下一条消息与原文合并再执行(两段式触发)">追问补充</th><th style="width:44px"></th></tr></thead><tbody data-list="triggers"></tbody></table>
190
- <button class="btn sm" data-add="triggers" style="margin-top:6px">+ 添加规则</button>
191
- <h4 style="margin:18px 0 2px;font-size:13.5px">环境变量(注入该机器人 Claude 子进程,覆盖本机同名值;改动需重启生效)</h4>
192
- <div class="hint" style="margin-bottom:6px">~/.claude/settings.json 已配置的键会被 CLI 自身应用且优先,无须在此重复。</div>
193
- <table><thead><tr><th style="width:38%">KEY</th><th>VALUE</th><th style="width:44px"></th></tr></thead><tbody data-list="env"></tbody></table>
194
- <button class="btn sm" data-add="env" style="margin-top:6px">+ 添加变量</button>`,
195
- footHtml: `<button class="btn danger" id="dwDel">删除应用</button><span class="spacer"></span><button class="btn" id="dwCancel">取消</button><button class="btn primary" id="dwSave">保存</button>`,
231
+ <div id="profileModelBox" style="${app.profile ? '' : 'display:none'}"><label class="fld">模型(留空 = 档案默认模型)</label>
232
+ <div id="profileModelMount"></div></div>
233
+ </div>
234
+ <div class="mini-note" style="margin-top:10px">认证经独立 settings 注入,模型经 --model 参数注入;改动下一条消息热生效。档案在「Claude 认证」页维护。</div>`;
235
+
236
+ const paneTrig = () => `
237
+ <div class="banner info" style="margin-bottom:10px"><span>命中即改写消息后入队;<code>{text}</code>=原文,<code>{args}</code>=首词后的参数。match 以 <code>/</code> 开头或关键词 = 消息首词精确匹配;可指向 bridge 本地命令(如 match=新会话、rewrite=/new)。<span class="tag info" style="margin-left:6px">热生效</span></span></div>
238
+ <table><thead><tr><th style="width:34%">match</th><th>rewrite</th><th style="width:76px" title="开启后命中先追问补充内容,下一条消息与原文合并再执行(两段式触发)">追问补充</th><th style="width:70px"></th></tr></thead>
239
+ <tbody data-list="triggers"></tbody></table>
240
+ <button class="btn sm" data-add="triggers" style="margin-top:8px">+ 添加规则</button>`;
241
+
242
+ const paneEnv = () => `
243
+ <div class="mini-note" style="margin-bottom:8px">注入该机器人的 Claude 子进程,覆盖本机同名值;~/.claude/settings.json 已配置的键会被 CLI 自身应用且优先,无须在此重复。<span class="tag warn" style="margin-left:4px">需重启</span></div>
244
+ <table><thead><tr><th style="width:36%">KEY</th><th>VALUE</th><th style="width:70px"></th></tr></thead>
245
+ <tbody data-list="env"></tbody></table>
246
+ <button class="btn sm" data-add="env" style="margin-top:8px">+ 添加变量</button>`;
247
+
248
+ const panes = { base: paneBase, role: paneRole, model: paneModel, trig: paneTrig, env: paneEnv };
249
+ const cnt = (s) => (s === 'trig' ? (app.triggers?.length || '') : s === 'env' ? (Object.keys(app.env || {}).length || '') : '');
250
+
251
+ openDrawer({
252
+ title: `${isNew ? '新增应用' : `应用:${app.name || app.app_id || '(未命名)'}`}`,
253
+ snap: snap ?? snapDoc(),
254
+ bodyHtml: `<div class="tabs" id="dwTabs" style="margin-bottom:18px"></div><div id="dwPane"></div>`,
255
+ footHtml: `<button class="btn danger" id="dwDel">删除应用</button><span class="sp"></span><button class="btn" id="dwCancel">取消</button><button class="btn primary" id="dwSave">保存</button>`,
196
256
  onMount: ({ body, foot }) => {
197
- // ---- 普通字段(语义与旧卡片版逐字一致;改动只进内存,抽屉「保存」落盘) ----
198
- body.querySelectorAll('[data-f]').forEach((input) => {
199
- input.oninput = input.onchange = () => {
200
- const f = input.dataset.f;
201
- if (f === 'default_workspace') {
202
- if (input.value) app.default_workspace = input.value; else delete app.default_workspace;
203
- return;
204
- }
205
- if (f === 'app_secret') {
206
- if (input.value.trim()) { app.app_secret = input.value; }
207
- else if (typeof app.app_secret === 'string') { app.app_secret = { secretSet: secret.secretSet }; }
208
- return;
209
- }
210
- if (f === 'concurrency') {
211
- if (input.value.trim()) app.concurrency = Number(input.value); else delete app.concurrency;
212
- return;
213
- }
214
- if (input.value.trim() || f === 'name' || f === 'app_id') { app[f] = input.value; }
215
- else { delete app[f]; }
216
- };
217
- });
218
- // ---- 角色(主/分身):切换 deputy 区块显示;选主时移除 role 键(yaml 干净,归一化仍为 primary) ----
219
- const roleSel = body.querySelector('[data-f="role"]');
220
- const deputyFields = body.querySelector('#deputyFields');
221
- roleSel.addEventListener('change', () => {
222
- if (roleSel.value === 'deputy') app.role = 'deputy';
223
- else delete app.role;
224
- deputyFields.style.display = roleSel.value === 'deputy' ? '' : 'none';
225
- });
226
- // ---- 厂商档案:模型 = 可搜索单选 combo(候选 = 所选档案的模型候选 ∪ 默认模型)----
227
- // 保存从 app 对象取值:combo onChange 实时写回(与分身白名单 multiSelectField 同构),
228
- // 不走 [data-f] 通用绑定(combo 非原生 input)。初始 mount 只填候选不清值(打开抽屉
229
- // 不能丢已存配置);切换档案时清空模型覆盖(留空 = 跟随新档案默认模型,旧档案的
230
- // profile_model 残留会串档案——后端对非候选值 warn 回退,前端直接不残留)
231
- const profileSel = body.querySelector('[data-f="profile"]');
232
- const profileModelBox = body.querySelector('#profileModelBox');
233
- const modelCombo = singleSelectField({
234
- mount: body.querySelector('#profileModelMount'),
235
- value: app.profile_model || '', // 遗留值(含已不在候选的)ghost 展示可清除
236
- placeholder: '输入关键词搜索选择;留空 = 档案默认模型',
237
- emptyText: '该档案未配置候选模型(到「Claude 认证」页为档案添加模型)',
238
- onChange: (v) => { if (v) app.profile_model = v; else delete app.profile_model; },
239
- });
240
- const applyProfileModels = () => {
241
- modelCombo.setOptions(toProfileModelOptions(profileByName(profileSel.value)));
242
- profileModelBox.style.display = profileSel.value ? '' : 'none';
257
+ const paintTabs = () => {
258
+ body.querySelector('#dwTabs').innerHTML = secs.map(([id, label]) =>
259
+ `<button data-s="${id}" class="${id === cur ? 'active' : ''}">${label}${cnt(id) !== '' ? `<span class="tag off" style="margin-left:4px">${cnt(id)}</span>` : ''}</button>`).join('');
260
+ body.querySelectorAll('#dwTabs button').forEach((b) => b.onclick = () => { cur = b.dataset.s; paint(); });
243
261
  };
244
- applyProfileModels(); // 初次填充:不动 value、不动 app.profile_model
245
- profileSel.addEventListener('change', () => {
246
- applyProfileModels();
247
- modelCombo.setValue(''); // 静默清空 → 显式删键(切「(跟随全局)」同样命中:隐藏 + 清空)
248
- delete app.profile_model;
249
- });
250
- // ---- 分身白名单(可搜索多选):onChange 实时写回 app——空数组删键,语义与旧 textarea 一致 ----
251
- const mselInst = {};
252
- for (const f of ['allowed_skills', 'allowed_workspaces', 'allowed_plugins']) {
253
- mselInst[f] = multiSelectField({
254
- mount: body.querySelector(`[data-msel="${f}"]`),
255
- value: app[f] || [],
256
- placeholder: '输入关键词搜索选择…',
257
- emptyText: '候选加载中…',
258
- onChange: (values) => { if (values.length) app[f] = values; else delete app[f]; },
259
- });
260
- }
261
- // 工作区候选来自配置本身(refresh 后 S.doc 是新引用,无须缓存)
262
- mselInst.allowed_workspaces.setOptions(
263
- wsNames.map((n) => ({ value: n })),
264
- wsNames.length ? undefined : '尚未配置工作区(先到「工作区」页添加)',
265
- );
266
- // 技能/插件候选走 API:组件先以空候选就位,数据到达(或失败提示)后回填,已选值不受影响
267
- void loadDeputyCandidates().then((c) => {
268
- mselInst.allowed_skills.setOptions(
269
- toSkillOptions(c.skills),
270
- c.skillsError ? `候选加载失败:${c.skillsError}(已选项不受影响)` : (c.skills?.length ? undefined : '暂无已安装技能'),
271
- );
272
- mselInst.allowed_plugins.setOptions(
273
- toPluginOptions(c.plugins),
274
- c.pluginsError ? `候选加载失败:${c.pluginsError}(已选项不受影响)` : (c.plugins?.length ? undefined : '暂无已安装插件'),
275
- );
276
- });
277
- // ---- 触发词:数组行编辑,实时写回 ----(显式插件区块已移除,统一走「插件」页全局配置;存量 apps[].plugins 数据继续生效)
278
- const LIST_DEFS = {
279
- triggers: { key: 'triggers', cols: [['match', '/produce 或 关键词'], ['rewrite', '请执行内容生产流程:{args}']], check: ['ask_detail', '补充'] },
280
- };
281
- const renderPairList = (name) => {
282
- const def = LIST_DEFS[name];
283
- const list = app[def.key] || [];
284
- const tb = body.querySelector(`[data-list="${name}"]`);
285
- tb.innerHTML = list.map((item, i) => `
286
- <tr>
287
- ${def.cols.map(([col, ph]) => `<td><input type="text" data-l="${i}" data-c="${col}" value="${esc(item?.[col] ?? '')}" placeholder="${esc(ph)}"></td>`).join('')}
288
- ${def.check ? `<td style="text-align:center;vertical-align:middle"><label class="switch" title="开启后命中先追问补充内容,下一条消息与原文合并再执行(两段式触发)"><input type="checkbox" data-l="${i}" data-ck="${def.check[0]}" ${item?.[def.check[0]] === true ? 'checked' : ''}><span class="track"></span></label></td>` : ''}
289
- <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
290
- </tr>`).join('');
291
- tb.querySelectorAll('input[data-l]').forEach((input) => {
292
- if (input.dataset.ck) { // 勾选列:未勾即删键,与后端「仅 true 时写入」语义一致(YAML 干净)
293
- input.onchange = () => {
294
- const row = app[def.key][Number(input.dataset.l)];
295
- if (input.checked) row[input.dataset.ck] = true;
296
- else delete row[input.dataset.ck];
262
+
263
+ /** 每次切 Tab / 行增删后重画当前 Tab 并重绑事件(状态都在 app 对象上,切页零丢失) */
264
+ const bindPane = () => {
265
+ const p = body.querySelector('#dwPane');
266
+ if (cur === 'base') {
267
+ p.querySelectorAll('[data-f]').forEach((input) => {
268
+ input.oninput = input.onchange = () => {
269
+ const f = input.dataset.f;
270
+ if (f === 'default_workspace') {
271
+ if (input.value) app.default_workspace = input.value; else delete app.default_workspace;
272
+ return;
273
+ }
274
+ if (f === 'app_secret') {
275
+ if (input.value.trim()) { app.app_secret = input.value; }
276
+ else if (typeof app.app_secret === 'string') { app.app_secret = { secretSet: secret.secretSet }; }
277
+ return;
278
+ }
279
+ if (f === 'concurrency') {
280
+ if (input.value.trim()) app.concurrency = Number(input.value); else delete app.concurrency;
281
+ return;
282
+ }
283
+ if (input.value.trim() || f === 'name' || f === 'app_id') { app[f] = input.value; }
284
+ else { delete app[f]; }
297
285
  };
298
- return;
286
+ });
287
+ const showBtn = p.querySelector('#dwSecretShow');
288
+ if (showBtn) showBtn.onclick = () => {
289
+ const sec = p.querySelector('[data-f="app_secret"]');
290
+ const hide = sec.type === 'password';
291
+ sec.type = hide ? 'text' : 'password';
292
+ showBtn.textContent = hide ? '隐藏' : '显示';
293
+ };
294
+ }
295
+ if (cur === 'role') {
296
+ p.querySelectorAll('.rcard').forEach((r) => r.onclick = (e) => {
297
+ e.preventDefault();
298
+ const v = r.querySelector('input').value;
299
+ if (v === 'deputy') app.role = 'deputy'; else delete app.role;
300
+ paint();
301
+ });
302
+ if (app.role === 'deputy') {
303
+ const mselInst = {};
304
+ for (const f of ['allowed_skills', 'allowed_workspaces', 'allowed_plugins']) {
305
+ mselInst[f] = multiSelectField({
306
+ mount: p.querySelector(`[data-msel="${f}"]`),
307
+ value: app[f] || [],
308
+ placeholder: '输入关键词搜索选择…',
309
+ emptyText: '候选加载中…',
310
+ onChange: (values) => { if (values.length) app[f] = values; else delete app[f]; },
311
+ });
312
+ }
313
+ // 工作区候选来自配置本身;技能/插件候选走 API(组件先空候选就位,数据到达后回填)
314
+ mselInst.allowed_workspaces.setOptions(
315
+ wsNames.map((n) => ({ value: n })),
316
+ wsNames.length ? undefined : '尚未配置工作区(先到「工作区」页添加)',
317
+ );
318
+ void loadDeputyCandidates().then((c) => {
319
+ mselInst.allowed_skills.setOptions(
320
+ toSkillOptions(c.skills),
321
+ c.skillsError ? `候选加载失败:${c.skillsError}(已选项不受影响)` : (c.skills?.length ? undefined : '暂无已安装技能'),
322
+ );
323
+ mselInst.allowed_plugins.setOptions(
324
+ toPluginOptions(c.plugins),
325
+ c.pluginsError ? `候选加载失败:${c.pluginsError}(已选项不受影响)` : (c.plugins?.length ? undefined : '暂无已安装插件'),
326
+ );
327
+ });
299
328
  }
300
- input.oninput = () => { app[def.key][Number(input.dataset.l)][input.dataset.c] = input.value; };
301
- });
302
- tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
303
- app[def.key].splice(Number(b.dataset.rm), 1);
304
- if (!app[def.key].length) delete app[def.key];
305
- renderPairList(name);
306
- });
307
- };
308
- for (const name of Object.keys(LIST_DEFS)) renderPairList(name);
309
- // ---- 环境变量:对象 ↔ 行数组同步(key 改名 / 删除即时生效到 app.env) ----
310
- const envRows = Object.entries(app.env || {}).map(([key, value]) => ({ key, value: String(value) }));
311
- const syncEnv = () => {
312
- const obj = {};
313
- for (const r of envRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
314
- if (Object.keys(obj).length) app.env = obj; else delete app.env;
329
+ }
330
+ if (cur === 'model') {
331
+ const profileSel = p.querySelector('[data-f="profile"]');
332
+ const profileModelBox = p.querySelector('#profileModelBox');
333
+ const modelCombo = singleSelectField({
334
+ mount: p.querySelector('#profileModelMount'),
335
+ value: app.profile_model || '', // 遗留值(含已不在候选的)ghost 展示可清除
336
+ placeholder: '输入关键词搜索选择;留空 = 档案默认模型',
337
+ emptyText: '该档案未配置候选模型(到「Claude 认证」页为档案添加模型)',
338
+ onChange: (v) => { if (v) app.profile_model = v; else delete app.profile_model; },
339
+ });
340
+ const applyProfileModels = () => {
341
+ modelCombo.setOptions(toProfileModelOptions(profileByName(profileSel.value)));
342
+ profileModelBox.style.display = profileSel.value ? '' : 'none';
343
+ };
344
+ applyProfileModels(); // 初次填充:不动 value、不动 app.profile_model
345
+ profileSel.addEventListener('change', () => {
346
+ if (profileSel.value) app.profile = profileSel.value; else delete app.profile;
347
+ applyProfileModels();
348
+ modelCombo.setValue(''); // 静默清空 → 显式删键(切「(跟随全局)」同样命中)
349
+ delete app.profile_model;
350
+ });
351
+ }
352
+ if (cur === 'trig') {
353
+ const tb = p.querySelector('[data-list="triggers"]');
354
+ const draw = () => {
355
+ const list = app.triggers || [];
356
+ tb.innerHTML = list.length ? list.map((t, i) => `
357
+ <tr>
358
+ <td><input type="text" data-l="${i}" data-c="match" value="${esc(t?.match ?? '')}" placeholder="/produce 或 关键词"></td>
359
+ <td><input type="text" data-l="${i}" data-c="rewrite" value="${esc(t?.rewrite ?? '')}" placeholder="请执行内容生产流程:{args}"></td>
360
+ <td style="text-align:center;vertical-align:middle"><label class="switch tight" title="开启后命中先追问补充内容,下一条消息与原文合并再执行(两段式触发)"><input type="checkbox" data-l="${i}" data-ck="ask_detail" ${t?.ask_detail === true ? 'checked' : ''}><span class="track"></span></label></td>
361
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
362
+ </tr>`).join('') : '<tr class="empty"><td colspan="4">暂无规则</td></tr>';
363
+ tb.querySelectorAll('input[data-l]').forEach((input) => {
364
+ if (input.dataset.ck) { // 勾选列:未勾即删键,与后端「仅 true 时写入」语义一致(YAML 干净)
365
+ input.onchange = () => {
366
+ const row = app.triggers[Number(input.dataset.l)];
367
+ if (input.checked) row[input.dataset.ck] = true;
368
+ else delete row[input.dataset.ck];
369
+ };
370
+ return;
371
+ }
372
+ input.oninput = () => { app.triggers[Number(input.dataset.l)][input.dataset.c] = input.value; };
373
+ });
374
+ tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
375
+ app.triggers.splice(Number(b.dataset.rm), 1);
376
+ if (!app.triggers.length) delete app.triggers;
377
+ draw();
378
+ paintTabs();
379
+ });
380
+ };
381
+ draw();
382
+ p.querySelector('[data-add="triggers"]').onclick = () => {
383
+ (app.triggers = app.triggers || []).push({ match: '', rewrite: '' });
384
+ draw();
385
+ paintTabs();
386
+ tb.querySelector('input')?.focus();
387
+ };
388
+ }
389
+ if (cur === 'env') {
390
+ const envRows = Object.entries(app.env || {}).map(([key, value]) => ({ key, value: String(value) }));
391
+ const syncEnv = () => {
392
+ const obj = {};
393
+ for (const r of envRows) if (r.key.trim()) obj[r.key.trim()] = r.value;
394
+ if (Object.keys(obj).length) app.env = obj; else delete app.env;
395
+ };
396
+ const tb = p.querySelector('[data-list="env"]');
397
+ const draw = () => {
398
+ tb.innerHTML = envRows.length ? envRows.map((r, i) => `
399
+ <tr>
400
+ <td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
401
+ <td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
402
+ <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
403
+ </tr>`).join('') : '<tr class="empty"><td colspan="3">暂无变量</td></tr>';
404
+ tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
405
+ envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
406
+ syncEnv();
407
+ });
408
+ tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
409
+ envRows.splice(Number(b.dataset.rm), 1);
410
+ syncEnv();
411
+ draw();
412
+ paintTabs();
413
+ });
414
+ };
415
+ draw();
416
+ p.querySelector('[data-add="env"]').onclick = () => {
417
+ envRows.push({ key: '', value: '' });
418
+ draw();
419
+ paintTabs();
420
+ tb.querySelector('input')?.focus();
421
+ };
422
+ }
315
423
  };
316
- const renderEnv = () => {
317
- const tb = body.querySelector('[data-list="env"]');
318
- tb.innerHTML = envRows.map((r, i) => `
319
- <tr>
320
- <td><input type="text" data-ei="${i}" data-c="key" value="${esc(r.key)}" placeholder="SOME_KEY"></td>
321
- <td><input type="text" data-ei="${i}" data-c="value" value="${esc(r.value)}" placeholder="some-value"></td>
322
- <td><button class="btn sm danger" data-rm="${i}">删除</button></td>
323
- </tr>`).join('');
324
- tb.querySelectorAll('input[data-ei]').forEach((input) => input.oninput = () => {
325
- envRows[Number(input.dataset.ei)][input.dataset.c] = input.value;
326
- syncEnv();
327
- });
328
- tb.querySelectorAll('button[data-rm]').forEach((b) => b.onclick = () => {
329
- envRows.splice(Number(b.dataset.rm), 1);
330
- syncEnv();
331
- renderEnv();
332
- });
424
+
425
+ const paint = () => {
426
+ paintTabs();
427
+ body.querySelector('#dwPane').innerHTML = panes[cur]();
428
+ bindPane();
333
429
  };
334
- renderEnv();
335
- // ---- 添加行 ----
336
- body.querySelectorAll('[data-add]').forEach((b) => b.onclick = () => {
337
- const name = b.dataset.add;
338
- if (name === 'env') { envRows.push({ key: '', value: '' }); renderEnv(); return; }
339
- const def = LIST_DEFS[name];
340
- (app[def.key] = app[def.key] || []).push({ [def.cols[0][0]]: '', [def.cols[1][0]]: '' });
341
- renderPairList(name);
342
- body.querySelector(`[data-list="${name}"] tbody input`)?.focus();
343
- });
430
+ paint();
431
+
344
432
  // ---- 删除 / 保存 / 取消 ----
345
433
  foot.querySelector('#dwDel').onclick = async () => {
346
434
  if (apps.length <= 1) return toast('至少保留一个应用', true);
@@ -363,14 +451,12 @@ function openAppDrawer(apps, idx, el, snap) {
363
451
  const badWs = (app.allowed_workspaces || []).filter((w) => !wsNames.includes(w));
364
452
  if (badWs.length) return toast(`「允许的工作区」含未定义的工作区:${badWs.join('、')}(请先在工作区列表中添加)`, true);
365
453
  }
366
- // 清理半填的空行:triggers 行两列全空剔除;env 以 key 非空为准(syncEnv 已过滤)。数组空则整键删除
367
- for (const name of Object.keys(LIST_DEFS)) {
368
- const def = LIST_DEFS[name];
369
- const list = app[def.key];
370
- if (!Array.isArray(list)) continue;
371
- const filtered = list.filter((item) => def.cols.some(([col]) => String(item?.[col] ?? '').trim()));
372
- if (filtered.length) app[def.key] = filtered;
373
- else delete app[def.key];
454
+ // 清理半填的空行:triggers 行两列全空剔除;env 以 key 非空为准(bindPane 内 syncEnv 已过滤)。数组空则整键删除
455
+ const list = app.triggers;
456
+ if (Array.isArray(list)) {
457
+ const filtered = list.filter((item) => String(item?.match ?? '').trim() || String(item?.rewrite ?? '').trim());
458
+ if (filtered.length) app.triggers = filtered;
459
+ else delete app.triggers;
374
460
  }
375
461
  // 档案未选时清掉残留的模型覆盖(profile_model 依附于 profile 存在)
376
462
  if (!app.profile) delete app.profile_model;
@@ -383,6 +469,7 @@ function openAppDrawer(apps, idx, el, snap) {
383
469
  export const page = {
384
470
  id: 'apps',
385
471
  title: '飞书应用',
472
+ desc: '每个应用一条独立长连接与独立会话池',
386
473
  icon: '<path d="M12 8V4H8"/><rect width="16" height="12" x="4" y="8" rx="2"/><path d="M2 14h2"/><path d="M20 14h2"/><path d="M15 13v2"/><path d="M9 13v2"/>',
387
474
  render: renderApps,
388
475
  };