noteconnection 1.6.8 → 1.8.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.
Files changed (212) hide show
  1. package/LICENSE +674 -21
  2. package/README.md +258 -64
  3. package/dist/src/agent_workspace.contract.parity.test.js +475 -0
  4. package/dist/src/agent_workspace.frontend.test.js +9989 -0
  5. package/dist/src/agent_workspace.locale.contract.test.js +95 -0
  6. package/dist/src/agent_workspace.runtime.behavior.test.js +5072 -0
  7. package/dist/src/copy.assets.contract.test.js +130 -0
  8. package/dist/src/core/PathBridge.js +28 -3
  9. package/dist/src/export/WorkspaceExportBundle.js +1130 -0
  10. package/dist/src/export/WorkspaceExportBundle.test.js +1480 -0
  11. package/dist/src/export/index.js +18 -0
  12. package/dist/src/export/types.js +2 -0
  13. package/dist/src/fixrisk.issue.verifier.contract.test.js +3 -1
  14. package/dist/src/foundation.ann.runtime.contract.test.js +81 -0
  15. package/dist/src/foundation.release.evidence.contract.test.js +406 -0
  16. package/dist/src/foundation.sqlite.runtime.contract.test.js +82 -0
  17. package/dist/src/frontend/README.md +258 -64
  18. package/dist/src/frontend/agent_workspace.js +5353 -0
  19. package/dist/src/frontend/agent_workspace_runtime.js +4434 -0
  20. package/dist/src/frontend/analysis.js +31 -4
  21. package/dist/src/frontend/app.js +3597 -141
  22. package/dist/src/frontend/focus_mode_interactions.js +33 -0
  23. package/dist/src/frontend/godot_future_path_renderer.js +540 -0
  24. package/dist/src/frontend/godot_tree_interactions.js +178 -0
  25. package/dist/src/frontend/graph_state.mjs +105 -0
  26. package/dist/src/frontend/hosted_future_path_runtime.js +157 -0
  27. package/dist/src/frontend/i18n.mjs +186 -0
  28. package/dist/src/frontend/index.html +641 -266
  29. package/dist/src/frontend/layout_gpu.js +12 -4
  30. package/dist/src/frontend/locales/en.json +721 -5
  31. package/dist/src/frontend/locales/zh.json +721 -5
  32. package/dist/src/frontend/main.mjs +60 -0
  33. package/dist/src/frontend/markdown_runtime.js +827 -0
  34. package/dist/src/frontend/notemd.css +49 -0
  35. package/dist/src/frontend/notemd.html +7 -1
  36. package/dist/src/frontend/notemd.js +64 -0
  37. package/dist/src/frontend/path.html +107 -0
  38. package/dist/src/frontend/path_app.js +2189 -150
  39. package/dist/src/frontend/path_layout.mjs +143 -0
  40. package/dist/src/frontend/path_mermaid_utils.mjs +108 -0
  41. package/dist/src/frontend/path_modules_bridge.js +486 -0
  42. package/dist/src/frontend/path_state.mjs +118 -0
  43. package/dist/src/frontend/path_styles.css +146 -0
  44. package/dist/src/frontend/path_worker_bridge.mjs +85 -0
  45. package/dist/src/frontend/reader.js +522 -27
  46. package/dist/src/frontend/runtime_bridge.js +67 -54
  47. package/dist/src/frontend/runtime_bridge.mjs +279 -0
  48. package/dist/src/frontend/settings.js +130 -12
  49. package/dist/src/frontend/simulationWorker.js +241 -6
  50. package/dist/src/frontend/source_manager.js +190 -21
  51. package/dist/src/frontend/styles.css +2853 -72
  52. package/dist/src/frontend/workbench_state.mjs +101 -0
  53. package/dist/src/frontend/workspace_panes.js +10168 -0
  54. package/dist/src/frontend.locale.contract.test.js +62 -0
  55. package/dist/src/godot.sidecar.bootstrap.contract.test.js +244 -0
  56. package/dist/src/indexing/IndexLifecycle.js +195 -0
  57. package/dist/src/indexing/IndexLifecycle.test.js +49 -0
  58. package/dist/src/indexing/SegmentBuilder.js +64 -0
  59. package/dist/src/indexing/UnitBuilder.js +48 -0
  60. package/dist/src/indexing/types.js +2 -0
  61. package/dist/src/knowledge.api.contract.test.js +170 -0
  62. package/dist/src/learning/KnowledgeLearningPlatform.js +10386 -0
  63. package/dist/src/learning/KnowledgeLearningPlatform.persistence.test.js +327 -0
  64. package/dist/src/learning/KnowledgeLearningPlatform.program-f.test.js +99 -0
  65. package/dist/src/learning/KnowledgeLearningPlatform.test.js +2971 -0
  66. package/dist/src/learning/KnowledgeWorkspaceConversationRegression.js +2974 -0
  67. package/dist/src/learning/KnowledgeWorkspaceConversationRegression.test.js +3928 -0
  68. package/dist/src/learning/answerReleaseReview.js +4319 -0
  69. package/dist/src/learning/answerReleaseReview.test.js +2888 -0
  70. package/dist/src/learning/api.js +2 -0
  71. package/dist/src/learning/conversationComposer.js +1480 -0
  72. package/dist/src/learning/conversationComposer.test.js +1817 -0
  73. package/dist/src/learning/domains/ConversationManager.js +53 -0
  74. package/dist/src/learning/domains/KnowledgeIngestor.js +238 -0
  75. package/dist/src/learning/domains/KnowledgeQuerier.js +187 -0
  76. package/dist/src/learning/domains/MasteryEngine.js +387 -0
  77. package/dist/src/learning/domains/MemoryPolicyManager.js +408 -0
  78. package/dist/src/learning/domains/QualityEvaluator.js +307 -0
  79. package/dist/src/learning/domains/TutorRouter.js +313 -0
  80. package/dist/src/learning/domains/index.js +33 -0
  81. package/dist/src/learning/domains/types.js +7 -0
  82. package/dist/src/learning/errors.js +29 -0
  83. package/dist/src/learning/evidenceContextAssembler.js +1176 -0
  84. package/dist/src/learning/evidenceContextAssembler.test.js +6332 -0
  85. package/dist/src/learning/graphContextAssembler.js +870 -0
  86. package/dist/src/learning/graphContextAssembler.test.js +1033 -0
  87. package/dist/src/learning/index.js +28 -0
  88. package/dist/src/learning/queryBackend.js +1898 -0
  89. package/dist/src/learning/queryBackend.test.js +955 -0
  90. package/dist/src/learning/ragContextPack.js +257 -0
  91. package/dist/src/learning/ragContextPack.test.js +160 -0
  92. package/dist/src/learning/ragPublicText.js +38 -0
  93. package/dist/src/learning/ragSufficiencyJudge.js +161 -0
  94. package/dist/src/learning/ragSufficiencyJudge.test.js +177 -0
  95. package/dist/src/learning/ragSufficiencyProviderJudge.js +227 -0
  96. package/dist/src/learning/ragSufficiencyProviderJudge.test.js +156 -0
  97. package/dist/src/learning/requestNormalization.js +198 -0
  98. package/dist/src/learning/runtimeCapability.js +4677 -0
  99. package/dist/src/learning/runtimeCapability.test.js +3635 -0
  100. package/dist/src/learning/store.js +1240 -0
  101. package/dist/src/learning/store.test.js +1126 -0
  102. package/dist/src/learning/tutorAdapter.js +2 -0
  103. package/dist/src/learning/types.js +2 -0
  104. package/dist/src/learning/vectorAccelerationAdapter.js +942 -0
  105. package/dist/src/learning/vectorAccelerationAdapter.test.js +382 -0
  106. package/dist/src/lfs.asset.policy.contract.test.js +153 -0
  107. package/dist/src/license.policy.contract.test.js +66 -0
  108. package/dist/src/memory/MemoryGovernance.js +74 -0
  109. package/dist/src/memory/MemoryGovernance.test.js +46 -0
  110. package/dist/src/memory/types.js +2 -0
  111. package/dist/src/mermaid.frontend.guard.contract.test.js +77 -0
  112. package/dist/src/middleware/auth.js +17 -0
  113. package/dist/src/middleware/body-parser.js +45 -0
  114. package/dist/src/middleware/cors.js +44 -0
  115. package/dist/src/middleware/index.js +21 -0
  116. package/dist/src/middleware/request-trace.js +96 -0
  117. package/dist/src/notemd/AppConfigToml.js +6 -4
  118. package/dist/src/notemd/MermaidProcessor.js +400 -50
  119. package/dist/src/notemd/NotemdService.js +498 -13
  120. package/dist/src/notemd/PromptManager.js +15 -0
  121. package/dist/src/notemd/cli/commands.js +357 -0
  122. package/dist/src/notemd/cli/dispatcher.js +225 -0
  123. package/dist/src/notemd/cli/index.js +169 -0
  124. package/dist/src/notemd/cli/parser.js +68 -0
  125. package/dist/src/notemd/cli/types.js +2 -0
  126. package/dist/src/notemd/constants.js +43 -0
  127. package/dist/src/notemd/diagram/diagramGenerationService.js +78 -0
  128. package/dist/src/notemd/diagram/diagramSpec.js +79 -0
  129. package/dist/src/notemd/diagram/diagramSpecResponseParser.js +131 -0
  130. package/dist/src/notemd/diagram/intent.js +95 -0
  131. package/dist/src/notemd/diagram/planner.js +71 -0
  132. package/dist/src/notemd/diagram/prompts/diagramSpecPrompt.js +42 -0
  133. package/dist/src/notemd/diagram/types.js +18 -0
  134. package/dist/src/notemd/index.js +26 -0
  135. package/dist/src/notemd/operations/capabilityManifest.js +23 -0
  136. package/dist/src/notemd/operations/cliContracts.js +17 -0
  137. package/dist/src/notemd/operations/configProfileCommands.js +85 -0
  138. package/dist/src/notemd/operations/registry.contract.test.js +95 -0
  139. package/dist/src/notemd/operations/registry.js +991 -0
  140. package/dist/src/notemd/operations/types.js +2 -0
  141. package/dist/src/notemd/providerDiagnostics.js +220 -0
  142. package/dist/src/notemd/providerProfiles.js +42 -0
  143. package/dist/src/notemd/providerTemplates.js +231 -0
  144. package/dist/src/notemd/search/DuckDuckGoProvider.js +39 -0
  145. package/dist/src/notemd/search/SearchManager.js +13 -0
  146. package/dist/src/notemd/search/SearchProvider.js +2 -0
  147. package/dist/src/notemd/search/TavilyProvider.js +44 -0
  148. package/dist/src/notemd.agent.manifest.test.js +85 -0
  149. package/dist/src/notemd.api.contract.test.js +14 -1
  150. package/dist/src/notemd.app_config_toml.test.js +2 -1
  151. package/dist/src/notemd.batch.workflow.test.js +117 -0
  152. package/dist/src/notemd.cli.e2e.test.js +136 -0
  153. package/dist/src/notemd.core.test.js +51 -0
  154. package/dist/src/notemd.diagram.pipeline.test.js +233 -0
  155. package/dist/src/notemd.providerTemplates.test.js +34 -0
  156. package/dist/src/notemd.server.integration.test.js +143 -35
  157. package/dist/src/notemd.workflow.pipeline.test.js +162 -0
  158. package/dist/src/pathbridge.handshake.contract.test.js +16 -2
  159. package/dist/src/pathmode.background.contract.test.js +69 -0
  160. package/dist/src/pathmode.settings.api.contract.test.js +9 -0
  161. package/dist/src/pkg.sidecar.contract.test.js +9 -3
  162. package/dist/src/platform/ExportProfile.js +58 -0
  163. package/dist/src/platform/PlatformCapabilities.js +45 -0
  164. package/dist/src/platform/PlatformCapabilities.test.js +30 -0
  165. package/dist/src/platform/RenderMaterializer.js +33 -0
  166. package/dist/src/platform/RenderMaterializer.test.js +32 -0
  167. package/dist/src/query_backend.external_http.integration.test.js +410 -0
  168. package/dist/src/reader_renderer.js +404 -3
  169. package/dist/src/reader_renderer.test.js +87 -0
  170. package/dist/src/release.godot.mirror.contract.test.js +73 -0
  171. package/dist/src/resources/ResourceRegistry.js +223 -0
  172. package/dist/src/resources/ResourceRegistry.test.js +61 -0
  173. package/dist/src/resources/types.js +2 -0
  174. package/dist/src/routes/agentWorkspaceDiagnostics.js +173 -0
  175. package/dist/src/routes/data.js +267 -0
  176. package/dist/src/routes/diagnostics.js +51 -0
  177. package/dist/src/routes/index.js +23 -0
  178. package/dist/src/routes/knowledge.js +968 -0
  179. package/dist/src/routes/markdown.js +287 -0
  180. package/dist/src/routes/notemd.js +565 -0
  181. package/dist/src/routes/registry.contract.test.js +130 -0
  182. package/dist/src/routes/render.js +285 -0
  183. package/dist/src/routes/runtimeRunbookRouteOps.js +149 -0
  184. package/dist/src/routes/runtimeRunbookRouteOps.test.js +194 -0
  185. package/dist/src/routes/settings.js +6 -0
  186. package/dist/src/routes/staticFiles.js +94 -0
  187. package/dist/src/routes/types.js +2 -0
  188. package/dist/src/runtime.transport.adapter.contract.test.js +81 -0
  189. package/dist/src/server.js +11090 -1549
  190. package/dist/src/server.migration.test.js +193 -21
  191. package/dist/src/server.port.fallback.contract.test.js +63 -0
  192. package/dist/src/session/SessionStateStore.js +81 -0
  193. package/dist/src/session/SessionStateStore.test.js +58 -0
  194. package/dist/src/session/types.js +2 -0
  195. package/dist/src/settings.runtime.contract.test.js +50 -0
  196. package/dist/src/shared/types.contract.test.js +107 -0
  197. package/dist/src/shared/types.js +22 -0
  198. package/dist/src/sidecar.replacement.boundary.contract.test.js +128 -0
  199. package/dist/src/sidecar.supply.readiness.contract.test.js +144 -0
  200. package/dist/src/source_manager.loadflow.test.js +46 -0
  201. package/dist/src/startup.layout.snapshot.contract.test.js +57 -0
  202. package/dist/src/tauri.frontend.build.contract.test.js +60 -0
  203. package/dist/src/tauri.sidecar.cleanup.contract.test.js +21 -0
  204. package/dist/src/utils/RuntimePaths.js +4 -13
  205. package/dist/src/utils/platform.js +153 -0
  206. package/dist/src/workflows/WorkflowArtifactStore.js +96 -0
  207. package/dist/src/workflows/WorkflowArtifactStore.test.js +80 -0
  208. package/dist/src/workflows/types.js +2 -0
  209. package/dist/src/workspace/WorkspaceRegistry.js +122 -0
  210. package/dist/src/workspace/WorkspaceRegistry.test.js +29 -0
  211. package/dist/src/workspace/types.js +2 -0
  212. package/package.json +61 -10
@@ -8,6 +8,1559 @@ const graphSemanticA11yState = {
8
8
  pendingTimer: null
9
9
  };
10
10
 
