dsh-bots 0.2.11 → 0.2.13

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.
package/lib/client.js CHANGED
@@ -317,6 +317,13 @@
317
317
  .dbs-error{margin:4px 8px;padding:5px 9px;border-radius:8px;font-size:12px;line-height:18px;cursor:pointer;background:var(--dsw-alias-interactive-bg-hover);color:var(--dsw-alias-state-error-primary,#f85149)}
318
318
 
319
319
  .dbs-chatview{position:absolute;top:0;bottom:0;pointer-events:auto;display:flex;flex-direction:column;background:var(--dsw-alias-bg-base);font-family:var(--dsw-font-family,inherit);z-index:2;--dsh-chat-content-width:748px;--dsh-composer-card-max-width:calc(var(--dsh-chat-content-width) + 32px);--dsh-composer-side-clearance:16px;--dsh-composer-dock-inset:8px;--dsh-composer-text-max-height:336px;min-width:0}
320
+ .dbs-mediaRefs{display:flex;flex-wrap:wrap;gap:6px;margin-top:6px;align-items:center}
321
+ .dbs-mediaImg{max-width:280px;max-height:210px;border-radius:10px;cursor:zoom-in;display:block;border:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08))}
322
+ .dbs-mediaLoading{width:28px;height:20px;display:inline-flex;align-items:center;color:var(--dsw-alias-label-tertiary)}
323
+ .dbs-fileChip{display:inline-flex;align-items:center;gap:5px;padding:3px 10px;border-radius:999px;background:var(--dsw-alias-interactive-bg-hover);font-size:12px;line-height:18px;color:var(--dsw-alias-label-secondary);cursor:pointer;max-width:100%;user-select:none}
324
+ .dbs-fileChip:hover{color:var(--dsw-alias-label-primary)}
325
+ .dbs-fileChipName{overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
326
+ .dbs-setLabel{font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);margin:8px 0 4px}
320
327
  .dbs-chatbar{flex:none;display:flex;align-items:center;gap:8px;height:44px;padding:0 12px;border-bottom:1px solid var(--dsw-alias-border-l1,rgba(0,0,0,.08))}
321
328
  .dbs-chatbarName{font-size:14px;line-height:20px;font-weight:600;color:var(--dsw-alias-label-primary);white-space:nowrap;overflow:hidden;text-overflow:ellipsis;min-width:0}
322
329
  .dbs-scrollBody{scrollbar-gutter:stable;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden auto}
@@ -450,7 +457,18 @@
450
457
  'action.saving': '保存中…',
451
458
  'action.stop': '停止生成',
452
459
  'chat.stop.noop': '当前没有进行中的生成',
460
+ 'media.openFailed': '打开失败,文件可能已移动或被删除',
453
461
  'chat.members.manage': '管理成员',
462
+ 'chat.settings': '设置',
463
+ 'chat.settings.title': '会话设置',
464
+ 'chat.settings.name': '名称',
465
+ 'chat.settings.desc': '简介',
466
+ 'chat.settings.workspace': '工作区隔离',
467
+ 'chat.settings.root': '工作区根目录',
468
+ 'chat.settings.rootPh': '留空 = 不启用',
469
+ 'chat.settings.paths': '额外可写路径(逗号分隔)',
470
+ 'chat.settings.pathsPh': '/tmp/xxx, /Users/you/shared',
471
+ 'chat.settings.hint': '写入被沙盒限制在工作区(含额外路径)内,读取不受限;同 slug 的 Bot 共享同一目录。',
454
472
  'modal.members.title': '管理群成员',
455
473
  'modal.members.hint': '勾选的 Bot 为群成员;保存后立即生效(可随时再改)。',
456
474
  'action.delete': '删除',
@@ -551,7 +569,18 @@
551
569
  'action.saving': 'Saving…',
552
570
  'action.stop': 'Stop generating',
553
571
  'chat.stop.noop': 'No generation in progress',
572
+ 'media.openFailed': 'Open failed — the file may have moved or been deleted',
554
573
  'chat.members.manage': 'Manage members',
