wtagent 0.4.0 → 1.0.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/README.md +161 -7
- package/package.json +5 -4
- package/src/artifacts/artifact-store.js +154 -0
- package/src/audio/music-artifact.js +66 -0
- package/src/audio/native-music-receiver.js +91 -0
- package/src/browser/base-web-adapter.js +354 -50
- package/src/browser/chatgpt-dom.js +179 -0
- package/src/browser/chatgpt-web-adapter.js +89 -125
- package/src/browser/fake-web-model-adapter.js +16 -1
- package/src/browser/gemini-web-adapter.js +61 -20
- package/src/browser/grok-web-adapter.js +35 -3
- package/src/browser/rendered-text.js +11 -0
- package/src/cli/i18n.js +40 -10
- package/src/cli/main.js +32 -3
- package/src/cli/prompt-input.js +68 -0
- package/src/image/browser-image-driver.js +238 -0
- package/src/image/browser-image-session-pool.js +96 -0
- package/src/image/image-generation-service.js +101 -0
- package/src/image/image-provider-driver.js +22 -0
- package/src/image/native-image-receiver.js +66 -0
- package/src/image/provider-registry.js +23 -0
- package/src/image/providers/chatgpt-image-driver.js +211 -0
- package/src/image/providers/gemini-image-driver.js +100 -0
- package/src/image/providers/grok-image-driver.js +193 -0
- package/src/policy/policy-engine.js +10 -5
- package/src/protocol/envelope.js +74 -0
- package/src/protocol/markers.js +16 -0
- package/src/protocol/prompt-builder.js +12 -9
- package/src/protocol/xml-protocol.js +36 -18
- package/src/runtime/agent-runtime.js +345 -65
- package/src/session/agent-session.js +73 -2
- package/src/tools/image-tools.js +92 -0
- package/src/tools/registry.js +13 -1
- package/docs/technical-design.md +0 -873
package/README.md
CHANGED
|
@@ -1,8 +1,13 @@
|
|
|
1
1
|
# WTAgent
|
|
2
2
|
|
|
3
|
-
|
|
3
|
+
Turn ChatGPT Web (including Pro) into Codex.
|
|
4
|
+
Turn Claude Web into Claude Code.
|
|
4
5
|
|
|
5
|
-
|
|
6
|
+
Also DeepSeek, Gemini, Grok, Kimi, GLM.
|
|
7
|
+
|
|
8
|
+
No API key. No MCP. No tunnel. No plugin.
|
|
9
|
+
|
|
10
|
+
https://xixian42.github.io/wtagent/
|
|
6
11
|
|
|
7
12
|
[中文](#中文)
|
|
8
13
|
|
|
@@ -24,7 +29,7 @@ cd wtagent-demo
|
|
|
24
29
|
wtagent
|
|
25
30
|
```
|
|
26
31
|
|
|
27
|
-
|
|
32
|
+
After login, interactive runs give you five seconds to choose a model in the browser. Press Enter to continue immediately, or wait to keep the current selection. Then type a task:
|
|
28
33
|
|
|
29
34
|
```text
|
|
30
35
|
$ wtagent
|
|
@@ -33,13 +38,86 @@ you › Create hello.js that writes "Hello from WTAgent" to hello.txt. Run it wi
|
|
|
33
38
|
|
|
34
39
|
WTAgent creates the files in the current directory, runs the script locally, and checks its output. You can continue chatting in the same terminal after the task finishes.
|
|
35
40
|
|
|
36
|
-
Choose a provider with `--model` (ChatGPT is the default). Supported values are `chatgpt`, `claude`, `deepseek`, `gemini`, `kimi`, and `glm`:
|
|
41
|
+
Choose a provider with `--model` (ChatGPT is the default). Supported values are `chatgpt`, `claude`, `deepseek`, `gemini`, `grok`, `kimi`, and `glm`:
|
|
37
42
|
|
|
38
43
|
```bash
|
|
39
44
|
wtagent --model claude "inspect this project and summarize its architecture"
|
|
40
45
|
wtagent --model gemini "inspect this project and summarize its architecture"
|
|
41
46
|
```
|
|
42
47
|
|
|
48
|
+
### Generate and save images
|
|
49
|
+
|
|
50
|
+
Use ChatGPT, Gemini, or Grok to generate images in the current conversation. No
|
|
51
|
+
extra image flag is needed; describe the image in your task:
|
|
52
|
+
|
|
53
|
+
```bash
|
|
54
|
+
wtagent --model chatgpt --once "Generate a square illustration of a blue robot watering a plant. Save it locally."
|
|
55
|
+
wtagent --model gemini --once "Generate a 16:9 watercolor city skyline and save the image locally."
|
|
56
|
+
wtagent --model grok --once "Generate a minimalist landscape illustration and save it locally."
|
|
57
|
+
```
|
|
58
|
+
|
|
59
|
+
WTAgent waits for the completed image reply, downloads its images, checks their
|
|
60
|
+
format and dimensions, and records SHA-256 hashes. Supported file formats are
|
|
61
|
+
PNG, JPEG, and WebP. Files are saved under the current project's
|
|
62
|
+
`artifacts/native-images/<turn-id>/`, for example:
|
|
63
|
+
|
|
64
|
+
```text
|
|
65
|
+
artifacts/native-images/<turn-id>/image-1.png
|
|
66
|
+
artifacts/native-images/<turn-id>/image-2.webp
|
|
67
|
+
```
|
|
68
|
+
|
|
69
|
+
The extension follows the downloaded file's actual format. Multiple images from
|
|
70
|
+
one reply are saved separately. WTAgent passes the verified local paths back to
|
|
71
|
+
the model, which can finish the task or continue with file operations such as
|
|
72
|
+
organizing assets and building a page that uses them.
|
|
73
|
+
|
|
74
|
+
In an interactive conversation, attach a local reference image with `@path`:
|
|
75
|
+
|
|
76
|
+
```text
|
|
77
|
+
you › @assets/reference.png Create a watercolor illustration based on this reference and save it locally.
|
|
78
|
+
```
|
|
79
|
+
|
|
80
|
+
Reference handling, image size, aspect ratio, and image availability depend on
|
|
81
|
+
the website and selected model. WTAgent uses your logged-in account and its
|
|
82
|
+
quota. A text-only explanation or refusal is returned as text; it is not reported
|
|
83
|
+
as a saved image.
|
|
84
|
+
|
|
85
|
+
If downloading or returning the saved paths is interrupted, resume the recorded
|
|
86
|
+
session without adding a new instruction:
|
|
87
|
+
|
|
88
|
+
```bash
|
|
89
|
+
wtagent resume <session-id>
|
|
90
|
+
```
|
|
91
|
+
|
|
92
|
+
Recovery reuses the recorded reply when it can verify the conversation. It does
|
|
93
|
+
not automatically repeat an image-generation request whose submission is
|
|
94
|
+
uncertain. Partially downloaded replies may need to download their files again.
|
|
95
|
+
|
|
96
|
+
### Generate music with Gemini
|
|
97
|
+
|
|
98
|
+
```bash
|
|
99
|
+
wtagent --model gemini --type music --once "Create peaceful instrumental piano music, no vocals"
|
|
100
|
+
```
|
|
101
|
+
|
|
102
|
+
`--type music` selects Gemini's native Music tool. Tracks are saved under
|
|
103
|
+
`artifacts/music/<turn-id>/`. If Gemini returns an MP4 with audio and a cover
|
|
104
|
+
video, WTAgent preserves that original; when `ffmpeg` is on PATH, it also extracts
|
|
105
|
+
an `.m4a` audio file without re-encoding. Without FFmpeg, the original MP4 remains
|
|
106
|
+
usable. Duration follows the provider's output.
|
|
107
|
+
|
|
108
|
+
Download interruptions can be resumed with `wtagent resume <session-id>`. A bare
|
|
109
|
+
resume of a completed music session returns its saved result without generating
|
|
110
|
+
again. The default `--type agent` retains ordinary agent behavior, including
|
|
111
|
+
native image output.
|
|
112
|
+
|
|
113
|
+
### Conversation recovery and diagnostics
|
|
114
|
+
|
|
115
|
+
The ChatGPT adapter supports legacy and modern message layouts, temporary
|
|
116
|
+
conversation URLs, and virtualized history. When a send cannot be verified,
|
|
117
|
+
WTAgent preserves the session for recovery. Structural failure reports identify
|
|
118
|
+
the recognized page layout, missing identity fields, and send stage; the CLI
|
|
119
|
+
shows the report path for startup, send-confirmation, and recovery failures.
|
|
120
|
+
|
|
43
121
|
Claude keeps the model selected by claude.ai in its browser profile; WTAgent does not override it.
|
|
44
122
|
Gemini likewise keeps the model selected in its browser profile.
|
|
45
123
|
|
|
@@ -57,7 +135,14 @@ Multiline paste and `↑` / `↓` input history are supported. Press `Ctrl+C` or
|
|
|
57
135
|
|
|
58
136
|
## 中文
|
|
59
137
|
|
|
60
|
-
|
|
138
|
+
把 ChatGPT 网页版(含 Pro)变成 Codex。
|
|
139
|
+
把 Claude 网页版变成 Claude Code。
|
|
140
|
+
|
|
141
|
+
同时支持 DeepSeek、Gemini、Grok、Kimi、GLM。
|
|
142
|
+
|
|
143
|
+
无需 API Key,无需 MCP,无需隧道,无需插件。
|
|
144
|
+
|
|
145
|
+
https://xixian42.github.io/wtagent/
|
|
61
146
|
|
|
62
147
|
### 快速开始
|
|
63
148
|
|
|
@@ -77,7 +162,7 @@ cd wtagent-demo
|
|
|
77
162
|
wtagent
|
|
78
163
|
```
|
|
79
164
|
|
|
80
|
-
|
|
165
|
+
登录后,交互模式会留出 5 秒供你在浏览器里选择模型。按 Enter 可立即继续,等待倒计时结束则沿用当前选择。然后输入任务:
|
|
81
166
|
|
|
82
167
|
```text
|
|
83
168
|
$ wtagent
|
|
@@ -86,13 +171,62 @@ you › 创建 hello.js,将“Hello from WTAgent”写入 hello.txt。用 Node
|
|
|
86
171
|
|
|
87
172
|
WTAgent 会在当前目录创建文件、运行本地脚本并检查输出。任务完成后,可以继续在同一个终端中对话。
|
|
88
173
|
|
|
89
|
-
通过 `--model` 选择 provider(默认是 ChatGPT)。可选值包括 `chatgpt`、`claude`、`deepseek`、`gemini`、`kimi` 和 `glm`:
|
|
174
|
+
通过 `--model` 选择 provider(默认是 ChatGPT)。可选值包括 `chatgpt`、`claude`、`deepseek`、`gemini`、`grok`、`kimi` 和 `glm`:
|
|
90
175
|
|
|
91
176
|
```bash
|
|
92
177
|
wtagent --model claude "分析这个项目并总结架构"
|
|
93
178
|
wtagent --model gemini "分析这个项目并总结架构"
|
|
94
179
|
```
|
|
95
180
|
|
|
181
|
+
### 生成图片并保存到本地
|
|
182
|
+
|
|
183
|
+
使用 ChatGPT、Gemini 或 Grok,在当前对话中直接生成图片。无需额外的生图参数,在任务里描述要画的内容即可:
|
|
184
|
+
|
|
185
|
+
```bash
|
|
186
|
+
wtagent --model chatgpt --once "生成一张蓝色小机器人给盆栽浇水的方形插画,保存到本地"
|
|
187
|
+
wtagent --model gemini --once "生成一张 16:9 的水彩城市天际线,保存到本地"
|
|
188
|
+
wtagent --model grok --once "生成一张极简风格的山水插画,保存到本地"
|
|
189
|
+
```
|
|
190
|
+
|
|
191
|
+
WTAgent 等待图片回复完成后下载文件,检查格式和尺寸,并记录 SHA-256 哈希。支持 PNG、JPEG 和 WebP。文件保存在当前项目的 `artifacts/native-images/<turn-id>/` 目录,例如:
|
|
192
|
+
|
|
193
|
+
```text
|
|
194
|
+
artifacts/native-images/<turn-id>/image-1.png
|
|
195
|
+
artifacts/native-images/<turn-id>/image-2.webp
|
|
196
|
+
```
|
|
197
|
+
|
|
198
|
+
扩展名以下载文件的实际格式为准。一条回复包含多张图片时,会分别保存。WTAgent 把经过验证的本地路径交回模型,模型可以结束任务,也可以继续整理素材、制作引用这些图片的页面等。
|
|
199
|
+
|
|
200
|
+
在交互对话中,可以通过 `@路径` 附加本地参考图:
|
|
201
|
+
|
|
202
|
+
```text
|
|
203
|
+
你 › @assets/reference.png 参考这张图,生成水彩风格的插画并保存到本地。
|
|
204
|
+
```
|
|
205
|
+
|
|
206
|
+
是否支持参考图、图片尺寸、宽高比和图片生成,取决于网站及当前选择的模型。WTAgent 使用你已登录的账号和额度。若网站只返回文字说明或拒绝生成,会如实返回文字,不会声称已经保存图片。
|
|
207
|
+
|
|
208
|
+
下载或回传文件路径时中断,可以不带新指令恢复原会话:
|
|
209
|
+
|
|
210
|
+
```bash
|
|
211
|
+
wtagent resume <session-id>
|
|
212
|
+
```
|
|
213
|
+
|
|
214
|
+
恢复时会核对会话并复用已记录的回复;无法确认是否已提交的生图请求不会自动重发。只下载了一部分图片的回复,恢复时可能需要重新下载文件。
|
|
215
|
+
|
|
216
|
+
### 使用 Gemini 生成音乐
|
|
217
|
+
|
|
218
|
+
```bash
|
|
219
|
+
wtagent --model gemini --type music --once "生成一段舒缓钢琴纯音乐,不要人声"
|
|
220
|
+
```
|
|
221
|
+
|
|
222
|
+
`--type music` 会启用 Gemini 原生 Music 工具,文件保存到 `artifacts/music/<turn-id>/`。如果 Gemini 返回带封面画面和音轨的 MP4,WTAgent 会保留原文件;本机 PATH 中有 `ffmpeg` 时,还会在不重新编码的情况下提取 `.m4a` 纯音频。未安装 FFmpeg 时仍可使用原始 MP4,时长以网站实际生成结果为准。
|
|
223
|
+
|
|
224
|
+
下载中断后,可用 `wtagent resume <session-id>` 继续。已完成的音乐会话不带新指令恢复时,会返回已保存的结果,不再生成一次。默认 `--type agent` 保持普通智能体行为,也支持直接接收图片。
|
|
225
|
+
|
|
226
|
+
### 会话恢复与兼容诊断
|
|
227
|
+
|
|
228
|
+
ChatGPT 适配器支持新旧消息结构、临时会话地址和只保留部分消息节点的长对话。无法确认发送结果时,会保留会话供恢复。结构化诊断会记录识别到的页面结构、缺失的消息身份字段和发送阶段;启动、发送确认或恢复失败时,终端会显示诊断文件路径。
|
|
229
|
+
|
|
96
230
|
Claude 会沿用 claude.ai 在该浏览器 Profile 中选择的模型,WTAgent 不会自动切换模型。
|
|
97
231
|
Gemini 同样沿用其浏览器 Profile 当前选择的模型。
|
|
98
232
|
|
|
@@ -108,6 +242,26 @@ wtagent "创建 hello.js,运行并验证输出"
|
|
|
108
242
|
|
|
109
243
|
支持多行粘贴和 `↑` / `↓` 输入历史。使用 `Ctrl+C` 或 `Ctrl+D` 退出。
|
|
110
244
|
|
|
245
|
+
## Development and regression tests / 开发与回归测试
|
|
246
|
+
|
|
247
|
+
Run from a source checkout / 在源码目录运行:
|
|
248
|
+
|
|
249
|
+
```bash
|
|
250
|
+
npm ci
|
|
251
|
+
npm test
|
|
252
|
+
npm run check
|
|
253
|
+
npm run test:media
|
|
254
|
+
npm run test:chatgpt-dom
|
|
255
|
+
```
|
|
256
|
+
|
|
257
|
+
`test:media` covers image downloads, native multi-image replies, interrupted
|
|
258
|
+
recovery, music handling, and provider DOM behavior. The browser tests require
|
|
259
|
+
Chrome/Chromium (or `WTAGENT_CHROME_PATH`), use isolated test pages, and do not
|
|
260
|
+
require a web account or consume generation quota. They do not replace live
|
|
261
|
+
account testing when a provider changes its service.
|
|
262
|
+
|
|
263
|
+
`test:media` 覆盖图片下载、多图回复、中断恢复、音乐处理和各网站的 DOM 行为。浏览器测试需要 Chrome/Chromium,也可通过 `WTAGENT_CHROME_PATH` 指定路径;测试使用隔离页面,无需账号,也不消耗生成额度。网站服务变化后的真实账号验证仍需单独进行。
|
|
264
|
+
|
|
111
265
|
## License
|
|
112
266
|
|
|
113
267
|
[MIT](./LICENSE)
|
package/package.json
CHANGED
|
@@ -1,7 +1,7 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "wtagent",
|
|
3
|
-
"version": "0.
|
|
4
|
-
"description": "Turn
|
|
3
|
+
"version": "1.0.0",
|
|
4
|
+
"description": "Turn ChatGPT Web (including Pro) into Codex. Turn Claude Web into Claude Code. Also DeepSeek, Gemini, Grok, Kimi, GLM. No API key, no MCP, no tunnel, no plugin.",
|
|
5
5
|
"repository": {
|
|
6
6
|
"type": "git",
|
|
7
7
|
"url": "git+https://github.com/XiXian42/wtagent.git"
|
|
@@ -25,7 +25,6 @@
|
|
|
25
25
|
"files": [
|
|
26
26
|
"src",
|
|
27
27
|
"README.md",
|
|
28
|
-
"docs/technical-design.md",
|
|
29
28
|
"LICENSE"
|
|
30
29
|
],
|
|
31
30
|
"scripts": {
|
|
@@ -38,7 +37,9 @@
|
|
|
38
37
|
"login": "node src/cli/main.js login",
|
|
39
38
|
"prepack": "npm run check && npm test",
|
|
40
39
|
"release": "npm publish --tag latest",
|
|
41
|
-
"release:alpha": "npm publish --tag alpha"
|
|
40
|
+
"release:alpha": "npm publish --tag alpha",
|
|
41
|
+
"test:chatgpt-dom": "node scripts/test-chatgpt-dom.js",
|
|
42
|
+
"test:media": "node scripts/test-media.js"
|
|
42
43
|
},
|
|
43
44
|
"engines": {
|
|
44
45
|
"node": "^20.17.0 || ^22.13.0 || >=23.5.0"
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import { createHash, randomUUID } from "node:crypto";
|
|
4
|
+
import { replaceFileAtomic } from "../shared/atomic-write.js";
|
|
5
|
+
import { inspectMusicBuffer } from "../audio/music-artifact.js";
|
|
6
|
+
import {
|
|
7
|
+
resolveCanonicalWriteTarget,
|
|
8
|
+
resolveToolPath,
|
|
9
|
+
} from "../policy/path-guard.js";
|
|
10
|
+
|
|
11
|
+
const SIGNATURES = Object.freeze([
|
|
12
|
+
{ mimeType: "image/png", extension: ".png", matches: (b) => b.length >= 24 && b.subarray(0, 8).equals(Buffer.from([0x89, 0x50, 0x4e, 0x47, 0x0d, 0x0a, 0x1a, 0x0a])) },
|
|
13
|
+
{ mimeType: "image/jpeg", extension: ".jpg", matches: (b) => b.length >= 4 && b[0] === 0xff && b[1] === 0xd8 && b.at(-2) === 0xff && b.at(-1) === 0xd9 },
|
|
14
|
+
{ mimeType: "image/webp", extension: ".webp", matches: (b) => b.length >= 16 && b.toString("ascii", 0, 4) === "RIFF" && b.toString("ascii", 8, 12) === "WEBP" },
|
|
15
|
+
]);
|
|
16
|
+
|
|
17
|
+
function pngDimensions(buffer) {
|
|
18
|
+
return buffer.length >= 24
|
|
19
|
+
? { width: buffer.readUInt32BE(16), height: buffer.readUInt32BE(20) }
|
|
20
|
+
: {};
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
function jpegDimensions(buffer) {
|
|
24
|
+
let offset = 2;
|
|
25
|
+
while (offset + 9 < buffer.length) {
|
|
26
|
+
if (buffer[offset] !== 0xff) {
|
|
27
|
+
offset += 1;
|
|
28
|
+
continue;
|
|
29
|
+
}
|
|
30
|
+
const marker = buffer[offset + 1];
|
|
31
|
+
offset += 2;
|
|
32
|
+
if (marker === 0xd8 || marker === 0xd9 || marker === 0x01) continue;
|
|
33
|
+
if (offset + 2 > buffer.length) break;
|
|
34
|
+
const length = buffer.readUInt16BE(offset);
|
|
35
|
+
if (length < 2 || offset + length > buffer.length) break;
|
|
36
|
+
if ([0xc0, 0xc1, 0xc2, 0xc3, 0xc5, 0xc6, 0xc7, 0xc9, 0xca, 0xcb, 0xcd, 0xce, 0xcf].includes(marker)) {
|
|
37
|
+
return {
|
|
38
|
+
height: buffer.readUInt16BE(offset + 3),
|
|
39
|
+
width: buffer.readUInt16BE(offset + 5),
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
offset += length;
|
|
43
|
+
}
|
|
44
|
+
return {};
|
|
45
|
+
}
|
|
46
|
+
|
|
47
|
+
function webpDimensions(buffer) {
|
|
48
|
+
const kind = buffer.toString("ascii", 12, 16);
|
|
49
|
+
if (kind === "VP8X" && buffer.length >= 30) {
|
|
50
|
+
return {
|
|
51
|
+
width: 1 + buffer.readUIntLE(24, 3),
|
|
52
|
+
height: 1 + buffer.readUIntLE(27, 3),
|
|
53
|
+
};
|
|
54
|
+
}
|
|
55
|
+
if (kind === "VP8L" && buffer.length >= 25) {
|
|
56
|
+
const bits = buffer.readUInt32LE(21);
|
|
57
|
+
return {
|
|
58
|
+
width: (bits & 0x3fff) + 1,
|
|
59
|
+
height: ((bits >>> 14) & 0x3fff) + 1,
|
|
60
|
+
};
|
|
61
|
+
}
|
|
62
|
+
if (kind === "VP8 " && buffer.length >= 30) {
|
|
63
|
+
return {
|
|
64
|
+
width: buffer.readUInt16LE(26) & 0x3fff,
|
|
65
|
+
height: buffer.readUInt16LE(28) & 0x3fff,
|
|
66
|
+
};
|
|
67
|
+
}
|
|
68
|
+
return {};
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
export function inspectImageBuffer(buffer) {
|
|
72
|
+
if (!Buffer.isBuffer(buffer) || buffer.length === 0) {
|
|
73
|
+
throw new Error("The provider returned an empty image artifact.");
|
|
74
|
+
}
|
|
75
|
+
const format = SIGNATURES.find((entry) => entry.matches(buffer));
|
|
76
|
+
if (!format) {
|
|
77
|
+
throw new Error("The downloaded artifact is not a supported PNG, JPEG, or WebP image.");
|
|
78
|
+
}
|
|
79
|
+
const dimensions = format.mimeType === "image/png"
|
|
80
|
+
? pngDimensions(buffer)
|
|
81
|
+
: format.mimeType === "image/jpeg"
|
|
82
|
+
? jpegDimensions(buffer)
|
|
83
|
+
: webpDimensions(buffer);
|
|
84
|
+
if (!Number.isSafeInteger(dimensions.width) || !Number.isSafeInteger(dimensions.height)
|
|
85
|
+
|| dimensions.width <= 0 || dimensions.height <= 0) {
|
|
86
|
+
throw new Error(`Could not read dimensions from ${format.mimeType} artifact.`);
|
|
87
|
+
}
|
|
88
|
+
return {
|
|
89
|
+
...format,
|
|
90
|
+
...dimensions,
|
|
91
|
+
size: buffer.length,
|
|
92
|
+
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
93
|
+
};
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export class ArtifactStore {
|
|
97
|
+
constructor({ projectRoot, allowOutside = false }) {
|
|
98
|
+
this.projectRoot = path.resolve(projectRoot);
|
|
99
|
+
this.allowOutside = allowOutside;
|
|
100
|
+
}
|
|
101
|
+
|
|
102
|
+
async resolveOutputPath(rawPath) {
|
|
103
|
+
const info = await resolveToolPath(this.projectRoot, rawPath);
|
|
104
|
+
if (!info.inside && !this.allowOutside) {
|
|
105
|
+
throw new Error(`Path is outside project root: ${info.path}`);
|
|
106
|
+
}
|
|
107
|
+
await fs.mkdir(path.dirname(info.path), { recursive: true });
|
|
108
|
+
return await resolveCanonicalWriteTarget(this.projectRoot, info.path, {
|
|
109
|
+
allowOutside: this.allowOutside,
|
|
110
|
+
});
|
|
111
|
+
}
|
|
112
|
+
|
|
113
|
+
async saveImage(sourcePath, outputPath, provenance = {}) {
|
|
114
|
+
return await this.saveValidatedMedia(sourcePath, outputPath, provenance, inspectImageBuffer, "image");
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
async saveMusic(sourcePath, outputPath, provenance = {}) {
|
|
118
|
+
return await this.saveValidatedMedia(sourcePath, outputPath, provenance, inspectMusicBuffer, "music");
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
async saveValidatedMedia(sourcePath, outputPath, provenance, inspect, type) {
|
|
122
|
+
const target = await this.resolveOutputPath(outputPath);
|
|
123
|
+
const buffer = await fs.readFile(sourcePath);
|
|
124
|
+
const inspected = inspect(buffer);
|
|
125
|
+
const requestedExtension = path.extname(target).toLowerCase();
|
|
126
|
+
const acceptedExtensions = inspected.mimeType === "image/jpeg"
|
|
127
|
+
? new Set([".jpg", ".jpeg"])
|
|
128
|
+
: new Set([inspected.extension]);
|
|
129
|
+
if (requestedExtension && !acceptedExtensions.has(requestedExtension)) {
|
|
130
|
+
throw new Error(
|
|
131
|
+
`Output extension ${requestedExtension} does not match downloaded ${inspected.mimeType}.`,
|
|
132
|
+
);
|
|
133
|
+
}
|
|
134
|
+
const temporary = `${target}.wtagent-${process.pid}-${randomUUID()}.tmp`;
|
|
135
|
+
try {
|
|
136
|
+
await fs.copyFile(sourcePath, temporary);
|
|
137
|
+
await replaceFileAtomic(temporary, target);
|
|
138
|
+
} finally {
|
|
139
|
+
await fs.rm(temporary, { force: true }).catch(() => null);
|
|
140
|
+
}
|
|
141
|
+
return {
|
|
142
|
+
id: `artifact_${inspected.sha256.slice(0, 20)}`,
|
|
143
|
+
type,
|
|
144
|
+
localPath: target,
|
|
145
|
+
mimeType: inspected.mimeType,
|
|
146
|
+
width: inspected.width,
|
|
147
|
+
height: inspected.height,
|
|
148
|
+
...(type === "music" ? { durationSeconds: inspected.durationSeconds, hasVideo: inspected.hasVideo } : {}),
|
|
149
|
+
size: inspected.size,
|
|
150
|
+
sha256: inspected.sha256,
|
|
151
|
+
provenance,
|
|
152
|
+
};
|
|
153
|
+
}
|
|
154
|
+
}
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
|
|
3
|
+
function boxes(buffer, start = 0, end = buffer.length) {
|
|
4
|
+
const result = [];
|
|
5
|
+
for (let offset = start; offset < end;) {
|
|
6
|
+
if (offset + 8 > end) throw new Error("Truncated music container.");
|
|
7
|
+
let size = buffer.readUInt32BE(offset);
|
|
8
|
+
let header = 8;
|
|
9
|
+
if (size === 1) {
|
|
10
|
+
if (offset + 16 > end) throw new Error("Truncated music container.");
|
|
11
|
+
size = Number(buffer.readBigUInt64BE(offset + 8));
|
|
12
|
+
header = 16;
|
|
13
|
+
} else if (size === 0) size = end - offset;
|
|
14
|
+
if (!Number.isSafeInteger(size) || size < header || offset + size > end) {
|
|
15
|
+
throw new Error("Invalid music container size.");
|
|
16
|
+
}
|
|
17
|
+
result.push({ type: buffer.toString("ascii", offset + 4, offset + 8), start: offset + header, end: offset + size });
|
|
18
|
+
offset += size;
|
|
19
|
+
}
|
|
20
|
+
return result;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
// Gemini's Music player currently serves an MP4 containing cover video and AAC.
|
|
24
|
+
// Check the actual audio track, not the URL suffix or the provider's caption.
|
|
25
|
+
export function inspectMusicBuffer(buffer) {
|
|
26
|
+
const top = boxes(buffer);
|
|
27
|
+
const moov = top.find((box) => box.type === "moov");
|
|
28
|
+
if (!top.some((box) => box.type === "ftyp") || !moov
|
|
29
|
+
|| !top.some((box) => box.type === "mdat" && box.end > box.start)) {
|
|
30
|
+
throw new Error("The downloaded music is not a complete MP4/M4A file.");
|
|
31
|
+
}
|
|
32
|
+
const tracks = boxes(buffer, moov.start, moov.end).filter((box) => box.type === "trak");
|
|
33
|
+
const handlers = [];
|
|
34
|
+
let durationSeconds = null;
|
|
35
|
+
for (const track of tracks) {
|
|
36
|
+
const mdia = boxes(buffer, track.start, track.end).find((box) => box.type === "mdia");
|
|
37
|
+
if (!mdia) continue;
|
|
38
|
+
const children = boxes(buffer, mdia.start, mdia.end);
|
|
39
|
+
const hdlr = children.find((box) => box.type === "hdlr");
|
|
40
|
+
if (!hdlr || hdlr.start + 12 > hdlr.end) continue;
|
|
41
|
+
const handler = buffer.toString("ascii", hdlr.start + 8, hdlr.start + 12);
|
|
42
|
+
handlers.push(handler);
|
|
43
|
+
if (handler !== "soun") continue;
|
|
44
|
+
const mdhd = children.find((box) => box.type === "mdhd");
|
|
45
|
+
if (!mdhd) continue;
|
|
46
|
+
const version = buffer[mdhd.start];
|
|
47
|
+
const timeOffset = mdhd.start + (version === 1 ? 20 : 12);
|
|
48
|
+
if (![0, 1].includes(version) || timeOffset + (version === 1 ? 12 : 8) > mdhd.end) continue;
|
|
49
|
+
const scale = buffer.readUInt32BE(timeOffset);
|
|
50
|
+
const duration = version === 1
|
|
51
|
+
? Number(buffer.readBigUInt64BE(timeOffset + 4)) : buffer.readUInt32BE(timeOffset + 4);
|
|
52
|
+
if (scale > 0 && duration > 0) durationSeconds = duration / scale;
|
|
53
|
+
}
|
|
54
|
+
if (!handlers.includes("soun") || !Number.isFinite(durationSeconds) || durationSeconds <= 0) {
|
|
55
|
+
throw new Error("The downloaded music file has no valid audio track.");
|
|
56
|
+
}
|
|
57
|
+
const hasVideo = handlers.includes("vide");
|
|
58
|
+
return {
|
|
59
|
+
mimeType: hasVideo ? "video/mp4" : "audio/mp4",
|
|
60
|
+
extension: hasVideo ? ".mp4" : ".m4a",
|
|
61
|
+
durationSeconds,
|
|
62
|
+
hasVideo,
|
|
63
|
+
size: buffer.length,
|
|
64
|
+
sha256: createHash("sha256").update(buffer).digest("hex"),
|
|
65
|
+
};
|
|
66
|
+
}
|
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import fs from "node:fs/promises";
|
|
2
|
+
import path from "node:path";
|
|
3
|
+
import os from "node:os";
|
|
4
|
+
import { createHash } from "node:crypto";
|
|
5
|
+
import { execFile } from "node:child_process";
|
|
6
|
+
import { promisify } from "node:util";
|
|
7
|
+
import { ArtifactStore } from "../artifacts/artifact-store.js";
|
|
8
|
+
import { inspectMusicBuffer } from "./music-artifact.js";
|
|
9
|
+
|
|
10
|
+
const execFileAsync = promisify(execFile);
|
|
11
|
+
export const MUSIC_SYSTEM_REMINDER = "Use Gemini's native Music tool to create the requested playable music. "
|
|
12
|
+
+ "Return the generated track directly, with an optional short description. Do not use XML, code, or local tools. "
|
|
13
|
+
+ "If music cannot be generated, explain the limitation plainly. WTAgent downloads the generated file automatically.";
|
|
14
|
+
|
|
15
|
+
export function validateGenerationType(type, provider) {
|
|
16
|
+
const value = type ?? "agent";
|
|
17
|
+
if (!["agent", "music"].includes(value)) throw new Error(`Unknown --type ${value}. Use agent or music.`);
|
|
18
|
+
if (value === "music" && provider !== "gemini") throw new Error("--type music requires --model gemini.");
|
|
19
|
+
return value;
|
|
20
|
+
}
|
|
21
|
+
|
|
22
|
+
export class NativeMusicReceiver {
|
|
23
|
+
constructor({ adapter, extractAudio = extractAudioTrack }) {
|
|
24
|
+
this.adapter = adapter;
|
|
25
|
+
this.extractAudio = extractAudio;
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
async prepare() {
|
|
29
|
+
await this.adapter.prepareMusicGeneration();
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
async read(message) {
|
|
33
|
+
return await message.locator("video, audio").evaluateAll((elements) => ({
|
|
34
|
+
pending: elements.some((element) => !element.currentSrc && !element.src),
|
|
35
|
+
music: elements.filter((element) => element.isConnected && (element.currentSrc || element.src))
|
|
36
|
+
.map((element) => ({ source: element.currentSrc || element.src })),
|
|
37
|
+
}));
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
async save(tracks, { projectRoot, handoffId, assistantMessageId }) {
|
|
41
|
+
const store = new ArtifactStore({ projectRoot });
|
|
42
|
+
const directory = path.join("artifacts", "music", createHash("sha256").update(handoffId).digest("hex").slice(0, 24));
|
|
43
|
+
const temporaryDir = await fs.mkdtemp(path.join(os.tmpdir(), "wtagent-music-"));
|
|
44
|
+
const artifacts = [];
|
|
45
|
+
try {
|
|
46
|
+
for (const [index, track] of tracks.entries()) {
|
|
47
|
+
const url = new URL(track.source);
|
|
48
|
+
if (url.protocol !== "https:" || !(url.hostname === "contribution.usercontent.google.com"
|
|
49
|
+
|| url.hostname.endsWith(".googleusercontent.com"))) {
|
|
50
|
+
throw new Error("The music source is not a supported Gemini media URL.");
|
|
51
|
+
}
|
|
52
|
+
const response = await this.adapter.page.request.get(track.source, { timeout: 60_000 });
|
|
53
|
+
if (!response.ok()) throw new Error(`Music download failed with HTTP ${response.status()}.`);
|
|
54
|
+
const body = await response.body();
|
|
55
|
+
const inspected = inspectMusicBuffer(body);
|
|
56
|
+
const source = path.join(temporaryDir, `track-${index + 1}${inspected.extension}`);
|
|
57
|
+
await fs.writeFile(source, body);
|
|
58
|
+
const provenance = { provider: "gemini", assistantMessageId, source: track.source };
|
|
59
|
+
const original = await store.saveMusic(source, path.join(directory, path.basename(source)), provenance);
|
|
60
|
+
artifacts.push(original);
|
|
61
|
+
if (inspected.hasVideo) {
|
|
62
|
+
const audioPath = path.join(temporaryDir, `track-${index + 1}.m4a`);
|
|
63
|
+
const extraction = await this.extractAudio(source, audioPath);
|
|
64
|
+
if (extraction.ok) {
|
|
65
|
+
artifacts.push(await store.saveMusic(audioPath, path.join(directory, path.basename(audioPath)), {
|
|
66
|
+
...provenance, extractedFrom: original.localPath, lossless: true,
|
|
67
|
+
}));
|
|
68
|
+
} else {
|
|
69
|
+
original.audioExtractionNote = extraction.message;
|
|
70
|
+
}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
return artifacts;
|
|
74
|
+
} finally {
|
|
75
|
+
await fs.rm(temporaryDir, { recursive: true, force: true });
|
|
76
|
+
}
|
|
77
|
+
}
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
async function extractAudioTrack(source, output) {
|
|
81
|
+
try {
|
|
82
|
+
await execFileAsync("ffmpeg", ["-nostdin", "-v", "error", "-n", "-i", source, "-map", "0:a:0", "-vn", "-c:a", "copy", output], {
|
|
83
|
+
timeout: 60_000, maxBuffer: 1024 * 1024, windowsHide: true,
|
|
84
|
+
});
|
|
85
|
+
return { ok: true };
|
|
86
|
+
} catch (error) {
|
|
87
|
+
return { ok: false, message: error.code === "ENOENT"
|
|
88
|
+
? "FFmpeg is not installed; the original MP4 with its audio track was saved."
|
|
89
|
+
: "Audio extraction failed; the original MP4 with its audio track was saved." };
|
|
90
|
+
}
|
|
91
|
+
}
|