dsh-bots 0.0.1 → 0.2.10
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/LICENSE +21 -0
- package/README.md +8 -16
- package/cordis.patch.yml +5 -0
- package/lib/client.js +2199 -0
- package/lib/gateway.js +240 -0
- package/lib/index.js +508 -0
- package/lib/shared.js +37 -0
- package/lib/sse.js +249 -0
- package/lib/types/client.d.ts +35 -0
- package/lib/types/gateway.d.ts +32 -0
- package/lib/types/index.d.ts +210 -0
- package/lib/types/shared.d.ts +191 -0
- package/lib/types/sse.d.ts +79 -0
- package/lib/types/unread.d.ts +74 -0
- package/lib/types/version.d.ts +28 -0
- package/lib/types/workspace.d.ts +49 -0
- package/lib/unread.js +196 -0
- package/lib/version.js +109 -0
- package/lib/workspace.js +129 -0
- package/package.json +50 -14
package/lib/client.js
ADDED
|
@@ -0,0 +1,2199 @@
|
|
|
1
|
+
"use strict";
|
|
2
|
+
/**
|
|
3
|
+
* dsh-bots — client half (web), formal-plugin runtime.
|
|
4
|
+
*
|
|
5
|
+
* Rendered by the dsh web shell through the standard slot system with full
|
|
6
|
+
* browser DOM access. Two rules keep the surface native rather than
|
|
7
|
+
* native-looking:
|
|
8
|
+
*
|
|
9
|
+
* 1. Components come from `@deepseek-ai/dsh-client-ui-primitives`, which the
|
|
10
|
+
* shell publishes in its static module map alongside `react`. Buttons,
|
|
11
|
+
* inputs, icons, state dots and the markdown renderer are therefore the
|
|
12
|
+
* shipped ones, not reimplementations. Every lookup degrades to a local
|
|
13
|
+
* fallback so a primitives reshuffle can never blank the plugin.
|
|
14
|
+
* 2. Our own CSS uses stable `dbs-` class names carrying the *values* read
|
|
15
|
+
* out of the shipped stylesheets (row heights, radii, the composer var
|
|
16
|
+
* family), expressed through dsh theme variables. No hashed class name is
|
|
17
|
+
* borrowed, so a dsh rebuild cannot break the visuals.
|
|
18
|
+
*
|
|
19
|
+
* Sidebar integration: the left-nav workspace region (`sidebar.workspaces` is
|
|
20
|
+
* a single slot) is shadowed at a low priority — an officially supported move,
|
|
21
|
+
* the shell's own error text reads "register at a different priority to shadow
|
|
22
|
+
* it (lowest renders)" — and re-rendered as a two-group collapsible nav:
|
|
23
|
+
* - 「工作区」 — delegates the ORIGINAL shipped workspace browser, with its
|
|
24
|
+
* child slots and its rail branch intact (see DelegatedBrowser).
|
|
25
|
+
* - 「Bots」 — our bot/group tree; clicking opens the chat.
|
|
26
|
+
*
|
|
27
|
+
* Chat lives in the `shell.overlay` layer but is inset to the frame's centre
|
|
28
|
+
* column, so the sidebar stays visible and usable while a bot conversation is
|
|
29
|
+
* open — matching how a native session behaves.
|
|
30
|
+
*
|
|
31
|
+
* Live data: the host keeps an SSE ring fed from the sdk-bots `/events`
|
|
32
|
+
* channel. ONE bus drains it here and fans channels out to subscribers; no
|
|
33
|
+
* component owns the cursor and no component polls the gateway directly.
|
|
34
|
+
* @module dsh-bots/client
|
|
35
|
+
*/
|
|
36
|
+
;
|
|
37
|
+
(() => {
|
|
38
|
+
const loader = window.__ModuleLoader__;
|
|
39
|
+
if (loader === undefined)
|
|
40
|
+
return;
|
|
41
|
+
loader.load({
|
|
42
|
+
id: 'dsh-bots',
|
|
43
|
+
factory: (require) => {
|
|
44
|
+
const module = { exports: {} };
|
|
45
|
+
const exports = module.exports;
|
|
46
|
+
const React = require('react');
|
|
47
|
+
const e = React.createElement;
|
|
48
|
+
/**
|
|
49
|
+
* Shipped primitives. Present in the shell's static module registry
|
|
50
|
+
* next to `react`; the guard keeps a missing/renamed package from
|
|
51
|
+
* taking the plugin down with it.
|
|
52
|
+
*/
|
|
53
|
+
let NATIVE = {};
|
|
54
|
+
try {
|
|
55
|
+
NATIVE = require('@deepseek-ai/dsh-client-ui-primitives') ?? {};
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
NATIVE = {};
|
|
59
|
+
}
|
|
60
|
+
/** Render a shipped icon by export name, or nothing if it is gone. */
|
|
61
|
+
function Ico(name, props) {
|
|
62
|
+
const C = NATIVE[name];
|
|
63
|
+
return C === undefined ? null : e(C, props ?? {});
|
|
64
|
+
}
|
|
65
|
+
/**
|
|
66
|
+
* The shipped composer's send glyph. It is not a named primitive —
|
|
67
|
+
* the official chatbar draws this SVG inline — so we replicate the
|
|
68
|
+
* path byte-exact from the shipped bundle (arrow-up, currentColor).
|
|
69
|
+
*/
|
|
70
|
+
function SendUpIcon() {
|
|
71
|
+
return e('svg', { viewBox: '0 0 16 16', width: '16', height: '16', 'aria-hidden': true }, e('path', {
|
|
72
|
+
d: 'M8.3125 0.980183C8.66767 1.0531 8.97902 1.20418 9.2627 1.43233C9.48724 1.61297 9.73029 1.85793 9.97949 2.10714L14.707 6.83468L13.293 8.24874L9 3.95577V15.0417H7V3.95577L2.70703 8.24874L1.29297 6.83468L6.02051 2.10714C6.26971 1.85793 6.51277 1.61297 6.7373 1.43233C6.97662 1.23986 7.28445 1.04402 7.6875 0.980183C7.8973 0.947006 8.1031 0.95516 8.3125 0.980183Z',
|
|
73
|
+
fill: 'currentColor',
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
/**
|
|
77
|
+
* The official chatbar morphs send into a stop square while a run is
|
|
78
|
+
* active (§12-29); replicate that glyph byte-simple — a rounded rect,
|
|
79
|
+
* currentColor — so the stop affordance reads native.
|
|
80
|
+
*/
|
|
81
|
+
function StopSquareIcon() {
|
|
82
|
+
return e('svg', { viewBox: '0 0 16 16', width: '16', height: '16', 'aria-hidden': true }, e('rect', { x: 3, y: 3, width: 10, height: 10, rx: 2.5, fill: 'currentColor' }));
|
|
83
|
+
}
|
|
84
|
+
/** Shipped component by export name, or a local stand-in. */
|
|
85
|
+
function nat(name, fallback) {
|
|
86
|
+
return NATIVE[name] ?? fallback;
|
|
87
|
+
}
|
|
88
|
+
// ---- Fallback stand-ins (only used if a primitive export disappears) ----
|
|
89
|
+
function FallbackButton(p) {
|
|
90
|
+
const { variant, size, icon, children, ...rest } = p;
|
|
91
|
+
return e('button', { type: 'button', ...rest }, icon ?? null, children);
|
|
92
|
+
}
|
|
93
|
+
function FallbackInput(p) {
|
|
94
|
+
const { icon, ...rest } = p;
|
|
95
|
+
return e('input', rest);
|
|
96
|
+
}
|
|
97
|
+
function FallbackText(p) {
|
|
98
|
+
return e('div', { className: 'dbs-plain' }, p.text);
|
|
99
|
+
}
|
|
100
|
+
/**
|
|
101
|
+
* Local markdown renderer for the (observed-in-the-wild) case where the
|
|
102
|
+
* shell's static module map serves the primitives package without
|
|
103
|
+
* MarkdownText: block-level fenced code / headings / hr / blockquote /
|
|
104
|
+
* lists / paragraphs, inline bold / italic / code / links. Built from
|
|
105
|
+
* createElement only — no HTML string ever crosses in. Streaming-safe:
|
|
106
|
+
* an unterminated fence renders as a code block running to the tail.
|
|
107
|
+
*/
|
|
108
|
+
/**
|
|
109
|
+
* Inline image preview. `` and bare image URLs render as a
|
|
110
|
+
* clickable thumbnail (click opens the full image in a new tab); a
|
|
111
|
+
* failed load degrades to the browser's broken-image box inside the
|
|
112
|
+
* same link, so the URL is never lost. Extension-based detection plus
|
|
113
|
+
* an allowlist for extension-less media hosts the crew actually posts
|
|
114
|
+
* (Pollinations MCP tool results).
|
|
115
|
+
*/
|
|
116
|
+
const IMG_EXT = /\.(png|jpe?g|gif|webp|avif|bmp|svg)(\?[^)\s]*)?$/i;
|
|
117
|
+
const IMG_HOSTS = new Set(['media.pollinations.ai', 'image.pollinations.ai']);
|
|
118
|
+
function isImageUrl(url) {
|
|
119
|
+
if (IMG_EXT.test(url))
|
|
120
|
+
return true;
|
|
121
|
+
try {
|
|
122
|
+
return IMG_HOSTS.has(new URL(url).hostname);
|
|
123
|
+
}
|
|
124
|
+
catch {
|
|
125
|
+
return false;
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
function imgLink(src, alt) {
|
|
129
|
+
return e('a', { href: src, target: '_blank', rel: 'noreferrer', className: 'dbs-imglink' }, e('img', { src, alt: alt !== '' ? alt : src, loading: 'lazy' }));
|
|
130
|
+
}
|
|
131
|
+
function mdInline(text) {
|
|
132
|
+
const out = [];
|
|
133
|
+
let buf = '';
|
|
134
|
+
let i = 0;
|
|
135
|
+
const push = () => { if (buf !== '') {
|
|
136
|
+
out.push(buf);
|
|
137
|
+
buf = '';
|
|
138
|
+
} };
|
|
139
|
+
while (i < text.length) {
|
|
140
|
+
const rest = text.slice(i);
|
|
141
|
+
let m;
|
|
142
|
+
if (rest.startsWith('`') && (m = /^`([^`\n]+)`/.exec(rest)) !== null) {
|
|
143
|
+
push();
|
|
144
|
+
out.push(e('code', null, m[1]));
|
|
145
|
+
i += m[0].length;
|
|
146
|
+
continue;
|
|
147
|
+
}
|
|
148
|
+
if (rest.startsWith('**') && (m = /^\*\*([\s\S]+?)\*\*/.exec(rest)) !== null) {
|
|
149
|
+
push();
|
|
150
|
+
out.push(e('strong', null, mdInline(m[1])));
|
|
151
|
+
i += m[0].length;
|
|
152
|
+
continue;
|
|
153
|
+
}
|
|
154
|
+
if (rest.startsWith('*') && (m = /^\*([^*\n]+)\*/.exec(rest)) !== null) {
|
|
155
|
+
push();
|
|
156
|
+
out.push(e('em', null, mdInline(m[1])));
|
|
157
|
+
i += m[0].length;
|
|
158
|
+
continue;
|
|
159
|
+
}
|
|
160
|
+
if ((m = /^!\[([^\]\n]*)\]\((https?:\/\/[^)\s]+)\)/.exec(rest)) !== null) {
|
|
161
|
+
push();
|
|
162
|
+
out.push(imgLink(m[2], m[1]));
|
|
163
|
+
i += m[0].length;
|
|
164
|
+
continue;
|
|
165
|
+
}
|
|
166
|
+
if ((m = /^\[([^\]\n]+)\]\((https?:\/\/[^)\s]+)\)/.exec(rest)) !== null) {
|
|
167
|
+
push();
|
|
168
|
+
out.push(e('a', { href: m[2], target: '_blank', rel: 'noreferrer' }, m[1]));
|
|
169
|
+
i += m[0].length;
|
|
170
|
+
continue;
|
|
171
|
+
}
|
|
172
|
+
if ((m = /^(https?:\/\/[^\s<>()[\]{}'"]+)/.exec(rest)) !== null) {
|
|
173
|
+
push();
|
|
174
|
+
if (isImageUrl(m[1])) {
|
|
175
|
+
out.push(imgLink(m[1], m[1]));
|
|
176
|
+
i += m[0].length;
|
|
177
|
+
continue;
|
|
178
|
+
}
|
|
179
|
+
out.push(e('a', { href: m[1], target: '_blank', rel: 'noreferrer' }, m[1]));
|
|
180
|
+
i += m[0].length;
|
|
181
|
+
continue;
|
|
182
|
+
}
|
|
183
|
+
buf += text[i];
|
|
184
|
+
i += 1;
|
|
185
|
+
}
|
|
186
|
+
push();
|
|
187
|
+
return out;
|
|
188
|
+
}
|
|
189
|
+
function mdBlocks(text) {
|
|
190
|
+
const lines = text.split('\n');
|
|
191
|
+
const out = [];
|
|
192
|
+
let i = 0;
|
|
193
|
+
let para = [];
|
|
194
|
+
const flushPara = () => { if (para.length > 0) {
|
|
195
|
+
out.push(e('p', null, mdInline(para.join('\n'))));
|
|
196
|
+
para = [];
|
|
197
|
+
} };
|
|
198
|
+
while (i < lines.length) {
|
|
199
|
+
const line = lines[i];
|
|
200
|
+
const fence = /^\s*```(\w*)\s*$/.exec(line);
|
|
201
|
+
if (fence !== null) {
|
|
202
|
+
flushPara();
|
|
203
|
+
const body = [];
|
|
204
|
+
i += 1;
|
|
205
|
+
while (i < lines.length && /^\s*```\s*$/.test(lines[i]) === false) {
|
|
206
|
+
body.push(lines[i]);
|
|
207
|
+
i += 1;
|
|
208
|
+
}
|
|
209
|
+
i += 1; // consume the closing fence; at EOF the block just ends open
|
|
210
|
+
out.push(e('pre', null, e('code', fence[1] !== '' ? { 'data-lang': fence[1] } : null, body.join('\n'))));
|
|
211
|
+
continue;
|
|
212
|
+
}
|
|
213
|
+
const h = /^(#{1,4})\s+(.*)$/.exec(line);
|
|
214
|
+
if (h !== null) {
|
|
215
|
+
flushPara();
|
|
216
|
+
out.push(e('h' + String(h[1].length), null, mdInline(h[2])));
|
|
217
|
+
i += 1;
|
|
218
|
+
continue;
|
|
219
|
+
}
|
|
220
|
+
if (/^\s*(---+|\*\*\*+)\s*$/.test(line)) {
|
|
221
|
+
flushPara();
|
|
222
|
+
out.push(e('hr', null));
|
|
223
|
+
i += 1;
|
|
224
|
+
continue;
|
|
225
|
+
}
|
|
226
|
+
const q = /^>\s?(.*)$/.exec(line);
|
|
227
|
+
if (q !== null) {
|
|
228
|
+
flushPara();
|
|
229
|
+
const body = [q[1]];
|
|
230
|
+
i += 1;
|
|
231
|
+
for (; i < lines.length; i += 1) {
|
|
232
|
+
const m2 = /^>\s?(.*)$/.exec(lines[i]);
|
|
233
|
+
if (m2 === null)
|
|
234
|
+
break;
|
|
235
|
+
body.push(m2[1]);
|
|
236
|
+
}
|
|
237
|
+
out.push(e('blockquote', null, e('p', null, mdInline(body.join('\n')))));
|
|
238
|
+
continue;
|
|
239
|
+
}
|
|
240
|
+
const isUl = /^\s*[-*+]\s+/.test(line);
|
|
241
|
+
const isOl = /^\s*\d+[.)]\s+/.test(line);
|
|
242
|
+
if (isUl || isOl) {
|
|
243
|
+
flushPara();
|
|
244
|
+
const items = [];
|
|
245
|
+
for (; i < lines.length; i += 1) {
|
|
246
|
+
const m2 = isUl ? /^\s*[-*+]\s+(.*)$/.exec(lines[i]) : /^\s*\d+[.)]\s+(.*)$/.exec(lines[i]);
|
|
247
|
+
if (m2 === null)
|
|
248
|
+
break;
|
|
249
|
+
items.push(e('li', null, mdInline(m2[1])));
|
|
250
|
+
}
|
|
251
|
+
out.push(e(isUl ? 'ul' : 'ol', null, items));
|
|
252
|
+
continue;
|
|
253
|
+
}
|
|
254
|
+
if (line.trim() === '') {
|
|
255
|
+
flushPara();
|
|
256
|
+
i += 1;
|
|
257
|
+
continue;
|
|
258
|
+
}
|
|
259
|
+
para.push(line);
|
|
260
|
+
i += 1;
|
|
261
|
+
}
|
|
262
|
+
flushPara();
|
|
263
|
+
return out;
|
|
264
|
+
}
|
|
265
|
+
function MarkdownFallback(p) {
|
|
266
|
+
return e('div', { className: 'dbs-md' }, mdBlocks(String(p.text ?? '')));
|
|
267
|
+
}
|
|
268
|
+
const Button = nat('Button', FallbackButton);
|
|
269
|
+
const Input = nat('Input', FallbackInput);
|
|
270
|
+
const MarkdownText = nat('MarkdownText', MarkdownFallback);
|
|
271
|
+
const MessageText = nat('MessageText', FallbackText);
|
|
272
|
+
const StateDot = NATIVE.StateDot ?? null;
|
|
273
|
+
// ---- CSS: own stable class names, values mirrored from the shell ----
|
|
274
|
+
const CSS = `
|
|
275
|
+
.dbs-nav{flex:1;min-height:0;display:flex;flex-direction:column;gap:2px;font-family:var(--dsw-font-family,inherit)}
|
|
276
|
+
.dbs-navGroup{display:flex;flex-direction:column;min-height:0}
|
|
277
|
+
.dbs-navGroup[data-open="true"]{flex:1 1 auto}
|
|
278
|
+
.dbs-navGroup[data-open="false"]{flex:none}
|
|
279
|
+
.dbs-navBody{display:flex;flex-direction:column;min-height:0;flex:1}
|
|
280
|
+
.dbs-botsBody{overflow-y:auto;scrollbar-gutter:stable;padding-right:var(--dsh-sidebar-inline-padding,8px)}
|
|
281
|
+
.dbs-navBodyErr{padding:6px 12px;font-size:12px;line-height:20px;color:var(--dsw-alias-label-tertiary)}
|
|
282
|
+
.dbs-prow,.dbs-srow{cursor:pointer;user-select:none;color:var(--dsw-alias-label-primary);border-radius:8px;align-items:center;gap:6px;padding:0 8px;display:flex;box-sizing:border-box}
|
|
283
|
+
/* Native mirror (dsh-client-ui-sidebar/workspace): header-class rows outdent
|
|
284
|
+
their label 4px (pl:4 → x=16 from the window edge) while list rows keep
|
|
285
|
+
pl:8 (→ x=20, identical to the native sessionRow). */
|
|
286
|
+
.dbs-prow{padding-left:4px}
|
|
287
|
+
.dbs-prow:hover,.dbs-srow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
288
|
+
.dbs-srow.dbs-selected{background:var(--dsw-alias-interactive-bg-hover)}
|
|
289
|
+
.dbs-prow{height:34px}
|
|
290
|
+
.dbs-srow{height:32px;gap:0}
|
|
291
|
+
.dbs-slot{width:16px;height:20px;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;display:inline-flex}
|
|
292
|
+
.dbs-chevron{color:var(--dsw-alias-label-caption);display:inline-flex}
|
|
293
|
+
.dbs-arrow{transition:transform .15s var(--ds-ease-in-out)}
|
|
294
|
+
.dbs-arrowOpen{transform:rotate(90deg)}
|
|
295
|
+
.dbs-title{text-overflow:ellipsis;white-space:nowrap;min-width:0;font-size:14px;line-height:20px;overflow:hidden;flex:1;margin:0 6px 0 4px}
|
|
296
|
+
.dbs-prow .dbs-title{font-weight:500}
|
|
297
|
+
.dbs-time{color:var(--dsw-alias-label-tertiary);flex:none;font-size:12px;line-height:20px}
|
|
298
|
+
.dbs-meta{text-overflow:ellipsis;white-space:nowrap;color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:20px;overflow:hidden}
|
|
299
|
+
.dbs-rowActions{flex:none;align-items:center;gap:4px;display:none}
|
|
300
|
+
.dbs-prow:hover .dbs-rowActions,.dbs-srow:hover .dbs-rowActions{display:inline-flex}
|
|
301
|
+
.dbs-prow:hover .dbs-hideOnHover,.dbs-srow:hover .dbs-hideOnHover{display:none}
|
|
302
|
+
.dbs-avatar{width:20px;height:20px;border-radius:6px;flex:none;display:grid;place-items:center;font-size:11px;line-height:1;font-weight:600;color:#fff;overflow:hidden;user-select:none}
|
|
303
|
+
.dbs-avatar.dbs-group{border-radius:999px}
|
|
304
|
+
.dbs-avatar img{width:100%;height:100%;object-fit:cover;display:block}
|
|
305
|
+
.dbs-badge{min-width:16px;height:16px;padding:0 5px;border-radius:999px;background:var(--dsw-alias-state-business-primary,#1a6dff);color:#fff;font-size:11px;line-height:16px;text-align:center;flex:none;font-variant-numeric:tabular-nums}
|
|
306
|
+
.dbs-railBtn{width:32px;height:32px;display:inline-flex;align-items:center;justify-content:center;border:none;border-radius:8px;background:transparent;color:var(--dsw-alias-label-secondary);cursor:pointer;padding:0}
|
|
307
|
+
.dbs-railBtn:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
308
|
+
.dbs-railBtn[data-active="true"]{color:var(--dsw-alias-state-business-primary)}
|
|
309
|
+
.dbs-rail{display:flex;flex-direction:column;align-items:center;gap:2px}
|
|
310
|
+
.dbs-railWrap{display:flex;flex-direction:column;min-height:0;flex:1;gap:2px}
|
|
311
|
+
.dbs-form{display:flex;flex-direction:column;gap:6px;padding:8px;border-radius:8px;margin:2px 0;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.2))}
|
|
312
|
+
.dbs-formRow{display:flex;gap:6px;align-items:center}
|
|
313
|
+
.dbs-members{display:flex;flex-direction:column;gap:2px;max-height:150px;overflow-y:auto}
|
|
314
|
+
.dbs-member{display:flex;align-items:center;gap:6px;font-size:13px;line-height:20px;padding:3px 6px;border-radius:6px;color:var(--dsw-alias-label-primary);cursor:pointer}
|
|
315
|
+
.dbs-member:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
316
|
+
.dbs-member.checked{color:var(--dsw-alias-state-business-primary,#1a6dff)}
|
|
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
|
+
|
|
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-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
|
+
.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
|
+
.dbs-scrollBody{scrollbar-gutter:stable;flex-direction:column;flex:1;min-height:0;display:flex;overflow:hidden auto}
|
|
323
|
+
.dbs-scroll{min-height:0;padding:16px calc(var(--dsh-composer-side-clearance) + 16px);flex:auto}
|
|
324
|
+
.dbs-column{max-width:var(--dsh-chat-content-width);flex-direction:column;gap:16px;width:100%;margin:0 auto;display:flex}
|
|
325
|
+
.dbs-userRow{flex-direction:column;align-items:flex-end;gap:6px;display:flex}
|
|
326
|
+
.dbs-userStack{flex-direction:column;align-items:flex-end;gap:8px;min-width:0;max-width:min(525px,82%);display:flex}
|
|
327
|
+
.dbs-bubble{background:var(--dsw-specific-bubble);max-width:100%;color:var(--dsw-alias-label-primary);border-radius:22px;padding:10px 16px;font-size:16px;line-height:24px}
|
|
328
|
+
.dbs-botRow{color:var(--dsw-alias-label-primary);flex-direction:column;font-size:16px;line-height:28px;display:flex;align-items:flex-start;gap:4px;width:100%}
|
|
329
|
+
.dbs-author{font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);display:flex;align-items:center;gap:6px;font-weight:600;margin-bottom:2px;flex-wrap:wrap}
|
|
330
|
+
.dbs-authorName{font-weight:600;letter-spacing:.2px}
|
|
331
|
+
.dbs-md{min-width:0}
|
|
332
|
+
.dbs-md p{margin:0 0 8px}
|
|
333
|
+
.dbs-md p:last-child{margin-bottom:0}
|
|
334
|
+
.dbs-md h1,.dbs-md h2,.dbs-md h3,.dbs-md h4{margin:12px 0 6px;line-height:1.35;font-weight:600}
|
|
335
|
+
.dbs-md h1{font-size:20px}.dbs-md h2{font-size:18px}.dbs-md h3{font-size:16px}.dbs-md h4{font-size:15px}
|
|
336
|
+
.dbs-md ul,.dbs-md ol{margin:0 0 8px;padding-left:22px}
|
|
337
|
+
.dbs-md li{margin:2px 0}
|
|
338
|
+
.dbs-md code{font-family:var(--dsw-font-family-mono,ui-monospace,monospace);font-size:.9em;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));border-radius:6px;padding:1px 5px}
|
|
339
|
+
.dbs-md pre{margin:0 0 8px;background:var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06));border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.1));border-radius:10px;padding:10px 12px;overflow-x:auto}
|
|
340
|
+
.dbs-md .dbs-imglink{display:inline-block;max-width:min(320px,100%);border-radius:10px;overflow:hidden;margin:4px 0;box-shadow:0 1px 4px rgba(0,0,0,.25);cursor:zoom-in}
|
|
341
|
+
.dbs-md .dbs-imglink img{display:block;width:100%;height:auto}
|
|
342
|
+
.dbs-md pre code{background:transparent;padding:0;font-size:.9em}
|
|
343
|
+
.dbs-md blockquote{margin:0 0 8px;padding:2px 0 2px 10px;border-left:3px solid var(--dsw-alias-border-l2,rgba(0,0,0,.2));color:var(--dsw-alias-label-secondary)}
|
|
344
|
+
.dbs-md a{color:var(--dsw-alias-state-business-primary,#1a6dff)}
|
|
345
|
+
.dbs-md hr{border:none;border-top:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.15));margin:10px 0}
|
|
346
|
+
.dbs-plain{white-space:pre-wrap;word-break:break-word}
|
|
347
|
+
.dbs-toolCard{border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));background:var(--dsw-specific-bubble);border-radius:12px;padding:8px 12px;font-size:13px;line-height:20px;color:var(--dsw-alias-label-secondary);width:100%;box-sizing:border-box}
|
|
348
|
+
.dbs-toolHdr{display:flex;align-items:center;gap:6px;color:var(--dsw-alias-label-primary);font-size:13px;line-height:20px}
|
|
349
|
+
.dbs-toolName{font:var(--dsw-font-markdown-code-block-small,inherit);font-size:13px}
|
|
350
|
+
.dbs-toolBody{margin-top:4px;white-space:pre-wrap;word-break:break-word;max-height:190px;overflow:auto;color:var(--dsw-alias-label-tertiary)}
|
|
351
|
+
.dbs-thinking{color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;white-space:pre-wrap;word-break:break-word;border-left:2px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));padding-left:10px}
|
|
352
|
+
.dbs-msgTime{flex:none;font-size:12px;line-height:16px;color:var(--dsw-alias-label-tertiary);font-variant-numeric:tabular-nums;user-select:none;white-space:nowrap}
|
|
353
|
+
.dbs-userRow .dbs-msgTime,.dbs-botRow .dbs-msgTime{padding:0 4px}
|
|
354
|
+
.dbs-toolTrail{margin-left:auto;flex:none;display:inline-flex;align-items:center;gap:8px}
|
|
355
|
+
.dbs-dayDivider{display:flex;align-items:center;justify-content:center}
|
|
356
|
+
.dbs-dayDivider span{padding:2px 10px;border-radius:999px;background:var(--dsw-specific-bubble);color:var(--dsw-alias-label-tertiary);font-size:12px;line-height:18px;user-select:none}
|
|
357
|
+
.dbs-turnStatus{height:26px;font-size:14px;font-weight:600;white-space:nowrap;background:linear-gradient(90deg,var(--dsw-static-deepseek-500,#4d6bfe) 0%,var(--dsw-static-deepseek-500,#4d6bfe) 40%,var(--dsw-static-deepseek-200,#b6c2ff) 50%,var(--dsw-static-deepseek-500,#4d6bfe) 60%,var(--dsw-static-deepseek-500,#4d6bfe) 100%);color:#0000;-webkit-text-fill-color:transparent;background-position:100% 0;background-size:250% 100%;-webkit-background-clip:text;background-clip:text;flex:none;align-self:flex-start;align-items:center;animation:1.8s linear infinite dbs-turn-status-shimmer;display:inline-flex}
|
|
358
|
+
@keyframes dbs-turn-status-shimmer{to{background-position:0 0}}
|
|
359
|
+
@media (prefers-reduced-motion:reduce){.dbs-turnStatus{background-position:0 0;background-size:100% 100%;animation:none}.dbs-arrow{transition:none}}
|
|
360
|
+
.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}
|
|
362
|
+
.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
|
+
.dbs-composerScroll{max-height:var(--dsh-composer-text-max-height);overflow-y:auto}
|
|
364
|
+
.dbs-composerRow{flex-wrap:wrap;justify-content:space-between;align-items:center;gap:12px;min-width:0;padding:2px 8px 6px;display:flex}
|
|
365
|
+
.dbs-composerTrailing{align-items:center;min-width:0;display:flex;flex:none;gap:8px;margin-left:auto}
|
|
366
|
+
.dbs-composerInput{resize:none;width:100%;box-sizing:border-box;border:none;outline:none;background:transparent;font-family:var(--dsw-font-family);font-size:16px;line-height:24px;white-space:pre-wrap;word-break:break-word;padding:4px 12px 0 16px;min-height:52px;color:var(--dsw-alias-label-primary)}
|
|
367
|
+
.dbs-composerInput::placeholder{color:var(--dsw-alias-label-caption);user-select:none}
|
|
368
|
+
.dbs-send{background:var(--dsw-alias-button-info-fill,#1a6dff);color:#fff;cursor:pointer;border:none;border-radius:999px;flex:none;place-items:center;width:34px;height:34px;transition:background-color .1s;display:grid;transform:translateY(-2px)}
|
|
369
|
+
.dbs-send:disabled{opacity:.4;cursor:default}
|
|
370
|
+
.dbs-modalBackdrop{position:fixed;inset:0;z-index:1000;background:rgba(0,0,0,.45);display:flex;align-items:center;justify-content:center;padding:24px;pointer-events:auto}
|
|
371
|
+
.dbs-modalCard{position:relative;width:min(440px,92vw);max-height:88vh;overflow-y:auto;background:var(--dsw-alias-bg-layer-1);border:1px solid var(--dsw-alias-border-l2);border-radius:14px;padding:18px 20px 22px;box-shadow:0 24px 80px rgba(0,0,0,.3);animation:dbs-modal-in .18s ease-out}
|
|
372
|
+
@keyframes dbs-modal-in{from{opacity:0;transform:translateY(6px) scale(.985)}to{opacity:1;transform:none}}
|
|
373
|
+
.dbs-modalTitleRow{display:flex;align-items:center;gap:8px;margin-bottom:14px}
|
|
374
|
+
.dbs-modalTitle{font-size:16px;line-height:24px;font-weight:600;color:var(--dsw-alias-label-primary);flex:1;min-width:0}
|
|
375
|
+
.dbs-modalBody{display:flex;flex-direction:column;gap:10px}
|
|
376
|
+
.dbs-modalMembers{display:flex;flex-direction:column;gap:2px;max-height:220px;overflow-y:auto;border:1px solid var(--dsw-alias-border-l2);border-radius:10px;padding:6px}
|
|
377
|
+
.dbs-modalFooter{display:flex;justify-content:flex-end;align-items:center;gap:8px;margin-top:16px}
|
|
378
|
+
.dbs-rowDel{display:none;border:none;background:transparent;cursor:pointer;color:var(--dsw-alias-label-tertiary);flex:none;justify-content:center;align-items:center;width:24px;height:24px;border-radius:6px;padding:0}
|
|
379
|
+
.dbs-rowDel:hover{color:#f85149;background:rgba(248,81,73,.1)}
|
|
380
|
+
.dbs-srow:hover .dbs-rowDel,.dbs-rowDel:focus-visible{display:inline-flex}
|
|
381
|
+
.dbs-mention{position:absolute;bottom:calc(100% + 6px);left:12px;right:12px;max-height:180px;overflow-y:auto;background:var(--dsw-specific-input-major);border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));border-radius:12px;box-shadow:var(--dsw-shadow-lv2);padding:4px;z-index:3}
|
|
382
|
+
.dbs-mentionRow{display:flex;align-items:center;gap:8px;padding:6px 8px;border-radius:8px;cursor:pointer;font-size:13px;line-height:20px;color:var(--dsw-alias-label-primary)}
|
|
383
|
+
.dbs-mentionRow[data-active="true"],.dbs-mentionRow:hover{background:var(--dsw-alias-interactive-bg-hover)}
|
|
384
|
+
.dbs-empty{color:var(--dsw-alias-label-tertiary);font-size:14px;line-height:22px;text-align:center;padding:32px 0}
|
|
385
|
+
.dbs-scrollArea{position:relative;flex:1;min-height:0;display:flex;flex-direction:column}
|
|
386
|
+
.dbs-scrollBody::-webkit-scrollbar{width:10px}
|
|
387
|
+
.dbs-scrollBody::-webkit-scrollbar-thumb{background:var(--dsw-alias-border-l2,rgba(0,0,0,.18));border-radius:5px;border:3px solid transparent;background-clip:content-box}
|
|
388
|
+
.dbs-scrollBody::-webkit-scrollbar-thumb:hover{background:var(--dsw-alias-label-caption,var(--dsw-alias-border-l2,rgba(0,0,0,.25)));border:3px solid transparent;background-clip:content-box}
|
|
389
|
+
.dbs-scrollBody::-webkit-scrollbar-track{background:transparent}
|
|
390
|
+
.dbs-jump{position:absolute;right:20px;bottom:12px;display:flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:999px;border:1px solid var(--dsw-alias-border-l2,rgba(0,0,0,.12));background:var(--dsw-specific-input-major,var(--dsw-alias-bg-layer-1,#fff));box-shadow:var(--dsw-shadow-lv2);color:var(--dsw-alias-label-secondary);cursor:pointer;z-index:5;transition:opacity .15s ease,transform .15s ease}
|
|
391
|
+
.dbs-jump:hover{color:var(--dsw-alias-label-primary);transform:translateY(-1px)}
|
|
392
|
+
.dbs-jump[data-show="false"]{opacity:0;pointer-events:none;transform:translateY(4px)}
|
|
393
|
+
.dbs-typing{display:inline-flex;align-items:center;gap:4px;padding:6px 2px}
|
|
394
|
+
.dbs-typing span{width:6px;height:6px;border-radius:999px;background:var(--dsw-alias-label-tertiary);animation:dbsTyping 1.2s ease-in-out infinite}
|
|
395
|
+
.dbs-typing span:nth-child(2){animation-delay:.15s}
|
|
396
|
+
.dbs-typing span:nth-child(3){animation-delay:.3s}
|
|
397
|
+
@keyframes dbsTyping{0%,60%,100%{opacity:.25;transform:translateY(0)}30%{opacity:1;transform:translateY(-3px)}}
|
|
398
|
+
.dbs-mdRow{display:flex;align-items:flex-start;gap:0;min-width:0;width:100%}
|
|
399
|
+
.dbs-caret{flex:none;display:inline-block;width:3px;height:18px;margin-top:5px;border-radius:2px;background:var(--dsw-alias-label-primary);animation:dbsCaret 1s steps(2) infinite}
|
|
400
|
+
@keyframes dbsCaret{0%,49%{opacity:1}50%,100%{opacity:0}}
|
|
401
|
+
.dbs-botRow[data-compact="true"]{padding-left:26px}
|
|
402
|
+
.dbs-compactTime{display:flex;justify-content:flex-end;width:100%}
|
|
403
|
+
.dbs-compactTime .dbs-msgTime{padding:0}
|
|
404
|
+
.dbs-welcome{display:flex;flex-direction:column;align-items:center;justify-content:center;text-align:center;gap:6px;padding:64px 24px 32px;min-height:50%}
|
|
405
|
+
.dbs-welcomeName{font-size:18px;line-height:26px;font-weight:600;color:var(--dsw-alias-label-primary)}
|
|
406
|
+
.dbs-welcomeDesc{font-size:14px;line-height:22px;color:var(--dsw-alias-label-secondary);max-width:420px}
|
|
407
|
+
.dbs-welcomeHint{font-size:13px;line-height:20px;color:var(--dsw-alias-label-tertiary);margin-top:12px}
|
|
408
|
+
.dbs-skeleton{display:flex;flex-direction:column;gap:18px;padding:24px 0;max-width:560px}
|
|
409
|
+
.dbs-skelRow{height:16px;border-radius:8px;background:linear-gradient(90deg,var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)) 25%,var(--dsw-alias-border-l1,rgba(0,0,0,.08)) 50%,var(--dsw-alias-interactive-bg-hover,rgba(0,0,0,.06)) 75%);background-size:200% 100%;animation:dbsShimmer 1.4s ease-in-out infinite}
|
|
410
|
+
@keyframes dbsShimmer{0%{background-position:200% 0}100%{background-position:-200% 0}}
|
|
411
|
+
.dbs-composerCard:focus-within{border-color:var(--dsw-alias-state-business-primary,#1a6dff)}
|
|
412
|
+
|
|
413
|
+
.dbs-settings{padding:4px 0 24px;max-width:640px;font-family:var(--dsw-font-family,inherit)}
|
|
414
|
+
.dbs-setcard{border:1px solid var(--dsw-alias-border-l,rgba(0,0,0,.12));border-radius:12px;padding:14px 16px;margin-top:14px}
|
|
415
|
+
.dbs-setrow{display:flex;align-items:center;gap:8px;font-size:13px;line-height:22px;color:var(--dsw-alias-label-secondary);padding:3px 0}
|
|
416
|
+
.dbs-setrow b{color:var(--dsw-alias-label-primary);font-weight:600;word-break:break-all}
|
|
417
|
+
.dbs-sethead{display:flex;align-items:center;gap:8px;font-size:14px;line-height:22px;color:var(--dsw-alias-label-primary);font-weight:600}
|
|
418
|
+
`;
|
|
419
|
+
// =========================================================
|
|
420
|
+
// Locale.
|
|
421
|
+
//
|
|
422
|
+
// Registered into the shell's own locale runtime under the `bots`
|
|
423
|
+
// namespace, so language follows the app-wide preference instead of
|
|
424
|
+
// stranding this plugin in one language. `zh` is the key-set source of
|
|
425
|
+
// truth; `en` mirrors it exactly. Wire error strings from the gateway
|
|
426
|
+
// pass through untranslated, matching how the shipped packages treat
|
|
427
|
+
// runtime failure text.
|
|
428
|
+
// =========================================================
|
|
429
|
+
const NS = 'bots';
|
|
430
|
+
const zh = {
|
|
431
|
+
'nav.workspaces': '工作区',
|
|
432
|
+
'nav.bots': 'Bots',
|
|
433
|
+
'nav.bots.aria': 'Bots',
|
|
434
|
+
'nav.bots.unread': 'Bots({n} 条未读)',
|
|
435
|
+
'delegate.loading': '加载中…',
|
|
436
|
+
'delegate.unavailable': '工作区视图不可用,请刷新页面。',
|
|
437
|
+
'delegate.failed': '工作区视图加载失败,Bots 不受影响。',
|
|
438
|
+
'gateway.online': '网关在线 :{port}',
|
|
439
|
+
'gateway.offline': '网关未连接',
|
|
440
|
+
'gateway.offlineHint': '网关未连接,详见设置页。',
|
|
441
|
+
'bot.new': '新建 Bot',
|
|
442
|
+
'group.new': '新建群聊',
|
|
443
|
+
'bot.namePlaceholder': 'Bot 名称',
|
|
444
|
+
'group.namePlaceholder': '群聊名称',
|
|
445
|
+
'bot.descPlaceholder': '简介 / 人设(可选)',
|
|
446
|
+
'action.create': '创建',
|
|
447
|
+
'action.cancel': '取消',
|
|
448
|
+
'action.close': '关闭',
|
|
449
|
+
'action.save': '保存',
|
|
450
|
+
'action.saving': '保存中…',
|
|
451
|
+
'action.stop': '停止生成',
|
|
452
|
+
'chat.stop.noop': '当前没有进行中的生成',
|
|
453
|
+
'chat.members.manage': '管理成员',
|
|
454
|
+
'modal.members.title': '管理群成员',
|
|
455
|
+
'modal.members.hint': '勾选的 Bot 为群成员;保存后立即生效(可随时再改)。',
|
|
456
|
+
'action.delete': '删除',
|
|
457
|
+
'delete.title.bot': '删除 Bot',
|
|
458
|
+
'delete.title.group': '删除群聊',
|
|
459
|
+
'delete.confirm': '将删除「{name}」及其全部会话记录,此操作不可恢复。',
|
|
460
|
+
'delete.working': '删除中…',
|
|
461
|
+
'action.send': '发送',
|
|
462
|
+
'action.refresh': '刷新',
|
|
463
|
+
'action.expand': '展开',
|
|
464
|
+
'action.collapse': '收起',
|
|
465
|
+
'list.loading': '加载中…',
|
|
466
|
+
'list.empty': '还没有 Bot,点 + 新建。',
|
|
467
|
+
'section.groups': '群聊',
|
|
468
|
+
'section.singles': '单聊',
|
|
469
|
+
'chat.group': '群聊 · {n} 名成员',
|
|
470
|
+
'chat.single': '单聊',
|
|
471
|
+
'chat.loading': '加载中…',
|
|
472
|
+
'chat.empty.single': '给 {name} 发第一条消息,开始你们的对话',
|
|
473
|
+
'chat.empty.group': '在群里说点什么,成员们会接龙回复',
|
|
474
|
+
'chat.jump': '回到底部',
|
|
475
|
+
'chat.composing': '生成中',
|
|
476
|
+
'chat.composingHint': '正在生成,请稍候',
|
|
477
|
+
'chat.placeholder.group': '@名字 可定向,默认全员',
|
|
478
|
+
'chat.placeholder.single': '给 {name} 发消息…',
|
|
479
|
+
'chat.charCount': '{n} 字',
|
|
480
|
+
'time.today': '今天',
|
|
481
|
+
'time.yesterday': '昨天',
|
|
482
|
+
'time.date': '{m} 月 {d} 日',
|
|
483
|
+
'time.dateFull': '{y} 年 {m} 月 {d} 日',
|
|
484
|
+
'tool.fallbackName': '工具',
|
|
485
|
+
'settings.summary': '多 Bot 工作台,桥接 sdk-bots 编排网关。',
|
|
486
|
+
'settings.probing': '检测中…',
|
|
487
|
+
'settings.address': '地址:',
|
|
488
|
+
'settings.pid': 'PID:',
|
|
489
|
+
'settings.busy': '忙',
|
|
490
|
+
'settings.idle': '闲',
|
|
491
|
+
'settings.auth': '鉴权:',
|
|
492
|
+
'settings.auth.token': 'token(自动携带)',
|
|
493
|
+
'settings.auth.none': '无(loopback 免鉴权)',
|
|
494
|
+
'settings.reason': '原因:',
|
|
495
|
+
'settings.reason.unknown': '未知',
|
|
496
|
+
'settings.dataDir': '数据目录:',
|
|
497
|
+
'settings.reading': '读取中…',
|
|
498
|
+
'settings.events': '实时事件:',
|
|
499
|
+
'settings.events.on': '已连接 · 缓冲 {n} 条',
|
|
500
|
+
'settings.events.off': '未连接',
|
|
501
|
+
'settings.entry': '入口:',
|
|
502
|
+
'settings.entry.value': '左侧边栏「工作区 | Bots」折叠导航',
|
|
503
|
+
'settings.workspaceRoot': '工作区根目录:',
|
|
504
|
+
'settings.jail.title': 'Bot 工作区隔离',
|
|
505
|
+
'settings.jail.summary': '开启后,该 Bot 的每条 Shell 命令被 macOS Seatbelt 包裹:只允许写入自己的工作目录(工作区根/<名字>)与系统临时目录,越界写入被内核拒绝;读取不受限(共享黑板仍可读)。下次对话生效,无需重启。注意:同一名字的多个 Bot 会共享同一目录。',
|
|
506
|
+
'settings.jail.count': '已隔离:',
|
|
507
|
+
'settings.jail.on': '开启隔离',
|
|
508
|
+
'settings.jail.off': '解除',
|
|
509
|
+
'settings.jail.offConfirm': '确认解除?',
|
|
510
|
+
'settings.mcp': 'MCP 服务器',
|
|
511
|
+
'settings.mcp.summary': 'Bot 的工具扩展总线:服务器由 sdk-bots 引擎托管,所有 Bot 共享。',
|
|
512
|
+
'settings.mcp.empty': '未安装任何 MCP 服务器',
|
|
513
|
+
'settings.mcp.tools': '可用工具:',
|
|
514
|
+
'settings.mcp.add': '添加服务器',
|
|
515
|
+
'settings.mcp.adding': '添加中…',
|
|
516
|
+
'settings.mcp.namePlaceholder': '名称,例如 github',
|
|
517
|
+
'settings.mcp.configPlaceholder': '{"command": "npx", "args": ["-y", "server包"]} 或 {"url": "https://…/mcp"}',
|
|
518
|
+
'settings.mcp.exampleStdio': '填入本地 stdio 示例',
|
|
519
|
+
'settings.mcp.exampleUrl': '填入远程 URL 示例',
|
|
520
|
+
'settings.mcp.exampleStdioValue': '{"command": "node", "args": ["/path/to/server.mjs"]}',
|
|
521
|
+
'settings.mcp.exampleUrlValue': '{"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer <token>"}}',
|
|
522
|
+
'settings.mcp.remove': '删除',
|
|
523
|
+
'settings.mcp.removeConfirm': '确认删除?',
|
|
524
|
+
'settings.mcp.restart': '重启连接',
|
|
525
|
+
'settings.mcp.restarting': '重启中…',
|
|
526
|
+
'settings.mcp.failed': '操作失败:',
|
|
527
|
+
'error.noConnection': '连接服务尚未就绪',
|
|
528
|
+
'error.callFailed': '调用失败',
|
|
529
|
+
'error.badResponse': '意外的 RPC 响应',
|
|
530
|
+
};
|
|
531
|
+
const en = {
|
|
532
|
+
'nav.workspaces': 'Workspaces',
|
|
533
|
+
'nav.bots': 'Bots',
|
|
534
|
+
'nav.bots.aria': 'Bots',
|
|
535
|
+
'nav.bots.unread': 'Bots ({n} unread)',
|
|
536
|
+
'delegate.loading': 'Loading…',
|
|
537
|
+
'delegate.unavailable': 'Workspace view unavailable — reload the page.',
|
|
538
|
+
'delegate.failed': 'Workspace view failed to render; Bots is unaffected.',
|
|
539
|
+
'gateway.online': 'Gateway online :{port}',
|
|
540
|
+
'gateway.offline': 'Gateway not connected',
|
|
541
|
+
'gateway.offlineHint': 'Gateway not connected — see the settings page.',
|
|
542
|
+
'bot.new': 'New bot',
|
|
543
|
+
'group.new': 'New group chat',
|
|
544
|
+
'bot.namePlaceholder': 'Bot name',
|
|
545
|
+
'group.namePlaceholder': 'Group name',
|
|
546
|
+
'bot.descPlaceholder': 'Description / persona (optional)',
|
|
547
|
+
'action.create': 'Create',
|
|
548
|
+
'action.cancel': 'Cancel',
|
|
549
|
+
'action.close': 'Close',
|
|
550
|
+
'action.save': 'Save',
|
|
551
|
+
'action.saving': 'Saving…',
|
|
552
|
+
'action.stop': 'Stop generating',
|
|
553
|
+
'chat.stop.noop': 'No generation in progress',
|
|
554
|
+
'chat.members.manage': 'Manage members',
|
|
555
|
+
'modal.members.title': 'Manage group members',
|
|
556
|
+
'modal.members.hint': 'Checked bots are members; changes apply immediately on save (editable again anytime).',
|
|
557
|
+
'action.delete': 'Delete',
|
|
558
|
+
'delete.title.bot': 'Delete bot',
|
|
559
|
+
'delete.title.group': 'Delete group chat',
|
|
560
|
+
'delete.confirm': 'This permanently deletes "{name}" and its transcript.',
|
|
561
|
+
'delete.working': 'Deleting…',
|
|
562
|
+
'action.send': 'Send',
|
|
563
|
+
'action.refresh': 'Refresh',
|
|
564
|
+
'action.expand': 'Expand',
|
|
565
|
+
'action.collapse': 'Collapse',
|
|
566
|
+
'list.loading': 'Loading…',
|
|
567
|
+
'list.empty': 'No bots yet — use + to create one.',
|
|
568
|
+
'section.groups': 'Groups',
|
|
569
|
+
'section.singles': 'Direct',
|
|
570
|
+
'chat.group': 'Group · {n} members',
|
|
571
|
+
'chat.single': 'Direct',
|
|
572
|
+
'chat.loading': 'Loading…',
|
|
573
|
+
'chat.empty.single': 'Send the first message to {name} and start the conversation.',
|
|
574
|
+
'chat.empty.group': 'Say something in the room — members will pick it up.',
|
|
575
|
+
'chat.jump': 'Jump to latest',
|
|
576
|
+
'chat.composing': 'Generating',
|
|
577
|
+
'chat.composingHint': 'Generating, please wait',
|
|
578
|
+
'chat.placeholder.group': 'Use @name to direct a turn; everyone by default',
|
|
579
|
+
'chat.placeholder.single': 'Message {name}…',
|
|
580
|
+
'chat.charCount': '{n} chars',
|
|
581
|
+
'time.today': 'Today',
|
|
582
|
+
'time.yesterday': 'Yesterday',
|
|
583
|
+
'time.date': '{m}/{d}',
|
|
584
|
+
'time.dateFull': '{y}/{m}/{d}',
|
|
585
|
+
'tool.fallbackName': 'Tool',
|
|
586
|
+
'settings.summary': 'Multi-bot workbench, bridged to the sdk-bots orchestration gateway.',
|
|
587
|
+
'settings.probing': 'Probing…',
|
|
588
|
+
'settings.address': 'Address: ',
|
|
589
|
+
'settings.pid': 'PID: ',
|
|
590
|
+
'settings.busy': 'busy',
|
|
591
|
+
'settings.idle': 'idle',
|
|
592
|
+
'settings.auth': 'Auth: ',
|
|
593
|
+
'settings.auth.token': 'token (sent automatically)',
|
|
594
|
+
'settings.auth.none': 'none (loopback, unauthenticated)',
|
|
595
|
+
'settings.reason': 'Reason: ',
|
|
596
|
+
'settings.reason.unknown': 'unknown',
|
|
597
|
+
'settings.dataDir': 'Data directory: ',
|
|
598
|
+
'settings.reading': 'reading…',
|
|
599
|
+
'settings.events': 'Live events: ',
|
|
600
|
+
'settings.events.on': 'connected · {n} buffered',
|
|
601
|
+
'settings.events.off': 'not connected',
|
|
602
|
+
'settings.entry': 'Entry point: ',
|
|
603
|
+
'settings.entry.value': 'Sidebar “Workspaces | Bots” collapsible nav',
|
|
604
|
+
'settings.workspaceRoot': 'Workspace root: ',
|
|
605
|
+
'settings.jail.title': 'Bot workspace jail',
|
|
606
|
+
'settings.jail.summary': 'When enabled, every shell command of that bot is wrapped in a macOS Seatbelt profile: writes are confined to its own workspace directory (workspace root /<name>) and the OS temp dir — out-of-bounds writes are denied by the kernel. Reads stay unrestricted (shared blackboards remain readable). Takes effect on the bot’s next turn, no restart. Note: bots sharing a name share one directory.',
|
|
607
|
+
'settings.jail.count': 'Jailed: ',
|
|
608
|
+
'settings.jail.on': 'Enable jail',
|
|
609
|
+
'settings.jail.off': 'Remove',
|
|
610
|
+
'settings.jail.offConfirm': 'Confirm removal?',
|
|
611
|
+
'settings.mcp': 'MCP servers',
|
|
612
|
+
'settings.mcp.summary': 'The tool-expansion bus for bots: servers are hosted by the sdk-bots engine and shared by every bot.',
|
|
613
|
+
'settings.mcp.empty': 'No MCP servers installed',
|
|
614
|
+
'settings.mcp.tools': 'Available tools: ',
|
|
615
|
+
'settings.mcp.add': 'Add server',
|
|
616
|
+
'settings.mcp.adding': 'Adding…',
|
|
617
|
+
'settings.mcp.namePlaceholder': 'Name, e.g. github',
|
|
618
|
+
'settings.mcp.configPlaceholder': '{"command": "npx", "args": ["-y", "package"]} or {"url": "https://…/mcp"}',
|
|
619
|
+
'settings.mcp.exampleStdio': 'Fill stdio example',
|
|
620
|
+
'settings.mcp.exampleUrl': 'Fill URL example',
|
|
621
|
+
'settings.mcp.exampleStdioValue': '{"command": "node", "args": ["/path/to/server.mjs"]}',
|
|
622
|
+
'settings.mcp.exampleUrlValue': '{"url": "https://example.com/mcp", "headers": {"Authorization": "Bearer <token>"}}',
|
|
623
|
+
'settings.mcp.remove': 'Remove',
|
|
624
|
+
'settings.mcp.removeConfirm': 'Confirm removal?',
|
|
625
|
+
'settings.mcp.restart': 'Restart connections',
|
|
626
|
+
'settings.mcp.restarting': 'Restarting…',
|
|
627
|
+
'settings.mcp.failed': 'Operation failed: ',
|
|
628
|
+
'error.noConnection': 'Connection service is not ready yet',
|
|
629
|
+
'error.callFailed': 'Call failed',
|
|
630
|
+
'error.badResponse': 'Unexpected RPC response',
|
|
631
|
+
};
|
|
632
|
+
/**
|
|
633
|
+
* Namespace-bound translator.
|
|
634
|
+
*
|
|
635
|
+
* `locale.bind` returns a stable function that resolves against whatever
|
|
636
|
+
* locale is active at call time, so this can live at module scope; a
|
|
637
|
+
* switch re-renders through the subscription installed in `apply`.
|
|
638
|
+
* Falls back to the zh dictionary (then the key) if the locale service
|
|
639
|
+
* is unavailable, so no surface ever renders a bare key.
|
|
640
|
+
*/
|
|
641
|
+
let boundT = null;
|
|
642
|
+
function t(key, params) {
|
|
643
|
+
if (boundT !== null)
|
|
644
|
+
return boundT(key, params);
|
|
645
|
+
const template = zh[key] ?? key;
|
|
646
|
+
if (params === undefined)
|
|
647
|
+
return template;
|
|
648
|
+
return template.replace(/\{(\w+)\}/g, (m, name) => (name in params ? String(params[name]) : m));
|
|
649
|
+
}
|
|
650
|
+
// =========================================================
|
|
651
|
+
// Module-level services and state.
|
|
652
|
+
// =========================================================
|
|
653
|
+
let ctx = null;
|
|
654
|
+
let connectionSvc = null;
|
|
655
|
+
let slotsSvc = null;
|
|
656
|
+
/** Cadence for draining the host's in-memory SSE ring (no gateway hop). */
|
|
657
|
+
const RING_DRAIN_MS = 1000;
|
|
658
|
+
async function botsCall(method, request) {
|
|
659
|
+
if (connectionSvc === null)
|
|
660
|
+
throw new Error(t('error.noConnection'));
|
|
661
|
+
const envelope = await connectionSvc.rpc.call('/api', 'bots/' + method, {
|
|
662
|
+
args: { request: request === undefined ? null : request },
|
|
663
|
+
});
|
|
664
|
+
if (envelope !== null && typeof envelope === 'object' && envelope.ok === false) {
|
|
665
|
+
throw new Error(envelope.error?.message ?? t('error.callFailed'));
|
|
666
|
+
}
|
|
667
|
+
if (envelope !== null && typeof envelope === 'object' && envelope.ok === true)
|
|
668
|
+
return envelope.value;
|
|
669
|
+
throw new Error(t('error.badResponse'));
|
|
670
|
+
}
|
|
671
|
+
/**
|
|
672
|
+
* The single reader of the host's event ring.
|
|
673
|
+
*
|
|
674
|
+
* The cursor lives here and nowhere else. Components subscribe and get
|
|
675
|
+
* told which channels moved; they never call `eventsSince` themselves,
|
|
676
|
+
* so two consumers can no longer race for the same increment (the old
|
|
677
|
+
* design had a module-global cursor that whoever polled first would
|
|
678
|
+
* advance, silently starving everyone else).
|
|
679
|
+
*/
|
|
680
|
+
const ring = (() => {
|
|
681
|
+
let seq = -1;
|
|
682
|
+
let timer = null;
|
|
683
|
+
let live = null;
|
|
684
|
+
/** Latest host-side unread snapshot (see `unread.ts` on the host). */
|
|
685
|
+
let unread = null;
|
|
686
|
+
const subs = new Set();
|
|
687
|
+
async function drain() {
|
|
688
|
+
let r;
|
|
689
|
+
try {
|
|
690
|
+
r = await botsCall('eventsSince', { seq });
|
|
691
|
+
}
|
|
692
|
+
catch {
|
|
693
|
+
return; /* gateway offline; the next tick retries */
|
|
694
|
+
}
|
|
695
|
+
if (r === null || typeof r !== 'object')
|
|
696
|
+
return;
|
|
697
|
+
if (Number.isFinite(r.nextSeq))
|
|
698
|
+
seq = Number(r.nextSeq);
|
|
699
|
+
if (r.state !== undefined)
|
|
700
|
+
live = r.state;
|
|
701
|
+
if (r.unread !== null && typeof r.unread === 'object')
|
|
702
|
+
unread = r.unread;
|
|
703
|
+
const events = Array.isArray(r.events) ? r.events : [];
|
|
704
|
+
if (events.length === 0)
|
|
705
|
+
return;
|
|
706
|
+
const channels = new Set();
|
|
707
|
+
for (const ev of events)
|
|
708
|
+
if (ev !== null && typeof ev?.channel === 'string')
|
|
709
|
+
channels.add(ev.channel);
|
|
710
|
+
for (const fn of [...subs]) {
|
|
711
|
+
try {
|
|
712
|
+
fn(channels, events);
|
|
713
|
+
}
|
|
714
|
+
catch { /* one bad subscriber must not stop the rest */ }
|
|
715
|
+
}
|
|
716
|
+
}
|
|
717
|
+
return {
|
|
718
|
+
subscribe(fn) {
|
|
719
|
+
subs.add(fn);
|
|
720
|
+
if (timer === null) {
|
|
721
|
+
timer = setInterval(() => { void drain(); }, RING_DRAIN_MS);
|
|
722
|
+
void drain();
|
|
723
|
+
}
|
|
724
|
+
return () => {
|
|
725
|
+
subs.delete(fn);
|
|
726
|
+
if (subs.size === 0 && timer !== null) {
|
|
727
|
+
clearInterval(timer);
|
|
728
|
+
timer = null;
|
|
729
|
+
}
|
|
730
|
+
};
|
|
731
|
+
},
|
|
732
|
+
state: () => live,
|
|
733
|
+
unread: () => unread,
|
|
734
|
+
};
|
|
735
|
+
})();
|
|
736
|
+
/** Shared UI state; every mutation goes through `patch`. */
|
|
737
|
+
const state = {
|
|
738
|
+
agents: [],
|
|
739
|
+
agentsLoaded: false,
|
|
740
|
+
info: undefined,
|
|
741
|
+
error: null,
|
|
742
|
+
chatAgentId: null,
|
|
743
|
+
open: { workspaces: true, bots: true },
|
|
744
|
+
/** Host-computed unread counts per agent id (plugin-owned model). */
|
|
745
|
+
unreadCounts: {},
|
|
746
|
+
/** Create dialog: 'bot' | 'group' | null — rendered as a system-style
|
|
747
|
+
* modal from shell.overlay, so the form state lives in the store. */
|
|
748
|
+
create: null,
|
|
749
|
+
createName: '',
|
|
750
|
+
createDesc: '',
|
|
751
|
+
createMembers: {},
|
|
752
|
+
createWorking: false,
|
|
753
|
+
/** Delete confirmation: { id, name, isGroup } | null (system modal). */
|
|
754
|
+
confirmDelete: null,
|
|
755
|
+
/** Manage-members dialog: group agentId | null (system modal). */
|
|
756
|
+
manageMembers: null,
|
|
757
|
+
/** Bumped on a language switch so module-scope `t` output re-renders. */
|
|
758
|
+
localeRev: 0,
|
|
759
|
+
};
|
|
760
|
+
const stateSubs = new Set();
|
|
761
|
+
function patch(next) {
|
|
762
|
+
Object.assign(state, next);
|
|
763
|
+
for (const fn of [...stateSubs])
|
|
764
|
+
fn();
|
|
765
|
+
}
|
|
766
|
+
function useStore() {
|
|
767
|
+
const [, force] = React.useState(0);
|
|
768
|
+
React.useEffect(() => {
|
|
769
|
+
const fn = () => force((n) => n + 1);
|
|
770
|
+
stateSubs.add(fn);
|
|
771
|
+
return () => { stateSubs.delete(fn); };
|
|
772
|
+
}, []);
|
|
773
|
+
return state;
|
|
774
|
+
}
|
|
775
|
+
async function refreshAgents() {
|
|
776
|
+
try {
|
|
777
|
+
const list = await botsCall('list');
|
|
778
|
+
patch({ agents: Array.isArray(list) ? list : [], agentsLoaded: true, error: null });
|
|
779
|
+
}
|
|
780
|
+
catch (err) {
|
|
781
|
+
patch({ agentsLoaded: true, error: String(err?.message ?? err) });
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
async function refreshInfo() {
|
|
785
|
+
try {
|
|
786
|
+
patch({ info: await botsCall('gatewayInfo', {}) });
|
|
787
|
+
}
|
|
788
|
+
catch { /* keep the last good reading */ }
|
|
789
|
+
}
|
|
790
|
+
/** Pull the latest host unread snapshot into the store. */
|
|
791
|
+
function syncUnread() {
|
|
792
|
+
const u = ring.unread();
|
|
793
|
+
if (u !== null)
|
|
794
|
+
patch({ unreadCounts: u });
|
|
795
|
+
}
|
|
796
|
+
function openChat(id) {
|
|
797
|
+
patch({ chatAgentId: id, unreadCounts: { ...state.unreadCounts, [id]: 0 } });
|
|
798
|
+
// Clear the badge at the source; the gateway owns unread state.
|
|
799
|
+
void botsCall('markRead', { id, atMs: Date.now() }).then(refreshAgents).catch(() => { });
|
|
800
|
+
}
|
|
801
|
+
function agentById(id) {
|
|
802
|
+
if (id === null)
|
|
803
|
+
return null;
|
|
804
|
+
return state.agents.find((a) => a.id === id) ?? null;
|
|
805
|
+
}
|
|
806
|
+
// =========================================================
|
|
807
|
+
// Delegation of the shipped workspace browser.
|
|
808
|
+
// =========================================================
|
|
809
|
+
/**
|
|
810
|
+
* `useSyncExternalStore` selector bound to one host observable, cached
|
|
811
|
+
* per source. The cache is not an optimization: a fresh hook identity on
|
|
812
|
+
* every render makes React tear down and re-create the child's
|
|
813
|
+
* subscription each pass, which is what the shell's own renderer avoids
|
|
814
|
+
* by caching the same way.
|
|
815
|
+
*/
|
|
816
|
+
const warned = new Set();
|
|
817
|
+
function warnOnce(message) {
|
|
818
|
+
if (warned.has(message))
|
|
819
|
+
return;
|
|
820
|
+
warned.add(message);
|
|
821
|
+
// eslint-disable-next-line no-console
|
|
822
|
+
console.warn('[dsh-bots] ' + message);
|
|
823
|
+
reportDiag('warn', { message });
|
|
824
|
+
}
|
|
825
|
+
/**
|
|
826
|
+
* Record a delegation milestone to the host's diagnostics file.
|
|
827
|
+
*
|
|
828
|
+
* Deduplicated by stage+detail, because these fire from render paths.
|
|
829
|
+
* Failure to record is never surfaced: diagnostics must not be able to
|
|
830
|
+
* break the thing they observe.
|
|
831
|
+
*/
|
|
832
|
+
const diagSeen = new Set();
|
|
833
|
+
function reportDiag(stage, detail) {
|
|
834
|
+
const key = stage + ':' + JSON.stringify(detail ?? null);
|
|
835
|
+
if (diagSeen.has(key))
|
|
836
|
+
return;
|
|
837
|
+
diagSeen.add(key);
|
|
838
|
+
void botsCall('diag', { stage, detail: detail ?? null }).catch(() => { });
|
|
839
|
+
}
|
|
840
|
+
const hookCache = new WeakMap();
|
|
841
|
+
function observableHook(source) {
|
|
842
|
+
if (source === null || typeof source !== 'object')
|
|
843
|
+
return undefined;
|
|
844
|
+
const hit = hookCache.get(source);
|
|
845
|
+
if (hit !== undefined)
|
|
846
|
+
return hit;
|
|
847
|
+
const subscribe = (fn) => source.subscribe(fn);
|
|
848
|
+
const hook = function useSelector(sel) {
|
|
849
|
+
return React.useSyncExternalStore(subscribe, () => (sel === undefined ? source.getSnapshot() : sel(source.getSnapshot())));
|
|
850
|
+
};
|
|
851
|
+
hookCache.set(source, hook);
|
|
852
|
+
return hook;
|
|
853
|
+
}
|
|
854
|
+
const Boundary = class extends React.Component {
|
|
855
|
+
constructor(props) { super(props); this.state = { failed: false }; }
|
|
856
|
+
static getDerivedStateFromError() { return { failed: true }; }
|
|
857
|
+
componentDidCatch(error) {
|
|
858
|
+
// eslint-disable-next-line no-console
|
|
859
|
+
console.error('[dsh-bots] delegated slot entry failed:', error);
|
|
860
|
+
reportDiag('delegate-crashed', { message: String(error?.message ?? error) });
|
|
861
|
+
}
|
|
862
|
+
render() { return this.state.failed ? this.props.fallback : this.props.children; }
|
|
863
|
+
};
|
|
864
|
+
/** Live entries of a slot that are not ours, most-specific first. */
|
|
865
|
+
function foreignEntries(key) {
|
|
866
|
+
if (slotsSvc === null)
|
|
867
|
+
return [];
|
|
868
|
+
let list = [];
|
|
869
|
+
try {
|
|
870
|
+
list = slotsSvc.entries(key) ?? [];
|
|
871
|
+
}
|
|
872
|
+
catch {
|
|
873
|
+
return [];
|
|
874
|
+
}
|
|
875
|
+
return list.filter((en) => en !== null && en.component !== undefined && en.registrant !== 'dsh-bots');
|
|
876
|
+
}
|
|
877
|
+
/**
|
|
878
|
+
* Render a shadowed entry the way the shell would.
|
|
879
|
+
*
|
|
880
|
+
* The shell's renderer assembles a "standard kit" before handing props
|
|
881
|
+
* to a slot component: global hooks, the entry's store and actions, its
|
|
882
|
+
* locale seat, and — when the registration declares `children` — a bound
|
|
883
|
+
* `renderSlot` so the component can fill its own holes. Taking over a
|
|
884
|
+
* single slot means taking over that assembly too. The previous version
|
|
885
|
+
* stubbed `renderSlot` to `() => null`, which silently blanked the
|
|
886
|
+
* workspace browser's directory-picker flow; here it recurses, so a
|
|
887
|
+
* delegated entry's children render exactly as they would natively.
|
|
888
|
+
*/
|
|
889
|
+
function synthesizeProps(entry, ownerProps) {
|
|
890
|
+
const props = {};
|
|
891
|
+
if (slotsSvc === null)
|
|
892
|
+
return { ...props, ...ownerProps };
|
|
893
|
+
const host = slotsSvc.hostFace();
|
|
894
|
+
if (host.sessions !== undefined && host.workspaces !== undefined) {
|
|
895
|
+
props.useSessions = observableHook(host.sessions.list);
|
|
896
|
+
props.useWorkspaces = observableHook(host.workspaces.list);
|
|
897
|
+
}
|
|
898
|
+
let actions;
|
|
899
|
+
if (entry.store !== undefined) {
|
|
900
|
+
try {
|
|
901
|
+
const store = host.storeOf(entry, undefined);
|
|
902
|
+
if (store !== undefined) {
|
|
903
|
+
props.useStore = observableHook(store);
|
|
904
|
+
props.actions = store.actions;
|
|
905
|
+
actions = store.actions;
|
|
906
|
+
}
|
|
907
|
+
}
|
|
908
|
+
catch (err) {
|
|
909
|
+
// The entry still renders, just without its store — better than a
|
|
910
|
+
// blank region, but worth a record since it means degraded props.
|
|
911
|
+
reportDiag('delegate-store-failed', { message: String(err?.message ?? err) });
|
|
912
|
+
}
|
|
913
|
+
}
|
|
914
|
+
if (entry.locale !== undefined && host.locale !== undefined) {
|
|
915
|
+
try {
|
|
916
|
+
const bound = host.locale.bind(entry.locale);
|
|
917
|
+
props.t = (key, params) => bound(key, params);
|
|
918
|
+
}
|
|
919
|
+
catch {
|
|
920
|
+
props.t = (key) => key;
|
|
921
|
+
}
|
|
922
|
+
}
|
|
923
|
+
if (entry.children !== undefined) {
|
|
924
|
+
props.renderSlot = renderChildSlot;
|
|
925
|
+
// Two child-spec flavours need renderer internals we cannot mint from
|
|
926
|
+
// out here (chain composition, the session seat). Neither is used by
|
|
927
|
+
// any slot we delegate today; warn loudly if that ever changes, so it
|
|
928
|
+
// surfaces as a message instead of another silently empty region.
|
|
929
|
+
const specs = Object.values(entry.children);
|
|
930
|
+
if (specs.some((spec) => spec?.kind === 'chain' || spec?.scope === 'session')) {
|
|
931
|
+
warnOnce(`delegated entry '${String(entry.name)}' declares chain/session children that this shadow cannot synthesize`);
|
|
932
|
+
}
|
|
933
|
+
}
|
|
934
|
+
// The shell passes the entry's own actions into `inject`; matching that
|
|
935
|
+
// matters for registrations whose injected callbacks close over them.
|
|
936
|
+
if (typeof entry.inject === 'function') {
|
|
937
|
+
let injected;
|
|
938
|
+
try {
|
|
939
|
+
injected = entry.inject(actions);
|
|
940
|
+
}
|
|
941
|
+
catch {
|
|
942
|
+
injected = undefined;
|
|
943
|
+
}
|
|
944
|
+
if (injected !== null && typeof injected === 'object') {
|
|
945
|
+
for (const key of Object.keys(injected)) {
|
|
946
|
+
if (key === 'hooks')
|
|
947
|
+
continue;
|
|
948
|
+
props[key] = injected[key];
|
|
949
|
+
}
|
|
950
|
+
if (injected.hooks !== null && typeof injected.hooks === 'object') {
|
|
951
|
+
for (const name of Object.keys(injected.hooks)) {
|
|
952
|
+
const source = injected.hooks[name];
|
|
953
|
+
const hook = observableHook(source);
|
|
954
|
+
if (hook !== undefined)
|
|
955
|
+
props['use' + name[0].toUpperCase() + name.slice(1)] = hook;
|
|
956
|
+
}
|
|
957
|
+
}
|
|
958
|
+
}
|
|
959
|
+
}
|
|
960
|
+
// Owner props win, exactly as in the shell's own merge order.
|
|
961
|
+
return { ...props, ...ownerProps };
|
|
962
|
+
}
|
|
963
|
+
/** Bound `renderSlot` handed to delegated entries (recursive). */
|
|
964
|
+
function renderChildSlot(key, ownerProps) {
|
|
965
|
+
const entries = foreignEntries(key);
|
|
966
|
+
if (entries.length === 0)
|
|
967
|
+
return null;
|
|
968
|
+
return entries.map((en, i) => e(Boundary, {
|
|
969
|
+
key: en.id ?? key + ':' + String(i),
|
|
970
|
+
fallback: null,
|
|
971
|
+
children: e(en.component, synthesizeProps(en, ownerProps ?? {})),
|
|
972
|
+
}));
|
|
973
|
+
}
|
|
974
|
+
/**
|
|
975
|
+
* Renders the shipped `sidebar.workspaces` entry underneath our shadow.
|
|
976
|
+
* `wide` is forwarded untouched so the shipped rail branch — search and
|
|
977
|
+
* add-workspace, which the plugin used to replace with two inert icons —
|
|
978
|
+
* keeps working when the sidebar is collapsed.
|
|
979
|
+
*/
|
|
980
|
+
function DelegatedBrowser(p) {
|
|
981
|
+
const [entry, setEntry] = React.useState(null);
|
|
982
|
+
const [status, setStatus] = React.useState('loading');
|
|
983
|
+
React.useEffect(() => {
|
|
984
|
+
let alive = true;
|
|
985
|
+
const check = () => {
|
|
986
|
+
const found = foreignEntries('sidebar.workspaces')[0] ?? null;
|
|
987
|
+
if (!alive)
|
|
988
|
+
return;
|
|
989
|
+
setEntry(found);
|
|
990
|
+
setStatus(found === null ? 'missing' : 'ready');
|
|
991
|
+
reportDiag(found === null ? 'delegate-missing' : 'delegate-ready', { slot: 'sidebar.workspaces' });
|
|
992
|
+
};
|
|
993
|
+
check();
|
|
994
|
+
const unsub = slotsSvc === null ? null : slotsSvc.subscribe('sidebar.workspaces', check);
|
|
995
|
+
return () => { alive = false; if (unsub)
|
|
996
|
+
unsub(); };
|
|
997
|
+
}, []);
|
|
998
|
+
const props = React.useMemo(() => (entry === null ? null : synthesizeProps(entry, { wide: p.wide, expandSidebar: p.expandSidebar })), [entry, p.wide, p.expandSidebar]);
|
|
999
|
+
if (status === 'loading')
|
|
1000
|
+
return e('div', { className: 'dbs-navBodyErr' }, t('delegate.loading'));
|
|
1001
|
+
if (entry === null || props === null)
|
|
1002
|
+
return e('div', { className: 'dbs-navBodyErr' }, t('delegate.unavailable'));
|
|
1003
|
+
return e(Boundary, {
|
|
1004
|
+
fallback: e('div', { className: 'dbs-navBodyErr' }, t('delegate.failed')),
|
|
1005
|
+
children: e(entry.component, props),
|
|
1006
|
+
});
|
|
1007
|
+
}
|
|
1008
|
+
// =========================================================
|
|
1009
|
+
// Shared row pieces.
|
|
1010
|
+
// =========================================================
|
|
1011
|
+
/** Stable hue from an id, so an avatar keeps its colour across reloads. */
|
|
1012
|
+
function hueOf(id) {
|
|
1013
|
+
let h = 0;
|
|
1014
|
+
for (let i = 0; i < id.length; i += 1)
|
|
1015
|
+
h = (h * 31 + id.charCodeAt(i)) % 360;
|
|
1016
|
+
return h;
|
|
1017
|
+
}
|
|
1018
|
+
function Avatar(p) {
|
|
1019
|
+
const a = p.agent;
|
|
1020
|
+
const size = p.size ?? 20;
|
|
1021
|
+
const style = { width: size, height: size };
|
|
1022
|
+
if (typeof a.avatarDataUrl === 'string' && a.avatarDataUrl !== '') {
|
|
1023
|
+
return e('span', { className: 'dbs-avatar' + (a.isGroup ? ' dbs-group' : ''), style }, e('img', { src: a.avatarDataUrl, alt: '' }));
|
|
1024
|
+
}
|
|
1025
|
+
style.background = typeof a.avatarColor === 'string' && a.avatarColor !== ''
|
|
1026
|
+
? a.avatarColor
|
|
1027
|
+
: `hsl(${String(hueOf(a.id))} 52% 46%)`;
|
|
1028
|
+
if (size >= 24)
|
|
1029
|
+
style.fontSize = '13px';
|
|
1030
|
+
const initial = (a.name ?? '').trim().slice(0, 1) || '·';
|
|
1031
|
+
return e('span', {
|
|
1032
|
+
className: 'dbs-avatar' + (a.isGroup ? ' dbs-group' : ''), style, 'aria-hidden': true,
|
|
1033
|
+
}, initial);
|
|
1034
|
+
}
|
|
1035
|
+
/** Chevron matching the shipped project row (fills in if the icon moves). */
|
|
1036
|
+
function Chevron(p) {
|
|
1037
|
+
const cls = 'dbs-arrow' + (p.open ? ' dbs-arrowOpen' : '');
|
|
1038
|
+
const native = NATIVE.IconTriangleRightFill14;
|
|
1039
|
+
return e('span', { className: 'dbs-chevron ' + cls }, native !== undefined
|
|
1040
|
+
? e(native, {})
|
|
1041
|
+
: e('svg', { width: 14, height: 14, viewBox: '0 0 14 14', 'aria-hidden': true }, e('path', { d: 'M4.25 2.83v8.34c0 .49.59.74.94.39l4.17-4.17a.75.75 0 0 0 0-1.06L5.19 2.16c-.35-.35-.94-.1-.94.39Z', fill: 'currentColor' })));
|
|
1042
|
+
}
|
|
1043
|
+
// =========================================================
|
|
1044
|
+
// Bots nav group.
|
|
1045
|
+
// =========================================================
|
|
1046
|
+
function BotsGroup() {
|
|
1047
|
+
const s = useStore();
|
|
1048
|
+
// Hidden agents are hidden: the gateway owns that flag and the sidebar
|
|
1049
|
+
// has to honour it, same as every other sdk-bots surface.
|
|
1050
|
+
const visible = s.agents
|
|
1051
|
+
.filter((a) => a.isHiddenFromSidebar !== true)
|
|
1052
|
+
.slice()
|
|
1053
|
+
.sort((a, b) => (b.lastActivityAt ?? 0) - (a.lastActivityAt ?? 0));
|
|
1054
|
+
const groups = visible.filter((a) => a.isGroup);
|
|
1055
|
+
const singles = visible.filter((a) => !a.isGroup);
|
|
1056
|
+
const connected = s.info?.ok === true;
|
|
1057
|
+
function row(a) {
|
|
1058
|
+
const busy = a.isComposingMessage === true || a.isRunning === true;
|
|
1059
|
+
// Plugin-owned count first (the gateway's own unreadCount is
|
|
1060
|
+
// desktop-app semantics and never accumulates on a headless host).
|
|
1061
|
+
const unread = Number(s.unreadCounts?.[a.id] ?? a.unreadCount ?? 0);
|
|
1062
|
+
return e('div', {
|
|
1063
|
+
key: a.id,
|
|
1064
|
+
className: 'dbs-srow' + (s.chatAgentId === a.id ? ' dbs-selected' : ''),
|
|
1065
|
+
role: 'treeitem',
|
|
1066
|
+
'aria-selected': s.chatAgentId === a.id,
|
|
1067
|
+
tabIndex: 0,
|
|
1068
|
+
onClick: () => openChat(a.id),
|
|
1069
|
+
onKeyDown: (ev) => { if (ev.key === 'Enter' || ev.key === ' ') {
|
|
1070
|
+
ev.preventDefault();
|
|
1071
|
+
openChat(a.id);
|
|
1072
|
+
} },
|
|
1073
|
+
title: a.description !== '' ? a.name + ' — ' + a.description : a.name,
|
|
1074
|
+
}, e(Avatar, { agent: a }), e('span', { className: 'dbs-title' }, a.name), busy && StateDot !== null
|
|
1075
|
+
? e(StateDot, { state: 'ongoing', size: 10 })
|
|
1076
|
+
: a.awaitingUserResponse !== null && a.awaitingUserResponse !== undefined && StateDot !== null
|
|
1077
|
+
? e(StateDot, { state: 'warning', size: 10 })
|
|
1078
|
+
: unread > 0
|
|
1079
|
+
? e('span', { className: 'dbs-badge' }, unread > 99 ? '99+' : String(unread))
|
|
1080
|
+
: null, e('button', {
|
|
1081
|
+
type: 'button', className: 'dbs-rowDel',
|
|
1082
|
+
title: t('action.delete'), 'aria-label': t('action.delete') + ' ' + a.name,
|
|
1083
|
+
onClick: (ev) => {
|
|
1084
|
+
ev.stopPropagation();
|
|
1085
|
+
patch({ confirmDelete: { id: a.id, name: a.name, isGroup: a.isGroup === true } });
|
|
1086
|
+
},
|
|
1087
|
+
}, Ico('IconTrashOutline16', { size: 14 })));
|
|
1088
|
+
}
|
|
1089
|
+
/**
|
|
1090
|
+
* Section header + rows. The header always renders (even when the
|
|
1091
|
+
* section is empty) so the far-right + is reachable from where you
|
|
1092
|
+
* are: creating the first bot or group never requires a trip to the
|
|
1093
|
+
* footer. `addAction` opens the same system-style modal as before.
|
|
1094
|
+
*/
|
|
1095
|
+
function sectionRows(label, list, addAction) {
|
|
1096
|
+
return e('div', { key: label }, e('div', {
|
|
1097
|
+
className: 'dbs-navBodyErr',
|
|
1098
|
+
// Native sectionHeader outdent: label at x=16 (12 sidebar
|
|
1099
|
+
// padding + 4), aligned with the group headers. The previous
|
|
1100
|
+
// 12px here pushed section labels 8px right of the native
|
|
1101
|
+
// baseline, making both sections read horizontally off.
|
|
1102
|
+
style: { padding: '4px 8px 2px 4px', display: 'flex', alignItems: 'center' },
|
|
1103
|
+
}, e('span', { style: { flex: 1 } }, label), e(Button, {
|
|
1104
|
+
variant: 'ghost', size: 'sm', title: addAction.title, 'aria-label': addAction.title,
|
|
1105
|
+
icon: Ico('IconPlusOutline16', { size: 14 }),
|
|
1106
|
+
onClick: addAction.onClick,
|
|
1107
|
+
})), list.map(row));
|
|
1108
|
+
}
|
|
1109
|
+
// No footer: the gateway status moved to the Bots group-header dot's
|
|
1110
|
+
// hover tooltip (0.2.8) — see SidebarNav's StateDot `title`.
|
|
1111
|
+
return e('div', { className: 'dbs-navBody dbs-botsBody' }, s.error !== null
|
|
1112
|
+
? e('div', { className: 'dbs-error', onClick: () => patch({ error: null }) }, s.error)
|
|
1113
|
+
: null, !s.agentsLoaded
|
|
1114
|
+
? e('div', { className: 'dbs-navBodyErr' }, t('list.loading'))
|
|
1115
|
+
: visible.length === 0
|
|
1116
|
+
? e('div', { className: 'dbs-navBodyErr' }, connected ? t('list.empty') : t('gateway.offlineHint'))
|
|
1117
|
+
: null, s.agentsLoaded
|
|
1118
|
+
? sectionRows(t('section.groups'), groups, {
|
|
1119
|
+
title: t('group.new'),
|
|
1120
|
+
onClick: () => patch({ create: 'group', createName: '', createMembers: {}, createWorking: false, error: null }),
|
|
1121
|
+
})
|
|
1122
|
+
: null, s.agentsLoaded
|
|
1123
|
+
? sectionRows(t('section.singles'), singles, {
|
|
1124
|
+
title: t('bot.new'),
|
|
1125
|
+
onClick: () => patch({ create: 'bot', createName: '', createDesc: '', createWorking: false, error: null }),
|
|
1126
|
+
})
|
|
1127
|
+
: null);
|
|
1128
|
+
}
|
|
1129
|
+
// =========================================================
|
|
1130
|
+
// Sidebar nav: 「工作区」 and 「Bots」 as two collapsible groups.
|
|
1131
|
+
// =========================================================
|
|
1132
|
+
function SidebarNav(p) {
|
|
1133
|
+
const wide = p.wide !== false;
|
|
1134
|
+
const s = useStore();
|
|
1135
|
+
// One controller owns the data lifecycle for every Bots surface.
|
|
1136
|
+
React.useEffect(() => {
|
|
1137
|
+
void refreshAgents();
|
|
1138
|
+
void refreshInfo();
|
|
1139
|
+
syncUnread();
|
|
1140
|
+
return ring.subscribe((channels) => {
|
|
1141
|
+
if (channels.has('agents') || channels.has('agent-upserted'))
|
|
1142
|
+
void refreshAgents();
|
|
1143
|
+
if (channels.has('host-settings'))
|
|
1144
|
+
void refreshInfo();
|
|
1145
|
+
if (channels.has('transcript'))
|
|
1146
|
+
syncUnread();
|
|
1147
|
+
});
|
|
1148
|
+
}, []);
|
|
1149
|
+
if (!wide) {
|
|
1150
|
+
// Rail: the shipped browser draws its own icon column (search, add
|
|
1151
|
+
// workspace); we append one Bots control instead of replacing it.
|
|
1152
|
+
const busy = s.agents.some((a) => a.isComposingMessage === true || a.isRunning === true);
|
|
1153
|
+
const unread = s.agents
|
|
1154
|
+
.filter((a) => a.isHiddenFromSidebar !== true)
|
|
1155
|
+
.reduce((n, a) => n + Number(s.unreadCounts?.[a.id] ?? a.unreadCount ?? 0), 0);
|
|
1156
|
+
return e('div', { className: 'dbs-railWrap' }, e(DelegatedBrowser, { wide: false, expandSidebar: p.expandSidebar }), e('div', { className: 'dbs-rail' }, e('button', {
|
|
1157
|
+
type: 'button', className: 'dbs-railBtn', title: unread > 0 ? t('nav.bots.unread', { n: unread }) : t('nav.bots'),
|
|
1158
|
+
'aria-label': t('nav.bots.aria'), 'data-active': busy || unread > 0,
|
|
1159
|
+
onClick: () => {
|
|
1160
|
+
patch({ open: { ...s.open, bots: true } });
|
|
1161
|
+
if (p.expandSidebar)
|
|
1162
|
+
p.expandSidebar();
|
|
1163
|
+
},
|
|
1164
|
+
}, Ico('IconAgentPresetOutline16', { size: 18 }) ?? '·')));
|
|
1165
|
+
}
|
|
1166
|
+
// Accordion: expanding one nav group collapses the other. The sidebar
|
|
1167
|
+
// is one column of attention, not two stacked browsers — collapsing a
|
|
1168
|
+
// group stays a plain collapse, only the expand is exclusive.
|
|
1169
|
+
function toggleNav(key, isOpen) {
|
|
1170
|
+
if (isOpen)
|
|
1171
|
+
return { ...s.open, [key]: false };
|
|
1172
|
+
const other = key === 'workspaces' ? 'bots' : 'workspaces';
|
|
1173
|
+
return { ...s.open, [key]: true, [other]: false };
|
|
1174
|
+
}
|
|
1175
|
+
function group(key, title, iconName, body, trailing) {
|
|
1176
|
+
const isOpen = s.open[key] !== false;
|
|
1177
|
+
return e('div', { className: 'dbs-navGroup', 'data-open': isOpen }, e('div', {
|
|
1178
|
+
className: 'dbs-prow', role: 'button', tabIndex: 0, 'aria-expanded': isOpen,
|
|
1179
|
+
onClick: () => patch({ open: toggleNav(key, isOpen) }),
|
|
1180
|
+
onKeyDown: (ev) => {
|
|
1181
|
+
if (ev.key === 'Enter' || ev.key === ' ') {
|
|
1182
|
+
ev.preventDefault();
|
|
1183
|
+
patch({ open: toggleNav(key, isOpen) });
|
|
1184
|
+
}
|
|
1185
|
+
},
|
|
1186
|
+
}, e(Chevron, { open: isOpen }), e('span', { className: 'dbs-slot' }, Ico(iconName, { size: 16 })), e('span', { className: 'dbs-title' }, title), trailing ?? null), isOpen ? body : null);
|
|
1187
|
+
}
|
|
1188
|
+
return e('div', { className: 'dbs-nav' }, group('workspaces', t('nav.workspaces'), 'IconFolderClose16',
|
|
1189
|
+
// Clicking into the native browser (a session row, "new chat"…)
|
|
1190
|
+
// is a navigation intent: dismiss the bot chat overlay so the
|
|
1191
|
+
// main window the user asked for is actually visible.
|
|
1192
|
+
e('div', {
|
|
1193
|
+
className: 'dbs-navBody',
|
|
1194
|
+
onClickCapture: () => { if (s.chatAgentId !== null)
|
|
1195
|
+
patch({ chatAgentId: null }); },
|
|
1196
|
+
}, e(DelegatedBrowser, { wide, expandSidebar: p.expandSidebar }))), group('bots', t('nav.bots'), 'IconAgentPresetOutline16', e(BotsGroup, null), StateDot !== null
|
|
1197
|
+
? e('span', {
|
|
1198
|
+
title: s.info?.ok === true ? t('gateway.online', { port: s.info.port }) : t('gateway.offline'),
|
|
1199
|
+
style: { display: 'inline-flex', alignItems: 'center', flex: 'none' },
|
|
1200
|
+
}, e(StateDot, { state: s.info?.ok === true ? 'done' : 'failed', size: 8 }))
|
|
1201
|
+
: null));
|
|
1202
|
+
}
|
|
1203
|
+
// =========================================================
|
|
1204
|
+
// Chat surface.
|
|
1205
|
+
// =========================================================
|
|
1206
|
+
/**
|
|
1207
|
+
* Pixel offsets of the frame's centre column.
|
|
1208
|
+
*
|
|
1209
|
+
* `shell.overlay` covers the whole AppFrame, so an `inset: 0` panel sits
|
|
1210
|
+
* on top of the sidebar too — which is how the chat used to hide the very
|
|
1211
|
+
* nav it is launched from. The frame is a CSS grid and
|
|
1212
|
+
* `grid-template-columns` resolves to pixels, so the sidebar and details
|
|
1213
|
+
* widths can be read straight off it. `[data-shell-overlay]` is a stable
|
|
1214
|
+
* attribute (not a hashed class), and its parent is the frame.
|
|
1215
|
+
*/
|
|
1216
|
+
function useCentreInset() {
|
|
1217
|
+
const [inset, setInset] = React.useState({ left: 0, right: 0 });
|
|
1218
|
+
React.useEffect(() => {
|
|
1219
|
+
const layer = document.querySelector('[data-shell-overlay]');
|
|
1220
|
+
const frame = layer === null ? null : layer.parentElement;
|
|
1221
|
+
if (frame === null)
|
|
1222
|
+
return undefined;
|
|
1223
|
+
const read = () => {
|
|
1224
|
+
const cols = window.getComputedStyle(frame).gridTemplateColumns.split(/\s+/).filter((c) => c !== '');
|
|
1225
|
+
const left = Number.parseFloat(cols[0]) || 0;
|
|
1226
|
+
const right = cols.length >= 3 ? (Number.parseFloat(cols[cols.length - 1]) || 0) : 0;
|
|
1227
|
+
setInset((prev) => (prev.left === left && prev.right === right ? prev : { left, right }));
|
|
1228
|
+
};
|
|
1229
|
+
read();
|
|
1230
|
+
const ro = new ResizeObserver(read);
|
|
1231
|
+
ro.observe(frame);
|
|
1232
|
+
// Collapsing a column rewrites the inline grid template without
|
|
1233
|
+
// resizing the frame, so watch the attribute as well.
|
|
1234
|
+
const mo = new MutationObserver(read);
|
|
1235
|
+
mo.observe(frame, { attributes: true, attributeFilter: ['style', 'data-details-collapsed'] });
|
|
1236
|
+
return () => { ro.disconnect(); mo.disconnect(); };
|
|
1237
|
+
}, []);
|
|
1238
|
+
return inset;
|
|
1239
|
+
}
|
|
1240
|
+
// =========================================================
|
|
1241
|
+
// Message clocks.
|
|
1242
|
+
//
|
|
1243
|
+
// Formatted out of our own dictionary rather than `Intl.DateTimeFormat`,
|
|
1244
|
+
// because the language every other string in this plugin follows is the
|
|
1245
|
+
// shell's preference — not the browser's. 24-hour, tabular digits, same
|
|
1246
|
+
// as the shipped surfaces.
|
|
1247
|
+
// =========================================================
|
|
1248
|
+
/** `HH:MM` for the message row. */
|
|
1249
|
+
function clockOf(ms) {
|
|
1250
|
+
const d = new Date(ms);
|
|
1251
|
+
return String(d.getHours()).padStart(2, '0') + ':' + String(d.getMinutes()).padStart(2, '0');
|
|
1252
|
+
}
|
|
1253
|
+
/** Local midnight index, so "same day" ignores the wall clock. */
|
|
1254
|
+
function dayIndexOf(ms) {
|
|
1255
|
+
return Math.floor((ms - new Date(ms).getTimezoneOffset() * 60000) / 86400000);
|
|
1256
|
+
}
|
|
1257
|
+
/** 今天 / 昨天 / 8 月 30 日 / 2025 年 8 月 30 日 — the divider label. */
|
|
1258
|
+
function dayLabelOf(ms) {
|
|
1259
|
+
const today = dayIndexOf(Date.now());
|
|
1260
|
+
const day = dayIndexOf(ms);
|
|
1261
|
+
if (day === today)
|
|
1262
|
+
return t('time.today');
|
|
1263
|
+
if (day === today - 1)
|
|
1264
|
+
return t('time.yesterday');
|
|
1265
|
+
const d = new Date(ms);
|
|
1266
|
+
const params = { y: d.getFullYear(), m: d.getMonth() + 1, d: d.getDate() };
|
|
1267
|
+
return d.getFullYear() === new Date().getFullYear() ? t('time.date', params) : t('time.dateFull', params);
|
|
1268
|
+
}
|
|
1269
|
+
/** Day + clock, for the hover title on a bare `HH:MM`. */
|
|
1270
|
+
function stampOf(ms) {
|
|
1271
|
+
return dayLabelOf(ms) + ' ' + clockOf(ms);
|
|
1272
|
+
}
|
|
1273
|
+
/** Readable timestamp of an entry, or null when the gateway sent none. */
|
|
1274
|
+
function timeOf(entry) {
|
|
1275
|
+
const ms = entry?.timestampMs;
|
|
1276
|
+
return typeof ms === 'number' && Number.isFinite(ms) ? ms : null;
|
|
1277
|
+
}
|
|
1278
|
+
/**
|
|
1279
|
+
* The reply clock under one message. Visible stamp carries the full
|
|
1280
|
+
* date (Y-M-D HH:MM) per product call; the hover title keeps the
|
|
1281
|
+
* locale-friendly "M 月 D 日 HH:MM" form.
|
|
1282
|
+
*
|
|
1283
|
+
* Suppressed while the entry is still streaming: a half-written turn has
|
|
1284
|
+
* no reply time yet, and stamping the tail would make the number twitch
|
|
1285
|
+
* on every chunk.
|
|
1286
|
+
*/
|
|
1287
|
+
function MsgTime(p) {
|
|
1288
|
+
const ms = timeOf(p.entry);
|
|
1289
|
+
if (ms === null || p.entry.isStreaming === true)
|
|
1290
|
+
return null;
|
|
1291
|
+
const d = new Date(ms);
|
|
1292
|
+
const p2 = (n) => String(n).padStart(2, '0');
|
|
1293
|
+
return e('span', { className: 'dbs-msgTime', title: stampOf(ms) }, d.getFullYear() + '-' + p2(d.getMonth() + 1) + '-' + p2(d.getDate()) + ' ' + clockOf(ms));
|
|
1294
|
+
}
|
|
1295
|
+
function ToolCard(p) {
|
|
1296
|
+
const [open, setOpen] = React.useState(false);
|
|
1297
|
+
const en = p.entry;
|
|
1298
|
+
const tone = en.toolStatus === 'error'
|
|
1299
|
+
? 'var(--dsw-alias-state-error-primary)'
|
|
1300
|
+
: en.toolStatus === 'running'
|
|
1301
|
+
? 'var(--dsw-alias-state-warn-primary)'
|
|
1302
|
+
: 'var(--dsw-alias-state-success-primary)';
|
|
1303
|
+
return e('div', { className: 'dbs-toolCard' }, e('div', {
|
|
1304
|
+
className: 'dbs-toolHdr', role: 'button', tabIndex: 0,
|
|
1305
|
+
style: { cursor: en.content === '' ? 'default' : 'pointer' },
|
|
1306
|
+
onClick: () => { if (en.content !== '')
|
|
1307
|
+
setOpen(!open); },
|
|
1308
|
+
onKeyDown: (ev) => { if ((ev.key === 'Enter' || ev.key === ' ') && en.content !== '') {
|
|
1309
|
+
ev.preventDefault();
|
|
1310
|
+
setOpen(!open);
|
|
1311
|
+
} },
|
|
1312
|
+
}, StateDot !== null
|
|
1313
|
+
? 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);
|
|
1315
|
+
}
|
|
1316
|
+
function Entry(p) {
|
|
1317
|
+
const en = p.entry;
|
|
1318
|
+
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 }));
|
|
1320
|
+
}
|
|
1321
|
+
if (en.display === 'tool')
|
|
1322
|
+
return e(ToolCard, { entry: en });
|
|
1323
|
+
if (en.display === 'thinking') {
|
|
1324
|
+
return e('div', { className: 'dbs-thinking' }, en.content);
|
|
1325
|
+
}
|
|
1326
|
+
if (en.display === 'event') {
|
|
1327
|
+
if (en.content === '')
|
|
1328
|
+
return null;
|
|
1329
|
+
return e('div', { className: 'dbs-meta', style: { textAlign: 'center' } }, en.content);
|
|
1330
|
+
}
|
|
1331
|
+
// Compact continuation of the same author's run: the header already
|
|
1332
|
+
// stands above; only the reply clock rides along, right-aligned and
|
|
1333
|
+
// quiet — the way native IMs chain quick follow-ups under one name.
|
|
1334
|
+
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 })));
|
|
1336
|
+
}
|
|
1337
|
+
// Bot message: avatar + prominent per-author name + full-datetime in
|
|
1338
|
+
// one header row, so multi-member rooms read at a glance.
|
|
1339
|
+
const authorKnown = typeof en.authorId === 'string' && en.authorId !== '';
|
|
1340
|
+
const memberAgent = authorKnown ? agentById(en.authorId) : null;
|
|
1341
|
+
const avAgent = memberAgent ?? {
|
|
1342
|
+
id: authorKnown ? en.authorId : (p.agent?.id ?? 'bot'),
|
|
1343
|
+
name: en.authorName ?? p.agent?.name ?? '',
|
|
1344
|
+
avatarColor: memberAgent?.avatarColor,
|
|
1345
|
+
avatarDataUrl: memberAgent?.avatarDataUrl,
|
|
1346
|
+
isGroup: false,
|
|
1347
|
+
};
|
|
1348
|
+
const displayName = p.isGroup ? en.authorName : p.agent?.name;
|
|
1349
|
+
const authorColor = 'hsl(' + String(hueOf(avAgent.id)) + ' 55% 45%)';
|
|
1350
|
+
return e('div', { className: 'dbs-botRow' }, e('div', { className: 'dbs-author' }, e(Avatar, { agent: avAgent, size: 18 }), displayName != null && displayName !== ''
|
|
1351
|
+
? 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));
|
|
1353
|
+
}
|
|
1354
|
+
function ChatView(p) {
|
|
1355
|
+
const s = useStore();
|
|
1356
|
+
const [entries, setEntries] = React.useState(null);
|
|
1357
|
+
const [input, setInput] = React.useState('');
|
|
1358
|
+
const [sending, setSending] = React.useState(false);
|
|
1359
|
+
const [stopping, setStopping] = React.useState(false);
|
|
1360
|
+
const [error, setError] = React.useState(null);
|
|
1361
|
+
const [mention, setMention] = React.useState(null); // {query, index} | null
|
|
1362
|
+
const scrollRef = React.useRef(null);
|
|
1363
|
+
const inputRef = React.useRef(null);
|
|
1364
|
+
// True between compositionstart/end: an IME candidate window owns the
|
|
1365
|
+
// keyboard while it is open and must never see our Enter binding.
|
|
1366
|
+
const imeRef = React.useRef(false);
|
|
1367
|
+
const agent = agentById(p.agentId);
|
|
1368
|
+
const isGroup = agent?.isGroup === true;
|
|
1369
|
+
// Authoritative, from the gateway — never a local "I just sent, so it
|
|
1370
|
+
// must be running" guess, which had no path back to false.
|
|
1371
|
+
const composing = agent?.isComposingMessage === true || agent?.isRunning === true;
|
|
1372
|
+
async function loadTranscript() {
|
|
1373
|
+
try {
|
|
1374
|
+
const r = await botsCall('transcriptTail', { id: p.agentId, limit: 80 });
|
|
1375
|
+
setEntries(Array.isArray(r?.entries) ? r.entries : []);
|
|
1376
|
+
}
|
|
1377
|
+
catch (err) {
|
|
1378
|
+
setError(String(err?.message ?? err));
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
React.useEffect(() => {
|
|
1382
|
+
setEntries(null);
|
|
1383
|
+
setError(null);
|
|
1384
|
+
setInput('');
|
|
1385
|
+
setMention(null);
|
|
1386
|
+
void loadTranscript();
|
|
1387
|
+
// While this conversation is open it is being read: arrivals for it
|
|
1388
|
+
// must not leave a badge on the row the user is looking at. The
|
|
1389
|
+
// host bump lands first, our debounced markRead cancels it out.
|
|
1390
|
+
let readTimer = null;
|
|
1391
|
+
const scheduleRead = () => {
|
|
1392
|
+
if (readTimer !== null)
|
|
1393
|
+
return;
|
|
1394
|
+
readTimer = setTimeout(() => {
|
|
1395
|
+
readTimer = null;
|
|
1396
|
+
patch({ unreadCounts: { ...state.unreadCounts, [p.agentId]: 0 } });
|
|
1397
|
+
void botsCall('markRead', { id: p.agentId, atMs: Date.now() }).catch(() => { });
|
|
1398
|
+
}, 1200);
|
|
1399
|
+
};
|
|
1400
|
+
const unsub = ring.subscribe((channels, events) => {
|
|
1401
|
+
if (channels.has('transcript') || channels.has('client-side-tool-v2'))
|
|
1402
|
+
void loadTranscript();
|
|
1403
|
+
if (channels.has('transcript') && Array.isArray(events)) {
|
|
1404
|
+
for (const ev of events) {
|
|
1405
|
+
const payload = ev?.data;
|
|
1406
|
+
if (payload?.type !== 'appended' && payload?.type !== 'snapshot')
|
|
1407
|
+
continue;
|
|
1408
|
+
if (String(payload?.agentId ?? payload?.activeAgentId ?? '') === p.agentId) {
|
|
1409
|
+
scheduleRead();
|
|
1410
|
+
break;
|
|
1411
|
+
}
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
});
|
|
1415
|
+
return () => {
|
|
1416
|
+
// A pending read timer dies with the view — a message that landed
|
|
1417
|
+
// as the user left stays unread.
|
|
1418
|
+
if (readTimer !== null) {
|
|
1419
|
+
clearTimeout(readTimer);
|
|
1420
|
+
readTimer = null;
|
|
1421
|
+
}
|
|
1422
|
+
unsub();
|
|
1423
|
+
};
|
|
1424
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1425
|
+
}, [p.agentId]);
|
|
1426
|
+
// The composer takes focus when a conversation opens, and keeps its
|
|
1427
|
+
// height in step with programmatic edits (send, mention completion,
|
|
1428
|
+
// agent switch) — user typing is fitted inline, without a frame gap.
|
|
1429
|
+
React.useEffect(() => {
|
|
1430
|
+
const node = inputRef.current;
|
|
1431
|
+
if (node === null || node === undefined)
|
|
1432
|
+
return undefined;
|
|
1433
|
+
node.focus();
|
|
1434
|
+
return undefined;
|
|
1435
|
+
}, [p.agentId]);
|
|
1436
|
+
React.useEffect(() => { fitInput(); }, [input, p.agentId]);
|
|
1437
|
+
// Stick-to-bottom: follow new output only while the reader already
|
|
1438
|
+
// sits at the tail; the moment they scroll up to reread, the thread
|
|
1439
|
+
// stops yanking them down and a jump-to-latest pill appears instead —
|
|
1440
|
+
// the native conversation contract.
|
|
1441
|
+
const stickRef = React.useRef(true);
|
|
1442
|
+
const [showJump, setShowJump] = React.useState(false);
|
|
1443
|
+
function onScrollBody() {
|
|
1444
|
+
const el = scrollRef.current;
|
|
1445
|
+
if (el === null || el === undefined)
|
|
1446
|
+
return;
|
|
1447
|
+
const distance = el.scrollHeight - el.scrollTop - el.clientHeight;
|
|
1448
|
+
stickRef.current = distance < 80;
|
|
1449
|
+
const next = distance > 240;
|
|
1450
|
+
setShowJump((prev) => (prev === next ? prev : next));
|
|
1451
|
+
}
|
|
1452
|
+
function jumpToLatest() {
|
|
1453
|
+
const el = scrollRef.current;
|
|
1454
|
+
if (el !== null && el !== undefined)
|
|
1455
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
1456
|
+
}
|
|
1457
|
+
React.useEffect(() => {
|
|
1458
|
+
const el = scrollRef.current;
|
|
1459
|
+
if (el === null || el === undefined)
|
|
1460
|
+
return;
|
|
1461
|
+
if (stickRef.current)
|
|
1462
|
+
el.scrollTo({ top: el.scrollHeight, behavior: 'smooth' });
|
|
1463
|
+
}, [entries, composing]);
|
|
1464
|
+
const memberNames = React.useMemo(() => {
|
|
1465
|
+
if (!isGroup)
|
|
1466
|
+
return [];
|
|
1467
|
+
const ids = agent?.memberIds ?? [];
|
|
1468
|
+
return ids
|
|
1469
|
+
.map((id) => state.agents.find((a) => a.id === id))
|
|
1470
|
+
.filter((a) => a !== undefined);
|
|
1471
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
1472
|
+
}, [isGroup, agent, s.agents]);
|
|
1473
|
+
/**
|
|
1474
|
+
* Grow the textarea to its content, the way the shipped card does:
|
|
1475
|
+
* one row when empty, taller line by line, and `.dbs-composerScroll`
|
|
1476
|
+
* caps it at `--dsh-composer-text-max-height`. A fixed-height textarea
|
|
1477
|
+
* scrolled its own body instead — the one thing the native card never
|
|
1478
|
+
* does — so a long draft became a 1-line peephole.
|
|
1479
|
+
*/
|
|
1480
|
+
function fitInput(node) {
|
|
1481
|
+
const el = node ?? inputRef.current;
|
|
1482
|
+
if (el === null || el === undefined)
|
|
1483
|
+
return;
|
|
1484
|
+
el.style.height = 'auto';
|
|
1485
|
+
el.style.height = String(el.scrollHeight) + 'px';
|
|
1486
|
+
}
|
|
1487
|
+
/**
|
|
1488
|
+
* `@` completion for directed group turns, driven by the caret rather
|
|
1489
|
+
* than by the keystroke: moving the caret back into a half-typed
|
|
1490
|
+
* mention re-opens the menu, and moving out of it closes the menu.
|
|
1491
|
+
*/
|
|
1492
|
+
function syncMention(node) {
|
|
1493
|
+
if (!isGroup || node === null || node === undefined) {
|
|
1494
|
+
setMention(null);
|
|
1495
|
+
return;
|
|
1496
|
+
}
|
|
1497
|
+
const value = String(node.value);
|
|
1498
|
+
const upto = value.slice(0, node.selectionStart ?? value.length);
|
|
1499
|
+
const m = /@([^\s@]*)$/.exec(upto);
|
|
1500
|
+
// Identity-stable when nothing changed, so a caret sweep does not
|
|
1501
|
+
// re-render the menu (and does not reset the highlighted row).
|
|
1502
|
+
setMention((prev) => {
|
|
1503
|
+
if (m === null)
|
|
1504
|
+
return null;
|
|
1505
|
+
return prev !== null && prev.query === m[1] ? prev : { query: m[1], index: 0 };
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
function onInputChange(node) {
|
|
1509
|
+
setInput(String(node.value));
|
|
1510
|
+
fitInput(node);
|
|
1511
|
+
syncMention(node);
|
|
1512
|
+
}
|
|
1513
|
+
const mentionHits = mention === null
|
|
1514
|
+
? []
|
|
1515
|
+
: memberNames.filter((a) => a.name.toLowerCase().startsWith(mention.query.toLowerCase())).slice(0, 8);
|
|
1516
|
+
function applyMention(a) {
|
|
1517
|
+
const node = inputRef.current;
|
|
1518
|
+
const caret = node?.selectionStart ?? input.length;
|
|
1519
|
+
const before = input.slice(0, caret).replace(/@([^\s@]*)$/, '@' + a.name + ' ');
|
|
1520
|
+
setInput(before + input.slice(caret));
|
|
1521
|
+
setMention(null);
|
|
1522
|
+
if (node === null || node === undefined)
|
|
1523
|
+
return;
|
|
1524
|
+
node.focus();
|
|
1525
|
+
// React rewrites `value` on the next commit, which parks the caret at
|
|
1526
|
+
// the end of the whole draft; put it back behind the name completed.
|
|
1527
|
+
window.requestAnimationFrame(() => {
|
|
1528
|
+
const live = inputRef.current;
|
|
1529
|
+
if (live === null || live === undefined)
|
|
1530
|
+
return;
|
|
1531
|
+
live.setSelectionRange(before.length, before.length);
|
|
1532
|
+
fitInput(live);
|
|
1533
|
+
});
|
|
1534
|
+
}
|
|
1535
|
+
/** Stop the active run: real gateway interrupt, honest no-op feedback. */
|
|
1536
|
+
async function doStop() {
|
|
1537
|
+
if (stopping)
|
|
1538
|
+
return;
|
|
1539
|
+
setStopping(true);
|
|
1540
|
+
setError(null);
|
|
1541
|
+
try {
|
|
1542
|
+
const r = await botsCall('interrupt', { id: p.agentId });
|
|
1543
|
+
if (r?.hadActiveRun !== true)
|
|
1544
|
+
setError(t('chat.stop.noop'));
|
|
1545
|
+
await refreshAgents();
|
|
1546
|
+
await loadTranscript();
|
|
1547
|
+
}
|
|
1548
|
+
catch (err) {
|
|
1549
|
+
setError(String(err?.message ?? err));
|
|
1550
|
+
}
|
|
1551
|
+
setStopping(false);
|
|
1552
|
+
}
|
|
1553
|
+
async function doSend() {
|
|
1554
|
+
const text = input.trim();
|
|
1555
|
+
if (text === '' || sending || composing)
|
|
1556
|
+
return;
|
|
1557
|
+
setSending(true);
|
|
1558
|
+
setError(null);
|
|
1559
|
+
try {
|
|
1560
|
+
await botsCall('send', { agentId: p.agentId, prompt: text });
|
|
1561
|
+
setInput('');
|
|
1562
|
+
setMention(null);
|
|
1563
|
+
await refreshAgents();
|
|
1564
|
+
await loadTranscript();
|
|
1565
|
+
}
|
|
1566
|
+
catch (err) {
|
|
1567
|
+
setError(String(err?.message ?? err));
|
|
1568
|
+
}
|
|
1569
|
+
setSending(false);
|
|
1570
|
+
// The caret stays in the composer after a send, as in every native
|
|
1571
|
+
// thread: the next turn is typed without reaching for the mouse.
|
|
1572
|
+
const node = inputRef.current;
|
|
1573
|
+
if (node !== null && node !== undefined)
|
|
1574
|
+
node.focus();
|
|
1575
|
+
}
|
|
1576
|
+
const list = (entries ?? []);
|
|
1577
|
+
const inset = useCentreInset();
|
|
1578
|
+
// Day dividers, folded in during render: a bare `HH:MM` turns
|
|
1579
|
+
// ambiguous the moment a transcript crosses midnight, so the date is
|
|
1580
|
+
// stated once and the rows beneath it carry only the clock.
|
|
1581
|
+
// Consecutive same-author bot messages within the window group under
|
|
1582
|
+
// one header — follow-ups go compact, the native IM run pattern.
|
|
1583
|
+
const GROUP_WINDOW_MS = 5 * 60 * 1000;
|
|
1584
|
+
const thread = [];
|
|
1585
|
+
let lastDay = null;
|
|
1586
|
+
let run = null;
|
|
1587
|
+
list.forEach((en, i) => {
|
|
1588
|
+
const ms = timeOf(en);
|
|
1589
|
+
if (ms !== null) {
|
|
1590
|
+
const day = dayIndexOf(ms);
|
|
1591
|
+
if (day !== lastDay) {
|
|
1592
|
+
lastDay = day;
|
|
1593
|
+
run = null;
|
|
1594
|
+
thread.push(e('div', { className: 'dbs-dayDivider', key: 'day:' + String(day) }, e('span', null, dayLabelOf(ms))));
|
|
1595
|
+
}
|
|
1596
|
+
}
|
|
1597
|
+
const authorId = en.display === 'bot' && typeof en.authorId === 'string' ? en.authorId : null;
|
|
1598
|
+
const compact = authorId !== null && run !== null && run.authorId === authorId
|
|
1599
|
+
&& ms !== null && run.at !== null && ms - run.at >= 0 && ms - run.at <= GROUP_WINDOW_MS;
|
|
1600
|
+
if (authorId !== null)
|
|
1601
|
+
run = { authorId, at: ms ?? run?.at ?? 0 };
|
|
1602
|
+
else
|
|
1603
|
+
run = null;
|
|
1604
|
+
thread.push(e(Entry, { key: en.id !== '' ? en.id : String(i), entry: en, isGroup, agent, compact }));
|
|
1605
|
+
});
|
|
1606
|
+
return e('div', { className: 'dbs-chatview', style: { left: inset.left, right: inset.right } }, e('div', { className: 'dbs-chatbar' }, e(Button, {
|
|
1607
|
+
variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
|
|
1608
|
+
icon: Ico('IconCloseOutline16', { size: 16 }),
|
|
1609
|
+
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
|
|
1611
|
+
? e(Button, {
|
|
1612
|
+
variant: 'ghost', size: 'sm', title: t('chat.members.manage'), 'aria-label': t('chat.members.manage'),
|
|
1613
|
+
onClick: () => patch({ manageMembers: p.agentId }),
|
|
1614
|
+
}, t('chat.members.manage'))
|
|
1615
|
+
: null, e('span', { style: { flex: 1 } })), error !== null
|
|
1616
|
+
? e('div', { className: 'dbs-error', onClick: () => setError(null) }, error)
|
|
1617
|
+
: 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
|
+
? 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%' } }))
|
|
1619
|
+
: list.length === 0
|
|
1620
|
+
? e('div', { className: 'dbs-welcome' }, agent !== null ? e(Avatar, { agent, size: 44 }) : null, e('div', { className: 'dbs-welcomeName' }, agent?.name ?? ''), typeof agent?.description === 'string' && agent.description !== ''
|
|
1621
|
+
? e('div', { className: 'dbs-welcomeDesc' }, agent.description) : null, e('div', { className: 'dbs-welcomeHint' }, isGroup ? t('chat.empty.group') : t('chat.empty.single', { name: agent?.name ?? '' })))
|
|
1622
|
+
: thread, composing
|
|
1623
|
+
? e('div', { className: 'dbs-typing', 'aria-label': t('chat.composing'), title: t('chat.composing') }, e('span', null), e('span', null), e('span', null))
|
|
1624
|
+
: null))), e('button', {
|
|
1625
|
+
type: 'button', className: 'dbs-jump', 'data-show': showJump ? 'true' : 'false',
|
|
1626
|
+
'aria-label': t('chat.jump'), title: t('chat.jump'),
|
|
1627
|
+
onClick: jumpToLatest,
|
|
1628
|
+
}, Ico('IconChevronDownOutline14', { size: 16 }))), e('div', { className: 'dbs-composerSeat' }, e('div', { className: 'dbs-composer' }, e('div', {
|
|
1629
|
+
className: 'dbs-composerCard',
|
|
1630
|
+
// The whole card is the input's hit area natively — clicking
|
|
1631
|
+
// its padding must land the caret, not swallow the click.
|
|
1632
|
+
// Buttons and the mention menu keep their own targets.
|
|
1633
|
+
onMouseDown: (ev) => {
|
|
1634
|
+
const node = inputRef.current;
|
|
1635
|
+
if (node === null || node === undefined || node === ev.target)
|
|
1636
|
+
return;
|
|
1637
|
+
if (typeof ev.target?.closest === 'function'
|
|
1638
|
+
&& ev.target.closest('button, textarea, input, .dbs-mention') !== null)
|
|
1639
|
+
return;
|
|
1640
|
+
ev.preventDefault();
|
|
1641
|
+
node.focus();
|
|
1642
|
+
},
|
|
1643
|
+
}, mentionHits.length > 0
|
|
1644
|
+
? e('div', { className: 'dbs-mention' }, mentionHits.map((a, i) => e('div', {
|
|
1645
|
+
key: a.id, className: 'dbs-mentionRow', 'data-active': i === (mention?.index ?? 0),
|
|
1646
|
+
onMouseDown: (ev) => { ev.preventDefault(); applyMention(a); },
|
|
1647
|
+
}, e(Avatar, { agent: a, size: 18 }), a.name)))
|
|
1648
|
+
: null, e('div', { className: 'dbs-composerScroll' }, e('textarea', {
|
|
1649
|
+
ref: inputRef, className: 'dbs-composerInput', value: input, rows: 1,
|
|
1650
|
+
placeholder: isGroup ? t('chat.placeholder.group') : t('chat.placeholder.single', { name: agent?.name ?? '' }),
|
|
1651
|
+
// Native chrome: no spellcheck squiggles, no autocomplete
|
|
1652
|
+
// dropdown over the card, and a plain multi-line seat.
|
|
1653
|
+
spellCheck: false, autoComplete: 'off', autoCorrect: 'off', autoCapitalize: 'off',
|
|
1654
|
+
onChange: (ev) => onInputChange(ev.target),
|
|
1655
|
+
// Caret moves (click, arrows, Home/End) re-evaluate the
|
|
1656
|
+
// mention token, so the menu tracks the caret, not typing.
|
|
1657
|
+
onSelect: (ev) => syncMention(ev.target),
|
|
1658
|
+
onCompositionStart: () => { imeRef.current = true; },
|
|
1659
|
+
onCompositionEnd: (ev) => {
|
|
1660
|
+
imeRef.current = false;
|
|
1661
|
+
// Firefox/Safari fire this *after* the keydown that
|
|
1662
|
+
// committed the candidate, so the committed value has to
|
|
1663
|
+
// be picked up here rather than in the change handler.
|
|
1664
|
+
onInputChange(ev.target);
|
|
1665
|
+
},
|
|
1666
|
+
onBlur: () => setMention(null),
|
|
1667
|
+
onKeyDown: (ev) => {
|
|
1668
|
+
// While an IME candidate window is open it owns Enter,
|
|
1669
|
+
// the arrows and Escape. `isComposing` is the standard
|
|
1670
|
+
// signal; keyCode 229 covers the engines that omit it.
|
|
1671
|
+
if (imeRef.current || ev.nativeEvent?.isComposing === true || ev.keyCode === 229)
|
|
1672
|
+
return;
|
|
1673
|
+
if (mentionHits.length > 0 && (ev.key === 'Enter' || ev.key === 'Tab')) {
|
|
1674
|
+
ev.preventDefault();
|
|
1675
|
+
applyMention(mentionHits[mention?.index ?? 0]);
|
|
1676
|
+
return;
|
|
1677
|
+
}
|
|
1678
|
+
if (mentionHits.length > 0 && (ev.key === 'ArrowDown' || ev.key === 'ArrowUp')) {
|
|
1679
|
+
ev.preventDefault();
|
|
1680
|
+
const d = ev.key === 'ArrowDown' ? 1 : -1;
|
|
1681
|
+
const n = mentionHits.length;
|
|
1682
|
+
setMention({ ...mention, index: (((mention?.index ?? 0) + d) % n + n) % n });
|
|
1683
|
+
return;
|
|
1684
|
+
}
|
|
1685
|
+
if (ev.key === 'Escape' && mention !== null) {
|
|
1686
|
+
// Dismissing the menu must not also reach the overlay's
|
|
1687
|
+
// window-level Escape, which would close the chat.
|
|
1688
|
+
ev.preventDefault();
|
|
1689
|
+
ev.stopPropagation();
|
|
1690
|
+
setMention(null);
|
|
1691
|
+
return;
|
|
1692
|
+
}
|
|
1693
|
+
if (ev.key === 'Enter' && !ev.shiftKey) {
|
|
1694
|
+
ev.preventDefault();
|
|
1695
|
+
void doSend();
|
|
1696
|
+
}
|
|
1697
|
+
},
|
|
1698
|
+
})), e('div', { className: 'dbs-composerRow' }, e('span', { className: 'dbs-meta' }, composing ? t('chat.composingHint') : input.trim() !== '' ? t('chat.charCount', { n: input.trim().length }) : ''), e('div', { className: 'dbs-composerTrailing' }, composing
|
|
1699
|
+
? e('button', {
|
|
1700
|
+
type: 'button', className: 'dbs-send',
|
|
1701
|
+
// Native contract (§12-29): the send button morphs
|
|
1702
|
+
// into a stop square while a run is active. The
|
|
1703
|
+
// gateway now exposes interruptAgent (engine patch,
|
|
1704
|
+
// e2e-verified), so the stop is real — `hadActiveRun`
|
|
1705
|
+
// tells "stopped" from "nothing to stop".
|
|
1706
|
+
disabled: stopping,
|
|
1707
|
+
title: t('action.stop'), 'aria-label': t('action.stop'),
|
|
1708
|
+
onClick: () => void doStop(),
|
|
1709
|
+
}, e(StopSquareIcon, null))
|
|
1710
|
+
: e('button', {
|
|
1711
|
+
type: 'button', className: 'dbs-send',
|
|
1712
|
+
disabled: sending || input.trim() === '',
|
|
1713
|
+
title: t('action.send'),
|
|
1714
|
+
'aria-label': t('action.send'),
|
|
1715
|
+
onClick: () => void doSend(),
|
|
1716
|
+
}, e(SendUpIcon, null))))))));
|
|
1717
|
+
}
|
|
1718
|
+
// =========================================================
|
|
1719
|
+
// Settings section.
|
|
1720
|
+
// =========================================================
|
|
1721
|
+
/**
|
|
1722
|
+
* MCP servers card (DEVELOPMENT.md §13): the engine hosts the MCP
|
|
1723
|
+
* stack, so this surface is a thin management view — list with live
|
|
1724
|
+
* status, add (stdio or URL config JSON), remove (two-click confirm),
|
|
1725
|
+
* restart. Tool try-run lives in the workbench, not here.
|
|
1726
|
+
*/
|
|
1727
|
+
function McpCard() {
|
|
1728
|
+
const [servers, setServers] = React.useState(null);
|
|
1729
|
+
const [toolCount, setToolCount] = React.useState(null);
|
|
1730
|
+
const [err, setErr] = React.useState(null);
|
|
1731
|
+
const [busy, setBusy] = React.useState(false);
|
|
1732
|
+
const [confirmId, setConfirmId] = React.useState(null);
|
|
1733
|
+
const [adding, setAdding] = React.useState(false);
|
|
1734
|
+
const [name, setName] = React.useState('');
|
|
1735
|
+
const [configJson, setConfigJson] = React.useState('');
|
|
1736
|
+
async function reload() {
|
|
1737
|
+
setErr(null);
|
|
1738
|
+
try {
|
|
1739
|
+
const s = await botsCall('mcpServers', {});
|
|
1740
|
+
setServers((s?.servers ?? []));
|
|
1741
|
+
const tl = await botsCall('mcpTools', {});
|
|
1742
|
+
setToolCount((tl?.tools ?? []).length);
|
|
1743
|
+
}
|
|
1744
|
+
catch (e2) {
|
|
1745
|
+
setErr(String(e2?.message ?? e2));
|
|
1746
|
+
}
|
|
1747
|
+
}
|
|
1748
|
+
React.useEffect(() => { void reload(); }, []);
|
|
1749
|
+
async function run(fn) {
|
|
1750
|
+
setBusy(true);
|
|
1751
|
+
setErr(null);
|
|
1752
|
+
try {
|
|
1753
|
+
await fn();
|
|
1754
|
+
await reload();
|
|
1755
|
+
}
|
|
1756
|
+
catch (e2) {
|
|
1757
|
+
setErr(String(e2?.message ?? e2));
|
|
1758
|
+
}
|
|
1759
|
+
finally {
|
|
1760
|
+
setBusy(false);
|
|
1761
|
+
}
|
|
1762
|
+
}
|
|
1763
|
+
function dotState(status) {
|
|
1764
|
+
if (status === 'connected')
|
|
1765
|
+
return 'done';
|
|
1766
|
+
if (status === 'needsAuth')
|
|
1767
|
+
return 'warning';
|
|
1768
|
+
if (status === 'error')
|
|
1769
|
+
return 'failed';
|
|
1770
|
+
return 'ongoing';
|
|
1771
|
+
}
|
|
1772
|
+
async function submitAdd() {
|
|
1773
|
+
if (busy || name.trim() === '' || configJson.trim() === '')
|
|
1774
|
+
return;
|
|
1775
|
+
await run(async () => {
|
|
1776
|
+
await botsCall('mcpAdd', { name: name.trim(), configJson: configJson.trim() });
|
|
1777
|
+
setAdding(false);
|
|
1778
|
+
setName('');
|
|
1779
|
+
setConfigJson('');
|
|
1780
|
+
});
|
|
1781
|
+
}
|
|
1782
|
+
return e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, e('span', { className: 'dbs-title' }, t('settings.mcp')), e('span', { style: { flex: 1 } }), e(Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: () => void reload() }, t('action.refresh')), e(Button, {
|
|
1783
|
+
variant: 'ghost', size: 'sm', disabled: busy,
|
|
1784
|
+
onClick: () => void run(() => botsCall('mcpRefresh', {})),
|
|
1785
|
+
}, busy ? t('settings.mcp.restarting') : t('settings.mcp.restart')), e(Button, { variant: 'outline', size: 'sm', disabled: busy, onClick: () => setAdding(!adding) }, t('settings.mcp.add'))), e('div', { className: 'dbs-setrow' }, t('settings.mcp.summary')), toolCount !== null
|
|
1786
|
+
? e('div', { className: 'dbs-setrow' }, t('settings.mcp.tools'), e('b', null, String(toolCount)))
|
|
1787
|
+
: null, adding ? e('div', { className: 'dbs-setcard', style: { margin: '6px 0' } }, e(Input, {
|
|
1788
|
+
placeholder: t('settings.mcp.namePlaceholder'), value: name, disabled: busy,
|
|
1789
|
+
onChange: (ev) => setName(ev.target.value),
|
|
1790
|
+
}), e('textarea', {
|
|
1791
|
+
placeholder: t('settings.mcp.configPlaceholder'), value: configJson, disabled: busy,
|
|
1792
|
+
rows: 3, onChange: (ev) => setConfigJson(ev.target.value),
|
|
1793
|
+
style: {
|
|
1794
|
+
width: '100%', marginTop: 6, padding: '6px 8px', fontSize: 12, lineHeight: '18px',
|
|
1795
|
+
borderRadius: 8, border: '1px solid var(--dsw-alias-border-l, #ccc)', resize: 'vertical',
|
|
1796
|
+
background: 'transparent', color: 'var(--dsw-alias-label-primary, inherit)', fontFamily: 'monospace',
|
|
1797
|
+
},
|
|
1798
|
+
}), e('div', { className: 'dbs-setrow', style: { marginTop: 6 } }, e(Button, {
|
|
1799
|
+
variant: 'ghost', size: 'sm', disabled: busy,
|
|
1800
|
+
onClick: () => setConfigJson(t('settings.mcp.exampleStdioValue')),
|
|
1801
|
+
}, t('settings.mcp.exampleStdio')), e(Button, {
|
|
1802
|
+
variant: 'ghost', size: 'sm', disabled: busy,
|
|
1803
|
+
onClick: () => setConfigJson(t('settings.mcp.exampleUrlValue')),
|
|
1804
|
+
}, t('settings.mcp.exampleUrl')), e('span', { style: { flex: 1 } }), e(Button, {
|
|
1805
|
+
variant: 'primary', size: 'sm', disabled: busy || name.trim() === '' || configJson.trim() === '',
|
|
1806
|
+
onClick: () => void submitAdd(),
|
|
1807
|
+
}, busy ? t('settings.mcp.adding') : t('settings.mcp.add')))) : null, servers === null
|
|
1808
|
+
? e('div', { className: 'dbs-setrow' }, t('settings.reading'))
|
|
1809
|
+
: servers.length === 0
|
|
1810
|
+
? e('div', { className: 'dbs-setrow' }, t('settings.mcp.empty'))
|
|
1811
|
+
: servers.map((sv) => e('div', {
|
|
1812
|
+
key: sv.id || sv.serverIdentifier, className: 'dbs-setrow',
|
|
1813
|
+
style: { alignItems: 'center' },
|
|
1814
|
+
}, StateDot !== null ? e(StateDot, { state: dotState(sv.status), size: 8 }) : null, e('b', null, sv.name || sv.serverIdentifier), e('span', { className: 'dbs-meta' }, ` ${sv.status}${sv.transport !== '' ? ' · ' + sv.transport : ''} · ${sv.toolCount} tools`), sv.statusDetail ? e('span', { className: 'dbs-meta' }, ` · ${sv.statusDetail}`) : null, e('span', { style: { flex: 1 } }), e(Button, {
|
|
1815
|
+
variant: 'ghost', size: 'sm', disabled: busy,
|
|
1816
|
+
onClick: () => {
|
|
1817
|
+
if (confirmId !== sv.id) {
|
|
1818
|
+
setConfirmId(sv.id);
|
|
1819
|
+
return;
|
|
1820
|
+
}
|
|
1821
|
+
setConfirmId(null);
|
|
1822
|
+
void run(() => botsCall('mcpRemove', { serverId: sv.id }));
|
|
1823
|
+
},
|
|
1824
|
+
}, confirmId === sv.id ? t('settings.mcp.removeConfirm') : t('settings.mcp.remove')))), err !== null ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.failed'), err) : null);
|
|
1825
|
+
}
|
|
1826
|
+
/**
|
|
1827
|
+
* Workspace card: per-agent Seatbelt jail management (the engine owns
|
|
1828
|
+
* the isolation; this surface edits each agent's settings.json jail
|
|
1829
|
+
* keys — see DEVELOPMENT.md §14). Toggling writes the config; the jail
|
|
1830
|
+
* arms on the bot's NEXT turn, no restart.
|
|
1831
|
+
*/
|
|
1832
|
+
function WorkspaceCard() {
|
|
1833
|
+
const [rows, setRows] = React.useState(null);
|
|
1834
|
+
const [names, setNames] = React.useState({});
|
|
1835
|
+
const [err, setErr] = React.useState(null);
|
|
1836
|
+
const [busy, setBusy] = React.useState(false);
|
|
1837
|
+
const [confirmId, setConfirmId] = React.useState(null);
|
|
1838
|
+
async function reload() {
|
|
1839
|
+
setErr(null);
|
|
1840
|
+
try {
|
|
1841
|
+
const ws = await botsCall('workspaceList', {});
|
|
1842
|
+
setRows((ws?.workspaces ?? []));
|
|
1843
|
+
const agents = await botsCall('list', {});
|
|
1844
|
+
const map = {};
|
|
1845
|
+
for (const a of agents?.agents ?? [])
|
|
1846
|
+
map[a.id] = a.name;
|
|
1847
|
+
setNames(map);
|
|
1848
|
+
}
|
|
1849
|
+
catch (e2) {
|
|
1850
|
+
setErr(String(e2?.message ?? e2));
|
|
1851
|
+
}
|
|
1852
|
+
}
|
|
1853
|
+
React.useEffect(() => { void reload(); }, []);
|
|
1854
|
+
async function toggle(row) {
|
|
1855
|
+
if (busy)
|
|
1856
|
+
return;
|
|
1857
|
+
setBusy(true);
|
|
1858
|
+
setErr(null);
|
|
1859
|
+
try {
|
|
1860
|
+
if (row.workspaceRoot === null) {
|
|
1861
|
+
const slug = (names[row.agentId] ?? row.agentId).trim();
|
|
1862
|
+
await botsCall('workspaceSet', { agentId: row.agentId, workspaceRoot: '/workspace/' + slug });
|
|
1863
|
+
}
|
|
1864
|
+
else {
|
|
1865
|
+
await botsCall('workspaceSet', { agentId: row.agentId, workspaceRoot: null });
|
|
1866
|
+
}
|
|
1867
|
+
await reload();
|
|
1868
|
+
}
|
|
1869
|
+
catch (e2) {
|
|
1870
|
+
setErr(String(e2?.message ?? e2));
|
|
1871
|
+
}
|
|
1872
|
+
finally {
|
|
1873
|
+
setBusy(false);
|
|
1874
|
+
}
|
|
1875
|
+
}
|
|
1876
|
+
const jailed = (rows ?? []).filter((r) => r.workspaceRoot !== null).length;
|
|
1877
|
+
return e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, e('span', { className: 'dbs-title' }, t('settings.jail.title')), e('span', { style: { flex: 1 } }), e(Button, { variant: 'ghost', size: 'sm', disabled: busy, onClick: () => void reload() }, t('action.refresh'))), e('div', { className: 'dbs-setrow' }, t('settings.jail.summary')), rows !== null ? e('div', { className: 'dbs-setrow' }, t('settings.jail.count'), e('b', null, `${jailed} / ${rows.length}`)) : null, rows === null
|
|
1878
|
+
? e('div', { className: 'dbs-setrow' }, t('settings.reading'))
|
|
1879
|
+
: rows.map((row) => e('div', {
|
|
1880
|
+
key: row.agentId, className: 'dbs-setrow', style: { alignItems: 'center' },
|
|
1881
|
+
}, row.workspaceRoot !== null && StateDot !== null ? e(StateDot, { state: 'done', size: 8 }) : null, e('b', null, names[row.agentId] ?? row.agentId), row.workspaceRoot !== null
|
|
1882
|
+
? e('span', { className: 'dbs-meta' }, ` ${row.workspaceRoot}${row.allowPaths.length > 0 ? ` · +${row.allowPaths.length}` : ''}`)
|
|
1883
|
+
: null, e('span', { style: { flex: 1 } }), e(Button, {
|
|
1884
|
+
variant: row.workspaceRoot !== null ? 'ghost' : 'outline', size: 'sm', disabled: busy,
|
|
1885
|
+
onClick: () => {
|
|
1886
|
+
if (row.workspaceRoot !== null && confirmId !== row.agentId) {
|
|
1887
|
+
setConfirmId(row.agentId);
|
|
1888
|
+
return;
|
|
1889
|
+
}
|
|
1890
|
+
setConfirmId(null);
|
|
1891
|
+
void toggle(row);
|
|
1892
|
+
},
|
|
1893
|
+
}, row.workspaceRoot !== null
|
|
1894
|
+
? (confirmId === row.agentId ? t('settings.jail.offConfirm') : t('settings.jail.off'))
|
|
1895
|
+
: t('settings.jail.on')))), err !== null ? e('div', { className: 'dbs-setrow' }, t('settings.mcp.failed'), err) : null);
|
|
1896
|
+
}
|
|
1897
|
+
function BotsSettings() {
|
|
1898
|
+
const [info, setInfo] = React.useState(undefined);
|
|
1899
|
+
const [sse, setSse] = React.useState(null);
|
|
1900
|
+
const [err, setErr] = React.useState(null);
|
|
1901
|
+
async function refresh() {
|
|
1902
|
+
setInfo(undefined);
|
|
1903
|
+
setErr(null);
|
|
1904
|
+
try {
|
|
1905
|
+
setInfo(await botsCall('gatewayInfo', {}));
|
|
1906
|
+
setSse(await botsCall('sseState'));
|
|
1907
|
+
}
|
|
1908
|
+
catch (e2) {
|
|
1909
|
+
setErr(String(e2?.message ?? e2));
|
|
1910
|
+
}
|
|
1911
|
+
}
|
|
1912
|
+
React.useEffect(() => { void refresh(); }, []);
|
|
1913
|
+
const ok = info?.ok === true;
|
|
1914
|
+
return e('div', { className: 'dbs-settings' }, e('div', { className: 'dbs-setrow' }, t('settings.summary')), e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-sethead' }, StateDot !== null ? e(StateDot, { state: ok ? 'done' : 'failed', size: 10 }) : null, info === undefined ? t('settings.probing') : ok ? t('gateway.online', { port: info.port }) : t('gateway.offline'), e('span', { style: { flex: 1 } }), e(Button, { variant: 'outline', size: 'sm', onClick: () => void refresh() }, t('action.refresh'))), ok ? e('div', null, e('div', { className: 'dbs-setrow' }, t('settings.address'), e('b', null, info.baseUrl)), e('div', { className: 'dbs-setrow' }, t('settings.pid'), e('b', null, String(info.pid)), ' · ' + (info.health?.isBusy === true ? t('settings.busy') : t('settings.idle'))), e('div', { className: 'dbs-setrow' }, t('settings.auth'), e('b', null, info.hasToken === true ? t('settings.auth.token') : t('settings.auth.none')))) : null, info !== undefined && !ok ? e('div', { className: 'dbs-setrow' }, t('settings.reason'), e('b', null, info.reason ?? t('settings.reason.unknown'))) : null, err !== null ? e('div', { className: 'dbs-setrow' }, err) : null), e('div', { className: 'dbs-setcard' }, e('div', { className: 'dbs-setrow' }, t('settings.dataDir'), e('b', null, info?.dataDir ?? t('settings.reading'))), info?.workspaceRoot
|
|
1915
|
+
? e('div', { className: 'dbs-setrow' }, t('settings.workspaceRoot'), e('b', null, info.workspaceRoot))
|
|
1916
|
+
: null, e('div', { className: 'dbs-setrow' }, t('settings.events'), e('b', null, sse?.running === true ? t('settings.events.on', { n: sse.buffered ?? 0 }) : t('settings.events.off')), sse?.lastError ? ' · ' + String(sse.lastError) : ''), e('div', { className: 'dbs-setrow' }, t('settings.entry'), e('b', null, t('settings.entry.value')))), info?.ok === true ? e(WorkspaceCard) : null, info?.ok === true ? e(McpCard) : null);
|
|
1917
|
+
}
|
|
1918
|
+
// =========================================================
|
|
1919
|
+
// Overlay root: hosts the chat surface only.
|
|
1920
|
+
// =========================================================
|
|
1921
|
+
/**
|
|
1922
|
+
* Create dialog ("新建 Bot" / "新建群聊") as a system-style modal:
|
|
1923
|
+
* fixed backdrop + centred card rendered from shell.overlay, matching
|
|
1924
|
+
* the wfx main-window modal parameters (rgba(0,0,0,.45), bg-layer-1,
|
|
1925
|
+
* 14px radius, entrance animation). ESC and backdrop clicks dismiss.
|
|
1926
|
+
*/
|
|
1927
|
+
function CreateModal() {
|
|
1928
|
+
const s = useStore();
|
|
1929
|
+
const [err, setErr] = React.useState(null);
|
|
1930
|
+
const isGroup = s.create === 'group';
|
|
1931
|
+
const singles = s.agents.filter((a) => !a.isGroup && a.isHiddenFromSidebar !== true);
|
|
1932
|
+
const picked = Object.keys(s.createMembers).filter((k) => s.createMembers[k]);
|
|
1933
|
+
const nameOk = s.createName.trim() !== '';
|
|
1934
|
+
const valid = nameOk && (!isGroup || picked.length > 0);
|
|
1935
|
+
async function submit() {
|
|
1936
|
+
if (!valid || s.createWorking)
|
|
1937
|
+
return;
|
|
1938
|
+
patch({ createWorking: true, error: null });
|
|
1939
|
+
setErr(null);
|
|
1940
|
+
try {
|
|
1941
|
+
if (isGroup)
|
|
1942
|
+
await botsCall('createGroup', { name: s.createName.trim(), memberIds: picked });
|
|
1943
|
+
else
|
|
1944
|
+
await botsCall('create', { name: s.createName.trim(), description: s.createDesc.trim() });
|
|
1945
|
+
patch({ create: null, createName: '', createDesc: '', createMembers: {}, createWorking: false });
|
|
1946
|
+
await refreshAgents();
|
|
1947
|
+
return;
|
|
1948
|
+
}
|
|
1949
|
+
catch (e2) {
|
|
1950
|
+
setErr(String(e2?.message ?? e2));
|
|
1951
|
+
}
|
|
1952
|
+
patch({ createWorking: false });
|
|
1953
|
+
}
|
|
1954
|
+
return e('div', {
|
|
1955
|
+
className: 'dbs-modalBackdrop',
|
|
1956
|
+
onClick: () => { if (!s.createWorking)
|
|
1957
|
+
patch({ create: null }); },
|
|
1958
|
+
}, e('div', {
|
|
1959
|
+
className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
|
|
1960
|
+
'aria-label': isGroup ? t('group.new') : t('bot.new'),
|
|
1961
|
+
onClick: (ev) => { ev.stopPropagation(); },
|
|
1962
|
+
}, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, isGroup ? t('group.new') : t('bot.new')), e(Button, {
|
|
1963
|
+
variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
|
|
1964
|
+
disabled: s.createWorking,
|
|
1965
|
+
icon: Ico('IconCloseOutline16', { size: 16 }),
|
|
1966
|
+
onClick: () => patch({ create: null }),
|
|
1967
|
+
})), e('div', { className: 'dbs-modalBody' }, e(Input, {
|
|
1968
|
+
placeholder: isGroup ? t('group.namePlaceholder') : t('bot.namePlaceholder'),
|
|
1969
|
+
value: s.createName, autoFocus: true, disabled: s.createWorking,
|
|
1970
|
+
onChange: (ev) => patch({ createName: ev.target.value }),
|
|
1971
|
+
onKeyDown: (ev) => { if (ev.key === 'Enter') {
|
|
1972
|
+
ev.preventDefault();
|
|
1973
|
+
void submit();
|
|
1974
|
+
} },
|
|
1975
|
+
}), !isGroup
|
|
1976
|
+
? e(Input, {
|
|
1977
|
+
placeholder: t('bot.descPlaceholder'), value: s.createDesc, disabled: s.createWorking,
|
|
1978
|
+
onChange: (ev) => patch({ createDesc: ev.target.value }),
|
|
1979
|
+
})
|
|
1980
|
+
: e('div', { className: 'dbs-modalMembers' }, singles.length === 0
|
|
1981
|
+
? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.loading'))
|
|
1982
|
+
: singles.map((m) => e('div', {
|
|
1983
|
+
key: m.id,
|
|
1984
|
+
className: 'dbs-member' + (s.createMembers[m.id] ? ' checked' : ''),
|
|
1985
|
+
style: { cursor: 'pointer', padding: '4px 6px', borderRadius: 8 },
|
|
1986
|
+
onClick: () => patch({ createMembers: { ...s.createMembers, [m.id]: !s.createMembers[m.id] } }),
|
|
1987
|
+
}, e('input', { type: 'checkbox', checked: Boolean(s.createMembers[m.id]), readOnly: true }), e(Avatar, { agent: m, size: 18 }), e('span', { className: 'dbs-title' }, m.name))), isGroup && singles.length > 0
|
|
1988
|
+
? e('div', { className: 'dbs-meta', style: { padding: '6px 6px 0' } }, t('chat.group', { n: picked.length }))
|
|
1989
|
+
: null), err !== null
|
|
1990
|
+
? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
|
|
1991
|
+
: null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
|
|
1992
|
+
variant: 'ghost', size: 'sm', disabled: s.createWorking,
|
|
1993
|
+
onClick: () => patch({ create: null }),
|
|
1994
|
+
}, t('action.cancel')), e(Button, {
|
|
1995
|
+
variant: 'primary', size: 'sm', disabled: !valid || s.createWorking,
|
|
1996
|
+
onClick: () => void submit(),
|
|
1997
|
+
}, t('action.create')))));
|
|
1998
|
+
}
|
|
1999
|
+
/**
|
|
2000
|
+
* Manage group members: full-set checkbox editor over the group's
|
|
2001
|
+
* current memberIds — add and remove are the same save (the gateway
|
|
2002
|
+
* command is a member-list put, not a delta). Same dialog chrome and
|
|
2003
|
+
* member-row markup as CreateModal.
|
|
2004
|
+
*/
|
|
2005
|
+
function ManageMembersModal() {
|
|
2006
|
+
const s = useStore();
|
|
2007
|
+
const group = agentById(s.manageMembers);
|
|
2008
|
+
const [picked, setPicked] = React.useState(null);
|
|
2009
|
+
const [err, setErr] = React.useState(null);
|
|
2010
|
+
const [working, setWorking] = React.useState(false);
|
|
2011
|
+
// Seed once per opened group; a group vanishing mid-edit (deleted by
|
|
2012
|
+
// the swarm, say) just renders the modal inert until closed.
|
|
2013
|
+
React.useEffect(() => {
|
|
2014
|
+
if (group === null || group === undefined)
|
|
2015
|
+
return;
|
|
2016
|
+
const init = {};
|
|
2017
|
+
for (const id of group.memberIds ?? [])
|
|
2018
|
+
init[id] = true;
|
|
2019
|
+
setPicked(init);
|
|
2020
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
2021
|
+
}, [s.manageMembers]);
|
|
2022
|
+
if (group === null || group === undefined)
|
|
2023
|
+
return null;
|
|
2024
|
+
const singles = s.agents.filter((a) => !a.isGroup && a.isHiddenFromSidebar !== true);
|
|
2025
|
+
const pickedIds = picked === null ? [] : Object.keys(picked).filter((k) => picked[k]);
|
|
2026
|
+
async function submit() {
|
|
2027
|
+
if (working || picked === null)
|
|
2028
|
+
return;
|
|
2029
|
+
setWorking(true);
|
|
2030
|
+
setErr(null);
|
|
2031
|
+
try {
|
|
2032
|
+
await botsCall('setGroupMembers', { id: group.id, memberIds: pickedIds });
|
|
2033
|
+
patch({ manageMembers: null });
|
|
2034
|
+
await refreshAgents();
|
|
2035
|
+
return;
|
|
2036
|
+
}
|
|
2037
|
+
catch (e2) {
|
|
2038
|
+
setErr(String(e2?.message ?? e2));
|
|
2039
|
+
}
|
|
2040
|
+
setWorking(false);
|
|
2041
|
+
}
|
|
2042
|
+
return e('div', {
|
|
2043
|
+
className: 'dbs-modalBackdrop',
|
|
2044
|
+
onClick: () => { if (!working)
|
|
2045
|
+
patch({ manageMembers: null }); },
|
|
2046
|
+
}, e('div', {
|
|
2047
|
+
className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
|
|
2048
|
+
'aria-label': t('modal.members.title'),
|
|
2049
|
+
onClick: (ev) => { ev.stopPropagation(); },
|
|
2050
|
+
}, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, t('modal.members.title')), e(Button, {
|
|
2051
|
+
variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
|
|
2052
|
+
disabled: working,
|
|
2053
|
+
icon: Ico('IconCloseOutline16', { size: 16 }),
|
|
2054
|
+
onClick: () => patch({ manageMembers: null }),
|
|
2055
|
+
})), e('div', { className: 'dbs-modalBody' }, e('div', { className: 'dbs-meta', style: { padding: '0 2px 6px' } }, t('modal.members.hint')), picked === null
|
|
2056
|
+
? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.loading'))
|
|
2057
|
+
: e('div', { className: 'dbs-modalMembers' }, singles.length === 0
|
|
2058
|
+
? e('div', { className: 'dbs-meta', style: { padding: '4px 6px' } }, t('list.empty'))
|
|
2059
|
+
: singles.map((m) => e('div', {
|
|
2060
|
+
key: m.id,
|
|
2061
|
+
className: 'dbs-member' + (picked[m.id] ? ' checked' : ''),
|
|
2062
|
+
style: { cursor: 'pointer', padding: '4px 6px', borderRadius: 8 },
|
|
2063
|
+
onClick: () => setPicked({ ...picked, [m.id]: !picked[m.id] }),
|
|
2064
|
+
}, e('input', { type: 'checkbox', checked: Boolean(picked[m.id]), readOnly: true }), e(Avatar, { agent: m, size: 18 }), e('span', { className: 'dbs-title' }, m.name))), singles.length > 0
|
|
2065
|
+
? e('div', { className: 'dbs-meta', style: { padding: '6px 6px 0' } }, t('chat.group', { n: pickedIds.length }))
|
|
2066
|
+
: null), err !== null
|
|
2067
|
+
? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
|
|
2068
|
+
: null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
|
|
2069
|
+
variant: 'ghost', size: 'sm', disabled: working,
|
|
2070
|
+
onClick: () => patch({ manageMembers: null }),
|
|
2071
|
+
}, t('action.cancel')), e(Button, {
|
|
2072
|
+
variant: 'primary', size: 'sm', disabled: picked === null || working,
|
|
2073
|
+
onClick: () => void submit(),
|
|
2074
|
+
}, working ? t('action.saving') : t('action.save')))));
|
|
2075
|
+
}
|
|
2076
|
+
/** Delete confirmation modal: same system-dialog chrome as CreateModal. */
|
|
2077
|
+
function ConfirmDeleteModal() {
|
|
2078
|
+
const s = useStore();
|
|
2079
|
+
const target = s.confirmDelete;
|
|
2080
|
+
const [err, setErr] = React.useState(null);
|
|
2081
|
+
const [working, setWorking] = React.useState(false);
|
|
2082
|
+
const isGroup = target?.isGroup === true;
|
|
2083
|
+
async function doDelete() {
|
|
2084
|
+
if (target === null || target === undefined || working)
|
|
2085
|
+
return;
|
|
2086
|
+
setWorking(true);
|
|
2087
|
+
setErr(null);
|
|
2088
|
+
try {
|
|
2089
|
+
await botsCall('remove', { id: target.id });
|
|
2090
|
+
// The chat for a deleted bot must not survive its bot.
|
|
2091
|
+
const closeChat = state.chatAgentId === target.id;
|
|
2092
|
+
patch({ confirmDelete: null });
|
|
2093
|
+
if (closeChat)
|
|
2094
|
+
patch({ chatAgentId: null });
|
|
2095
|
+
await refreshAgents();
|
|
2096
|
+
return;
|
|
2097
|
+
}
|
|
2098
|
+
catch (e2) {
|
|
2099
|
+
setErr(String(e2?.message ?? e2));
|
|
2100
|
+
}
|
|
2101
|
+
setWorking(false);
|
|
2102
|
+
}
|
|
2103
|
+
return e('div', {
|
|
2104
|
+
className: 'dbs-modalBackdrop',
|
|
2105
|
+
onClick: () => { if (!working)
|
|
2106
|
+
patch({ confirmDelete: null }); },
|
|
2107
|
+
}, e('div', {
|
|
2108
|
+
className: 'dbs-modalCard', role: 'dialog', 'aria-modal': true,
|
|
2109
|
+
'aria-label': isGroup ? t('delete.title.group') : t('delete.title.bot'),
|
|
2110
|
+
onClick: (ev) => { ev.stopPropagation(); },
|
|
2111
|
+
}, e('div', { className: 'dbs-modalTitleRow' }, e('span', { className: 'dbs-modalTitle' }, isGroup ? t('delete.title.group') : t('delete.title.bot')), e(Button, {
|
|
2112
|
+
variant: 'ghost', size: 'sm', title: t('action.close'), 'aria-label': t('action.close'),
|
|
2113
|
+
disabled: working,
|
|
2114
|
+
icon: Ico('IconCloseOutline16', { size: 16 }),
|
|
2115
|
+
onClick: () => patch({ confirmDelete: null }),
|
|
2116
|
+
})), e('div', { className: 'dbs-modalBody' }, e('span', { className: 'dbs-meta', style: { fontSize: 14, lineHeight: 22 } }, t('delete.confirm', { name: target?.name ?? '' })), err !== null
|
|
2117
|
+
? e('div', { className: 'dbs-error', onClick: () => { setErr(null); } }, err)
|
|
2118
|
+
: null), e('div', { className: 'dbs-modalFooter' }, e(Button, {
|
|
2119
|
+
variant: 'ghost', size: 'sm', disabled: working,
|
|
2120
|
+
onClick: () => patch({ confirmDelete: null }),
|
|
2121
|
+
}, t('action.cancel')), e(Button, {
|
|
2122
|
+
variant: 'primary', size: 'sm', disabled: working,
|
|
2123
|
+
onClick: () => void doDelete(),
|
|
2124
|
+
}, working ? t('delete.working') : t('action.delete')))));
|
|
2125
|
+
}
|
|
2126
|
+
function BotsLayer() {
|
|
2127
|
+
const s = useStore();
|
|
2128
|
+
React.useEffect(() => {
|
|
2129
|
+
function onKey(ev) {
|
|
2130
|
+
if (ev.key !== 'Escape')
|
|
2131
|
+
return;
|
|
2132
|
+
// Topmost dialog first: delete confirm, then create, then chat.
|
|
2133
|
+
if (state.confirmDelete !== null) {
|
|
2134
|
+
patch({ confirmDelete: null });
|
|
2135
|
+
return;
|
|
2136
|
+
}
|
|
2137
|
+
if (state.create !== null) {
|
|
2138
|
+
patch({ create: null });
|
|
2139
|
+
return;
|
|
2140
|
+
}
|
|
2141
|
+
if (state.chatAgentId !== null)
|
|
2142
|
+
patch({ chatAgentId: null });
|
|
2143
|
+
}
|
|
2144
|
+
window.addEventListener('keydown', onKey);
|
|
2145
|
+
return () => { window.removeEventListener('keydown', onKey); };
|
|
2146
|
+
}, []);
|
|
2147
|
+
const layers = [];
|
|
2148
|
+
if (s.chatAgentId !== null)
|
|
2149
|
+
layers.push(e(ChatView, { agentId: s.chatAgentId, key: s.chatAgentId }));
|
|
2150
|
+
if (s.create !== null)
|
|
2151
|
+
layers.push(e(CreateModal, { key: 'create' }));
|
|
2152
|
+
if (s.manageMembers !== null)
|
|
2153
|
+
layers.push(e(ManageMembersModal, { key: 'manage-members' }));
|
|
2154
|
+
if (s.confirmDelete !== null)
|
|
2155
|
+
layers.push(e(ConfirmDeleteModal, { key: 'confirm-delete' }));
|
|
2156
|
+
return layers.length === 0 ? null : e('div', null, layers);
|
|
2157
|
+
}
|
|
2158
|
+
// =========================================================
|
|
2159
|
+
// Slot registration (formal runtime contract).
|
|
2160
|
+
// =========================================================
|
|
2161
|
+
const inject = ['connection', 'slots', 'locale'];
|
|
2162
|
+
function apply(c) {
|
|
2163
|
+
ctx = c;
|
|
2164
|
+
connectionSvc = c.get('connection');
|
|
2165
|
+
slotsSvc = c.get('slots');
|
|
2166
|
+
const slots = slotsSvc;
|
|
2167
|
+
if (slots === undefined || slots === null)
|
|
2168
|
+
return;
|
|
2169
|
+
c.effect(() => {
|
|
2170
|
+
const styleEl = document.createElement('style');
|
|
2171
|
+
styleEl.setAttribute('data-dsh-plugin', 'dsh-bots');
|
|
2172
|
+
styleEl.textContent = CSS;
|
|
2173
|
+
document.head.appendChild(styleEl);
|
|
2174
|
+
return () => { styleEl.remove(); };
|
|
2175
|
+
}, 'dsh-bots: styles');
|
|
2176
|
+
// Dictionaries first: a slot may render on the same tick it registers.
|
|
2177
|
+
const locale = c.get('locale');
|
|
2178
|
+
if (locale !== undefined && locale !== null) {
|
|
2179
|
+
c.effect(() => locale.register(NS, { zh, en }), 'dsh-bots: dictionaries');
|
|
2180
|
+
boundT = locale.bind(NS);
|
|
2181
|
+
// Our surfaces read `t` from module scope rather than from the prop
|
|
2182
|
+
// the renderer hands the slot root, because the strings live six
|
|
2183
|
+
// components deep. That means a language switch has to be pushed
|
|
2184
|
+
// into our own store to re-render them.
|
|
2185
|
+
c.effect(() => locale.subscribe(() => { patch({ localeRev: state.localeRev + 1 }); }), 'dsh-bots: locale refresh');
|
|
2186
|
+
}
|
|
2187
|
+
c.effect(() => slots.inject('shell.overlay', () => slots.register({ name: 'shell.overlay', id: 'dsh-bots.chat', order: 20, registrant: 'dsh-bots', locale: NS }, () => e(BotsLayer))), 'dsh-bots: chat overlay');
|
|
2188
|
+
// Shadow the single workspace slot at a lower priority (lowest renders);
|
|
2189
|
+
// the shipped entry stays registered and is delegated to by name.
|
|
2190
|
+
c.effect(() => slots.inject('sidebar.workspaces', () => slots.register({ name: 'sidebar.workspaces', priority: -100, registrant: 'dsh-bots', locale: NS }, (props) => e(SidebarNav, { wide: props.wide, expandSidebar: props.expandSidebar }))), 'dsh-bots: sidebar workspaces shadow');
|
|
2191
|
+
c.effect(() => slots.inject('settings.section', () => slots.register({ name: 'settings.section', id: 'bots', order: 40, label: () => t('nav.bots'), registrant: 'dsh-bots', locale: NS }, () => e(BotsSettings))), 'dsh-bots: settings section');
|
|
2192
|
+
reportDiag('apply', { slots: ['shell.overlay', 'sidebar.workspaces', 'settings.section'] });
|
|
2193
|
+
}
|
|
2194
|
+
exports.apply = apply;
|
|
2195
|
+
exports.inject = inject;
|
|
2196
|
+
return module.exports;
|
|
2197
|
+
},
|
|
2198
|
+
});
|
|
2199
|
+
})();
|