@linxin666/dsh-remote-web-ui 0.3.5 → 0.3.6
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/lib/client.js +1 -1
- package/lib/mobile.js +47 -10
- package/lib/mobile.js.map +1 -1
- package/lib/types/mobile/messages.d.ts +13 -5
- package/lib/types/mobile/messages.d.ts.map +1 -1
- package/lib/types/mobile/messages.js +13 -5
- package/lib/types/mobile/views/ChatView.d.ts +7 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +53 -5
- package/lib/types/mobile/views/ChatView.stream.test.js +8 -3
- package/package.json +1 -1
- package/src/mobile/messages.ts +13 -5
- package/src/mobile/views/ChatView.stream.test.tsx +8 -3
- package/src/mobile/views/ChatView.tsx +52 -5
|
@@ -118,11 +118,19 @@ export declare function foldEvents(events: readonly WireEvent[], existing?: read
|
|
|
118
118
|
* Incremental folder for one message stream. Live chat folds one event at a
|
|
119
119
|
* time; rebuilding the five index maps by scanning every message per event
|
|
120
120
|
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
121
|
-
* indexes alive across folds, applies each event in O(1) map
|
|
122
|
-
* returns the previous snapshot identity unchanged when
|
|
123
|
-
* React skips the re-render entirely. Replayed events
|
|
124
|
-
* watermark advanced by the first application skips
|
|
125
|
-
* double-invoked React state updater harmless.
|
|
121
|
+
* indexes alive across folds, applies each event in (near-)O(1) map
|
|
122
|
+
* operations, and returns the previous snapshot identity unchanged when
|
|
123
|
+
* nothing applied, so React skips the re-render entirely. Replayed events
|
|
124
|
+
* are no-ops: the maxSeq watermark advanced by the first application skips
|
|
125
|
+
* them, which also makes a double-invoked React state updater harmless.
|
|
126
|
+
*
|
|
127
|
+
* Cost contract: every fold that applies at least one event rebuilds the
|
|
128
|
+
* snapshot as one fresh O(messages) array copy (React needs the new
|
|
129
|
+
* identity), and replacement paths locate rows by an O(messages) identity
|
|
130
|
+
* scan. Both are amortized by the consumer folding bursts as ONE batch on a
|
|
131
|
+
* fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
|
|
132
|
+
* id-to-index map was measured and deliberately rejected: at realistic
|
|
133
|
+
* message counts the Map maintenance cost exceeds the identity-scan cost.
|
|
126
134
|
*/
|
|
127
135
|
export declare class EventFolder {
|
|
128
136
|
private state;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED
|
|
1
|
+
{"version":3,"file":"messages.d.ts","sourceRoot":"","sources":["../../../src/mobile/messages.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;;GA2CG;AACH,MAAM,WAAW,aAAa;IAC5B,8EAA8E;IAC9E,QAAQ,CAAC,EAAE,EAAE,MAAM,CAAA;IACnB,QAAQ,CAAC,IAAI,EAAE,MAAM,GAAG,WAAW,CAAA;IACnC,6EAA6E;IAC7E,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB;;;OAGG;IACH,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;IAC3B;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE,YAAY,EAAE,CAAA;IAC/B,8EAA8E;IAC9E,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,oCAAoC;IACpC,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,kFAAkF;IAClF,QAAQ,CAAC,OAAO,CAAC,EAAE,OAAO,CAAA;IAC1B,sFAAsF;IACtF,QAAQ,CAAC,WAAW,CAAC,EAAE,MAAM,CAAA;IAC7B,kDAAkD;IAClD,QAAQ,CAAC,MAAM,CAAC,EAAE,OAAO,CAAA;IACzB;;;OAGG;IACH,QAAQ,CAAC,KAAK,CAAC,EAAE;QACf,WAAW,EAAE,MAAM,CAAA;QACnB,YAAY,EAAE,MAAM,CAAA;QACpB,eAAe,CAAC,EAAE,MAAM,CAAA;QACxB,gBAAgB,CAAC,EAAE,MAAM,CAAA;KAC1B,CAAA;IACD,sFAAsF;IACtF,QAAQ,CAAC,aAAa,CAAC,EAAE,MAAM,CAAA;IAC/B,gEAAgE;IAChE,QAAQ,CAAC,UAAU,CAAC,EAAE,MAAM,CAAA;CAC7B;AAED,+EAA+E;AAC/E,MAAM,WAAW,YAAY;IAC3B,0EAA0E;IAC1E,QAAQ,CAAC,MAAM,EAAE,MAAM,CAAA;IACvB,8BAA8B;IAC9B,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,qDAAqD;IACrD,QAAQ,CAAC,SAAS,CAAC,EAAE,MAAM,CAAA;CAC5B;AAED;;;;GAIG;AACH,MAAM,WAAW,SAAS;IACxB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,GAAG,EAAE,MAAM,CAAA;IACpB,QAAQ,CAAC,IAAI,EAAE,MAAM,CAAA;IACrB,QAAQ,CAAC,IAAI,EAAE,OAAO,CAAA;IACtB,QAAQ,CAAC,eAAe,CAAC,EAAE,MAAM,EAAE,CAAA;IACnC,QAAQ,CAAC,SAAS,CAAC,EAAE,OAAO,CAAA;IAC5B,QAAQ,CAAC,SAAS,CAAC,EAAE,IAAI,CAAA;CAC1B;AAyeD;;;;;;GAMG;AACH,wBAAgB,UAAU,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,EAAE,QAAQ,CAAC,EAAE,SAAS,aAAa,EAAE,GAAG,aAAa,EAAE,CAE7G;AAED;;;;;;;;;;;;;;;;;GAiBG;AACH,qBAAa,WAAW;IACtB,OAAO,CAAC,KAAK,CAAW;IACxB,OAAO,CAAC,YAAY,CAA6B;IAEjD,gFAAgF;gBACpE,OAAO,CAAC,EAAE,SAAS,aAAa,EAAE;IAI9C,uEAAuE;IACvE,IAAI,CAAC,MAAM,EAAE,SAAS,SAAS,EAAE,GAAG,aAAa,EAAE;IAanD,kEAAkE;IAClE,IAAI,CAAC,QAAQ,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,OAAO,CAAC,KAAK,EAAE,SAAS,aAAa,EAAE,GAAG,IAAI;IAK9C,uEAAuE;IACvE,QAAQ,IAAI,aAAa,EAAE;CAK5B"}
|
|
@@ -490,11 +490,19 @@ export function foldEvents(events, existing) {
|
|
|
490
490
|
* Incremental folder for one message stream. Live chat folds one event at a
|
|
491
491
|
* time; rebuilding the five index maps by scanning every message per event
|
|
492
492
|
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
493
|
-
* indexes alive across folds, applies each event in O(1) map
|
|
494
|
-
* returns the previous snapshot identity unchanged when
|
|
495
|
-
* React skips the re-render entirely. Replayed events
|
|
496
|
-
* watermark advanced by the first application skips
|
|
497
|
-
* double-invoked React state updater harmless.
|
|
493
|
+
* indexes alive across folds, applies each event in (near-)O(1) map
|
|
494
|
+
* operations, and returns the previous snapshot identity unchanged when
|
|
495
|
+
* nothing applied, so React skips the re-render entirely. Replayed events
|
|
496
|
+
* are no-ops: the maxSeq watermark advanced by the first application skips
|
|
497
|
+
* them, which also makes a double-invoked React state updater harmless.
|
|
498
|
+
*
|
|
499
|
+
* Cost contract: every fold that applies at least one event rebuilds the
|
|
500
|
+
* snapshot as one fresh O(messages) array copy (React needs the new
|
|
501
|
+
* identity), and replacement paths locate rows by an O(messages) identity
|
|
502
|
+
* scan. Both are amortized by the consumer folding bursts as ONE batch on a
|
|
503
|
+
* fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
|
|
504
|
+
* id-to-index map was measured and deliberately rejected: at realistic
|
|
505
|
+
* message counts the Map maintenance cost exceeds the identity-scan cost.
|
|
498
506
|
*/
|
|
499
507
|
export class EventFolder {
|
|
500
508
|
state;
|
|
@@ -25,6 +25,13 @@ export interface ChatViewProps {
|
|
|
25
25
|
* history tail re-pull closes the seam.
|
|
26
26
|
*/
|
|
27
27
|
export declare const MAX_TAIL_BUFFER_EVENTS = 500;
|
|
28
|
+
/**
|
|
29
|
+
* Cadence for folding buffered live events into the message list. Streaming
|
|
30
|
+
* chunks can arrive at a far higher rate; flushing on this cadence caps fold
|
|
31
|
+
* and re-render work per second without visible lag, and keeps working in
|
|
32
|
+
* background tabs where requestAnimationFrame is paused.
|
|
33
|
+
*/
|
|
34
|
+
export declare const FOLD_FLUSH_MS = 50;
|
|
28
35
|
/** One switchable permission preset (the `permissions` projection shape). */
|
|
29
36
|
export interface PermissionOption {
|
|
30
37
|
value: string;
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;
|
|
1
|
+
{"version":3,"file":"ChatView.d.ts","sourceRoot":"","sources":["../../../../src/mobile/views/ChatView.tsx"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAKH,OAAO,EAAuB,KAAK,WAAW,EAAE,MAAM,WAAW,CAAA;AAMjE,OAAO,EAAE,SAAS,EAAE,MAAM,WAAW,CAAA;AAGrC,+BAA+B;AAC/B,MAAM,WAAW,aAAa;IAC5B,OAAO,EAAE,WAAW,CAAA;IACpB,6EAA6E;IAC7E,GAAG,CAAC,EAAE,SAAS,GAAG,SAAS,CAAA;IAC3B,MAAM,IAAI,IAAI,CAAA;CACf;AAED;;;;GAIG;AACH,eAAO,MAAM,sBAAsB,MAAM,CAAA;AAEzC;;;;;GAKG;AACH,eAAO,MAAM,aAAa,KAAK,CAAA;AAqC/B,6EAA6E;AAC7E,MAAM,WAAW,gBAAgB;IAC/B,KAAK,EAAE,MAAM,CAAA;IACb,IAAI,EAAE,MAAM,CAAA;IACZ,WAAW,CAAC,EAAE,MAAM,CAAA;CACrB;AAED,iFAAiF;AACjF,MAAM,WAAW,qBAAqB;IACpC,OAAO,EAAE,gBAAgB,EAAE,CAAA;IAC3B,YAAY,EAAE,MAAM,CAAA;CACrB;AAqCD;;;;GAIG;AACH,wBAAgB,QAAQ,CAAC,EAAE,OAAO,EAAE,GAAG,EAAE,MAAM,EAAE,EAAE,aAAa,+BA2gB/D;AA2GD;;;;;;;;GAQG;AACH,eAAO,MAAM,yBAAyB,MAAM,CAAA;AA6F5C,eAAO,MAAM,eAAe,OAAO,CAAA"}
|
|
@@ -24,6 +24,13 @@ import { ThemeToggle } from "../theme-toggle.js";
|
|
|
24
24
|
* history tail re-pull closes the seam.
|
|
25
25
|
*/
|
|
26
26
|
export const MAX_TAIL_BUFFER_EVENTS = 500;
|
|
27
|
+
/**
|
|
28
|
+
* Cadence for folding buffered live events into the message list. Streaming
|
|
29
|
+
* chunks can arrive at a far higher rate; flushing on this cadence caps fold
|
|
30
|
+
* and re-render work per second without visible lag, and keeps working in
|
|
31
|
+
* background tabs where requestAnimationFrame is paused.
|
|
32
|
+
*/
|
|
33
|
+
export const FOLD_FLUSH_MS = 50;
|
|
27
34
|
/** localStorage key for the tool-call display toggle (persisted on the /m origin). */
|
|
28
35
|
const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls';
|
|
29
36
|
/** localStorage key for the injected-system-message display toggle. */
|
|
@@ -121,6 +128,14 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
121
128
|
const liveBufferRef = useRef([]);
|
|
122
129
|
/** Incremental folder for this session's stream (indexes stay hot across events). */
|
|
123
130
|
const folderRef = useRef(undefined);
|
|
131
|
+
/**
|
|
132
|
+
* Live events buffered between fold flushes. Folding every single event
|
|
133
|
+
* costs one O(messages) snapshot copy per event; flushing at a fixed
|
|
134
|
+
* cadence folds each burst once while keeping streaming text fluid.
|
|
135
|
+
*/
|
|
136
|
+
const foldBufferRef = useRef([]);
|
|
137
|
+
/** Timer id of the scheduled fold flush, if any. */
|
|
138
|
+
const foldFlushTimerRef = useRef(undefined);
|
|
124
139
|
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
125
140
|
const liveBufferOverflowRef = useRef(false);
|
|
126
141
|
/** The session's permission select (absent = capability not composed). */
|
|
@@ -238,7 +253,22 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
238
253
|
useEffect(() => {
|
|
239
254
|
if (mux === undefined)
|
|
240
255
|
return;
|
|
241
|
-
|
|
256
|
+
/** Fold every buffered live event in one batch and publish the snapshot. */
|
|
257
|
+
const flushFoldBuffer = () => {
|
|
258
|
+
if (foldFlushTimerRef.current !== undefined) {
|
|
259
|
+
clearTimeout(foldFlushTimerRef.current);
|
|
260
|
+
foldFlushTimerRef.current = undefined;
|
|
261
|
+
}
|
|
262
|
+
const events = foldBufferRef.current;
|
|
263
|
+
if (events.length === 0)
|
|
264
|
+
return;
|
|
265
|
+
foldBufferRef.current = [];
|
|
266
|
+
setMessages(previous => {
|
|
267
|
+
const folder = folderRef.current;
|
|
268
|
+
return folder === undefined ? foldEvents(events, previous) : folder.fold(events);
|
|
269
|
+
});
|
|
270
|
+
};
|
|
271
|
+
const unsubscribe = mux.onFrame((frame) => {
|
|
242
272
|
if (frame.type === 'session/event') {
|
|
243
273
|
if (frame.sessionId !== session.sessionId)
|
|
244
274
|
return;
|
|
@@ -264,10 +294,17 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
264
294
|
liveBufferRef.current.push(event);
|
|
265
295
|
return;
|
|
266
296
|
}
|
|
267
|
-
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
297
|
+
foldBufferRef.current.push(event);
|
|
298
|
+
// Only chunk streams batch on the flush cadence; every other event
|
|
299
|
+
// (final message, turn close, user echo, update/delete) flushes the
|
|
300
|
+
// buffer synchronously so settled UI states never wait for a timer.
|
|
301
|
+
const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk';
|
|
302
|
+
if (!isChunk) {
|
|
303
|
+
flushFoldBuffer();
|
|
304
|
+
}
|
|
305
|
+
else if (foldFlushTimerRef.current === undefined) {
|
|
306
|
+
foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS);
|
|
307
|
+
}
|
|
271
308
|
return;
|
|
272
309
|
}
|
|
273
310
|
// Live projection pushes keep the permission picker current.
|
|
@@ -307,6 +344,17 @@ export function ChatView({ session, mux, onBack }) {
|
|
|
307
344
|
return;
|
|
308
345
|
}
|
|
309
346
|
});
|
|
347
|
+
return () => {
|
|
348
|
+
unsubscribe();
|
|
349
|
+
// Drop the pending flush: its events belong to the session (or
|
|
350
|
+
// component instance) being torn down, and the next session reseeds
|
|
351
|
+
// its folder from a fresh history tail.
|
|
352
|
+
if (foldFlushTimerRef.current !== undefined) {
|
|
353
|
+
clearTimeout(foldFlushTimerRef.current);
|
|
354
|
+
foldFlushTimerRef.current = undefined;
|
|
355
|
+
}
|
|
356
|
+
foldBufferRef.current = [];
|
|
357
|
+
};
|
|
310
358
|
}, [mux, session.sessionId]);
|
|
311
359
|
// Weak-network polling fallback: when the assistant is running, poll for
|
|
312
360
|
// pending approvals/questions every 1.5 s so the phone can act even if the
|
|
@@ -9,7 +9,7 @@ import { jsx as _jsx } from "react/jsx-runtime";
|
|
|
9
9
|
*/
|
|
10
10
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
11
11
|
import { act, cleanup, render, screen } from '@testing-library/react';
|
|
12
|
-
import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from "./ChatView.js";
|
|
12
|
+
import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from "./ChatView.js";
|
|
13
13
|
import { renderMarkdown } from "../markdown.js";
|
|
14
14
|
// The api module is fully mocked; App.tsx's history wrapper is overridden to
|
|
15
15
|
// feed a fixed empty history page, its pure helpers stay real.
|
|
@@ -134,8 +134,13 @@ describe('ChatView streaming markdown throttle', () => {
|
|
|
134
134
|
'第三段 ~~删除线~~ 和 [链接](https://example.com/a)。',
|
|
135
135
|
];
|
|
136
136
|
const full = parts.join('');
|
|
137
|
-
// The first chunk mounts the pending message
|
|
138
|
-
|
|
137
|
+
// The first chunk mounts the pending message on the next fold flush
|
|
138
|
+
// (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
|
|
139
|
+
// render parses it.
|
|
140
|
+
await act(async () => {
|
|
141
|
+
chunk(mux, parts[0] ?? '', 6);
|
|
142
|
+
vi.advanceTimersByTime(FOLD_FLUSH_MS + 5);
|
|
143
|
+
});
|
|
139
144
|
const afterMount = renderMarkdownMock.mock.calls.length;
|
|
140
145
|
expect(afterMount).toBeGreaterThanOrEqual(1);
|
|
141
146
|
// A burst of further chunks does not re-parse per chunk: at most one
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Scan-to-pair mobile and PC remote control for the dsh web GUI: QR entry beside the settings button, one-time pairing tokens, a gated /remote/api desktop channel, live device status, and revocable sessions",
|
|
4
|
-
"version": "0.3.
|
|
4
|
+
"version": "0.3.6",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
package/src/mobile/messages.ts
CHANGED
|
@@ -611,11 +611,19 @@ export function foldEvents(events: readonly WireEvent[], existing?: readonly Ren
|
|
|
611
611
|
* Incremental folder for one message stream. Live chat folds one event at a
|
|
612
612
|
* time; rebuilding the five index maps by scanning every message per event
|
|
613
613
|
* made that path O(n) per event (O(n * events) per turn). A folder keeps the
|
|
614
|
-
* indexes alive across folds, applies each event in O(1) map
|
|
615
|
-
* returns the previous snapshot identity unchanged when
|
|
616
|
-
* React skips the re-render entirely. Replayed events
|
|
617
|
-
* watermark advanced by the first application skips
|
|
618
|
-
* double-invoked React state updater harmless.
|
|
614
|
+
* indexes alive across folds, applies each event in (near-)O(1) map
|
|
615
|
+
* operations, and returns the previous snapshot identity unchanged when
|
|
616
|
+
* nothing applied, so React skips the re-render entirely. Replayed events
|
|
617
|
+
* are no-ops: the maxSeq watermark advanced by the first application skips
|
|
618
|
+
* them, which also makes a double-invoked React state updater harmless.
|
|
619
|
+
*
|
|
620
|
+
* Cost contract: every fold that applies at least one event rebuilds the
|
|
621
|
+
* snapshot as one fresh O(messages) array copy (React needs the new
|
|
622
|
+
* identity), and replacement paths locate rows by an O(messages) identity
|
|
623
|
+
* scan. Both are amortized by the consumer folding bursts as ONE batch on a
|
|
624
|
+
* fixed cadence (see FOLD_FLUSH_MS in ChatView) instead of per event. An
|
|
625
|
+
* id-to-index map was measured and deliberately rejected: at realistic
|
|
626
|
+
* message counts the Map maintenance cost exceeds the identity-scan cost.
|
|
619
627
|
*/
|
|
620
628
|
export class EventFolder {
|
|
621
629
|
private state: FoldState
|
|
@@ -8,7 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
10
10
|
import { act, cleanup, render, screen } from '@testing-library/react'
|
|
11
|
-
import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT } from './ChatView.tsx'
|
|
11
|
+
import { ChatView, STREAM_RENDER_INTERVAL_MS, LONG_TEXT_LIMIT, FOLD_FLUSH_MS } from './ChatView.tsx'
|
|
12
12
|
import { type SessionView } from './App.tsx'
|
|
13
13
|
import { renderMarkdown } from '../markdown.ts'
|
|
14
14
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
@@ -150,8 +150,13 @@ describe('ChatView streaming markdown throttle', () => {
|
|
|
150
150
|
]
|
|
151
151
|
const full = parts.join('')
|
|
152
152
|
|
|
153
|
-
// The first chunk mounts the pending message
|
|
154
|
-
|
|
153
|
+
// The first chunk mounts the pending message on the next fold flush
|
|
154
|
+
// (chunk streams fold in batches on the FOLD_FLUSH_MS cadence); the mount
|
|
155
|
+
// render parses it.
|
|
156
|
+
await act(async () => {
|
|
157
|
+
chunk(mux, parts[0] ?? '', 6)
|
|
158
|
+
vi.advanceTimersByTime(FOLD_FLUSH_MS + 5)
|
|
159
|
+
})
|
|
155
160
|
const afterMount = renderMarkdownMock.mock.calls.length
|
|
156
161
|
expect(afterMount).toBeGreaterThanOrEqual(1)
|
|
157
162
|
|
|
@@ -38,6 +38,14 @@ export interface ChatViewProps {
|
|
|
38
38
|
*/
|
|
39
39
|
export const MAX_TAIL_BUFFER_EVENTS = 500
|
|
40
40
|
|
|
41
|
+
/**
|
|
42
|
+
* Cadence for folding buffered live events into the message list. Streaming
|
|
43
|
+
* chunks can arrive at a far higher rate; flushing on this cadence caps fold
|
|
44
|
+
* and re-render work per second without visible lag, and keeps working in
|
|
45
|
+
* background tabs where requestAnimationFrame is paused.
|
|
46
|
+
*/
|
|
47
|
+
export const FOLD_FLUSH_MS = 50
|
|
48
|
+
|
|
41
49
|
/** localStorage key for the tool-call display toggle (persisted on the /m origin). */
|
|
42
50
|
const SHOW_TOOL_CALLS_KEY = 'dsh.mobile.showToolCalls'
|
|
43
51
|
/** localStorage key for the injected-system-message display toggle. */
|
|
@@ -147,6 +155,14 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
147
155
|
const liveBufferRef = useRef<WireEvent[]>([])
|
|
148
156
|
/** Incremental folder for this session's stream (indexes stay hot across events). */
|
|
149
157
|
const folderRef = useRef<EventFolder | undefined>(undefined)
|
|
158
|
+
/**
|
|
159
|
+
* Live events buffered between fold flushes. Folding every single event
|
|
160
|
+
* costs one O(messages) snapshot copy per event; flushing at a fixed
|
|
161
|
+
* cadence folds each burst once while keeping streaming text fluid.
|
|
162
|
+
*/
|
|
163
|
+
const foldBufferRef = useRef<WireEvent[]>([])
|
|
164
|
+
/** Timer id of the scheduled fold flush, if any. */
|
|
165
|
+
const foldFlushTimerRef = useRef<ReturnType<typeof setTimeout> | undefined>(undefined)
|
|
150
166
|
/** True once the live buffer hit its cap (oldest events were dropped). */
|
|
151
167
|
const liveBufferOverflowRef = useRef(false)
|
|
152
168
|
|
|
@@ -273,7 +289,21 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
273
289
|
// Live frames: fold session events for this session in as they arrive.
|
|
274
290
|
useEffect(() => {
|
|
275
291
|
if (mux === undefined) return
|
|
276
|
-
|
|
292
|
+
/** Fold every buffered live event in one batch and publish the snapshot. */
|
|
293
|
+
const flushFoldBuffer = (): void => {
|
|
294
|
+
if (foldFlushTimerRef.current !== undefined) {
|
|
295
|
+
clearTimeout(foldFlushTimerRef.current)
|
|
296
|
+
foldFlushTimerRef.current = undefined
|
|
297
|
+
}
|
|
298
|
+
const events = foldBufferRef.current
|
|
299
|
+
if (events.length === 0) return
|
|
300
|
+
foldBufferRef.current = []
|
|
301
|
+
setMessages(previous => {
|
|
302
|
+
const folder = folderRef.current
|
|
303
|
+
return folder === undefined ? foldEvents(events, previous) : folder.fold(events)
|
|
304
|
+
})
|
|
305
|
+
}
|
|
306
|
+
const unsubscribe = mux.onFrame((frame: MuxFrame) => {
|
|
277
307
|
if (frame.type === 'session/event') {
|
|
278
308
|
if (frame.sessionId !== session.sessionId) return
|
|
279
309
|
const event = frame.event as WireEvent
|
|
@@ -298,10 +328,16 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
298
328
|
liveBufferRef.current.push(event)
|
|
299
329
|
return
|
|
300
330
|
}
|
|
301
|
-
|
|
302
|
-
|
|
303
|
-
|
|
304
|
-
|
|
331
|
+
foldBufferRef.current.push(event)
|
|
332
|
+
// Only chunk streams batch on the flush cadence; every other event
|
|
333
|
+
// (final message, turn close, user echo, update/delete) flushes the
|
|
334
|
+
// buffer synchronously so settled UI states never wait for a timer.
|
|
335
|
+
const isChunk = event.type === 'assistant/chunk' || event.type === 'message/chunk'
|
|
336
|
+
if (!isChunk) {
|
|
337
|
+
flushFoldBuffer()
|
|
338
|
+
} else if (foldFlushTimerRef.current === undefined) {
|
|
339
|
+
foldFlushTimerRef.current = setTimeout(flushFoldBuffer, FOLD_FLUSH_MS)
|
|
340
|
+
}
|
|
305
341
|
return
|
|
306
342
|
}
|
|
307
343
|
// Live projection pushes keep the permission picker current.
|
|
@@ -342,6 +378,17 @@ export function ChatView({ session, mux, onBack }: ChatViewProps) {
|
|
|
342
378
|
return
|
|
343
379
|
}
|
|
344
380
|
})
|
|
381
|
+
return () => {
|
|
382
|
+
unsubscribe()
|
|
383
|
+
// Drop the pending flush: its events belong to the session (or
|
|
384
|
+
// component instance) being torn down, and the next session reseeds
|
|
385
|
+
// its folder from a fresh history tail.
|
|
386
|
+
if (foldFlushTimerRef.current !== undefined) {
|
|
387
|
+
clearTimeout(foldFlushTimerRef.current)
|
|
388
|
+
foldFlushTimerRef.current = undefined
|
|
389
|
+
}
|
|
390
|
+
foldBufferRef.current = []
|
|
391
|
+
}
|
|
345
392
|
}, [mux, session.sessionId])
|
|
346
393
|
|
|
347
394
|
// Weak-network polling fallback: when the assistant is running, poll for
|