diff --git a/README.md b/README.md index 7f1e2c5..dc48fd9 100644 --- a/README.md +++ b/README.md @@ -44,6 +44,45 @@ npm install -g @zed-industries/codex-acp npx weixin-acp start -- codex-acp ``` +### 在同一个微信会话里切换 Claude / Codex + +如果你希望在手机上直接切换后端,而不是为每个后端单独启动一个 bot,可以使用 `router config` 模式: + +```bash +# 安装两个 ACP agent +npm install -g @zed-industries/claude-agent-acp @zed-industries/codex-acp + +# 使用多后端路由启动 +npx weixin-acp start --router-config ./packages/agent-acp/router.example.json +``` + +示例配置文件: + +```json +{ + "defaultBackend": "claude", + "backends": { + "claude": { + "command": "claude-agent-acp" + }, + "codex": { + "command": "codex-acp" + } + } +} +``` + +启动后,可以在微信里直接发送: + +- `/claude`:切换当前会话默认后端到 Claude +- `/codex`:切换当前会话默认后端到 Codex +- `/claude 解释这个错误`:本条消息直接走 Claude,并切换默认后端 +- `/codex 帮我改这段代码`:本条消息直接走 Codex,并切换默认后端 +- `/mode`:查看当前默认后端和可用后端 +- `/mode claude` / `/mode codex`:只切换默认后端,不发送问题 + +这个默认后端会按微信会话持久化保存,重启桥接进程后仍然生效。 + ### kimi-cli ```bash diff --git a/packages/agent-acp/main.ts b/packages/agent-acp/main.ts index ad4e434..bccf702 100644 --- a/packages/agent-acp/main.ts +++ b/packages/agent-acp/main.ts @@ -1,10 +1,14 @@ #!/usr/bin/env node +import fs from "node:fs"; +import path from "node:path"; + /** * WeChat + ACP (Agent Client Protocol) adapter. * * Usage: * npx weixin-acp login # QR-code login + * npx weixin-acp start --router-config file.json # Start bot with multi-backend router * npx weixin-acp start -- [args...] # Start bot * * Examples: @@ -15,9 +19,27 @@ import { login, start } from "weixin-agent-sdk"; import { AcpAgent } from "./src/acp-agent.js"; +import type { AcpRouterConfig } from "./src/types.js"; const command = process.argv[2]; +function resolveRouterConfigPath(input: string): string { + return path.isAbsolute(input) ? input : path.resolve(process.cwd(), input); +} + +function loadRouterConfig(configPath: string): AcpRouterConfig { + const resolvedPath = resolveRouterConfigPath(configPath); + const raw = fs.readFileSync(resolvedPath, "utf8"); + const parsed = JSON.parse(raw) as AcpRouterConfig; + if (!parsed.defaultBackend?.trim()) { + throw new Error("router config 缺少 defaultBackend"); + } + if (!parsed.backends || Object.keys(parsed.backends).length === 0) { + throw new Error("router config 缺少 backends"); + } + return parsed; +} + async function main() { switch (command) { case "login": { @@ -26,10 +48,39 @@ async function main() { } case "start": { + const routerArgIndex = process.argv.indexOf("--router-config"); + if (routerArgIndex !== -1) { + const configPath = process.argv[routerArgIndex + 1]; + if (!configPath) { + console.error("错误: --router-config 后必须跟 JSON 文件路径"); + process.exit(1); + } + + const agent = new AcpAgent({ + command: "", + router: loadRouterConfig(configPath), + }); + + const ac = new AbortController(); + process.on("SIGINT", () => { + console.log("\n正在停止..."); + agent.dispose(); + ac.abort(); + }); + process.on("SIGTERM", () => { + agent.dispose(); + ac.abort(); + }); + + await start(agent, { abortSignal: ac.signal }); + break; + } + const ddIndex = process.argv.indexOf("--"); if (ddIndex === -1 || ddIndex + 1 >= process.argv.length) { console.error("错误: 请在 -- 后指定 ACP agent 启动命令"); console.error("示例: npx weixin-acp start -- codex-acp"); + console.error("或: npx weixin-acp start --router-config ./router.json"); process.exit(1); } @@ -61,9 +112,11 @@ async function main() { 用法: npx weixin-acp login 扫码登录微信 + npx weixin-acp start --router-config file.json 启动多后端路由 npx weixin-acp start -- [args...] 启动 bot 示例: + npx weixin-acp start --router-config ./router.json npx weixin-acp start -- codex-acp npx weixin-acp start -- node ./my-agent.js`); break; diff --git a/packages/agent-acp/router.example.json b/packages/agent-acp/router.example.json new file mode 100644 index 0000000..e3e89e5 --- /dev/null +++ b/packages/agent-acp/router.example.json @@ -0,0 +1,11 @@ +{ + "defaultBackend": "claude", + "backends": { + "claude": { + "command": "claude-agent-acp" + }, + "codex": { + "command": "codex-acp" + } + } +} diff --git a/packages/agent-acp/src/acp-agent.ts b/packages/agent-acp/src/acp-agent.ts index 04db212..931f004 100644 --- a/packages/agent-acp/src/acp-agent.ts +++ b/packages/agent-acp/src/acp-agent.ts @@ -5,76 +5,233 @@ import type { AcpAgentOptions } from "./types.js"; import { AcpConnection } from "./acp-connection.js"; import { convertRequestToContentBlocks } from "./content-converter.js"; import { ResponseCollector } from "./response-collector.js"; +import { RouterStateStore } from "./router-state.js"; function log(msg: string) { console.log(`[acp] ${msg}`); } +type RouterCommand = + | { kind: "status" } + | { kind: "switch"; backend: string; prompt?: string }; + /** * Agent adapter that bridges ACP (Agent Client Protocol) agents * to the weixin-agent-sdk Agent interface. */ export class AcpAgent implements Agent { - private connection: AcpConnection; + private singleConnection: AcpConnection | null = null; + private connections = new Map(); private sessions = new Map(); private options: AcpAgentOptions; + private routerState: RouterStateStore | null = null; + private backendNames: string[]; constructor(options: AcpAgentOptions) { this.options = options; - this.connection = new AcpConnection(options); + this.backendNames = this.options.router + ? Object.keys(this.options.router.backends) + : ["default"]; + if (this.options.router) { + this.routerState = new RouterStateStore(this.options.router.stateFile); + } } async chat(request: ChatRequest): Promise { - const conn = await this.connection.ensureReady(); + const routed = this.routeRequest(request); + if (routed.reply) { + return { text: routed.reply }; + } + + const conn = await this.getConnection(routed.backend).ensureReady(); // Get or create an ACP session for this conversation - const sessionId = await this.getOrCreateSession(request.conversationId, conn); + const sessionId = await this.getOrCreateSession(routed.backend, request.conversationId, conn); // Convert the ChatRequest to ACP ContentBlock[] - const blocks = await convertRequestToContentBlocks(request); + const blocks = await convertRequestToContentBlocks(routed.request); if (blocks.length === 0) { return { text: "" }; } // Register a collector, send the prompt, then gather the response - const preview = request.text?.slice(0, 50) || (request.media ? `[${request.media.type}]` : ""); - log(`prompt: "${preview}" (session=${sessionId})`); + const preview = routed.request.text?.slice(0, 50) || (routed.request.media ? `[${routed.request.media.type}]` : ""); + log(`prompt: "${preview}" (backend=${routed.backend}, session=${sessionId})`); const collector = new ResponseCollector(); - this.connection.registerCollector(sessionId, collector); + const connection = this.getConnection(routed.backend); + connection.registerCollector(sessionId, collector); try { await conn.prompt({ sessionId, prompt: blocks }); } finally { - this.connection.unregisterCollector(sessionId); + connection.unregisterCollector(sessionId); } const response = await collector.toResponse(); - log(`response: ${response.text?.slice(0, 80) ?? "[no text]"}${response.media ? " +media" : ""}`); + log(`response: ${response.text?.slice(0, 80) ?? "[no text]"}${response.media ? " +media" : ""} (backend=${routed.backend})`); return response; } private async getOrCreateSession( + backend: string, conversationId: string, conn: Awaited>, ): Promise { - const existing = this.sessions.get(conversationId); + const sessionKey = `${backend}:${conversationId}`; + const existing = this.sessions.get(sessionKey); if (existing) return existing; - log(`creating new session for conversation=${conversationId}`); + log(`creating new session for conversation=${conversationId} backend=${backend}`); const res = await conn.newSession({ cwd: this.options.cwd ?? process.cwd(), mcpServers: [], }); log(`session created: ${res.sessionId}`); - this.sessions.set(conversationId, res.sessionId); + this.sessions.set(sessionKey, res.sessionId); return res.sessionId; } + private getConnection(backend: string): AcpConnection { + if (!this.options.router) { + if (!this.singleConnection) { + this.singleConnection = new AcpConnection(this.options); + } + return this.singleConnection; + } + + const existing = this.connections.get(backend); + if (existing) return existing; + + const spec = this.options.router.backends[backend]; + if (!spec) { + throw new Error(`unknown backend: ${backend}`); + } + const conn = new AcpConnection({ + ...spec, + promptTimeoutMs: spec.promptTimeoutMs ?? this.options.promptTimeoutMs, + }); + this.connections.set(backend, conn); + return conn; + } + + private routeRequest(request: ChatRequest): { + backend: string; + request: ChatRequest; + reply?: string; + } { + if (!this.options.router) { + return { backend: "default", request }; + } + + const command = this.parseRouterCommand(request.text); + if (command?.kind === "status") { + return { + backend: this.getDefaultBackend(request.conversationId), + request, + reply: this.buildStatusMessage(request.conversationId), + }; + } + + if (command?.kind === "switch") { + const backend = this.requireBackend(command.backend); + this.routerState?.setBackend(request.conversationId, backend); + + if (!command.prompt && !request.media) { + return { + backend, + request, + reply: `已切换到 ${backend}。\n之后未加前缀的消息都会走 ${backend}。\n可发送 /mode 查看当前状态。`, + }; + } + + return { + backend, + request: { + ...request, + text: command.prompt ?? request.text, + }, + }; + } + + const backend = this.getDefaultBackend(request.conversationId); + return { backend, request }; + } + + private parseRouterCommand(text: string): RouterCommand | null { + const trimmed = text.trim(); + if (!trimmed.startsWith("/")) return null; + const router = this.options.router; + if (!router) return null; + + const [rawCommand, ...restParts] = trimmed.split(/\s+/); + const command = rawCommand.toLowerCase(); + const rest = restParts.join(" ").trim(); + + if (command === "/mode") { + if (!rest) return { kind: "status" }; + return { kind: "switch", backend: rest.toLowerCase() }; + } + + if (command === "/backends") { + return { kind: "status" }; + } + + const backend = command.slice(1); + if (router.backends[backend]) { + return { kind: "switch", backend, prompt: rest || undefined }; + } + + return null; + } + + private requireBackend(backend: string): string { + if (!this.options.router?.backends[backend]) { + throw new Error(`unknown backend "${backend}", available: ${this.backendNames.join(", ")}`); + } + return backend; + } + + private getDefaultBackend(conversationId: string): string { + if (!this.options.router) return "default"; + const routed = this.routerState?.getBackend(conversationId); + if (routed && this.options.router.backends[routed]) { + return routed; + } + return this.requireBackend(this.options.router.defaultBackend); + } + + private buildStatusMessage(conversationId: string): string { + if (!this.options.router) { + return "当前为单后端模式。"; + } + const current = this.getDefaultBackend(conversationId); + const backends = this.backendNames.map((name) => `- ${name}`).join("\n"); + return [ + `当前默认后端:${current}`, + "", + "可用后端:", + backends, + "", + "用法:", + "- /claude", + "- /codex", + "- /claude 你的问题", + "- /codex 你的问题", + "- /mode claude", + "- /mode codex", + ].join("\n"); + } + /** * Kill the ACP subprocess and clean up all sessions. */ dispose(): void { this.sessions.clear(); - this.connection.dispose(); + this.singleConnection?.dispose(); + this.singleConnection = null; + for (const conn of this.connections.values()) { + conn.dispose(); + } + this.connections.clear(); } } diff --git a/packages/agent-acp/src/router-state.ts b/packages/agent-acp/src/router-state.ts new file mode 100644 index 0000000..12df58a --- /dev/null +++ b/packages/agent-acp/src/router-state.ts @@ -0,0 +1,57 @@ +import fs from "node:fs"; +import os from "node:os"; +import path from "node:path"; + +type RouterState = { + conversations: Record; +}; + +function defaultState(): RouterState { + return { conversations: {} }; +} + +function resolveDefaultStatePath(): string { + return path.join(os.homedir(), ".openclaw", "openclaw-weixin", "acp-router-state.json"); +} + +export class RouterStateStore { + private readonly filePath: string; + + constructor(filePath?: string) { + this.filePath = filePath?.trim() || resolveDefaultStatePath(); + } + + getBackend(conversationId: string): string | undefined { + const state = this.readState(); + const backend = state.conversations[conversationId]; + return backend?.trim() || undefined; + } + + setBackend(conversationId: string, backend: string): void { + const state = this.readState(); + state.conversations[conversationId] = backend; + this.writeState(state); + } + + private readState(): RouterState { + try { + if (!fs.existsSync(this.filePath)) { + return defaultState(); + } + const raw = fs.readFileSync(this.filePath, "utf8"); + const parsed = JSON.parse(raw) as Partial; + return { + conversations: parsed.conversations ?? {}, + }; + } catch { + return defaultState(); + } + } + + private writeState(state: RouterState): void { + fs.mkdirSync(path.dirname(this.filePath), { recursive: true }); + const tmp = `${this.filePath}.tmp`; + fs.writeFileSync(tmp, JSON.stringify(state, null, 2) + "\n", "utf8"); + fs.renameSync(tmp, this.filePath); + } +} diff --git a/packages/agent-acp/src/types.ts b/packages/agent-acp/src/types.ts index 0038532..826ac6d 100644 --- a/packages/agent-acp/src/types.ts +++ b/packages/agent-acp/src/types.ts @@ -1,4 +1,4 @@ -export type AcpAgentOptions = { +export type AcpBackendSpec = { /** Command to launch the ACP agent, e.g. "npx" */ command: string; /** Command arguments, e.g. ["@zed-industries/codex-acp"] */ @@ -10,3 +10,17 @@ export type AcpAgentOptions = { /** Prompt timeout in milliseconds (default: 120_000) */ promptTimeoutMs?: number; }; + +export type AcpRouterConfig = { + /** Default backend name used when no per-conversation override exists. */ + defaultBackend: string; + /** Available ACP backends keyed by user-facing name, e.g. "claude" or "codex". */ + backends: Record; + /** Optional persistent state path for per-conversation backend selection. */ + stateFile?: string; +}; + +export type AcpAgentOptions = AcpBackendSpec & { + /** Optional router mode for switching backends from WeChat commands. */ + router?: AcpRouterConfig; +};