11
+ function nowMs() {
12
+ return (typeof performance !== 'undefined' && typeof performance.now === 'function')
13
+ ? performance.now()
14
+ : Date.now();
15
+ }
16
+
17
+ function readStartupPerfProfileOverride() {
18
+ if (typeof localStorage === 'undefined') {
19
+ return '';
20
+ }
21
+
22
+ try {
23
+ const value = localStorage.getItem('nc.startupPerfProfile');
24
+ return typeof value === 'string' ? value.trim() : '';
25
+ } catch (_err) {
26
+ return '';
27
+ }
28
+ }
29
+
30
+ const mermaidErrorGuardState = {
31
+ observer: null,
32
+ toastHost: null,
33
+ seenAtBySignature: new Map(),
34
+ renderGuardInstalled: false,
35
+ periodicSweepHandle: null,
36
+ suppressedArtifacts: [],
37
+ };
38
+
39
+ function isMermaidErrorArtifactText(text) {
40
+ const normalized = String(text || '').toLowerCase();
41
+ if (!normalized) {
42
+ return false;
43
+ }
44
+ return (
45
+ normalized.includes('syntax error in text') ||
46
+ normalized.includes('lexical error on line') ||
47
+ normalized.includes('parse error on line') ||
48
+ normalized.includes('mermaid version') ||
49
+ normalized.includes('diagram syntax error')
50
+ );
51
+ }
52
+
53
+ function normalizeMermaidErrorArtifactText(text) {
54
+ return String(text || '')
55
+ .replace(/\s+/g, ' ')
56
+ .trim()
57
+ .slice(0, 240);
58
+ }
59
+
60
+ function registerMermaidSuppressedArtifact(detail, context = {}) {
61
+ const signature = normalizeMermaidErrorArtifactText(detail);
62
+ if (!signature) {
63
+ return;
64
+ }
65
+ const entry = {
66
+ signature,
67
+ detectedAt: new Date().toISOString(),
68
+ context,
69
+ };
70
+ mermaidErrorGuardState.suppressedArtifacts.push(entry);
71
+ if (mermaidErrorGuardState.suppressedArtifacts.length > 20) {
72
+ mermaidErrorGuardState.suppressedArtifacts.shift();
73
+ }
74
+ }
75
+
76
+ function ensureMermaidErrorToastHost() {
77
+ if (mermaidErrorGuardState.toastHost && mermaidErrorGuardState.toastHost.isConnected) {
78
+ return mermaidErrorGuardState.toastHost;
79
+ }
80
+ const host = document.createElement('div');
81
+ host.id = 'mermaid-error-toast-stack';
82
+ host.className = 'mermaid-error-toast-stack';
83
+ document.body.appendChild(host);
84
+ mermaidErrorGuardState.toastHost = host;
85
+ return host;
86
+ }
87
+
88
+ function emitMermaidErrorToast(detail) {
89
+ const signature = normalizeMermaidErrorArtifactText(detail);
90
+ if (!signature) {
91
+ return;
92
+ }
93
+ const now = Date.now();
94
+ const lastSeenAt = mermaidErrorGuardState.seenAtBySignature.get(signature) || 0;
95
+ if ((now - lastSeenAt) < 8000) {
96
+ return;
97
+ }
98
+ mermaidErrorGuardState.seenAtBySignature.set(signature, now);
99
+
100
+ const host = ensureMermaidErrorToastHost();
101
+ const toast = document.createElement('div');
102
+ toast.className = 'mermaid-error-toast';
103
+
104
+ const title = document.createElement('div');
105
+ title.className = 'mermaid-error-toast-title';
106
+ title.textContent = 'Mermaid diagram skipped';
107
+ toast.appendChild(title);
108
+
109
+ const summary = document.createElement('div');
110
+ summary.className = 'mermaid-error-toast-summary';
111
+ summary.textContent = 'A malformed Mermaid block was suppressed to keep the workspace readable.';
112
+ toast.appendChild(summary);
113
+
114
+ const detailLine = document.createElement('div');
115
+ detailLine.className = 'mermaid-error-toast-detail';
116
+ detailLine.textContent = signature;
117
+ toast.appendChild(detailLine);
118
+
119
+ host.appendChild(toast);
120
+ while (host.childElementCount > 3) {
121
+ host.removeChild(host.firstElementChild);
122
+ }
123
+
124
+ window.setTimeout(() => {
125
+ if (toast.parentNode) {
126
+ toast.parentNode.removeChild(toast);
127
+ }
128
+ }, 6500);
129
+ }
130
+
131
+ function createInlineMermaidErrorNotice(detail) {
132
+ const wrapper = document.createElement('div');
133
+ wrapper.className = 'mermaid-inline-guard';
134
+
135
+ const title = document.createElement('div');
136
+ title.className = 'mermaid-inline-guard-title';
137
+ title.textContent = 'Mermaid diagram unavailable';
138
+ wrapper.appendChild(title);
139
+
140
+ const summary = document.createElement('div');
141
+ summary.className = 'mermaid-inline-guard-summary';
142
+ summary.textContent = 'Malformed Mermaid content was suppressed in this view.';
143
+ wrapper.appendChild(summary);
144
+
145
+ if (detail) {
146
+ const detailLine = document.createElement('div');
147
+ detailLine.className = 'mermaid-inline-guard-detail';
148
+ detailLine.textContent = normalizeMermaidErrorArtifactText(detail);
149
+ wrapper.appendChild(detailLine);
150
+ }
151
+
152
+ return wrapper;
153
+ }
154
+
155
+ function resolveMermaidErrorArtifactHost(node) {
156
+ if (!node || typeof node.closest !== 'function') {
157
+ return node;
158
+ }
159
+ return (
160
+ node.closest('.mermaid-render-host-offscreen') ||
161
+ node.closest('.mermaid-render-failed') ||
162
+ node.closest('.mermaid') ||
163
+ node.closest('.reader-block') ||
164
+ node.closest('svg') ||
165
+ node
166
+ );
167
+ }
168
+
169
+ function isProtectedMermaidSuppressionHost(host) {
170
+ if (!host || host.nodeType !== Node.ELEMENT_NODE) {
171
+ return true;
172
+ }
173
+ return (
174
+ host === document.body ||
175
+ host === document.documentElement ||
176
+ host === document.head ||
177
+ host.id === 'graph-wrapper' ||
178
+ host.id === 'path-container' ||
179
+ host.id === 'reading-window' ||
180
+ host.id === 'reading-content-box' ||
181
+ host.id === 'reading-body'
182
+ );
183
+ }
184
+
185
+ function collectMermaidErrorArtifactCandidates(root) {
186
+ const hosts = [];
187
+ const hostSet = new Set();
188
+ const candidates = [];
189
+
190
+ if (!root) {
191
+ return hosts;
192
+ }
193
+ if (
194
+ root.nodeType === Node.ELEMENT_NODE &&
195
+ root !== document.body &&
196
+ root !== document.documentElement &&
197
+ root !== document.head
198
+ ) {
199
+ candidates.push(root);
200
+ }
201
+
202
+ const queryRoot = root.nodeType === Node.ELEMENT_NODE ? root : document.body;
203
+ if (queryRoot && typeof queryRoot.querySelectorAll === 'function') {
204
+ queryRoot.querySelectorAll('svg, .mermaid, div, section, article, aside, img, foreignObject').forEach((node) => candidates.push(node));
205
+ }
206
+
207
+ candidates.forEach((candidate) => {
208
+ if (!candidate || candidate.nodeType !== Node.ELEMENT_NODE) {
209
+ return;
210
+ }
211
+ const element = candidate;
212
+ const text = normalizeMermaidErrorArtifactText(
213
+ element.textContent ||
214
+ element.getAttribute?.('alt') ||
215
+ element.getAttribute?.('aria-label') ||
216
+ ''
217
+ );
218
+ if (!isMermaidErrorArtifactText(text)) {
219
+ return;
220
+ }
221
+ const host = resolveMermaidErrorArtifactHost(element);
222
+ if (!host || hostSet.has(host) || isProtectedMermaidSuppressionHost(host)) {
223
+ return;
224
+ }
225
+ hostSet.add(host);
226
+ hosts.push(host);
227
+ });
228
+
229
+ return hosts;
230
+ }
231
+
232
+ function suppressMermaidErrorArtifacts(root, context = {}) {
233
+ if (!root) {
234
+ return;
235
+ }
236
+
237
+ const candidates = collectMermaidErrorArtifactCandidates(root);
238
+ candidates.forEach((candidate) => {
239
+ if (!candidate || candidate.dataset?.mermaidErrorSuppressed === '1') {
240
+ return;
241
+ }
242
+ const text = normalizeMermaidErrorArtifactText(candidate.textContent || '');
243
+ if (!isMermaidErrorArtifactText(text)) {
244
+ return;
245
+ }
246
+
247
+ const host = resolveMermaidErrorArtifactHost(candidate);
248
+ if (!host || host.dataset?.mermaidErrorSuppressed === '1') {
249
+ return;
250
+ }
251
+ if (host.dataset) {
252
+ host.dataset.mermaidErrorSuppressed = '1';
253
+ }
254
+ registerMermaidSuppressedArtifact(text, {
255
+ hostTag: host.tagName || '',
256
+ hostId: host.id || '',
257
+ hostClass: host.className || '',
258
+ ...context,
259
+ });
260
+
261
+ const shouldKeepInline = Boolean(
262
+ host.closest('#reading-window') ||
263
+ host.closest('#reading-body') ||
264
+ host.closest('.notemd-embed-shell')
265
+ );
266
+
267
+ if (shouldKeepInline) {
268
+ const inlineNotice = createInlineMermaidErrorNotice(text);
269
+ if (host.parentNode) {
270
+ host.parentNode.replaceChild(inlineNotice, host);
271
+ }
272
+ } else {
273
+ emitMermaidErrorToast(text);
274
+ if (host.parentNode) {
275
+ host.parentNode.removeChild(host);
276
+ } else {
277
+ host.style.display = 'none';
278
+ }
279
+ }
280
+ });
281
+ }
282
+
283
+ function installMermaidRuntimeGuards() {
284
+ const mermaid = window.mermaid;
285
+ if (!mermaid || mermaidErrorGuardState.renderGuardInstalled === true || mermaid.__noteConnectionErrorGuardInstalled === true) {
286
+ return;
287
+ }
288
+
289
+ if (typeof mermaid.render === 'function') {
290
+ const originalRender = mermaid.render.bind(mermaid);
291
+ mermaid.render = async function(...args) {
292
+ const result = await originalRender(...args);
293
+ if (result && typeof result.svg === 'string' && isMermaidErrorArtifactText(result.svg)) {
294
+ registerMermaidSuppressedArtifact(result.svg, {
295
+ source: 'mermaid.render',
296
+ });
297
+ throw new Error('Suppressed Mermaid error SVG before it could be mounted.');
298
+ }
299
+ return result;
300
+ };
301
+ }
302
+
303
+ if (typeof mermaid.run === 'function') {
304
+ const originalRun = mermaid.run.bind(mermaid);
305
+ mermaid.run = async function(options) {
306
+ const result = await originalRun(options);
307
+ try {
308
+ if (options && Array.isArray(options.nodes) && options.nodes.length > 0) {
309
+ options.nodes.forEach((node) => suppressMermaidErrorArtifacts(node, {
310
+ source: 'mermaid.run.nodes',
311
+ }));
312
+ } else {
313
+ suppressMermaidErrorArtifacts(document.body, {
314
+ source: 'mermaid.run.document',
315
+ });
316
+ }
317
+ } catch (_error) {
318
+ // Guard should never break main rendering.
319
+ }
320
+ return result;
321
+ };
322
+ }
323
+
324
+ mermaid.__noteConnectionErrorGuardInstalled = true;
325
+ mermaidErrorGuardState.renderGuardInstalled = true;
326
+ }
327
+
328
+ function exposeMermaidDebugInterface() {
329
+ const existing = (window.__NC_DEBUG__ && typeof window.__NC_DEBUG__ === 'object')
330
+ ? window.__NC_DEBUG__
331
+ : {};
332
+ window.__NC_DEBUG__ = {
333
+ ...existing,
334
+ scanMermaidErrorArtifacts: () => collectMermaidErrorArtifactCandidates(document.body).map((host) => ({
335
+ tag: host.tagName || '',
336
+ id: host.id || '',
337
+ className: host.className || '',
338
+ text: normalizeMermaidErrorArtifactText(host.textContent || ''),
339
+ })),
340
+ suppressMermaidErrorArtifactsNow: () => {
341
+ suppressMermaidErrorArtifacts(document.body, { source: 'manual-debug-scan' });
342
+ return mermaidErrorGuardState.suppressedArtifacts.slice();
343
+ },
344
+ getMermaidGuardState: () => ({
345
+ renderGuardInstalled: mermaidErrorGuardState.renderGuardInstalled,
346
+ suppressedArtifacts: mermaidErrorGuardState.suppressedArtifacts.slice(),
347
+ }),
348
+ captureRuntimeState: () => ({
349
+ url: window.location.href,
350
+ title: document.title,
351
+ mermaidGuardState: {
352
+ renderGuardInstalled: mermaidErrorGuardState.renderGuardInstalled,
353
+ suppressedArtifacts: mermaidErrorGuardState.suppressedArtifacts.slice(),
354
+ },
355
+ activeMermaidErrors: collectMermaidErrorArtifactCandidates(document.body).map((host) => ({
356
+ tag: host.tagName || '',
357
+ id: host.id || '',
358
+ className: host.className || '',
359
+ text: normalizeMermaidErrorArtifactText(host.textContent || ''),
360
+ })),
361
+ }),
362
+ };
363
+ }
364
+
365
+ function installMermaidErrorArtifactObserver() {
366
+ installMermaidRuntimeGuards();
367
+ exposeMermaidDebugInterface();
368
+
369
+ if (mermaidErrorGuardState.observer || typeof MutationObserver === 'undefined') {
370
+ suppressMermaidErrorArtifacts(document.body, { source: 'observer-reuse' });
371
+ return;
372
+ }
373
+
374
+ mermaidErrorGuardState.observer = new MutationObserver((mutations) => {
375
+ mutations.forEach((mutation) => {
376
+ mutation.addedNodes.forEach((node) => {
377
+ if (!node || node.nodeType !== Node.ELEMENT_NODE) {
378
+ return;
379
+ }
380
+ suppressMermaidErrorArtifacts(node, { source: 'mutation-observer' });
381
+ });
382
+ });
383
+ });
384
+
385
+ mermaidErrorGuardState.observer.observe(document.body, {
386
+ childList: true,
387
+ subtree: true,
388
+ });
389
+
390
+ suppressMermaidErrorArtifacts(document.body, { source: 'observer-install' });
391
+ window.setTimeout(() => {
392
+ installMermaidRuntimeGuards();
393
+ suppressMermaidErrorArtifacts(document.body, { source: 'delayed-sweep-1200' });
394
+ }, 1200);
395
+ window.setTimeout(() => {
396
+ installMermaidRuntimeGuards();
397
+ suppressMermaidErrorArtifacts(document.body, { source: 'delayed-sweep-3200' });
398
+ }, 3200);
399
+ if (mermaidErrorGuardState.periodicSweepHandle === null) {
400
+ mermaidErrorGuardState.periodicSweepHandle = window.setInterval(() => {
401
+ installMermaidRuntimeGuards();
402
+ suppressMermaidErrorArtifacts(document.body, { source: 'periodic-sweep' });
403
+ }, 750);
404
+ }
405
+ }
406
+
407
+ installMermaidErrorArtifactObserver();
408
+
409
+ function resolveRuntimePlatform(runtimeCaps) {
410
+ const rawPlatform = runtimeCaps && typeof runtimeCaps.platform === 'string'
411
+ ? runtimeCaps.platform.trim().toLowerCase()
412
+ : '';
413
+
414
+ if (rawPlatform.includes('android')) {
415
+ return 'android';
416
+ }
417
+ if (rawPlatform.includes('ios')) {
418
+ return 'ios';
419
+ }
420
+ if (rawPlatform.includes('windows') || rawPlatform === 'win32') {
421
+ return 'windows';
422
+ }
423
+ if (rawPlatform.includes('macos') || rawPlatform.includes('darwin') || rawPlatform === 'mac') {
424
+ return 'macos';
425
+ }
426
+ if (rawPlatform.includes('linux')) {
427
+ return 'linux';
428
+ }
429
+ if (rawPlatform.includes('web')) {
430
+ return 'web';
431
+ }
432
+
433
+ if (typeof navigator !== 'undefined' && typeof navigator.userAgent === 'string') {
434
+ const ua = navigator.userAgent;
435
+ if (/Android/i.test(ua)) return 'android';
436
+ if (/iPhone|iPad|iPod/i.test(ua)) return 'ios';
437
+ if (/Windows/i.test(ua)) return 'windows';
438
+ if (/Macintosh|Mac OS X/i.test(ua)) return 'macos';
439
+ if (/Linux/i.test(ua)) return 'linux';
440
+ }
441
+
442
+ if (typeof window !== 'undefined' && window.Capacitor && typeof window.Capacitor.getPlatform === 'function') {
443
+ try {
444
+ const capPlatform = String(window.Capacitor.getPlatform() || '').toLowerCase();
445
+ if (capPlatform === 'android') return 'android';
446
+ if (capPlatform === 'ios') return 'ios';
447
+ if (capPlatform === 'web') return 'web';
448
+ } catch (_err) {
449
+ // Ignore and fall through.
450
+ }
451
+ }
452
+
453
+ return 'unknown';
454
+ }
455
+
456
+ function resolveStartupPerfProfile(runtimeCaps) {
457
+ const base = {
458
+ id: 'default',
459
+ pilotEnabled: false,
460
+ tickMaxFps: 0,
461
+ edgeGeometryDelayMs: 0,
462
+ edgeStartupWindowMs: 0,
463
+ edgeStartupSvgCap: 0,
464
+ edgeStage1TopK: 0,
465
+ stableAlphaThreshold: 0.05,
466
+ stableHoldTicks: 8,
467
+ stableTimeoutMs: 12000,
468
+ lowAlphaThreshold: 0.08,
469
+ lowAlphaTickMaxFps: 0,
470
+ deltaTickEnabled: false,
471
+ deltaEpsilonPx: 0.6,
472
+ deltaFullSyncEveryTicks: 3,
473
+ deltaLowAlphaEpsilonMultiplier: 1.35,
474
+ deltaLowAlphaFullSyncEveryTicks: 5,
475
+ startupOverlayEnabled: true,
476
+ overlaySafetyTimeoutMs: 30000,
477
+ overlayMinStars: 70,
478
+ overlayMaxStars: 180,
479
+ overlayStarDensity: 2400,
480
+ overlayDprCap: 2
481
+ };
482
+
483
+ const profileCatalog = {
484
+ desktop_windows_pilot: {
485
+ ...base,
486
+ id: 'desktop_windows_pilot',
487
+ pilotEnabled: true,
488
+ tickMaxFps: 26,
489
+ edgeGeometryDelayMs: 400,
490
+ edgeStartupWindowMs: 1500,
491
+ edgeStartupSvgCap: 18000,
492
+ edgeStage1TopK: 3500,
493
+ lowAlphaTickMaxFps: 12,
494
+ deltaTickEnabled: true,
495
+ deltaEpsilonPx: 0.75,
496
+ deltaFullSyncEveryTicks: 3,
497
+ deltaLowAlphaEpsilonMultiplier: 1.45,
498
+ deltaLowAlphaFullSyncEveryTicks: 5,
499
+ overlayMinStars: 90,
500
+ overlayMaxStars: 220,
501
+ overlayStarDensity: 2200
502
+ },
503
+ desktop_macos_pilot: {
504
+ ...base,
505
+ id: 'desktop_macos_pilot',
506
+ pilotEnabled: true,
507
+ tickMaxFps: 24,
508
+ edgeGeometryDelayMs: 430,
509
+ edgeStartupWindowMs: 1700,
510
+ edgeStartupSvgCap: 15000,
511
+ edgeStage1TopK: 3000,
512
+ lowAlphaTickMaxFps: 11,
513
+ deltaTickEnabled: true,
514
+ deltaEpsilonPx: 0.75,
515
+ deltaFullSyncEveryTicks: 3,
516
+ deltaLowAlphaEpsilonMultiplier: 1.45,
517
+ deltaLowAlphaFullSyncEveryTicks: 5,
518
+ overlayMinStars: 84,
519
+ overlayMaxStars: 200,
520
+ overlayStarDensity: 2400
521
+ },
522
+ desktop_linux_pilot: {
523
+ ...base,
524
+ id: 'desktop_linux_pilot',
525
+ pilotEnabled: true,
526
+ tickMaxFps: 24,
527
+ edgeGeometryDelayMs: 420,
528
+ edgeStartupWindowMs: 1600,
529
+ edgeStartupSvgCap: 16000,
530
+ edgeStage1TopK: 3200,
531
+ lowAlphaTickMaxFps: 11,
532
+ deltaTickEnabled: true,
533
+ deltaEpsilonPx: 0.75,
534
+ deltaFullSyncEveryTicks: 3,
535
+ deltaLowAlphaEpsilonMultiplier: 1.45,
536
+ deltaLowAlphaFullSyncEveryTicks: 5,
537
+ overlayMinStars: 86,
538
+ overlayMaxStars: 205,
539
+ overlayStarDensity: 2350
540
+ },
541
+ mobile_android_pilot: {
542
+ ...base,
543
+ id: 'mobile_android_pilot',
544
+ pilotEnabled: true,
545
+ tickMaxFps: 18,
546
+ edgeGeometryDelayMs: 560,
547
+ edgeStartupWindowMs: 2200,
548
+ edgeStartupSvgCap: 7000,
549
+ edgeStage1TopK: 1500,
550
+ stableHoldTicks: 10,
551
+ stableTimeoutMs: 18000,
552
+ lowAlphaThreshold: 0.1,
553
+ lowAlphaTickMaxFps: 8,
554
+ deltaTickEnabled: true,
555
+ deltaEpsilonPx: 0.95,
556
+ deltaFullSyncEveryTicks: 4,
557
+ deltaLowAlphaEpsilonMultiplier: 1.6,
558
+ deltaLowAlphaFullSyncEveryTicks: 6,
559
+ overlaySafetyTimeoutMs: 36000,
560
+ overlayMinStars: 52,
561
+ overlayMaxStars: 120,
562
+ overlayStarDensity: 3600,
563
+ overlayDprCap: 1.6
564
+ },
565
+ mobile_ios_pilot: {
566
+ ...base,
567
+ id: 'mobile_ios_pilot',
568
+ pilotEnabled: true,
569
+ tickMaxFps: 17,
570
+ edgeGeometryDelayMs: 600,
571
+ edgeStartupWindowMs: 2300,
572
+ edgeStartupSvgCap: 6200,
573
+ edgeStage1TopK: 1300,
574
+ stableHoldTicks: 10,
575
+ stableTimeoutMs: 19000,
576
+ lowAlphaThreshold: 0.1,
577
+ lowAlphaTickMaxFps: 8,
578
+ deltaTickEnabled: true,
579
+ deltaEpsilonPx: 0.95,
580
+ deltaFullSyncEveryTicks: 4,
581
+ deltaLowAlphaEpsilonMultiplier: 1.6,
582
+ deltaLowAlphaFullSyncEveryTicks: 6,
583
+ overlaySafetyTimeoutMs: 36000,
584
+ overlayMinStars: 48,
585
+ overlayMaxStars: 110,
586
+ overlayStarDensity: 3900,
587
+ overlayDprCap: 1.5
588
+ }
589
+ };
590
+
591
+ const detectedPlatform = resolveRuntimePlatform(runtimeCaps);
592
+ const override = readStartupPerfProfileOverride();
593
+ if (override === 'off') {
594
+ return {
595
+ ...base,
596
+ override: 'off',
597
+ detectedPlatform
598
+ };
599
+ }
600
+
601
+ if (override && Object.prototype.hasOwnProperty.call(profileCatalog, override)) {
602
+ return {
603
+ ...profileCatalog[override],
604
+ override: 'forced',
605
+ detectedPlatform
606
+ };
607
+ }
608
+
609
+ if (override && override !== 'auto') {
610
+ console.warn(`[Startup Perf] Unknown override profile "${override}", fallback to auto-detected platform profile.`);
611
+ }
612
+
613
+ switch (detectedPlatform) {
614
+ case 'windows':
615
+ return { ...profileCatalog.desktop_windows_pilot, override: '', detectedPlatform };
616
+ case 'macos':
617
+ return { ...profileCatalog.desktop_macos_pilot, override: '', detectedPlatform };
618
+ case 'linux':
619
+ return { ...profileCatalog.desktop_linux_pilot, override: '', detectedPlatform };
620
+ case 'android':
621
+ return { ...profileCatalog.mobile_android_pilot, override: '', detectedPlatform };
622
+ case 'ios':
623
+ return { ...profileCatalog.mobile_ios_pilot, override: '', detectedPlatform };
624
+ default:
625
+ return { ...base, override: '', detectedPlatform };
626
+ }
627
+ }
628
+
629
+ const startupRuntimeCaps = (typeof window !== 'undefined' && window.__NC_RUNTIME_CAPS)
630
+ ? window.__NC_RUNTIME_CAPS
631
+ : {};
632
+ const startupPerfProfile = resolveStartupPerfProfile(startupRuntimeCaps);
633
+ const startupPerfState = {
634
+ bootTs: nowMs(),
635
+ checkpoints: {},
636
+ t3Seen: false,
637
+ t4Seen: false,
638
+ t5Seen: false,
639
+ edgeDelayLogged: false,
640
+ edgeDelayReleasedLogged: false,
641
+ edgeCapLogged: false,
642
+ edgeCapReleasedLogged: false,
643
+ edgeStage1Logged: false,
644
+ edgeStage1ReleasedLogged: false,
645
+ tickModeSamples: {},
646
+ lastWorkerAlpha: null,
647
+ tickMessagesReceived: 0,
648
+ tickPayloadNodesTotal: 0,
649
+ tickPayloadNodesMax: 0,
650
+ tickFramesApplied: 0,
651
+ tickEmptyDeltaFramesSkipped: 0
652
+ };
653
+
654
+ const STARTUP_LAYOUT_SNAPSHOT_DB_NAME = 'noteconnection-startup';
655
+ const STARTUP_LAYOUT_SNAPSHOT_STORE_NAME = 'layoutSnapshots';
656
+ const STARTUP_LAYOUT_SNAPSHOT_LS_PREFIX = 'nc.startupLayoutSnapshot.';
657
+ const STARTUP_LAYOUT_SNAPSHOT_VERSION = 1;
658
+ const STARTUP_LAYOUT_SNAPSHOT_MAX_RECORDS = 6;
659
+ const STARTUP_LAYOUT_SNAPSHOT_MAX_AGE_MS = 1000 * 60 * 60 * 24 * 30;
660
+
661
+ const startupLayoutSnapshotState = {
662
+ fingerprint: '',
663
+ warmRestoreApplied: false,
664
+ pendingRecord: null,
665
+ restorePromise: null,
666
+ saveHandle: null,
667
+ lastSaveAtMs: 0,
668
+ sourceLayoutSummary: null,
669
+ sourceLayoutById: null
670
+ };
671
+
672
+ function hashFNV1a32(input) {
673
+ let hash = 0x811c9dc5;
674
+ const text = String(input || '');
675
+ for (let i = 0; i < text.length; i += 1) {
676
+ hash ^= text.charCodeAt(i);
677
+ hash += (hash << 1) + (hash << 4) + (hash << 7) + (hash << 8) + (hash << 24);
678
+ }
679
+ return (hash >>> 0).toString(16).padStart(8, '0');
680
+ }
681
+
682
+ function buildStartupGraphFingerprint(nodeList, linkList, graphPayload) {
683
+ const safeNodes = Array.isArray(nodeList) ? nodeList : [];
684
+ const safeLinks = Array.isArray(linkList) ? linkList : [];
685
+ const graphVersion = graphPayload && graphPayload.version !== undefined
686
+ ? String(graphPayload.version)
687
+ : 'v0';
688
+
689
+ const nodeStep = Math.max(1, Math.floor(safeNodes.length / 256));
690
+ const nodeTokens = [];
691
+ for (let index = 0; index < safeNodes.length && nodeTokens.length < 256; index += nodeStep) {
692
+ const item = safeNodes[index];
693
+ nodeTokens.push(item && item.id !== undefined ? String(item.id) : `idx:${index}`);
694
+ }
695
+
696
+ const linkStep = Math.max(1, Math.floor(safeLinks.length / 256));
697
+ const linkTokens = [];
698
+ for (let index = 0; index < safeLinks.length && linkTokens.length < 256; index += linkStep) {
699
+ const item = safeLinks[index];
700
+ const sourceId = item && item.source && typeof item.source === 'object' ? item.source.id : item && item.source;
701
+ const targetId = item && item.target && typeof item.target === 'object' ? item.target.id : item && item.target;
702
+ linkTokens.push(`${sourceId}->${targetId}`);
703
+ }
704
+
705
+ const signature = [
706
+ `version=${graphVersion}`,
707
+ `nodes=${safeNodes.length}`,
708
+ `links=${safeLinks.length}`,
709
+ `nodeSample=${nodeTokens.join('|')}`,
710
+ `linkSample=${linkTokens.join('|')}`
711
+ ].join('||');
712
+
713
+ return `nc-layout-v${STARTUP_LAYOUT_SNAPSHOT_VERSION}-${hashFNV1a32(signature)}`;
714
+ }
715
+
716
+ function summarizeLayoutPositions(positionItems) {
717
+ const safeItems = Array.isArray(positionItems) ? positionItems : [];
718
+ const finitePositions = safeItems.filter((item) => (
719
+ item &&
720
+ Number.isFinite(Number(item.x)) &&
721
+ Number.isFinite(Number(item.y))
722
+ ));
723
+
724
+ if (finitePositions.length === 0) {
725
+ return {
726
+ finiteCount: 0,
727
+ minX: 0,
728
+ maxX: 0,
729
+ minY: 0,
730
+ maxY: 0,
731
+ spanX: 0,
732
+ spanY: 0,
733
+ uniqueRatio: 0
734
+ };
735
+ }
736
+
737
+ let minX = Infinity;
738
+ let maxX = -Infinity;
739
+ let minY = Infinity;
740
+ let maxY = -Infinity;
741
+ const uniqueBuckets = new Set();
742
+
743
+ for (let index = 0; index < finitePositions.length; index += 1) {
744
+ const item = finitePositions[index];
745
+ const x = Number(item.x);
746
+ const y = Number(item.y);
747
+ if (x < minX) minX = x;
748
+ if (x > maxX) maxX = x;
749
+ if (y < minY) minY = y;
750
+ if (y > maxY) maxY = y;
751
+ uniqueBuckets.add(`${Math.round(x)}:${Math.round(y)}`);
752
+ }
753
+
754
+ return {
755
+ finiteCount: finitePositions.length,
756
+ minX,
757
+ maxX,
758
+ minY,
759
+ maxY,
760
+ spanX: maxX - minX,
761
+ spanY: maxY - minY,
762
+ uniqueRatio: uniqueBuckets.size / Math.max(1, finitePositions.length)
763
+ };
764
+ }
765
+
766
+ function isDegenerateLayoutSummary(summary) {
767
+ return Boolean(
768
+ summary &&
769
+ summary.finiteCount > 0 &&
770
+ ((summary.spanX < 48 && summary.spanY < 48) || summary.uniqueRatio < 0.12)
771
+ );
772
+ }
773
+
774
+ function isSnapshotLayoutCollapsedVsSource(summary, sourceSummary) {
775
+ if (!summary || !sourceSummary || sourceSummary.finiteCount < 10) {
776
+ return false;
777
+ }
778
+
779
+ const sourceWideX = sourceSummary.spanX >= 320;
780
+ const sourceWideY = sourceSummary.spanY >= 320;
781
+ if (!sourceWideX && !sourceWideY) {
782
+ return false;
783
+ }
784
+
785
+ const spanXTooSmall = sourceWideX && summary.spanX < Math.max(72, sourceSummary.spanX * 0.08);
786
+ const spanYTooSmall = sourceWideY && summary.spanY < Math.max(72, sourceSummary.spanY * 0.08);
787
+ return spanXTooSmall && spanYTooSmall;
788
+ }
789
+
790
+ function buildSourceLayoutById(nodeList) {
791
+ const map = new Map();
792
+ const safeNodes = Array.isArray(nodeList) ? nodeList : [];
793
+ for (let index = 0; index < safeNodes.length; index += 1) {
794
+ const node = safeNodes[index];
795
+ if (!node || node.id === undefined || node.id === null) {
796
+ continue;
797
+ }
798
+ const x = Number(node.x);
799
+ const y = Number(node.y);
800
+ if (!Number.isFinite(x) || !Number.isFinite(y)) {
801
+ continue;
802
+ }
803
+ map.set(node.id, { x, y });
804
+ }
805
+ return map;
806
+ }
807
+
808
+ function restoreSourceLayoutOrJitterNodes(nodeList, viewportWidth, viewportHeight, reason = '') {
809
+ const safeNodes = Array.isArray(nodeList) ? nodeList : [];
810
+ if (safeNodes.length === 0) {
811
+ return false;
812
+ }
813
+
814
+ const currentSummary = summarizeLayoutPositions(safeNodes);
815
+ const sourceSummary = startupLayoutSnapshotState.sourceLayoutSummary;
816
+ const sourceLayoutById = startupLayoutSnapshotState.sourceLayoutById;
817
+ const shouldRestoreSource = Boolean(
818
+ sourceLayoutById instanceof Map &&
819
+ sourceLayoutById.size > 0 &&
820
+ isSnapshotLayoutCollapsedVsSource(currentSummary, sourceSummary)
821
+ );
822
+
823
+ if (!isDegenerateLayoutSummary(currentSummary) && !shouldRestoreSource) {
824
+ return false;
825
+ }
826
+
827
+ if (shouldRestoreSource) {
828
+ let restoredCount = 0;
829
+ for (let index = 0; index < safeNodes.length; index += 1) {
830
+ const node = safeNodes[index];
831
+ const sourcePos = sourceLayoutById.get(node.id);
832
+ if (!sourcePos) {
833
+ continue;
834
+ }
835
+ node.x = sourcePos.x;
836
+ node.y = sourcePos.y;
837
+ node.fx = null;
838
+ node.fy = null;
839
+ if (currentPositions && typeof currentPositions.set === 'function') {
840
+ currentPositions.set(node.id, { x: node.x, y: node.y });
841
+ }
842
+ restoredCount += 1;
843
+ }
844
+ console.warn('[Startup Warm Snapshot] Restored source layout because active positions collapsed relative to source.', {
845
+ reason,
846
+ restoredCount,
847
+ currentSpanX: Number(currentSummary.spanX.toFixed(2)),
848
+ currentSpanY: Number(currentSummary.spanY.toFixed(2)),
849
+ sourceSpanX: sourceSummary ? Number(sourceSummary.spanX.toFixed(2)) : null,
850
+ sourceSpanY: sourceSummary ? Number(sourceSummary.spanY.toFixed(2)) : null
851
+ });
852
+ return restoredCount > 0;
853
+ }
854
+
855
+ const centerX = Number.isFinite(Number(viewportWidth)) && Number(viewportWidth) > 0
856
+ ? Number(viewportWidth) / 2
857
+ : 400;
858
+ const centerY = Number.isFinite(Number(viewportHeight)) && Number(viewportHeight) > 0
859
+ ? Number(viewportHeight) / 2
860
+ : 300;
861
+ const goldenAngle = Math.PI * (3 - Math.sqrt(5));
862
+
863
+ for (let index = 0; index < safeNodes.length; index += 1) {
864
+ const node = safeNodes[index];
865
+ const rankOffset = Number.isFinite(Number(node.rank)) ? Number(node.rank) * 4 : 0;
866
+ const radius = 28 + (Math.sqrt(index + 1) * 18) + rankOffset;
867
+ const angle = (index * goldenAngle) + (rankOffset * 0.017);
868
+ node.x = centerX + (Math.cos(angle) * radius);
869
+ node.y = centerY + (Math.sin(angle) * radius);
870
+ node.fx = null;
871
+ node.fy = null;
872
+ if (currentPositions && typeof currentPositions.set === 'function') {
873
+ currentPositions.set(node.id, { x: node.x, y: node.y });
874
+ }
875
+ }
876
+
877
+ console.warn('[Startup Warm Snapshot] Re-seeded degenerate initial layout before simulation bootstrap.', {
878
+ reason,
879
+ nodeCount: safeNodes.length,
880
+ spanX: Number(currentSummary.spanX.toFixed(2)),
881
+ spanY: Number(currentSummary.spanY.toFixed(2)),
882
+ uniqueRatio: Number(currentSummary.uniqueRatio.toFixed(4))
883
+ });
884
+ return true;
885
+ }
886
+
887
+ function openStartupLayoutSnapshotDb() {
888
+ return new Promise((resolve, reject) => {
889
+ if (typeof window === 'undefined' || !window.indexedDB) {
890
+ resolve(null);
891
+ return;
892
+ }
893
+
894
+ let request;
895
+ try {
896
+ request = window.indexedDB.open(STARTUP_LAYOUT_SNAPSHOT_DB_NAME, STARTUP_LAYOUT_SNAPSHOT_VERSION);
897
+ } catch (error) {
898
+ reject(error);
899
+ return;
900
+ }
901
+
902
+ request.onupgradeneeded = () => {
903
+ const db = request.result;
904
+ if (!db.objectStoreNames.contains(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME)) {
905
+ const store = db.createObjectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, { keyPath: 'fingerprint' });
906
+ store.createIndex('savedAt', 'savedAt');
907
+ }
908
+ };
909
+
910
+ request.onsuccess = () => resolve(request.result);
911
+ request.onerror = () => reject(request.error || new Error('indexedDB open failed'));
912
+ });
913
+ }
914
+
915
+ function loadStartupLayoutSnapshotFromLocalStorage(fingerprint) {
916
+ if (!fingerprint || typeof localStorage === 'undefined') {
917
+ return null;
918
+ }
919
+ const key = `${STARTUP_LAYOUT_SNAPSHOT_LS_PREFIX}${fingerprint}`;
920
+ try {
921
+ const raw = localStorage.getItem(key);
922
+ if (!raw) {
923
+ return null;
924
+ }
925
+ const parsed = JSON.parse(raw);
926
+ return parsed && typeof parsed === 'object' ? parsed : null;
927
+ } catch (_err) {
928
+ return null;
929
+ }
930
+ }
931
+
932
+ function saveStartupLayoutSnapshotToLocalStorage(record) {
933
+ if (!record || !record.fingerprint || typeof localStorage === 'undefined') {
934
+ return;
935
+ }
936
+ try {
937
+ const key = `${STARTUP_LAYOUT_SNAPSHOT_LS_PREFIX}${record.fingerprint}`;
938
+ localStorage.setItem(key, JSON.stringify(record));
939
+ } catch (_err) {
940
+ // Ignore quota failures.
941
+ }
942
+ }
943
+
944
+ async function loadStartupLayoutSnapshotRecord(fingerprint) {
945
+ if (!fingerprint) {
946
+ return null;
947
+ }
948
+
949
+ const db = await openStartupLayoutSnapshotDb();
950
+ if (!db) {
951
+ return loadStartupLayoutSnapshotFromLocalStorage(fingerprint);
952
+ }
953
+
954
+ return new Promise((resolve, reject) => {
955
+ const tx = db.transaction(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, 'readonly');
956
+ const store = tx.objectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME);
957
+ const req = store.get(fingerprint);
958
+ req.onsuccess = () => resolve(req.result || null);
959
+ req.onerror = () => reject(req.error || new Error('indexedDB read failed'));
960
+ tx.oncomplete = () => db.close();
961
+ tx.onerror = () => db.close();
962
+ tx.onabort = () => db.close();
963
+ });
964
+ }
965
+
966
+ async function persistStartupLayoutSnapshotRecord(record) {
967
+ if (!record || !record.fingerprint) {
968
+ return;
969
+ }
970
+
971
+ const db = await openStartupLayoutSnapshotDb();
972
+ if (!db) {
973
+ saveStartupLayoutSnapshotToLocalStorage(record);
974
+ return;
975
+ }
976
+
977
+ await new Promise((resolve, reject) => {
978
+ const tx = db.transaction(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, 'readwrite');
979
+ const store = tx.objectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME);
980
+ store.put(record);
981
+ tx.oncomplete = () => resolve();
982
+ tx.onerror = () => reject(tx.error || new Error('indexedDB write failed'));
983
+ tx.onabort = () => reject(tx.error || new Error('indexedDB write aborted'));
984
+ });
985
+
986
+ // Keep snapshot store bounded to avoid unbounded growth.
987
+ await new Promise((resolve, reject) => {
988
+ const tx = db.transaction(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, 'readwrite');
989
+ const store = tx.objectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME);
990
+ const req = store.getAll();
991
+ req.onsuccess = () => {
992
+ const now = Date.now();
993
+ const allRecords = Array.isArray(req.result) ? req.result : [];
994
+ allRecords.sort((a, b) => Number(b.savedAt || 0) - Number(a.savedAt || 0));
995
+ for (let i = 0; i < allRecords.length; i += 1) {
996
+ const item = allRecords[i];
997
+ const isExpired = Number.isFinite(item.savedAt) && (now - item.savedAt) > STARTUP_LAYOUT_SNAPSHOT_MAX_AGE_MS;
998
+ const overflow = i >= STARTUP_LAYOUT_SNAPSHOT_MAX_RECORDS;
999
+ if (isExpired || overflow) {
1000
+ store.delete(item.fingerprint);
1001
+ }
1002
+ }
1003
+ };
1004
+ tx.oncomplete = () => resolve();
1005
+ tx.onerror = () => reject(tx.error || new Error('indexedDB cleanup failed'));
1006
+ tx.onabort = () => reject(tx.error || new Error('indexedDB cleanup aborted'));
1007
+ });
1008
+
1009
+ db.close();
1010
+ }
1011
+
1012
+ async function deleteStartupLayoutSnapshotRecord(fingerprint) {
1013
+ if (!fingerprint) {
1014
+ return;
1015
+ }
1016
+
1017
+ if (typeof localStorage !== 'undefined') {
1018
+ try {
1019
+ localStorage.removeItem(`${STARTUP_LAYOUT_SNAPSHOT_LS_PREFIX}${fingerprint}`);
1020
+ } catch (_err) {
1021
+ // Ignore localStorage cleanup failures.
1022
+ }
1023
+ }
1024
+
1025
+ const db = await openStartupLayoutSnapshotDb();
1026
+ if (!db) {
1027
+ return;
1028
+ }
1029
+
1030
+ await new Promise((resolve, reject) => {
1031
+ const tx = db.transaction(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME, 'readwrite');
1032
+ const store = tx.objectStore(STARTUP_LAYOUT_SNAPSHOT_STORE_NAME);
1033
+ store.delete(fingerprint);
1034
+ tx.oncomplete = () => resolve();
1035
+ tx.onerror = () => reject(tx.error || new Error('indexedDB delete failed'));
1036
+ tx.onabort = () => reject(tx.error || new Error('indexedDB delete aborted'));
1037
+ });
1038
+
1039
+ db.close();
1040
+ }
1041
+
1042
+ function collectStartupLayoutSnapshotRecord(reason = '') {
1043
+ if (!startupLayoutSnapshotState.fingerprint || !Array.isArray(nodes) || nodes.length === 0) {
1044
+ return null;
1045
+ }
1046
+
1047
+ const positions = new Array(nodes.length);
1048
+ for (let index = 0; index < nodes.length; index += 1) {
1049
+ const n = nodes[index];
1050
+ positions[index] = {
1051
+ id: n.id,
1052
+ x: Number.isFinite(Number(n.x)) ? Number(n.x) : 0,
1053
+ y: Number.isFinite(Number(n.y)) ? Number(n.y) : 0,
1054
+ fx: null,
1055
+ fy: null
1056
+ };
1057
+ }
1058
+
1059
+ return {
1060
+ fingerprint: startupLayoutSnapshotState.fingerprint,
1061
+ savedAt: Date.now(),
1062
+ reason: String(reason || ''),
1063
+ profileId: startupPerfProfile.id,
1064
+ nodeCount: nodes.length,
1065
+ edgeCount: Array.isArray(links) ? links.length : 0,
1066
+ positions
1067
+ };
1068
+ }
1069
+
1070
+ function validateStartupLayoutSnapshotRecord(record) {
1071
+ if (!record || !Array.isArray(record.positions) || record.positions.length === 0) {
1072
+ return { ok: false, reason: 'empty-positions' };
1073
+ }
1074
+
1075
+ if (startupLayoutSnapshotState.fingerprint && record.fingerprint && record.fingerprint !== startupLayoutSnapshotState.fingerprint) {
1076
+ return { ok: false, reason: 'fingerprint-mismatch' };
1077
+ }
1078
+
1079
+ const now = Date.now();
1080
+ if (Number.isFinite(record.savedAt) && (now - record.savedAt) > STARTUP_LAYOUT_SNAPSHOT_MAX_AGE_MS) {
1081
+ return { ok: false, reason: 'snapshot-expired' };
1082
+ }
1083
+
1084
+ const expectedNodeCount = Array.isArray(nodes) ? nodes.length : 0;
1085
+ const expectedEdgeCount = Array.isArray(links) ? links.length : 0;
1086
+
1087
+ if (Number.isFinite(record.nodeCount) && expectedNodeCount > 0 && record.nodeCount !== expectedNodeCount) {
1088
+ return { ok: false, reason: 'node-count-mismatch' };
1089
+ }
1090
+ if (Number.isFinite(record.edgeCount) && expectedEdgeCount > 0 && record.edgeCount !== expectedEdgeCount) {
1091
+ return { ok: false, reason: 'edge-count-mismatch' };
1092
+ }
1093
+
1094
+ const positionCount = record.positions.length;
1095
+ const coverage = expectedNodeCount > 0 ? (positionCount / expectedNodeCount) : 0;
1096
+ if (expectedNodeCount > 0 && coverage < 0.9) {
1097
+ return { ok: false, reason: 'position-coverage-low', coverage: Number(coverage.toFixed(4)) };
1098
+ }
1099
+
1100
+ const positionSummary = summarizeLayoutPositions(record.positions);
1101
+ if (expectedNodeCount >= 10 && positionSummary.finiteCount > 0) {
1102
+ if (isDegenerateLayoutSummary(positionSummary)) {
1103
+ return {
1104
+ ok: false,
1105
+ reason: 'degenerate-layout',
1106
+ purge: true,
1107
+ spanX: Number(positionSummary.spanX.toFixed(2)),
1108
+ spanY: Number(positionSummary.spanY.toFixed(2)),
1109
+ uniqueRatio: Number(positionSummary.uniqueRatio.toFixed(4)),
1110
+ };
1111
+ }
1112
+
1113
+ if (isSnapshotLayoutCollapsedVsSource(positionSummary, startupLayoutSnapshotState.sourceLayoutSummary)) {
1114
+ return {
1115
+ ok: false,
1116
+ reason: 'degenerate-layout-vs-source',
1117
+ purge: true,
1118
+ spanX: Number(positionSummary.spanX.toFixed(2)),
1119
+ spanY: Number(positionSummary.spanY.toFixed(2)),
1120
+ uniqueRatio: Number(positionSummary.uniqueRatio.toFixed(4)),
1121
+ sourceSpanX: startupLayoutSnapshotState.sourceLayoutSummary
1122
+ ? Number(startupLayoutSnapshotState.sourceLayoutSummary.spanX.toFixed(2))
1123
+ : null,
1124
+ sourceSpanY: startupLayoutSnapshotState.sourceLayoutSummary
1125
+ ? Number(startupLayoutSnapshotState.sourceLayoutSummary.spanY.toFixed(2))
1126
+ : null
1127
+ };
1128
+ }
1129
+ }
1130
+
1131
+ return {
1132
+ ok: true,
1133
+ coverage: Number(coverage.toFixed(4)),
1134
+ positionCount,
1135
+ expectedNodeCount,
1136
+ expectedEdgeCount
1137
+ };
1138
+ }
1139
+
1140
+ function applyStartupLayoutSnapshotRecord(record) {
1141
+ if (!record || !Array.isArray(record.positions) || record.positions.length === 0 || !nodeMap) {
1142
+ return { appliedCount: 0, total: 0 };
1143
+ }
1144
+ const layoutById = new Map(record.positions.map((item) => [item.id, item]));
1145
+ let appliedCount = 0;
1146
+
1147
+ for (let index = 0; index < nodes.length; index += 1) {
1148
+ const n = nodes[index];
1149
+ const saved = layoutById.get(n.id);
1150
+ if (!saved) {
1151
+ continue;
1152
+ }
1153
+ n.x = Number.isFinite(saved.x) ? saved.x : n.x;
1154
+ n.y = Number.isFinite(saved.y) ? saved.y : n.y;
1155
+ n.fx = null;
1156
+ n.fy = null;
1157
+ if (currentPositions && typeof currentPositions.set === 'function') {
1158
+ currentPositions.set(n.id, { x: n.x, y: n.y });
1159
+ }
1160
+ appliedCount += 1;
1161
+ }
1162
+
1163
+ return { appliedCount, total: nodes.length };
1164
+ }
1165
+
1166
+ function maybeApplyStartupWarmSnapshot(trigger = '') {
1167
+ if (startupLayoutSnapshotState.warmRestoreApplied) {
1168
+ return false;
1169
+ }
1170
+ const record = startupLayoutSnapshotState.pendingRecord;
1171
+ if (!record || !simulationWorker) {
1172
+ return false;
1173
+ }
1174
+
1175
+ const validation = validateStartupLayoutSnapshotRecord(record);
1176
+ if (!validation.ok) {
1177
+ console.warn('[Startup Warm Snapshot] Skip applying invalid snapshot record.', {
1178
+ trigger,
1179
+ reason: validation.reason,
1180
+ fingerprint: startupLayoutSnapshotState.fingerprint,
1181
+ recordFingerprint: record.fingerprint || null,
1182
+ recordNodeCount: record.nodeCount || 0,
1183
+ recordEdgeCount: record.edgeCount || 0,
1184
+ positionCount: Array.isArray(record.positions) ? record.positions.length : 0,
1185
+ spanX: validation.spanX,
1186
+ spanY: validation.spanY,
1187
+ uniqueRatio: validation.uniqueRatio,
1188
+ });
1189
+ if (validation.purge === true && record.fingerprint) {
1190
+ deleteStartupLayoutSnapshotRecord(record.fingerprint).catch((error) => {
1191
+ console.warn('[Startup Warm Snapshot] Failed to purge invalid snapshot record:', error && error.message ? error.message : String(error));
1192
+ });
1193
+ }
1194
+ startupLayoutSnapshotState.pendingRecord = null;
1195
+ return false;
1196
+ }
1197
+
1198
+ const result = applyStartupLayoutSnapshotRecord(record);
1199
+ if (result.appliedCount <= 0) {
1200
+ return false;
1201
+ }
1202
+ startupLayoutSnapshotState.warmRestoreApplied = true;
1203
+
1204
+ console.log('[Startup Warm Snapshot] Applied persisted layout snapshot.', {
1205
+ trigger,
1206
+ fingerprint: startupLayoutSnapshotState.fingerprint,
1207
+ appliedCount: result.appliedCount,
1208
+ totalNodes: result.total,
1209
+ savedAt: record.savedAt || null,
1210
+ coverage: validation.coverage
1211
+ });
1212
+
1213
+ if (typeof ticked === 'function') {
1214
+ ticked();
1215
+ }
1216
+
1217
+ const syncNodes = nodes.map((n) => ({
1218
+ id: n.id,
1219
+ x: Number.isFinite(Number(n.x)) ? Number(n.x) : 0,
1220
+ y: Number.isFinite(Number(n.y)) ? Number(n.y) : 0,
1221
+ fx: Number.isFinite(Number(n.fx)) ? Number(n.fx) : null,
1222
+ fy: Number.isFinite(Number(n.fy)) ? Number(n.fy) : null,
1223
+ rank: n.rank
1224
+ }));
1225
+ const syncLinks = physicsLinks.map((l) => ({
1226
+ source: l.source && typeof l.source === 'object' ? l.source.id : l.source,
1227
+ target: l.target && typeof l.target === 'object' ? l.target.id : l.target
1228
+ }));
1229
+ simulationWorker.postMessage({
1230
+ type: 'setNodes',
1231
+ payload: {
1232
+ nodes: syncNodes,
1233
+ links: syncLinks,
1234
+ restart: true
1235
+ }
1236
+ });
1237
+ simulationWorker.postMessage({
1238
+ type: 'updateParams',
1239
+ payload: {
1240
+ alpha: 0.18,
1241
+ velocityDecay: 0.92,
1242
+ restart: true
1243
+ }
1244
+ });
1245
+
1246
+ return true;
1247
+ }
1248
+
1249
+ function scheduleStartupLayoutSnapshotPersist(reason = '', delayMs = 800) {
1250
+ if (startupPerfProfile.pilotEnabled !== true) {
1251
+ return;
1252
+ }
1253
+ if (!startupLayoutSnapshotState.fingerprint) {
1254
+ return;
1255
+ }
1256
+
1257
+ if (startupLayoutSnapshotState.saveHandle !== null) {
1258
+ clearTimeout(startupLayoutSnapshotState.saveHandle);
1259
+ startupLayoutSnapshotState.saveHandle = null;
1260
+ }
1261
+
1262
+ startupLayoutSnapshotState.saveHandle = setTimeout(async () => {
1263
+ startupLayoutSnapshotState.saveHandle = null;
1264
+ const record = collectStartupLayoutSnapshotRecord(reason);
1265
+ if (!record) {
1266
+ return;
1267
+ }
1268
+ try {
1269
+ await persistStartupLayoutSnapshotRecord(record);
1270
+ startupLayoutSnapshotState.lastSaveAtMs = record.savedAt;
1271
+ console.log('[Startup Warm Snapshot] Persisted layout snapshot.', {
1272
+ fingerprint: record.fingerprint,
1273
+ reason: record.reason,
1274
+ nodeCount: record.nodeCount
1275
+ });
1276
+ } catch (error) {
1277
+ console.warn('[Startup Warm Snapshot] Persist failed:', error && error.message ? error.message : String(error));
1278
+ }
1279
+ }, Math.max(0, Math.floor(delayMs)));
1280
+ }
1281
+
1282
+ let startupWorldOverlayState = null;
1283
+
1284
+ function createStartupWorldOverlay() {
1285
+ if (startupPerfProfile.startupOverlayEnabled === false) {
1286
+ return null;
1287
+ }
1288
+
1289
+ const wrapper = document.getElementById('graph-wrapper');
1290
+ if (!wrapper) {
1291
+ return null;
1292
+ }
1293
+
1294
+ const existing = document.getElementById('startup-world-overlay');
1295
+ if (existing) {
1296
+ return null;
1297
+ }
1298
+
1299
+ const overlay = document.createElement('div');
1300
+ overlay.id = 'startup-world-overlay';
1301
+ overlay.className = 'startup-world-overlay';
1302
+ overlay.innerHTML = `
1303
+ <div class="startup-world-card" role="status" aria-live="polite">
1304
+ <div class="startup-world-title">等待世界构建</div>
1305
+ <div class="startup-world-subtitle">星辰正在连接知识节点...</div>
1306
+ <canvas class="startup-world-canvas" aria-label="Startup Starfield"></canvas>
1307
+ <div class="startup-world-hint">点击星辰可点暗</div>
1308
+ </div>
1309
+ `;
1310
+ wrapper.appendChild(overlay);
1311
+
1312
+ const card = overlay.querySelector('.startup-world-card');
1313
+ const canvas = overlay.querySelector('.startup-world-canvas');
1314
+ if (!card || !canvas) {
1315
+ overlay.remove();
1316
+ return null;
1317
+ }
1318
+
1319
+ const ctx = canvas.getContext('2d');
1320
+ if (!ctx) {
1321
+ overlay.remove();
1322
+ return null;
1323
+ }
1324
+
1325
+ const state = {
1326
+ overlay,
1327
+ card,
1328
+ canvas,
1329
+ ctx,
1330
+ stars: [],
1331
+ running: true,
1332
+ frameHandle: null,
1333
+ resizeObserver: null,
1334
+ resizeHandler: null,
1335
+ width: 0,
1336
+ height: 0,
1337
+ dpr: 1,
1338
+ leaveHandle: null,
1339
+ fallbackHandle: null,
1340
+ hidden: false,
1341
+ reducedMotion: false
1342
+ };
1343
+
1344
+ if (typeof window.matchMedia === 'function') {
1345
+ try {
1346
+ state.reducedMotion = window.matchMedia('(prefers-reduced-motion: reduce)').matches;
1347
+ } catch (_err) {
1348
+ state.reducedMotion = false;
1349
+ }
1350
+ }
1351
+
1352
+ function buildStars() {
1353
+ const minStars = Number.isFinite(startupPerfProfile.overlayMinStars)
1354
+ ? Math.max(20, Math.floor(startupPerfProfile.overlayMinStars))
1355
+ : 70;
1356
+ const maxStars = Number.isFinite(startupPerfProfile.overlayMaxStars)
1357
+ ? Math.max(minStars, Math.floor(startupPerfProfile.overlayMaxStars))
1358
+ : 180;
1359
+ const densityDivisor = Number.isFinite(startupPerfProfile.overlayStarDensity)
1360
+ ? Math.max(800, Math.floor(startupPerfProfile.overlayStarDensity))
1361
+ : 2400;
1362
+ let starCount = Math.min(maxStars, Math.max(minStars, Math.round((state.width * state.height) / densityDivisor)));
1363
+ if (state.reducedMotion) {
1364
+ starCount = Math.max(24, Math.round(starCount * 0.7));
1365
+ }
1366
+ state.stars = new Array(starCount);
1367
+ const twinkleScale = state.reducedMotion ? 0.45 : 1;
1368
+ for (let index = 0; index < starCount; index += 1) {
1369
+ state.stars[index] = {
1370
+ x: Math.random() * state.width,
1371
+ y: Math.random() * state.height,
1372
+ radius: 0.7 + Math.random() * 2.4,
1373
+ hue: 195 + Math.random() * 35,
1374
+ lightness: 75 + Math.random() * 20,
1375
+ baseAlpha: 0.3 + Math.random() * 0.55,
1376
+ twinkleSpeed: (0.8 + Math.random() * 2.4) * twinkleScale,
1377
+ phase: Math.random() * Math.PI * 2,
1378
+ dimTarget: 0,
1379
+ dimValue: 0
1380
+ };
1381
+ }
1382
+ }
1383
+
1384
+ function resizeCanvas() {
1385
+ const rect = state.canvas.getBoundingClientRect();
1386
+ state.width = Math.max(1, Math.floor(rect.width));
1387
+ state.height = Math.max(1, Math.floor(rect.height));
1388
+ const dprCap = Number.isFinite(startupPerfProfile.overlayDprCap)
1389
+ ? Math.max(1, startupPerfProfile.overlayDprCap)
1390
+ : 2;
1391
+ state.dpr = Math.min(dprCap, Math.max(1, window.devicePixelRatio || 1));
1392
+ state.canvas.width = Math.floor(state.width * state.dpr);
1393
+ state.canvas.height = Math.floor(state.height * state.dpr);
1394
+ state.ctx.setTransform(state.dpr, 0, 0, state.dpr, 0, 0);
1395
+ buildStars();
1396
+ }
1397
+
1398
+ function drawFrame(frameTimeMs) {
1399
+ if (!state.running) {
1400
+ return;
1401
+ }
1402
+
1403
+ const t = frameTimeMs * 0.001;
1404
+ const renderCtx = state.ctx;
1405
+ renderCtx.clearRect(0, 0, state.width, state.height);
1406
+
1407
+ const nebulaA = renderCtx.createRadialGradient(
1408
+ state.width * 0.28,
1409
+ state.height * 0.28,
1410
+ 8,
1411
+ state.width * 0.28,
1412
+ state.height * 0.28,
1413
+ state.width * 0.85
1414
+ );
1415
+ nebulaA.addColorStop(0, 'rgba(108, 149, 255, 0.22)');
1416
+ nebulaA.addColorStop(1, 'rgba(108, 149, 255, 0)');
1417
+ renderCtx.fillStyle = nebulaA;
1418
+ renderCtx.fillRect(0, 0, state.width, state.height);
1419
+
1420
+ const nebulaB = renderCtx.createRadialGradient(
1421
+ state.width * 0.78,
1422
+ state.height * 0.72,
1423
+ 12,
1424
+ state.width * 0.78,
1425
+ state.height * 0.72,
1426
+ state.width * 0.7
1427
+ );
1428
+ nebulaB.addColorStop(0, 'rgba(165, 126, 255, 0.18)');
1429
+ nebulaB.addColorStop(1, 'rgba(165, 126, 255, 0)');
1430
+ renderCtx.fillStyle = nebulaB;
1431
+ renderCtx.fillRect(0, 0, state.width, state.height);
1432
+
1433
+ for (let index = 0; index < state.stars.length; index += 1) {
1434
+ const star = state.stars[index];
1435
+ star.dimValue += (star.dimTarget - star.dimValue) * 0.12;
1436
+
1437
+ const twinkle = state.reducedMotion
1438
+ ? 0.82
1439
+ : (0.55 + 0.45 * (0.5 + 0.5 * Math.sin(t * star.twinkleSpeed + star.phase)));
1440
+ const alpha = Math.max(0.02, star.baseAlpha * twinkle * (1 - 0.9 * star.dimValue));
1441
+ const radius = star.radius * (0.9 + twinkle * 0.3);
1442
+
1443
+ renderCtx.beginPath();
1444
+ renderCtx.arc(star.x, star.y, radius, 0, Math.PI * 2);
1445
+ renderCtx.fillStyle = `hsla(${star.hue}, 92%, ${star.lightness}%, ${alpha})`;
1446
+ renderCtx.shadowBlur = state.reducedMotion ? (5 + star.radius * 3) : (9 + star.radius * 6);
1447
+ renderCtx.shadowColor = `hsla(${star.hue}, 95%, ${Math.min(98, star.lightness + 5)}%, ${Math.min(0.85, alpha + 0.12)})`;
1448
+ renderCtx.fill();
1449
+ renderCtx.shadowBlur = 0;
1450
+ }
1451
+
1452
+ state.frameHandle = window.requestAnimationFrame(drawFrame);
1453
+ }
1454
+
1455
+ function dimNearestStar(event) {
1456
+ const rect = state.canvas.getBoundingClientRect();
1457
+ const x = event.clientX - rect.left;
1458
+ const y = event.clientY - rect.top;
1459
+
1460
+ let nearestStar = null;
1461
+ let nearestDistSq = Infinity;
1462
+ for (let index = 0; index < state.stars.length; index += 1) {
1463
+ const star = state.stars[index];
1464
+ const dx = star.x - x;
1465
+ const dy = star.y - y;
1466
+ const distSq = dx * dx + dy * dy;
1467
+ const hitRadius = Math.max(11, star.radius * 5);
1468
+ if (distSq <= hitRadius * hitRadius && distSq < nearestDistSq) {
1469
+ nearestDistSq = distSq;
1470
+ nearestStar = star;
1471
+ }
1472
+ }
1473
+
1474
+ if (nearestStar) {
1475
+ nearestStar.dimTarget = 1;
1476
+ }
1477
+ }
1478
+
1479
+ state.canvas.addEventListener('pointerdown', dimNearestStar);
1480
+
1481
+ state.resizeHandler = resizeCanvas;
1482
+ if (typeof ResizeObserver === 'function') {
1483
+ state.resizeObserver = new ResizeObserver(() => resizeCanvas());
1484
+ state.resizeObserver.observe(state.card);
1485
+ } else {
1486
+ window.addEventListener('resize', state.resizeHandler);
1487
+ }
1488
+
1489
+ resizeCanvas();
1490
+ state.frameHandle = window.requestAnimationFrame(drawFrame);
1491
+
1492
+ return state;
1493
+ }
1494
+
1495
+ function hideStartupWorldOverlay(reason = '') {
1496
+ if (!startupWorldOverlayState || startupWorldOverlayState.hidden) {
1497
+ return;
1498
+ }
1499
+
1500
+ const state = startupWorldOverlayState;
1501
+ state.hidden = true;
1502
+ state.running = false;
1503
+
1504
+ if (state.frameHandle !== null) {
1505
+ window.cancelAnimationFrame(state.frameHandle);
1506
+ state.frameHandle = null;
1507
+ }
1508
+
1509
+ if (state.resizeObserver) {
1510
+ state.resizeObserver.disconnect();
1511
+ state.resizeObserver = null;
1512
+ } else if (state.resizeHandler) {
1513
+ window.removeEventListener('resize', state.resizeHandler);
1514
+ }
1515
+
1516
+ if (state.fallbackHandle !== null) {
1517
+ window.clearTimeout(state.fallbackHandle);
1518
+ state.fallbackHandle = null;
1519
+ }
1520
+
1521
+ state.overlay.classList.add('is-leaving');
1522
+ if (reason) {
1523
+ console.log(`[Startup Overlay] Closing overlay: ${reason}`);
1524
+ }
1525
+
1526
+ state.leaveHandle = window.setTimeout(() => {
1527
+ if (state.overlay && state.overlay.parentNode) {
1528
+ state.overlay.parentNode.removeChild(state.overlay);
1529
+ }
1530
+ startupWorldOverlayState = null;
1531
+ }, 620);
1532
+ }
1533
+
1534
+ function markStartupCheckpoint(label, details = null) {
1535
+ if (Object.prototype.hasOwnProperty.call(startupPerfState.checkpoints, label)) {
1536
+ return startupPerfState.checkpoints[label];
1537
+ }
1538
+
1539
+ const at = nowMs();
1540
+ const elapsedMs = at - startupPerfState.bootTs;
1541
+ startupPerfState.checkpoints[label] = at;
1542
+
1543
+ if (details && typeof details === 'object' && Object.keys(details).length > 0) {
1544
+ console.log(`[Startup Perf] ${label} +${elapsedMs.toFixed(2)}ms`, details);
1545
+ } else {
1546
+ console.log(`[Startup Perf] ${label} +${elapsedMs.toFixed(2)}ms`);
1547
+ }
1548
+
1549
+ if (typeof label === 'string' && label.startsWith('T5 ')) {
1550
+ hideStartupWorldOverlay('initial-layout-complete');
1551
+ }
1552
+
1553
+ return at;
1554
+ }
1555
+
1556
+ markStartupCheckpoint('T0 app_boot', {
1557
+ profile: startupPerfProfile.id,
1558
+ pilotEnabled: startupPerfProfile.pilotEnabled,
1559
+ platform: startupPerfProfile.detectedPlatform || 'unknown',
1560
+ runtimePlatformRaw: startupRuntimeCaps && startupRuntimeCaps.platform ? startupRuntimeCaps.platform : 'unknown',
1561
+ override: startupPerfProfile.override || 'none'
1562
+ });
1563
+
11
1564
  // State for Cluster Filtering
