pi2web 0.2.0
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/ANDROID_APP_FEATURES.md +256 -0
- package/LICENSE +21 -0
- package/README.md +194 -0
- package/VOICE_SERVER_SETUP.md +393 -0
- package/dist/agents.js +123 -0
- package/dist/auth.js +31 -0
- package/dist/mail-notifier.js +132 -0
- package/dist/main.js +90 -0
- package/dist/metadata-store.js +139 -0
- package/dist/sdk-backend.js +85 -0
- package/dist/server.js +419 -0
- package/dist/terminals.js +70 -0
- package/dist/voice.js +322 -0
- package/dist/workspaces.js +60 -0
- package/package.json +79 -0
- package/packages/edge-tts-server/README.md +68 -0
- package/packages/edge-tts-server/pyproject.toml +19 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__init__.py +3 -0
- package/packages/edge-tts-server/src/remote_pi_edge_tts/__main__.py +77 -0
- package/packages/protocol/openapi.yaml +38 -0
- package/packages/protocol/websocket.schema.json +17 -0
- package/web/app.js +544 -0
- package/web/icon.svg +19 -0
- package/web/index.html +84 -0
- package/web/styles.css +29 -0
|
@@ -0,0 +1,393 @@
|
|
|
1
|
+
# Remote Pi 本地语音服务器启动指南(Windows)
|
|
2
|
+
|
|
3
|
+
本文记录两种语音服务启动方式,以及启动 Remote Pi 语音功能的完整步骤:
|
|
4
|
+
|
|
5
|
+
1. Speaches:本地模型,可同时提供 TTS 和可选 STT。
|
|
6
|
+
2. Edge TTS Server:调用 Microsoft Edge 在线语音,只提供 TTS,不需要 Docker 或 FFmpeg。
|
|
7
|
+
|
|
8
|
+
## 当前配置
|
|
9
|
+
|
|
10
|
+
- Speaches 源码目录:`C:\src\opensource\speaches`
|
|
11
|
+
- Speaches 地址:`http://127.0.0.1:8000/v1`
|
|
12
|
+
- 中文 TTS 模型:`speaches-ai/piper-zh_CN-huayan-medium`
|
|
13
|
+
- TTS voice:`huayan`
|
|
14
|
+
- Remote Pi 地址:`http://127.0.0.1:11318`
|
|
15
|
+
|
|
16
|
+
> Windows 上启动 Speaches 时必须启用 Python UTF-8 模式。否则读取中文 Piper 模型的 `config.json` 时可能发生 `UnicodeDecodeError`,客户端表现为 `HTTP 200, 0 bytes` 或 `curl: (18) transfer closed`。
|
|
17
|
+
|
|
18
|
+
## 一、每次启动 Speaches
|
|
19
|
+
|
|
20
|
+
打开一个新的 PowerShell 窗口,执行:
|
|
21
|
+
|
|
22
|
+
```powershell
|
|
23
|
+
cd C:\src\opensource\speaches
|
|
24
|
+
|
|
25
|
+
.\.venv\Scripts\python.exe -X utf8 -m uvicorn `
|
|
26
|
+
--factory `
|
|
27
|
+
--host 127.0.0.1 `
|
|
28
|
+
--port 8000 `
|
|
29
|
+
speaches.main:create_app
|
|
30
|
+
```
|
|
31
|
+
|
|
32
|
+
保持这个窗口运行。看到以下内容表示服务已经启动:
|
|
33
|
+
|
|
34
|
+
```text
|
|
35
|
+
Uvicorn running on http://127.0.0.1:8000
|
|
36
|
+
```
|
|
37
|
+
|
|
38
|
+
在另一个 PowerShell 窗口检查服务:
|
|
39
|
+
|
|
40
|
+
```powershell
|
|
41
|
+
curl.exe --fail-with-body http://127.0.0.1:8000/health
|
|
42
|
+
```
|
|
43
|
+
|
|
44
|
+
正常响应:
|
|
45
|
+
|
|
46
|
+
```json
|
|
47
|
+
{"message":"OK"}
|
|
48
|
+
```
|
|
49
|
+
|
|
50
|
+
Speaches Web UI:
|
|
51
|
+
|
|
52
|
+
```text
|
|
53
|
+
http://127.0.0.1:8000
|
|
54
|
+
```
|
|
55
|
+
|
|
56
|
+
## 二、首次安装或模型丢失时下载中文 TTS 模型
|
|
57
|
+
|
|
58
|
+
模型已经下载后不需要每次执行。
|
|
59
|
+
|
|
60
|
+
```powershell
|
|
61
|
+
curl.exe --fail-with-body -X POST `
|
|
62
|
+
"http://127.0.0.1:8000/v1/models/speaches-ai/piper-zh_CN-huayan-medium"
|
|
63
|
+
```
|
|
64
|
+
|
|
65
|
+
查看已经安装的模型:
|
|
66
|
+
|
|
67
|
+
```powershell
|
|
68
|
+
curl.exe --fail-with-body http://127.0.0.1:8000/v1/models
|
|
69
|
+
```
|
|
70
|
+
|
|
71
|
+
输出中应包含:
|
|
72
|
+
|
|
73
|
+
```text
|
|
74
|
+
speaches-ai/piper-zh_CN-huayan-medium
|
|
75
|
+
```
|
|
76
|
+
|
|
77
|
+
## 三、测试中文语音合成
|
|
78
|
+
|
|
79
|
+
先创建测试请求文件:
|
|
80
|
+
|
|
81
|
+
```powershell
|
|
82
|
+
@'
|
|
83
|
+
{
|
|
84
|
+
"model": "speaches-ai/piper-zh_CN-huayan-medium",
|
|
85
|
+
"voice": "huayan",
|
|
86
|
+
"input": "语音服务器配置成功,这是一段标准普通话测试。",
|
|
87
|
+
"response_format": "wav",
|
|
88
|
+
"sample_rate": 24000,
|
|
89
|
+
"stream_format": "audio"
|
|
90
|
+
}
|
|
91
|
+
'@ | Set-Content "C:\Users\sunxiaowen\piper-request.json" -Encoding utf8
|
|
92
|
+
```
|
|
93
|
+
|
|
94
|
+
发送请求:
|
|
95
|
+
|
|
96
|
+
```powershell
|
|
97
|
+
curl.exe --fail-with-body -sS `
|
|
98
|
+
-X POST "http://127.0.0.1:8000/v1/audio/speech" `
|
|
99
|
+
-H "Content-Type: application/json" `
|
|
100
|
+
--data-binary "@C:\Users\sunxiaowen\piper-request.json" `
|
|
101
|
+
--output "C:\Users\sunxiaowen\piper-test.wav" `
|
|
102
|
+
-w "`nHTTP %{http_code}, %{size_download} bytes`n"
|
|
103
|
+
```
|
|
104
|
+
|
|
105
|
+
正常情况下应显示 `HTTP 200`,并且下载大小明显大于零。播放测试文件:
|
|
106
|
+
|
|
107
|
+
```powershell
|
|
108
|
+
Invoke-Item "C:\Users\sunxiaowen\piper-test.wav"
|
|
109
|
+
```
|
|
110
|
+
|
|
111
|
+
## 四、启动 Remote Pi 的语音功能
|
|
112
|
+
|
|
113
|
+
Remote Pi 需要两个服务:
|
|
114
|
+
|
|
115
|
+
1. OpenAI-compatible 语音服务:可以是 Speaches,也可以是本文第九节的 Edge TTS Server。
|
|
116
|
+
2. OpenAI-compatible 摘要 LLM:把 Agent 最终输出压缩成适合朗读的短摘要。
|
|
117
|
+
|
|
118
|
+
打开另一个 PowerShell 窗口:
|
|
119
|
+
|
|
120
|
+
```powershell
|
|
121
|
+
cd C:\src\sxw\remote-pi
|
|
122
|
+
```
|
|
123
|
+
|
|
124
|
+
如果摘要 LLM 需要 API key,先设置环境变量:
|
|
125
|
+
|
|
126
|
+
```powershell
|
|
127
|
+
$env:SUMMARY_API_KEY = "替换成摘要服务的实际密钥"
|
|
128
|
+
```
|
|
129
|
+
|
|
130
|
+
启动 Remote Pi:
|
|
131
|
+
|
|
132
|
+
```powershell
|
|
133
|
+
npm run dev -- `
|
|
134
|
+
--voice-base-url "http://127.0.0.1:8000/v1" `
|
|
135
|
+
--voice-tts-model "speaches-ai/piper-zh_CN-huayan-medium" `
|
|
136
|
+
--voice-tts-voice "huayan" `
|
|
137
|
+
--voice-summary-base-url "https://替换成摘要服务地址/v1" `
|
|
138
|
+
--voice-summary-model "替换成摘要模型ID" `
|
|
139
|
+
--voice-summary-api-key-env "SUMMARY_API_KEY" `
|
|
140
|
+
--voice-language "zh-CN"
|
|
141
|
+
```
|
|
142
|
+
|
|
143
|
+
如果摘要服务不需要 API key,删除下面两项:
|
|
144
|
+
|
|
145
|
+
```powershell
|
|
146
|
+
$env:SUMMARY_API_KEY = "..."
|
|
147
|
+
--voice-summary-api-key-env "SUMMARY_API_KEY"
|
|
148
|
+
```
|
|
149
|
+
|
|
150
|
+
如果 Speaches 已经配置 STT 模型,可在启动参数中增加:
|
|
151
|
+
|
|
152
|
+
```text
|
|
153
|
+
--voice-stt-model "实际的STT模型ID"
|
|
154
|
+
```
|
|
155
|
+
|
|
156
|
+
未配置 `--voice-stt-model` 时,语音播报仍可使用,但浏览器麦克风转写不可用。
|
|
157
|
+
|
|
158
|
+
## 五、Web UI 中启用语音
|
|
159
|
+
|
|
160
|
+
1. 打开 `http://127.0.0.1:11318`。
|
|
161
|
+
2. 输入 Remote Pi 终端显示的配对码。
|
|
162
|
+
3. 点击 Web UI 中的“启用语音”。
|
|
163
|
+
4. Agent 完成任务后,Remote Pi 会先请求摘要 LLM,再调用已配置的语音服务播放中文摘要。
|
|
164
|
+
|
|
165
|
+
浏览器麦克风通常要求 HTTPS 安全上下文;通过 `localhost` 访问时可以使用 HTTP。
|
|
166
|
+
|
|
167
|
+
## 六、推荐的日常启动顺序
|
|
168
|
+
|
|
169
|
+
每次开机后按以下顺序启动:
|
|
170
|
+
|
|
171
|
+
1. 启动 Speaches,并保持窗口运行。
|
|
172
|
+
2. 调用 `/health` 确认 Speaches 正常。
|
|
173
|
+
3. 启动摘要 LLM(如果是本地服务)。
|
|
174
|
+
4. 启动 Remote Pi。
|
|
175
|
+
5. 打开 Web UI 并启用语音。
|
|
176
|
+
|
|
177
|
+
## 七、停止服务
|
|
178
|
+
|
|
179
|
+
在对应的 PowerShell 窗口按:
|
|
180
|
+
|
|
181
|
+
```text
|
|
182
|
+
Ctrl+C
|
|
183
|
+
```
|
|
184
|
+
|
|
185
|
+
建议先停止 Remote Pi,再停止 Speaches 或 Edge TTS Server。
|
|
186
|
+
|
|
187
|
+
## 八、常见问题
|
|
188
|
+
|
|
189
|
+
### `HTTP 200, 0 bytes` 或 `curl: (18)`
|
|
190
|
+
|
|
191
|
+
确认 Speaches 是使用下面的 UTF-8 启动方式,而不是直接运行 `uvicorn.exe`:
|
|
192
|
+
|
|
193
|
+
```powershell
|
|
194
|
+
.\.venv\Scripts\python.exe -X utf8 -m uvicorn --factory --host 127.0.0.1 --port 8000 speaches.main:create_app
|
|
195
|
+
```
|
|
196
|
+
|
|
197
|
+
### `Failed to open ...piper-request.json`
|
|
198
|
+
|
|
199
|
+
确认文件存在:
|
|
200
|
+
|
|
201
|
+
```powershell
|
|
202
|
+
Get-Item "C:\Users\sunxiaowen\piper-request.json"
|
|
203
|
+
```
|
|
204
|
+
|
|
205
|
+
然后在 `curl.exe --data-binary` 中使用完整绝对路径。
|
|
206
|
+
|
|
207
|
+
### 模型不存在
|
|
208
|
+
|
|
209
|
+
重新下载:
|
|
210
|
+
|
|
211
|
+
```powershell
|
|
212
|
+
curl.exe --fail-with-body -X POST `
|
|
213
|
+
"http://127.0.0.1:8000/v1/models/speaches-ai/piper-zh_CN-huayan-medium"
|
|
214
|
+
```
|
|
215
|
+
|
|
216
|
+
### Speaches 无法连接
|
|
217
|
+
|
|
218
|
+
检查端口:
|
|
219
|
+
|
|
220
|
+
```powershell
|
|
221
|
+
Test-NetConnection 127.0.0.1 -Port 8000
|
|
222
|
+
```
|
|
223
|
+
|
|
224
|
+
如果 `TcpTestSucceeded` 为 `False`,说明 Speaches 尚未启动或已经退出。
|
|
225
|
+
|
|
226
|
+
### 能生成测试音频,但 Remote Pi 没有声音
|
|
227
|
+
|
|
228
|
+
依次确认:
|
|
229
|
+
|
|
230
|
+
1. Web UI 已点击“启用语音”。
|
|
231
|
+
2. 摘要 LLM 的 `/v1/chat/completions` 支持流式响应。
|
|
232
|
+
3. Remote Pi 启动终端和语音服务终端没有报错。
|
|
233
|
+
4. 浏览器标签页没有静音,系统输出设备选择正确。
|
|
234
|
+
|
|
235
|
+
## 九、不使用 Docker 启动 Edge TTS Server
|
|
236
|
+
|
|
237
|
+
项目内置了一个轻量 Python 服务,源码位于:
|
|
238
|
+
|
|
239
|
+
```text
|
|
240
|
+
packages/edge-tts-server
|
|
241
|
+
```
|
|
242
|
+
|
|
243
|
+
它使用:
|
|
244
|
+
|
|
245
|
+
- `edge-tts` 调用 Microsoft Edge 在线语音服务。
|
|
246
|
+
- `aiohttp` 提供 OpenAI-compatible `POST /v1/audio/speech` 接口。
|
|
247
|
+
- MP3 作为输出格式,由浏览器直接解码播放。
|
|
248
|
+
|
|
249
|
+
该服务不使用 Docker 或 FFmpeg,也不提供语音识别。
|
|
250
|
+
|
|
251
|
+
### 9.1 首次安装
|
|
252
|
+
|
|
253
|
+
打开 PowerShell:
|
|
254
|
+
|
|
255
|
+
```powershell
|
|
256
|
+
cd C:\src\sxw\remote-pi\packages\edge-tts-server
|
|
257
|
+
python -m venv .venv
|
|
258
|
+
.\.venv\Scripts\Activate.ps1
|
|
259
|
+
python -m pip install -e .
|
|
260
|
+
```
|
|
261
|
+
|
|
262
|
+
如果 PowerShell 禁止执行激活脚本,可以不激活虚拟环境,直接执行:
|
|
263
|
+
|
|
264
|
+
```powershell
|
|
265
|
+
.\.venv\Scripts\python.exe -m pip install -e .
|
|
266
|
+
```
|
|
267
|
+
|
|
268
|
+
### 9.2 每次启动 Edge TTS Server
|
|
269
|
+
|
|
270
|
+
激活虚拟环境后启动:
|
|
271
|
+
|
|
272
|
+
```powershell
|
|
273
|
+
cd C:\src\sxw\remote-pi\packages\edge-tts-server
|
|
274
|
+
.\.venv\Scripts\Activate.ps1
|
|
275
|
+
remote-pi-edge-tts
|
|
276
|
+
```
|
|
277
|
+
|
|
278
|
+
或者直接使用虚拟环境中的 Python:
|
|
279
|
+
|
|
280
|
+
```powershell
|
|
281
|
+
.\.venv\Scripts\python.exe -m remote_pi_edge_tts
|
|
282
|
+
```
|
|
283
|
+
|
|
284
|
+
默认配置:
|
|
285
|
+
|
|
286
|
+
- 地址:`http://127.0.0.1:5050`
|
|
287
|
+
- 默认音色:`zh-CN-XiaoxiaoNeural`
|
|
288
|
+
- 健康检查:`http://127.0.0.1:5050/health`
|
|
289
|
+
|
|
290
|
+
可以显式指定监听地址、端口和默认音色:
|
|
291
|
+
|
|
292
|
+
```powershell
|
|
293
|
+
remote-pi-edge-tts `
|
|
294
|
+
--host 127.0.0.1 `
|
|
295
|
+
--port 5050 `
|
|
296
|
+
--voice zh-CN-XiaoxiaoNeural
|
|
297
|
+
```
|
|
298
|
+
|
|
299
|
+
### 9.3 检查服务
|
|
300
|
+
|
|
301
|
+
```powershell
|
|
302
|
+
curl.exe --fail-with-body http://127.0.0.1:5050/health
|
|
303
|
+
```
|
|
304
|
+
|
|
305
|
+
正常响应:
|
|
306
|
+
|
|
307
|
+
```json
|
|
308
|
+
{"status":"ok","service":"remote-pi-edge-tts"}
|
|
309
|
+
```
|
|
310
|
+
|
|
311
|
+
### 9.4 测试 Edge 中文语音
|
|
312
|
+
|
|
313
|
+
先创建请求文件:
|
|
314
|
+
|
|
315
|
+
```powershell
|
|
316
|
+
@'
|
|
317
|
+
{
|
|
318
|
+
"model": "edge-tts",
|
|
319
|
+
"voice": "zh-CN-XiaoxiaoNeural",
|
|
320
|
+
"input": "Edge 语音服务器配置成功,这是一段普通话测试。",
|
|
321
|
+
"response_format": "mp3"
|
|
322
|
+
}
|
|
323
|
+
'@ | Set-Content "$env:TEMP\edge-tts-request.json" -Encoding utf8
|
|
324
|
+
```
|
|
325
|
+
|
|
326
|
+
发送请求:
|
|
327
|
+
|
|
328
|
+
```powershell
|
|
329
|
+
curl.exe --fail-with-body -sS `
|
|
330
|
+
-X POST "http://127.0.0.1:5050/v1/audio/speech" `
|
|
331
|
+
-H "Content-Type: application/json" `
|
|
332
|
+
--data-binary "@$env:TEMP\edge-tts-request.json" `
|
|
333
|
+
--output "$env:TEMP\edge-tts-test.mp3" `
|
|
334
|
+
-w "`nHTTP %{http_code}, %{size_download} bytes`n"
|
|
335
|
+
```
|
|
336
|
+
|
|
337
|
+
播放测试文件:
|
|
338
|
+
|
|
339
|
+
```powershell
|
|
340
|
+
Invoke-Item "$env:TEMP\edge-tts-test.mp3"
|
|
341
|
+
```
|
|
342
|
+
|
|
343
|
+
### 9.5 使用 Edge TTS 启动 Remote Pi
|
|
344
|
+
|
|
345
|
+
打开另一个 PowerShell 窗口:
|
|
346
|
+
|
|
347
|
+
```powershell
|
|
348
|
+
cd C:\src\sxw\remote-pi
|
|
349
|
+
$env:SUMMARY_API_KEY = "替换成摘要服务的实际密钥"
|
|
350
|
+
|
|
351
|
+
npm run dev -- `
|
|
352
|
+
--voice-base-url "http://127.0.0.1:5050/v1" `
|
|
353
|
+
--voice-tts-model "edge-tts" `
|
|
354
|
+
--voice-tts-voice "zh-CN-XiaoxiaoNeural" `
|
|
355
|
+
--voice-tts-format "mp3" `
|
|
356
|
+
--voice-summary-base-url "https://替换成摘要服务地址/v1" `
|
|
357
|
+
--voice-summary-model "替换成摘要模型ID" `
|
|
358
|
+
--voice-summary-api-key-env "SUMMARY_API_KEY" `
|
|
359
|
+
--voice-language "zh-CN"
|
|
360
|
+
```
|
|
361
|
+
|
|
362
|
+
不要配置 `--voice-stt-model`。此时:
|
|
363
|
+
|
|
364
|
+
- Agent 完成后仍会生成并播放语音摘要。
|
|
365
|
+
- Web UI 会隐藏麦克风按钮。
|
|
366
|
+
- `/api/v1/system/status` 会报告 TTS 可用、STT 不可用。
|
|
367
|
+
|
|
368
|
+
### 9.6 常用中文音色
|
|
369
|
+
|
|
370
|
+
可以把 `--voice-tts-voice` 替换为:
|
|
371
|
+
|
|
372
|
+
- `zh-CN-XiaoxiaoNeural`:通用女声。
|
|
373
|
+
- `zh-CN-YunxiNeural`:年轻男声。
|
|
374
|
+
- `zh-CN-YunyangNeural`:正式男声。
|
|
375
|
+
- `zh-CN-YunjianNeural`:有力男声。
|
|
376
|
+
|
|
377
|
+
### 9.7 Edge TTS 故障排查
|
|
378
|
+
|
|
379
|
+
#### `Edge TTS failed` 或 `NoAudioReceived`
|
|
380
|
+
|
|
381
|
+
依次检查:
|
|
382
|
+
|
|
383
|
+
1. 当前机器能否正常访问 Microsoft Edge 在线语音服务。
|
|
384
|
+
2. `edge-tts` 是否需要升级:
|
|
385
|
+
|
|
386
|
+
```powershell
|
|
387
|
+
.\.venv\Scripts\python.exe -m pip install --upgrade "edge-tts>=7.2,<8"
|
|
388
|
+
```
|
|
389
|
+
|
|
390
|
+
3. 音色名称是否正确。
|
|
391
|
+
4. 重新启动 Edge TTS Server 后再次测试。
|
|
392
|
+
|
|
393
|
+
Edge TTS 依赖非官方消费者接口,上游协议或可用性可能发生变化,建议仅用于本机或可信私有网络。
|
package/dist/agents.js
ADDED
|
@@ -0,0 +1,123 @@
|
|
|
1
|
+
import { EventEmitter } from 'node:events';
|
|
2
|
+
import { randomUUID } from 'node:crypto';
|
|
3
|
+
import { relative as pathRelative } from 'node:path';
|
|
4
|
+
import { createSdkBackend } from './sdk-backend.js';
|
|
5
|
+
/** Deterministic backend used by the server and tests. The SDK adapter can implement the same contract. */
|
|
6
|
+
export class MockBackend {
|
|
7
|
+
emitter = new EventEmitter();
|
|
8
|
+
state;
|
|
9
|
+
messages = [];
|
|
10
|
+
timer;
|
|
11
|
+
constructor(agentId, cwd, sessionId = randomUUID()) { this.state = { agentId, sessionId, cwd, status: 'idle' }; }
|
|
12
|
+
emit(e) { this.emitter.emit('event', e); }
|
|
13
|
+
async prompt(message) { if (this.state.status === 'streaming')
|
|
14
|
+
throw Object.assign(new Error('Agent is busy'), { code: 'AGENT_BUSY' }); this.state.status = 'streaming'; this.emit({ type: 'agent_start', message }); await new Promise(r => { this.timer = setTimeout(r, 0); }); this.messages.push({ role: 'user', content: message }); this.emit({ type: 'message_start', role: 'assistant' }); this.emit({ type: 'message_update', assistantMessageEvent: { type: 'text_delta', delta: `Mock response: ${message}` } }); this.emit({ type: 'message_end', message: `Mock response: ${message}` }); this.state.status = 'idle'; this.emit({ type: 'agent_end' }); }
|
|
15
|
+
async steer(message) { this.emit({ type: 'steer', message }); }
|
|
16
|
+
async followUp(message) { this.emit({ type: 'follow_up', message }); }
|
|
17
|
+
async abort() { if (this.state.status === 'streaming') {
|
|
18
|
+
if (this.timer)
|
|
19
|
+
clearTimeout(this.timer);
|
|
20
|
+
this.state.status = 'idle';
|
|
21
|
+
this.emit({ type: 'agent_end', aborted: true });
|
|
22
|
+
} }
|
|
23
|
+
async getState() { return { ...this.state }; }
|
|
24
|
+
async getMessages() { return [...this.messages]; }
|
|
25
|
+
async getCapabilities() { return { model: null, models: [], thinkingLevel: 'off', thinkingLevels: ['off'], supportsThinking: false }; }
|
|
26
|
+
async getSession() { return { ...await this.getSessionInfo(), entries: [], tree: [] }; }
|
|
27
|
+
async getSessionInfo() { return { sessionId: this.state.sessionId, sessionName: this.state.sessionName, stats: { totalMessages: this.messages.length, tokens: { total: 0 }, cost: 0 }, contextUsage: { tokens: null, contextWindow: 0, percent: null } }; }
|
|
28
|
+
async compact() { return { summary: 'mock' }; }
|
|
29
|
+
async setModel(_provider, _modelId) { }
|
|
30
|
+
async setThinkingLevel(_level) { }
|
|
31
|
+
async setSessionName(name) { this.state.sessionName = name; this.emit({ type: 'session_info_changed', name }); }
|
|
32
|
+
async navigate(_entryId) { return { cancelled: false }; }
|
|
33
|
+
async fork(_entryId) { return {}; }
|
|
34
|
+
async extensionResponse(_requestId, _value) { }
|
|
35
|
+
subscribe(listener) { this.emitter.on('event', listener); return () => this.emitter.off('event', listener); }
|
|
36
|
+
async dispose() { await this.abort(); this.state.status = 'stopped'; this.emitter.removeAllListeners(); }
|
|
37
|
+
}
|
|
38
|
+
export class AgentManager {
|
|
39
|
+
workspaces;
|
|
40
|
+
factory;
|
|
41
|
+
agents = new Map();
|
|
42
|
+
listeners = new Map();
|
|
43
|
+
globalListeners = new Set();
|
|
44
|
+
constructor(workspaces, factory = createSdkBackend) {
|
|
45
|
+
this.workspaces = workspaces;
|
|
46
|
+
this.factory = factory;
|
|
47
|
+
}
|
|
48
|
+
async create(workspaceId, relativeCwd = '.', sessionFile) { const ws = this.workspaces.get(workspaceId); if (!ws)
|
|
49
|
+
throw Object.assign(new Error('Workspace not found'), { code: 'WORKSPACE_NOT_FOUND' }); const cwd = await this.workspaces.resolve(ws, relativeCwd); if (!(await import('node:fs/promises')).stat(cwd).then(s => s.isDirectory()))
|
|
50
|
+
throw new Error('cwd is not a directory'); const id = `agent-${randomUUID()}`, backend = await this.factory(id, cwd, sessionFile), now = new Date().toISOString(); const record = { ...(await backend.getState()), workspaceId, createdAt: now, lastActiveAt: now }; this.attach(id, record, backend); return { ...record }; }
|
|
51
|
+
connect(entry, backend) { entry.backend = backend; backend.subscribe(event => { const record = entry.record; record.status = event.type === 'agent_start' || event.type === 'auto_retry_start' || (event.type === 'agent_end' && event.willRetry === true) ? 'streaming' : event.type === 'agent_end' || event.type === 'agent_settled' ? 'idle' : record.status; if (event.type === 'session_info_changed')
|
|
52
|
+
record.sessionName = typeof event.name === 'string' && event.name.trim() ? event.name.trim() : undefined; record.lastActiveAt = new Date().toISOString(); const item = { id: randomUUID(), sequence: entry.nextSequence++, timestamp: Math.floor(Date.now() / 1000), event }; entry.events.push(item); if (entry.events.length > 1000)
|
|
53
|
+
entry.events.shift(); for (const listener of this.listeners.get(record.agentId) ?? [])
|
|
54
|
+
listener(item); for (const listener of this.globalListeners)
|
|
55
|
+
listener(record.agentId, item); }); }
|
|
56
|
+
attach(id, record, backend) { const entry = { record, backend, events: [], nextSequence: 1 }; this.connect(entry, backend); this.agents.set(id, entry); }
|
|
57
|
+
async restore(records) { for (const saved of records) {
|
|
58
|
+
try {
|
|
59
|
+
const ws = this.workspaces.get(saved.workspaceId);
|
|
60
|
+
if (!ws)
|
|
61
|
+
continue;
|
|
62
|
+
const cwd = await this.workspaces.resolve(ws, pathRelative(ws.rootPath, saved.cwd));
|
|
63
|
+
const sessionModified = saved.sessionFile ? await import('node:fs/promises').then(fs => fs.stat(saved.sessionFile).then(value => value.mtime.toISOString()).catch(() => undefined)) : undefined;
|
|
64
|
+
const record = { ...saved, cwd, status: 'unloaded', lastActiveAt: sessionModified && sessionModified > saved.lastActiveAt ? sessionModified : saved.lastActiveAt };
|
|
65
|
+
this.agents.set(saved.agentId, { record, events: [], nextSequence: 1 });
|
|
66
|
+
}
|
|
67
|
+
catch { /* Keep server startup resilient to deleted workspaces. */ }
|
|
68
|
+
} }
|
|
69
|
+
get(id) { const x = this.agents.get(id); if (!x)
|
|
70
|
+
throw Object.assign(new Error('Agent not found'), { code: 'AGENT_NOT_FOUND' }); return x; }
|
|
71
|
+
list() { return [...this.agents.values()].map(x => ({ ...x.record })); }
|
|
72
|
+
isLoaded(id) { return !!this.get(id).backend; }
|
|
73
|
+
async ensureLoaded(id) { const entry = this.get(id); if (entry.backend)
|
|
74
|
+
return entry.backend; if (entry.record.status === 'stopped')
|
|
75
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); if (entry.loading)
|
|
76
|
+
return entry.loading; entry.record.status = 'starting'; entry.loading = (async () => { try {
|
|
77
|
+
const backend = await this.factory(id, entry.record.cwd, entry.record.sessionFile);
|
|
78
|
+
const state = await backend.getState();
|
|
79
|
+
entry.record = { ...entry.record, ...state, status: state.status === 'stopped' ? 'idle' : state.status };
|
|
80
|
+
this.connect(entry, backend);
|
|
81
|
+
return backend;
|
|
82
|
+
}
|
|
83
|
+
catch (error) {
|
|
84
|
+
entry.record.status = 'error';
|
|
85
|
+
throw error;
|
|
86
|
+
}
|
|
87
|
+
finally {
|
|
88
|
+
entry.loading = undefined;
|
|
89
|
+
} })(); return entry.loading; }
|
|
90
|
+
async command(id, kind, message = '') { const entry = this.get(id); if (kind === 'abort' && !entry.backend)
|
|
91
|
+
return; if (entry.record.status === 'stopped')
|
|
92
|
+
throw Object.assign(new Error('Agent is not running'), { code: 'AGENT_NOT_RUNNING' }); const backend = await this.ensureLoaded(id); if (kind === 'abort')
|
|
93
|
+
return backend.abort(); return backend[kind === 'follow-up' ? 'followUp' : kind](message); }
|
|
94
|
+
events(id, last = 0) { return this.get(id).events.filter(e => e.sequence > last); }
|
|
95
|
+
currentSequence(id) { return this.get(id).nextSequence - 1; }
|
|
96
|
+
async snapshot(id, messageLimit) { const a = this.get(id), messages = a.backend ? await a.backend.getMessages() : []; if (messageLimit === undefined)
|
|
97
|
+
return { state: a.backend ? await a.backend.getState() : { ...a.record }, messages, lastSequence: a.nextSequence - 1 }; const page = this.paginateMessages(messages, messageLimit); return { state: a.backend ? await a.backend.getState() : { ...a.record }, messages: page.items, messagePage: page, lastSequence: a.nextSequence - 1 }; }
|
|
98
|
+
hasReplayGap(id, last) { const a = this.get(id); const current = a.nextSequence - 1; return last > current || (last > 0 && a.events.length > 0 && last < a.events[0].sequence - 1); }
|
|
99
|
+
subscribe(id, listener) { this.get(id); if (!this.listeners.has(id))
|
|
100
|
+
this.listeners.set(id, new Set()); this.listeners.get(id).add(listener); return () => this.listeners.get(id)?.delete(listener); }
|
|
101
|
+
subscribeAll(listener) { this.globalListeners.add(listener); return () => this.globalListeners.delete(listener); }
|
|
102
|
+
async dispose(id) { const a = this.get(id); if (a.loading)
|
|
103
|
+
await a.loading.catch(() => { }); await a.backend?.dispose(); a.backend = undefined; a.record.status = 'unloaded'; }
|
|
104
|
+
async remove(id) { const a = this.get(id); if (a.loading)
|
|
105
|
+
await a.loading.catch(() => { }); await a.backend?.dispose(); this.agents.delete(id); this.listeners.delete(id); }
|
|
106
|
+
async archive(id) { const current = this.get(id).record; if (['starting', 'streaming', 'waiting_for_user', 'stopping'].includes(current.status))
|
|
107
|
+
throw Object.assign(new Error('Active Agent cannot be archived'), { code: 'AGENT_ACTIVE' }); const record = { ...current, status: 'unloaded' }; await this.remove(id); return record; }
|
|
108
|
+
async state(id) { return (await this.ensureLoaded(id)).getState(); }
|
|
109
|
+
async messages(id) { return (await this.ensureLoaded(id)).getMessages(); }
|
|
110
|
+
async messagePage(id, limit = 25, before) { return this.paginateMessages(await (await this.ensureLoaded(id)).getMessages(), limit, before); }
|
|
111
|
+
paginateMessages(messages, limit, before) { const safeLimit = Math.min(200, Math.max(1, Math.trunc(Number(limit) || 25))), total = messages.length, end = Math.min(total, Math.max(0, before === undefined ? total : Math.trunc(Number(before) || 0))), start = Math.max(0, end - safeLimit); return { items: messages.slice(start, end), total, start, end, limit: safeLimit, hasMore: start > 0 }; }
|
|
112
|
+
async capabilities(id) { return (await this.ensureLoaded(id)).getCapabilities(); }
|
|
113
|
+
async session(id) { return (await this.ensureLoaded(id)).getSession(); }
|
|
114
|
+
async sessionInfo(id) { return (await this.ensureLoaded(id)).getSessionInfo(); }
|
|
115
|
+
async compact(id, instructions) { return (await this.ensureLoaded(id)).compact(instructions); }
|
|
116
|
+
async setModel(id, provider, modelId) { return (await this.ensureLoaded(id)).setModel(provider, modelId); }
|
|
117
|
+
async setThinkingLevel(id, level) { return (await this.ensureLoaded(id)).setThinkingLevel(level); }
|
|
118
|
+
async setSessionName(id, name) { await (await this.ensureLoaded(id)).setSessionName(name); this.get(id).record.sessionName = name.trim(); }
|
|
119
|
+
async navigate(id, entryId) { return (await this.ensureLoaded(id)).navigate(entryId); }
|
|
120
|
+
async fork(id, entryId) { const source = this.get(id), result = await (await this.ensureLoaded(id)).fork(entryId); if (!result.sessionFile)
|
|
121
|
+
throw Object.assign(new Error('Session persistence is disabled'), { code: 'SESSION_NOT_PERSISTED' }); const ws = this.workspaces.get(source.record.workspaceId); const agent = await this.create(source.record.workspaceId, pathRelative(ws.rootPath, source.record.cwd), result.sessionFile); return { agent, selectedText: result.selectedText }; }
|
|
122
|
+
async extensionResponse(id, requestId, value) { return (await this.ensureLoaded(id)).extensionResponse(requestId, value); }
|
|
123
|
+
}
|
package/dist/auth.js
ADDED
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
import { randomBytes, createHash, timingSafeEqual } from 'node:crypto';
|
|
2
|
+
import { chmod, mkdir, readFile, writeFile } from 'node:fs/promises';
|
|
3
|
+
import path from 'node:path';
|
|
4
|
+
const hash = (token) => createHash('sha256').update(token).digest('hex');
|
|
5
|
+
export class AuthStore {
|
|
6
|
+
dir;
|
|
7
|
+
constructor(dir) {
|
|
8
|
+
this.dir = dir;
|
|
9
|
+
}
|
|
10
|
+
get file() { return path.join(this.dir, 'auth.json'); }
|
|
11
|
+
async init() { await mkdir(this.dir, { recursive: true }); try {
|
|
12
|
+
await readFile(this.file);
|
|
13
|
+
return {};
|
|
14
|
+
}
|
|
15
|
+
catch {
|
|
16
|
+
const token = randomBytes(32).toString('base64url');
|
|
17
|
+
await this.save({ tokenHash: hash(token), createdAt: new Date().toISOString() });
|
|
18
|
+
return { token };
|
|
19
|
+
} }
|
|
20
|
+
async save(data) { await writeFile(this.file, JSON.stringify(data, null, 2) + '\n', { mode: 0o600 }); await chmod(this.file, 0o600); }
|
|
21
|
+
async verify(token) { try {
|
|
22
|
+
const data = JSON.parse(await readFile(this.file, 'utf8'));
|
|
23
|
+
const a = Buffer.from(hash(token));
|
|
24
|
+
const b = Buffer.from(data.tokenHash);
|
|
25
|
+
return a.length === b.length && timingSafeEqual(a, b);
|
|
26
|
+
}
|
|
27
|
+
catch {
|
|
28
|
+
return false;
|
|
29
|
+
} }
|
|
30
|
+
async rotate() { const token = randomBytes(32).toString('base64url'); const old = JSON.parse(await readFile(this.file, 'utf8')); await this.save({ ...old, tokenHash: hash(token), rotatedAt: new Date().toISOString() }); return token; }
|
|
31
|
+
}
|
|
@@ -0,0 +1,132 @@
|
|
|
1
|
+
import { randomUUID } from 'node:crypto';
|
|
2
|
+
import { assistantText } from './voice.js';
|
|
3
|
+
const defaultSettings = { enabled: true, aggregationDelaySeconds: 0, includeResponse: true, includeSessionDetails: true };
|
|
4
|
+
const clip = (value, max) => value.length <= max ? value : `${value.slice(0, max)}\n\n[内容过长,已截断]`;
|
|
5
|
+
const oneLine = (value, max) => value.replace(/[\r\n\t]+/g, ' ').replace(/\s{2,}/g, ' ').trim().slice(0, max);
|
|
6
|
+
export class MailNotifier {
|
|
7
|
+
fetcher;
|
|
8
|
+
lastAssistant = new Map();
|
|
9
|
+
queue = [];
|
|
10
|
+
timer;
|
|
11
|
+
pending = new Set();
|
|
12
|
+
closed = false;
|
|
13
|
+
settings = { ...defaultSettings };
|
|
14
|
+
config;
|
|
15
|
+
constructor(config, fetcher = fetch) {
|
|
16
|
+
this.fetcher = fetcher;
|
|
17
|
+
this.config = { priority: 100, timeoutMs: 15_000, ...config };
|
|
18
|
+
}
|
|
19
|
+
getSettings() { return { ...this.settings }; }
|
|
20
|
+
updateSettings(value) {
|
|
21
|
+
const next = { ...this.settings, ...value };
|
|
22
|
+
next.enabled = Boolean(next.enabled);
|
|
23
|
+
next.includeResponse = Boolean(next.includeResponse);
|
|
24
|
+
next.includeSessionDetails = Boolean(next.includeSessionDetails);
|
|
25
|
+
next.aggregationDelaySeconds = Math.trunc(Number(next.aggregationDelaySeconds));
|
|
26
|
+
if (!Number.isFinite(next.aggregationDelaySeconds) || next.aggregationDelaySeconds < 0 || next.aggregationDelaySeconds > 86400)
|
|
27
|
+
throw Object.assign(new Error('aggregationDelaySeconds must be an integer between 0 and 86400'), { code: 'INVALID_MAIL_NOTIFICATION_SETTINGS' });
|
|
28
|
+
this.settings = next;
|
|
29
|
+
if (!next.enabled) {
|
|
30
|
+
this.clearTimer();
|
|
31
|
+
this.queue = [];
|
|
32
|
+
return this.getSettings();
|
|
33
|
+
}
|
|
34
|
+
if (this.queue.length) {
|
|
35
|
+
this.clearTimer();
|
|
36
|
+
this.scheduleFlush();
|
|
37
|
+
}
|
|
38
|
+
return this.getSettings();
|
|
39
|
+
}
|
|
40
|
+
handleAgentEvent(agentId, event, context = {}) {
|
|
41
|
+
if (this.closed)
|
|
42
|
+
return;
|
|
43
|
+
if (event.type === 'message_end') {
|
|
44
|
+
const text = assistantText(event.message);
|
|
45
|
+
if (text)
|
|
46
|
+
this.lastAssistant.set(agentId, text);
|
|
47
|
+
return;
|
|
48
|
+
}
|
|
49
|
+
if (event.type !== 'agent_settled')
|
|
50
|
+
return;
|
|
51
|
+
const finalOutput = this.lastAssistant.get(agentId) ?? '';
|
|
52
|
+
this.lastAssistant.delete(agentId);
|
|
53
|
+
if (!this.settings.enabled)
|
|
54
|
+
return;
|
|
55
|
+
this.queue.push({ agentId, finalOutput, context });
|
|
56
|
+
if (!this.timer)
|
|
57
|
+
this.scheduleFlush();
|
|
58
|
+
}
|
|
59
|
+
async close() {
|
|
60
|
+
this.closed = true;
|
|
61
|
+
this.clearTimer();
|
|
62
|
+
if (this.queue.length)
|
|
63
|
+
await this.flushQueue();
|
|
64
|
+
await Promise.allSettled([...this.pending]);
|
|
65
|
+
this.lastAssistant.clear();
|
|
66
|
+
}
|
|
67
|
+
scheduleFlush() {
|
|
68
|
+
const delay = this.settings.aggregationDelaySeconds * 1000;
|
|
69
|
+
if (delay <= 0) {
|
|
70
|
+
this.startFlush();
|
|
71
|
+
return;
|
|
72
|
+
}
|
|
73
|
+
this.timer = setTimeout(() => { this.timer = undefined; this.startFlush(); }, delay);
|
|
74
|
+
this.timer.unref?.();
|
|
75
|
+
}
|
|
76
|
+
startFlush() {
|
|
77
|
+
const job = this.flushQueue().catch(error => console.error(`MailDispatch notification failed: ${error.message}`));
|
|
78
|
+
this.pending.add(job);
|
|
79
|
+
void job.finally(() => this.pending.delete(job));
|
|
80
|
+
}
|
|
81
|
+
async flushQueue() {
|
|
82
|
+
const items = this.queue.splice(0);
|
|
83
|
+
if (!items.length)
|
|
84
|
+
return;
|
|
85
|
+
await this.sendBatch(items);
|
|
86
|
+
if (this.queue.length && !this.timer && !this.closed)
|
|
87
|
+
this.scheduleFlush();
|
|
88
|
+
}
|
|
89
|
+
clearTimer() { if (this.timer) {
|
|
90
|
+
clearTimeout(this.timer);
|
|
91
|
+
this.timer = undefined;
|
|
92
|
+
} }
|
|
93
|
+
async sendBatch(items) {
|
|
94
|
+
const includeResponse = this.settings.includeResponse, includeSessionDetails = this.settings.includeSessionDetails, count = items.length;
|
|
95
|
+
let subject = `[Remote Pi] ${count > 1 ? `${count} 个 Agent 任务` : 'Agent 任务'}已完成`;
|
|
96
|
+
if (count === 1 && includeSessionDetails) {
|
|
97
|
+
const label = oneLine(items[0].context.sessionName || '', 80);
|
|
98
|
+
if (label)
|
|
99
|
+
subject = `[Remote Pi] ${label}已完成`;
|
|
100
|
+
}
|
|
101
|
+
let text;
|
|
102
|
+
if (!includeResponse && !includeSessionDetails)
|
|
103
|
+
text = count > 1 ? `${count} 个 Agent 任务已完成。` : '有 Agent 任务完成。';
|
|
104
|
+
else
|
|
105
|
+
text = items.map((item, index) => {
|
|
106
|
+
const lines = count > 1 ? [`任务 ${index + 1}`] : [];
|
|
107
|
+
if (includeSessionDetails) {
|
|
108
|
+
lines.push(`Session:${item.context.sessionName?.trim() || '未命名'}`);
|
|
109
|
+
if (item.context.cwd)
|
|
110
|
+
lines.push(`路径:${item.context.cwd}`);
|
|
111
|
+
}
|
|
112
|
+
if (includeResponse) {
|
|
113
|
+
if (lines.length)
|
|
114
|
+
lines.push('');
|
|
115
|
+
lines.push('Agent 回复:', clip(item.finalOutput.trim() || '(没有可用的最终文本回复)', 24_000));
|
|
116
|
+
}
|
|
117
|
+
return lines.join('\n');
|
|
118
|
+
}).join('\n\n--------------------\n\n');
|
|
119
|
+
const payload = {
|
|
120
|
+
to: [this.config.recipient], subject, text, priority: this.config.priority, purpose: 'transactional',
|
|
121
|
+
metadata: { source: 'remote-pi', task_count: count, notification_id: randomUUID() }
|
|
122
|
+
};
|
|
123
|
+
if (this.config.senderId)
|
|
124
|
+
payload.sender_id = this.config.senderId;
|
|
125
|
+
const response = await this.fetcher(this.config.endpoint, { method: 'POST', signal: AbortSignal.timeout(this.config.timeoutMs), headers: { authorization: `Bearer ${this.config.apiKey}`, 'content-type': 'application/json; charset=utf-8', accept: 'application/json', 'idempotency-key': randomUUID() }, body: JSON.stringify(payload) });
|
|
126
|
+
if (!response.ok)
|
|
127
|
+
throw new Error(`HTTP ${response.status}: ${(await response.text()).slice(0, 500)}`);
|
|
128
|
+
const result = await response.json().catch(() => undefined);
|
|
129
|
+
if (!result?.ok)
|
|
130
|
+
throw new Error('MailDispatch returned an invalid response');
|
|
131
|
+
}
|
|
132
|
+
}
|