@oar-sh/oar 0.9.15 → 0.9.17
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/.github/extensions/web-relay/polling/polling-loop.mjs +14 -87
- package/CHANGELOG.md +305 -0
- package/README.md +12 -11
- package/package.json +2 -1
- package/server/claude-worker/claude-attachments.mjs +7 -4
- package/server/claude-worker/claude-remote-relay-tool.mjs +36 -1
- package/server/claude-worker/claude-sdk-adapter.mjs +167 -9
- package/server/claude-worker/claude-session-process.mjs +131 -33
- package/server/claude-worker/claude-session-worker-main.mjs +266 -0
- package/server/claude-worker/claude-session-worker.mjs +2 -253
- package/server/claude-worker/claude-turn-failure.mjs +8 -0
- package/server/claude-worker/claude-turn-publisher.mjs +282 -13
- package/server/claude-worker/claude-usage-totals.mjs +108 -0
- package/server/claude-worker/sdk-message-normalizer.mjs +55 -0
- package/server/copilot-worker/copilot-sdk-adapter.mjs +18 -4
- package/server/copilot-worker/copilot-sdk-session-process.mjs +73 -7
- package/server/db-schema.mjs +115 -0
- package/server/deepseek-worker/deepseek-claude-backend.mjs +433 -0
- package/server/deepseek-worker/deepseek-session-worker.mjs +37 -0
- package/server/migrations/0019-relay-event-subscription-options.mjs +106 -0
- package/server/migrations/0020-conversation-may-create-sessions.mjs +67 -0
- package/server/migrations/0021-conversation-group-agent-marks.mjs +77 -0
- package/server/migrations/0022-relay-schedules.mjs +142 -0
- package/server/migrations/0023-relay-asks.mjs +141 -0
- package/server/migrations/0024-deepseek-usage-totals.mjs +151 -0
- package/server/migrations/0025-deepseek-usage-requests-daily.mjs +179 -0
- package/server/migrations/0026-deepseek-usage-served-models.mjs +222 -0
- package/server/migrations/0027-deepseek-usage-checkpoint-runs.mjs +101 -0
- package/server/migrations/0028-deepseek-usage-checkpoint-request-runs.mjs +99 -0
- package/server/migrations/0029-deepseek-balance-rises.mjs +99 -0
- package/server/migrations/0030-deepseek-usage-actuals.mjs +123 -0
- package/server/public/app/api-client.js +53 -0
- package/server/public/app/ask-user-view.js +3 -0
- package/server/public/app/bootstrap.js +162 -16
- package/server/public/app/context-tier-options.mjs +4 -0
- package/server/public/app/context-usage-view.mjs +17 -0
- package/server/public/app/conversation-group-order.mjs +35 -0
- package/server/public/app/conversation-provider-indicator.mjs +7 -0
- package/server/public/app/conversation-view.js +2 -0
- package/server/public/app/deepseek-settings-ui.js +342 -0
- package/server/public/app/inbox-ui.js +367 -0
- package/server/public/app/inbox-view.mjs +370 -0
- package/server/public/app/journal-view.js +54 -5
- package/server/public/app/model-selector-options.mjs +11 -0
- package/server/public/app/model-vision-support.mjs +2 -0
- package/server/public/app/no-conversation-state.mjs +1 -1
- package/server/public/app/plan-usage-view.mjs +165 -13
- package/server/public/app/provider-readiness.mjs +4 -1
- package/server/public/app/relay-error-ctas.mjs +8 -0
- package/server/public/app/relay-event-subscriptions-ui.js +4 -2
- package/server/public/app/relay-event-view.mjs +33 -7
- package/server/public/app/relay-schedule-view.mjs +119 -0
- package/server/public/app/relay-schedules-ui.js +172 -0
- package/server/public/app/settings-modal.js +11 -0
- package/server/public/app/settings-tabs.js +1 -1
- package/server/public/app/socket-handlers.js +37 -3
- package/server/public/app/store.js +18 -5
- package/server/public/index.html +567 -35
- package/server/relay-tools.md +18 -3
- package/server/repositories/conversation-group-repository.mjs +110 -17
- package/server/repositories/remote-relay-repository.mjs +20 -2
- package/server/repositories/session-repository.mjs +7 -1
- package/server/routes/ask-user-routes.mjs +57 -3
- package/server/routes/cache-routes.mjs +6 -0
- package/server/routes/conversation-group-routes.mjs +19 -3
- package/server/routes/deepseek-usage-import-routes.mjs +72 -0
- package/server/routes/messages-routes.mjs +195 -18
- package/server/routes/relay-ask-routes.mjs +387 -0
- package/server/routes/relay-event-routes.mjs +3 -1
- package/server/routes/relay-schedule-routes.mjs +57 -0
- package/server/routes/sessions-routes.mjs +932 -97
- package/server/server-runtime.mjs +203 -11
- package/server/services/about-service.mjs +1 -0
- package/server/services/cache-rebuild-service.mjs +19 -2
- package/server/services/claude-session-leftovers.mjs +226 -0
- package/server/services/conversation-preferences-service.mjs +17 -0
- package/server/services/deepseek-balance-service.mjs +102 -0
- package/server/services/deepseek-settings-service.mjs +380 -0
- package/server/services/deepseek-usage-import.mjs +471 -0
- package/server/services/model-variant-catalog-service.mjs +1 -0
- package/server/services/plan-usage-contract.mjs +100 -5
- package/server/services/plan-usage-deepseek.mjs +931 -0
- package/server/services/plan-usage-service.mjs +431 -0
- package/server/services/provider-model-selection.mjs +1 -0
- package/server/services/provider-reasoning-effort.mjs +15 -2
- package/server/services/relay-ask-link-service.mjs +174 -0
- package/server/services/relay-ask-service.mjs +604 -0
- package/server/services/relay-event-service.mjs +589 -73
- package/server/services/relay-schedule-service.mjs +596 -0
- package/server/services/remote-relay-caller-context.mjs +89 -20
- package/server/services/remote-relay-dispatcher.mjs +958 -31
- package/server/services/session-worker-launch-service.mjs +233 -82
- package/server/services/worker-secret-redaction.mjs +318 -0
- package/shared/context-window-fallbacks.mjs +3 -0
- package/shared/deepseek-api.mjs +137 -0
- package/shared/deepseek-models.mjs +114 -0
- package/shared/deepseek-pricing.mjs +146 -0
- package/shared/host-claude-session-env.mjs +101 -0
- package/shared/model-id.mjs +7 -0
- package/shared/preview-tool-core.mjs +1 -1
- package/shared/provider-routing.mjs +2 -2
- package/shared/relay-asks.mjs +145 -0
- package/shared/relay-events.mjs +268 -18
- package/shared/relay-schedules.mjs +128 -0
- package/shared/remote-relay-contract.mjs +554 -26
- package/shared/remote-relay-tool-core.mjs +16 -5
- package/shared/steer-settle-failure.mjs +1 -0
- package/shared/worker-runtime/direct-openai-image.mjs +128 -0
- package/shared/worker-runtime/worker-log-file.mjs +60 -0
|
@@ -35,6 +35,7 @@ const TAB_LABELS = Object.freeze(Object.assign(Object.create(null), {
|
|
|
35
35
|
'claude-cloud': 'Claude Cloud',
|
|
36
36
|
cursor: 'Cursor',
|
|
37
37
|
grok: 'Grok',
|
|
38
|
+
deepseek: 'DeepSeek',
|
|
38
39
|
}));
|
|
39
40
|
|
|
40
41
|
// The composer speaks 'github'/'openai'/… while the usage report keys Copilot as
|
|
@@ -86,9 +87,9 @@ export function utilizationColor(utilization) {
|
|
|
86
87
|
export function formatAmount(value, unit) {
|
|
87
88
|
const n = toNullableNumber(value);
|
|
88
89
|
if (n === null) return null;
|
|
89
|
-
if (unit === 'usd') {
|
|
90
|
+
if (unit === 'usd' || unit === 'cny') {
|
|
90
91
|
const rounded = Math.round(n * 100) / 100;
|
|
91
|
-
return `${rounded < 0 ? '-' : ''}
|
|
92
|
+
return `${rounded < 0 ? '-' : ''}${unit === 'cny' ? '¥' : '$'}${Math.abs(rounded).toFixed(2)}`;
|
|
92
93
|
}
|
|
93
94
|
if (unit === 'percent') return `${Math.round(n * 10) / 10}%`;
|
|
94
95
|
const abs = Math.abs(n);
|
|
@@ -189,15 +190,103 @@ function renderMeter(meter, now) {
|
|
|
189
190
|
`;
|
|
190
191
|
}
|
|
191
192
|
|
|
193
|
+
/**
|
|
194
|
+
* A chart from `buildChart` data: stacked `columns` (one per item, e.g. a
|
|
195
|
+
* day) or horizontal stacked `bars` (one per item, e.g. a model), drawn with
|
|
196
|
+
* plain HTML and CSS. Colours come from the theme's series variables (fixed
|
|
197
|
+
* order, so a series keeps its colour between reads); every item has a text
|
|
198
|
+
* equivalent as its label, a tooltip on hover, focus or tap, and the chart
|
|
199
|
+
* has a legend for more than one series and a table view of the same data.
|
|
200
|
+
*/
|
|
201
|
+
function renderChart(chart) {
|
|
202
|
+
const series = Array.isArray(chart?.series) ? chart.series.filter((entry) => entry && entry.key) : [];
|
|
203
|
+
const items = Array.isArray(chart?.items) ? chart.items.filter((entry) => entry && entry.key) : [];
|
|
204
|
+
if (!series.length || !items.length) return '';
|
|
205
|
+
const unit = chart.unit || 'credits';
|
|
206
|
+
// Money under a cent keeps four decimals, as the card's estimates do.
|
|
207
|
+
const amount = (value) => {
|
|
208
|
+
const n = toNullableNumber(value) ?? 0;
|
|
209
|
+
if ((unit === 'usd' || unit === 'cny') && n !== 0 && Math.abs(n) < 0.01) {
|
|
210
|
+
return `${n < 0 ? '-' : ''}${unit === 'cny' ? '¥' : '$'}${Math.abs(n).toFixed(4)}`;
|
|
211
|
+
}
|
|
212
|
+
return formatAmount(n, unit) ?? '0';
|
|
213
|
+
};
|
|
214
|
+
const valueOf = (item, key) => Math.max(0, toNullableNumber(item.values?.[key]) ?? 0);
|
|
215
|
+
const totalOf = (item) => series.reduce((sum, entry) => sum + valueOf(item, entry.key), 0);
|
|
216
|
+
const seriesColor = (index) => `var(--plan-usage-series-${Math.min(index + 1, 5)})`;
|
|
217
|
+
const partsText = (item) => {
|
|
218
|
+
const total = totalOf(item);
|
|
219
|
+
return series
|
|
220
|
+
.map((entry) => ({ entry, value: valueOf(item, entry.key) }))
|
|
221
|
+
.filter(({ value }) => value > 0)
|
|
222
|
+
.map(({ entry, value }) => `${entry.label} ${amount(value)}${series.length > 1 && total > 0 ? ` (${Math.round((value / total) * 100)}%)` : ''}`)
|
|
223
|
+
.join(', ');
|
|
224
|
+
};
|
|
225
|
+
const itemText = (item) => {
|
|
226
|
+
const total = totalOf(item);
|
|
227
|
+
if (total <= 0) return `${item.label}: nothing`;
|
|
228
|
+
const parts = series.length > 1 ? partsText(item) : '';
|
|
229
|
+
return `${item.label}: ${amount(total)}${parts ? ` (${parts})` : ''}`;
|
|
230
|
+
};
|
|
231
|
+
const max = Math.max(0, ...items.map(totalOf));
|
|
232
|
+
const segments = (item, dimension) => series.map((entry, index) => {
|
|
233
|
+
const value = valueOf(item, entry.key);
|
|
234
|
+
if (value <= 0) return '';
|
|
235
|
+
const base = dimension === 'height' ? max : totalOf(item);
|
|
236
|
+
const share = base > 0 ? (value / base) * 100 : 0;
|
|
237
|
+
return `<span class="plan-usage-chart-seg" data-series="${escapeHtml(entry.key)}" style="${dimension}:${share.toFixed(2)}%;background:${seriesColor(index)}" title="${escapeHtml(`${entry.label} ${amount(value)}`)}"></span>`;
|
|
238
|
+
}).join('');
|
|
239
|
+
|
|
240
|
+
const plot = chart.kind === 'bars'
|
|
241
|
+
? `<div class="plan-usage-chart-bars">${items.map((item) => `
|
|
242
|
+
<div class="plan-usage-chart-row" tabindex="0" aria-label="${escapeHtml(itemText(item))}">
|
|
243
|
+
<span class="plan-usage-chart-row-label">${escapeHtml(item.label)}</span>
|
|
244
|
+
<span class="plan-usage-chart-bar" aria-hidden="true">${segments(item, 'width')}</span>
|
|
245
|
+
<span class="plan-usage-chart-row-value">${escapeHtml(amount(totalOf(item)))}</span>
|
|
246
|
+
<span class="plan-usage-chart-tip" role="tooltip">${escapeHtml(itemText(item))}</span>
|
|
247
|
+
</div>`).join('')}</div>`
|
|
248
|
+
: `<div class="plan-usage-chart-columns" style="--plan-usage-columns:${items.length}">${items.map((item) => `
|
|
249
|
+
<div class="plan-usage-chart-col${totalOf(item) > 0 ? '' : ' plan-usage-chart-col-empty'}" tabindex="0" aria-label="${escapeHtml(itemText(item))}" data-item="${escapeHtml(item.key)}">
|
|
250
|
+
<span class="plan-usage-chart-stack" aria-hidden="true">${segments(item, 'height')}</span>
|
|
251
|
+
<span class="plan-usage-chart-tip" role="tooltip">${escapeHtml(itemText(item))}</span>
|
|
252
|
+
</div>`).join('')}
|
|
253
|
+
<span class="plan-usage-chart-axis" aria-hidden="true"><span>${escapeHtml(items[0].label)}</span><span>${escapeHtml(items[items.length - 1].label)}</span></span>
|
|
254
|
+
</div>`;
|
|
255
|
+
|
|
256
|
+
const legend = series.length > 1
|
|
257
|
+
? `<ul class="plan-usage-chart-legend">${series.map((entry, index) => `<li><span class="plan-usage-chart-swatch" style="background:${seriesColor(index)}" aria-hidden="true"></span>${escapeHtml(entry.label)}</li>`).join('')}</ul>`
|
|
258
|
+
: '';
|
|
259
|
+
const table = `
|
|
260
|
+
<details class="plan-usage-chart-table">
|
|
261
|
+
<summary>As a table</summary>
|
|
262
|
+
<table class="plan-usage-detail-table">
|
|
263
|
+
<thead><tr><th scope="col"></th>${series.map((entry) => `<th scope="col">${escapeHtml(entry.label)}</th>`).join('')}${series.length > 1 ? '<th scope="col">Total</th>' : ''}</tr></thead>
|
|
264
|
+
<tbody>${items.filter((item) => totalOf(item) > 0).map((item) => `<tr><th scope="row">${escapeHtml(item.label)}</th>${series.map((entry) => `<td class="plan-usage-detail-value">${escapeHtml(amount(valueOf(item, entry.key)))}</td>`).join('')}${series.length > 1 ? `<td class="plan-usage-detail-value">${escapeHtml(amount(totalOf(item)))}</td>` : ''}</tr>`).join('')}</tbody>
|
|
265
|
+
</table>
|
|
266
|
+
</details>`;
|
|
267
|
+
return `
|
|
268
|
+
<figure class="plan-usage-chart plan-usage-chart-kind-${chart.kind === 'bars' ? 'bars' : 'columns'}" data-chart-id="${escapeHtml(chart.id)}" role="group" aria-label="${escapeHtml(chart.label)}">
|
|
269
|
+
<figcaption class="plan-usage-chart-head">
|
|
270
|
+
<span class="plan-usage-chart-label">${escapeHtml(chart.label)}</span>
|
|
271
|
+
${chart.summary ? `<span class="plan-usage-chart-summary">${escapeHtml(chart.summary)}</span>` : ''}
|
|
272
|
+
</figcaption>
|
|
273
|
+
${plot}
|
|
274
|
+
${legend}
|
|
275
|
+
${chart.note ? `<div class="plan-usage-meter-note">${escapeHtml(chart.note)}</div>` : ''}
|
|
276
|
+
${table}
|
|
277
|
+
</figure>
|
|
278
|
+
`;
|
|
279
|
+
}
|
|
280
|
+
|
|
192
281
|
function renderDetailSection(section) {
|
|
193
282
|
const rows = section.rows.map((row) => `
|
|
194
283
|
<tr>
|
|
195
284
|
<td class="plan-usage-detail-label">${escapeHtml(row.label)}${row.hint ? `<span class="plan-usage-detail-hint">${escapeHtml(row.hint)}</span>` : ''}</td>
|
|
196
|
-
<td class="plan-usage-detail-value">${
|
|
285
|
+
<td class="plan-usage-detail-value">${String(row.value).split(' · ').map((part) => `<span class="plan-usage-detail-part">${escapeHtml(part)}</span>`).join(' · ')}</td>
|
|
197
286
|
</tr>
|
|
198
287
|
`).join('');
|
|
199
288
|
return `
|
|
200
|
-
<details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}">
|
|
289
|
+
<details class="plan-usage-details" data-section-id="${escapeHtml(section.id)}"${section.open ? ' open' : ''}>
|
|
201
290
|
<summary>${escapeHtml(section.label)}</summary>
|
|
202
291
|
${section.note ? `<div class="plan-usage-detail-note">${escapeHtml(section.note)}</div>` : ''}
|
|
203
292
|
<table class="plan-usage-detail-table"><tbody>${rows}</tbody></table>
|
|
@@ -205,9 +294,59 @@ function renderDetailSection(section) {
|
|
|
205
294
|
`;
|
|
206
295
|
}
|
|
207
296
|
|
|
208
|
-
/**
|
|
209
|
-
|
|
297
|
+
/**
|
|
298
|
+
* Hours of the day the card note names, shown in the reader's own time zone.
|
|
299
|
+
* `utcRanges` are `[start, end)` whole hours in UTC; the day is `now`'s, so
|
|
300
|
+
* daylight-saving time is the reader's current one. "03:00–06:00 and
|
|
301
|
+
* 08:00–12:00 in your time zone" for `[[1, 4], [6, 10]]` read in Berlin in
|
|
302
|
+
* summer. `timeZone` is for tests; the browser leaves it to Intl.
|
|
303
|
+
*/
|
|
304
|
+
export function formatLocalHourRanges(utcRanges, { now = new Date(), timeZone = undefined } = {}) {
|
|
305
|
+
if (!Array.isArray(utcRanges) || !utcRanges.length) return null;
|
|
306
|
+
let formatter;
|
|
307
|
+
try {
|
|
308
|
+
formatter = new Intl.DateTimeFormat('en-GB', { hour: '2-digit', minute: '2-digit', hourCycle: 'h23', timeZone });
|
|
309
|
+
} catch {
|
|
310
|
+
return null;
|
|
311
|
+
}
|
|
312
|
+
const day = now instanceof Date && !Number.isNaN(now.getTime()) ? now : new Date();
|
|
313
|
+
const clock = (hour) => formatter.format(new Date(Date.UTC(day.getUTCFullYear(), day.getUTCMonth(), day.getUTCDate(), hour, 0, 0)));
|
|
314
|
+
const parts = [];
|
|
315
|
+
for (const range of utcRanges) {
|
|
316
|
+
if (!Array.isArray(range) || range.length !== 2) return null;
|
|
317
|
+
const start = Number(range[0]);
|
|
318
|
+
const end = Number(range[1]);
|
|
319
|
+
if (!Number.isInteger(start) || !Number.isInteger(end) || start < 0 || end > 24 || start >= end) return null;
|
|
320
|
+
parts.push(`${clock(start)}–${clock(end)}`);
|
|
321
|
+
}
|
|
322
|
+
const joined = parts.length > 1 ? `${parts.slice(0, -1).join(', ')} and ${parts[parts.length - 1]}` : parts[0];
|
|
323
|
+
return `${joined} in your time zone`;
|
|
324
|
+
}
|
|
325
|
+
|
|
326
|
+
/** The note's text with its UTC hours replaced by the reader's local ones, when it names any. */
|
|
327
|
+
function noteTextForReader(note, { now, timeZone } = {}) {
|
|
210
328
|
const text = String(note?.text ?? '').trim();
|
|
329
|
+
const utcText = String(note?.hours?.utcText ?? '').trim();
|
|
330
|
+
if (!text || !utcText || !text.includes(utcText)) return text;
|
|
331
|
+
const local = formatLocalHourRanges(note.hours.utcRanges, { now, timeZone });
|
|
332
|
+
return local ? text.split(utcText).join(local) : text;
|
|
333
|
+
}
|
|
334
|
+
|
|
335
|
+
/**
|
|
336
|
+
* A note that is a figure (buildCardNote's label and value): one row of the
|
|
337
|
+
* card's figures, the hint under the label, the whole sentence as its title.
|
|
338
|
+
*/
|
|
339
|
+
function renderFact(note) {
|
|
340
|
+
const hint = String(note?.hint ?? '').trim();
|
|
341
|
+
return `<div class="plan-usage-fact" data-note-id="${escapeHtml(note?.id ?? '')}" title="${escapeHtml(String(note?.text ?? '').trim())}">`
|
|
342
|
+
+ `<dt>${escapeHtml(note.label)}${hint ? `<span class="plan-usage-fact-hint">${escapeHtml(hint)}</span>` : ''}</dt>`
|
|
343
|
+
+ `<dd>${escapeHtml(note.value)}</dd></div>`;
|
|
344
|
+
}
|
|
345
|
+
|
|
346
|
+
const isFactNote = (note) => Boolean(String(note?.label ?? '').trim() && String(note?.value ?? '').trim());
|
|
347
|
+
|
|
348
|
+
function renderNote(note, { now, timeZone } = {}) {
|
|
349
|
+
const text = noteTextForReader(note, { now, timeZone });
|
|
211
350
|
if (!text) return '';
|
|
212
351
|
const url = String(note?.link?.url ?? '').trim();
|
|
213
352
|
const label = String(note?.link?.label ?? '').trim();
|
|
@@ -215,7 +354,11 @@ function renderNote(note) {
|
|
|
215
354
|
const link = label && /^https:\/\//i.test(url)
|
|
216
355
|
? ` <a class="plan-usage-link" href="${escapeHtml(url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(label)}</a>`
|
|
217
356
|
: '';
|
|
218
|
-
|
|
357
|
+
// The UTC wording stays on the element for a reader who wants it.
|
|
358
|
+
const utcTitle = note?.hours?.utcText && text !== String(note.text ?? '').trim()
|
|
359
|
+
? ` title="${escapeHtml(String(note.text).trim())}"`
|
|
360
|
+
: '';
|
|
361
|
+
return `<div class="plan-usage-detail-note plan-usage-note" data-note-id="${escapeHtml(note?.id ?? '')}"${utcTitle}>${escapeHtml(text)}${link}</div>`;
|
|
219
362
|
}
|
|
220
363
|
|
|
221
364
|
/**
|
|
@@ -240,10 +383,18 @@ function renderAccountLine(account) {
|
|
|
240
383
|
return `<span class="plan-usage-account">${parts.map(escapeHtml).join(' · ')}</span>`;
|
|
241
384
|
}
|
|
242
385
|
|
|
243
|
-
function renderCard(card, now, { active = true } = {}) {
|
|
386
|
+
function renderCard(card, now, { active = true, timeZone = undefined } = {}) {
|
|
244
387
|
const meters = Array.isArray(card.meters) ? card.meters : [];
|
|
388
|
+
const charts = Array.isArray(card.charts) ? card.charts.filter(Boolean) : [];
|
|
245
389
|
const details = Array.isArray(card.details) ? card.details : [];
|
|
246
|
-
const
|
|
390
|
+
const allNotes = Array.isArray(card.notes) ? card.notes.filter(Boolean) : [];
|
|
391
|
+
// Figures go in one list under the bars; sentences stay notes, and on a
|
|
392
|
+
// card with figures they come last, after the tables, so the numbers read
|
|
393
|
+
// first.
|
|
394
|
+
const facts = allNotes.filter(isFactNote);
|
|
395
|
+
const notes = allNotes.filter((note) => !isFactNote(note));
|
|
396
|
+
const notesHtml = notes.map((note) => renderNote(note, { now, timeZone })).join('');
|
|
397
|
+
const detailsHtml = details.map(renderDetailSection).join('');
|
|
247
398
|
const links = Array.isArray(card.links) ? card.links : [];
|
|
248
399
|
|
|
249
400
|
const statusBadge = STATUS_BADGES[card.status];
|
|
@@ -281,8 +432,9 @@ function renderCard(card, now, { active = true } = {}) {
|
|
|
281
432
|
</header>
|
|
282
433
|
${card.message ? `<div class="plan-usage-message">${escapeHtml(card.message)}</div>` : ''}
|
|
283
434
|
${metersHtml}
|
|
284
|
-
${
|
|
285
|
-
${
|
|
435
|
+
${facts.length ? `<dl class="plan-usage-facts">${facts.map(renderFact).join('')}</dl>` : ''}
|
|
436
|
+
${charts.map(renderChart).join('')}
|
|
437
|
+
${facts.length ? `${detailsHtml}${notesHtml}` : `${notesHtml}${detailsHtml}`}
|
|
286
438
|
<div class="plan-usage-card-foot">
|
|
287
439
|
${capturedLabel ? `<span class="plan-usage-captured">${capturedLabel}</span>` : ''}
|
|
288
440
|
${links.map((link) => `<a class="plan-usage-link" href="${escapeHtml(link.url)}" target="_blank" rel="noopener noreferrer">${escapeHtml(link.label)}</a>`).join('')}
|
|
@@ -306,13 +458,13 @@ function renderTab(card, activeProvider) {
|
|
|
306
458
|
* tab when that provider has a card
|
|
307
459
|
* @returns {string} modal body HTML, or '' when there is nothing to render
|
|
308
460
|
*/
|
|
309
|
-
export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '' } = {}) {
|
|
461
|
+
export function renderPlanUsageHtml(report, { now = new Date(), activeProvider = '', timeZone = undefined } = {}) {
|
|
310
462
|
const providers = Array.isArray(report?.providers) ? report.providers.filter(Boolean) : [];
|
|
311
463
|
if (!providers.length) return '';
|
|
312
464
|
const active = resolveActiveUsageProvider(providers, activeProvider);
|
|
313
465
|
const tabs = providers.map((card) => renderTab(card, active)).join('');
|
|
314
466
|
const cards = providers
|
|
315
|
-
.map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active }))
|
|
467
|
+
.map((card) => renderCard(card, now, { active: normalizeUsageProvider(card.provider) === active, timeZone }))
|
|
316
468
|
.join('');
|
|
317
469
|
return `<div class="plan-usage"><div class="plan-usage-tab-strip" role="tablist" aria-label="Usage providers">${tabs}</div>${cards}</div>`;
|
|
318
470
|
}
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// Which providers are known to be ready to run a chat, from what the client
|
|
2
2
|
// already loads: the provider settings payloads and the model catalog. No DOM.
|
|
3
3
|
//
|
|
4
|
-
// OpenAI, Claude, Claude Cloud, Cursor and
|
|
4
|
+
// OpenAI, Claude, Claude Cloud, Cursor, Grok and DeepSeek say so themselves: their
|
|
5
5
|
// settings carry `enabled`. Copilot has no such flag and the relay never asks
|
|
6
6
|
// whether the Copilot CLI is signed in, so the best signal is indirect: has a
|
|
7
7
|
// real Copilot model list ever reached the catalog? A fresh database is seeded
|
|
@@ -23,6 +23,7 @@ const SETTINGS_PROVIDERS = Object.freeze([
|
|
|
23
23
|
['claudeCloud', 'claude-cloud'],
|
|
24
24
|
['cursor', 'cursor'],
|
|
25
25
|
['grok', 'grok'],
|
|
26
|
+
['deepseek', 'deepseek'],
|
|
26
27
|
]);
|
|
27
28
|
|
|
28
29
|
/** Payloads whose absence means "could not be loaded" rather than "not offered". */
|
|
@@ -35,6 +36,7 @@ const REQUIRED_INPUTS = Object.freeze(['openai', 'claude', 'cursor', 'grok', 'ca
|
|
|
35
36
|
* @param {object|null} [input.claudeCloud] null on a relay without Claude Cloud: not offered, not unknown
|
|
36
37
|
* @param {object|null} [input.cursor]
|
|
37
38
|
* @param {object|null} [input.grok]
|
|
39
|
+
* @param {object|null} [input.deepseek] null on a relay without DeepSeek: not offered, not unknown
|
|
38
40
|
* @param {object|null} [input.catalog]
|
|
39
41
|
* @returns {{ known: boolean, anyReady: boolean, ready: string[], copilot: 'ready' | 'unknown' }}
|
|
40
42
|
*/
|
|
@@ -69,6 +71,7 @@ const inputs = {
|
|
|
69
71
|
claudeCloud: undefined,
|
|
70
72
|
cursor: undefined,
|
|
71
73
|
grok: undefined,
|
|
74
|
+
deepseek: undefined,
|
|
72
75
|
catalog: undefined,
|
|
73
76
|
};
|
|
74
77
|
let current = resolveProviderReadiness(inputs);
|
|
@@ -36,6 +36,14 @@ const RELAY_ERROR_CTAS = Object.freeze({
|
|
|
36
36
|
'claude-account-on-hold': CLAUDE_SETTINGS_CTA,
|
|
37
37
|
'claude-verification-required': CLAUDE_SETTINGS_CTA,
|
|
38
38
|
'claude-account-access': CLAUDE_SETTINGS_CTA,
|
|
39
|
+
// A DeepSeek turn refused for its key or its balance (deepseek-claude-backend.mjs).
|
|
40
|
+
'deepseek-api-key': Object.freeze([
|
|
41
|
+
Object.freeze({ action: 'open-deepseek-settings', label: 'DeepSeek settings' }),
|
|
42
|
+
]),
|
|
43
|
+
'deepseek-balance': Object.freeze([
|
|
44
|
+
Object.freeze({ action: 'open-deepseek-top-up', label: 'Top up DeepSeek' }),
|
|
45
|
+
Object.freeze({ action: 'open-deepseek-settings', label: 'DeepSeek settings' }),
|
|
46
|
+
]),
|
|
39
47
|
});
|
|
40
48
|
|
|
41
49
|
const STABLE_CODE_PATTERN = /error code:\s*relay\.([a-z0-9-]+)/i;
|
|
@@ -10,6 +10,7 @@ import {
|
|
|
10
10
|
renderWatchedBadgeHtml,
|
|
11
11
|
renderWatchingPanelHtml,
|
|
12
12
|
watchersOf,
|
|
13
|
+
watchingChipHint,
|
|
13
14
|
watchingChipText,
|
|
14
15
|
watchingSummary,
|
|
15
16
|
} from './relay-event-view.mjs';
|
|
@@ -164,15 +165,16 @@ export function syncRelayEventHeader() {
|
|
|
164
165
|
}
|
|
165
166
|
const summary = watchingSummary(subscriptions, id);
|
|
166
167
|
const chipText = watchingChipText(summary);
|
|
168
|
+
const chipHint = chipText ? watchingChipHint(summary) : '';
|
|
167
169
|
const watchers = watchersOf(subscriptions, id, { includeAll: true });
|
|
168
170
|
if (!chipText) panelOpen = false;
|
|
169
|
-
const key = JSON.stringify([id, chipText, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
|
|
171
|
+
const key = JSON.stringify([id, chipText, chipHint, panelOpen, summary.subscriptions.map((sub) => [sub.id, sub.sessions, sub.events]), watchers]);
|
|
170
172
|
if (key === renderedChipKey) return;
|
|
171
173
|
renderedChipKey = key;
|
|
172
174
|
const parts = [];
|
|
173
175
|
if (chipText) {
|
|
174
176
|
const where = summary.relays.length ? ` (also on ${summary.relays.join(', ')})` : '';
|
|
175
|
-
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}</button>`);
|
|
177
|
+
parts.push(`<button type="button" class="relay-watch-chip" aria-expanded="${panelOpen ? 'true' : 'false'}" aria-controls="relay-watch-panel" title="${escHtml(`Event subscriptions of this conversation${where}`)}">👁 ${escHtml(chipText)}${chipHint ? ` <span class="relay-watch-hint">${escHtml(chipHint)}</span>` : ''}</button>`);
|
|
176
178
|
}
|
|
177
179
|
const badge = renderWatchedBadgeHtml(watchers, { conversations, subscriptions, escHtml });
|
|
178
180
|
if (badge) parts.push(badge);
|
|
@@ -28,6 +28,7 @@ const EVENT_TYPES = new Set([
|
|
|
28
28
|
]);
|
|
29
29
|
const URGENT_TYPES = new Set(['question_asked', 'error']);
|
|
30
30
|
const DIGEST_PATTERN = /^Digest of \d+ events?/;
|
|
31
|
+
const QUIET_PATTERN = /^Held during quiet hours/;
|
|
31
32
|
const REPLAYED_PATTERN = /^Replayed: \d+ events?/;
|
|
32
33
|
const LINE_PATTERN = /^\[relay event\] (?:[^"·\n]{1,60} · )?(?:session "(?:[^"]*)" \([^)]*\)(?: on relay "[^"]*")?: )?([a-z_]+)/;
|
|
33
34
|
const LINE_PREFIX = '[relay event] ';
|
|
@@ -63,15 +64,17 @@ export function isRelayEventMessage(message) {
|
|
|
63
64
|
/**
|
|
64
65
|
* The event lines of an event message without the lead, the types they
|
|
65
66
|
* carry in order, how many events (a digest's "… and N more" counted),
|
|
66
|
-
* whether it is a digest,
|
|
67
|
-
* while the link to it was down. Any text
|
|
67
|
+
* whether it is a digest, whether quiet hours held it, and whether it
|
|
68
|
+
* replays what a paired relay kept while the link to it was down. Any text
|
|
69
|
+
* renders: a stray one as one line.
|
|
68
70
|
*/
|
|
69
71
|
export function parseRelayEventMessageText(text) {
|
|
70
72
|
const rows = String(text ?? '').split(/\r?\n/).map((line) => line.trimEnd()).filter((line) => line.trim());
|
|
71
73
|
const body = rows[0] === RELAY_EVENT_LEAD ? rows.slice(1) : rows;
|
|
72
|
-
const digest = DIGEST_PATTERN.test(
|
|
74
|
+
const digest = body.some((line) => DIGEST_PATTERN.test(line));
|
|
75
|
+
const quiet = body.some((line) => QUIET_PATTERN.test(line));
|
|
73
76
|
const replayed = body.some((line) => REPLAYED_PATTERN.test(line));
|
|
74
|
-
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
77
|
+
const lines = body.filter((line) => !DIGEST_PATTERN.test(line) && !QUIET_PATTERN.test(line) && !REPLAYED_PATTERN.test(line));
|
|
75
78
|
const types = [];
|
|
76
79
|
let count = 0;
|
|
77
80
|
for (const line of lines) {
|
|
@@ -84,7 +87,7 @@ export function parseRelayEventMessageText(text) {
|
|
|
84
87
|
const more = /^\[relay event\] … and (\d+) more$/.exec(line);
|
|
85
88
|
if (more) count += Number(more[1]) || 0;
|
|
86
89
|
}
|
|
87
|
-
return { lines, types, count: count || lines.length, digest, replayed };
|
|
90
|
+
return { lines, types, count: count || lines.length, digest, quiet, replayed };
|
|
88
91
|
}
|
|
89
92
|
|
|
90
93
|
/** The collapsed row's one line: "3 events · question, reply". */
|
|
@@ -92,7 +95,8 @@ export function relayEventSummaryText(parsed) {
|
|
|
92
95
|
const count = Number(parsed?.count) || 0;
|
|
93
96
|
const labels = (parsed?.types || []).map((type) => TYPE_LABELS[type] || type);
|
|
94
97
|
const events = `${count} event${count === 1 ? '' : 's'}`;
|
|
95
|
-
const
|
|
98
|
+
const tags = [parsed?.quiet ? 'After quiet hours' : '', parsed?.digest ? 'Digest' : ''].filter(Boolean);
|
|
99
|
+
const head = [...tags, events].join(' · ');
|
|
96
100
|
return labels.length ? `${head} · ${labels.join(', ')}` : head;
|
|
97
101
|
}
|
|
98
102
|
|
|
@@ -175,11 +179,23 @@ export function watchersOf(subscriptions, conversationId, { includeAll = true }
|
|
|
175
179
|
const watchers = new Set();
|
|
176
180
|
for (const sub of activeList(subscriptions)) {
|
|
177
181
|
if (sub.watcher === id || isOnPairedRelay(sub)) continue;
|
|
178
|
-
|
|
182
|
+
const watches = sub.scope === 'all'
|
|
183
|
+
? includeAll && !(Array.isArray(sub.exclude) && sub.exclude.includes(id))
|
|
184
|
+
: sessionIdsOf(sub).includes(id);
|
|
185
|
+
if (watches) watchers.add(watcherKey(sub));
|
|
179
186
|
}
|
|
180
187
|
return [...watchers];
|
|
181
188
|
}
|
|
182
189
|
|
|
190
|
+
/** "digest 30 min" / "quiet 23:00–06:00": what a subscription's options hold back, or ''. */
|
|
191
|
+
export function subscriptionOptionHints(sub) {
|
|
192
|
+
const quiet = sub?.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
|
|
193
|
+
return [
|
|
194
|
+
Number(sub?.digestMinutes) > 0 ? `digest ${Number(sub.digestMinutes)} min` : '',
|
|
195
|
+
quiet ? `quiet ${String(quiet.from || '')}–${String(quiet.to || '')}` : '',
|
|
196
|
+
].filter(Boolean);
|
|
197
|
+
}
|
|
198
|
+
|
|
183
199
|
export function watchingChipText(summary) {
|
|
184
200
|
if (!summary?.subscriptions?.length) return '';
|
|
185
201
|
if (summary.all) return summary.count ? `watching all sessions (+${summary.count} listed)` : 'watching all sessions';
|
|
@@ -188,6 +204,11 @@ export function watchingChipText(summary) {
|
|
|
188
204
|
return `watching ${summary.count} session${summary.count === 1 ? '' : 's'}`;
|
|
189
205
|
}
|
|
190
206
|
|
|
207
|
+
/** The chip's small hint: the options of the conversation's subscriptions, each named once (at most two). */
|
|
208
|
+
export function watchingChipHint(summary) {
|
|
209
|
+
return [...new Set((summary?.subscriptions || []).flatMap(subscriptionOptionHints))].slice(0, 2).join(' · ');
|
|
210
|
+
}
|
|
211
|
+
|
|
191
212
|
function conversationTitle(conversations, id) {
|
|
192
213
|
const title = String(conversations?.[id]?.title || '').trim();
|
|
193
214
|
return title || `session ${String(id).slice(0, 8)}`;
|
|
@@ -203,9 +224,14 @@ function describeSubscription(sub, conversations) {
|
|
|
203
224
|
const what = sub.scope === 'all'
|
|
204
225
|
? (paired ? `All sessions of ${relay}` : 'All sessions')
|
|
205
226
|
: (paired ? `${relay} · ${listed}` : (listed || 'This relay'));
|
|
227
|
+
const quiet = sub.quietHours && typeof sub.quietHours === 'object' ? sub.quietHours : null;
|
|
228
|
+
const excluded = Array.isArray(sub.exclude) ? sub.exclude.length : 0;
|
|
206
229
|
const extras = [
|
|
207
230
|
sub.once ? 'once' : '',
|
|
208
231
|
sub.heartbeatMinutes ? `heartbeat ${sub.heartbeatMinutes} min` : '',
|
|
232
|
+
sub.digestMinutes ? `digest ${sub.digestMinutes} min` : '',
|
|
233
|
+
quiet ? `quiet ${quiet.from}–${quiet.to} ${quiet.tz}` : '',
|
|
234
|
+
excluded ? `${excluded} left out` : '',
|
|
209
235
|
].filter(Boolean);
|
|
210
236
|
return { what, events: (sub.events || []).join(', '), extras: extras.join(' · ') };
|
|
211
237
|
}
|
|
@@ -0,0 +1,119 @@
|
|
|
1
|
+
// Relay schedules in the browser: the "scheduled" marker on a conversation's
|
|
2
|
+
// sidebar row and the header's "N scheduled" chip with its list and Cancel
|
|
3
|
+
// buttons.
|
|
4
|
+
//
|
|
5
|
+
// DOM-free on purpose, like relay-event-view.mjs: the callers own the
|
|
6
|
+
// elements, this module owns the decisions and the markup.
|
|
7
|
+
//
|
|
8
|
+
// The interval words mirror shared/relay-schedules.mjs, which the browser
|
|
9
|
+
// cannot import (only server/public is served); relay-schedule-view.test.mjs
|
|
10
|
+
// holds the two together.
|
|
11
|
+
|
|
12
|
+
export const RELAY_SCHEDULE_SOCKET_EVENT = 'relay_schedules';
|
|
13
|
+
export const RELAY_SCHEDULE_MESSAGE_KIND = 'relay-schedule';
|
|
14
|
+
|
|
15
|
+
function defaultEscape(value) {
|
|
16
|
+
return String(value ?? '')
|
|
17
|
+
.replace(/&/g, '&')
|
|
18
|
+
.replace(/</g, '<')
|
|
19
|
+
.replace(/>/g, '>')
|
|
20
|
+
.replace(/"/g, '"')
|
|
21
|
+
.replace(/'/g, ''');
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
function defaultFormatTime(iso) {
|
|
25
|
+
const at = new Date(iso);
|
|
26
|
+
if (!Number.isFinite(at.getTime())) return '';
|
|
27
|
+
const sameDay = new Date().toDateString() === at.toDateString();
|
|
28
|
+
return at.toLocaleString([], sameDay
|
|
29
|
+
? { hour: '2-digit', minute: '2-digit' }
|
|
30
|
+
: { weekday: 'short', day: 'numeric', month: 'short', hour: '2-digit', minute: '2-digit' });
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
function activeList(schedules) {
|
|
34
|
+
return (Array.isArray(schedules) ? schedules : []).filter((schedule) => schedule && schedule.id && !schedule.endedAt);
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/** "every 30 min", "every 2 h", "every day", "every 7 days". */
|
|
38
|
+
export function describeScheduleInterval(everyMinutes) {
|
|
39
|
+
const minutes = Number(everyMinutes);
|
|
40
|
+
if (!(minutes > 0)) return '';
|
|
41
|
+
if (minutes % 1440 === 0) return minutes === 1440 ? 'every day' : `every ${minutes / 1440} days`;
|
|
42
|
+
if (minutes % 60 === 0) return minutes === 60 ? 'every hour' : `every ${minutes / 60} h`;
|
|
43
|
+
return `every ${minutes} min`;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
/** The schedules that fire into a conversation. */
|
|
47
|
+
export function schedulesInto(schedules, conversationId) {
|
|
48
|
+
const id = String(conversationId || '').trim();
|
|
49
|
+
if (!id) return [];
|
|
50
|
+
return activeList(schedules).filter((schedule) => schedule.session?.id === id);
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/** The schedules that fire into a conversation or that it set for another. */
|
|
54
|
+
export function schedulesOf(schedules, conversationId) {
|
|
55
|
+
const id = String(conversationId || '').trim();
|
|
56
|
+
if (!id) return [];
|
|
57
|
+
return activeList(schedules).filter((schedule) => schedule.session?.id === id || schedule.setBy?.id === id);
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
/** "every 30 min · next 10:30", or "once · 06:30". */
|
|
61
|
+
export function describeScheduleWhen(schedule, { formatTime = defaultFormatTime } = {}) {
|
|
62
|
+
const next = schedule?.nextFireAt ? formatTime(schedule.nextFireAt) : '';
|
|
63
|
+
const every = schedule?.once ? '' : describeScheduleInterval(schedule?.everyMinutes);
|
|
64
|
+
if (every) return next ? `${every} · next ${next}` : every;
|
|
65
|
+
return next ? `once · ${next}` : 'once';
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/** The earliest next time among the schedules, or null. */
|
|
69
|
+
export function nextScheduledAt(schedules) {
|
|
70
|
+
const times = activeList(schedules).map((schedule) => Date.parse(schedule.nextFireAt || '')).filter(Number.isFinite);
|
|
71
|
+
return times.length ? new Date(Math.min(...times)).toISOString() : null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
export function scheduleChipText(schedules) {
|
|
75
|
+
const count = activeList(schedules).length;
|
|
76
|
+
return count ? `${count} scheduled` : '';
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function conversationTitle(conversations, id, fallback = '') {
|
|
80
|
+
const title = String(conversations?.[id]?.title || fallback || '').trim();
|
|
81
|
+
return title || `session ${String(id).slice(0, 8)}`;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** The sidebar's marker for a conversation a schedule fires into, or ''. */
|
|
85
|
+
export function renderScheduledBadgeHtml(schedules, conversationId, { escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
|
|
86
|
+
const into = schedulesInto(schedules, conversationId);
|
|
87
|
+
if (!into.length) return '';
|
|
88
|
+
const next = nextScheduledAt(into);
|
|
89
|
+
const hint = `${into.length} schedule${into.length === 1 ? '' : 's'}${next ? ` · next ${formatTime(next)}` : ''}`;
|
|
90
|
+
return `<span class="conv-scheduled-badge" title="${escHtml(hint)}">scheduled</span>`;
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
/**
|
|
94
|
+
* The chip's list for the conversation on screen: one row per schedule, what
|
|
95
|
+
* it is called and sends, where it goes or who set it, when it fires next,
|
|
96
|
+
* and its Cancel button.
|
|
97
|
+
*/
|
|
98
|
+
export function renderSchedulePanelHtml(schedules, conversationId, { conversations = null, escHtml = defaultEscape, formatTime = defaultFormatTime } = {}) {
|
|
99
|
+
const id = String(conversationId || '').trim();
|
|
100
|
+
const rows = schedulesOf(schedules, id).map((schedule) => {
|
|
101
|
+
const name = String(schedule.title || '').trim() || String(schedule.text || '').trim().split(/\r?\n/)[0] || schedule.id;
|
|
102
|
+
const where = schedule.session?.id !== id
|
|
103
|
+
? `for ${conversationTitle(conversations, schedule.session?.id, schedule.session?.title)}`
|
|
104
|
+
: (schedule.setBy?.id ? `set by ${conversationTitle(conversations, schedule.setBy.id, schedule.setBy.title)}` : '');
|
|
105
|
+
// Held: its setter's agent has prompted other agents as often as it may
|
|
106
|
+
// before you write in that conversation.
|
|
107
|
+
const held = schedule.heldForApproval
|
|
108
|
+
? `waiting until you write in ${conversationTitle(conversations, schedule.setBy?.id, schedule.setBy?.title)}`
|
|
109
|
+
: '';
|
|
110
|
+
const details = [held || describeScheduleWhen(schedule, { formatTime }), where, schedule.skippedCount ? `${schedule.skippedCount} skipped` : '']
|
|
111
|
+
.filter(Boolean).join(' · ');
|
|
112
|
+
return '<li class="relay-watch-item relay-schedule-item">'
|
|
113
|
+
+ `<span class="relay-watch-what" title="${escHtml(schedule.text || '')}">${escHtml(name)}</span>`
|
|
114
|
+
+ `<span class="relay-watch-events">${escHtml(details)}</span>`
|
|
115
|
+
+ `<button type="button" class="relay-schedule-cancel" data-schedule-id="${escHtml(schedule.id)}">Cancel</button>`
|
|
116
|
+
+ '</li>';
|
|
117
|
+
}).join('');
|
|
118
|
+
return `<ul class="relay-watch-list">${rows}</ul>`;
|
|
119
|
+
}
|