@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
|
@@ -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
|
|
|
@@ -3,6 +3,9 @@ import { appClient } from '@/transport';
|
|
|
3
3
|
import { useUiStore } from '@/stores/ui.store';
|
|
4
4
|
import type { QueryClient } from '@tanstack/react-query';
|
|
5
5
|
|
|
6
|
+
type ConnectionStatus = 'connected' | 'disconnected' | 'connecting';
|
|
7
|
+
type SetConnectionStatus = (status: ConnectionStatus) => void;
|
|
8
|
+
|
|
6
9
|
function shouldInvalidateConfigQuery(configPath: string) {
|
|
7
10
|
const normalized = configPath.trim().toLowerCase();
|
|
8
11
|
if (!normalized) {
|
|
@@ -27,82 +30,85 @@ function invalidateMarketplaceQueries(queryClient: QueryClient | undefined, conf
|
|
|
27
30
|
}
|
|
28
31
|
}
|
|
29
32
|
|
|
30
|
-
|
|
31
|
-
|
|
32
|
-
|
|
33
|
-
|
|
34
|
-
|
|
35
|
-
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
45
|
-
queryClient.invalidateQueries({ queryKey: ['session-history'] });
|
|
46
|
-
queryClient.invalidateQueries({ queryKey: ['ncp-session-messages'] });
|
|
47
|
-
};
|
|
48
|
-
|
|
49
|
-
setConnectionStatus('connecting');
|
|
50
|
-
|
|
51
|
-
return appClient.subscribe((event) => {
|
|
52
|
-
if (event.type === 'connection.open') {
|
|
53
|
-
setConnectionStatus('connected');
|
|
54
|
-
return;
|
|
55
|
-
}
|
|
33
|
+
function invalidateSessionQueries(queryClient: QueryClient | undefined, sessionKey?: string): void {
|
|
34
|
+
if (!queryClient) {
|
|
35
|
+
return;
|
|
36
|
+
}
|
|
37
|
+
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
|
38
|
+
queryClient.invalidateQueries({ queryKey: ['ncp-sessions'] });
|
|
39
|
+
if (sessionKey && sessionKey.trim().length > 0) {
|
|
40
|
+
queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey.trim()] });
|
|
41
|
+
queryClient.invalidateQueries({ queryKey: ['ncp-session-messages', sessionKey.trim()] });
|
|
42
|
+
return;
|
|
43
|
+
}
|
|
44
|
+
queryClient.invalidateQueries({ queryKey: ['session-history'] });
|
|
45
|
+
queryClient.invalidateQueries({ queryKey: ['ncp-session-messages'] });
|
|
46
|
+
}
|
|
56
47
|
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
48
|
+
function handleConfigUpdatedEvent(queryClient: QueryClient | undefined, path: string): void {
|
|
49
|
+
if (queryClient && shouldInvalidateConfigQuery(path)) {
|
|
50
|
+
queryClient.invalidateQueries({ queryKey: ['config'] });
|
|
51
|
+
}
|
|
52
|
+
if (path.startsWith('session')) {
|
|
53
|
+
invalidateSessionQueries(queryClient);
|
|
54
|
+
}
|
|
55
|
+
invalidateMarketplaceQueries(queryClient, path);
|
|
56
|
+
}
|
|
61
57
|
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
65
|
-
|
|
58
|
+
function handleRunUpdatedEvent(queryClient: QueryClient | undefined, payload: { run: { sessionKey?: string; runId?: string } }): void {
|
|
59
|
+
if (!queryClient) {
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
const { sessionKey, runId } = payload.run;
|
|
63
|
+
queryClient.invalidateQueries({ queryKey: ['chat-runs'] });
|
|
64
|
+
if (sessionKey) {
|
|
65
|
+
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
|
66
|
+
queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey] });
|
|
67
|
+
} else {
|
|
68
|
+
queryClient.invalidateQueries({ queryKey: ['session-history'] });
|
|
69
|
+
}
|
|
70
|
+
if (runId) {
|
|
71
|
+
queryClient.invalidateQueries({ queryKey: ['chat-run', runId] });
|
|
72
|
+
}
|
|
73
|
+
}
|
|
66
74
|
|
|
67
|
-
|
|
68
|
-
|
|
69
|
-
|
|
70
|
-
|
|
71
|
-
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
|
|
77
|
-
|
|
75
|
+
function handleRealtimeEvent(
|
|
76
|
+
queryClient: QueryClient | undefined,
|
|
77
|
+
setConnectionStatus: SetConnectionStatus,
|
|
78
|
+
event: Parameters<Parameters<typeof appClient.subscribe>[0]>[0]
|
|
79
|
+
): void {
|
|
80
|
+
if (event.type === 'connection.open') {
|
|
81
|
+
setConnectionStatus('connected');
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (event.type === 'connection.close' || event.type === 'connection.error') {
|
|
85
|
+
setConnectionStatus('disconnected');
|
|
86
|
+
return;
|
|
87
|
+
}
|
|
88
|
+
if (event.type === 'config.updated') {
|
|
89
|
+
const configPath = typeof event.payload?.path === 'string' ? event.payload.path : '';
|
|
90
|
+
handleConfigUpdatedEvent(queryClient, configPath);
|
|
91
|
+
return;
|
|
92
|
+
}
|
|
93
|
+
if (event.type === 'run.updated') {
|
|
94
|
+
handleRunUpdatedEvent(queryClient, event.payload);
|
|
95
|
+
return;
|
|
96
|
+
}
|
|
97
|
+
if (event.type === 'session.updated') {
|
|
98
|
+
invalidateSessionQueries(queryClient, event.payload.sessionKey);
|
|
99
|
+
return;
|
|
100
|
+
}
|
|
101
|
+
if (event.type === 'error') {
|
|
102
|
+
console.error('Realtime transport error:', event.payload.message);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
78
105
|
|
|
79
|
-
|
|
80
|
-
|
|
81
|
-
return;
|
|
82
|
-
}
|
|
83
|
-
const sessionKey = event.payload.run.sessionKey;
|
|
84
|
-
const runId = event.payload.run.runId;
|
|
85
|
-
queryClient.invalidateQueries({ queryKey: ['chat-runs'] });
|
|
86
|
-
if (sessionKey) {
|
|
87
|
-
queryClient.invalidateQueries({ queryKey: ['sessions'] });
|
|
88
|
-
queryClient.invalidateQueries({ queryKey: ['session-history', sessionKey] });
|
|
89
|
-
} else {
|
|
90
|
-
queryClient.invalidateQueries({ queryKey: ['session-history'] });
|
|
91
|
-
}
|
|
92
|
-
if (runId) {
|
|
93
|
-
queryClient.invalidateQueries({ queryKey: ['chat-run', runId] });
|
|
94
|
-
}
|
|
95
|
-
return;
|
|
96
|
-
}
|
|
106
|
+
export function useRealtimeQueryBridge(queryClient?: QueryClient) {
|
|
107
|
+
const { setConnectionStatus } = useUiStore();
|
|
97
108
|
|
|
98
|
-
|
|
99
|
-
|
|
100
|
-
return;
|
|
101
|
-
}
|
|
109
|
+
useEffect(() => {
|
|
110
|
+
setConnectionStatus('connecting');
|
|
102
111
|
|
|
103
|
-
|
|
104
|
-
console.error('Realtime transport error:', event.payload.message);
|
|
105
|
-
}
|
|
106
|
-
});
|
|
112
|
+
return appClient.subscribe((event) => handleRealtimeEvent(queryClient, setConnectionStatus, event));
|
|
107
113
|
}, [queryClient, setConnectionStatus]);
|
|
108
114
|
}
|