@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.
- package/CHANGELOG.md +13 -0
- package/dist/assets/{ChannelsList-BgJbR6E9.js → ChannelsList-CJy2GG1a.js} +1 -1
- package/dist/assets/ChatPage-C7WxI8VY.js +41 -0
- package/dist/assets/{DocBrowser-Dw9BGO1m.js → DocBrowser-Nu-ae-eS.js} +1 -1
- package/dist/assets/{LogoBadge-CLc2B6st.js → LogoBadge-DbbMxPlr.js} +1 -1
- package/dist/assets/{MarketplacePage-ChqCNL7k.js → MarketplacePage-BQYQPeg2.js} +2 -2
- package/dist/assets/{McpMarketplacePage-B3PF-7ED.js → McpMarketplacePage-kiMJbS8r.js} +1 -1
- package/dist/assets/{ModelConfig-Dqz_NOow.js → ModelConfig-DRQ07Snj.js} +1 -1
- package/dist/assets/{ProvidersList-D2WaZShJ.js → ProvidersList-C0NjzKX1.js} +1 -1
- package/dist/assets/RemoteAccessPage-DVJ5hBNJ.js +1 -0
- package/dist/assets/{RuntimeConfig-TDxQLuGy.js → RuntimeConfig-BkYWyRW7.js} +1 -1
- package/dist/assets/{SearchConfig-gba64nGn.js → SearchConfig-DZTW8Wnq.js} +1 -1
- package/dist/assets/{SecretsConfig-DpL8wgly.js → SecretsConfig-WMcwg5KV.js} +2 -2
- package/dist/assets/{SessionsConfig-CAODVTNW.js → SessionsConfig-CWtCXQRn.js} +1 -1
- package/dist/assets/{chat-message-CSG50nNb.js → chat-message-BcjCODYN.js} +1 -1
- package/dist/assets/index-BOhlxC12.js +8 -0
- package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
- package/dist/assets/{label-3T28q3PJ.js → label-DOWMfYPL.js} +1 -1
- package/dist/assets/{page-layout-BrXOQeua.js → page-layout-DQtmTgqR.js} +1 -1
- package/dist/assets/popover-k11l1-ko.js +1 -0
- package/dist/assets/{security-config-oGAhN4Zf.js → security-config-FFy-bOJb.js} +1 -1
- package/dist/assets/skeleton-DQ4QRdSe.js +1 -0
- package/dist/assets/{status-dot-QL3hmT1d.js → status-dot-CsZRxe8p.js} +1 -1
- package/dist/assets/{switch-Dbt2kUg2.js → switch-DfMy8G96.js} +1 -1
- package/dist/assets/{tabs-custom-y5hdkzXk.js → tabs-custom-CITPDGXY.js} +1 -1
- package/dist/assets/{useConfirmDialog-B4zwBVbl.js → useConfirmDialog-Dr39o-0I.js} +1 -1
- package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
- package/dist/index.html +3 -3
- package/package.json +3 -3
- package/src/account/managers/account.manager.ts +8 -1
- package/src/account/stores/account.store.ts +3 -0
- package/src/api/api-base.ts +16 -0
- package/src/api/client.test.ts +69 -0
- package/src/api/client.ts +29 -87
- package/src/api/config.stream.test.ts +115 -0
- package/src/api/config.ts +50 -125
- package/src/api/raw-client.ts +87 -0
- package/src/components/chat/ChatSidebar.test.tsx +134 -1
- package/src/components/chat/ChatSidebar.tsx +87 -37
- package/src/components/chat/chat-session-label.service.ts +34 -0
- package/src/components/chat/chat-sidebar-session-item.tsx +147 -0
- package/src/components/chat/ncp/NcpChatPage.tsx +3 -10
- package/src/components/chat/ncp/ncp-app-client-fetch.test.ts +69 -0
- package/src/components/chat/ncp/ncp-app-client-fetch.ts +127 -0
- package/src/components/remote/RemoteAccessPage.test.tsx +103 -0
- package/src/components/remote/RemoteAccessPage.tsx +28 -93
- package/src/hooks/use-realtime-query-bridge.ts +77 -71
- package/src/lib/i18n.remote.ts +20 -8
- package/src/remote/managers/remote-access.manager.ts +13 -0
- package/src/remote/remote-access-feedback.service.test.ts +75 -0
- package/src/remote/remote-access-feedback.service.ts +195 -0
- package/src/transport/app-client.ts +1 -1
- package/src/transport/local.transport.ts +8 -125
- package/src/transport/remote.transport.ts +44 -74
- package/src/transport/sse-stream.ts +114 -0
- package/src/transport/transport-websocket-url.ts +24 -0
- package/dist/assets/ChatPage-Bv9UJPse.js +0 -38
- package/dist/assets/RemoteAccessPage-D_l9irp4.js +0 -1
- package/dist/assets/index-DaEflNCE.js +0 -8
- package/dist/assets/popover-BrBJjElY.js +0 -1
- package/dist/assets/skeleton-CIPQUKo2.js +0 -1
package/src/lib/i18n.remote.ts
CHANGED
|
@@ -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
|
-
|
|
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
|
|
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: '
|
|
53
|
+
remoteStatusIssueDetailTitle: { zh: '下一步', en: 'Next Step' },
|
|
54
|
+
remoteStatusRecoveryTitle: { zh: '推荐操作', en: 'Recommended Next Step' },
|
|
49
55
|
remoteStatusIssueDetailGeneric: {
|
|
50
|
-
zh: '
|
|
51
|
-
en: '
|
|
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
|
|
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/
|
|
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
|
|
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,
|
|
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(
|
|
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
|
|
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
|
-
|
|
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/
|
|
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 =
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
370
|
-
|
|
371
|
-
|
|
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
|
-
|
|
380
|
-
|
|
381
|
-
|
|
382
|
-
|
|
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 === '
|
|
390
|
-
|
|
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
|
}
|