@nextclaw/ui 0.9.11 → 0.9.13
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 +14 -0
- package/dist/assets/{ChannelsList-Brc1qLSU.js → ChannelsList-bROKR37R.js} +1 -1
- package/dist/assets/ChatPage-B9dHVmrV.js +41 -0
- package/dist/assets/{DocBrowser-xLVf1p4L.js → DocBrowser-S-1-qnZQ.js} +1 -1
- package/dist/assets/{LogoBadge-CcTyimdr.js → LogoBadge-t1JzzCtI.js} +1 -1
- package/dist/assets/{MarketplacePage-Bk-qXxyh.js → MarketplacePage-CzIHYJpM.js} +2 -2
- package/dist/assets/{McpMarketplacePage-gFqAYekc.js → McpMarketplacePage-BTJdjNQ1.js} +1 -1
- package/dist/assets/{ModelConfig-DnKNTuw6.js → ModelConfig-BD4o3Kna.js} +1 -1
- package/dist/assets/{ProvidersList-Cjr8EFu_.js → ProvidersList-BOQArFRk.js} +1 -1
- package/dist/assets/RemoteAccessPage-CYNQ53xu.js +1 -0
- package/dist/assets/{RuntimeConfig-CttN--Tv.js → RuntimeConfig-B0B73pye.js} +1 -1
- package/dist/assets/{SearchConfig-D-GzinsL.js → SearchConfig-CKy2QkAP.js} +1 -1
- package/dist/assets/{SecretsConfig-BvqQq4Ds.js → SecretsConfig-BpZLUu88.js} +2 -2
- package/dist/assets/{SessionsConfig-DbtnLmI6.js → SessionsConfig-CoFI6Fa2.js} +1 -1
- package/dist/assets/{chat-message-DYQjL1tD.js → chat-message-D3jZIASl.js} +1 -1
- package/dist/assets/index-CmGwUgcl.js +8 -0
- package/dist/assets/{index-DfEAJJsA.css → index-SGSkQCPi.css} +1 -1
- package/dist/assets/{label-DBSKOMGE.js → label-BOvIOmQx.js} +1 -1
- package/dist/assets/{page-layout-B5th9UzR.js → page-layout-PG3cwSpz.js} +1 -1
- package/dist/assets/popover-BB-kINz7.js +1 -0
- package/dist/assets/{security-config-D72JskP5.js → security-config-Bb6l-viE.js} +1 -1
- package/dist/assets/skeleton-CLSc5FYO.js +1 -0
- package/dist/assets/{status-dot-CU5ZpOn1.js → status-dot-Behu7kDZ.js} +1 -1
- package/dist/assets/{switch-BdaXEtXk.js → switch-CvNG9775.js} +1 -1
- package/dist/assets/{tabs-custom-BVhSoteN.js → tabs-custom-CUdBQO_7.js} +1 -1
- package/dist/assets/{useConfirmDialog-Dugi9V-Z.js → useConfirmDialog-CLLe2uIJ.js} +1 -1
- package/dist/assets/{vendor-CmQZsDAE.js → vendor-TJ2hy_Lv.js} +87 -82
- package/dist/index.html +3 -3
- package/package.json +4 -4
- 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 +49 -121
- 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/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.test.ts +49 -0
- package/src/transport/app-client.ts +23 -7
- package/src/transport/local.transport.ts +3 -2
- package/src/transport/remote.transport.ts +7 -2
- package/dist/assets/ChatPage-DmGI776q.js +0 -38
- package/dist/assets/RemoteAccessPage-Rzi5a6Gc.js +0 -1
- package/dist/assets/index-ClLy_7T2.js +0 -8
- package/dist/assets/popover-BEIWRoeP.js +0 -1
- package/dist/assets/skeleton-B_Pn9x0i.js +0 -1
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
import { createNcpAppClientFetch } from '@/components/chat/ncp/ncp-app-client-fetch';
|
|
2
|
+
|
|
3
|
+
const mocks = vi.hoisted(() => ({
|
|
4
|
+
request: vi.fn(),
|
|
5
|
+
openStream: vi.fn()
|
|
6
|
+
}));
|
|
7
|
+
|
|
8
|
+
vi.mock('@/transport', () => ({
|
|
9
|
+
appClient: {
|
|
10
|
+
request: mocks.request,
|
|
11
|
+
openStream: mocks.openStream
|
|
12
|
+
}
|
|
13
|
+
}));
|
|
14
|
+
|
|
15
|
+
describe('ncp-app-client-fetch', () => {
|
|
16
|
+
beforeEach(() => {
|
|
17
|
+
mocks.request.mockReset();
|
|
18
|
+
mocks.openStream.mockReset();
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it('routes JSON requests through appClient.request', async () => {
|
|
22
|
+
mocks.request.mockResolvedValue({ stopped: true });
|
|
23
|
+
const fetchImpl = createNcpAppClientFetch();
|
|
24
|
+
|
|
25
|
+
const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/abort', {
|
|
26
|
+
method: 'POST',
|
|
27
|
+
headers: {
|
|
28
|
+
accept: 'application/json',
|
|
29
|
+
'content-type': 'application/json'
|
|
30
|
+
},
|
|
31
|
+
body: JSON.stringify({ sessionId: 's1' })
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
expect(mocks.request).toHaveBeenCalledWith({
|
|
35
|
+
method: 'POST',
|
|
36
|
+
path: '/api/ncp/agent/abort',
|
|
37
|
+
body: { sessionId: 's1' }
|
|
38
|
+
});
|
|
39
|
+
expect(response.ok).toBe(true);
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
it('re-encodes appClient stream events as SSE frames', async () => {
|
|
43
|
+
mocks.openStream.mockImplementation(({ onEvent }) => {
|
|
44
|
+
onEvent({ name: 'ncp-event', payload: { type: 'message.chunk', payload: { text: 'hello' } } });
|
|
45
|
+
return {
|
|
46
|
+
finished: Promise.resolve(undefined),
|
|
47
|
+
cancel: vi.fn()
|
|
48
|
+
};
|
|
49
|
+
});
|
|
50
|
+
const fetchImpl = createNcpAppClientFetch();
|
|
51
|
+
|
|
52
|
+
const response = await fetchImpl('http://127.0.0.1:55667/api/ncp/agent/stream?sessionId=s1', {
|
|
53
|
+
method: 'GET',
|
|
54
|
+
headers: {
|
|
55
|
+
accept: 'text/event-stream'
|
|
56
|
+
}
|
|
57
|
+
});
|
|
58
|
+
const text = await response.text();
|
|
59
|
+
|
|
60
|
+
expect(mocks.openStream).toHaveBeenCalledWith({
|
|
61
|
+
method: 'GET',
|
|
62
|
+
path: '/api/ncp/agent/stream?sessionId=s1',
|
|
63
|
+
signal: undefined,
|
|
64
|
+
onEvent: expect.any(Function)
|
|
65
|
+
});
|
|
66
|
+
expect(text).toContain('event: ncp-event');
|
|
67
|
+
expect(text).toContain('"text":"hello"');
|
|
68
|
+
});
|
|
69
|
+
});
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
import { API_BASE } from '@/api/api-base';
|
|
2
|
+
import { appClient } from '@/transport';
|
|
3
|
+
|
|
4
|
+
type FetchLike = typeof fetch;
|
|
5
|
+
|
|
6
|
+
export function createNcpAppClientFetch(): FetchLike {
|
|
7
|
+
return async (input, init) => {
|
|
8
|
+
const request = toRequestSnapshot(input, init);
|
|
9
|
+
if (isSseRequest(request)) {
|
|
10
|
+
return createSseResponse(request);
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
try {
|
|
14
|
+
const data = await appClient.request<unknown>({
|
|
15
|
+
method: request.method,
|
|
16
|
+
path: request.path,
|
|
17
|
+
...(request.body !== undefined ? { body: request.body } : {})
|
|
18
|
+
});
|
|
19
|
+
return new Response(JSON.stringify(data ?? {}), {
|
|
20
|
+
status: 200,
|
|
21
|
+
headers: {
|
|
22
|
+
'content-type': 'application/json'
|
|
23
|
+
}
|
|
24
|
+
});
|
|
25
|
+
} catch (error) {
|
|
26
|
+
return new Response(error instanceof Error ? error.message : String(error), {
|
|
27
|
+
status: 500,
|
|
28
|
+
headers: {
|
|
29
|
+
'content-type': 'text/plain; charset=utf-8'
|
|
30
|
+
}
|
|
31
|
+
});
|
|
32
|
+
}
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
type RequestSnapshot = {
|
|
37
|
+
method: 'GET' | 'POST' | 'PUT' | 'PATCH' | 'DELETE';
|
|
38
|
+
path: string;
|
|
39
|
+
body?: unknown;
|
|
40
|
+
signal?: AbortSignal;
|
|
41
|
+
headers: Headers;
|
|
42
|
+
};
|
|
43
|
+
|
|
44
|
+
function toRequestSnapshot(input: URL | string | Request, init?: RequestInit): RequestSnapshot {
|
|
45
|
+
const request = input instanceof Request ? input : null;
|
|
46
|
+
const url = new URL(
|
|
47
|
+
typeof input === 'string'
|
|
48
|
+
? input
|
|
49
|
+
: input instanceof URL
|
|
50
|
+
? input.toString()
|
|
51
|
+
: input.url,
|
|
52
|
+
API_BASE
|
|
53
|
+
);
|
|
54
|
+
const headers = new Headers(init?.headers ?? request?.headers);
|
|
55
|
+
const method = ((init?.method ?? request?.method ?? 'GET').toUpperCase()) as RequestSnapshot['method'];
|
|
56
|
+
return {
|
|
57
|
+
method,
|
|
58
|
+
path: `${url.pathname}${url.search}`,
|
|
59
|
+
body: parseRequestBody(init?.body),
|
|
60
|
+
signal: init?.signal ?? request?.signal ?? undefined,
|
|
61
|
+
headers
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
function parseRequestBody(body: BodyInit | null | undefined): unknown {
|
|
66
|
+
if (body === undefined || body === null) {
|
|
67
|
+
return undefined;
|
|
68
|
+
}
|
|
69
|
+
if (typeof body === 'string') {
|
|
70
|
+
try {
|
|
71
|
+
return JSON.parse(body);
|
|
72
|
+
} catch {
|
|
73
|
+
return body;
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
return body;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function isSseRequest(request: RequestSnapshot): boolean {
|
|
80
|
+
const accept = request.headers.get('accept')?.toLowerCase() ?? '';
|
|
81
|
+
return accept.includes('text/event-stream');
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
function createSseResponse(request: RequestSnapshot): Response {
|
|
85
|
+
const encoder = new TextEncoder();
|
|
86
|
+
let session: ReturnType<typeof appClient.openStream<unknown>> | null = null;
|
|
87
|
+
|
|
88
|
+
const stream = new ReadableStream<Uint8Array>({
|
|
89
|
+
start(controller) {
|
|
90
|
+
session = appClient.openStream<unknown>({
|
|
91
|
+
method: request.method === 'GET' ? 'GET' : 'POST',
|
|
92
|
+
path: request.path,
|
|
93
|
+
...(request.body !== undefined ? { body: request.body } : {}),
|
|
94
|
+
signal: request.signal,
|
|
95
|
+
onEvent: (event) => {
|
|
96
|
+
controller.enqueue(encoder.encode(encodeSseFrame(event.name, event.payload)));
|
|
97
|
+
}
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
void session.finished
|
|
101
|
+
.then(() => {
|
|
102
|
+
controller.close();
|
|
103
|
+
})
|
|
104
|
+
.catch((error) => {
|
|
105
|
+
controller.enqueue(encoder.encode(encodeSseFrame('error', {
|
|
106
|
+
message: error instanceof Error ? error.message : String(error)
|
|
107
|
+
})));
|
|
108
|
+
controller.close();
|
|
109
|
+
});
|
|
110
|
+
},
|
|
111
|
+
cancel() {
|
|
112
|
+
session?.cancel();
|
|
113
|
+
}
|
|
114
|
+
});
|
|
115
|
+
|
|
116
|
+
return new Response(stream, {
|
|
117
|
+
status: 200,
|
|
118
|
+
headers: {
|
|
119
|
+
'content-type': 'text/event-stream'
|
|
120
|
+
}
|
|
121
|
+
});
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
function encodeSseFrame(event: string, payload: unknown): string {
|
|
125
|
+
const data = payload === undefined ? '' : JSON.stringify(payload);
|
|
126
|
+
return `event: ${event}\ndata: ${data}\n\n`;
|
|
127
|
+
}
|
|
@@ -0,0 +1,103 @@
|
|
|
1
|
+
import { render, screen } from '@testing-library/react';
|
|
2
|
+
import userEvent from '@testing-library/user-event';
|
|
3
|
+
import { RemoteAccessPage } from '@/components/remote/RemoteAccessPage';
|
|
4
|
+
import { setLanguage } from '@/lib/i18n';
|
|
5
|
+
import { useRemoteAccessStore } from '@/remote/stores/remote-access.store';
|
|
6
|
+
|
|
7
|
+
const mocks = vi.hoisted(() => ({
|
|
8
|
+
reauthorizeRemoteAccess: vi.fn(),
|
|
9
|
+
repairRemoteAccess: vi.fn(),
|
|
10
|
+
enableRemoteAccess: vi.fn(),
|
|
11
|
+
disableRemoteAccess: vi.fn(),
|
|
12
|
+
syncStatus: vi.fn(),
|
|
13
|
+
openNextClawWeb: vi.fn(),
|
|
14
|
+
statusQuery: {
|
|
15
|
+
data: undefined as unknown,
|
|
16
|
+
isLoading: false
|
|
17
|
+
}
|
|
18
|
+
}));
|
|
19
|
+
|
|
20
|
+
vi.mock('@/hooks/useRemoteAccess', () => ({
|
|
21
|
+
useRemoteStatus: () => mocks.statusQuery
|
|
22
|
+
}));
|
|
23
|
+
|
|
24
|
+
vi.mock('@/presenter/app-presenter-context', () => ({
|
|
25
|
+
useAppPresenter: () => ({
|
|
26
|
+
remoteAccessManager: {
|
|
27
|
+
reauthorizeRemoteAccess: mocks.reauthorizeRemoteAccess,
|
|
28
|
+
repairRemoteAccess: mocks.repairRemoteAccess,
|
|
29
|
+
enableRemoteAccess: mocks.enableRemoteAccess,
|
|
30
|
+
disableRemoteAccess: mocks.disableRemoteAccess,
|
|
31
|
+
syncStatus: mocks.syncStatus
|
|
32
|
+
},
|
|
33
|
+
accountManager: {
|
|
34
|
+
openNextClawWeb: mocks.openNextClawWeb
|
|
35
|
+
}
|
|
36
|
+
})
|
|
37
|
+
}));
|
|
38
|
+
|
|
39
|
+
describe('RemoteAccessPage', () => {
|
|
40
|
+
beforeEach(() => {
|
|
41
|
+
setLanguage('zh');
|
|
42
|
+
mocks.reauthorizeRemoteAccess.mockReset();
|
|
43
|
+
mocks.repairRemoteAccess.mockReset();
|
|
44
|
+
mocks.enableRemoteAccess.mockReset();
|
|
45
|
+
mocks.disableRemoteAccess.mockReset();
|
|
46
|
+
mocks.syncStatus.mockReset();
|
|
47
|
+
mocks.openNextClawWeb.mockReset();
|
|
48
|
+
useRemoteAccessStore.setState({
|
|
49
|
+
enabled: false,
|
|
50
|
+
deviceName: '',
|
|
51
|
+
platformApiBase: '',
|
|
52
|
+
draftTouched: false,
|
|
53
|
+
advancedOpen: false,
|
|
54
|
+
actionLabel: null,
|
|
55
|
+
doctor: null
|
|
56
|
+
});
|
|
57
|
+
mocks.statusQuery = {
|
|
58
|
+
data: {
|
|
59
|
+
account: {
|
|
60
|
+
loggedIn: true,
|
|
61
|
+
email: 'user@example.com',
|
|
62
|
+
apiBase: 'https://ai-gateway-api.nextclaw.io/v1',
|
|
63
|
+
platformBase: 'https://ai-gateway-api.nextclaw.io'
|
|
64
|
+
},
|
|
65
|
+
settings: {
|
|
66
|
+
enabled: true,
|
|
67
|
+
deviceName: 'MacBook Pro',
|
|
68
|
+
platformApiBase: 'https://ai-gateway-api.nextclaw.io/v1'
|
|
69
|
+
},
|
|
70
|
+
service: {
|
|
71
|
+
running: true,
|
|
72
|
+
currentProcess: false
|
|
73
|
+
},
|
|
74
|
+
localOrigin: 'http://127.0.0.1:55667',
|
|
75
|
+
configuredEnabled: true,
|
|
76
|
+
platformBase: 'https://ai-gateway-api.nextclaw.io',
|
|
77
|
+
runtime: {
|
|
78
|
+
enabled: true,
|
|
79
|
+
mode: 'service',
|
|
80
|
+
state: 'error',
|
|
81
|
+
lastError: 'Invalid or expired token.',
|
|
82
|
+
updatedAt: '2026-03-23T00:00:00.000Z'
|
|
83
|
+
}
|
|
84
|
+
},
|
|
85
|
+
isLoading: false
|
|
86
|
+
};
|
|
87
|
+
});
|
|
88
|
+
|
|
89
|
+
it('shows a user-facing reauthorization flow instead of raw token errors', async () => {
|
|
90
|
+
const user = userEvent.setup();
|
|
91
|
+
|
|
92
|
+
render(<RemoteAccessPage />);
|
|
93
|
+
|
|
94
|
+
expect(screen.getByText('登录已过期,请重新登录 NextClaw')).toBeTruthy();
|
|
95
|
+
expect(screen.getByText('重新登录并恢复远程访问')).toBeTruthy();
|
|
96
|
+
expect(screen.queryByText('Invalid or expired token.')).toBeNull();
|
|
97
|
+
|
|
98
|
+
await user.click(screen.getByRole('button', { name: '重新登录并恢复远程访问' }));
|
|
99
|
+
|
|
100
|
+
expect(mocks.reauthorizeRemoteAccess).toHaveBeenCalledTimes(1);
|
|
101
|
+
expect(mocks.repairRemoteAccess).not.toHaveBeenCalled();
|
|
102
|
+
});
|
|
103
|
+
});
|
|
@@ -1,4 +1,3 @@
|
|
|
1
|
-
import type { RemoteAccessView } from '@/api/remote.types';
|
|
2
1
|
import { PageHeader, PageLayout } from '@/components/layout/page-layout';
|
|
3
2
|
import { Button } from '@/components/ui/button';
|
|
4
3
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
|
@@ -7,102 +6,33 @@ import { useRemoteStatus } from '@/hooks/useRemoteAccess';
|
|
|
7
6
|
import { formatDateTime, t } from '@/lib/i18n';
|
|
8
7
|
import { useAppPresenter } from '@/presenter/app-presenter-context';
|
|
9
8
|
import { resolveRemoteWebBase } from '@/remote/remote-access.query';
|
|
9
|
+
import { buildRemoteAccessFeedbackView } from '@/remote/remote-access-feedback.service';
|
|
10
10
|
import { useRemoteAccessStore } from '@/remote/stores/remote-access.store';
|
|
11
11
|
import { Laptop, RefreshCcw, SquareArrowOutUpRight } from 'lucide-react';
|
|
12
12
|
import { useEffect, useMemo } from 'react';
|
|
13
13
|
|
|
14
|
-
type RemoteHeroView = {
|
|
15
|
-
badgeStatus: 'active' | 'inactive' | 'ready' | 'setup' | 'warning';
|
|
16
|
-
badgeLabel: string;
|
|
17
|
-
title: string;
|
|
18
|
-
description: string;
|
|
19
|
-
};
|
|
20
|
-
|
|
21
14
|
function KeyValueRow(props: { label: string; value?: string | number | null; muted?: boolean }) {
|
|
22
|
-
const
|
|
15
|
+
const { label, muted, value: rawValue } = props;
|
|
16
|
+
const value = rawValue === undefined || rawValue === null || rawValue === '' ? '-' : String(rawValue);
|
|
23
17
|
return (
|
|
24
18
|
<div className="flex items-start justify-between gap-4 py-2 text-sm">
|
|
25
|
-
<span className="text-gray-500">{
|
|
26
|
-
<span className={
|
|
19
|
+
<span className="text-gray-500">{label}</span>
|
|
20
|
+
<span className={muted ? 'text-right text-gray-500' : 'text-right text-gray-900'}>{value}</span>
|
|
27
21
|
</div>
|
|
28
22
|
);
|
|
29
23
|
}
|
|
30
24
|
|
|
31
|
-
function buildHeroView(status: RemoteAccessView | undefined): RemoteHeroView {
|
|
32
|
-
if (!status?.account.loggedIn) {
|
|
33
|
-
return {
|
|
34
|
-
badgeStatus: 'setup',
|
|
35
|
-
badgeLabel: t('statusSetup'),
|
|
36
|
-
title: t('remoteStatusNeedsSignIn'),
|
|
37
|
-
description: t('remoteStatusNeedsSignInDescription')
|
|
38
|
-
};
|
|
39
|
-
}
|
|
40
|
-
|
|
41
|
-
if (!status.settings.enabled) {
|
|
42
|
-
return {
|
|
43
|
-
badgeStatus: 'inactive',
|
|
44
|
-
badgeLabel: t('statusInactive'),
|
|
45
|
-
title: t('remoteStatusNeedsEnable'),
|
|
46
|
-
description: t('remoteStatusNeedsEnableDescription')
|
|
47
|
-
};
|
|
48
|
-
}
|
|
49
|
-
|
|
50
|
-
if (!status.service.running) {
|
|
51
|
-
return {
|
|
52
|
-
badgeStatus: 'warning',
|
|
53
|
-
badgeLabel: t('remoteServiceStopped'),
|
|
54
|
-
title: t('remoteStatusNeedsServiceTitle'),
|
|
55
|
-
description: t('remoteStatusNeedsServiceDescription')
|
|
56
|
-
};
|
|
57
|
-
}
|
|
58
|
-
|
|
59
|
-
if (status.runtime?.state === 'connected') {
|
|
60
|
-
return {
|
|
61
|
-
badgeStatus: 'ready',
|
|
62
|
-
badgeLabel: t('statusReady'),
|
|
63
|
-
title: t('remoteStatusReadyTitle'),
|
|
64
|
-
description: t('remoteStatusReadyDescription')
|
|
65
|
-
};
|
|
66
|
-
}
|
|
67
|
-
|
|
68
|
-
if (status.runtime?.state === 'connecting') {
|
|
69
|
-
return {
|
|
70
|
-
badgeStatus: 'active',
|
|
71
|
-
badgeLabel: t('connecting'),
|
|
72
|
-
title: t('remoteStatusConnectingTitle'),
|
|
73
|
-
description: t('remoteStatusConnectingDescription')
|
|
74
|
-
};
|
|
75
|
-
}
|
|
76
|
-
|
|
77
|
-
return {
|
|
78
|
-
badgeStatus: 'warning',
|
|
79
|
-
badgeLabel: t('remoteStateDisconnected'),
|
|
80
|
-
title: t('remoteStatusIssueTitle'),
|
|
81
|
-
description: t('remoteStatusIssueDescription')
|
|
82
|
-
};
|
|
83
|
-
}
|
|
84
|
-
|
|
85
|
-
function buildIssueHint(status: RemoteAccessView | undefined) {
|
|
86
|
-
if (!status?.settings.enabled) {
|
|
87
|
-
return null;
|
|
88
|
-
}
|
|
89
|
-
if (!status.service.running) {
|
|
90
|
-
return t('remoteStatusIssueDetailServiceStopped');
|
|
91
|
-
}
|
|
92
|
-
return status.runtime?.lastError?.trim() || t('remoteStatusIssueDetailGeneric');
|
|
93
|
-
}
|
|
94
25
|
|
|
95
26
|
export function RemoteAccessPage() {
|
|
96
27
|
const presenter = useAppPresenter();
|
|
97
28
|
const remoteStatus = useRemoteStatus();
|
|
98
29
|
const status = remoteStatus.data;
|
|
99
30
|
const actionLabel = useRemoteAccessStore((state) => state.actionLabel);
|
|
100
|
-
const
|
|
101
|
-
const issueHint = useMemo(() => buildIssueHint(status), [status]);
|
|
31
|
+
const feedbackView = useMemo(() => buildRemoteAccessFeedbackView(status), [status]);
|
|
102
32
|
const busy = Boolean(actionLabel);
|
|
103
33
|
const deviceName = status?.runtime?.deviceName?.trim() || status?.settings.deviceName?.trim() || t('remoteDeviceNameAuto');
|
|
104
34
|
const canOpenDeviceList = Boolean(status?.account.loggedIn && resolveRemoteWebBase(status));
|
|
105
|
-
const
|
|
35
|
+
const { hero: heroView, issueHint } = feedbackView;
|
|
106
36
|
|
|
107
37
|
useEffect(() => {
|
|
108
38
|
presenter.remoteAccessManager.syncStatus(status);
|
|
@@ -134,20 +64,25 @@ export function RemoteAccessPage() {
|
|
|
134
64
|
</div>
|
|
135
65
|
|
|
136
66
|
<div className="flex flex-wrap gap-3">
|
|
137
|
-
{
|
|
138
|
-
<Button
|
|
139
|
-
{
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
67
|
+
{feedbackView.primaryAction ? (
|
|
68
|
+
<Button
|
|
69
|
+
onClick={() => {
|
|
70
|
+
if (feedbackView.primaryAction?.kind === 'reauthorize') {
|
|
71
|
+
void presenter.remoteAccessManager.reauthorizeRemoteAccess(status);
|
|
72
|
+
return;
|
|
73
|
+
}
|
|
74
|
+
if (feedbackView.primaryAction?.kind === 'repair') {
|
|
75
|
+
void presenter.remoteAccessManager.repairRemoteAccess(status);
|
|
76
|
+
return;
|
|
77
|
+
}
|
|
78
|
+
void presenter.remoteAccessManager.enableRemoteAccess(status);
|
|
79
|
+
}}
|
|
80
|
+
disabled={busy}
|
|
81
|
+
>
|
|
82
|
+
{feedbackView.primaryAction.showRefreshIcon ? <RefreshCcw className="mr-2 h-4 w-4" /> : null}
|
|
83
|
+
{actionLabel || feedbackView.primaryAction.label}
|
|
144
84
|
</Button>
|
|
145
|
-
) :
|
|
146
|
-
<Button onClick={() => void presenter.remoteAccessManager.repairRemoteAccess(status)} disabled={busy}>
|
|
147
|
-
<RefreshCcw className="mr-2 h-4 w-4" />
|
|
148
|
-
{actionLabel || t('remoteReconnectNow')}
|
|
149
|
-
</Button>
|
|
150
|
-
)}
|
|
85
|
+
) : null}
|
|
151
86
|
|
|
152
87
|
<Button
|
|
153
88
|
variant="outline"
|
|
@@ -165,10 +100,10 @@ export function RemoteAccessPage() {
|
|
|
165
100
|
) : null}
|
|
166
101
|
</div>
|
|
167
102
|
|
|
168
|
-
{shouldShowIssueHint ? (
|
|
103
|
+
{feedbackView.shouldShowIssueHint && issueHint ? (
|
|
169
104
|
<div className="rounded-2xl border border-amber-200 bg-amber-50 px-4 py-3">
|
|
170
|
-
<p className="text-sm font-medium text-amber-900">{
|
|
171
|
-
<p className="mt-1 text-sm leading-6 text-amber-800">{issueHint}</p>
|
|
105
|
+
<p className="text-sm font-medium text-amber-900">{issueHint.title}</p>
|
|
106
|
+
<p className="mt-1 text-sm leading-6 text-amber-800">{issueHint.body}</p>
|
|
172
107
|
</div>
|
|
173
108
|
) : null}
|
|
174
109
|
|
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
|
+
});
|