dsh-bots 0.2.10 → 0.2.12

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,12 @@
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}
320
326
  .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
327
  .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
328
  .dbs-scrollBody{scrollbar-gutter:stable;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden auto}
@@ -358,7 +364,7 @@
358
364
  @keyframes dbs-turn-status-shimmer{to{background-position:0 0}}
359
365
  @media (prefers-reduced-motion:reduce){.dbs-turnStatus{background-position:0 0;background-size:100% 100%;animation:none}.dbs-arrow{transition:none}}
360
366
  .dbs-composerSeat{flex:none;display:flex;flex-direction:column;z-index:7;background:linear-gradient(180deg,color-mix(in srgb,var(--dsw-alias-bg-base) 0%,transparent) 0px,var(--dsw-alias-bg-base) 36px)}
361
- .dbs-composer{padding:0 var(--dsh-composer-side-clearance) 8px;flex-direction:column;align-items:center;display:flex}
367
+ .dbs-composer{padding:12px var(--dsh-composer-side-clearance) 10px;flex-direction:column;align-items:center;display:flex}
362
368
  .dbs-composerCard{cursor:text;box-sizing:border-box;width:100%;max-width:var(--dsh-composer-card-max-width);border:1px solid var(--dsw-alias-border-l2-darkmode-thin,rgba(0,0,0,.12));background:var(--dsw-specific-input-major);box-shadow:var(--dsw-shadow-lv2);border-radius:22px;flex-direction:column;gap:12px;padding-top:10px;font-size:16px;line-height:24px;display:flex;position:relative}
363
369
  .dbs-composerScroll{max-height:var(--dsh-composer-text-max-height);overflow-y:auto}
364
370
  .dbs-composerRow{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;min-width:0;padding:2px 8px 6px;display:flex}
@@ -450,6 +456,7 @@
450
456
  'action.saving': '保存中…',
451
457
  'action.stop': '停止生成',
452
458
  'chat.stop.noop': '当前没有进行中的生成',
459
+ 'media.openFailed': '打开失败,文件可能已移动或被删除',
453
460
  'chat.members.manage': '管理成员',
454
461
  'modal.members.title': '管理群成员',
455
462
  'modal.members.hint': '勾选的 Bot 为群成员;保存后立即生效(可随时再改)。',
@@ -551,6 +558,7 @@
551
558
  'action.saving': 'Saving…',
552
559
  'action.stop': 'Stop generating',
553
560
  'chat.stop.noop': 'No generation in progress',
561
+ 'media.openFailed': 'Open failed — the file may have moved or been deleted',
554
562
  'chat.members.manage': 'Manage members',
555
563
  'modal.members.title': 'Manage group members',
556
564
  'modal.members.hint': 'Checked bots are members; changes apply immediately on save (editable again anytime).',
@@ -1292,6 +1300,86 @@
1292
1300
  const p2 = (n) => String(n).padStart(2, '0');
1293
1301
  return e('span', { className: 'dbs-msgTime', title: stampOf(ms) }, d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + clockOf(ms));
1294
1302
  }