574
+ 'chat.settings': 'Settings',
575
+ 'chat.settings.title': 'Session settings',
576
+ 'chat.settings.name': 'Name',
577
+ 'chat.settings.desc': 'Description',
578
+ 'chat.settings.workspace': 'Workspace jail',
579
+ 'chat.settings.root': 'Workspace root',
580
+ 'chat.settings.rootPh': 'Empty = disabled',
581
+ 'chat.settings.paths': 'Extra writable paths (comma-separated)',
582
+ 'chat.settings.pathsPh': '/tmp/xxx, /Users/you/shared',
583
+ 'chat.settings.hint': 'Writes are sandboxed to the workspace (plus extra paths); reads stay unrestricted. Bots sharing a slug share one directory.',
555
584
  'modal.members.title': 'Manage group members',
556
585
  'modal.members.hint': 'Checked bots are members; changes apply immediately on save (editable again anytime).',
557
586
  'action.delete': 'Delete',
@@ -754,6 +783,7 @@
754
783
  confirmDelete: null,
755
784
  /** Manage-members dialog: group agentId | null (system modal). */
756
785
  manageMembers: null,
786
+ settingsAgentId: null,
757
787
  /** Bumped on a language switch so module-scope `t` output re-renders. */
758
788
  localeRev: 0,
759
789
  };
@@ -1292,6 +1322,93 @@
1292
1322
  const p2 = (n) => String(n).padStart(2, '0');
1293
1323
  return e('span', { className: 'dbs-msgTime', title: stampOf(ms) }, d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + clockOf(ms));
1294
1324
  }
1325
+ // ---- Media references: bot-written local files in message text. ----
1326
+ // Images render inline (host readImage → data URL, session-cached);
1327
+ // documents/media render as a click-to-open chip (host openFile). The
1328
+ // host enforces the real boundary — gateway data dir only, ≤8MB, image
1329
+ // extension allowlist — so the client regexes are UX filters, not trust.
1330
+ const MEDIA_IMG_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
1331
+ const MEDIA_FILE_RE = /\.(pdf|mp3|wav|m4a|mp4|mov|zip|csv|xlsx|docx|pptx|md|txt|json)$/i;
1332
+ const MEDIA_PATH_RE = /\/[^\s,。;、!?:;""''()【】《》<>"'`\\|]+/g;
1333
+ const MEDIA_MAX_REFS = 6;
1334
+ const mediaUrlCache = new Map();
1335
+ function mediaPathsOf(text) {
1336
+ if (typeof text !== 'string' || text.indexOf('/') === -1)
1337
+ return [];
1338
+ const out = [];
1339
+ for (const m of text.matchAll(MEDIA_PATH_RE)) {
1340
+ const p = m[0].replace(/[.,;:!?、。,;:!?))】\]>}]+$/, '');
1341
+ if (p.length < 4 || p.startsWith('//'))
1342
+ continue; // //… = URL fragment
1343
+ if (!MEDIA_IMG_RE.test(p) && !MEDIA_FILE_RE.test(p))
1344
+ continue;
1345
+ if (out.indexOf(p) !== -1)
1346
+ continue;
1347
+ out.push(p);
1348
+ if (out.length >= MEDIA_MAX_REFS)
1349
+ break;
1350
+ }
1351
+ return out;
1352
+ }
1353
+ function FileChip(p) {
1354
+ const [failed, setFailed] = React.useState(false);
1355
+ const base = p.path.slice(p.path.lastIndexOf('/') + 1) || p.path;
1356
+ const open = () => { botsCall('openFile', { path: p.path }).catch(() => setFailed(true)); };
1357
+ return e('span', {
1358
+ className: 'dbs-fileChip', title: p.path, role: 'button', tabIndex: 0,
1359
+ onClick: open,
1360
+ onKeyDown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') {
1361
+ ev.preventDefault();
1362
+ open();
1363
+ } },
1364
+ }, Ico('IconFolderClose16', { size: 12 }), e('span', { className: 'dbs-fileChipName' }, base), failed === true ? e('span', { className: 'dbs-meta' }, t('media.openFailed')) : null);
1365
+ }
1366
+ function MediaImage(p) {
1367
+ const [url, setUrl] = React.useState(mediaUrlCache.get(p.path) ?? null);
1368
+ const [err, setErr] = React.useState(false);
1369
+ React.useEffect(() => {
1370
+ if (mediaUrlCache.has(p.path))
1371
+ return;
1372
+ let alive = true;
1373
+ botsCall('readImage', { path: p.path })
1374
+ .then((r) => {
1375
+ const u = 'data:' + r.mime + ';base64,' + r.dataBase64;
1376
+ mediaUrlCache.set(p.path, u);
1377
+ if (alive)
1378
+ setUrl(u);
1379
+ })
1380
+ .catch(() => { if (alive)
1381
+ setErr(true); });
1382
+ return () => { alive = false; };
1383
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1384
+ }, [p.path]);
1385
+ if (url !== null) {
1386
+ return e('img', {
1387
+ className: 'dbs-mediaImg', src: url, alt: p.path, title: p.path,
1388
+ onClick: () => { botsCall('openFile', { path: p.path }).catch(() => undefined); },
1389
+ });
1390
+ }
1391
+ if (err === true)
1392
+ return e(FileChip, { path: p.path });
1393
+ return e('span', { className: 'dbs-mediaLoading' }, Ico('IconLoadingOutline16', { size: 14 }));
1394
+ }
1395
+ function MediaRefs(p) {
1396
+ const refs = mediaPathsOf(p.text);
1397
+ if (refs.length === 0)
1398
+ return null;
1399
+ const imgs = refs.filter((r) => MEDIA_IMG_RE.test(r));
1400
+ const files = p.files === true ? refs.filter((r) => !MEDIA_IMG_RE.test(r)) : [];
1401
+ if (imgs.length === 0 && files.length === 0)
1402
+ return null;
1403
+ return e('div', { className: 'dbs-mediaRefs' }, imgs.map((r) => e(MediaImage, { key: r, path: r })), files.map((r) => e(FileChip, { key: r, path: r })));
1404
+ }
1405
+ /**
1406
+ * Settings glyph — three sliders, drawn inline (the primitives package
1407
+ * exposes no verified gear export; same policy as SendUpIcon).
1408
+ */
1409
+ function SettingsGlyph() {
1410
+ return e('svg', { viewBox: '0 0 16 16', width: '16', height: '16', 'aria-hidden': true }, e('g', { stroke: 'currentColor', strokeWidth: 1.5, strokeLinecap: 'round' }, e('line', { x1: 2, y1: 4, x2: 14, y2: 4 }), e('line', { x1: 2, y1: 8, x2: 14, y2: 8 }), e('line', { x1: 2, y1: 12, x2: 14, y2: 12 })), e('g', { fill: 'currentColor' }, e('circle', { cx: 10, cy: 4, r: 2 }), e('circle', { cx: 5.5, cy: 8, r: 2 }), e('circle', { cx: 11.5, cy: 12, r: 2 })));
1411
+ }
1295
1412
  function ToolCard(p) {
1296
1413
  const [open, setOpen] = React.useState(false);
1297
1414
  const en = p.entry;
@@ -1311,12 +1428,12 @@
1311
1428
  } },
