@nextclaw/ui 0.9.10 → 0.9.12

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 (61) hide show
  1. package/CHANGELOG.md +13 -0
  2. package/dist/assets/{ChannelsList-BgJbR6E9.js → ChannelsList-CJy2GG1a.js} +1 -1
  3. package/dist/assets/ChatPage-C7WxI8VY.js +41 -0
  4. package/dist/assets/{DocBrowser-Dw9BGO1m.js → DocBrowser-Nu-ae-eS.js} +1 -1
  5. package/dist/assets/{LogoBadge-CLc2B6st.js → LogoBadge-DbbMxPlr.js} +1 -1
  6. package/dist/assets/{MarketplacePage-ChqCNL7k.js → MarketplacePage-BQYQPeg2.js} +2 -2
  7. package/dist/assets/{McpMarketplacePage-B3PF-7ED.js → McpMarketplacePage-kiMJbS8r.js} +1 -1
  8. package/dist/assets/{ModelConfig-Dqz_NOow.js → ModelConfig-DRQ07Snj.js} +1 -1
  9. package/dist/assets/{ProvidersList-D2WaZShJ.js → ProvidersList-C0NjzKX1.js} +1 -1
  10. package/dist/assets/RemoteAccessPage-DVJ5hBNJ.js +1 -0
  11. package/dist/assets/{RuntimeConfig-TDxQLuGy.js → RuntimeConfig-BkYWyRW7.js} +1 -1
  12. package/dist/assets/{SearchConfig-gba64nGn.js → SearchConfig-DZTW8Wnq.js} +1 -1
  13. package/dist/assets/{SecretsConfig-DpL8wgly.js → SecretsConfig-WMcwg5KV.js} +2 -2
  14. package/dist/assets/{SessionsConfig-CAODVTNW.js → SessionsConfig-CWtCXQRn.js} +1 -1
  15. package/dist/assets/{chat-message-CSG50nNb.js → chat-message-BcjCODYN.js} +1 -1
  16. package/dist/assets/index-BOhlxC12.js +8 -0
  17. package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
  18. package/dist/assets/{label-3T28q3PJ.js → label-DOWMfYPL.js} +1 -1
  19. package/dist/assets/{page-layout-BrXOQeua.js → page-layout-DQtmTgqR.js} +1 -1
  20. package/dist/assets/popover-k11l1-ko.js +1 -0
  21. package/dist/assets/{security-config-oGAhN4Zf.js → security-config-FFy-bOJb.js} +1 -1
  22. package/dist/assets/skeleton-DQ4QRdSe.js +1 -0
  23. package/dist/assets/{status-dot-QL3hmT1d.js → status-dot-CsZRxe8p.js} +1 -1
  24. package/dist/assets/{switch-Dbt2kUg2.js → switch-DfMy8G96.js} +1 -1
  25. package/dist/assets/{tabs-custom-y5hdkzXk.js → tabs-custom-CITPDGXY.js} +1 -1
  26. package/dist/assets/{useConfirmDialog-B4zwBVbl.js → useConfirmDialog-Dr39o-0I.js} +1 -1
  27. package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
  28. package/dist/index.html +3 -3
  29. package/package.json +3 -3
  30. package/src/account/managers/account.manager.ts +8 -1
  31. package/src/account/stores/account.store.ts +3 -0
  32. package/src/api/api-base.ts +16 -0
  33. package/src/api/client.test.ts +69 -0
  34. package/src/api/client.ts +29 -87
  35. package/src/api/config.stream.test.ts +115 -0
  36. package/src/api/config.ts +50 -125
  37. package/src/api/raw-client.ts +87 -0
  38. package/src/components/chat/ChatSidebar.test.tsx +134 -1
  39. package/src/components/chat/ChatSidebar.tsx +87 -37
  40. package/src/components/chat/chat-session-label.service.ts +34 -0
  41. package/src/components/chat/chat-sidebar-session-item.tsx +147 -0
  42. package/src/components/chat/ncp/NcpChatPage.tsx +3 -10
  43. package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +69 -0
  44. package/src/components/chat/ncp/ncp-app-client-fetch.ts +127 -0
  45. package/src/components/remote/RemoteAccessPage.test.tsx +103 -0
  46. package/src/components/remote/RemoteAccessPage.tsx +28 -93
  47. package/src/hooks/use-realtime-query-bridge.ts +77 -71
  48. package/src/lib/i18n.remote.ts +20 -8
  49. package/src/remote/managers/remote-access.manager.ts +13 -0
  50. package/src/remote/remote-access-feedback.service.test.ts +75 -0
  51. package/src/remote/remote-access-feedback.service.ts +195 -0
  52. package/src/transport/app-client.ts +1 -1
  53. package/src/transport/local.transport.ts +8 -125
  54. package/src/transport/remote.transport.ts +44 -74
  55. package/src/transport/sse-stream.ts +114 -0
  56. package/src/transport/transport-websocket-url.ts +24 -0
  57. package/dist/assets/ChatPage-Bv9UJPse.js +0 -38
  58. package/dist/assets/RemoteAccessPage-D_l9irp4.js +0 -1
  59. package/dist/assets/index-DaEflNCE.js +0 -8
  60. package/dist/assets/popover-BrBJjElY.js +0 -1
  61. package/dist/assets/skeleton-CIPQUKo2.js +0 -1
