@linxin666/dsh-remote-web-ui 0.1.3 → 0.1.7
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 +14 -5
- package/lib/client.js +70 -70
- package/lib/mobile.js +160 -32
- package/lib/mobile.js.map +1 -1
- package/lib/types/mobile/mux.d.ts +66 -2
- package/lib/types/mobile/mux.d.ts.map +1 -1
- package/lib/types/mobile/mux.js +155 -8
- package/lib/types/mobile/mux.test.d.ts +2 -0
- package/lib/types/mobile/mux.test.d.ts.map +1 -0
- package/lib/types/mobile/mux.test.js +141 -0
- package/lib/types/mobile/views/App.d.ts.map +1 -1
- package/lib/types/mobile/views/App.js +6 -0
- package/lib/types/mobile/views/ChatView.d.ts.map +1 -1
- package/lib/types/mobile/views/ChatView.js +20 -6
- package/lib/types/mobile/views/ChatView.test.js +92 -2
- package/package.json +1 -1
- package/src/mobile/mux.test.ts +172 -0
- package/src/mobile/mux.ts +170 -8
- package/src/mobile/views/App.tsx +7 -0
- package/src/mobile/views/ChatView.test.tsx +98 -3
- package/src/mobile/views/ChatView.tsx +20 -6
- package/lib/types/client/PairFailedNotice.js.map +0 -1
- package/lib/types/client/PhoneIcon.js.map +0 -1
- package/lib/types/client/PluginSettingsCard.js.map +0 -1
- package/lib/types/client/RemoteEntry.js.map +0 -1
- package/lib/types/client/RemotePanel.js.map +0 -1
- package/lib/types/client/RemoteSettingsCard.js.map +0 -1
- package/lib/types/client/deep-link.js.map +0 -1
- package/lib/types/client/index.js.map +0 -1
- package/lib/types/client/locales.js.map +0 -1
- package/lib/types/client/pair-api.js.map +0 -1
- package/lib/types/client/settings-form.js.map +0 -1
- package/lib/types/gate.js.map +0 -1
- package/lib/types/index.js.map +0 -1
- package/lib/types/invariant.js.map +0 -1
- package/lib/types/lan.js.map +0 -1
- package/lib/types/mobile/api.js.map +0 -1
- package/lib/types/mobile/index.js.map +0 -1
- package/lib/types/mobile/messages.js.map +0 -1
- package/lib/types/mobile/messages.test.js.map +0 -1
- package/lib/types/mobile/mobile-styles.js.map +0 -1
- package/lib/types/mobile/mux.js.map +0 -1
- package/lib/types/mobile/rpc.js.map +0 -1
- package/lib/types/mobile/views/App.js.map +0 -1
- package/lib/types/mobile/views/ChatView.js.map +0 -1
- package/lib/types/mobile/views/SessionListView.js.map +0 -1
- package/lib/types/mobile/views/WorkspaceView.js.map +0 -1
- package/lib/types/mobile-api.js.map +0 -1
- package/lib/types/mobile-routes.js.map +0 -1
- package/lib/types/pairing.js.map +0 -1
- package/lib/types/routes.js.map +0 -1
- package/lib/types/tunnel.js.map +0 -1
|
@@ -1,8 +1,8 @@
|
|
|
1
1
|
import { jsx as _jsx } from "react/jsx-runtime";
|
|
2
2
|
// @vitest-environment jsdom
|
|
3
3
|
/** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
|
|
4
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
4
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest';
|
|
5
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react';
|
|
6
6
|
import { ChatView } from "./ChatView.js";
|
|
7
7
|
// The api module is fully mocked; App.tsx's history wrapper is overridden to
|
|
8
8
|
// feed fixed history pages, its pure helpers (errorText / formatTime) stay real.
|
|
@@ -213,3 +213,93 @@ describe('ChatView model sheet', () => {
|
|
|
213
213
|
expect(await screen.findByText(/重启 dsh web/)).toBeTruthy();
|
|
214
214
|
});
|
|
215
215
|
});
|
|
216
|
+
describe('ChatView scrolling', () => {
|
|
217
|
+
// Controllable scrollHeight + a write log for the chat-scroll element. The
|
|
218
|
+
// accessors live on Element.prototype, so patching them here lets every
|
|
219
|
+
// scrollToBottom assignment drive a deterministic assertion regardless of
|
|
220
|
+
// when the effect runs relative to mount.
|
|
221
|
+
let scrollHeightMock = 0;
|
|
222
|
+
let scrollWrites = [];
|
|
223
|
+
const origScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight');
|
|
224
|
+
const origScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop');
|
|
225
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
226
|
+
class FakeMux {
|
|
227
|
+
listeners = new Set();
|
|
228
|
+
onFrame(listener) {
|
|
229
|
+
this.listeners.add(listener);
|
|
230
|
+
return () => { this.listeners.delete(listener); };
|
|
231
|
+
}
|
|
232
|
+
emit(frame) {
|
|
233
|
+
for (const listener of this.listeners)
|
|
234
|
+
listener(frame);
|
|
235
|
+
}
|
|
236
|
+
}
|
|
237
|
+
beforeAll(() => {
|
|
238
|
+
Object.defineProperty(Element.prototype, 'scrollHeight', {
|
|
239
|
+
configurable: true,
|
|
240
|
+
get() { return scrollHeightMock; },
|
|
241
|
+
});
|
|
242
|
+
Object.defineProperty(Element.prototype, 'scrollTop', {
|
|
243
|
+
configurable: true,
|
|
244
|
+
get() { const stored = this['__scrollTop']; return typeof stored === 'number' ? stored : 0; },
|
|
245
|
+
set(value) { scrollWrites.push(value); this['__scrollTop'] = value; },
|
|
246
|
+
});
|
|
247
|
+
});
|
|
248
|
+
afterAll(() => {
|
|
249
|
+
if (origScrollHeight)
|
|
250
|
+
Object.defineProperty(Element.prototype, 'scrollHeight', origScrollHeight);
|
|
251
|
+
if (origScrollTop)
|
|
252
|
+
Object.defineProperty(Element.prototype, 'scrollTop', origScrollTop);
|
|
253
|
+
});
|
|
254
|
+
beforeEach(() => { scrollHeightMock = 0; scrollWrites = []; });
|
|
255
|
+
/** A final assistant/message event (non-pending) appended live after the history turn. */
|
|
256
|
+
const liveFinalEvent = (seq) => makeEntry('assistant/message', {
|
|
257
|
+
turn: 1,
|
|
258
|
+
step: 0,
|
|
259
|
+
message: { id: 'a-2', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] },
|
|
260
|
+
}, seq);
|
|
261
|
+
it('positions to the latest message when a session is opened', async () => {
|
|
262
|
+
scrollHeightMock = 400;
|
|
263
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
|
|
264
|
+
render(_jsx(ChatView, { session: session, onBack: () => { } }));
|
|
265
|
+
// The tail page renders, then the commit-time effect pins scrollTop to the tail.
|
|
266
|
+
expect(await screen.findByText('已完成修改')).toBeTruthy();
|
|
267
|
+
expect(scrollWrites.at(-1)).toBe(400);
|
|
268
|
+
});
|
|
269
|
+
it('auto-scrolls to the bottom when a new live message arrives', async () => {
|
|
270
|
+
scrollHeightMock = 400;
|
|
271
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()));
|
|
272
|
+
const mux = new FakeMux();
|
|
273
|
+
render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
|
|
274
|
+
await screen.findByText('已完成修改');
|
|
275
|
+
expect(scrollWrites.at(-1)).toBe(400);
|
|
276
|
+
// A real-time message grows content; the newly appended (non-pending) last
|
|
277
|
+
// message must still pull the view down to the new bottom.
|
|
278
|
+
scrollHeightMock = 800;
|
|
279
|
+
await act(async () => {
|
|
280
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
|
|
281
|
+
});
|
|
282
|
+
expect(await screen.findByText('实时新消息')).toBeTruthy();
|
|
283
|
+
expect(scrollWrites.at(-1)).toBe(800);
|
|
284
|
+
});
|
|
285
|
+
it('keeps the current scroll position when older messages are loaded', async () => {
|
|
286
|
+
scrollHeightMock = 400;
|
|
287
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), { hasMore: true }));
|
|
288
|
+
const mux = new FakeMux();
|
|
289
|
+
render(_jsx(ChatView, { session: session, mux: mux, onBack: () => { } }));
|
|
290
|
+
await screen.findByText('已完成修改');
|
|
291
|
+
// Move to the bottom (streaming), as a stable baseline to preserve.
|
|
292
|
+
scrollHeightMock = 800;
|
|
293
|
+
await act(async () => {
|
|
294
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event });
|
|
295
|
+
});
|
|
296
|
+
await screen.findByText('实时新消息');
|
|
297
|
+
const writesBefore = scrollWrites.length;
|
|
298
|
+
expect(scrollWrites.at(-1)).toBe(800);
|
|
299
|
+
// Prepend an older page; the last message is untouched, so no re-scroll fires.
|
|
300
|
+
scrollHeightMock = 900;
|
|
301
|
+
loadHistoryMock.mockResolvedValueOnce(historyPage(turnEvents(), { hasMore: false }));
|
|
302
|
+
fireEvent.click(screen.getByRole('button', { name: /加载更早的消息/ }));
|
|
303
|
+
await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore); });
|
|
304
|
+
});
|
|
305
|
+
});
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@linxin666/dsh-remote-web-ui",
|
|
3
3
|
"description": "Mobile remote control for the dsh web GUI: scan-to-pair QR entry beside the settings button, one-time pairing tokens, live device status, and revocable mobile sessions",
|
|
4
|
-
"version": "0.1.
|
|
4
|
+
"version": "0.1.7",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"engines": {
|
|
7
7
|
"node": "^22.19.0 || >=24.0.0"
|
|
@@ -0,0 +1,172 @@
|
|
|
1
|
+
/** mux: live-event client, SSE delivery + stall-driven polling fallback. */
|
|
2
|
+
import { beforeEach, describe, expect, it, vi } from 'vitest'
|
|
3
|
+
import { MuxClient, type EventSourceLike } from './mux.ts'
|
|
4
|
+
import type { HistoryPage } from './api.ts'
|
|
5
|
+
|
|
6
|
+
/** A recorded fake EventSource (delivery driven by the test). */
|
|
7
|
+
interface FakeSource extends EventSourceLike {
|
|
8
|
+
url: string
|
|
9
|
+
closed: boolean
|
|
10
|
+
close: () => void
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
/** Create an EventSource factory that records every opened source. */
|
|
14
|
+
function makeSources(): { factory: (url: string) => EventSourceLike; sources: FakeSource[] } {
|
|
15
|
+
const sources: FakeSource[] = []
|
|
16
|
+
const factory = (url: string): EventSourceLike => {
|
|
17
|
+
const source: FakeSource = {
|
|
18
|
+
url,
|
|
19
|
+
onmessage: null,
|
|
20
|
+
onerror: null,
|
|
21
|
+
closed: false,
|
|
22
|
+
close: () => { source.closed = true },
|
|
23
|
+
}
|
|
24
|
+
sources.push(source)
|
|
25
|
+
return source
|
|
26
|
+
}
|
|
27
|
+
return { factory, sources }
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
/** One history page whose events carry sequential ids. */
|
|
31
|
+
function pageOf(seqs: readonly number[]): HistoryPage {
|
|
32
|
+
return {
|
|
33
|
+
hasMore: false,
|
|
34
|
+
events: seqs.map(seq => ({
|
|
35
|
+
event: { type: 'user/message', seq, time: seq * 1_000, data: { text: String(seq) } },
|
|
36
|
+
})),
|
|
37
|
+
} as unknown as HistoryPage
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
/** A server-request envelope carrying one mux frame (the SSE wire shape). */
|
|
41
|
+
function envelopeWith(payload: unknown): string {
|
|
42
|
+
return JSON.stringify({ type: 'server-request', rpcId: 'r1', method: 'events.mux', payload })
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** Options common to every test: tight clocks, injected data source. */
|
|
46
|
+
function baseOptions(pollLatest: (sessionId: string) => Promise<HistoryPage>, factory: (url: string) => EventSourceLike) {
|
|
47
|
+
return { sourceFactory: factory, pollLatest, stallThresholdMs: 800, pollIntervalMs: 400 }
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
describe('MuxClient polling fallback', () => {
|
|
51
|
+
beforeEach(() => {
|
|
52
|
+
vi.useFakeTimers()
|
|
53
|
+
})
|
|
54
|
+
|
|
55
|
+
it('does not poll while the SSE channel is fresh', async () => {
|
|
56
|
+
const { factory } = makeSources()
|
|
57
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
58
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
59
|
+
const frames: unknown[] = []
|
|
60
|
+
client.onFrame(frame => { frames.push(frame) })
|
|
61
|
+
client.start()
|
|
62
|
+
client.observe('s1')
|
|
63
|
+
await vi.advanceTimersByTimeAsync(400) // well under the stall threshold
|
|
64
|
+
expect(pollLatest).not.toHaveBeenCalled()
|
|
65
|
+
expect(frames).toHaveLength(0)
|
|
66
|
+
client.stop()
|
|
67
|
+
})
|
|
68
|
+
|
|
69
|
+
it('starts polling after silence and emits appended events as session/event frames', async () => {
|
|
70
|
+
const { factory } = makeSources()
|
|
71
|
+
const pages = [pageOf([0]), pageOf([0, 1])]
|
|
72
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pages.shift() ?? pageOf([]))
|
|
73
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
74
|
+
const frames: Array<{ type: string; sessionId: string; event?: { seq: number } }> = []
|
|
75
|
+
client.onFrame(frame => { frames.push(frame as never) })
|
|
76
|
+
client.start()
|
|
77
|
+
client.observe('s1')
|
|
78
|
+
|
|
79
|
+
// Nothing live within a poll interval until the stall window passes.
|
|
80
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
81
|
+
expect(pollLatest).not.toHaveBeenCalled()
|
|
82
|
+
|
|
83
|
+
// Past the stall threshold the first poll runs and emits seq 0.
|
|
84
|
+
await vi.advanceTimersByTimeAsync(700) // 1100ms total -> first stall-checker tick
|
|
85
|
+
expect(pollLatest).toHaveBeenCalledWith('s1')
|
|
86
|
+
expect(frames).toHaveLength(1)
|
|
87
|
+
expect(frames[0]).toMatchObject({ type: 'session/event', sessionId: 's1' })
|
|
88
|
+
expect(frames[0]?.event).toMatchObject({ seq: 0 })
|
|
89
|
+
|
|
90
|
+
// The next poll emits only the appended event (seq 1), not seq 0 again.
|
|
91
|
+
await vi.advanceTimersByTimeAsync(400)
|
|
92
|
+
expect(frames).toHaveLength(2)
|
|
93
|
+
expect(frames[1]?.event).toMatchObject({ seq: 1 })
|
|
94
|
+
client.stop()
|
|
95
|
+
})
|
|
96
|
+
|
|
97
|
+
it('keeps the watermark so a repeated page never re-emits old events', async () => {
|
|
98
|
+
const { factory } = makeSources()
|
|
99
|
+
// Two calls return the same page: the second must emit nothing.
|
|
100
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0, 1, 2]))
|
|
101
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
102
|
+
const frames: Array<{ type: string; event?: { seq: number } }> = []
|
|
103
|
+
client.onFrame(frame => { frames.push(frame as never) })
|
|
104
|
+
client.start()
|
|
105
|
+
client.observe('s1')
|
|
106
|
+
|
|
107
|
+
await vi.advanceTimersByTimeAsync(1100) // first poll -> 3 events
|
|
108
|
+
expect(frames).toHaveLength(3)
|
|
109
|
+
|
|
110
|
+
await vi.advanceTimersByTimeAsync(400) // second poll -> same page, nothing new
|
|
111
|
+
expect(frames).toHaveLength(3)
|
|
112
|
+
client.stop()
|
|
113
|
+
})
|
|
114
|
+
|
|
115
|
+
it('stops polling when observe is cleared and keeps it stopped', async () => {
|
|
116
|
+
const { factory } = makeSources()
|
|
117
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
118
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
119
|
+
client.start()
|
|
120
|
+
client.observe('s1')
|
|
121
|
+
|
|
122
|
+
await vi.advanceTimersByTimeAsync(1100)
|
|
123
|
+
expect(pollLatest).toHaveBeenCalled()
|
|
124
|
+
|
|
125
|
+
client.observe(undefined)
|
|
126
|
+
const callsAfterClear = pollLatest.mock.calls.length
|
|
127
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
128
|
+
expect(pollLatest.mock.calls.length).toBe(callsAfterClear)
|
|
129
|
+
client.stop()
|
|
130
|
+
})
|
|
131
|
+
|
|
132
|
+
it('stops polling on stop(), closing any live source', async () => {
|
|
133
|
+
const { factory, sources } = makeSources()
|
|
134
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
135
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
136
|
+
client.start()
|
|
137
|
+
client.observe('s1')
|
|
138
|
+
|
|
139
|
+
await vi.advanceTimersByTimeAsync(1100)
|
|
140
|
+
expect(pollLatest).toHaveBeenCalled()
|
|
141
|
+
|
|
142
|
+
client.stop()
|
|
143
|
+
const callsAfterStop = pollLatest.mock.calls.length
|
|
144
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
145
|
+
expect(pollLatest.mock.calls.length).toBe(callsAfterStop)
|
|
146
|
+
expect(sources[0]?.closed).toBe(true)
|
|
147
|
+
})
|
|
148
|
+
|
|
149
|
+
it('returns to SSE when a frame arrives, dropping the fallback poller', async () => {
|
|
150
|
+
const { factory, sources } = makeSources()
|
|
151
|
+
const pollLatest = vi.fn(async (_sessionId: string) => pageOf([0]))
|
|
152
|
+
const client = new MuxClient('/m/api/events.mux', baseOptions(pollLatest, factory))
|
|
153
|
+
const frames: unknown[] = []
|
|
154
|
+
client.onFrame(frame => { frames.push(frame) })
|
|
155
|
+
client.start()
|
|
156
|
+
client.observe('s1')
|
|
157
|
+
|
|
158
|
+
// Stall into polling.
|
|
159
|
+
await vi.advanceTimersByTimeAsync(1100)
|
|
160
|
+
expect(pollLatest).toHaveBeenCalledTimes(1)
|
|
161
|
+
|
|
162
|
+
// A live mux frame proves SSE delivers again -> fallback stops.
|
|
163
|
+
sources[0]?.onmessage?.({ data: envelopeWith({ type: 'session/subscribed', sessionId: 's1', lastSeq: 4 }) })
|
|
164
|
+
|
|
165
|
+
await vi.advanceTimersByTimeAsync(2000)
|
|
166
|
+
expect(pollLatest.mock.calls.length).toBe(1) // polling stopped after the live frame
|
|
167
|
+
const live = frames.filter(frame => (frame as { type?: string })?.type === 'session/subscribed')
|
|
168
|
+
expect(live).toHaveLength(1)
|
|
169
|
+
expect(live[0]).toMatchObject({ type: 'session/subscribed', sessionId: 's1' })
|
|
170
|
+
client.stop()
|
|
171
|
+
})
|
|
172
|
+
})
|
package/src/mobile/mux.ts
CHANGED
|
@@ -7,17 +7,44 @@
|
|
|
7
7
|
* opens — no subscription handshake is needed. Frames arrive as
|
|
8
8
|
* server-request envelopes whose payload is the mux frame; unknown frame
|
|
9
9
|
* types are dropped so a newer host never breaks this client.
|
|
10
|
-
*
|
|
10
|
+
*
|
|
11
|
+
* EventSource reconnects automatically — but only over a tunnel that
|
|
12
|
+
* actually forwards frames. Public quick tunnels (Cloudflare quick tunnel /
|
|
13
|
+
* Tailscale Serve) do not transparently pass Server-Sent Events: ordinary
|
|
14
|
+
* HTTP works, yet the SSE connection stays open or reconnects with zero
|
|
15
|
+
* bytes, so no live frame ever arrives. That is a transport-layer limit of
|
|
16
|
+
* the tunnel, not something the host can fix. This client therefore
|
|
17
|
+
* degrades gracefully: once the SSE channel has silently stalled (no frame
|
|
18
|
+
* for {@link MuxClientOptions.stallThresholdMs}, or the EventSource reports
|
|
19
|
+
* an error), it starts polling the open session's history over plain HTTP
|
|
20
|
+
* (the `/m/api/session.history` RPC — unaffected by the SSE limitation),
|
|
21
|
+
* and re-emits freshly appended events as `session/event` frames through
|
|
22
|
+
* the same subscriber contract, so listeners (and the message fold) behave
|
|
23
|
+
* exactly as if the frames had arrived over SSE. When the SSE channel
|
|
24
|
+
* delivers again, fallback polling stops and the live stream takes over.
|
|
11
25
|
*/
|
|
12
26
|
|
|
13
27
|
import type { MuxFrame } from '@deepseek-ai/dsh-host-apiproxy/api/events'
|
|
14
28
|
import { muxFrameSchema } from '@deepseek-ai/dsh-host-apiproxy/api/events.schema'
|
|
15
29
|
import { serverRequestSchema } from '@deepseek-ai/dsh-host-apiproxy/api/rpc.schema'
|
|
30
|
+
import { history as fetchHistory, type HistoryPage } from './api.ts'
|
|
16
31
|
|
|
17
32
|
/** Injectable seams for tests. */
|
|
18
33
|
export interface MuxClientOptions {
|
|
19
34
|
/** EventSource factory (defaults to the browser EventSource). */
|
|
20
35
|
sourceFactory?: (url: string) => EventSourceLike
|
|
36
|
+
/**
|
|
37
|
+
* Fetch one history page (tail) for a session — the polling fallback's
|
|
38
|
+
* data source. Defaults to the mobile `session.history` RPC, which rides
|
|
39
|
+
* the ordinary HTTP channel (unaffected by SSE-impairing tunnels).
|
|
40
|
+
*/
|
|
41
|
+
pollLatest?: (sessionId: string) => Promise<HistoryPage>
|
|
42
|
+
/** Poll cadence while SSE is stalled (default 3000 ms). */
|
|
43
|
+
pollIntervalMs?: number
|
|
44
|
+
/** How long SSE must go without a frame before fallback kicks in (default 12000 ms). */
|
|
45
|
+
stallThresholdMs?: number
|
|
46
|
+
/** Clock seam for tests (defaults to Date.now). */
|
|
47
|
+
now?: () => number
|
|
21
48
|
}
|
|
22
49
|
|
|
23
50
|
/** The EventSource subset this client uses (browser EventSource fits). */
|
|
@@ -34,18 +61,47 @@ function browserSource(url: string): EventSourceLike {
|
|
|
34
61
|
return new EventSource(url) as unknown as EventSourceLike
|
|
35
62
|
}
|
|
36
63
|
|
|
64
|
+
/** The `session/event` arm of the mux frame union. */
|
|
65
|
+
type SessionEventFrame = Extract<MuxFrame, { type: 'session/event' }>
|
|
66
|
+
|
|
67
|
+
const DEFAULT_POLL_INTERVAL_MS = 3000
|
|
68
|
+
const DEFAULT_STALL_THRESHOLD_MS = 12000
|
|
69
|
+
/** Poll window: enough recent events to cover a few seconds of agent output. */
|
|
70
|
+
const DEFAULT_POLL_PAGE_SIZE = 50
|
|
71
|
+
|
|
37
72
|
/**
|
|
38
73
|
* Keep one SSE subscription open, fanning validated frames out to
|
|
39
74
|
* subscribers. EventSource owns reconnection (with its own backoff); this
|
|
40
|
-
* class only manages the subscription lifecycle
|
|
75
|
+
* class only manages the subscription lifecycle, plus a polling fallback
|
|
76
|
+
* that keeps the open session live when the SSE channel cannot deliver.
|
|
41
77
|
*/
|
|
42
78
|
export class MuxClient {
|
|
43
79
|
private readonly sourceFactory: (url: string) => EventSourceLike
|
|
80
|
+
private readonly pollLatest: (sessionId: string) => Promise<HistoryPage>
|
|
81
|
+
private readonly pollIntervalMs: number
|
|
82
|
+
private readonly stallThresholdMs: number
|
|
83
|
+
private readonly now: () => number
|
|
44
84
|
private readonly listeners = new Set<(frame: MuxFrame) => void>()
|
|
45
85
|
private source: EventSourceLike | undefined
|
|
46
86
|
private stopped = false
|
|
47
87
|
private readonly url: string
|
|
48
88
|
|
|
89
|
+
/** The session to keep live via fallback polling (undefined = none). */
|
|
90
|
+
private observeSessionId: string | undefined
|
|
91
|
+
/** Last epoch ms the SSE channel produced a frame (or the stream opened). */
|
|
92
|
+
private lastDataAt = 0
|
|
93
|
+
/**
|
|
94
|
+
* Whether the SSE channel has ever delivered a frame in this stream (a
|
|
95
|
+
* delivered frame proves the tunnel forwards SSE; silence alone then means
|
|
96
|
+
* the agent idle, not a dead channel — only an onerror re-arms fallback).
|
|
97
|
+
*/
|
|
98
|
+
private sseAlive = false
|
|
99
|
+
/** Per-session highest event seq already emitted, for poll dedup. */
|
|
100
|
+
private readonly pollWatermark = new Map<string, number>()
|
|
101
|
+
private stallTimer: ReturnType<typeof setInterval> | undefined
|
|
102
|
+
private pollTimer: ReturnType<typeof setInterval> | undefined
|
|
103
|
+
private polling = false
|
|
104
|
+
|
|
49
105
|
/**
|
|
50
106
|
* @param url - the mobile events endpoint (browser-relative).
|
|
51
107
|
* @param options - seams.
|
|
@@ -53,19 +109,27 @@ export class MuxClient {
|
|
|
53
109
|
constructor(url = '/m/api/events.mux', options: MuxClientOptions = {}) {
|
|
54
110
|
this.url = url
|
|
55
111
|
this.sourceFactory = options.sourceFactory ?? browserSource
|
|
112
|
+
this.pollLatest = options.pollLatest ?? ((sessionId) => fetchHistory(sessionId, undefined, DEFAULT_POLL_PAGE_SIZE))
|
|
113
|
+
this.pollIntervalMs = options.pollIntervalMs ?? DEFAULT_POLL_INTERVAL_MS
|
|
114
|
+
this.stallThresholdMs = options.stallThresholdMs ?? DEFAULT_STALL_THRESHOLD_MS
|
|
115
|
+
this.now = options.now ?? (() => Date.now())
|
|
56
116
|
}
|
|
57
117
|
|
|
58
118
|
/** Open the stream (idempotent; EventSource reconnects until {@link stop}). */
|
|
59
119
|
start(): void {
|
|
60
120
|
this.stopped = false
|
|
61
|
-
|
|
62
|
-
this.connect()
|
|
121
|
+
this.lastDataAt = this.now()
|
|
122
|
+
if (this.source === undefined) this.connect()
|
|
123
|
+
this.startStallChecker()
|
|
63
124
|
}
|
|
64
125
|
|
|
65
126
|
/** Close for good. */
|
|
66
127
|
stop(): void {
|
|
67
128
|
this.stopped = true
|
|
129
|
+
this.stopStallChecker()
|
|
130
|
+
this.stopPolling()
|
|
68
131
|
this.closeSource()
|
|
132
|
+
this.observeSessionId = undefined
|
|
69
133
|
}
|
|
70
134
|
|
|
71
135
|
/** Subscribe to validated frames; returns an unsubscribe function. */
|
|
@@ -74,7 +138,26 @@ export class MuxClient {
|
|
|
74
138
|
return () => { this.listeners.delete(listener) }
|
|
75
139
|
}
|
|
76
140
|
|
|
141
|
+
/**
|
|
142
|
+
* Point the fallback at one open session (or `undefined` to stop it).
|
|
143
|
+
* While the SSE channel is stalled this client polls that session's
|
|
144
|
+
* history and re-emits new events as `session/event` frames.
|
|
145
|
+
*/
|
|
146
|
+
observe(sessionId: string | undefined): void {
|
|
147
|
+
this.observeSessionId = sessionId
|
|
148
|
+
if (sessionId === undefined) {
|
|
149
|
+
this.stopPolling()
|
|
150
|
+
return
|
|
151
|
+
}
|
|
152
|
+
// If SSE is already stalled for this session, start patching right away.
|
|
153
|
+
if (!this.polling && !this.stopped && !this.sseAlive && (this.now() - this.lastDataAt) > this.stallThresholdMs) {
|
|
154
|
+
this.startPolling()
|
|
155
|
+
}
|
|
156
|
+
}
|
|
157
|
+
|
|
77
158
|
private connect(): void {
|
|
159
|
+
// A fresh stream starts unknown; only a delivered frame proves it works.
|
|
160
|
+
this.sseAlive = false
|
|
78
161
|
const source = this.sourceFactory(this.url)
|
|
79
162
|
this.source = source
|
|
80
163
|
source.onmessage = (event) => {
|
|
@@ -82,8 +165,77 @@ export class MuxClient {
|
|
|
82
165
|
}
|
|
83
166
|
source.onerror = () => {
|
|
84
167
|
// EventSource reconnects by itself; when we are closing, detach first
|
|
85
|
-
// so the native reconnect cannot outlive stop().
|
|
86
|
-
|
|
168
|
+
// so the native reconnect cannot outlive stop(). Otherwise an error is
|
|
169
|
+
// a strong signal the transport is not delivering — degrade to polling.
|
|
170
|
+
if (this.stopped && this.source === source) {
|
|
171
|
+
this.closeSource()
|
|
172
|
+
return
|
|
173
|
+
}
|
|
174
|
+
this.sseAlive = false
|
|
175
|
+
if (this.observeSessionId !== undefined) this.startPolling()
|
|
176
|
+
}
|
|
177
|
+
}
|
|
178
|
+
|
|
179
|
+
private startStallChecker(): void {
|
|
180
|
+
this.stopStallChecker()
|
|
181
|
+
this.stallTimer = setInterval(() => {
|
|
182
|
+
if (this.stopped) return
|
|
183
|
+
if (this.observeSessionId === undefined) return
|
|
184
|
+
if (this.polling) return
|
|
185
|
+
// A live SSE channel only goes silent while the agent idles; never
|
|
186
|
+
// poll against it. Fallback arms again only via onerror or a stream
|
|
187
|
+
// that has never delivered.
|
|
188
|
+
if (this.sseAlive) return
|
|
189
|
+
if ((this.now() - this.lastDataAt) > this.stallThresholdMs) this.startPolling()
|
|
190
|
+
}, 1000)
|
|
191
|
+
}
|
|
192
|
+
|
|
193
|
+
private stopStallChecker(): void {
|
|
194
|
+
if (this.stallTimer !== undefined) {
|
|
195
|
+
clearInterval(this.stallTimer)
|
|
196
|
+
this.stallTimer = undefined
|
|
197
|
+
}
|
|
198
|
+
}
|
|
199
|
+
|
|
200
|
+
private startPolling(): void {
|
|
201
|
+
if (this.polling || this.stopped) return
|
|
202
|
+
this.polling = true
|
|
203
|
+
void this.pollTick()
|
|
204
|
+
this.pollTimer = setInterval(() => { void this.pollTick() }, this.pollIntervalMs)
|
|
205
|
+
}
|
|
206
|
+
|
|
207
|
+
private stopPolling(): void {
|
|
208
|
+
this.polling = false
|
|
209
|
+
if (this.pollTimer !== undefined) {
|
|
210
|
+
clearInterval(this.pollTimer)
|
|
211
|
+
this.pollTimer = undefined
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
|
|
215
|
+
/**
|
|
216
|
+
* Fetch the latest history page for the observed session and re-emit any
|
|
217
|
+
* event above the per-session watermark as a `session/event` frame.
|
|
218
|
+
* Idempotent by seq: listeners (and the fold) never see a duplicate.
|
|
219
|
+
*/
|
|
220
|
+
private async pollTick(): Promise<void> {
|
|
221
|
+
const sessionId = this.observeSessionId
|
|
222
|
+
if (sessionId === undefined) {
|
|
223
|
+
this.stopPolling()
|
|
224
|
+
return
|
|
225
|
+
}
|
|
226
|
+
try {
|
|
227
|
+
const page = await this.pollLatest(sessionId)
|
|
228
|
+
let maxSeq = this.pollWatermark.get(sessionId) ?? -1
|
|
229
|
+
for (const entry of page.events) {
|
|
230
|
+
const event = entry.event
|
|
231
|
+
const seq = typeof event?.seq === 'number' ? event.seq : -1
|
|
232
|
+
if (seq <= maxSeq) continue
|
|
233
|
+
maxSeq = seq
|
|
234
|
+
this.emit({ type: 'session/event', sessionId: sessionId as SessionEventFrame['sessionId'], event } as SessionEventFrame)
|
|
235
|
+
}
|
|
236
|
+
this.pollWatermark.set(sessionId, maxSeq)
|
|
237
|
+
} catch {
|
|
238
|
+
// Transient (network, pairing, history paging); the next tick retries.
|
|
87
239
|
}
|
|
88
240
|
}
|
|
89
241
|
|
|
@@ -101,9 +253,19 @@ export class MuxClient {
|
|
|
101
253
|
if (!envelope.success) return
|
|
102
254
|
const frame = muxFrameSchema.safeParse(envelope.data.payload)
|
|
103
255
|
if (!frame.success) return
|
|
256
|
+
// A delivered frame proves the SSE channel is live (the tunnel forwards
|
|
257
|
+
// it) and delivers again — drop any fallback polling so the live stream
|
|
258
|
+
// takes over without double delivery.
|
|
259
|
+
this.sseAlive = true
|
|
260
|
+
this.lastDataAt = this.now()
|
|
261
|
+
if (this.polling) this.stopPolling()
|
|
262
|
+
this.emit(frame.data)
|
|
263
|
+
}
|
|
264
|
+
|
|
265
|
+
private emit(frame: MuxFrame): void {
|
|
104
266
|
for (const listener of this.listeners) {
|
|
105
267
|
try {
|
|
106
|
-
listener(frame
|
|
268
|
+
listener(frame)
|
|
107
269
|
} catch {
|
|
108
270
|
// A throwing subscriber must not break the emit loop.
|
|
109
271
|
}
|
|
@@ -123,4 +285,4 @@ export class MuxClient {
|
|
|
123
285
|
}
|
|
124
286
|
}
|
|
125
287
|
}
|
|
126
|
-
}
|
|
288
|
+
}
|
package/src/mobile/views/App.tsx
CHANGED
|
@@ -94,6 +94,13 @@ export function App() {
|
|
|
94
94
|
return () => { mux.stop() }
|
|
95
95
|
}, [])
|
|
96
96
|
|
|
97
|
+
// Keep the live-event client pointed at the session currently on screen so
|
|
98
|
+
// its polling fallback can keep that chat fresh over SSE-impairing tunnels
|
|
99
|
+
// (quick tunnel / Tailscale Serve do not forward Server-Sent Events).
|
|
100
|
+
useEffect(() => {
|
|
101
|
+
muxRef.current?.observe(route.kind === 'chat' ? route.session.sessionId : undefined)
|
|
102
|
+
}, [route])
|
|
103
|
+
|
|
97
104
|
const back = useCallback(() => {
|
|
98
105
|
setRoute(previous => {
|
|
99
106
|
if (previous.kind === 'chat') return { kind: 'sessions', workspace: previous.workspace }
|
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
// @vitest-environment jsdom
|
|
2
2
|
/** ChatView: collapsible message folds, toolbar chips, and the bottom sheets. */
|
|
3
|
-
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
-
import { cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
3
|
+
import { afterAll, afterEach, beforeAll, beforeEach, describe, expect, it, vi } from 'vitest'
|
|
4
|
+
import { act, cleanup, fireEvent, render, screen, waitFor } from '@testing-library/react'
|
|
5
5
|
import type { SessionModels } from '@deepseek-ai/dsh-host-apiproxy/api/sessions'
|
|
6
6
|
import { ChatView } from './ChatView.tsx'
|
|
7
7
|
import { type SessionView } from './App.tsx'
|
|
@@ -230,7 +230,6 @@ describe('ChatView model sheet', () => {
|
|
|
230
230
|
expect(selectModelMock).toHaveBeenCalledWith('s-1', { provider: 'fx', model: 'fx-2', reasoningEffort: 'high' })
|
|
231
231
|
})
|
|
232
232
|
})
|
|
233
|
-
|
|
234
233
|
it('explains a transport 403 on the model channel as a stale host', async () => {
|
|
235
234
|
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
236
235
|
modelsMock.mockRejectedValue(new Error('HTTP 403'))
|
|
@@ -241,3 +240,99 @@ describe('ChatView model sheet', () => {
|
|
|
241
240
|
expect(await screen.findByText(/重启 dsh web/)).toBeTruthy()
|
|
242
241
|
})
|
|
243
242
|
})
|
|
243
|
+
|
|
244
|
+
describe('ChatView scrolling', () => {
|
|
245
|
+
// Controllable scrollHeight + a write log for the chat-scroll element. The
|
|
246
|
+
// accessors live on Element.prototype, so patching them here lets every
|
|
247
|
+
// scrollToBottom assignment drive a deterministic assertion regardless of
|
|
248
|
+
// when the effect runs relative to mount.
|
|
249
|
+
let scrollHeightMock = 0
|
|
250
|
+
let scrollWrites: number[] = []
|
|
251
|
+
|
|
252
|
+
const origScrollHeight = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollHeight')
|
|
253
|
+
const origScrollTop = Object.getOwnPropertyDescriptor(Element.prototype, 'scrollTop')
|
|
254
|
+
|
|
255
|
+
/** Minimal mux stand-in: captures the ChatView's frame listener for hand-off. */
|
|
256
|
+
class FakeMux {
|
|
257
|
+
listeners = new Set<(frame: unknown) => void>()
|
|
258
|
+
onFrame(listener: (frame: unknown) => void): () => void {
|
|
259
|
+
this.listeners.add(listener)
|
|
260
|
+
return () => { this.listeners.delete(listener) }
|
|
261
|
+
}
|
|
262
|
+
emit(frame: unknown): void {
|
|
263
|
+
for (const listener of this.listeners) listener(frame)
|
|
264
|
+
}
|
|
265
|
+
}
|
|
266
|
+
|
|
267
|
+
beforeAll(() => {
|
|
268
|
+
Object.defineProperty(Element.prototype, 'scrollHeight', {
|
|
269
|
+
configurable: true,
|
|
270
|
+
get() { return scrollHeightMock },
|
|
271
|
+
})
|
|
272
|
+
Object.defineProperty(Element.prototype, 'scrollTop', {
|
|
273
|
+
configurable: true,
|
|
274
|
+
get(this: Element) { const stored = (this as unknown as Record<string, unknown>)['__scrollTop']; return typeof stored === 'number' ? stored : 0 },
|
|
275
|
+
set(this: Element, value: number) { scrollWrites.push(value); (this as unknown as Record<string, unknown>)['__scrollTop'] = value },
|
|
276
|
+
})
|
|
277
|
+
})
|
|
278
|
+
afterAll(() => {
|
|
279
|
+
if (origScrollHeight) Object.defineProperty(Element.prototype, 'scrollHeight', origScrollHeight)
|
|
280
|
+
if (origScrollTop) Object.defineProperty(Element.prototype, 'scrollTop', origScrollTop)
|
|
281
|
+
})
|
|
282
|
+
|
|
283
|
+
beforeEach(() => { scrollHeightMock = 0; scrollWrites = [] })
|
|
284
|
+
|
|
285
|
+
/** A final assistant/message event (non-pending) appended live after the history turn. */
|
|
286
|
+
const liveFinalEvent = (seq: number) => makeEntry('assistant/message', {
|
|
287
|
+
turn: 1,
|
|
288
|
+
step: 0,
|
|
289
|
+
message: { id: 'a-2', role: 'assistant', content: [{ type: 'text', text: '实时新消息' }] },
|
|
290
|
+
}, seq)
|
|
291
|
+
|
|
292
|
+
it('positions to the latest message when a session is opened', async () => {
|
|
293
|
+
scrollHeightMock = 400
|
|
294
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
295
|
+
render(<ChatView session={session} onBack={() => {}} />)
|
|
296
|
+
// The tail page renders, then the commit-time effect pins scrollTop to the tail.
|
|
297
|
+
expect(await screen.findByText('已完成修改')).toBeTruthy()
|
|
298
|
+
expect(scrollWrites.at(-1)).toBe(400)
|
|
299
|
+
})
|
|
300
|
+
|
|
301
|
+
it('auto-scrolls to the bottom when a new live message arrives', async () => {
|
|
302
|
+
scrollHeightMock = 400
|
|
303
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents()))
|
|
304
|
+
const mux = new FakeMux()
|
|
305
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
306
|
+
await screen.findByText('已完成修改')
|
|
307
|
+
expect(scrollWrites.at(-1)).toBe(400)
|
|
308
|
+
// A real-time message grows content; the newly appended (non-pending) last
|
|
309
|
+
// message must still pull the view down to the new bottom.
|
|
310
|
+
scrollHeightMock = 800
|
|
311
|
+
await act(async () => {
|
|
312
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event })
|
|
313
|
+
})
|
|
314
|
+
expect(await screen.findByText('实时新消息')).toBeTruthy()
|
|
315
|
+
expect(scrollWrites.at(-1)).toBe(800)
|
|
316
|
+
})
|
|
317
|
+
|
|
318
|
+
it('keeps the current scroll position when older messages are loaded', async () => {
|
|
319
|
+
scrollHeightMock = 400
|
|
320
|
+
loadHistoryMock.mockResolvedValue(historyPage(turnEvents(), { hasMore: true }))
|
|
321
|
+
const mux = new FakeMux()
|
|
322
|
+
render(<ChatView session={session} mux={mux as never} onBack={() => {}} />)
|
|
323
|
+
await screen.findByText('已完成修改')
|
|
324
|
+
// Move to the bottom (streaming), as a stable baseline to preserve.
|
|
325
|
+
scrollHeightMock = 800
|
|
326
|
+
await act(async () => {
|
|
327
|
+
mux.emit({ type: 'session/event', sessionId: 's-1', event: liveFinalEvent(6).event })
|
|
328
|
+
})
|
|
329
|
+
await screen.findByText('实时新消息')
|
|
330
|
+
const writesBefore = scrollWrites.length
|
|
331
|
+
expect(scrollWrites.at(-1)).toBe(800)
|
|
332
|
+
// Prepend an older page; the last message is untouched, so no re-scroll fires.
|
|
333
|
+
scrollHeightMock = 900
|
|
334
|
+
loadHistoryMock.mockResolvedValueOnce(historyPage(turnEvents(), { hasMore: false }))
|
|
335
|
+
fireEvent.click(screen.getByRole('button', { name: /加载更早的消息/ }))
|
|
336
|
+
await waitFor(() => { expect(scrollWrites.length).toBe(writesBefore) })
|
|
337
|
+
})
|
|
338
|
+
})
|