wechat-to-anything 0.5.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/LICENSE +21 -0
- package/README.md +144 -0
- package/bin/cli.mjs +120 -0
- package/cli/agent-adapter.mjs +250 -0
- package/cli/bridge.mjs +330 -0
- package/cli/cdn.mjs +144 -0
- package/cli/weixin.mjs +343 -0
- package/docs/wechat-image-receive.png +0 -0
- package/docs/wechat-image-send.png +0 -0
- package/docs/wechat-voice-demo.gif +0 -0
- package/docs/wechat-voice-demo.mp4 +0 -0
- package/examples/claude-code/README.md +19 -0
- package/examples/claude-code/package-lock.json +340 -0
- package/examples/claude-code/package.json +11 -0
- package/examples/claude-code/server.mjs +98 -0
- package/examples/gemini/README.md +32 -0
- package/examples/gemini/server.mjs +98 -0
- package/examples/openai/README.md +35 -0
- package/examples/openai/server.mjs +119 -0
- package/package.json +39 -0
package/LICENSE
ADDED
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
MIT License
|
|
2
|
+
|
|
3
|
+
Copyright (c) 2026 Kellyxiaowei
|
|
4
|
+
|
|
5
|
+
Permission is hereby granted, free of charge, to any person obtaining a copy
|
|
6
|
+
of this software and associated documentation files (the "Software"), to deal
|
|
7
|
+
in the Software without restriction, including without limitation the rights
|
|
8
|
+
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
|
|
9
|
+
copies of the Software, and to permit persons to whom the Software is
|
|
10
|
+
furnished to do so, subject to the following conditions:
|
|
11
|
+
|
|
12
|
+
The above copyright notice and this permission notice shall be included in all
|
|
13
|
+
copies or substantial portions of the Software.
|
|
14
|
+
|
|
15
|
+
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
|
|
16
|
+
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
|
|
17
|
+
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
|
|
18
|
+
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
|
|
19
|
+
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
|
|
20
|
+
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
|
|
21
|
+
SOFTWARE.
|
package/README.md
ADDED
|
@@ -0,0 +1,144 @@
|
|
|
1
|
+
# wechat-to-anything
|
|
2
|
+
|
|
3
|
+
> 把微信变成任何 AI Agent 的前端。零依赖,一条命令。
|
|
4
|
+
>
|
|
5
|
+
> ⭐ 如果这个项目对你有帮助,请给个 Star!本项目仅用于技术学习和交流,开源不易。
|
|
6
|
+
|
|
7
|
+
微信双向支持 Agent 多种模态消息发送和接收,支持文本、图片、语音、文件。
|
|
8
|
+
|
|
9
|
+
<p align="center">
|
|
10
|
+
<img src="docs/wechat-image-send.png" width="250" alt="发送图片给 Agent 识别" />
|
|
11
|
+
<img src="docs/wechat-image-receive.png" width="250" alt="Agent 发送图片到微信" />
|
|
12
|
+
<a href="https://github.com/kellyvv/wechat-to-anything/raw/main/docs/wechat-voice-demo.mp4">
|
|
13
|
+
<img src="docs/wechat-voice-demo.gif" width="250" alt="语音发送演示(点击播放有声版)" />
|
|
14
|
+
</a>
|
|
15
|
+
</p>
|
|
16
|
+
|
|
17
|
+
## 原理
|
|
18
|
+
|
|
19
|
+
```
|
|
20
|
+
微信 ←→ ilinkai API (腾讯) ←→ wechat-to-anything ←→ 你的 Agent (HTTP)
|
|
21
|
+
```
|
|
22
|
+
|
|
23
|
+
直接调用腾讯 ilinkai 接口收发微信消息,无中间层。你的 Agent 只需暴露一个 OpenAI 兼容的 HTTP 接口(`POST /v1/chat/completions`),任何语言都行。
|
|
24
|
+
|
|
25
|
+
### 多种模态支持
|
|
26
|
+
|
|
27
|
+
| 方向 | 图片 | 语音 | 文件 |
|
|
28
|
+
|---|---|---|---|
|
|
29
|
+
| **微信 → Agent** | ✅ 自动识别 | ✅ 语音转文字 | ✅ 提取文本 |
|
|
30
|
+
| **Agent → 微信** | ✅ 自动发图 | ✅ 语音消息 | 文本回复 |
|
|
31
|
+
|
|
32
|
+
## 前置条件
|
|
33
|
+
|
|
34
|
+
- Node.js >= 22(`nvm install 22`)
|
|
35
|
+
|
|
36
|
+
## 快速开始
|
|
37
|
+
|
|
38
|
+
```bash
|
|
39
|
+
# 一条命令,选你喜欢的 Agent:
|
|
40
|
+
npx wechat-to-anything --codex # OpenAI Codex
|
|
41
|
+
npx wechat-to-anything --gemini # Google Gemini
|
|
42
|
+
npx wechat-to-anything --claude # Claude Code
|
|
43
|
+
npx wechat-to-anything --openclaw # OpenClaw
|
|
44
|
+
|
|
45
|
+
# 多 Agent 同时用:
|
|
46
|
+
npx wechat-to-anything --codex --gemini
|
|
47
|
+
```
|
|
48
|
+
|
|
49
|
+
> 需要先安装对应 CLI:`npm i -g @openai/codex` / `@google/gemini-cli` / `@anthropic-ai/claude-code` / `openclaw`
|
|
50
|
+
>
|
|
51
|
+
> 也支持直接传 URL:`npx wechat-to-anything http://your-agent:8000/v1`
|
|
52
|
+
|
|
53
|
+
### 接入 OpenClaw
|
|
54
|
+
|
|
55
|
+
```bash
|
|
56
|
+
# 1. 安装并配置 OpenClaw
|
|
57
|
+
npm i -g openclaw
|
|
58
|
+
openclaw configure # 设置模型(如 Gemini / OpenAI)
|
|
59
|
+
|
|
60
|
+
# 2. 启动 Gateway
|
|
61
|
+
openclaw gateway
|
|
62
|
+
|
|
63
|
+
# 3. 启动桥(另一个终端)
|
|
64
|
+
npx wechat-to-anything --openclaw
|
|
65
|
+
```
|
|
66
|
+
|
|
67
|
+
> OpenClaw 的 Gateway 需要先配好模型 provider(运行 `openclaw configure`)。
|
|
68
|
+
> 如果 OpenClaw 已有 `openclaw-weixin` 插件,需先禁用以避免消息冲突。
|
|
69
|
+
|
|
70
|
+
### 首次使用
|
|
71
|
+
|
|
72
|
+
终端会弹出二维码 → 微信扫码 → 完成。之后自动复用登录凭证。
|
|
73
|
+
|
|
74
|
+
## 接入你自己的 Agent
|
|
75
|
+
|
|
76
|
+
暴露 `POST /v1/chat/completions` 即可,任何语言:
|
|
77
|
+
|
|
78
|
+
```python
|
|
79
|
+
@app.post("/v1/chat/completions")
|
|
80
|
+
def chat(request):
|
|
81
|
+
message = request.json["messages"][-1]["content"]
|
|
82
|
+
reply = your_agent(message)
|
|
83
|
+
return {"choices": [{"message": {"role": "assistant", "content": reply}}]}
|
|
84
|
+
```
|
|
85
|
+
|
|
86
|
+
然后 `npx wechat-to-anything http://your-agent:8000/v1`。
|
|
87
|
+
|
|
88
|
+
## 多 Agent 模式
|
|
89
|
+
|
|
90
|
+
同时接入多个 Agent,通过 `@` 前缀路由消息。支持 OpenAI 兼容格式和 [ACP (Agent Communication Protocol)](https://agentcommunicationprotocol.dev/) 两种协议:
|
|
91
|
+
|
|
92
|
+
```bash
|
|
93
|
+
npx wechat-to-anything \
|
|
94
|
+
--agent codex=http://localhost:3001/v1 \
|
|
95
|
+
--agent gemini=http://localhost:3002/v1 \
|
|
96
|
+
--agent bee=acp://localhost:8000/chat \
|
|
97
|
+
--default codex
|
|
98
|
+
```
|
|
99
|
+
|
|
100
|
+
> `http://` → OpenAI 格式,`acp://` → ACP 协议,自动识别。
|
|
101
|
+
|
|
102
|
+
微信里使用:
|
|
103
|
+
|
|
104
|
+
| 消息 | 效果 |
|
|
105
|
+
|---|---|
|
|
106
|
+
| `你好` | 发给默认 Agent |
|
|
107
|
+
| `@codex 写个排序` | 路由到 Codex |
|
|
108
|
+
| `@gemini 审查代码` | 路由到 Gemini |
|
|
109
|
+
| `@bee 分析数据` | 路由到 ACP Agent |
|
|
110
|
+
| `@list` | 查看已注册的 Agent |
|
|
111
|
+
| `@切换 gemini` | 切换默认 Agent |
|
|
112
|
+
|
|
113
|
+
多 Agent 模式下回复自动带 `[agentName]` 前缀标识来源。每个用户独立维护默认 Agent。
|
|
114
|
+
|
|
115
|
+
**图片支持**:消息格式遵循 [OpenAI Vision API](https://platform.openai.com/docs/guides/vision),`content` 为数组:
|
|
116
|
+
|
|
117
|
+
```json
|
|
118
|
+
{
|
|
119
|
+
"messages": [{
|
|
120
|
+
"role": "user",
|
|
121
|
+
"content": [
|
|
122
|
+
{ "type": "text", "text": "这是什么?" },
|
|
123
|
+
{ "type": "image_url", "image_url": { "url": "data:image/jpeg;base64,..." } }
|
|
124
|
+
]
|
|
125
|
+
}]
|
|
126
|
+
}
|
|
127
|
+
```
|
|
128
|
+
|
|
129
|
+
**图片回复**:Agent 回复中包含 markdown 图片 `` 会自动作为图片消息发到微信。
|
|
130
|
+
|
|
131
|
+
|
|
132
|
+
## 凭证
|
|
133
|
+
|
|
134
|
+
登录凭证保存在 `~/.wechat-to-anything/credentials.json`,删除即可重新登录。
|
|
135
|
+
|
|
136
|
+
|
|
137
|
+
|
|
138
|
+
## Star History
|
|
139
|
+
|
|
140
|
+
如果这个项目帮到了你,请给个 ⭐ Star,这是对我们最大的支持!
|
|
141
|
+
|
|
142
|
+
## License
|
|
143
|
+
|
|
144
|
+
[MIT](LICENSE)
|
package/bin/cli.mjs
ADDED
|
@@ -0,0 +1,120 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import pc from "picocolors";
|
|
4
|
+
|
|
5
|
+
// 解析命令行参数
|
|
6
|
+
const args = process.argv.slice(2);
|
|
7
|
+
const agents = new Map();
|
|
8
|
+
let defaultAgent = null;
|
|
9
|
+
|
|
10
|
+
if (args.length === 0 || args[0] === "--help" || args[0] === "-h") {
|
|
11
|
+
console.log(`
|
|
12
|
+
${pc.cyan("🌉 wechat-to-anything")}
|
|
13
|
+
|
|
14
|
+
${pc.dim("一条命令,把微信变成任何 AI Agent 的入口")}
|
|
15
|
+
|
|
16
|
+
${pc.bold("用法:")}
|
|
17
|
+
${pc.green("最简单:")}
|
|
18
|
+
npx wechat-to-anything ${pc.green("--codex")}
|
|
19
|
+
npx wechat-to-anything ${pc.green("--gemini")}
|
|
20
|
+
npx wechat-to-anything ${pc.green("--claude")}
|
|
21
|
+
npx wechat-to-anything ${pc.green("--openclaw")}
|
|
22
|
+
npx wechat-to-anything ${pc.green("--codex --gemini --claude")} ${pc.dim("多 Agent")}
|
|
23
|
+
|
|
24
|
+
${pc.green("自定义 Agent:")}
|
|
25
|
+
npx wechat-to-anything ${pc.green("<agent-url>")}
|
|
26
|
+
npx wechat-to-anything ${pc.green("--agent name=url --agent name2=url2")}
|
|
27
|
+
|
|
28
|
+
${pc.bold("参数:")}
|
|
29
|
+
--codex ${pc.dim("内置 Codex CLI(需先 npm i -g @openai/codex)")}
|
|
30
|
+
--gemini ${pc.dim("内置 Gemini CLI(需先 npm i -g @google/gemini-cli)")}
|
|
31
|
+
--claude ${pc.dim("内置 Claude Code CLI(需先 npm i -g @anthropic-ai/claude-code)")}
|
|
32
|
+
--openclaw ${pc.dim("内置 OpenClaw(需先 npm i -g openclaw)")}
|
|
33
|
+
--agent ${pc.dim("name=url")} ${pc.dim("注册自定义 Agent")}
|
|
34
|
+
--default ${pc.dim("name")} ${pc.dim("设置默认 Agent")}
|
|
35
|
+
|
|
36
|
+
${pc.dim("Docs: https://github.com/kellyvv/wechat-to-anything")}
|
|
37
|
+
`);
|
|
38
|
+
process.exit(args.length > 0 ? 0 : 1);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
// 解析参数
|
|
42
|
+
let i = 0;
|
|
43
|
+
while (i < args.length) {
|
|
44
|
+
if (args[i] === "--codex") {
|
|
45
|
+
agents.set("codex", "cli://codex");
|
|
46
|
+
i++;
|
|
47
|
+
} else if (args[i] === "--gemini") {
|
|
48
|
+
agents.set("gemini", "cli://gemini");
|
|
49
|
+
i++;
|
|
50
|
+
} else if (args[i] === "--claude") {
|
|
51
|
+
agents.set("claude", "cli://claude");
|
|
52
|
+
i++;
|
|
53
|
+
} else if (args[i] === "--openclaw") {
|
|
54
|
+
agents.set("openclaw", "cli://openclaw");
|
|
55
|
+
i++;
|
|
56
|
+
} else if (args[i] === "--agent" && args[i + 1]) {
|
|
57
|
+
const [name, ...urlParts] = args[i + 1].split("=");
|
|
58
|
+
const url = urlParts.join("=");
|
|
59
|
+
if (!name || !url) {
|
|
60
|
+
console.error(pc.red(`无效的 --agent 参数: ${args[i + 1]},格式: name=url`));
|
|
61
|
+
process.exit(1);
|
|
62
|
+
}
|
|
63
|
+
if (!url.startsWith("acp://") && !url.startsWith("cli://")) {
|
|
64
|
+
try { new URL(url); } catch {
|
|
65
|
+
console.error(pc.red(`无效的 Agent URL: ${url}`));
|
|
66
|
+
process.exit(1);
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
agents.set(name.toLowerCase(), url);
|
|
70
|
+
i += 2;
|
|
71
|
+
} else if (args[i] === "--default" && args[i + 1]) {
|
|
72
|
+
defaultAgent = args[i + 1].toLowerCase();
|
|
73
|
+
i += 2;
|
|
74
|
+
} else if (!args[i].startsWith("--")) {
|
|
75
|
+
if (!args[i].startsWith("acp://")) {
|
|
76
|
+
try { new URL(args[i]); } catch {
|
|
77
|
+
console.error(pc.red(`无效的 URL: ${args[i]}`));
|
|
78
|
+
process.exit(1);
|
|
79
|
+
}
|
|
80
|
+
}
|
|
81
|
+
agents.set("default", args[i]);
|
|
82
|
+
defaultAgent = "default";
|
|
83
|
+
i++;
|
|
84
|
+
} else {
|
|
85
|
+
console.error(pc.red(`未知参数: ${args[i]}`));
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
}
|
|
89
|
+
|
|
90
|
+
if (agents.size === 0) {
|
|
91
|
+
console.error(pc.red("至少需要一个 Agent,用 --agent name=url 或直接传 URL"));
|
|
92
|
+
process.exit(1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
// 默认用第一个注册的 Agent
|
|
96
|
+
if (!defaultAgent) {
|
|
97
|
+
defaultAgent = agents.keys().next().value;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
if (!agents.has(defaultAgent)) {
|
|
101
|
+
console.error(pc.red(`默认 Agent "${defaultAgent}" 未注册`));
|
|
102
|
+
process.exit(1);
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
console.log();
|
|
106
|
+
console.log(pc.cyan("🌉 wechat-to-anything"));
|
|
107
|
+
if (agents.size === 1 && agents.has("default")) {
|
|
108
|
+
console.log(pc.dim(` Agent: ${agents.get("default")}`));
|
|
109
|
+
} else {
|
|
110
|
+
for (const [name, url] of agents) {
|
|
111
|
+
const isDefault = name === defaultAgent;
|
|
112
|
+
console.log(pc.dim(` ${isDefault ? "★" : " "} ${name}: ${url}`));
|
|
113
|
+
}
|
|
114
|
+
}
|
|
115
|
+
console.log();
|
|
116
|
+
|
|
117
|
+
import("../cli/bridge.mjs").then((mod) => mod.start(agents, defaultAgent)).catch((err) => {
|
|
118
|
+
console.error(pc.red(err.message));
|
|
119
|
+
process.exit(1);
|
|
120
|
+
});
|
|
@@ -0,0 +1,250 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Agent 调用适配器
|
|
3
|
+
*
|
|
4
|
+
* 根据 URL scheme 自动选择协议:
|
|
5
|
+
* - http:// / https:// → OpenAI 兼容格式
|
|
6
|
+
* - acp:// → ACP (Agent Communication Protocol)
|
|
7
|
+
* - cli://codex → 内置 Codex CLI 适配器
|
|
8
|
+
* - cli://gemini → 内置 Gemini CLI 适配器
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { execFile, spawn } from "node:child_process";
|
|
12
|
+
import { writeFile, readFile, unlink, mkdir } from "node:fs/promises";
|
|
13
|
+
import { join } from "node:path";
|
|
14
|
+
import { tmpdir } from "node:os";
|
|
15
|
+
import { randomBytes } from "node:crypto";
|
|
16
|
+
|
|
17
|
+
/**
|
|
18
|
+
* 统一调用接口 — 根据 URL 自动选择适配器
|
|
19
|
+
*/
|
|
20
|
+
export async function callAgentAuto(url, messages) {
|
|
21
|
+
if (url.startsWith("acp://")) return callACP(url, messages);
|
|
22
|
+
if (url.startsWith("cli://")) return callCLI(url, messages);
|
|
23
|
+
return callOpenAI(url, messages);
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
/**
|
|
27
|
+
* 验证 Agent 是否可达
|
|
28
|
+
*/
|
|
29
|
+
export async function checkAgent(url) {
|
|
30
|
+
if (url.startsWith("acp://")) {
|
|
31
|
+
const { httpUrl } = parseACPUrl(url);
|
|
32
|
+
const res = await fetch(`${httpUrl}/agents`, { signal: AbortSignal.timeout(5000) });
|
|
33
|
+
if (!res.ok) throw new Error(`ACP server 不可达: ${res.status}`);
|
|
34
|
+
return;
|
|
35
|
+
}
|
|
36
|
+
if (url.startsWith("cli://")) {
|
|
37
|
+
const name = url.replace("cli://", "");
|
|
38
|
+
const cmd = { codex: "codex", gemini: "gemini", claude: "claude", openclaw: "openclaw" }[name] || name;
|
|
39
|
+
return new Promise((resolve, reject) => {
|
|
40
|
+
execFile(cmd, ["--version"], { timeout: 5000 }, (err) => {
|
|
41
|
+
if (err) reject(new Error(`${cmd} CLI 未安装(npm install -g ${{
|
|
42
|
+
codex: "@openai/codex", gemini: "@google/gemini-cli", claude: "@anthropic-ai/claude-code", openclaw: "openclaw"
|
|
43
|
+
}[name] || cmd})`));
|
|
44
|
+
else resolve();
|
|
45
|
+
});
|
|
46
|
+
});
|
|
47
|
+
}
|
|
48
|
+
await fetch(url, { signal: AbortSignal.timeout(5000) });
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
// ========== OpenAI 适配器 ==========
|
|
52
|
+
|
|
53
|
+
async function callOpenAI(agentUrl, messages) {
|
|
54
|
+
const res = await fetch(`${agentUrl}/chat/completions`, {
|
|
55
|
+
method: "POST",
|
|
56
|
+
headers: { "Content-Type": "application/json" },
|
|
57
|
+
body: JSON.stringify({ messages }),
|
|
58
|
+
signal: AbortSignal.timeout(300_000),
|
|
59
|
+
});
|
|
60
|
+
if (!res.ok) {
|
|
61
|
+
const text = await res.text();
|
|
62
|
+
throw new Error(`${res.status}: ${text.slice(0, 200)}`);
|
|
63
|
+
}
|
|
64
|
+
const data = await res.json();
|
|
65
|
+
return data.choices?.[0]?.message?.content || "(empty response)";
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
// ========== ACP 适配器 ==========
|
|
69
|
+
|
|
70
|
+
function parseACPUrl(acpUrl) {
|
|
71
|
+
const withoutScheme = acpUrl.replace(/^acp:\/\//, "");
|
|
72
|
+
const slashIdx = withoutScheme.indexOf("/");
|
|
73
|
+
if (slashIdx === -1) throw new Error(`无效的 ACP URL: ${acpUrl}`);
|
|
74
|
+
return { httpUrl: `http://${withoutScheme.slice(0, slashIdx)}`, agentName: withoutScheme.slice(slashIdx + 1) };
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
async function callACP(acpUrl, messages) {
|
|
78
|
+
const { httpUrl, agentName } = parseACPUrl(acpUrl);
|
|
79
|
+
const input = messages.map((msg) => {
|
|
80
|
+
if (typeof msg.content === "string") {
|
|
81
|
+
return { parts: [{ content: msg.content, content_type: "text/plain" }] };
|
|
82
|
+
}
|
|
83
|
+
const parts = [];
|
|
84
|
+
for (const item of msg.content) {
|
|
85
|
+
if (item.type === "text") parts.push({ content: item.text, content_type: "text/plain" });
|
|
86
|
+
else if (item.type === "image_url") parts.push({ content: item.image_url.url, content_type: "image/jpeg" });
|
|
87
|
+
}
|
|
88
|
+
return { parts };
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
const res = await fetch(`${httpUrl}/agents/${agentName}/run`, {
|
|
92
|
+
method: "POST",
|
|
93
|
+
headers: { "Content-Type": "application/json" },
|
|
94
|
+
body: JSON.stringify({ input }),
|
|
95
|
+
signal: AbortSignal.timeout(300_000),
|
|
96
|
+
});
|
|
97
|
+
if (!res.ok) { const t = await res.text(); throw new Error(`ACP ${res.status}: ${t.slice(0, 200)}`); }
|
|
98
|
+
const data = await res.json();
|
|
99
|
+
const texts = [];
|
|
100
|
+
for (const msg of data.output || []) {
|
|
101
|
+
for (const part of msg.parts || []) {
|
|
102
|
+
if (part.content_type === "text/plain" || !part.content_type) texts.push(part.content);
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
return texts.join("\n") || "(empty response)";
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
// ========== 内置 CLI 适配器 ==========
|
|
109
|
+
|
|
110
|
+
const TMP_DIR = join(tmpdir(), "wechat-cli-agents");
|
|
111
|
+
|
|
112
|
+
async function callCLI(cliUrl, messages) {
|
|
113
|
+
const name = cliUrl.replace("cli://", "");
|
|
114
|
+
const lastMsg = messages[messages.length - 1];
|
|
115
|
+
|
|
116
|
+
// 提取文本和图片
|
|
117
|
+
let prompt = "";
|
|
118
|
+
const imagePaths = [];
|
|
119
|
+
const tmpFiles = [];
|
|
120
|
+
|
|
121
|
+
if (typeof lastMsg.content === "string") {
|
|
122
|
+
prompt = lastMsg.content;
|
|
123
|
+
} else if (Array.isArray(lastMsg.content)) {
|
|
124
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
125
|
+
for (const part of lastMsg.content) {
|
|
126
|
+
if (part.type === "text") prompt += (prompt ? "\n" : "") + part.text;
|
|
127
|
+
else if (part.type === "image_url" && part.image_url?.url) {
|
|
128
|
+
const tmpPath = join(TMP_DIR, `img-${randomBytes(4).toString("hex")}.jpg`);
|
|
129
|
+
const url = part.image_url.url;
|
|
130
|
+
if (url.startsWith("data:")) {
|
|
131
|
+
await writeFile(tmpPath, Buffer.from(url.replace(/^data:[^;]+;base64,/, ""), "base64"));
|
|
132
|
+
} else {
|
|
133
|
+
const r = await fetch(url);
|
|
134
|
+
if (r.ok) await writeFile(tmpPath, Buffer.from(await r.arrayBuffer()));
|
|
135
|
+
}
|
|
136
|
+
imagePaths.push(tmpPath);
|
|
137
|
+
tmpFiles.push(tmpPath);
|
|
138
|
+
}
|
|
139
|
+
}
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
if (!prompt && imagePaths.length > 0) prompt = "请描述这张图片";
|
|
143
|
+
if (!prompt) throw new Error("empty prompt");
|
|
144
|
+
|
|
145
|
+
try {
|
|
146
|
+
if (name === "codex") return await runCodex(prompt, imagePaths);
|
|
147
|
+
if (name === "gemini") return await runGemini(prompt);
|
|
148
|
+
if (name === "claude") return await runClaude(prompt);
|
|
149
|
+
if (name === "openclaw") return await runOpenClaw(prompt);
|
|
150
|
+
throw new Error(`未知的内置 CLI Agent: ${name}`);
|
|
151
|
+
} finally {
|
|
152
|
+
for (const f of tmpFiles) unlink(f).catch(() => {});
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function runCodex(prompt, imagePaths = []) {
|
|
157
|
+
return new Promise(async (resolve, reject) => {
|
|
158
|
+
await mkdir(TMP_DIR, { recursive: true });
|
|
159
|
+
const outFile = join(TMP_DIR, `out-${randomBytes(4).toString("hex")}.txt`);
|
|
160
|
+
const args = ["exec", "--skip-git-repo-check", "--ephemeral", "-o", outFile];
|
|
161
|
+
for (const img of imagePaths) args.push("-i", img);
|
|
162
|
+
args.push("--", prompt);
|
|
163
|
+
|
|
164
|
+
execFile("codex", args, { timeout: 300_000, maxBuffer: 2 * 1024 * 1024, cwd: tmpdir() },
|
|
165
|
+
async (err, stdout, stderr) => {
|
|
166
|
+
try {
|
|
167
|
+
const reply = await readFile(outFile, "utf-8").catch(() => "");
|
|
168
|
+
await unlink(outFile).catch(() => {});
|
|
169
|
+
if (reply.trim()) resolve(reply.trim());
|
|
170
|
+
else if (stdout.trim()) resolve(stdout.trim());
|
|
171
|
+
else if (err) reject(new Error((stderr || err.message).trim().slice(0, 300)));
|
|
172
|
+
else resolve("(empty response)");
|
|
173
|
+
} catch (e) { reject(e); }
|
|
174
|
+
});
|
|
175
|
+
});
|
|
176
|
+
}
|
|
177
|
+
|
|
178
|
+
function runGemini(prompt) {
|
|
179
|
+
return new Promise((resolve, reject) => {
|
|
180
|
+
const child = spawn("gemini", [], { cwd: tmpdir(), stdio: ["pipe", "pipe", "pipe"], timeout: 300_000 });
|
|
181
|
+
let stdout = "", stderr = "";
|
|
182
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
183
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
184
|
+
child.stdin.write(prompt);
|
|
185
|
+
child.stdin.end();
|
|
186
|
+
child.on("close", (code) => {
|
|
187
|
+
if (stdout.trim()) resolve(stdout.trim());
|
|
188
|
+
else if (code !== 0) reject(new Error((stderr || `exit code ${code}`).trim().slice(0, 300)));
|
|
189
|
+
else resolve("(empty response)");
|
|
190
|
+
});
|
|
191
|
+
child.on("error", (err) => reject(new Error(`gemini CLI 未安装: ${err.message}`)));
|
|
192
|
+
});
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
function runClaude(prompt) {
|
|
196
|
+
return new Promise((resolve, reject) => {
|
|
197
|
+
const child = spawn("claude", ["--print", prompt], {
|
|
198
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
199
|
+
timeout: 300_000,
|
|
200
|
+
});
|
|
201
|
+
let stdout = "", stderr = "";
|
|
202
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
203
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
204
|
+
child.on("close", (code) => {
|
|
205
|
+
if (code !== 0) reject(new Error((stderr + stdout).trim().slice(0, 300) || `exit code ${code}`));
|
|
206
|
+
else resolve(stdout.trim() || "(empty response)");
|
|
207
|
+
});
|
|
208
|
+
child.on("error", (err) => reject(new Error(`claude CLI 未安装: ${err.message}`)));
|
|
209
|
+
});
|
|
210
|
+
}
|
|
211
|
+
|
|
212
|
+
function runOpenClaw(prompt) {
|
|
213
|
+
return new Promise((resolve, reject) => {
|
|
214
|
+
const child = spawn("openclaw", [
|
|
215
|
+
"agent", "--agent", "main",
|
|
216
|
+
"--message", prompt, "--json",
|
|
217
|
+
], {
|
|
218
|
+
cwd: tmpdir(),
|
|
219
|
+
stdio: ["ignore", "pipe", "pipe"],
|
|
220
|
+
timeout: 300_000,
|
|
221
|
+
});
|
|
222
|
+
let stdout = "", stderr = "";
|
|
223
|
+
child.stdout.on("data", (d) => (stdout += d));
|
|
224
|
+
child.stderr.on("data", (d) => (stderr += d));
|
|
225
|
+
child.on("close", (code) => {
|
|
226
|
+
if (code !== 0) {
|
|
227
|
+
reject(new Error((stderr + stdout).trim().slice(0, 300) || `exit code ${code}`));
|
|
228
|
+
return;
|
|
229
|
+
}
|
|
230
|
+
try {
|
|
231
|
+
// OpenClaw stdout 可能含有 [plugins] 日志行在 JSON 之前,跳过
|
|
232
|
+
const jsonStart = stdout.indexOf("\n{");
|
|
233
|
+
const raw = jsonStart >= 0 ? stdout.slice(jsonStart + 1) : stdout;
|
|
234
|
+
const data = JSON.parse(raw);
|
|
235
|
+
// OpenClaw JSON: { result: { payloads: [{ text: "...", mediaUrl: null }] } }
|
|
236
|
+
const payloads = data?.result?.payloads;
|
|
237
|
+
if (Array.isArray(payloads)) {
|
|
238
|
+
const texts = payloads.map(p => p.text).filter(Boolean);
|
|
239
|
+
if (texts.length) { resolve(texts.join("\n")); return; }
|
|
240
|
+
}
|
|
241
|
+
resolve((data?.summary || data?.reply || data?.text || "").trim() || "(empty response)");
|
|
242
|
+
} catch {
|
|
243
|
+
// JSON 解析仍然失败,过滤掉日志行返回纯文本
|
|
244
|
+
const lines = stdout.split("\n").filter(l => !l.match(/^\d{2}:\d{2}:\d{2} \[/));
|
|
245
|
+
resolve(lines.join("\n").trim() || "(empty response)");
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
child.on("error", (err) => reject(new Error(`openclaw CLI 未安装: ${err.message}`)));
|
|
249
|
+
});
|
|
250
|
+
}
|