12
1565
  let activeClusterFilter = localStorage.getItem('activeClusterFilter') || 'all';
13
1566
  // Clear it immediately so it doesn't persist unwantedly on manual refreshes?
@@ -62,6 +1615,31 @@ const runtimeGraphData =
62
1615
  const graphDataExists = runtimeGraphData !== null;
63
1616
  const sourceNodes = (graphDataExists && Array.isArray(runtimeGraphData.nodes)) ? runtimeGraphData.nodes : [];
64
1617
  const sourceLinks = (graphDataExists && Array.isArray(runtimeGraphData.edges)) ? runtimeGraphData.edges : [];
1618
+
1619
+ if (graphDataExists && sourceNodes.length > 0) {
1620
+ startupWorldOverlayState = createStartupWorldOverlay();
1621
+ if (startupWorldOverlayState) {
1622
+ const overlaySafetyTimeoutMs = Number.isFinite(startupPerfProfile.overlaySafetyTimeoutMs)
1623
+ ? Math.max(8000, Math.floor(startupPerfProfile.overlaySafetyTimeoutMs))
1624
+ : 30000;
1625
+ console.log('[Startup Overlay] Activated.', {
1626
+ profile: startupPerfProfile.id,
1627
+ platform: startupPerfProfile.detectedPlatform || 'unknown',
1628
+ overlaySafetyTimeoutMs
1629
+ });
1630
+ startupWorldOverlayState.fallbackHandle = window.setTimeout(() => {
1631
+ hideStartupWorldOverlay('safety-timeout');
1632
+ }, overlaySafetyTimeoutMs);
1633
+ } else {
1634
+ console.log('[Startup Overlay] Skipped by profile/runtime policy.', {
1635
+ profile: startupPerfProfile.id,
1636
+ platform: startupPerfProfile.detectedPlatform || 'unknown'
1637
+ });
1638
+ }
1639
+ } else {
1640
+ console.log('[Startup Overlay] Skipped: no preloaded graph payload detected.');
1641
+ }
1642
+
65
1643
  const graphPreprocessStartTs = (typeof performance !== 'undefined' && typeof performance.now === 'function')
