@muyichengshayu/promptx 0.1.5 → 0.1.6
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 +37 -0
- package/README.md +21 -0
- package/apps/server/src/index.js +42 -6
- package/apps/server/src/relayClient.js +431 -0
- package/apps/server/src/relayConfig.js +84 -0
- package/apps/server/src/relayProtocol.js +143 -0
- package/apps/server/src/relayServer.js +823 -0
- package/apps/server/src/relayTenants.js +228 -0
- package/apps/web/dist/assets/CodexSessionManagerDialog-E16PIzLB.js +1 -0
- package/apps/web/dist/assets/{TaskDiffReviewDialog-Ae6hSwBS.js → TaskDiffReviewDialog-MsxH_AKt.js} +1 -1
- package/apps/web/dist/assets/WorkbenchSettingsDialog-CEYbW3cO.js +21 -0
- package/apps/web/dist/assets/{WorkbenchView-CxQdxLrh.js → WorkbenchView-iQBWLLDq.js} +5 -5
- package/apps/web/dist/assets/index-C2HSUDwd.css +1 -0
- package/apps/web/dist/assets/index-NZ5CT31R.js +25 -0
- package/apps/web/dist/assets/info-C7kzOdmJ.js +6 -0
- package/apps/web/dist/index.html +2 -2
- package/bin/promptx.js +24 -1
- package/docs/relay-quickstart.md +241 -0
- package/package.json +10 -3
- package/scripts/relay-service.mjs +301 -0
- package/scripts/relay-tenant.mjs +133 -0
- package/scripts/relay.mjs +12 -0
- package/apps/web/dist/assets/CodexSessionManagerDialog-BOpeBi6M.js +0 -6
- package/apps/web/dist/assets/WorkbenchSettingsDialog-BbYYkqdm.js +0 -1
- package/apps/web/dist/assets/index-AK0zYNQo.js +0 -25
- package/apps/web/dist/assets/index-BtGLsYNA.css +0 -1
package/CHANGELOG.md
ADDED
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
# Changelog
|
|
2
|
+
|
|
3
|
+
## 0.1.6
|
|
4
|
+
|
|
5
|
+
- 新增多租户 Relay 子域名接入能力,一个 Relay 进程即可同时服务多个同事的远程访问。
|
|
6
|
+
- 新增 `promptx relay tenant add/list/remove` 与 `promptx relay start/stop/restart/status`,补齐 Relay 的租户管理和后台运维命令。
|
|
7
|
+
- 完善 Relay 转发稳定性与诊断信息,修复远程请求体转发问题,并增加更清晰的 host、tenant 与拒绝原因日志。
|
|
8
|
+
- README 精简为入口说明,详细 Relay 部署与使用流程迁移到 `docs/relay-quickstart.md`。
|
|
9
|
+
- 修复 Windows 开发环境下 `pnpm dev` / `pnpm dev:tailscale` 可能报 `spawn EINVAL` 的问题。
|
|
10
|
+
|
|
11
|
+
## 0.1.5
|
|
12
|
+
|
|
13
|
+
- 本轮提示词支持按新数据结构展示图片块,新的图文输入在会话区可直接看到附图预览。
|
|
14
|
+
- 修复旧库升级时 `prompt_blocks_json` 字段缺失导致服务启动报错的问题,补齐增量迁移。
|
|
15
|
+
- 优化项目管理交互:运行中也可打开“管理项目”,管理弹窗优先选中当前项目,编辑表单能正确回填。
|
|
16
|
+
- 简化项目选择器与项目列表状态展示,减少无意义抖动和过重高亮;新建任务按钮不再因列表加载短暂禁用。
|
|
17
|
+
|
|
18
|
+
## 0.1.4
|
|
19
|
+
|
|
20
|
+
- 修复 Windows 下通过 npm 安装后的正式版在启动服务、发送请求和执行辅助命令时频繁弹出黑色控制台窗口的问题。
|
|
21
|
+
- 为 PromptX、Codex、Git 与发布检查相关的 Windows 子进程统一补充隐藏窗口选项,减少系统级弹窗干扰。
|
|
22
|
+
|
|
23
|
+
## 0.1.3
|
|
24
|
+
|
|
25
|
+
- 修复工作台输入区在输入法候选、实时刷新和快速发送场景下的尾字丢失问题。
|
|
26
|
+
- 修复设置面板首次打开时版本信息不加载、一直停留在“读取中...”的问题。
|
|
27
|
+
|
|
28
|
+
## 0.1.2
|
|
29
|
+
|
|
30
|
+
- 设置面板增加版本信息展示,并在读取失败时显示明确状态。
|
|
31
|
+
- CLI 新增 `version`、`--version`、`-v` 版本查询,同时兼容 `-versioin`。
|
|
32
|
+
- README 补充说明:禅道扩展目前需要下载或克隆仓库源码后手动加载。
|
|
33
|
+
|
|
34
|
+
## 0.1.1
|
|
35
|
+
|
|
36
|
+
- 修复工作台中“代码变更”入口、执行过程“查看”按钮和任务卡文件数徽标的闪烁问题。
|
|
37
|
+
- 优化任务列表刷新时的代码变更摘要复用逻辑,减少界面抖动。
|
package/README.md
CHANGED
|
@@ -36,12 +36,18 @@ promptx doctor
|
|
|
36
36
|
promptx start
|
|
37
37
|
promptx status
|
|
38
38
|
promptx stop
|
|
39
|
+
promptx relay start
|
|
39
40
|
```
|
|
40
41
|
|
|
41
42
|
```bash
|
|
42
43
|
promptx doctor
|
|
43
44
|
```
|
|
44
45
|
|
|
46
|
+
其中:
|
|
47
|
+
|
|
48
|
+
- `promptx start`:启动本机 PromptX 工作台
|
|
49
|
+
- `promptx relay start`:启动公网中转服务,适合部署到你自己的云服务器
|
|
50
|
+
|
|
45
51
|
## 使用方式
|
|
46
52
|
|
|
47
53
|
1. 打开工作台,新建或选择一个任务
|
|
@@ -50,6 +56,21 @@ promptx doctor
|
|
|
50
56
|
4. 点击发送,把当前内容交给 Codex
|
|
51
57
|
5. 在中间继续查看执行过程,并按需多轮发送
|
|
52
58
|
|
|
59
|
+
## 远程访问 Relay(预览)
|
|
60
|
+
|
|
61
|
+
如果你希望在手机上远程访问自己电脑上的 PromptX,或想在云端部署多租户 Relay,请直接查看:
|
|
62
|
+
|
|
63
|
+
- `docs/relay-quickstart.md`
|
|
64
|
+
|
|
65
|
+
文档里已经整理好这些内容:
|
|
66
|
+
|
|
67
|
+
- 本地 PromptX 接入 Relay
|
|
68
|
+
- 云端 Relay 启动与后台管理
|
|
69
|
+
- 多租户子域名接入
|
|
70
|
+
- `promptx relay tenant add/list/remove`
|
|
71
|
+
- `promptx relay start/stop/restart/status`
|
|
72
|
+
- Nginx、DNS、健康检查与常见排查
|
|
73
|
+
|
|
53
74
|
## 禅道扩展
|
|
54
75
|
|
|
55
76
|
仓库内置了禅道 Chrome 扩展:`apps/zentao-extension`
|
package/apps/server/src/index.js
CHANGED
|
@@ -57,6 +57,8 @@ import {
|
|
|
57
57
|
searchWorkspaceEntries,
|
|
58
58
|
} from './workspaceFiles.js'
|
|
59
59
|
import { ensurePromptxStorageReady, serverRootDir } from './appPaths.js'
|
|
60
|
+
import { createRelayClient } from './relayClient.js'
|
|
61
|
+
import { getRelayConfigForClient, isRelayConfigManagedByEnv, writeStoredRelayConfig } from './relayConfig.js'
|
|
60
62
|
import { createSseHub } from './sseHub.js'
|
|
61
63
|
|
|
62
64
|
const app = Fastify({ logger: true })
|
|
@@ -80,6 +82,13 @@ function readPromptxVersion() {
|
|
|
80
82
|
}
|
|
81
83
|
|
|
82
84
|
const promptxVersion = readPromptxVersion()
|
|
85
|
+
const relayConfig = getRelayConfigForClient()
|
|
86
|
+
const relayClient = createRelayClient({
|
|
87
|
+
logger: app.log,
|
|
88
|
+
appVersion: promptxVersion,
|
|
89
|
+
localBaseUrl: process.env.PROMPTX_RELAY_LOCAL_BASE_URL || `http://127.0.0.1:${port}`,
|
|
90
|
+
...relayConfig,
|
|
91
|
+
})
|
|
83
92
|
|
|
84
93
|
let lastExpiredPurgeAt = 0
|
|
85
94
|
const sseHub = createSseHub()
|
|
@@ -338,6 +347,32 @@ app.get('/api/meta', async () => ({
|
|
|
338
347
|
visibilityOptions: VISIBILITY_OPTIONS,
|
|
339
348
|
}))
|
|
340
349
|
|
|
350
|
+
app.get('/api/relay/status', async () => ({
|
|
351
|
+
relay: relayClient.getStatus(),
|
|
352
|
+
}))
|
|
353
|
+
|
|
354
|
+
app.get('/api/relay/config', async () => ({
|
|
355
|
+
config: {
|
|
356
|
+
...getRelayConfigForClient(),
|
|
357
|
+
},
|
|
358
|
+
managedByEnv: isRelayConfigManagedByEnv(),
|
|
359
|
+
relay: relayClient.getStatus(),
|
|
360
|
+
}))
|
|
361
|
+
|
|
362
|
+
app.put('/api/relay/config', async (request) => {
|
|
363
|
+
const savedConfig = writeStoredRelayConfig(request.body || {})
|
|
364
|
+
relayClient.updateConfig({
|
|
365
|
+
...savedConfig,
|
|
366
|
+
localBaseUrl: process.env.PROMPTX_RELAY_LOCAL_BASE_URL || `http://127.0.0.1:${port}`,
|
|
367
|
+
})
|
|
368
|
+
|
|
369
|
+
return {
|
|
370
|
+
config: getRelayConfigForClient(),
|
|
371
|
+
managedByEnv: isRelayConfigManagedByEnv(),
|
|
372
|
+
relay: relayClient.getStatus(),
|
|
373
|
+
}
|
|
374
|
+
})
|
|
375
|
+
|
|
341
376
|
app.get('/api/events/stream', async (request, reply) => {
|
|
342
377
|
reply.hijack()
|
|
343
378
|
const requestOrigin = request.headers.origin
|
|
@@ -915,10 +950,11 @@ app.setErrorHandler((error, request, reply) => {
|
|
|
915
950
|
markInterruptedCodexRuns()
|
|
916
951
|
purgeExpiredContent(true)
|
|
917
952
|
|
|
918
|
-
app.listen({ port, host }).then(() => {
|
|
919
|
-
app.log.info(`server running at http://${host}:${port}`)
|
|
920
|
-
buildServerAccessUrls(host, port).forEach((message) => {
|
|
921
|
-
app.log.info(message)
|
|
922
|
-
})
|
|
923
|
-
|
|
953
|
+
app.listen({ port, host }).then(() => {
|
|
954
|
+
app.log.info(`server running at http://${host}:${port}`)
|
|
955
|
+
buildServerAccessUrls(host, port).forEach((message) => {
|
|
956
|
+
app.log.info(message)
|
|
957
|
+
})
|
|
958
|
+
relayClient.start()
|
|
959
|
+
})
|
|
924
960
|
|
|
@@ -0,0 +1,431 @@
|
|
|
1
|
+
import process from 'node:process'
|
|
2
|
+
import WebSocket from 'ws'
|
|
3
|
+
|
|
4
|
+
import {
|
|
5
|
+
buildRelayWebSocketUrl,
|
|
6
|
+
createRelayRequestId,
|
|
7
|
+
decodeChunk,
|
|
8
|
+
encodeChunk,
|
|
9
|
+
sanitizeProxyHeaders,
|
|
10
|
+
} from './relayProtocol.js'
|
|
11
|
+
|
|
12
|
+
const DEFAULT_RECONNECT_DELAY_MS = 3_000
|
|
13
|
+
const REQUEST_CANCEL_REASON = 'relay_request_cancelled'
|
|
14
|
+
|
|
15
|
+
function createDisabledStatus() {
|
|
16
|
+
return {
|
|
17
|
+
enabled: false,
|
|
18
|
+
connected: false,
|
|
19
|
+
relayUrl: '',
|
|
20
|
+
websocketUrl: '',
|
|
21
|
+
deviceId: '',
|
|
22
|
+
lastConnectedAt: '',
|
|
23
|
+
lastDisconnectedAt: '',
|
|
24
|
+
lastCloseCode: 0,
|
|
25
|
+
lastCloseReason: '',
|
|
26
|
+
lastError: '',
|
|
27
|
+
}
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
function normalizeCloseReason(reason = '') {
|
|
31
|
+
const normalized = String(reason || '').trim()
|
|
32
|
+
if (!normalized) {
|
|
33
|
+
return ''
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const reasonMap = {
|
|
37
|
+
invalid_tenant: '当前 Relay 域名未匹配到租户',
|
|
38
|
+
invalid_token: '设备令牌不匹配',
|
|
39
|
+
invalid_device: '设备 ID 不匹配',
|
|
40
|
+
missing_hello: '缺少设备认证报文',
|
|
41
|
+
missing_auth: '设备认证超时',
|
|
42
|
+
replaced_by_new_connection: '已被新的设备连接替换',
|
|
43
|
+
config_updated: '配置已更新,正在重连',
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
return reasonMap[normalized] || normalized
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
function readRelayClientConfig({
|
|
50
|
+
relayUrl = process.env.PROMPTX_RELAY_URL,
|
|
51
|
+
deviceId = process.env.PROMPTX_RELAY_DEVICE_ID,
|
|
52
|
+
deviceToken = process.env.PROMPTX_RELAY_DEVICE_TOKEN,
|
|
53
|
+
enabled = process.env.PROMPTX_RELAY_ENABLED,
|
|
54
|
+
localBaseUrl = process.env.PROMPTX_RELAY_LOCAL_BASE_URL,
|
|
55
|
+
} = {}) {
|
|
56
|
+
const normalizedRelayUrl = String(relayUrl || '').trim()
|
|
57
|
+
const normalizedDeviceId = String(deviceId || '').trim()
|
|
58
|
+
const normalizedDeviceToken = String(deviceToken || '').trim()
|
|
59
|
+
const hasExplicitEnabled = typeof enabled === 'boolean'
|
|
60
|
+
|| (typeof enabled !== 'undefined' && String(enabled || '').trim() !== '')
|
|
61
|
+
const normalizedEnabled = hasExplicitEnabled ? String(enabled).trim().toLowerCase() : ''
|
|
62
|
+
const shouldEnable = hasExplicitEnabled
|
|
63
|
+
? !['0', 'false', 'off', 'no'].includes(normalizedEnabled)
|
|
64
|
+
: Boolean(normalizedRelayUrl && normalizedDeviceId && normalizedDeviceToken)
|
|
65
|
+
|
|
66
|
+
return {
|
|
67
|
+
enabled: shouldEnable && Boolean(normalizedRelayUrl && normalizedDeviceId && normalizedDeviceToken),
|
|
68
|
+
relayUrl: normalizedRelayUrl,
|
|
69
|
+
websocketUrl: buildRelayWebSocketUrl(normalizedRelayUrl),
|
|
70
|
+
deviceId: normalizedDeviceId,
|
|
71
|
+
deviceToken: normalizedDeviceToken,
|
|
72
|
+
localBaseUrl: String(localBaseUrl || 'http://127.0.0.1:3000').trim() || 'http://127.0.0.1:3000',
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
function createRelayClient({
|
|
77
|
+
relayUrl,
|
|
78
|
+
deviceId,
|
|
79
|
+
deviceToken,
|
|
80
|
+
localBaseUrl,
|
|
81
|
+
logger = console,
|
|
82
|
+
appVersion = '0.0.0',
|
|
83
|
+
} = {}) {
|
|
84
|
+
let config = readRelayClientConfig({
|
|
85
|
+
relayUrl,
|
|
86
|
+
deviceId,
|
|
87
|
+
deviceToken,
|
|
88
|
+
localBaseUrl,
|
|
89
|
+
})
|
|
90
|
+
|
|
91
|
+
const status = {
|
|
92
|
+
...createDisabledStatus(),
|
|
93
|
+
enabled: config.enabled,
|
|
94
|
+
relayUrl: config.relayUrl,
|
|
95
|
+
websocketUrl: config.websocketUrl,
|
|
96
|
+
deviceId: config.deviceId,
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
let socket = null
|
|
100
|
+
let stopped = false
|
|
101
|
+
let reconnectTimer = null
|
|
102
|
+
let requestMap = new Map()
|
|
103
|
+
let authenticated = false
|
|
104
|
+
|
|
105
|
+
function updateStatus(patch = {}) {
|
|
106
|
+
Object.assign(status, patch)
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
function syncStatusFromConfig() {
|
|
110
|
+
updateStatus({
|
|
111
|
+
enabled: config.enabled,
|
|
112
|
+
relayUrl: config.relayUrl,
|
|
113
|
+
websocketUrl: config.websocketUrl,
|
|
114
|
+
deviceId: config.deviceId,
|
|
115
|
+
})
|
|
116
|
+
}
|
|
117
|
+
|
|
118
|
+
syncStatusFromConfig()
|
|
119
|
+
|
|
120
|
+
function logInfo(message, extra) {
|
|
121
|
+
if (extra) {
|
|
122
|
+
logger.info?.(extra, message)
|
|
123
|
+
return
|
|
124
|
+
}
|
|
125
|
+
logger.info?.(message)
|
|
126
|
+
}
|
|
127
|
+
|
|
128
|
+
function logWarn(message, extra) {
|
|
129
|
+
if (extra) {
|
|
130
|
+
logger.warn?.(extra, message)
|
|
131
|
+
return
|
|
132
|
+
}
|
|
133
|
+
logger.warn?.(message)
|
|
134
|
+
}
|
|
135
|
+
|
|
136
|
+
function logError(message, extra) {
|
|
137
|
+
if (extra) {
|
|
138
|
+
logger.error?.(extra, message)
|
|
139
|
+
return
|
|
140
|
+
}
|
|
141
|
+
logger.error?.(message)
|
|
142
|
+
}
|
|
143
|
+
|
|
144
|
+
function sendFrame(payload = {}) {
|
|
145
|
+
if (!socket || socket.readyState !== WebSocket.OPEN) {
|
|
146
|
+
return false
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
socket.send(JSON.stringify(payload))
|
|
150
|
+
return true
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
async function forwardLocalResponse(requestId, response) {
|
|
154
|
+
sendFrame({
|
|
155
|
+
type: 'response.start',
|
|
156
|
+
requestId,
|
|
157
|
+
status: response.status,
|
|
158
|
+
statusText: response.statusText,
|
|
159
|
+
headers: sanitizeProxyHeaders(response.headers, ['content-encoding']),
|
|
160
|
+
})
|
|
161
|
+
|
|
162
|
+
if (response.body) {
|
|
163
|
+
for await (const chunk of response.body) {
|
|
164
|
+
sendFrame({
|
|
165
|
+
type: 'response.body',
|
|
166
|
+
requestId,
|
|
167
|
+
chunk: encodeChunk(chunk),
|
|
168
|
+
})
|
|
169
|
+
}
|
|
170
|
+
} else {
|
|
171
|
+
const bodyBuffer = Buffer.from(await response.arrayBuffer())
|
|
172
|
+
if (bodyBuffer.length) {
|
|
173
|
+
sendFrame({
|
|
174
|
+
type: 'response.body',
|
|
175
|
+
requestId,
|
|
176
|
+
chunk: encodeChunk(bodyBuffer),
|
|
177
|
+
})
|
|
178
|
+
}
|
|
179
|
+
}
|
|
180
|
+
|
|
181
|
+
sendFrame({
|
|
182
|
+
type: 'response.end',
|
|
183
|
+
requestId,
|
|
184
|
+
})
|
|
185
|
+
}
|
|
186
|
+
|
|
187
|
+
async function dispatchLocalRequest(record) {
|
|
188
|
+
const bodyBuffer = Buffer.concat(record.bodyChunks)
|
|
189
|
+
const controller = new AbortController()
|
|
190
|
+
record.controller = controller
|
|
191
|
+
requestMap.set(record.requestId, record)
|
|
192
|
+
|
|
193
|
+
try {
|
|
194
|
+
const targetUrl = new URL(record.path, config.localBaseUrl)
|
|
195
|
+
const response = await fetch(targetUrl, {
|
|
196
|
+
method: record.method,
|
|
197
|
+
headers: sanitizeProxyHeaders(record.headers, ['cookie']),
|
|
198
|
+
body: ['GET', 'HEAD'].includes(record.method) || !bodyBuffer.length ? undefined : bodyBuffer,
|
|
199
|
+
signal: controller.signal,
|
|
200
|
+
})
|
|
201
|
+
|
|
202
|
+
await forwardLocalResponse(record.requestId, response)
|
|
203
|
+
} catch (error) {
|
|
204
|
+
const errorCode = error?.name === 'AbortError' ? REQUEST_CANCEL_REASON : 'relay_local_request_failed'
|
|
205
|
+
sendFrame({
|
|
206
|
+
type: 'response.error',
|
|
207
|
+
requestId: record.requestId,
|
|
208
|
+
code: errorCode,
|
|
209
|
+
message: error?.message || '本地 PromptX 请求失败。',
|
|
210
|
+
})
|
|
211
|
+
} finally {
|
|
212
|
+
requestMap.delete(record.requestId)
|
|
213
|
+
}
|
|
214
|
+
}
|
|
215
|
+
|
|
216
|
+
function handleIncomingFrame(rawPayload = '') {
|
|
217
|
+
let payload
|
|
218
|
+
try {
|
|
219
|
+
payload = JSON.parse(String(rawPayload || ''))
|
|
220
|
+
} catch {
|
|
221
|
+
return
|
|
222
|
+
}
|
|
223
|
+
|
|
224
|
+
if (payload.type === 'request.start') {
|
|
225
|
+
requestMap.set(payload.requestId, {
|
|
226
|
+
requestId: String(payload.requestId || createRelayRequestId()),
|
|
227
|
+
method: String(payload.method || 'GET').toUpperCase(),
|
|
228
|
+
path: String(payload.path || '/'),
|
|
229
|
+
headers: payload.headers || {},
|
|
230
|
+
bodyChunks: [],
|
|
231
|
+
controller: null,
|
|
232
|
+
})
|
|
233
|
+
return
|
|
234
|
+
}
|
|
235
|
+
|
|
236
|
+
if (payload.type === 'request.body') {
|
|
237
|
+
const record = requestMap.get(payload.requestId)
|
|
238
|
+
if (!record) {
|
|
239
|
+
return
|
|
240
|
+
}
|
|
241
|
+
record.bodyChunks.push(decodeChunk(payload.chunk))
|
|
242
|
+
return
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
if (payload.type === 'request.end') {
|
|
246
|
+
const record = requestMap.get(payload.requestId)
|
|
247
|
+
if (!record) {
|
|
248
|
+
return
|
|
249
|
+
}
|
|
250
|
+
dispatchLocalRequest(record).catch((error) => {
|
|
251
|
+
sendFrame({
|
|
252
|
+
type: 'response.error',
|
|
253
|
+
requestId: record.requestId,
|
|
254
|
+
code: 'relay_dispatch_failed',
|
|
255
|
+
message: error?.message || 'Relay 转发失败。',
|
|
256
|
+
})
|
|
257
|
+
requestMap.delete(record.requestId)
|
|
258
|
+
})
|
|
259
|
+
return
|
|
260
|
+
}
|
|
261
|
+
|
|
262
|
+
if (payload.type === 'request.cancel') {
|
|
263
|
+
const record = requestMap.get(payload.requestId)
|
|
264
|
+
record?.controller?.abort()
|
|
265
|
+
}
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
function scheduleReconnect() {
|
|
269
|
+
if (stopped || reconnectTimer || !config.enabled) {
|
|
270
|
+
return
|
|
271
|
+
}
|
|
272
|
+
|
|
273
|
+
reconnectTimer = setTimeout(() => {
|
|
274
|
+
reconnectTimer = null
|
|
275
|
+
connect().catch(() => {})
|
|
276
|
+
}, DEFAULT_RECONNECT_DELAY_MS)
|
|
277
|
+
}
|
|
278
|
+
|
|
279
|
+
async function connect() {
|
|
280
|
+
if (!config.enabled) {
|
|
281
|
+
return
|
|
282
|
+
}
|
|
283
|
+
if (stopped || socket?.readyState === WebSocket.OPEN || socket?.readyState === WebSocket.CONNECTING) {
|
|
284
|
+
return
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
socket = new WebSocket(config.websocketUrl)
|
|
288
|
+
authenticated = false
|
|
289
|
+
|
|
290
|
+
socket.on('open', () => {
|
|
291
|
+
sendFrame({
|
|
292
|
+
type: 'hello',
|
|
293
|
+
deviceId: config.deviceId,
|
|
294
|
+
deviceToken: config.deviceToken,
|
|
295
|
+
version: appVersion,
|
|
296
|
+
})
|
|
297
|
+
logInfo(`[relay] WebSocket 已建立,等待设备认证 ${config.relayUrl}`)
|
|
298
|
+
})
|
|
299
|
+
|
|
300
|
+
socket.on('message', (payload, isBinary) => {
|
|
301
|
+
if (isBinary) {
|
|
302
|
+
return
|
|
303
|
+
}
|
|
304
|
+
let message = null
|
|
305
|
+
try {
|
|
306
|
+
message = JSON.parse(payload.toString('utf8'))
|
|
307
|
+
} catch {
|
|
308
|
+
return
|
|
309
|
+
}
|
|
310
|
+
|
|
311
|
+
if (message?.type === 'hello.ack') {
|
|
312
|
+
authenticated = true
|
|
313
|
+
updateStatus({
|
|
314
|
+
connected: true,
|
|
315
|
+
lastConnectedAt: new Date().toISOString(),
|
|
316
|
+
lastCloseCode: 0,
|
|
317
|
+
lastCloseReason: '',
|
|
318
|
+
lastError: '',
|
|
319
|
+
})
|
|
320
|
+
logInfo(`[relay] 已连接 ${config.relayUrl}`)
|
|
321
|
+
return
|
|
322
|
+
}
|
|
323
|
+
handleIncomingFrame(JSON.stringify(message))
|
|
324
|
+
})
|
|
325
|
+
|
|
326
|
+
socket.on('close', (code, reason) => {
|
|
327
|
+
const wasAuthenticated = authenticated
|
|
328
|
+
const closeReason = normalizeCloseReason(reason?.toString('utf8'))
|
|
329
|
+
const nextError = closeReason && closeReason !== '配置已更新,正在重连'
|
|
330
|
+
? `${wasAuthenticated ? 'Relay 已断开' : 'Relay 连接被拒绝'}:${closeReason}`
|
|
331
|
+
: (!wasAuthenticated && code && code !== 1000 ? `Relay 连接已关闭(code=${code})` : '')
|
|
332
|
+
|
|
333
|
+
updateStatus({
|
|
334
|
+
connected: false,
|
|
335
|
+
lastDisconnectedAt: new Date().toISOString(),
|
|
336
|
+
lastCloseCode: Number(code || 0),
|
|
337
|
+
lastCloseReason: closeReason,
|
|
338
|
+
...(nextError ? { lastError: nextError } : {}),
|
|
339
|
+
})
|
|
340
|
+
socket = null
|
|
341
|
+
authenticated = false
|
|
342
|
+
logWarn('[relay] 连接已关闭', {
|
|
343
|
+
code: Number(code || 0),
|
|
344
|
+
reason: closeReason || 'none',
|
|
345
|
+
authenticated: wasAuthenticated,
|
|
346
|
+
})
|
|
347
|
+
scheduleReconnect()
|
|
348
|
+
})
|
|
349
|
+
|
|
350
|
+
socket.on('error', (error) => {
|
|
351
|
+
updateStatus({
|
|
352
|
+
lastError: error?.message || 'Relay 连接失败。',
|
|
353
|
+
})
|
|
354
|
+
logWarn('[relay] 连接异常', {
|
|
355
|
+
error: error?.message || String(error || ''),
|
|
356
|
+
})
|
|
357
|
+
})
|
|
358
|
+
}
|
|
359
|
+
|
|
360
|
+
return {
|
|
361
|
+
start() {
|
|
362
|
+
stopped = false
|
|
363
|
+
if (!config.enabled) {
|
|
364
|
+
syncStatusFromConfig()
|
|
365
|
+
return
|
|
366
|
+
}
|
|
367
|
+
connect().catch((error) => {
|
|
368
|
+
updateStatus({
|
|
369
|
+
lastError: error?.message || 'Relay 连接失败。',
|
|
370
|
+
})
|
|
371
|
+
logError('[relay] 初次连接失败', {
|
|
372
|
+
error: error?.message || String(error || ''),
|
|
373
|
+
})
|
|
374
|
+
scheduleReconnect()
|
|
375
|
+
})
|
|
376
|
+
},
|
|
377
|
+
stop() {
|
|
378
|
+
stopped = true
|
|
379
|
+
if (reconnectTimer) {
|
|
380
|
+
clearTimeout(reconnectTimer)
|
|
381
|
+
reconnectTimer = null
|
|
382
|
+
}
|
|
383
|
+
requestMap.forEach((record) => {
|
|
384
|
+
record.controller?.abort()
|
|
385
|
+
})
|
|
386
|
+
requestMap = new Map()
|
|
387
|
+
socket?.close()
|
|
388
|
+
socket = null
|
|
389
|
+
authenticated = false
|
|
390
|
+
updateStatus({
|
|
391
|
+
connected: false,
|
|
392
|
+
})
|
|
393
|
+
},
|
|
394
|
+
updateConfig(nextConfig = {}) {
|
|
395
|
+
const previousEnabled = config.enabled
|
|
396
|
+
config = readRelayClientConfig({
|
|
397
|
+
...config,
|
|
398
|
+
...nextConfig,
|
|
399
|
+
})
|
|
400
|
+
syncStatusFromConfig()
|
|
401
|
+
updateStatus({
|
|
402
|
+
lastError: '',
|
|
403
|
+
})
|
|
404
|
+
|
|
405
|
+
if (!config.enabled) {
|
|
406
|
+
this.stop()
|
|
407
|
+
return
|
|
408
|
+
}
|
|
409
|
+
|
|
410
|
+
if (!previousEnabled) {
|
|
411
|
+
this.start()
|
|
412
|
+
return
|
|
413
|
+
}
|
|
414
|
+
|
|
415
|
+
if (socket) {
|
|
416
|
+
socket.close(1012, 'config_updated')
|
|
417
|
+
} else {
|
|
418
|
+
this.start()
|
|
419
|
+
}
|
|
420
|
+
},
|
|
421
|
+
getStatus() {
|
|
422
|
+
return { ...status }
|
|
423
|
+
},
|
|
424
|
+
}
|
|
425
|
+
}
|
|
426
|
+
|
|
427
|
+
export {
|
|
428
|
+
createDisabledStatus,
|
|
429
|
+
createRelayClient,
|
|
430
|
+
readRelayClientConfig,
|
|
431
|
+
}
|
|
@@ -0,0 +1,84 @@
|
|
|
1
|
+
import fs from 'node:fs'
|
|
2
|
+
import path from 'node:path'
|
|
3
|
+
|
|
4
|
+
import { ensurePromptxStorageReady } from './appPaths.js'
|
|
5
|
+
|
|
6
|
+
const RELAY_CONFIG_FILE = 'relay-config.json'
|
|
7
|
+
|
|
8
|
+
function getRelayConfigPath() {
|
|
9
|
+
const { dataDir } = ensurePromptxStorageReady()
|
|
10
|
+
return path.join(dataDir, RELAY_CONFIG_FILE)
|
|
11
|
+
}
|
|
12
|
+
|
|
13
|
+
function normalizeRelayConfig(input = {}) {
|
|
14
|
+
const relayUrl = String(input?.relayUrl || '').trim()
|
|
15
|
+
const deviceId = String(input?.deviceId || '').trim()
|
|
16
|
+
const deviceToken = String(input?.deviceToken || '').trim()
|
|
17
|
+
const enabled = typeof input?.enabled === 'boolean'
|
|
18
|
+
? input.enabled
|
|
19
|
+
: !['0', 'false', 'off', 'no'].includes(String(input?.enabled || '').trim().toLowerCase())
|
|
20
|
+
|
|
21
|
+
return {
|
|
22
|
+
relayUrl,
|
|
23
|
+
deviceId,
|
|
24
|
+
deviceToken,
|
|
25
|
+
enabled: Boolean(enabled && relayUrl && deviceId && deviceToken),
|
|
26
|
+
}
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function readStoredRelayConfig() {
|
|
30
|
+
const filePath = getRelayConfigPath()
|
|
31
|
+
|
|
32
|
+
try {
|
|
33
|
+
const payload = JSON.parse(fs.readFileSync(filePath, 'utf8'))
|
|
34
|
+
return normalizeRelayConfig(payload)
|
|
35
|
+
} catch {
|
|
36
|
+
return normalizeRelayConfig()
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
function writeStoredRelayConfig(input = {}) {
|
|
41
|
+
const filePath = getRelayConfigPath()
|
|
42
|
+
const normalized = normalizeRelayConfig(input)
|
|
43
|
+
fs.writeFileSync(filePath, `${JSON.stringify(normalized, null, 2)}\n`, 'utf8')
|
|
44
|
+
return normalized
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function getRelayConfigForClient() {
|
|
48
|
+
const stored = readStoredRelayConfig()
|
|
49
|
+
const relayUrl = String(process.env.PROMPTX_RELAY_URL || stored.relayUrl || '').trim()
|
|
50
|
+
const deviceId = String(process.env.PROMPTX_RELAY_DEVICE_ID || stored.deviceId || '').trim()
|
|
51
|
+
const deviceToken = String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || stored.deviceToken || '').trim()
|
|
52
|
+
const envEnabled = String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
|
|
53
|
+
const managedByEnv = isRelayConfigManagedByEnv()
|
|
54
|
+
const enabled = envEnabled
|
|
55
|
+
? !['0', 'false', 'off', 'no'].includes(envEnabled.toLowerCase())
|
|
56
|
+
: managedByEnv
|
|
57
|
+
? Boolean(relayUrl && deviceId && deviceToken)
|
|
58
|
+
: Boolean(stored.enabled)
|
|
59
|
+
|
|
60
|
+
return normalizeRelayConfig({
|
|
61
|
+
relayUrl,
|
|
62
|
+
deviceId,
|
|
63
|
+
deviceToken,
|
|
64
|
+
enabled,
|
|
65
|
+
})
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
function isRelayConfigManagedByEnv() {
|
|
69
|
+
return Boolean(
|
|
70
|
+
String(process.env.PROMPTX_RELAY_URL || '').trim()
|
|
71
|
+
|| String(process.env.PROMPTX_RELAY_DEVICE_ID || '').trim()
|
|
72
|
+
|| String(process.env.PROMPTX_RELAY_DEVICE_TOKEN || '').trim()
|
|
73
|
+
|| String(process.env.PROMPTX_RELAY_ENABLED || '').trim()
|
|
74
|
+
)
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
export {
|
|
78
|
+
getRelayConfigForClient,
|
|
79
|
+
getRelayConfigPath,
|
|
80
|
+
isRelayConfigManagedByEnv,
|
|
81
|
+
normalizeRelayConfig,
|
|
82
|
+
readStoredRelayConfig,
|
|
83
|
+
writeStoredRelayConfig,
|
|
84
|
+
}
|