@@ -40,23 +40,34 @@ export const REMOTE_LABELS: Record<string, { zh: string; en: string }> = {
40
40
  zh: '远程访问已经开启,但后台服务没有运行。拉起后才会真正连到网页版。',
41
41
  en: 'Remote access is enabled, but the managed service is not running yet. Start it to connect to the web app.'
42
42
  },
43
- remoteStatusIssueTitle: { zh: '远程连接当前有异常', en: 'The remote connection needs attention' },
43
+ remoteStatusReauthorizationTitle: { zh: '登录已过期,请重新登录 NextClaw', en: 'Your sign-in expired. Sign in to NextClaw again.' },
44
+ remoteStatusReauthorizationDescription: {
45
+ zh: '为了保护你的账号安全,远程访问已暂停。重新登录后会自动恢复,不需要重新配置设备。',
46
+ en: 'Remote access is paused to protect your account. Sign in again and it will recover automatically without reconfiguring this device.'
47
+ },
48
+ remoteStatusIssueTitle: { zh: '远程访问暂时没有连上', en: 'Remote access is temporarily offline' },
44
49
  remoteStatusIssueDescription: {
45
- zh: '账号和设备配置都还在,但当前没有稳定连上平台。你可以重新连接,或先去设备列表确认这台设备的状态。',
46
- en: 'Your account and device settings are still there, but this device is not stably connected to the platform right now. Reconnect it or check the device list first.'
50
+ zh: '设备配置还在,但当前没有稳定连上平台。你可以先重新连接;如果问题持续,再重新登录或稍后再试。',
51
+ en: 'Your device settings are still there, but this device is not stably connected to the platform right now. Reconnect first, then sign in again or try later if it keeps happening.'
47
52
  },
48
- remoteStatusIssueDetailTitle: { zh: '当前提示', en: 'Current Hint' },
53
+ remoteStatusIssueDetailTitle: { zh: '下一步', en: 'Next Step' },
54
+ remoteStatusRecoveryTitle: { zh: '推荐操作', en: 'Recommended Next Step' },
49
55
  remoteStatusIssueDetailGeneric: {
50
- zh: '连接曾经建立,但随后被平台侧主动关闭。常见原因包括登录态失效、平台侧中继不可用,或云端配额暂时触顶。',
51
- en: 'The connection was established and then closed by the platform. Common causes include an expired session, an unavailable relay, or a temporary cloud quota limit.'
56
+ zh: '远程访问暂时不可用。你可以先重新连接;如果问题持续,再重新登录或稍后再试。',
57
+ en: 'Remote access is temporarily unavailable. Reconnect first, then sign in again or try later if the issue continues.'
52
58
  },
53
59
  remoteStatusIssueDetailServiceStopped: {
54
- zh: '本地托管服务没有在运行,所以远程连接不会保持在线。',
55
- en: 'The local managed service is not running, so the remote connection cannot stay online.'
60
+ zh: '后台服务当前没有运行。启动后,这台设备才会重新出现在网页版设备列表里。',
61
+ en: 'The managed service is not running right now. Start it so this device can show up in the web device list again.'
62
+ },
63
+ remoteStatusReauthorizationHint: {
64
+ zh: '点击下方按钮后会打开登录页。完成登录后,这台设备会自动恢复远程访问,不需要重新配置。',
65
+ en: 'Use the button below to open the sign-in page. Once you finish signing in, this device will recover remote access automatically.'
56
66
  },
