wisp-agents 0.12.0 → 0.14.0
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/CHANGELOG.md +35 -1
- package/README.md +27 -5
- package/bin/wisp.js +2 -2
- package/package.json +2 -2
- package/server/adapters/claude.js +50 -12
- package/server/client.js +2 -2
- package/server/index.js +173 -17
- package/server/machines.js +5 -0
- package/server/mcp-wisp.js +5 -5
- package/server/openapi.js +25 -3
- package/server/protocol.js +4 -0
- package/server/remote.js +103 -17
- package/server/router-priors.json +54 -0
- package/server/router.js +340 -0
- package/server/runner.js +66 -13
- package/server/store.js +10 -2
- package/server/subagents.js +6 -2
- package/server/telemetry.js +58 -7
- package/web/css/app.css +83 -7
- package/web/index.html +2 -0
- package/web/js/app.js +459 -54
package/server/telemetry.js
CHANGED
|
@@ -5,6 +5,10 @@
|
|
|
5
5
|
//
|
|
6
6
|
// Off when any of: no API key, DO_NOT_TRACK=1, WISP_TELEMETRY=0, CI, or "telemetry": "off" in
|
|
7
7
|
// settings. See the Telemetry section of the README.
|
|
8
|
+
//
|
|
9
|
+
// Feedback (thumbs on a result, /feedback, "Send feedback") is different: the user hands it to us
|
|
10
|
+
// on purpose, so it goes out even with usage stats off. Only WISP_FEEDBACK=0 or no key stops it.
|
|
11
|
+
// Offline, it waits in feedback-outbox.json and goes with the next feedback or the next start.
|
|
8
12
|
|
|
9
13
|
import fs from 'node:fs';
|
|
10
14
|
import os from 'node:os';
|
|
@@ -22,10 +26,16 @@ const TIMEOUT_MS = 3000;
|
|
|
22
26
|
const VERSION = JSON.parse(fs.readFileSync(new URL('../package.json', import.meta.url), 'utf8')).version;
|
|
23
27
|
const truthy = (v) => !!v && !/^(0|false|no|off)$/i.test(v);
|
|
24
28
|
|
|
29
|
+
const falsy = (v) => /^(0|false|no|off)$/i.test(v || '');
|
|
30
|
+
|
|
25
31
|
export function envDisabled(env = process.env) {
|
|
26
|
-
return !KEY || truthy(env.DO_NOT_TRACK) ||
|
|
32
|
+
return !KEY || truthy(env.DO_NOT_TRACK) || falsy(env.WISP_TELEMETRY) || truthy(env.CI);
|
|
27
33
|
}
|
|
28
34
|
|
|
35
|
+
export const feedbackAvailable = (env = process.env) => !!KEY && !falsy(env.WISP_FEEDBACK);
|
|
36
|
+
const FEEDBACK_MAX = 4000;
|
|
37
|
+
const OUTBOX_MAX = 100;
|
|
38
|
+
|
|
29
39
|
const ROOT = path.dirname(path.dirname(fileURLToPath(import.meta.url)));
|
|
30
40
|
|
|
31
41
|
// Paths in messages and stacks would leak usernames and project names: wisp's own files keep
|
|
@@ -50,10 +60,12 @@ function frames(stack = '') {
|
|
|
50
60
|
}
|
|
51
61
|
|
|
52
62
|
export class Telemetry {
|
|
53
|
-
constructor(store, { shell = process.versions.electron ? 'electron' : 'browser' } = {}) {
|
|
63
|
+
constructor(store, { shell = process.versions.electron ? 'electron' : 'browser', post } = {}) {
|
|
54
64
|
this.store = store;
|
|
55
65
|
this.queue = [];
|
|
56
66
|
this.pendingFile = path.join(store.home, 'telemetry-pending.json');
|
|
67
|
+
this.outboxFile = path.join(store.home, 'feedback-outbox.json');
|
|
68
|
+
if (post) this.post = post;
|
|
57
69
|
this.common = {
|
|
58
70
|
$lib: 'wisp',
|
|
59
71
|
$process_person_profile: false, // anonymous events: no person profiles
|
|
@@ -68,6 +80,7 @@ export class Telemetry {
|
|
|
68
80
|
this.timer = setInterval(() => this.flush(), FLUSH_MS);
|
|
69
81
|
this.timer.unref();
|
|
70
82
|
this.#sendPending();
|
|
83
|
+
this.#sendOutbox();
|
|
71
84
|
}
|
|
72
85
|
|
|
73
86
|
get enabled() {
|
|
@@ -93,17 +106,49 @@ export class Telemetry {
|
|
|
93
106
|
});
|
|
94
107
|
}
|
|
95
108
|
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
109
|
+
// Thumbs on a turn ('turn_rated') or a written note ('feedback'). Resolves true once PostHog has
|
|
110
|
+
// it; false means it's kept in the outbox and retried.
|
|
111
|
+
async feedback(event, props = {}) {
|
|
112
|
+
if (!feedbackAvailable()) return false;
|
|
113
|
+
// With usage stats off, a fresh id per message so feedback can't be tied to anything else.
|
|
114
|
+
const id = (this.enabled && this.store.config.telemetryId) || randomUUID();
|
|
115
|
+
const text = props.text ? String(props.text).split(os.homedir()).join('~').slice(0, FEEDBACK_MAX) : undefined;
|
|
116
|
+
const ev = { event, timestamp: new Date().toISOString(), properties: { ...this.common, ...props, text, distinct_id: id } };
|
|
117
|
+
const batch = [...this.#outbox(), ev].slice(-OUTBOX_MAX);
|
|
118
|
+
const ok = await this.post(batch);
|
|
119
|
+
try {
|
|
120
|
+
if (ok) fs.rmSync(this.outboxFile, { force: true });
|
|
121
|
+
else fs.writeFileSync(this.outboxFile, JSON.stringify(batch));
|
|
122
|
+
} catch {}
|
|
123
|
+
return ok;
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
#outbox() {
|
|
127
|
+
try {
|
|
128
|
+
const list = JSON.parse(fs.readFileSync(this.outboxFile, 'utf8'));
|
|
129
|
+
return Array.isArray(list) ? list : [];
|
|
130
|
+
} catch {
|
|
131
|
+
return [];
|
|
132
|
+
}
|
|
133
|
+
}
|
|
134
|
+
|
|
135
|
+
async post(batch) {
|
|
99
136
|
try {
|
|
100
|
-
await fetch(`${HOST}/batch/`, {
|
|
137
|
+
const res = await fetch(`${HOST}/batch/`, {
|
|
101
138
|
method: 'POST',
|
|
102
139
|
headers: { 'content-type': 'application/json' },
|
|
103
140
|
body: JSON.stringify({ api_key: KEY, batch }),
|
|
104
141
|
signal: AbortSignal.timeout(TIMEOUT_MS),
|
|
105
142
|
});
|
|
106
|
-
|
|
143
|
+
return res.ok;
|
|
144
|
+
} catch {
|
|
145
|
+
return false;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
async flush() {
|
|
150
|
+
if (!this.queue.length) return;
|
|
151
|
+
await this.post(this.queue.splice(0)); // offline or blocked: drop it, telemetry must never get in the way
|
|
107
152
|
}
|
|
108
153
|
|
|
109
154
|
// Shutdown and crashes leave no time for a request: write what's queued to disk (sync) and
|
|
@@ -122,6 +167,12 @@ export class Telemetry {
|
|
|
122
167
|
this.persist();
|
|
123
168
|
}
|
|
124
169
|
|
|
170
|
+
#sendOutbox() {
|
|
171
|
+
const batch = this.#outbox();
|
|
172
|
+
if (!batch.length || !feedbackAvailable()) return;
|
|
173
|
+
this.post(batch).then((ok) => ok && fs.rmSync(this.outboxFile, { force: true }));
|
|
174
|
+
}
|
|
175
|
+
|
|
125
176
|
#sendPending() {
|
|
126
177
|
try {
|
|
127
178
|
const pending = JSON.parse(fs.readFileSync(this.pendingFile, 'utf8'));
|
package/web/css/app.css
CHANGED
|
@@ -305,7 +305,8 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
305
305
|
.l-bar { display: flex; align-items: center; gap: 8px; padding-top: 6px; }
|
|
306
306
|
.l-bar > :not(.runners) { flex: none; }
|
|
307
307
|
.l-bar .grow { flex: 1; }
|
|
308
|
-
/* harness pill(s): "vs" rides on the end of it;
|
|
308
|
+
/* harness pill(s): "vs" rides on the end of it; each harness added joins the same pill behind a
|
|
309
|
+
"vs" of its own (hover: ×, removes it), and the end button turns into a + for the next one */
|
|
309
310
|
.runners {
|
|
310
311
|
display: inline-flex; align-items: stretch; min-width: 0; height: 30px; border-radius: 9px;
|
|
311
312
|
border: 1px solid var(--line); background: var(--surface); transition: border-color 0.15s;
|
|
@@ -318,6 +319,12 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
318
319
|
.runners .rb-h { flex: 0 1 auto; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
319
320
|
.runners .rb-m { flex: 0 100 auto; min-width: 0; }
|
|
320
321
|
.runners.cmp .rb-e, .l-bar:has(.runners.cmp) #wtBtn span { display: none; }
|
|
322
|
+
/* three or four harnesses: logos only (each button's tooltip names it), the bar's other labels fold too */
|
|
323
|
+
.runners.many .runner-btn { flex: none; padding-inline: 6px; }
|
|
324
|
+
.runners.many :is(.rb-h, .rb-m) { display: none; }
|
|
325
|
+
.runners.many .rb-logo { margin-right: 3px; }
|
|
326
|
+
.runners.many .runner-btn .chev { margin-left: 0; }
|
|
327
|
+
.l-bar:has(.runners.many) :is(.mode-btn span, .team-btn .tb-label) { display: none; }
|
|
321
328
|
/* two harnesses on a narrow bar: drop the model names, then the harness names (logos stay) */
|
|
322
329
|
@container launcher (max-width: 640px) { .runners.cmp .rb-m { display: none; } }
|
|
323
330
|
@container launcher (max-width: 520px) {
|
|
@@ -326,20 +333,29 @@ body:has(.launcher.docked:is(:focus-within, :has(.open))) .to-top { display: non
|
|
|
326
333
|
.runners.cmp .runner-btn .chev { margin-left: 0; }
|
|
327
334
|
.runners.cmp .rb-logo { margin-right: 4px; }
|
|
328
335
|
}
|
|
336
|
+
@container launcher (max-width: 600px) {
|
|
337
|
+
.runners.many .runner-btn .chev { display: none; }
|
|
338
|
+
.runners.many .vs-del { width: 18px; }
|
|
339
|
+
}
|
|
329
340
|
.vs-btn {
|
|
330
341
|
position: relative; flex: none; display: grid; place-items: center; width: 30px;
|
|
331
342
|
font: 600 11px var(--mono); letter-spacing: 0.02em; color: var(--faint); transition: color 0.15s, background 0.15s;
|
|
332
343
|
}
|
|
333
|
-
.vs-btn
|
|
344
|
+
.vs-btn[hidden] { display: none; }
|
|
334
345
|
.vs-btn:hover { color: var(--accent-ink); }
|
|
335
346
|
.vs-btn .i { display: none; width: 13px; height: 13px; }
|
|
336
347
|
.vs-btn:focus-visible { outline: 0; border-radius: 6px; box-shadow: inset 0 0 0 1px var(--accent); }
|
|
348
|
+
.vs-add::before { content: ''; position: absolute; left: 0; top: 7px; bottom: 7px; width: 1px; background: var(--line); }
|
|
337
349
|
.runners.cmp { border-color: color-mix(in srgb, var(--accent) 35%, var(--line)); }
|
|
338
|
-
.runners.cmp .vs-
|
|
339
|
-
.runners.cmp .vs-
|
|
340
|
-
.runners.cmp .vs-
|
|
341
|
-
.
|
|
342
|
-
.runners.
|
|
350
|
+
.runners.cmp .vs-add { width: 26px; }
|
|
351
|
+
.runners.cmp .vs-add span { display: none; }
|
|
352
|
+
.runners.cmp .vs-add .i { display: block; width: 12px; height: 12px; }
|
|
353
|
+
.vs-del { width: 24px; color: var(--accent-ink); }
|
|
354
|
+
.runners.many .vs-del { width: 20px; }
|
|
355
|
+
.vs-del:hover { color: var(--bad); }
|
|
356
|
+
.vs-del:not(.fresh):is(:hover, :focus-visible) span { display: none; }
|
|
357
|
+
.vs-del:not(.fresh):is(:hover, :focus-visible) .i { display: block; }
|
|
358
|
+
.vs-del.fresh { cursor: default; }
|
|
343
359
|
/* permission mode: icon-only on the default, labelled otherwise */
|
|
344
360
|
.mode-btn {
|
|
345
361
|
display: inline-flex; align-items: center; gap: 6px; height: 32px; min-width: 32px; padding: 0 9px; border-radius: 9px;
|
|
@@ -1070,6 +1086,28 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1070
1086
|
.trail-bar .link { font-size: 12px; }
|
|
1071
1087
|
.trail-body { padding: 8px 0 0; animation: rise 0.4s var(--ease); }
|
|
1072
1088
|
.trail-body .presentation { gap: 10px; }
|
|
1089
|
+
/* Thumbs: faint until the trail is hovered or the turn is rated, so a finished turn reads the same. */
|
|
1090
|
+
.rate { display: inline-flex; gap: 1px; margin-left: -6px; opacity: 0.5; transition: opacity 0.15s; }
|
|
1091
|
+
.turn:hover .rate, .rate.rated, .rate:focus-within { opacity: 1; }
|
|
1092
|
+
.rate-btn { display: inline-grid; place-items: center; width: 24px; height: 22px; border-radius: 6px; color: var(--faint); transition: color 0.15s, background 0.15s; }
|
|
1093
|
+
.rate-btn .i { width: 13px; height: 13px; }
|
|
1094
|
+
.rate-btn:hover { color: var(--ink); background: var(--bg-2); }
|
|
1095
|
+
.rate-btn[data-rate="up"].on { color: var(--good); }
|
|
1096
|
+
.rate-btn[data-rate="down"].on { color: var(--bad); }
|
|
1097
|
+
.rate-btn.on .i { fill: color-mix(in srgb, currentColor 18%, transparent); }
|
|
1098
|
+
.rate.thanked [data-rate="up"].on .i { animation: thumb-pop 0.45s var(--ease); }
|
|
1099
|
+
@keyframes thumb-pop { 40% { transform: scale(1.35) rotate(-12deg); } }
|
|
1100
|
+
.why { display: grid; gap: 6px; }
|
|
1101
|
+
.why .textarea { min-height: 0; resize: none; }
|
|
1102
|
+
.why-foot { display: flex; align-items: center; gap: 10px; }
|
|
1103
|
+
.why-foot .muted { margin-right: auto; }
|
|
1104
|
+
.why-foot .primary { height: 24px; padding: 0 10px; font-size: 12px; border-radius: 7px; }
|
|
1105
|
+
.fb-modal { width: min(520px, 100%); }
|
|
1106
|
+
.fb-rate { display: flex; align-items: center; gap: 4px; margin-bottom: 10px; font-size: 13px; color: var(--muted); }
|
|
1107
|
+
.fb-rate span { margin-right: 6px; }
|
|
1108
|
+
.fb-rate .rate-btn { width: 30px; height: 28px; }
|
|
1109
|
+
.fb-rate .rate-btn .i { width: 16px; height: 16px; }
|
|
1110
|
+
.fb-modal .ship-foot { margin-top: 12px; }
|
|
1073
1111
|
|
|
1074
1112
|
/* ---------- per-item composer ---------- */
|
|
1075
1113
|
.composer {
|
|
@@ -1087,6 +1125,12 @@ pre.code { margin: 0 0 12px; padding: 12px 14px; border-radius: 10px; background
|
|
|
1087
1125
|
.cq .att-x:hover { color: var(--bad); }
|
|
1088
1126
|
.cq-text { min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--muted); }
|
|
1089
1127
|
.c-hint { flex: 1; font-size: 11.5px; color: var(--faint); }
|
|
1128
|
+
.c-bar .mode-btn { height: 30px; min-width: 30px; padding: 0 8px; }
|
|
1129
|
+
.c-bar .mode-btn.changed { background: var(--bg-2); }
|
|
1130
|
+
.t-mode { display: inline-flex; align-items: center; gap: 4px; padding: 1px 6px; border-radius: 5px; font: 500 11px var(--sans); color: var(--muted); border: 1px solid var(--line); }
|
|
1131
|
+
.t-mode .i { width: 12px; height: 12px; }
|
|
1132
|
+
.t-mode.m-plan { color: var(--accent-ink); }
|
|
1133
|
+
.t-mode.m-auto { color: var(--warn); }
|
|
1090
1134
|
.send { display: grid; place-items: center; width: 30px; height: 30px; border-radius: 8px; background: var(--ink); color: var(--bg); transition: opacity 0.15s; }
|
|
1091
1135
|
.send:hover { opacity: 0.86; }
|
|
1092
1136
|
.send:disabled { opacity: 0.5; }
|
|
@@ -1693,6 +1737,8 @@ body.electron.mac .topbar { padding-left: 88px; }
|
|
|
1693
1737
|
.ph-ok { color: var(--good); }
|
|
1694
1738
|
.ph-bad { color: var(--bad); max-width: 260px; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
1695
1739
|
.ph-ok .i, .ph-bad .i { width: 12px; height: 12px; }
|
|
1740
|
+
.ph-start { margin-top: 10px; padding-top: 10px; border-top: 1px solid var(--line); }
|
|
1741
|
+
.ph-row { display: flex; align-items: center; gap: 8px; font-size: 13px; margin-bottom: 6px; }
|
|
1696
1742
|
|
|
1697
1743
|
/* ---------- subagents ---------- */
|
|
1698
1744
|
/* logo stack: the lead in front, its subagents tucked behind; they fan out on hover */
|
|
@@ -1823,3 +1869,33 @@ body.electron.mac .topbar { padding-left: 88px; }
|
|
|
1823
1869
|
.sub-task p { margin: 3px 0 0; font-size: 13px; color: var(--ink-2); white-space: pre-wrap; }
|
|
1824
1870
|
.sub-wait { margin: 0; font-size: 13px; }
|
|
1825
1871
|
@container (max-width: 520px) { .sub-files, .sub-dur, .sub-model { display: none; } }
|
|
1872
|
+
|
|
1873
|
+
/* Auto: wisp's router as a harness (picker, card badge, Settings → Auto) */
|
|
1874
|
+
.auto-mark { display: inline-flex; align-items: center; justify-content: center; color: var(--accent); }
|
|
1875
|
+
.auto-mark .i { width: 100%; height: 100%; }
|
|
1876
|
+
.model-logo.routed::before { content: ''; position: absolute; right: -3px; bottom: -3px; width: 6px; height: 6px; border-radius: 50%; background: var(--accent); box-shadow: 0 0 0 1.5px var(--surface); }
|
|
1877
|
+
.pk-note { margin: 8px 10px 4px; font-size: 11.5px; line-height: 1.45; color: var(--muted); }
|
|
1878
|
+
.pk-note a { color: var(--accent-ink); }
|
|
1879
|
+
.ro-goal { flex: none; }
|
|
1880
|
+
.ro-allowed { display: flex; flex-wrap: wrap; justify-content: flex-end; gap: 4px; max-width: 360px; }
|
|
1881
|
+
.chip-tog { display: inline-flex; align-items: center; gap: 5px; padding: 3px 8px; border-radius: 7px; border: 1px solid var(--line); font-size: 12px; color: var(--muted); }
|
|
1882
|
+
.chip-tog:hover { color: var(--ink); }
|
|
1883
|
+
.chip-tog.on { border-color: var(--accent); background: var(--accent-soft); color: var(--accent-ink); }
|
|
1884
|
+
.router-modal { width: min(720px, 100%); position: relative; padding-bottom: 20px; }
|
|
1885
|
+
.router-modal .hint { margin: 0 0 12px; }
|
|
1886
|
+
.router-modal h4 { margin: 16px 0 6px; }
|
|
1887
|
+
.ro-table td small.ro-types { display: block; margin: 2px 0 0 19px; font-size: 11px; color: var(--faint); }
|
|
1888
|
+
.ro-table .sx-state { justify-content: flex-end; }
|
|
1889
|
+
.sx-state.ok { color: var(--good); }
|
|
1890
|
+
.sx-state.warn { color: var(--warn); }
|
|
1891
|
+
.dot.warn { background: var(--warn); }
|
|
1892
|
+
.ro-recent { list-style: none; margin: 0; padding: 0; }
|
|
1893
|
+
.ro-recent li { display: flex; align-items: center; gap: 8px; padding: 6px 2px; border-bottom: 1px solid var(--line); font-size: 12.5px; }
|
|
1894
|
+
.ro-recent .ro-title { flex: 1; min-width: 0; overflow: hidden; text-overflow: ellipsis; white-space: nowrap; color: var(--ink); }
|
|
1895
|
+
.ro-recent small { flex: none; color: var(--faint); }
|
|
1896
|
+
.ro-recent .ok { color: var(--good); }
|
|
1897
|
+
.ro-recent .warn { color: var(--warn); }
|
|
1898
|
+
.ro-recent .bad { color: var(--bad); }
|
|
1899
|
+
.ro-recent .dim { color: var(--faint); }
|
|
1900
|
+
.ro-foot { display: flex; align-items: center; gap: 10px; margin-top: 16px; }
|
|
1901
|
+
.ro-foot .hint { margin: 0; }
|
package/web/index.html
CHANGED
|
@@ -56,6 +56,8 @@
|
|
|
56
56
|
<symbol id="i-clock" viewBox="0 0 24 24"><circle cx="12" cy="12" r="8.5" fill="none" stroke="currentColor" stroke-width="1.8"/><path d="M12 7.5V12l3 2" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round"/></symbol>
|
|
57
57
|
<symbol id="i-laptop" viewBox="0 0 24 24"><rect x="4.5" y="5" width="15" height="10.5" rx="1.8" fill="none" stroke="currentColor" stroke-width="1.7"/><path d="M2.5 19h19" stroke="currentColor" stroke-width="1.7" stroke-linecap="round"/></symbol>
|
|
58
58
|
<symbol id="i-server" viewBox="0 0 24 24"><rect x="3.5" y="4" width="17" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><rect x="3.5" y="13" width="17" height="7" rx="2" fill="none" stroke="currentColor" stroke-width="1.7"/><circle cx="7.3" cy="7.5" r="1.1" fill="currentColor"/><circle cx="7.3" cy="16.5" r="1.1" fill="currentColor"/><path d="M11 7.5h5.5M11 16.5h5.5" stroke="currentColor" stroke-width="1.5" stroke-linecap="round"/></symbol>
|
|
59
|
+
<symbol id="i-thumb-up" viewBox="0 0 24 24"><path d="M7.5 10.5v9H4.8a.8.8 0 01-.8-.8v-7.4a.8.8 0 01.8-.8h2.7zm0 0l3.6-6.3a1.7 1.7 0 013.1 1.2l-.6 3.6h4.6a2 2 0 012 2.3l-1.1 6.4a2.4 2.4 0 01-2.4 2.1H7.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
|
60
|
+
<symbol id="i-thumb-down" viewBox="0 0 24 24"><path d="M7.5 13.5v-9H4.8a.8.8 0 00-.8.8v7.4a.8.8 0 00.8.8h2.7zm0 0l3.6 6.3a1.7 1.7 0 003.1-1.2l-.6-3.6h4.6a2 2 0 002-2.3l-1.1-6.4a2.4 2.4 0 00-2.4-2.1H7.5" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linejoin="round"/></symbol>
|
|
59
61
|
<symbol id="i-play" viewBox="0 0 24 24"><path d="M7 5.5v13l11-6.5-11-6.5z" fill="currentColor"/></symbol>
|
|
60
62
|
</defs>
|
|
61
63
|
</svg>
|