1303
+ // ---- Media references: bot-written local files in message text. ----
1304
+ // Images render inline (host readImage → data URL, session-cached);
1305
+ // documents/media render as a click-to-open chip (host openFile). The
1306
+ // host enforces the real boundary — gateway data dir only, ≤8MB, image
1307
+ // extension allowlist — so the client regexes are UX filters, not trust.
1308
+ const MEDIA_IMG_RE = /\.(png|jpe?g|gif|webp|bmp|svg)$/i;
1309
+ const MEDIA_FILE_RE = /\.(pdf|mp3|wav|m4a|mp4|mov|zip|csv|xlsx|docx|pptx|md|txt|json)$/i;
1310
+ const MEDIA_PATH_RE = /\/[^\s,。;、!?:;""''()【】《》<>"'`\\|]+/g;
1311
+ const MEDIA_MAX_REFS = 6;
1312
+ const mediaUrlCache = new Map();
1313
+ function mediaPathsOf(text) {
1314
+ if (typeof text !== 'string' || text.indexOf('/') === -1)
1315
+ return [];
1316
+ const out = [];
1317
+ for (const m of text.matchAll(MEDIA_PATH_RE)) {
1318
+ const p = m[0].replace(/[.,;:!?、。,;:!?))】\]>}]+$/, '');
1319
+ if (p.length < 4 || p.startsWith('//'))
1320
+ continue; // //… = URL fragment
1321
+ if (!MEDIA_IMG_RE.test(p) && !MEDIA_FILE_RE.test(p))
1322
+ continue;
1323
+ if (out.indexOf(p) !== -1)
1324
+ continue;
1325
+ out.push(p);
1326
+ if (out.length >= MEDIA_MAX_REFS)
1327
+ break;
1328
+ }
1329
+ return out;
1330
+ }
1331
+ function FileChip(p) {
1332
+ const [failed, setFailed] = React.useState(false);
1333
+ const base = p.path.slice(p.path.lastIndexOf('/') + 1) || p.path;
1334
+ const open = () => { botsCall('openFile', { path: p.path }).catch(() => setFailed(true)); };
1335
+ return e('span', {
1336
+ className: 'dbs-fileChip', title: p.path, role: 'button', tabIndex: 0,
1337
+ onClick: open,
1338
+ onKeyDown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') {
1339
+ ev.preventDefault();
1340
+ open();
1341
+ } },
1342
+ }, Ico('IconFolderClose16', { size: 12 }), e('span', { className: 'dbs-fileChipName' }, base), failed === true ? e('span', { className: 'dbs-meta' }, t('media.openFailed')) : null);
1343
+ }
1344
+ function MediaImage(p) {
1345
+ const [url, setUrl] = React.useState(mediaUrlCache.get(p.path) ?? null);
1346
+ const [err, setErr] = React.useState(false);
1347
+ React.useEffect(() => {
1348
+ if (mediaUrlCache.has(p.path))
1349
+ return;
1350
+ let alive = true;
1351
+ botsCall('readImage', { path: p.path })
1352
+ .then((r) => {
1353
+ const u = 'data:' + r.mime + ';base64,' + r.dataBase64;
1354
+ mediaUrlCache.set(p.path, u);
1355
+ if (alive)
1356
+ setUrl(u);
1357
+ })
1358
+ .catch(() => { if (alive)
1359
+ setErr(true); });
1360
+ return () => { alive = false; };
1361
+ // eslint-disable-next-line react-hooks/exhaustive-deps
1362
+ }, [p.path]);
1363
+ if (url !== null) {
1364
+ return e('img', {
1365
+ className: 'dbs-mediaImg', src: url, alt: p.path, title: p.path,
1366
+ onClick: () => { botsCall('openFile', { path: p.path }).catch(() => undefined); },
1367
+ });
1368
+ }
1369
+ if (err === true)
1370
+ return e(FileChip, { path: p.path });
1371
+ return e('span', { className: 'dbs-mediaLoading' }, Ico('IconLoadingOutline16', { size: 14 }));
1372
+ }
1373
+ function MediaRefs(p) {
1374
+ const refs = mediaPathsOf(p.text);
1375
+ if (refs.length === 0)
1376
+ return null;
1377
+ const imgs = refs.filter((r) => MEDIA_IMG_RE.test(r));
1378
+ const files = p.files === true ? refs.filter((r) => !MEDIA_IMG_RE.test(r)) : [];
1379
+ if (imgs.length === 0 && files.length === 0)
1380
+ return null;
1381
+ return e('div', { className: 'dbs-mediaRefs' }, imgs.map((r) => e(MediaImage, { key: r, path: r })), files.map((r) => e(FileChip, { key: r, path: r })));
1382
+ }
1295
1383
  function ToolCard(p) {
1296
1384
  const [open, setOpen] = React.useState(false);
1297
1385
  const en = p.entry;
@@ -1311,12 +1399,12 @@
1311
1399
  } },
1312
1400
  }, StateDot !== null
1313
1401
  ? 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);
1402
+ : 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
1403
  }
1316
1404
  function Entry(p) {
1317
1405
  const en = p.entry;
1318
1406
  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 }));
1407
+ 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
1408
  }
1321
1409
  if (en.display === 'tool')
1322
1410
  return e(ToolCard, { entry: en });
@@ -1332,7 +1420,7 @@
1332
1420
  // stands above; only the reply clock rides along, right-aligned and
1333
1421
  // quiet — the way native IMs chain quick follow-ups under one name.
1334
1422
  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 })));
1423
+ 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
1424
  }
1337
1425
  // Bot message: avatar + prominent per-author name + full-datetime in
1338
1426
  // one header row, so multi-member rooms read at a glance.
@@ -1349,7 +1437,7 @@
1349
1437
  const authorColor = 'hsl(' + String(hueOf(avAgent.id)) + ' 55% 45%)';
1350
1438
  return e('div', { className: 'dbs-botRow' }, e('div', { className: 'dbs-author' }, e(Avatar, { agent: avAgent, size: 18 }), displayName != null && displayName !== ''
1351
1439
  ? 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));
1440
+ : 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
1441
  }
1354
1442
  function ChatView(p) {
1355
1443
  const s = useStore();
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.10",
3
+ "version": "0.2.12",
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",