@memtensor/memos-local-openclaw-plugin 1.0.4-beta.2 → 1.0.4-beta.20
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 +7 -0
- package/README.md +111 -44
- package/dist/capture/index.d.ts +1 -1
- package/dist/capture/index.d.ts.map +1 -1
- package/dist/capture/index.js +36 -2
- package/dist/capture/index.js.map +1 -1
- package/dist/client/connector.d.ts +6 -2
- package/dist/client/connector.d.ts.map +1 -1
- package/dist/client/connector.js +160 -26
- package/dist/client/connector.js.map +1 -1
- package/dist/client/hub.d.ts.map +1 -1
- package/dist/client/hub.js +22 -0
- package/dist/client/hub.js.map +1 -1
- package/dist/client/skill-sync.d.ts +7 -0
- package/dist/client/skill-sync.d.ts.map +1 -1
- package/dist/client/skill-sync.js +10 -0
- package/dist/client/skill-sync.js.map +1 -1
- package/dist/config.d.ts.map +1 -1
- package/dist/config.js +2 -3
- package/dist/config.js.map +1 -1
- package/dist/hub/server.d.ts +9 -0
- package/dist/hub/server.d.ts.map +1 -1
- package/dist/hub/server.js +500 -112
- package/dist/hub/server.js.map +1 -1
- package/dist/hub/user-manager.d.ts +11 -0
- package/dist/hub/user-manager.d.ts.map +1 -1
- package/dist/hub/user-manager.js +31 -3
- package/dist/hub/user-manager.js.map +1 -1
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +7 -2
- package/dist/index.js.map +1 -1
- package/dist/ingest/chunker.d.ts +2 -1
- package/dist/ingest/chunker.d.ts.map +1 -1
- package/dist/ingest/chunker.js +14 -10
- package/dist/ingest/chunker.js.map +1 -1
- package/dist/ingest/providers/index.d.ts.map +1 -1
- package/dist/ingest/providers/index.js +37 -6
- package/dist/ingest/providers/index.js.map +1 -1
- package/dist/recall/engine.d.ts.map +1 -1
- package/dist/recall/engine.js +96 -1
- package/dist/recall/engine.js.map +1 -1
- package/dist/shared/llm-call.d.ts +1 -0
- package/dist/shared/llm-call.d.ts.map +1 -1
- package/dist/shared/llm-call.js +84 -9
- package/dist/shared/llm-call.js.map +1 -1
- package/dist/sharing/types.d.ts +1 -1
- package/dist/sharing/types.d.ts.map +1 -1
- package/dist/skill/evolver.d.ts +4 -0
- package/dist/skill/evolver.d.ts.map +1 -1
- package/dist/skill/evolver.js +59 -5
- package/dist/skill/evolver.js.map +1 -1
- package/dist/skill/generator.d.ts +2 -0
- package/dist/skill/generator.d.ts.map +1 -1
- package/dist/skill/generator.js +45 -3
- package/dist/skill/generator.js.map +1 -1
- package/dist/skill/installer.d.ts +26 -0
- package/dist/skill/installer.d.ts.map +1 -1
- package/dist/skill/installer.js +80 -4
- package/dist/skill/installer.js.map +1 -1
- package/dist/skill/upgrader.d.ts +2 -0
- package/dist/skill/upgrader.d.ts.map +1 -1
- package/dist/skill/upgrader.js +139 -1
- package/dist/skill/upgrader.js.map +1 -1
- package/dist/skill/validator.d.ts +3 -0
- package/dist/skill/validator.d.ts.map +1 -1
- package/dist/skill/validator.js +75 -0
- package/dist/skill/validator.js.map +1 -1
- package/dist/storage/ensure-binding.d.ts +12 -0
- package/dist/storage/ensure-binding.d.ts.map +1 -0
- package/dist/storage/ensure-binding.js +53 -0
- package/dist/storage/ensure-binding.js.map +1 -0
- package/dist/storage/sqlite.d.ts +115 -20
- package/dist/storage/sqlite.d.ts.map +1 -1
- package/dist/storage/sqlite.js +458 -110
- package/dist/storage/sqlite.js.map +1 -1
- package/dist/telemetry.d.ts +12 -5
- package/dist/telemetry.d.ts.map +1 -1
- package/dist/telemetry.js +156 -40
- package/dist/telemetry.js.map +1 -1
- package/dist/tools/memory-search.d.ts +3 -1
- package/dist/tools/memory-search.d.ts.map +1 -1
- package/dist/tools/memory-search.js +3 -1
- package/dist/tools/memory-search.js.map +1 -1
- package/dist/types.d.ts +11 -2
- package/dist/types.d.ts.map +1 -1
- package/dist/types.js +4 -0
- package/dist/types.js.map +1 -1
- package/dist/viewer/html.d.ts.map +1 -1
- package/dist/viewer/html.js +2952 -910
- package/dist/viewer/html.js.map +1 -1
- package/dist/viewer/server.d.ts +39 -8
- package/dist/viewer/server.d.ts.map +1 -1
- package/dist/viewer/server.js +1198 -227
- package/dist/viewer/server.js.map +1 -1
- package/index.ts +774 -74
- package/openclaw.plugin.json +2 -2
- package/package.json +3 -2
- package/scripts/postinstall.cjs +1 -1
- package/skill/memos-memory-guide/SKILL.md +64 -26
- package/src/capture/index.ts +40 -1
- package/src/client/connector.ts +161 -28
- package/src/client/hub.ts +18 -0
- package/src/client/skill-sync.ts +14 -0
- package/src/config.ts +2 -3
- package/src/hub/server.ts +481 -107
- package/src/hub/user-manager.ts +48 -8
- package/src/index.ts +10 -2
- package/src/ingest/chunker.ts +19 -13
- package/src/ingest/providers/index.ts +41 -7
- package/src/recall/engine.ts +89 -1
- package/src/shared/llm-call.ts +99 -10
- package/src/sharing/types.ts +1 -1
- package/src/skill/evolver.ts +63 -6
- package/src/skill/generator.ts +44 -5
- package/src/skill/installer.ts +107 -4
- package/src/skill/upgrader.ts +139 -1
- package/src/skill/validator.ts +79 -0
- package/src/storage/ensure-binding.ts +52 -0
- package/src/storage/sqlite.ts +498 -137
- package/src/telemetry.ts +172 -41
- package/src/tools/memory-search.ts +2 -1
- package/src/types.ts +12 -2
- package/src/viewer/html.ts +2952 -910
- package/src/viewer/server.ts +1109 -212
package/src/viewer/server.ts
CHANGED
|
@@ -34,6 +34,7 @@ export interface ViewerServerOptions {
|
|
|
34
34
|
log: Logger;
|
|
35
35
|
dataDir: string;
|
|
36
36
|
ctx?: PluginContext;
|
|
37
|
+
defaultHubPort?: number;
|
|
37
38
|
}
|
|
38
39
|
|
|
39
40
|
interface AuthState {
|
|
@@ -51,6 +52,8 @@ export class ViewerServer {
|
|
|
51
52
|
private readonly authFile: string;
|
|
52
53
|
private readonly auth: AuthState;
|
|
53
54
|
private readonly ctx?: PluginContext;
|
|
55
|
+
private readonly cookieName: string;
|
|
56
|
+
private readonly defaultHubPort: number;
|
|
54
57
|
|
|
55
58
|
private static readonly SESSION_TTL = 24 * 60 * 60 * 1000;
|
|
56
59
|
private static readonly PLUGIN_VERSION: string = (() => {
|
|
@@ -84,6 +87,12 @@ export class ViewerServer {
|
|
|
84
87
|
{ running: false, done: false, stopped: false, processed: 0, total: 0, tasksCreated: 0, skillsCreated: 0, errors: 0, skippedSessions: 0, totalSessions: 0 };
|
|
85
88
|
private ppSSEClients: http.ServerResponse[] = [];
|
|
86
89
|
|
|
90
|
+
private notifSSEClients: http.ServerResponse[] = [];
|
|
91
|
+
private notifPollTimer?: ReturnType<typeof setInterval>;
|
|
92
|
+
private lastKnownNotifCount = 0;
|
|
93
|
+
private hubHeartbeatTimer?: ReturnType<typeof setInterval>;
|
|
94
|
+
private static readonly HUB_HEARTBEAT_INTERVAL_MS = 45_000;
|
|
95
|
+
|
|
87
96
|
constructor(opts: ViewerServerOptions) {
|
|
88
97
|
this.store = opts.store;
|
|
89
98
|
this.embedder = opts.embedder;
|
|
@@ -93,25 +102,40 @@ export class ViewerServer {
|
|
|
93
102
|
this.ctx = opts.ctx;
|
|
94
103
|
this.authFile = path.join(opts.dataDir, "viewer-auth.json");
|
|
95
104
|
this.auth = { passwordHash: null, sessions: new Map() };
|
|
105
|
+
this.cookieName = `memos_token_${opts.port}`;
|
|
106
|
+
this.defaultHubPort = opts.defaultHubPort ?? 18800;
|
|
96
107
|
this.resetToken = crypto.randomBytes(16).toString("hex");
|
|
97
108
|
this.loadAuth();
|
|
98
109
|
}
|
|
99
110
|
|
|
111
|
+
private getHubPort(): number {
|
|
112
|
+
const configured = this.ctx?.config?.sharing?.hub?.port;
|
|
113
|
+
if (configured && configured !== 18800) return configured;
|
|
114
|
+
return this.defaultHubPort;
|
|
115
|
+
}
|
|
116
|
+
|
|
100
117
|
start(): Promise<string> {
|
|
118
|
+
const MAX_PORT_RETRIES = 5;
|
|
101
119
|
return new Promise((resolve, reject) => {
|
|
120
|
+
let retries = 0;
|
|
102
121
|
this.server = http.createServer((req, res) => this.handleRequest(req, res));
|
|
103
122
|
this.server.on("error", (err: NodeJS.ErrnoException) => {
|
|
104
|
-
if (err.code === "EADDRINUSE") {
|
|
105
|
-
|
|
106
|
-
this.
|
|
123
|
+
if (err.code === "EADDRINUSE" && retries < MAX_PORT_RETRIES) {
|
|
124
|
+
retries++;
|
|
125
|
+
const nextPort = this.port + retries;
|
|
126
|
+
this.log.warn(`Viewer port ${this.port + retries - 1} in use, trying ${nextPort}`);
|
|
127
|
+
this.server!.listen(nextPort, "0.0.0.0");
|
|
128
|
+
} else if (err.code === "EADDRINUSE") {
|
|
129
|
+
reject(new Error(`Viewer failed to find open port after ${MAX_PORT_RETRIES} retries (tried ${this.port}–${this.port + MAX_PORT_RETRIES})`));
|
|
107
130
|
} else {
|
|
108
131
|
reject(err);
|
|
109
132
|
}
|
|
110
133
|
});
|
|
111
|
-
this.server.listen(this.port, "
|
|
134
|
+
this.server.listen(this.port, "0.0.0.0", () => {
|
|
112
135
|
const addr = this.server!.address();
|
|
113
136
|
const actualPort = typeof addr === "object" && addr ? addr.port : this.port;
|
|
114
137
|
this.autoCleanupPolluted();
|
|
138
|
+
this.startHubHeartbeat();
|
|
115
139
|
resolve(`http://127.0.0.1:${actualPort}`);
|
|
116
140
|
});
|
|
117
141
|
});
|
|
@@ -134,6 +158,10 @@ export class ViewerServer {
|
|
|
134
158
|
}
|
|
135
159
|
|
|
136
160
|
stop(): void {
|
|
161
|
+
this.stopHubHeartbeat();
|
|
162
|
+
this.stopNotifPoll();
|
|
163
|
+
for (const c of this.notifSSEClients) { try { c.end(); } catch {} }
|
|
164
|
+
this.notifSSEClients = [];
|
|
137
165
|
this.server?.close();
|
|
138
166
|
this.server = null;
|
|
139
167
|
}
|
|
@@ -176,7 +204,8 @@ export class ViewerServer {
|
|
|
176
204
|
|
|
177
205
|
private isValidSession(req: http.IncomingMessage): boolean {
|
|
178
206
|
const cookie = req.headers.cookie ?? "";
|
|
179
|
-
const
|
|
207
|
+
const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
|
|
208
|
+
const match = cookie.match(re);
|
|
180
209
|
if (!match) return false;
|
|
181
210
|
const expiry = this.auth.sessions.get(match[1]);
|
|
182
211
|
if (!expiry) return false;
|
|
@@ -224,6 +253,11 @@ export class ViewerServer {
|
|
|
224
253
|
}
|
|
225
254
|
|
|
226
255
|
if (p === "/api/memories" && req.method === "GET") this.serveMemories(res, url);
|
|
256
|
+
else if (p === "/api/memories/share-local" && req.method === "POST") this.handleMemoryLocalShare(req, res);
|
|
257
|
+
else if (p === "/api/memories/unshare-local" && req.method === "POST") this.handleMemoryLocalUnshare(req, res);
|
|
258
|
+
else if (p.match(/^\/api\/memory\/[^/]+\/scope$/) && req.method === "PUT") this.handleMemoryScope(req, res, p);
|
|
259
|
+
else if (p.match(/^\/api\/task\/[^/]+\/scope$/) && req.method === "PUT") this.handleTaskScope(req, res, p);
|
|
260
|
+
else if (p.match(/^\/api\/skill\/[^/]+\/scope$/) && req.method === "PUT") this.handleSkillScope(req, res, p);
|
|
227
261
|
else if (p === "/api/stats") this.serveStats(res, url);
|
|
228
262
|
else if (p === "/api/metrics") this.serveMetrics(res, url);
|
|
229
263
|
else if (p === "/api/tool-metrics") this.serveToolMetrics(res, url);
|
|
@@ -251,7 +285,10 @@ export class ViewerServer {
|
|
|
251
285
|
else if (p === "/api/sharing/pending-users" && req.method === "GET") this.serveSharingPendingUsers(res);
|
|
252
286
|
else if (p === "/api/sharing/approve-user" && req.method === "POST") this.handleSharingApproveUser(req, res);
|
|
253
287
|
else if (p === "/api/sharing/reject-user" && req.method === "POST") this.handleSharingRejectUser(req, res);
|
|
288
|
+
else if (p === "/api/sharing/remove-user" && req.method === "POST") this.handleSharingRemoveUser(req, res);
|
|
289
|
+
else if (p === "/api/sharing/change-role" && req.method === "POST") this.handleSharingChangeRole(req, res);
|
|
254
290
|
else if (p === "/api/sharing/retry-join" && req.method === "POST") this.handleRetryJoin(req, res);
|
|
291
|
+
else if (p === "/api/sharing/leave" && req.method === "POST") this.handleLeaveTeam(req, res);
|
|
255
292
|
else if (p === "/api/sharing/search/memories" && req.method === "POST") this.handleSharingMemorySearch(req, res);
|
|
256
293
|
else if (p === "/api/sharing/memories/list" && req.method === "GET") this.serveSharingMemoryList(res, url);
|
|
257
294
|
else if (p === "/api/sharing/tasks/list" && req.method === "GET") this.serveSharingTaskList(res, url);
|
|
@@ -261,23 +298,24 @@ export class ViewerServer {
|
|
|
261
298
|
else if (p === "/api/sharing/tasks/share" && req.method === "POST") this.handleSharingTaskShare(req, res);
|
|
262
299
|
else if (p === "/api/sharing/tasks/unshare" && req.method === "POST") this.handleSharingTaskUnshare(req, res);
|
|
263
300
|
else if (p === "/api/sharing/update-username" && req.method === "POST") this.handleUpdateUsername(req, res);
|
|
301
|
+
else if (p === "/api/sharing/rename-user" && req.method === "POST") this.handleAdminRenameUser(req, res);
|
|
264
302
|
else if (p === "/api/sharing/test-hub" && req.method === "POST") this.handleTestHubConnection(req, res);
|
|
265
303
|
else if (p === "/api/sharing/memories/share" && req.method === "POST") this.handleSharingMemoryShare(req, res);
|
|
266
304
|
else if (p === "/api/sharing/memories/unshare" && req.method === "POST") this.handleSharingMemoryUnshare(req, res);
|
|
267
305
|
else if (p === "/api/sharing/skills/pull" && req.method === "POST") this.handleSharingSkillPull(req, res);
|
|
268
306
|
else if (p === "/api/sharing/skills/share" && req.method === "POST") this.handleSharingSkillShare(req, res);
|
|
269
307
|
else if (p === "/api/sharing/skills/unshare" && req.method === "POST") this.handleSharingSkillUnshare(req, res);
|
|
270
|
-
else if (p === "/api/sharing/groups" && req.method === "GET") this.serveSharingGroups(res);
|
|
271
|
-
else if (p === "/api/sharing/groups" && req.method === "POST") this.handleSharingGroupCreate(req, res);
|
|
272
|
-
else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "PUT") this.handleSharingGroupUpdate(req, res, p);
|
|
273
|
-
else if (p.match(/^\/api\/sharing\/groups\/[^/]+$/) && req.method === "DELETE") this.handleSharingGroupDelete(res, p);
|
|
274
|
-
else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "GET") this.serveSharingGroupMembers(res, p);
|
|
275
|
-
else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "POST") this.handleSharingGroupAddMember(req, res, p);
|
|
276
|
-
else if (p.match(/^\/api\/sharing\/groups\/[^/]+\/members$/) && req.method === "DELETE") this.handleSharingGroupRemoveMember(req, res, p);
|
|
277
308
|
else if (p === "/api/sharing/users" && req.method === "GET") this.serveSharingUsers(res);
|
|
309
|
+
else if (p === "/api/sharing/notifications" && req.method === "GET") this.serveSharingNotifications(res, url);
|
|
310
|
+
else if (p === "/api/sharing/notifications/read" && req.method === "POST") this.handleSharingNotificationsRead(req, res);
|
|
311
|
+
else if (p === "/api/sharing/notifications/clear" && req.method === "POST") this.handleSharingNotificationsClear(req, res);
|
|
312
|
+
else if (p === "/api/sharing/sync-hub-removal" && req.method === "POST") this.handleSyncHubRemoval(req, res);
|
|
313
|
+
else if (p === "/api/notifications/stream" && req.method === "GET") this.handleNotifSSE(req, res);
|
|
278
314
|
else if (p === "/api/admin/shared-tasks" && req.method === "GET") this.serveAdminSharedTasks(res);
|
|
315
|
+
else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+\/detail$/) && req.method === "GET") this.serveHubTaskDetail(res, p);
|
|
279
316
|
else if (p.match(/^\/api\/admin\/shared-tasks\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteTask(res, p);
|
|
280
317
|
else if (p === "/api/admin/shared-skills" && req.method === "GET") this.serveAdminSharedSkills(res);
|
|
318
|
+
else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+\/detail$/) && req.method === "GET") this.serveHubSkillDetail(res, p);
|
|
281
319
|
else if (p.match(/^\/api\/admin\/shared-skills\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteSkill(res, p);
|
|
282
320
|
else if (p === "/api/admin/shared-memories" && req.method === "GET") this.serveAdminSharedMemories(res);
|
|
283
321
|
else if (p.match(/^\/api\/admin\/shared-memories\/[^/]+$/) && req.method === "DELETE") this.handleAdminDeleteMemory(res, p);
|
|
@@ -332,7 +370,7 @@ export class ViewerServer {
|
|
|
332
370
|
const token = this.createSession();
|
|
333
371
|
res.writeHead(200, {
|
|
334
372
|
"Content-Type": "application/json",
|
|
335
|
-
"Set-Cookie":
|
|
373
|
+
"Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
336
374
|
});
|
|
337
375
|
res.end(JSON.stringify({ ok: true, message: "Password set successfully" }));
|
|
338
376
|
} catch (err) {
|
|
@@ -354,7 +392,7 @@ export class ViewerServer {
|
|
|
354
392
|
const token = this.createSession();
|
|
355
393
|
res.writeHead(200, {
|
|
356
394
|
"Content-Type": "application/json",
|
|
357
|
-
"Set-Cookie":
|
|
395
|
+
"Set-Cookie": `${this.cookieName}=${token}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
358
396
|
});
|
|
359
397
|
res.end(JSON.stringify({ ok: true }));
|
|
360
398
|
} catch (err) {
|
|
@@ -366,11 +404,12 @@ export class ViewerServer {
|
|
|
366
404
|
|
|
367
405
|
private handleLogout(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
368
406
|
const cookie = req.headers.cookie ?? "";
|
|
369
|
-
const
|
|
407
|
+
const re = new RegExp(`${this.cookieName}=([a-f0-9]+)`);
|
|
408
|
+
const match = cookie.match(re);
|
|
370
409
|
if (match) this.auth.sessions.delete(match[1]);
|
|
371
410
|
res.writeHead(200, {
|
|
372
411
|
"Content-Type": "application/json",
|
|
373
|
-
"Set-Cookie":
|
|
412
|
+
"Set-Cookie": `${this.cookieName}=; Path=/; HttpOnly; Max-Age=0`,
|
|
374
413
|
});
|
|
375
414
|
res.end(JSON.stringify({ ok: true }));
|
|
376
415
|
}
|
|
@@ -397,7 +436,7 @@ export class ViewerServer {
|
|
|
397
436
|
const sessionToken = this.createSession();
|
|
398
437
|
res.writeHead(200, {
|
|
399
438
|
"Content-Type": "application/json",
|
|
400
|
-
"Set-Cookie":
|
|
439
|
+
"Set-Cookie": `${this.cookieName}=${sessionToken}; Path=/; HttpOnly; SameSite=Strict; Max-Age=86400`,
|
|
401
440
|
});
|
|
402
441
|
res.end(JSON.stringify({ ok: true, message: "Password reset successfully" }));
|
|
403
442
|
} catch (err) {
|
|
@@ -432,22 +471,36 @@ export class ViewerServer {
|
|
|
432
471
|
const params: any[] = [];
|
|
433
472
|
if (session) { conditions.push("session_key = ?"); params.push(session); }
|
|
434
473
|
if (role) { conditions.push("role = ?"); params.push(role); }
|
|
435
|
-
if (owner
|
|
474
|
+
if (owner && owner.startsWith("agent:")) {
|
|
475
|
+
conditions.push("(owner = ? OR owner = 'public')");
|
|
476
|
+
params.push(owner);
|
|
477
|
+
} else if (owner) {
|
|
478
|
+
conditions.push("owner = ?"); params.push(owner);
|
|
479
|
+
}
|
|
436
480
|
if (dateFrom) { conditions.push("created_at >= ?"); params.push(new Date(dateFrom).getTime()); }
|
|
437
481
|
if (dateTo) { conditions.push("created_at <= ?"); params.push(new Date(dateTo).getTime()); }
|
|
438
482
|
|
|
439
483
|
const where = conditions.length > 0 ? " WHERE " + conditions.join(" AND ") : "";
|
|
440
484
|
const totalRow = db.prepare("SELECT COUNT(*) as count FROM chunks" + where).get(...params) as any;
|
|
441
|
-
const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
485
|
+
const rawMemories = db.prepare("SELECT * FROM chunks" + where + ` ORDER BY CASE WHEN dedup_status IN ('duplicate','merged') THEN 1 ELSE 0 END ASC, created_at ${sortBy} LIMIT ? OFFSET ?`).all(...params, limit, offset);
|
|
442
486
|
const findMergeSources = db.prepare("SELECT id, summary, role FROM chunks WHERE dedup_target = ? AND (dedup_status = 'merged' OR dedup_status = 'duplicate')");
|
|
443
487
|
|
|
444
488
|
const chunkIds = rawMemories.map((m: any) => m.id);
|
|
445
489
|
const sharingMap = new Map<string, { visibility: string; group_id: string | null }>();
|
|
490
|
+
const localShareMap = new Map<string, { original_owner: string; shared_at: number }>();
|
|
446
491
|
if (chunkIds.length > 0) {
|
|
447
492
|
try {
|
|
448
493
|
const placeholders = chunkIds.map(() => "?").join(",");
|
|
449
494
|
const sharedRows = db.prepare(`SELECT source_chunk_id, visibility, group_id FROM hub_memories WHERE source_chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ source_chunk_id: string; visibility: string; group_id: string | null }>;
|
|
450
495
|
for (const r of sharedRows) sharingMap.set(r.source_chunk_id, r);
|
|
496
|
+
const teamMetaRows = db.prepare(`SELECT chunk_id, visibility, group_id FROM team_shared_chunks WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; visibility: string; group_id: string | null }>;
|
|
497
|
+
for (const r of teamMetaRows) {
|
|
498
|
+
if (!sharingMap.has(r.chunk_id)) {
|
|
499
|
+
sharingMap.set(r.chunk_id, { visibility: r.visibility, group_id: r.group_id });
|
|
500
|
+
}
|
|
501
|
+
}
|
|
502
|
+
const localRows = db.prepare(`SELECT chunk_id, original_owner, shared_at FROM local_shared_memories WHERE chunk_id IN (${placeholders})`).all(...chunkIds) as Array<{ chunk_id: string; original_owner: string; shared_at: number }>;
|
|
503
|
+
for (const r of localRows) localShareMap.set(r.chunk_id, r);
|
|
451
504
|
} catch {
|
|
452
505
|
}
|
|
453
506
|
}
|
|
@@ -458,8 +511,12 @@ export class ViewerServer {
|
|
|
458
511
|
out.merge_sources = sources;
|
|
459
512
|
}
|
|
460
513
|
const shared = sharingMap.get(m.id);
|
|
514
|
+
const localShared = localShareMap.get(m.id);
|
|
461
515
|
out.sharingVisibility = shared?.visibility ?? null;
|
|
462
516
|
out.sharingGroupId = shared?.group_id ?? null;
|
|
517
|
+
out.localSharing = out.owner === "public";
|
|
518
|
+
out.localSharingManaged = !!localShared;
|
|
519
|
+
out.localOriginalOwner = localShared?.original_owner ?? null;
|
|
463
520
|
return out;
|
|
464
521
|
});
|
|
465
522
|
|
|
@@ -477,7 +534,21 @@ export class ViewerServer {
|
|
|
477
534
|
}
|
|
478
535
|
|
|
479
536
|
private serveToolMetrics(res: http.ServerResponse, url: URL): void {
|
|
480
|
-
const
|
|
537
|
+
const fromParam = url.searchParams.get("from");
|
|
538
|
+
const toParam = url.searchParams.get("to");
|
|
539
|
+
if (fromParam) {
|
|
540
|
+
const fromMs = new Date(fromParam).getTime();
|
|
541
|
+
const toMs = toParam ? new Date(toParam).getTime() : Date.now();
|
|
542
|
+
if (isNaN(fromMs) || isNaN(toMs)) {
|
|
543
|
+
this.jsonResponse(res, { error: "Invalid date" }, 400);
|
|
544
|
+
return;
|
|
545
|
+
}
|
|
546
|
+
const diffMin = Math.max(10, Math.min(43200, Math.round((toMs - fromMs) / 60000)));
|
|
547
|
+
const data = this.store.getToolMetrics(diffMin, fromMs, toMs);
|
|
548
|
+
this.jsonResponse(res, data);
|
|
549
|
+
return;
|
|
550
|
+
}
|
|
551
|
+
const minutes = Math.min(43200, Math.max(10, Number(url.searchParams.get("minutes")) || 60));
|
|
481
552
|
const data = this.store.getToolMetrics(minutes);
|
|
482
553
|
this.jsonResponse(res, data);
|
|
483
554
|
}
|
|
@@ -485,13 +556,15 @@ export class ViewerServer {
|
|
|
485
556
|
private serveTasks(res: http.ServerResponse, url: URL): void {
|
|
486
557
|
this.store.recordViewerEvent("tasks_list");
|
|
487
558
|
const status = url.searchParams.get("status") ?? undefined;
|
|
559
|
+
const owner = url.searchParams.get("owner") ?? undefined;
|
|
488
560
|
const limit = Math.min(100, Math.max(1, Number(url.searchParams.get("limit")) || 50));
|
|
489
561
|
const offset = Math.max(0, Number(url.searchParams.get("offset")) || 0);
|
|
490
|
-
const { tasks, total } = this.store.listTasks({ status, limit, offset });
|
|
562
|
+
const { tasks, total } = this.store.listTasks({ status, limit, offset, owner });
|
|
491
563
|
|
|
492
564
|
const db = (this.store as any).db;
|
|
493
565
|
const items = tasks.map((t) => {
|
|
494
|
-
const meta = db.prepare("SELECT skill_status FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null } | undefined;
|
|
566
|
+
const meta = db.prepare("SELECT skill_status, owner FROM tasks WHERE id = ?").get(t.id) as { skill_status: string | null; owner: string | null } | undefined;
|
|
567
|
+
const sharedTask = db.prepare("SELECT visibility FROM hub_tasks WHERE source_task_id = ? ORDER BY updated_at DESC LIMIT 1").get(t.id) as { visibility: string } | undefined;
|
|
495
568
|
return {
|
|
496
569
|
id: t.id,
|
|
497
570
|
sessionKey: t.sessionKey,
|
|
@@ -502,6 +575,8 @@ export class ViewerServer {
|
|
|
502
575
|
endedAt: t.endedAt,
|
|
503
576
|
chunkCount: this.store.countChunksByTask(t.id),
|
|
504
577
|
skillStatus: meta?.skill_status ?? null,
|
|
578
|
+
owner: meta?.owner ?? "agent:main",
|
|
579
|
+
sharingVisibility: sharedTask?.visibility ?? null,
|
|
505
580
|
};
|
|
506
581
|
});
|
|
507
582
|
|
|
@@ -544,6 +619,7 @@ export class ViewerServer {
|
|
|
544
619
|
title: task.title,
|
|
545
620
|
summary: task.summary,
|
|
546
621
|
status: task.status,
|
|
622
|
+
owner: task.owner ?? "agent:main",
|
|
547
623
|
startedAt: task.startedAt,
|
|
548
624
|
endedAt: task.endedAt,
|
|
549
625
|
chunks: chunkItems,
|
|
@@ -552,6 +628,7 @@ export class ViewerServer {
|
|
|
552
628
|
skillLinks,
|
|
553
629
|
sharingVisibility: sharedTask?.visibility ?? null,
|
|
554
630
|
sharingGroupId: sharedTask?.group_id ?? null,
|
|
631
|
+
hubTaskId: sharedTask ? true : false,
|
|
555
632
|
});
|
|
556
633
|
}
|
|
557
634
|
|
|
@@ -586,12 +663,19 @@ export class ViewerServer {
|
|
|
586
663
|
}
|
|
587
664
|
let embCount = 0;
|
|
588
665
|
try { embCount = (db.prepare("SELECT COUNT(*) as count FROM embeddings").get() as any).count; } catch { /* table may not exist */ }
|
|
589
|
-
|
|
590
|
-
|
|
591
|
-
|
|
592
|
-
|
|
593
|
-
|
|
594
|
-
|
|
666
|
+
let sessionQuery: string;
|
|
667
|
+
let sessionParams: any[];
|
|
668
|
+
if (ownerFilter && ownerFilter.startsWith("agent:")) {
|
|
669
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE (owner = ? OR owner = 'public') GROUP BY session_key ORDER BY latest DESC";
|
|
670
|
+
sessionParams = [ownerFilter];
|
|
671
|
+
} else if (ownerFilter) {
|
|
672
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks WHERE owner = ? GROUP BY session_key ORDER BY latest DESC";
|
|
673
|
+
sessionParams = [ownerFilter];
|
|
674
|
+
} else {
|
|
675
|
+
sessionQuery = "SELECT session_key, COUNT(*) as count, MIN(created_at) as earliest, MAX(created_at) as latest FROM chunks GROUP BY session_key ORDER BY latest DESC";
|
|
676
|
+
sessionParams = [];
|
|
677
|
+
}
|
|
678
|
+
const sessionList = db.prepare(sessionQuery).all(...sessionParams) as any[];
|
|
595
679
|
|
|
596
680
|
let skillCount = 0;
|
|
597
681
|
try { skillCount = (db.prepare("SELECT COUNT(*) as count FROM skills").get() as any).count; } catch { /* table may not exist yet */ }
|
|
@@ -604,10 +688,16 @@ export class ViewerServer {
|
|
|
604
688
|
|
|
605
689
|
let owners: string[] = [];
|
|
606
690
|
try {
|
|
607
|
-
const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL ORDER BY owner").all() as any[];
|
|
691
|
+
const ownerRows = db.prepare("SELECT DISTINCT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY owner").all() as any[];
|
|
608
692
|
owners = ownerRows.map((o: any) => o.owner);
|
|
609
693
|
} catch { /* column may not exist yet */ }
|
|
610
694
|
|
|
695
|
+
let currentAgentOwner = "agent:main";
|
|
696
|
+
try {
|
|
697
|
+
const latest = db.prepare("SELECT owner FROM chunks WHERE owner IS NOT NULL AND owner LIKE 'agent:%' ORDER BY created_at DESC LIMIT 1").get() as any;
|
|
698
|
+
if (latest?.owner) currentAgentOwner = latest.owner;
|
|
699
|
+
} catch { /* best-effort */ }
|
|
700
|
+
|
|
611
701
|
this.jsonResponse(res, {
|
|
612
702
|
totalMemories: total.count, totalSessions: sessions.count, totalEmbeddings: embCount,
|
|
613
703
|
totalSkills: skillCount,
|
|
@@ -616,6 +706,7 @@ export class ViewerServer {
|
|
|
616
706
|
timeRange: { earliest: timeRange.earliest, latest: timeRange.latest },
|
|
617
707
|
sessions: sessionList,
|
|
618
708
|
owners,
|
|
709
|
+
currentAgentOwner,
|
|
619
710
|
});
|
|
620
711
|
} catch (e) {
|
|
621
712
|
this.log.warn(`stats error: ${e}`);
|
|
@@ -727,7 +818,12 @@ export class ViewerServer {
|
|
|
727
818
|
if (visibility) {
|
|
728
819
|
skills = skills.filter(s => s.visibility === visibility);
|
|
729
820
|
}
|
|
730
|
-
this.
|
|
821
|
+
const db = (this.store as any).db;
|
|
822
|
+
const enriched = skills.map(s => {
|
|
823
|
+
const hub = db.prepare("SELECT visibility FROM hub_skills WHERE source_skill_id = ? ORDER BY updated_at DESC LIMIT 1").get(s.id) as { visibility: string } | undefined;
|
|
824
|
+
return { ...s, sharingVisibility: hub?.visibility ?? null };
|
|
825
|
+
});
|
|
826
|
+
this.jsonResponse(res, { skills: enriched });
|
|
731
827
|
}
|
|
732
828
|
|
|
733
829
|
private serveSkillDetail(res: http.ServerResponse, urlPath: string): void {
|
|
@@ -978,11 +1074,21 @@ export class ViewerServer {
|
|
|
978
1074
|
});
|
|
979
1075
|
}
|
|
980
1076
|
|
|
981
|
-
private handleSkillDelete(res: http.ServerResponse, urlPath: string): void {
|
|
1077
|
+
private async handleSkillDelete(res: http.ServerResponse, urlPath: string): Promise<void> {
|
|
982
1078
|
const skillId = urlPath.replace("/api/skill/", "");
|
|
983
1079
|
const skill = this.store.getSkill(skillId);
|
|
984
1080
|
if (!skill) { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Skill not found" })); return; }
|
|
985
|
-
|
|
1081
|
+
try {
|
|
1082
|
+
const hub = this.resolveHubConnection();
|
|
1083
|
+
if (hub) {
|
|
1084
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/skills/unpublish", {
|
|
1085
|
+
method: "POST",
|
|
1086
|
+
body: JSON.stringify({ sourceSkillId: skillId }),
|
|
1087
|
+
}).catch(() => {});
|
|
1088
|
+
}
|
|
1089
|
+
const db = (this.store as any).db;
|
|
1090
|
+
db.prepare("DELETE FROM hub_skills WHERE source_skill_id = ?").run(skillId);
|
|
1091
|
+
} catch (_) {}
|
|
986
1092
|
try {
|
|
987
1093
|
if (skill.dirPath && fs.existsSync(skill.dirPath)) {
|
|
988
1094
|
fs.rmSync(skill.dirPath, { recursive: true, force: true });
|
|
@@ -1029,7 +1135,15 @@ export class ViewerServer {
|
|
|
1029
1135
|
const cleaned = chunk.role === "user" && chunk.content
|
|
1030
1136
|
? { ...chunk, content: stripInboundMetadata(chunk.content) }
|
|
1031
1137
|
: chunk;
|
|
1032
|
-
this.
|
|
1138
|
+
const localShared = this.store.getLocalSharedMemory(chunkId);
|
|
1139
|
+
this.jsonResponse(res, {
|
|
1140
|
+
memory: {
|
|
1141
|
+
...cleaned,
|
|
1142
|
+
localSharing: cleaned.owner === "public",
|
|
1143
|
+
localSharingManaged: !!localShared,
|
|
1144
|
+
localOriginalOwner: localShared?.originalOwner ?? null,
|
|
1145
|
+
},
|
|
1146
|
+
});
|
|
1033
1147
|
}
|
|
1034
1148
|
|
|
1035
1149
|
private handleUpdate(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
|
|
@@ -1058,6 +1172,365 @@ export class ViewerServer {
|
|
|
1058
1172
|
else { res.writeHead(404, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Not found" })); }
|
|
1059
1173
|
}
|
|
1060
1174
|
|
|
1175
|
+
private handleMemoryLocalShare(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1176
|
+
this.readBody(req, (body) => {
|
|
1177
|
+
try {
|
|
1178
|
+
const parsed = JSON.parse(body || "{}");
|
|
1179
|
+
const chunkId = String(parsed.chunkId || "");
|
|
1180
|
+
if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
|
|
1181
|
+
const result = this.store.markMemorySharedLocally(chunkId);
|
|
1182
|
+
if (!result.ok) {
|
|
1183
|
+
return this.jsonResponse(res, { ok: false, error: result.reason ?? "share_failed" }, result.reason === "not_found" ? 404 : 400);
|
|
1184
|
+
}
|
|
1185
|
+
this.jsonResponse(res, {
|
|
1186
|
+
ok: true,
|
|
1187
|
+
chunkId,
|
|
1188
|
+
owner: result.owner,
|
|
1189
|
+
localSharing: true,
|
|
1190
|
+
localSharingManaged: true,
|
|
1191
|
+
localOriginalOwner: result.originalOwner ?? null,
|
|
1192
|
+
});
|
|
1193
|
+
} catch (err) {
|
|
1194
|
+
this.jsonResponse(res, { ok: false, error: String(err) }, 400);
|
|
1195
|
+
}
|
|
1196
|
+
});
|
|
1197
|
+
}
|
|
1198
|
+
|
|
1199
|
+
private handleMemoryLocalUnshare(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1200
|
+
this.readBody(req, (body) => {
|
|
1201
|
+
try {
|
|
1202
|
+
const parsed = JSON.parse(body || "{}");
|
|
1203
|
+
const chunkId = String(parsed.chunkId || "");
|
|
1204
|
+
const privateOwner = typeof parsed.privateOwner === "string" ? parsed.privateOwner : undefined;
|
|
1205
|
+
if (!chunkId) return this.jsonResponse(res, { ok: false, error: "missing_chunk_id" }, 400);
|
|
1206
|
+
const result = this.store.unmarkMemorySharedLocally(chunkId, privateOwner);
|
|
1207
|
+
if (!result.ok) {
|
|
1208
|
+
return this.jsonResponse(res, { ok: false, error: result.reason ?? "unshare_failed" }, result.reason === "not_found" ? 404 : 400);
|
|
1209
|
+
}
|
|
1210
|
+
this.jsonResponse(res, {
|
|
1211
|
+
ok: true,
|
|
1212
|
+
chunkId,
|
|
1213
|
+
owner: result.owner,
|
|
1214
|
+
localSharing: false,
|
|
1215
|
+
localOriginalOwner: result.originalOwner ?? null,
|
|
1216
|
+
});
|
|
1217
|
+
} catch (err) {
|
|
1218
|
+
this.jsonResponse(res, { ok: false, error: String(err) }, 400);
|
|
1219
|
+
}
|
|
1220
|
+
});
|
|
1221
|
+
}
|
|
1222
|
+
|
|
1223
|
+
// ─── Unified scope API ───
|
|
1224
|
+
|
|
1225
|
+
private handleMemoryScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
|
|
1226
|
+
const chunkId = urlPath.split("/")[3];
|
|
1227
|
+
this.readBody(req, async (body) => {
|
|
1228
|
+
try {
|
|
1229
|
+
const parsed = JSON.parse(body || "{}");
|
|
1230
|
+
const scope = parsed.scope as string;
|
|
1231
|
+
if (!["private", "local", "team"].includes(scope)) {
|
|
1232
|
+
return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
|
|
1233
|
+
}
|
|
1234
|
+
const db = (this.store as any).db;
|
|
1235
|
+
const chunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
|
|
1236
|
+
if (!chunk) return this.jsonResponse(res, { ok: false, error: "not_found" }, 404);
|
|
1237
|
+
|
|
1238
|
+
if (chunk.dedup_status && chunk.dedup_status !== "active") {
|
|
1239
|
+
return this.jsonResponse(res, { ok: false, error: "inactive_memory", message: "Merged/duplicate memories cannot be shared" }, 400);
|
|
1240
|
+
}
|
|
1241
|
+
|
|
1242
|
+
const isLocalShared = chunk.owner === "public";
|
|
1243
|
+
const hubMemory = this.getHubMemoryForChunk(chunkId);
|
|
1244
|
+
const isTeamShared = !!hubMemory;
|
|
1245
|
+
const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
|
|
1246
|
+
|
|
1247
|
+
if (scope === currentScope) {
|
|
1248
|
+
return this.jsonResponse(res, { ok: true, scope, changed: false });
|
|
1249
|
+
}
|
|
1250
|
+
|
|
1251
|
+
let hubSynced = false;
|
|
1252
|
+
|
|
1253
|
+
if (scope === "team") {
|
|
1254
|
+
if (!isTeamShared) {
|
|
1255
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1256
|
+
const refreshedChunk = db.prepare("SELECT * FROM chunks WHERE id = ?").get(chunkId) as any;
|
|
1257
|
+
const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/share", {
|
|
1258
|
+
method: "POST",
|
|
1259
|
+
body: JSON.stringify({ memory: { sourceChunkId: refreshedChunk.id, role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary, kind: refreshedChunk.kind, groupId: null, visibility: "public" } }),
|
|
1260
|
+
});
|
|
1261
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1262
|
+
const memoryId = String((response as any)?.memoryId ?? "");
|
|
1263
|
+
const isHubRole = this.ctx?.config?.sharing?.role === "hub";
|
|
1264
|
+
if (hubClient.userId && isHubRole) {
|
|
1265
|
+
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunkId);
|
|
1266
|
+
this.store.upsertHubMemory({
|
|
1267
|
+
id: memoryId || existing?.id || crypto.randomUUID(),
|
|
1268
|
+
sourceChunkId: chunkId, sourceUserId: hubClient.userId,
|
|
1269
|
+
role: refreshedChunk.role, content: refreshedChunk.content, summary: refreshedChunk.summary ?? "",
|
|
1270
|
+
kind: refreshedChunk.kind, groupId: null, visibility: "public",
|
|
1271
|
+
createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
|
|
1272
|
+
});
|
|
1273
|
+
} else if (hubClient.userId) {
|
|
1274
|
+
this.store.upsertTeamSharedChunk(chunkId, { hubMemoryId: memoryId, visibility: "public", groupId: null });
|
|
1275
|
+
}
|
|
1276
|
+
hubSynced = true;
|
|
1277
|
+
} else {
|
|
1278
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1279
|
+
}
|
|
1280
|
+
} else if (scope === "local") {
|
|
1281
|
+
if (isTeamShared) {
|
|
1282
|
+
try {
|
|
1283
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1284
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
|
|
1285
|
+
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1286
|
+
});
|
|
1287
|
+
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1288
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1289
|
+
hubSynced = true;
|
|
1290
|
+
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1291
|
+
}
|
|
1292
|
+
if (!isLocalShared) this.store.markMemorySharedLocally(chunkId);
|
|
1293
|
+
} else {
|
|
1294
|
+
if (isTeamShared) {
|
|
1295
|
+
try {
|
|
1296
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1297
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/memories/unshare", {
|
|
1298
|
+
method: "POST", body: JSON.stringify({ sourceChunkId: chunkId }),
|
|
1299
|
+
});
|
|
1300
|
+
if (hubClient.userId) this.store.deleteHubMemoryBySource(hubClient.userId, chunkId);
|
|
1301
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1302
|
+
hubSynced = true;
|
|
1303
|
+
} catch (err) { this.log.warn(`Failed to unshare memory from team: ${err}`); }
|
|
1304
|
+
}
|
|
1305
|
+
if (isLocalShared) this.store.unmarkMemorySharedLocally(chunkId);
|
|
1306
|
+
}
|
|
1307
|
+
|
|
1308
|
+
this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
|
|
1309
|
+
} catch (err) {
|
|
1310
|
+
this.jsonResponse(res, { ok: false, error: String(err) }, 500);
|
|
1311
|
+
}
|
|
1312
|
+
});
|
|
1313
|
+
}
|
|
1314
|
+
|
|
1315
|
+
private handleTaskScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
|
|
1316
|
+
const taskId = urlPath.split("/")[3];
|
|
1317
|
+
this.readBody(req, async (body) => {
|
|
1318
|
+
try {
|
|
1319
|
+
const parsed = JSON.parse(body || "{}");
|
|
1320
|
+
const scope = parsed.scope as string;
|
|
1321
|
+
if (!["private", "local", "team"].includes(scope)) {
|
|
1322
|
+
return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
|
|
1323
|
+
}
|
|
1324
|
+
const task = this.store.getTask(taskId);
|
|
1325
|
+
if (!task) return this.jsonResponse(res, { ok: false, error: "task_not_found" }, 404);
|
|
1326
|
+
|
|
1327
|
+
if (scope !== "private" && task.status !== "completed") {
|
|
1328
|
+
return this.jsonResponse(res, { ok: false, error: "only_completed_tasks_can_be_shared" }, 400);
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
const isLocalShared = task.owner === "public";
|
|
1332
|
+
const hubTask = this.getHubTaskForLocal(taskId);
|
|
1333
|
+
const isTeamShared = !!hubTask;
|
|
1334
|
+
const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
|
|
1335
|
+
|
|
1336
|
+
if (scope === currentScope) {
|
|
1337
|
+
return this.jsonResponse(res, { ok: true, scope, changed: false });
|
|
1338
|
+
}
|
|
1339
|
+
|
|
1340
|
+
let hubSynced = false;
|
|
1341
|
+
|
|
1342
|
+
if (scope === "team") {
|
|
1343
|
+
if (!isTeamShared) {
|
|
1344
|
+
const chunks = this.store.getChunksByTask(taskId);
|
|
1345
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1346
|
+
const refreshedTask = this.store.getTask(taskId)!;
|
|
1347
|
+
const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/share", {
|
|
1348
|
+
method: "POST",
|
|
1349
|
+
body: JSON.stringify({
|
|
1350
|
+
task: { id: refreshedTask.id, sourceTaskId: refreshedTask.id, title: refreshedTask.title, summary: refreshedTask.summary, groupId: null, visibility: "public", createdAt: refreshedTask.startedAt ?? Date.now(), updatedAt: refreshedTask.updatedAt ?? Date.now() },
|
|
1351
|
+
chunks: chunks.map((c) => ({ id: c.id, hubTaskId: refreshedTask.id, sourceTaskId: refreshedTask.id, sourceChunkId: c.id, role: c.role, content: c.content, summary: c.summary, kind: c.kind, groupId: null, visibility: "public", createdAt: c.createdAt ?? Date.now() })),
|
|
1352
|
+
}),
|
|
1353
|
+
});
|
|
1354
|
+
if (hubClient.userId) {
|
|
1355
|
+
const existing = this.store.getHubTaskBySource(hubClient.userId, taskId);
|
|
1356
|
+
this.store.upsertHubTask({
|
|
1357
|
+
id: (response as any)?.taskId ?? existing?.id ?? crypto.randomUUID(),
|
|
1358
|
+
sourceTaskId: taskId, sourceUserId: hubClient.userId, title: refreshedTask.title ?? "",
|
|
1359
|
+
summary: refreshedTask.summary ?? "", groupId: null, visibility: "public",
|
|
1360
|
+
createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
|
|
1361
|
+
});
|
|
1362
|
+
}
|
|
1363
|
+
hubSynced = true;
|
|
1364
|
+
}
|
|
1365
|
+
if (!isLocalShared) {
|
|
1366
|
+
const originalOwner = task.owner;
|
|
1367
|
+
const db = (this.store as any).db;
|
|
1368
|
+
db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
|
|
1369
|
+
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1370
|
+
}
|
|
1371
|
+
}
|
|
1372
|
+
|
|
1373
|
+
if (scope === "local") {
|
|
1374
|
+
if (!isLocalShared) {
|
|
1375
|
+
const originalOwner = task.owner;
|
|
1376
|
+
const db = (this.store as any).db;
|
|
1377
|
+
db.prepare("INSERT INTO local_shared_tasks (task_id, hub_task_id, original_owner, shared_at) VALUES (?, ?, ?, ?) ON CONFLICT(task_id) DO UPDATE SET original_owner = excluded.original_owner, shared_at = excluded.shared_at").run(taskId, "", originalOwner, Date.now());
|
|
1378
|
+
db.prepare("UPDATE tasks SET owner = 'public' WHERE id = ?").run(taskId);
|
|
1379
|
+
}
|
|
1380
|
+
}
|
|
1381
|
+
|
|
1382
|
+
if (scope === "local" && isTeamShared) {
|
|
1383
|
+
try {
|
|
1384
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1385
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
|
|
1386
|
+
method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
|
|
1387
|
+
});
|
|
1388
|
+
if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
|
|
1389
|
+
hubSynced = true;
|
|
1390
|
+
} catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
|
|
1391
|
+
}
|
|
1392
|
+
|
|
1393
|
+
if (scope === "private") {
|
|
1394
|
+
if (isTeamShared) {
|
|
1395
|
+
try {
|
|
1396
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1397
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/tasks/unshare", {
|
|
1398
|
+
method: "POST", body: JSON.stringify({ sourceTaskId: taskId }),
|
|
1399
|
+
});
|
|
1400
|
+
if (hubClient.userId) this.store.deleteHubTaskBySource(hubClient.userId, taskId);
|
|
1401
|
+
hubSynced = true;
|
|
1402
|
+
} catch (err) { this.log.warn(`Failed to unshare task from team: ${err}`); }
|
|
1403
|
+
}
|
|
1404
|
+
if (isLocalShared) {
|
|
1405
|
+
const db = (this.store as any).db;
|
|
1406
|
+
const shared = db.prepare("SELECT original_owner FROM local_shared_tasks WHERE task_id = ?").get(taskId) as any;
|
|
1407
|
+
const restoreOwner = shared?.original_owner ?? task.owner;
|
|
1408
|
+
if (restoreOwner && restoreOwner !== "public") {
|
|
1409
|
+
db.prepare("UPDATE tasks SET owner = ? WHERE id = ?").run(restoreOwner, taskId);
|
|
1410
|
+
}
|
|
1411
|
+
db.prepare("DELETE FROM local_shared_tasks WHERE task_id = ?").run(taskId);
|
|
1412
|
+
}
|
|
1413
|
+
}
|
|
1414
|
+
|
|
1415
|
+
this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
|
|
1416
|
+
} catch (err) {
|
|
1417
|
+
this.jsonResponse(res, { ok: false, error: String(err) }, 500);
|
|
1418
|
+
}
|
|
1419
|
+
});
|
|
1420
|
+
}
|
|
1421
|
+
|
|
1422
|
+
private handleSkillScope(req: http.IncomingMessage, res: http.ServerResponse, urlPath: string): void {
|
|
1423
|
+
const skillId = urlPath.split("/")[3];
|
|
1424
|
+
this.readBody(req, async (body) => {
|
|
1425
|
+
try {
|
|
1426
|
+
const parsed = JSON.parse(body || "{}");
|
|
1427
|
+
const scope = parsed.scope as string;
|
|
1428
|
+
if (!["private", "local", "team"].includes(scope)) {
|
|
1429
|
+
return this.jsonResponse(res, { ok: false, error: "scope must be 'private', 'local', or 'team'" }, 400);
|
|
1430
|
+
}
|
|
1431
|
+
const skill = this.store.getSkill(skillId);
|
|
1432
|
+
if (!skill) return this.jsonResponse(res, { ok: false, error: "skill_not_found" }, 404);
|
|
1433
|
+
|
|
1434
|
+
if (scope !== "private" && skill.status !== "active") {
|
|
1435
|
+
return this.jsonResponse(res, { ok: false, error: "only_active_skills_can_be_shared" }, 400);
|
|
1436
|
+
}
|
|
1437
|
+
|
|
1438
|
+
const isLocalShared = skill.visibility === "public";
|
|
1439
|
+
const hubSkill = this.getHubSkillForLocal(skillId);
|
|
1440
|
+
const isTeamShared = !!hubSkill;
|
|
1441
|
+
const currentScope = isTeamShared ? "team" : isLocalShared ? "local" : "private";
|
|
1442
|
+
|
|
1443
|
+
if (scope === currentScope) {
|
|
1444
|
+
return this.jsonResponse(res, { ok: true, scope, changed: false });
|
|
1445
|
+
}
|
|
1446
|
+
|
|
1447
|
+
let hubSynced = false;
|
|
1448
|
+
|
|
1449
|
+
if (scope === "team") {
|
|
1450
|
+
if (!isTeamShared) {
|
|
1451
|
+
const bundle = buildSkillBundleForHub(this.store, skillId);
|
|
1452
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1453
|
+
const response = await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/publish", {
|
|
1454
|
+
method: "POST",
|
|
1455
|
+
body: JSON.stringify({ visibility: "public", groupId: null, metadata: bundle.metadata, bundle: bundle.bundle }),
|
|
1456
|
+
});
|
|
1457
|
+
if (hubClient.userId) {
|
|
1458
|
+
const existing = this.store.getHubSkillBySource(hubClient.userId, skillId);
|
|
1459
|
+
this.store.upsertHubSkill({
|
|
1460
|
+
id: (response as any)?.skillId ?? existing?.id ?? crypto.randomUUID(),
|
|
1461
|
+
sourceSkillId: skillId, sourceUserId: hubClient.userId,
|
|
1462
|
+
name: skill.name, description: skill.description, version: skill.version,
|
|
1463
|
+
groupId: null, visibility: "public",
|
|
1464
|
+
bundle: JSON.stringify(bundle.bundle), qualityScore: skill.qualityScore,
|
|
1465
|
+
createdAt: existing?.createdAt ?? Date.now(), updatedAt: Date.now(),
|
|
1466
|
+
});
|
|
1467
|
+
}
|
|
1468
|
+
hubSynced = true;
|
|
1469
|
+
}
|
|
1470
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1471
|
+
}
|
|
1472
|
+
|
|
1473
|
+
if (scope === "local") {
|
|
1474
|
+
if (!isLocalShared) this.store.setSkillVisibility(skillId, "public");
|
|
1475
|
+
}
|
|
1476
|
+
|
|
1477
|
+
if (scope === "local" && isTeamShared) {
|
|
1478
|
+
try {
|
|
1479
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1480
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
|
|
1481
|
+
method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
|
|
1482
|
+
});
|
|
1483
|
+
if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
|
|
1484
|
+
hubSynced = true;
|
|
1485
|
+
} catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
|
|
1486
|
+
}
|
|
1487
|
+
|
|
1488
|
+
if (scope === "private") {
|
|
1489
|
+
if (isTeamShared) {
|
|
1490
|
+
try {
|
|
1491
|
+
const hubClient = await this.resolveHubClientAware();
|
|
1492
|
+
await hubRequestJson(hubClient.hubUrl, hubClient.userToken, "/api/v1/hub/skills/unpublish", {
|
|
1493
|
+
method: "POST", body: JSON.stringify({ sourceSkillId: skillId }),
|
|
1494
|
+
});
|
|
1495
|
+
if (hubClient.userId) this.store.deleteHubSkillBySource(hubClient.userId, skillId);
|
|
1496
|
+
hubSynced = true;
|
|
1497
|
+
} catch (err) { this.log.warn(`Failed to unpublish skill from team: ${err}`); }
|
|
1498
|
+
}
|
|
1499
|
+
if (isLocalShared) this.store.setSkillVisibility(skillId, "private");
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1502
|
+
this.jsonResponse(res, { ok: true, scope, changed: true, hubSynced });
|
|
1503
|
+
} catch (err) {
|
|
1504
|
+
this.jsonResponse(res, { ok: false, error: String(err) }, 500);
|
|
1505
|
+
}
|
|
1506
|
+
});
|
|
1507
|
+
}
|
|
1508
|
+
|
|
1509
|
+
private getHubMemoryForChunk(chunkId: string): any {
|
|
1510
|
+
const db = (this.store as any).db;
|
|
1511
|
+
const hub = db.prepare("SELECT * FROM hub_memories WHERE source_chunk_id = ? LIMIT 1").get(chunkId);
|
|
1512
|
+
if (hub) return hub;
|
|
1513
|
+
const ts = this.store.getTeamSharedChunk(chunkId);
|
|
1514
|
+
if (ts) {
|
|
1515
|
+
return {
|
|
1516
|
+
source_chunk_id: chunkId,
|
|
1517
|
+
visibility: ts.visibility,
|
|
1518
|
+
group_id: ts.groupId,
|
|
1519
|
+
};
|
|
1520
|
+
}
|
|
1521
|
+
return undefined;
|
|
1522
|
+
}
|
|
1523
|
+
|
|
1524
|
+
private getHubTaskForLocal(taskId: string): any {
|
|
1525
|
+
const db = (this.store as any).db;
|
|
1526
|
+
return db.prepare("SELECT * FROM hub_tasks WHERE source_task_id = ? LIMIT 1").get(taskId);
|
|
1527
|
+
}
|
|
1528
|
+
|
|
1529
|
+
private getHubSkillForLocal(skillId: string): any {
|
|
1530
|
+
const db = (this.store as any).db;
|
|
1531
|
+
return db.prepare("SELECT * FROM hub_skills WHERE source_skill_id = ? LIMIT 1").get(skillId);
|
|
1532
|
+
}
|
|
1533
|
+
|
|
1061
1534
|
private handleDeleteSession(res: http.ServerResponse, url: URL): void {
|
|
1062
1535
|
const key = url.searchParams.get("key");
|
|
1063
1536
|
if (!key) { res.writeHead(400, { "Content-Type": "application/json" }); res.end(JSON.stringify({ error: "Missing key" })); return; }
|
|
@@ -1092,8 +1565,10 @@ export class ViewerServer {
|
|
|
1092
1565
|
// ─── Config API ───
|
|
1093
1566
|
|
|
1094
1567
|
private getOpenClawConfigPath(): string {
|
|
1568
|
+
if (process.env.OPENCLAW_CONFIG_PATH) return process.env.OPENCLAW_CONFIG_PATH;
|
|
1095
1569
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
1096
|
-
|
|
1570
|
+
const ocHome = process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
1571
|
+
return path.join(ocHome, "openclaw.json");
|
|
1097
1572
|
}
|
|
1098
1573
|
|
|
1099
1574
|
private getPluginEntryConfig(raw: any): Record<string, unknown> {
|
|
@@ -1157,8 +1632,7 @@ export class ViewerServer {
|
|
|
1157
1632
|
base.connection.connected = true;
|
|
1158
1633
|
base.connection.hubUrl = resolvedHubUrl ?? undefined;
|
|
1159
1634
|
|
|
1160
|
-
|
|
1161
|
-
let adminUser: any = { username: "hub-admin", role: "admin", groups: [] };
|
|
1635
|
+
let adminUser: any = { username: "hub-admin", role: "admin" };
|
|
1162
1636
|
try {
|
|
1163
1637
|
const hub = this.resolveHubConnection();
|
|
1164
1638
|
if (hub) {
|
|
@@ -1168,7 +1642,6 @@ export class ViewerServer {
|
|
|
1168
1642
|
id: me.id,
|
|
1169
1643
|
username: me.username ?? "hub-admin",
|
|
1170
1644
|
role: me.role ?? "admin",
|
|
1171
|
-
groups: Array.isArray(me.groups) ? me.groups : [],
|
|
1172
1645
|
};
|
|
1173
1646
|
}
|
|
1174
1647
|
}
|
|
@@ -1182,7 +1655,20 @@ export class ViewerServer {
|
|
|
1182
1655
|
base.connection.teamName = info?.teamName ?? sharing.hub?.teamName ?? null;
|
|
1183
1656
|
base.connection.apiVersion = info?.apiVersion ?? null;
|
|
1184
1657
|
} catch { /* ignore */ }
|
|
1185
|
-
|
|
1658
|
+
|
|
1659
|
+
const hubStats: any = { totalMembers: 0, onlineMembers: 0, pendingMembers: 0 };
|
|
1660
|
+
try {
|
|
1661
|
+
const activeUsers = this.store.listHubUsers("active");
|
|
1662
|
+
const pendingUsers = this.store.listHubUsers("pending");
|
|
1663
|
+
const now = Date.now();
|
|
1664
|
+
const OFFLINE_THRESHOLD = 120_000;
|
|
1665
|
+
hubStats.totalMembers = activeUsers.length;
|
|
1666
|
+
hubStats.onlineMembers = activeUsers.filter(u =>
|
|
1667
|
+
u.lastActiveAt && (now - u.lastActiveAt < OFFLINE_THRESHOLD),
|
|
1668
|
+
).length;
|
|
1669
|
+
hubStats.pendingMembers = pendingUsers.length;
|
|
1670
|
+
} catch { /* best-effort */ }
|
|
1671
|
+
this.jsonResponse(res, { ...base, hubStats });
|
|
1186
1672
|
return;
|
|
1187
1673
|
}
|
|
1188
1674
|
|
|
@@ -1201,6 +1687,9 @@ export class ViewerServer {
|
|
|
1201
1687
|
if (status.user?.status === "rejected") {
|
|
1202
1688
|
output.connection.rejected = true;
|
|
1203
1689
|
}
|
|
1690
|
+
if (status.user?.status === "removed") {
|
|
1691
|
+
output.connection.removed = true;
|
|
1692
|
+
}
|
|
1204
1693
|
if (status.connected && status.hubUrl) {
|
|
1205
1694
|
try {
|
|
1206
1695
|
const info = await fetch(`${status.hubUrl}/api/v1/hub/info`).then((r) => (r.ok ? r.json() : null)).catch(() => null) as any;
|
|
@@ -1269,6 +1758,67 @@ export class ViewerServer {
|
|
|
1269
1758
|
});
|
|
1270
1759
|
}
|
|
1271
1760
|
|
|
1761
|
+
private handleSharingChangeRole(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1762
|
+
this.readBody(req, async (body) => {
|
|
1763
|
+
if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
|
|
1764
|
+
try {
|
|
1765
|
+
const parsed = JSON.parse(body || "{}");
|
|
1766
|
+
const hub = this.resolveHubConnection();
|
|
1767
|
+
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1768
|
+
const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/change-role", {
|
|
1769
|
+
method: "POST",
|
|
1770
|
+
body: JSON.stringify({ userId: parsed.userId, role: parsed.role }),
|
|
1771
|
+
});
|
|
1772
|
+
this.jsonResponse(res, { ok: true, result });
|
|
1773
|
+
} catch (err) {
|
|
1774
|
+
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1775
|
+
}
|
|
1776
|
+
});
|
|
1777
|
+
}
|
|
1778
|
+
|
|
1779
|
+
private handleSharingRemoveUser(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1780
|
+
this.readBody(req, async (body) => {
|
|
1781
|
+
if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
|
|
1782
|
+
try {
|
|
1783
|
+
const parsed = JSON.parse(body || "{}");
|
|
1784
|
+
const hub = this.resolveHubConnection();
|
|
1785
|
+
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1786
|
+
const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/remove-user", {
|
|
1787
|
+
method: "POST",
|
|
1788
|
+
body: JSON.stringify({ userId: parsed.userId, cleanResources: parsed.cleanResources === true }),
|
|
1789
|
+
});
|
|
1790
|
+
this.jsonResponse(res, { ok: true, result });
|
|
1791
|
+
} catch (err) {
|
|
1792
|
+
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1793
|
+
}
|
|
1794
|
+
});
|
|
1795
|
+
}
|
|
1796
|
+
|
|
1797
|
+
private handleAdminRenameUser(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1798
|
+
this.readBody(req, async (body) => {
|
|
1799
|
+
if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
|
|
1800
|
+
try {
|
|
1801
|
+
const parsed = JSON.parse(body || "{}");
|
|
1802
|
+
const hub = this.resolveHubConnection();
|
|
1803
|
+
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1804
|
+
const result = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/rename-user", {
|
|
1805
|
+
method: "POST",
|
|
1806
|
+
body: JSON.stringify({ userId: parsed.userId, username: parsed.username }),
|
|
1807
|
+
});
|
|
1808
|
+
this.jsonResponse(res, { ok: true, result });
|
|
1809
|
+
} catch (err) {
|
|
1810
|
+
const errStr = String(err);
|
|
1811
|
+
if (errStr.includes("username_taken")) {
|
|
1812
|
+
this.jsonResponse(res, { ok: false, error: "username_taken" });
|
|
1813
|
+
} else if (errStr.includes("invalid_params")) {
|
|
1814
|
+
this.jsonResponse(res, { ok: false, error: "invalid_params" });
|
|
1815
|
+
} else {
|
|
1816
|
+
this.jsonResponse(res, { ok: false, error: errStr });
|
|
1817
|
+
}
|
|
1818
|
+
}
|
|
1819
|
+
});
|
|
1820
|
+
}
|
|
1821
|
+
|
|
1272
1822
|
private handleRetryJoin(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1273
1823
|
this.readBody(req, async (_body) => {
|
|
1274
1824
|
if (!this.ctx) return this.jsonResponse(res, { ok: false, error: "sharing_unavailable" });
|
|
@@ -1284,12 +1834,16 @@ export class ViewerServer {
|
|
|
1284
1834
|
try {
|
|
1285
1835
|
const hubUrl = normalizeHubUrl(hubAddress);
|
|
1286
1836
|
const os = await import("os");
|
|
1287
|
-
const
|
|
1837
|
+
const nickname = sharing.client?.nickname;
|
|
1838
|
+
const username = nickname || os.userInfo().username || "user";
|
|
1288
1839
|
const hostname = os.hostname() || "unknown";
|
|
1840
|
+
const persisted = this.store.getClientHubConnection();
|
|
1841
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
1289
1842
|
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
1290
1843
|
method: "POST",
|
|
1291
|
-
body: JSON.stringify({ teamToken, username, deviceName: hostname }),
|
|
1844
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, reapply: true, identityKey: existingIdentityKey }),
|
|
1292
1845
|
}) as any;
|
|
1846
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
1293
1847
|
this.store.setClientHubConnection({
|
|
1294
1848
|
hubUrl,
|
|
1295
1849
|
userId: String(result.userId || ""),
|
|
@@ -1297,6 +1851,8 @@ export class ViewerServer {
|
|
|
1297
1851
|
userToken: result.userToken || "",
|
|
1298
1852
|
role: "member",
|
|
1299
1853
|
connectedAt: Date.now(),
|
|
1854
|
+
identityKey: returnedIdentityKey,
|
|
1855
|
+
lastKnownStatus: result.status || "",
|
|
1300
1856
|
});
|
|
1301
1857
|
this.jsonResponse(res, { ok: true, status: result.status || "pending" });
|
|
1302
1858
|
} catch (err) {
|
|
@@ -1309,7 +1865,13 @@ export class ViewerServer {
|
|
|
1309
1865
|
if (!this.ctx) return this.jsonResponse(res, { memories: [], error: "sharing_unavailable" });
|
|
1310
1866
|
try {
|
|
1311
1867
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1312
|
-
const
|
|
1868
|
+
const hub = this.resolveHubConnection();
|
|
1869
|
+
let data: any;
|
|
1870
|
+
if (hub) {
|
|
1871
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/memories?limit=${limit}`);
|
|
1872
|
+
} else {
|
|
1873
|
+
data = await hubListMemories(this.store, this.ctx, { limit });
|
|
1874
|
+
}
|
|
1313
1875
|
this.jsonResponse(res, { memories: Array.isArray(data?.memories) ? data.memories : [] });
|
|
1314
1876
|
} catch (err) {
|
|
1315
1877
|
this.jsonResponse(res, { memories: [], error: String(err) });
|
|
@@ -1320,7 +1882,13 @@ export class ViewerServer {
|
|
|
1320
1882
|
if (!this.ctx) return this.jsonResponse(res, { tasks: [], error: "sharing_unavailable" });
|
|
1321
1883
|
try {
|
|
1322
1884
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1323
|
-
const
|
|
1885
|
+
const hub = this.resolveHubConnection();
|
|
1886
|
+
let data: any;
|
|
1887
|
+
if (hub) {
|
|
1888
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/tasks?limit=${limit}`);
|
|
1889
|
+
} else {
|
|
1890
|
+
data = await hubListTasks(this.store, this.ctx, { limit });
|
|
1891
|
+
}
|
|
1324
1892
|
this.jsonResponse(res, { tasks: Array.isArray(data?.tasks) ? data.tasks : [] });
|
|
1325
1893
|
} catch (err) {
|
|
1326
1894
|
this.jsonResponse(res, { tasks: [], error: String(err) });
|
|
@@ -1331,7 +1899,13 @@ export class ViewerServer {
|
|
|
1331
1899
|
if (!this.ctx) return this.jsonResponse(res, { skills: [], error: "sharing_unavailable" });
|
|
1332
1900
|
try {
|
|
1333
1901
|
const limit = Number(url.searchParams.get("limit") || 40);
|
|
1334
|
-
const
|
|
1902
|
+
const hub = this.resolveHubConnection();
|
|
1903
|
+
let data: any;
|
|
1904
|
+
if (hub) {
|
|
1905
|
+
data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/skills/list?limit=${limit}`);
|
|
1906
|
+
} else {
|
|
1907
|
+
data = await hubListSkills(this.store, this.ctx, { limit });
|
|
1908
|
+
}
|
|
1335
1909
|
this.jsonResponse(res, { skills: Array.isArray(data?.skills) ? data.skills : [] });
|
|
1336
1910
|
} catch (err) {
|
|
1337
1911
|
this.jsonResponse(res, { skills: [], error: String(err) });
|
|
@@ -1347,13 +1921,21 @@ export class ViewerServer {
|
|
|
1347
1921
|
const query = String(parsed.query || "");
|
|
1348
1922
|
const role = typeof parsed.role === "string" ? parsed.role : undefined;
|
|
1349
1923
|
const maxResults = typeof parsed.maxResults === "number" ? parsed.maxResults : 10;
|
|
1350
|
-
const scope = parsed.scope === "group" || parsed.scope === "all" ? parsed.scope : "local";
|
|
1924
|
+
const scope = parsed.scope === "group" || parsed.scope === "all" || parsed.scope === "hub" ? (parsed.scope === "hub" ? "all" : parsed.scope) : "local";
|
|
1351
1925
|
const local = this.searchLocalViewerMemories(query, { role, maxResults });
|
|
1352
1926
|
if (scope === "local") {
|
|
1353
1927
|
return this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub });
|
|
1354
1928
|
}
|
|
1355
1929
|
try {
|
|
1356
|
-
const
|
|
1930
|
+
const conn = this.resolveHubConnection();
|
|
1931
|
+
let hub: any;
|
|
1932
|
+
if (conn) {
|
|
1933
|
+
hub = await hubRequestJson(conn.hubUrl, conn.userToken, "/api/v1/hub/search", {
|
|
1934
|
+
method: "POST", body: JSON.stringify({ query, maxResults, scope }),
|
|
1935
|
+
});
|
|
1936
|
+
} else {
|
|
1937
|
+
hub = await hubSearchMemories(this.store, this.ctx!, { query, maxResults, scope });
|
|
1938
|
+
}
|
|
1357
1939
|
this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub });
|
|
1358
1940
|
} catch (err) {
|
|
1359
1941
|
this.jsonResponse(res, { local: { hits: local.hits, meta: local.meta }, hub: emptyHub, error: String(err) });
|
|
@@ -1546,14 +2128,14 @@ export class ViewerServer {
|
|
|
1546
2128
|
},
|
|
1547
2129
|
}),
|
|
1548
2130
|
});
|
|
1549
|
-
const
|
|
1550
|
-
if (
|
|
2131
|
+
const mid = String((response as any)?.memoryId ?? "");
|
|
2132
|
+
if (hubClient.userId && this.ctx?.config?.sharing?.role === "hub") {
|
|
1551
2133
|
const now = Date.now();
|
|
1552
|
-
const existing = this.store.getHubMemoryBySource(
|
|
2134
|
+
const existing = this.store.getHubMemoryBySource(hubClient.userId, chunk.id);
|
|
1553
2135
|
this.store.upsertHubMemory({
|
|
1554
|
-
id:
|
|
2136
|
+
id: mid || existing?.id || crypto.randomUUID(),
|
|
1555
2137
|
sourceChunkId: chunk.id,
|
|
1556
|
-
sourceUserId:
|
|
2138
|
+
sourceUserId: hubClient.userId,
|
|
1557
2139
|
role: chunk.role,
|
|
1558
2140
|
content: chunk.content,
|
|
1559
2141
|
summary: chunk.summary ?? "",
|
|
@@ -1563,6 +2145,8 @@ export class ViewerServer {
|
|
|
1563
2145
|
createdAt: existing?.createdAt ?? now,
|
|
1564
2146
|
updatedAt: now,
|
|
1565
2147
|
});
|
|
2148
|
+
} else if (hubClient.userId) {
|
|
2149
|
+
this.store.upsertTeamSharedChunk(chunk.id, { hubMemoryId: mid, visibility, groupId });
|
|
1566
2150
|
}
|
|
1567
2151
|
this.jsonResponse(res, { ok: true, chunkId, visibility, response });
|
|
1568
2152
|
} catch (err) {
|
|
@@ -1584,6 +2168,7 @@ export class ViewerServer {
|
|
|
1584
2168
|
});
|
|
1585
2169
|
const hubUserId = hubClient.userId;
|
|
1586
2170
|
if (hubUserId) this.store.deleteHubMemoryBySource(hubUserId, chunkId);
|
|
2171
|
+
this.store.deleteTeamSharedChunk(chunkId);
|
|
1587
2172
|
this.jsonResponse(res, { ok: true, chunkId });
|
|
1588
2173
|
} catch (err) {
|
|
1589
2174
|
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
@@ -1680,7 +2265,7 @@ export class ViewerServer {
|
|
|
1680
2265
|
// Hub 模式:连接自己,用 bootstrap admin token
|
|
1681
2266
|
const sharing = this.ctx.config.sharing;
|
|
1682
2267
|
if (sharing?.role === "hub") {
|
|
1683
|
-
const hubPort =
|
|
2268
|
+
const hubPort = this.getHubPort();
|
|
1684
2269
|
const hubUrl = `http://127.0.0.1:${hubPort}`;
|
|
1685
2270
|
try {
|
|
1686
2271
|
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
@@ -1720,123 +2305,12 @@ export class ViewerServer {
|
|
|
1720
2305
|
return resolveHubClient(this.store, this.ctx);
|
|
1721
2306
|
}
|
|
1722
2307
|
|
|
1723
|
-
private
|
|
1724
|
-
const m = path.match(/\/api\/sharing\/groups\/([^/]+)/);
|
|
1725
|
-
return m ? decodeURIComponent(m[1]) : "";
|
|
1726
|
-
}
|
|
1727
|
-
|
|
1728
|
-
private async serveSharingGroups(res: http.ServerResponse): Promise<void> {
|
|
2308
|
+
private async serveSharingUsers(res: http.ServerResponse): Promise<void> {
|
|
1729
2309
|
const hub = this.resolveHubConnection();
|
|
1730
|
-
if (!hub) return this.jsonResponse(res, {
|
|
2310
|
+
if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
|
|
1731
2311
|
try {
|
|
1732
|
-
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/
|
|
1733
|
-
this.jsonResponse(res, {
|
|
1734
|
-
} catch (err) {
|
|
1735
|
-
this.jsonResponse(res, { groups: [], error: String(err) });
|
|
1736
|
-
}
|
|
1737
|
-
}
|
|
1738
|
-
|
|
1739
|
-
private handleSharingGroupCreate(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1740
|
-
this.readBody(req, async (body) => {
|
|
1741
|
-
const hub = this.resolveHubConnection();
|
|
1742
|
-
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1743
|
-
try {
|
|
1744
|
-
const parsed = JSON.parse(body || "{}");
|
|
1745
|
-
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/groups", {
|
|
1746
|
-
method: "POST",
|
|
1747
|
-
body: JSON.stringify({ name: parsed.name, description: parsed.description }),
|
|
1748
|
-
}) as any;
|
|
1749
|
-
this.jsonResponse(res, { ok: true, ...data });
|
|
1750
|
-
} catch (err) {
|
|
1751
|
-
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1752
|
-
}
|
|
1753
|
-
});
|
|
1754
|
-
}
|
|
1755
|
-
|
|
1756
|
-
private handleSharingGroupUpdate(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
|
|
1757
|
-
this.readBody(req, async (body) => {
|
|
1758
|
-
const hub = this.resolveHubConnection();
|
|
1759
|
-
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1760
|
-
const groupId = this.extractGroupId(p);
|
|
1761
|
-
try {
|
|
1762
|
-
const parsed = JSON.parse(body || "{}");
|
|
1763
|
-
await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, {
|
|
1764
|
-
method: "PUT",
|
|
1765
|
-
body: JSON.stringify({ name: parsed.name, description: parsed.description }),
|
|
1766
|
-
});
|
|
1767
|
-
this.jsonResponse(res, { ok: true });
|
|
1768
|
-
} catch (err) {
|
|
1769
|
-
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1770
|
-
}
|
|
1771
|
-
});
|
|
1772
|
-
}
|
|
1773
|
-
|
|
1774
|
-
private async handleSharingGroupDelete(res: http.ServerResponse, p: string): Promise<void> {
|
|
1775
|
-
const hub = this.resolveHubConnection();
|
|
1776
|
-
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1777
|
-
const groupId = this.extractGroupId(p);
|
|
1778
|
-
try {
|
|
1779
|
-
await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "DELETE" });
|
|
1780
|
-
this.jsonResponse(res, { ok: true });
|
|
1781
|
-
} catch (err) {
|
|
1782
|
-
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1783
|
-
}
|
|
1784
|
-
}
|
|
1785
|
-
|
|
1786
|
-
private async serveSharingGroupMembers(res: http.ServerResponse, p: string): Promise<void> {
|
|
1787
|
-
const hub = this.resolveHubConnection();
|
|
1788
|
-
if (!hub) return this.jsonResponse(res, { members: [], error: "not_configured" });
|
|
1789
|
-
const groupId = this.extractGroupId(p);
|
|
1790
|
-
try {
|
|
1791
|
-
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}`, { method: "GET" }) as any;
|
|
1792
|
-
this.jsonResponse(res, { members: Array.isArray(data?.members) ? data.members : [] });
|
|
1793
|
-
} catch (err) {
|
|
1794
|
-
this.jsonResponse(res, { members: [], error: String(err) });
|
|
1795
|
-
}
|
|
1796
|
-
}
|
|
1797
|
-
|
|
1798
|
-
private handleSharingGroupAddMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
|
|
1799
|
-
this.readBody(req, async (body) => {
|
|
1800
|
-
const hub = this.resolveHubConnection();
|
|
1801
|
-
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1802
|
-
const groupId = this.extractGroupId(p);
|
|
1803
|
-
try {
|
|
1804
|
-
const parsed = JSON.parse(body || "{}");
|
|
1805
|
-
await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
|
|
1806
|
-
method: "POST",
|
|
1807
|
-
body: JSON.stringify({ userId: parsed.userId }),
|
|
1808
|
-
});
|
|
1809
|
-
this.jsonResponse(res, { ok: true });
|
|
1810
|
-
} catch (err) {
|
|
1811
|
-
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1812
|
-
}
|
|
1813
|
-
});
|
|
1814
|
-
}
|
|
1815
|
-
|
|
1816
|
-
private handleSharingGroupRemoveMember(req: http.IncomingMessage, res: http.ServerResponse, p: string): void {
|
|
1817
|
-
this.readBody(req, async (body) => {
|
|
1818
|
-
const hub = this.resolveHubConnection();
|
|
1819
|
-
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
1820
|
-
const groupId = this.extractGroupId(p);
|
|
1821
|
-
try {
|
|
1822
|
-
const parsed = JSON.parse(body || "{}");
|
|
1823
|
-
await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/groups/${encodeURIComponent(groupId)}/members`, {
|
|
1824
|
-
method: "DELETE",
|
|
1825
|
-
body: JSON.stringify({ userId: parsed.userId }),
|
|
1826
|
-
});
|
|
1827
|
-
this.jsonResponse(res, { ok: true });
|
|
1828
|
-
} catch (err) {
|
|
1829
|
-
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
1830
|
-
}
|
|
1831
|
-
});
|
|
1832
|
-
}
|
|
1833
|
-
|
|
1834
|
-
private async serveSharingUsers(res: http.ServerResponse): Promise<void> {
|
|
1835
|
-
const hub = this.resolveHubConnection();
|
|
1836
|
-
if (!hub) return this.jsonResponse(res, { users: [], error: "not_configured" });
|
|
1837
|
-
try {
|
|
1838
|
-
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/users", { method: "GET" }) as any;
|
|
1839
|
-
this.jsonResponse(res, { users: Array.isArray(data?.users) ? data.users : [] });
|
|
2312
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/users", { method: "GET" }) as any;
|
|
2313
|
+
this.jsonResponse(res, { users: Array.isArray(data?.users) ? data.users : [] });
|
|
1840
2314
|
} catch (err) {
|
|
1841
2315
|
this.jsonResponse(res, { users: [], error: String(err) });
|
|
1842
2316
|
}
|
|
@@ -1849,7 +2323,14 @@ export class ViewerServer {
|
|
|
1849
2323
|
if (!hub) return this.jsonResponse(res, { tasks: [], error: "not_configured" });
|
|
1850
2324
|
try {
|
|
1851
2325
|
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-tasks", { method: "GET" }) as any;
|
|
1852
|
-
|
|
2326
|
+
const tasks = Array.isArray(data?.tasks) ? data.tasks : [];
|
|
2327
|
+
for (const tk of tasks) {
|
|
2328
|
+
if (!tk.summary && tk.sourceTaskId) {
|
|
2329
|
+
const local = this.store.getTask(tk.sourceTaskId);
|
|
2330
|
+
if (local) { tk.summary = local.summary; tk.title = tk.title || local.title; }
|
|
2331
|
+
}
|
|
2332
|
+
}
|
|
2333
|
+
this.jsonResponse(res, { tasks });
|
|
1853
2334
|
} catch (err) {
|
|
1854
2335
|
this.jsonResponse(res, { tasks: [], error: String(err) });
|
|
1855
2336
|
}
|
|
@@ -1867,12 +2348,47 @@ export class ViewerServer {
|
|
|
1867
2348
|
}
|
|
1868
2349
|
}
|
|
1869
2350
|
|
|
2351
|
+
private async serveHubTaskDetail(res: http.ServerResponse, p: string): Promise<void> {
|
|
2352
|
+
const hub = this.resolveHubConnection();
|
|
2353
|
+
if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
|
|
2354
|
+
const m = p.match(/^\/api\/admin\/shared-tasks\/([^/]+)\/detail$/);
|
|
2355
|
+
if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
|
|
2356
|
+
const taskId = decodeURIComponent(m[1]);
|
|
2357
|
+
try {
|
|
2358
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-tasks/${encodeURIComponent(taskId)}/detail`, { method: "GET" }) as any;
|
|
2359
|
+
this.jsonResponse(res, data);
|
|
2360
|
+
} catch (err) {
|
|
2361
|
+
this.jsonResponse(res, { error: String(err) }, 500);
|
|
2362
|
+
}
|
|
2363
|
+
}
|
|
2364
|
+
|
|
2365
|
+
private async serveHubSkillDetail(res: http.ServerResponse, p: string): Promise<void> {
|
|
2366
|
+
const hub = this.resolveHubConnection();
|
|
2367
|
+
if (!hub) return this.jsonResponse(res, { error: "not_configured" }, 500);
|
|
2368
|
+
const m = p.match(/^\/api\/admin\/shared-skills\/([^/]+)\/detail$/);
|
|
2369
|
+
if (!m) return this.jsonResponse(res, { error: "bad_request" }, 400);
|
|
2370
|
+
const skillId = decodeURIComponent(m[1]);
|
|
2371
|
+
try {
|
|
2372
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/shared-skills/${encodeURIComponent(skillId)}/detail`, { method: "GET" }) as any;
|
|
2373
|
+
this.jsonResponse(res, data);
|
|
2374
|
+
} catch (err) {
|
|
2375
|
+
this.jsonResponse(res, { error: String(err) }, 500);
|
|
2376
|
+
}
|
|
2377
|
+
}
|
|
2378
|
+
|
|
1870
2379
|
private async serveAdminSharedSkills(res: http.ServerResponse): Promise<void> {
|
|
1871
2380
|
const hub = this.resolveHubConnection();
|
|
1872
2381
|
if (!hub) return this.jsonResponse(res, { skills: [], error: "not_configured" });
|
|
1873
2382
|
try {
|
|
1874
2383
|
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-skills", { method: "GET" }) as any;
|
|
1875
|
-
|
|
2384
|
+
const skills = Array.isArray(data?.skills) ? data.skills : [];
|
|
2385
|
+
for (const sk of skills) {
|
|
2386
|
+
if (!sk.description && sk.sourceSkillId) {
|
|
2387
|
+
const local = this.store.getSkill(sk.sourceSkillId);
|
|
2388
|
+
if (local) { sk.description = sk.description || local.description; sk.name = sk.name || local.name; }
|
|
2389
|
+
}
|
|
2390
|
+
}
|
|
2391
|
+
this.jsonResponse(res, { skills });
|
|
1876
2392
|
} catch (err) {
|
|
1877
2393
|
this.jsonResponse(res, { skills: [], error: String(err) });
|
|
1878
2394
|
}
|
|
@@ -1895,7 +2411,14 @@ export class ViewerServer {
|
|
|
1895
2411
|
if (!hub) return this.jsonResponse(res, { memories: [], error: "not_configured" });
|
|
1896
2412
|
try {
|
|
1897
2413
|
const data = await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/admin/shared-memories", { method: "GET" }) as any;
|
|
1898
|
-
|
|
2414
|
+
const memories = Array.isArray(data?.memories) ? data.memories : [];
|
|
2415
|
+
for (const m of memories) {
|
|
2416
|
+
if (!m.content && m.sourceChunkId) {
|
|
2417
|
+
const local = this.store.getChunk(m.sourceChunkId);
|
|
2418
|
+
if (local) { m.content = local.content; if (!m.summary && local.summary) m.summary = local.summary; }
|
|
2419
|
+
}
|
|
2420
|
+
}
|
|
2421
|
+
this.jsonResponse(res, { memories });
|
|
1899
2422
|
} catch (err) {
|
|
1900
2423
|
this.jsonResponse(res, { memories: [], error: String(err) });
|
|
1901
2424
|
}
|
|
@@ -1913,6 +2436,150 @@ export class ViewerServer {
|
|
|
1913
2436
|
}
|
|
1914
2437
|
}
|
|
1915
2438
|
|
|
2439
|
+
private async serveSharingNotifications(res: http.ServerResponse, url: URL): Promise<void> {
|
|
2440
|
+
const hub = this.resolveHubConnection();
|
|
2441
|
+
if (!hub) return this.jsonResponse(res, { notifications: [], unreadCount: 0 });
|
|
2442
|
+
try {
|
|
2443
|
+
const unread = url.searchParams.get("unread") === "1" ? "?unread=1" : "";
|
|
2444
|
+
const data = await hubRequestJson(hub.hubUrl, hub.userToken, `/api/v1/hub/notifications${unread}`) as any;
|
|
2445
|
+
this.jsonResponse(res, data);
|
|
2446
|
+
} catch {
|
|
2447
|
+
this.jsonResponse(res, { notifications: [], unreadCount: 0 });
|
|
2448
|
+
}
|
|
2449
|
+
}
|
|
2450
|
+
|
|
2451
|
+
private handleSharingNotificationsRead(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2452
|
+
const hub = this.resolveHubConnection();
|
|
2453
|
+
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
2454
|
+
this.readBody(req, async (raw) => {
|
|
2455
|
+
try {
|
|
2456
|
+
const body = JSON.parse(raw || "{}");
|
|
2457
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/read", { method: "POST", body: JSON.stringify(body) });
|
|
2458
|
+
this.jsonResponse(res, { ok: true });
|
|
2459
|
+
try {
|
|
2460
|
+
const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
|
|
2461
|
+
const count = data?.unreadCount ?? 0;
|
|
2462
|
+
this.lastKnownNotifCount = count;
|
|
2463
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2464
|
+
} catch { /* best effort */ }
|
|
2465
|
+
} catch (err) {
|
|
2466
|
+
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
2467
|
+
}
|
|
2468
|
+
});
|
|
2469
|
+
}
|
|
2470
|
+
|
|
2471
|
+
private handleSharingNotificationsClear(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2472
|
+
const hub = this.resolveHubConnection();
|
|
2473
|
+
if (!hub) return this.jsonResponse(res, { ok: false, error: "not_configured" });
|
|
2474
|
+
this.readBody(req, async () => {
|
|
2475
|
+
try {
|
|
2476
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications/clear", { method: "POST", body: "{}" });
|
|
2477
|
+
this.jsonResponse(res, { ok: true });
|
|
2478
|
+
this.broadcastNotifSSE({ type: "cleared", unreadCount: 0 });
|
|
2479
|
+
} catch (err) {
|
|
2480
|
+
this.jsonResponse(res, { ok: false, error: String(err) });
|
|
2481
|
+
}
|
|
2482
|
+
});
|
|
2483
|
+
}
|
|
2484
|
+
|
|
2485
|
+
/** Badge-only: clear Client team-share UI metadata when Hub admin removes that memory. Does NOT touch chunks, embeddings, or hub_memories (recall paths). */
|
|
2486
|
+
private handleSyncHubRemoval(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2487
|
+
this.readBody(req, (body) => {
|
|
2488
|
+
try {
|
|
2489
|
+
const parsed = JSON.parse(body || "{}");
|
|
2490
|
+
const sourceChunkId = String(parsed.sourceChunkId || "");
|
|
2491
|
+
if (!sourceChunkId) return this.jsonResponse(res, { ok: false, error: "missing_source_chunk_id" }, 400);
|
|
2492
|
+
this.store.deleteTeamSharedChunk(sourceChunkId);
|
|
2493
|
+
this.jsonResponse(res, { ok: true, sourceChunkId });
|
|
2494
|
+
} catch (e) {
|
|
2495
|
+
this.jsonResponse(res, { ok: false, error: String(e) }, 500);
|
|
2496
|
+
}
|
|
2497
|
+
});
|
|
2498
|
+
}
|
|
2499
|
+
|
|
2500
|
+
private handleNotifSSE(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2501
|
+
res.writeHead(200, {
|
|
2502
|
+
"Content-Type": "text/event-stream",
|
|
2503
|
+
"Cache-Control": "no-cache",
|
|
2504
|
+
Connection: "keep-alive",
|
|
2505
|
+
"Access-Control-Allow-Origin": "*",
|
|
2506
|
+
});
|
|
2507
|
+
res.write("data: {\"type\":\"connected\"}\n\n");
|
|
2508
|
+
this.notifSSEClients.push(res);
|
|
2509
|
+
if (!this.notifPollTimer) this.startNotifPoll();
|
|
2510
|
+
else this.notifPollImmediate();
|
|
2511
|
+
req.on("close", () => {
|
|
2512
|
+
this.notifSSEClients = this.notifSSEClients.filter((c) => c !== res);
|
|
2513
|
+
if (this.notifSSEClients.length === 0) this.stopNotifPoll();
|
|
2514
|
+
});
|
|
2515
|
+
}
|
|
2516
|
+
|
|
2517
|
+
private broadcastNotifSSE(data: Record<string, unknown>): void {
|
|
2518
|
+
const msg = `data: ${JSON.stringify(data)}\n\n`;
|
|
2519
|
+
this.notifSSEClients = this.notifSSEClients.filter((c) => {
|
|
2520
|
+
try { c.write(msg); return true; } catch { return false; }
|
|
2521
|
+
});
|
|
2522
|
+
}
|
|
2523
|
+
|
|
2524
|
+
private startNotifPoll(): void {
|
|
2525
|
+
this.stopNotifPoll();
|
|
2526
|
+
const tick = async () => {
|
|
2527
|
+
const hub = this.resolveHubConnection();
|
|
2528
|
+
if (!hub) return;
|
|
2529
|
+
try {
|
|
2530
|
+
const data = (await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")) as any;
|
|
2531
|
+
const count = data?.unreadCount ?? 0;
|
|
2532
|
+
if (count !== this.lastKnownNotifCount) {
|
|
2533
|
+
this.lastKnownNotifCount = count;
|
|
2534
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2535
|
+
}
|
|
2536
|
+
} catch { /* ignore */ }
|
|
2537
|
+
};
|
|
2538
|
+
tick();
|
|
2539
|
+
this.notifPollTimer = setInterval(tick, 3000);
|
|
2540
|
+
}
|
|
2541
|
+
|
|
2542
|
+
private stopNotifPoll(): void {
|
|
2543
|
+
if (this.notifPollTimer) { clearInterval(this.notifPollTimer); this.notifPollTimer = undefined; }
|
|
2544
|
+
}
|
|
2545
|
+
|
|
2546
|
+
private notifPollImmediate(): void {
|
|
2547
|
+
const hub = this.resolveHubConnection();
|
|
2548
|
+
if (!hub) return;
|
|
2549
|
+
hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/notifications?unread=1")
|
|
2550
|
+
.then((data: any) => {
|
|
2551
|
+
const count = data?.unreadCount ?? 0;
|
|
2552
|
+
if (count !== this.lastKnownNotifCount) {
|
|
2553
|
+
this.lastKnownNotifCount = count;
|
|
2554
|
+
this.broadcastNotifSSE({ type: "update", unreadCount: count });
|
|
2555
|
+
}
|
|
2556
|
+
})
|
|
2557
|
+
.catch(() => {});
|
|
2558
|
+
}
|
|
2559
|
+
|
|
2560
|
+
private startHubHeartbeat(): void {
|
|
2561
|
+
this.stopHubHeartbeat();
|
|
2562
|
+
const sendHeartbeat = async () => {
|
|
2563
|
+
try {
|
|
2564
|
+
const hub = this.resolveHubConnection();
|
|
2565
|
+
if (!hub) {
|
|
2566
|
+
const persisted = this.store.getClientHubConnection();
|
|
2567
|
+
if (persisted?.hubUrl && persisted?.userToken) {
|
|
2568
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
|
|
2569
|
+
}
|
|
2570
|
+
return;
|
|
2571
|
+
}
|
|
2572
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/heartbeat", { method: "POST" });
|
|
2573
|
+
} catch { /* best-effort */ }
|
|
2574
|
+
};
|
|
2575
|
+
sendHeartbeat();
|
|
2576
|
+
this.hubHeartbeatTimer = setInterval(sendHeartbeat, ViewerServer.HUB_HEARTBEAT_INTERVAL_MS);
|
|
2577
|
+
}
|
|
2578
|
+
|
|
2579
|
+
private stopHubHeartbeat(): void {
|
|
2580
|
+
if (this.hubHeartbeatTimer) { clearInterval(this.hubHeartbeatTimer); this.hubHeartbeatTimer = undefined; }
|
|
2581
|
+
}
|
|
2582
|
+
|
|
1916
2583
|
private getLocalIPs(): string[] {
|
|
1917
2584
|
const nets = os.networkInterfaces();
|
|
1918
2585
|
const ips: string[] = [];
|
|
@@ -1965,7 +2632,7 @@ export class ViewerServer {
|
|
|
1965
2632
|
}
|
|
1966
2633
|
|
|
1967
2634
|
private handleSaveConfig(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
1968
|
-
this.readBody(req, (body) => {
|
|
2635
|
+
this.readBody(req, async (body) => {
|
|
1969
2636
|
try {
|
|
1970
2637
|
const newCfg = JSON.parse(body);
|
|
1971
2638
|
const cfgPath = this.getOpenClawConfigPath();
|
|
@@ -1988,6 +2655,11 @@ export class ViewerServer {
|
|
|
1988
2655
|
if (!entry.config) entry.config = {};
|
|
1989
2656
|
const config = entry.config as Record<string, unknown>;
|
|
1990
2657
|
|
|
2658
|
+
const oldSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2659
|
+
const oldSharingRole = oldSharing?.role as string | undefined;
|
|
2660
|
+
const oldSharingEnabled = Boolean(oldSharing?.enabled);
|
|
2661
|
+
const oldClientHubAddress = String((oldSharing?.client as Record<string, unknown>)?.hubAddress || "");
|
|
2662
|
+
|
|
1991
2663
|
if (newCfg.embedding) config.embedding = newCfg.embedding;
|
|
1992
2664
|
if (newCfg.summarizer) config.summarizer = newCfg.summarizer;
|
|
1993
2665
|
if (newCfg.skillEvolution) config.skillEvolution = newCfg.skillEvolution;
|
|
@@ -2002,12 +2674,14 @@ export class ViewerServer {
|
|
|
2002
2674
|
if (merged.role === "client" && merged.client) {
|
|
2003
2675
|
const clientCfg = merged.client as Record<string, unknown>;
|
|
2004
2676
|
const addr = String(clientCfg.hubAddress || "");
|
|
2005
|
-
if (addr) {
|
|
2677
|
+
if (addr && oldSharingRole === "hub" && oldSharingEnabled) {
|
|
2678
|
+
const selfHubPort = (oldSharing?.hub as Record<string, unknown>)?.port ?? 18800;
|
|
2006
2679
|
const localIPs = this.getLocalIPs();
|
|
2007
2680
|
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
2008
2681
|
try {
|
|
2009
2682
|
const u = new URL(addr.startsWith("http") ? addr : `http://${addr}`);
|
|
2010
|
-
|
|
2683
|
+
const targetPort = u.port || (u.protocol === "https:" ? "443" : "80");
|
|
2684
|
+
if (localIPs.includes(u.hostname) && targetPort === String(selfHubPort)) {
|
|
2011
2685
|
res.writeHead(400, { "Content-Type": "application/json" });
|
|
2012
2686
|
res.end(JSON.stringify({ error: "cannot_join_self" }));
|
|
2013
2687
|
return;
|
|
@@ -2015,10 +2689,44 @@ export class ViewerServer {
|
|
|
2015
2689
|
} catch {}
|
|
2016
2690
|
}
|
|
2017
2691
|
}
|
|
2692
|
+
|
|
2693
|
+
const newRole = merged.role as string | undefined;
|
|
2694
|
+
const newEnabled = Boolean(merged.enabled);
|
|
2695
|
+
|
|
2696
|
+
// Detect disabling sharing or switching away from hub mode
|
|
2697
|
+
const wasHub = oldSharingEnabled && oldSharingRole === "hub";
|
|
2698
|
+
const isHub = newEnabled && newRole === "hub";
|
|
2699
|
+
if (wasHub && !isHub) {
|
|
2700
|
+
await this.notifyHubShutdown();
|
|
2701
|
+
this.stopHubHeartbeat();
|
|
2702
|
+
this.log.info("Hub shutting down: notified connected clients");
|
|
2703
|
+
}
|
|
2704
|
+
|
|
2705
|
+
// Detect disabling sharing or switching away from client mode
|
|
2706
|
+
const wasClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2707
|
+
const isClient = newEnabled && newRole === "client";
|
|
2708
|
+
if (wasClient && !isClient) {
|
|
2709
|
+
await this.withdrawOrLeaveHub();
|
|
2710
|
+
this.store.clearClientHubConnection();
|
|
2711
|
+
this.log.info("Client hub connection cleared (sharing disabled or role changed)");
|
|
2712
|
+
}
|
|
2713
|
+
|
|
2714
|
+
if (wasClient && isClient) {
|
|
2715
|
+
const newClientAddr = String((merged.client as Record<string, unknown>)?.hubAddress || "");
|
|
2716
|
+
if (newClientAddr && oldClientHubAddress && normalizeHubUrl(newClientAddr) !== normalizeHubUrl(oldClientHubAddress)) {
|
|
2717
|
+
this.notifyHubLeave();
|
|
2718
|
+
const oldConn = this.store.getClientHubConnection();
|
|
2719
|
+
if (oldConn) {
|
|
2720
|
+
this.store.setClientHubConnection({ ...oldConn, hubUrl: normalizeHubUrl(newClientAddr), userToken: "", lastKnownStatus: "hub_changed" });
|
|
2721
|
+
}
|
|
2722
|
+
this.log.info("Client hub connection token cleared (switched to different Hub), identity preserved");
|
|
2723
|
+
}
|
|
2724
|
+
}
|
|
2725
|
+
|
|
2018
2726
|
if (merged.role === "hub") {
|
|
2019
2727
|
merged.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2020
2728
|
} else if (merged.role === "client") {
|
|
2021
|
-
merged.hub = {
|
|
2729
|
+
merged.hub = { teamName: "", teamToken: "" };
|
|
2022
2730
|
}
|
|
2023
2731
|
config.sharing = merged;
|
|
2024
2732
|
}
|
|
@@ -2026,7 +2734,27 @@ export class ViewerServer {
|
|
|
2026
2734
|
fs.mkdirSync(path.dirname(cfgPath), { recursive: true });
|
|
2027
2735
|
fs.writeFileSync(cfgPath, JSON.stringify(raw, null, 2), "utf-8");
|
|
2028
2736
|
this.log.info("Plugin config updated via Viewer");
|
|
2029
|
-
this.
|
|
2737
|
+
this.stopHubHeartbeat();
|
|
2738
|
+
|
|
2739
|
+
// When switching to client mode or re-enabling sharing as client, send join request
|
|
2740
|
+
const finalSharing = config.sharing as Record<string, unknown> | undefined;
|
|
2741
|
+
const nowClient = Boolean(finalSharing?.enabled) && finalSharing?.role === "client";
|
|
2742
|
+
const previouslyClient = oldSharingEnabled && oldSharingRole === "client";
|
|
2743
|
+
let joinStatus: string | undefined;
|
|
2744
|
+
if (nowClient && !previouslyClient) {
|
|
2745
|
+
try {
|
|
2746
|
+
joinStatus = await this.autoJoinOnSave(finalSharing);
|
|
2747
|
+
} catch (e) {
|
|
2748
|
+
this.log.warn(`Auto-join on save failed: ${e}`);
|
|
2749
|
+
}
|
|
2750
|
+
}
|
|
2751
|
+
|
|
2752
|
+
this.jsonResponse(res, { ok: true, joinStatus, restart: true });
|
|
2753
|
+
|
|
2754
|
+
setTimeout(() => {
|
|
2755
|
+
this.log.info("config-save: triggering gateway restart via SIGUSR1...");
|
|
2756
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
2757
|
+
}, 500);
|
|
2030
2758
|
} catch (e) {
|
|
2031
2759
|
this.log.warn(`handleSaveConfig error: ${e}`);
|
|
2032
2760
|
res.writeHead(500, { "Content-Type": "application/json" });
|
|
@@ -2035,6 +2763,166 @@ export class ViewerServer {
|
|
|
2035
2763
|
});
|
|
2036
2764
|
}
|
|
2037
2765
|
|
|
2766
|
+
private async autoJoinOnSave(sharing: Record<string, unknown>): Promise<string | undefined> {
|
|
2767
|
+
const clientCfg = sharing.client as Record<string, unknown> | undefined;
|
|
2768
|
+
const hubAddress = String(clientCfg?.hubAddress || "");
|
|
2769
|
+
const teamToken = String(clientCfg?.teamToken || "");
|
|
2770
|
+
if (!hubAddress || !teamToken) return undefined;
|
|
2771
|
+
const hubUrl = normalizeHubUrl(hubAddress);
|
|
2772
|
+
const os = await import("os");
|
|
2773
|
+
const nickname = String(clientCfg?.nickname || "");
|
|
2774
|
+
const username = nickname || os.userInfo().username || "user";
|
|
2775
|
+
const hostname = os.hostname() || "unknown";
|
|
2776
|
+
const persisted = this.store.getClientHubConnection();
|
|
2777
|
+
const existingIdentityKey = persisted?.identityKey || "";
|
|
2778
|
+
const result = await hubRequestJson(hubUrl, "", "/api/v1/hub/join", {
|
|
2779
|
+
method: "POST",
|
|
2780
|
+
body: JSON.stringify({ teamToken, username, deviceName: hostname, identityKey: existingIdentityKey }),
|
|
2781
|
+
}) as any;
|
|
2782
|
+
const returnedIdentityKey = String(result.identityKey || existingIdentityKey || "");
|
|
2783
|
+
this.store.setClientHubConnection({
|
|
2784
|
+
hubUrl,
|
|
2785
|
+
userId: String(result.userId || ""),
|
|
2786
|
+
username,
|
|
2787
|
+
userToken: result.userToken || "",
|
|
2788
|
+
role: "member",
|
|
2789
|
+
connectedAt: Date.now(),
|
|
2790
|
+
identityKey: returnedIdentityKey,
|
|
2791
|
+
lastKnownStatus: result.status || "",
|
|
2792
|
+
});
|
|
2793
|
+
this.log.info(`Auto-join on save: status=${result.status}, userId=${result.userId}`);
|
|
2794
|
+
if (result.userToken) {
|
|
2795
|
+
this.startHubHeartbeat();
|
|
2796
|
+
}
|
|
2797
|
+
return result.status;
|
|
2798
|
+
}
|
|
2799
|
+
|
|
2800
|
+
private handleLeaveTeam(_req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2801
|
+
this.readBody(_req, async () => {
|
|
2802
|
+
try {
|
|
2803
|
+
await this.withdrawOrLeaveHub();
|
|
2804
|
+
this.store.clearClientHubConnection();
|
|
2805
|
+
|
|
2806
|
+
const configPath = this.getOpenClawConfigPath();
|
|
2807
|
+
if (configPath && fs.existsSync(configPath)) {
|
|
2808
|
+
const raw = JSON.parse(fs.readFileSync(configPath, "utf8"));
|
|
2809
|
+
const pluginKey = Object.keys(raw.plugins?.entries ?? {}).find(k => k.includes("memos-local"));
|
|
2810
|
+
if (pluginKey) {
|
|
2811
|
+
const cfg = raw.plugins.entries[pluginKey].config ?? {};
|
|
2812
|
+
if (cfg.sharing) {
|
|
2813
|
+
cfg.sharing.enabled = false;
|
|
2814
|
+
cfg.sharing.client = { hubAddress: "", userToken: "", teamToken: "" };
|
|
2815
|
+
}
|
|
2816
|
+
raw.plugins.entries[pluginKey].config = cfg;
|
|
2817
|
+
fs.writeFileSync(configPath, JSON.stringify(raw, null, 2) + "\n");
|
|
2818
|
+
this.log.info("handleLeaveTeam: config updated, sharing disabled");
|
|
2819
|
+
}
|
|
2820
|
+
}
|
|
2821
|
+
|
|
2822
|
+
this.jsonResponse(res, { ok: true, restart: true });
|
|
2823
|
+
|
|
2824
|
+
setTimeout(() => {
|
|
2825
|
+
this.log.info("handleLeaveTeam: triggering gateway restart via SIGUSR1...");
|
|
2826
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
2827
|
+
}, 500);
|
|
2828
|
+
} catch (e) {
|
|
2829
|
+
this.log.warn(`handleLeaveTeam error: ${e}`);
|
|
2830
|
+
this.jsonResponse(res, { ok: false, error: String(e) });
|
|
2831
|
+
}
|
|
2832
|
+
});
|
|
2833
|
+
}
|
|
2834
|
+
|
|
2835
|
+
private async notifyHubLeave(): Promise<void> {
|
|
2836
|
+
try {
|
|
2837
|
+
const hub = this.resolveHubConnection();
|
|
2838
|
+
if (hub) {
|
|
2839
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2840
|
+
this.log.info("Notified Hub of voluntary leave");
|
|
2841
|
+
return;
|
|
2842
|
+
}
|
|
2843
|
+
const persisted = this.store.getClientHubConnection();
|
|
2844
|
+
if (persisted?.hubUrl && persisted?.userToken) {
|
|
2845
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2846
|
+
this.log.info("Notified Hub of voluntary leave (persisted connection)");
|
|
2847
|
+
}
|
|
2848
|
+
} catch (e) {
|
|
2849
|
+
this.log.warn(`Failed to notify Hub of leave: ${e}`);
|
|
2850
|
+
}
|
|
2851
|
+
}
|
|
2852
|
+
|
|
2853
|
+
private async withdrawOrLeaveHub(): Promise<void> {
|
|
2854
|
+
try {
|
|
2855
|
+
const persisted = this.store.getClientHubConnection();
|
|
2856
|
+
const sharing = this.ctx?.config?.sharing;
|
|
2857
|
+
|
|
2858
|
+
if (persisted?.userToken && persisted?.hubUrl) {
|
|
2859
|
+
await hubRequestJson(persisted.hubUrl, persisted.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2860
|
+
this.log.info("Notified Hub of voluntary leave (had token)");
|
|
2861
|
+
return;
|
|
2862
|
+
}
|
|
2863
|
+
|
|
2864
|
+
const hub = this.resolveHubConnection();
|
|
2865
|
+
if (hub?.userToken) {
|
|
2866
|
+
await hubRequestJson(hub.hubUrl, hub.userToken, "/api/v1/hub/leave", { method: "POST" });
|
|
2867
|
+
this.log.info("Notified Hub of voluntary leave (resolved connection)");
|
|
2868
|
+
return;
|
|
2869
|
+
}
|
|
2870
|
+
|
|
2871
|
+
const hubUrl = persisted?.hubUrl || (sharing?.client?.hubAddress ? normalizeHubUrl(sharing.client.hubAddress) : null);
|
|
2872
|
+
const userId = persisted?.userId;
|
|
2873
|
+
const teamToken = sharing?.client?.teamToken;
|
|
2874
|
+
if (hubUrl && userId && teamToken) {
|
|
2875
|
+
const withdrawUrl = `${normalizeHubUrl(hubUrl)}/api/v1/hub/withdraw-pending`;
|
|
2876
|
+
await fetch(withdrawUrl, {
|
|
2877
|
+
method: "POST",
|
|
2878
|
+
headers: { "content-type": "application/json" },
|
|
2879
|
+
body: JSON.stringify({ teamToken, userId }),
|
|
2880
|
+
});
|
|
2881
|
+
this.log.info("Withdrew pending application from Hub");
|
|
2882
|
+
return;
|
|
2883
|
+
}
|
|
2884
|
+
|
|
2885
|
+
this.log.info("No hub connection to clean up (no token, no pending)");
|
|
2886
|
+
} catch (e) {
|
|
2887
|
+
this.log.warn(`Failed to withdraw/leave Hub: ${e}`);
|
|
2888
|
+
}
|
|
2889
|
+
}
|
|
2890
|
+
|
|
2891
|
+
private async notifyHubShutdown(): Promise<void> {
|
|
2892
|
+
try {
|
|
2893
|
+
const sharing = this.ctx?.config.sharing;
|
|
2894
|
+
if (!sharing || sharing.role !== "hub") return;
|
|
2895
|
+
const hubPort = this.getHubPort();
|
|
2896
|
+
const authPath = path.join(this.dataDir, "hub-auth.json");
|
|
2897
|
+
let adminToken: string | undefined;
|
|
2898
|
+
try {
|
|
2899
|
+
const authData = JSON.parse(fs.readFileSync(authPath, "utf8"));
|
|
2900
|
+
adminToken = authData?.bootstrapAdminToken;
|
|
2901
|
+
} catch { return; }
|
|
2902
|
+
if (!adminToken) return;
|
|
2903
|
+
|
|
2904
|
+
const users = this.store.listHubUsers("active");
|
|
2905
|
+
const { v4: uuidv4 } = require("uuid");
|
|
2906
|
+
for (const u of users) {
|
|
2907
|
+
try {
|
|
2908
|
+
this.store.insertHubNotification({
|
|
2909
|
+
id: uuidv4(),
|
|
2910
|
+
userId: u.id,
|
|
2911
|
+
type: "hub_shutdown",
|
|
2912
|
+
resource: "hub",
|
|
2913
|
+
title: "Hub is shutting down",
|
|
2914
|
+
message: "The Hub server is shutting down. You may be disconnected.",
|
|
2915
|
+
});
|
|
2916
|
+
} catch (e) {
|
|
2917
|
+
this.log.warn(`Failed to insert shutdown notification for user ${u.id}: ${e}`);
|
|
2918
|
+
}
|
|
2919
|
+
}
|
|
2920
|
+
this.log.info(`Hub shutdown: notified ${users.length} approved user(s)`);
|
|
2921
|
+
} catch (e) {
|
|
2922
|
+
this.log.warn(`notifyHubShutdown error: ${e}`);
|
|
2923
|
+
}
|
|
2924
|
+
}
|
|
2925
|
+
|
|
2038
2926
|
private handleUpdateUsername(req: http.IncomingMessage, res: http.ServerResponse): void {
|
|
2039
2927
|
this.readBody(req, async (body) => {
|
|
2040
2928
|
if (!this.ctx) return this.jsonResponse(res, { error: "sharing_unavailable" });
|
|
@@ -2062,10 +2950,10 @@ export class ViewerServer {
|
|
|
2062
2950
|
this.log.warn(`Failed to update hub-auth.json: ${e}`);
|
|
2063
2951
|
}
|
|
2064
2952
|
} else {
|
|
2065
|
-
const
|
|
2066
|
-
if (
|
|
2953
|
+
const persistedConn = this.store.getClientHubConnection();
|
|
2954
|
+
if (persistedConn) {
|
|
2067
2955
|
this.store.setClientHubConnection({
|
|
2068
|
-
...
|
|
2956
|
+
...persistedConn,
|
|
2069
2957
|
username: result.username,
|
|
2070
2958
|
userToken: result.userToken,
|
|
2071
2959
|
});
|
|
@@ -2089,12 +2977,17 @@ export class ViewerServer {
|
|
|
2089
2977
|
const { hubUrl } = JSON.parse(body);
|
|
2090
2978
|
if (!hubUrl) { this.jsonResponse(res, { ok: false, error: "hubUrl is required" }); return; }
|
|
2091
2979
|
try {
|
|
2092
|
-
const
|
|
2093
|
-
|
|
2094
|
-
|
|
2095
|
-
|
|
2096
|
-
|
|
2097
|
-
|
|
2980
|
+
const sharing = this.ctx?.config?.sharing;
|
|
2981
|
+
if (sharing?.enabled && sharing.role === "hub") {
|
|
2982
|
+
const selfHubPort = this.getHubPort();
|
|
2983
|
+
const localIPs = this.getLocalIPs();
|
|
2984
|
+
localIPs.push("127.0.0.1", "localhost", "0.0.0.0");
|
|
2985
|
+
const parsed = new URL(hubUrl.startsWith("http") ? hubUrl : `http://${hubUrl}`);
|
|
2986
|
+
const targetPort = parsed.port || (parsed.protocol === "https:" ? "443" : "80");
|
|
2987
|
+
if (localIPs.includes(parsed.hostname) && targetPort === String(selfHubPort)) {
|
|
2988
|
+
this.jsonResponse(res, { ok: false, error: "cannot_join_self" });
|
|
2989
|
+
return;
|
|
2990
|
+
}
|
|
2098
2991
|
}
|
|
2099
2992
|
} catch {}
|
|
2100
2993
|
const url = hubUrl.replace(/\/+$/, "") + "/api/v1/hub/info";
|
|
@@ -2332,10 +3225,9 @@ export class ViewerServer {
|
|
|
2332
3225
|
this.log.info(`update-install: success! Updated to ${newVersion}`);
|
|
2333
3226
|
this.jsonResponse(res, { ok: true, version: newVersion });
|
|
2334
3227
|
|
|
2335
|
-
// Trigger Gateway restart after response is sent
|
|
2336
3228
|
setTimeout(() => {
|
|
2337
|
-
this.log.info(`update-install: triggering gateway restart...`);
|
|
2338
|
-
process.kill(process.pid, "SIGUSR1");
|
|
3229
|
+
this.log.info(`update-install: triggering gateway restart via SIGUSR1...`);
|
|
3230
|
+
try { process.kill(process.pid, "SIGUSR1"); } catch (sig) { this.log.warn(`SIGUSR1 failed: ${sig}`); }
|
|
2339
3231
|
}, 500);
|
|
2340
3232
|
});
|
|
2341
3233
|
});
|
|
@@ -2488,7 +3380,7 @@ export class ViewerServer {
|
|
|
2488
3380
|
|
|
2489
3381
|
private getOpenClawHome(): string {
|
|
2490
3382
|
const home = process.env.HOME || process.env.USERPROFILE || "";
|
|
2491
|
-
return path.join(home, ".openclaw");
|
|
3383
|
+
return process.env.OPENCLAW_STATE_DIR || path.join(home, ".openclaw");
|
|
2492
3384
|
}
|
|
2493
3385
|
|
|
2494
3386
|
private handleCleanupPolluted(res: http.ServerResponse): void {
|
|
@@ -2517,7 +3409,7 @@ export class ViewerServer {
|
|
|
2517
3409
|
try {
|
|
2518
3410
|
const ocHome = this.getOpenClawHome();
|
|
2519
3411
|
const memoryDir = path.join(ocHome, "memory");
|
|
2520
|
-
const
|
|
3412
|
+
const agentsDir = path.join(ocHome, "agents");
|
|
2521
3413
|
|
|
2522
3414
|
const sqliteFiles: Array<{ file: string; chunks: number }> = [];
|
|
2523
3415
|
if (fs.existsSync(memoryDir)) {
|
|
@@ -2536,31 +3428,36 @@ export class ViewerServer {
|
|
|
2536
3428
|
|
|
2537
3429
|
let sessionCount = 0;
|
|
2538
3430
|
let messageCount = 0;
|
|
2539
|
-
if (fs.existsSync(
|
|
2540
|
-
const
|
|
2541
|
-
|
|
2542
|
-
|
|
2543
|
-
|
|
2544
|
-
|
|
2545
|
-
|
|
2546
|
-
|
|
2547
|
-
|
|
2548
|
-
|
|
2549
|
-
|
|
2550
|
-
|
|
2551
|
-
|
|
2552
|
-
|
|
2553
|
-
|
|
2554
|
-
|
|
2555
|
-
|
|
2556
|
-
|
|
2557
|
-
|
|
2558
|
-
|
|
3431
|
+
if (fs.existsSync(agentsDir)) {
|
|
3432
|
+
for (const entry of fs.readdirSync(agentsDir, { withFileTypes: true })) {
|
|
3433
|
+
if (!entry.isDirectory()) continue;
|
|
3434
|
+
const sessDir = path.join(agentsDir, entry.name, "sessions");
|
|
3435
|
+
if (!fs.existsSync(sessDir)) continue;
|
|
3436
|
+
const jsonlFiles = fs.readdirSync(sessDir).filter(f => f.includes(".jsonl"));
|
|
3437
|
+
sessionCount += jsonlFiles.length;
|
|
3438
|
+
for (const f of jsonlFiles) {
|
|
3439
|
+
try {
|
|
3440
|
+
const content = fs.readFileSync(path.join(sessDir, f), "utf-8");
|
|
3441
|
+
const lines = content.split("\n").filter(l => l.trim());
|
|
3442
|
+
for (const line of lines) {
|
|
3443
|
+
try {
|
|
3444
|
+
const obj = JSON.parse(line);
|
|
3445
|
+
if (obj.type === "message") {
|
|
3446
|
+
const role = obj.message?.role ?? obj.role;
|
|
3447
|
+
if (role === "user" || role === "assistant") {
|
|
3448
|
+
const mc = obj.message?.content ?? obj.content;
|
|
3449
|
+
let txt = "";
|
|
3450
|
+
if (typeof mc === "string") txt = mc;
|
|
3451
|
+
else if (Array.isArray(mc)) txt = mc.filter((p: any) => p.type === "text" && p.text).map((p: any) => p.text).join("\n");
|
|
3452
|
+
else txt = JSON.stringify(mc);
|
|
3453
|
+
if (role === "user") txt = stripInboundMetadata(txt);
|
|
3454
|
+
if (txt && txt.length >= 10) messageCount++;
|
|
3455
|
+
}
|
|
2559
3456
|
}
|
|
2560
|
-
}
|
|
2561
|
-
}
|
|
2562
|
-
}
|
|
2563
|
-
}
|
|
3457
|
+
} catch { /* skip bad lines */ }
|
|
3458
|
+
}
|
|
3459
|
+
} catch { /* skip unreadable */ }
|
|
3460
|
+
}
|
|
2564
3461
|
}
|
|
2565
3462
|
}
|
|
2566
3463
|
|