1312
1429
  }, StateDot !== null
1313
1430
  ? e(StateDot, { state: en.toolStatus === 'running' ? 'ongoing' : en.toolStatus === 'error' ? 'error' : 'done', size: 10 })
1314
- : e('span', { style: { width: 10, height: 10, borderRadius: 999, background: tone, display: 'inline-block' } }), e('span', { className: 'dbs-toolName' }, en.toolName ?? t('tool.fallbackName')), e('span', { className: 'dbs-toolTrail' }, en.content !== '' ? e('span', { className: 'dbs-meta' }, open ? t('action.collapse') : t('action.expand')) : null, e(MsgTime, { entry: en }))), open && en.content !== '' ? e('div', { className: 'dbs-toolBody' }, en.content) : null);
1431
+ : e('span', { style: { width: 10, height: 10, borderRadius: 999, background: tone, display: 'inline-block' } }), e('span', { className: 'dbs-toolName' }, en.toolName ?? t('tool.fallbackName')), e('span', { className: 'dbs-toolTrail' }, en.content !== '' ? e('span', { className: 'dbs-meta' }, open ? t('action.collapse') : t('action.expand')) : null, e(MsgTime, { entry: en }))), open && en.content !== '' ? e('div', { className: 'dbs-toolBody' }, en.content, e(MediaRefs, { text: en.content })) : null);
1315
1432
  }