57
67
  remoteSignInAndEnable: { zh: '登录并开启远程访问', en: 'Sign In and Enable Remote Access' },
58
68
  remoteEnableNow: { zh: '开启远程访问', en: 'Enable Remote Access' },
59
69
  remoteReconnectNow: { zh: '重新连接', en: 'Reconnect' },
70
+ remoteReauthorizeNow: { zh: '重新登录并恢复远程访问', en: 'Sign In Again and Restore Remote Access' },
60
71
  remoteDisable: { zh: '关闭远程访问', en: 'Disable Remote Access' },
61
72
  remoteDeviceSummaryTitle: { zh: '当前设备', en: 'This Device' },
62
73
  remoteDeviceSummaryDescription: {
@@ -114,6 +125,7 @@ export const REMOTE_LABELS: Record<string, { zh: string; en: string }> = {
114
125
  remoteStateError: { zh: '连接异常', en: 'Error' },
115
126
  remoteStateDisconnected: { zh: '已断开', en: 'Disconnected' },
116
127
  remoteStateDisabled: { zh: '未启用', en: 'Disabled' },
128
+ remoteStateReauthorizationRequired: { zh: '需要重新登录', en: 'Sign-In Required' },
117
129
  remoteLocalOrigin: { zh: '本地服务地址', en: 'Local Origin' },
118
130
  remotePublicPlatform: { zh: '平台地址', en: 'Platform Base' },
119
131
  remoteDeviceId: { zh: '设备 ID', en: 'Device ID' },
@@ -79,6 +79,15 @@ export class RemoteAccessManager {
79
79
  });
80
80
  };
81
81
 
82
+ reauthorizeRemoteAccess = async (status: RemoteAccessView | undefined) => {
83
+ const currentStatus = status ?? (await refreshRemoteStatus());
84
+ await this.accountManager?.startBrowserSignIn({
85
+ status: currentStatus,
86
+ apiBase: useRemoteAccessStore.getState().platformApiBase,
87
+ pendingAction: { type: 'repair-remote' }
88
+ });
89
+ };
90
+
82
91
  saveAdvancedSettings = async (status: RemoteAccessView | undefined) => {
83
92
  const currentStatus = status ?? (await refreshRemoteStatus());
84
93
  const draft = useRemoteAccessStore.getState();
@@ -126,6 +135,10 @@ export class RemoteAccessManager {
126
135
  }
127
136
  if (action.type === 'enable-remote') {
128
137
  await this.applyEnabledState(true, status);
138
+ return;
139
+ }
140
+ if (action.type === 'repair-remote') {
141
+ await this.repairRemoteAccess(status);
129
142
  }
130
143
  };
131
144
 
