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