@hexiaowu/dsh-web-search-zai 0.0.0-stage → 0.1.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/client.js ADDED
@@ -0,0 +1,538 @@
1
+ /**
2
+ * Browser half: a small settings form on the Plugins page for the Z.ai API key.
3
+ *
4
+ * The Host half only exposes a `Config` schema; the Plugins page renders a
5
+ * bundle's own configuration through the keyed `plugins.bundle.config` slot, so
6
+ * this module registers a form there. The form writes the key through the DSH
7
+ * credential seam (`remote.credentials.set`) under the reference the provider
8
+ * reads (`ZAI_CODING_CN_API_KEY`), never into the profile patch or a session
9
+ * log. It only reads back `configured`/`writable`, never the key itself.
10
+ *
11
+ * Loaded through the Web client module table; React and the slot/remote/locale
12
+ * services are provided by the host.
13
+ */
14
+ window.__ModuleLoader__.load({
15
+ id: '@hexiaowu/dsh-web-search-zai',
16
+ factory(require) {
17
+ const React = require('react');
18
+ const h = React.createElement;
19
+
20
+ /** Locale namespace, shared with the package's locale resources. */
21
+ const NS = 'dsh-web-search-zai';
22
+ /** Credential reference the Host provider resolves at each search. */
23
+ const KEY_REF = 'ZAI_CODING_CN_API_KEY';
24
+ /** Host route behind the Test button; keep in sync with lib/index.js. */
25
+ const TEST_PATH = '/dsh-web-search-zai/test';
26
+ /** Host route that reveals the current key to this local Settings page. */
27
+ const KEY_PATH = '/dsh-web-search-zai/key';
28
+
29
+ const zh = {
30
+ nav: 'Z.ai 搜索',
31
+ keyTitle: 'API Key',
32
+ keyHint: '保存在 DSH 凭据库的 refs 段,不会写进 profile 配置,也不会出现在会话日志里。输入框默认明文显示当前 key(本机 loopback 读取),输入框内右侧的眼睛图标可切换明文/隐藏;保存后下一次 web_search 立即生效。若插件配置里填了字面 apiKey,它会优先,建议保持为空。',
33
+ refLabel: '引用名',
34
+ placeholder: '粘贴 Z.ai / 智谱 coding plan key',
35
+ configured: '已配置',
36
+ notConfigured: '未配置',
37
+ checking: '检查中…',
38
+ save: '保存',
39
+ saving: '保存中…',
40
+ saved: '已保存,下一次搜索生效。',
41
+ empty: '请先输入 key。',
42
+ readError: '读取 key 状态失败。',
43
+ saveError: '保存失败,请检查凭据库权限后重试。',
44
+ managedElsewhere: '当前 key 由启动环境提供,不能在这里替换。',
45
+ fallbackTitle: 'Z.ai 不可用时回退到 DeepSeek',
46
+ fallbackHint: 'Z.ai 全部失败时按 deepseek-official → deepseek-search-api 顺序尝试;会使用 DeepSeek 额度。',
47
+ configUnavailable: '配置服务不可用,回退开关暂不可用。',
48
+ configSaved: '回退设置已保存。',
49
+ configError: '回退设置保存失败。',
50
+ testTitle: '测试搜索',
51
+ testHint: '会真实调用一次搜索(消耗一次 Z.ai 套餐调用;Z.ai 失败时可能走 DeepSeek)。',
52
+ testRun: '测试',
53
+ testRunning: '测试中…',
54
+ testOk: '搜索可用',
55
+ sourcesUnit: ' 条结果',
56
+ testFailed: '搜索失败',
57
+ testUnavailable: '测试端点不可用(重启 Harness 后重试)。',
58
+ showKey: '显示',
59
+ hideKey: '隐藏',
60
+ };
61
+ const en = {
62
+ nav: 'Z.ai search',
63
+ keyTitle: 'API key',
64
+ keyHint: 'Stored in the DSH credential store under refs, never in the profile configuration or a session log. The field shows the current key in plain text by default (read from a local loopback endpoint); the eye icon inside the input toggles visibility. The next web_search uses a saved key immediately. A literal apiKey in the plugin config takes precedence, so leave it empty.',
65
+ refLabel: 'Reference',
66
+ placeholder: 'Paste a Z.ai / GLM coding plan key',
67
+ configured: 'Configured',
68
+ notConfigured: 'Not configured',
69
+ checking: 'Checking…',
70
+ save: 'Save',
71
+ saving: 'Saving…',
72
+ saved: 'Saved. The next search uses the new key.',
73
+ empty: 'Enter a key first.',
74
+ readError: 'Could not read the key status.',
75
+ saveError: 'Could not save the key. Check the credential store permissions and retry.',
76
+ managedElsewhere: 'This key comes from the launch environment and cannot be replaced here.',
77
+ fallbackTitle: 'Fall back to DeepSeek when Z.ai is down',
78
+ fallbackHint: 'Tries deepseek-official, then deepseek-search-api, after every Z.ai attempt fails. Uses DeepSeek quota.',
79
+ configUnavailable: 'The configuration service is unavailable; the fallback switch is disabled.',
80
+ configSaved: 'Fallback setting saved.',
81
+ configError: 'Could not save the fallback setting.',
82
+ testTitle: 'Test search',
83
+ testHint: 'Runs one real search (one Z.ai plan call; may use DeepSeek when Z.ai fails).',
84
+ testRun: 'Test',
85
+ testRunning: 'Testing…',
86
+ testOk: 'Search works',
87
+ sourcesUnit: ' sources',
88
+ testFailed: 'Search failed',
89
+ testUnavailable: 'The test endpoint is unavailable (restart Harness and retry).',
90
+ showKey: 'Show',
91
+ hideKey: 'Hide',
92
+ };
93
+
94
+ const styles = {
95
+ card: {
96
+ display: 'flex',
97
+ flexDirection: 'column',
98
+ gap: 8,
99
+ padding: '12px 14px',
100
+ border: '1px solid var(--dsw-alias-border-l2)',
101
+ borderRadius: 10,
102
+ background: 'var(--dsw-alias-bg-layer-1)',
103
+ color: 'var(--dsw-alias-label-primary)',
104
+ maxWidth: 760,
105
+ boxSizing: 'border-box',
106
+ },
107
+ header: { display: 'flex', alignItems: 'baseline', gap: 8, flexWrap: 'wrap' },
108
+ title: { fontSize: 13, fontWeight: 600 },
109
+ hint: { fontSize: 11, lineHeight: 1.55, color: 'var(--dsw-alias-label-secondary)' },
110
+ meta: { fontSize: 10.5, color: 'var(--dsw-alias-label-secondary)' },
111
+ code: {
112
+ fontFamily: 'var(--ds-font-family-code, ui-monospace, SFMono-Regular, Menlo, monospace)',
113
+ fontSize: 10.5,
114
+ },
115
+ row: { display: 'flex', gap: 8, alignItems: 'center', flexWrap: 'wrap' },
116
+ inputWrap: {
117
+ position: 'relative',
118
+ flex: '1 1 280px',
119
+ minWidth: 0,
120
+ display: 'flex',
121
+ },
122
+ input: {
123
+ width: '100%',
124
+ boxSizing: 'border-box',
125
+ padding: '7px 36px 7px 9px',
126
+ borderRadius: 7,
127
+ border: '1px solid var(--dsw-alias-border-l2)',
128
+ background: 'var(--dsw-alias-bg-layer-2)',
129
+ color: 'var(--dsw-alias-label-primary)',
130
+ fontSize: 12,
131
+ outline: 'none',
132
+ },
133
+ button: (busy) => ({
134
+ padding: '7px 14px',
135
+ borderRadius: 7,
136
+ border: '1px solid var(--dsw-alias-brand-primary)',
137
+ background: 'var(--dsw-alias-brand-primary)',
138
+ color: 'var(--dsw-alias-label-primary-foreground, #fff)',
139
+ fontSize: 12,
140
+ fontWeight: 500,
141
+ cursor: busy ? 'default' : 'pointer',
142
+ opacity: busy ? 0.7 : 1,
143
+ }),
144
+ message: { fontSize: 11, color: 'var(--dsw-alias-state-success-primary)' },
145
+ error: { fontSize: 11, color: 'var(--dsw-alias-state-error-primary)' },
146
+ divider: { borderTop: '1px solid var(--dsw-alias-border-l2)', margin: '2px 0' },
147
+ toggleRow: { display: 'flex', alignItems: 'flex-start', gap: 8, cursor: 'pointer' },
148
+ toggleCopy: { display: 'flex', flexDirection: 'column', gap: 2, minWidth: 0 },
149
+ toggleTitle: { fontSize: 12, fontWeight: 500, color: 'var(--dsw-alias-label-primary)' },
150
+ link: { fontSize: 11, color: 'var(--dsw-alias-brand-primary)', wordBreak: 'break-all' },
151
+ eyeButton: {
152
+ position: 'absolute',
153
+ right: 6,
154
+ top: '50%',
155
+ transform: 'translateY(-50%)',
156
+ width: 26,
157
+ height: 26,
158
+ display: 'flex',
159
+ alignItems: 'center',
160
+ justifyContent: 'center',
161
+ padding: 0,
162
+ border: 'none',
163
+ borderRadius: 6,
164
+ background: 'transparent',
165
+ color: 'var(--dsw-alias-label-secondary)',
166
+ cursor: 'pointer',
167
+ },
168
+ };
169
+
170
+ /** Password visibility glyph; `off` draws the slash for the visible state. */
171
+ function EyeIcon({ off }) {
172
+ return h(
173
+ 'svg',
174
+ {
175
+ viewBox: '0 0 24 24',
176
+ width: 16,
177
+ height: 16,
178
+ 'aria-hidden': true,
179
+ fill: 'none',
180
+ stroke: 'currentColor',
181
+ strokeWidth: 1.8,
182
+ strokeLinecap: 'round',
183
+ strokeLinejoin: 'round',
184
+ },
185
+ h('path', { d: 'M2 12s3.5-7 10-7 10 7 10 7-3.5 7-10 7S2 12 2 12Z' }),
186
+ h('circle', { cx: 12, cy: 12, r: 3 }),
187
+ off ? h('path', { d: 'm4 4 16 16' }) : null,
188
+ );
189
+ }
190
+
191
+ /** The Z.ai search settings form: key, fallback switch, and test button. */
192
+ function KeyForm(props) {
193
+ const { t, describeKey, setKey, form, testPath } = props;
194
+ const [state, setState] = React.useState({
195
+ loading: true,
196
+ configured: false,
197
+ writable: true,
198
+ draft: '',
199
+ busy: false,
200
+ message: '',
201
+ error: '',
202
+ });
203
+ const [config, setConfig] = React.useState(() => (typeof form?.getSnapshot === 'function' ? form.getSnapshot() : undefined));
204
+ const [fallbackBusy, setFallbackBusy] = React.useState(false);
205
+ const [fallbackNotice, setFallbackNotice] = React.useState('');
206
+ const [fallbackNoticeError, setFallbackNoticeError] = React.useState(false);
207
+ const [test, setTest] = React.useState({ busy: false, result: null, error: '' });
208
+ const [revealKey, setRevealKey] = React.useState(true);
209
+
210
+ React.useEffect(() => {
211
+ if (!form || typeof form.subscribe !== 'function') return undefined;
212
+ setConfig(form.getSnapshot());
213
+ return form.subscribe(() => setConfig(form.getSnapshot()));
214
+ }, [form]);
215
+
216
+ const refresh = React.useCallback(async () => {
217
+ try {
218
+ const response = await describeKey([KEY_REF]);
219
+ if (!response || response.ok === false) throw new Error('describe failed');
220
+ const info = response.value ? response.value[KEY_REF] : undefined;
221
+ setState((current) => ({
222
+ ...current,
223
+ loading: false,
224
+ configured: info?.configured === true,
225
+ writable: info?.writable !== false,
226
+ }));
227
+ } catch {
228
+ setState((current) => ({ ...current, loading: false, error: t('readError') }));
229
+ }
230
+ }, [describeKey, t]);
231
+
232
+ React.useEffect(() => {
233
+ refresh();
234
+ }, [refresh]);
235
+
236
+ React.useEffect(() => {
237
+ let cancelled = false;
238
+ (async () => {
239
+ try {
240
+ const response = await fetch(KEY_PATH, {
241
+ headers: { accept: 'application/json' },
242
+ cache: 'no-store',
243
+ });
244
+ if (!response.ok) return;
245
+ const data = await response.json();
246
+ if (
247
+ !cancelled
248
+ && data?.ok === true
249
+ && typeof data.apiKey === 'string'
250
+ && data.apiKey.length > 0
251
+ ) {
252
+ setState((current) => (current.draft.length === 0
253
+ ? { ...current, draft: data.apiKey }
254
+ : current));
255
+ }
256
+ } catch {
257
+ // The field still accepts a new key when the read is unavailable.
258
+ }
259
+ })();
260
+ return () => {
261
+ cancelled = true;
262
+ };
263
+ }, []);
264
+
265
+ const update = (patch) => setState((current) => ({ ...current, ...patch }));
266
+
267
+ const onSave = async () => {
268
+ if (state.busy || !state.writable) return;
269
+ const value = state.draft.trim();
270
+ if (value.length === 0) {
271
+ update({ error: t('empty'), message: '' });
272
+ return;
273
+ }
274
+ update({ busy: true, error: '', message: '' });
275
+ try {
276
+ const response = await setKey(KEY_REF, value);
277
+ if (response && response.ok === false) throw new Error('set failed');
278
+ await refresh();
279
+ update({ busy: false, message: t('saved') });
280
+ } catch {
281
+ update({ busy: false, error: t('saveError') });
282
+ }
283
+ };
284
+
285
+ const onKeyDown = (event) => {
286
+ if (event.key !== 'Enter' || event.isComposing || event.nativeEvent?.isComposing) return;
287
+ event.preventDefault();
288
+ onSave();
289
+ };
290
+
291
+ const fallbackEnabled = config?.value?.fallbackEnabled !== false;
292
+ const fallbackBase = config?.base?.fallbackEnabled !== false;
293
+ const fallbackWritable = form !== undefined
294
+ && config !== undefined
295
+ && config.status === 'ready'
296
+ && config.writable !== false;
297
+
298
+ const onToggleFallback = async (event) => {
299
+ const next = event.target.checked;
300
+ if (!form || !fallbackWritable) return;
301
+ setFallbackBusy(true);
302
+ setFallbackNotice('');
303
+ setFallbackNoticeError(false);
304
+ try {
305
+ const ok = next === fallbackBase
306
+ ? await form.unset('fallbackEnabled')
307
+ : await form.set('fallbackEnabled', next);
308
+ setFallbackBusy(false);
309
+ if (ok) {
310
+ setFallbackNotice(t('configSaved'));
311
+ } else {
312
+ setFallbackNotice(t('configError'));
313
+ setFallbackNoticeError(true);
314
+ }
315
+ } catch {
316
+ setFallbackBusy(false);
317
+ setFallbackNotice(t('configError'));
318
+ setFallbackNoticeError(true);
319
+ }
320
+ };
321
+
322
+ const onTest = async () => {
323
+ if (test.busy) return;
324
+ setTest({ busy: true, result: null, error: '' });
325
+ try {
326
+ const response = await fetch(testPath ?? TEST_PATH, {
327
+ headers: { accept: 'application/json' },
328
+ cache: 'no-store',
329
+ });
330
+ if (!response.ok) {
331
+ setTest({
332
+ busy: false,
333
+ result: null,
334
+ error: response.status === 404 ? t('testUnavailable') : `HTTP ${response.status}`,
335
+ });
336
+ return;
337
+ }
338
+ const data = await response.json();
339
+ if (data?.ok === true) setTest({ busy: false, result: data, error: '' });
340
+ else setTest({ busy: false, result: data, error: data?.message ?? t('testFailed') });
341
+ } catch {
342
+ setTest({ busy: false, result: null, error: t('testUnavailable') });
343
+ }
344
+ };
345
+
346
+ return h(
347
+ 'section',
348
+ { style: styles.card, 'data-zai-settings': 'api-key' },
349
+ h(
350
+ 'div',
351
+ { style: styles.header },
352
+ h('span', { style: styles.title }, t('keyTitle')),
353
+ h(
354
+ 'span',
355
+ {
356
+ style: {
357
+ fontSize: 11,
358
+ color: state.configured
359
+ ? 'var(--dsw-alias-state-success-primary)'
360
+ : 'var(--dsw-alias-label-secondary)',
361
+ },
362
+ },
363
+ state.loading ? t('checking') : state.configured ? t('configured') : t('notConfigured'),
364
+ ),
365
+ ),
366
+ h('div', { style: styles.hint }, t('keyHint')),
367
+ h(
368
+ 'div',
369
+ { style: styles.meta },
370
+ `${t('refLabel')}: `,
371
+ h('code', { style: styles.code }, KEY_REF),
372
+ ),
373
+ h(
374
+ 'div',
375
+ { style: styles.row },
376
+ h(
377
+ 'div',
378
+ { style: styles.inputWrap },
379
+ h('input', {
380
+ type: revealKey ? 'text' : 'password',
381
+ value: state.draft,
382
+ placeholder: t('placeholder'),
383
+ 'aria-label': t('keyTitle'),
384
+ autoComplete: 'off',
385
+ spellCheck: false,
386
+ disabled: state.busy || !state.writable,
387
+ onChange: (event) => update({ draft: event.target.value, error: '', message: '' }),
388
+ onKeyDown,
389
+ style: {
390
+ ...styles.input,
391
+ opacity: state.writable ? 1 : 0.6,
392
+ },
393
+ }),
394
+ h(
395
+ 'button',
396
+ {
397
+ type: 'button',
398
+ 'aria-label': revealKey ? t('hideKey') : t('showKey'),
399
+ 'aria-pressed': revealKey,
400
+ title: revealKey ? t('hideKey') : t('showKey'),
401
+ onClick: () => setRevealKey((value) => !value),
402
+ style: styles.eyeButton,
403
+ },
404
+ h(EyeIcon, { off: revealKey }),
405
+ ),
406
+ ),
407
+ h(
408
+ 'button',
409
+ {
410
+ type: 'button',
411
+ disabled: state.busy || !state.writable,
412
+ onClick: onSave,
413
+ style: styles.button(state.busy),
414
+ },
415
+ state.busy ? t('saving') : t('save'),
416
+ ),
417
+ ),
418
+ state.message ? h('div', { style: styles.message }, state.message) : null,
419
+ state.error ? h('div', { style: styles.error }, state.error) : null,
420
+ !state.writable ? h('div', { style: styles.hint }, t('managedElsewhere')) : null,
421
+ h('div', { style: styles.divider }),
422
+ h(
423
+ 'label',
424
+ { style: styles.toggleRow },
425
+ h('input', {
426
+ type: 'checkbox',
427
+ checked: fallbackEnabled,
428
+ disabled: fallbackBusy || !fallbackWritable,
429
+ onChange: onToggleFallback,
430
+ style: { marginTop: 2 },
431
+ }),
432
+ h(
433
+ 'span',
434
+ { style: styles.toggleCopy },
435
+ h('span', { style: styles.toggleTitle }, t('fallbackTitle')),
436
+ h('span', { style: styles.hint }, t('fallbackHint')),
437
+ !form || config?.status === 'unavailable' ? h('span', { style: styles.hint }, t('configUnavailable')) : null,
438
+ ),
439
+ ),
440
+ fallbackNotice
441
+ ? h('div', { style: fallbackNoticeError ? styles.error : styles.message }, fallbackNotice)
442
+ : null,
443
+ h('div', { style: styles.divider }),
444
+ h('div', { style: styles.header }, h('span', { style: styles.title }, t('testTitle'))),
445
+ h(
446
+ 'div',
447
+ { style: styles.row },
448
+ h(
449
+ 'button',
450
+ {
451
+ type: 'button',
452
+ disabled: test.busy,
453
+ onClick: onTest,
454
+ style: styles.button(test.busy),
455
+ },
456
+ test.busy ? t('testRunning') : t('testRun'),
457
+ ),
458
+ h('span', { style: styles.hint }, t('testHint')),
459
+ ),
460
+ test.result?.ok === true
461
+ ? h(
462
+ 'div',
463
+ { style: styles.message },
464
+ `${t('testOk')} · ${test.result.count}${t('sourcesUnit')}${test.result.firstTitle ? ` · ${test.result.firstTitle}` : ''}`,
465
+ )
466
+ : null,
467
+ test.result?.ok === true && test.result.firstUrl
468
+ ? h(
469
+ 'a',
470
+ { href: test.result.firstUrl, target: '_blank', rel: 'noreferrer', style: styles.link },
471
+ test.result.firstUrl,
472
+ )
473
+ : null,
474
+ test.error
475
+ ? h(
476
+ 'div',
477
+ { style: { ...styles.error, whiteSpace: 'pre-wrap', maxHeight: 160, overflow: 'auto' } },
478
+ test.error,
479
+ )
480
+ : null,
481
+ );
482
+ }
483
+
484
+ /** Settings-section icon: the same magnifier/Z mark as the bundle icon. */
485
+ function ZaiIcon({ size }) {
486
+ const dimension = Number.isFinite(size) ? size : 16;
487
+ return h(
488
+ 'svg',
489
+ {
490
+ viewBox: '0 0 24 24',
491
+ width: dimension,
492
+ height: dimension,
493
+ 'aria-hidden': true,
494
+ fill: 'none',
495
+ stroke: 'currentColor',
496
+ strokeWidth: 1.9,
497
+ strokeLinecap: 'round',
498
+ strokeLinejoin: 'round',
499
+ },
500
+ h('circle', { cx: 10.5, cy: 10.5, r: 6 }),
501
+ h('path', { d: 'm15 15 5 5' }),
502
+ h('path', { d: 'M8 8h5l-5 5h5' }),
503
+ );
504
+ }
505
+
506
+
507
+
508
+ return {
509
+ inject: ['slots', 'locale', 'remote', 'remote.credentials', 'configForms'],
510
+ apply(ctx) {
511
+ const t = ctx.locale.bind(NS);
512
+ ctx.effect(() => ctx.locale.register(NS, { zh, en }), 'dsh-web-search-zai: dictionaries');
513
+
514
+ const describeKey = (refs) => ctx.remote.credentials.describe(refs);
515
+ const setKey = (ref, value) => ctx.remote.credentials.set(ref, value);
516
+ const form = ctx.configForms?.get?.('web-search-zai');
517
+ const settingsProps = () => ({ t, describeKey, setKey, form, testPath: TEST_PATH });
518
+
519
+ // The one UI entry: a first-class Settings section, like dsh-mcp-link's
520
+ // "MCP 联动". The Plugins bundle card only installs/enables the bundle.
521
+ ctx.slots.inject('settings.section', () => ctx.slots.register({
522
+ name: 'settings.section',
523
+ id: 'web-search-zai',
524
+ order: 19,
525
+ label: () => t('nav'),
526
+ locale: NS,
527
+ inject: settingsProps,
528
+ }, KeyForm));
529
+
530
+ ctx.slots.inject('settings.section.icon', () => ctx.slots.register({
531
+ name: 'settings.section.icon',
532
+ id: 'web-search-zai',
533
+ locale: NS,
534
+ }, ZaiIcon));
535
+ },
536
+ };
537
+ },
538
+ });
@@ -0,0 +1,18 @@
1
+ # 1) Insert this bundle's provider row. `name` must equal package.json#name
2
+ # verbatim: the Loader resolves modules by that specifier.
3
+ - insert:
4
+ - id: web-search-zai
5
+ name: '@hexiaowu/dsh-web-search-zai'
6
+ config:
7
+ apiKeyEnv: ZAI_CODING_CN_API_KEY
8
+ mode: auto
9
+ region: cn
10
+
11
+ # 2) Point the web seam's search channel at this provider.
12
+ # A patch replaces the row's whole `config` (never deep-merges), so
13
+ # `fetchProvider` must be restated or web_fetch loses its provider too.
14
+ - id: web
15
+ name: '@deepseek-ai/dsh-web'
16
+ config:
17
+ searchProvider: zai
18
+ fetchProvider: http
package/icon.svg ADDED
@@ -0,0 +1,12 @@
1
+ <svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 64 64" role="img" aria-label="Z.ai web search">
2
+ <defs>
3
+ <linearGradient id="g" x1="0" y1="0" x2="1" y2="1">
4
+ <stop offset="0" stop-color="#4f7cff"/>
5
+ <stop offset="1" stop-color="#22c1a6"/>
6
+ </linearGradient>
7
+ </defs>
8
+ <rect width="64" height="64" rx="14" fill="url(#g)"/>
9
+ <circle cx="28" cy="28" r="13" fill="none" stroke="#fff" stroke-width="5"/>
10
+ <path d="M37.5 37.5 50 50" fill="none" stroke="#fff" stroke-width="6" stroke-linecap="round"/>
11
+ <path d="M21 21h13l-13 14h13" fill="none" stroke="#fff" stroke-width="3.2" stroke-linecap="round" stroke-linejoin="round"/>
12
+ </svg>