@vellumai/assistant 0.4.2 → 0.4.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/.env.example +3 -0
- package/ARCHITECTURE.md +124 -10
- package/README.md +43 -35
- package/docs/trusted-contact-access.md +20 -0
- package/package.json +1 -1
- package/scripts/ipc/generate-swift.ts +1 -0
- package/src/__tests__/__snapshots__/ipc-snapshot.test.ts.snap +58 -120
- package/src/__tests__/access-request-decision.test.ts +0 -1
- package/src/__tests__/actor-token-service.test.ts +1099 -0
- package/src/__tests__/agent-loop.test.ts +51 -0
- package/src/__tests__/approval-routes-http.test.ts +2 -0
- package/src/__tests__/assistant-events-sse-hardening.test.ts +7 -5
- package/src/__tests__/assistant-id-boundary-guard.test.ts +415 -0
- package/src/__tests__/call-controller.test.ts +49 -0
- package/src/__tests__/call-pointer-message-composer.test.ts +171 -0
- package/src/__tests__/call-pointer-messages.test.ts +93 -3
- package/src/__tests__/call-pointer-no-hardcoded-copy.guard.test.ts +42 -0
- package/src/__tests__/call-routes-http.test.ts +0 -25
- package/src/__tests__/callback-handoff-copy.test.ts +186 -0
- package/src/__tests__/channel-approval-routes.test.ts +133 -12
- package/src/__tests__/channel-guardian.test.ts +0 -86
- package/src/__tests__/channel-readiness-service.test.ts +10 -16
- package/src/__tests__/checker.test.ts +33 -12
- package/src/__tests__/config-schema.test.ts +6 -0
- package/src/__tests__/confirmation-request-guardian-bridge.test.ts +410 -0
- package/src/__tests__/conversation-routes-guardian-reply.test.ts +256 -0
- package/src/__tests__/conversation-routes.test.ts +12 -3
- package/src/__tests__/credential-security-invariants.test.ts +1 -1
- package/src/__tests__/daemon-server-session-init.test.ts +4 -0
- package/src/__tests__/deterministic-verification-control-plane.test.ts +0 -1
- package/src/__tests__/guardian-actions-endpoint.test.ts +39 -13
- package/src/__tests__/guardian-dispatch.test.ts +8 -0
- package/src/__tests__/guardian-outbound-http.test.ts +4 -5
- package/src/__tests__/guardian-question-mode.test.ts +200 -0
- package/src/__tests__/guardian-routing-invariants.test.ts +178 -0
- package/src/__tests__/guardian-routing-state.test.ts +525 -0
- package/src/__tests__/handle-user-message-secret-resume.test.ts +2 -0
- package/src/__tests__/handlers-telegram-config.test.ts +0 -83
- package/src/__tests__/handlers-user-message-approval-consumption.test.ts +55 -0
- package/src/__tests__/headless-browser-navigate.test.ts +2 -0
- package/src/__tests__/inbound-invite-redemption.test.ts +0 -1
- package/src/__tests__/ingress-routes-http.test.ts +55 -0
- package/src/__tests__/ipc-snapshot.test.ts +18 -51
- package/src/__tests__/non-member-access-request.test.ts +159 -9
- package/src/__tests__/notification-decision-fallback.test.ts +129 -4
- package/src/__tests__/notification-decision-strategy.test.ts +106 -2
- package/src/__tests__/notification-guardian-path.test.ts +3 -0
- package/src/__tests__/recording-intent-handler.test.ts +1 -0
- package/src/__tests__/relay-server.test.ts +1475 -33
- package/src/__tests__/send-endpoint-busy.test.ts +5 -0
- package/src/__tests__/session-agent-loop.test.ts +1 -0
- package/src/__tests__/session-confirmation-signals.test.ts +523 -0
- package/src/__tests__/session-init.benchmark.test.ts +0 -2
- package/src/__tests__/session-runtime-assembly.test.ts +4 -1
- package/src/__tests__/session-surfaces-task-progress.test.ts +44 -1
- package/src/__tests__/session-tool-setup-app-refresh.test.ts +81 -2
- package/src/__tests__/session-tool-setup-memory-scope.test.ts +1 -1
- package/src/__tests__/session-tool-setup-side-effect-flag.test.ts +1 -1
- package/src/__tests__/tool-executor.test.ts +21 -2
- package/src/__tests__/tool-grant-request-escalation.test.ts +333 -27
- package/src/__tests__/trusted-contact-approval-notifier.test.ts +678 -0
- package/src/__tests__/trusted-contact-inline-approval-integration.test.ts +1064 -0
- package/src/__tests__/trusted-contact-lifecycle-notifications.test.ts +11 -1
- package/src/__tests__/trusted-contact-multichannel.test.ts +0 -1
- package/src/__tests__/trusted-contact-verification.test.ts +0 -1
- package/src/__tests__/twilio-config.test.ts +2 -13
- package/src/__tests__/twilio-routes.test.ts +4 -3
- package/src/__tests__/update-bulletin.test.ts +0 -1
- package/src/agent/loop.ts +1 -1
- package/src/approvals/guardian-decision-primitive.ts +12 -3
- package/src/approvals/guardian-request-resolvers.ts +169 -11
- package/src/calls/call-constants.ts +29 -0
- package/src/calls/call-controller.ts +11 -3
- package/src/calls/call-domain.ts +33 -11
- package/src/calls/call-pointer-message-composer.ts +154 -0
- package/src/calls/call-pointer-messages.ts +106 -27
- package/src/calls/guardian-dispatch.ts +4 -2
- package/src/calls/relay-server.ts +921 -112
- package/src/calls/twilio-config.ts +4 -11
- package/src/calls/twilio-routes.ts +4 -6
- package/src/calls/types.ts +3 -1
- package/src/calls/voice-session-bridge.ts +4 -3
- package/src/cli/core-commands.ts +7 -4
- package/src/cli.ts +5 -4
- package/src/config/bundled-skills/agentmail/SKILL.md +4 -0
- package/src/config/bundled-skills/app-builder/SKILL.md +309 -10
- package/src/config/bundled-skills/app-builder/TOOLS.json +1 -1
- package/src/config/bundled-skills/email-setup/SKILL.md +1 -1
- package/src/config/bundled-skills/google-oauth-setup/SKILL.md +105 -81
- package/src/config/bundled-skills/messaging/SKILL.md +61 -12
- package/src/config/bundled-skills/messaging/TOOLS.json +58 -0
- package/src/config/bundled-skills/messaging/tools/gmail-sender-digest.ts +6 -1
- package/src/config/bundled-skills/messaging/tools/messaging-archive-by-sender.ts +35 -0
- package/src/config/bundled-skills/messaging/tools/messaging-sender-digest.ts +52 -0
- package/src/config/bundled-skills/phone-calls/SKILL.md +30 -39
- package/src/config/bundled-skills/twitter/SKILL.md +3 -3
- package/src/config/bundled-skills/vercel-token-setup/SKILL.md +215 -0
- package/src/config/calls-schema.ts +36 -0
- package/src/config/env.ts +22 -0
- package/src/config/feature-flag-registry.json +8 -8
- package/src/config/schema.ts +2 -2
- package/src/config/skills.ts +11 -0
- package/src/config/system-prompt.ts +11 -1
- package/src/config/templates/SOUL.md +2 -0
- package/src/config/vellum-skills/sms-setup/SKILL.md +71 -82
- package/src/config/vellum-skills/trusted-contacts/SKILL.md +8 -1
- package/src/config/vellum-skills/twilio-setup/SKILL.md +88 -73
- package/src/daemon/call-pointer-generators.ts +59 -0
- package/src/daemon/computer-use-session.ts +2 -5
- package/src/daemon/handlers/apps.ts +76 -20
- package/src/daemon/handlers/config-channels.ts +9 -61
- package/src/daemon/handlers/config-inbox.ts +11 -3
- package/src/daemon/handlers/config-ingress.ts +28 -3
- package/src/daemon/handlers/config-telegram.ts +12 -0
- package/src/daemon/handlers/config.ts +2 -6
- package/src/daemon/handlers/index.ts +2 -1
- package/src/daemon/handlers/pairing.ts +2 -0
- package/src/daemon/handlers/publish.ts +11 -46
- package/src/daemon/handlers/sessions.ts +59 -5
- package/src/daemon/handlers/shared.ts +17 -2
- package/src/daemon/ipc-contract/apps.ts +1 -0
- package/src/daemon/ipc-contract/inbox.ts +4 -0
- package/src/daemon/ipc-contract/integrations.ts +1 -97
- package/src/daemon/ipc-contract/messages.ts +47 -1
- package/src/daemon/ipc-contract/notifications.ts +11 -0
- package/src/daemon/ipc-contract-inventory.json +2 -4
- package/src/daemon/lifecycle.ts +17 -0
- package/src/daemon/server.ts +16 -2
- package/src/daemon/session-agent-loop-handlers.ts +20 -0
- package/src/daemon/session-agent-loop.ts +24 -12
- package/src/daemon/session-lifecycle.ts +1 -1
- package/src/daemon/session-process.ts +11 -1
- package/src/daemon/session-runtime-assembly.ts +6 -1
- package/src/daemon/session-surfaces.ts +32 -3
- package/src/daemon/session.ts +88 -1
- package/src/daemon/tool-side-effects.ts +22 -0
- package/src/home-base/prebuilt/brain-graph.html +1483 -0
- package/src/home-base/prebuilt/index.html +40 -0
- package/src/inbound/platform-callback-registration.ts +157 -0
- package/src/memory/canonical-guardian-store.ts +1 -1
- package/src/memory/conversation-crud.ts +2 -1
- package/src/memory/conversation-title-service.ts +16 -2
- package/src/memory/db-init.ts +8 -0
- package/src/memory/delivery-crud.ts +2 -1
- package/src/memory/guardian-action-store.ts +2 -1
- package/src/memory/guardian-approvals.ts +3 -2
- package/src/memory/ingress-invite-store.ts +12 -2
- package/src/memory/ingress-member-store.ts +4 -3
- package/src/memory/migrations/038-actor-token-records.ts +39 -0
- package/src/memory/migrations/124-voice-invite-display-metadata.ts +14 -0
- package/src/memory/migrations/index.ts +2 -0
- package/src/memory/schema.ts +26 -5
- package/src/messaging/provider-types.ts +24 -0
- package/src/messaging/provider.ts +7 -0
- package/src/messaging/providers/gmail/adapter.ts +127 -0
- package/src/messaging/providers/sms/adapter.ts +40 -37
- package/src/notifications/adapters/macos.ts +45 -2
- package/src/notifications/broadcaster.ts +16 -0
- package/src/notifications/copy-composer.ts +50 -2
- package/src/notifications/decision-engine.ts +22 -9
- package/src/notifications/destination-resolver.ts +16 -2
- package/src/notifications/emit-signal.ts +18 -9
- package/src/notifications/guardian-question-mode.ts +419 -0
- package/src/notifications/signal.ts +14 -3
- package/src/permissions/checker.ts +13 -1
- package/src/permissions/prompter.ts +14 -0
- package/src/providers/anthropic/client.ts +20 -0
- package/src/providers/provider-send-message.ts +15 -3
- package/src/runtime/access-request-helper.ts +82 -4
- package/src/runtime/actor-token-service.ts +234 -0
- package/src/runtime/actor-token-store.ts +236 -0
- package/src/runtime/actor-trust-resolver.ts +2 -2
- package/src/runtime/assistant-scope.ts +10 -0
- package/src/runtime/channel-approvals.ts +5 -3
- package/src/runtime/channel-readiness-service.ts +23 -64
- package/src/runtime/channel-readiness-types.ts +3 -4
- package/src/runtime/channel-retry-sweep.ts +4 -1
- package/src/runtime/confirmation-request-guardian-bridge.ts +197 -0
- package/src/runtime/guardian-action-followup-executor.ts +1 -1
- package/src/runtime/guardian-context-resolver.ts +82 -0
- package/src/runtime/guardian-outbound-actions.ts +5 -7
- package/src/runtime/guardian-reply-router.ts +67 -30
- package/src/runtime/guardian-vellum-migration.ts +57 -0
- package/src/runtime/http-server.ts +75 -31
- package/src/runtime/http-types.ts +13 -0
- package/src/runtime/ingress-service.ts +14 -0
- package/src/runtime/invite-redemption-service.ts +10 -1
- package/src/runtime/local-actor-identity.ts +76 -0
- package/src/runtime/middleware/actor-token.ts +271 -0
- package/src/runtime/middleware/twilio-validation.ts +2 -4
- package/src/runtime/routes/approval-routes.ts +82 -7
- package/src/runtime/routes/brain-graph-routes.ts +222 -0
- package/src/runtime/routes/call-routes.ts +2 -1
- package/src/runtime/routes/channel-readiness-routes.ts +71 -0
- package/src/runtime/routes/channel-route-shared.ts +3 -3
- package/src/runtime/routes/conversation-attention-routes.ts +2 -1
- package/src/runtime/routes/conversation-routes.ts +142 -53
- package/src/runtime/routes/events-routes.ts +22 -8
- package/src/runtime/routes/guardian-action-routes.ts +45 -3
- package/src/runtime/routes/guardian-approval-interception.ts +29 -0
- package/src/runtime/routes/guardian-bootstrap-routes.ts +145 -0
- package/src/runtime/routes/inbound-conversation.ts +4 -3
- package/src/runtime/routes/inbound-message-handler.ts +147 -5
- package/src/runtime/routes/ingress-routes.ts +2 -0
- package/src/runtime/routes/integration-routes.ts +7 -15
- package/src/runtime/routes/pairing-routes.ts +163 -0
- package/src/runtime/routes/twilio-routes.ts +934 -0
- package/src/runtime/tool-grant-request-helper.ts +3 -1
- package/src/security/oauth2.ts +27 -2
- package/src/security/token-manager.ts +46 -10
- package/src/tools/browser/browser-execution.ts +4 -3
- package/src/tools/browser/browser-handoff.ts +10 -18
- package/src/tools/browser/browser-manager.ts +80 -25
- package/src/tools/browser/browser-screencast.ts +35 -119
- package/src/tools/calls/call-start.ts +2 -1
- package/src/tools/permission-checker.ts +15 -4
- package/src/tools/terminal/parser.ts +12 -0
- package/src/tools/tool-approval-handler.ts +244 -19
- package/src/workspace/git-service.ts +19 -0
- package/src/__tests__/handlers-twilio-config.test.ts +0 -1928
- package/src/daemon/handlers/config-twilio.ts +0 -1082
|
@@ -0,0 +1,1483 @@
|
|
|
1
|
+
<!doctype html>
|
|
2
|
+
<html lang="en">
|
|
3
|
+
<head>
|
|
4
|
+
<meta charset="utf-8" />
|
|
5
|
+
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
|
6
|
+
<title>🧠 Knowledge Brain</title>
|
|
7
|
+
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
8
|
+
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
9
|
+
<link href="https://fonts.googleapis.com/css2?family=DM+Sans:ital,opsz,wght@0,9..40,300;0,9..40,400;0,9..40,500;0,9..40,600;0,9..40,700;0,9..40,800;1,9..40,400&family=DM+Mono:wght@400;500&display=swap" rel="stylesheet" />
|
|
10
|
+
<script src="https://cdn.jsdelivr.net/npm/d3@7/dist/d3.min.js"></script>
|
|
11
|
+
<script type="importmap">
|
|
12
|
+
{
|
|
13
|
+
"imports": {
|
|
14
|
+
"three": "https://cdn.jsdelivr.net/npm/three@0.169.0/build/three.module.js",
|
|
15
|
+
"three/addons/": "https://cdn.jsdelivr.net/npm/three@0.169.0/examples/jsm/"
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
</script>
|
|
19
|
+
<style>
|
|
20
|
+
:root {
|
|
21
|
+
--v-slate-950: #070D19;
|
|
22
|
+
--v-slate-900: #0F172A;
|
|
23
|
+
--v-slate-800: #1E293B;
|
|
24
|
+
--v-slate-700: #334155;
|
|
25
|
+
--v-slate-600: #475569;
|
|
26
|
+
--v-slate-500: #64748B;
|
|
27
|
+
--v-slate-400: #94A3B8;
|
|
28
|
+
--v-slate-300: #CBD5E1;
|
|
29
|
+
--v-slate-200: #E2E8F0;
|
|
30
|
+
--v-slate-100: #F1F5F9;
|
|
31
|
+
--v-slate-50: #F8FAFC;
|
|
32
|
+
|
|
33
|
+
--v-violet-950: #321669;
|
|
34
|
+
--v-violet-900: #4A2390;
|
|
35
|
+
--v-violet-800: #5C2FB2;
|
|
36
|
+
--v-violet-700: #7240CC;
|
|
37
|
+
--v-violet-600: #8A5BE0;
|
|
38
|
+
--v-violet-500: #9878EA;
|
|
39
|
+
--v-violet-400: #B8A6F1;
|
|
40
|
+
--v-violet-300: #D4C8F7;
|
|
41
|
+
--v-violet-200: #E8E1FB;
|
|
42
|
+
--v-violet-100: #F4F0FD;
|
|
43
|
+
|
|
44
|
+
--bg: var(--v-slate-950);
|
|
45
|
+
--surface: var(--v-slate-900);
|
|
46
|
+
--surface-raised: var(--v-slate-800);
|
|
47
|
+
--border: var(--v-slate-700);
|
|
48
|
+
--border-subtle: rgba(51, 65, 85, 0.5);
|
|
49
|
+
--text: var(--v-slate-50);
|
|
50
|
+
--text-secondary: var(--v-slate-400);
|
|
51
|
+
--text-muted: var(--v-slate-500);
|
|
52
|
+
--accent: var(--v-violet-600);
|
|
53
|
+
--accent-dim: rgba(138, 91, 224, 0.12);
|
|
54
|
+
}
|
|
55
|
+
|
|
56
|
+
* { box-sizing: border-box; margin: 0; padding: 0; }
|
|
57
|
+
|
|
58
|
+
body {
|
|
59
|
+
font-family: 'DM Sans', -apple-system, BlinkMacSystemFont, sans-serif;
|
|
60
|
+
background: var(--bg);
|
|
61
|
+
color: var(--text);
|
|
62
|
+
min-height: 100vh;
|
|
63
|
+
-webkit-font-smoothing: antialiased;
|
|
64
|
+
overflow-x: hidden;
|
|
65
|
+
display: flex;
|
|
66
|
+
flex-direction: column;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
body::before {
|
|
70
|
+
content: "";
|
|
71
|
+
position: fixed;
|
|
72
|
+
top: -20%;
|
|
73
|
+
left: 50%;
|
|
74
|
+
transform: translateX(-50%);
|
|
75
|
+
width: 120%;
|
|
76
|
+
height: 50%;
|
|
77
|
+
background: radial-gradient(
|
|
78
|
+
ellipse at center,
|
|
79
|
+
rgba(138, 91, 224, 0.06) 0%,
|
|
80
|
+
rgba(91, 78, 255, 0.02) 50%,
|
|
81
|
+
transparent 70%
|
|
82
|
+
);
|
|
83
|
+
pointer-events: none;
|
|
84
|
+
z-index: 0;
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
/* ── Header ─────────────────────────────────────────────── */
|
|
88
|
+
.page-header {
|
|
89
|
+
position: relative;
|
|
90
|
+
z-index: 1;
|
|
91
|
+
padding: 24px 32px 16px;
|
|
92
|
+
display: flex;
|
|
93
|
+
flex-direction: column;
|
|
94
|
+
gap: 12px;
|
|
95
|
+
border-bottom: 1px solid var(--border-subtle);
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
.header-top {
|
|
99
|
+
display: flex;
|
|
100
|
+
align-items: center;
|
|
101
|
+
justify-content: space-between;
|
|
102
|
+
flex-wrap: wrap;
|
|
103
|
+
gap: 12px;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
.header-title-block {
|
|
107
|
+
display: flex;
|
|
108
|
+
flex-direction: column;
|
|
109
|
+
gap: 2px;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
.header-title {
|
|
113
|
+
font-size: 24px;
|
|
114
|
+
font-weight: 800;
|
|
115
|
+
letter-spacing: -0.02em;
|
|
116
|
+
background: linear-gradient(180deg, #fff 30%, var(--v-slate-300) 100%);
|
|
117
|
+
-webkit-background-clip: text;
|
|
118
|
+
-webkit-text-fill-color: transparent;
|
|
119
|
+
background-clip: text;
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
.header-subtitle {
|
|
123
|
+
font-size: 12px;
|
|
124
|
+
color: var(--text-muted);
|
|
125
|
+
letter-spacing: 0.04em;
|
|
126
|
+
text-transform: uppercase;
|
|
127
|
+
font-weight: 500;
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
.stats-bar {
|
|
131
|
+
display: flex;
|
|
132
|
+
align-items: center;
|
|
133
|
+
gap: 6px;
|
|
134
|
+
flex-wrap: wrap;
|
|
135
|
+
font-size: 13px;
|
|
136
|
+
color: var(--text-secondary);
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
.stat-item {
|
|
140
|
+
display: flex;
|
|
141
|
+
align-items: center;
|
|
142
|
+
gap: 5px;
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
.stat-value {
|
|
146
|
+
font-weight: 700;
|
|
147
|
+
color: var(--v-violet-400);
|
|
148
|
+
font-variant-numeric: tabular-nums;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
.stat-sep {
|
|
152
|
+
color: var(--border);
|
|
153
|
+
user-select: none;
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
.knowledge-count-badge {
|
|
157
|
+
display: inline-flex;
|
|
158
|
+
align-items: center;
|
|
159
|
+
gap: 6px;
|
|
160
|
+
padding: 4px 12px;
|
|
161
|
+
background: var(--accent-dim);
|
|
162
|
+
border: 1px solid rgba(138, 91, 224, 0.25);
|
|
163
|
+
border-radius: 999px;
|
|
164
|
+
font-size: 12px;
|
|
165
|
+
font-weight: 600;
|
|
166
|
+
color: var(--v-violet-400);
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
.brain-pulse {
|
|
170
|
+
width: 8px;
|
|
171
|
+
height: 8px;
|
|
172
|
+
border-radius: 50%;
|
|
173
|
+
background: var(--accent);
|
|
174
|
+
animation: pulse-dot 2s ease-in-out infinite;
|
|
175
|
+
}
|
|
176
|
+
|
|
177
|
+
@keyframes pulse-dot {
|
|
178
|
+
0%, 100% { opacity: 1; transform: scale(1); }
|
|
179
|
+
50% { opacity: 0.5; transform: scale(0.7); }
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
/* ── Mode Toggle ─────────────────────────────────────────── */
|
|
183
|
+
.mode-toggle {
|
|
184
|
+
display: flex;
|
|
185
|
+
background: var(--surface);
|
|
186
|
+
border: 1px solid var(--border-subtle);
|
|
187
|
+
border-radius: 10px;
|
|
188
|
+
padding: 3px;
|
|
189
|
+
gap: 2px;
|
|
190
|
+
}
|
|
191
|
+
|
|
192
|
+
.mode-btn {
|
|
193
|
+
font-family: inherit;
|
|
194
|
+
font-size: 13px;
|
|
195
|
+
font-weight: 600;
|
|
196
|
+
padding: 6px 16px;
|
|
197
|
+
border: none;
|
|
198
|
+
border-radius: 7px;
|
|
199
|
+
cursor: pointer;
|
|
200
|
+
transition: all 0.15s ease;
|
|
201
|
+
background: transparent;
|
|
202
|
+
color: var(--text-muted);
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
.mode-btn.active {
|
|
206
|
+
background: var(--accent);
|
|
207
|
+
color: #fff;
|
|
208
|
+
box-shadow: 0 1px 4px rgba(138, 91, 224, 0.35);
|
|
209
|
+
}
|
|
210
|
+
|
|
211
|
+
.mode-btn:not(.active):hover {
|
|
212
|
+
background: var(--surface-raised);
|
|
213
|
+
color: var(--text-secondary);
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
/* ── Main Content ─────────────────────────────────────────── */
|
|
217
|
+
.main-content {
|
|
218
|
+
position: relative;
|
|
219
|
+
z-index: 1;
|
|
220
|
+
flex: 1;
|
|
221
|
+
display: flex;
|
|
222
|
+
flex-direction: column;
|
|
223
|
+
padding: 16px 24px;
|
|
224
|
+
gap: 16px;
|
|
225
|
+
min-height: 0;
|
|
226
|
+
}
|
|
227
|
+
|
|
228
|
+
/* ── Visualization Panels ─────────────────────────────────── */
|
|
229
|
+
.viz-panel {
|
|
230
|
+
flex: 1;
|
|
231
|
+
display: none;
|
|
232
|
+
position: relative;
|
|
233
|
+
background: var(--surface);
|
|
234
|
+
border: 1px solid var(--border-subtle);
|
|
235
|
+
border-radius: 16px;
|
|
236
|
+
overflow: hidden;
|
|
237
|
+
min-height: 460px;
|
|
238
|
+
}
|
|
239
|
+
|
|
240
|
+
.viz-panel.active {
|
|
241
|
+
display: flex;
|
|
242
|
+
flex-direction: column;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
/* ── 2D Panel ─────────────────────────────────────────────── */
|
|
246
|
+
#panel-2d {
|
|
247
|
+
align-items: stretch;
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
#graph-svg {
|
|
251
|
+
width: 100%;
|
|
252
|
+
height: 100%;
|
|
253
|
+
min-height: 460px;
|
|
254
|
+
}
|
|
255
|
+
|
|
256
|
+
/* Brain silhouette animation — gentle breathing glow */
|
|
257
|
+
@keyframes brain-breathe {
|
|
258
|
+
0%, 100% {
|
|
259
|
+
opacity: 0.35;
|
|
260
|
+
filter: drop-shadow(0 0 4px rgba(138, 91, 224, 0.2));
|
|
261
|
+
}
|
|
262
|
+
50% {
|
|
263
|
+
opacity: 0.75;
|
|
264
|
+
filter: drop-shadow(0 0 12px rgba(138, 91, 224, 0.55));
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
.brain-outline {
|
|
269
|
+
animation: brain-breathe 3.5s ease-in-out infinite;
|
|
270
|
+
}
|
|
271
|
+
|
|
272
|
+
/* ── Refresh button ─────────────────────────────────────────── */
|
|
273
|
+
.refresh-btn {
|
|
274
|
+
font-family: inherit;
|
|
275
|
+
display: inline-flex;
|
|
276
|
+
align-items: center;
|
|
277
|
+
justify-content: center;
|
|
278
|
+
width: 34px;
|
|
279
|
+
height: 34px;
|
|
280
|
+
border-radius: 8px;
|
|
281
|
+
border: 1px solid var(--border);
|
|
282
|
+
background: transparent;
|
|
283
|
+
color: var(--text-muted);
|
|
284
|
+
font-size: 18px;
|
|
285
|
+
cursor: pointer;
|
|
286
|
+
transition: all 0.15s ease;
|
|
287
|
+
line-height: 1;
|
|
288
|
+
padding: 0;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
.refresh-btn:hover {
|
|
292
|
+
border-color: var(--accent);
|
|
293
|
+
background: var(--accent-dim);
|
|
294
|
+
color: var(--v-violet-400);
|
|
295
|
+
}
|
|
296
|
+
|
|
297
|
+
.refresh-btn:active { transform: scale(0.93); }
|
|
298
|
+
|
|
299
|
+
.refresh-btn.spinning #refresh-icon {
|
|
300
|
+
display: inline-block;
|
|
301
|
+
animation: spin 0.7s linear infinite;
|
|
302
|
+
}
|
|
303
|
+
|
|
304
|
+
@keyframes spin {
|
|
305
|
+
from { transform: rotate(0deg); }
|
|
306
|
+
to { transform: rotate(360deg); }
|
|
307
|
+
}
|
|
308
|
+
|
|
309
|
+
/* ── 3D Panel ─────────────────────────────────────────────── */
|
|
310
|
+
#panel-3d {
|
|
311
|
+
align-items: stretch;
|
|
312
|
+
justify-content: stretch;
|
|
313
|
+
position: relative;
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
#brain-canvas-3d {
|
|
317
|
+
display: block;
|
|
318
|
+
width: 100%;
|
|
319
|
+
height: 100%;
|
|
320
|
+
min-height: 460px;
|
|
321
|
+
border-radius: 16px;
|
|
322
|
+
}
|
|
323
|
+
|
|
324
|
+
/* ── Empty / Error states ─────────────────────────────────── */
|
|
325
|
+
.state-overlay {
|
|
326
|
+
position: absolute;
|
|
327
|
+
inset: 0;
|
|
328
|
+
display: flex;
|
|
329
|
+
flex-direction: column;
|
|
330
|
+
align-items: center;
|
|
331
|
+
justify-content: center;
|
|
332
|
+
gap: 12px;
|
|
333
|
+
text-align: center;
|
|
334
|
+
padding: 48px;
|
|
335
|
+
pointer-events: none;
|
|
336
|
+
}
|
|
337
|
+
|
|
338
|
+
.state-overlay .icon {
|
|
339
|
+
font-size: 48px;
|
|
340
|
+
line-height: 1;
|
|
341
|
+
}
|
|
342
|
+
|
|
343
|
+
@keyframes empty-brain-pulse {
|
|
344
|
+
0%, 100% { transform: scale(1); opacity: 0.8; }
|
|
345
|
+
50% { transform: scale(1.12); opacity: 1; }
|
|
346
|
+
}
|
|
347
|
+
|
|
348
|
+
.empty-brain-icon {
|
|
349
|
+
animation: empty-brain-pulse 2.5s ease-in-out infinite;
|
|
350
|
+
display: inline-block;
|
|
351
|
+
}
|
|
352
|
+
|
|
353
|
+
.state-overlay h3 {
|
|
354
|
+
font-size: 18px;
|
|
355
|
+
font-weight: 700;
|
|
356
|
+
color: var(--text);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
.state-overlay p {
|
|
360
|
+
font-size: 14px;
|
|
361
|
+
color: var(--text-muted);
|
|
362
|
+
line-height: 1.5;
|
|
363
|
+
max-width: 280px;
|
|
364
|
+
}
|
|
365
|
+
|
|
366
|
+
/* ── Tooltip ──────────────────────────────────────────────── */
|
|
367
|
+
#tooltip {
|
|
368
|
+
position: fixed;
|
|
369
|
+
display: none;
|
|
370
|
+
flex-direction: column;
|
|
371
|
+
gap: 4px;
|
|
372
|
+
padding: 10px 14px;
|
|
373
|
+
background: rgba(15, 23, 42, 0.92);
|
|
374
|
+
border: 1px solid rgba(138, 91, 224, 0.35);
|
|
375
|
+
border-radius: 10px;
|
|
376
|
+
backdrop-filter: blur(8px);
|
|
377
|
+
-webkit-backdrop-filter: blur(8px);
|
|
378
|
+
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
|
379
|
+
pointer-events: none;
|
|
380
|
+
z-index: 9999;
|
|
381
|
+
max-width: 220px;
|
|
382
|
+
font-size: 13px;
|
|
383
|
+
}
|
|
384
|
+
|
|
385
|
+
#tooltip.visible {
|
|
386
|
+
display: flex;
|
|
387
|
+
}
|
|
388
|
+
|
|
389
|
+
.tooltip-name {
|
|
390
|
+
font-weight: 700;
|
|
391
|
+
color: var(--text);
|
|
392
|
+
font-size: 14px;
|
|
393
|
+
}
|
|
394
|
+
|
|
395
|
+
.tooltip-type {
|
|
396
|
+
font-size: 11px;
|
|
397
|
+
text-transform: uppercase;
|
|
398
|
+
letter-spacing: 0.05em;
|
|
399
|
+
font-weight: 600;
|
|
400
|
+
color: var(--v-violet-400);
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
.tooltip-row {
|
|
404
|
+
font-size: 12px;
|
|
405
|
+
color: var(--text-secondary);
|
|
406
|
+
display: flex;
|
|
407
|
+
align-items: center;
|
|
408
|
+
gap: 4px;
|
|
409
|
+
}
|
|
410
|
+
|
|
411
|
+
.tooltip-dot {
|
|
412
|
+
width: 8px;
|
|
413
|
+
height: 8px;
|
|
414
|
+
border-radius: 50%;
|
|
415
|
+
flex-shrink: 0;
|
|
416
|
+
}
|
|
417
|
+
|
|
418
|
+
/* ── Legend ───────────────────────────────────────────────── */
|
|
419
|
+
.legend {
|
|
420
|
+
display: flex;
|
|
421
|
+
flex-wrap: wrap;
|
|
422
|
+
gap: 8px 16px;
|
|
423
|
+
padding: 4px 0;
|
|
424
|
+
}
|
|
425
|
+
|
|
426
|
+
.legend-item {
|
|
427
|
+
display: flex;
|
|
428
|
+
align-items: center;
|
|
429
|
+
gap: 6px;
|
|
430
|
+
font-size: 12px;
|
|
431
|
+
color: var(--text-muted);
|
|
432
|
+
}
|
|
433
|
+
|
|
434
|
+
.legend-dot {
|
|
435
|
+
width: 10px;
|
|
436
|
+
height: 10px;
|
|
437
|
+
border-radius: 50%;
|
|
438
|
+
flex-shrink: 0;
|
|
439
|
+
}
|
|
440
|
+
|
|
441
|
+
.legend-label {
|
|
442
|
+
font-size: 12px;
|
|
443
|
+
}
|
|
444
|
+
|
|
445
|
+
.legend-item input[type="checkbox"] {
|
|
446
|
+
width: 13px;
|
|
447
|
+
height: 13px;
|
|
448
|
+
accent-color: var(--accent);
|
|
449
|
+
cursor: pointer;
|
|
450
|
+
flex-shrink: 0;
|
|
451
|
+
}
|
|
452
|
+
|
|
453
|
+
.legend-item {
|
|
454
|
+
cursor: pointer;
|
|
455
|
+
user-select: none;
|
|
456
|
+
}
|
|
457
|
+
|
|
458
|
+
/* Dim legend item when hidden */
|
|
459
|
+
.legend-item.hidden-category {
|
|
460
|
+
opacity: 0.4;
|
|
461
|
+
}
|
|
462
|
+
|
|
463
|
+
/* ── Footer ───────────────────────────────────────────────── */
|
|
464
|
+
.page-footer {
|
|
465
|
+
position: relative;
|
|
466
|
+
z-index: 1;
|
|
467
|
+
padding: 16px 32px;
|
|
468
|
+
border-top: 1px solid var(--border-subtle);
|
|
469
|
+
display: flex;
|
|
470
|
+
align-items: center;
|
|
471
|
+
}
|
|
472
|
+
|
|
473
|
+
.back-link {
|
|
474
|
+
font-size: 13px;
|
|
475
|
+
font-weight: 600;
|
|
476
|
+
color: var(--text-secondary);
|
|
477
|
+
text-decoration: none;
|
|
478
|
+
display: inline-flex;
|
|
479
|
+
align-items: center;
|
|
480
|
+
gap: 6px;
|
|
481
|
+
transition: color 0.15s ease;
|
|
482
|
+
}
|
|
483
|
+
|
|
484
|
+
.back-link:hover {
|
|
485
|
+
color: var(--v-violet-400);
|
|
486
|
+
}
|
|
487
|
+
|
|
488
|
+
/* ── D3 node styles ───────────────────────────────────────── */
|
|
489
|
+
.node-circle {
|
|
490
|
+
cursor: pointer;
|
|
491
|
+
transition: filter 0.15s ease;
|
|
492
|
+
}
|
|
493
|
+
|
|
494
|
+
.node-circle:hover {
|
|
495
|
+
filter: brightness(1.3);
|
|
496
|
+
}
|
|
497
|
+
|
|
498
|
+
.link-line {
|
|
499
|
+
pointer-events: none;
|
|
500
|
+
}
|
|
501
|
+
|
|
502
|
+
.lobe-label {
|
|
503
|
+
pointer-events: none;
|
|
504
|
+
user-select: none;
|
|
505
|
+
}
|
|
506
|
+
|
|
507
|
+
/* ── Responsive ───────────────────────────────────────────── */
|
|
508
|
+
@media (max-width: 600px) {
|
|
509
|
+
.page-header { padding: 16px; }
|
|
510
|
+
.main-content { padding: 12px; }
|
|
511
|
+
.header-title { font-size: 20px; }
|
|
512
|
+
.stats-bar { font-size: 12px; }
|
|
513
|
+
}
|
|
514
|
+
</style>
|
|
515
|
+
</head>
|
|
516
|
+
<body>
|
|
517
|
+
|
|
518
|
+
<!-- ── Header ─────────────────────────────────────────────── -->
|
|
519
|
+
<header class="page-header">
|
|
520
|
+
<div class="header-top">
|
|
521
|
+
<div class="header-title-block">
|
|
522
|
+
<div class="header-title">🧠 Knowledge Brain</div>
|
|
523
|
+
<div class="header-subtitle">Identity Knowledge Graph</div>
|
|
524
|
+
</div>
|
|
525
|
+
<div style="display:flex;align-items:center;gap:12px;flex-wrap:wrap;">
|
|
526
|
+
<div class="knowledge-count-badge">
|
|
527
|
+
<div class="brain-pulse"></div>
|
|
528
|
+
<span id="badge-knowledge-count">0 knowledge items</span>
|
|
529
|
+
</div>
|
|
530
|
+
<button class="refresh-btn" id="btn-refresh" onclick="refreshBrainData()" title="Refresh">
|
|
531
|
+
<span id="refresh-icon">↻</span>
|
|
532
|
+
</button>
|
|
533
|
+
<div class="mode-toggle">
|
|
534
|
+
<button class="mode-btn active" id="btn-2d" onclick="switchMode('2d')">2D</button>
|
|
535
|
+
<button class="mode-btn" id="btn-3d" onclick="switchMode('3d')">3D</button>
|
|
536
|
+
</div>
|
|
537
|
+
</div>
|
|
538
|
+
</div>
|
|
539
|
+
<div class="stats-bar" id="stats-bar">
|
|
540
|
+
<span class="stat-item">
|
|
541
|
+
<span id="stat-entities" class="stat-value">0</span> entities
|
|
542
|
+
</span>
|
|
543
|
+
<span class="stat-sep">·</span>
|
|
544
|
+
<span class="stat-item">
|
|
545
|
+
<span id="stat-relations" class="stat-value">0</span> relations
|
|
546
|
+
</span>
|
|
547
|
+
<span class="stat-sep">·</span>
|
|
548
|
+
<span class="stat-item">
|
|
549
|
+
<span id="stat-memory" class="stat-value">0</span> memory items
|
|
550
|
+
</span>
|
|
551
|
+
<span class="stat-sep">·</span>
|
|
552
|
+
<span class="stat-item">Last updated: <span id="stat-updated" class="stat-value">—</span></span>
|
|
553
|
+
</div>
|
|
554
|
+
</header>
|
|
555
|
+
|
|
556
|
+
<!-- ── Main Content ───────────────────────────────────────── -->
|
|
557
|
+
<main class="main-content">
|
|
558
|
+
|
|
559
|
+
<!-- 2D Panel -->
|
|
560
|
+
<div class="viz-panel active" id="panel-2d">
|
|
561
|
+
<svg id="graph-svg"></svg>
|
|
562
|
+
</div>
|
|
563
|
+
|
|
564
|
+
<!-- 3D Panel -->
|
|
565
|
+
<div class="viz-panel" id="panel-3d">
|
|
566
|
+
<canvas id="brain-canvas-3d"></canvas>
|
|
567
|
+
</div>
|
|
568
|
+
|
|
569
|
+
<!-- Legend with category filter checkboxes -->
|
|
570
|
+
<div class="legend" id="legend">
|
|
571
|
+
<label class="legend-item" data-category="person">
|
|
572
|
+
<input type="checkbox" checked data-category="person" onchange="toggleCategory(this)" />
|
|
573
|
+
<div class="legend-dot" style="background:#22c55e"></div><span class="legend-label">👤 Person</span>
|
|
574
|
+
</label>
|
|
575
|
+
<label class="legend-item" data-category="project">
|
|
576
|
+
<input type="checkbox" checked data-category="project" onchange="toggleCategory(this)" />
|
|
577
|
+
<div class="legend-dot" style="background:#f97316"></div><span class="legend-label">📁 Project</span>
|
|
578
|
+
</label>
|
|
579
|
+
<label class="legend-item" data-category="tool">
|
|
580
|
+
<input type="checkbox" checked data-category="tool" onchange="toggleCategory(this)" />
|
|
581
|
+
<div class="legend-dot" style="background:#06b6d4"></div><span class="legend-label">🔧 Tool</span>
|
|
582
|
+
</label>
|
|
583
|
+
<label class="legend-item" data-category="organization">
|
|
584
|
+
<input type="checkbox" checked data-category="organization" onchange="toggleCategory(this)" />
|
|
585
|
+
<div class="legend-dot" style="background:#a855f7"></div><span class="legend-label">🏢 Organization</span>
|
|
586
|
+
</label>
|
|
587
|
+
<label class="legend-item" data-category="concept">
|
|
588
|
+
<input type="checkbox" checked data-category="concept" onchange="toggleCategory(this)" />
|
|
589
|
+
<div class="legend-dot" style="background:#eab308"></div><span class="legend-label">💡 Concept</span>
|
|
590
|
+
</label>
|
|
591
|
+
<label class="legend-item" data-category="location">
|
|
592
|
+
<input type="checkbox" checked data-category="location" onchange="toggleCategory(this)" />
|
|
593
|
+
<div class="legend-dot" style="background:#14b8a6"></div><span class="legend-label">📍 Location</span>
|
|
594
|
+
</label>
|
|
595
|
+
<label class="legend-item" data-category="profile">
|
|
596
|
+
<input type="checkbox" checked data-category="profile" onchange="toggleCategory(this)" />
|
|
597
|
+
<div class="legend-dot" style="background:#8b5cf6"></div><span class="legend-label">🟣 Profile</span>
|
|
598
|
+
</label>
|
|
599
|
+
<label class="legend-item" data-category="preference">
|
|
600
|
+
<input type="checkbox" checked data-category="preference" onchange="toggleCategory(this)" />
|
|
601
|
+
<div class="legend-dot" style="background:#3b82f6"></div><span class="legend-label">🔵 Preference</span>
|
|
602
|
+
</label>
|
|
603
|
+
<label class="legend-item" data-category="constraint">
|
|
604
|
+
<input type="checkbox" checked data-category="constraint" onchange="toggleCategory(this)" />
|
|
605
|
+
<div class="legend-dot" style="background:#ef4444"></div><span class="legend-label">🔴 Constraint</span>
|
|
606
|
+
</label>
|
|
607
|
+
<label class="legend-item" data-category="instruction">
|
|
608
|
+
<input type="checkbox" checked data-category="instruction" onchange="toggleCategory(this)" />
|
|
609
|
+
<div class="legend-dot" style="background:#f59e0b"></div><span class="legend-label">🟡 Instruction</span>
|
|
610
|
+
</label>
|
|
611
|
+
<label class="legend-item" data-category="style">
|
|
612
|
+
<input type="checkbox" checked data-category="style" onchange="toggleCategory(this)" />
|
|
613
|
+
<div class="legend-dot" style="background:#ec4899"></div><span class="legend-label">🩷 Style</span>
|
|
614
|
+
</label>
|
|
615
|
+
</div>
|
|
616
|
+
|
|
617
|
+
</main>
|
|
618
|
+
|
|
619
|
+
<!-- ── Footer ─────────────────────────────────────────────── -->
|
|
620
|
+
<footer class="page-footer">
|
|
621
|
+
<a href="/v1/home-base-ui" class="back-link">← Home</a>
|
|
622
|
+
</footer>
|
|
623
|
+
|
|
624
|
+
<!-- ── Tooltip ─────────────────────────────────────────────── -->
|
|
625
|
+
<div id="tooltip">
|
|
626
|
+
<div class="tooltip-name" id="tt-name"></div>
|
|
627
|
+
<div class="tooltip-type" id="tt-type"></div>
|
|
628
|
+
<div class="tooltip-row"><div class="tooltip-dot" id="tt-dot"></div><span id="tt-lobe"></span></div>
|
|
629
|
+
<div class="tooltip-row">🔗 <span id="tt-connections"></span></div>
|
|
630
|
+
<div class="tooltip-row">📌 <span id="tt-mentions"></span></div>
|
|
631
|
+
</div>
|
|
632
|
+
|
|
633
|
+
<script>
|
|
634
|
+
(function () {
|
|
635
|
+
'use strict';
|
|
636
|
+
|
|
637
|
+
/* ── Category filter state ───────────────────────────────── */
|
|
638
|
+
// Tracks which entity/memory types are currently hidden
|
|
639
|
+
var hiddenCategories = {};
|
|
640
|
+
|
|
641
|
+
function toggleCategory(checkbox) {
|
|
642
|
+
var cat = checkbox.getAttribute('data-category');
|
|
643
|
+
var label = checkbox.closest('.legend-item');
|
|
644
|
+
if (!checkbox.checked) {
|
|
645
|
+
hiddenCategories[cat] = true;
|
|
646
|
+
if (label) label.classList.add('hidden-category');
|
|
647
|
+
} else {
|
|
648
|
+
delete hiddenCategories[cat];
|
|
649
|
+
if (label) label.classList.remove('hidden-category');
|
|
650
|
+
}
|
|
651
|
+
// Apply visibility to 2D D3 nodes
|
|
652
|
+
// Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
|
|
653
|
+
d3.selectAll('.node-group').each(function(d) {
|
|
654
|
+
var type = (d.type === 'Memory' ? d.kind : (d.type || d.kind || '')).toLowerCase();
|
|
655
|
+
var visible = !hiddenCategories[type];
|
|
656
|
+
d3.select(this).style('display', visible ? null : 'none');
|
|
657
|
+
});
|
|
658
|
+
// Apply visibility to 3D nodes if they expose a filter hook
|
|
659
|
+
if (typeof window.filterThreeDCategories === 'function') {
|
|
660
|
+
window.filterThreeDCategories(hiddenCategories);
|
|
661
|
+
}
|
|
662
|
+
}
|
|
663
|
+
window.toggleCategory = toggleCategory;
|
|
664
|
+
|
|
665
|
+
/* ── Refresh ─────────────────────────────────────────────── */
|
|
666
|
+
function refreshBrainData() {
|
|
667
|
+
var btn = document.getElementById('btn-refresh');
|
|
668
|
+
if (btn) btn.classList.add('spinning');
|
|
669
|
+
// Clear existing panels
|
|
670
|
+
var panel2d = document.getElementById('panel-2d');
|
|
671
|
+
var panel3d = document.getElementById('panel-3d');
|
|
672
|
+
// Remove overlays (empty/error states)
|
|
673
|
+
panel2d.querySelectorAll('.state-overlay').forEach(function(el) { el.remove(); });
|
|
674
|
+
panel3d.querySelectorAll('.state-overlay').forEach(function(el) { el.remove(); });
|
|
675
|
+
// Reset 3D so it can reinitialise with new data
|
|
676
|
+
if (typeof window.destroyThreeD === 'function') window.destroyThreeD();
|
|
677
|
+
loadBrainData(function () {
|
|
678
|
+
if (btn) btn.classList.remove('spinning');
|
|
679
|
+
});
|
|
680
|
+
}
|
|
681
|
+
window.refreshBrainData = refreshBrainData;
|
|
682
|
+
|
|
683
|
+
/* ── Mode toggle ─────────────────────────────────────────── */
|
|
684
|
+
function switchMode(mode) {
|
|
685
|
+
document.getElementById('panel-2d').classList.toggle('active', mode === '2d');
|
|
686
|
+
document.getElementById('panel-3d').classList.toggle('active', mode === '3d');
|
|
687
|
+
document.getElementById('btn-2d').classList.toggle('active', mode === '2d');
|
|
688
|
+
document.getElementById('btn-3d').classList.toggle('active', mode === '3d');
|
|
689
|
+
if (mode === '3d') {
|
|
690
|
+
if (window.brainData && typeof window.initThreeD === 'function') {
|
|
691
|
+
window.initThreeD(window.brainData);
|
|
692
|
+
}
|
|
693
|
+
// Re-start the animation loop if it was previously stopped (e.g. after returning from 2D)
|
|
694
|
+
if (typeof window.resumeThreeD === 'function') {
|
|
695
|
+
window.resumeThreeD();
|
|
696
|
+
}
|
|
697
|
+
} else {
|
|
698
|
+
// Stop the Three.js render loop when leaving 3D to avoid wasting CPU/GPU
|
|
699
|
+
if (typeof window.stopThreeD === 'function') {
|
|
700
|
+
window.stopThreeD();
|
|
701
|
+
}
|
|
702
|
+
// Hide any lingering 3D tooltip - mouseleave is never fired when the
|
|
703
|
+
// parent panel is hidden via display:none, so we clear it explicitly.
|
|
704
|
+
document.getElementById('tooltip').classList.remove('visible');
|
|
705
|
+
}
|
|
706
|
+
}
|
|
707
|
+
window.switchMode = switchMode;
|
|
708
|
+
|
|
709
|
+
/* ── Stats helpers ────────────────────────────────────────── */
|
|
710
|
+
function updateStats(data) {
|
|
711
|
+
var entities = data.entities || [];
|
|
712
|
+
var relations = data.relations || [];
|
|
713
|
+
var memory = data.memorySummary || [];
|
|
714
|
+
var totalMemory = memory.reduce(function(s, m) { return s + (m.count || 0); }, 0);
|
|
715
|
+
var total = data.totalKnowledgeCount || (entities.length + totalMemory);
|
|
716
|
+
|
|
717
|
+
document.getElementById('stat-entities').textContent = entities.length;
|
|
718
|
+
document.getElementById('stat-relations').textContent = relations.length;
|
|
719
|
+
document.getElementById('stat-memory').textContent = totalMemory;
|
|
720
|
+
document.getElementById('badge-knowledge-count').textContent =
|
|
721
|
+
total + ' knowledge items';
|
|
722
|
+
|
|
723
|
+
if (data.generatedAt) {
|
|
724
|
+
var d = new Date(data.generatedAt);
|
|
725
|
+
document.getElementById('stat-updated').textContent = d.toLocaleString(undefined, {
|
|
726
|
+
month: 'short', day: 'numeric', hour: '2-digit', minute: '2-digit'
|
|
727
|
+
});
|
|
728
|
+
}
|
|
729
|
+
}
|
|
730
|
+
|
|
731
|
+
/* ── Brain SVG silhouette ─────────────────────────────────── */
|
|
732
|
+
function drawBrainSilhouette(svg, W, H) {
|
|
733
|
+
var cx = W / 2;
|
|
734
|
+
var cy = H / 2;
|
|
735
|
+
|
|
736
|
+
// Scale factors — brain occupies ~80% W, ~70% H
|
|
737
|
+
var bw = W * 0.78;
|
|
738
|
+
var bh = H * 0.68;
|
|
739
|
+
var lx = cx - bw * 0.26; // left lobe center x
|
|
740
|
+
var rx = cx + bw * 0.26; // right lobe center x
|
|
741
|
+
var lw = bw * 0.46;
|
|
742
|
+
var rw = bw * 0.46;
|
|
743
|
+
var lh = bh * 0.88;
|
|
744
|
+
var rh = bh * 0.88;
|
|
745
|
+
|
|
746
|
+
var brainG = svg.append('g').attr('class', 'brain-silhouette');
|
|
747
|
+
|
|
748
|
+
// Left lobe outline (organic blob via cubic bezier)
|
|
749
|
+
var leftPath = [
|
|
750
|
+
'M', cx - 4, cy + lh * 0.42,
|
|
751
|
+
'C', cx - 4, cy + lh * 0.55, lx - lw * 0.55, cy + lh * 0.55, lx - lw * 0.55, cy + lh * 0.2,
|
|
752
|
+
'C', lx - lw * 0.6, cy - lh * 0.05, lx - lw * 0.55, cy - lh * 0.35, lx - lw * 0.2, cy - lh * 0.5,
|
|
753
|
+
'C', lx + lw * 0.1, cy - lh * 0.62, lx + lw * 0.4, cy - lh * 0.58, lx + lw * 0.45, cy - lh * 0.42,
|
|
754
|
+
'C', lx + lw * 0.52, cy - lh * 0.22, cx - 4, cy - lh * 0.2, cx - 4, cy + lh * 0.42,
|
|
755
|
+
'Z'
|
|
756
|
+
].join(' ');
|
|
757
|
+
|
|
758
|
+
// Right lobe outline
|
|
759
|
+
var rightPath = [
|
|
760
|
+
'M', cx + 4, cy + rh * 0.42,
|
|
761
|
+
'C', cx + 4, cy + rh * 0.55, rx + rw * 0.55, cy + rh * 0.55, rx + rw * 0.55, cy + rh * 0.2,
|
|
762
|
+
'C', rx + rw * 0.6, cy - rh * 0.05, rx + rw * 0.55, cy - rh * 0.35, rx + rw * 0.2, cy - rh * 0.5,
|
|
763
|
+
'C', rx - rw * 0.1, cy - rh * 0.62, rx - rw * 0.4, cy - rh * 0.58, rx - rw * 0.45, cy - rh * 0.42,
|
|
764
|
+
'C', rx - rw * 0.52, cy - rh * 0.22, cx + 4, cy - rh * 0.2, cx + 4, cy + rh * 0.42,
|
|
765
|
+
'Z'
|
|
766
|
+
].join(' ');
|
|
767
|
+
|
|
768
|
+
// Draw fills
|
|
769
|
+
brainG.append('path')
|
|
770
|
+
.attr('d', leftPath)
|
|
771
|
+
.attr('fill', 'rgba(138, 91, 224, 0.04)')
|
|
772
|
+
.attr('stroke', 'rgba(138, 91, 224, 0.4)')
|
|
773
|
+
.attr('stroke-width', 1.5)
|
|
774
|
+
.attr('class', 'brain-outline');
|
|
775
|
+
|
|
776
|
+
brainG.append('path')
|
|
777
|
+
.attr('d', rightPath)
|
|
778
|
+
.attr('fill', 'rgba(138, 91, 224, 0.04)')
|
|
779
|
+
.attr('stroke', 'rgba(138, 91, 224, 0.4)')
|
|
780
|
+
.attr('stroke-width', 1.5)
|
|
781
|
+
.attr('class', 'brain-outline');
|
|
782
|
+
|
|
783
|
+
// Corpus callosum divider (dashed vertical line in center)
|
|
784
|
+
var ccTop = cy - lh * 0.28;
|
|
785
|
+
var ccBottom = cy + lh * 0.35;
|
|
786
|
+
brainG.append('line')
|
|
787
|
+
.attr('x1', cx).attr('y1', ccTop)
|
|
788
|
+
.attr('x2', cx).attr('y2', ccBottom)
|
|
789
|
+
.attr('stroke', 'rgba(138, 91, 224, 0.25)')
|
|
790
|
+
.attr('stroke-width', 1)
|
|
791
|
+
.attr('stroke-dasharray', '4,4');
|
|
792
|
+
|
|
793
|
+
// Lobe labels
|
|
794
|
+
var labelStyle = {
|
|
795
|
+
'font-family': "'DM Sans', sans-serif",
|
|
796
|
+
'font-size': '10px',
|
|
797
|
+
'font-weight': '600',
|
|
798
|
+
'fill': 'rgba(148, 163, 184, 0.45)',
|
|
799
|
+
'letter-spacing': '0.06em',
|
|
800
|
+
'text-transform': 'uppercase',
|
|
801
|
+
'pointer-events': 'none',
|
|
802
|
+
'user-select': 'none'
|
|
803
|
+
};
|
|
804
|
+
|
|
805
|
+
function addLabel(x, y, text) {
|
|
806
|
+
var t = brainG.append('text')
|
|
807
|
+
.attr('x', x).attr('y', y)
|
|
808
|
+
.attr('text-anchor', 'middle')
|
|
809
|
+
.attr('class', 'lobe-label')
|
|
810
|
+
.text(text);
|
|
811
|
+
Object.keys(labelStyle).forEach(function(k) { t.attr(k, labelStyle[k]); });
|
|
812
|
+
}
|
|
813
|
+
|
|
814
|
+
addLabel(lx - lw * 0.15, cy + 5, 'Analytical');
|
|
815
|
+
addLabel(rx + rw * 0.15, cy + 5, 'Creative');
|
|
816
|
+
addLabel(lx - lw * 0.1, cy - lh * 0.32, 'Planning');
|
|
817
|
+
addLabel(rx + rw * 0.1, cy - rh * 0.32, 'Social');
|
|
818
|
+
addLabel(cx, cy + lh * 0.5, 'Memory');
|
|
819
|
+
|
|
820
|
+
// Return brain bounds for use in bounding force
|
|
821
|
+
return {
|
|
822
|
+
leftLobe: { cx: lx, cy: cy, rx: lw * 0.52, ry: lh * 0.52 },
|
|
823
|
+
rightLobe: { cx: rx, cy: cy, rx: rw * 0.52, ry: rh * 0.52 },
|
|
824
|
+
centerX: cx,
|
|
825
|
+
centerY: cy,
|
|
826
|
+
width: bw,
|
|
827
|
+
height: bh
|
|
828
|
+
};
|
|
829
|
+
}
|
|
830
|
+
|
|
831
|
+
/* ── Lobe target positions ────────────────────────────────── */
|
|
832
|
+
function lobeTarget(lobeRegion, bounds) {
|
|
833
|
+
var cx = bounds.centerX;
|
|
834
|
+
var cy = bounds.centerY;
|
|
835
|
+
var lx = bounds.leftLobe.cx;
|
|
836
|
+
var rx = bounds.rightLobe.cx;
|
|
837
|
+
var hw = bounds.width * 0.18;
|
|
838
|
+
var hh = bounds.height * 0.18;
|
|
839
|
+
switch (lobeRegion) {
|
|
840
|
+
case 'right-social': return { x: rx + hw * 0.3, y: cy - hh * 1.2 };
|
|
841
|
+
case 'left-planning': return { x: lx - hw * 0.3, y: cy - hh * 1.2 };
|
|
842
|
+
case 'left-technical': return { x: lx - hw * 0.2, y: cy };
|
|
843
|
+
case 'right-creative': return { x: rx + hw * 0.2, y: cy };
|
|
844
|
+
case 'right-spatial': return { x: rx + hw * 0.2, y: cy + hh * 1.2 };
|
|
845
|
+
case 'center':
|
|
846
|
+
default: return { x: cx, y: cy + hh };
|
|
847
|
+
}
|
|
848
|
+
}
|
|
849
|
+
|
|
850
|
+
/* ── Tooltip logic ────────────────────────────────────────── */
|
|
851
|
+
var tooltip = document.getElementById('tooltip');
|
|
852
|
+
|
|
853
|
+
function showTooltip(event, d, connectionCount) {
|
|
854
|
+
document.getElementById('tt-name').textContent = d.name;
|
|
855
|
+
document.getElementById('tt-type').textContent = d.type || d.kind || 'memory';
|
|
856
|
+
document.getElementById('tt-dot').style.background = d.color || '#8A5BE0';
|
|
857
|
+
document.getElementById('tt-lobe').textContent = d.lobeRegion || 'center';
|
|
858
|
+
document.getElementById('tt-connections').textContent = (connectionCount || 0) + ' connections';
|
|
859
|
+
document.getElementById('tt-mentions').textContent = (d.mentionCount || d.count || 0) + ' mentions';
|
|
860
|
+
tooltip.classList.add('visible');
|
|
861
|
+
moveTooltip(event);
|
|
862
|
+
}
|
|
863
|
+
|
|
864
|
+
function moveTooltip(event) {
|
|
865
|
+
var x = event.clientX + 14;
|
|
866
|
+
var y = event.clientY - 10;
|
|
867
|
+
var tw = tooltip.offsetWidth || 200;
|
|
868
|
+
var th = tooltip.offsetHeight || 100;
|
|
869
|
+
if (x + tw > window.innerWidth - 8) x = event.clientX - tw - 14;
|
|
870
|
+
if (y + th > window.innerHeight - 8) y = event.clientY - th - 14;
|
|
871
|
+
tooltip.style.left = x + 'px';
|
|
872
|
+
tooltip.style.top = y + 'px';
|
|
873
|
+
}
|
|
874
|
+
|
|
875
|
+
function hideTooltip() {
|
|
876
|
+
tooltip.classList.remove('visible');
|
|
877
|
+
}
|
|
878
|
+
|
|
879
|
+
/* ── Render graph ─────────────────────────────────────────── */
|
|
880
|
+
function renderBrain(data) {
|
|
881
|
+
var svgEl = document.getElementById('graph-svg');
|
|
882
|
+
var W = svgEl.clientWidth || svgEl.parentElement.clientWidth || 800;
|
|
883
|
+
var H = svgEl.clientHeight || svgEl.parentElement.clientHeight || 500;
|
|
884
|
+
|
|
885
|
+
var svg = d3.select('#graph-svg')
|
|
886
|
+
.attr('viewBox', '0 0 ' + W + ' ' + H)
|
|
887
|
+
.attr('preserveAspectRatio', 'xMidYMid meet');
|
|
888
|
+
|
|
889
|
+
svg.selectAll('*').remove();
|
|
890
|
+
|
|
891
|
+
var entities = data.entities || [];
|
|
892
|
+
var relations = data.relations || [];
|
|
893
|
+
var memorySummary = data.memorySummary || [];
|
|
894
|
+
|
|
895
|
+
// Empty state — improved with pulsing brain emoji
|
|
896
|
+
if (entities.length === 0 && memorySummary.length === 0) {
|
|
897
|
+
var panel = document.getElementById('panel-2d');
|
|
898
|
+
var overlay = document.createElement('div');
|
|
899
|
+
overlay.className = 'state-overlay';
|
|
900
|
+
overlay.innerHTML =
|
|
901
|
+
'<div class="icon empty-brain-icon">🧠</div>' +
|
|
902
|
+
'<h3>Your brain is empty</h3>' +
|
|
903
|
+
'<p>Start a conversation to grow it!</p>';
|
|
904
|
+
panel.appendChild(overlay);
|
|
905
|
+
return;
|
|
906
|
+
}
|
|
907
|
+
|
|
908
|
+
// Draw brain silhouette
|
|
909
|
+
var bounds = drawBrainSilhouette(svg, W, H);
|
|
910
|
+
|
|
911
|
+
// Build node list
|
|
912
|
+
var nodeMap = {};
|
|
913
|
+
var nodes = entities.map(function(e) {
|
|
914
|
+
var n = Object.assign({}, e, {
|
|
915
|
+
_isEntity: true,
|
|
916
|
+
r: Math.max(6, Math.min(20, 6 + (e.mentionCount || 1) * 1.5))
|
|
917
|
+
});
|
|
918
|
+
nodeMap[e.id] = n;
|
|
919
|
+
return n;
|
|
920
|
+
});
|
|
921
|
+
|
|
922
|
+
// Add memory cluster nodes
|
|
923
|
+
memorySummary.forEach(function(m, i) {
|
|
924
|
+
var n = {
|
|
925
|
+
id: 'memory-' + i,
|
|
926
|
+
name: m.kind,
|
|
927
|
+
type: 'Memory',
|
|
928
|
+
kind: m.kind,
|
|
929
|
+
lobeRegion: 'center',
|
|
930
|
+
color: m.color || '#8b5cf6',
|
|
931
|
+
mentionCount: m.count || 0,
|
|
932
|
+
count: m.count || 0,
|
|
933
|
+
_isMemory: true,
|
|
934
|
+
r: Math.max(4, Math.min(8, 4 + Math.sqrt(m.count || 1)))
|
|
935
|
+
};
|
|
936
|
+
nodes.push(n);
|
|
937
|
+
});
|
|
938
|
+
|
|
939
|
+
// Build links
|
|
940
|
+
var links = relations.map(function(rel) {
|
|
941
|
+
return {
|
|
942
|
+
source: rel.sourceId,
|
|
943
|
+
target: rel.targetId,
|
|
944
|
+
relation: rel.relation
|
|
945
|
+
};
|
|
946
|
+
}).filter(function(l) {
|
|
947
|
+
return nodeMap[l.source] && nodeMap[l.target];
|
|
948
|
+
});
|
|
949
|
+
|
|
950
|
+
// Connection count map
|
|
951
|
+
var connCount = {};
|
|
952
|
+
links.forEach(function(l) {
|
|
953
|
+
connCount[l.source] = (connCount[l.source] || 0) + 1;
|
|
954
|
+
connCount[l.target] = (connCount[l.target] || 0) + 1;
|
|
955
|
+
});
|
|
956
|
+
|
|
957
|
+
// Initial positions near lobe targets
|
|
958
|
+
nodes.forEach(function(n) {
|
|
959
|
+
var t = lobeTarget(n.lobeRegion, bounds);
|
|
960
|
+
n.x = t.x + (Math.random() - 0.5) * 60;
|
|
961
|
+
n.y = t.y + (Math.random() - 0.5) * 60;
|
|
962
|
+
});
|
|
963
|
+
|
|
964
|
+
// ── D3 force simulation ──────────────────────────────────
|
|
965
|
+
var sim = d3.forceSimulation(nodes)
|
|
966
|
+
.force('link', d3.forceLink(links)
|
|
967
|
+
.id(function(d) { return d.id; })
|
|
968
|
+
.distance(60)
|
|
969
|
+
.strength(0.3))
|
|
970
|
+
.force('charge', d3.forceManyBody().strength(-80))
|
|
971
|
+
.force('collide', d3.forceCollide().radius(function(d) { return d.r + 4; }).strength(0.8))
|
|
972
|
+
// Lobe attraction force
|
|
973
|
+
.force('lobe', function(alpha) {
|
|
974
|
+
nodes.forEach(function(n) {
|
|
975
|
+
var t = lobeTarget(n.lobeRegion, bounds);
|
|
976
|
+
n.vx += (t.x - n.x) * alpha * 0.12;
|
|
977
|
+
n.vy += (t.y - n.y) * alpha * 0.12;
|
|
978
|
+
});
|
|
979
|
+
})
|
|
980
|
+
// Bounding force — keep nodes inside brain bounding ellipse
|
|
981
|
+
.force('bound', function(alpha) {
|
|
982
|
+
nodes.forEach(function(n) {
|
|
983
|
+
var lobe = bounds.leftLobe;
|
|
984
|
+
var rlobe = bounds.rightLobe;
|
|
985
|
+
// Check if inside either lobe (ellipse test)
|
|
986
|
+
var inLeft = Math.pow((n.x - lobe.cx) / (lobe.rx + n.r), 2) + Math.pow((n.y - lobe.cy) / (lobe.ry + n.r), 2);
|
|
987
|
+
var inRight = Math.pow((n.x - rlobe.cx) / (rlobe.rx + n.r), 2) + Math.pow((n.y - rlobe.cy) / (rlobe.ry + n.r), 2);
|
|
988
|
+
if (inLeft > 1 && inRight > 1) {
|
|
989
|
+
// Push toward closer lobe center
|
|
990
|
+
var dl = Math.hypot(n.x - lobe.cx, n.y - lobe.cy);
|
|
991
|
+
var dr = Math.hypot(n.x - rlobe.cx, n.y - rlobe.cy);
|
|
992
|
+
var target = dl < dr ? lobe : rlobe;
|
|
993
|
+
n.vx += (target.cx - n.x) * alpha * 0.5;
|
|
994
|
+
n.vy += (target.cy - n.y) * alpha * 0.5;
|
|
995
|
+
}
|
|
996
|
+
});
|
|
997
|
+
})
|
|
998
|
+
.alphaDecay(0.025)
|
|
999
|
+
.velocityDecay(0.4);
|
|
1000
|
+
|
|
1001
|
+
// Draw links
|
|
1002
|
+
var linkLayer = svg.append('g').attr('class', 'links');
|
|
1003
|
+
var linkSel = linkLayer.selectAll('line')
|
|
1004
|
+
.data(links)
|
|
1005
|
+
.enter().append('line')
|
|
1006
|
+
.attr('class', 'link-line')
|
|
1007
|
+
.attr('stroke', function(d) {
|
|
1008
|
+
var src = typeof d.source === 'object' ? d.source : nodeMap[d.source];
|
|
1009
|
+
return src ? src.color : 'rgba(138,91,224,0.15)';
|
|
1010
|
+
})
|
|
1011
|
+
.attr('stroke-width', 1)
|
|
1012
|
+
.attr('stroke-opacity', 0.15);
|
|
1013
|
+
|
|
1014
|
+
// Draw nodes
|
|
1015
|
+
var nodeLayer = svg.append('g').attr('class', 'nodes');
|
|
1016
|
+
var nodeSel = nodeLayer.selectAll('g')
|
|
1017
|
+
.data(nodes)
|
|
1018
|
+
.enter().append('g')
|
|
1019
|
+
.attr('class', 'node-group')
|
|
1020
|
+
.call(d3.drag()
|
|
1021
|
+
.on('start', function(event, d) {
|
|
1022
|
+
if (!event.active) sim.alphaTarget(0.3).restart();
|
|
1023
|
+
d.fx = d.x; d.fy = d.y;
|
|
1024
|
+
})
|
|
1025
|
+
.on('drag', function(event, d) {
|
|
1026
|
+
d.fx = event.x; d.fy = event.y;
|
|
1027
|
+
})
|
|
1028
|
+
.on('end', function(event, d) {
|
|
1029
|
+
if (!event.active) sim.alphaTarget(0);
|
|
1030
|
+
d.fx = null; d.fy = null;
|
|
1031
|
+
})
|
|
1032
|
+
);
|
|
1033
|
+
|
|
1034
|
+
nodeSel.append('circle')
|
|
1035
|
+
.attr('class', 'node-circle')
|
|
1036
|
+
.attr('r', function(d) { return d.r; })
|
|
1037
|
+
.attr('fill', function(d) { return d.color || '#8A5BE0'; })
|
|
1038
|
+
.attr('stroke', function(d) { return d.color || '#8A5BE0'; })
|
|
1039
|
+
.attr('stroke-width', 1.5)
|
|
1040
|
+
.attr('stroke-opacity', 0.6)
|
|
1041
|
+
.attr('fill-opacity', 0.85);
|
|
1042
|
+
|
|
1043
|
+
// Node hover events
|
|
1044
|
+
nodeSel
|
|
1045
|
+
.on('mouseenter', function(event, d) {
|
|
1046
|
+
d3.select(this).select('circle')
|
|
1047
|
+
.transition().duration(120)
|
|
1048
|
+
.attr('r', function(d) { return d.r * 1.35; })
|
|
1049
|
+
.attr('fill-opacity', 1);
|
|
1050
|
+
showTooltip(event, d, connCount[d.id] || 0);
|
|
1051
|
+
})
|
|
1052
|
+
.on('mousemove', function(event) {
|
|
1053
|
+
moveTooltip(event);
|
|
1054
|
+
})
|
|
1055
|
+
.on('mouseleave', function(event, d) {
|
|
1056
|
+
d3.select(this).select('circle')
|
|
1057
|
+
.transition().duration(120)
|
|
1058
|
+
.attr('r', function(d) { return d.r; })
|
|
1059
|
+
.attr('fill-opacity', 0.85);
|
|
1060
|
+
hideTooltip();
|
|
1061
|
+
});
|
|
1062
|
+
|
|
1063
|
+
// Tick
|
|
1064
|
+
sim.on('tick', function() {
|
|
1065
|
+
linkSel
|
|
1066
|
+
.attr('x1', function(d) { return d.source.x; })
|
|
1067
|
+
.attr('y1', function(d) { return d.source.y; })
|
|
1068
|
+
.attr('x2', function(d) { return d.target.x; })
|
|
1069
|
+
.attr('y2', function(d) { return d.target.y; });
|
|
1070
|
+
|
|
1071
|
+
nodeSel.attr('transform', function(d) {
|
|
1072
|
+
return 'translate(' + d.x + ',' + d.y + ')';
|
|
1073
|
+
});
|
|
1074
|
+
});
|
|
1075
|
+
}
|
|
1076
|
+
|
|
1077
|
+
/* ── Data fetch ───────────────────────────────────────────── */
|
|
1078
|
+
async function loadBrainData(done) {
|
|
1079
|
+
try {
|
|
1080
|
+
var apiToken = document.querySelector('meta[name="api-token"]')?.content;
|
|
1081
|
+
var headers = {};
|
|
1082
|
+
if (apiToken) headers['Authorization'] = 'Bearer ' + apiToken;
|
|
1083
|
+
var res = await fetch('/v1/brain-graph', { headers });
|
|
1084
|
+
if (!res.ok) throw new Error('HTTP ' + res.status);
|
|
1085
|
+
var data = await res.json();
|
|
1086
|
+
window.brainData = data;
|
|
1087
|
+
updateStats(data);
|
|
1088
|
+
renderBrain(data);
|
|
1089
|
+
// Re-apply active category filters after re-render.
|
|
1090
|
+
// Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
|
|
1091
|
+
d3.selectAll('.node-group').each(function(d) {
|
|
1092
|
+
var type = (d.type === 'Memory' ? d.kind : (d.type || d.kind || '')).toLowerCase();
|
|
1093
|
+
if (hiddenCategories[type]) d3.select(this).style('display', 'none');
|
|
1094
|
+
});
|
|
1095
|
+
// If the user switched to 3D before this fetch resolved, the panel is
|
|
1096
|
+
// already active but initThreeD was never called. Kick it off now.
|
|
1097
|
+
if (document.getElementById('panel-3d').classList.contains('active') &&
|
|
1098
|
+
typeof window.initThreeD === 'function') {
|
|
1099
|
+
window.initThreeD(data);
|
|
1100
|
+
}
|
|
1101
|
+
if (typeof done === 'function') done();
|
|
1102
|
+
} catch (err) {
|
|
1103
|
+
console.error('Brain graph load error:', err);
|
|
1104
|
+
var panel = document.getElementById('panel-2d');
|
|
1105
|
+
var overlay = document.createElement('div');
|
|
1106
|
+
overlay.className = 'state-overlay';
|
|
1107
|
+
overlay.innerHTML =
|
|
1108
|
+
'<div class="icon">⚠️</div>' +
|
|
1109
|
+
'<h3>Could not load brain data</h3>' +
|
|
1110
|
+
'<p></p>';
|
|
1111
|
+
overlay.querySelector('p').textContent = err.message || 'An unexpected error occurred.';
|
|
1112
|
+
panel.appendChild(overlay);
|
|
1113
|
+
if (typeof done === 'function') done();
|
|
1114
|
+
}
|
|
1115
|
+
}
|
|
1116
|
+
|
|
1117
|
+
// Kick off on load
|
|
1118
|
+
loadBrainData();
|
|
1119
|
+
})();
|
|
1120
|
+
</script>
|
|
1121
|
+
|
|
1122
|
+
<script type="module">
|
|
1123
|
+
import * as THREE from 'three';
|
|
1124
|
+
import { OrbitControls } from 'three/addons/controls/OrbitControls.js';
|
|
1125
|
+
|
|
1126
|
+
// Lobe region → scatter bounds in 3D space
|
|
1127
|
+
const LOBE_BOUNDS = {
|
|
1128
|
+
'right-social': { xMin: 0.5, xMax: 1.5, yMin: 0.5, yMax: 1.5, zMin: -1, zMax: 1 },
|
|
1129
|
+
'left-planning': { xMin: -1.5, xMax: -0.5, yMin: 0.5, yMax: 1.5, zMin: -1, zMax: 1 },
|
|
1130
|
+
'left-technical': { xMin: -1.5, xMax: -0.5, yMin: -0.5, yMax: 0.5, zMin: -1, zMax: 1 },
|
|
1131
|
+
'right-creative': { xMin: 0.5, xMax: 1.5, yMin: -0.5, yMax: 0.5, zMin: -1, zMax: 1 },
|
|
1132
|
+
'right-spatial': { xMin: 0.5, xMax: 1.5, yMin: -1.5, yMax: -0.5, zMin: -1, zMax: 1 },
|
|
1133
|
+
'center': { xMin: -0.5, xMax: 0.5, yMin: -0.5, yMax: 0.5, zMin: -0.5, zMax: 0.5 },
|
|
1134
|
+
};
|
|
1135
|
+
|
|
1136
|
+
function rand(min, max) {
|
|
1137
|
+
return min + Math.random() * (max - min);
|
|
1138
|
+
}
|
|
1139
|
+
|
|
1140
|
+
function nodePosition(lobeRegion) {
|
|
1141
|
+
const b = LOBE_BOUNDS[lobeRegion] || LOBE_BOUNDS['center'];
|
|
1142
|
+
return new THREE.Vector3(
|
|
1143
|
+
rand(b.xMin, b.xMax) + rand(-0.2, 0.2),
|
|
1144
|
+
rand(b.yMin, b.yMax) + rand(-0.2, 0.2),
|
|
1145
|
+
rand(b.zMin, b.zMax) + rand(-0.2, 0.2)
|
|
1146
|
+
);
|
|
1147
|
+
}
|
|
1148
|
+
|
|
1149
|
+
function hexStringToInt(hex) {
|
|
1150
|
+
// Accepts strings like '#22c55e' or '22c55e'
|
|
1151
|
+
return parseInt(hex.replace('#', ''), 16);
|
|
1152
|
+
}
|
|
1153
|
+
|
|
1154
|
+
let initialized = false;
|
|
1155
|
+
let animFrameId = null;
|
|
1156
|
+
let renderer = null;
|
|
1157
|
+
// animate reference stored here so resumeThreeD can call it without a closure capture issue
|
|
1158
|
+
let animateFn = null;
|
|
1159
|
+
// Stored so destroyThreeD can remove it and prevent stale calls into a null renderer after refresh
|
|
1160
|
+
let resizeHandler = null;
|
|
1161
|
+
|
|
1162
|
+
function stopAnimation() {
|
|
1163
|
+
if (animFrameId !== null) {
|
|
1164
|
+
cancelAnimationFrame(animFrameId);
|
|
1165
|
+
animFrameId = null;
|
|
1166
|
+
}
|
|
1167
|
+
}
|
|
1168
|
+
|
|
1169
|
+
// Exposed so the tab-switch handler in the non-module script can call them
|
|
1170
|
+
window.stopThreeD = stopAnimation;
|
|
1171
|
+
window.resumeThreeD = function resumeThreeD() {
|
|
1172
|
+
// Only restart if the 3D scene is already initialised and loop is not running
|
|
1173
|
+
if (initialized && animFrameId === null && typeof animateFn === 'function') {
|
|
1174
|
+
animateFn();
|
|
1175
|
+
}
|
|
1176
|
+
};
|
|
1177
|
+
|
|
1178
|
+
// Reset 3D state so initThreeD can be called again after a refresh
|
|
1179
|
+
window.destroyThreeD = function destroyThreeD() {
|
|
1180
|
+
stopAnimation();
|
|
1181
|
+
if (resizeHandler) {
|
|
1182
|
+
window.removeEventListener('resize', resizeHandler);
|
|
1183
|
+
resizeHandler = null;
|
|
1184
|
+
}
|
|
1185
|
+
if (renderer) {
|
|
1186
|
+
if (renderer.domElement && renderer.domElement.parentNode) {
|
|
1187
|
+
renderer.domElement.parentNode.removeChild(renderer.domElement);
|
|
1188
|
+
}
|
|
1189
|
+
renderer.dispose();
|
|
1190
|
+
renderer = null;
|
|
1191
|
+
}
|
|
1192
|
+
initialized = false;
|
|
1193
|
+
animateFn = null;
|
|
1194
|
+
};
|
|
1195
|
+
|
|
1196
|
+
window.addEventListener('beforeunload', stopAnimation);
|
|
1197
|
+
|
|
1198
|
+
window.initThreeD = function initThreeD(data) {
|
|
1199
|
+
// Avoid double-initialisation if already running
|
|
1200
|
+
if (initialized) return;
|
|
1201
|
+
|
|
1202
|
+
let canvas = document.getElementById('brain-canvas-3d');
|
|
1203
|
+
const panel = document.getElementById('panel-3d');
|
|
1204
|
+
|
|
1205
|
+
const entities = (data && data.entities) || [];
|
|
1206
|
+
const relations = (data && data.relations) || [];
|
|
1207
|
+
const memorySummary = (data && data.memorySummary) || [];
|
|
1208
|
+
const totalKnowledgeCount = (data && data.totalKnowledgeCount) || (entities.length + memorySummary.reduce((s, m) => s + (m.count || 0), 0));
|
|
1209
|
+
|
|
1210
|
+
// ── Empty state ──────────────────────────────────────────────
|
|
1211
|
+
if (entities.length === 0 && memorySummary.length === 0) {
|
|
1212
|
+
if (!panel.querySelector('.state-overlay')) {
|
|
1213
|
+
const overlay = document.createElement('div');
|
|
1214
|
+
overlay.className = 'state-overlay';
|
|
1215
|
+
overlay.innerHTML = '<div class="icon">🌱</div><h3>No knowledge yet</h3><p>Start a conversation to grow it!</p>';
|
|
1216
|
+
panel.appendChild(overlay);
|
|
1217
|
+
}
|
|
1218
|
+
return;
|
|
1219
|
+
}
|
|
1220
|
+
|
|
1221
|
+
// Mark as initialized only after all early returns, so empty-data calls
|
|
1222
|
+
// don't permanently block a subsequent call with real data.
|
|
1223
|
+
initialized = true;
|
|
1224
|
+
|
|
1225
|
+
// ── Renderer ─────────────────────────────────────────────────
|
|
1226
|
+
// canvas may be null if destroyThreeD removed it from the DOM; in that
|
|
1227
|
+
// case omit the canvas option so Three.js creates a fresh canvas element.
|
|
1228
|
+
const rendererOpts = canvas
|
|
1229
|
+
? { canvas, antialias: true, alpha: true }
|
|
1230
|
+
: { antialias: true, alpha: true };
|
|
1231
|
+
renderer = new THREE.WebGLRenderer(rendererOpts);
|
|
1232
|
+
if (!canvas) {
|
|
1233
|
+
renderer.domElement.id = 'brain-canvas-3d';
|
|
1234
|
+
panel.appendChild(renderer.domElement);
|
|
1235
|
+
}
|
|
1236
|
+
canvas = canvas || renderer.domElement;
|
|
1237
|
+
renderer.setPixelRatio(window.devicePixelRatio);
|
|
1238
|
+
renderer.setClearColor(0x000000, 0);
|
|
1239
|
+
|
|
1240
|
+
function getSize() {
|
|
1241
|
+
return { w: panel.clientWidth || 800, h: panel.clientHeight || 500 };
|
|
1242
|
+
}
|
|
1243
|
+
const { w, h } = getSize();
|
|
1244
|
+
renderer.setSize(w, h);
|
|
1245
|
+
|
|
1246
|
+
// ── Scene ────────────────────────────────────────────────────
|
|
1247
|
+
const scene = new THREE.Scene();
|
|
1248
|
+
|
|
1249
|
+
// ── Camera ───────────────────────────────────────────────────
|
|
1250
|
+
const camera = new THREE.PerspectiveCamera(60, w / h, 0.1, 100);
|
|
1251
|
+
camera.position.z = 8;
|
|
1252
|
+
|
|
1253
|
+
// ── Lights ───────────────────────────────────────────────────
|
|
1254
|
+
scene.add(new THREE.AmbientLight(0xffffff, 0.4));
|
|
1255
|
+
const dirLight = new THREE.DirectionalLight(0xffffff, 0.8);
|
|
1256
|
+
dirLight.position.set(10, 10, 10);
|
|
1257
|
+
scene.add(dirLight);
|
|
1258
|
+
const pointLight = new THREE.PointLight(0x8a5be0, 1.5, 15);
|
|
1259
|
+
pointLight.position.set(0, 3, 3);
|
|
1260
|
+
scene.add(pointLight);
|
|
1261
|
+
|
|
1262
|
+
// ── Brain mesh ───────────────────────────────────────────────
|
|
1263
|
+
const brainGeo = new THREE.SphereGeometry(2.5, 64, 64);
|
|
1264
|
+
|
|
1265
|
+
// Deform to brain-like shape
|
|
1266
|
+
const positions = brainGeo.attributes.position;
|
|
1267
|
+
for (let i = 0; i < positions.count; i++) {
|
|
1268
|
+
const x = positions.getX(i), y = positions.getY(i), z = positions.getZ(i);
|
|
1269
|
+
const noise = 0.3 * Math.sin(3 * x) * Math.cos(3 * y) + 0.1 * Math.sin(7 * z);
|
|
1270
|
+
const scale = 1 + noise;
|
|
1271
|
+
positions.setXYZ(i, x * scale, y * scale, z * scale);
|
|
1272
|
+
}
|
|
1273
|
+
brainGeo.computeVertexNormals();
|
|
1274
|
+
|
|
1275
|
+
// Scale based on knowledge count
|
|
1276
|
+
const rawScale = 0.5 + Math.log10(Math.max(totalKnowledgeCount, 1)) * 0.5;
|
|
1277
|
+
const brainScale = Math.min(rawScale, 2.0);
|
|
1278
|
+
|
|
1279
|
+
// Wireframe shell (outer visual)
|
|
1280
|
+
const wireMat = new THREE.MeshBasicMaterial({
|
|
1281
|
+
color: 0x8a5be0,
|
|
1282
|
+
wireframe: true,
|
|
1283
|
+
transparent: true,
|
|
1284
|
+
opacity: 0.25,
|
|
1285
|
+
});
|
|
1286
|
+
const brainMesh = new THREE.Mesh(brainGeo, wireMat);
|
|
1287
|
+
brainMesh.scale.setScalar(brainScale);
|
|
1288
|
+
scene.add(brainMesh);
|
|
1289
|
+
|
|
1290
|
+
// Solid inner fill (BackSide for depth effect)
|
|
1291
|
+
const solidMat = new THREE.MeshPhongMaterial({
|
|
1292
|
+
color: 0x321669,
|
|
1293
|
+
transparent: true,
|
|
1294
|
+
opacity: 0.15,
|
|
1295
|
+
side: THREE.BackSide,
|
|
1296
|
+
});
|
|
1297
|
+
const solidMesh = new THREE.Mesh(brainGeo, solidMat);
|
|
1298
|
+
solidMesh.scale.setScalar(brainScale);
|
|
1299
|
+
scene.add(solidMesh);
|
|
1300
|
+
|
|
1301
|
+
// ── Build nodes ───────────────────────────────────────────────
|
|
1302
|
+
const nodeMeshes = []; // for raycasting
|
|
1303
|
+
const nodeEntityMap = new WeakMap(); // mesh → entity data
|
|
1304
|
+
|
|
1305
|
+
// Entity nodes
|
|
1306
|
+
entities.forEach(function(entity) {
|
|
1307
|
+
const radius = 0.04 + 0.02 * Math.min(entity.mentionCount || 1, 10);
|
|
1308
|
+
const geo = new THREE.SphereGeometry(radius, 16, 16);
|
|
1309
|
+
const color = hexStringToInt(entity.color || '#8a5be0');
|
|
1310
|
+
const mat = new THREE.MeshPhongMaterial({
|
|
1311
|
+
color,
|
|
1312
|
+
emissive: color,
|
|
1313
|
+
emissiveIntensity: 0.3,
|
|
1314
|
+
});
|
|
1315
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
1316
|
+
const pos = nodePosition(entity.lobeRegion || 'center');
|
|
1317
|
+
mesh.position.copy(pos);
|
|
1318
|
+
scene.add(mesh);
|
|
1319
|
+
nodeMeshes.push(mesh);
|
|
1320
|
+
nodeEntityMap.set(mesh, entity);
|
|
1321
|
+
});
|
|
1322
|
+
|
|
1323
|
+
// Memory summary nodes (center, smaller)
|
|
1324
|
+
memorySummary.forEach(function(m, i) {
|
|
1325
|
+
const geo = new THREE.SphereGeometry(0.06, 16, 16);
|
|
1326
|
+
const color = hexStringToInt(m.color || '#8b5cf6');
|
|
1327
|
+
const mat = new THREE.MeshPhongMaterial({
|
|
1328
|
+
color,
|
|
1329
|
+
emissive: color,
|
|
1330
|
+
emissiveIntensity: 0.3,
|
|
1331
|
+
});
|
|
1332
|
+
const mesh = new THREE.Mesh(geo, mat);
|
|
1333
|
+
const pos = nodePosition('center');
|
|
1334
|
+
mesh.position.copy(pos);
|
|
1335
|
+
scene.add(mesh);
|
|
1336
|
+
nodeMeshes.push(mesh);
|
|
1337
|
+
nodeEntityMap.set(mesh, {
|
|
1338
|
+
name: m.kind,
|
|
1339
|
+
type: 'Memory',
|
|
1340
|
+
kind: m.kind,
|
|
1341
|
+
lobeRegion: 'center',
|
|
1342
|
+
color: m.color || '#8b5cf6',
|
|
1343
|
+
mentionCount: m.count || 0,
|
|
1344
|
+
count: m.count || 0,
|
|
1345
|
+
});
|
|
1346
|
+
});
|
|
1347
|
+
|
|
1348
|
+
// Build a position lookup by entity id for relation lines
|
|
1349
|
+
const entityPositions = {};
|
|
1350
|
+
let meshIdx = 0;
|
|
1351
|
+
entities.forEach(function(entity) {
|
|
1352
|
+
entityPositions[entity.id] = nodeMeshes[meshIdx].position;
|
|
1353
|
+
meshIdx++;
|
|
1354
|
+
});
|
|
1355
|
+
|
|
1356
|
+
// ── Relation lines ────────────────────────────────────────────
|
|
1357
|
+
relations.forEach(function(rel) {
|
|
1358
|
+
const srcPos = entityPositions[rel.sourceId];
|
|
1359
|
+
const tgtPos = entityPositions[rel.targetId];
|
|
1360
|
+
if (!srcPos || !tgtPos) return;
|
|
1361
|
+
|
|
1362
|
+
const srcEntity = entities.find(function(e) { return e.id === rel.sourceId; });
|
|
1363
|
+
const color = hexStringToInt((srcEntity && srcEntity.color) || '#8a5be0');
|
|
1364
|
+
|
|
1365
|
+
const points = [srcPos.clone(), tgtPos.clone()];
|
|
1366
|
+
const geo = new THREE.BufferGeometry().setFromPoints(points);
|
|
1367
|
+
const mat = new THREE.LineBasicMaterial({ color, transparent: true, opacity: 0.15 });
|
|
1368
|
+
scene.add(new THREE.Line(geo, mat));
|
|
1369
|
+
});
|
|
1370
|
+
|
|
1371
|
+
// ── Controls ──────────────────────────────────────────────────
|
|
1372
|
+
const controls = new OrbitControls(camera, renderer.domElement);
|
|
1373
|
+
controls.enableDamping = true;
|
|
1374
|
+
controls.autoRotate = true;
|
|
1375
|
+
controls.autoRotateSpeed = 0.5;
|
|
1376
|
+
|
|
1377
|
+
// Pause auto-rotate while hovering
|
|
1378
|
+
canvas.addEventListener('mouseenter', function() { controls.autoRotate = false; });
|
|
1379
|
+
canvas.addEventListener('mouseleave', function() { controls.autoRotate = true; });
|
|
1380
|
+
|
|
1381
|
+
// ── Tooltip via raycasting ────────────────────────────────────
|
|
1382
|
+
const raycaster = new THREE.Raycaster();
|
|
1383
|
+
const mouse = new THREE.Vector2();
|
|
1384
|
+
const tooltip = document.getElementById('tooltip');
|
|
1385
|
+
|
|
1386
|
+
canvas.addEventListener('mousemove', function(event) {
|
|
1387
|
+
// Guard against stale listener firing after destroyThreeD nulls the renderer
|
|
1388
|
+
if (!renderer) return;
|
|
1389
|
+
const rect = canvas.getBoundingClientRect();
|
|
1390
|
+
mouse.x = ((event.clientX - rect.left) / rect.width) * 2 - 1;
|
|
1391
|
+
mouse.y = -((event.clientY - rect.top) / rect.height) * 2 + 1;
|
|
1392
|
+
|
|
1393
|
+
raycaster.setFromCamera(mouse, camera);
|
|
1394
|
+
const intersects = raycaster.intersectObjects(nodeMeshes);
|
|
1395
|
+
if (intersects.length > 0) {
|
|
1396
|
+
const entity = nodeEntityMap.get(intersects[0].object);
|
|
1397
|
+
if (entity) {
|
|
1398
|
+
document.getElementById('tt-name').textContent = entity.name || '';
|
|
1399
|
+
document.getElementById('tt-type').textContent = entity.type || entity.kind || 'memory';
|
|
1400
|
+
document.getElementById('tt-dot').style.background = entity.color || '#8A5BE0';
|
|
1401
|
+
document.getElementById('tt-lobe').textContent = entity.lobeRegion || 'center';
|
|
1402
|
+
document.getElementById('tt-connections').textContent = '— connections';
|
|
1403
|
+
document.getElementById('tt-mentions').textContent = (entity.mentionCount || entity.count || 0) + ' mentions';
|
|
1404
|
+
tooltip.classList.add('visible');
|
|
1405
|
+
// Position near cursor
|
|
1406
|
+
let x = event.clientX + 14;
|
|
1407
|
+
let y = event.clientY - 10;
|
|
1408
|
+
const tw = tooltip.offsetWidth || 200;
|
|
1409
|
+
const th = tooltip.offsetHeight || 100;
|
|
1410
|
+
if (x + tw > window.innerWidth - 8) x = event.clientX - tw - 14;
|
|
1411
|
+
if (y + th > window.innerHeight - 8) y = event.clientY - th - 14;
|
|
1412
|
+
tooltip.style.left = x + 'px';
|
|
1413
|
+
tooltip.style.top = y + 'px';
|
|
1414
|
+
}
|
|
1415
|
+
} else {
|
|
1416
|
+
tooltip.classList.remove('visible');
|
|
1417
|
+
}
|
|
1418
|
+
});
|
|
1419
|
+
|
|
1420
|
+
canvas.addEventListener('mouseleave', function() {
|
|
1421
|
+
tooltip.classList.remove('visible');
|
|
1422
|
+
});
|
|
1423
|
+
|
|
1424
|
+
// ── Category filter for 3D nodes ──────────────────────────────
|
|
1425
|
+
window.filterThreeDCategories = function (hiddenCategories) {
|
|
1426
|
+
nodeMeshes.forEach(function (mesh) {
|
|
1427
|
+
const entity = nodeEntityMap.get(mesh);
|
|
1428
|
+
if (!entity) return;
|
|
1429
|
+
// Memory nodes carry type='Memory' with the real category in kind, so prefer kind for them.
|
|
1430
|
+
const type = (entity.type === 'Memory' ? entity.kind : (entity.type || entity.kind || '')).toLowerCase();
|
|
1431
|
+
mesh.visible = !hiddenCategories[type];
|
|
1432
|
+
});
|
|
1433
|
+
};
|
|
1434
|
+
|
|
1435
|
+
// Replay any checkbox state that was set before 3D was initialised.
|
|
1436
|
+
// If the user unchecked categories while in 2D view, those hidden states
|
|
1437
|
+
// are recorded in the checkboxes but filterThreeDCategories didn't exist yet.
|
|
1438
|
+
(function replayHiddenCategories() {
|
|
1439
|
+
var hidden = {};
|
|
1440
|
+
document.querySelectorAll('.legend-item input[type="checkbox"]').forEach(function(cb) {
|
|
1441
|
+
if (!cb.checked) hidden[cb.dataset.category || cb.value] = true;
|
|
1442
|
+
});
|
|
1443
|
+
if (Object.keys(hidden).length > 0) {
|
|
1444
|
+
window.filterThreeDCategories(hidden);
|
|
1445
|
+
}
|
|
1446
|
+
})();
|
|
1447
|
+
|
|
1448
|
+
// ── Resize handler ────────────────────────────────────────────
|
|
1449
|
+
function onResize() {
|
|
1450
|
+
// Guard against stale listener firing after destroyThreeD nulls the renderer
|
|
1451
|
+
if (!renderer) return;
|
|
1452
|
+
const { w, h } = getSize();
|
|
1453
|
+
if (w === 0 || h === 0) return;
|
|
1454
|
+
camera.aspect = w / h;
|
|
1455
|
+
camera.updateProjectionMatrix();
|
|
1456
|
+
renderer.setSize(w, h);
|
|
1457
|
+
}
|
|
1458
|
+
resizeHandler = onResize;
|
|
1459
|
+
window.addEventListener('resize', resizeHandler);
|
|
1460
|
+
|
|
1461
|
+
// ── Animation loop ────────────────────────────────────────────
|
|
1462
|
+
function animate() {
|
|
1463
|
+
animFrameId = requestAnimationFrame(animate);
|
|
1464
|
+
controls.update();
|
|
1465
|
+
|
|
1466
|
+
// Slow y-rotation of the brain mesh
|
|
1467
|
+
brainMesh.rotation.y += 0.001;
|
|
1468
|
+
solidMesh.rotation.y += 0.001;
|
|
1469
|
+
|
|
1470
|
+
// Gentle breathing pulse
|
|
1471
|
+
const pulse = brainScale * (1 + 0.02 * Math.sin(Date.now() / 1000));
|
|
1472
|
+
brainMesh.scale.setScalar(pulse);
|
|
1473
|
+
solidMesh.scale.setScalar(pulse);
|
|
1474
|
+
|
|
1475
|
+
renderer.render(scene, camera);
|
|
1476
|
+
}
|
|
1477
|
+
// Store reference so resumeThreeD can restart the loop after stopThreeD cancels it
|
|
1478
|
+
animateFn = animate;
|
|
1479
|
+
animate();
|
|
1480
|
+
};
|
|
1481
|
+
</script>
|
|
1482
|
+
</body>
|
|
1483
|
+
</html>
|