@rezti/dsh-rez-wechat 0.1.6 → 0.1.7
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 +9 -2
- package/lib/channel.d.ts +1 -0
- package/lib/channel.js +15 -4
- package/lib/index.js +1 -1
- package/lib/web-shim-cli.d.ts +2 -0
- package/lib/web-shim-cli.js +22 -0
- package/lib/web-shim.d.ts +65 -0
- package/lib/web-shim.js +433 -0
- package/package.json +1 -1
package/README.md
CHANGED
|
@@ -4,9 +4,16 @@
|
|
|
4
4
|
|
|
5
5
|
| 能力 | 做法 |
|
|
6
6
|
|---|---|
|
|
7
|
-
| 个人微信(QClaw / ClawBot) | 打开 dsh Web → Rez 面板 → **微信** →
|
|
7
|
+
| 个人微信(QClaw / ClawBot) | 打开 dsh Web → Rez 面板 → **微信** → 扫码。之后直接在微信里说话。消息进左侧 **微信** 文件夹的同一会话,模型跟网页默认。 |
|
|
8
8
|
| 企业微信发到群 | `wecom-mcp`(`mcp__wechat__*`)+ `REZ_WECHAT_WEBHOOK` |
|
|
9
9
|
|
|
10
|
-
个人微信复用现成 `dsh-wechat-bridge`(腾讯官方 iLink),不自写 WebSocket,也不用员工再跑 `npx weixin-mcp login
|
|
10
|
+
个人微信复用现成 `dsh-wechat-bridge`(腾讯官方 iLink),不自写 WebSocket,也不用员工再跑 `npx weixin-mcp login`。桥接到正在跑的 `dsh web`(不是 `dsh --profile headless`),所以:
|
|
11
|
+
|
|
12
|
+
- 左侧工作区分组是 **微信**,不是 Ungrouped
|
|
13
|
+
- 连续消息延续同一会话,不会一条一个窗口
|
|
14
|
+
- 模型跟网页默认(在网页里用 Kimi Code,微信也会用)
|
|
15
|
+
- 能用 web profile 里已经挂上的 Odoo 等 MCP
|
|
16
|
+
|
|
17
|
+
网页端必须开着。微信里 `/new <名字>` 会新开一条网页会话。
|
|
11
18
|
|
|
12
19
|
这是 ClawBot 客服会话,不是用个人微信号管好友/群。
|
package/lib/channel.d.ts
CHANGED
package/lib/channel.js
CHANGED
|
@@ -5,9 +5,20 @@
|
|
|
5
5
|
import { spawn } from 'node:child_process';
|
|
6
6
|
import { existsSync } from 'node:fs';
|
|
7
7
|
import { homedir } from 'node:os';
|
|
8
|
-
import { join } from 'node:path';
|
|
8
|
+
import { dirname, join } from 'node:path';
|
|
9
|
+
import { fileURLToPath } from 'node:url';
|
|
10
|
+
import { weixinBridgeEnv, writeWeixinDshLauncher } from './web-shim.js';
|
|
9
11
|
const DATA_DIR = join(homedir(), '.dsh', 'dsh-rez-weixin');
|
|
10
12
|
const AUTH_FILE = join(DATA_DIR, 'weixin-auth.json');
|
|
13
|
+
const SHIM_SCRIPT = join(dirname(fileURLToPath(import.meta.url)), 'web-shim-cli.js');
|
|
14
|
+
export function weixinRunEnv(env = process.env) {
|
|
15
|
+
const launcher = writeWeixinDshLauncher({
|
|
16
|
+
dataDir: DATA_DIR,
|
|
17
|
+
shimScript: SHIM_SCRIPT,
|
|
18
|
+
nodePath: process.execPath,
|
|
19
|
+
});
|
|
20
|
+
return weixinBridgeEnv({ env, dataDir: DATA_DIR, launcherPath: launcher, shimScript: SHIM_SCRIPT });
|
|
21
|
+
}
|
|
11
22
|
function extractQrContent(chunk) {
|
|
12
23
|
const labeled = chunk.match(/二维码链接[^\n]*\n\s*(\S+)/);
|
|
13
24
|
if (labeled?.[1] !== undefined && labeled[1].length > 8)
|
|
@@ -94,7 +105,7 @@ export class WeixinChannel {
|
|
|
94
105
|
this.loginChild = undefined;
|
|
95
106
|
if (code === 0 && existsSync(AUTH_FILE)) {
|
|
96
107
|
this.phase = 'listening';
|
|
97
|
-
this.hint = '
|
|
108
|
+
this.hint = '已登录。微信消息会进网页左侧「微信」文件夹,沿用网页默认模型。';
|
|
98
109
|
this.startRun();
|
|
99
110
|
return;
|
|
100
111
|
}
|
|
@@ -139,9 +150,9 @@ export class WeixinChannel {
|
|
|
139
150
|
if (this.runChild !== undefined && this.runChild.exitCode === null)
|
|
140
151
|
return;
|
|
141
152
|
this.phase = 'listening';
|
|
142
|
-
this.hint = '
|
|
153
|
+
this.hint = '微信通道已在后台听着。消息会进网页「微信」文件夹的同一会话,模型跟网页默认(例如 Kimi)。';
|
|
143
154
|
const child = spawn('npx', ['-y', 'dsh-wechat-bridge', 'run', '--data-dir', DATA_DIR], {
|
|
144
|
-
env:
|
|
155
|
+
env: weixinRunEnv(),
|
|
145
156
|
stdio: ['ignore', 'pipe', 'pipe'],
|
|
146
157
|
});
|
|
147
158
|
this.runChild = child;
|
package/lib/index.js
CHANGED
|
@@ -4,7 +4,7 @@ export const name = 'rez-wechat';
|
|
|
4
4
|
export const inject = ['systemPrompt', 'webServer', 'rezSso'];
|
|
5
5
|
const GUIDANCE = [
|
|
6
6
|
'企业微信群发送走官方 dsh-mcp-client + wecom-mcp(mcp__wechat__*),密钥 REZ_WECHAT_WEBHOOK。未配置时不拉起 wecom-mcp。Webhook 在 设置 → 插件 → ReZ-TI 里填。',
|
|
7
|
-
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez
|
|
7
|
+
'个人微信是 QClaw 同款 ClawBot 通道:在 Rez 面板「微信」页扫码一次,然后直接在微信里说话。消息进入网页左侧「微信」文件夹并延续同一会话,模型跟网页默认(在网页里选了 Kimi 就会走 Kimi)。不要再跑 npx weixin-mcp login,也不要调用 mcp__weixin__*。网页端必须开着。',
|
|
8
8
|
'这是 ClawBot 客服会话,不是用个人微信号管好友/群。对外发消息必须先征得用户批准。',
|
|
9
9
|
].join('');
|
|
10
10
|
function isLoopbackRequest(request) {
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
/**
|
|
3
|
+
* Executable stand-in for `dsh`. dsh-wechat-bridge spawns:
|
|
4
|
+
* $DSH_BRIDGE_DSH --profile headless <task>
|
|
5
|
+
* stdout is the WeChat reply; logs go to stderr.
|
|
6
|
+
*/
|
|
7
|
+
import { parseHeadlessArgv, runHeadlessViaWeb } from './web-shim.js';
|
|
8
|
+
const parsed = parseHeadlessArgv(process.argv.slice(1));
|
|
9
|
+
if ('error' in parsed) {
|
|
10
|
+
process.stderr.write(`dsh-web-shim: ${parsed.error}\n`);
|
|
11
|
+
process.stdout.write('本机微信桥启动参数不对。请从 Rez 面板重新打开微信通道。\n');
|
|
12
|
+
process.exit(0);
|
|
13
|
+
}
|
|
14
|
+
try {
|
|
15
|
+
const reply = await runHeadlessViaWeb({ task: parsed.task, cwd: process.cwd() });
|
|
16
|
+
process.stdout.write(reply.endsWith('\n') ? reply : `${reply}\n`);
|
|
17
|
+
}
|
|
18
|
+
catch (error) {
|
|
19
|
+
const message = error instanceof Error ? error.message : String(error);
|
|
20
|
+
process.stderr.write(`dsh-web-shim: ${message}\n`);
|
|
21
|
+
process.stdout.write(`${message}\n`);
|
|
22
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pretend to be `dsh --profile headless <task>` so dsh-wechat-bridge stays
|
|
3
|
+
* unchanged, but actually drive the running `dsh web` over loopback RPC.
|
|
4
|
+
*
|
|
5
|
+
* Headless always mints a fresh Agent (DeepSeek default, Ungrouped cwd hash).
|
|
6
|
+
* Web RPC reuses one session under ~/.dsh/workspaces/微信 so the sidebar
|
|
7
|
+
* folder is named 微信, turns continue, and the model follows the web default
|
|
8
|
+
* (Kimi if that is what the user selected).
|
|
9
|
+
*/
|
|
10
|
+
export declare const WECHAT_WORKSPACE_NAME = "\u5FAE\u4FE1";
|
|
11
|
+
export declare const DEFAULT_WEB_URL = "http://127.0.0.1:3080";
|
|
12
|
+
export interface ModelRef {
|
|
13
|
+
provider?: string;
|
|
14
|
+
model: string;
|
|
15
|
+
}
|
|
16
|
+
export interface SessionStore {
|
|
17
|
+
sessions: Record<string, {
|
|
18
|
+
sessionId: string;
|
|
19
|
+
updatedAt: number;
|
|
20
|
+
}>;
|
|
21
|
+
}
|
|
22
|
+
export interface RpcFn {
|
|
23
|
+
(method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
24
|
+
}
|
|
25
|
+
export declare function parseHeadlessArgv(argv: string[]): {
|
|
26
|
+
task: string;
|
|
27
|
+
} | {
|
|
28
|
+
error: string;
|
|
29
|
+
};
|
|
30
|
+
export declare function isFreshBridgeTurn(task: string): boolean;
|
|
31
|
+
export declare function extractLatestUserText(task: string): string;
|
|
32
|
+
export declare function dshHomeDir(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
33
|
+
export declare function wechatWorkspaceDir(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
34
|
+
export declare function sessionStorePath(env?: NodeJS.ProcessEnv, home?: string): string;
|
|
35
|
+
export declare function webBaseUrl(env?: NodeJS.ProcessEnv): string;
|
|
36
|
+
export declare function loadSessionStore(path: string): SessionStore;
|
|
37
|
+
export declare function saveSessionStore(path: string, store: SessionStore): void;
|
|
38
|
+
export declare function unwrapRpc(body: unknown): unknown;
|
|
39
|
+
export declare function rpcErrorMessage(body: unknown): string | undefined;
|
|
40
|
+
export declare function extractSessionId(body: unknown): string | undefined;
|
|
41
|
+
export declare function historyEvents(body: unknown): unknown[];
|
|
42
|
+
export declare function lastAssistantText(events: unknown[]): string;
|
|
43
|
+
export declare function turnIsIdle(events: unknown[]): boolean;
|
|
44
|
+
export declare function pickWechatModel(models: unknown, env?: NodeJS.ProcessEnv): ModelRef | undefined;
|
|
45
|
+
export declare function selectModelPayloads(sessionId: string, ref: ModelRef): Array<Record<string, unknown>>;
|
|
46
|
+
export declare function writeWeixinDshLauncher(opts: {
|
|
47
|
+
dataDir: string;
|
|
48
|
+
shimScript: string;
|
|
49
|
+
nodePath: string;
|
|
50
|
+
platform?: NodeJS.Platform;
|
|
51
|
+
}): string;
|
|
52
|
+
export declare function weixinBridgeEnv(opts: {
|
|
53
|
+
env?: NodeJS.ProcessEnv;
|
|
54
|
+
dataDir: string;
|
|
55
|
+
launcherPath: string;
|
|
56
|
+
shimScript: string;
|
|
57
|
+
}): NodeJS.ProcessEnv;
|
|
58
|
+
export declare function postRpc(baseUrl: string, method: string, params: Record<string, unknown>): Promise<unknown>;
|
|
59
|
+
export declare function runHeadlessViaWeb(opts: {
|
|
60
|
+
task: string;
|
|
61
|
+
cwd?: string;
|
|
62
|
+
env?: NodeJS.ProcessEnv;
|
|
63
|
+
rpc?: RpcFn;
|
|
64
|
+
timeoutMs?: number;
|
|
65
|
+
}): Promise<string>;
|
package/lib/web-shim.js
ADDED
|
@@ -0,0 +1,433 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Pretend to be `dsh --profile headless <task>` so dsh-wechat-bridge stays
|
|
3
|
+
* unchanged, but actually drive the running `dsh web` over loopback RPC.
|
|
4
|
+
*
|
|
5
|
+
* Headless always mints a fresh Agent (DeepSeek default, Ungrouped cwd hash).
|
|
6
|
+
* Web RPC reuses one session under ~/.dsh/workspaces/微信 so the sidebar
|
|
7
|
+
* folder is named 微信, turns continue, and the model follows the web default
|
|
8
|
+
* (Kimi if that is what the user selected).
|
|
9
|
+
*/
|
|
10
|
+
import { chmodSync, mkdirSync, readFileSync, writeFileSync } from 'node:fs';
|
|
11
|
+
import { homedir } from 'node:os';
|
|
12
|
+
import { basename, dirname, join } from 'node:path';
|
|
13
|
+
export const WECHAT_WORKSPACE_NAME = '微信';
|
|
14
|
+
export const DEFAULT_WEB_URL = 'http://127.0.0.1:3080';
|
|
15
|
+
export function parseHeadlessArgv(argv) {
|
|
16
|
+
const args = argv[0]?.endsWith('node') || argv[0]?.includes('node.exe') ? argv.slice(1) : argv;
|
|
17
|
+
const start = args[0]?.endsWith('.js') || args[0]?.endsWith('.mjs') || args[0]?.endsWith('.cjs') ? 1 : 0;
|
|
18
|
+
const rest = args.slice(start).filter(item => item !== '--');
|
|
19
|
+
const profileAt = rest.findIndex((item, index) => item === '--profile' && rest[index + 1] === 'headless');
|
|
20
|
+
const taskParts = profileAt >= 0 ? rest.slice(profileAt + 2) : rest.filter(item => item !== '--profile' && item !== 'headless');
|
|
21
|
+
const task = taskParts.join(' ').trim();
|
|
22
|
+
if (task.length === 0)
|
|
23
|
+
return { error: 'missing headless task' };
|
|
24
|
+
return { task };
|
|
25
|
+
}
|
|
26
|
+
export function isFreshBridgeTurn(task) {
|
|
27
|
+
return !task.includes('【桥接上下文】');
|
|
28
|
+
}
|
|
29
|
+
export function extractLatestUserText(task) {
|
|
30
|
+
if (!task.includes('【桥接上下文】'))
|
|
31
|
+
return task.trim();
|
|
32
|
+
let last = '';
|
|
33
|
+
for (const line of task.split('\n')) {
|
|
34
|
+
if (line.startsWith('[用户] '))
|
|
35
|
+
last = line.slice('[用户] '.length);
|
|
36
|
+
}
|
|
37
|
+
return last.trim() || task.trim();
|
|
38
|
+
}
|
|
39
|
+
export function dshHomeDir(env = process.env, home = homedir()) {
|
|
40
|
+
return env.DSH_HOME !== undefined && env.DSH_HOME !== '' ? env.DSH_HOME : join(home, '.dsh');
|
|
41
|
+
}
|
|
42
|
+
export function wechatWorkspaceDir(env = process.env, home = homedir()) {
|
|
43
|
+
if (env.REZ_WECHAT_WORKSPACE !== undefined && env.REZ_WECHAT_WORKSPACE !== '')
|
|
44
|
+
return env.REZ_WECHAT_WORKSPACE;
|
|
45
|
+
return join(dshHomeDir(env, home), 'workspaces', WECHAT_WORKSPACE_NAME);
|
|
46
|
+
}
|
|
47
|
+
export function sessionStorePath(env = process.env, home = homedir()) {
|
|
48
|
+
if (env.REZ_WECHAT_SESSION_STORE !== undefined && env.REZ_WECHAT_SESSION_STORE !== '')
|
|
49
|
+
return env.REZ_WECHAT_SESSION_STORE;
|
|
50
|
+
return join(dshHomeDir(env, home), 'dsh-rez-weixin', 'web-sessions.json');
|
|
51
|
+
}
|
|
52
|
+
export function webBaseUrl(env = process.env) {
|
|
53
|
+
return (env.REZ_DSH_WEB_URL ?? DEFAULT_WEB_URL).replace(/\/$/, '');
|
|
54
|
+
}
|
|
55
|
+
export function loadSessionStore(path) {
|
|
56
|
+
try {
|
|
57
|
+
const parsed = JSON.parse(readFileSync(path, 'utf8'));
|
|
58
|
+
if (typeof parsed === 'object' && parsed !== null && 'sessions' in parsed) {
|
|
59
|
+
const sessions = parsed.sessions;
|
|
60
|
+
if (typeof sessions === 'object' && sessions !== null)
|
|
61
|
+
return { sessions };
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
catch {
|
|
65
|
+
// missing or corrupt store → start empty
|
|
66
|
+
}
|
|
67
|
+
return { sessions: {} };
|
|
68
|
+
}
|
|
69
|
+
export function saveSessionStore(path, store) {
|
|
70
|
+
mkdirSync(dirname(path), { recursive: true });
|
|
71
|
+
writeFileSync(path, JSON.stringify(store, null, 2), 'utf8');
|
|
72
|
+
}
|
|
73
|
+
export function unwrapRpc(body) {
|
|
74
|
+
if (typeof body !== 'object' || body === null)
|
|
75
|
+
return body;
|
|
76
|
+
const rec = body;
|
|
77
|
+
if (rec.error !== undefined)
|
|
78
|
+
return rec;
|
|
79
|
+
if (typeof rec.result === 'object' && rec.result !== null)
|
|
80
|
+
return rec.result;
|
|
81
|
+
if (typeof rec.data === 'object' && rec.data !== null && rec.sessionId === undefined)
|
|
82
|
+
return rec.data;
|
|
83
|
+
return body;
|
|
84
|
+
}
|
|
85
|
+
export function rpcErrorMessage(body) {
|
|
86
|
+
if (typeof body !== 'object' || body === null)
|
|
87
|
+
return undefined;
|
|
88
|
+
const rec = body;
|
|
89
|
+
if (typeof rec.error === 'string')
|
|
90
|
+
return rec.error;
|
|
91
|
+
if (typeof rec.error === 'object' && rec.error !== null) {
|
|
92
|
+
const err = rec.error;
|
|
93
|
+
if (typeof err.message === 'string')
|
|
94
|
+
return err.message;
|
|
95
|
+
if (typeof err.code === 'string')
|
|
96
|
+
return err.code;
|
|
97
|
+
}
|
|
98
|
+
return undefined;
|
|
99
|
+
}
|
|
100
|
+
export function extractSessionId(body) {
|
|
101
|
+
if (typeof body === 'string' && body.startsWith('session'))
|
|
102
|
+
return body;
|
|
103
|
+
if (typeof body !== 'object' || body === null)
|
|
104
|
+
return undefined;
|
|
105
|
+
const rec = body;
|
|
106
|
+
if (typeof rec.sessionId === 'string')
|
|
107
|
+
return rec.sessionId;
|
|
108
|
+
if (typeof rec.id === 'string')
|
|
109
|
+
return rec.id;
|
|
110
|
+
if (typeof rec.session === 'object' && rec.session !== null) {
|
|
111
|
+
const nested = rec.session;
|
|
112
|
+
if (typeof nested.id === 'string')
|
|
113
|
+
return nested.id;
|
|
114
|
+
if (typeof nested.sessionId === 'string')
|
|
115
|
+
return nested.sessionId;
|
|
116
|
+
}
|
|
117
|
+
return undefined;
|
|
118
|
+
}
|
|
119
|
+
function flattenText(content) {
|
|
120
|
+
if (typeof content === 'string')
|
|
121
|
+
return content;
|
|
122
|
+
if (!Array.isArray(content))
|
|
123
|
+
return '';
|
|
124
|
+
return content
|
|
125
|
+
.map(block => {
|
|
126
|
+
if (typeof block === 'string')
|
|
127
|
+
return block;
|
|
128
|
+
if (typeof block !== 'object' || block === null)
|
|
129
|
+
return '';
|
|
130
|
+
const rec = block;
|
|
131
|
+
if (typeof rec.text === 'string')
|
|
132
|
+
return rec.text;
|
|
133
|
+
return '';
|
|
134
|
+
})
|
|
135
|
+
.join('');
|
|
136
|
+
}
|
|
137
|
+
export function historyEvents(body) {
|
|
138
|
+
const unwrapped = unwrapRpc(body);
|
|
139
|
+
if (Array.isArray(unwrapped))
|
|
140
|
+
return unwrapped;
|
|
141
|
+
if (typeof unwrapped !== 'object' || unwrapped === null)
|
|
142
|
+
return [];
|
|
143
|
+
const rec = unwrapped;
|
|
144
|
+
if (Array.isArray(rec.events))
|
|
145
|
+
return rec.events;
|
|
146
|
+
if (Array.isArray(rec.messages))
|
|
147
|
+
return rec.messages;
|
|
148
|
+
if (Array.isArray(rec.items))
|
|
149
|
+
return rec.items;
|
|
150
|
+
if (Array.isArray(rec.log))
|
|
151
|
+
return rec.log;
|
|
152
|
+
return [];
|
|
153
|
+
}
|
|
154
|
+
export function lastAssistantText(events) {
|
|
155
|
+
let text = '';
|
|
156
|
+
for (const event of events) {
|
|
157
|
+
if (typeof event !== 'object' || event === null)
|
|
158
|
+
continue;
|
|
159
|
+
const rec = event;
|
|
160
|
+
const type = typeof rec.type === 'string' ? rec.type : '';
|
|
161
|
+
const role = typeof rec.role === 'string' ? rec.role : '';
|
|
162
|
+
const isAssistant = type === 'assistant/message' || role === 'assistant';
|
|
163
|
+
if (!isAssistant)
|
|
164
|
+
continue;
|
|
165
|
+
const data = typeof rec.data === 'object' && rec.data !== null ? rec.data : rec;
|
|
166
|
+
const message = typeof data.message === 'object' && data.message !== null ? data.message : data;
|
|
167
|
+
const chunk = flattenText(message.content ?? data.content ?? rec.content) || (typeof rec.text === 'string' ? rec.text : '');
|
|
168
|
+
if (chunk.trim() !== '')
|
|
169
|
+
text = chunk;
|
|
170
|
+
}
|
|
171
|
+
return text.trim();
|
|
172
|
+
}
|
|
173
|
+
export function turnIsIdle(events) {
|
|
174
|
+
let open = 0;
|
|
175
|
+
for (const event of events) {
|
|
176
|
+
if (typeof event !== 'object' || event === null)
|
|
177
|
+
continue;
|
|
178
|
+
const type = event.type;
|
|
179
|
+
if (type === 'turn/start')
|
|
180
|
+
open += 1;
|
|
181
|
+
if (type === 'turn/end')
|
|
182
|
+
open = Math.max(0, open - 1);
|
|
183
|
+
}
|
|
184
|
+
return open === 0;
|
|
185
|
+
}
|
|
186
|
+
function looksLikeKimi(value) {
|
|
187
|
+
return /kimi|moonshot/i.test(value);
|
|
188
|
+
}
|
|
189
|
+
function collectModelRows(input, out, depth = 0, inheritedProvider) {
|
|
190
|
+
if (depth > 8 || input === null || input === undefined)
|
|
191
|
+
return;
|
|
192
|
+
if (Array.isArray(input)) {
|
|
193
|
+
for (const item of input)
|
|
194
|
+
collectModelRows(item, out, depth + 1, inheritedProvider);
|
|
195
|
+
return;
|
|
196
|
+
}
|
|
197
|
+
if (typeof input !== 'object')
|
|
198
|
+
return;
|
|
199
|
+
const rec = input;
|
|
200
|
+
const provider = typeof rec.provider === 'string' ? rec.provider
|
|
201
|
+
: typeof rec.providerID === 'string' ? rec.providerID
|
|
202
|
+
: typeof rec.adapter === 'string' ? rec.adapter
|
|
203
|
+
: inheritedProvider;
|
|
204
|
+
const model = typeof rec.model === 'string' ? rec.model
|
|
205
|
+
: typeof rec.id === 'string' && !rec.id.startsWith('session') ? rec.id
|
|
206
|
+
: undefined;
|
|
207
|
+
if (model !== undefined)
|
|
208
|
+
out.push(provider !== undefined ? { provider, model } : { model });
|
|
209
|
+
for (const value of Object.values(rec)) {
|
|
210
|
+
if (typeof value === 'object')
|
|
211
|
+
collectModelRows(value, out, depth + 1, provider);
|
|
212
|
+
}
|
|
213
|
+
}
|
|
214
|
+
function currentModelRef(models) {
|
|
215
|
+
if (typeof models !== 'object' || models === null)
|
|
216
|
+
return undefined;
|
|
217
|
+
const rec = models;
|
|
218
|
+
const current = rec.current ?? rec.selection;
|
|
219
|
+
if (typeof current === 'string' && current !== '')
|
|
220
|
+
return { model: current };
|
|
221
|
+
if (typeof current === 'object' && current !== null) {
|
|
222
|
+
const row = current;
|
|
223
|
+
const model = typeof row.model === 'string' ? row.model : typeof row.id === 'string' ? row.id : undefined;
|
|
224
|
+
if (model === undefined)
|
|
225
|
+
return undefined;
|
|
226
|
+
const provider = typeof row.provider === 'string' ? row.provider
|
|
227
|
+
: typeof row.providerID === 'string' ? row.providerID
|
|
228
|
+
: undefined;
|
|
229
|
+
return provider !== undefined ? { provider, model } : { model };
|
|
230
|
+
}
|
|
231
|
+
return undefined;
|
|
232
|
+
}
|
|
233
|
+
export function pickWechatModel(models, env = process.env) {
|
|
234
|
+
const forced = env.REZ_WECHAT_MODEL?.trim();
|
|
235
|
+
if (forced !== undefined && forced !== '') {
|
|
236
|
+
const provider = env.REZ_WECHAT_PROVIDER?.trim();
|
|
237
|
+
return provider !== undefined && provider !== '' ? { provider, model: forced } : { model: forced };
|
|
238
|
+
}
|
|
239
|
+
if (env.REZ_WECHAT_PREFER_KIMI === '0')
|
|
240
|
+
return undefined;
|
|
241
|
+
const current = currentModelRef(models);
|
|
242
|
+
if (current !== undefined && looksLikeKimi(`${current.provider ?? ''} ${current.model}`))
|
|
243
|
+
return undefined;
|
|
244
|
+
const rows = [];
|
|
245
|
+
collectModelRows(models, rows);
|
|
246
|
+
const kimi = rows.filter(row => looksLikeKimi(`${row.provider ?? ''} ${row.model}`));
|
|
247
|
+
if (kimi.length === 0)
|
|
248
|
+
return undefined;
|
|
249
|
+
const coding = kimi.find(row => /coding|kimi-code|kimi\.code/i.test(row.model));
|
|
250
|
+
return coding ?? kimi[0];
|
|
251
|
+
}
|
|
252
|
+
export function selectModelPayloads(sessionId, ref) {
|
|
253
|
+
const payloads = [
|
|
254
|
+
{ sessionId, model: ref.model, ...(ref.provider !== undefined ? { provider: ref.provider } : {}) },
|
|
255
|
+
{ sessionId, selection: ref },
|
|
256
|
+
{ sessionId, model: ref },
|
|
257
|
+
];
|
|
258
|
+
return payloads;
|
|
259
|
+
}
|
|
260
|
+
export function writeWeixinDshLauncher(opts) {
|
|
261
|
+
mkdirSync(opts.dataDir, { recursive: true });
|
|
262
|
+
const platform = opts.platform ?? process.platform;
|
|
263
|
+
if (platform === 'win32') {
|
|
264
|
+
const path = join(opts.dataDir, 'dsh-web-shim.cmd');
|
|
265
|
+
writeFileSync(path, `@echo off\r\n"${opts.nodePath}" "${opts.shimScript}" %*\r\n`);
|
|
266
|
+
return path;
|
|
267
|
+
}
|
|
268
|
+
const path = join(opts.dataDir, 'dsh-web-shim');
|
|
269
|
+
writeFileSync(path, `#!/bin/sh\nexec "${opts.nodePath}" "${opts.shimScript}" "$@"\n`, { mode: 0o755 });
|
|
270
|
+
chmodSync(path, 0o755);
|
|
271
|
+
return path;
|
|
272
|
+
}
|
|
273
|
+
export function weixinBridgeEnv(opts) {
|
|
274
|
+
const env = { ...(opts.env ?? process.env) };
|
|
275
|
+
env.DSH_BRIDGE_DSH = opts.launcherPath;
|
|
276
|
+
env.REZ_WECHAT_SESSION_STORE = env.REZ_WECHAT_SESSION_STORE ?? join(opts.dataDir, 'web-sessions.json');
|
|
277
|
+
env.REZ_WECHAT_WORKSPACE = env.REZ_WECHAT_WORKSPACE ?? wechatWorkspaceDir(env);
|
|
278
|
+
env.REZ_DSH_WEB_URL = env.REZ_DSH_WEB_URL ?? DEFAULT_WEB_URL;
|
|
279
|
+
return env;
|
|
280
|
+
}
|
|
281
|
+
async function sleep(ms) {
|
|
282
|
+
await new Promise(resolve => setTimeout(resolve, ms));
|
|
283
|
+
}
|
|
284
|
+
export async function postRpc(baseUrl, method, params) {
|
|
285
|
+
const url = `${baseUrl.replace(/\/$/, '')}/api/${method}`;
|
|
286
|
+
let response;
|
|
287
|
+
try {
|
|
288
|
+
response = await fetch(url, {
|
|
289
|
+
method: 'POST',
|
|
290
|
+
headers: { 'content-type': 'application/json' },
|
|
291
|
+
body: JSON.stringify(params),
|
|
292
|
+
});
|
|
293
|
+
}
|
|
294
|
+
catch (error) {
|
|
295
|
+
const why = error instanceof Error ? error.message : String(error);
|
|
296
|
+
throw new Error(`连不上本机 dsh web(${baseUrl}):${why}。请保持网页端开着。`);
|
|
297
|
+
}
|
|
298
|
+
const raw = await response.text();
|
|
299
|
+
let parsed;
|
|
300
|
+
try {
|
|
301
|
+
parsed = raw === '' ? {} : JSON.parse(raw);
|
|
302
|
+
}
|
|
303
|
+
catch {
|
|
304
|
+
throw new Error(`dsh web ${method} 返回非 JSON (${response.status}): ${raw.slice(0, 240)}`);
|
|
305
|
+
}
|
|
306
|
+
const body = unwrapRpc(parsed);
|
|
307
|
+
const err = rpcErrorMessage(parsed) ?? rpcErrorMessage(body);
|
|
308
|
+
if (!response.ok || err !== undefined) {
|
|
309
|
+
throw new Error(`dsh web ${method} 失败:${err ?? `HTTP ${response.status}`}`);
|
|
310
|
+
}
|
|
311
|
+
return body;
|
|
312
|
+
}
|
|
313
|
+
async function ensureWorkspace(rpc, cwd) {
|
|
314
|
+
mkdirSync(cwd, { recursive: true });
|
|
315
|
+
await rpc('workspace.create', { path: cwd, title: WECHAT_WORKSPACE_NAME });
|
|
316
|
+
}
|
|
317
|
+
async function createSession(rpc, cwd) {
|
|
318
|
+
const created = await rpc('session.create', { cwd });
|
|
319
|
+
const id = extractSessionId(created);
|
|
320
|
+
if (id === undefined)
|
|
321
|
+
throw new Error('session.create 没有返回 sessionId');
|
|
322
|
+
return id;
|
|
323
|
+
}
|
|
324
|
+
async function maybeSelectModel(rpc, sessionId, env) {
|
|
325
|
+
let models;
|
|
326
|
+
try {
|
|
327
|
+
models = await rpc('session.models', { sessionId });
|
|
328
|
+
}
|
|
329
|
+
catch {
|
|
330
|
+
models = undefined;
|
|
331
|
+
}
|
|
332
|
+
const ref = pickWechatModel(models ?? {}, env);
|
|
333
|
+
if (ref === undefined)
|
|
334
|
+
return;
|
|
335
|
+
for (const payload of selectModelPayloads(sessionId, ref)) {
|
|
336
|
+
try {
|
|
337
|
+
await rpc('session.selectModel', payload);
|
|
338
|
+
return;
|
|
339
|
+
}
|
|
340
|
+
catch {
|
|
341
|
+
// try the next payload shape
|
|
342
|
+
}
|
|
343
|
+
}
|
|
344
|
+
}
|
|
345
|
+
async function sessionAlive(rpc, sessionId) {
|
|
346
|
+
try {
|
|
347
|
+
await rpc('session.history', { sessionId, maxMessages: 1 });
|
|
348
|
+
return true;
|
|
349
|
+
}
|
|
350
|
+
catch {
|
|
351
|
+
try {
|
|
352
|
+
await rpc('session.get', { sessionId });
|
|
353
|
+
return true;
|
|
354
|
+
}
|
|
355
|
+
catch {
|
|
356
|
+
return false;
|
|
357
|
+
}
|
|
358
|
+
}
|
|
359
|
+
}
|
|
360
|
+
async function waitForAssistant(rpc, sessionId, beforeText, timeoutMs) {
|
|
361
|
+
const deadline = Date.now() + timeoutMs;
|
|
362
|
+
let last = '';
|
|
363
|
+
let idleOnce = false;
|
|
364
|
+
while (Date.now() < deadline) {
|
|
365
|
+
let events = [];
|
|
366
|
+
try {
|
|
367
|
+
events = historyEvents(await rpc('session.history', { sessionId, maxMessages: 40 }));
|
|
368
|
+
}
|
|
369
|
+
catch {
|
|
370
|
+
try {
|
|
371
|
+
events = historyEvents(await rpc('session.get', { sessionId }));
|
|
372
|
+
}
|
|
373
|
+
catch {
|
|
374
|
+
events = [];
|
|
375
|
+
}
|
|
376
|
+
}
|
|
377
|
+
const text = lastAssistantText(events);
|
|
378
|
+
if (text !== '')
|
|
379
|
+
last = text;
|
|
380
|
+
const idle = turnIsIdle(events);
|
|
381
|
+
if (idle)
|
|
382
|
+
idleOnce = true;
|
|
383
|
+
if (idle && text !== '' && text !== beforeText)
|
|
384
|
+
return text;
|
|
385
|
+
if (idleOnce && text !== '' && text !== beforeText)
|
|
386
|
+
return text;
|
|
387
|
+
await sleep(400);
|
|
388
|
+
}
|
|
389
|
+
if (last !== '' && last !== beforeText)
|
|
390
|
+
return last;
|
|
391
|
+
throw new Error('等待 dsh web 回复超时。请看网页左侧「微信」文件夹里的会话。');
|
|
392
|
+
}
|
|
393
|
+
export async function runHeadlessViaWeb(opts) {
|
|
394
|
+
const env = opts.env ?? process.env;
|
|
395
|
+
const text = extractLatestUserText(opts.task);
|
|
396
|
+
if (text.length === 0)
|
|
397
|
+
return '(空消息)';
|
|
398
|
+
const workspace = wechatWorkspaceDir(env);
|
|
399
|
+
const storeFile = sessionStorePath(env);
|
|
400
|
+
const key = basename(opts.cwd ?? process.cwd()) || 'default';
|
|
401
|
+
const rpc = opts.rpc ?? ((method, params) => postRpc(webBaseUrl(env), method, params));
|
|
402
|
+
const timeoutMs = opts.timeoutMs ?? Number(env.DSH_BRIDGE_TIMEOUT_MS ?? 10 * 60 * 1000);
|
|
403
|
+
await ensureWorkspace(rpc, workspace);
|
|
404
|
+
const store = loadSessionStore(storeFile);
|
|
405
|
+
let sessionId = store.sessions[key]?.sessionId;
|
|
406
|
+
const fresh = isFreshBridgeTurn(opts.task);
|
|
407
|
+
if (sessionId !== undefined && !fresh) {
|
|
408
|
+
const alive = await sessionAlive(rpc, sessionId);
|
|
409
|
+
if (!alive)
|
|
410
|
+
sessionId = undefined;
|
|
411
|
+
}
|
|
412
|
+
else {
|
|
413
|
+
sessionId = undefined;
|
|
414
|
+
}
|
|
415
|
+
if (sessionId === undefined) {
|
|
416
|
+
sessionId = await createSession(rpc, workspace);
|
|
417
|
+
await maybeSelectModel(rpc, sessionId, env);
|
|
418
|
+
}
|
|
419
|
+
store.sessions[key] = { sessionId, updatedAt: Date.now() };
|
|
420
|
+
saveSessionStore(storeFile, store);
|
|
421
|
+
let before = '';
|
|
422
|
+
try {
|
|
423
|
+
before = lastAssistantText(historyEvents(await rpc('session.history', { sessionId, maxMessages: 40 })));
|
|
424
|
+
}
|
|
425
|
+
catch {
|
|
426
|
+
before = '';
|
|
427
|
+
}
|
|
428
|
+
await rpc('session.prompt', {
|
|
429
|
+
sessionId,
|
|
430
|
+
content: [{ type: 'text', text }],
|
|
431
|
+
});
|
|
432
|
+
return await waitForAssistant(rpc, sessionId, before, timeoutMs);
|
|
433
|
+
}
|
package/package.json
CHANGED