dsh-memory-cloud 1.0.1 → 1.1.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +10 -2
- package/index.js +160 -0
- package/nebula.css +67 -0
- package/nebula.html +16 -0
- package/nebula.js +245 -1
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -15,8 +15,8 @@ DSH 没有独立的记忆库:每一条持久记忆都是 `$DSH_HOME/sessions`
|
|
|
15
15
|
- **星云**(默认):不规则絮状气体云(无球核、无硬边界,参考 NASA 星系云团),团间用细电弧连接;电流靠**沿线明暗渐变**表现传递,暗区纯黑、余晖自然衰减。
|
|
16
16
|
- **神经元**:细胞体 + 递归分叉的树状树突 + 突触终扣 + 弯曲轴突,共用同一套信号波与流速。
|
|
17
17
|
- **规模映射真实数据**:云团凝聚核数、光斑数、纤维/尘埃密度、记忆星数量、树突主干数与分叉深度,全部按该会话的实际碎片数/事件数归一化缩放。
|
|
18
|
-
-
|
|
19
|
-
| 等级 | 徽章 | 触发缩放 | 显示内容 |
|
|
18
|
+
- **实时记忆流**:插件订阅 Host 的 `session/event`(提交后追加流),通过 SSE 把"记忆正在写入 / 正在被读取"推到页面。右下角 HUD 显示写入/读取计数、事件速率与滚动活动流;画布上同步出现**写入涟漪向外扩散、读取涟漪向内收拢**、新生记忆星,以及被激活星云团持续数秒的热晕。对话开始时若该会话还没落盘,会先长出一个**临时星云团**,落盘后自动并入正式数据。工具调用类噪声事件按 600ms 合并,避免刷屏。
|
|
19
|
+
- **分级细节(LOD)**:缩放即钻取,每放大一级就多露出一层记忆—— | 等级 | 徽章 | 触发缩放 | 显示内容 |
|
|
20
20
|
|---|---|---|---|
|
|
21
21
|
| 0 | 全景 | < 1.5× | 云团 + 会话标题 |
|
|
22
22
|
| 1 | 星团 | ≥ 1.5× | 标题 + 记忆条数/对话数/工具数 |
|
|
@@ -106,6 +106,14 @@ trustedHosts: [] # 额外允许访问插件路由的 Host
|
|
|
106
106
|
{ "id", "title", "workspaceLabel", "createdAt", "updatedAt", "counts", "fragments": [{ "kind", "text", "truncated", "time", "seq" }] }
|
|
107
107
|
```
|
|
108
108
|
|
|
109
|
+
`GET /memory-cloud/api/live` —— **SSE 实时记忆活动流**(`text/event-stream`,15 秒心跳,连接建立时回放最近 24 条):
|
|
110
|
+
|
|
111
|
+
```json
|
|
112
|
+
{ "seq", "sessionId", "sessionTitle", "kind": "write" | "read", "type", "text", "tool", "time" }
|
|
113
|
+
```
|
|
114
|
+
|
|
115
|
+
`kind` 语义:`write` = 一条记忆刚被提交写入会话日志(用户消息、助手消息、会话标题);`read` = 一条记忆正被读取/引用(工具调用与结果、上下文注入、压缩整理、轮次结束)。
|
|
116
|
+
|
|
109
117
|
插件对会话日志与 profile **只读**,不写入任何状态;唯一的持久化是浏览器 `localStorage` 里的皮肤选择。
|
|
110
118
|
|
|
111
119
|
## 许可
|
package/index.js
CHANGED
|
@@ -17,6 +17,133 @@ import { zstdDecompressSync } from 'node:zlib'
|
|
|
17
17
|
|
|
18
18
|
export const inject = ['webServer']
|
|
19
19
|
|
|
20
|
+
// Events that constitute a memory access. `session/event` is the post-commit
|
|
21
|
+
// append feed, so every item we forward is already durable in the session log.
|
|
22
|
+
// "write" = a memory being stored; "read" = a memory being consulted.
|
|
23
|
+
const LIVE_EVENT_KINDS = new Map([
|
|
24
|
+
['user/message', 'write'],
|
|
25
|
+
['assistant/message', 'write'],
|
|
26
|
+
['session/title', 'write'],
|
|
27
|
+
['agent/inbox/spliced', 'read'],
|
|
28
|
+
['request/context', 'read'],
|
|
29
|
+
['tool/call', 'read'],
|
|
30
|
+
['tool/result', 'read'],
|
|
31
|
+
['compaction/summary', 'read'],
|
|
32
|
+
['turn/end', 'read'],
|
|
33
|
+
])
|
|
34
|
+
|
|
35
|
+
/**
|
|
36
|
+
* Broadcast hub for live memory activity. One SSE connection per open nebula
|
|
37
|
+
* page; the registration is an effect so plugin unload tears every socket,
|
|
38
|
+
* listener and timer down cleanly.
|
|
39
|
+
*/
|
|
40
|
+
class LiveHub {
|
|
41
|
+
constructor() {
|
|
42
|
+
this.clients = new Set()
|
|
43
|
+
this.recent = [] // replay buffer so a fresh page sees the last burst
|
|
44
|
+
this.seq = 0
|
|
45
|
+
this.heartbeat = null
|
|
46
|
+
this.lastSent = new Map() // "session:kind:type:tool" -> timestamp
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
add(res) {
|
|
50
|
+
const client = { res, alive: true }
|
|
51
|
+
this.clients.add(client)
|
|
52
|
+
if (this.clients.size === 1) {
|
|
53
|
+
this.heartbeat = setInterval(() => {
|
|
54
|
+
for (const c of this.clients) if (c.alive) c.res.write(': ping\n\n')
|
|
55
|
+
}, 15000)
|
|
56
|
+
}
|
|
57
|
+
// Initial handshake + replay of the last few events.
|
|
58
|
+
res.write(`event: hello\ndata: ${JSON.stringify({ clients: this.clients.size })}\n\n`)
|
|
59
|
+
for (const item of this.recent) res.write(`data: ${JSON.stringify(item)}\n\n`)
|
|
60
|
+
const close = () => this.remove(client)
|
|
61
|
+
res.on('close', close)
|
|
62
|
+
res.on('error', close)
|
|
63
|
+
return () => this.remove(client)
|
|
64
|
+
}
|
|
65
|
+
|
|
66
|
+
remove(client) {
|
|
67
|
+
client.alive = false
|
|
68
|
+
this.clients.delete(client)
|
|
69
|
+
if (this.clients.size === 0 && this.heartbeat !== null) {
|
|
70
|
+
clearInterval(this.heartbeat)
|
|
71
|
+
this.heartbeat = null
|
|
72
|
+
}
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
publish(item) {
|
|
76
|
+
// A single agent step can emit dozens of tool/call + tool/result rows.
|
|
77
|
+
// Coalesce identical chatter inside a short window so the feed shows the
|
|
78
|
+
// shape of activity rather than drowning in duplicates.
|
|
79
|
+
const key = `${item.sessionId}:${item.kind}:${item.type}:${item.tool || ''}`
|
|
80
|
+
const now = Date.now()
|
|
81
|
+
const noisy = item.type === 'tool/call' || item.type === 'tool/result'
|
|
82
|
+
if (noisy) {
|
|
83
|
+
const last = this.lastSent.get(key) || 0
|
|
84
|
+
if (now - last < 600) return
|
|
85
|
+
this.lastSent.set(key, now)
|
|
86
|
+
if (this.lastSent.size > 200) this.lastSent.clear()
|
|
87
|
+
}
|
|
88
|
+
item.seq = ++this.seq
|
|
89
|
+
this.recent.push(item)
|
|
90
|
+
if (this.recent.length > 24) this.recent.shift()
|
|
91
|
+
const line = `data: ${JSON.stringify(item)}\n\n`
|
|
92
|
+
for (const c of this.clients) {
|
|
93
|
+
if (!c.alive) continue
|
|
94
|
+
try { c.res.write(line) } catch { this.remove(c) }
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
|
|
98
|
+
dispose() {
|
|
99
|
+
if (this.heartbeat !== null) clearInterval(this.heartbeat)
|
|
100
|
+
this.heartbeat = null
|
|
101
|
+
for (const c of this.clients) {
|
|
102
|
+
c.alive = false
|
|
103
|
+
try { c.res.end() } catch { /* already gone */ }
|
|
104
|
+
}
|
|
105
|
+
this.clients.clear()
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
/** Fold one raw committed session event into a compact live activity item. */
|
|
110
|
+
function liveItemFromEvent(session, event) {
|
|
111
|
+
const kind = LIVE_EVENT_KINDS.get(event?.type)
|
|
112
|
+
if (kind === undefined) return null
|
|
113
|
+
const data = event.data || {}
|
|
114
|
+
const sessionId = typeof session?.id === 'string' ? session.id : ''
|
|
115
|
+
if (sessionId === '') return null
|
|
116
|
+
const text = livePreview(event, data)
|
|
117
|
+
const title = typeof session?.title === 'string' ? session.title : ''
|
|
118
|
+
return {
|
|
119
|
+
sessionId,
|
|
120
|
+
sessionTitle: title,
|
|
121
|
+
kind,
|
|
122
|
+
type: event.type,
|
|
123
|
+
text,
|
|
124
|
+
tool: typeof data.name === 'string' ? data.name : undefined,
|
|
125
|
+
time: typeof event.time === 'number' ? event.time : Date.now(),
|
|
126
|
+
}
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
function livePreview(event, data) {
|
|
130
|
+
let content
|
|
131
|
+
switch (event.type) {
|
|
132
|
+
case 'user/message': content = data.content; break
|
|
133
|
+
case 'assistant/message': content = data.message?.content; break
|
|
134
|
+
case 'tool/call': return typeof data.name === 'string' ? `调用 ${data.name}` : ''
|
|
135
|
+
case 'tool/result': return '工具返回'
|
|
136
|
+
case 'turn/end': return '一轮对话完成'
|
|
137
|
+
case 'compaction/summary': return '上下文压缩 / 记忆整理'
|
|
138
|
+
case 'request/context': return '读取运行时上下文'
|
|
139
|
+
default: content = null
|
|
140
|
+
}
|
|
141
|
+
if (content == null) return event.type === 'session/title' && typeof data.title === 'string' ? data.title : ''
|
|
142
|
+
const s = contentText(content)
|
|
143
|
+
if (s.startsWith(RUNTIME_CONTEXT_PREFIX)) return '' // injected scaffolding
|
|
144
|
+
return s.replace(/\s+/g, ' ').slice(0, 90)
|
|
145
|
+
}
|
|
146
|
+
|
|
20
147
|
const ZSTD_MAGIC = Buffer.from([0x28, 0xb5, 0x2f, 0xfd])
|
|
21
148
|
const LOG_PATTERN = /^session(?:\.[\w-]+)*\.jsonl\.zstd$/
|
|
22
149
|
const RUNTIME_CONTEXT_PREFIX = 'Current runtime context. This snapshot supersedes earlier runtime-context snapshots.'
|
|
@@ -564,6 +691,26 @@ export function apply(ctx, config) {
|
|
|
564
691
|
? config.sessionsDir
|
|
565
692
|
: (process.env.DSH_HOME ? join(process.env.DSH_HOME, 'sessions') : '')
|
|
566
693
|
const reader = new MemoryReader(sessionsDir, limits)
|
|
694
|
+
const live = new LiveHub()
|
|
695
|
+
// Title is not on the Session object for every event; keep a small map so the
|
|
696
|
+
// HUD can label the cluster being written to.
|
|
697
|
+
const liveTitles = new Map()
|
|
698
|
+
ctx.on('session/event', (session, event) => {
|
|
699
|
+
try {
|
|
700
|
+
if (event?.type === 'session/title' && typeof event.data?.title === 'string') {
|
|
701
|
+
liveTitles.set(session.id, event.data.title)
|
|
702
|
+
}
|
|
703
|
+
const item = liveItemFromEvent(session, event)
|
|
704
|
+
if (item === null) return
|
|
705
|
+
if (!item.sessionTitle) item.sessionTitle = liveTitles.get(item.sessionId) || ''
|
|
706
|
+
// Ignore empty scaffolding previews (runtime context, empty assistant).
|
|
707
|
+
if (item.type !== 'tool/call' && item.type !== 'tool/result' && item.text === '') return
|
|
708
|
+
live.publish(item)
|
|
709
|
+
} catch (error) {
|
|
710
|
+
ctx.logger.warn(error instanceof Error ? error : new Error(String(error)))
|
|
711
|
+
}
|
|
712
|
+
})
|
|
713
|
+
ctx.effect(() => () => live.dispose(), 'memory-cloud: live hub')
|
|
567
714
|
|
|
568
715
|
// The DSH /api browser-trust fence does not cover these routes, so the
|
|
569
716
|
// handler checks the Host header itself (DNS-rebinding defense), mirroring
|
|
@@ -587,6 +734,19 @@ export function apply(ctx, config) {
|
|
|
587
734
|
if (req.method !== 'GET' && req.method !== 'HEAD') return sendJson(res, 405, { error: 'Method not allowed' })
|
|
588
735
|
const path = new URL(req.url ?? '/', 'http://dsh.local').pathname
|
|
589
736
|
|
|
737
|
+
if (path === '/memory-cloud/api/live') {
|
|
738
|
+
res.writeHead(200, {
|
|
739
|
+
'content-type': 'text/event-stream; charset=utf-8',
|
|
740
|
+
'cache-control': 'no-cache, no-store',
|
|
741
|
+
connection: 'keep-alive',
|
|
742
|
+
'x-accel-buffering': 'no',
|
|
743
|
+
})
|
|
744
|
+
res.write(': connected\n\n')
|
|
745
|
+
const release = live.add(res)
|
|
746
|
+
req.on('close', release)
|
|
747
|
+
req.on('error', release)
|
|
748
|
+
return
|
|
749
|
+
}
|
|
590
750
|
if (path === '/memory-cloud/api/memories') {
|
|
591
751
|
const data = await reader.snapshot()
|
|
592
752
|
return sendJson(res, 200, data)
|
package/nebula.css
CHANGED
|
@@ -132,6 +132,73 @@ body {
|
|
|
132
132
|
.legend-item .count { margin-left: auto; color: var(--mc-muted); opacity: .8; font-variant-numeric: tabular-nums; }
|
|
133
133
|
.legend-item.dim { opacity: .38; }
|
|
134
134
|
|
|
135
|
+
/* ---------------------------------------------------------------------------
|
|
136
|
+
Live memory activity panel
|
|
137
|
+
--------------------------------------------------------------------------- */
|
|
138
|
+
.live-panel {
|
|
139
|
+
position: fixed; z-index: 6; right: calc(20px + var(--mc-safe-r));
|
|
140
|
+
bottom: calc(76px + var(--mc-safe-b)); width: 248px;
|
|
141
|
+
background: var(--mc-panel); border: 1px solid var(--mc-line); border-radius: 11px;
|
|
142
|
+
backdrop-filter: blur(14px); box-shadow: 0 10px 32px rgba(0,0,0,.38);
|
|
143
|
+
overflow: hidden;
|
|
144
|
+
}
|
|
145
|
+
.live-head {
|
|
146
|
+
display: flex; align-items: center; gap: 8px; padding: 9px 11px;
|
|
147
|
+
border-bottom: 1px solid var(--mc-line);
|
|
148
|
+
}
|
|
149
|
+
.live-dot {
|
|
150
|
+
width: 8px; height: 8px; border-radius: 50%; flex: 0 0 auto;
|
|
151
|
+
background: #6fe0b0; box-shadow: 0 0 9px #6fe0b0;
|
|
152
|
+
animation: livePulse 2s ease-in-out infinite;
|
|
153
|
+
}
|
|
154
|
+
.live-panel[data-connected="false"] .live-dot { background: #d8a25a; box-shadow: 0 0 9px #d8a25a; animation: none; }
|
|
155
|
+
.live-panel[data-active="true"] .live-dot { animation-duration: .55s; }
|
|
156
|
+
@keyframes livePulse { 0%,100% { opacity: .45; transform: scale(.82) } 50% { opacity: 1; transform: scale(1.15) } }
|
|
157
|
+
.live-title { font-size: 11.5px; font-weight: 650; color: var(--mc-fg); letter-spacing: .4px; }
|
|
158
|
+
.live-toggle {
|
|
159
|
+
margin-left: auto; width: 20px; height: 20px; border: 1px solid var(--mc-line);
|
|
160
|
+
border-radius: 5px; background: transparent; color: var(--mc-muted); cursor: pointer;
|
|
161
|
+
font-size: 13px; line-height: 1;
|
|
162
|
+
}
|
|
163
|
+
.live-toggle:hover { color: var(--mc-fg); }
|
|
164
|
+
.live-panel.collapsed #live-body { display: none; }
|
|
165
|
+
.live-metrics {
|
|
166
|
+
display: flex; gap: 10px; flex-wrap: wrap; padding: 8px 11px 5px;
|
|
167
|
+
font-size: 10px; color: var(--mc-muted);
|
|
168
|
+
}
|
|
169
|
+
.live-metrics b { color: var(--mc-fg); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
170
|
+
.live-metrics .live-rate { margin-left: auto; }
|
|
171
|
+
.live-feed { max-height: 148px; overflow-y: auto; padding: 2px 11px 9px; scrollbar-width: none; }
|
|
172
|
+
.live-feed::-webkit-scrollbar { display: none; }
|
|
173
|
+
.live-row {
|
|
174
|
+
display: flex; align-items: baseline; gap: 7px; padding: 4px 0;
|
|
175
|
+
border-bottom: 1px solid rgba(150,170,255,.07); font-size: 10.5px; line-height: 1.45;
|
|
176
|
+
animation: liveIn .35s ease-out both;
|
|
177
|
+
}
|
|
178
|
+
.live-row:last-child { border-bottom: 0; }
|
|
179
|
+
@keyframes liveIn { from { opacity: 0; transform: translateY(-5px) } to { opacity: 1; transform: none } }
|
|
180
|
+
.live-tag { flex: 0 0 auto; font-size: 9px; font-weight: 700; letter-spacing: .3px; padding: 1px 5px; border-radius: 4px; }
|
|
181
|
+
.live-row.write .live-tag { color: #9fe0ff; background: rgba(90,170,255,.18); }
|
|
182
|
+
.live-row.write { border-left: 2px solid #6fc8ff; padding-left: 6px; }
|
|
183
|
+
.live-row.write .live-text { color: #cfe4ff; }
|
|
184
|
+
.live-row.read .live-tag { color: #e0c59a; background: rgba(220,180,100,.16); }
|
|
185
|
+
.live-text { color: var(--mc-muted); overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
|
|
186
|
+
.live-text b { color: var(--mc-fg); font-weight: 550; }
|
|
187
|
+
.live-empty { padding: 8px 0; font-size: 10.5px; color: var(--mc-muted); opacity: .7; }
|
|
188
|
+
|
|
189
|
+
@media (max-width: 820px) {
|
|
190
|
+
.live-panel {
|
|
191
|
+
right: auto; left: calc(12px + var(--mc-safe-l));
|
|
192
|
+
bottom: calc(70px + var(--mc-safe-b)); width: min(220px, 58vw);
|
|
193
|
+
}
|
|
194
|
+
.live-feed { max-height: 88px; }
|
|
195
|
+
}
|
|
196
|
+
@media (max-width: 420px) {
|
|
197
|
+
.live-panel { width: min(190px, 54vw); }
|
|
198
|
+
.live-metrics { gap: 7px; font-size: 9px; }
|
|
199
|
+
.live-feed { max-height: 70px; }
|
|
200
|
+
}
|
|
201
|
+
|
|
135
202
|
.stats {
|
|
136
203
|
position: fixed; left: calc(20px + var(--mc-safe-l)); bottom: calc(20px + var(--mc-safe-b)); z-index: 4;
|
|
137
204
|
display: flex; flex-wrap: wrap; gap: 8px 18px; max-width: 62vw;
|
package/nebula.html
CHANGED
|
@@ -41,6 +41,22 @@
|
|
|
41
41
|
|
|
42
42
|
<div id="relation-key" class="relation-key" aria-label="关联图例"></div>
|
|
43
43
|
|
|
44
|
+
<section id="live-panel" class="live-panel" aria-label="实时记忆活动">
|
|
45
|
+
<header class="live-head">
|
|
46
|
+
<span class="live-dot" aria-hidden="true"></span>
|
|
47
|
+
<span class="live-title">实时记忆流</span>
|
|
48
|
+
<button id="live-toggle" class="live-toggle" type="button" aria-pressed="true" title="收起/展开">−</button>
|
|
49
|
+
</header>
|
|
50
|
+
<div id="live-body">
|
|
51
|
+
<div class="live-metrics">
|
|
52
|
+
<span><b id="live-writes">0</b> 写入</span>
|
|
53
|
+
<span><b id="live-reads">0</b> 读取</span>
|
|
54
|
+
<span class="live-rate"><b id="live-rate">0</b> 事件/分</span>
|
|
55
|
+
</div>
|
|
56
|
+
<div id="live-feed" class="live-feed" aria-live="polite"></div>
|
|
57
|
+
</div>
|
|
58
|
+
</section>
|
|
59
|
+
|
|
44
60
|
<div id="stats" class="stats" aria-label="统计信息"></div>
|
|
45
61
|
|
|
46
62
|
<div id="tooltip" class="tooltip" hidden></div>
|
package/nebula.js
CHANGED
|
@@ -16,6 +16,12 @@
|
|
|
16
16
|
legend: document.getElementById('legend'),
|
|
17
17
|
relationKey: document.getElementById('relation-key'),
|
|
18
18
|
stats: document.getElementById('stats'),
|
|
19
|
+
livePanel: document.getElementById('live-panel'),
|
|
20
|
+
liveFeed: document.getElementById('live-feed'),
|
|
21
|
+
liveWrites: document.getElementById('live-writes'),
|
|
22
|
+
liveReads: document.getElementById('live-reads'),
|
|
23
|
+
liveRate: document.getElementById('live-rate'),
|
|
24
|
+
liveToggle: document.getElementById('live-toggle'),
|
|
19
25
|
skinButtons: [...document.querySelectorAll('.skin-btn')],
|
|
20
26
|
tooltip: document.getElementById('tooltip'),
|
|
21
27
|
detail: document.getElementById('detail'),
|
|
@@ -386,6 +392,17 @@
|
|
|
386
392
|
w: 0, h: 0, dpr: 1,
|
|
387
393
|
time: 0,
|
|
388
394
|
quality: 1, // 1 = full crest sampling; 0.5 under sustained load
|
|
395
|
+
// --- live memory activity ---
|
|
396
|
+
live: {
|
|
397
|
+
connected: false,
|
|
398
|
+
writes: 0,
|
|
399
|
+
reads: 0,
|
|
400
|
+
pulses: [], // expanding rings on the target cloud
|
|
401
|
+
newborns: [], // fresh stars that fade into the cloud
|
|
402
|
+
times: [], // timestamps for the events/minute readout
|
|
403
|
+
lastBurst: 0,
|
|
404
|
+
liveClusters: new Map(), // sessions not on disk yet -> ephemeral nebula
|
|
405
|
+
},
|
|
389
406
|
isMobile: false,
|
|
390
407
|
// Two-finger pinch state, kept separate from the single-finger drag.
|
|
391
408
|
pinch: null, // { startDist, startZoom }
|
|
@@ -531,6 +548,223 @@
|
|
|
531
548
|
: `${data.stats.sessions} 个会话记忆 · ${data.stats.fragments} 颗记忆星 · 拖拽以探索`
|
|
532
549
|
if (state.isMobile) el.search.placeholder = '搜索记忆'
|
|
533
550
|
updateHint()
|
|
551
|
+
connectLive()
|
|
552
|
+
}
|
|
553
|
+
|
|
554
|
+
// -------------------------------------------------------------------------
|
|
555
|
+
// Live memory activity
|
|
556
|
+
//
|
|
557
|
+
// `session/event` is the post-commit append feed, so each SSE item is a
|
|
558
|
+
// memory that was JUST written (or a memory being read back as context).
|
|
559
|
+
// A brand-new session has no cluster on disk yet, so we grow a small
|
|
560
|
+
// ephemeral cloud for it and retire it once the log appears on the next
|
|
561
|
+
// snapshot. This makes an active conversation visibly bloom.
|
|
562
|
+
// -------------------------------------------------------------------------
|
|
563
|
+
|
|
564
|
+
function escapeLive(s) {
|
|
565
|
+
return String(s).replace(/[&<>"']/g, c => ({ '&': '&', '<': '<', '>': '>', '"': '"', "'": ''' }[c]))
|
|
566
|
+
}
|
|
567
|
+
|
|
568
|
+
function connectLive() {
|
|
569
|
+
if (typeof EventSource === 'undefined') return
|
|
570
|
+
let source
|
|
571
|
+
try { source = new EventSource('/memory-cloud/api/live') } catch { return }
|
|
572
|
+
source.onopen = () => {
|
|
573
|
+
state.live.connected = true
|
|
574
|
+
el.livePanel.dataset.connected = 'true'
|
|
575
|
+
}
|
|
576
|
+
source.onerror = () => {
|
|
577
|
+
state.live.connected = false
|
|
578
|
+
el.livePanel.dataset.connected = 'false'
|
|
579
|
+
}
|
|
580
|
+
source.onmessage = event => {
|
|
581
|
+
let item
|
|
582
|
+
try { item = JSON.parse(event.data) } catch { return }
|
|
583
|
+
if (!item || !item.sessionId) return
|
|
584
|
+
onLiveItem(item)
|
|
585
|
+
}
|
|
586
|
+
}
|
|
587
|
+
|
|
588
|
+
function clusterForLive(item) {
|
|
589
|
+
let cluster = state.clusters.find(c => c.session.id === item.sessionId)
|
|
590
|
+
if (cluster) return cluster
|
|
591
|
+
// Not yet on disk: create a temporary nebula at a free spot.
|
|
592
|
+
let ephemeral = state.live.liveClusters.get(item.sessionId)
|
|
593
|
+
if (!ephemeral) {
|
|
594
|
+
const idx = state.live.liveClusters.size
|
|
595
|
+
const rand = rng(hash(item.sessionId) ^ 0x51ab)
|
|
596
|
+
const angle = idx * 2.399 + rand()
|
|
597
|
+
const radius = 150 + rand() * 90
|
|
598
|
+
const center = [Math.cos(angle) * radius, (rand() - 0.5) * 170, Math.sin(angle) * radius]
|
|
599
|
+
const cloud = buildCloud(center, hash(item.sessionId) ^ 0x77, 0.8, 0.25)
|
|
600
|
+
ephemeral = {
|
|
601
|
+
session: {
|
|
602
|
+
id: item.sessionId,
|
|
603
|
+
title: item.sessionTitle || '正在进行的对话',
|
|
604
|
+
counts: { events: 0, user: 0, assistant: 0, tools: 0, turns: 0, fragments: 0 },
|
|
605
|
+
fragments: [], topics: [],
|
|
606
|
+
},
|
|
607
|
+
color: PALETTE[(idx + 3) % PALETTE.length],
|
|
608
|
+
center, cloud, stars: [], ephemeral: true,
|
|
609
|
+
dendrites: buildDendrites(center, hash(item.sessionId) ^ 0x2c91, 0.35),
|
|
610
|
+
core: null, weight: 0.25,
|
|
611
|
+
lobeCenters: cloud.lobeCenters,
|
|
612
|
+
}
|
|
613
|
+
// Reuse the normal hit-test core so tapping the new cloud works.
|
|
614
|
+
const core = {
|
|
615
|
+
kind: 'core', cluster: ephemeral, isClusterCore: true, fragment: null,
|
|
616
|
+
pos: center.slice(), size: 2.2, phase: 0, color: ephemeral.color,
|
|
617
|
+
}
|
|
618
|
+
ephemeral.core = core
|
|
619
|
+
state.stars.push(core)
|
|
620
|
+
state.live.liveClusters.set(item.sessionId, ephemeral)
|
|
621
|
+
state.clusters.push(ephemeral)
|
|
622
|
+
}
|
|
623
|
+
if (item.sessionTitle && ephemeral.session.title === '正在进行的对话') {
|
|
624
|
+
ephemeral.session.title = item.sessionTitle
|
|
625
|
+
}
|
|
626
|
+
return ephemeral
|
|
627
|
+
}
|
|
628
|
+
|
|
629
|
+
function onLiveItem(item) {
|
|
630
|
+
const now = performance.now()
|
|
631
|
+
const cluster = clusterForLive(item)
|
|
632
|
+
cluster.lastLiveAt = now
|
|
633
|
+
const isWrite = item.kind === 'write'
|
|
634
|
+
if (isWrite) state.live.writes += 1
|
|
635
|
+
else state.live.reads += 1
|
|
636
|
+
state.live.times.push(now)
|
|
637
|
+
if (state.live.times.length > 120) state.live.times.shift()
|
|
638
|
+
|
|
639
|
+
// Expanding ring on the target cloud: write rings ripple outward, read
|
|
640
|
+
// rings ripple inward (memory being consulted / pulled back).
|
|
641
|
+
state.live.pulses.push({
|
|
642
|
+
cluster, born: now, kind: isWrite ? 'write' : 'read',
|
|
643
|
+
color: isWrite ? [150, 215, 255] : [235, 195, 130],
|
|
644
|
+
})
|
|
645
|
+
if (state.live.pulses.length > 26) state.live.pulses.shift()
|
|
646
|
+
|
|
647
|
+
if (isWrite) {
|
|
648
|
+
const lobe = cluster.cloud.lobeCenters[
|
|
649
|
+
Math.floor(Math.random() * cluster.cloud.lobeCenters.length)] || cluster.center
|
|
650
|
+
state.live.newborns.push({
|
|
651
|
+
cluster, born: now,
|
|
652
|
+
pos: [lobe[0] + (Math.random() - 0.5) * 16, lobe[1] + (Math.random() - 0.5) * 12, lobe[2] + (Math.random() - 0.5) * 16],
|
|
653
|
+
color: cluster.color,
|
|
654
|
+
phase: Math.random() * Math.PI * 2,
|
|
655
|
+
})
|
|
656
|
+
if (state.live.newborns.length > 60) state.live.newborns.shift()
|
|
657
|
+
cluster.session.counts.fragments += 1
|
|
658
|
+
}
|
|
659
|
+
|
|
660
|
+
state.live.lastBurst = now
|
|
661
|
+
el.livePanel.dataset.active = 'true'
|
|
662
|
+
el.liveWrites.textContent = String(state.live.writes)
|
|
663
|
+
el.liveReads.textContent = String(state.live.reads)
|
|
664
|
+
appendLiveRow(item, cluster)
|
|
665
|
+
updateLiveRate(now)
|
|
666
|
+
}
|
|
667
|
+
|
|
668
|
+
function appendLiveRow(item, cluster) {
|
|
669
|
+
if (el.liveFeed.querySelector('.live-empty')) el.liveFeed.innerHTML = ''
|
|
670
|
+
const row = document.createElement('div')
|
|
671
|
+
row.className = `live-row ${item.kind}`
|
|
672
|
+
const label = item.kind === 'write' ? '写入' : '读取'
|
|
673
|
+
const body = item.text || item.tool || item.type || ''
|
|
674
|
+
const title = cluster.session.title || '会话'
|
|
675
|
+
row.innerHTML = `<span class="live-tag">${label}</span><span class="live-text" title="${escapeLive(body)}"><b>${escapeLive(title.slice(0, 14))}</b> ${escapeLive(body.slice(0, 60))}</span>`
|
|
676
|
+
el.liveFeed.prepend(row)
|
|
677
|
+
while (el.liveFeed.children.length > 14) el.liveFeed.lastChild.remove()
|
|
678
|
+
}
|
|
679
|
+
|
|
680
|
+
function updateLiveRate(now) {
|
|
681
|
+
const recent = state.live.times.filter(t => now - t < 60000)
|
|
682
|
+
el.liveRate.textContent = String(recent.length)
|
|
683
|
+
}
|
|
684
|
+
|
|
685
|
+
/** Draw the live rings and newborn stars on top of the scene. */
|
|
686
|
+
function drawLiveEffects() {
|
|
687
|
+
const now = performance.now()
|
|
688
|
+
const L = state.live
|
|
689
|
+
ctx.save()
|
|
690
|
+
ctx.globalCompositeOperation = 'lighter'
|
|
691
|
+
|
|
692
|
+
// A soft, lingering aura on any cloud touched in the last few seconds:
|
|
693
|
+
// the ring is brief, but the cluster itself keeps a "hot" edge so an
|
|
694
|
+
// active conversation is visible even between events.
|
|
695
|
+
for (const cluster of state.clusters) {
|
|
696
|
+
const last = cluster.lastLiveAt || 0
|
|
697
|
+
const heat = Math.max(0, 1 - (now - last) / 3200)
|
|
698
|
+
if (heat <= 0) continue
|
|
699
|
+
const pr = project(cluster.center)
|
|
700
|
+
const sprite = softSprite(cluster.color)
|
|
701
|
+
const d = 130 * pr.scale * (0.8 + heat * 0.5)
|
|
702
|
+
ctx.globalAlpha = heat * 0.16
|
|
703
|
+
ctx.drawImage(sprite, pr.x - d / 2, pr.y - d / 2, d, d)
|
|
704
|
+
}
|
|
705
|
+
ctx.globalAlpha = 1
|
|
706
|
+
|
|
707
|
+
for (let i = L.pulses.length - 1; i >= 0; i -= 1) {
|
|
708
|
+
const p = L.pulses[i]
|
|
709
|
+
const age = (now - p.born) / 1400
|
|
710
|
+
if (age >= 1) { L.pulses.splice(i, 1); continue }
|
|
711
|
+
const pr = project(p.cluster.center)
|
|
712
|
+
const baseR = 8 * pr.scale
|
|
713
|
+
const grow = p.kind === 'write' ? age : 1 - age
|
|
714
|
+
const r = Math.max(2, baseR + grow * 40 * pr.scale)
|
|
715
|
+
ctx.beginPath()
|
|
716
|
+
ctx.arc(pr.x, pr.y, r, 0, Math.PI * 2)
|
|
717
|
+
ctx.strokeStyle = rgba(p.color, (1 - age) * 0.5)
|
|
718
|
+
ctx.lineWidth = 1.2
|
|
719
|
+
ctx.stroke()
|
|
720
|
+
}
|
|
721
|
+
for (let i = L.newborns.length - 1; i >= 0; i -= 1) {
|
|
722
|
+
const s = L.newborns[i]
|
|
723
|
+
const age = (now - s.born) / 6000
|
|
724
|
+
if (age >= 1) { L.newborns.splice(i, 1); continue }
|
|
725
|
+
const pr = project(s.pos)
|
|
726
|
+
const twinkle = 0.75 + Math.sin(now * 0.004 + s.phase) * 0.25
|
|
727
|
+
const r = Math.max(0.8, (1.4 + (1 - age) * 1.6) * pr.scale * twinkle)
|
|
728
|
+
const sprite = softSprite([255, 255, 255])
|
|
729
|
+
const d = r * 7
|
|
730
|
+
ctx.globalAlpha = (1 - age) * 0.85
|
|
731
|
+
ctx.drawImage(sprite, pr.x - d / 2, pr.y - d / 2, d, d)
|
|
732
|
+
ctx.globalAlpha = 1
|
|
733
|
+
ctx.beginPath()
|
|
734
|
+
ctx.arc(pr.x, pr.y, r, 0, Math.PI * 2)
|
|
735
|
+
ctx.fillStyle = '#ffffff'
|
|
736
|
+
ctx.fill()
|
|
737
|
+
}
|
|
738
|
+
ctx.restore()
|
|
739
|
+
// Idle the activity dot shortly after the last event.
|
|
740
|
+
if (now - L.lastBurst > 1200) el.livePanel.dataset.active = 'false'
|
|
741
|
+
}
|
|
742
|
+
|
|
743
|
+
/** Fold an on-disk snapshot back in: drop ephemeral clouds now on disk. */
|
|
744
|
+
function refreshFromSnapshot() {
|
|
745
|
+
fetch('/memory-cloud/api/memories', { headers: { accept: 'application/json' } })
|
|
746
|
+
.then(r => (r.ok ? r.json() : Promise.reject(new Error(`HTTP ${r.status}`))))
|
|
747
|
+
.then(data => {
|
|
748
|
+
const ids = new Set((data.sessions || []).map(s => s.id))
|
|
749
|
+
for (const [id, cluster] of state.live.liveClusters) {
|
|
750
|
+
if (!ids.has(id)) continue
|
|
751
|
+
state.live.liveClusters.delete(id)
|
|
752
|
+
const ci = state.clusters.indexOf(cluster)
|
|
753
|
+
if (ci >= 0) state.clusters.splice(ci, 1)
|
|
754
|
+
// Remove its core from the flat hit-test list.
|
|
755
|
+
const si = state.stars.indexOf(cluster.core)
|
|
756
|
+
if (si >= 0) state.stars.splice(si, 1)
|
|
757
|
+
}
|
|
758
|
+
if (state.stats) {
|
|
759
|
+
state.stats.sessions = data.stats.sessions
|
|
760
|
+
state.stats.fragments = data.stats.fragments
|
|
761
|
+
buildStats(state.stats)
|
|
762
|
+
el.subtitle.textContent = state.isMobile
|
|
763
|
+
? `${data.stats.sessions} 个会话 · ${data.stats.fragments} 颗记忆星 · 单指旋转 / 双指缩放`
|
|
764
|
+
: `${data.stats.sessions} 个会话记忆 · ${data.stats.fragments} 颗记忆星 · 拖拽以探索`
|
|
765
|
+
}
|
|
766
|
+
})
|
|
767
|
+
.catch(() => { /* transient; next tick retries */ })
|
|
534
768
|
}
|
|
535
769
|
|
|
536
770
|
const SKIN_KEY = 'dsh-memory-cloud:skin:v1'
|
|
@@ -632,7 +866,7 @@
|
|
|
632
866
|
const q = state.query.trim().toLowerCase()
|
|
633
867
|
if (q === '') return true
|
|
634
868
|
if (cluster.session.title.toLowerCase().includes(q)) return true
|
|
635
|
-
if (cluster.session.workspaceLabel.toLowerCase().includes(q)) return true
|
|
869
|
+
if (cluster.session.workspaceLabel && cluster.session.workspaceLabel.toLowerCase().includes(q)) return true
|
|
636
870
|
return (cluster.session.fragments || []).some(f => f.text.toLowerCase().includes(q))
|
|
637
871
|
}
|
|
638
872
|
|
|
@@ -1206,6 +1440,7 @@
|
|
|
1206
1440
|
drawRelationArcs()
|
|
1207
1441
|
drawNebulaStars(projected)
|
|
1208
1442
|
}
|
|
1443
|
+
drawLiveEffects()
|
|
1209
1444
|
|
|
1210
1445
|
// Captions only make sense once a cluster fills the view.
|
|
1211
1446
|
if (lod >= 2) drawFragmentCaptions(lod)
|
|
@@ -1530,9 +1765,18 @@
|
|
|
1530
1765
|
else if (window.parent !== window) window.parent.postMessage({ source: 'dsh-memory-cloud', type: 'close' }, location.origin)
|
|
1531
1766
|
}
|
|
1532
1767
|
})
|
|
1768
|
+
el.liveToggle.addEventListener('click', () => {
|
|
1769
|
+
const collapsed = el.livePanel.classList.toggle('collapsed')
|
|
1770
|
+
el.liveToggle.textContent = collapsed ? '+' : '−'
|
|
1771
|
+
el.liveToggle.setAttribute('aria-pressed', String(!collapsed))
|
|
1772
|
+
})
|
|
1773
|
+
el.liveFeed.innerHTML = '<div class="live-empty">等待对话记忆写入…</div>'
|
|
1774
|
+
el.livePanel.dataset.connected = 'false'
|
|
1533
1775
|
// The nebula is dark-only by design; no theme messages are exchanged.
|
|
1534
1776
|
window.addEventListener('resize', resize)
|
|
1535
1777
|
window.addEventListener('orientationchange', () => window.setTimeout(resize, 250))
|
|
1778
|
+
// Periodically reconcile ephemeral live clouds with the on-disk snapshot.
|
|
1779
|
+
window.setInterval(refreshFromSnapshot, 15000)
|
|
1536
1780
|
|
|
1537
1781
|
// -------------------------------------------------------------------------
|
|
1538
1782
|
// Boot
|
package/package.json
CHANGED