@oversk7/pi-web-dsh-style 1.3.3 → 1.4.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +12 -0
- package/package.json +1 -1
- package/server.ts +49 -19
- package/sse-channel.ts +37 -1
- package/web/app.js +145 -32
- package/web/session-sync.d.ts +12 -0
- package/web/session-sync.js +105 -0
package/README.md
CHANGED
|
@@ -40,6 +40,12 @@ 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
|
+
|
|
47
|
+
In the in-app workspace directory browser, click the pencil to paste a full path. The list loads the corresponding directory and filters matching folders, including deeply nested paths. Click a folder to select it, or press Enter to navigate directly; a trailing separator previews the folder's contents. Relative file links in replies resolve against the selected session's workspace.
|
|
48
|
+
|
|
43
49
|
The interface starts in Chinese. To switch to English, open **设置 (Settings) → 外观 (Appearance) → 界面语言 (Interface language) → English**. Your choice is saved in this browser.
|
|
44
50
|
|
|
45
51
|
### Phone and remote access
|
|
@@ -85,6 +91,12 @@ pi --web
|
|
|
85
91
|
|
|
86
92
|
也可以在已有的 Pi 终端中输入 `/web`。网页会在本机打开,默认地址为 `http://127.0.0.1:18789`。执行 `/web --stop` 或退出宿主 Pi 可停止服务;仅关闭浏览器标签页不会停止服务。
|
|
87
93
|
|
|
94
|
+
每个可见网页只订阅正在查看的会话,首次接收快照,随后只传输变化内容。隐藏标签页会暂停实时更新和通知,返回网页后同步最新状态。没有网页查看时,Pi 和工具仍会继续运行。
|
|
95
|
+
|
|
96
|
+
切回新建或已加载的会话时,立即显示缓存内容,后台刷新期间仍可编辑输入。重复打开会复用待完成请求;已有运行进程的会话直接返回当前快照,避免等待 RPC 刷新。
|
|
97
|
+
|
|
98
|
+
在内置工作区目录浏览器中,点击铅笔图标即可粘贴完整路径,列表会加载对应目录并筛选匹配的文件夹,支持多层嵌套路径。点击文件夹选择,或按 Enter 直接跳转;路径末尾带分隔符时预览该文件夹的内容。回答中的相对文件链接按当前会话的工作区解析。
|
|
99
|
+
|
|
88
100
|
界面默认使用中文。打开 **设置 → 外观 → 界面语言**,即可一键切换中文或 English;选择会保存在当前浏览器。
|
|
89
101
|
|
|
90
102
|
### 手机与远程访问
|
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 {
|
|
@@ -1909,21 +1943,11 @@ async function listDirectories(input: string): Promise<Json> {
|
|
|
1909
1943
|
.map((e) => ({ name: e.name, path: join(target, e.name), hidden: e.name.startsWith(".") }))
|
|
1910
1944
|
.sort((a, b) => a.name.localeCompare(b.name, "zh-CN"));
|
|
1911
1945
|
const crumbs: { name: string; path: string; hidden: boolean }[] = [];
|
|
1912
|
-
{
|
|
1913
|
-
const
|
|
1914
|
-
|
|
1915
|
-
|
|
1916
|
-
|
|
1917
|
-
const up = dirname(current);
|
|
1918
|
-
if (up === current) break;
|
|
1919
|
-
current = up;
|
|
1920
|
-
}
|
|
1921
|
-
let acc = parts[0];
|
|
1922
|
-
crumbs.push({ name: parts[0], path: parts[0], hidden: false });
|
|
1923
|
-
for (const part of parts.slice(1)) {
|
|
1924
|
-
acc = join(acc, part);
|
|
1925
|
-
crumbs.push({ name: part, path: acc, hidden: part.startsWith(".") });
|
|
1926
|
-
}
|
|
1946
|
+
for (let current = target; ; current = dirname(current)) {
|
|
1947
|
+
const isRoot = dirname(current) === current;
|
|
1948
|
+
const name = isRoot ? current : basename(current);
|
|
1949
|
+
crumbs.unshift({ name, path: current, hidden: !isRoot && name.startsWith(".") });
|
|
1950
|
+
if (isRoot) break;
|
|
1927
1951
|
}
|
|
1928
1952
|
const parent = dirname(target) === target ? null : dirname(target);
|
|
1929
1953
|
return { path: target, home, parent, crumbs, entries: dirs, truncated: false };
|
|
@@ -2593,6 +2617,9 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
|
|
|
2593
2617
|
}
|
|
2594
2618
|
|
|
2595
2619
|
if (method === "GET" && path === "/api/events") {
|
|
2620
|
+
const sessionId = url.searchParams.get("sessionId") || undefined;
|
|
2621
|
+
const subscribedSession = sessionId ? sessions.get(sessionId) : undefined;
|
|
2622
|
+
if (sessionId && !subscribedSession) return sendJson(res, 404, { error: "session not found" });
|
|
2596
2623
|
res.writeHead(200, {
|
|
2597
2624
|
"content-type": "text/event-stream; charset=utf-8",
|
|
2598
2625
|
"cache-control": "no-store",
|
|
@@ -2604,10 +2631,13 @@ async function handleApi(req: import("node:http").IncomingMessage, res: ServerRe
|
|
|
2604
2631
|
const removeClient = () => {
|
|
2605
2632
|
closeSseClient(client);
|
|
2606
2633
|
sseClients.delete(client);
|
|
2634
|
+
if (sessionId && !hasSessionSubscribers(sessionId)) snapshotScheduler.cancel(sessionId);
|
|
2607
2635
|
};
|
|
2608
2636
|
client = createSseClient(res, removeClient);
|
|
2637
|
+
client.sessionId = sessionId;
|
|
2609
2638
|
sseClients.add(client);
|
|
2610
2639
|
req.on("close", removeClient);
|
|
2640
|
+
if (subscribedSession) sendSseEvent(client, sessionSnapshot(subscribedSession));
|
|
2611
2641
|
return true;
|
|
2612
2642
|
}
|
|
2613
2643
|
|
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;
|
|
@@ -3257,6 +3278,7 @@ async function ensureWorkspaceThen(action) {
|
|
|
3257
3278
|
let browser = null;
|
|
3258
3279
|
let pickerResolve = null;
|
|
3259
3280
|
let browserKeyHandler = null;
|
|
3281
|
+
let directoryRequest = null;
|
|
3260
3282
|
|
|
3261
3283
|
async function adoptWorkspacePath(path) {
|
|
3262
3284
|
const added = await post("/api/workspaces", { path });
|
|
@@ -3333,6 +3355,7 @@ function promptWorkspaceBrowse() {
|
|
|
3333
3355
|
}
|
|
3334
3356
|
|
|
3335
3357
|
function closeWorkspacePicker() {
|
|
3358
|
+
directoryRequest = null;
|
|
3336
3359
|
if (browserKeyHandler) {
|
|
3337
3360
|
document.removeEventListener("keydown", browserKeyHandler);
|
|
3338
3361
|
browserKeyHandler = null;
|
|
@@ -3366,10 +3389,44 @@ function draftDirectory(draft) {
|
|
|
3366
3389
|
function readDraft(listing, draft) {
|
|
3367
3390
|
const directory = draftDirectory(draft);
|
|
3368
3391
|
if (directory === null) return { directory: null, tail: null };
|
|
3369
|
-
const
|
|
3392
|
+
const fold = (value) => separatorOf() === "\\" ? value.replaceAll("/", "\\").toLowerCase() : value;
|
|
3393
|
+
const answers = fold(directory) === fold(levelDirectory(listing));
|
|
3370
3394
|
return { directory, tail: answers ? draft.slice(directory.length) : null };
|
|
3371
3395
|
}
|
|
3372
3396
|
|
|
3397
|
+
async function previewPathDraft(draft) {
|
|
3398
|
+
const current = browser;
|
|
3399
|
+
const request = {};
|
|
3400
|
+
directoryRequest = request;
|
|
3401
|
+
if (!current || current.pathDraft !== draft) return;
|
|
3402
|
+
const listing = current.child ?? current.parent;
|
|
3403
|
+
const directory = draftDirectory(draft);
|
|
3404
|
+
current.error = null;
|
|
3405
|
+
if (directory === null || (listing && readDraft(listing, draft).tail !== null)) {
|
|
3406
|
+
current.loading = false;
|
|
3407
|
+
renderBrowser();
|
|
3408
|
+
return;
|
|
3409
|
+
}
|
|
3410
|
+
current.loading = true;
|
|
3411
|
+
renderBrowser();
|
|
3412
|
+
try {
|
|
3413
|
+
const data = await api(`/api/fs/list?path=${encodeURIComponent(directory)}`);
|
|
3414
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3415
|
+
if (data.error) throw new Error(data.error);
|
|
3416
|
+
current.parent = data;
|
|
3417
|
+
current.selected = null;
|
|
3418
|
+
current.child = null;
|
|
3419
|
+
current.home = data.home || current.home;
|
|
3420
|
+
current.loading = false;
|
|
3421
|
+
renderBrowser();
|
|
3422
|
+
} catch (error) {
|
|
3423
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3424
|
+
current.loading = false;
|
|
3425
|
+
current.error = error.message || String(error);
|
|
3426
|
+
renderBrowser();
|
|
3427
|
+
}
|
|
3428
|
+
}
|
|
3429
|
+
|
|
3373
3430
|
function visibleEntries(entries, selectedPath, showHidden, filterPrefix) {
|
|
3374
3431
|
const needle = filterPrefix === null ? "" : filterPrefix.toLowerCase();
|
|
3375
3432
|
const displayable = (entry) => showHidden || !entry.hidden || needle.startsWith(".");
|
|
@@ -3408,6 +3465,9 @@ function browserTargetPath() {
|
|
|
3408
3465
|
|
|
3409
3466
|
async function navigate(path) {
|
|
3410
3467
|
if (!browser || !path) return;
|
|
3468
|
+
const current = browser;
|
|
3469
|
+
const request = {};
|
|
3470
|
+
directoryRequest = request;
|
|
3411
3471
|
browser.loading = true;
|
|
3412
3472
|
browser.error = null;
|
|
3413
3473
|
browser.selected = null;
|
|
@@ -3416,7 +3476,7 @@ async function navigate(path) {
|
|
|
3416
3476
|
let target = null;
|
|
3417
3477
|
try {
|
|
3418
3478
|
const data = await api(`/api/fs/list?path=${encodeURIComponent(path)}`);
|
|
3419
|
-
if (
|
|
3479
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3420
3480
|
if (data.error) throw new Error(data.error);
|
|
3421
3481
|
target = data;
|
|
3422
3482
|
browser.home = data.home || browser.home;
|
|
@@ -3425,7 +3485,7 @@ async function navigate(path) {
|
|
|
3425
3485
|
browser.loading = false;
|
|
3426
3486
|
renderBrowser();
|
|
3427
3487
|
} catch (error) {
|
|
3428
|
-
if (
|
|
3488
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3429
3489
|
browser.loading = false;
|
|
3430
3490
|
browser.error = error.message || String(error);
|
|
3431
3491
|
renderBrowser();
|
|
@@ -3438,7 +3498,7 @@ async function navigate(path) {
|
|
|
3438
3498
|
if (!parentCrumb) return;
|
|
3439
3499
|
try {
|
|
3440
3500
|
const data = await api(`/api/fs/list?path=${encodeURIComponent(parentCrumb.path)}`);
|
|
3441
|
-
if (
|
|
3501
|
+
if (browser !== current || directoryRequest !== request || data.error) return;
|
|
3442
3502
|
const sep = separatorOf();
|
|
3443
3503
|
const fold = (value) => (sep === "\\" ? value.toLowerCase() : value);
|
|
3444
3504
|
const match = (data.entries || []).find((entry) => fold(entry.path) === fold(target.path));
|
|
@@ -3454,6 +3514,10 @@ async function navigate(path) {
|
|
|
3454
3514
|
|
|
3455
3515
|
async function selectEntry(entry) {
|
|
3456
3516
|
if (!browser) return;
|
|
3517
|
+
const current = browser;
|
|
3518
|
+
const request = {};
|
|
3519
|
+
directoryRequest = request;
|
|
3520
|
+
browser.pathDraft = null;
|
|
3457
3521
|
browser.selected = entry;
|
|
3458
3522
|
browser.child = null;
|
|
3459
3523
|
browser.loading = true;
|
|
@@ -3461,13 +3525,13 @@ async function selectEntry(entry) {
|
|
|
3461
3525
|
renderBrowser();
|
|
3462
3526
|
try {
|
|
3463
3527
|
const data = await api(`/api/fs/list?path=${encodeURIComponent(entry.path)}`);
|
|
3464
|
-
if (
|
|
3528
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3465
3529
|
if (data.error) throw new Error(data.error);
|
|
3466
3530
|
browser.child = data;
|
|
3467
3531
|
browser.loading = false;
|
|
3468
3532
|
renderBrowser();
|
|
3469
3533
|
} catch (error) {
|
|
3470
|
-
if (
|
|
3534
|
+
if (browser !== current || directoryRequest !== request) return;
|
|
3471
3535
|
browser.loading = false;
|
|
3472
3536
|
browser.error = error.message || String(error);
|
|
3473
3537
|
browser.selected = null;
|
|
@@ -3492,6 +3556,8 @@ function openPathEditor() {
|
|
|
3492
3556
|
|
|
3493
3557
|
function cancelPathEdit() {
|
|
3494
3558
|
if (!browser) return;
|
|
3559
|
+
directoryRequest = null;
|
|
3560
|
+
browser.loading = false;
|
|
3495
3561
|
browser.pathDraft = null;
|
|
3496
3562
|
browser.error = null;
|
|
3497
3563
|
renderBrowser();
|
|
@@ -3607,6 +3673,8 @@ function renderBrowser() {
|
|
|
3607
3673
|
</div>
|
|
3608
3674
|
</div>` : "";
|
|
3609
3675
|
|
|
3676
|
+
const previousPathInput = $("#dbPathInput");
|
|
3677
|
+
const pathSelection = previousPathInput ? [previousPathInput.selectionStart, previousPathInput.selectionEnd] : null;
|
|
3610
3678
|
mount.innerHTML = `
|
|
3611
3679
|
<div class="db-modal">
|
|
3612
3680
|
<div class="db-modalMask" data-action="db-cancel"></div>
|
|
@@ -3633,10 +3701,9 @@ function renderBrowser() {
|
|
|
3633
3701
|
|
|
3634
3702
|
const pathInput = $("#dbPathInput");
|
|
3635
3703
|
if (pathInput) {
|
|
3636
|
-
const start = pathInput.selectionStart;
|
|
3637
|
-
const end = pathInput.selectionEnd;
|
|
3704
|
+
const [start, end] = pathSelection ?? [pathInput.selectionStart, pathInput.selectionEnd];
|
|
3638
3705
|
pathInput.focus();
|
|
3639
|
-
if (start != null && end != null
|
|
3706
|
+
if (start != null && end != null) pathInput.setSelectionRange(start, end);
|
|
3640
3707
|
pathInput.addEventListener("keydown", (event) => {
|
|
3641
3708
|
if (event.key === "Enter" && !event.isComposing) {
|
|
3642
3709
|
event.preventDefault();
|
|
@@ -3651,7 +3718,7 @@ function renderBrowser() {
|
|
|
3651
3718
|
pathInput.addEventListener("input", () => {
|
|
3652
3719
|
const caret = pathInput.selectionStart ?? pathInput.value.length;
|
|
3653
3720
|
b.pathDraft = pathInput.value;
|
|
3654
|
-
|
|
3721
|
+
void previewPathDraft(b.pathDraft);
|
|
3655
3722
|
const next = $("#dbPathInput");
|
|
3656
3723
|
if (next) next.setSelectionRange(caret, caret);
|
|
3657
3724
|
});
|
|
@@ -4869,6 +4936,7 @@ async function onClick(ev) {
|
|
|
4869
4936
|
];
|
|
4870
4937
|
await Promise.all(orphanedAttachments.map((attachment) => deleteAttachment(attachment.id)));
|
|
4871
4938
|
S.snapshots.delete(sessionId);
|
|
4939
|
+
S.loadedSessions.delete(sessionId);
|
|
4872
4940
|
S.drafts.delete(sessionDraftKey);
|
|
4873
4941
|
S.failedDrafts.delete(sessionId);
|
|
4874
4942
|
S.attachmentDrafts.delete(sessionDraftKey);
|
|
@@ -5124,7 +5192,8 @@ let loginPromise;
|
|
|
5124
5192
|
|
|
5125
5193
|
function requireLogin() {
|
|
5126
5194
|
if (loginPromise) return loginPromise;
|
|
5127
|
-
|
|
5195
|
+
let authenticated = false;
|
|
5196
|
+
disconnectEvents();
|
|
5128
5197
|
loginPromise = (async () => {
|
|
5129
5198
|
let { passwordConfigured } = await api("/api/auth");
|
|
5130
5199
|
const dialog = el(`<div class="pi-loginBackdrop">
|
|
@@ -5163,6 +5232,7 @@ function requireLogin() {
|
|
|
5163
5232
|
input.value = "";
|
|
5164
5233
|
dialog.remove();
|
|
5165
5234
|
root.inert = false;
|
|
5235
|
+
authenticated = true;
|
|
5166
5236
|
resolve();
|
|
5167
5237
|
} catch (error) {
|
|
5168
5238
|
message.textContent = error.message || String(error);
|
|
@@ -5172,8 +5242,10 @@ function requireLogin() {
|
|
|
5172
5242
|
}
|
|
5173
5243
|
};
|
|
5174
5244
|
});
|
|
5175
|
-
|
|
5176
|
-
|
|
5245
|
+
})().finally(() => {
|
|
5246
|
+
loginPromise = null;
|
|
5247
|
+
if (authenticated && !document.hidden) connectEvents();
|
|
5248
|
+
});
|
|
5177
5249
|
return loginPromise;
|
|
5178
5250
|
}
|
|
5179
5251
|
|
|
@@ -5182,6 +5254,17 @@ let eventWatchdog;
|
|
|
5182
5254
|
let lastEventAt = 0;
|
|
5183
5255
|
let resyncPending;
|
|
5184
5256
|
|
|
5257
|
+
function disconnectEvents() {
|
|
5258
|
+
eventSource?.close();
|
|
5259
|
+
eventSource = null;
|
|
5260
|
+
clearInterval(eventWatchdog);
|
|
5261
|
+
}
|
|
5262
|
+
|
|
5263
|
+
function syncEventSubscription() {
|
|
5264
|
+
if (document.hidden || !eventSource || loginPromise) return;
|
|
5265
|
+
if (eventSource.sessionId !== (S.currentSessionId || null)) connectEvents(false);
|
|
5266
|
+
}
|
|
5267
|
+
|
|
5185
5268
|
async function resyncAfterReconnect() {
|
|
5186
5269
|
if (resyncPending) return resyncPending;
|
|
5187
5270
|
resyncPending = (async () => {
|
|
@@ -5200,7 +5283,7 @@ async function resyncAfterReconnect() {
|
|
|
5200
5283
|
const data = await api(`/api/sessions/${encodeURIComponent(id)}/open`, {
|
|
5201
5284
|
method: "POST", body: "{}", signal: AbortSignal.timeout(15_000),
|
|
5202
5285
|
});
|
|
5203
|
-
if (S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
5286
|
+
if (id === S.currentSessionId && generation === S.openGeneration && S.snapshots.get(id) === before) S.snapshots.set(id, data);
|
|
5204
5287
|
} else if (id && generation === S.openGeneration) {
|
|
5205
5288
|
rememberCurrentDraft();
|
|
5206
5289
|
++S.openGeneration;
|
|
@@ -5215,21 +5298,31 @@ async function resyncAfterReconnect() {
|
|
|
5215
5298
|
return resyncPending;
|
|
5216
5299
|
}
|
|
5217
5300
|
|
|
5218
|
-
function connectEvents() {
|
|
5219
|
-
|
|
5220
|
-
|
|
5301
|
+
function connectEvents(resync = true) {
|
|
5302
|
+
disconnectEvents();
|
|
5303
|
+
if (document.hidden || loginPromise) return;
|
|
5304
|
+
const sessionId = S.currentSessionId || null;
|
|
5305
|
+
const query = sessionId ? `?sessionId=${encodeURIComponent(sessionId)}` : "";
|
|
5306
|
+
const es = new EventSource(`/api/events${query}`);
|
|
5307
|
+
es.sessionId = sessionId;
|
|
5221
5308
|
eventSource = es;
|
|
5309
|
+
let streamedSnapshot;
|
|
5310
|
+
let streamRevision = 0;
|
|
5311
|
+
let opened = false;
|
|
5222
5312
|
lastEventAt = Date.now();
|
|
5223
|
-
clearInterval(eventWatchdog);
|
|
5224
5313
|
// Heartbeats arrive every 25s, including when the model is idle. Some mobile
|
|
5225
5314
|
// networks leave a dead stream open without notifying EventSource.
|
|
5226
5315
|
eventWatchdog = setInterval(() => {
|
|
5227
|
-
if (document.hidden || loginPromise
|
|
5316
|
+
if (eventSource !== es || document.hidden || loginPromise) return;
|
|
5228
5317
|
if (Date.now() - lastEventAt > 60_000) connectEvents();
|
|
5229
5318
|
}, 5_000);
|
|
5230
5319
|
es.onopen = () => {
|
|
5320
|
+
if (eventSource !== es) return;
|
|
5321
|
+
streamedSnapshot = undefined;
|
|
5322
|
+
streamRevision = 0;
|
|
5231
5323
|
lastEventAt = Date.now();
|
|
5232
|
-
void resyncAfterReconnect();
|
|
5324
|
+
if (resync || opened) void resyncAfterReconnect();
|
|
5325
|
+
opened = true;
|
|
5233
5326
|
};
|
|
5234
5327
|
es.onmessage = (ev) => {
|
|
5235
5328
|
if (eventSource !== es) return;
|
|
@@ -5265,9 +5358,26 @@ function connectEvents() {
|
|
|
5265
5358
|
}
|
|
5266
5359
|
return;
|
|
5267
5360
|
}
|
|
5268
|
-
if (event.type === "snapshot") {
|
|
5269
|
-
if (!event.sessionId) return;
|
|
5270
|
-
|
|
5361
|
+
if (event.type === "snapshot" || event.type === "session_patch") {
|
|
5362
|
+
if (!event.sessionId || event.sessionId !== es.sessionId || event.sessionId !== S.currentSessionId) return;
|
|
5363
|
+
if (event.type === "snapshot") {
|
|
5364
|
+
const { revision, ...snapshot } = event;
|
|
5365
|
+
streamedSnapshot = snapshot;
|
|
5366
|
+
streamRevision = revision;
|
|
5367
|
+
} else {
|
|
5368
|
+
if (!streamedSnapshot || event.baseRevision !== streamRevision || event.revision !== streamRevision + 1) {
|
|
5369
|
+
connectEvents(false);
|
|
5370
|
+
return;
|
|
5371
|
+
}
|
|
5372
|
+
try {
|
|
5373
|
+
streamedSnapshot = applySessionPatches(streamedSnapshot, event.patches);
|
|
5374
|
+
} catch {
|
|
5375
|
+
connectEvents(false);
|
|
5376
|
+
return;
|
|
5377
|
+
}
|
|
5378
|
+
streamRevision = event.revision;
|
|
5379
|
+
}
|
|
5380
|
+
S.snapshots.set(event.sessionId, { ...snapshotFor(event.sessionId), error: undefined, ...streamedSnapshot });
|
|
5271
5381
|
const current = event.sessionId === S.currentSessionId;
|
|
5272
5382
|
// Settings do not depend on session snapshots. Rebuilding the panel on
|
|
5273
5383
|
// every streamed chunk replaces controls between pointerdown and click.
|
|
@@ -5300,15 +5410,18 @@ function connectEvents() {
|
|
|
5300
5410
|
// ignore malformed event
|
|
5301
5411
|
}
|
|
5302
5412
|
};
|
|
5303
|
-
es.onerror = () => {
|
|
5413
|
+
es.onerror = () => {
|
|
5414
|
+
if (eventSource === es && !document.hidden) void resyncAfterReconnect();
|
|
5415
|
+
};
|
|
5304
5416
|
}
|
|
5305
5417
|
|
|
5306
5418
|
window.addEventListener("resize", () => { updateViewport(); applyFrameLayout(); });
|
|
5307
5419
|
window.visualViewport?.addEventListener("resize", updateViewport);
|
|
5308
5420
|
window.visualViewport?.addEventListener("scroll", updateViewport);
|
|
5309
|
-
window.addEventListener("online", () => { if (
|
|
5421
|
+
window.addEventListener("online", () => { if (!document.hidden && !loginPromise) connectEvents(); });
|
|
5310
5422
|
document.addEventListener("visibilitychange", () => {
|
|
5311
|
-
if (
|
|
5423
|
+
if (document.hidden) disconnectEvents();
|
|
5424
|
+
else if (!loginPromise) connectEvents();
|
|
5312
5425
|
});
|
|
5313
5426
|
setLanguage(getLanguage());
|
|
5314
5427
|
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
|
+
}
|