1316
1433
  function Entry(p) {
1317
1434
  const en = p.entry;
1318
1435
  if (en.display === 'user') {
1319
- return e('div', { className: 'dbs-userRow' }, e('div', { className: 'dbs-userStack' }, e('div', { className: 'dbs-bubble' }, e(MessageText, { text: en.content }))), e(MsgTime, { entry: en }));
1436
+ return e('div', { className: 'dbs-userRow' }, e('div', { className: 'dbs-userStack' }, e('div', { className: 'dbs-bubble' }, e(MessageText, { text: en.content }), e(MediaRefs, { text: en.content, files: true }))), e(MsgTime, { entry: en }));
1320
1437
  }
1321
1438
  if (en.display === 'tool')
1322
1439
  return e(ToolCard, { entry: en });
@@ -1332,7 +1449,7 @@
1332
1449
  // stands above; only the reply clock rides along, right-aligned and
1333
1450
  // quiet — the way native IMs chain quick follow-ups under one name.
1334
1451
  if (p.compact === true) {
1335
- return e('div', { className: 'dbs-botRow', 'data-compact': 'true' }, e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null), e('div', { className: 'dbs-compactTime' }, e(MsgTime, { entry: en })));
1452
+ return e('div', { className: 'dbs-botRow', 'data-compact': 'true' }, e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null, en.isStreaming === true ? null : e(MediaRefs, { text: en.content, files: true })), e('div', { className: 'dbs-compactTime' }, e(MsgTime, { entry: en })));
1336
1453
  }
1337
1454
  // Bot message: avatar + prominent per-author name + full-datetime in
1338
1455
  // one header row, so multi-member rooms read at a glance.
@@ -1349,7 +1466,7 @@
1349
1466
  const authorColor = 'hsl(' + String(hueOf(avAgent.id)) + ' 55% 45%)';
1350
1467
  return e('div', { className: 'dbs-botRow' }, e('div', { className: 'dbs-author' }, e(Avatar, { agent: avAgent, size: 18 }), displayName != null && displayName !== ''
1351
1468
  ? e('span', { className: 'dbs-authorName', style: { color: authorColor } }, displayName)
1352
- : null, e(MsgTime, { entry: en })), e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null));
1469
+ : null, e(MsgTime, { entry: en })), e('div', { className: 'dbs-mdRow' }, e(MarkdownText, { text: en.content, streaming: en.isStreaming === true }), en.isStreaming === true ? e('span', { className: 'dbs-caret' }) : null, en.isStreaming === true ? null : e(MediaRefs, { text: en.content, files: true })));
1353
1470
  }
1354
1471
  function ChatView(p) {
1355
1472
  const s = useStore();
@@ -1607,12 +1724,16 @@
1607
1724
  variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
1608
1725
  icon: Ico('IconCloseOutline16', { size: 16 }),
1609
1726
  onClick: () => patch({ chatAgentId: null }),
1610
- }), agent !== null ? e(Avatar, { agent, size: 24 }) : null, e('span', { className: 'dbs-chatbarName' }, agent?.name ?? t('chat.loading')), e('span', { className: 'dbs-meta' }, isGroup ? t('chat.group', { n: memberNames.length }) : t('chat.single')), isGroup
1727
+ }), agent !== null ? e(Avatar, { agent, size: 24 }) : null, e('span', { className: 'dbs-chatbarName' }, agent?.name ?? t('chat.loading')), e('span', { className: 'dbs-meta' }, isGroup ? t('chat.group', { n: memberNames.length }) : t('chat.single')), e('span', { style: { flex: 1 } }), isGroup
1611
1728
  ? e(Button, {
1612
1729
  variant: 'ghost', size: 'sm', title: t('chat.members.manage'), 'aria-label': t('chat.members.manage'),
1613
1730
  onClick: () => patch({ manageMembers: p.agentId }),
1614
1731
  }, t('chat.members.manage'))
1615
- : null, e('span', { style: { flex: 1 } })), error !== null
1732
+ : null, e('button', {
1733
+ className: 'dbs-railBtn', type: 'button',
1734
+ title: t('chat.settings'), 'aria-label': t('chat.settings'),
1735
+ onClick: () => patch({ settingsAgentId: p.agentId }),
1736
+ }, e(SettingsGlyph, null))), error !== null
1616
1737
  ? e('div', { className: 'dbs-error', onClick: () => setError(null) }, error)
