@liustack/modlens 3.16.6 → 3.16.7

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/dsh/client.js CHANGED
@@ -15,7 +15,7 @@
15
15
  // host half.
16
16
  window.__ModuleLoader__.load({
17
17
  id: '@liustack/modlens',
18
- factory: () => {
18
+ factory: (require) => {
19
19
  var module = { exports: {} }
20
20
  var exports = module.exports
21
21
 
@@ -172,7 +172,615 @@ window.__ModuleLoader__.load({
172
172
  })
173
173
  }
174
174
 
175
+ // The settings card (issue #39). dsh renders a fixed set of plugin cards
176
+ // and does not enumerate settings namespaces, so a card is contributed
177
+ // through the `settings.plugin.item` slot rather than by declaring a
178
+ // schema. It reads and writes the host route above, which owns
179
+ // ~/.modlens/config.json: the browser never sees an API key, and never
180
+ // sends a blank one back over a stored key.
181
+ var ENGINES = ['antigravity-cli', 'gemini-api', 'openai', 'anthropic', 'claude-cli']
182
+ var REUSE = ['claude', 'codex', 'opencode', 'pi', 'grok']
183
+
184
+ // Two short label sets rather than a locale bundle: the card has a dozen
185
+ // strings, and a bundle would be more machinery than the thing it labels.
186
+ var TEXT = {
187
+ en: {
188
+ title: 'Vision engine (ModLens)',
189
+ subtitle: 'Vision engine provider configuration.',
190
+ openConfig: 'Open config file',
191
+ automatic: 'Automatic (failover chain decides)',
192
+ pickToConfigure: 'Pick an engine above to configure its key and endpoint.',
193
+ engine: 'Engine',
194
+ apiKey: 'API key',
195
+ baseUrl: 'Base URL',
196
+ model: 'Model',
197
+ stored: 'stored, leave empty to keep it',
198
+ unset: 'not set',
199
+ fallback: 'provider default',
200
+ save: 'Save',
201
+ saving: 'saving...',
202
+ saved: 'saved',
203
+ loading: 'loading...',
204
+ discard: 'Discard',
205
+ cliNote: 'This engine signs in through its own CLI: no key, no endpoint.',
206
+ autoTitle: 'Auto mode',
207
+ autoHint: 'Reuse the vision engines already on this machine.',
208
+ found: 'found',
209
+ notLoggedIn: 'found, not signed in',
210
+ notFound: 'not on this machine',
211
+ },
212
+ zh: {
213
+ title: '视觉引擎(ModLens)',
214
+ subtitle: '视觉引擎提供商配置。',
215
+ openConfig: '打开配置文件',
216
+ automatic: '自动(不固定,由故障转移链决定)',
217
+ pickToConfigure: '在上面选一个引擎,才能配置它的密钥和地址。',
218
+ engine: '引擎',
219
+ apiKey: 'API 密钥',
220
+ baseUrl: '接口地址',
221
+ model: '模型',
222
+ stored: '已保存,留空即不改动',
223
+ unset: '未设置',
224
+ fallback: '使用该引擎默认值',
225
+ save: '保存',
226
+ saving: '保存中…',
227
+ saved: '已保存',
228
+ loading: '加载中…',
229
+ discard: '放弃修改',
230
+ cliNote: '该引擎通过自己的 CLI 登录,无需密钥和接口地址。',
231
+ autoTitle: 'auto 模式',
232
+ autoHint: '自动复用本机已有视觉引擎。',
233
+ found: '已找到',
234
+ notLoggedIn: '已找到,未登录',
235
+ notFound: '本机没有',
236
+ },
237
+ }
238
+
239
+ function labels() {
240
+ var lang = (document.documentElement.lang || navigator.language || 'en').toLowerCase()
241
+ return lang.indexOf('zh') === 0 ? TEXT.zh : TEXT.en
242
+ }
243
+
244
+ // The next draft when the engine changes or a summary arrives. The three
245
+ // engine fields belong to the newly selected engine; the reuse grants are
246
+ // the user's pending answers and survive an engine switch, since granting
247
+ // codex has nothing to do with which engine reads the images.
248
+ function nextDraft(summary, provider, keepReuse) {
249
+ // provider '' is its own answer: not pinned, the failover chain
250
+ // decides. There is then no single engine whose key belongs in these
251
+ // fields, so they stay empty and the card says how to get them back.
252
+ var engine = summary.engines[provider] || { baseUrl: '', model: '' }
253
+ return {
254
+ provider: provider,
255
+ apiKey: '',
256
+ baseUrl: engine.baseUrl,
257
+ model: engine.model,
258
+ reuse: Object.assign({}, keepReuse || summary.reuse),
259
+ }
260
+ }
261
+
262
+ // What one save is actually about. The pin travels only when the select
263
+ // moved; the engine fields only when they were edited. A save that always
264
+ // carried both pinned an engine nobody chose and wrote the values the
265
+ // card loaded back over whatever the file holds now.
266
+ function savePayload(summary, draft) {
267
+ var payload = { reuse: {} }
268
+ REUSE.forEach((name) => {
269
+ if (draft.reuse[name] !== summary.reuse[name]) {
270
+ payload.reuse[name] = draft.reuse[name]
271
+ }
272
+ })
273
+ if (draft.provider !== summary.provider) {
274
+ payload.provider = draft.provider
275
+ }
276
+ var pristine = nextDraft(summary, draft.provider, draft.reuse)
277
+ var engineEdited = draft.apiKey !== '' || draft.baseUrl !== pristine.baseUrl || draft.model !== pristine.model
278
+ if (draft.provider !== '' && engineEdited) {
279
+ payload.engine = draft.provider
280
+ payload.apiKey = draft.apiKey
281
+ payload.baseUrl = draft.baseUrl
282
+ payload.model = draft.model
283
+ }
284
+ return payload
285
+ }
286
+
287
+ function ConfigCard(react, ui) {
288
+ var h = react.createElement
289
+ var Input = ui.Input
290
+
291
+ // The chrome is the native plugin card's, value for value (border,
292
+ // layer backgrounds, 12px radius, header row with a rotating chevron,
293
+ // footer with discard ghost + save primary), so this card reads as a
294
+ // sibling of the built-in three rather than a lodger.
295
+ var chevron = (open) =>
296
+ h(
297
+ 'svg',
298
+ {
299
+ width: 16,
300
+ height: 16,
301
+ viewBox: '0 0 16 16',
302
+ style: {
303
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
304
+ flex: 'none',
305
+ transition: 'transform .16s',
306
+ transform: open ? 'rotate(180deg)' : 'none',
307
+ },
308
+ },
309
+ h('path', {
310
+ d: 'M4 6l4 4 4-4',
311
+ fill: 'none',
312
+ stroke: 'currentColor',
313
+ strokeWidth: 1.5,
314
+ strokeLinecap: 'round',
315
+ strokeLinejoin: 'round',
316
+ }),
317
+ )
318
+
319
+ return function ModlensCard() {
320
+ var t = labels()
321
+ var openState = react.useState(false)
322
+ var summaryState = react.useState(null)
323
+ var draftState = react.useState(null)
324
+ var noteState = react.useState('')
325
+ var open = openState[0]
326
+ var summary = summaryState[0]
327
+ var draft = draftState[0]
328
+ var note = noteState[0]
329
+
330
+ var seed = (next, provider, keepReuse) => nextDraft(next, provider, keepReuse)
331
+
332
+ var load = react.useCallback(() => {
333
+ // discover: the self-check probing which local harnesses exist to
334
+ // be borrowed. Paid once per expand, cached host-side.
335
+ fetch('/modlens/config?discover=1')
336
+ .then((r) =>
337
+ r.json().then((body) => {
338
+ if (!r.ok) throw new Error(body.error || 'load failed')
339
+ return body
340
+ }),
341
+ )
342
+ .then((next) => {
343
+ summaryState[1](next)
344
+ draftState[1](seed(next, next.provider))
345
+ noteState[1]('')
346
+ })
347
+ .catch((error) => {
348
+ noteState[1](String(error.message ? error.message : error))
349
+ })
350
+ }, [])
351
+
352
+ react.useEffect(() => {
353
+ if (open && summary === null) load()
354
+ }, [open, summary, load])
355
+
356
+ // A row wrapping ONE control is a label, which names that control. A
357
+ // row wrapping a set of them must not be: the label becomes the first
358
+ // checkbox's accessible name and swallows the whole section's prose.
359
+ // Those rows are a named group instead.
360
+ var fieldRow = (label, control, key, groupName) =>
361
+ h(
362
+ groupName ? 'div' : 'label',
363
+ {
364
+ key: key,
365
+ role: groupName ? 'group' : undefined,
366
+ 'aria-label': groupName || undefined,
367
+ style: {
368
+ display: 'flex',
369
+ flexDirection: 'column',
370
+ gap: '6px',
371
+ padding: '12px 0',
372
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
373
+ },
374
+ },
375
+ h('div', { style: { fontSize: '13px', color: 'var(--dsw-alias-label-secondary, inherit)' } }, label),
376
+ control,
377
+ )
378
+
379
+ var body = null
380
+ if (open) {
381
+ if (summary === null || draft === null) {
382
+ body = h(
383
+ 'div',
384
+ {
385
+ style: {
386
+ padding: '12px 0',
387
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
388
+ fontSize: '13px',
389
+ },
390
+ },
391
+ note || t.loading,
392
+ )
393
+ } else {
394
+ var keyless = (summary.keyless || []).indexOf(draft.provider) >= 0
395
+ var current = summary.engines[draft.provider] || { hasKey: false }
396
+ var pristine = seed(summary, draft.provider)
397
+ var dirty =
398
+ draft.provider !== summary.provider ||
399
+ draft.apiKey !== '' ||
400
+ draft.baseUrl !== pristine.baseUrl ||
401
+ draft.model !== pristine.model ||
402
+ REUSE.some((name) => draft.reuse[name] !== summary.reuse[name])
403
+
404
+ var set = (key, value) => {
405
+ var next = Object.assign({}, draft)
406
+ next[key] = value
407
+ draftState[1](next)
408
+ noteState[1]('')
409
+ }
410
+
411
+ var textField = (label, key, type, placeholder) =>
412
+ fieldRow(
413
+ label,
414
+ h(Input, {
415
+ type: type,
416
+ value: draft[key],
417
+ placeholder: placeholder,
418
+ onChange: (event) => {
419
+ set(key, event.target.value)
420
+ },
421
+ }),
422
+ key,
423
+ )
424
+
425
+ // Auto mode: the probes say which harnesses exist on this
426
+ // machine. Found ones get a checkbox with their status; missing
427
+ // ones are named as absent so the list explains itself.
428
+ var probes = Array.isArray(summary.discovery) ? summary.discovery : null
429
+ // Being listed means being found: an absent harness is simply
430
+ // not shown, and only "not signed in" earns a note.
431
+ var autoRows = REUSE.filter((name) => {
432
+ if (!probes) return true
433
+ var probe = probes.find((candidate) => candidate.harness === name)
434
+ return probe ? probe.cliFound : false
435
+ }).map((name) => {
436
+ var probe = probes && probes.find((candidate) => candidate.harness === name)
437
+ return h(
438
+ 'label',
439
+ {
440
+ key: name,
441
+ style: {
442
+ display: 'flex',
443
+ alignItems: 'center',
444
+ gap: '8px',
445
+ fontSize: '13px',
446
+ },
447
+ },
448
+ h('input', {
449
+ type: 'checkbox',
450
+ checked: Boolean(draft.reuse[name]),
451
+ onChange: (event) => {
452
+ var next = Object.assign({}, draft.reuse)
453
+ next[name] = event.target.checked
454
+ set('reuse', next)
455
+ },
456
+ }),
457
+ h('span', null, name),
458
+ probe && probe.loggedIn === false
459
+ ? h(
460
+ 'span',
461
+ {
462
+ style: {
463
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
464
+ fontSize: '12px',
465
+ },
466
+ },
467
+ t.notLoggedIn,
468
+ )
469
+ : null,
470
+ )
471
+ })
472
+
473
+ body = h(
474
+ 'div',
475
+ null,
476
+ fieldRow(
477
+ t.engine,
478
+ h(
479
+ 'select',
480
+ {
481
+ value: draft.provider,
482
+ onChange: (event) => {
483
+ draftState[1](seed(summary, event.target.value, draft.reuse))
484
+ noteState[1]('')
485
+ },
486
+ style: {
487
+ appearance: 'none',
488
+ width: '100%',
489
+ padding: '8px 12px',
490
+ borderRadius: '8px',
491
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
492
+ background: 'transparent',
493
+ color: 'inherit',
494
+ font: 'inherit',
495
+ fontSize: '13px',
496
+ },
497
+ },
498
+ [h('option', { key: '', value: '' }, t.automatic)].concat(
499
+ ENGINES.map((name) => h('option', { key: name, value: name }, name)),
500
+ ),
501
+ ),
502
+ 'engine',
503
+ ),
504
+ draft.provider === ''
505
+ ? fieldRow(
506
+ t.apiKey,
507
+ h(
508
+ 'div',
509
+ {
510
+ style: {
511
+ fontSize: '13px',
512
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
513
+ },
514
+ },
515
+ t.pickToConfigure,
516
+ ),
517
+ 'unpinned',
518
+ )
519
+ : keyless
520
+ ? fieldRow(
521
+ t.apiKey,
522
+ h(
523
+ 'div',
524
+ {
525
+ style: { fontSize: '13px', color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))' },
526
+ },
527
+ t.cliNote,
528
+ ),
529
+ 'clinote',
530
+ )
531
+ : textField(t.apiKey, 'apiKey', 'password', current.hasKey ? t.stored : t.unset),
532
+ draft.provider === '' || keyless ? null : textField(t.baseUrl, 'baseUrl', 'text', t.fallback),
533
+ draft.provider === '' ? null : textField(t.model, 'model', 'text', t.fallback),
534
+ fieldRow(
535
+ h(
536
+ 'span',
537
+ null,
538
+ t.autoTitle,
539
+ h(
540
+ 'span',
541
+ {
542
+ style: {
543
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
544
+ fontWeight: 400,
545
+ marginLeft: '8px',
546
+ },
547
+ },
548
+ t.autoHint,
549
+ ),
550
+ ),
551
+ h(
552
+ 'div',
553
+ { style: { display: 'flex', flexWrap: 'wrap', gap: '10px 18px', paddingTop: '2px' } },
554
+ autoRows,
555
+ ),
556
+ 'auto',
557
+ t.autoTitle,
558
+ ),
559
+ h(
560
+ 'div',
561
+ {
562
+ key: 'footer',
563
+ style: {
564
+ borderTop: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
565
+ display: 'flex',
566
+ justifyContent: 'flex-end',
567
+ alignItems: 'center',
568
+ gap: '8px',
569
+ padding: '12px 0 4px',
570
+ },
571
+ },
572
+ h(
573
+ 'a',
574
+ {
575
+ href: '#',
576
+ onClick: (event) => {
577
+ event.preventDefault()
578
+ fetch('/modlens/config', {
579
+ method: 'POST',
580
+ headers: { 'content-type': 'application/json' },
581
+ body: JSON.stringify({ open: true }),
582
+ }).catch(() => {})
583
+ },
584
+ style: {
585
+ fontSize: '12px',
586
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
587
+ textDecoration: 'underline',
588
+ textUnderlineOffset: '2px',
589
+ },
590
+ },
591
+ t.openConfig,
592
+ ),
593
+ h(
594
+ 'span',
595
+ {
596
+ role: 'status',
597
+ style: {
598
+ marginRight: 'auto',
599
+ marginLeft: '10px',
600
+ fontSize: '12px',
601
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
602
+ },
603
+ },
604
+ note,
605
+ ),
606
+ h(
607
+ 'button',
608
+ {
609
+ type: 'button',
610
+ disabled: !dirty || note === t.saving,
611
+ onClick: () => {
612
+ draftState[1](seed(summary, summary.provider))
613
+ noteState[1]('')
614
+ },
615
+ style: {
616
+ appearance: 'none',
617
+ font: 'inherit',
618
+ fontSize: '13px',
619
+ lineHeight: 1.5,
620
+ cursor: dirty ? 'pointer' : 'default',
621
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
622
+ borderRadius: '8px',
623
+ padding: '5px 14px',
624
+ background: 'none',
625
+ color: 'var(--dsw-alias-label-secondary, inherit)',
626
+ opacity: dirty ? 1 : 0.4,
627
+ },
628
+ },
629
+ t.discard,
630
+ ),
631
+ h(
632
+ 'button',
633
+ {
634
+ type: 'button',
635
+ disabled: !dirty || note === t.saving,
636
+ onClick: () => {
637
+ noteState[1](t.saving)
638
+ var payload = savePayload(summary, draft)
639
+ fetch('/modlens/config', {
640
+ method: 'POST',
641
+ headers: { 'content-type': 'application/json' },
642
+ body: JSON.stringify(payload),
643
+ })
644
+ .then((r) =>
645
+ r.json().then((payload) => {
646
+ if (!r.ok) throw new Error(payload.error || 'save failed')
647
+ return payload
648
+ }),
649
+ )
650
+ .then((next) => {
651
+ // The save response carries no discovery; keep the
652
+ // probes already on screen.
653
+ next.discovery = summary.discovery
654
+ summaryState[1](next)
655
+ draftState[1](seed(next, next.provider))
656
+ noteState[1](t.saved)
657
+ })
658
+ .catch((error) => {
659
+ noteState[1](String(error.message ? error.message : error))
660
+ })
661
+ },
662
+ style: {
663
+ appearance: 'none',
664
+ font: 'inherit',
665
+ fontSize: '13px',
666
+ lineHeight: 1.5,
667
+ cursor: dirty ? 'pointer' : 'default',
668
+ border: '1px solid transparent',
669
+ borderRadius: '8px',
670
+ padding: '5px 14px',
671
+ background: 'var(--dsw-alias-label-primary, currentColor)',
672
+ color: 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
673
+ opacity: dirty ? 1 : 0.4,
674
+ },
675
+ },
676
+ t.save,
677
+ ),
678
+ ),
679
+ )
680
+ }
681
+ }
682
+
683
+ return h(
684
+ 'div',
685
+ {
686
+ style: {
687
+ border: '1px solid var(--dsw-alias-border-l2, rgba(127,127,127,0.35))',
688
+ background: open
689
+ ? 'var(--dsw-alias-bg-layer-2, rgba(127,127,127,0.10))'
690
+ : 'var(--dsw-alias-bg-layer-3, rgba(127,127,127,0.05))',
691
+ borderRadius: '12px',
692
+ transition: 'border-color .16s, background .16s',
693
+ },
694
+ },
695
+ h(
696
+ 'button',
697
+ {
698
+ type: 'button',
699
+ 'aria-expanded': open,
700
+ onClick: () => {
701
+ openState[1](!open)
702
+ },
703
+ style: {
704
+ appearance: 'none',
705
+ width: '100%',
706
+ font: 'inherit',
707
+ color: 'inherit',
708
+ textAlign: 'left',
709
+ cursor: 'pointer',
710
+ background: 'none',
711
+ border: 0,
712
+ borderRadius: '12px',
713
+ display: 'flex',
714
+ alignItems: 'center',
715
+ gap: '12px',
716
+ padding: '14px 16px',
717
+ },
718
+ },
719
+ h(
720
+ 'div',
721
+ { style: { flex: 1, minWidth: 0 } },
722
+ h('div', { style: { fontSize: '14px', fontWeight: 600 } }, t.title),
723
+ h(
724
+ 'div',
725
+ {
726
+ style: {
727
+ color: 'var(--dsw-alias-label-tertiary, rgba(127,127,127,0.8))',
728
+ fontSize: '13px',
729
+ lineHeight: 1.5,
730
+ },
731
+ },
732
+ t.subtitle,
733
+ ),
734
+ ),
735
+ chevron(open),
736
+ ),
737
+ open ? h('div', { style: { margin: '0 16px', paddingBottom: '8px' } }, body) : null,
738
+ )
739
+ }
740
+ }
741
+
742
+ function registerCard(ctx) {
743
+ // Reaching for an undeclared service throws in cordis, so the optional
744
+ // dependency rides a scoped ctx.inject: the closure runs where slots
745
+ // exists and never runs where it does not, exactly as the host half
746
+ // takes webServer.
747
+ if (typeof ctx.inject !== 'function') return
748
+ ctx.inject(['slots'], (scope) => {
749
+ // The card and its route live and die together: with the host route
750
+ // off (settingsCard: false, or no web profile) a card would only
751
+ // render an error, which is not what turning a feature off means.
752
+ // Any response at all proves the route exists; only a 404 or a
753
+ // network failure reads as absent.
754
+ fetch('/modlens/config')
755
+ .then((response) => {
756
+ if (response.status === 404) return
757
+ try {
758
+ mountCard(scope)
759
+ } catch (error) {
760
+ console.error('[modlens] settings card skipped: ' + error)
761
+ }
762
+ })
763
+ .catch(() => {})
764
+ })
765
+ }
766
+
767
+ function mountCard(ctx) {
768
+ var react
769
+ try {
770
+ react = require('react')
771
+ } catch (error) {
772
+ console.error('[modlens] settings card skipped: ' + error)
773
+ return
774
+ }
775
+ var ui = require('@deepseek-ai/dsh-client-ui-primitives')
776
+ var Card = ConfigCard(react, ui)
777
+ ctx.slots.inject('settings.plugin.item', function* () {
778
+ yield ctx.slots.register({ name: 'settings.plugin.item', id: 'modlens', order: 30 }, Card)
779
+ })
780
+ }
781
+
175
782
  function apply(ctx) {
783
+ registerCard(ctx)
176
784
  document.addEventListener('paste', onPaste, true)
177
785
  document.addEventListener('focusin', onFocusIn, true)
178
786
  // cordis effect: unregister on plugin disposal (HMR, profile reload).
@@ -188,6 +796,9 @@ window.__ModuleLoader__.load({
188
796
  }
189
797
 
190
798
  exports.apply = apply
799
+ // Exposed for the repo's tests only; not part of the plugin contract.
800
+ exports.__card = { nextDraft: nextDraft, savePayload: savePayload }
801
+ // `slots` is optional, so it is not required here: registerCard checks.
191
802
  exports.inject = []
192
803
  return module.exports
193
804
  },