@@ -0,0 +1,75 @@
1
+ import { beforeEach, describe, expect, it } from 'vitest';
2
+ import type { RemoteAccessView } from '@/api/remote.types';
3
+ import { setLanguage } from '@/lib/i18n';
4
+ import { buildRemoteAccessFeedbackView, requiresRemoteReauthorization } from '@/remote/remote-access-feedback.service';
5
+
6
+ function createRemoteAccessView(overrides: Partial<RemoteAccessView> = {}): RemoteAccessView {
7
+ return {
8
+ account: {
9
+ loggedIn: true,
10
+ email: 'user@example.com',
11
+ apiBase: 'https://ai-gateway-api.nextclaw.io/v1',
12
+ platformBase: 'https://ai-gateway-api.nextclaw.io'
13
+ },
14
+ settings: {
15
+ enabled: true,
16
+ deviceName: 'MacBook Pro',
17
+ platformApiBase: 'https://ai-gateway-api.nextclaw.io/v1'
18
+ },
19
+ service: {
20
+ running: true,
21
+ currentProcess: false
22
+ },
23
+ localOrigin: 'http://127.0.0.1:55667',
24
+ configuredEnabled: true,
25
+ platformBase: 'https://ai-gateway-api.nextclaw.io',
26
+ runtime: {
27
+ enabled: true,
28
+ mode: 'service',
29
+ state: 'error',
30
+ deviceName: 'MacBook Pro',
31
+ lastError: 'Remote relay closed unexpectedly.',
32
+ updatedAt: '2026-03-23T00:00:00.000Z'
33
+ },
34
+ ...overrides
35
+ };
36
+ }
37
+
38
+ describe('remote-access-feedback.service', () => {
39
+ beforeEach(() => {
40
+ setLanguage('zh');
41
+ });
42
+
43
+ it('turns token errors into a reauthorization experience', () => {
44
+ const status = createRemoteAccessView({
45
+ runtime: {
46
+ enabled: true,
47
+ mode: 'service',
48
+ state: 'error',
49
+ lastError: 'Invalid or expired token.',
50
+ updatedAt: '2026-03-23T00:00:00.000Z'
51
+ }
52
+ });
53
+
54
+ expect(requiresRemoteReauthorization(status)).toBe(true);
55
+
56
+ const feedback = buildRemoteAccessFeedbackView(status);
57
+
58
+ expect(feedback.hero.title).toBe('登录已过期,请重新登录 NextClaw');
59
+ expect(feedback.primaryAction?.kind).toBe('reauthorize');
60
+ expect(feedback.primaryAction?.label).toBe('重新登录并恢复远程访问');
61
+ expect(feedback.issueHint?.body).not.toContain('Invalid or expired token');
62
+ });
63
+
64
+ it('keeps generic reconnect guidance for non-auth runtime errors', () => {
65
+ const status = createRemoteAccessView();
66
+
67
+ expect(requiresRemoteReauthorization(status)).toBe(false);
68
+
69
+ const feedback = buildRemoteAccessFeedbackView(status);
70
+
71
+ expect(feedback.hero.title).toBe('远程访问暂时没有连上');
72
+ expect(feedback.primaryAction?.kind).toBe('repair');
73
+ expect(feedback.issueHint?.body).toBe('远程访问暂时不可用。你可以先重新连接;如果问题持续,再重新登录或稍后再试。');
74
+ });
75
+ });
@@ -0,0 +1,195 @@
1
+ import type { RemoteAccessView } from '@/api/remote.types';
2
+ import { t } from '@/lib/i18n';
3
+
4
+ type RemoteHeroView = {
5
+ badgeStatus: 'active' | 'inactive' | 'ready' | 'setup' | 'warning';
6
+ badgeLabel: string;
7
+ title: string;
8
+ description: string;
9
+ };
10
+
11
+ type RemotePrimaryAction =
12
+ | {
13
+ kind: 'sign-in-enable' | 'enable' | 'repair' | 'reauthorize';
14
+ label: string;
15
+ showRefreshIcon: boolean;
16
+ }
17
+ | null;
18
+
19
+ type RemoteIssueHint = {
20
+ title: string;
21
+ body: string;
22
+ };
23
+
24
+ export type RemoteAccessFeedbackView = {
25
+ hero: RemoteHeroView;
26
+ primaryAction: RemotePrimaryAction;
27
+ issueHint: RemoteIssueHint | null;
28
+ shouldShowIssueHint: boolean;
29
+ requiresReauthorization: boolean;
30
+ };
31
+
32
+ const AUTH_EXPIRED_PATTERNS = [
33
+ /invalid or expired token/i,
34
+ /missing bearer token/i,
35
+ /token expired/i,
36
+ /token is invalid/i,
37
+ /run "nextclaw login"/i,
38
+ /browser sign-in again/i
39
+ ];
40
+
41
+ function readRuntimeError(status: RemoteAccessView | undefined): string {
42
+ return status?.runtime?.lastError?.trim() || '';
43
+ }
44
+
45
+ export function requiresRemoteReauthorization(status: RemoteAccessView | undefined): boolean {
46
+ if (!status?.settings.enabled) {
47
+ return false;
48
+ }
49
+ const error = readRuntimeError(status);
50
+ return AUTH_EXPIRED_PATTERNS.some((pattern) => pattern.test(error));
51
+ }
52
+
53
+ export function buildRemoteAccessFeedbackView(status: RemoteAccessView | undefined): RemoteAccessFeedbackView {
54
+ const reauthorizationRequired = requiresRemoteReauthorization(status);
55
+
56
+ if (reauthorizationRequired) {
57
+ return {
58
+ hero: {
59
+ badgeStatus: 'warning',
60
+ badgeLabel: t('remoteStateReauthorizationRequired'),
61
+ title: t('remoteStatusReauthorizationTitle'),
62
+ description: t('remoteStatusReauthorizationDescription')
63
+ },
64
+ primaryAction: {
65
+ kind: 'reauthorize',
66
+ label: t('remoteReauthorizeNow'),
67
+ showRefreshIcon: false
68
+ },
69
+ issueHint: {
70
+ title: t('remoteStatusRecoveryTitle'),
71
+ body: t('remoteStatusReauthorizationHint')
72
+ },
73
+ shouldShowIssueHint: true,
74
+ requiresReauthorization: true
75
+ };
76
+ }
77
+
78
+ if (!status?.account.loggedIn) {
79
+ return {
80
+ hero: {
81
+ badgeStatus: 'setup',
82
+ badgeLabel: t('statusSetup'),
83
+ title: t('remoteStatusNeedsSignIn'),
84
+ description: t('remoteStatusNeedsSignInDescription')
85
+ },
86
+ primaryAction: {
87
+ kind: 'sign-in-enable',
88
+ label: t('remoteSignInAndEnable'),
89
+ showRefreshIcon: false
90
+ },
91
+ issueHint: null,
92
+ shouldShowIssueHint: false,
93
+ requiresReauthorization: false
94
+ };
95
+ }
96
+
97
+ if (!status.settings.enabled) {
98
+ return {
99
+ hero: {
100
+ badgeStatus: 'inactive',
101
+ badgeLabel: t('statusInactive'),
102
+ title: t('remoteStatusNeedsEnable'),
103
+ description: t('remoteStatusNeedsEnableDescription')
104
+ },
105
+ primaryAction: {
106
+ kind: 'enable',
107
+ label: t('remoteEnableNow'),
108
+ showRefreshIcon: false
109
+ },
110
+ issueHint: null,
111
+ shouldShowIssueHint: false,
112
+ requiresReauthorization: false
113
+ };
114
+ }
115
+
116
+ if (!status.service.running) {
117
+ return {
118
+ hero: {
119
+ badgeStatus: 'warning',
120
+ badgeLabel: t('remoteServiceStopped'),
121
+ title: t('remoteStatusNeedsServiceTitle'),
122
+ description: t('remoteStatusNeedsServiceDescription')
123
+ },
124
+ primaryAction: {
125
+ kind: 'repair',
126
+ label: t('remoteReconnectNow'),
127
+ showRefreshIcon: true
128
+ },
129
+ issueHint: {
130
+ title: t('remoteStatusRecoveryTitle'),
131
+ body: t('remoteStatusIssueDetailServiceStopped')
132
+ },
133
+ shouldShowIssueHint: true,
134
+ requiresReauthorization: false
135
+ };
136
+ }
137
+
138
+ if (status.runtime?.state === 'connected') {
139
+ return {
140
+ hero: {
141
+ badgeStatus: 'ready',
142
+ badgeLabel: t('statusReady'),
143
+ title: t('remoteStatusReadyTitle'),
144
+ description: t('remoteStatusReadyDescription')
145
+ },
146
+ primaryAction: {
147
+ kind: 'repair',
148
+ label: t('remoteReconnectNow'),
149
+ showRefreshIcon: true
150
+ },
151
+ issueHint: null,
152
+ shouldShowIssueHint: false,
153
+ requiresReauthorization: false
154
+ };
155
+ }
156
+
157
+ if (status.runtime?.state === 'connecting') {
158
+ return {
159
+ hero: {
160
+ badgeStatus: 'active',
161
+ badgeLabel: t('connecting'),
162
+ title: t('remoteStatusConnectingTitle'),
163
+ description: t('remoteStatusConnectingDescription')
164
+ },
165
+ primaryAction: {
166
+ kind: 'repair',
167
+ label: t('remoteReconnectNow'),
168
+ showRefreshIcon: true
169
+ },
170
+ issueHint: null,
171
+ shouldShowIssueHint: false,
172
+ requiresReauthorization: false
173
+ };
174
+ }
175
+
176
+ return {
177
+ hero: {
178
+ badgeStatus: 'warning',
179
+ badgeLabel: t('remoteStateDisconnected'),
180
+ title: t('remoteStatusIssueTitle'),
181
+ description: t('remoteStatusIssueDescription')
182
+ },
183
+ primaryAction: {
184
+ kind: 'repair',
185
+ label: t('remoteReconnectNow'),
186
+ showRefreshIcon: true
187
+ },
188
+ issueHint: {
189
+ title: t('remoteStatusRecoveryTitle'),
190
+ body: t('remoteStatusIssueDetailGeneric')
191
+ },
192
+ shouldShowIssueHint: Boolean(status.settings.enabled && status.account.loggedIn),
193
+ requiresReauthorization: false
194
+ };
195
+ }
@@ -1,4 +1,4 @@
1
- import { API_BASE } from '@/api/client';
1
+ import { API_BASE } from '@/api/api-base';
2
2
  import { LocalAppTransport } from './local.transport';