1617
1738
  : null, e('div', { className: 'dbs-scrollArea' }, e('div', { className: 'dbs-scrollBody', ref: scrollRef, onScroll: onScrollBody }, e('div', { className: 'dbs-scroll' }, e('div', { className: 'dbs-column' }, entries === null
1618
1739
  ? e('div', { className: 'dbs-skeleton' }, e('div', { className: 'dbs-skelRow', style: { width: '42%' } }), e('div', { className: 'dbs-skelRow', style: { width: '76%' } }), e('div', { className: 'dbs-skelRow', style: { width: '58%' } }))
@@ -2073,6 +2194,113 @@
2073
2194
  onClick: () => void submit(),
2074
2195
  }, working ? t('action.saving') : t('action.save')))));
2075
2196
  }
2197
+ /**
2198
+ * Per-session settings: name (+ description) for every chat; singles
2199
+ * additionally get the §14 workspace-jail editor (root + extra writable
2200
+ * paths). updateAgent merges only the name/description/title/avatar
2201
+ * fields engine-side, so saving cannot clobber the rest of a profile;
2202
+ * an empty root removes the jail (workspaceRoot: null).
2203
+ */
2204
+ function AgentSettingsModal() {
2205
+ const s = useStore();
2206
+ const agent = agentById(s.settingsAgentId);
2207
+ const isGroup = agent?.isGroup === true;
2208
+ const [name, setName] = React.useState('');
2209
+ const [desc, setDesc] = React.useState('');
2210
+ const [root, setRoot] = React.useState('');
2211
+ const [paths, setPaths] = React.useState('');
2212
+ const [wsLoaded, setWsLoaded] = React.useState(false);
2213
+ const [working, setWorking] = React.useState(false);
2214
+ const [err, setErr] = React.useState(null);
2215
+ React.useEffect(() => {
2216
+ if (agent === null || agent === undefined)
2217
+ return;
2218
+ setName(String(agent.name ?? ''));
2219
+ setDesc(String(agent.description ?? ''));
2220
+ if (agent.isGroup === true) {
2221
+ setWsLoaded(true);
2222
+ return;
2223
+ }
2224
+ let alive = true;
2225
+ botsCall('workspaceGet', { agentId: agent.id })
2226
+ .then((c) => {
2227
+ if (alive === false)
2228
+ return;
2229
+ setRoot(c.workspaceRoot ?? '');
2230
+ setPaths((c.allowPaths ?? []).join(', '));
2231
+ setWsLoaded(true);
2232
+ })
2233
+ .catch(() => { if (alive)
2234
+ setWsLoaded(true); });
2235
+ return () => { alive = false; };
2236
+ // eslint-disable-next-line react-hooks/exhaustive-deps
2237
+ }, [s.settingsAgentId]);
2238
+ if (agent === null || agent === undefined)
2239
+ return null;
2240
+ const valid = name.trim() !== '';
2241
+ async function save() {
2242
+ if (working || valid === false)
2243
+ return;
2244
+ setWorking(true);
2245
+ setErr(null);
2246
+ try {
2247
+ await botsCall('update', { id: agent.id, profile: { name: name.trim(), description: desc.trim() } });
2248
+ if (agent.isGroup !== true && wsLoaded === true) {
2249
+ const list = paths.split(/[,,]/).map((x) => x.trim()).filter((x) => x !== '');
2250
+ await botsCall('workspaceSet', {
2251
+ agentId: agent.id,
2252
+ workspaceRoot: root.trim() === '' ? null : root.trim(),
2253
+ allowPaths: list,
2254
+ });
2255
+ }
2256
+ patch({ settingsAgentId: null });
2257
+ await refreshAgents();
2258
+ return;
2259
+ }
2260
+ catch (e2) {
2261
+ setErr(String(e2?.message ?? e2));
2262
+ }
2263
+ setWorking(false);
2264
+ }
2265
+ return e('div', {
2266
+ className: 'dbs-modalBackdrop',
2267
+ onClick: () => { if (working === false)
2268
+ patch({ settingsAgentId: null }); },
2269
+ }, e('div', {
2270
+ className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
2271
+ 'aria-label': t('chat.settings.title'),
2272
+ onClick: (ev) => { ev.stopPropagation(); },
2273
+ }, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, t('chat.settings.title')), e(Button, {
2274
+ variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
2275
+ disabled: working,
2276
+ icon: Ico('IconCloseOutline16', { size: 16 }),
2277
+ onClick: () => patch({ settingsAgentId: null }),
2278
+ })), e('div', { className: 'dbs-modalBody' }, e('div', { className: 'dbs-setLabel' }, t('chat.settings.name')), e(Input, {
2279
+ value: name, autoFocus: true, disabled: working,
2280
+ onChange: (ev) => setName(ev.target.value),
2281
+ }), isGroup === false
2282
+ ? e('div', null, e('div', { className: 'dbs-setLabel' }, t('chat.settings.desc')), e(Input, {
2283
+ value: desc, disabled: working,
2284
+ onChange: (ev) => setDesc(ev.target.value),
2285
+ }), e('div', { className: 'dbs-setLabel', style: { marginTop: 12, fontWeight: 600 } }, t('chat.settings.workspace')), e('div', { className: 'dbs-setLabel' }, t('chat.settings.root')), e(Input, {
2286
+ value: root, disabled: working || wsLoaded === false,
2287
+ placeholder: t('chat.settings.rootPh'),
2288
+ onChange: (ev) => setRoot(ev.target.value),
2289
+ }), e('div', { className: 'dbs-setLabel' }, t('chat.settings.paths')), e(Input, {
2290
+ value: paths, disabled: working || wsLoaded === false,
2291
+ placeholder: t('chat.settings.pathsPh'),
2292
+ onChange: (ev) => setPaths(ev.target.value),
2293
+ }), e('div', { className: 'dbs-meta', style: { marginTop: 8 } }, t('chat.settings.hint')))
2294
+ : null, err !== null
2295
+ ? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
2296
+ : null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
2297
+ variant: 'ghost', size: 'sm', disabled: working,
2298
+ onClick: () => patch({ settingsAgentId: null }),
2299
+ }, t('action.cancel')), e(Button, {
2300
+ variant: 'primary', size: 'sm', disabled: valid === false || working,
2301
+ onClick: () => void save(),
2302
+ }, working === true ? t('action.saving') : t('action.save')))));
2303
+ }
2076
2304
  /** Delete confirmation modal: same system-dialog chrome as CreateModal. */
