dsh-biocore 0.1.7 → 0.1.9

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/cordis.patch.yml CHANGED
@@ -8,6 +8,8 @@
8
8
  dataDir: ''
9
9
  # Memory (task-203): capture both sides, inject <=420 chars, 20/hour.
10
10
  memoryEnabled: true
11
+ # Standalone session/event capture listener (off: capture happens in core turn_start/end)
12
+ memoryCaptureEnabled: false
11
13
  injectMaxChars: 420
12
14
  hourlyCap: 20
13
15
  # Proactive push (task-205): both switches must be true; default OFF.
@@ -19,4 +21,4 @@
19
21
  proactivePerHour: 2
20
22
  proactiveMinTrust: 0.6
21
23
  proactiveMaxChars: 60
22
- proactiveRequireOpenLoop: true
24
+ proactiveRequireOpenLoop: true
package/lib/index.js CHANGED
@@ -665,7 +665,7 @@ export function makeSessionEventListener(options) {
665
665
  }
666
666
  }
667
667
 
668
- const CORE_STATE_TTL_MS = 1500
668
+ const CORE_STATE_TTL_MS = 5000
669
669
  const CORE_STATE_CACHE = new Map()
670
670
 
671
671
  /** Read the core pipeline state action (panel state, task-212), briefly cached. */
