@fxz26284407/yuanma-gateway 0.1.0 → 0.1.3
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/LICENSE +0 -1
- package/NOTICE +0 -1
- package/README.md +28 -72
- package/bin/yuanma-gateway.mjs +0 -0
- package/dist/agents.js +1 -1
- package/dist/antigravity-conversation.js +128 -0
- package/dist/antigravity-native.js +184 -0
- package/dist/antigravity.js +937 -0
- package/dist/cli.js +5 -4
- package/dist/codex.js +2 -2
- package/dist/deepseek-conversation.js +4 -3
- package/dist/deepseek.js +37 -14
- package/dist/execution-summary.js +5 -0
- package/dist/extension-icons.js +2 -1
- package/dist/mobile-history.js +8 -1
- package/dist/progress.js +22 -11
- package/dist/secure-ws.js +4 -2
- package/dist/server.js +3 -1
- package/package.json +1 -3
package/LICENSE
CHANGED
package/NOTICE
CHANGED
|
@@ -23,4 +23,3 @@ qrcode-terminal includes QRCode for JavaScript, Copyright (c) 2009 Kazuhiko Aras
|
|
|
23
23
|
Development and transitive dependencies retain their respective licenses in the npm packages and package lock metadata. Relay packaging copies the complete ws package, including its license. No third-party copyright notices are removed.
|
|
24
24
|
|
|
25
25
|
Original Yuanma gateway code is licensed under Apache-2.0; dependency licenses remain unchanged.
|
|
26
|
-
|
package/README.md
CHANGED
|
@@ -1,23 +1,23 @@
|
|
|
1
1
|
# 远码网关(yuanma-gateway)
|
|
2
2
|
|
|
3
|
-
远码的 Mac 本地网关与 HTTP/WS
|
|
3
|
+
远码的 Mac 本地网关与 HTTP/WS 局域网密文中继。只包含两端网关,不包含鸿蒙工程、签名文件、个人文档库或运行数据。
|
|
4
4
|
|
|
5
5
|
## 项目系列
|
|
6
6
|
|
|
7
7
|
[远码 Yuanma · GitHub Project](https://github.com/users/fxz26284407/projects/2)
|
|
8
8
|
|
|
9
|
-
- [
|
|
9
|
+
- [远码网关与局域网中继](https://github.com/fxz26284407/yuanma-gateway)
|
|
10
10
|
- [远码鸿蒙客户端](https://github.com/fxz26284407/yuanma-harmony)
|
|
11
11
|
|
|
12
12
|
## 部署架构
|
|
13
13
|
|
|
14
14
|

|
|
15
15
|
|
|
16
|
-
|
|
16
|
+
手机与 Mac 位于同一局域网,主动连接用户自行部署的内网 WS 中继,例如 `ws://192.168.1.10:19088`。业务内容通过 Noise 在两端加密,中继只负责转发。Codex Desktop 与 Mac 网关连接同一个 app-server,任务、文件操作和审批发生在 Mac。SQLite 消息账本与设备授权也保存在 Mac。
|
|
17
17
|
|
|
18
|
-
|
|
18
|
+
中继部署在 Mac 的 Docker 或同一局域网内的主机,端口映射为 `19088:8080`,支持 amd64 / arm64。示例使用私有 IP,不需要公网域名、内网穿透或外部反向代理;不要在路由器上配置公网端口转发。按下文将服务绑定到宿主机实际的局域网 IP,并由防火墙仅允许可信内网设备访问。
|
|
19
19
|
|
|
20
|
-
|
|
20
|
+
这里的“局域网”指手机、网关与中继之间的连接范围,不表示模型在本地离线运行。Mac 上的 Codex、DeepSeek 及所选模型服务仍按各自配置联网;安装依赖和拉取镜像也需要访问相应软件源。
|
|
21
21
|
|
|
22
22
|
[架构图源文件](docs/deployment.archify.json)
|
|
23
23
|
|
|
@@ -26,7 +26,7 @@
|
|
|
26
26
|
| 目录 | 内容 | 运行位置 |
|
|
27
27
|
| --- | --- | --- |
|
|
28
28
|
| `gateway/` | Codex 适配、鉴权、项目权限、SQLite 消息账本、自有队列、图片和审批路由 | 工作 Mac |
|
|
29
|
-
| `gateway/relay.ts`、`deploy/relay/` | HTTP 健康检查、WebSocket 密文路由、Docker 配置 |
|
|
29
|
+
| `gateway/relay.ts`、`deploy/relay/` | HTTP 健康检查、WebSocket 密文路由、Docker 配置 | 局域网主机或 Mac Docker |
|
|
30
30
|
| `scripts/` | 配对二维码、Mac 本地批准页、网关重启和中继打包 | Mac |
|
|
31
31
|
|
|
32
32
|
手机与 Mac 建立固定公钥的 Noise 加密通道,中继只转发密文。Codex 桌面和网关需要连接同一后端才能共享实时会话。
|
|
@@ -66,18 +66,18 @@ yuanma-gateway projects
|
|
|
66
66
|
### 初始化
|
|
67
67
|
|
|
68
68
|
```sh
|
|
69
|
-
yuanma-gateway init --relay-url
|
|
69
|
+
yuanma-gateway init --relay-url ws://192.168.1.10:19088
|
|
70
70
|
```
|
|
71
71
|
|
|
72
|
-
|
|
72
|
+
将示例 IP 替换为运行中继的主机局域网 IP,并先按下文启动中继。初始化读取真实 Codex 项目列表,由你选择初始项目。**现有产品会继续同步发现其他项目;配对后所有发现的项目和工作区默认可执行,初始列表不是权限白名单。** 初始化会明确说明并要求接受这项既有策略;会话中的执行审批仍独立管理。
|
|
73
73
|
|
|
74
|
-
|
|
74
|
+
当前版本的跨设备连接还需显式启用本机私有文件保存传输密钥的模式;交互界面会说明并征求选择。
|
|
75
75
|
|
|
76
76
|
在非交互环境中,提供真实项目 ID 和对应选项:
|
|
77
77
|
|
|
78
78
|
```sh
|
|
79
79
|
yuanma-gateway init \
|
|
80
|
-
--relay-url
|
|
80
|
+
--relay-url ws://192.168.1.10:19088 \
|
|
81
81
|
--projects '真实项目ID1,真实项目ID2' \
|
|
82
82
|
--allow-development-keys \
|
|
83
83
|
--accept-project-access
|
|
@@ -107,7 +107,7 @@ yuanma-gateway restart
|
|
|
107
107
|
更新中继地址并保存,供后续重启/启动复用:
|
|
108
108
|
|
|
109
109
|
```sh
|
|
110
|
-
yuanma-gateway restart --relay-url
|
|
110
|
+
yuanma-gateway restart --relay-url ws://192.168.1.20:19088
|
|
111
111
|
```
|
|
112
112
|
|
|
113
113
|
网关尚未运行时使用 `start --relay-url 地址`。URL 支持 `ws://` 和 `wss://`,拒绝内嵌账号密码、查询参数、片段或非法端口。状态输出中的 `relayUrl` 是实际生效地址,末尾 `/` 属正常规范化结果。
|
|
@@ -126,7 +126,7 @@ yuanma-gateway pair
|
|
|
126
126
|
|
|
127
127
|
### 手机版本要求
|
|
128
128
|
|
|
129
|
-
|
|
129
|
+
局域网连接使用 `ws://内网IP:19088`。建议手机与 Mac 网关保持兼容版本;更新 relay 镜像或 npm 网关,不会自动升级手机 App。
|
|
130
130
|
|
|
131
131
|
使用同一签名的新包覆盖安装,保留应用数据与原配对;不要先卸载或清空数据。在手机连接页选择“扫码更新连接地址”,扫描刚生成且未过期的二维码。手机和 Mac 须连接同一套 relay,两套中继之间不共享注册路由。
|
|
132
132
|
|
|
@@ -199,7 +199,7 @@ yuanma-gateway devices
|
|
|
199
199
|
|
|
200
200
|
同步前会在网关私有状态目录保存桌面状态备份;桌面运行时不会写入其状态文件。待同步记录在网关重启后保留,旧版手机分区记录会自动纳入一次补同步。
|
|
201
201
|
|
|
202
|
-
##
|
|
202
|
+
## 局域网中继部署:直接拉取 Docker Hub 镜像
|
|
203
203
|
|
|
204
204
|
公开仓库:[fxz26284407/yuanma-relay](https://hub.docker.com/r/fxz26284407/yuanma-relay)。普通部署不再需要在 Mac 打包源码、上传发布目录或在服务器构建镜像;宿主机只需 Docker,不需要安装 Node、Codex 或网关依赖。
|
|
205
205
|
|
|
@@ -212,14 +212,14 @@ yuanma-gateway devices
|
|
|
212
212
|
|
|
213
213
|
### 启动与端口
|
|
214
214
|
|
|
215
|
-
|
|
215
|
+
以下假设中继宿主机的局域网 IP 为 `192.168.1.10`。先替换为本机实际 IP,确认 `19088` 未被占用,再执行:
|
|
216
216
|
|
|
217
217
|
```sh
|
|
218
218
|
docker pull fxz26284407/yuanma-relay:2026.11.03
|
|
219
219
|
|
|
220
220
|
docker run -d --name yuanma-relay \
|
|
221
221
|
--restart unless-stopped --init \
|
|
222
|
-
-p 19088:8080 \
|
|
222
|
+
-p 192.168.1.10:19088:8080 \
|
|
223
223
|
--read-only --tmpfs /tmp:rw,noexec,nosuid,size=16m \
|
|
224
224
|
--cap-drop ALL --security-opt no-new-privileges:true \
|
|
225
225
|
--memory 128m --cpus 0.5 --pids-limit 64 \
|
|
@@ -227,82 +227,38 @@ docker run -d --name yuanma-relay \
|
|
|
227
227
|
fxz26284407/yuanma-relay:2026.11.03
|
|
228
228
|
```
|
|
229
229
|
|
|
230
|
-
`19088:8080` 表示
|
|
230
|
+
`192.168.1.10:19088:8080` 表示 **宿主机局域网 IP 的 19088 → 容器 8080**,仅绑定指定内网接口。如需换端口,同步修改两端连接地址。无需给 relay 挂载工作目录、数据库、网关配置或 Mac 私钥。镜像自带 Node 运行时,内部不含 Nginx。
|
|
231
231
|
|
|
232
232
|
```sh
|
|
233
|
-
curl --max-time 5 -fsS http://
|
|
233
|
+
curl --max-time 5 -fsS http://192.168.1.10:19088/health
|
|
234
234
|
docker inspect yuanma-relay --format '{{.State.Status}} / {{.State.Health.Status}}'
|
|
235
235
|
docker logs --tail 30 yuanma-relay
|
|
236
236
|
```
|
|
237
237
|
|
|
238
238
|
健康接口应返回 `{"ok":true}`;启动后短暂的 `starting` 会在健康检查完成后转为 `healthy`。重启已有容器可执行 `docker restart yuanma-relay`。仅拉取新镜像不会自动替换运行中的容器,升级时按原部署方式重建并保留端口和资源配置;已有 relay 占用端口时不要重复启动。
|
|
239
239
|
|
|
240
|
-
###
|
|
240
|
+
### 手机与 Mac 连接同一局域网
|
|
241
241
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
手机 / Mac 网关
|
|
246
|
-
→ wss://你的花生壳域名:443
|
|
247
|
-
→ 花生壳 HTTPS 入口(TLS 与证书)
|
|
248
|
-
→ 花生壳客户端
|
|
249
|
-
→ 宿主机可达地址:19088
|
|
250
|
-
→ relay 容器 8080(HTTP/WS)
|
|
251
|
-
```
|
|
252
|
-
|
|
253
|
-
| 花生壳配置项 | 填写方式 |
|
|
254
|
-
| --- | --- |
|
|
255
|
-
| 映射协议 | HTTPS |
|
|
256
|
-
| 外网域名 | 花生壳分配或绑定的实际域名 |
|
|
257
|
-
| 外网端口 | 默认 443,或产品实际提供的 HTTPS 外网端口 |
|
|
258
|
-
| 内网主机 | 从花生壳客户端所在环境可达的宿主机地址 |
|
|
259
|
-
| 内网端口 | 19088 |
|
|
260
|
-
| 回源协议(如有此选项) | HTTP,relay 内部不提供 TLS |
|
|
261
|
-
|
|
262
|
-
**公网 HTTPS 不等于内网也要 HTTPS。** 花生壳负责证书和 TLS,relay 继续用 HTTP/WS。外层入口需要支持 WebSocket 升级;若有单独的 WebSocket 开关,应开启。容器内加 Nginx 无法替代外层的 WS 支持。
|
|
263
|
-
|
|
264
|
-
“内网主机”按花生壳实际运行位置选择:
|
|
265
|
-
|
|
266
|
-
- 花生壳直接运行在 relay 宿主机上:可以使用 `127.0.0.1:19088`。
|
|
267
|
-
- 花生壳在 Docker 容器里:`127.0.0.1` 是花生壳容器自身,应改为可达的宿主机地址。
|
|
268
|
-
- Mac Docker Desktop 可查询 `host.docker.internal`;本次环境解析为 `192.168.65.254`,已核对从花生壳容器能访问 `192.168.65.254:19088`。该 IP 不是所有机器的固定值。
|
|
269
|
-
- Linux 或其他环境使用从花生壳客户端可达的宿主机内网 IP,不直接照抄 Mac 示例。
|
|
270
|
-
|
|
271
|
-
例如花生壳容器名为 `oray`,且内置以下工具:
|
|
242
|
+
1. 将手机连接到与 Mac、中继互通的 Wi-Fi;Mac 或中继主机可使用同一路由器下的有线网络。不要使用隔离设备的访客网络。
|
|
243
|
+
2. 为中继主机配置 DHCP 地址保留或固定内网 IP,允许可信内网设备访问 TCP `19088`,关闭路由器端口转发、穿透及其他对外入口。
|
|
244
|
+
3. 在 Mac 检查 `http://192.168.1.10:19088/health`,返回 `{"ok":true}` 后更新网关并配对:
|
|
272
245
|
|
|
273
246
|
```sh
|
|
274
|
-
|
|
275
|
-
docker exec oray wget -qO- -T 5 -t 1 http://192.168.65.254:19088/health
|
|
276
|
-
curl --max-time 10 -fsS https://relay.example.com/health
|
|
247
|
+
yuanma-gateway pair --relay-url ws://192.168.1.10:19088
|
|
277
248
|
```
|
|
278
249
|
|
|
279
|
-
|
|
280
|
-
|
|
281
|
-
配置完成后,已通过 npm 初始化的 Mac 网关可在任意目录执行:
|
|
282
|
-
|
|
283
|
-
```sh
|
|
284
|
-
source ~/.nvm/nvm.sh
|
|
285
|
-
nvm use 22
|
|
286
|
-
yuanma-gateway pair --relay-url wss://relay.example.com
|
|
287
|
-
```
|
|
288
|
-
|
|
289
|
-
将 `relay.example.com` 换成花生壳实际域名;非 443 外网端口需写入 URL。npm 配对命令会按现有安装启动网关,地址变化时安全重启原实例;已配对手机通过“扫码更新连接地址”沿用原身份登录。使用 HTTPS 穿透时,网关参数写 `wss://`,不能写 `https://`。手机须先升级至上文要求的 WSS 版本。
|
|
290
|
-
|
|
291
|
-
本机部署依赖 Mac、Docker Desktop 和花生壳持续在线。休眠、退出 Docker 或花生壳离线都会中断连接;中继镜像不会自动配置这些运行条件。
|
|
292
|
-
|
|
293
|
-
### 公网服务器直连或外部反代
|
|
250
|
+
将示例 IP 替换为中继主机实际的内网 IP。手机扫描 Mac 生成的二维码;已有配对通过“扫码更新连接地址”沿用原身份。手机和 Mac 须使用同一个中继地址,手机不能将 `127.0.0.1` 或 `localhost` 作为 Mac 地址。
|
|
294
251
|
|
|
295
|
-
|
|
252
|
+
离开该局域网后,此部署方式无法连接 Mac;回到互通网络后可重新连接。更换局域网或中继 IP 后,更新网关地址并重新扫码。部署在 Mac Docker 上时,Mac 与 Docker 必须持续运行;休眠或退出 Docker 会中断连接。
|
|
296
253
|
|
|
297
254
|
### 常见连接问题
|
|
298
255
|
|
|
299
256
|
| 现象 | 优先核对 |
|
|
300
257
|
| --- | --- |
|
|
301
|
-
| 宿主机健康检查失败 | relay
|
|
302
|
-
|
|
|
303
|
-
|
|
|
304
|
-
|
|
|
305
|
-
| 手机扫码后仍恢复旧连接 | 手机版本是否支持 WSS、二维码是否过期、新地址是否成功登录并保存 |
|
|
258
|
+
| 宿主机健康检查失败 | relay 是否运行、绑定的内网 IP 是否属于本机、端口是否被占用 |
|
|
259
|
+
| Mac 可达,手机不可达 | 手机是否连接同一局域网、是否启用访客隔离、防火墙是否允许内网端口 |
|
|
260
|
+
| 手机扫码后仍恢复旧连接 | 二维码是否过期、新地址是否成功登录并保存 |
|
|
261
|
+
| 更换网络后无法连接 | 是否仍能访问中继内网 IP;地址变化后需更新网关并重新扫码 |
|
|
306
262
|
| WS 打开后路由立即断开 | Mac 网关是否注册到同一套 relay;“配置了中继”不等于注册成功 |
|
|
307
263
|
|
|
308
264
|
Docker Hub 首页有完整的镜像使用说明。源码构建和 `npm run package:relay` 仍保留给需要自行修改中继的开发者,不作为普通部署的前置步骤。
|
package/bin/yuanma-gateway.mjs
CHANGED
|
File without changes
|
package/dist/agents.js
CHANGED
|
@@ -0,0 +1,128 @@
|
|
|
1
|
+
import { createHash } from 'node:crypto';
|
|
2
|
+
import { basename } from 'node:path';
|
|
3
|
+
import { requireValue } from './domain.js';
|
|
4
|
+
export const fingerprint = (value) => createHash('sha256').update(JSON.stringify(value)).digest('hex').slice(0, 16);
|
|
5
|
+
const valueText = (value) => typeof value === 'string' ? value : value === undefined ? '' : JSON.stringify(value, null, 2);
|
|
6
|
+
export function userText(step) {
|
|
7
|
+
const text = valueText(step.content);
|
|
8
|
+
// Strip only the observed whole envelope, never arbitrary user-authored tags.
|
|
9
|
+
const match = /^<USER_REQUEST>\s*([\s\S]*?)\s*<\/USER_REQUEST>(?:\s*<(ADDITIONAL_METADATA|USER_SETTINGS_CHANGE)>[\s\S]*?<\/\2>)*\s*$/.exec(text);
|
|
10
|
+
return match ? match[1] : text;
|
|
11
|
+
}
|
|
12
|
+
export function stepParts(step) {
|
|
13
|
+
const parts = [];
|
|
14
|
+
if (step.source === 'USER_EXPLICIT' && step.type === 'USER_INPUT') {
|
|
15
|
+
const text = userText(step);
|
|
16
|
+
if (text.trim() && !/^<USER_SETTINGS_CHANGE>[\s\S]*<\/USER_SETTINGS_CHANGE>$/.test(text.trim()))
|
|
17
|
+
parts.push({ part: 'content', role: 'user', text });
|
|
18
|
+
}
|
|
19
|
+
else if (step.source === 'MODEL' && step.type === 'PLANNER_RESPONSE') {
|
|
20
|
+
if (step.thinking)
|
|
21
|
+
parts.push({ part: 'thinking', role: 'tool', text: valueText(step.thinking), label: '思考记录' });
|
|
22
|
+
if (Array.isArray(step.tool_calls) && step.tool_calls.length)
|
|
23
|
+
parts.push({ part: 'tools', role: 'tool', text: valueText(step.tool_calls), label: '工具调用记录' });
|
|
24
|
+
if (step.content)
|
|
25
|
+
parts.push({ part: 'content', role: 'assistant', text: valueText(step.content) });
|
|
26
|
+
}
|
|
27
|
+
else if (step.source === 'MODEL' && step.type === 'GENERIC' && step.content) {
|
|
28
|
+
parts.push({ part: 'content', role: 'tool', text: valueText(step.content), label: step.label ?? '执行记录' });
|
|
29
|
+
}
|
|
30
|
+
return parts;
|
|
31
|
+
}
|
|
32
|
+
export function recordSteps(lines) {
|
|
33
|
+
const records = [];
|
|
34
|
+
let turnId = 'agy-context';
|
|
35
|
+
const latest = new Map();
|
|
36
|
+
for (const line of lines) {
|
|
37
|
+
if (!line.trim())
|
|
38
|
+
continue;
|
|
39
|
+
let step;
|
|
40
|
+
try {
|
|
41
|
+
step = JSON.parse(line);
|
|
42
|
+
}
|
|
43
|
+
catch {
|
|
44
|
+
throw new Error('Antigravity 历史格式损坏,请刷新后重试');
|
|
45
|
+
}
|
|
46
|
+
requireValue(Number.isSafeInteger(step.step_index) && step.step_index >= 0 && typeof step.type === 'string' && typeof step.source === 'string', 'PROTOCOL_INCOMPATIBLE', 'Antigravity 历史步骤格式已变化', 503);
|
|
47
|
+
latest.set(step.step_index, step);
|
|
48
|
+
}
|
|
49
|
+
// Native rewrites may append another version of an existing step.
|
|
50
|
+
for (const step of [...latest.values()].sort((a, b) => a.step_index - b.step_index)) {
|
|
51
|
+
if (step.source === 'USER_EXPLICIT' && step.type === 'USER_INPUT' && !/^<USER_SETTINGS_CHANGE>/.test(userText(step).trim()))
|
|
52
|
+
turnId = 'agy-turn-' + step.step_index;
|
|
53
|
+
records.push({ step, turnId });
|
|
54
|
+
}
|
|
55
|
+
return records;
|
|
56
|
+
}
|
|
57
|
+
// Content changes during generation; identity must survive native updates and disk catch-up.
|
|
58
|
+
export function partId(step, part) { const date = Date.parse(step.created_at); return 'agy:' + step.step_index + ':' + fingerprint([step.source, step.type, Number.isFinite(date) ? date : step.created_at]) + ':' + part; }
|
|
59
|
+
export function stepStatus(status) {
|
|
60
|
+
const value = status.replace('CORTEX_STEP_STATUS_', '');
|
|
61
|
+
return value === 'DONE' ? 'completed' : value === 'ERROR' ? 'failed' : value === 'CANCELED' ? 'cancelled' : value === 'WAITING' ? 'waiting' : ['RUNNING', 'GENERATING'].includes(value) ? 'running' : 'pending';
|
|
62
|
+
}
|
|
63
|
+
export function nativeRecords(steps, fallbackTurn = 'agy-context') {
|
|
64
|
+
let turnId = fallbackTurn;
|
|
65
|
+
const records = [];
|
|
66
|
+
for (const [index, raw] of [...steps].sort((a, b) => a[0] - b[0])) {
|
|
67
|
+
if (raw.userInput?.isQueuedMessage)
|
|
68
|
+
continue;
|
|
69
|
+
const source = String(raw.metadata?.source ?? '').replace('CORTEX_STEP_SOURCE_', ''), type = String(raw.type ?? '').replace('CORTEX_STEP_TYPE_', '');
|
|
70
|
+
const step = { step_index: index, source, type, status: String(raw.status ?? '').replace('CORTEX_STEP_STATUS_', ''), created_at: raw.metadata?.createdAt ?? '' };
|
|
71
|
+
if (raw.userInput) {
|
|
72
|
+
step.content = (raw.userInput.items ?? []).filter((i) => typeof i.text === 'string').map((i) => i.text).join('');
|
|
73
|
+
if (source === 'USER_EXPLICIT' && !raw.userInput.isQueuedMessage && !/^<USER_SETTINGS_CHANGE>/.test(userText(step).trim()))
|
|
74
|
+
turnId = 'agy-turn-' + index;
|
|
75
|
+
}
|
|
76
|
+
else if (raw.plannerResponse) {
|
|
77
|
+
step.content = raw.plannerResponse.response ?? raw.plannerResponse.modifiedResponse;
|
|
78
|
+
if (!raw.plannerResponse.thinkingRedacted)
|
|
79
|
+
step.thinking = raw.plannerResponse.thinking;
|
|
80
|
+
step.tool_calls = raw.plannerResponse.toolCalls;
|
|
81
|
+
}
|
|
82
|
+
else if (source === 'MODEL') {
|
|
83
|
+
const key = ['runCommand', 'viewFile', 'codeAction', 'generic'].find(k => raw[k]);
|
|
84
|
+
if (key) {
|
|
85
|
+
step.type = 'GENERIC';
|
|
86
|
+
step.content = raw[key];
|
|
87
|
+
const render = raw.generic?.result?.stepRenderInfo, name = raw.metadata?.toolCall?.name;
|
|
88
|
+
const action = { edit_file: '编辑文件', write_to_file: '写入文件', write_file: '写入文件', create_file: '创建文件', read_file: '读取文件', view_file: '读取文件', run_command: '执行命令', run_terminal_command: '执行命令', list_dir: '浏览目录', find_by_name: '查找文件', grep_search: '搜索文件', search_web: '搜索网页' }[name] ?? { edit: '编辑文件', read: '读取文件', write: '写入文件' }[render?.icon] ?? { runCommand: '执行命令', viewFile: '读取文件', codeAction: '执行文件操作', generic: '执行操作' }[key];
|
|
89
|
+
const files = [];
|
|
90
|
+
for (const part of Array.isArray(render?.titleParts) ? render.titleParts : []) {
|
|
91
|
+
const uri = part?.file?.uri ?? part?.fileDiff?.uri;
|
|
92
|
+
if (typeof uri === 'string')
|
|
93
|
+
try {
|
|
94
|
+
const file = basename(decodeURIComponent(new URL(uri).pathname));
|
|
95
|
+
if (file && !files.includes(file))
|
|
96
|
+
files.push(file);
|
|
97
|
+
}
|
|
98
|
+
catch { /* Optional display metadata must not break conversation reading. */ }
|
|
99
|
+
}
|
|
100
|
+
const status = stepStatus(step.status), summary = raw.metadata?.toolSummary ?? raw.metadata?.toolAction;
|
|
101
|
+
step.label = status === 'waiting' ? (name === 'ask_question' ? '等待回答…' : '等待批准…') : typeof summary === 'string' && summary.trim() ? summary.slice(0, 200) : (status === 'running' || status === 'pending' ? '正在' : status === 'completed' ? '已' : status === 'failed' ? '失败 · ' : '已停止 · ') + action + (files.length ? ' ' + files.join('、') : '');
|
|
102
|
+
step.label = step.label.slice(0, 240);
|
|
103
|
+
step.execution_kind = { edit_file: 'edit', write_to_file: 'write', write_file: 'write', create_file: 'write', read_file: 'read', view_file: 'read', run_command: 'command', run_terminal_command: 'command', ask_question: 'question' }[name] ?? { runCommand: 'command', viewFile: 'read', codeAction: 'edit', generic: 'tool' }[key];
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
records.push({ step, turnId });
|
|
107
|
+
}
|
|
108
|
+
return records;
|
|
109
|
+
}
|
|
110
|
+
export function conversationPage(records, nextCursor, fullReplies = new Map()) {
|
|
111
|
+
const turns = new Map();
|
|
112
|
+
for (const { step, turnId } of records)
|
|
113
|
+
for (const part of stepParts(step)) {
|
|
114
|
+
const fields = step.truncated_fields ?? [];
|
|
115
|
+
const id = partId(step, part.part), reply = part.role === 'assistant';
|
|
116
|
+
const text = fullReplies.get(id) ?? part.text;
|
|
117
|
+
const truncated = (!reply && text.length > 1000) || (fields.includes(part.part === 'tools' ? 'tool_calls' : part.part) && !fullReplies.has(id));
|
|
118
|
+
const item = { id, type: part.role === 'user' ? 'userMessage' : reply ? 'agentMessage' : 'commandExecution', role: part.role, text: reply ? text : text.slice(0, 1000), totalChars: text.length, truncated };
|
|
119
|
+
item.readId = id + ':' + fingerprint(step);
|
|
120
|
+
item.groupKey = turnId + ':' + step.step_index;
|
|
121
|
+
if (part.role === 'tool')
|
|
122
|
+
item.execution = { kind: part.part === 'thinking' ? 'reasoning' : step.execution_kind ?? 'tool', label: part.label, status: stepStatus(step.status) };
|
|
123
|
+
if (!turns.has(turnId))
|
|
124
|
+
turns.set(turnId, { id: turnId, status: 'recorded', items: [] });
|
|
125
|
+
turns.get(turnId).items.push(item);
|
|
126
|
+
}
|
|
127
|
+
return { data: [...turns.values()].reverse(), nextCursor };
|
|
128
|
+
}
|
|
@@ -0,0 +1,184 @@
|
|
|
1
|
+
import { execFile } from 'node:child_process';
|
|
2
|
+
import { promisify } from 'node:util';
|
|
3
|
+
import { request } from 'node:https';
|
|
4
|
+
import { readFile, stat } from 'node:fs/promises';
|
|
5
|
+
import { randomUUID } from 'node:crypto';
|
|
6
|
+
import { Fault, requireValue } from './domain.js';
|
|
7
|
+
const exec = promisify(execFile);
|
|
8
|
+
const app = '/Applications/Antigravity.app/Contents/Resources/app';
|
|
9
|
+
const binary = '/Applications/Antigravity.app/Contents/Resources/bin/language_server';
|
|
10
|
+
/** Same-user desktop process only. Local CSRF credentials never leave this client. */
|
|
11
|
+
export class AntigravityNative {
|
|
12
|
+
#authority;
|
|
13
|
+
#checked = 0;
|
|
14
|
+
ready = false;
|
|
15
|
+
get generation() { return this.#authority ? this.#authority.pid + ':' + this.#authority.port : ''; }
|
|
16
|
+
async connect() {
|
|
17
|
+
if (this.ready && Date.now() - this.#checked < 1000)
|
|
18
|
+
return;
|
|
19
|
+
requireValue(process.platform === 'darwin', 'BACKEND_UNAVAILABLE', 'Antigravity 桌面接入需要 macOS', 503);
|
|
20
|
+
const version = JSON.parse(await readFile(app + '/package.json', 'utf8')).version;
|
|
21
|
+
requireValue(version === '2.29.0', 'PROTOCOL_INCOMPATIBLE', '当前仅适配 Antigravity App 2.29.0,请核对桌面版本', 503);
|
|
22
|
+
const { stdout } = await exec('/bin/ps', ['-axo', 'uid,pid,ppid,command'], { maxBuffer: 4 * 1024 * 1024 });
|
|
23
|
+
const processes = stdout.split('\n').flatMap(line => {
|
|
24
|
+
const match = /^\s*(\d+)\s+(\d+)\s+(\d+)\s+(.*)$/.exec(line);
|
|
25
|
+
if (!match || Number(match[1]) !== process.getuid?.() || !match[4].startsWith(binary + ' '))
|
|
26
|
+
return [];
|
|
27
|
+
const flag = (name) => new RegExp('--' + name + '(?:=|\\s+)([^\\s]+)').exec(match[4])?.[1];
|
|
28
|
+
if (flag('app_data_dir') !== 'antigravity' || flag('subclient_type') !== 'hub' || flag('override_ide_name') !== 'antigravity')
|
|
29
|
+
return [];
|
|
30
|
+
const token = flag('csrf_token');
|
|
31
|
+
return token ? [{ pid: Number(match[2]), token }] : [];
|
|
32
|
+
});
|
|
33
|
+
requireValue(processes.length === 1, 'BACKEND_UNAVAILABLE', '请打开唯一的 Antigravity App 桌面实例', 503);
|
|
34
|
+
const desktop = processes[0];
|
|
35
|
+
const { stdout: listeners } = await exec('/usr/sbin/lsof', ['-nP', '-a', '-p', String(desktop.pid), '-iTCP', '-sTCP:LISTEN', '-Fn'], { maxBuffer: 65536 });
|
|
36
|
+
const ports = [...new Set([...listeners.matchAll(/^n(?:127\.0\.0\.1|\*|\[::1\]):(\d+)$/gm)].map(m => Number(m[1])))];
|
|
37
|
+
requireValue(ports.length > 0 && ports.length <= 4, 'BACKEND_UNAVAILABLE', '无法核实 Antigravity 桌面监听地址', 503);
|
|
38
|
+
const executable = await stat(binary);
|
|
39
|
+
requireValue(executable.isFile() && !(executable.mode & 0o002), 'UNSAFE_STORAGE', 'Antigravity 可执行文件权限异常', 403);
|
|
40
|
+
let found;
|
|
41
|
+
for (const port of ports) {
|
|
42
|
+
const authority = { ...desktop, port };
|
|
43
|
+
try {
|
|
44
|
+
const value = await this.rpc(authority, 'GetAllCascadeTrajectories', { excludeSubtrajectories: true }, 5000);
|
|
45
|
+
if (value.trajectorySummaries && typeof value.trajectorySummaries === 'object') {
|
|
46
|
+
found = authority;
|
|
47
|
+
break;
|
|
48
|
+
}
|
|
49
|
+
}
|
|
50
|
+
catch { /* Only ports owned by the verified desktop process are considered. */ }
|
|
51
|
+
}
|
|
52
|
+
requireValue(found, 'BACKEND_UNAVAILABLE', 'Antigravity 桌面会话接口未就绪', 503);
|
|
53
|
+
this.#authority = found;
|
|
54
|
+
this.#checked = Date.now();
|
|
55
|
+
this.ready = true;
|
|
56
|
+
}
|
|
57
|
+
async call(method, params, timeout = 15000, signal) {
|
|
58
|
+
await this.connect();
|
|
59
|
+
const authority = this.#authority;
|
|
60
|
+
try {
|
|
61
|
+
return await this.rpc(authority, method, params, timeout, signal);
|
|
62
|
+
}
|
|
63
|
+
catch (error) {
|
|
64
|
+
if (!signal?.aborted) {
|
|
65
|
+
this.ready = false;
|
|
66
|
+
this.#checked = 0;
|
|
67
|
+
}
|
|
68
|
+
throw error;
|
|
69
|
+
}
|
|
70
|
+
}
|
|
71
|
+
async state() {
|
|
72
|
+
return this.readState('JetboxSubscribeToState', {});
|
|
73
|
+
}
|
|
74
|
+
async subscribeAgentState(conversationId, startIndex, onValue, onEnd) {
|
|
75
|
+
return this.stream('StreamAgentStateUpdates', { conversationId, subscriberId: 'yuanma-live:' + randomUUID(), disableRehydration: true, trajectoryVerbosity: 'CLIENT_TRAJECTORY_VERBOSITY_PROD_UI', initialStepsPageBounds: { startIndex } }, onValue, onEnd);
|
|
76
|
+
}
|
|
77
|
+
async readState(method, params) {
|
|
78
|
+
return new Promise((resolve, reject) => {
|
|
79
|
+
let close, received = false;
|
|
80
|
+
void this.stream(method, params, value => { received = true; resolve(value); close?.(); }, error => reject(error ?? new Error('Antigravity 状态流提前结束'))).then(stop => { close = stop; if (received)
|
|
81
|
+
stop(); }, reject);
|
|
82
|
+
});
|
|
83
|
+
}
|
|
84
|
+
async stream(method, params, onValue, onEnd) {
|
|
85
|
+
await this.connect();
|
|
86
|
+
const authority = this.#authority;
|
|
87
|
+
const payload = Buffer.from(JSON.stringify(params));
|
|
88
|
+
requireValue(payload.length <= 1024 * 1024, 'PAYLOAD_TOO_LARGE', 'Antigravity 流请求过大', 413);
|
|
89
|
+
const frame = Buffer.alloc(5 + payload.length);
|
|
90
|
+
frame.writeUInt32BE(payload.length, 1);
|
|
91
|
+
payload.copy(frame, 5);
|
|
92
|
+
let ended = false, received = false;
|
|
93
|
+
const finish = (error) => { if (ended)
|
|
94
|
+
return; ended = true; clearTimeout(first); req.destroy(); onEnd(error); };
|
|
95
|
+
const req = request({ hostname: '127.0.0.1', port: authority.port, path: '/exa.language_server_pb.LanguageServerService/' + method, method: 'POST', rejectUnauthorized: false, headers: { 'content-type': 'application/connect+json', 'connect-protocol-version': '1', 'x-codeium-csrf-token': authority.token } }, res => {
|
|
96
|
+
if (res.statusCode !== 200) {
|
|
97
|
+
finish(new Error('Antigravity 状态订阅不可用(HTTP ' + res.statusCode + ')'));
|
|
98
|
+
return;
|
|
99
|
+
}
|
|
100
|
+
if (!res.headers['content-type']?.startsWith('application/connect+json')) {
|
|
101
|
+
finish(new Error('Antigravity 状态订阅协议不兼容'));
|
|
102
|
+
return;
|
|
103
|
+
}
|
|
104
|
+
let buffer = Buffer.alloc(0);
|
|
105
|
+
res.on('data', (chunk) => {
|
|
106
|
+
if (ended)
|
|
107
|
+
return;
|
|
108
|
+
buffer = Buffer.concat([buffer, chunk]);
|
|
109
|
+
try {
|
|
110
|
+
while (buffer.length >= 5) {
|
|
111
|
+
const flags = buffer[0], length = buffer.readUInt32BE(1);
|
|
112
|
+
if (length > 32 * 1024 * 1024)
|
|
113
|
+
throw new Error('Antigravity 状态帧过大');
|
|
114
|
+
if (buffer.length < 5 + length)
|
|
115
|
+
break;
|
|
116
|
+
const value = JSON.parse(buffer.subarray(5, 5 + length).toString('utf8'));
|
|
117
|
+
buffer = buffer.subarray(5 + length);
|
|
118
|
+
if (flags === 2) {
|
|
119
|
+
finish(value.error ? new Error('Antigravity 状态订阅中断') : undefined);
|
|
120
|
+
return;
|
|
121
|
+
}
|
|
122
|
+
if (flags !== 0)
|
|
123
|
+
throw new Error('Antigravity 状态帧不兼容');
|
|
124
|
+
received = true;
|
|
125
|
+
clearTimeout(first);
|
|
126
|
+
onValue(value);
|
|
127
|
+
if (ended)
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
}
|
|
131
|
+
catch {
|
|
132
|
+
finish(new Error('Antigravity 状态帧解析失败'));
|
|
133
|
+
}
|
|
134
|
+
});
|
|
135
|
+
res.on('end', () => finish(buffer.length ? new Error('Antigravity 状态帧不完整') : received ? undefined : new Error('Antigravity 状态流为空')));
|
|
136
|
+
res.on('error', () => finish(new Error('Antigravity 状态连接中断')));
|
|
137
|
+
res.on('aborted', () => finish(new Error('Antigravity 状态连接中断')));
|
|
138
|
+
});
|
|
139
|
+
const first = setTimeout(() => finish(new Error('Antigravity 状态订阅超时')), 10000);
|
|
140
|
+
first.unref();
|
|
141
|
+
req.on('error', () => finish(new Error('Antigravity 桌面连接中断')));
|
|
142
|
+
req.end(frame);
|
|
143
|
+
return () => { ended = true; clearTimeout(first); req.destroy(); };
|
|
144
|
+
}
|
|
145
|
+
rpc(authority, method, params, timeout, signal) {
|
|
146
|
+
return new Promise((resolve, reject) => {
|
|
147
|
+
const payload = Buffer.from(JSON.stringify(params));
|
|
148
|
+
if (payload.length > 32 * 1024 * 1024) {
|
|
149
|
+
reject(new Fault('PAYLOAD_TOO_LARGE', 'Antigravity 输入超过接口容量限制', 413));
|
|
150
|
+
return;
|
|
151
|
+
}
|
|
152
|
+
// Match the desktop's trust policy only for the loopback port owned by its verified PID.
|
|
153
|
+
// Never disable TLS validation globally or forward this authorization to a remote host.
|
|
154
|
+
const req = request({ hostname: '127.0.0.1', port: authority.port, path: '/exa.language_server_pb.LanguageServerService/' + method, method: 'POST', rejectUnauthorized: false, signal, headers: { 'content-type': 'application/json', 'connect-protocol-version': '1', 'content-length': payload.length, 'x-codeium-csrf-token': authority.token } }, res => {
|
|
155
|
+
let size = 0;
|
|
156
|
+
const chunks = [];
|
|
157
|
+
res.on('data', (chunk) => { size += chunk.length; if (size > 32 * 1024 * 1024) {
|
|
158
|
+
req.destroy(new Error('Antigravity 返回内容超过容量限制'));
|
|
159
|
+
return;
|
|
160
|
+
} chunks.push(chunk); });
|
|
161
|
+
res.on('error', () => reject(new Error('Antigravity 本地响应中断,结果可能未知')));
|
|
162
|
+
res.on('end', () => {
|
|
163
|
+
let value;
|
|
164
|
+
try {
|
|
165
|
+
value = JSON.parse(Buffer.concat(chunks).toString('utf8'));
|
|
166
|
+
}
|
|
167
|
+
catch {
|
|
168
|
+
reject(new Error('Antigravity 返回了不兼容的响应'));
|
|
169
|
+
return;
|
|
170
|
+
}
|
|
171
|
+
if (res.statusCode !== 200) {
|
|
172
|
+
reject(Object.assign(new Error('Antigravity 本地请求未完成,需核对原生状态'), { rpcError: { code: value.code, message: value.message } }));
|
|
173
|
+
return;
|
|
174
|
+
}
|
|
175
|
+
resolve(value);
|
|
176
|
+
});
|
|
177
|
+
});
|
|
178
|
+
req.on('error', () => reject(new Error('Antigravity 桌面连接中断,结果可能未知')));
|
|
179
|
+
req.setTimeout(timeout, () => req.destroy(new Error('Antigravity 本地请求超时')));
|
|
180
|
+
req.end(payload);
|
|
181
|
+
});
|
|
182
|
+
}
|
|
183
|
+
close() { this.#authority = undefined; this.#checked = 0; this.ready = false; }
|
|
184
|
+
}
|