66
1644
  ? performance.now()
67
1645
  : Date.now();
@@ -131,6 +1709,50 @@ const graphPreprocessElapsedMs = ((typeof performance !== 'undefined' && typeof
131
1709
  ? performance.now()
132
1710
  : Date.now()) - graphPreprocessStartTs;
133
1711
  console.log(`[Init Perf] Graph preprocessing completed in ${graphPreprocessElapsedMs.toFixed(2)}ms (nodes=${nodes.length}, links=${links.length}).`);
1712
+ markStartupCheckpoint('T1 graph_preprocessed', {
1713
+ nodes: nodes.length,
1714
+ links: links.length,
1715
+ preprocessMs: Number(graphPreprocessElapsedMs.toFixed(2))
1716
+ });
1717
+
1718
+ startupLayoutSnapshotState.sourceLayoutById = buildSourceLayoutById(nodes);
1719
+ startupLayoutSnapshotState.sourceLayoutSummary = summarizeLayoutPositions(nodes);
1720
+ if (startupLayoutSnapshotState.sourceLayoutSummary.finiteCount > 0) {
1721
+ console.log('[Startup Warm Snapshot] Source layout summary prepared.', {
1722
+ finiteCount: startupLayoutSnapshotState.sourceLayoutSummary.finiteCount,
1723
+ spanX: Number(startupLayoutSnapshotState.sourceLayoutSummary.spanX.toFixed(2)),
1724
+ spanY: Number(startupLayoutSnapshotState.sourceLayoutSummary.spanY.toFixed(2)),
1725
+ uniqueRatio: Number(startupLayoutSnapshotState.sourceLayoutSummary.uniqueRatio.toFixed(4))
1726
+ });
1727
+ }
1728
+
1729
+ startupLayoutSnapshotState.fingerprint = buildStartupGraphFingerprint(nodes, links, runtimeGraphData);
1730
+ console.log('[Startup Warm Snapshot] Fingerprint prepared.', {
1731
+ fingerprint: startupLayoutSnapshotState.fingerprint,
1732
+ nodes: nodes.length,
1733
+ links: links.length
1734
+ });
1735
+ startupLayoutSnapshotState.restorePromise = loadStartupLayoutSnapshotRecord(startupLayoutSnapshotState.fingerprint)
1736
+ .then((record) => {
1737
+ startupLayoutSnapshotState.pendingRecord = record;
1738
+ if (!record) {
1739
+ console.log('[Startup Warm Snapshot] No persisted snapshot matched current fingerprint.');
1740
+ return;
1741
+ }
1742
+ if (!Array.isArray(record.positions) || record.positions.length === 0) {
1743
+ console.log('[Startup Warm Snapshot] Matched snapshot is empty. Skip applying.');
1744
+ return;
1745
+ }
1746
+ console.log('[Startup Warm Snapshot] Snapshot matched and ready for apply.', {
1747
+ fingerprint: startupLayoutSnapshotState.fingerprint,
1748
+ savedAt: record.savedAt || null,
1749
+ positionCount: record.positions.length
1750
+ });
1751
+ maybeApplyStartupWarmSnapshot('restore-ready');
1752
+ })
1753
+ .catch((error) => {
1754
+ console.warn('[Startup Warm Snapshot] Restore load failed:', error && error.message ? error.message : String(error));
1755
+ });
134
1756
 
135
1757
  // Optimization: Default to Canvas for large graphs (>3000 nodes) to save memory
136
1758
  if (nodes.length > 3000) {
@@ -262,25 +1884,187 @@ const simulationWorker = new Worker("simulationWorker.js");
262
1884
 
263
1885
  // Position buffer for rendering
264
1886
  let currentPositions = new Map();
1887
+ const startupTickApplyQueue = {
1888
+ frameHandle: null,
1889
+ pendingById: new Map(),
1890
+ pendingTickMode: 'delta',
1891
+ pendingStable: false
1892
+ };
1893
+
1894
+ function buildStartupTickModeSummary() {
1895
+ const fullCount = Number(startupPerfState.tickModeSamples.full || 0);
1896
+ const deltaCount = Number(startupPerfState.tickModeSamples.delta || 0);
1897
+ const totalTickCount = fullCount + deltaCount;
1898
+ return {
1899
+ tickMessages: startupPerfState.tickMessagesReceived,
1900
+ tickFramesApplied: startupPerfState.tickFramesApplied,
1901
+ fullTicks: fullCount,
1902
+ deltaTicks: deltaCount,
1903
+ deltaRatio: totalTickCount > 0 ? Number((deltaCount / totalTickCount).toFixed(4)) : 0,
1904
+ avgPayloadNodes: startupPerfState.tickMessagesReceived > 0
1905
+ ? Number((startupPerfState.tickPayloadNodesTotal / startupPerfState.tickMessagesReceived).toFixed(2))
1906
+ : 0,
1907
+ maxPayloadNodes: startupPerfState.tickPayloadNodesMax,
1908
+ skippedEmptyDeltaFrames: startupPerfState.tickEmptyDeltaFramesSkipped
1909
+ };
1910
+ }
1911
+
1912
+ function flushStartupTickApplyQueue() {
1913
+ startupTickApplyQueue.frameHandle = null;
1914
+
1915
+ if (focusNode) {
1916
+ startupTickApplyQueue.pendingById.clear();
1917
+ startupTickApplyQueue.pendingTickMode = 'delta';
1918
+ startupTickApplyQueue.pendingStable = false;
1919
+ return;
1920
+ }
1921
+
1922
+ let appliedCount = 0;
1923
+ startupTickApplyQueue.pendingById.forEach((payloadNode, nodeId) => {
1924
+ if (!payloadNode) {
1925
+ return;
1926
+ }
1927
+
1928
+ const payloadIndex = Number(payloadNode.i);
1929
+ const hasValidIndex = Number.isInteger(payloadIndex) && payloadIndex >= 0 && payloadIndex < nodes.length;
1930
+ let originalNode = hasValidIndex ? nodes[payloadIndex] : null;
1931
+ if (!originalNode || originalNode.id !== nodeId) {
1932
+ originalNode = nodeMap.get(nodeId);
1933
+ }
1934
+ if (!originalNode) {
1935
+ return;
1936
+ }
1937
+
1938
+ const nextX = Number.isFinite(Number(payloadNode.x)) ? Number(payloadNode.x) : originalNode.x;
1939
+ const nextY = Number.isFinite(Number(payloadNode.y)) ? Number(payloadNode.y) : originalNode.y;
1940
+ originalNode.x = nextX;
1941
+ originalNode.y = nextY;
1942
+ currentPositions.set(nodeId, { x: nextX, y: nextY });
1943
+ appliedCount += 1;
1944
+ });
1945
+
1946
+ const shouldRender = appliedCount > 0 || startupTickApplyQueue.pendingTickMode !== 'delta';
1947
+ if (shouldRender) {
1948
+ ticked();
1949
+ startupPerfState.tickFramesApplied += 1;
1950
+ } else {
1951
+ startupPerfState.tickEmptyDeltaFramesSkipped += 1;
1952
+ }
1953
+
1954
+ if (!startupPerfState.t4Seen && shouldRender) {
1955
+ startupPerfState.t4Seen = true;
1956
+ const rendererModeInput = document.querySelector('input[name="rendererMode"]:checked');
1957
+ const layoutModeInput = document.querySelector('input[name="layoutMode"]:checked');
1958
+ markStartupCheckpoint('T4 first_interactive_render', {
1959
+ renderer: rendererModeInput ? rendererModeInput.value : 'unknown',
1960
+ layout: layoutModeInput ? layoutModeInput.value : 'unknown'
1961
+ });
1962
+ }
1963
+
1964
+ if (startupTickApplyQueue.pendingStable === true && !startupPerfState.t5Seen) {
1965
+ startupPerfState.t5Seen = true;
1966
+ markStartupCheckpoint('T5 stable_layout', {
1967
+ alpha: Number.isFinite(startupPerfState.lastWorkerAlpha) ? Number(startupPerfState.lastWorkerAlpha.toFixed(4)) : null,
1968
+ source: 'worker_tick',
1969
+ tickSummary: buildStartupTickModeSummary()
1970
+ });
1971
+ scheduleStartupLayoutSnapshotPersist('startup-stable-worker-tick');
1972
+ }
1973
+
1974
+ startupTickApplyQueue.pendingById.clear();
1975
+ startupTickApplyQueue.pendingTickMode = 'delta';
1976
+ startupTickApplyQueue.pendingStable = false;
1977
+ }
1978
+
1979
+ function scheduleStartupTickApplyFlush() {
1980
+ if (startupTickApplyQueue.frameHandle !== null) {
1981
+ return;
1982
+ }
1983
+
1984
+ if (typeof window !== 'undefined' && typeof window.requestAnimationFrame === 'function') {
1985
+ startupTickApplyQueue.frameHandle = window.requestAnimationFrame(() => {
1986
+ flushStartupTickApplyQueue();
1987
+ });
1988
+ } else {
1989
+ startupTickApplyQueue.frameHandle = setTimeout(() => {
1990
+ flushStartupTickApplyQueue();
1991
+ }, 16);
1992
+ }
1993
+ }
1994
+
1995
+ function queueStartupTickForApply(workerNodes, resolvedTickMode, isStartupStable) {
1996
+ if (resolvedTickMode === 'full') {
1997
+ startupTickApplyQueue.pendingById.clear();
1998
+ startupTickApplyQueue.pendingTickMode = 'full';
1999
+ } else if (startupTickApplyQueue.pendingTickMode !== 'full') {
2000
+ startupTickApplyQueue.pendingTickMode = 'delta';
2001
+ }
2002
+
2003
+ for (let index = 0; index < workerNodes.length; index += 1) {
2004
+ const n = workerNodes[index];
2005
+ if (!n || n.id === undefined || n.id === null) {
2006
+ continue;
2007
+ }
2008
+ startupTickApplyQueue.pendingById.set(n.id, n);
2009
+ }
2010
+
2011
+ if (isStartupStable === true) {
2012
+ startupTickApplyQueue.pendingStable = true;
2013
+ }
2014
+ scheduleStartupTickApplyFlush();
2015
+ }
265
2016
 
266
2017
  simulationWorker.onmessage = function(event) {
267
- const { type, nodes: workerNodes } = event.data;
2018
+ const {
2019
+ type,
2020
+ nodes: workerNodes,
2021
+ alpha: workerAlpha,
2022
+ isStartupStable,
2023
+ isDelta,
2024
+ tickMode
2025
+ } = event.data;
268
2026
  if (type === 'tick') {
2027
+ const safeWorkerNodes = Array.isArray(workerNodes) ? workerNodes : [];
2028
+ const resolvedTickMode = (typeof tickMode === 'string' && tickMode.length > 0)
2029
+ ? tickMode
2030
+ : (isDelta === true ? 'delta' : 'full');
2031
+
2032
+ startupPerfState.tickMessagesReceived += 1;
2033
+ startupPerfState.tickPayloadNodesTotal += safeWorkerNodes.length;
2034
+ startupPerfState.tickPayloadNodesMax = Math.max(startupPerfState.tickPayloadNodesMax, safeWorkerNodes.length);
2035
+
2036
+ if (!Object.prototype.hasOwnProperty.call(startupPerfState.tickModeSamples, resolvedTickMode)) {
2037
+ startupPerfState.tickModeSamples[resolvedTickMode] = 0;
2038
+ }
2039
+ startupPerfState.tickModeSamples[resolvedTickMode] += 1;
2040
+
2041
+ if (!startupPerfState.t3Seen) {
2042
+ startupPerfState.t3Seen = true;
2043
+ markStartupCheckpoint('T3 first_tick_received', {
2044
+ alpha: Number.isFinite(workerAlpha) ? Number(workerAlpha.toFixed(4)) : null,
2045
+ tickMode: resolvedTickMode,
2046
+ payloadNodes: safeWorkerNodes.length
2047
+ });
2048
+ }
2049
+
2050
+ if (Number.isFinite(workerAlpha)) {
2051
+ startupPerfState.lastWorkerAlpha = workerAlpha;
2052
+ }
2053
+
269
2054
  // v0.9.80: Ignore worker ticks in Focus Mode to prevent position overwrite
270
2055
  // In Focus Mode, positions are managed by the main thread's highlightManager
271
2056
  if (focusNode) return;
272
-
273
- // Update positions
274
- workerNodes.forEach(n => {
275
- currentPositions.set(n.id, { x: n.x, y: n.y });
276
- const originalNode = nodeMap.get(n.id);
277
- if (originalNode) {
278
- originalNode.x = n.x;
279
- originalNode.y = n.y;
280
- }
281
- });
282
-
283
- ticked();
2057
+ queueStartupTickForApply(safeWorkerNodes, resolvedTickMode, isStartupStable === true);
2058
+ } else if (type === 'startupStable') {
2059
+ if (!startupPerfState.t5Seen) {
2060
+ startupPerfState.t5Seen = true;
2061
+ markStartupCheckpoint('T5 stable_layout', {
2062
+ alpha: Number.isFinite(workerAlpha) ? Number(workerAlpha.toFixed(4)) : null,
2063
+ source: 'worker_signal',
2064
+ tickSummary: buildStartupTickModeSummary()
2065
+ });
2066
+ scheduleStartupLayoutSnapshotPersist('startup-stable-worker-signal');
2067
+ }
284
2068
  }
285
2069
  };
286
2070
 
@@ -339,6 +2123,7 @@ const simulation = {
339
2123
  const workerPayloadBuildStartTs = (typeof performance !== 'undefined' && typeof performance.now === 'function')
340
2124
  ? performance.now()
341
2125
  : Date.now();
2126
+ restoreSourceLayoutOrJitterNodes(nodes, width, height, 'worker-init');
342
2127
  const workerNodes = new Array(nodes.length);
343
2128
  for (let nodeIndex = 0; nodeIndex < nodes.length; nodeIndex += 1) {
344
2129
  const node = nodes[nodeIndex];
@@ -366,6 +2151,32 @@ const workerPayloadBuildElapsedMs = ((typeof performance !== 'undefined' && type
366
2151
  : Date.now()) - workerPayloadBuildStartTs;
367
2152
  console.log(`[Init Perf] Worker payload prepared in ${workerPayloadBuildElapsedMs.toFixed(2)}ms (workerNodes=${workerNodes.length}, workerLinks=${workerLinks.length}).`);
368
2153
 
2154
+ const workerStartupProfile = {
2155
+ id: startupPerfProfile.id,
2156
+ pilotEnabled: startupPerfProfile.pilotEnabled === true,
2157
+ tickMaxFps: startupPerfProfile.tickMaxFps,
2158
+ lowAlphaTickMaxFps: startupPerfProfile.lowAlphaTickMaxFps,
2159
+ lowAlphaThreshold: startupPerfProfile.lowAlphaThreshold,
2160
+ stableAlphaThreshold: startupPerfProfile.stableAlphaThreshold,
2161
+ stableHoldTicks: startupPerfProfile.stableHoldTicks,
2162
+ stableTimeoutMs: startupPerfProfile.stableTimeoutMs,
2163
+ deltaEnabled: startupPerfProfile.deltaTickEnabled === true,
2164
+ deltaEpsilonPx: startupPerfProfile.deltaEpsilonPx,
2165
+ fullSyncEveryTicks: startupPerfProfile.deltaFullSyncEveryTicks,
2166
+ lowAlphaDeltaEpsilonMultiplier: startupPerfProfile.deltaLowAlphaEpsilonMultiplier,
2167
+ lowAlphaFullSyncEveryTicks: startupPerfProfile.deltaLowAlphaFullSyncEveryTicks
2168
+ };
2169
+
2170
+ markStartupCheckpoint('T2 worker_init_sent', {
2171
+ workerNodes: workerNodes.length,
2172
+ workerLinks: workerLinks.length,
2173
+ profile: workerStartupProfile.id,
2174
+ tickMaxFps: workerStartupProfile.tickMaxFps,
2175
+ deltaEnabled: workerStartupProfile.deltaEnabled,
2176
+ deltaFullSyncEveryTicks: workerStartupProfile.fullSyncEveryTicks,
2177
+ deltaLowAlphaEpsilonMultiplier: workerStartupProfile.lowAlphaDeltaEpsilonMultiplier,
2178
+ deltaLowAlphaFullSyncEveryTicks: workerStartupProfile.lowAlphaFullSyncEveryTicks
2179
+ });
369
2180
  simulationWorker.postMessage({
370
2181
  type: 'init',
371
2182
  payload: {
@@ -378,9 +2189,11 @@ simulationWorker.postMessage({
378
2189
  distance: 100,
379
2190
  velocityDecay: 0.2,
380
2191
  gpuRendering: (window.settingsManager ? window.settingsManager.settings.performance.gpuRendering : true)
381
- }
2192
+ },
2193
+ startupProfile: workerStartupProfile
382
2194
  }
383
2195
  });
2196
+ maybeApplyStartupWarmSnapshot('after-worker-init');
384
2197
 
385
2198
  // v0.9.37: Two-stage Damping Strategy handled in worker or re-implemented here?
386
2199
  // Re-implementing logic via messages
@@ -400,10 +2213,23 @@ setTimeout(() => {
400
2213
  // Static Mode Enforcement
401
2214
  if (nodes.length > 5000 || links.length > 200000) {
402
2215
  console.log("[Simulation] Large graph detected. Freezing simulation after relaxation.");
403
- simulation.stop();
2216
+ simulation.stop();
404
2217
  }
405
2218
  }, 2000);
406
2219
 
2220
+ if (typeof document !== 'undefined' && typeof document.addEventListener === 'function') {
2221
+ document.addEventListener('visibilitychange', () => {
2222
+ if (document.visibilityState === 'hidden') {
2223
+ scheduleStartupLayoutSnapshotPersist('visibility-hidden', 0);
2224
+ }
2225
+ });
2226
+ }
2227
+ if (typeof window !== 'undefined' && typeof window.addEventListener === 'function') {
2228
+ window.addEventListener('beforeunload', () => {
2229
+ scheduleStartupLayoutSnapshotPersist('beforeunload', 0);
2230
+ });
2231
+ }
2232
+
407
2233
  // Handle Resize
408
2234
  const resizeObserver = new ResizeObserver(entries => {
409
2235
  for (let entry of entries) {
@@ -465,6 +2291,68 @@ const link = g.append("g")
465
2291
  .attr("class", "link")
466
2292
  .attr("marker-end", "url(#arrow)");
467
2293
 
2294
+ function buildStartupStage1LinkSet(allNodes, allLinks, topK) {
2295
+ if (!Array.isArray(allLinks) || allLinks.length === 0) {
2296
+ return null;
2297
+ }
2298
+ const normalizedTopK = Math.max(0, Math.floor(Number(topK) || 0));
2299
+ if (normalizedTopK <= 0 || normalizedTopK >= allLinks.length) {
2300
+ return null;
2301
+ }
2302
+
2303
+ const topNodeCount = Math.min(Math.max(normalizedTopK, 64), Math.max(64, Math.floor(allNodes.length * 0.08)));
2304
+ const sortedNodes = allNodes
2305
+ .map((n) => ({
2306
+ id: n.id,
2307
+ score: Number.isFinite(Number(n.centrality))
2308
+ ? Number(n.centrality)
2309
+ : Number((n.inDegree || 0) + (n.outDegree || 0))
2310
+ }))
2311
+ .sort((a, b) => b.score - a.score)
2312
+ .slice(0, topNodeCount);
2313
+ const topNodeIds = new Set(sortedNodes.map((item) => item.id));
2314
+
2315
+ const selectedLinks = [];
2316
+ for (let index = 0; index < allLinks.length && selectedLinks.length < normalizedTopK; index += 1) {
2317
+ const edge = allLinks[index];
2318
+ const sourceId = edge && edge.source && typeof edge.source === 'object' ? edge.source.id : edge.source;
2319
+ const targetId = edge && edge.target && typeof edge.target === 'object' ? edge.target.id : edge.target;
2320
+ if (topNodeIds.has(sourceId) || topNodeIds.has(targetId)) {
2321
+ selectedLinks.push(edge);
2322
+ }
2323
+ }
2324
+
2325
+ if (selectedLinks.length < normalizedTopK) {
2326
+ for (let index = 0; index < allLinks.length && selectedLinks.length < normalizedTopK; index += 1) {
2327
+ const edge = allLinks[index];
2328
+ if (!selectedLinks.includes(edge)) {
2329
+ selectedLinks.push(edge);
2330
+ }
2331
+ }
2332
+ }
2333
+
2334
+ return new Set(selectedLinks);
2335
+ }
2336
+
2337
+ const startupStage1TopK = Math.max(
2338
+ 0,
2339
+ Math.floor(
2340
+ Number.isFinite(startupPerfProfile.edgeStage1TopK) && startupPerfProfile.edgeStage1TopK > 0
2341
+ ? startupPerfProfile.edgeStage1TopK
2342
+ : startupPerfProfile.edgeStartupSvgCap
2343
+ )
2344
+ );
2345
+ const startupStage1LinkSet = buildStartupStage1LinkSet(nodes, links, startupStage1TopK);
2346
+ let startupSvgStage1LinkSelection = link;
2347
+ if (startupStage1LinkSet && startupStage1LinkSet.size > 0 && startupStage1LinkSet.size < links.length) {
2348
+ startupSvgStage1LinkSelection = link.filter((edgeDatum) => startupStage1LinkSet.has(edgeDatum));
2349
+ console.log('[Startup Perf] Startup SVG key-edge stage prepared.', {
2350
+ totalLinks: links.length,
2351
+ stage1Links: startupStage1LinkSet.size,
2352
+ stage1TopK: startupStage1TopK
2353
+ });
2354
+ }
2355
+
468
2356
  // Render Nodes
469
2357
  const node = g.append("g")
470
2358
  .attr("class", "nodes")
@@ -1225,11 +3113,23 @@ function handleDoubleClick(event, d) {
1225
3113
  // v0.9.20 Enhancement: Auto-clear selection state when entering focus mode
1226
3114
  // v0.9.20 增强:进入专注模式时自动清除选择状态
1227
3115
 
1228
- if (focusNode && focusNode.id === d.id) {
3116
+ const focusInteractions = window.NoteConnectionFocusModeInteractions;
3117
+ const focusAction = focusInteractions && typeof focusInteractions.resolveDoubleClickAction === 'function'
3118
+ ? focusInteractions.resolveDoubleClickAction({
3119
+ currentAnchorId: focusNode && focusNode.id,
3120
+ clickedNodeId: d && d.id,
3121
+ })
3122
+ : {
3123
+ action: focusNode && focusNode.id === d.id ? 'open-reader' : 'switch-focus',
3124
+ clickedNodeId: d && d.id,
3125
+ currentAnchorId: focusNode && focusNode.id,
3126
+ };
3127
+
3128
+ if (focusAction.action === 'open-reader') {
1229
3129
  // Already focused on same node -> Open Reader
1230
3130
  // 已经专注于同一节点 -> 打开阅读器
1231
3131
  if (window.reader) window.reader.open(d);
1232
- } else {
3132
+ } else if (focusAction.action === 'switch-focus') {
1233
3133
  // Clear any existing selection/highlight state before entering focus mode
1234
3134
  // 在进入专注模式前清除任何现有的选择/高亮状态
1235
3135
  if (window.highlightManager) {
@@ -1611,6 +3511,55 @@ function checkSimulationState() {
1611
3511
  }
1612
3512
  }
1613
3513
 
3514
+ function startupElapsedMs() {
3515
+ return nowMs() - startupPerfState.bootTs;
3516
+ }
3517
+
3518
+ function shouldBypassStartupEdgeOptimizations() {
3519
+ if (focusNode) {
3520
+ return true;
3521
+ }
3522
+
3523
+ const highlightState = window.highlightManager ? window.highlightManager.getState() : null;
3524
+ return Boolean(highlightState && highlightState.currentNode);
3525
+ }
3526
+
3527
+ function isStartupEdgeDelayActive() {
3528
+ if (startupPerfProfile.pilotEnabled !== true) {
3529
+ return false;
3530
+ }
3531
+
3532
+ if (!Number.isFinite(startupPerfProfile.edgeGeometryDelayMs) || startupPerfProfile.edgeGeometryDelayMs <= 0) {
3533
+ return false;
3534
+ }
3535
+
3536
+ if (shouldBypassStartupEdgeOptimizations()) {
3537
+ return false;
3538
+ }
3539
+
3540
+ return startupElapsedMs() < startupPerfProfile.edgeGeometryDelayMs;
3541
+ }
3542
+
3543
+ function shouldUseStartupSvgStage1Edges() {
3544
+ if (startupPerfProfile.pilotEnabled !== true) {
3545
+ return false;
3546
+ }
3547
+
3548
+ if (!Number.isFinite(startupPerfProfile.edgeStartupWindowMs) || startupPerfProfile.edgeStartupWindowMs <= 0) {
3549
+ return false;
3550
+ }
3551
+
3552
+ if (startupSvgStage1LinkSelection === link) {
3553
+ return false;
3554
+ }
3555
+
3556
+ if (shouldBypassStartupEdgeOptimizations()) {
3557
+ return false;
3558
+ }
3559
+
3560
+ return startupElapsedMs() < startupPerfProfile.edgeStartupWindowMs;
3561
+ }
3562
+
1614
3563
  // Simulation Tick
1615
3564
  function ticked() {
1616
3565
  const renderer = document.querySelector('input[name="rendererMode"]:checked').value;
@@ -1630,17 +3579,50 @@ function ticked() {
1630
3579
  }
1631
3580
 
1632
3581
  if (renderer === 'svg') {
1633
- // SVG Update Logic
1634
- if (layoutMode === 'dag') {
1635
- link.attr("d", d => {
1636
- const sx = d.source.x;
1637
- const sy = d.source.y;
1638
- const tx = d.target.x;
1639
- const ty = d.target.y;
1640
- return `M${sx},${sy} C${sx},${(sy + ty) / 2} ${tx},${(sy + ty) / 2} ${tx},${ty}`;
3582
+ const edgeDelayActive = isStartupEdgeDelayActive();
3583
+ if (edgeDelayActive && !startupPerfState.edgeDelayLogged) {
3584
+ startupPerfState.edgeDelayLogged = true;
3585
+ console.log('[Startup Perf] Startup SVG edge geometry delay active.', {
3586
+ edgeGeometryDelayMs: startupPerfProfile.edgeGeometryDelayMs
1641
3587
  });
1642
- } else {
1643
- link.attr("d", d => `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`);
3588
+ }
3589
+
3590
+ if (!edgeDelayActive) {
3591
+ if (startupPerfState.edgeDelayLogged && !startupPerfState.edgeDelayReleasedLogged) {
3592
+ startupPerfState.edgeDelayReleasedLogged = true;
3593
+ console.log('[Startup Perf] Startup SVG edge geometry delay released.', {
3594
+ elapsedMs: Number(startupElapsedMs().toFixed(2))
3595
+ });
3596
+ }
3597
+
3598
+ const useStartupStage1Edges = shouldUseStartupSvgStage1Edges();
3599
+ if (useStartupStage1Edges && !startupPerfState.edgeStage1Logged) {
3600
+ startupPerfState.edgeStage1Logged = true;
3601
+ console.log('[Startup Perf] Startup SVG key-edge stage active.', {
3602
+ edgeStartupWindowMs: startupPerfProfile.edgeStartupWindowMs,
3603
+ edgeStage1TopK: startupStage1TopK
3604
+ });
3605
+ }
3606
+
3607
+ if (!useStartupStage1Edges && startupPerfState.edgeStage1Logged && !startupPerfState.edgeStage1ReleasedLogged) {
3608
+ startupPerfState.edgeStage1ReleasedLogged = true;
3609
+ console.log('[Startup Perf] Startup SVG key-edge stage released.', {
3610
+ elapsedMs: Number(startupElapsedMs().toFixed(2))
3611
+ });
3612
+ }
3613
+
3614
+ const activeLinkSelection = useStartupStage1Edges ? startupSvgStage1LinkSelection : link;
3615
+ if (layoutMode === 'dag') {
3616
+ activeLinkSelection.attr("d", d => {
3617
+ const sx = d.source.x;
3618
+ const sy = d.source.y;
3619
+ const tx = d.target.x;
3620
+ const ty = d.target.y;
3621
+ return `M${sx},${sy} C${sx},${(sy + ty) / 2} ${tx},${(sy + ty) / 2} ${tx},${ty}`;
3622
+ });
3623
+ } else {
3624
+ activeLinkSelection.attr("d", d => `M${d.source.x},${d.source.y}L${d.target.x},${d.target.y}`);
3625
+ }
1644
3626
  }
1645
3627
  node.filter(d => !d.isCulled).attr("transform", d => `translate(${d.x},${d.y})`);
1646
3628
  } else {
@@ -2368,26 +4350,578 @@ window.highlightNode = function(id) {
2368
4350
  // 3. Show Popup
2369
4351
  showNodePopup(id);
2370
4352
  }
2371
- };
4353
+ };
4354
+
4355
+ // Helper to expose focusOnNode for external modules
4356
+ // 为外部模块公开focusOnNode
4357
+ window.focusOnNode = function(id) {
4358
+ const d = nodes.find(n => n.id === id);
4359
+ if (d) {
4360
+ // Reuse double click logic or call enterFocusMode directly
4361
+ // Clear highlight first
4362
+ if (window.highlightManager) {
4363
+ window.highlightManager.unhighlight({ force: true });
4364
+ }
4365
+
4366
+ // Hide popup
4367
+ const popup = document.getElementById('node-stats-popup');
4368
+ if (popup) popup.style.display = 'none';
4369
+
4370
+ // Enter Focus Mode
4371
+ enterFocusMode(d);
4372
+ }
4373
+ };
4374
+
4375
+ function normalizeGraphViewText(value) {
4376
+ return String(value || '').replace(/\s+/g, ' ').trim();
4377
+ }
4378
+
4379
+ function normalizeGraphViewLookupKey(value) {
4380
+ return normalizeGraphViewText(value).toLowerCase();
4381
+ }
4382
+
4383
+ function resolveGraphViewSourceBasename(sourcePath) {
4384
+ const normalized = String(sourcePath || '').replace(/\\/g, '/').trim();
4385
+ if (!normalized) {
4386
+ return '';
4387
+ }
4388
+ const fileName = normalized.split('/').filter(Boolean).pop() || normalized;
4389
+ return fileName.replace(/\.[^/.]+$/, '').trim();
4390
+ }
4391
+
4392
+ function getGraphViewNodeLabel(node) {
4393
+ return normalizeGraphViewText(node && (node.label || node.title || node.name || node.id));
4394
+ }
4395
+
4396
+ function getGraphViewLinkNodeId(endpoint) {
4397
+ if (endpoint && typeof endpoint === 'object') {
4398
+ return normalizeGraphViewText(endpoint.id || endpoint.nodeId || endpoint.key);
4399
+ }
4400
+ return normalizeGraphViewText(endpoint);
4401
+ }
4402
+
4403
+ function collectGraphViewKnowledgePointCandidates(payload) {
4404
+ const candidates = [];
4405
+ const seen = new Set();
4406
+ const appendCandidate = (value) => {
4407
+ const normalized = normalizeGraphViewText(value);
4408
+ if (!normalized || seen.has(normalized)) {
4409
+ return;
4410
+ }
4411
+ seen.add(normalized);
4412
+ candidates.push(normalized);
4413
+ };
4414
+ appendCandidate(payload && payload.graphNodeId);
4415
+ appendCandidate(payload && payload.graphTargetId);
4416
+ appendCandidate(payload && payload.nodeId);
4417
+ appendCandidate(payload && payload.documentId);
4418
+ appendCandidate(payload && payload.title);
4419
+ appendCandidate(payload && payload.label);
4420
+ appendCandidate(payload && payload.sourceBasename);
4421
+ appendCandidate(resolveGraphViewSourceBasename(payload && payload.sourcePath));
4422
+ appendCandidate(payload && payload.atomId);
4423
+ if (Array.isArray(payload && payload.atomIds)) {
4424
+ payload.atomIds.forEach(appendCandidate);
4425
+ }
4426
+ if (Array.isArray(payload && payload.nodeIds)) {
4427
+ payload.nodeIds.forEach(appendCandidate);
4428
+ }
4429
+ if (Array.isArray(payload && payload.matchedSpans)) {
4430
+ payload.matchedSpans.forEach((span) => {
4431
+ appendCandidate(span && span.title);
4432
+ appendCandidate(span && span.atomId);
4433
+ appendCandidate(resolveGraphViewSourceBasename(span && span.sourcePath));
4434
+ });
4435
+ }
4436
+ return candidates;
4437
+ }
4438
+
4439
+ function resolveGraphViewNodeByIdOrLabel(candidate) {
4440
+ const normalized = normalizeGraphViewText(candidate);
4441
+ if (!normalized || !Array.isArray(nodes)) {
4442
+ return null;
4443
+ }
4444
+ const exactId = nodes.find((node) => normalizeGraphViewText(node && node.id) === normalized);
4445
+ if (exactId) {
4446
+ return exactId;
4447
+ }
4448
+ const lookupKey = normalizeGraphViewLookupKey(normalized);
4449
+ const exactLabelMatches = nodes.filter((node) => normalizeGraphViewLookupKey(getGraphViewNodeLabel(node)) === lookupKey);
4450
+ if (exactLabelMatches.length === 1) {
4451
+ return exactLabelMatches[0];
4452
+ }
4453
+ const sourceMatches = nodes.filter((node) => {
4454
+ const metadata = node && typeof node.metadata === 'object' ? node.metadata : {};
4455
+ return normalizeGraphViewLookupKey(resolveGraphViewSourceBasename(node && (node.sourcePath || node.filepath || metadata.filepath || metadata.sourcePath))) === lookupKey;
4456
+ });
4457
+ return sourceMatches.length === 1 ? sourceMatches[0] : null;
4458
+ }
4459
+
4460
+ function resolveGraphViewNodeByKnowledgePoint(payload) {
4461
+ const candidates = collectGraphViewKnowledgePointCandidates(payload);
4462
+ for (const candidate of candidates) {
4463
+ const node = resolveGraphViewNodeByIdOrLabel(candidate);
4464
+ if (node) {
4465
+ return node;
4466
+ }
4467
+ }
4468
+ return null;
4469
+ }
4470
+
4471
+ function buildGraphViewFocusModeSnapshot(nodeId) {
4472
+ const focusD = resolveGraphViewNodeByIdOrLabel(nodeId);
4473
+ if (!focusD) {
4474
+ return null;
4475
+ }
4476
+ const incoming = [];
4477
+ const outgoing = [];
4478
+ const linkList = Array.isArray(links) ? links : [];
4479
+ linkList.forEach((linkItem) => {
4480
+ const sourceId = getGraphViewLinkNodeId(linkItem && linkItem.source);
4481
+ const targetId = getGraphViewLinkNodeId(linkItem && linkItem.target);
4482
+ if (sourceId === focusD.id) {
4483
+ const targetNode = resolveGraphViewNodeByIdOrLabel(targetId);
4484
+ if (targetNode) {
4485
+ outgoing.push(targetNode);
4486
+ }
4487
+ } else if (targetId === focusD.id) {
4488
+ const sourceNode = resolveGraphViewNodeByIdOrLabel(sourceId);
4489
+ if (sourceNode) {
4490
+ incoming.push(sourceNode);
4491
+ }
4492
+ }
4493
+ });
4494
+
4495
+ const uniqueById = (items) => {
4496
+ const seen = new Set();
4497
+ const unique = [];
4498
+ items.forEach((item) => {
4499
+ const id = normalizeGraphViewText(item && item.id);
4500
+ if (!id || seen.has(id)) {
4501
+ return;
4502
+ }
4503
+ seen.add(id);
4504
+ unique.push(item);
4505
+ });
4506
+ return unique;
4507
+ };
4508
+ const incomingNodes = uniqueById(incoming).slice(0, 5);
4509
+ const outgoingNodes = uniqueById(outgoing).slice(0, 5);
4510
+ const snapshotNodes = [];
4511
+ const addSnapshotNode = (node, role, x, y) => {
4512
+ snapshotNodes.push({
4513
+ id: normalizeGraphViewText(node && node.id),
4514
+ label: getGraphViewNodeLabel(node),
4515
+ role,
4516
+ x,
4517
+ y,
4518
+ });
4519
+ };
4520
+ const placeLane = (nodeList, role, x) => {
4521
+ const step = 80 / (nodeList.length + 1);
4522
+ nodeList.forEach((node, index) => {
4523
+ addSnapshotNode(node, role, x, 10 + step * (index + 1));
4524
+ });
4525
+ };
4526
+ placeLane(incomingNodes, 'incoming', 22);
4527
+ addSnapshotNode(focusD, 'anchor', 50, 50);
4528
+ placeLane(outgoingNodes, 'outgoing', 78);
4529
+
4530
+ const visibleIds = new Set(snapshotNodes.map((node) => node.id));
4531
+ const snapshotEdges = linkList
4532
+ .map((linkItem) => {
4533
+ const sourceId = getGraphViewLinkNodeId(linkItem && linkItem.source);
4534
+ const targetId = getGraphViewLinkNodeId(linkItem && linkItem.target);
4535
+ if (!sourceId || !targetId || !visibleIds.has(sourceId) || !visibleIds.has(targetId)) {
4536
+ return null;
4537
+ }
4538
+ return {
4539
+ sourceId,
4540
+ targetId,
4541
+ relationKind: normalizeGraphViewText(linkItem && (linkItem.type || linkItem.relationKind || linkItem.kind)) || 'related',
4542
+ confidence: Number(linkItem && (linkItem.confidence || linkItem.weight)),
4543
+ };
4544
+ })
4545
+ .filter(Boolean);
4546
+
4547
+ return {
4548
+ anchorId: focusD.id,
4549
+ anchorLabel: getGraphViewNodeLabel(focusD),
4550
+ nodes: snapshotNodes,
4551
+ edges: snapshotEdges,
4552
+ };
4553
+ }
4554
+
4555
+ function buildGraphViewFocusModeProjection(nodeId, options = {}) {
4556
+ const focusD = resolveGraphViewNodeByIdOrLabel(nodeId);
4557
+ if (!focusD) {
4558
+ return null;
4559
+ }
4560
+ const linkList = Array.isArray(links) ? links : [];
4561
+ const uniqueById = (items) => {
4562
+ const seen = new Set();
4563
+ const unique = [];
4564
+ items.forEach((item) => {
4565
+ const id = normalizeGraphViewText(item && item.id);
4566
+ if (!id || seen.has(id)) {
4567
+ return;
4568
+ }
4569
+ seen.add(id);
4570
+ unique.push(item);
4571
+ });
4572
+ return unique;
4573
+ };
4574
+ const getEndpointNode = (endpoint) => {
4575
+ if (endpoint && typeof endpoint === 'object') {
4576
+ const endpointId = normalizeGraphViewText(endpoint.id || endpoint.nodeId || endpoint.key);
4577
+ return endpointId ? resolveGraphViewNodeByIdOrLabel(endpointId) || endpoint : null;
4578
+ }
4579
+ return resolveGraphViewNodeByIdOrLabel(endpoint);
4580
+ };
4581
+ const getLinkWeight = (linkItem) => {
4582
+ const numericWeight = Number(linkItem && (linkItem.weight || linkItem.confidence));
4583
+ return Number.isFinite(numericWeight) ? numericWeight : 0.5;
4584
+ };
4585
+ const getRelationKind = (linkItem) => (
4586
+ normalizeGraphViewText(linkItem && (linkItem.type || linkItem.relationKind || linkItem.kind)) || 'related'
4587
+ );
4588
+ const outgoing = [];
4589
+ const incoming = [];
4590
+ const focusEdgeMap = new Map();
4591
+ linkList.forEach((linkItem) => {
4592
+ const sourceId = getGraphViewLinkNodeId(linkItem && linkItem.source);
4593
+ const targetId = getGraphViewLinkNodeId(linkItem && linkItem.target);
4594
+ if (!sourceId || !targetId) {
4595
+ return;
4596
+ }
4597
+ if (sourceId === focusD.id || targetId === focusD.id) {
4598
+ focusEdgeMap.set(`${sourceId}-${targetId}`, linkItem);
4599
+ }
4600
+ if (sourceId === focusD.id) {
4601
+ const targetNode = getEndpointNode(linkItem && linkItem.target);
4602
+ if (targetNode) {
4603
+ outgoing.push(targetNode);
4604
+ }
4605
+ return;
4606
+ }
4607
+ if (targetId === focusD.id) {
4608
+ const sourceNode = getEndpointNode(linkItem && linkItem.source);
4609
+ if (sourceNode) {
4610
+ incoming.push(sourceNode);
4611
+ }
4612
+ }
4613
+ });
4614
+ const scoreNode = (node) => {
4615
+ const key1 = `${focusD.id}-${node.id}`;
4616
+ const key2 = `${node.id}-${focusD.id}`;
4617
+ const edge = focusEdgeMap.get(key1) || focusEdgeMap.get(key2);
4618
+ const weight = getLinkWeight(edge);
4619
+ const degreeRatio = (Number(node && node.outDegree) || 0) / ((Number(node && node.inDegree) || 0) + 1);
4620
+ const normalizedDegreeRatio = Math.min(degreeRatio, 5) / 5;
4621
+ return (weight * 0.7) + (normalizedDegreeRatio * 0.3);
4622
+ };
4623
+ const sortByFocusScore = (left, right) => scoreNode(right) - scoreNode(left);
4624
+ const incomingNodes = uniqueById(incoming).sort(sortByFocusScore);
4625
+ const outgoingNodes = uniqueById(outgoing).sort(sortByFocusScore);
4626
+ const activeIdSet = new Set([focusD.id].concat(
4627
+ incomingNodes.map((node) => node.id),
4628
+ outgoingNodes.map((node) => node.id)
4629
+ ));
4630
+ const associatedNodes = uniqueById(linkList
4631
+ .map((linkItem) => {
4632
+ const sourceId = getGraphViewLinkNodeId(linkItem && linkItem.source);
4633
+ const targetId = getGraphViewLinkNodeId(linkItem && linkItem.target);
4634
+ if ((sourceId !== focusD.id && targetId !== focusD.id) || getLinkWeight(linkItem) <= 0.6) {
4635
+ return null;
4636
+ }
4637
+ const otherId = sourceId === focusD.id ? targetId : sourceId;
4638
+ if (!otherId || activeIdSet.has(otherId)) {
4639
+ return null;
4640
+ }
4641
+ return getEndpointNode(sourceId === focusD.id ? linkItem.target : linkItem.source);
4642
+ })
4643
+ .filter(Boolean))
4644
+ .sort(sortByFocusScore);
4645
+ associatedNodes.forEach((node) => activeIdSet.add(node.id));
4646
+
4647
+ const currentWidth = Number.isFinite(Number(width)) ? Number(width) : 1200;
4648
+ const currentHeight = Number.isFinite(Number(height)) ? Number(height) : 800;
4649
+ const cx = Number.isFinite(Number(focusD.x)) ? Number(focusD.x) : currentWidth / 2;
4650
+ const cy = Number.isFinite(Number(focusD.y)) ? Number(focusD.y) : currentHeight / 2;
4651
+ const selectedLayoutType = normalizeGraphViewText(options.layoutType)
4652
+ || (document.getElementById('focus-layout-select') ? document.getElementById('focus-layout-select').value : '')
4653
+ || 'horizontal';
4654
+ const layoutType = selectedLayoutType === 'vertical' ? 'vertical' : 'horizontal';
4655
+ const defaultLayerGap = focusSpacingSettings && focusSpacingSettings[layoutType]
4656
+ ? Number(focusSpacingSettings[layoutType].layer)
4657
+ : (layoutType === 'horizontal' ? 125 : 250);
4658
+ const defaultNodeGap = focusSpacingSettings && focusSpacingSettings[layoutType]
4659
+ ? Number(focusSpacingSettings[layoutType].node)
4660
+ : (layoutType === 'horizontal' ? 80 : 20);
4661
+ const layerGap = Number.isFinite(Number(options.layerGap)) ? Number(options.layerGap) : defaultLayerGap;
4662
+ const nodeGap = Number.isFinite(Number(options.nodeGap)) ? Number(options.nodeGap) : defaultNodeGap;
4663
+ const projectionNodes = [];
4664
+ const projectionLabels = [];
4665
+ const points = new Map();
4666
+ const addProjectionNode = (node, role, x, y, labelDy, labelDx) => {
4667
+ const id = normalizeGraphViewText(node && node.id);
4668
+ if (!id || points.has(id)) {
4669
+ return;
4670
+ }
4671
+ points.set(id, { x, y });
4672
+ projectionNodes.push({
4673
+ id,
4674
+ label: getGraphViewNodeLabel(node) || id,
4675
+ role,
4676
+ x,
4677
+ y,
4678
+ score: Number(scoreNode(node).toFixed(4)),
4679
+ inDegree: Number(node && node.inDegree) || 0,
4680
+ outDegree: Number(node && node.outDegree) || 0,
4681
+ sourcePath: normalizeGraphViewText(node && (
4682
+ node.sourcePath
4683
+ || node.filepath
4684
+ || (node.metadata && (node.metadata.sourcePath || node.metadata.filepath))
4685
+ )),
4686
+ radius: role === 'anchor' ? 25 : 8,
4687
+ labelDy,
4688
+ labelDx,
4689
+ });
4690
+ };
4691
+ addProjectionNode(focusD, 'anchor', cx, cy, 35, layoutType === 'vertical' ? 25 : 29);
4692
+ if (layoutType === 'vertical') {
4693
+ const spreadVertical = (nodeList, baselineX, role) => {
4694
+ const totalHeight = (nodeList.length - 1) * nodeGap;
4695
+ const startY = cy - totalHeight / 2;
4696
+ nodeList.forEach((node, index) => {
4697
+ addProjectionNode(node, role, baselineX, startY + (index * nodeGap), 25, 25);
4698
+ });
4699
+ };
4700
+ spreadVertical(incomingNodes, cx - layerGap, 'incoming');
4701
+ spreadVertical(outgoingNodes, cx + layerGap, 'outgoing');
4702
+ projectionLabels.push({
4703
+ text: typeof t === 'function' ? t('focus_inbound') : 'Inbound',
4704
+ x: cx - layerGap,
4705
+ y: cy - (incomingNodes.length * nodeGap / 2) - 40,
4706
+ align: 'middle',
4707
+ role: 'incoming',
4708
+ });
4709
+ projectionLabels.push({
4710
+ text: typeof t === 'function' ? t('focus_outbound') : 'Outbound',
4711
+ x: cx + layerGap,
4712
+ y: cy - (outgoingNodes.length * nodeGap / 2) - 40,
4713
+ align: 'middle',
4714
+ role: 'outgoing',
4715
+ });
4716
+ } else {
4717
+ const spreadHorizontal = (nodeList, baselineY, role) => {
4718
+ const totalWidth = (nodeList.length - 1) * nodeGap;
4719
+ const startX = cx - totalWidth / 2;
4720
+ nodeList.forEach((node, index) => {
4721
+ const nodeScore = scoreNode(node);
4722
+ const stagger = (index % 2 === 0 ? -1 : 1) * 20;
4723
+ const nodeY = baselineY + stagger + (nodeScore * 20);
4724
+ addProjectionNode(node, role, startX + (index * nodeGap), nodeY, nodeY < baselineY ? -15 : 25, 12);
4725
+ });
4726
+ };
4727
+ spreadHorizontal(outgoingNodes, cy - layerGap, 'outgoing');
4728
+ spreadHorizontal(incomingNodes, cy + layerGap, 'incoming');
4729
+ projectionLabels.push({
4730
+ text: typeof t === 'function' ? t('focus_outbound') : 'Outbound',
4731
+ x: cx,
4732
+ y: cy - layerGap - 60,
4733
+ align: 'middle',
4734
+ role: 'outgoing',
4735
+ });
4736
+ projectionLabels.push({
4737
+ text: typeof t === 'function' ? t('focus_inbound') : 'Inbound',
4738
+ x: cx,
4739
+ y: cy + layerGap + 80,
4740
+ align: 'middle',
4741
+ role: 'incoming',
4742
+ });
4743
+ }
4744
+ if (associatedNodes.length > 0) {
4745
+ const left = [];
4746
+ const right = [];
4747
+ associatedNodes.forEach((node, index) => {
4748
+ if (index % 2 === 0) {
4749
+ left.push(node);
4750
+ } else {
4751
+ right.push(node);
4752
+ }
4753
+ });
4754
+ const sideGap = layerGap * 1.2;
4755
+ const placeSide = (nodeList, direction) => {
4756
+ nodeList.forEach((node, index) => {
4757
+ addProjectionNode(
4758
+ node,
4759
+ 'associated',
4760
+ cx + (direction * sideGap),
4761
+ cy + (index * 60) - (nodeList.length * 30),
4762
+ 25,
4763
+ 12
4764
+ );
4765
+ });
4766
+ };
4767
+ placeSide(left, -1);
4768
+ placeSide(right, 1);
4769
+ }
2372
4770
 
2373
- // Helper to expose focusOnNode for external modules
2374
- // 为外部模块公开focusOnNode
2375
- window.focusOnNode = function(id) {
2376
- const d = nodes.find(n => n.id === id);
2377
- if (d) {
2378
- // Reuse double click logic or call enterFocusMode directly
2379
- // Clear highlight first
2380
- if (window.highlightManager) {
4771
+ const visibleIds = new Set(projectionNodes.map((node) => node.id));
4772
+ const projectionEdges = linkList
4773
+ .map((linkItem) => {
4774
+ const sourceId = getGraphViewLinkNodeId(linkItem && linkItem.source);
4775
+ const targetId = getGraphViewLinkNodeId(linkItem && linkItem.target);
4776
+ if (!sourceId || !targetId || !visibleIds.has(sourceId) || !visibleIds.has(targetId)) {
4777
+ return null;
4778
+ }
4779
+ return {
4780
+ sourceId,
4781
+ targetId,
4782
+ relationKind: getRelationKind(linkItem),
4783
+ confidence: getLinkWeight(linkItem),
4784
+ role: sourceId === focusD.id
4785
+ ? 'outgoing'
4786
+ : targetId === focusD.id
4787
+ ? 'incoming'
4788
+ : 'associated',
4789
+ };
4790
+ })
4791
+ .filter(Boolean);
4792
+ const maxContextNodes = Number.isFinite(Number(options.maxContextNodes))
4793
+ ? Math.max(0, Math.min(500, Math.trunc(Number(options.maxContextNodes))))
4794
+ : 220;
4795
+ const contextNodes = maxContextNodes > 0
4796
+ ? (Array.isArray(nodes) ? nodes : [])
4797
+ .map((node) => {
4798
+ const id = normalizeGraphViewText(node && node.id);
4799
+ const x = Number(node && node.x);
4800
+ const y = Number(node && node.y);
4801
+ if (!id || visibleIds.has(id) || !Number.isFinite(x) || !Number.isFinite(y)) {
4802
+ return null;
4803
+ }
4804
+ const dx = x - cx;
4805
+ const dy = y - cy;
4806
+ const distance = Math.sqrt((dx * dx) + (dy * dy));
4807
+ const degree = (Number(node && node.inDegree) || 0) + (Number(node && node.outDegree) || 0);
4808
+ const degreeScore = Math.min(degree, 40) / 40;
4809
+ const proximityScore = 1 / (1 + (distance / Math.max(layerGap, nodeGap, 1)));
4810
+ return {
4811
+ id,
4812
+ label: getGraphViewNodeLabel(node) || id,
4813
+ x,
4814
+ y,
4815
+ inDegree: Number(node && node.inDegree) || 0,
4816
+ outDegree: Number(node && node.outDegree) || 0,
4817
+ score: Number(((proximityScore * 0.72) + (degreeScore * 0.28)).toFixed(4)),
4818
+ };
4819
+ })
4820
+ .filter(Boolean)
4821
+ .sort((left, right) => right.score - left.score)
4822
+ .slice(0, maxContextNodes)
4823
+ : [];
4824
+ const boundsPoints = projectionNodes
4825
+ .map((node) => ({ x: node.x, y: node.y }))
4826
+ .concat(projectionLabels.map((label) => ({ x: label.x, y: label.y })));
4827
+ const contextBoundsPoints = contextNodes.length > 0
4828
+ ? contextNodes.map((node) => ({ x: node.x, y: node.y }))
4829
+ : boundsPoints;
4830
+ const minX = Math.min(...boundsPoints.map((point) => point.x));
4831
+ const maxX = Math.max(...boundsPoints.map((point) => point.x));
4832
+ const minY = Math.min(...boundsPoints.map((point) => point.y));
4833
+ const maxY = Math.max(...boundsPoints.map((point) => point.y));
4834
+ const contextMinX = Math.min(...contextBoundsPoints.map((point) => point.x));
4835
+ const contextMaxX = Math.max(...contextBoundsPoints.map((point) => point.x));
4836
+ const contextMinY = Math.min(...contextBoundsPoints.map((point) => point.y));
4837
+ const contextMaxY = Math.max(...contextBoundsPoints.map((point) => point.y));
4838
+ return {
4839
+ anchorId: focusD.id,
4840
+ anchorLabel: getGraphViewNodeLabel(focusD) || focusD.id,
4841
+ layoutType,
4842
+ layerGap,
4843
+ nodeGap,
4844
+ stats: {
4845
+ inDegree: Number(focusD.inDegree) || incomingNodes.length,
4846
+ outDegree: Number(focusD.outDegree) || outgoingNodes.length,
4847
+ incomingCount: incomingNodes.length,
4848
+ outgoingCount: outgoingNodes.length,
4849
+ associatedCount: associatedNodes.length,
4850
+ contextCount: contextNodes.length,
4851
+ },
4852
+ controls: {
4853
+ layoutType,
4854
+ layerGap,
4855
+ nodeGap,
4856
+ rendererMode: document.querySelector('input[name="rendererMode"]:checked')
4857
+ ? document.querySelector('input[name="rendererMode"]:checked').value
4858
+ : '',
4859
+ },
4860
+ labels: projectionLabels,
4861
+ contextNodes,
4862
+ nodes: projectionNodes,
4863
+ edges: projectionEdges,
4864
+ bounds: {
4865
+ minX,
4866
+ maxX,
4867
+ minY,
4868
+ maxY,
4869
+ width: Math.max(1, maxX - minX),
4870
+ height: Math.max(1, maxY - minY),
4871
+ },
4872
+ contextBounds: {
4873
+ minX: contextMinX,
4874
+ maxX: contextMaxX,
4875
+ minY: contextMinY,
4876
+ maxY: contextMaxY,
4877
+ width: Math.max(1, contextMaxX - contextMinX),
4878
+ height: Math.max(1, contextMaxY - contextMinY),
4879
+ },
4880
+ };
4881
+ }
4882
+
4883
+ // Compatibility API for agent workspace graph-focus capabilities.
4884
+ // Keeps agent runtime decoupled from historical global helper names.
4885
+ window.NoteConnectionGraphView = {
4886
+ resolveNodeById: function(nodeId) {
4887
+ const id = String(nodeId || '').trim();
4888
+ if (!id) {
4889
+ return null;
4890
+ }
4891
+ return resolveGraphViewNodeByIdOrLabel(id);
4892
+ },
4893
+ resolveNodeByKnowledgePoint: function(payload) {
4894
+ return resolveGraphViewNodeByKnowledgePoint(payload);
4895
+ },
4896
+ openFocusModeById: function(nodeId) {
4897
+ const node = this.resolveNodeById(nodeId);
4898
+ if (!node) {
4899
+ return false;
4900
+ }
4901
+ if (window.highlightManager && typeof window.highlightManager.unhighlight === 'function') {
2381
4902
  window.highlightManager.unhighlight({ force: true });
2382
4903
  }
2383
-
2384
- // Hide popup
2385
4904
  const popup = document.getElementById('node-stats-popup');
2386
- if (popup) popup.style.display = 'none';
2387
-
2388
- // Enter Focus Mode
2389
- enterFocusMode(d);
2390
- }
4905
+ if (popup) {
4906
+ popup.style.display = 'none';
4907
+ }
4908
+ enterFocusMode(node);
4909
+ return true;
4910
+ },
4911
+ getFocusNode: function() {
4912
+ return focusNode && typeof focusNode === 'object'
4913
+ ? focusNode
4914
+ : null;
4915
+ },
4916
+ getFocusModeSnapshot: function(nodeId) {
4917
+ return buildGraphViewFocusModeSnapshot(nodeId || (focusNode && focusNode.id));
4918
+ },
4919
+ getFocusModeProjection: function(nodeId, options) {
4920
+ return buildGraphViewFocusModeProjection(nodeId || (focusNode && focusNode.id), options || {});
4921
+ },
4922
+ getNodeCount: function() {
4923
+ return Array.isArray(nodes) ? nodes.length : 0;
4924
+ },
2391
4925
  };
2392
4926
 
2393
4927
 
@@ -2984,84 +5518,750 @@ function enterFocusMode(focusDInput) {
2984
5518
  links: workerLinks,
2985
5519
  restart: false // Set data, don't restart yet
2986
5520
  }
2987
- });
5521
+ });
5522
+
5523
+ // 4. Check Freeze Layout State & Restart if needed
5524
+ const isFrozen = document.getElementById('freeze-layout') ? document.getElementById('freeze-layout').checked : false;
5525
+
5526
+ if (isFrozen) {
5527
+ simulation.stop();
5528
+ ticked(); // Force one render to show all nodes in their current (restored) positions
5529
+ } else {
5530
+ simulation.alpha(1).restart();
5531
+ }
5532
+ scheduleGraphSemanticA11yRefresh('Focus mode exited');
5533
+ }
5534
+
5535
+ // Expose Focus Mode functions for Tutorial and External Modules
5536
+ window.enterFocusMode = enterFocusMode;
5537
+ window.exitFocusMode = exitFocusMode;
5538
+
5539
+ // Max Workers (Performance)
5540
+ const workersSlider = document.getElementById('set-workers-slider');
5541
+ const workersInput = document.getElementById('set-workers-input');
5542
+ const gpuCheckbox = document.getElementById('set-gpu');
5543
+
5544
+ if (workersSlider && workersInput) {
5545
+ const updateWorkers = (val) => {
5546
+ const num = parseInt(val);
5547
+ if (isNaN(num) || num < 1) return;
5548
+ workersSlider.value = num;
5549
+ workersInput.value = num;
5550
+ settingsManager.set('performance', 'maxWorkers', num);
5551
+ };
5552
+
5553
+ workersSlider.addEventListener('input', (e) => updateWorkers(e.target.value));
5554
+ workersInput.addEventListener('change', (e) => updateWorkers(e.target.value));
5555
+ }
5556
+
5557
+ if (gpuCheckbox) {
5558
+ gpuCheckbox.addEventListener('change', (e) => {
5559
+ settingsManager.set('performance', 'enableGPU', e.target.checked);
5560
+ });
5561
+ }
5562
+
5563
+ // --- Settings Integration ---
5564
+ function initSettingsUI() {
5565
+ const modal = document.getElementById('settings-modal');
5566
+ const agentModal = document.getElementById('agent-settings-modal');
5567
+ const openBtn = document.getElementById('btn-open-settings');
5568
+ const mainCloseBtns = modal ? modal.querySelectorAll('[data-settings-close="main"]') : [];
5569
+ const agentCloseBtns = agentModal ? agentModal.querySelectorAll('[data-settings-close="agent"]') : [];
5570
+ const openAgentSettingsBtn = document.getElementById('btn-open-agent-settings');
5571
+ const agentSettingsBackBtn = document.getElementById('btn-agent-settings-back');
5572
+ const agentSettingsBackFooterBtn = document.getElementById('btn-agent-settings-back-footer');
5573
+ const resetBtn = document.getElementById('btn-reset-settings');
5574
+
5575
+ // Track settings page state so simulation only resumes after every settings
5576
+ // surface is actually closed.
5577
+ let activeSettingsPage = null;
5578
+
5579
+ // Controls
5580
+ const inputs = {
5581
+ charge: document.getElementById('set-charge'),
5582
+ distance: document.getElementById('set-distance'),
5583
+ collision: document.getElementById('set-collision'),
5584
+ opacity: document.getElementById('set-opacity')
5585
+ };
5586
+
5587
+ const displays = {
5588
+ charge: document.getElementById('val-charge'),
5589
+ distance: document.getElementById('val-distance'),
5590
+ collision: document.getElementById('val-collision'),
5591
+ opacity: document.getElementById('val-opacity')
5592
+ };
5593
+
5594
+ // Performance Controls
5595
+ const workersSlider = document.getElementById('set-workers-slider');
5596
+ const workersInput = document.getElementById('set-workers-input');
5597
+ const gpuCheckbox = document.getElementById('set-gpu');
5598
+ const staticModeCheckbox = document.getElementById('set-static-mode');
5599
+ const gpuRenderingCheckbox = document.getElementById('set-gpu-rendering');
5600
+ const memorySavingCheckbox = document.getElementById('set-memory-saving');
5601
+ const compactModeCheckbox = document.getElementById('set-compact-mode');
5602
+ const deepDebugCheckbox = document.getElementById('set-deep-debug');
5603
+
5604
+ // Reader Settings
5605
+ const inputReadingMode = document.getElementById('set-reading-mode');
5606
+
5607
+ // Simplified NoteMD Provider Settings
5608
+ const providerTemplateSelect = document.getElementById('set-notemd-provider-template');
5609
+ const providerTemplateHint = document.getElementById('set-notemd-provider-template-hint');
5610
+ const providerNameSelect = document.getElementById('set-notemd-provider-name');
5611
+ const providerBaseUrlInput = document.getElementById('set-notemd-provider-base-url');
5612
+ const providerModelInput = document.getElementById('set-notemd-provider-model');
5613
+ const providerApiKeyInput = document.getElementById('set-notemd-provider-api-key');
5614
+ const providerApiVersionInput = document.getElementById('set-notemd-provider-api-version');
5615
+ const providerApiVersionRow = document.getElementById('set-notemd-provider-api-version-row');
5616
+ const providerApiVersionHint = document.getElementById('set-notemd-provider-api-version-hint');
5617
+ const providerStatus = document.getElementById('set-notemd-provider-status');
5618
+ const providerConfigPath = document.getElementById('set-notemd-provider-config-path');
5619
+ const applyProviderTemplateBtn = document.getElementById('btn-apply-notemd-provider-template');
5620
+ const testProviderBtn = document.getElementById('btn-test-notemd-provider');
5621
+ const saveProviderBtn = document.getElementById('btn-save-notemd-provider');
5622
+ const materializeProviderTemplatesBtn = document.getElementById('btn-materialize-notemd-provider-templates');
5623
+ const NOTEMD_PROVIDER_DRAFT_STORAGE_KEY = 'nc.notemdProviderDraft.v1';
5624
+ const NOTEMD_PROVIDER_AUTOSAVE_DEBOUNCE_MS = 900;
5625
+
5626
+ const notemdProviderModalState = {
5627
+ settings: null,
5628
+ templates: [],
5629
+ configPath: '',
5630
+ loading: false,
5631
+ autosaveTimer: null,
5632
+ lastSavedFingerprint: '',
5633
+ applyingDraft: false,
5634
+ };
5635
+
5636
+ const t = (key, fallback, params) => {
5637
+ if (window.i18n && typeof window.i18n.t === 'function') {
5638
+ const translated = window.i18n.t(key, params);
5639
+ if (translated && translated !== key) {
5640
+ return translated;
5641
+ }
5642
+ }
5643
+ return fallback;
5644
+ };
5645
+
5646
+ const buildRuntimeUrl = (resourcePath) => {
5647
+ if (window.NoteConnectionRuntime && typeof window.NoteConnectionRuntime.buildUrl === 'function') {
5648
+ return window.NoteConnectionRuntime.buildUrl(resourcePath.replace(/^\/+/, ''));
5649
+ }
5650
+ return resourcePath;
5651
+ };
5652
+
5653
+ const buildRuntimeFetchOptions = (init) => {
5654
+ if (window.NoteConnectionRuntime && typeof window.NoteConnectionRuntime.buildFetchOptions === 'function') {
5655
+ return window.NoteConnectionRuntime.buildFetchOptions(init);
5656
+ }
5657
+ return init;
5658
+ };
5659
+
5660
+ const cloneJson = (value) => JSON.parse(JSON.stringify(value));
5661
+
5662
+ const clearProviderAutosaveTimer = () => {
5663
+ if (notemdProviderModalState.autosaveTimer) {
5664
+ clearTimeout(notemdProviderModalState.autosaveTimer);
5665
+ notemdProviderModalState.autosaveTimer = null;
5666
+ }
5667
+ };
5668
+
5669
+ const readProviderDraftStore = () => {
5670
+ try {
5671
+ const raw = localStorage.getItem(NOTEMD_PROVIDER_DRAFT_STORAGE_KEY);
5672
+ const parsed = raw ? JSON.parse(raw) : {};
5673
+ return parsed && typeof parsed === 'object' ? parsed : {};
5674
+ } catch (_error) {
5675
+ return {};
5676
+ }
5677
+ };
5678
+
5679
+ const writeProviderDraftStore = (draftStore) => {
5680
+ try {
5681
+ localStorage.setItem(NOTEMD_PROVIDER_DRAFT_STORAGE_KEY, JSON.stringify(draftStore || {}));
5682
+ } catch (_error) {
5683
+ // Best effort only.
5684
+ }
5685
+ };
5686
+
5687
+ const getProviderDraft = (providerName) => {
5688
+ const draftStore = readProviderDraftStore();
5689
+ const key = String(providerName || '').trim();
5690
+ if (!key || !draftStore[key] || typeof draftStore[key] !== 'object') {
5691
+ return null;
5692
+ }
5693
+ return draftStore[key];
5694
+ };
5695
+
5696
+ const updateProviderDraft = (providerName, draftPatch) => {
5697
+ const key = String(providerName || '').trim();
5698
+ if (!key) {
5699
+ return;
5700
+ }
5701
+ const draftStore = readProviderDraftStore();
5702
+ const current = draftStore[key] && typeof draftStore[key] === 'object' ? draftStore[key] : {};
5703
+ draftStore[key] = {
5704
+ ...current,
5705
+ ...draftPatch,
5706
+ updatedAt: new Date().toISOString(),
5707
+ };
5708
+ writeProviderDraftStore(draftStore);
5709
+ };
5710
+
5711
+ const removeProviderDraft = (providerName) => {
5712
+ const key = String(providerName || '').trim();
5713
+ if (!key) {
5714
+ return;
5715
+ }
5716
+ const draftStore = readProviderDraftStore();
5717
+ if (Object.prototype.hasOwnProperty.call(draftStore, key)) {
5718
+ delete draftStore[key];
5719
+ writeProviderDraftStore(draftStore);
5720
+ }
5721
+ };
5722
+
5723
+ const setProviderModalStatus = (message, tone = 'muted') => {
5724
+ if (!providerStatus) {
5725
+ return;
5726
+ }
5727
+ providerStatus.textContent = String(message || '');
5728
+ providerStatus.style.color =
5729
+ tone === 'error'
5730
+ ? '#fca5a5'
5731
+ : tone === 'success'
5732
+ ? '#93c5fd'
5733
+ : '#aeb7c3';
5734
+ };
5735
+
5736
+ const setProviderModalBusy = (busy) => {
5737
+ notemdProviderModalState.loading = busy === true;
5738
+ const disabled = notemdProviderModalState.loading;
5739
+ [
5740
+ providerTemplateSelect,
5741
+ providerNameSelect,
5742
+ providerBaseUrlInput,
5743
+ providerModelInput,
5744
+ providerApiKeyInput,
5745
+ providerApiVersionInput,
5746
+ applyProviderTemplateBtn,
5747
+ testProviderBtn,
5748
+ saveProviderBtn,
5749
+ materializeProviderTemplatesBtn,
5750
+ ].forEach((element) => {
5751
+ if (element) {
5752
+ element.disabled = disabled;
5753
+ }
5754
+ });
5755
+ };
5756
+
5757
+ const ensureRuntimeBridgeReady = async () => {
5758
+ if (
5759
+ window.NoteConnectionRuntime
5760
+ && typeof window.NoteConnectionRuntime.whenReady === 'function'
5761
+ ) {
5762
+ await window.NoteConnectionRuntime.whenReady();
5763
+ }
5764
+ };
5765
+
5766
+ const refreshRuntimeBridgeFromTauri = async () => {
5767
+ if (
5768
+ !window.NoteConnectionRuntime
5769
+ || typeof window.NoteConnectionRuntime.refreshFromTauri !== 'function'
5770
+ ) {
5771
+ return false;
5772
+ }
5773
+ try {
5774
+ await window.NoteConnectionRuntime.refreshFromTauri();
5775
+ return true;
5776
+ } catch (error) {
5777
+ console.warn('[Settings] Runtime bridge refresh failed before retrying agent settings request.', error);
5778
+ return false;
5779
+ }
5780
+ };
5781
+
5782
+ const fetchRuntimeJsonOnce = async (resourcePath, init) => {
5783
+ await ensureRuntimeBridgeReady();
5784
+ const response = await fetch(buildRuntimeUrl(resourcePath), buildRuntimeFetchOptions(init));
5785
+ const payload = await response.json().catch(() => null);
5786
+ return { response, payload };
5787
+ };
5788
+
5789
+ const isSuccessfulRuntimeJsonResponse = ({ response, payload }) => (
5790
+ response.ok && payload && payload.success === true
5791
+ );
5792
+
5793
+ const buildRuntimeJsonError = (resourcePath, { response, payload }) => {
5794
+ const message = payload && payload.error
5795
+ ? String(payload.error)
5796
+ : `Request failed (${resourcePath} ${response.status})`;
5797
+ return new Error(message);
5798
+ };
5799
+
5800
+ const requestRuntimeJson = async (resourcePath, init = {}) => {
5801
+ const firstAttempt = await fetchRuntimeJsonOnce(resourcePath, init);
5802
+ if (isSuccessfulRuntimeJsonResponse(firstAttempt)) {
5803
+ return firstAttempt.payload;
5804
+ }
5805
+
5806
+ if (firstAttempt.response.status === 401 && await refreshRuntimeBridgeFromTauri()) {
5807
+ const secondAttempt = await fetchRuntimeJsonOnce(resourcePath, init);
5808
+ if (isSuccessfulRuntimeJsonResponse(secondAttempt)) {
5809
+ return secondAttempt.payload;
5810
+ }
5811
+ throw buildRuntimeJsonError(resourcePath, secondAttempt);
5812
+ }
5813
+
5814
+ throw buildRuntimeJsonError(resourcePath, firstAttempt);
5815
+ };
5816
+
5817
+ const isAnySettingsPanelOpen = () => activeSettingsPage !== null;
5818
+
5819
+ const resumeSimulationIfAllowed = () => {
5820
+ const isFrozen = document.getElementById('freeze-layout')
5821
+ ? document.getElementById('freeze-layout').checked
5822
+ : false;
5823
+ if (!isFrozen) {
5824
+ simulation.alpha(0.3).restart();
5825
+ }
5826
+ };
5827
+
5828
+ const showMainSettingsPage = () => {
5829
+ if (modal) {
5830
+ modal.style.display = 'flex';
5831
+ const body = modal.querySelector('.settings-modal-body');
5832
+ if (body) {
5833
+ body.scrollTop = 0;
5834
+ }
5835
+ }
5836
+ if (agentModal) {
5837
+ agentModal.style.display = 'none';
5838
+ }
5839
+ activeSettingsPage = 'main';
5840
+ simulation.stop();
5841
+ };
5842
+
5843
+ const showAgentSettingsPage = async () => {
5844
+ if (modal) {
5845
+ modal.style.display = 'none';
5846
+ }
5847
+ if (agentModal) {
5848
+ agentModal.style.display = 'flex';
5849
+ const body = agentModal.querySelector('.settings-modal-body');
5850
+ if (body) {
5851
+ body.scrollTop = 0;
5852
+ }
5853
+ }
5854
+ activeSettingsPage = 'agent';
5855
+ simulation.stop();
5856
+ await loadNotemdProviderModalState();
5857
+ };
5858
+
5859
+ const closeAllSettingsPages = () => {
5860
+ clearProviderAutosaveTimer();
5861
+ const nextFingerprint = getCurrentProviderFingerprint();
5862
+ if (
5863
+ activeSettingsPage === 'agent'
5864
+ && nextFingerprint
5865
+ && nextFingerprint !== notemdProviderModalState.lastSavedFingerprint
5866
+ && !notemdProviderModalState.loading
5867
+ ) {
5868
+ void saveNotemdProviderModalState({ auto: true }).catch((error) => {
5869
+ setProviderModalStatus(
5870
+ t('notemd_provider_autosave_failed', 'Auto-save failed: {error}', {
5871
+ error: error && error.message ? error.message : String(error),
5872
+ }),
5873
+ 'error'
5874
+ );
5875
+ });
5876
+ }
5877
+ if (modal) {
5878
+ modal.style.display = 'none';
5879
+ }
5880
+ if (agentModal) {
5881
+ agentModal.style.display = 'none';
5882
+ }
5883
+ activeSettingsPage = null;
5884
+ resumeSimulationIfAllowed();
5885
+ };
5886
+
5887
+ const getCurrentProviderFromModalState = () => {
5888
+ if (!notemdProviderModalState.settings || !providerNameSelect) {
5889
+ return null;
5890
+ }
5891
+ const providerName = String(providerNameSelect.value || '').trim();
5892
+ return notemdProviderModalState.settings.providers.find((provider) => provider.name === providerName) || null;
5893
+ };
5894
+
5895
+ const getCurrentProviderFingerprint = () => {
5896
+ const providerName = String(providerNameSelect ? providerNameSelect.value : '').trim();
5897
+ if (!providerName) {
5898
+ return '';
5899
+ }
5900
+ return JSON.stringify({
5901
+ providerName,
5902
+ templateId: String(providerTemplateSelect ? providerTemplateSelect.value : '').trim(),
5903
+ baseUrl: String(providerBaseUrlInput ? providerBaseUrlInput.value : '').trim(),
5904
+ model: String(providerModelInput ? providerModelInput.value : '').trim(),
5905
+ apiKey: String(providerApiKeyInput ? providerApiKeyInput.value : ''),
5906
+ apiVersion: String(providerApiVersionInput ? providerApiVersionInput.value : '').trim(),
5907
+ });
5908
+ };
5909
+
5910
+ const buildCurrentProviderRequestPayload = () => {
5911
+ const providerName = String(providerNameSelect ? providerNameSelect.value : '').trim();
5912
+ const persistedProvider = getCurrentProviderFromModalState();
5913
+ return {
5914
+ provider: {
5915
+ name: providerName,
5916
+ baseUrl: String(providerBaseUrlInput ? providerBaseUrlInput.value : '').trim(),
5917
+ model: String(providerModelInput ? providerModelInput.value : '').trim(),
5918
+ apiKey: String(providerApiKeyInput ? providerApiKeyInput.value : ''),
5919
+ apiVersion: String(providerApiVersionInput ? providerApiVersionInput.value : '').trim(),
5920
+ temperature: Number.isFinite(Number(persistedProvider && persistedProvider.temperature))
5921
+ ? Number(persistedProvider.temperature)
5922
+ : 0.5,
5923
+ },
5924
+ providerName,
5925
+ };
5926
+ };
5927
+
5928
+ const syncCurrentProviderDraft = () => {
5929
+ const providerName = String(providerNameSelect ? providerNameSelect.value : '').trim();
5930
+ if (!providerName || notemdProviderModalState.applyingDraft) {
5931
+ return;
5932
+ }
5933
+ updateProviderDraft(providerName, {
5934
+ templateId: String(providerTemplateSelect ? providerTemplateSelect.value : '').trim(),
5935
+ baseUrl: String(providerBaseUrlInput ? providerBaseUrlInput.value : '').trim(),
5936
+ model: String(providerModelInput ? providerModelInput.value : '').trim(),
5937
+ apiKey: String(providerApiKeyInput ? providerApiKeyInput.value : ''),
5938
+ apiVersion: String(providerApiVersionInput ? providerApiVersionInput.value : '').trim(),
5939
+ });
5940
+ };
2988
5941
 
2989
- // 4. Check Freeze Layout State & Restart if needed
2990
- const isFrozen = document.getElementById('freeze-layout') ? document.getElementById('freeze-layout').checked : false;
5942
+ const findTemplateForProvider = (providerName) => {
5943
+ const templates = Array.isArray(notemdProviderModalState.templates) ? notemdProviderModalState.templates : [];
5944
+ return templates.find((template) => template.providerName === providerName) || null;
5945
+ };
2991
5946
 
2992
- if (isFrozen) {
2993
- simulation.stop();
2994
- ticked(); // Force one render to show all nodes in their current (restored) positions
2995
- } else {
2996
- simulation.alpha(1).restart();
2997
- }
2998
- scheduleGraphSemanticA11yRefresh('Focus mode exited');
2999
- }
5947
+ const updateProviderApiVersionVisibility = (provider) => {
5948
+ if (!providerApiVersionRow) {
5949
+ return;
5950
+ }
5951
+ const isAzure = provider && String(provider.name || '').trim() === 'Azure OpenAI';
5952
+ providerApiVersionRow.style.display = isAzure ? '' : 'none';
5953
+ if (providerApiVersionInput) {
5954
+ providerApiVersionInput.placeholder = isAzure ? '2025-01-01-preview' : '';
5955
+ }
5956
+ if (providerApiVersionHint) {
5957
+ providerApiVersionHint.textContent = isAzure
5958
+ ? t('notemd_provider_api_version_required', 'Required for Azure OpenAI. Example: 2025-01-01-preview.')
5959
+ : t('notemd_provider_api_version_optional', 'Optional. Leave blank for standard OpenAI-compatible endpoints. This is mainly used by Azure OpenAI.');
5960
+ }
5961
+ };
3000
5962
 
3001
- // Expose Focus Mode functions for Tutorial and External Modules
3002
- window.enterFocusMode = enterFocusMode;
3003
- window.exitFocusMode = exitFocusMode;
5963
+ const applyProviderDraftToFields = (providerName) => {
5964
+ const draft = getProviderDraft(providerName);
5965
+ if (!draft) {
5966
+ return;
5967
+ }
5968
+ notemdProviderModalState.applyingDraft = true;
5969
+ try {
5970
+ if (providerTemplateSelect && typeof draft.templateId === 'string' && draft.templateId) {
5971
+ providerTemplateSelect.value = draft.templateId;
5972
+ renderProviderTemplateHint();
5973
+ }
5974
+ if (providerBaseUrlInput && typeof draft.baseUrl === 'string') {
5975
+ providerBaseUrlInput.value = draft.baseUrl;
5976
+ }
5977
+ if (providerModelInput && typeof draft.model === 'string') {
5978
+ providerModelInput.value = draft.model;
5979
+ }
5980
+ if (providerApiKeyInput && typeof draft.apiKey === 'string') {
5981
+ providerApiKeyInput.value = draft.apiKey;
5982
+ }
5983
+ if (providerApiVersionInput && typeof draft.apiVersion === 'string') {
5984
+ providerApiVersionInput.value = draft.apiVersion;
5985
+ }
5986
+ } finally {
5987
+ notemdProviderModalState.applyingDraft = false;
5988
+ }
5989
+ };
3004
5990
 
3005
- // Max Workers (Performance)
3006
- const workersSlider = document.getElementById('set-workers-slider');
3007
- const workersInput = document.getElementById('set-workers-input');
3008
- const gpuCheckbox = document.getElementById('set-gpu');
5991
+ const renderProviderTemplateHint = () => {
5992
+ if (!providerTemplateHint) {
5993
+ return;
5994
+ }
5995
+ const templateId = providerTemplateSelect ? String(providerTemplateSelect.value || '').trim() : '';
5996
+ const templates = Array.isArray(notemdProviderModalState.templates) ? notemdProviderModalState.templates : [];
5997
+ const template = templates.find((item) => item.id === templateId) || null;
5998
+ if (!template) {
5999
+ providerTemplateHint.textContent = '';
6000
+ return;
6001
+ }
6002
+ const noteSummary = Array.isArray(template.notes) ? template.notes.slice(0, 2).join(' ') : '';
6003
+ providerTemplateHint.textContent = `${template.label} · ${template.category} · ${template.transport}. ${template.recommendedFor} ${template.hostHint} ${noteSummary}`.trim();
6004
+ };
3009
6005
 
3010
- if (workersSlider && workersInput) {
3011
- const updateWorkers = (val) => {
3012
- const num = parseInt(val);
3013
- if (isNaN(num) || num < 1) return;
3014
- workersSlider.value = num;
3015
- workersInput.value = num;
3016
- settingsManager.set('performance', 'maxWorkers', num);
3017
- };
6006
+ const renderSelectedProviderFields = () => {
6007
+ const provider = getCurrentProviderFromModalState();
6008
+ if (!provider) {
6009
+ if (providerBaseUrlInput) providerBaseUrlInput.value = '';
6010
+ if (providerModelInput) providerModelInput.value = '';
6011
+ if (providerApiKeyInput) providerApiKeyInput.value = '';
6012
+ if (providerApiVersionInput) providerApiVersionInput.value = '';
6013
+ updateProviderApiVersionVisibility(null);
6014
+ return;
6015
+ }
6016
+ if (providerBaseUrlInput) providerBaseUrlInput.value = String(provider.baseUrl || '');
6017
+ if (providerModelInput) providerModelInput.value = String(provider.model || '');
6018
+ if (providerApiKeyInput) providerApiKeyInput.value = String(provider.apiKey || '');
6019
+ if (providerApiVersionInput) providerApiVersionInput.value = String(provider.apiVersion || '');
6020
+ updateProviderApiVersionVisibility(provider);
6021
+
6022
+ const matchedTemplate = findTemplateForProvider(provider.name);
6023
+ if (providerTemplateSelect && matchedTemplate) {
6024
+ providerTemplateSelect.value = matchedTemplate.id;
6025
+ renderProviderTemplateHint();
6026
+ }
6027
+ applyProviderDraftToFields(provider.name);
6028
+ };
3018
6029
 
3019
- workersSlider.addEventListener('input', (e) => updateWorkers(e.target.value));
3020
- workersInput.addEventListener('change', (e) => updateWorkers(e.target.value));
3021
- }
3022
-
3023
- if (gpuCheckbox) {
3024
- gpuCheckbox.addEventListener('change', (e) => {
3025
- settingsManager.set('performance', 'enableGPU', e.target.checked);
6030
+ const populateProviderTemplateSelect = () => {
6031
+ if (!providerTemplateSelect) {
6032
+ return;
6033
+ }
6034
+ providerTemplateSelect.innerHTML = '';
6035
+ const placeholder = document.createElement('option');
6036
+ placeholder.value = '';
6037
+ placeholder.textContent = t('notemd_provider_template_select', 'Choose a preset template');
6038
+ providerTemplateSelect.appendChild(placeholder);
6039
+ const templates = Array.isArray(notemdProviderModalState.templates) ? notemdProviderModalState.templates : [];
6040
+ templates.forEach((template) => {
6041
+ const option = document.createElement('option');
6042
+ option.value = template.id;
6043
+ option.textContent = `${template.label} (${template.category})`;
6044
+ providerTemplateSelect.appendChild(option);
3026
6045
  });
3027
- }
6046
+ };
3028
6047
 
3029
- // --- Settings Integration ---
3030
- function initSettingsUI() {
3031
- const modal = document.getElementById('settings-modal');
3032
- const openBtn = document.getElementById('btn-open-settings');
3033
- const closeBtns = document.querySelectorAll('.modal-close');
3034
- const resetBtn = document.getElementById('btn-reset-settings');
3035
-
3036
- // v0.9.41: Track modal state to freeze simulation
3037
- let isSettingsModalOpen = false;
6048
+ const populateProviderSelect = () => {
6049
+ if (!providerNameSelect) {
6050
+ return;
6051
+ }
6052
+ providerNameSelect.innerHTML = '';
6053
+ const settings = notemdProviderModalState.settings;
6054
+ const providers = settings && Array.isArray(settings.providers) ? settings.providers : [];
6055
+ providers.forEach((provider) => {
6056
+ const option = document.createElement('option');
6057
+ option.value = provider.name;
6058
+ option.textContent = provider.name;
6059
+ providerNameSelect.appendChild(option);
6060
+ });
6061
+ if (settings && settings.activeProvider) {
6062
+ providerNameSelect.value = settings.activeProvider;
6063
+ }
6064
+ renderSelectedProviderFields();
6065
+ };
3038
6066
 
3039
- // Controls
3040
- const inputs = {
3041
- charge: document.getElementById('set-charge'),
3042
- distance: document.getElementById('set-distance'),
3043
- collision: document.getElementById('set-collision'),
3044
- opacity: document.getElementById('set-opacity')
6067
+ const loadNotemdProviderModalState = async (options = {}) => {
6068
+ if (notemdProviderModalState.loading) {
6069
+ return;
6070
+ }
6071
+ setProviderModalBusy(true);
6072
+ setProviderModalStatus(t('notemd_provider_loading', 'Loading provider settings...'));
6073
+ try {
6074
+ const templatesPayload = await requestRuntimeJson('/api/notemd/provider-templates?persist=1');
6075
+ const settingsPayload = await requestRuntimeJson('/api/notemd/settings');
6076
+ notemdProviderModalState.templates = Array.isArray(templatesPayload.templates)
6077
+ ? cloneJson(templatesPayload.templates)
6078
+ : [];
6079
+ notemdProviderModalState.configPath = String(templatesPayload.configPath || '');
6080
+ notemdProviderModalState.settings = cloneJson(settingsPayload.settings || {});
6081
+ populateProviderTemplateSelect();
6082
+ populateProviderSelect();
6083
+ if (providerConfigPath) {
6084
+ providerConfigPath.textContent = notemdProviderModalState.configPath
6085
+ ? `${t('notemd_provider_config_path', 'TOML path')}: ${notemdProviderModalState.configPath}`
6086
+ : '';
6087
+ }
6088
+ notemdProviderModalState.lastSavedFingerprint = getCurrentProviderFingerprint();
6089
+ setProviderModalStatus(
6090
+ t('notemd_provider_loaded', 'Provider presets and TOML templates are ready.'),
6091
+ 'success'
6092
+ );
6093
+ } catch (error) {
6094
+ setProviderModalStatus(
6095
+ t('notemd_provider_load_failed', 'Provider settings load failed: {error}', {
6096
+ error: error && error.message ? error.message : String(error),
6097
+ }),
6098
+ 'error'
6099
+ );
6100
+ } finally {
6101
+ setProviderModalBusy(false);
6102
+ }
3045
6103
  };
3046
6104
 
3047
- const displays = {
3048
- charge: document.getElementById('val-charge'),
3049
- distance: document.getElementById('val-distance'),
3050
- collision: document.getElementById('val-collision'),
3051
- opacity: document.getElementById('val-opacity')
6105
+ const saveNotemdProviderModalState = async (options = {}) => {
6106
+ if (!notemdProviderModalState.settings || !providerNameSelect) {
6107
+ return;
6108
+ }
6109
+ const auto = options.auto === true;
6110
+ const nextSettings = cloneJson(notemdProviderModalState.settings);
6111
+ const providerName = String(providerNameSelect.value || '').trim();
6112
+ const nextProvider = nextSettings.providers.find((provider) => provider.name === providerName);
6113
+ if (!nextProvider) {
6114
+ throw new Error(`Unsupported provider: ${providerName}`);
6115
+ }
6116
+ nextSettings.activeProvider = providerName;
6117
+ nextProvider.baseUrl = String(providerBaseUrlInput ? providerBaseUrlInput.value : '').trim();
6118
+ nextProvider.model = String(providerModelInput ? providerModelInput.value : '').trim();
6119
+ nextProvider.apiKey = String(providerApiKeyInput ? providerApiKeyInput.value : '');
6120
+ nextProvider.apiVersion = String(providerApiVersionInput ? providerApiVersionInput.value : '').trim();
6121
+ setProviderModalBusy(true);
6122
+ setProviderModalStatus(
6123
+ auto
6124
+ ? t('notemd_provider_autosaving', 'Auto-saving provider settings...')
6125
+ : t('notemd_provider_saving', 'Saving provider settings...')
6126
+ );
6127
+ try {
6128
+ const payload = await requestRuntimeJson('/api/notemd/settings', {
6129
+ method: 'POST',
6130
+ headers: { 'Content-Type': 'application/json' },
6131
+ body: JSON.stringify({ settings: nextSettings }),
6132
+ });
6133
+ notemdProviderModalState.settings = cloneJson(payload.settings || nextSettings);
6134
+ populateProviderSelect();
6135
+ notemdProviderModalState.lastSavedFingerprint = getCurrentProviderFingerprint();
6136
+ removeProviderDraft(providerName);
6137
+ setProviderModalStatus(
6138
+ auto
6139
+ ? t('notemd_provider_autosaved', 'Provider settings auto-saved.')
6140
+ : t('notemd_provider_saved', 'Provider settings saved to app_config.toml.'),
6141
+ 'success'
6142
+ );
6143
+ } finally {
6144
+ setProviderModalBusy(false);
6145
+ }
6146
+ };
6147
+
6148
+ const scheduleProviderAutosave = () => {
6149
+ if (notemdProviderModalState.loading || notemdProviderModalState.applyingDraft) {
6150
+ return;
6151
+ }
6152
+ const providerName = String(providerNameSelect ? providerNameSelect.value : '').trim();
6153
+ if (!providerName || !notemdProviderModalState.settings) {
6154
+ return;
6155
+ }
6156
+ const nextFingerprint = getCurrentProviderFingerprint();
6157
+ if (!nextFingerprint || nextFingerprint === notemdProviderModalState.lastSavedFingerprint) {
6158
+ return;
6159
+ }
6160
+ clearProviderAutosaveTimer();
6161
+ notemdProviderModalState.autosaveTimer = setTimeout(() => {
6162
+ notemdProviderModalState.autosaveTimer = null;
6163
+ void saveNotemdProviderModalState({ auto: true }).catch((error) => {
6164
+ setProviderModalStatus(
6165
+ t('notemd_provider_autosave_failed', 'Auto-save failed: {error}', {
6166
+ error: error && error.message ? error.message : String(error),
6167
+ }),
6168
+ 'error'
6169
+ );
6170
+ });
6171
+ }, NOTEMD_PROVIDER_AUTOSAVE_DEBOUNCE_MS);
6172
+ };
6173
+
6174
+ const applyNotemdProviderTemplate = async () => {
6175
+ if (!providerTemplateSelect) {
6176
+ return;
6177
+ }
6178
+ const templateId = String(providerTemplateSelect.value || '').trim();
6179
+ if (!templateId) {
6180
+ setProviderModalStatus(t('notemd_provider_template_required', 'Choose a preset template first.'), 'error');
6181
+ return;
6182
+ }
6183
+ setProviderModalBusy(true);
6184
+ setProviderModalStatus(t('notemd_provider_template_applying', 'Applying provider preset...'));
6185
+ try {
6186
+ const payload = await requestRuntimeJson('/api/notemd/provider-templates/apply', {
6187
+ method: 'POST',
6188
+ headers: { 'Content-Type': 'application/json' },
6189
+ body: JSON.stringify({ templateId }),
6190
+ });
6191
+ notemdProviderModalState.settings = cloneJson(payload.settings || {});
6192
+ if (payload.configPath) {
6193
+ notemdProviderModalState.configPath = String(payload.configPath);
6194
+ }
6195
+ populateProviderSelect();
6196
+ if (providerConfigPath) {
6197
+ providerConfigPath.textContent = notemdProviderModalState.configPath
6198
+ ? `${t('notemd_provider_config_path', 'TOML path')}: ${notemdProviderModalState.configPath}`
6199
+ : '';
6200
+ }
6201
+ notemdProviderModalState.lastSavedFingerprint = getCurrentProviderFingerprint();
6202
+ removeProviderDraft(String(providerNameSelect ? providerNameSelect.value : '').trim());
6203
+ setProviderModalStatus(
6204
+ t('notemd_provider_template_applied', 'Preset applied and saved to app_config.toml.'),
6205
+ 'success'
6206
+ );
6207
+ } finally {
6208
+ setProviderModalBusy(false);
6209
+ }
6210
+ };
6211
+
6212
+ const materializeNotemdProviderTemplates = async () => {
6213
+ setProviderModalBusy(true);
6214
+ setProviderModalStatus(t('notemd_provider_templates_writing', 'Writing TOML provider templates...'));
6215
+ try {
6216
+ const payload = await requestRuntimeJson('/api/notemd/provider-templates?persist=1');
6217
+ notemdProviderModalState.templates = Array.isArray(payload.templates)
6218
+ ? cloneJson(payload.templates)
6219
+ : [];
6220
+ notemdProviderModalState.configPath = String(payload.configPath || notemdProviderModalState.configPath || '');
6221
+ populateProviderTemplateSelect();
6222
+ renderProviderTemplateHint();
6223
+ if (providerConfigPath) {
6224
+ providerConfigPath.textContent = notemdProviderModalState.configPath
6225
+ ? `${t('notemd_provider_config_path', 'TOML path')}: ${notemdProviderModalState.configPath}`
6226
+ : '';
6227
+ }
6228
+ setProviderModalStatus(
6229
+ payload.persisted
6230
+ ? t('notemd_provider_templates_written', 'Provider templates were written to app_config.toml.')
6231
+ : t('notemd_provider_templates_present', 'Provider templates are already present in app_config.toml.'),
6232
+ 'success'
6233
+ );
6234
+ } finally {
6235
+ setProviderModalBusy(false);
6236
+ }
6237
+ };
6238
+
6239
+ const testNotemdProviderConnection = async () => {
6240
+ const payload = buildCurrentProviderRequestPayload();
6241
+ if (!payload.providerName) {
6242
+ throw new Error('Provider name is required.');
6243
+ }
6244
+
6245
+ setProviderModalBusy(true);
6246
+ setProviderModalStatus(
6247
+ t('btn_test_provider_connection', 'Test Connection') + '...',
6248
+ 'muted'
6249
+ );
6250
+ try {
6251
+ const response = await requestRuntimeJson('/api/notemd/test-llm', {
6252
+ method: 'POST',
6253
+ headers: { 'Content-Type': 'application/json' },
6254
+ body: JSON.stringify(payload),
6255
+ });
6256
+ const result = response.result || {};
6257
+ setProviderModalStatus(
6258
+ String(result.message || `Connected to ${payload.providerName}.`),
6259
+ result.success === false ? 'error' : 'success'
6260
+ );
6261
+ } finally {
6262
+ setProviderModalBusy(false);
6263
+ }
3052
6264
  };
3053
-
3054
- // Performance Controls
3055
- const workersSlider = document.getElementById('set-workers-slider');
3056
- const workersInput = document.getElementById('set-workers-input');
3057
- const gpuCheckbox = document.getElementById('set-gpu');
3058
- const gpuRenderingCheckbox = document.getElementById('set-gpu-rendering');
3059
- const memorySavingCheckbox = document.getElementById('set-memory-saving');
3060
- const compactModeCheckbox = document.getElementById('set-compact-mode');
3061
- const deepDebugCheckbox = document.getElementById('set-deep-debug');
3062
-
3063
- // Reader Settings
3064
- const inputReadingMode = document.getElementById('set-reading-mode');
3065
6265
 
3066
6266
  // Load initial values
3067
6267
  const updateUIFromSettings = (settings) => {
@@ -3104,6 +6304,9 @@ function initSettingsUI() {
3104
6304
  if (settings.performance.enableGPU !== undefined) {
3105
6305
  if (gpuCheckbox) gpuCheckbox.checked = settings.performance.enableGPU;
3106
6306
  }
6307
+ if (settings.performance.staticMode !== undefined) {
6308
+ if (staticModeCheckbox) staticModeCheckbox.checked = settings.performance.staticMode;
6309
+ }
3107
6310
  if (settings.performance.gpuRendering !== undefined) {
3108
6311
  if (gpuRenderingCheckbox) gpuRenderingCheckbox.checked = settings.performance.gpuRendering;
3109
6312
  }
@@ -3166,6 +6369,12 @@ function initSettingsUI() {
3166
6369
  });
3167
6370
  }
3168
6371
 
6372
+ if (staticModeCheckbox) {
6373
+ staticModeCheckbox.addEventListener('change', (e) => {
6374
+ settingsManager.set('performance', 'staticMode', e.target.checked);
6375
+ });
6376
+ }
6377
+
3169
6378
  if (gpuRenderingCheckbox) {
3170
6379
  gpuRenderingCheckbox.addEventListener('change', (e) => {
3171
6380
  settingsManager.set('performance', 'gpuRendering', e.target.checked);
@@ -3211,33 +6420,132 @@ function initSettingsUI() {
3211
6420
  settingsManager.set('reading', 'mode', e.target.value);
3212
6421
  });
3213
6422
 
3214
- // Helper to close settings
3215
- const closeSettings = () => {
3216
- modal.style.display = 'none';
3217
- isSettingsModalOpen = false;
3218
- // Resume if not globally frozen
3219
- const isFrozen = document.getElementById('freeze-layout') ? document.getElementById('freeze-layout').checked : false;
3220
- if (!isFrozen) {
3221
- simulation.alpha(0.3).restart();
6423
+ if (providerTemplateSelect) {
6424
+ providerTemplateSelect.addEventListener('change', () => {
6425
+ renderProviderTemplateHint();
6426
+ syncCurrentProviderDraft();
6427
+ scheduleProviderAutosave();
6428
+ });
6429
+ }
6430
+
6431
+ if (providerNameSelect) {
6432
+ providerNameSelect.addEventListener('change', () => {
6433
+ renderSelectedProviderFields();
6434
+ syncCurrentProviderDraft();
6435
+ scheduleProviderAutosave();
6436
+ });
6437
+ }
6438
+
6439
+ [
6440
+ providerBaseUrlInput,
6441
+ providerModelInput,
6442
+ providerApiKeyInput,
6443
+ providerApiVersionInput,
6444
+ ].forEach((input) => {
6445
+ if (!input) {
6446
+ return;
3222
6447
  }
3223
- };
6448
+ input.addEventListener('input', () => {
6449
+ syncCurrentProviderDraft();
6450
+ scheduleProviderAutosave();
6451
+ });
6452
+ input.addEventListener('change', () => {
6453
+ syncCurrentProviderDraft();
6454
+ scheduleProviderAutosave();
6455
+ });
6456
+ });
6457
+
6458
+ if (applyProviderTemplateBtn) {
6459
+ applyProviderTemplateBtn.addEventListener('click', () => {
6460
+ void applyNotemdProviderTemplate().catch((error) => {
6461
+ setProviderModalStatus(
6462
+ t('notemd_provider_template_apply_failed', 'Provider preset apply failed: {error}', {
6463
+ error: error && error.message ? error.message : String(error),
6464
+ }),
6465
+ 'error'
6466
+ );
6467
+ });
6468
+ });
6469
+ }
6470
+
6471
+ if (testProviderBtn) {
6472
+ testProviderBtn.addEventListener('click', () => {
6473
+ void testNotemdProviderConnection().catch((error) => {
6474
+ setProviderModalStatus(
6475
+ error && error.message ? error.message : String(error),
6476
+ 'error'
6477
+ );
6478
+ });
6479
+ });
6480
+ }
6481
+
6482
+ if (saveProviderBtn) {
6483
+ saveProviderBtn.addEventListener('click', () => {
6484
+ void saveNotemdProviderModalState().catch((error) => {
6485
+ setProviderModalStatus(
6486
+ t('notemd_provider_save_failed', 'Provider save failed: {error}', {
6487
+ error: error && error.message ? error.message : String(error),
6488
+ }),
6489
+ 'error'
6490
+ );
6491
+ });
6492
+ });
6493
+ }
6494
+
6495
+ if (materializeProviderTemplatesBtn) {
6496
+ materializeProviderTemplatesBtn.addEventListener('click', () => {
6497
+ void materializeNotemdProviderTemplates().catch((error) => {
6498
+ setProviderModalStatus(
6499
+ t('notemd_provider_templates_write_failed', 'Template write failed: {error}', {
6500
+ error: error && error.message ? error.message : String(error),
6501
+ }),
6502
+ 'error'
6503
+ );
6504
+ });
6505
+ });
6506
+ }
3224
6507
 
3225
6508
  // Modal Actions
3226
- // v0.9.42: When opening settings, update UI to reflect current mode's values
3227
6509
  openBtn.addEventListener('click', () => {
3228
6510
  updateUIFromSettings(settingsManager.settings);
3229
- modal.style.display = 'flex';
3230
- isSettingsModalOpen = true;
3231
- simulation.stop(); // v0.9.41: Force freeze to save resources
3232
- });
3233
-
3234
- closeBtns.forEach(btn => btn.addEventListener('click', closeSettings));
3235
-
3236
- // Close on click outside
3237
- modal.addEventListener('click', (e) => {
3238
- if (e.target === modal) closeSettings();
6511
+ showMainSettingsPage();
3239
6512
  });
3240
6513
 
6514
+ if (openAgentSettingsBtn) {
6515
+ openAgentSettingsBtn.addEventListener('click', () => {
6516
+ void showAgentSettingsPage().catch((error) => {
6517
+ console.warn('[Settings] Failed to open agent settings.', error);
6518
+ });
6519
+ });
6520
+ }
6521
+
6522
+ if (agentSettingsBackBtn) {
6523
+ agentSettingsBackBtn.addEventListener('click', showMainSettingsPage);
6524
+ }
6525
+
6526
+ if (agentSettingsBackFooterBtn) {
6527
+ agentSettingsBackFooterBtn.addEventListener('click', showMainSettingsPage);
6528
+ }
6529
+
6530
+ mainCloseBtns.forEach((btn) => btn.addEventListener('click', closeAllSettingsPages));
6531
+ agentCloseBtns.forEach((btn) => btn.addEventListener('click', closeAllSettingsPages));
6532
+
6533
+ if (modal) {
6534
+ modal.addEventListener('click', (e) => {
6535
+ if (e.target === modal) {
6536
+ closeAllSettingsPages();
6537
+ }
6538
+ });
6539
+ }
6540
+
6541
+ if (agentModal) {
6542
+ agentModal.addEventListener('click', (e) => {
6543
+ if (e.target === agentModal) {
6544
+ closeAllSettingsPages();
6545
+ }
6546
+ });
6547
+ }
6548
+
3241
6549
  resetBtn.addEventListener('click', () => {
3242
6550
  settingsManager.reset();
3243
6551
  updateUIFromSettings(settingsManager.settings);
@@ -3251,7 +6559,7 @@ function initSettingsUI() {
3251
6559
 
3252
6560
  // v0.9.40: Check Freeze Layout State before restarting
3253
6561
  const globalFreeze = document.getElementById('freeze-layout') ? document.getElementById('freeze-layout').checked : false;
3254
- const isFrozen = globalFreeze || isSettingsModalOpen;
6562
+ const isFrozen = globalFreeze || isAnySettingsPanelOpen();
3255
6563
 
3256
6564
  if (!isFrozen) {
3257
6565
  simulation.alpha(0.3).restart();
@@ -3462,6 +6770,151 @@ function getTauriDialogApi() {
3462
6770
  return null;
3463
6771
  }
3464
6772
 
6773
+ function normalizePathModeRuntimeText(value) {
6774
+ return String(value || '').replace(/\s+/g, ' ').trim();
6775
+ }
6776
+
6777
+ function normalizePathModeLanguage(value) {
6778
+ const raw = normalizePathModeRuntimeText(value).toLowerCase();
6779
+ return raw.startsWith('zh') ? 'zh' : 'en';
6780
+ }
6781
+
6782
+ function resolvePathModeRuntimeTarget(targetId) {
6783
+ const normalizedTargetId = normalizePathModeRuntimeText(targetId);
6784
+ if (!normalizedTargetId) {
6785
+ return null;
6786
+ }
6787
+ const node = resolveGraphViewNodeByIdOrLabel(normalizedTargetId);
6788
+ if (!node) {
6789
+ return {
6790
+ id: normalizedTargetId,
6791
+ label: normalizedTargetId,
6792
+ resolved: false,
6793
+ };
6794
+ }
6795
+ return {
6796
+ id: node.id,
6797
+ label: getGraphViewNodeLabel(node) || node.id,
6798
+ resolved: true,
6799
+ };
6800
+ }
6801
+
6802
+ function buildGodotFuturePathRuntimeConfig(targetId, options = {}) {
6803
+ const runtimeTarget = resolvePathModeRuntimeTarget(targetId);
6804
+ if (!runtimeTarget || !runtimeTarget.id) {
6805
+ return null;
6806
+ }
6807
+ const existingConfig = options.config && typeof options.config === 'object'
6808
+ ? options.config
6809
+ : {};
6810
+ const targetIds = [];
6811
+ const seen = new Set();
6812
+ const appendTargetId = function(value) {
6813
+ const normalized = normalizePathModeRuntimeText(value);
6814
+ if (!normalized || seen.has(normalized)) {
6815
+ return;
6816
+ }
6817
+ seen.add(normalized);
6818
+ targetIds.push(normalized);
6819
+ };
6820
+ appendTargetId(runtimeTarget.id);
6821
+ if (Array.isArray(existingConfig.targetIds)) {
6822
+ existingConfig.targetIds.forEach(appendTargetId);
6823
+ }
6824
+ return {
6825
+ ...existingConfig,
6826
+ mode: 'diffusion',
6827
+ strategy: 'core',
6828
+ layout: 'orbital',
6829
+ targetId: runtimeTarget.id,
6830
+ target_id: runtimeTarget.id,
6831
+ targetIds,
6832
+ focus_mode: true,
6833
+ language: normalizePathModeLanguage(
6834
+ existingConfig.language
6835
+ || window.i18n && window.i18n.currentLanguage
6836
+ || document.documentElement && document.documentElement.lang
6837
+ || 'en'
6838
+ ),
6839
+ };
6840
+ }
6841
+
6842
+ async function openGodotFuturePathById(targetId, options = {}) {
6843
+ const config = buildGodotFuturePathRuntimeConfig(targetId, options);
6844
+ if (!config) {
6845
+ throw new Error('Missing Godot Future Path target node.');
6846
+ }
6847
+ window.__NC_LAST_GODOT_FUTURE_PATH_REQUEST = { ...config };
6848
+
6849
+ const pathApp = window.pathApp;
6850
+ if (pathApp && typeof pathApp === 'object') {
6851
+ if (!window.__NC_AGENT_GODOT_FUTURE_PATH_INITIALIZED && typeof pathApp.init === 'function') {
6852
+ pathApp.init(config.targetId);
6853
+ window.__NC_AGENT_GODOT_FUTURE_PATH_INITIALIZED = true;
6854
+ }
6855
+ if (pathApp.runtimeConfig && typeof pathApp.runtimeConfig === 'object') {
6856
+ pathApp.runtimeConfig.mode = 'diffusion';
6857
+ pathApp.runtimeConfig.strategy = 'core';
6858
+ pathApp.runtimeConfig.layout = 'orbital';
6859
+ pathApp.runtimeConfig.targetId = config.targetId;
6860
+ pathApp.runtimeConfig.targetIds = config.targetIds.slice();
6861
+ }
6862
+ pathApp.currentTargetId = config.targetId;
6863
+ pathApp.currentTargetIds = config.targetIds.slice();
6864
+ pathApp.centralNodeId = config.targetId;
6865
+ if (typeof pathApp.applyRemoteConfigure === 'function') {
6866
+ pathApp.applyRemoteConfigure(config);
6867
+ }
6868
+ if (typeof pathApp._sendBridgeMessage === 'function') {
6869
+ pathApp._sendBridgeMessage('configure', config);
6870
+ }
6871
+ if (typeof pathApp.triggerUpdate === 'function') {
6872
+ pathApp.triggerUpdate();
6873
+ }
6874
+ if (typeof pathApp.requestBridgeWindowVisibility === 'function') {
6875
+ const bridgeReady = await pathApp.requestBridgeWindowVisibility(true, {
6876
+ waitMs: 1800,
6877
+ reason: options.source || 'open-godot-future-path',
6878
+ });
6879
+ if (bridgeReady && typeof pathApp._sendBridgeMessage === 'function') {
6880
+ pathApp._sendBridgeMessage('configure', config);
6881
+ }
6882
+ }
6883
+ }
6884
+
6885
+ const invoke = getTauriCoreInvoke();
6886
+ if (invoke) {
6887
+ const caps = window.__NC_RUNTIME_CAPS || {};
6888
+ if (caps.platform === 'android' && caps.supports_native_pathmode === true) {
6889
+ await invoke('open_native_pathmode', {
6890
+ request: {
6891
+ mode: 'diffusion',
6892
+ strategy: 'core',
6893
+ targetId: config.targetId,
6894
+ },
6895
+ });
6896
+ } else {
6897
+ await invoke('toggle_pathmode_window', { showGodot: true });
6898
+ }
6899
+ }
6900
+
6901
+ return {
6902
+ opened: true,
6903
+ targetId: config.targetId,
6904
+ targetIds: config.targetIds.slice(),
6905
+ strategy: config.strategy,
6906
+ mode: config.mode,
6907
+ };
6908
+ }
6909
+
6910
+ window.NoteConnectionPathMode = {
6911
+ ...(window.NoteConnectionPathMode && typeof window.NoteConnectionPathMode === 'object'
6912
+ ? window.NoteConnectionPathMode
6913
+ : {}),
6914
+ buildGodotFuturePathRuntimeConfig,
6915
+ openGodotFuturePathById,
6916
+ };
6917
+
3465
6918
  function normalizeNotemdPickerPayload(payload) {
3466
6919
  const safePayload = payload && typeof payload === 'object' ? { ...payload } : {};
3467
6920
  const initialPath = typeof safePayload.initialPath === 'string'
@@ -3808,21 +7261,25 @@ if (btnPathMode) {
3808
7261
  // 单窗口切换:隐藏 Tauri,显示 Godot。
3809
7262
  if (window.__TAURI__ && window.__TAURI__.core && typeof window.__TAURI__.core.invoke === 'function') {
3810
7263
  try {
3811
- // 1. Hide the Tauri window via Rust IPC.
3812
- await window.__TAURI__.core.invoke('toggle_pathmode_window', { showGodot: true });
3813
- // 2. Send setWindowVisible to Godot via PathBridge WebSocket.
7264
+ let bridgeVisibilityReady = true;
3814
7265
  if (
3815
7266
  multiWindowOptions.singleWindowMode &&
3816
7267
  window.pathApp &&
3817
- window.pathApp.ws &&
3818
- window.pathApp.ws.readyState === WebSocket.OPEN
7268
+ typeof window.pathApp.requestBridgeWindowVisibility === 'function'
3819
7269
  ) {
3820
- window.pathApp.ws.send(JSON.stringify({
3821
- type: 'setWindowVisible',
3822
- payload: { visible: true }
3823
- }));
7270
+ bridgeVisibilityReady = await window.pathApp.requestBridgeWindowVisibility(true, {
7271
+ waitMs: 2500,
7272
+ reason: 'enter-pathmode'
7273
+ });
7274
+ }
7275
+
7276
+ if (multiWindowOptions.singleWindowMode && !bridgeVisibilityReady) {
7277
+ console.warn('[Path Mode] Bridge window visibility message was not delivered in time; skip hiding Tauri to avoid black-screen switch.');
7278
+ } else {
7279
+ // Hide the Tauri window via Rust IPC only after window-visibility intent is delivered.
7280
+ await window.__TAURI__.core.invoke('toggle_pathmode_window', { showGodot: true });
7281
+ console.log('[Path Mode] Single-window toggle: Tauri hidden, Godot shown.');
3824
7282
  }
3825
- console.log('[Path Mode] Single-window toggle: Tauri hidden, Godot shown.');
3826
7283
  } catch (err) {
3827
7284
  console.warn('[Path Mode] toggle_pathmode_window failed:', err);
3828
7285
  }
@@ -3832,4 +7289,3 @@ if (btnPathMode) {
3832
7289
  }
3833
7290
  });
3834
7291
  }
3835
-