@nextclaw/ui 0.9.9 → 0.9.11

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.
Files changed (46) hide show
  1. package/CHANGELOG.md +12 -0
  2. package/dist/assets/ChannelsList-Brc1qLSU.js +1 -0
  3. package/dist/assets/{ChatPage-CMthudUt.js → ChatPage-DmGI776q.js} +24 -24
  4. package/dist/assets/{DocBrowser-BOvBC_5q.js → DocBrowser-xLVf1p4L.js} +1 -1
  5. package/dist/assets/{LogoBadge-BUvLZbji.js → LogoBadge-CcTyimdr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-CcbfvtGX.js → MarketplacePage-Bk-qXxyh.js} +1 -1
  7. package/dist/assets/{McpMarketplacePage-D56yvyWI.js → McpMarketplacePage-gFqAYekc.js} +1 -1
  8. package/dist/assets/ModelConfig-DnKNTuw6.js +1 -0
  9. package/dist/assets/{ProvidersList-Bd4n7muZ.js → ProvidersList-Cjr8EFu_.js} +1 -1
  10. package/dist/assets/{RemoteAccessPage-Be8jduPM.js → RemoteAccessPage-Rzi5a6Gc.js} +1 -1
  11. package/dist/assets/{RuntimeConfig-D8DYogZ1.js → RuntimeConfig-CttN--Tv.js} +1 -1
  12. package/dist/assets/{SearchConfig-BtiGCmXR.js → SearchConfig-D-GzinsL.js} +1 -1
  13. package/dist/assets/{SecretsConfig-fwAjbwlq.js → SecretsConfig-BvqQq4Ds.js} +1 -1
  14. package/dist/assets/{SessionsConfig-Y7_TDSk2.js → SessionsConfig-DbtnLmI6.js} +1 -1
  15. package/dist/assets/{chat-message-Cwq8nW0e.js → chat-message-DYQjL1tD.js} +1 -1
  16. package/dist/assets/index-ClLy_7T2.js +8 -0
  17. package/dist/assets/{label-C0dJBNgU.js → label-DBSKOMGE.js} +1 -1
  18. package/dist/assets/{page-layout-4_789zOC.js → page-layout-B5th9UzR.js} +1 -1
  19. package/dist/assets/{popover-CWmq2f6H.js → popover-BEIWRoeP.js} +1 -1
  20. package/dist/assets/{security-config-CZeVwEwq.js → security-config-D72JskP5.js} +1 -1
  21. package/dist/assets/{skeleton-kjkadEki.js → skeleton-B_Pn9x0i.js} +1 -1
  22. package/dist/assets/{status-dot-C7cVa53V.js → status-dot-CU5ZpOn1.js} +1 -1
  23. package/dist/assets/{switch-C6zdGbY0.js → switch-BdaXEtXk.js} +1 -1
  24. package/dist/assets/{tabs-custom-BQj0Z-ZC.js → tabs-custom-BVhSoteN.js} +1 -1
  25. package/dist/assets/{useConfirmDialog-yX-ZMNf9.js → useConfirmDialog-Dugi9V-Z.js} +1 -1
  26. package/dist/index.html +1 -1
  27. package/package.json +4 -4
  28. package/src/App.tsx +2 -2
  29. package/src/api/client.ts +5 -5
  30. package/src/api/config.ts +2 -4
  31. package/src/components/chat/chat-stream/transport.ts +67 -16
  32. package/src/components/config/ModelConfig.test.tsx +78 -0
  33. package/src/components/config/ModelConfig.tsx +4 -1
  34. package/src/hooks/use-realtime-query-bridge.ts +114 -0
  35. package/src/transport/app-client.ts +107 -0
  36. package/src/transport/index.ts +9 -0
  37. package/src/transport/local.transport.ts +168 -0
  38. package/src/transport/remote.transport.ts +363 -0
  39. package/src/transport/sse-stream.ts +114 -0
  40. package/src/transport/transport-websocket-url.ts +24 -0
  41. package/src/transport/transport.types.ts +41 -0
  42. package/dist/assets/ChannelsList-a063_8pv.js +0 -1
  43. package/dist/assets/ModelConfig-D5AuTffd.js +0 -1
  44. package/dist/assets/index-C6dwNe7e.js +0 -8
  45. package/src/api/websocket.ts +0 -79
  46. package/src/hooks/useWebSocket.ts +0 -190
