evolclaw-web 1.2.2 → 1.3.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/dist/server.js +11 -2
- package/dist/sources/aid.js +4 -2
- package/dist/sources/baseagent-detector.js +72 -0
- package/dist/sources/msg.js +366 -31
- package/dist/sources/session-codex.js +618 -0
- package/dist/sources/session.js +24 -11
- package/dist/sources/system.js +37 -2
- package/dist/static/app.js +169 -71
- package/dist/static/index.html +1 -1
- package/dist/static/style.css +1 -0
- package/package.json +1 -1
package/dist/server.js
CHANGED
|
@@ -29,6 +29,7 @@ import { gatewaySource } from './sources/gateway.js';
|
|
|
29
29
|
import { queryStatsForDashboard, queryStatsExplorer, queryStatsByPeer, queryStatsOverview, queryUsageDetail, queryUsedModels } from './sources/stats.js';
|
|
30
30
|
import { getSessionsAunDir, listLocalAids, listPeers, readMessages } from './fs-utils.js';
|
|
31
31
|
import { ccProjectsDir } from './paths.js';
|
|
32
|
+
import { detectBaseAgents } from './sources/baseagent-detector.js';
|
|
32
33
|
const __dirname = path.dirname(fileURLToPath(import.meta.url));
|
|
33
34
|
const STATIC_DIR = path.join(__dirname, 'static');
|
|
34
35
|
const TOKEN_TTL_MS = 30 * 24 * 60 * 60 * 1000; // 30天(滑动窗口:每次有效访问刷新 lastActive 自动续期)
|
|
@@ -486,7 +487,9 @@ function handleConnection(ws, req, log) {
|
|
|
486
487
|
params.project = msg.project;
|
|
487
488
|
if (msg.agent)
|
|
488
489
|
params.agent = msg.agent;
|
|
489
|
-
|
|
490
|
+
if (msg.baseagent)
|
|
491
|
+
params.baseagent = msg.baseagent;
|
|
492
|
+
dlog(`▸ 订阅 ${msg.view}${msg.aid ? ` aid=${String(msg.aid).split('.')[0]}` : ''}${msg.peer ? ` peer=${String(msg.peer).split('.')[0]}` : ''}${msg.sessionId ? ` session=${String(msg.sessionId).slice(0, 8)}` : ''}${msg.baseagent ? ` baseagent=${msg.baseagent}` : ''} from ${ip}`);
|
|
490
493
|
await switchSubscription(msg.view, params);
|
|
491
494
|
return;
|
|
492
495
|
}
|
|
@@ -614,7 +617,13 @@ export async function startWatchWebServer(opts = {}) {
|
|
|
614
617
|
return { code: pairingCode, expiresAt: pairingExpiry };
|
|
615
618
|
}
|
|
616
619
|
const server = http.createServer((req, res) => {
|
|
617
|
-
if (req.method === 'GET' && (req.url || '') === '/api/
|
|
620
|
+
if (req.method === 'GET' && (req.url || '') === '/api/available-baseagents') {
|
|
621
|
+
// Base agent 可用性检测 API
|
|
622
|
+
const available = detectBaseAgents();
|
|
623
|
+
res.writeHead(200, { 'Content-Type': 'application/json' });
|
|
624
|
+
res.end(JSON.stringify(available));
|
|
625
|
+
}
|
|
626
|
+
else if (req.method === 'GET' && (req.url || '') === '/api/pair-code') {
|
|
618
627
|
// 取码 API:仅真本地直连可用(socket 回环 + 无 x-aun-provider-aid)。
|
|
619
628
|
// 隧道回连虽然 socket 也是 127.0.0.1,但有 x-aun-provider-aid 头 → 拒绝,
|
|
620
629
|
// 防止远程访客通过隧道拿到配对码(安全漏洞)。
|
package/dist/sources/aid.js
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* AID 数据源 — 复用 daemon 的 IPC socket(与 evolclaw `watch aid` 同源)。
|
|
3
3
|
*
|
|
4
|
-
* daemon 运行时:拉 aun-aids / aun-aid-stats / status / evolagent.list。
|
|
4
|
+
* daemon 运行时:拉 aun-aids / aun-aid-stats / agent-stats / status / evolagent.list。
|
|
5
5
|
* daemon 未运行时:降级到读 instance/ 目录的 aid-*.jsonl(精简版 scanInstances)。
|
|
6
6
|
* IPC 无推送能力,故 1s 轮询 + JSON diff,仅在变化时 push。
|
|
7
7
|
*/
|
|
@@ -83,9 +83,10 @@ async function fetchVersion(socket) {
|
|
|
83
83
|
}
|
|
84
84
|
async function buildSnapshot() {
|
|
85
85
|
const p = resolvePaths();
|
|
86
|
-
const [aidsResp, statsResp, statusResp, agentsResp, version] = await Promise.all([
|
|
86
|
+
const [aidsResp, statsResp, agentStatsResp, statusResp, agentsResp, version] = await Promise.all([
|
|
87
87
|
ipcQuery(p.socket, { type: 'aun-aids' }),
|
|
88
88
|
ipcQuery(p.socket, { type: 'aun-aid-stats' }),
|
|
89
|
+
ipcQuery(p.socket, { type: 'agent-stats' }),
|
|
89
90
|
ipcQuery(p.socket, { type: 'status' }),
|
|
90
91
|
ipcQuery(p.socket, { type: 'evolagent.list' }),
|
|
91
92
|
fetchVersion(p.socket),
|
|
@@ -105,6 +106,7 @@ async function buildSnapshot() {
|
|
|
105
106
|
daemonRunning: true,
|
|
106
107
|
aids: aidsResp?.aids ?? [],
|
|
107
108
|
stats: statsResp?.stats ?? [],
|
|
109
|
+
agentStats: agentStatsResp?.stats ?? [],
|
|
108
110
|
status: statusResp ?? null,
|
|
109
111
|
agents: agentsResp?.agents ?? [],
|
|
110
112
|
version: version ?? null,
|
|
@@ -0,0 +1,72 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Base agent 环境检测 — 检测 Claude 和 Codex 的可用性。
|
|
3
|
+
*
|
|
4
|
+
* 检测条件:
|
|
5
|
+
* - Claude: ~/.claude/projects/ 目录存在
|
|
6
|
+
* - Codex: ~/.codex/sessions/ 和 ~/.codex/state_*.sqlite 存在,且 Node 版本 >= 22.5
|
|
7
|
+
*/
|
|
8
|
+
import fs from 'fs';
|
|
9
|
+
import path from 'path';
|
|
10
|
+
import os from 'os';
|
|
11
|
+
import { createRequire } from 'module';
|
|
12
|
+
const requireFromHere = createRequire(import.meta.url);
|
|
13
|
+
let _cached = null;
|
|
14
|
+
/**
|
|
15
|
+
* 检测 node:sqlite 模块是否可用(Node 22.5+)
|
|
16
|
+
*/
|
|
17
|
+
function checkSqliteAvailable() {
|
|
18
|
+
try {
|
|
19
|
+
requireFromHere('node:sqlite');
|
|
20
|
+
return true;
|
|
21
|
+
}
|
|
22
|
+
catch {
|
|
23
|
+
return false;
|
|
24
|
+
}
|
|
25
|
+
}
|
|
26
|
+
/**
|
|
27
|
+
* 检测 Codex state_*.sqlite 文件是否存在
|
|
28
|
+
*/
|
|
29
|
+
function checkCodexStateDb() {
|
|
30
|
+
const codexHome = path.join(os.homedir(), '.codex');
|
|
31
|
+
if (!fs.existsSync(codexHome))
|
|
32
|
+
return false;
|
|
33
|
+
try {
|
|
34
|
+
const files = fs.readdirSync(codexHome).filter(f => /^state_\d+\.sqlite$/.test(f));
|
|
35
|
+
return files.length > 0;
|
|
36
|
+
}
|
|
37
|
+
catch {
|
|
38
|
+
return false;
|
|
39
|
+
}
|
|
40
|
+
}
|
|
41
|
+
/**
|
|
42
|
+
* 检测 Claude projects 目录是否存在
|
|
43
|
+
*/
|
|
44
|
+
function checkClaudeProjects() {
|
|
45
|
+
const claudeProjects = path.join(os.homedir(), '.claude', 'projects');
|
|
46
|
+
return fs.existsSync(claudeProjects);
|
|
47
|
+
}
|
|
48
|
+
/**
|
|
49
|
+
* 检测 Codex sessions 目录是否存在
|
|
50
|
+
*/
|
|
51
|
+
function checkCodexSessions() {
|
|
52
|
+
const codexSessions = path.join(os.homedir(), '.codex', 'sessions');
|
|
53
|
+
return fs.existsSync(codexSessions);
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* 检测所有 base agent 的可用性
|
|
57
|
+
* 结果会缓存,避免重复检测
|
|
58
|
+
*/
|
|
59
|
+
export function detectBaseAgents() {
|
|
60
|
+
if (_cached)
|
|
61
|
+
return _cached;
|
|
62
|
+
const claude = checkClaudeProjects();
|
|
63
|
+
const codex = checkCodexSessions() && checkCodexStateDb() && checkSqliteAvailable();
|
|
64
|
+
_cached = { claude, codex };
|
|
65
|
+
return _cached;
|
|
66
|
+
}
|
|
67
|
+
/**
|
|
68
|
+
* 重置缓存(测试用)
|
|
69
|
+
*/
|
|
70
|
+
export function resetDetectionCache() {
|
|
71
|
+
_cached = null;
|
|
72
|
+
}
|
package/dist/sources/msg.js
CHANGED
|
@@ -1,38 +1,372 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
2
|
+
* 消息数据源:扫描 data/sessions 下所有渠道的 messages.jsonl。
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
* - 有 aid 无 peer: 该 AID 的对端列表 + 全部消息
|
|
7
|
-
* - 有 aid 有 peer: 该对端的消息
|
|
8
|
-
* subscribe: fs.watch(sessions/aun, recursive),messages.jsonl 变化时防抖 150ms 后重推。
|
|
4
|
+
* 左列按 agent 聚合;中列列出该 agent 下所有渠道的私聊/群聊会话。
|
|
5
|
+
* 兼容旧参数名 aid/peer:前端的 aid 实际上传 scope id。
|
|
9
6
|
*/
|
|
10
7
|
import fs from 'fs';
|
|
11
|
-
import
|
|
12
|
-
|
|
13
|
-
|
|
14
|
-
|
|
15
|
-
|
|
8
|
+
import path from 'path';
|
|
9
|
+
import { resolvePaths } from '../paths.js';
|
|
10
|
+
import { readAllJsonlLines, readJsonFile, } from '../fs-utils.js';
|
|
11
|
+
function safeId(value) {
|
|
12
|
+
return Buffer.from(value, 'utf8').toString('base64url');
|
|
13
|
+
}
|
|
14
|
+
function findMessageFiles(root) {
|
|
15
|
+
const out = [];
|
|
16
|
+
const visit = (dir) => {
|
|
17
|
+
let entries;
|
|
18
|
+
try {
|
|
19
|
+
entries = fs.readdirSync(dir, { withFileTypes: true });
|
|
20
|
+
}
|
|
21
|
+
catch {
|
|
22
|
+
return;
|
|
23
|
+
}
|
|
24
|
+
for (const entry of entries) {
|
|
25
|
+
const full = path.join(dir, entry.name);
|
|
26
|
+
if (entry.isDirectory())
|
|
27
|
+
visit(full);
|
|
28
|
+
else if (entry.isFile() && entry.name === 'messages.jsonl')
|
|
29
|
+
out.push(full);
|
|
30
|
+
}
|
|
31
|
+
};
|
|
32
|
+
visit(root);
|
|
33
|
+
return out;
|
|
34
|
+
}
|
|
35
|
+
function listConfiguredAgentAids(root) {
|
|
36
|
+
const agentsDir = path.join(root, 'agents');
|
|
37
|
+
const aids = new Set();
|
|
38
|
+
let entries;
|
|
39
|
+
try {
|
|
40
|
+
entries = fs.readdirSync(agentsDir, { withFileTypes: true });
|
|
16
41
|
}
|
|
17
|
-
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
42
|
+
catch {
|
|
43
|
+
return aids;
|
|
44
|
+
}
|
|
45
|
+
for (const entry of entries) {
|
|
46
|
+
if (!entry.isDirectory())
|
|
47
|
+
continue;
|
|
48
|
+
const config = readJsonFile(path.join(agentsDir, entry.name, 'config.json'));
|
|
49
|
+
const aid = typeof config?.aid === 'string' ? config.aid : entry.name;
|
|
50
|
+
if (looksLikeAID(aid))
|
|
51
|
+
aids.add(aid);
|
|
52
|
+
}
|
|
53
|
+
return aids;
|
|
54
|
+
}
|
|
55
|
+
function decodeDirSegment(seg) {
|
|
56
|
+
return seg.replace(/%([0-9A-Fa-f]{2})/g, (_, hex) => String.fromCharCode(parseInt(hex, 16)));
|
|
57
|
+
}
|
|
58
|
+
function parseChannelKey(value) {
|
|
59
|
+
if (typeof value !== 'string')
|
|
60
|
+
return null;
|
|
61
|
+
const parts = value.split('#');
|
|
62
|
+
if (parts.length !== 3)
|
|
63
|
+
return null;
|
|
64
|
+
const [type, selfAID, name] = parts;
|
|
65
|
+
if (!type || !selfAID || !name)
|
|
66
|
+
return null;
|
|
67
|
+
return { type, selfAID, name };
|
|
68
|
+
}
|
|
69
|
+
function looksLikeAID(value) {
|
|
70
|
+
return !!value && value !== 'self' && value !== '_unknown' && /^[^./#\s]+\.[^/#\s]+/.test(value);
|
|
71
|
+
}
|
|
72
|
+
function parseLegacySessionKey(value, channelType) {
|
|
73
|
+
if (typeof value !== 'string')
|
|
74
|
+
return null;
|
|
75
|
+
const parts = value.split('#');
|
|
76
|
+
if (parts.length < 3 || parts[0] !== channelType)
|
|
77
|
+
return null;
|
|
78
|
+
if (!looksLikeAID(parts[1]))
|
|
79
|
+
return null;
|
|
80
|
+
return { type: parts[0], selfAID: parts[1], name: parts[2] || 'main' };
|
|
81
|
+
}
|
|
82
|
+
function pathIdentity(sessionsDir, dirPath) {
|
|
83
|
+
const relParts = path.relative(sessionsDir, dirPath).split(path.sep).filter(Boolean);
|
|
84
|
+
if (!relParts.length)
|
|
85
|
+
return {};
|
|
86
|
+
const firstKey = parseChannelKey(decodeDirSegment(relParts[0]));
|
|
87
|
+
if (firstKey) {
|
|
88
|
+
return {
|
|
89
|
+
channelType: firstKey.type,
|
|
90
|
+
channel: `${firstKey.type}#${firstKey.selfAID}#${firstKey.name}`,
|
|
91
|
+
channelId: relParts[1] ? decodeDirSegment(relParts[1]) : undefined,
|
|
92
|
+
selfAID: firstKey.selfAID,
|
|
93
|
+
};
|
|
24
94
|
}
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
95
|
+
if (relParts[0] === 'aun' && relParts.length >= 3) {
|
|
96
|
+
const selfAID = decodeDirSegment(relParts[1]);
|
|
97
|
+
return {
|
|
98
|
+
channelType: 'aun',
|
|
99
|
+
channel: looksLikeAID(selfAID) ? `aun#${selfAID}#main` : undefined,
|
|
100
|
+
channelId: decodeDirSegment(relParts[2]),
|
|
101
|
+
selfAID: looksLikeAID(selfAID) ? selfAID : undefined,
|
|
102
|
+
};
|
|
31
103
|
}
|
|
32
|
-
|
|
33
|
-
|
|
104
|
+
return {
|
|
105
|
+
channelType: decodeDirSegment(relParts[0]),
|
|
106
|
+
channelId: relParts[1] ? decodeDirSegment(relParts[1]) : undefined,
|
|
107
|
+
};
|
|
108
|
+
}
|
|
109
|
+
function inferSelfAID(active, messages, hints) {
|
|
110
|
+
if (looksLikeAID(active?.selfAID))
|
|
111
|
+
return String(active.selfAID);
|
|
112
|
+
if (looksLikeAID(hints.selfAID))
|
|
113
|
+
return hints.selfAID;
|
|
114
|
+
const channelKey = parseChannelKey(active?.channel) || parseChannelKey(active?.channelType);
|
|
115
|
+
if (channelKey)
|
|
116
|
+
return channelKey.selfAID;
|
|
117
|
+
const sessionKey = parseLegacySessionKey(active?.sessionKey, hints.channelType || active?.channelType || '');
|
|
118
|
+
if (sessionKey)
|
|
119
|
+
return sessionKey.selfAID;
|
|
120
|
+
const out = messages.find(m => m.dir === 'out' && m.from && m.from !== 'self');
|
|
121
|
+
if (out?.from)
|
|
122
|
+
return String(out.from);
|
|
123
|
+
const inboundTo = messages.find(m => m.dir === 'in' && m.to && m.to !== 'self');
|
|
124
|
+
if (inboundTo?.to)
|
|
125
|
+
return String(inboundTo.to);
|
|
126
|
+
return 'unknown';
|
|
127
|
+
}
|
|
128
|
+
function normalizeChatIdentity(sessionsDir, dirPath, active, messages) {
|
|
129
|
+
const pathHints = pathIdentity(sessionsDir, dirPath);
|
|
130
|
+
const activeChannelKey = parseChannelKey(active?.channel);
|
|
131
|
+
const activeTypeKey = parseChannelKey(active?.channelType);
|
|
132
|
+
const msgChannelType = messages.find(m => m.channelType)?.channelType;
|
|
133
|
+
const rawType = String(activeChannelKey?.type
|
|
134
|
+
|| activeTypeKey?.type
|
|
135
|
+
|| active?.channelType
|
|
136
|
+
|| msgChannelType
|
|
137
|
+
|| pathHints.channelType
|
|
138
|
+
|| 'unknown');
|
|
139
|
+
const channelType = parseChannelKey(rawType)?.type || rawType;
|
|
140
|
+
const selfAID = inferSelfAID(active, messages, { ...pathHints, channelType });
|
|
141
|
+
const rawChannel = typeof active?.channel === 'string' && active.channel
|
|
142
|
+
? active.channel
|
|
143
|
+
: pathHints.channel || `${channelType}#${selfAID}#main`;
|
|
144
|
+
const channelKey = parseChannelKey(rawChannel);
|
|
145
|
+
const channel = channelKey
|
|
146
|
+
? `${channelKey.type}#${channelKey.selfAID}#${channelKey.name}`
|
|
147
|
+
: (selfAID !== 'unknown' ? `${channelType}#${selfAID}#main` : rawChannel);
|
|
148
|
+
const channelId = String(active.channelId || pathHints.channelId || path.basename(dirPath));
|
|
149
|
+
return { channelType, channel, channelId, selfAID };
|
|
150
|
+
}
|
|
151
|
+
function chatDisplayId(active, messages) {
|
|
152
|
+
const metadata = active?.metadata ?? {};
|
|
153
|
+
const chatType = active?.chatType || messages.find(m => m.chatType)?.chatType || 'private';
|
|
154
|
+
const groupId = chatType === 'group'
|
|
155
|
+
? String(metadata.groupId || active?.channelId || messages.find(m => m.groupId)?.groupId || '')
|
|
156
|
+
: null;
|
|
157
|
+
const groupName = chatType === 'group' && metadata.groupName ? String(metadata.groupName) : null;
|
|
158
|
+
if (chatType === 'group') {
|
|
159
|
+
return {
|
|
160
|
+
peerId: groupId || String(active?.channelId || 'group'),
|
|
161
|
+
peerName: groupName,
|
|
162
|
+
groupId,
|
|
163
|
+
groupName,
|
|
164
|
+
};
|
|
34
165
|
}
|
|
35
|
-
|
|
166
|
+
const peerId = String(metadata.peerId || active?.channelId || messages.find(m => m.dir === 'in')?.from || messages.find(m => m.dir === 'out')?.to || 'unknown');
|
|
167
|
+
return {
|
|
168
|
+
peerId,
|
|
169
|
+
peerName: metadata.peerName ? String(metadata.peerName) : null,
|
|
170
|
+
groupId: null,
|
|
171
|
+
groupName: null,
|
|
172
|
+
};
|
|
173
|
+
}
|
|
174
|
+
function loadChats() {
|
|
175
|
+
const paths = resolvePaths();
|
|
176
|
+
const sessionsDir = paths.sessionsDir;
|
|
177
|
+
const configuredAids = listConfiguredAgentAids(paths.root);
|
|
178
|
+
const files = findMessageFiles(sessionsDir);
|
|
179
|
+
const chats = [];
|
|
180
|
+
for (const file of files) {
|
|
181
|
+
const dirPath = path.dirname(file);
|
|
182
|
+
const active = readJsonFile(path.join(dirPath, 'active.json')) ?? {};
|
|
183
|
+
const messages = readAllJsonlLines(file);
|
|
184
|
+
if (!messages.length)
|
|
185
|
+
continue;
|
|
186
|
+
const identity = normalizeChatIdentity(sessionsDir, dirPath, active, messages);
|
|
187
|
+
const { channelType, channel, channelId, selfAID } = identity;
|
|
188
|
+
if (configuredAids.size && !configuredAids.has(selfAID))
|
|
189
|
+
continue;
|
|
190
|
+
const chatType = String(active.chatType || messages.find(m => m.chatType)?.chatType || 'private');
|
|
191
|
+
const display = chatDisplayId({ ...active, chatType, channelId }, messages);
|
|
192
|
+
const lastMsgAt = messages.reduce((max, m) => Math.max(max, m.ts || 0), 0);
|
|
193
|
+
const updatedAt = Math.max(Number(active.updatedAt || 0), lastMsgAt);
|
|
194
|
+
chats.push({
|
|
195
|
+
id: safeId(`${selfAID}\n${channelType}\n${channelId}\n${chatType}`),
|
|
196
|
+
dirPath,
|
|
197
|
+
channelType,
|
|
198
|
+
channel,
|
|
199
|
+
channelId,
|
|
200
|
+
selfAID,
|
|
201
|
+
chatType,
|
|
202
|
+
peerId: display.peerId,
|
|
203
|
+
peerName: display.peerName,
|
|
204
|
+
groupId: display.groupId,
|
|
205
|
+
groupName: display.groupName,
|
|
206
|
+
updatedAt,
|
|
207
|
+
messages,
|
|
208
|
+
});
|
|
209
|
+
}
|
|
210
|
+
return chats;
|
|
211
|
+
}
|
|
212
|
+
function scopeId(chat) {
|
|
213
|
+
return safeId(chat.selfAID);
|
|
214
|
+
}
|
|
215
|
+
function legacyChannelScopeId(chat) {
|
|
216
|
+
return safeId(`${chat.selfAID}\n${chat.channelType}\n${chat.channel}`);
|
|
217
|
+
}
|
|
218
|
+
function buildScopes(chats) {
|
|
219
|
+
const map = new Map();
|
|
220
|
+
const seenChats = new Map();
|
|
221
|
+
const seenChannels = new Map();
|
|
222
|
+
const seenChannelTypes = new Map();
|
|
223
|
+
for (const chat of chats) {
|
|
224
|
+
const id = scopeId(chat);
|
|
225
|
+
let scope = map.get(id);
|
|
226
|
+
if (!scope) {
|
|
227
|
+
scope = {
|
|
228
|
+
id,
|
|
229
|
+
aid: id,
|
|
230
|
+
selfAID: chat.selfAID,
|
|
231
|
+
totalIn: 0,
|
|
232
|
+
totalOut: 0,
|
|
233
|
+
peerCount: 0,
|
|
234
|
+
groupCount: 0,
|
|
235
|
+
channelCount: 0,
|
|
236
|
+
channelTypes: [],
|
|
237
|
+
channels: [],
|
|
238
|
+
lastAt: 0,
|
|
239
|
+
};
|
|
240
|
+
map.set(id, scope);
|
|
241
|
+
seenChats.set(id, new Set());
|
|
242
|
+
seenChannels.set(id, new Set());
|
|
243
|
+
seenChannelTypes.set(id, new Set());
|
|
244
|
+
}
|
|
245
|
+
const channelSeen = seenChannels.get(id);
|
|
246
|
+
if (!channelSeen.has(chat.channel)) {
|
|
247
|
+
channelSeen.add(chat.channel);
|
|
248
|
+
scope.channels.push(chat.channel);
|
|
249
|
+
scope.channelCount = channelSeen.size;
|
|
250
|
+
}
|
|
251
|
+
const typeSeen = seenChannelTypes.get(id);
|
|
252
|
+
if (!typeSeen.has(chat.channelType)) {
|
|
253
|
+
typeSeen.add(chat.channelType);
|
|
254
|
+
scope.channelTypes.push(chat.channelType);
|
|
255
|
+
scope.channelTypes.sort();
|
|
256
|
+
}
|
|
257
|
+
let hasIn = false;
|
|
258
|
+
let hasOut = false;
|
|
259
|
+
for (const msg of chat.messages) {
|
|
260
|
+
if (msg.dir === 'in') {
|
|
261
|
+
scope.totalIn++;
|
|
262
|
+
hasIn = true;
|
|
263
|
+
}
|
|
264
|
+
else if (msg.dir === 'out') {
|
|
265
|
+
scope.totalOut++;
|
|
266
|
+
hasOut = true;
|
|
267
|
+
}
|
|
268
|
+
if (msg.ts > scope.lastAt)
|
|
269
|
+
scope.lastAt = msg.ts;
|
|
270
|
+
}
|
|
271
|
+
if (hasIn || hasOut) {
|
|
272
|
+
const chatKey = `${chat.channel}\n${chat.channelId}\n${chat.chatType}`;
|
|
273
|
+
const seen = seenChats.get(id);
|
|
274
|
+
if (!seen.has(chatKey)) {
|
|
275
|
+
seen.add(chatKey);
|
|
276
|
+
scope.peerCount++;
|
|
277
|
+
if (chat.chatType === 'group')
|
|
278
|
+
scope.groupCount++;
|
|
279
|
+
}
|
|
280
|
+
}
|
|
281
|
+
if (chat.updatedAt > scope.lastAt)
|
|
282
|
+
scope.lastAt = chat.updatedAt;
|
|
283
|
+
}
|
|
284
|
+
return Array.from(map.values())
|
|
285
|
+
.sort((a, b) => b.lastAt - a.lastAt);
|
|
286
|
+
}
|
|
287
|
+
function channelName(channel) {
|
|
288
|
+
return parseChannelKey(channel)?.name ?? null;
|
|
289
|
+
}
|
|
290
|
+
function chatKey(chat) {
|
|
291
|
+
return safeId(`${chat.channelType}\n${chat.channel}\n${chat.channelId}\n${chat.chatType}`);
|
|
292
|
+
}
|
|
293
|
+
function mergePeerInfo(chats) {
|
|
294
|
+
const map = new Map();
|
|
295
|
+
for (const chat of chats) {
|
|
296
|
+
const key = chatKey(chat);
|
|
297
|
+
let peer = map.get(key);
|
|
298
|
+
if (!peer) {
|
|
299
|
+
peer = {
|
|
300
|
+
id: key,
|
|
301
|
+
peerId: key,
|
|
302
|
+
peerName: chat.chatType === 'group' ? (chat.groupName || chat.groupId || chat.channelId) : (chat.peerName || chat.peerId),
|
|
303
|
+
channel: chat.channel,
|
|
304
|
+
channelType: chat.channelType,
|
|
305
|
+
channelName: channelName(chat.channel),
|
|
306
|
+
chatType: chat.chatType,
|
|
307
|
+
groupId: chat.groupId,
|
|
308
|
+
groupName: chat.groupName,
|
|
309
|
+
inbound: 0,
|
|
310
|
+
outbound: 0,
|
|
311
|
+
lastAt: 0,
|
|
312
|
+
};
|
|
313
|
+
map.set(key, peer);
|
|
314
|
+
}
|
|
315
|
+
for (const msg of chat.messages) {
|
|
316
|
+
if (msg.dir === 'in')
|
|
317
|
+
peer.inbound++;
|
|
318
|
+
else if (msg.dir === 'out')
|
|
319
|
+
peer.outbound++;
|
|
320
|
+
if (msg.ts > peer.lastAt)
|
|
321
|
+
peer.lastAt = msg.ts;
|
|
322
|
+
}
|
|
323
|
+
if (chat.updatedAt > peer.lastAt)
|
|
324
|
+
peer.lastAt = chat.updatedAt;
|
|
325
|
+
}
|
|
326
|
+
return Array.from(map.values()).sort((a, b) => b.lastAt - a.lastAt);
|
|
327
|
+
}
|
|
328
|
+
function selectedChats(chats, scope) {
|
|
329
|
+
if (!scope)
|
|
330
|
+
return [];
|
|
331
|
+
return chats.filter(chat => scopeId(chat) === scope);
|
|
332
|
+
}
|
|
333
|
+
function messagesFor(chats, peer) {
|
|
334
|
+
const selected = peer
|
|
335
|
+
? chats.filter(chat => chatKey(chat) === peer)
|
|
336
|
+
: chats;
|
|
337
|
+
const messages = selected.flatMap(chat => chat.messages.map(msg => ({
|
|
338
|
+
...msg,
|
|
339
|
+
channel: chat.channel,
|
|
340
|
+
channelType: chat.channelType,
|
|
341
|
+
selfAID: chat.selfAID,
|
|
342
|
+
peerName: chat.peerName,
|
|
343
|
+
groupName: chat.groupName,
|
|
344
|
+
})));
|
|
345
|
+
messages.sort((a, b) => (a.ts || 0) - (b.ts || 0));
|
|
346
|
+
return messages.length > 1000 ? messages.slice(-1000) : messages;
|
|
347
|
+
}
|
|
348
|
+
function buildSnapshot(params) {
|
|
349
|
+
const chats = loadChats();
|
|
350
|
+
const scopes = buildScopes(chats);
|
|
351
|
+
const requestedScope = params.scope || params.aid || null;
|
|
352
|
+
const scope = requestedScope && !scopes.some(s => s.id === requestedScope)
|
|
353
|
+
? (chats.find(chat => legacyChannelScopeId(chat) === requestedScope)
|
|
354
|
+
? scopeId(chats.find(chat => legacyChannelScopeId(chat) === requestedScope))
|
|
355
|
+
: requestedScope)
|
|
356
|
+
: requestedScope;
|
|
357
|
+
const peer = params.peer || null;
|
|
358
|
+
const inScope = selectedChats(chats, scope);
|
|
359
|
+
const peers = scope ? mergePeerInfo(inScope) : [];
|
|
360
|
+
const messages = scope ? messagesFor(inScope, peer) : [];
|
|
361
|
+
return {
|
|
362
|
+
scopes,
|
|
363
|
+
aids: scopes,
|
|
364
|
+
peers,
|
|
365
|
+
messages,
|
|
366
|
+
scope,
|
|
367
|
+
aid: scope,
|
|
368
|
+
peer,
|
|
369
|
+
};
|
|
36
370
|
}
|
|
37
371
|
export const msgSource = {
|
|
38
372
|
kind: 'msg',
|
|
@@ -40,7 +374,7 @@ export const msgSource = {
|
|
|
40
374
|
return buildSnapshot(params);
|
|
41
375
|
},
|
|
42
376
|
subscribe(params, push) {
|
|
43
|
-
const
|
|
377
|
+
const sessionsDir = resolvePaths().sessionsDir;
|
|
44
378
|
let watcher = null;
|
|
45
379
|
let debounce = null;
|
|
46
380
|
const fire = () => {
|
|
@@ -54,12 +388,13 @@ export const msgSource = {
|
|
|
54
388
|
}, 150);
|
|
55
389
|
};
|
|
56
390
|
try {
|
|
57
|
-
watcher = fs.watch(
|
|
58
|
-
|
|
391
|
+
watcher = fs.watch(sessionsDir, { recursive: true }, (_evt, filename) => {
|
|
392
|
+
const name = String(filename || '');
|
|
393
|
+
if (name.endsWith('messages.jsonl') || name.endsWith('active.json'))
|
|
59
394
|
fire();
|
|
60
395
|
});
|
|
61
396
|
}
|
|
62
|
-
catch { /*
|
|
397
|
+
catch { /* sessionsDir may not exist yet */ }
|
|
63
398
|
return () => {
|
|
64
399
|
if (watcher)
|
|
65
400
|
watcher.close();
|