2077
2305
  function ConfirmDeleteModal() {
2078
2306
  const s = useStore();
@@ -2151,6 +2379,8 @@
2151
2379
  layers.push(e(CreateModal, { key: 'create' }));
2152
2380
  if (s.manageMembers !== null)
2153
2381
  layers.push(e(ManageMembersModal, { key: 'manage-members' }));
2382
+ if (s.settingsAgentId !== null)
2383
+ layers.push(e(AgentSettingsModal, { key: 'agent-settings' }));
2154
2384
  if (s.confirmDelete !== null)
2155
2385
  layers.push(e(ConfirmDeleteModal, { key: 'confirm-delete' }));
2156
2386
  return layers.length === 0 ? null : e('div', null, layers);
package/lib/index.js CHANGED
@@ -19,8 +19,9 @@
19
19
  * @module dsh-bots
20
20
  */
21
21
  import { createRequire } from 'node:module';
22
- import { dirname, join } from 'node:path';
23
- import { appendFileSync, realpathSync } from 'node:fs';
22
+ import { dirname, extname, join, resolve, sep } from 'node:path';
23
+ import { appendFileSync, readFileSync, realpathSync, statSync } from 'node:fs';
24
+ import { spawn } from 'node:child_process';
24
25
  import { pathToFileURL } from 'node:url';
25
26
  import { callGateway, discover, expandHome, nextNonce, normalizeAgents, readDiscovery, trimAgent, trimEntry } from './gateway.js';
26
27
  import { GatewaySseClient, SseRingBuffer } from './sse.js';
@@ -284,6 +285,51 @@ export class BotsRemote extends TypertRemoteService {
284
285
  });
285
286
  return trimAgent(updated?.agent ?? updated);
286
287
  }