3
3
  import { RemoteSessionMultiplexTransport } from './remote.transport';
4
4
  import type { AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
@@ -1,34 +1,12 @@
1
- import { API_BASE, requestApiResponse } from '@/api/client';
1
+ import { API_BASE } from '@/api/api-base';
2
+ import { requestRawApiResponse } from '@/api/raw-client';
2
3
  import type { ApiResponse } from '@/api/types';
3
- import type { AppEvent, AppTransport, RequestInput, StreamEvent, StreamInput, StreamSession } from './transport.types';
4
+ import type { AppEvent, AppTransport, RequestInput, StreamInput, StreamSession } from './transport.types';
5
+ import { readSseStreamResult } from './sse-stream';
6
+ import { resolveTransportWebSocketUrl } from './transport-websocket-url';
4
7
 
5
8
  type EventHandler = (event: AppEvent) => void;
6
9
 
7
- function toWebSocketUrl(base: string, path: string): string {
8
- const normalizedBase = base.replace(/\/$/, '');
9
- try {
10
- const resolved = new URL(normalizedBase, window.location.origin);
11
- const protocol =
12
- resolved.protocol === 'https:'
13
- ? 'wss:'
14
- : resolved.protocol === 'http:'
15
- ? 'ws:'
16
- : resolved.protocol;
17
- return `${protocol}//${resolved.host}${path}`;
18
- } catch {
19
- if (normalizedBase.startsWith('wss://') || normalizedBase.startsWith('ws://')) {
20
- return `${normalizedBase}${path}`;
21
- }
22
- if (normalizedBase.startsWith('https://')) {
23
- return `${normalizedBase.replace(/^https:/, 'wss:')}${path}`;
24
- }
25
- if (normalizedBase.startsWith('http://')) {
26
- return `${normalizedBase.replace(/^http:/, 'ws:')}${path}`;
27
- }
28
- return `${normalizedBase}${path}`;
29
- }
30
- }
31
-
32
10
  function createTransportError(response: ApiResponse<unknown>, fallback: string): Error {
33
11
  if (!response.ok) {
34
12
  return new Error(response.error.message);
@@ -36,43 +14,6 @@ function createTransportError(response: ApiResponse<unknown>, fallback: string):
36
14
  return new Error(fallback);
37
15
  }
38
16
 
39
- function parseSseFrame(frame: string): StreamEvent | null {
40
- const lines = frame.split('\n');
41
- let name = '';
42
- const dataLines: string[] = [];
43
- for (const raw of lines) {
44
- const line = raw.trimEnd();
45
- if (!line || line.startsWith(':')) {
46
- continue;
47
- }
48
- if (line.startsWith('event:')) {
49
- name = line.slice(6).trim();
50
- continue;
51
- }
52
- if (line.startsWith('data:')) {
53
- dataLines.push(line.slice(5).trimStart());
54
- }
55
- }
56
- if (!name) {
57
- return null;
58
- }
59
-
60
- let payload: unknown = undefined;
61
- const data = dataLines.join('\n');
62
- if (data) {
63
- try {
64
- payload = JSON.parse(data);
65
- } catch {
66
- payload = data;
67
- }
68
- }
69
-
70
- return {
71
- name,
72
- payload
73
- };
74
- }
75
-
76
17
  class LocalRealtimeGateway {
77
18
  private socket: WebSocket | null = null;
78
19
  private reconnectTimer: number | null = null;
@@ -168,11 +109,11 @@ export class LocalAppTransport implements AppTransport {
168
109
  } = {}
169
110
  ) {
170
111
  const apiBase = options.apiBase ?? API_BASE;
171
- this.realtimeGateway = new LocalRealtimeGateway(toWebSocketUrl(apiBase, options.wsPath ?? '/ws'));
112
+ this.realtimeGateway = new LocalRealtimeGateway(resolveTransportWebSocketUrl(apiBase, options.wsPath ?? '/ws'));
172
113
  }
173
114
 
174
115
  async request<T>(input: RequestInput): Promise<T> {
175
- const response = await requestApiResponse<T>(input.path, {
116
+ const response = await requestRawApiResponse<T>(input.path, {
176
117
  method: input.method,
177
118
  ...(input.body !== undefined ? { body: JSON.stringify(input.body) } : {})
178
119
  });
@@ -209,69 +150,11 @@ export class LocalAppTransport implements AppTransport {
209
150
  const text = await response.text();
210
151
  throw new Error(text.trim() || `HTTP ${response.status}`);
211
152
  }
212
-
213
- const reader = response.body?.getReader();
214
- if (!reader) {
215
- throw new Error('SSE response body unavailable');
216
- }
217
-
218
- const decoder = new TextDecoder();
219
- let buffer = '';
220
- let finalResult: unknown;
221
-
222
153
  try {
223
- while (true) {
224
- const { value, done } = await reader.read();
225
- if (done) {
226
- break;
227
- }
228
- buffer += decoder.decode(value, { stream: true });
229
- let boundary = buffer.indexOf('\n\n');
230
- while (boundary !== -1) {
231
- const frame = parseSseFrame(buffer.slice(0, boundary));
232
- buffer = buffer.slice(boundary + 2);
233
- if (frame) {
234
- if (frame.name === 'final') {
235
- finalResult = frame.payload;
236
- } else if (frame.name === 'error') {
237
- const errorPayload = frame.payload as { message?: string } | string | undefined;
238
- const message = typeof errorPayload === 'string'
239
- ? errorPayload
240
- : errorPayload?.message ?? 'chat stream failed';
241
- throw new Error(message);
242
- } else {
243
- input.onEvent(frame);
244
- }
245
- }
246
- boundary = buffer.indexOf('\n\n');
247
- }
248
- }
249
-
250
- if (buffer.trim()) {
251
- const frame = parseSseFrame(buffer);
252
- if (frame) {
253
- if (frame.name === 'final') {
254
- finalResult = frame.payload;
255
- } else if (frame.name === 'error') {
256
- const errorPayload = frame.payload as { message?: string } | string | undefined;
257
- const message = typeof errorPayload === 'string'
258
- ? errorPayload
259
- : errorPayload?.message ?? 'chat stream failed';
260
- throw new Error(message);
261
- } else {
262
- input.onEvent(frame);
263
- }
264
- }
265
- }
154
+ return await readSseStreamResult<TFinal>(response, input.onEvent);
266
155
  } finally {
267
- reader.releaseLock();
268
156
  input.signal?.removeEventListener('abort', abort);
269
157
  }
270
-
271
- if (finalResult === undefined) {
272
- throw new Error('stream ended without final event');
273
- }
274
- return finalResult as TFinal;
275
158
  })();
276
159
 
277
160
  return {
@@ -1,6 +1,7 @@
1
- import { API_BASE } from '@/api/client';
1
+ import { API_BASE } from '@/api/api-base';
2
2
  import type { ApiError } from '@/api/types';
3
3
  import type { AppEvent, AppTransport, RemoteRuntimeInfo, RequestInput, StreamInput, StreamSession } from './transport.types';
4
+ import { resolveTransportWebSocketUrl } from './transport-websocket-url';
4
5
 
5
6
  type RemoteTarget = {
6
7
  method: string;
@@ -34,31 +35,6 @@ type PendingStream = {
34
35
  reject: (error: Error) => void;
35
36
  };
36
37
 
37
- function createWsUrl(apiBase: string, wsPath: string): string {
38
- const normalizedBase = apiBase.replace(/\/$/, '');
39
- try {
40
- const resolved = new URL(normalizedBase, window.location.origin);
41
- const protocol =
42
- resolved.protocol === 'https:'
43
- ? 'wss:'
44
- : resolved.protocol === 'http:'
45
- ? 'ws:'
46
- : resolved.protocol;
47
- return `${protocol}//${resolved.host}${wsPath}`;
48
- } catch {
49
- if (normalizedBase.startsWith('wss://') || normalizedBase.startsWith('ws://')) {
50
- return `${normalizedBase}${wsPath}`;
51
- }
52
- if (normalizedBase.startsWith('https://')) {
53
- return `${normalizedBase.replace(/^https:/, 'wss:')}${wsPath}`;
54
- }
55
- if (normalizedBase.startsWith('http://')) {
56
- return `${normalizedBase.replace(/^http:/, 'ws:')}${wsPath}`;
57
- }
58
- return `${normalizedBase}${wsPath}`;
59
- }
60
- }
61
-
62
38
  function normalizeApiError(body: unknown, status: number, fallback: string): Error {
63
39
  if (typeof body === 'object' && body && 'ok' in body) {
64
40
  const typed = body as { ok?: boolean; error?: ApiError; data?: unknown };
@@ -243,7 +219,7 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
243
219
  return await this.connectPromise;
244
220
  }
245
221
 
246
- const wsUrl = createWsUrl(this.apiBase, this.runtime.wsPath);
222
+ const wsUrl = resolveTransportWebSocketUrl(this.apiBase, this.runtime.wsPath);
247
223
  this.manualClose = false;
248
224
  this.connectPromise = new Promise<void>((innerResolve, innerReject) => {
249
225
  const socket = new WebSocket(wsUrl);
@@ -330,69 +306,63 @@ export class RemoteSessionMultiplexTransport implements AppTransport {
330
306
  }
331
307
 
332
308
  private handleFrame(frame: RemoteBrowserFrame): void {
333
- if (frame.type === 'response') {
334
- const pending = this.pendingRequests.get(frame.id);
335
- if (!pending) {
336
- return;
337
- }
338
- this.pendingRequests.delete(frame.id);
339
- if (frame.status >= 400) {
340
- pending.reject(normalizeApiError(frame.body, frame.status, 'Remote request failed.'));
341
- return;
342
- }
343
- try {
344
- pending.resolve(unwrapApiBody(frame.body));
345
- } catch (error) {
346
- pending.reject(error instanceof Error ? error : new Error(String(error)));
347
- }
309
+ if (frame.type === 'response' || frame.type === 'request.error') {
310
+ this.handleRequestFrame(frame);
348
311
  return;
349
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
+ }
350
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);
351
332
  if (frame.type === 'request.error') {
352
- const pending = this.pendingRequests.get(frame.id);
353
- if (!pending) {
354
- return;
355
- }
356
- this.pendingRequests.delete(frame.id);
357
333
  pending.reject(new Error(frame.message));
358
334
  return;
359
335
  }
360
-
361
- if (frame.type === 'stream.event') {
362
- this.pendingStreams.get(frame.streamId)?.onEvent({
363
- name: frame.event,
364
- payload: frame.payload
365
- });
336
+ if (frame.status >= 400) {
337
+ pending.reject(normalizeApiError(frame.body, frame.status, 'Remote request failed.'));
366
338
  return;
367
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
+ }
368
346
 
369
- if (frame.type === 'stream.end') {
370
- const pending = this.pendingStreams.get(frame.streamId);
371
- if (!pending) {
372
- return;
373
- }
374
- this.pendingStreams.delete(frame.streamId);
375
- pending.resolve(frame.result);
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) {
376
350
  return;
377
351
  }
378
-
379
- if (frame.type === 'stream.error') {
380
- const pending = this.pendingStreams.get(frame.streamId);
381
- if (!pending) {
382
- return;
352
+ if (frame.type === 'stream.event') {
353
+ try {
354
+ pending.onEvent({ name: frame.event, payload: frame.payload });
355
+ } catch (error) {
356
+ this.pendingStreams.delete(frame.streamId);
357
+ pending.reject(error instanceof Error ? error : new Error(String(error)));
383
358
  }
384
- this.pendingStreams.delete(frame.streamId);
385
- pending.reject(new Error(frame.message));
386
359
  return;
387
360
  }
388
-
389
- if (frame.type === 'event') {
390
- this.emit(frame.event);
361
+ this.pendingStreams.delete(frame.streamId);
362
+ if (frame.type === 'stream.end') {
363
+ pending.resolve(frame.result);
391
364
  return;
392
365
  }
393
-
394
- if (frame.type === 'connection.error') {
395
- this.emit({ type: 'connection.error', payload: { message: frame.message } });
396
- }
366
+ pending.reject(new Error(frame.message));
397
367
  }
398
368
  }