@oversk7/pi-web-dsh-style 1.3.3 → 1.3.4
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +8 -0
- package/package.json +1 -1
- package/server.ts +44 -4
- package/sse-channel.ts +37 -1
- package/web/app.js +89 -22
- package/web/session-sync.d.ts +12 -0
- package/web/session-sync.js +105 -0
package/README.md
CHANGED
|
@@ -40,6 +40,10 @@ pi --web
|
|
|
40
40
|
|
|
41
41
|
You can also enter `/web` in an existing Pi terminal. The page opens on your computer; the default local address is `http://127.0.0.1:18789`. To stop the service, run `/web --stop` or exit the host Pi process. Closing the browser tab alone does not stop it.
|
|
42
42
|
|
|
43
|
+
Each visible page subscribes to its selected session, receives an initial snapshot, and then receives only changes. Hidden tabs pause live updates and notifications; returning to the page synchronizes the latest state. Pi and its tools continue running while no page is viewing them.
|
|
44
|
+
|
|
45
|
+
Switching back to a newly created or previously loaded session shows its cached content immediately and keeps the composer available while refreshing. Repeated opens share pending requests, and sessions with a running process return their current snapshot without waiting for RPC refreshes.
|
|
46
|
+
|
|
43
47
|
The interface starts in Chinese. To switch to English, open **设置 (Settings) → 外观 (Appearance) → 界面语言 (Interface language) → English**. Your choice is saved in this browser.
|
|
44
48
|
|
|
45
49
|
### Phone and remote access
|
|
@@ -85,6 +89,10 @@ pi --web
|
|
|
85
89
|
|
|
86
90
|
也可以在已有的 Pi 终端中输入 `/web`。网页会在本机打开,默认地址为 `http://127.0.0.1:18789`。执行 `/web --stop` 或退出宿主 Pi 可停止服务;仅关闭浏览器标签页不会停止服务。
|
|
87
91
|
|
|
92
|
+
每个可见网页只订阅正在查看的会话,首次接收快照,随后只传输变化内容。隐藏标签页会暂停实时更新和通知,返回网页后同步最新状态。没有网页查看时,Pi 和工具仍会继续运行。
|
|
93
|
+
|
|
94
|
+
切回新建或已加载的会话时,立即显示缓存内容,后台刷新期间仍可编辑输入。重复打开会复用待完成请求;已有运行进程的会话直接返回当前快照,避免等待 RPC 刷新。
|
|
95
|
+
|
|
88
96
|
界面默认使用中文。打开 **设置 → 外观 → 界面语言**,即可一键切换中文或 English;选择会保存在当前浏览器。
|
|
89
97
|
|
|
90
98
|
### 手机与远程访问
|
package/package.json
CHANGED
package/server.ts
CHANGED
|
@@ -183,6 +183,7 @@ interface RuntimeSession {
|
|
|
183
183
|
streaming: boolean;
|
|
184
184
|
needsAttention: boolean;
|
|
185
185
|
starting?: Promise<void>;
|
|
186
|
+
openRefresh?: Promise<void>;
|
|
186
187
|
modelRefresh?: Promise<void>;
|
|
187
188
|
commandRefresh?: Promise<void>;
|
|
188
189
|
lastUsed: number;
|
|
@@ -620,7 +621,14 @@ function firstMessageTitle(session: SessionInfo): string {
|
|
|
620
621
|
// ---------------------------------------------------------------------------
|
|
621
622
|
|
|
622
623
|
function broadcastEvent(event: Json): void {
|
|
623
|
-
for (const client of [...sseClients])
|
|
624
|
+
for (const client of [...sseClients]) {
|
|
625
|
+
if (event.sessionId && client.sessionId !== event.sessionId) continue;
|
|
626
|
+
sendSseEvent(client, event);
|
|
627
|
+
}
|
|
628
|
+
}
|
|
629
|
+
|
|
630
|
+
function hasSessionSubscribers(sessionId: string): boolean {
|
|
631
|
+
return [...sseClients].some((client) => !client.closed && client.sessionId === sessionId);
|
|
624
632
|
}
|
|
625
633
|
|
|
626
634
|
function sessionSummary(record: RuntimeSession): Json {
|
|
@@ -684,12 +692,17 @@ function setPendingQueue(record: RuntimeSession, queue: RpcQueueSnapshot): void
|
|
|
684
692
|
const snapshotScheduler = createSnapshotScheduler<RuntimeSession>(emitSessionSnapshot);
|
|
685
693
|
|
|
686
694
|
function broadcastSessionSnapshot(record: RuntimeSession): void {
|
|
687
|
-
if (shuttingDown) return;
|
|
695
|
+
if (shuttingDown || !hasSessionSubscribers(record.id)) return;
|
|
688
696
|
snapshotScheduler.schedule(record.id, record, !record.streaming || record.dialogs.length > 0);
|
|
689
697
|
}
|
|
690
698
|
|
|
691
699
|
function emitSessionSnapshot(record: RuntimeSession): void {
|
|
692
|
-
|
|
700
|
+
if (shuttingDown || !hasSessionSubscribers(record.id)) return;
|
|
701
|
+
broadcastEvent(sessionSnapshot(record));
|
|
702
|
+
}
|
|
703
|
+
|
|
704
|
+
function sessionSnapshot(record: RuntimeSession): Json {
|
|
705
|
+
return {
|
|
693
706
|
type: "snapshot",
|
|
694
707
|
sessionId: record.id,
|
|
695
708
|
session: sessionSummary(record),
|
|
@@ -703,7 +716,7 @@ function emitSessionSnapshot(record: RuntimeSession): void {
|
|
|
703
716
|
messages: browserMessages(snapshotMessages(record)),
|
|
704
717
|
pendingQueue: pendingQueueSnapshot(record),
|
|
705
718
|
dialogs: record.dialogs,
|
|
706
|
-
}
|
|
719
|
+
};
|
|
707
720
|
}
|
|
708
721
|
|
|
709
722
|
function serializeWorkspaces(): Json[] {
|
|
@@ -1473,6 +1486,19 @@ async function ensureProcess(record: RuntimeSession): Promise<void> {
|
|
|
1473
1486
|
return task;
|
|
1474
1487
|
}
|
|
1475
1488
|
|
|
1489
|
+
function refreshOpenedSession(record: RuntimeSession): void {
|
|
1490
|
+
if (record.openRefresh || record.streaming || record.starting) return;
|
|
1491
|
+
record.openRefresh = (async () => {
|
|
1492
|
+
await Promise.allSettled([refreshState(record), refreshMessages(record)]);
|
|
1493
|
+
if (record.models.length === 0) await refreshModels(record).catch(() => {});
|
|
1494
|
+
applyCachedModelInfo(record);
|
|
1495
|
+
await refreshStats(record);
|
|
1496
|
+
broadcastSessionSnapshot(record);
|
|
1497
|
+
})().catch((error) => {
|
|
1498
|
+
console.error("[pi-web] open session refresh failed:", error);
|
|
1499
|
+
}).finally(() => { record.openRefresh = undefined; });
|
|
1500
|
+
}
|
|
1501
|
+
|
|
1476
1502
|
async function openSessionRecord(record: RuntimeSession): Promise<void> {
|
|
1477
1503
|
record.lastUsed = Date.now();
|
|
1478
1504
|
applyCachedCommands(record);
|
|
@@ -1535,6 +1561,14 @@ async function openSessionRecord(record: RuntimeSession): Promise<void> {
|
|
|
1535
1561
|
return;
|
|
1536
1562
|
}
|
|
1537
1563
|
|
|
1564
|
+
if (record.proc && record.state) {
|
|
1565
|
+
// Live events keep this snapshot current; browsing must not wait for RPC.
|
|
1566
|
+
applyCachedModelInfo(record);
|
|
1567
|
+
refreshOpenedSession(record);
|
|
1568
|
+
broadcastSessionSnapshot(record);
|
|
1569
|
+
return;
|
|
1570
|
+
}
|
|
1571
|
+
|
|
1538
1572
|
if (!record.proc) {
|
|
1539
1573
|
await ensureProcess(record);
|
|
1540
1574
|
} else {
|
|
@@ -2593,6 +2627,9 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
|
|
|
2593
2627
|
}
|
|
2594
2628
|
|
|
2595
2629
|
if (method === "GET" && path === "/api/events") {
|
|
2630
|
+
const sessionId = url.searchParams.get("sessionId") || undefined;
|
|
2631
|
+
const subscribedSession = sessionId ? sessions.get(sessionId) : undefined;
|
|
2632
|
+
if (sessionId && !subscribedSession) return sendJson(res, 404, { error: "session not found" });
|
|
2596
2633
|
res.writeHead(200, {
|
|
2597
2634
|
"content-type": "text/event-stream; charset=utf-8",
|
|
2598
2635
|
"cache-control": "no-store",
|
|
@@ -2604,10 +2641,13 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
|
|
|
2604
2641
|
const removeClient = () => {
|
|
2605
2642
|
closeSseClient(client);
|
|
2606
2643
|
sseClients.delete(client);
|
|
2644
|
+
if (sessionId && !hasSessionSubscribers(sessionId)) snapshotScheduler.cancel(sessionId);
|
|
2607
2645
|
};
|
|
2608
2646
|
client = createSseClient(res, removeClient);
|
|
2647
|
+
client.sessionId = sessionId;
|
|
2609
2648
|
sseClients.add(client);
|
|
2610
2649
|
req.on("close", removeClient);
|
|
2650
|
+
if (subscribedSession) sendSseEvent(client, sessionSnapshot(subscribedSession));
|
|
2611
2651
|
return true;
|
|
2612
2652
|
}
|
|
2613
2653
|
|
package/sse-channel.ts
CHANGED
|
@@ -1,3 +1,5 @@
|
|
|
1
|
+
import { canonicalizeJson, diffSessionSnapshots, type JsonValue } from "./web/session-sync.js";
|
|
2
|
+
|
|
1
3
|
export type SseEvent = Record<string, unknown>;
|
|
2
4
|
|
|
3
5
|
// Coalesce before serializing full histories, so a burst of tokens does not
|
|
@@ -44,6 +46,9 @@ export interface SseWritable {
|
|
|
44
46
|
|
|
45
47
|
export interface SseClient {
|
|
46
48
|
res: SseWritable;
|
|
49
|
+
sessionId?: string;
|
|
50
|
+
snapshotBaseline?: Record<string, JsonValue>;
|
|
51
|
+
snapshotRevision: number;
|
|
47
52
|
waitingForDrain: boolean;
|
|
48
53
|
pendingEvents: Map<string, SseEvent>;
|
|
49
54
|
closed: boolean;
|
|
@@ -59,6 +64,7 @@ function eventKey(event: SseEvent): string {
|
|
|
59
64
|
export function createSseClient(res: SseWritable, onError: () => void): SseClient {
|
|
60
65
|
return {
|
|
61
66
|
res,
|
|
67
|
+
snapshotRevision: 0,
|
|
62
68
|
waitingForDrain: false,
|
|
63
69
|
pendingEvents: new Map(),
|
|
64
70
|
closed: false,
|
|
@@ -68,7 +74,24 @@ export function createSseClient(res: SseWritable, onError: () => void): SseClien
|
|
|
68
74
|
|
|
69
75
|
function writeEvent(client: SseClient, event: SseEvent): boolean {
|
|
70
76
|
try {
|
|
71
|
-
|
|
77
|
+
let outgoing = event;
|
|
78
|
+
const snapshot = event.type === "snapshot" ? event as Record<string, JsonValue> : undefined;
|
|
79
|
+
if (snapshot) {
|
|
80
|
+
const revision = client.snapshotRevision + 1;
|
|
81
|
+
if (client.snapshotBaseline && client.snapshotBaseline.sessionId === snapshot.sessionId) {
|
|
82
|
+
const patches = diffSessionSnapshots(client.snapshotBaseline, snapshot);
|
|
83
|
+
if (patches.length === 0) return true;
|
|
84
|
+
outgoing = { type: "session_patch", sessionId: snapshot.sessionId, baseRevision: client.snapshotRevision, revision, patches };
|
|
85
|
+
} else {
|
|
86
|
+
outgoing = { ...snapshot, revision };
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
const accepted = client.res.write(`data: ${JSON.stringify(outgoing)}\n\n`);
|
|
90
|
+
// A false return still means Node accepted the bytes into its buffer.
|
|
91
|
+
if (snapshot) {
|
|
92
|
+
client.snapshotBaseline = snapshot;
|
|
93
|
+
client.snapshotRevision++;
|
|
94
|
+
}
|
|
72
95
|
if (accepted) return true;
|
|
73
96
|
client.waitingForDrain = true;
|
|
74
97
|
client.res.once("drain", () => flushPendingEvents(client));
|
|
@@ -93,7 +116,18 @@ function flushPendingEvents(client: SseClient): void {
|
|
|
93
116
|
|
|
94
117
|
export function sendSseEvent(client: SseClient, event: SseEvent): void {
|
|
95
118
|
if (client.closed) return;
|
|
119
|
+
if (event.type === "snapshot") {
|
|
120
|
+
try {
|
|
121
|
+
const { revision: _revision, ...body } = event;
|
|
122
|
+
event = canonicalizeJson(body) as Record<string, JsonValue>;
|
|
123
|
+
} catch {
|
|
124
|
+
closeSseClient(client);
|
|
125
|
+
client.onError();
|
|
126
|
+
return;
|
|
127
|
+
}
|
|
128
|
+
}
|
|
96
129
|
if (client.waitingForDrain) {
|
|
130
|
+
// Snapshot entries are detached full targets, diffed only when written.
|
|
97
131
|
// Keep only the newest event of each kind while the browser catches up.
|
|
98
132
|
// This bounds memory even when snapshots contain a long conversation.
|
|
99
133
|
client.pendingEvents.set(eventKey(event), event);
|
|
@@ -106,4 +140,6 @@ export function closeSseClient(client: SseClient): void {
|
|
|
106
140
|
client.closed = true;
|
|
107
141
|
client.waitingForDrain = false;
|
|
108
142
|
client.pendingEvents.clear();
|
|
143
|
+
client.snapshotBaseline = undefined;
|
|
144
|
+
client.snapshotRevision = 0;
|
|
109
145
|
}
|
package/web/app.js
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
|
|
3
3
|
import { markdown as renderMarkdown } from "./assets/markdown.js";
|
|
4
4
|
import { observeMarkdownDiagrams } from "./markdown-diagrams.js";
|
|
5
|
+
import { applySessionPatches } from "./session-sync.js";
|
|
5
6
|
|
|
6
7
|
observeMarkdownDiagrams();
|
|
7
8
|
import { webSlashCommands, unsupportedSlashCommands, parseSlashCommand } from "./slash-commands.js";
|
|
@@ -20,6 +21,8 @@ const S = {
|
|
|
20
21
|
currentWorkspaceId: null,
|
|
21
22
|
currentSessionId: null,
|
|
22
23
|
snapshots: new Map(),
|
|
24
|
+
loadedSessions: new Set(),
|
|
25
|
+
sessionOpens: new Map(),
|
|
23
26
|
newSessionSnapshot: null,
|
|
24
27
|
newSessionPreferenceWorkspaceId: null,
|
|
25
28
|
collapsed: new Set(),
|
|
@@ -108,8 +111,8 @@ async function api(path, options = {}) {
|
|
|
108
111
|
return data;
|
|
109
112
|
}
|
|
110
113
|
|
|
111
|
-
function post(path, body) {
|
|
112
|
-
return api(path, { method: "POST", body: JSON.stringify(body || {}) });
|
|
114
|
+
function post(path, body, options = {}) {
|
|
115
|
+
return api(path, { ...options, method: "POST", body: JSON.stringify(body || {}) });
|
|
113
116
|
}
|
|
114
117
|
|
|
115
118
|
function timeLabel(iso) {
|
|
@@ -1456,6 +1459,7 @@ function renderActive() {
|
|
|
1456
1459
|
}
|
|
1457
1460
|
|
|
1458
1461
|
function renderConversation() {
|
|
1462
|
+
syncEventSubscription();
|
|
1459
1463
|
const mount = $("#conversationMount");
|
|
1460
1464
|
if (!mount) return;
|
|
1461
1465
|
const previousInput = $("#composerInput");
|
|
@@ -3127,7 +3131,14 @@ async function openSession(id, opts = {}) {
|
|
|
3127
3131
|
if (!opts.skipRemember) rememberCurrentDraft();
|
|
3128
3132
|
const generation = ++S.openGeneration;
|
|
3129
3133
|
S.currentSessionId = id;
|
|
3130
|
-
|
|
3134
|
+
if (opts.initialSnapshot) {
|
|
3135
|
+
S.snapshots.set(id, opts.initialSnapshot);
|
|
3136
|
+
S.loadedSessions.add(id);
|
|
3137
|
+
}
|
|
3138
|
+
const before = S.snapshots.get(id);
|
|
3139
|
+
const cached = S.loadedSessions.has(id) && before?.session?.id === id && !before.error;
|
|
3140
|
+
S.loadingSession = cached ? null : id;
|
|
3141
|
+
if (before?.session?.workspaceId) S.currentWorkspaceId = before.session.workspaceId;
|
|
3131
3142
|
if (!opts.silent) {
|
|
3132
3143
|
const key = draftKey(id);
|
|
3133
3144
|
const savedDraft = S.drafts.get(key) || "";
|
|
@@ -3142,14 +3153,24 @@ async function openSession(id, opts = {}) {
|
|
|
3142
3153
|
}
|
|
3143
3154
|
renderSidebar();
|
|
3144
3155
|
renderConversation();
|
|
3156
|
+
let request = S.sessionOpens.get(id);
|
|
3145
3157
|
try {
|
|
3146
|
-
|
|
3147
|
-
|
|
3158
|
+
if (!opts.initialSnapshot && !request) {
|
|
3159
|
+
const promise = post(`/api/sessions/${encodeURIComponent(id)}/open`, {}, { signal: AbortSignal.timeout(15_000) })
|
|
3160
|
+
.finally(() => { S.sessionOpens.delete(id); });
|
|
3161
|
+
request = { promise, snapshot: before };
|
|
3162
|
+
S.sessionOpens.set(id, request);
|
|
3163
|
+
}
|
|
3164
|
+
const data = opts.initialSnapshot ?? await request.promise;
|
|
3165
|
+
if (S.snapshots.get(id) === (opts.initialSnapshot ? before : request.snapshot)) S.snapshots.set(id, data);
|
|
3166
|
+
S.loadedSessions.add(id);
|
|
3148
3167
|
if (generation !== S.openGeneration) return;
|
|
3149
3168
|
const ws = data.session?.workspaceId;
|
|
3150
3169
|
if (ws) S.currentWorkspaceId = ws;
|
|
3151
3170
|
} catch (error) {
|
|
3152
|
-
S.snapshots.
|
|
3171
|
+
if (!cached && S.snapshots.get(id) === (request ? request.snapshot : before)) {
|
|
3172
|
+
S.snapshots.set(id, { ...snapshotFor(id), session: { id, title: "会话", status: "error" }, error: String(error) });
|
|
3173
|
+
}
|
|
3153
3174
|
} finally {
|
|
3154
3175
|
if (generation === S.openGeneration) {
|
|
3155
3176
|
S.loadingSession = null;
|
|
@@ -4869,6 +4890,7 @@ async function onClick(ev) {
|
|
|
4869
4890
|
];
|
|
4870
4891
|
await Promise.all(orphanedAttachments.map((attachment) => deleteAttachment(attachment.id)));
|
|
4871
4892
|
S.snapshots.delete(sessionId);
|
|
4893
|
+
S.loadedSessions.delete(sessionId);
|
|
4872
4894
|
S.drafts.delete(sessionDraftKey);
|
|
4873
4895
|
S.failedDrafts.delete(sessionId);
|
|
4874
4896
|
S.attachmentDrafts.delete(sessionDraftKey);
|
|
@@ -5124,7 +5146,8 @@ let loginPromise;
|
|
|
5124
5146
|
|
|
5125
5147
|
function requireLogin() {
|
|
5126
5148
|
if (loginPromise) return loginPromise;
|
|
5127
|
-
|
|
5149
|
+
let authenticated = false;
|
|
5150
|
+
disconnectEvents();
|
|
5128
5151
|
loginPromise = (async () => {
|
|
5129
5152
|
let { passwordConfigured } = await api("/api/auth");
|
|
5130
5153
|
const dialog = el(`<div class="pi-loginBackdrop">
|
|
@@ -5163,6 +5186,7 @@ function requireLogin() {
|
|
|
5163
5186
|
input.value = "";
|
|
5164
5187
|
dialog.remove();
|
|
5165
5188
|
root.inert = false;
|
|
5189
|
+
authenticated = true;
|
|
5166
5190
|
resolve();
|
|
5167
5191
|
} catch (error) {
|
|
5168
5192
|
message.textContent = error.message || String(error);
|
|
@@ -5172,8 +5196,10 @@ function requireLogin() {
|
|
|
5172
5196
|
}
|
|
5173
5197
|
};
|
|
5174
5198
|
});
|
|
5175
|
-
|
|
5176
|
-
|
|
5199
|
+
})().finally(() => {
|
|
5200
|
+
loginPromise = null;
|
|
5201
|
+
if (authenticated && !document.hidden) connectEvents();
|
|
5202
|
+
});
|
|
5177
5203
|
return loginPromise;
|
|
5178
5204
|
}
|
|
5179
5205
|
|
|
@@ -5182,6 +5208,17 @@ let eventWatchdog;
|
|
|
5182
5208
|
let lastEventAt = 0;
|
|
5183
5209
|
let resyncPending;
|
|
5184
5210
|
|
|
5211
|
+
function disconnectEvents() {
|
|
5212
|
+
eventSource?.close();
|
|
5213
|
+
eventSource = null;
|
|
5214
|
+
clearInterval(eventWatchdog);
|
|
5215
|
+
}
|
|
5216
|
+
|
|
5217
|
+
function syncEventSubscription() {
|
|
5218
|
+
if (document.hidden || !eventSource || loginPromise) return;
|
|
5219
|
+
if (eventSource.sessionId !== (S.currentSessionId || null)) connectEvents(false);
|
|
5220
|
+
}
|
|
5221
|
+
|
|
5185
5222
|
async function resyncAfterReconnect() {
|
|
5186
5223
|
if (resyncPending) return resyncPending;
|
|
5187
5224
|
resyncPending = (async () => {
|
|
@@ -5200,7 +5237,7 @@ async function resyncAfterReconnect() {
|
|
|
5200
5237
|
const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
|
|
5201
5238
|
method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
|
|
5202
5239
|
});
|
|
5203
|
-
if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
5240
|
+
if (id === S.currentSessionId && generation === S.openGeneration && S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
5204
5241
|
} else if (id && generation === S.openGeneration) {
|
|
5205
5242
|
rememberCurrentDraft();
|
|
5206
5243
|
++S.openGeneration;
|
|
@@ -5215,21 +5252,31 @@ async function resyncAfterReconnect() {
|
|
|
5215
5252
|
return resyncPending;
|
|
5216
5253
|
}
|
|
5217
5254
|
|
|
5218
|
-
function connectEvents() {
|
|
5219
|
-
|
|
5220
|
-
|
|
5255
|
+
function connectEvents(resync = true) {
|
|
5256
|
+
disconnectEvents();
|
|
5257
|
+
if (document.hidden || loginPromise) return;
|
|
5258
|
+
const sessionId = S.currentSessionId || null;
|
|
5259
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
|
5260
|
+
const es = new EventSource(`/api/events${query}`);
|
|
5261
|
+
es.sessionId = sessionId;
|
|
5221
5262
|
eventSource = es;
|
|
5263
|
+
let streamedSnapshot;
|
|
5264
|
+
let streamRevision = 0;
|
|
5265
|
+
let opened = false;
|
|
5222
5266
|
lastEventAt = Date.now();
|
|
5223
|
-
clearInterval(eventWatchdog);
|
|
5224
5267
|
// Heartbeats arrive every 25s, including when the model is idle. Some mobile
|
|
5225
5268
|
// networks leave a dead stream open without notifying EventSource.
|
|
5226
5269
|
eventWatchdog = setInterval(() => {
|
|
5227
|
-
if (document.hidden || loginPromise
|
|
5270
|
+
if (eventSource !== es || document.hidden || loginPromise) return;
|
|
5228
5271
|
if (Date.now() - lastEventAt > 60_000) connectEvents();
|
|
5229
5272
|
}, 5_000);
|
|
5230
5273
|
es.onopen = () => {
|
|
5274
|
+
if (eventSource !== es) return;
|
|
5275
|
+
streamedSnapshot = undefined;
|
|
5276
|
+
streamRevision = 0;
|
|
5231
5277
|
lastEventAt = Date.now();
|
|
5232
|
-
void resyncAfterReconnect();
|
|
5278
|
+
if (resync || opened) void resyncAfterReconnect();
|
|
5279
|
+
opened = true;
|
|
5233
5280
|
};
|
|
5234
5281
|
es.onmessage = (ev) => {
|
|
5235
5282
|
if (eventSource !== es) return;
|
|
@@ -5265,9 +5312,26 @@ function connectEvents() {
|
|
|
5265
5312
|
}
|
|
5266
5313
|
return;
|
|
5267
5314
|
}
|
|
5268
|
-
if (event.type === "snapshot") {
|
|
5269
|
-
if (!event.sessionId) return;
|
|
5270
|
-
|
|
5315
|
+
if (event.type === "snapshot" || event.type === "session_patch") {
|
|
5316
|
+
if (!event.sessionId || event.sessionId !== es.sessionId || event.sessionId !== S.currentSessionId) return;
|
|
5317
|
+
if (event.type === "snapshot") {
|
|
5318
|
+
const { revision, ...snapshot } = event;
|
|
5319
|
+
streamedSnapshot = snapshot;
|
|
5320
|
+
streamRevision = revision;
|
|
5321
|
+
} else {
|
|
5322
|
+
if (!streamedSnapshot || event.baseRevision !== streamRevision || event.revision !== streamRevision + 1) {
|
|
5323
|
+
connectEvents(false);
|
|
5324
|
+
return;
|
|
5325
|
+
}
|
|
5326
|
+
try {
|
|
5327
|
+
streamedSnapshot = applySessionPatches(streamedSnapshot, event.patches);
|
|
5328
|
+
} catch {
|
|
5329
|
+
connectEvents(false);
|
|
5330
|
+
return;
|
|
5331
|
+
}
|
|
5332
|
+
streamRevision = event.revision;
|
|
5333
|
+
}
|
|
5334
|
+
S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), error: undefined, ...streamedSnapshot });
|
|
5271
5335
|
const current = event.sessionId === S.currentSessionId;
|
|
5272
5336
|
// Settings do not depend on session snapshots. Rebuilding the panel on
|
|
5273
5337
|
// every streamed chunk replaces controls between pointerdown and click.
|
|
@@ -5300,15 +5364,18 @@ function connectEvents() {
|
|
|
5300
5364
|
// ignore malformed event
|
|
5301
5365
|
}
|
|
5302
5366
|
};
|
|
5303
|
-
es.onerror = () => {
|
|
5367
|
+
es.onerror = () => {
|
|
5368
|
+
if (eventSource === es && !document.hidden) void resyncAfterReconnect();
|
|
5369
|
+
};
|
|
5304
5370
|
}
|
|
5305
5371
|
|
|
5306
5372
|
window.addEventListener("resize", () => { updateViewport(); applyFrameLayout(); });
|
|
5307
5373
|
window.visualViewport?.addEventListener("resize", updateViewport);
|
|
5308
5374
|
window.visualViewport?.addEventListener("scroll", updateViewport);
|
|
5309
|
-
window.addEventListener("online", () => { if (
|
|
5375
|
+
window.addEventListener("online", () => { if (!document.hidden && !loginPromise) connectEvents(); });
|
|
5310
5376
|
document.addEventListener("visibilitychange", () => {
|
|
5311
|
-
if (
|
|
5377
|
+
if (document.hidden) disconnectEvents();
|
|
5378
|
+
else if (!loginPromise) connectEvents();
|
|
5312
5379
|
});
|
|
5313
5380
|
setLanguage(getLanguage());
|
|
5314
5381
|
observeUi();
|
|
@@ -0,0 +1,12 @@
|
|
|
1
|
+
export type JsonValue = null | boolean | number | string | JsonValue[] | { [key: string]: JsonValue };
|
|
2
|
+
export type SessionPatch =
|
|
3
|
+
| { op: "set"; path: (string | number)[]; value: JsonValue }
|
|
4
|
+
| { op: "delete"; path: (string | number)[] }
|
|
5
|
+
| { op: "append"; path: (string | number)[]; value: string }
|
|
6
|
+
| { op: "truncate"; path: (string | number)[]; length: number };
|
|
7
|
+
/** Detached JSON normalization: undefined object fields disappear; array holes become null. */
|
|
8
|
+
export function canonicalizeJson(value: unknown): JsonValue | undefined;
|
|
9
|
+
/** Compare canonical JSON, emitting string suffixes and changes to individual paths. */
|
|
10
|
+
export function diffSessionSnapshots(previous: JsonValue, next: JsonValue): SessionPatch[];
|
|
11
|
+
/** Immutable application with structural sharing; invalid or unsafe paths throw. */
|
|
12
|
+
export function applySessionPatches<T extends JsonValue>(snapshot: T, patches: readonly SessionPatch[]): T;
|
|
@@ -0,0 +1,105 @@
|
|
|
1
|
+
// Paths contain object keys or array indices. Operations are set, delete (object
|
|
2
|
+
// field), append (string suffix), and truncate (array length). A root set resets
|
|
3
|
+
// structure. Paths traverse only own properties, including ordinary JSON keys
|
|
4
|
+
// such as __proto__; assignments cannot invoke prototype setters.
|
|
5
|
+
const own = (value, key) => Object.prototype.hasOwnProperty.call(value, key);
|
|
6
|
+
function setOwn(value, key, item) {
|
|
7
|
+
Object.defineProperty(value, key, { value: item, enumerable: true, writable: true, configurable: true });
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export function canonicalizeJson(value) {
|
|
11
|
+
const ancestors = new Set();
|
|
12
|
+
function visit(input, key = "") {
|
|
13
|
+
if (input && typeof input.toJSON === "function") input = input.toJSON(key);
|
|
14
|
+
if (input === null || typeof input === "string" || typeof input === "boolean") return input;
|
|
15
|
+
if (typeof input === "number") return Number.isFinite(input) ? input : null;
|
|
16
|
+
if (typeof input === "bigint") throw new TypeError("BigInt is not JSON data");
|
|
17
|
+
if (typeof input !== "object") return undefined;
|
|
18
|
+
if (ancestors.has(input)) throw new TypeError("Circular JSON data");
|
|
19
|
+
ancestors.add(input);
|
|
20
|
+
let result;
|
|
21
|
+
if (Array.isArray(input)) {
|
|
22
|
+
result = Array.from({ length: input.length }, (_, i) => visit(input[i], String(i)) ?? null);
|
|
23
|
+
} else {
|
|
24
|
+
result = {};
|
|
25
|
+
for (const key of Object.keys(input)) {
|
|
26
|
+
const item = visit(input[key], key);
|
|
27
|
+
if (item !== undefined) setOwn(result, key, item);
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
ancestors.delete(input);
|
|
31
|
+
return result;
|
|
32
|
+
}
|
|
33
|
+
return visit(value);
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
// Compare detached canonical JSON without serializing unchanged history strings.
|
|
37
|
+
export function diffSessionSnapshots(previous, next) {
|
|
38
|
+
const patches = [];
|
|
39
|
+
function walk(before, after, path) {
|
|
40
|
+
if (before === after) return;
|
|
41
|
+
if (typeof before === "string" && typeof after === "string" && after.startsWith(before)) {
|
|
42
|
+
patches.push({ op: "append", path, value: after.slice(before.length) });
|
|
43
|
+
} else if (Array.isArray(before) && Array.isArray(after)) {
|
|
44
|
+
const common = Math.min(before.length, after.length);
|
|
45
|
+
for (let i = 0; i < common; i++) walk(before[i], after[i], [...path, i]);
|
|
46
|
+
for (let i = common; i < after.length; i++) patches.push({ op: "set", path: [...path, i], value: after[i] });
|
|
47
|
+
if (after.length < before.length) patches.push({ op: "truncate", path, length: after.length });
|
|
48
|
+
} else if (before && after && typeof before === "object" && typeof after === "object" && !Array.isArray(before) && !Array.isArray(after)) {
|
|
49
|
+
for (const key of Object.keys(before)) {
|
|
50
|
+
if (!own(after, key)) patches.push({ op: "delete", path: [...path, key] });
|
|
51
|
+
}
|
|
52
|
+
for (const key of Object.keys(after)) {
|
|
53
|
+
if (own(before, key)) walk(before[key], after[key], [...path, key]);
|
|
54
|
+
else patches.push({ op: "set", path: [...path, key], value: after[key] });
|
|
55
|
+
}
|
|
56
|
+
} else {
|
|
57
|
+
patches.push({ op: "set", path, value: after });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
walk(previous, next, []);
|
|
61
|
+
return patches;
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
// Rebuild only modified paths; the input snapshot and unchanged branches retain
|
|
65
|
+
// their identity. Invalid operations throw before any input can be mutated.
|
|
66
|
+
export function applySessionPatches(snapshot, patches) {
|
|
67
|
+
if (!Array.isArray(patches)) throw new TypeError("Expected patches array");
|
|
68
|
+
let result = snapshot;
|
|
69
|
+
for (const patch of patches) {
|
|
70
|
+
if (!patch || !["set", "delete", "append", "truncate"].includes(patch.op) || !Array.isArray(patch.path)) throw new TypeError("Invalid patch");
|
|
71
|
+
for (const key of patch.path) {
|
|
72
|
+
if (typeof key !== "string" && (!Number.isSafeInteger(key) || key < 0)) throw new TypeError("Invalid patch path");
|
|
73
|
+
}
|
|
74
|
+
function update(current, depth) {
|
|
75
|
+
if (depth === patch.path.length) {
|
|
76
|
+
if (patch.op === "set") {
|
|
77
|
+
if (!own(patch, "value")) throw new TypeError("Missing patch value");
|
|
78
|
+
const value = canonicalizeJson(patch.value);
|
|
79
|
+
if (value === undefined) throw new TypeError("Invalid patch value");
|
|
80
|
+
return value;
|
|
81
|
+
}
|
|
82
|
+
if (patch.op === "append" && typeof current === "string" && typeof patch.value === "string") return current + patch.value;
|
|
83
|
+
if (patch.op === "truncate" && Array.isArray(current) && Number.isSafeInteger(patch.length) && patch.length >= 0 && patch.length <= current.length) return current.slice(0, patch.length);
|
|
84
|
+
throw new TypeError("Invalid patch target");
|
|
85
|
+
}
|
|
86
|
+
const key = patch.path[depth];
|
|
87
|
+
if (!current || typeof current !== "object") throw new TypeError("Invalid patch parent");
|
|
88
|
+
const array = Array.isArray(current);
|
|
89
|
+
if (array ? typeof key !== "number" || key > current.length : typeof key !== "string") throw new TypeError("Invalid patch key");
|
|
90
|
+
const leaf = depth === patch.path.length - 1;
|
|
91
|
+
const exists = own(current, key);
|
|
92
|
+
if (!exists && !(leaf && patch.op === "set")) throw new TypeError("Missing patch path");
|
|
93
|
+
const copy = array ? current.slice() : { ...current };
|
|
94
|
+
if (leaf && patch.op === "delete") {
|
|
95
|
+
if (array) throw new TypeError("Use truncate for arrays");
|
|
96
|
+
delete copy[key];
|
|
97
|
+
} else {
|
|
98
|
+
setOwn(copy, key, update(exists ? current[key] : undefined, depth + 1));
|
|
99
|
+
}
|
|
100
|
+
return copy;
|
|
101
|
+
}
|
|
102
|
+
result = update(result, 0);
|
|
103
|
+
}
|
|
104
|
+
return result;
|
|
105
|
+
}
|