@@ -726,33 +726,34 @@ export async function handleState(req, res, stateDir, options) {
726
726
  const sessionId = cleanSessionId(rawSession)
727
727
  if (!sessionId) return sendJson(res, 200, unavailable('bad_session'))
728
728
  const stateFile = sessionStateFile(stateDir, sessionId)
729
- let read = readStateFile(stateFile)
730
- let created = false
729
+ const read = readStateFile(stateFile)
730
+ let body = null
731
731
  if (read.missing) {
732
- const ensured = await ensureState(stateFile, personaProfilePathFor(stateDir))
733
- if (!ensured || ensured.ok !== true) {
734
- return sendJson(res, 200, unavailable('no_state_yet',
735
- { detail: (ensured && (ensured.reason || ensured.detail)) || 'ensure_failed' }))
736
- }
737
- created = true
738
- read = readStateFile(stateFile)
739
- }
740
- if (read.error) return sendJson(res, 200, unavailable(read.error))
741
- let body = projectState(read.state, { mtimeMs: read.mtimeMs, sessionId })
742
- // task-202: additive fields for the standalone instrument panel.
743
- Object.assign(body, buildPanelExtras(read.state))
744
- if (created) body.lazily_created = true
745
- // task-212: panel state comes from the core state action when a switch is on.
746
- try {
747
- const getSwitches = typeof opts.getSwitches === 'function' ? opts.getSwitches : null
748
- if (getSwitches && anySwitchOn(getSwitches())) {
749
- const core = await fetchCoreState(sessionId, {
750
- runAction: opts.runAction,
751
- store: opts.store,
752
- })
753
- body = mergeCoreState(body, core)
732
+ // Fast path: never call Python when the session file exists. Only a
733
+ // missing file + an enabled switch may fall back to runtimeAction('state').
734
+ let core = null
735
+ try {
736
+ const getSwitches = typeof opts.getSwitches === 'function' ? opts.getSwitches : null
737
+ if (getSwitches && anySwitchOn(getSwitches())) {
738
+ core = await fetchCoreState(sessionId, {
739
+ runAction: opts.runAction,
740
+ store: opts.store,
741
+ })
742
+ }
743
+ } catch { core = null }
744
+ if (!core || core.ok !== true || !core.state || typeof core.state !== 'object') {
745
+ return sendJson(res, 200, unavailable('no_state_yet'))
754
746
  }
755
- } catch { /* keep the file projection */ }
747
+ body = projectState(core.state, { mtimeMs: 0, sessionId })
748
+ Object.assign(body, buildPanelExtras(core.state))
749
+ body.source = 'runtime'
750
+ } else if (read.error) {
751
+ return sendJson(res, 200, unavailable(read.error))
752
+ } else {
753
+ body = projectState(read.state, { mtimeMs: read.mtimeMs, sessionId })
754
+ Object.assign(body, buildPanelExtras(read.state))
755
+ body.source = 'file'
756
+ }
756
757
  if (!payloadWithinLimit(body)) {
757
758
  return sendJson(res, 200, unavailable('payload_too_large'))
758
759
  }
@@ -867,10 +868,14 @@ export function apply(ctx, config) {
867
868
  const memoryDir = resolveMemoryDir(config)
868
869
  const injectionState = createInjectionState()
869
870
  try {
871
+ // Normally capture happens inside core turn_start/turn_end. The standalone
872
+ // session/event capture listener stays available but is opt-in.
873
+ if (config && config.memoryCaptureEnabled === true) {
870
874
  const capture = registerMemoryCapture(ctx, () => config, memoryDir, {
871
875
  injectionState,
872
876
  })
873
877
  if (capture) keep(capture.dispose)
878
+ }
874
879
  } catch { /* memory capture unavailable */ }
875
880
  try {
876
881
  const injection = registerMemoryInjection(ctx, () => config, memoryDir,
@@ -14,6 +14,8 @@
14
14
  * unavailable it falls back to the old adapter bridge once and logs a single
15
15
  * warning; all switches off means no python call, no prompt text, no gate.
16
16
  */
17
+ import { readFileSync } from 'node:fs'
18
+ import { join } from 'node:path'
17
19
  import { readStateFile, sessionStateFile } from './state.js'
18
20
  import { pendingSessionTurn, queueSessionTurn } from './turn-queue.js'
19
21
  import {
@@ -105,6 +107,13 @@ export function chooseCleanReply(original, coreCleanText, options) {
105
107
  return String(sanitize(source))
106
108
  }
107
109
 
110
+ export function personaApplied(dataDir) {
111
+ try {
112
+ const status = JSON.parse(readFileSync(join(String(dataDir || '').trim(), 'persona_preset.json'), 'utf8'))
113
+ return status && status.applied === true
114
+ } catch { return false }
115
+ }
116
+
108
117
  export function turnStartPayload(options) {
109
118
  const opts = options || {}
110
119
  const expression = opts.expression && typeof opts.expression === 'object'
@@ -114,6 +123,9 @@ export function turnStartPayload(options) {
114
123
  user_text: stringId(opts.userText).slice(0, 2000),
115
124
  switches: opts.switches || {},
116
125
  store: opts.store || defaultStoreDir(),
126
+ capture: true,
127
+ memoryEnabled: true,
128
+ persona_prompt: opts.personaPrompt !== false,
117
129
  length_hint: expression.length_hint === true,
118
130
  length_target: Number.isFinite(Number(expression.length_target))
119
131
  ? Number(expression.length_target) : EXPRESSION_DEFAULTS.length_target,
@@ -132,8 +144,8 @@ export function turnEndPayload(options) {
132
144
  user_text: stringId(opts.userText).slice(0, 2000),
133
145
  switches: opts.switches || {},
134
146
  store: opts.store || defaultStoreDir(),
135
- capture: false,
136
- memoryEnabled: false,
147
+ capture: true,
148
+ memoryEnabled: true,
137
149
  sanitize_reply: opts.sanitizeReply !== false,
138
150
  diagnostics,
139
151
  }, diagnostics, {
@@ -189,6 +201,7 @@ export function makeCoreSessionListener(options) {
189
201
  try {
190
202
  core = await runAction('turn_start', turnStartPayload({
191
203
  sessionId, userText: text, switches, store, expression: getExpression(),
204
+ personaPrompt: !personaApplied(stateDir),
192
205
  }))
193
206
  } catch (error) {
194
207
  core = { ok: false, reason: 'core_exception' }
@@ -347,4 +360,4 @@ function joinProfile(stateDir) {
347
360
  try {
348
361
  return stateDir ? String(stateDir).replace(/[\\/]+$/, '') + '/persona_profile.json' : ''
349
362
  } catch { return '' }
350
- }
363
+ }
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "dsh-biocore",
3
- "version": "0.1.7",
3
+ "version": "0.1.9",
4
4
  "type": "module",
5
5
  "description": "BioCore DSH UI: per-session state tab, standalone instrument panel, /api/biocore/state, session/event one_turn updates, /bio sync.",
6
6
  "main": "lib/index.js",
@@ -1780,6 +1780,24 @@ def dispatch(action, payload=None):
1780
1780
  state_store = _relationship_store(data)
1781
1781
  if state_store is not None:
1782
1782
  _save_session_state(state_store, session_id, session_state)
1783
+ capture_enabled = _as_bool(data.get("capture"), True)
1784
+ captured_user = {"captured": False, "entry_id": "", "reason": "disabled"}
1785
+ capture_store = data.get("store")
1786
+ if capture_enabled and capture_store:
1787
+ user_capture = capture(session_id, "user", user_text, capture_store, {
1788
+ "enabled": _as_bool(data.get("memoryEnabled", True), True),
1789
+ "hourly_cap": data.get("hourly_cap", HOURLY_CAP),
1790
+ "min_chars": data.get("min_chars", MIN_CHARS),
1791
+ })
1792
+ decision = user_capture.get("decision") or {}
1793
+ entry = user_capture.get("entry") or {}
1794
+ captured_user = {
1795
+ "captured": bool(decision.get("capture")),
1796
+ "entry_id": str(entry.get("id") or ""),
1797
+ "reason": str(decision.get("reason") or ""),
1798
+ }
1799
+ elif capture_enabled:
1800
+ captured_user["reason"] = "no_store"
1783
1801
  block = prompt_block(dict(
1784
1802
  data, phase="turn", session_id=session_id,
1785
1803
  user_text=user_text,
@@ -1792,7 +1810,8 @@ def dispatch(action, payload=None):
1792
1810
  return {"ok": True, "prompt_block": block["text"],
1793
1811
  "constraints": self_layer_constraints(
1794
1812
  dict(data, user_text=user_text)),
1795
- "state": session_state}
1813
+ "state": session_state,
1814
+ "captured_user": captured_user}
1796
1815
  if action == "turn_end":
1797
1816
  session_id = str(data.get("session_id") or "").strip()
1798
1817
  if not session_id:
@@ -1828,13 +1847,22 @@ def dispatch(action, payload=None):
1828
1847
  gate["text"] = _sanitize.clean(gate.get("text") or "")
1829
1848
  except Exception:
1830
1849
  pass
1831
- captured = capture(session_id, "assistant", assistant_text, store, {
1832
- "enabled": _as_bool(data.get("memoryEnabled",
1833
- switches.get("memoryEnabled", True)),
1834
- True),
1835
- "hourly_cap": data.get("hourly_cap", HOURLY_CAP),
1836
- "min_chars": data.get("min_chars", MIN_CHARS),
1837
- })
1850
+ capture_enabled = _as_bool(data.get("capture"), True)
1851
+ capture_store = data.get("store")
1852
+ if capture_enabled and capture_store:
1853
+ captured = capture(session_id, "assistant", assistant_text,
1854
+ capture_store, {
1855
+ "enabled": _as_bool(data.get("memoryEnabled",
1856
+ switches.get("memoryEnabled", True)),
1857
+ True),
1858
+ "hourly_cap": data.get("hourly_cap", HOURLY_CAP),
1859
+ "min_chars": data.get("min_chars", MIN_CHARS),
1860
+ })
1861
+ else:
1862
+ captured = {"ok": True, "decision": {
1863
+ "capture": False,
1864
+ "reason": "disabled" if not capture_enabled else "no_store",
1865
+ }, "entry": None}
1838
1866
  merge_state_relationship_into_shared(session_id, data)
1839
1867
  decision = captured.get("decision") or {}
1840
1868
  entry = captured.get("entry") or {}
package/ui/panel.css CHANGED
@@ -1,4 +1,4 @@
1
- /* BioCore instrument panel — FlClash mobile spec. | layout v24
1
+ /* BioCore instrument panel — FlClash mobile spec. | layout v26
2
2
  1) tokens 2) base 3) topbar 4) cards 5) figures 6) lists 7) forms
3
3
  8) switches 9) nav 10) edit mode 11) desktop.
4
4
  Rule: one component rule per selector, px values from docs/DSH-PANEL-FLCLASH-SPEC.md. */
@@ -556,3 +556,270 @@ body { max-width: 100%; }
556
556
  .model-card .model-section-title:first-of-type { margin-top: 0; }
557
557
  .rerank-toggle { margin: 0 0 10px; }
558
558
  .rerank-grid[hidden] { display: none !important; }
559
+
560
+ /* ---------- 15. desktop (>=1024): left rail + 12-col dashboard ---------- */
561
+ .rail, .desk { display: none; }
562
+
563
+ @media (min-width: 1024px) {
564
+ :root {
565
+ --rail-w: 220px;
566
+ --radius-card: 32px;
567
+ --pad-card: 20px;
568
+ --gap-card: 12px;
569
+ }
570
+ body { padding: 0; }
571
+ body.rail-collapsed { --rail-w: 64px; }
572
+
573
+ .panel {
574
+ width: auto;
575
+ max-width: none;
576
+ min-height: 100vh;
577
+ margin: 0 0 0 var(--rail-w);
578
+ padding: 16px 24px 24px 16px;
579
+ display: grid;
580
+ grid-template-columns: repeat(12, minmax(0, 1fr));
581
+ gap: 12px;
582
+ align-content: start;
583
+ transition: margin-left 160ms ease;
584
+ }
585
+
586
+ /* --- left rail --- */
587
+ .rail {
588
+ position: fixed;
589
+ inset: 0 auto 0 0;
590
+ z-index: 40;
591
+ width: var(--rail-w);
592
+ display: flex;
593
+ flex-direction: column;
594
+ gap: 6px;
595
+ padding: 12px 10px;
596
+ background: var(--page);
597
+ border-right: 1px solid var(--line);
598
+ overflow: hidden;
599
+ transition: width 160ms ease, padding 160ms ease;
600
+ }
601
+ .rail-toggle {
602
+ display: grid;
603
+ place-items: center;
604
+ width: 40px;
605
+ height: 40px;
606
+ margin: 0 0 8px 2px;
607
+ padding: 0;
608
+ border: 0;
609
+ border-radius: 10px;
610
+ background: transparent;
611
+ color: var(--text-2);
612
+ cursor: pointer;
613
+ transition: background 160ms ease, color 160ms ease;
614
+ }
615
+ .rail-toggle:hover { background: rgba(255, 255, 255, 0.04); color: var(--text); }
616
+ .rail-toggle svg { width: 20px; height: 20px; }
617
+ .rail-nav { display: flex; flex-direction: column; gap: 2px; }
618
+ .rail-item {
619
+ position: relative;
620
+ display: flex;
621
+ align-items: center;
622
+ gap: 10px;
623
+ height: 40px;
624
+ padding: 0 12px;
625
+ border: 0;
626
+ border-radius: 8px;
627
+ background: transparent;
628
+ color: var(--text-2);
629
+ font: inherit;
630
+ font-size: 14px;
631
+ line-height: 20px;
632
+ text-align: left;
633
+ white-space: nowrap;
634
+ overflow: hidden;
635
+ cursor: pointer;
636
+ transition: background 160ms ease, color 160ms ease;
637
+ }
638
+ .rail-item svg { flex: 0 0 20px; width: 20px; height: 20px; }
639
+ .rail-item:hover { background: rgba(255, 255, 255, 0.04); color: var(--text); }
640
+ .rail-item.active { background: rgba(255, 255, 255, 0.06); color: var(--text); }
641
+ .rail-item.active::before {
642
+ content: '';
643
+ position: absolute;
644
+ left: 0;
645
+ top: 8px;
646
+ bottom: 8px;
647
+ width: 2px;
648
+ border-radius: 2px;
649
+ background: var(--accent);
650
+ }
651
+ body.rail-collapsed .rail-item { justify-content: center; padding: 0; }
652
+ body.rail-collapsed .rail-item span { display: none; }
653
+
654
+ /* --- header --- */
655
+ .topbar { position: static; height: 56px; padding: 0; grid-column: 1 / -1; }
656
+ .topbar h1 { font-size: 22px; line-height: 28px; }
657
+ .masthead { display: none; }
658
+ .tabbar { display: none; }
659
+ .footer { grid-column: 1 / -1; padding: 0 4px; }
660
+
661
+ /* --- desktop dashboard: 12 col x 5 rows --- */
662
+ .desk {
663
+ display: grid;
664
+ grid-column: 1 / -1;
665
+ grid-template-columns: repeat(12, minmax(0, 1fr));
666
+ grid-template-rows: 208px 64px 176px minmax(200px, auto) minmax(120px, auto);
667
+ gap: 12px;
668
+ }
669
+ .desk-card {
670
+ position: relative;
671
+ min-width: 0;
672
+ padding: 20px;
673
+ border-radius: 32px;
674
+ overflow: hidden;
675
+ }
676
+ .desk-card-head {
677
+ display: flex;
678
+ align-items: baseline;
679
+ justify-content: space-between;
680
+ gap: 8px;
681
+ margin-bottom: 10px;
682
+ }
683
+ .desk-card-head h2 { margin: 0; font-size: 15px; line-height: 24px; font-weight: 600; }
684
+ .desk-status, .desk-seg, .desk-memory, .desk-chart { grid-column: span 6; }
685
+ .desk-metric, .desk-switch, .desk-tile { grid-column: span 3; }
686
+ .desk-stat { grid-column: span 4; }
687
+
688
+ .desk-status { display: flex; align-items: center; gap: 20px; }
689
+ .desk-orb { width: 88px; height: 88px; flex: 0 0 88px; display: grid; place-items: center; }
690
+ .desk-status-body { display: flex; flex-direction: column; gap: 6px; min-width: 0; }
691
+ .desk-status-body h2 { margin: 0; font-size: 22px; line-height: 28px; font-weight: 600; }
692
+ .desk-status-body .motivation { margin: 0; color: var(--text-2); font-size: 15px; line-height: 22px; }
693
+
694
+ .desk-metric { display: flex; flex-direction: column; justify-content: center; gap: 10px; }
695
+ .desk-metric-head {
696
+ display: flex;
697
+ align-items: baseline;
698
+ justify-content: space-between;
699
+ gap: 8px;
700
+ color: var(--text-2);
701
+ font-size: 13px;
702
+ line-height: 18px;
703
+ }
704
+ .desk-metric-value { display: flex; align-items: baseline; gap: 10px; }
705
+ .desk-metric-value strong {
706
+ font-size: 40px;
707
+ line-height: 44px;
708
+ font-weight: 700;
709
+ letter-spacing: -0.02em;
710
+ font-variant-numeric: tabular-nums;
711
+ }
712
+ .desk-metric-value .sub-status { margin-left: auto; color: var(--text-2); font-size: 13px; }
713
+
714
+ .desk-seg { display: flex; align-items: center; gap: 6px; padding: 6px; border-radius: 32px; background: var(--tone); box-shadow: none; }
715
+ .seg-item {
716
+ flex: 1 1 0;
717
+ height: 52px;
718
+ border: 0;
719
+ border-radius: 16px;
720
+ background: transparent;
721
+ color: var(--text-2);
722
+ font: inherit;
723
+ font-size: 14px;
724
+ cursor: pointer;
725
+ transition: background 160ms ease, color 160ms ease;
726
+ }
727
+ .seg-item:hover { background: rgba(255, 255, 255, 0.04); color: var(--text); }
728
+ .seg-item.active { background: var(--accent); color: #ffffff; }
729
+
730
+ .desk-switch { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
731
+ .desk-switch-label { font-size: 15px; line-height: 24px; font-weight: 600; }
732
+ .desk-switch-control { display: flex; align-items: center; gap: 8px; }
733
+ .desk-switch-control input { width: 22px; height: 22px; accent-color: var(--accent); }
734
+ .desk-switch-state { color: var(--text-3); font-size: 12px; line-height: 16px; }
735
+
736
+ .desk-tile { display: flex; flex-direction: column; justify-content: center; gap: 8px; }
737
+ .desk-tile-head {
738
+ display: flex;
739
+ align-items: center;
740
+ justify-content: space-between;
741
+ gap: 8px;
742
+ color: var(--text-2);
743
+ font-size: 12px;
744
+ line-height: 16px;
745
+ }
746
+ .desk-tile strong {
747
+ font-size: 22px;
748
+ line-height: 28px;
749
+ font-weight: 600;
750
+ letter-spacing: -0.01em;
751
+ overflow: hidden;
752
+ text-overflow: ellipsis;
753
+ white-space: nowrap;
754
+ }
755
+ .desk-tile-note { color: var(--text-3); font-size: 12px; line-height: 16px; }
756
+ .desk-tile-row { display: flex; align-items: baseline; justify-content: space-between; gap: 8px; color: var(--text-2); font-size: 13px; line-height: 18px; }
757
+ .desk-tile-row b { color: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
758
+ .desk-chevron { color: var(--text-3); font-size: 14px; }
759
+ .desk-copy {
760
+ height: 24px;
761
+ padding: 0 10px;
762
+ border: 0;
763
+ border-radius: 8px;
764
+ background: var(--block);
765
+ color: var(--text-2);
766
+ font: inherit;
767
+ font-size: 12px;
768
+ cursor: pointer;
769
+ }
770
+
771
+ .desk-memory { display: flex; flex-direction: column; }
772
+ .desk-memory .memory-list { max-height: none; }
773
+ .desk-chart { display: flex; flex-direction: column; }
774
+ #deskChart { width: 100%; height: 100%; min-height: 110px; display: block; }
775
+
776
+ .desk-stat { display: flex; flex-direction: column; justify-content: center; gap: 6px; }
777
+ .desk-stat-row {
778
+ display: flex;
779
+ align-items: baseline;
780
+ justify-content: space-between;
781
+ gap: 8px;
782
+ color: var(--text-2);
783
+ font-size: 13px;
784
+ line-height: 18px;
785
+ }
786
+ .desk-stat-row b { color: var(--text); font-size: 15px; font-weight: 600; font-variant-numeric: tabular-nums; }
787
+
788
+ /* --- tab switching on desktop --- */
789
+ .panel.tab-dashboard #cards { display: none; }
790
+ .panel:not(.tab-dashboard) .desk { display: none; }
791
+ .panel:not(.tab-dashboard) #cards {
792
+ display: grid;
793
+ grid-column: 1 / -1;
794
+ grid-template-columns: repeat(12, minmax(0, 1fr));
795
+ gap: 12px;
796
+ }
797
+ .panel:not(.tab-dashboard) #cards > * { grid-column: span 6; }
798
+ .panel:not(.tab-dashboard) #cards > .channels-wrap { grid-column: span 12; }
799
+ .panel:not(.tab-dashboard) .channels-wrap .channel-grid { grid-template-columns: repeat(4, minmax(0, 1fr)); }
800
+ .panel:not(.tab-dashboard) .add-cards { grid-column: 1 / -1; }
801
+ .layout-editing #cards { display: grid; grid-column: 1 / -1; grid-template-columns: repeat(12, minmax(0, 1fr)); gap: 12px; }
802
+ .layout-editing #cards > * { grid-column: span 6; }
803
+
804
+ /* --- desktop edit mode (hide chips on desk cards) --- */
805
+ .desk-hide {
806
+ display: none;
807
+ position: absolute;
808
+ top: -8px;
809
+ right: -8px;
810
+ z-index: 3;
811
+ place-items: center;
812
+ width: 28px;
813
+ height: 28px;
814
+ padding: 0;
815
+ border: 0;
816
+ border-radius: 50%;
817
+ background: var(--danger);
818
+ color: #ffffff;
819
+ font-size: 18px;
820
+ line-height: 1;
821
+ cursor: pointer;
822
+ }
823
+ .layout-editing .desk-hide { display: grid; }
824
+ .desk-card.is-desk-hidden { display: none !important; }
825
+ }
package/ui/panel.html CHANGED
@@ -1,13 +1,24 @@
1
1
  <!doctype html>
2
- <!-- BioCore panel layout v24 (FlClash mobile): masthead/orb centered, channels 2x2, pill tabbar -->
2
+ <!-- BioCore panel layout v26: mobile = FlClash phone (masthead/channels 2x2/pill tabbar); desktop >=1024 = left rail + 12-col 5-row dashboard -->
3
3
  <html lang="zh-CN">
4
4
  <head>
5
5
  <meta charset="utf-8">
6
6
  <meta name="viewport" content="width=device-width, initial-scale=1">
7
7
  <title>生物核 · 仪器面板</title>
8
- <link rel="stylesheet" href="panel.css?v=24">
8
+ <link rel="stylesheet" href="panel.css?v=26">
9
9
  </head>
10
10
  <body>
11
+ <aside class="rail" id="rail" aria-label="主导航">
12
+ <button class="rail-toggle" id="railToggle" type="button" aria-label="收起导航" aria-expanded="true" title="收起导航">
13
+ <svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="2.5" y="3.5" width="15" height="13" rx="2.5"/><path d="M7.5 3.5v13M12.5 8l-2 2 2 2"/></svg>
14
+ </button>
15
+ <nav class="rail-nav" aria-label="页面导航">
16
+ <button class="rail-item active" type="button" data-tab="dashboard" title="仪表盘" aria-selected="true"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><rect x="3" y="3" width="6" height="6" rx="1.5"/><rect x="11" y="3" width="6" height="6" rx="1.5"/><rect x="3" y="11" width="6" height="6" rx="1.5"/><rect x="11" y="11" width="6" height="6" rx="1.5"/></svg><span>仪表盘</span></button>
17
+ <button class="rail-item" type="button" data-tab="memory" title="记忆" aria-selected="false"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M5 3.5h10v13l-5-3-5 3z"/></svg><span>记忆</span></button>
18
+ <button class="rail-item" type="button" data-tab="settings" title="设置" aria-selected="false"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><circle cx="10" cy="10" r="2.6"/><path d="M10 3.2v2M10 14.8v2M3.2 10h2M14.8 10h2M5.2 5.2l1.4 1.4M13.4 13.4l1.4 1.4M14.8 5.2l-1.4 1.4M6.6 13.4l-1.4 1.4"/></svg><span>设置</span></button>
19
+ <button class="rail-item" type="button" data-tab="diagnosis" title="诊断" aria-selected="false"><svg viewBox="0 0 20 20" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round" aria-hidden="true"><path d="M3 11h3.5l2-5 3 10 2-5H17"/></svg><span>诊断</span></button>
20
+ </nav>
21
+ </aside>
11
22
  <main class="panel" id="panel">
12
23
  <header class="topbar" id="topBar">
13
24
  <div class="topbar-title">
@@ -22,6 +33,101 @@
22
33
  </button>
23
34
  </header>
24
35
 
36
+ <section class="desk" id="deskGrid" aria-label="桌面仪表盘">
37
+ <header class="card desk-card desk-status">
38
+ <div class="orb-wrap desk-orb" aria-hidden="true"><div class="orb"><span class="orb-ring"></span></div></div>
39
+ <div class="desk-status-body">
40
+ <h2>她现在的样子</h2>
41
+ <p class="motivation" id="deskMotivation">正在读取…</p>
42
+ <div class="status" id="deskStatus"><i class="dot"></i><span id="deskStatusText">未连接</span></div>
43
+ </div>
44
+ </header>
45
+
46
+ <section class="card desk-card desk-metric" id="deskMoodCard" data-card-label="心情">
47
+ <div class="desk-metric-head"><span>心情</span><span class="sub-trend"><b id="deskArrowV">→</b><span class="delta" id="deskDeltaV">—</span></span></div>
48
+ <div class="desk-metric-value"><strong id="deskNumV">–</strong><span class="sub-status" id="deskWordV">—</span></div>
49
+ <div class="bar"><i id="deskBarV"></i></div>
50
+ </section>
51
+
52
+ <section class="card desk-card desk-metric" id="deskTensionCard" data-card-label="张力">
53
+ <div class="desk-metric-head"><span>张力</span><span class="sub-trend"><b id="deskArrowTension">→</b><span class="delta" id="deskDeltaTension">—</span></span></div>
54
+ <div class="desk-metric-value"><strong id="deskNumTension">–</strong><span class="sub-status" id="deskWordTension">—</span></div>
55
+ <div class="bar"><i id="deskBarTension"></i></div>
56
+ </section>
57
+
58
+ <nav class="card desk-card desk-seg" id="segTabs" aria-label="分组导航">
59
+ <button class="seg-item active" type="button" data-tab="dashboard" aria-selected="true">仪表盘</button>
60
+ <button class="seg-item" type="button" data-tab="memory" aria-selected="false">记忆</button>
61
+ <button class="seg-item" type="button" data-tab="settings" aria-selected="false">设置</button>
62
+ <button class="seg-item" type="button" data-tab="diagnosis" aria-selected="false">诊断</button>
63
+ </nav>
64
+
65
+ <section class="card desk-card desk-switch" aria-label="安全闸">
66
+ <span class="desk-switch-label">安全闸</span>
67
+ <label class="desk-switch-control"><input type="checkbox" data-switch="safety_enabled" aria-label="安全闸"><span class="desk-switch-state" data-switch-state="safety_enabled">当前:关</span></label>
68
+ </section>
69
+
70
+ <section class="card desk-card desk-switch" aria-label="主动回复">
71
+ <span class="desk-switch-label">主动回复</span>
72
+ <label class="desk-switch-control"><input type="checkbox" data-switch="proactive_send" aria-label="主动回复"><span class="desk-switch-state" data-switch-state="proactive_send">当前:关</span></label>
73
+ </section>
74
+
75
+ <section class="card desk-card desk-tile">
76
+ <div class="desk-tile-head"><span>数据目录</span><button class="desk-copy" id="deskCopyDataDir" type="button">复制</button></div>
77
+ <strong id="deskDataDir">宿主托管</strong>
78
+ </section>
79
+
80
+ <section class="card desk-card desk-tile">
81
+ <div class="desk-tile-head"><span>人设</span><span class="desk-chevron" aria-hidden="true">›</span></div>
82
+ <strong id="deskPersona">未载入</strong>
83
+ <span class="desk-tile-note" id="deskPersonaNote">—</span>
84
+ </section>
85
+
86
+ <section class="card desk-card desk-tile">
87
+ <div class="desk-tile-head"><span>运行时</span><span class="desk-chevron" aria-hidden="true">›</span></div>
88
+ <div class="desk-tile-row"><span>运行时长</span><b id="deskUptime">—</b></div>
89
+ <div class="desk-tile-row"><span>轮次</span><b id="deskTurns">—</b></div>
90
+ <div class="desk-tile-row"><span>向量模式</span><b id="deskVector">—</b></div>
91
+ </section>
92
+
93
+ <section class="card desk-card desk-tile">
94
+ <div class="desk-tile-head"><span>记忆容量</span><span class="desk-tile-note" id="deskCapText">0 / 200</span></div>
95
+ <div class="bar"><i id="deskCapFill"></i></div>
96
+ </section>
97
+
98
+ <section class="card desk-card desk-memory">
99
+ <div class="desk-card-head"><h2>最近记忆</h2><span class="card-aside" id="deskMemoryHint">—</span></div>
100
+ <ul class="memory-list" id="deskMemoryList"><li class="empty">还没有记忆,聊几句就会记住</li></ul>
101
+ </section>
102
+
103
+ <section class="card desk-card desk-chart">
104
+ <div class="desk-card-head"><h2>心情 · 24 小时</h2><span class="card-aside" id="deskChartHint">单线 + 事件点</span></div>
105
+ <canvas id="deskChart" height="170"></canvas>
106
+ </section>
107
+
108
+ <section class="card desk-card desk-stat">
109
+ <div class="desk-card-head"><h2>四通道均值</h2></div>
110
+ <div class="desk-stat-row"><span>绷紧</span><b id="deskAvgTension">—</b></div>
111
+ <div class="desk-stat-row"><span>暖</span><b id="deskAvgWarmth">—</b></div>
112
+ <div class="desk-stat-row"><span>沉</span><b id="deskAvgHeaviness">—</b></div>
113
+ <div class="desk-stat-row"><span>清</span><b id="deskAvgClarity">—</b></div>
114
+ </section>
115
+
116
+ <section class="card desk-card desk-stat">
117
+ <div class="desk-card-head"><h2>关系</h2></div>
118
+ <div class="desk-stat-row"><span>亲近</span><b id="deskRelCloseness">—</b></div>
119
+ <div class="desk-stat-row"><span>信任</span><b id="deskRelTrust">—</b></div>
120
+ <div class="desk-stat-row"><span>冲突</span><b id="deskRelConflict">—</b></div>
121
+ </section>
122
+
123
+ <section class="card desk-card desk-stat">
124
+ <div class="desk-card-head"><h2>今日</h2></div>
125
+ <div class="desk-stat-row"><span>新增记忆</span><b id="deskTodayMem">—</b></div>
126
+ <div class="desk-stat-row"><span>轮次</span><b id="deskTodayTurns">—</b></div>
127
+ <div class="desk-stat-row"><span>主动发送</span><b id="deskTodayProactive">—</b></div>
128
+ </section>
129
+ </section>
130
+
25
131
  <header class="masthead" id="masthead">
26
132
  <div class="orb-wrap" aria-hidden="true">
27
133
  <div class="orb"><span class="orb-ring"></span></div>
package/ui/panel.js CHANGED
@@ -7,7 +7,7 @@
7
7
  (function () {
8
8
  'use strict'
9
9
 
10
- var POLL_MS = 2000
10
+ var POLL_MS = 5000
11
11
  var SESSION = (new URLSearchParams(window.location.search).get('session') || '').trim()
12
12
  var SWITCHES = [
13
13
  { key: 'safety_enabled', label: '安全闸', desc: '打开后可能拦截/改写回复' },
@@ -442,8 +442,7 @@
442
442
  return localPoints.slice()
443
443
  }
444
444
 
445
- function renderChart(data) {
446
- var canvas = $('moodChart')
445
+ function drawChartInto(canvas, data) {
447
446
  if (!canvas) return
448
447
  var points = mergePoints(data)
449
448
  var wrapWidth = canvas.clientWidth || canvas.parentElement.clientWidth || 600
@@ -523,7 +522,7 @@
523
522
  ctx.arc(toX(eventTs), eventY, 2.6, 0, Math.PI * 2)
524
523
  ctx.fill()
525
524
  }
526
- setText('chartHint', '单线 + ' + events.length + ' 个事件点')
525
+ setText(canvas.id === 'deskChart' ? 'deskChartHint' : 'chartHint', '单线 + ' + events.length + ' 个事件点')
527
526
  } else {
528
527
  ctx.strokeStyle = lineColor
529
528
  ctx.lineWidth = 2
@@ -538,12 +537,183 @@
538
537
  ctx.arc(padL + plotW - 3, padT + ((1 - Math.max(-1, Math.min(1, points[0].v))) / 2) * plotH, 3, 0, Math.PI * 2)
539
538
  ctx.fill()
540
539
  }
541
- setText('chartHint', '积累中 · 单点')
540
+ setText(canvas.id === 'deskChart' ? 'deskChartHint' : 'chartHint', '积累中 · 单点')
542
541
  }
543
542
  ctx.fillStyle = mutedColor
544
543
  ctx.fillText('24 小时', padL, wrapHeight - 5)
545
544
  }
546
545
 
546
+ function renderChart(data) {
547
+ drawChartInto($('moodChart'), data)
548
+ drawChartInto($('deskChart'), data)
549
+ }
550
+
551
+ var DESK_HIDDEN_KEY = 'biocore-desk-hidden'
552
+ var RAIL_KEY = 'biocore-rail-collapsed'
553
+
554
+ function copyText(fromId, toId) {
555
+ var from = $(fromId)
556
+ var to = $(toId)
557
+ if (from && to) to.textContent = from.textContent
558
+ }
559
+
560
+ function copyBar(fromId, toId) {
561
+ var from = $(fromId)
562
+ var to = $(toId)
563
+ if (from && to) to.style.width = from.style.width
564
+ }
565
+
566
+ /** Desktop grid mirrors the mobile DOM (single data source, no duplicate state). */
567
+ function deskSync(data) {
568
+ copyText('motivation', 'deskMotivation')
569
+ copyText('statusText', 'deskStatusText')
570
+ var mobileStatus = $('status')
571
+ var deskStatus = $('deskStatus')
572
+ if (mobileStatus && deskStatus) {
573
+ deskStatus.className = 'status' + (mobileStatus.classList.contains('on') ? ' on' : (mobileStatus.classList.contains('off') ? ' off' : ''))
574
+ }
575
+ copyText('num-v', 'deskNumV')
576
+ copyText('moodNote', 'deskWordV')
577
+ copyText('arrow-v', 'deskArrowV')
578
+ copyText('delta-v', 'deskDeltaV')
579
+ copyBar('bar-v', 'deskBarV')
580
+ copyText('num-emotion-tension', 'deskNumTension')
581
+ copyText('status-tension', 'deskWordTension')
582
+ copyText('arrow-emotion-tension', 'deskArrowTension')
583
+ copyText('delta-emotion-tension', 'deskDeltaTension')
584
+ copyBar('bar-emotion-tension', 'deskBarTension')
585
+ copyText('runtimeUptime', 'deskUptime')
586
+ copyText('runtimeTurns', 'deskTurns')
587
+ copyText('runtimeVector', 'deskVector')
588
+ copyText('capText', 'deskCapText')
589
+ copyBar('capFill', 'deskCapFill')
590
+ copyText('dataDirText', 'deskDataDir')
591
+ copyText('memTotal', 'deskTodayMem')
592
+ copyText('runtimeTurns', 'deskTodayTurns')
593
+ copyText('memoryHint', 'deskMemoryHint')
594
+ var memoryFrom = $('memoryList')
595
+ var memoryTo = $('deskMemoryList')
596
+ if (memoryFrom && memoryTo) memoryTo.innerHTML = memoryFrom.innerHTML
597
+ var available = data && data.available !== false
598
+ var intero = (available && data.intero) || {}
599
+ var channels = [['tension', 'deskAvgTension'], ['warmth', 'deskAvgWarmth'], ['heaviness', 'deskAvgHeaviness'], ['clarity', 'deskAvgClarity']]
600
+ for (var i = 0; i < channels.length; i += 1) {
601
+ var value = Number(intero[channels[i][0]])
602
+ setText(channels[i][1], Number.isFinite(value) ? fmt(value) : '—')
603
+ }
604
+ var rel = (available && data.relationship_metrics) || {}
605
+ setText('deskRelCloseness', available && Number.isFinite(Number(rel.closeness)) ? fmt(rel.closeness) : '—')
606
+ setText('deskRelTrust', available && Number.isFinite(Number(rel.trust)) ? fmt(rel.trust) : '—')
607
+ setText('deskRelConflict', available && Number.isFinite(Number(rel.conflict)) ? fmt(rel.conflict) : '—')
608
+ setText('deskTodayProactive', '—')
609
+ var personaBox = $('personaText')
610
+ var personaChars = personaBox ? String(personaBox.value || '').replace(/s+/g, '').length : 0
611
+ setText('deskPersona', personaChars ? '已载入' : '未载入')
612
+ setText('deskPersonaNote', personaChars ? (personaChars + ' 字 · 编译后只改倾向') : '还没有写人设')
613
+ }
614
+
615
+ function deskCards() {
616
+ return Array.prototype.slice.call(document.querySelectorAll('#deskGrid .desk-card'))
617
+ }
618
+
619
+ function deskCardId(card, index) {
620
+ if (!card.id) card.id = 'desk-card-' + index
621
+ return card.id
622
+ }
623
+
624
+ function readDeskHidden() {
625
+ try {
626
+ var raw = window.localStorage.getItem(DESK_HIDDEN_KEY)
627
+ var list = raw ? JSON.parse(raw) : []
628
+ return Array.isArray(list) ? list : []
629
+ } catch (e) { return [] }
630
+ }
631
+
632
+ function writeDeskHidden(list) {
633
+ try { window.localStorage.setItem(DESK_HIDDEN_KEY, JSON.stringify(list)) } catch (e) { /* ignore */ }
634
+ }
635
+
636
+ function applyDeskHidden() {
637
+ var hidden = readDeskHidden()
638
+ deskCards().forEach(function (card, index) {
639
+ card.classList.toggle('is-desk-hidden', hidden.indexOf(deskCardId(card, index)) >= 0)
640
+ })
641
+ }
642
+
643
+ function ensureDeskHides() {
644
+ deskCards().forEach(function (card, index) {
645
+ deskCardId(card, index)
646
+ if (card.querySelector('[data-desk-hide]')) return
647
+ var button = document.createElement('button')
648
+ button.type = 'button'
649
+ button.className = 'desk-hide'
650
+ button.setAttribute('data-desk-hide', '')
651
+ button.setAttribute('aria-label', '隐藏这张卡片')
652
+ button.textContent = '−'
653
+ card.appendChild(button)
654
+ })
655
+ }
656
+
657
+ function deskLabel(card) {
658
+ var named = card.getAttribute('data-card-label')
659
+ if (named) return named
660
+ var title = card.querySelector('h2')
661
+ return title ? title.textContent.trim() : card.id
662
+ }
663
+
664
+ function restoreDeskCard(id) {
665
+ writeDeskHidden(readDeskHidden().filter(function (item) { return item !== id }))
666
+ applyDeskHidden()
667
+ renderAddCards()
668
+ layoutToast('已恢复卡片')
669
+ }
670
+
671
+ function onDeskEditClick(event) {
672
+ var target = event.target
673
+ var add = target && target.closest ? target.closest('[data-desk-add]') : null
674
+ if (add) {
675
+ event.preventDefault()
676
+ restoreDeskCard(add.getAttribute('data-desk-add'))
677
+ return
678
+ }
679
+ var hide = target && target.closest ? target.closest('[data-desk-hide]') : null
680
+ if (!hide) return
681
+ event.preventDefault()
682
+ var card = hide.closest('.desk-card')
683
+ if (!card) return
684
+ var hidden = readDeskHidden()
685
+ var id = card.id
686
+ if (hidden.indexOf(id) < 0) hidden.push(id)
687
+ writeDeskHidden(hidden)
688
+ applyDeskHidden()
689
+ renderAddCards()
690
+ layoutToast('已隐藏「' + deskLabel(card) + '」')
691
+ }
692
+
693
+ function railApply(collapsed) {
694
+ document.body.classList.toggle('rail-collapsed', !!collapsed)
695
+ var button = $('railToggle')
696
+ if (!button) return
697
+ var label = collapsed ? '展开导航' : '收起导航'
698
+ button.setAttribute('aria-expanded', String(!collapsed))
699
+ button.setAttribute('aria-label', label)
700
+ button.setAttribute('title', label)
701
+ }
702
+
703
+ function railInit() {
704
+ var storage = layoutStorage()
705
+ var collapsed = false
706
+ try { collapsed = !!storage && storage.getItem(RAIL_KEY) === '1' } catch (e) { collapsed = false }
707
+ railApply(collapsed)
708
+ var button = $('railToggle')
709
+ if (!button) return
710
+ button.addEventListener('click', function () {
711
+ var next = !document.body.classList.contains('rail-collapsed')
712
+ railApply(next)
713
+ try { if (storage) storage.setItem(RAIL_KEY, next ? '1' : '0') } catch (e) { /* ignore */ }
714
+ })
715
+ }
716
+
547
717
  function updateFooter(data) {
548
718
  var memory = data && data.available !== false && typeof data.memory_count === 'number'
549
719
  ? data.memory_count : '-'
@@ -560,6 +730,8 @@
560
730
  renderRuntime(data)
561
731
  renderChart(data)
562
732
  updateFooter(data)
733
+ deskSync(data)
734
+ deskSync(data)
563
735
  if (!data) renderEmpty('request_failed')
564
736
  else if (data.available === false) renderEmpty(String(data.reason || 'request_failed'))
565
737
  else clearEmpty()
@@ -611,16 +783,20 @@
611
783
  }
612
784
 
613
785
  function paintSwitchState(key, checked) {
614
- var state = document.querySelector('[data-switch-state="' + key + '"]')
615
- if (state) state.textContent = '当前:' + (checked ? '开' : '关')
786
+ var states = document.querySelectorAll('[data-switch-state="' + key + '"]')
787
+ for (var i = 0; i < states.length; i += 1) states[i].textContent = '当前:' + (checked ? '开' : '关')
788
+ }
789
+
790
+ function setSwitchInputs(key, checked) {
791
+ var inputs = document.querySelectorAll('input[data-switch="' + key + '"]')
792
+ for (var i = 0; i < inputs.length; i += 1) inputs[i].checked = !!checked
793
+ paintSwitchState(key, checked)
616
794
  }
617
795
 
618
796
  function applySwitchValues(values) {
619
797
  for (var i = 0; i < SWITCHES.length; i += 1) {
620
- var input = document.querySelector('input[data-switch="' + SWITCHES[i].key + '"]')
621
- if (input && values && typeof values[SWITCHES[i].key] === 'boolean') {
622
- input.checked = values[SWITCHES[i].key]
623
- paintSwitchState(SWITCHES[i].key, input.checked)
798
+ if (values && typeof values[SWITCHES[i].key] === 'boolean') {
799
+ setSwitchInputs(SWITCHES[i].key, values[SWITCHES[i].key])
624
800
  }
625
801
  }
626
802
  }
@@ -673,14 +849,12 @@
673
849
  applySwitchValues(body.switches)
674
850
  setText('switchHint', '已写入宿主设置')
675
851
  } else {
676
- input.checked = !next
677
- paintSwitchState(key, input.checked)
852
+ setSwitchInputs(key, !next)
678
853
  setText('switchHint', '写入失败:' + ((body && body.reason) || 'unknown'))
679
854
  }
680
855
  })
681
856
  .catch(function () {
682
- input.checked = !next
683
- paintSwitchState(key, input.checked)
857
+ setSwitchInputs(key, !next)
684
858
  setText('switchHint', '写入失败:未连接')
685
859
  })
686
860
  .then(function () {
@@ -1270,15 +1444,18 @@
1270
1444
  if (!layoutApi) return
1271
1445
  var group = layoutEditing ? null : (layoutApi.TAB_GROUPS[activeTab] || [])
1272
1446
  var panel = $('panel')
1273
- if (panel) panel.classList.toggle('tab-dashboard', activeTab === 'dashboard')
1447
+ if (panel) {
1448
+ var tabsKnown = ['dashboard', 'memory', 'settings', 'diagnosis']
1449
+ for (var t = 0; t < tabsKnown.length; t += 1) {
1450
+ panel.classList.toggle('tab-' + tabsKnown[t], activeTab === tabsKnown[t])
1451
+ }
1452
+ }
1274
1453
  var cards = layoutCards()
1275
1454
  for (var i = 0; i < cards.length; i += 1) {
1276
1455
  var id = cards[i].getAttribute('data-card-id')
1277
1456
  cards[i].classList.toggle('tab-hidden', !!group && group.indexOf(id) < 0)
1278
1457
  }
1279
- var bar = $('tabBar')
1280
- if (!bar) return
1281
- var items = bar.querySelectorAll('[data-tab]')
1458
+ var items = document.querySelectorAll('[data-tab]')
1282
1459
  for (var j = 0; j < items.length; j += 1) {
1283
1460
  var on = items[j].getAttribute('data-tab') === activeTab
1284
1461
  items[j].classList.toggle('active', on)
@@ -1286,17 +1463,19 @@
1286
1463
  }
1287
1464
  }
1288
1465
 
1466
+ function setActiveTab(tab) {
1467
+ if (!layoutApi || !layoutApi.TAB_GROUPS[tab]) return
1468
+ activeTab = tab
1469
+ applyTabGroup()
1470
+ }
1471
+
1289
1472
  function bindTabBar() {
1290
- var bar = $('tabBar')
1291
- if (!bar) return
1292
- bar.addEventListener('click', function (event) {
1473
+ document.addEventListener('click', function (event) {
1293
1474
  var target = event.target
1294
1475
  var button = target && target.closest ? target.closest('[data-tab]') : null
1295
1476
  if (!button) return
1296
- var tab = button.getAttribute('data-tab')
1297
- if (!layoutApi || !layoutApi.TAB_GROUPS[tab]) return
1298
- activeTab = tab
1299
- applyTabGroup()
1477
+ event.preventDefault()
1478
+ setActiveTab(button.getAttribute('data-tab'))
1300
1479
  })
1301
1480
  applyTabGroup()
1302
1481
  }
@@ -1354,6 +1533,8 @@
1354
1533
  if (reset) reset.hidden = !layoutEditing
1355
1534
  var cards = layoutCards()
1356
1535
  for (var i = 0; i < cards.length; i += 1) ensureHideButton(cards[i])
1536
+ ensureDeskHides()
1537
+ applyDeskHidden()
1357
1538
  renderAddCards()
1358
1539
  applyTabGroup()
1359
1540
  if (!layoutEditing) persistDomOrder()
@@ -1396,7 +1577,11 @@
1396
1577
  return
1397
1578
  }
1398
1579
  var hidden = hiddenCards()
1399
- if (!hidden.length) {
1580
+ var deskHidden = readDeskHidden()
1581
+ var deskHiddenCards = deskCards().filter(function (card, index) {
1582
+ return deskHidden.indexOf(deskCardId(card, index)) >= 0
1583
+ })
1584
+ if (!hidden.length && !deskHiddenCards.length) {
1400
1585
  section.hidden = true
1401
1586
  list.textContent = ''
1402
1587
  return
@@ -1411,6 +1596,14 @@
1411
1596
  chip.textContent = '+ ' + hidden[i].label
1412
1597
  list.appendChild(chip)
1413
1598
  }
1599
+ for (var d = 0; d < deskHiddenCards.length; d += 1) {
1600
+ var deskChip = document.createElement('button')
1601
+ deskChip.type = 'button'
1602
+ deskChip.className = 'add-chip'
1603
+ deskChip.setAttribute('data-desk-add', deskCardId(deskHiddenCards[d], 0))
1604
+ deskChip.textContent = '+ ' + deskLabel(deskHiddenCards[d])
1605
+ list.appendChild(deskChip)
1606
+ }
1414
1607
  }
1415
1608
 
1416
1609
  function showHiddenCard(id) {
@@ -1583,9 +1776,13 @@
1583
1776
  document.addEventListener('pointermove', onCardsPointerMove, { passive: false })
1584
1777
  document.addEventListener('pointerup', onCardsPointerUp)
1585
1778
  document.addEventListener('pointercancel', onCardsPointerUp)
1779
+ ensureDeskHides()
1780
+ applyDeskHidden()
1781
+ document.addEventListener('click', onDeskEditClick)
1586
1782
  }
1587
1783
 
1588
1784
  function boot() {
1785
+ railInit()
1589
1786
  initLayout()
1590
1787
  bindAdvanced()
1591
1788
  bindPersona()
@@ -1605,9 +1802,18 @@
1605
1802
  loadSeries()
1606
1803
  fetchState()
1607
1804
  fetchTrace()
1608
- window.setInterval(fetchState, POLL_MS)
1609
- window.setInterval(fetchTrace, POLL_MS)
1805
+ function pollTick() {
1806
+ if (document.hidden) return
1807
+ fetchState()
1808
+ fetchTrace()
1809
+ }
1810
+ window.setInterval(pollTick, POLL_MS)
1610
1811
  window.setInterval(updateStatusClock, 1000)
1812
+ document.addEventListener('visibilitychange', function () {
1813
+ if (document.hidden) return
1814
+ fetchState()
1815
+ fetchTrace()
1816
+ })
1611
1817
  window.addEventListener('resize', function () { renderChart(lastData) })
1612
1818
  }
1613
1819