dsh-biocore 0.1.6 → 0.1.8
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 +3 -1
- package/lib/index.js +33 -27
- package/lib/modules/core-turn.js +16 -3
- package/lib/modules/data-dir.js +90 -5
- package/package.json +1 -1
- package/python/biocore_runtime/pipeline.py +36 -8
- package/ui/panel.js +12 -3
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
|
@@ -28,7 +28,7 @@ import { TRACE_ROUTE_PATH, defaultStoreDir, handleTraceRoute } from './modules/t
|
|
|
28
28
|
import { queueSessionTurn } from './modules/turn-queue.js'
|
|
29
29
|
import { anySwitchOn } from './modules/expression-inject.js'
|
|
30
30
|
import { makeCoreSessionListener, registerCoreTurn } from './modules/core-turn.js'
|
|
31
|
-
import { PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
|
|
31
|
+
import { migrateLegacyPluginData, PLUGIN_DATA_DIR, resolveDataDir } from './modules/data-dir.js'
|
|
32
32
|
import {
|
|
33
33
|
PANEL_MOUNT_PATH,
|
|
34
34
|
PANEL_ROUTE_PREFIX,
|
|
@@ -665,7 +665,7 @@ export function makeSessionEventListener(options) {
|
|
|
665
665
|
}
|
|
666
666
|
}
|
|
667
667
|
|
|
668
|
-
const CORE_STATE_TTL_MS =
|
|
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
|
-
|
|
730
|
-
let
|
|
729
|
+
const read = readStateFile(stateFile)
|
|
730
|
+
let body = null
|
|
731
731
|
if (read.missing) {
|
|
732
|
-
|
|
733
|
-
|
|
734
|
-
|
|
735
|
-
|
|
736
|
-
|
|
737
|
-
|
|
738
|
-
|
|
739
|
-
|
|
740
|
-
|
|
741
|
-
|
|
742
|
-
|
|
743
|
-
|
|
744
|
-
|
|
745
|
-
|
|
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
|
-
|
|
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
|
}
|
|
@@ -761,6 +762,7 @@ export async function handleState(req, res, stateDir, options) {
|
|
|
761
762
|
|
|
762
763
|
export function apply(ctx, config) {
|
|
763
764
|
const dataDir = resolveDataDir(config)
|
|
765
|
+
try { migrateLegacyPluginData(dataDir) } catch { /* best-effort data migration */ }
|
|
764
766
|
try { migratePersonaToShared(dataDir) } catch { /* best-effort migration */ }
|
|
765
767
|
const disposers = []
|
|
766
768
|
const keep = (dispose) => {
|
|
@@ -866,10 +868,14 @@ export function apply(ctx, config) {
|
|
|
866
868
|
const memoryDir = resolveMemoryDir(config)
|
|
867
869
|
const injectionState = createInjectionState()
|
|
868
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) {
|
|
869
874
|
const capture = registerMemoryCapture(ctx, () => config, memoryDir, {
|
|
870
875
|
injectionState,
|
|
871
876
|
})
|
|
872
877
|
if (capture) keep(capture.dispose)
|
|
878
|
+
}
|
|
873
879
|
} catch { /* memory capture unavailable */ }
|
|
874
880
|
try {
|
|
875
881
|
const injection = registerMemoryInjection(ctx, () => config, memoryDir,
|
package/lib/modules/core-turn.js
CHANGED
|
@@ -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:
|
|
136
|
-
memoryEnabled:
|
|
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/lib/modules/data-dir.js
CHANGED
|
@@ -4,21 +4,24 @@
|
|
|
4
4
|
* <dataDir>/sessions/<session_id>.json (per-session BioCore state)
|
|
5
5
|
* <dataDir>/flags.json (four switches)
|
|
6
6
|
*
|
|
7
|
-
* Resolution order
|
|
7
|
+
* Resolution order:
|
|
8
8
|
* 1. --profile <name> / --profile=<name> from process.argv
|
|
9
9
|
* -> <homedir>/.dsh/profiles/<name>/data, only when that profile dir exists
|
|
10
10
|
* 2. config.dataDir (cordis insert config)
|
|
11
11
|
* 3. BIOCORE_UI_DATA_DIR
|
|
12
12
|
* 4. <DSH_PROFILE_DIR>/data -- only when it is a real DSH profile directory
|
|
13
13
|
* (contains package.json or dsh.yml)
|
|
14
|
-
* 5. <
|
|
14
|
+
* 5. <home>/.dsh/profiles/web/data -- when profiles/web/package.json exists
|
|
15
|
+
* 6. <home>/.dsh/profiles/<only>/data -- exactly one profile under profiles/
|
|
16
|
+
* 7. <plugin>/data -- last resort, logs __bioDataDirFallback
|
|
17
|
+
* (never cwd; previous node_modules data is migrated, not deleted)
|
|
15
18
|
*
|
|
16
19
|
* Why argv first: DSH passes the serving process its own --profile flag, but
|
|
17
20
|
* DSH_PROFILE_DIR in the environment can point at the *default* profile (web)
|
|
18
21
|
* even when the instance runs --profile web-bio. Trusting the env first wrote
|
|
19
22
|
* session state into the wrong profile's data dir.
|
|
20
23
|
*/
|
|
21
|
-
import { existsSync } from 'node:fs'
|
|
24
|
+
import { copyFileSync, existsSync, mkdirSync, readdirSync } from 'node:fs'
|
|
22
25
|
import { homedir } from 'node:os'
|
|
23
26
|
import { fileURLToPath } from 'node:url'
|
|
24
27
|
import { dirname, join, resolve } from 'node:path'
|
|
@@ -28,6 +31,10 @@ export const PROFILE_DIR_ENV = 'DSH_PROFILE_DIR'
|
|
|
28
31
|
export const PROFILE_MARKERS = Object.freeze(['package.json', 'dsh.yml'])
|
|
29
32
|
/** Fallback: the plugin checkout's own data/ directory. */
|
|
30
33
|
export const PLUGIN_DATA_DIR = fileURLToPath(new URL('../../data', import.meta.url))
|
|
34
|
+
export const FALLBACK_WARN_PREFIX = '__bioDataDirFallback'
|
|
35
|
+
export const MIGRATE_LOG_PREFIX = '__bioDataMigrated'
|
|
36
|
+
|
|
37
|
+
let warnedFallback = false
|
|
31
38
|
|
|
32
39
|
function text(value) {
|
|
33
40
|
return typeof value === 'string' ? value.trim() : ''
|
|
@@ -69,6 +76,71 @@ export function isProfileDir(dir) {
|
|
|
69
76
|
}
|
|
70
77
|
}
|
|
71
78
|
|
|
79
|
+
/** <home>/.dsh/profiles */
|
|
80
|
+
export function profilesRootDir(homeDir) {
|
|
81
|
+
const home = text(homeDir) || homedir()
|
|
82
|
+
return join(resolve(home), '.dsh', 'profiles')
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
/** All real profile names directly under <home>/.dsh/profiles. */
|
|
86
|
+
export function listProfiles(homeDir) {
|
|
87
|
+
const root = profilesRootDir(homeDir)
|
|
88
|
+
let names = []
|
|
89
|
+
try {
|
|
90
|
+
names = readdirSync(root, { withFileTypes: true })
|
|
91
|
+
.filter((entry) => entry && entry.isDirectory())
|
|
92
|
+
.map((entry) => entry.name)
|
|
93
|
+
} catch { return [] }
|
|
94
|
+
return names.filter((name) => isProfileDir(join(root, name)))
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function warnPluginFallback(path) {
|
|
98
|
+
if (warnedFallback) return
|
|
99
|
+
warnedFallback = true
|
|
100
|
+
try {
|
|
101
|
+
console.error(FALLBACK_WARN_PREFIX + ' using plugin data dir=' + path
|
|
102
|
+
+ ' reason=no-profile-dir (writes will live inside the plugin package)')
|
|
103
|
+
} catch { /* logging must not throw */ }
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
/** Copy existing legacy plugin data into the resolved profile dataDir.
|
|
107
|
+
* Missing files are copied; existing files are never overwritten; the old
|
|
108
|
+
* plugin copy stays in place for rollback. */
|
|
109
|
+
export function migrateLegacyPluginData(dataDir, legacyDir) {
|
|
110
|
+
const target = text(dataDir)
|
|
111
|
+
const source = text(legacyDir) || PLUGIN_DATA_DIR
|
|
112
|
+
const result = { migrated: false, copied: 0, target, source }
|
|
113
|
+
if (!target || resolve(target) === resolve(source)) return result
|
|
114
|
+
// Only migrate into <profile>/data when the parent is a real profile root.
|
|
115
|
+
const parent = dirname(resolve(target))
|
|
116
|
+
if (!isProfileDir(parent)) return result
|
|
117
|
+
const copyIfMissing = (from, to) => {
|
|
118
|
+
try {
|
|
119
|
+
if (!existsSync(from) || existsSync(to)) return false
|
|
120
|
+
mkdirSync(dirname(to), { recursive: true })
|
|
121
|
+
copyFileSync(from, to)
|
|
122
|
+
result.copied += 1
|
|
123
|
+
return true
|
|
124
|
+
} catch { return false }
|
|
125
|
+
}
|
|
126
|
+
const files = ['flags.json', 'persona_profile.json', 'persona.md', 'config.json']
|
|
127
|
+
for (const name of files) copyIfMissing(join(source, name), join(target, name))
|
|
128
|
+
try {
|
|
129
|
+
const sessions = readdirSync(join(source, 'sessions'))
|
|
130
|
+
for (const name of sessions) {
|
|
131
|
+
copyIfMissing(join(source, 'sessions', name), join(target, 'sessions', name))
|
|
132
|
+
}
|
|
133
|
+
} catch { /* no legacy sessions */ }
|
|
134
|
+
result.migrated = result.copied > 0
|
|
135
|
+
if (result.migrated) {
|
|
136
|
+
try {
|
|
137
|
+
console.error(MIGRATE_LOG_PREFIX + ' from=' + source + ' to=' + target
|
|
138
|
+
+ ' copied=' + result.copied)
|
|
139
|
+
} catch { /* logging must not throw */ }
|
|
140
|
+
}
|
|
141
|
+
return result
|
|
142
|
+
}
|
|
143
|
+
|
|
72
144
|
/**
|
|
73
145
|
* Resolve the one directory that owns flags.json and sessions/.
|
|
74
146
|
* @param {object} [config] plugin config (config.dataDir)
|
|
@@ -103,5 +175,18 @@ export function resolveDataDir(config, env, argv, homeDir) {
|
|
|
103
175
|
if (isProfileDir(candidate)) return join(candidate, 'data')
|
|
104
176
|
}
|
|
105
177
|
|
|
106
|
-
|
|
107
|
-
|
|
178
|
+
// 5) default web profile, when it is a real profile.
|
|
179
|
+
const webProfile = join(profilesRootDir(homeDir), 'web')
|
|
180
|
+
if (isProfileDir(webProfile)) return join(webProfile, 'data')
|
|
181
|
+
|
|
182
|
+
// 6) exactly one profile -> deterministic; multiple profiles are ambiguous.
|
|
183
|
+
const profiles = listProfiles(homeDir)
|
|
184
|
+
if (profiles.length === 1) {
|
|
185
|
+
return join(profilesRootDir(homeDir), profiles[0], 'data')
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
// 7) last resort: the plugin package itself.
|
|
189
|
+
const fallback = resolve(PLUGIN_DATA_DIR)
|
|
190
|
+
warnPluginFallback(fallback)
|
|
191
|
+
return fallback
|
|
192
|
+
}
|
package/package.json
CHANGED
|
@@ -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
|
-
|
|
1832
|
-
|
|
1833
|
-
|
|
1834
|
-
|
|
1835
|
-
|
|
1836
|
-
|
|
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.js
CHANGED
|
@@ -7,7 +7,7 @@
|
|
|
7
7
|
(function () {
|
|
8
8
|
'use strict'
|
|
9
9
|
|
|
10
|
-
var POLL_MS =
|
|
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: '打开后可能拦截/改写回复' },
|
|
@@ -1605,9 +1605,18 @@
|
|
|
1605
1605
|
loadSeries()
|
|
1606
1606
|
fetchState()
|
|
1607
1607
|
fetchTrace()
|
|
1608
|
-
|
|
1609
|
-
|
|
1608
|
+
function pollTick() {
|
|
1609
|
+
if (document.hidden) return
|
|
1610
|
+
fetchState()
|
|
1611
|
+
fetchTrace()
|
|
1612
|
+
}
|
|
1613
|
+
window.setInterval(pollTick, POLL_MS)
|
|
1610
1614
|
window.setInterval(updateStatusClock, 1000)
|
|
1615
|
+
document.addEventListener('visibilitychange', function () {
|
|
1616
|
+
if (document.hidden) return
|
|
1617
|
+
fetchState()
|
|
1618
|
+
fetchTrace()
|
|
1619
|
+
})
|
|
1611
1620
|
window.addEventListener('resize', function () { renderChart(lastData) })
|
|
1612
1621
|
}
|
|
1613
1622
|
|