@@ -0,0 +1,168 @@
1
+ import { API_BASE, requestApiResponse } from '@/api/client';
2
+ import type { ApiResponse } from '@/api/types';
3
+ import type { AppEvent, AppTransport, RequestInput, StreamInput, StreamSession } from './transport.types';
4
+ import { readSseStreamResult } from './sse-stream';
5
+ import { resolveTransportWebSocketUrl } from './transport-websocket-url';
6
+
7
+ type EventHandler = (event: AppEvent) => void;
8
+
9
+ function createTransportError(response: ApiResponse<unknown>, fallback: string): Error {
10
+ if (!response.ok) {
11
+ return new Error(response.error.message);
12
+ }
13
+ return new Error(fallback);
14
+ }
15
+
16
+ class LocalRealtimeGateway {
17
+ private socket: WebSocket | null = null;
18
+ private reconnectTimer: number | null = null;
19
+ private manualClose = false;
20
+ private subscribers = new Set<EventHandler>();
21
+
22
+ constructor(private readonly wsUrl: string) {}
23
+
24
+ subscribe(handler: EventHandler): () => void {
25
+ this.subscribers.add(handler);
26
+ if (this.subscribers.size === 1) {
27
+ this.connect();
28
+ } else if (this.socket?.readyState === WebSocket.OPEN) {
29
+ handler({ type: 'connection.open', payload: {} });
30
+ }
31
+
32
+ return () => {
33
+ this.subscribers.delete(handler);
34
+ if (this.subscribers.size === 0) {
35
+ this.disconnect();
36
+ }
37
+ };
38
+ }
39
+
40
+ private emit(event: AppEvent): void {
41
+ for (const subscriber of this.subscribers) {
42
+ subscriber(event);
43
+ }
44
+ }
45
+
46
+ private connect(): void {
47
+ if (this.socket && (this.socket.readyState === WebSocket.CONNECTING || this.socket.readyState === WebSocket.OPEN)) {
48
+ return;
49
+ }
50
+ this.manualClose = false;
51
+ const socket = new WebSocket(this.wsUrl);
52
+ this.socket = socket;
53
+
54
+ socket.onopen = () => {
55
+ this.emit({ type: 'connection.open', payload: {} });
56
+ };
57
+
58
+ socket.onmessage = (event) => {
59
+ try {
60
+ const data = JSON.parse(String(event.data ?? '')) as AppEvent;
61
+ this.emit(data);
62
+ } catch (error) {
63
+ console.error('Failed to parse websocket message:', error);
64
+ }
65
+ };
66
+
67
+ socket.onerror = () => {
68
+ this.emit({ type: 'connection.error', payload: { message: 'websocket error' } });
69
+ };
70
+
71
+ socket.onclose = () => {
72
+ this.emit({ type: 'connection.close', payload: {} });
73
+ this.socket = null;
74
+ if (!this.manualClose && this.subscribers.size > 0) {
75
+ this.scheduleReconnect();
76
+ }
77
+ };
78
+ }
79
+
80
+ private scheduleReconnect(): void {
81
+ if (this.reconnectTimer !== null) {
82
+ return;
83
+ }
84
+ this.reconnectTimer = window.setTimeout(() => {
85
+ this.reconnectTimer = null;
86
+ this.connect();
87
+ }, 3_000);
88
+ }
89
+
90
+ private disconnect(): void {
91
+ this.manualClose = true;
92
+ if (this.reconnectTimer !== null) {
93
+ window.clearTimeout(this.reconnectTimer);
94
+ this.reconnectTimer = null;
95
+ }
96
+ this.socket?.close();
97
+ this.socket = null;
98
+ }
99
+ }
100
+
101
+ export class LocalAppTransport implements AppTransport {
102
+ private readonly realtimeGateway: LocalRealtimeGateway;
103
+
104
+ constructor(
105
+ private readonly options: {
106
+ apiBase?: string;
107
+ wsPath?: string;
108
+ } = {}
109
+ ) {
110
+ const apiBase = options.apiBase ?? API_BASE;
111
+ this.realtimeGateway = new LocalRealtimeGateway(resolveTransportWebSocketUrl(apiBase, options.wsPath ?? '/ws'));
112
+ }
113
+
114
+ async request<T>(input: RequestInput): Promise<T> {
115
+ const response = await requestApiResponse<T>(input.path, {
116
+ method: input.method,
117
+ ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {})
118
+ });
119
+ if (!response.ok) {
120
+ throw createTransportError(response, `Request failed for ${input.method} ${input.path}`);
121
+ }
122
+ return response.data;
123
+ }
124
+
125
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
126
+ const controller = new AbortController();
127
+ const abort = () => controller.abort();
128
+ if (input.signal) {
129
+ if (input.signal.aborted) {
130
+ abort();
131
+ } else {
132
+ input.signal.addEventListener('abort', abort, { once: true });
133
+ }
134
+ }
135
+
136
+ const finished = (async () => {
137
+ const response = await fetch(`${API_BASE}${input.path}`, {
138
+ method: input.method,
139
+ credentials: 'include',
140
+ headers: {
141
+ 'Content-Type': 'application/json',
142
+ Accept: 'text/event-stream'
143
+ },
144
+ ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {}),
145
+ signal: controller.signal
146
+ });
147
+
148
+ if (!response.ok) {
149
+ const text = await response.text();
150
+ throw new Error(text.trim() || `HTTP ${response.status}`);
151
+ }
152
+ try {
153
+ return await readSseStreamResult<TFinal>(response, input.onEvent);
154
+ } finally {
155
+ input.signal?.removeEventListener('abort', abort);
156
+ }
157
+ })();
158
+
159
+ return {
160
+ finished,
161
+ cancel: () => controller.abort()
162
+ };
163
+ }
164
+
165
+ subscribe(handler: (event: AppEvent) => void): () => void {
166
+ return this.realtimeGateway.subscribe(handler);
167
+ }
168
+ }
@@ -0,0 +1,363 @@
1
+ import { API_BASE } from '@/api/client';
2
+ import type { ApiError } from '@/api/types';
3
+ import type { AppEvent, AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
4
+ import { resolveTransportWebSocketUrl } from './transport-websocket-url';
5
+
6
+ type RemoteTarget = {
7
+ method: string;
8
+ path: string;
9
+ body?: unknown;
10
+ };
11
+
12
+ type RemoteBrowserFrame =
13
+ | { type: 'connection.ready'; connectionId: string; protocolVersion: 1 }
14
+ | { type: 'response'; id: string; status: number; body?: unknown }
15
+ | { type: 'request.error'; id: string; message: string; code?: string }
16
+ | { type: 'stream.event'; streamId: string; event: string; payload?: unknown }
17
+ | { type: 'stream.end'; streamId: string; result?: unknown }
18
+ | { type: 'stream.error'; streamId: string; message: string; code?: string }
19
+ | { type: 'event'; event: AppEvent }
20
+ | { type: 'connection.error'; message: string; code?: string };
21
+
22
+ type RemoteBrowserCommand =
23
+ | { type: 'request'; id: string; target: RemoteTarget }
24
+ | { type: 'stream.open'; streamId: string; target: RemoteTarget }
25
+ | { type: 'stream.cancel'; streamId: string };
26
+
27
+ type PendingRequest = {
28
+ resolve: (value: unknown) => void;
29
+ reject: (error: Error) => void;
30
+ };
31
+
32
+ type PendingStream = {
33
+ onEvent: StreamInput['onEvent'];
34
+ resolve: (value: unknown) => void;
35
+ reject: (error: Error) => void;
36
+ };
37
+
38
+ function normalizeApiError(body: unknown, status: number, fallback: string): Error {
39
+ if (typeof body === 'object' && body && 'ok' in body) {
40
+ const typed = body as { ok?: boolean; error?: ApiError; data?: unknown };
41
+ if (typed.ok === false && typed.error?.message) {
42
+ return new Error(typed.error.message);
43
+ }
44
+ if (typed.ok === true) {
45
+ return new Error(fallback);
46
+ }
47
+ }
48
+ if (typeof body === 'string' && body.trim()) {
49
+ return new Error(body.trim());
50
+ }
51
+ return new Error(`${fallback} (${status})`);
52
+ }
53
+
54
+ function unwrapApiBody<T>(body: unknown): T {
55
+ if (typeof body === 'object' && body && 'ok' in body) {
56
+ const typed = body as { ok?: boolean; error?: ApiError; data?: T };
57
+ if (typed.ok === false) {
58
+ throw new Error(typed.error?.message ?? 'Remote request failed.');
59
+ }
60
+ if (typed.ok === true) {
61
+ return typed.data as T;
62
+ }
63
+ }
64
+ return body as T;
65
+ }
66
+
67
+ function createId(prefix: string): string {
68
+ return `${prefix}-${Date.now().toString(36)}-${Math.random().toString(36).slice(2, 10)}`;
69
+ }
70
+
71
+ export class RemoteSessionMultiplexTransport implements AppTransport {
72
+ private socket: WebSocket | null = null;
73
+ private connectPromise: Promise<void> | null = null;
74
+ private connectTimeoutId: number | null = null;
75
+ private reconnectTimer: number | null = null;
76
+ private manualClose = false;
77
+ private subscribers = new Set<(event: AppEvent) => void>();
78
+ private pendingRequests = new Map<string, PendingRequest>();
79
+ private pendingStreams = new Map<string, PendingStream>();
80
+
81
+ constructor(
82
+ private readonly runtime: RemoteRuntimeInfo,
83
+ private readonly apiBase: string = API_BASE
84
+ ) {}
85
+
86
+ async request<T>(input: RequestInput): Promise<T> {
87
+ await this.ensureSocket();
88
+ const id = createId('req');
89
+ return await new Promise<T>((resolve, reject) => {
90
+ this.pendingRequests.set(id, {
91
+ resolve: (value) => resolve(value as T),
92
+ reject
93
+ });
94
+ this.send({
95
+ type: 'request',
96
+ id,
97
+ target: {
98
+ method: input.method,
99
+ path: input.path,
100
+ ...(input.body !== undefined ? { body: input.body } : {})
101
+ }
102
+ });
103
+ });
104
+ }
105
+
106
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal> {
107
+ const streamId = createId('stream');
108
+ let settled = false;
109
+ const rejectEarly = (error: Error) => {
110
+ if (!settled) {
111
+ settled = true;
112
+ reject(error);
113
+ }
114
+ };
115
+ let resolve!: (value: TFinal) => void;
116
+ let reject!: (error: Error) => void;
117
+ const finished = new Promise<TFinal>((innerResolve, innerReject) => {
118
+ resolve = innerResolve;
119
+ reject = innerReject;
120
+ });
121
+
122
+ const cancel = () => {
123
+ this.pendingStreams.delete(streamId);
124
+ if (settled) {
125
+ return;
126
+ }
127
+ settled = true;
128
+ this.send({ type: 'stream.cancel', streamId });
129
+ reject(new Error('stream cancelled'));
130
+ };
131
+
132
+ this.pendingStreams.set(streamId, {
133
+ onEvent: input.onEvent,
134
+ resolve: (value) => {
135
+ if (settled) {
136
+ return;
137
+ }
138
+ settled = true;
139
+ resolve(value as TFinal);
140
+ },
141
+ reject: (error) => {
142
+ if (settled) {
143
+ return;
144
+ }
145
+ settled = true;
146
+ reject(error);
147
+ }
148
+ });
149
+
150
+ const abort = () => cancel();
151
+ if (input.signal) {
152
+ if (input.signal.aborted) {
153
+ cancel();
154
+ } else {
155
+ input.signal.addEventListener('abort', abort, { once: true });
156
+ }
157
+ }
158
+
159
+ void this.ensureSocket()
160
+ .then(() => {
161
+ if (settled) {
162
+ return;
163
+ }
164
+ this.send({
165
+ type: 'stream.open',
166
+ streamId,
167
+ target: {
168
+ method: input.method,
169
+ path: input.path,
170
+ ...(input.body !== undefined ? { body: input.body } : {})
171
+ }
172
+ });
173
+ })
174
+ .catch((error) => {
175
+ this.pendingStreams.delete(streamId);
176
+ rejectEarly(error instanceof Error ? error : new Error(String(error)));
177
+ });
178
+
179
+ return {
180
+ finished: finished.finally(() => {
181
+ input.signal?.removeEventListener('abort', abort);
182
+ this.pendingStreams.delete(streamId);
183
+ }),
184
+ cancel
185
+ };
186
+ }
187
+
188
+ subscribe(handler: (event: AppEvent) => void): () => void {
189
+ this.subscribers.add(handler);
190
+ void this.ensureSocket().catch((error) => {
191
+ handler({
192
+ type: 'connection.error',
193
+ payload: { message: error instanceof Error ? error.message : String(error) }
194
+ });
195
+ });
196
+ return () => {
197
+ this.subscribers.delete(handler);
198
+ };
199
+ }
200
+
201
+ private emit(event: AppEvent): void {
202
+ for (const subscriber of this.subscribers) {
203
+ subscriber(event);
204
+ }
205
+ }
206
+
207
+ private send(frame: RemoteBrowserCommand): void {
208
+ if (!this.socket || this.socket.readyState !== WebSocket.OPEN) {
209
+ throw new Error('Remote transport websocket is not connected.');
210
+ }
211
+ this.socket.send(JSON.stringify(frame));
212
+ }
213
+
214
+ private async ensureSocket(): Promise<void> {
215
+ if (this.socket?.readyState === WebSocket.OPEN && this.connectPromise === null) {
216
+ return;
217
+ }
218
+ if (this.connectPromise) {
219
+ return await this.connectPromise;
220
+ }
221
+
222
+ const wsUrl = resolveTransportWebSocketUrl(this.apiBase, this.runtime.wsPath);
223
+ this.manualClose = false;
224
+ this.connectPromise = new Promise<void>((innerResolve, innerReject) => {
225
+ const socket = new WebSocket(wsUrl);
226
+ this.socket = socket;
227
+ let connectionOpened = false;
228
+
229
+ const clearConnectTimeout = () => {
230
+ if (this.connectTimeoutId !== null) {
231
+ window.clearTimeout(this.connectTimeoutId);
232
+ this.connectTimeoutId = null;
233
+ }
234
+ };
235
+
236
+ this.connectTimeoutId = window.setTimeout(() => {
237
+ this.connectTimeoutId = null;
238
+ innerReject(new Error('Timed out waiting for remote transport websocket.'));
239
+ socket.close();
240
+ }, 8_000);
241
+
242
+ socket.onopen = () => {
243
+ connectionOpened = true;
244
+ clearConnectTimeout();
245
+ this.connectPromise = null;
246
+ innerResolve();
247
+ this.emit({ type: 'connection.open', payload: {} });
248
+ };
249
+
250
+ socket.onmessage = (event) => {
251
+ try {
252
+ const frame = JSON.parse(String(event.data ?? '')) as RemoteBrowserFrame;
253
+ if (frame.type === 'connection.ready') {
254
+ return;
255
+ }
256
+ this.handleFrame(frame);
257
+ } catch (error) {
258
+ console.error('Failed to parse remote websocket frame:', error);
259
+ }
260
+ };
261
+
262
+ socket.onerror = () => {
263
+ this.emit({ type: 'connection.error', payload: { message: 'remote websocket error' } });
264
+ };
265
+
266
+ socket.onclose = () => {
267
+ clearConnectTimeout();
268
+ const wasConnecting = this.connectPromise !== null;
269
+ this.socket = null;
270
+ this.connectPromise = null;
271
+ this.failPendingWork(new Error('Remote transport connection closed.'));
272
+ this.emit({ type: 'connection.close', payload: {} });
273
+ if (wasConnecting && !connectionOpened) {
274
+ innerReject(new Error('Remote transport connection closed before ready.'));
275
+ }
276
+ if (!this.manualClose && this.subscribers.size > 0) {
277
+ this.scheduleReconnect();
278
+ }
279
+ };
280
+ });
281
+
282
+ const connectPromise = this.connectPromise;
283
+ return await connectPromise;
284
+ }
285
+
286
+ private scheduleReconnect(): void {
287
+ if (this.reconnectTimer !== null) {
288
+ return;
289
+ }
290
+ this.reconnectTimer = window.setTimeout(() => {
291
+ this.reconnectTimer = null;
292
+ void this.ensureSocket().catch(() => undefined);
293
+ }, 3_000);
294
+ }
295
+
296
+ private failPendingWork(error: Error): void {
297
+ for (const pending of this.pendingRequests.values()) {
298
+ pending.reject(error);
299
+ }
300
+ this.pendingRequests.clear();
301
+
302
+ for (const pending of this.pendingStreams.values()) {
303
+ pending.reject(error);
304
+ }
305
+ this.pendingStreams.clear();
306
+ }
307
+
308
+ private handleFrame(frame: RemoteBrowserFrame): void {
309
+ if (frame.type === 'response' || frame.type === 'request.error') {
310
+ this.handleRequestFrame(frame);
311
+ return;
312
+ }
313
+ if (frame.type === 'stream.event' || frame.type === 'stream.end' || frame.type === 'stream.error') {
314
+ this.handleStreamFrame(frame);
315
+ return;
316
+ }
317
+ if (frame.type === 'event') {
318
+ this.emit(frame.event);
319
+ return;
320
+ }
321
+ if (frame.type === 'connection.error') {
322
+ this.emit({ type: 'connection.error', payload: { message: frame.message } });
323
+ }
324
+ }
325
+
326
+ private handleRequestFrame(frame: Extract<RemoteBrowserFrame, { type: 'response' | 'request.error' }>): void {
327
+ const pending = this.pendingRequests.get(frame.id);
328
+ if (!pending) {
329
+ return;
330
+ }
331
+ this.pendingRequests.delete(frame.id);
332
+ if (frame.type === 'request.error') {
333
+ pending.reject(new Error(frame.message));
334
+ return;
335
+ }
336
+ if (frame.status >= 400) {
337
+ pending.reject(normalizeApiError(frame.body, frame.status, 'Remote request failed.'));
338
+ return;
339
+ }
340
+ try {
341
+ pending.resolve(unwrapApiBody(frame.body));
342
+ } catch (error) {
343
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
344
+ }
345
+ }
346
+
347
+ private handleStreamFrame(frame: Extract<RemoteBrowserFrame, { type: 'stream.event' | 'stream.end' | 'stream.error' }>): void {
348
+ const pending = this.pendingStreams.get(frame.streamId);
349
+ if (!pending) {
350
+ return;
351
+ }
352
+ if (frame.type === 'stream.event') {
353
+ pending.onEvent({ name: frame.event, payload: frame.payload });
354
+ return;
355
+ }
356
+ this.pendingStreams.delete(frame.streamId);
357
+ if (frame.type === 'stream.end') {
358
+ pending.resolve(frame.result);
359
+ return;
360
+ }
361
+ pending.reject(new Error(frame.message));
362
+ }
363
+ }
@@ -0,0 +1,114 @@
1
+ import type { StreamEvent } from './transport.types';
2
+
3
+ type SseErrorPayload = { message?: string } | string | undefined;
4
+
5
+ function parseSseFrame(frame: string): StreamEvent | null {
6
+ const lines = frame.split('\n');
7
+ let name = '';
8
+ const dataLines: string[] = [];
9
+ for (const raw of lines) {
10
+ const line = raw.trimEnd();
11
+ if (!line || line.startsWith(':')) {
12
+ continue;
13
+ }
14
+ if (line.startsWith('event:')) {
15
+ name = line.slice(6).trim();
16
+ continue;
17
+ }
18
+ if (line.startsWith('data:')) {
19
+ dataLines.push(line.slice(5).trimStart());
20
+ }
21
+ }
22
+ if (!name) {
23
+ return null;
24
+ }
25
+
26
+ let payload: unknown = undefined;
27
+ const data = dataLines.join('\n');
28
+ if (data) {
29
+ try {
30
+ payload = JSON.parse(data);
31
+ } catch {
32
+ payload = data;
33
+ }
34
+ }
35
+
36
+ return { name, payload };
37
+ }
38
+
39
+ function readSseErrorMessage(payload: SseErrorPayload, fallback: string): string {
40
+ return typeof payload === 'string'
41
+ ? payload
42
+ : payload?.message ?? fallback;
43
+ }
44
+
45
+ function processSseFrame(
46
+ rawFrame: string,
47
+ onEvent: (event: StreamEvent) => void,
48
+ setFinalResult: (value: unknown) => void
49
+ ): void {
50
+ const frame = parseSseFrame(rawFrame);
51
+ if (!frame) {
52
+ return;
53
+ }
54
+ if (frame.name === 'final') {
55
+ setFinalResult(frame.payload);
56
+ return;
57
+ }
58
+ if (frame.name === 'error') {
59
+ throw new Error(readSseErrorMessage(frame.payload as SseErrorPayload, 'chat stream failed'));
60
+ }
61
+ onEvent(frame);
62
+ }
63
+
64
+ function flushBufferedFrames(
65
+ bufferState: { value: string },
66
+ onEvent: (event: StreamEvent) => void,
67
+ setFinalResult: (value: unknown) => void
68
+ ): void {
69
+ let boundary = bufferState.value.indexOf('\n\n');
70
+ while (boundary !== -1) {
71
+ processSseFrame(bufferState.value.slice(0, boundary), onEvent, setFinalResult);
72
+ bufferState.value = bufferState.value.slice(boundary + 2);
73
+ boundary = bufferState.value.indexOf('\n\n');
74
+ }
75
+ }
76
+
77
+ export async function readSseStreamResult<TFinal>(
78
+ response: Response,
79
+ onEvent: (event: StreamEvent) => void
80
+ ): Promise<TFinal> {
81
+ const reader = response.body?.getReader();
82
+ if (!reader) {
83
+ throw new Error('SSE response body unavailable');
84
+ }
85
+
86
+ const decoder = new TextDecoder();
87
+ const bufferState = { value: '' };
88
+ let finalResult: unknown = undefined;
89
+
90
+ try {
91
+ while (true) {
92
+ const { value, done } = await reader.read();
93
+ if (done) {
94
+ break;
95
+ }
96
+ bufferState.value += decoder.decode(value, { stream: true });
97
+ flushBufferedFrames(bufferState, onEvent, (nextValue) => {
98
+ finalResult = nextValue;
99
+ });
100
+ }
101
+ if (bufferState.value.trim()) {
102
+ processSseFrame(bufferState.value, onEvent, (nextValue) => {
103
+ finalResult = nextValue;
104
+ });
105
+ }
106
+ } finally {
107
+ reader.releaseLock();
108
+ }
109
+
110
+ if (finalResult === undefined) {
111
+ throw new Error('stream ended without final event');
112
+ }
113
+ return finalResult as TFinal;
114
+ }
@@ -0,0 +1,24 @@
1
+ export function resolveTransportWebSocketUrl(base: string, path: string): string {
2
+ const normalizedBase = base.replace(/\/$/, '');
3
+ try {
4
+ const resolved = new URL(normalizedBase, window.location.origin);
5
+ const protocol =
6
+ resolved.protocol === 'https:'
7
+ ? 'wss:'
8
+ : resolved.protocol === 'http:'
9
+ ? 'ws:'
10
+ : resolved.protocol;
11
+ return `${protocol}//${resolved.host}${path}`;
12
+ } catch {
13
+ if (normalizedBase.startsWith('wss://') || normalizedBase.startsWith('ws://')) {
14
+ return `${normalizedBase}${path}`;
15
+ }
16
+ if (normalizedBase.startsWith('https://')) {
17
+ return `${normalizedBase.replace(/^https:/, 'wss:')}${path}`;
18
+ }
19
+ if (normalizedBase.startsWith('http://')) {
20
+ return `${normalizedBase.replace(/^http:/, 'ws:')}${path}`;
21
+ }
22
+ return `${normalizedBase}${path}`;
23
+ }
24
+ }
@@ -0,0 +1,41 @@
1
+ import type { WsEvent } from '@/api/types';
2
+
3
+ export type RequestMethod = 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
4
+
5
+ export type RequestInput = {
6
+ method: RequestMethod;
7
+ path: string;
8
+ body?: unknown;
9
+ };
10
+
11
+ export type StreamEvent = {
12
+ name: string;
13
+ payload?: unknown;
14
+ };
15
+
16
+ export type StreamInput = {
17
+ method: Extract<RequestMethod, 'GET' | 'POST'>;
18
+ path: string;
19
+ body?: unknown;
20
+ signal?: AbortSignal;
21
+ onEvent: (event: StreamEvent) => void;
22
+ };
23
+
24
+ export type StreamSession<TFinal = unknown> = {
25
+ finished: Promise<TFinal>;
26
+ cancel: () => void;
27
+ };
28
+
29
+ export type AppEvent = WsEvent;
30
+
31
+ export type AppTransport = {
32
+ request<T>(input: RequestInput): Promise<T>;
33
+ openStream<TFinal = unknown>(input: StreamInput): StreamSession<TFinal>;
34
+ subscribe(handler: (event: AppEvent) => void): () => void;
35
+ };
36
+
37
+ export type RemoteRuntimeInfo = {
38
+ mode: 'remote';
39
+ protocolVersion: 1;
40
+ wsPath: string;
41
+ };