288
+ /**
289
+ * Resolve a bot-written path against the media allowlist: only files inside
290
+ * the gateway data dir (agent dirs, box-workspace, swarm blackboard…) may
291
+ * be served or opened — the workbench must never become a disk-wide file
292
+ * reader. Symlinks resolve before the check so escapes fail closed.
293
+ */
294
+ resolveMediaPath(raw) {
295
+ const p = String(raw ?? '').trim();
296
+ if (p === '')
297
+ throw new Error('path is required');
298
+ const root = realpathSync(expandHome(this.cfg.dataDir));
299
+ let resolved = resolve(expandHome(p));
300
+ try {
301
+ resolved = realpathSync(resolved);
302
+ }
303
+ catch { /* missing → prefix-check the literal path */ }
304
+ if (resolved !== root && !resolved.startsWith(root + sep)) {
305
+ throw new Error('path is outside the gateway data dir: ' + resolved);
306
+ }
307
+ return { resolved, ext: extname(resolved).toLowerCase() };
308
+ }
309
+ /** Inline-preview support: read a bot-written image as base64 for a data URL. */
310
+ async readImage(request) {
311
+ const { resolved, ext } = this.resolveMediaPath(request?.path);
312
+ const mime = {
313
+ '.png': 'image/png', '.jpg': 'image/jpeg', '.jpeg': 'image/jpeg', '.gif': 'image/gif',
314
+ '.webp': 'image/webp', '.bmp': 'image/bmp', '.svg': 'image/svg+xml',
315
+ }[ext];
316
+ if (mime === undefined)
317
+ throw new Error('not a supported image: ' + ext);
318
+ const stat = statSync(resolved);
319
+ if (!stat.isFile())
320
+ throw new Error('not a file: ' + resolved);
321
+ if (stat.size > 8 * 1024 * 1024)
322
+ throw new Error('image exceeds the 8MB preview cap (' + stat.size + ' bytes)');
323
+ return { mime, dataBase64: readFileSync(resolved).toString('base64'), sizeBytes: stat.size };
324
+ }
325
+ /** Open a bot-written file with the desktop default app (macOS `open`). */
326
+ async openFile(request) {
327
+ const { resolved } = this.resolveMediaPath(request?.path);
328
+ statSync(resolved); // missing → honest error instead of a silent no-op
329
+ const cmd = process.platform === 'darwin' ? 'open' : 'xdg-open';
330
+ spawn(cmd, [resolved], { detached: true, stdio: 'ignore' }).unref();
331
+ return { opened: true };
332
+ }
287
333
  async update(request) {
288
334
  const updated = await callGateway(this.cfg.dataDir, 'updateAgent', {
289
335
  id: request?.id,
@@ -493,7 +539,7 @@ export class BotsRemote extends TypertRemoteService {
493
539
  }
494
540
  for (const m of [
495
541
  'gatewayInfo', 'list', 'workspaces', 'sessions',
496
- 'create', 'createGroup', 'setGroupMembers', 'update', 'remove', 'send', 'interrupt', 'transcriptTail', 'markRead', 'diag',
542
+ 'create', 'createGroup', 'setGroupMembers', 'update', 'remove', 'send', 'interrupt', 'readImage', 'openFile', 'transcriptTail', 'markRead', 'diag',
497
543
  'mcpServers', 'mcpTools', 'mcpAdd', 'mcpRemove', 'mcpRefresh', 'mcpExecute',
498
544
  'workspaceList', 'workspaceGet', 'workspaceSet',
499
545
  'eventsSince', 'sseState',
@@ -92,6 +92,27 @@ export declare class BotsRemote extends TypertRemoteService {
92
92
  id?: string;
93
93
  memberIds?: string[];
94
94
  } | null): Promise<AgentInfo | null>;
95
+ /**
96
+ * Resolve a bot-written path against the media allowlist: only files inside
97
+ * the gateway data dir (agent dirs, box-workspace, swarm blackboard…) may
98
+ * be served or opened — the workbench must never become a disk-wide file
99
+ * reader. Symlinks resolve before the check so escapes fail closed.
100
+ */
101
+ private resolveMediaPath;
102
+ /** Inline-preview support: read a bot-written image as base64 for a data URL. */
103
+ readImage(request: {
104
+ path?: string;
105
+ } | null): Promise<{
106
+ mime: string;
107
+ dataBase64: string;
108
+ sizeBytes: number;
109
+ }>;
110
+ /** Open a bot-written file with the desktop default app (macOS `open`). */
111
+ openFile(request: {
112
+ path?: string;
113
+ } | null): Promise<{
114
+ opened: boolean;
115
+ }>;
95
116
  update(request: {
96
117
  id?: string;
97
118
  profile?: Record<string, unknown>;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-bots",
3
- "version": "0.2.11",
3
+ "version": "0.2.13",
4
4
  "description": "Multi-bot workbench for DeepSeek Harness: bridges the sdk-bots orchestration gateway (group chats, single bots) into the dsh web shell with official-styled UI.",
5
5
  "type": "module",
6
6
  "main": "lib/index.js",