qiwei-training.js 18 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475
  1. #!/usr/bin/env node
  2. 'use strict';
  3. // 培训版单文件入口:同一个可执行程序按子命令扮演工作台、监听 Runtime 和好友轮询 Worker。
  4. // 编译后 process.execPath 指向 exe 自身,因此包根、工作区根和前端目录都以 exe 所在目录为准。
  5. const fs = require('fs');
  6. const path = require('path');
  7. const NODE_HOSTS = new Set(['node', 'node.exe', 'bun', 'bun.exe']);
  8. function isSourceRun() {
  9. return NODE_HOSTS.has(path.basename(process.execPath).toLowerCase());
  10. }
  11. function resolveBaseDir() {
  12. return isSourceRun() ? path.resolve(__dirname, '..') : path.dirname(process.execPath);
  13. }
  14. function applyTrainingEnv() {
  15. const baseDir = resolveBaseDir();
  16. const compiled = !isSourceRun();
  17. // A compiled delivery must be self-contained. Never inherit a developer
  18. // machine's package root, otherwise login persistence can target the wrong
  19. // .env.local instead of the directory beside the executable.
  20. if (compiled) {
  21. process.env.QIWEI_PACKAGE_ROOT = baseDir;
  22. process.env.QIWEI_WORKSPACE_ROOT = baseDir;
  23. process.env.QIWEI_OUTPUTS_DIR = path.join(baseDir, 'outputs');
  24. process.env.CLAUDE_CODE_WORKDIR = baseDir;
  25. } else {
  26. process.env.QIWEI_PACKAGE_ROOT ||= baseDir;
  27. process.env.QIWEI_WORKSPACE_ROOT ||= baseDir;
  28. process.env.QIWEI_OUTPUTS_DIR ||= path.join(baseDir, 'outputs');
  29. process.env.CLAUDE_CODE_WORKDIR ||= baseDir;
  30. }
  31. process.env.QIWEI_AGENT_CONVERSATION_MODE ||= 'session';
  32. if (compiled) {
  33. process.env.QIWEI_RUNTIME_LAUNCHER ||= 'self';
  34. process.env.QIWEI_DASHBOARD_STATIC_DIR ||= path.join(baseDir, 'web');
  35. }
  36. const runtimeConfig = path.join(baseDir, 'qiwei.runtime.config.mjs');
  37. if (fs.existsSync(runtimeConfig)) process.env.QIWEI_RUNTIME_CONFIG ||= runtimeConfig;
  38. fs.mkdirSync(path.join(baseDir, 'outputs', 'runtime'), { recursive: true });
  39. try { process.chdir(baseDir); } catch {}
  40. hydrateEnvFromLocalFiles();
  41. process.env.QIWEI_LOGIN_FLOW_UI ||= 'true';
  42. return { baseDir, compiled };
  43. }
  44. // 把 exe 同级的 .env.local 灌进 process.env。页面里保存的 token、现场手填的凭据,
  45. // 重启后必须对所有只读 process.env 的模块可见,不能只给 credentials.js 看见。
  46. function hydrateEnvFromLocalFiles() {
  47. try {
  48. const { readEnvFiles } = require('../mcp/src/core/credentials');
  49. const fileEnv = readEnvFiles();
  50. for (const [key, value] of Object.entries(fileEnv)) {
  51. process.env[key] = String(value);
  52. }
  53. } catch {}
  54. }
  55. function parsePort(argv) {
  56. const index = argv.indexOf('--port');
  57. const raw = index >= 0 ? Number(argv[index + 1]) : Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
  58. if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error('端口无效,请使用 --port 4320 这类有效端口');
  59. return raw;
  60. }
  61. function openBrowser(url) {
  62. const { spawn } = require('child_process');
  63. // 部分 Windows 环境的 PATH 缺少 System32,用 ComSpec 定位 cmd.exe 而不是依赖 PATH 查找。
  64. const comspec = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
  65. const [command, args] = process.platform === 'win32'
  66. ? [comspec, ['/d', '/s', '/c', 'start', '', url]]
  67. : process.platform === 'darwin'
  68. ? ['open', [url]]
  69. : ['xdg-open', [url]];
  70. try {
  71. spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
  72. } catch (error) {
  73. process.stderr.write(`自动打开浏览器失败,请手动访问 ${url}(${error.message})\n`);
  74. }
  75. }
  76. function probeWorkbench(port, workspaceId, timeoutMs = 500) {
  77. const http = require('http');
  78. return new Promise(resolve => {
  79. const request = http.get({
  80. hostname: '127.0.0.1',
  81. port,
  82. path: '/api/health',
  83. headers: { accept: 'application/json' },
  84. }, response => {
  85. let body = '';
  86. response.setEncoding('utf8');
  87. response.on('data', chunk => {
  88. if (body.length < 64 * 1024) body += chunk;
  89. });
  90. response.on('end', () => {
  91. try {
  92. const payload = JSON.parse(body);
  93. resolve(
  94. response.statusCode === 200
  95. && payload?.status === 'ok'
  96. && Number(payload?.data?.port) === port
  97. && payload?.data?.workspaceId === workspaceId
  98. );
  99. } catch {
  100. resolve(false);
  101. }
  102. });
  103. });
  104. request.setTimeout(timeoutMs, () => request.destroy());
  105. request.on('error', () => resolve(false));
  106. });
  107. }
  108. function canListen(port) {
  109. const net = require('net');
  110. return new Promise(resolve => {
  111. const server = net.createServer();
  112. server.unref();
  113. server.once('error', () => resolve(false));
  114. server.listen(port, '127.0.0.1', () => {
  115. server.close(error => resolve(!error));
  116. });
  117. });
  118. }
  119. async function selectWorkbenchPort(requestedPort, allowFallback, workspaceId) {
  120. const candidates = allowFallback
  121. ? Array.from({ length: Math.min(10, 65536 - requestedPort) }, (_, index) => requestedPort + index)
  122. : [requestedPort];
  123. const probes = await Promise.all(
  124. candidates.map(async port => ({ port, running: await probeWorkbench(port, workspaceId) }))
  125. );
  126. const running = probes.find(item => item.running);
  127. if (running) return { port: running.port, reuse: true };
  128. for (const port of candidates) {
  129. if (await canListen(port)) return { port, reuse: false };
  130. }
  131. const message = allowFallback
  132. ? `端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭占用程序后重试`
  133. : `端口 ${requestedPort} 已被其他程序占用,请关闭占用程序后重试`;
  134. throw new Error(message);
  135. }
  136. function reuseRunningWorkbench(port, argv) {
  137. const url = `http://127.0.0.1:${port}/#status`;
  138. process.stdout.write(`[培训包] 工作台已在运行,正在打开:${url}\n`);
  139. if (!argv.includes('--no-open')) openBrowser(url);
  140. }
  141. function runtimeStatePath(baseDir) {
  142. return path.join(baseDir, 'outputs', 'runtime', 'qiwei-runtime.json');
  143. }
  144. function defaultFlowPort(dashboardPort) {
  145. // Keep the documented single-instance default (4320 -> 4310). When a second
  146. // package is forced onto 4321+, give it a matching flow port so its QR/verify
  147. // context cannot be served by an older package still listening on 4310.
  148. const offset = Math.max(0, Number(dashboardPort) - 4320);
  149. return Math.min(65535, 4310 + offset);
  150. }
  151. async function selectFlowPort(dashboardPort) {
  152. const configured = Number(String(process.env.QIWEI_FLOW_PORT || '').trim());
  153. const requestedPort = Number.isInteger(configured) && configured >= 1 && configured <= 65535
  154. ? configured
  155. : defaultFlowPort(dashboardPort);
  156. const candidates = Array.from(
  157. { length: Math.min(10, 65536 - requestedPort) },
  158. (_, index) => requestedPort + index,
  159. );
  160. for (const port of candidates) {
  161. if (await canListen(port)) return port;
  162. }
  163. throw new Error(`登录流程端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭旧工作台后重试`);
  164. }
  165. function isProcessAlive(pid) {
  166. const numeric = Number(pid);
  167. if (!Number.isInteger(numeric) || numeric <= 0) return false;
  168. try { process.kill(numeric, 0); return true; } catch { return false; }
  169. }
  170. function ensureCallbackRuntime(baseDir) {
  171. let state = {};
  172. try { state = JSON.parse(fs.readFileSync(runtimeStatePath(baseDir), 'utf8')); } catch {}
  173. if (['starting', 'running'].includes(state.status) && isProcessAlive(state.pid)) {
  174. process.stdout.write(`[培训包] 监听 Runtime 已在运行 (pid=${state.pid})\n`);
  175. return;
  176. }
  177. const { spawn } = require('child_process');
  178. const { callbackRuntimeLauncher } = require('../mcp/src/core/runtime-launcher');
  179. const launcher = callbackRuntimeLauncher(['start', '--no-dashboard']);
  180. const outFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stdout.log'), 'a');
  181. const errFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stderr.log'), 'a');
  182. try {
  183. const child = spawn(launcher.command, launcher.args, {
  184. cwd: baseDir,
  185. detached: true,
  186. stdio: ['ignore', outFd, errFd],
  187. windowsHide: true,
  188. env: { ...process.env },
  189. });
  190. child.on('error', error => process.stderr.write(`[培训包] 拉起监听 Runtime 失败:${error.message}\n`));
  191. child.unref();
  192. process.stdout.write(`[培训包] 已拉起监听 Runtime (pid=${child.pid})\n`);
  193. } finally {
  194. fs.closeSync(outFd);
  195. fs.closeSync(errFd);
  196. }
  197. }
  198. async function commandStart(argv, context) {
  199. const requestedPort = parsePort(argv);
  200. const portConfigured = argv.includes('--port') || Boolean(String(process.env.QIWEI_DASHBOARD_PORT || '').trim());
  201. const { workspaceIdentity } = require('../mcp/src/core/runtime-context');
  202. const workspaceId = workspaceIdentity(context.baseDir);
  203. const selected = await selectWorkbenchPort(requestedPort, !portConfigured, workspaceId);
  204. const port = selected.port;
  205. process.env.QIWEI_DASHBOARD_PORT = String(port);
  206. process.env.QIWEI_FLOW_PORT = String(await selectFlowPort(port));
  207. if (selected.reuse) {
  208. reuseRunningWorkbench(port, argv);
  209. return;
  210. }
  211. const { startServer } = require('../mcp/src/dashboard/server');
  212. try {
  213. await startServer(port);
  214. } catch (error) {
  215. // Two rapid double-clicks can both probe before the first server starts.
  216. // Recheck after EADDRINUSE so the second launch becomes an idempotent open.
  217. if (error?.code === 'EADDRINUSE' && await probeWorkbench(port, workspaceId)) {
  218. reuseRunningWorkbench(port, argv);
  219. return;
  220. }
  221. if (error?.code === 'EADDRINUSE') {
  222. throw new Error(`端口 ${port} 已被其他程序占用,请关闭占用程序后重试`);
  223. }
  224. throw error;
  225. }
  226. ensureCallbackRuntime(context.baseDir);
  227. // Version check is deliberately asynchronous and best-effort: CDN/network
  228. // outages must never delay login, listening, or the dashboard first paint.
  229. void require('../mcp/src/core/update-service').checkForUpdate({ root: context.baseDir, timeoutMs: 3500 }).catch(() => {});
  230. const url = `http://127.0.0.1:${port}/#status`;
  231. process.stdout.write([
  232. '',
  233. `企微培训工作台已启动:${url}`,
  234. `登录流程地址:http://127.0.0.1:${process.env.QIWEI_FLOW_PORT}/`,
  235. `数据目录:${path.join(context.baseDir, 'outputs')}`,
  236. '',
  237. '现场检查顺序:填写 Fmode token(若未自动检测到)→ 开通席位 → 企微扫码登录 → 账号在线 → 白名单 → 开启监听。',
  238. '关闭本窗口即停止工作台;监听 Runtime 需运行 stop 子命令结束。',
  239. '',
  240. ].join('\n'));
  241. if (!argv.includes('--no-open')) openBrowser(url);
  242. }
  243. async function commandRuntime(argv) {
  244. const { runCli } = await import('../runtime/callback-service/src/index.mjs');
  245. await runCli(argv.length ? argv : ['start']);
  246. }
  247. async function commandWorker(argv) {
  248. const target = argv[0] || 'friend';
  249. if (target !== 'friend') throw new Error(`未知 worker:${target}`);
  250. require('../scripts/friend-polling-worker.js');
  251. }
  252. async function commandDashboard(argv) {
  253. const port = parsePort(argv);
  254. process.env.QIWEI_DASHBOARD_PORT = String(port);
  255. const { startServer } = require('../mcp/src/dashboard/server');
  256. await startServer(port);
  257. process.stdout.write(`企微工作台已启动:http://127.0.0.1:${port}/#agent\n`);
  258. }
  259. async function commandUpdate(argv, context) {
  260. const updates = require('../mcp/src/core/update-service');
  261. const action = argv[0] || 'check';
  262. if (action === 'check') {
  263. const result = await updates.checkForUpdate({ root: context.baseDir });
  264. process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
  265. return;
  266. }
  267. if (action === 'apply') {
  268. if (!require('../mcp/src/core/update-service').isCompiledDelivery()) {
  269. throw new Error('开发源码模式仅支持版本检查,交付包更新请在 Bun 工作台中执行');
  270. }
  271. const checked = await updates.checkForUpdate({ root: context.baseDir });
  272. if (checked.status !== 'update_available') {
  273. process.stdout.write(`${JSON.stringify(checked, null, 2)}\n`);
  274. return;
  275. }
  276. const downloaded = await updates.downloadUpdate(checked, { root: context.baseDir });
  277. updates.stopRuntimeBeforeUpdate(context.baseDir);
  278. const helper = updates.spawnUpdateHelper(downloaded.archive, context.baseDir);
  279. process.stdout.write(`${JSON.stringify({ status: 'update_scheduled', version: downloaded.version, helperPid: helper.pid }, null, 2)}\n`);
  280. return;
  281. }
  282. if (action === 'update-helper') {
  283. const result = await updates.runUpdateHelper(argv.slice(1));
  284. process.stdout.write(`${JSON.stringify({ status: 'updated', version: result.version }, null, 2)}\n`);
  285. return;
  286. }
  287. throw new Error(`未知更新命令:${action}(可用:check、apply)`);
  288. }
  289. async function commandMcp() {
  290. // The compiled executable embeds the same MCP server as the source package.
  291. // Keep stdio untouched: MCP clients use it as the protocol transport.
  292. require('../mcp/src/server.js');
  293. }
  294. function runVoiceCommand(executable, args) {
  295. const { spawn } = require('child_process');
  296. return new Promise((resolve, reject) => {
  297. const child = spawn(executable, args, { windowsHide: true });
  298. let stdout = '';
  299. let stderr = '';
  300. child.stdout.on('data', chunk => { stdout += String(chunk); });
  301. child.stderr.on('data', chunk => { stderr += String(chunk); });
  302. child.on('error', reject);
  303. child.on('close', code => {
  304. if (code === 0) resolve({ stdout, stderr });
  305. else reject(new Error(`${path.basename(executable)} 退出码 ${code}:${stderr.slice(-500)}`));
  306. });
  307. });
  308. }
  309. async function smokeVoiceBinaries(diagnostics) {
  310. const os = require('os');
  311. const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-binaries-'));
  312. const files = {
  313. wav: path.join(root, 'sample.wav'),
  314. pcm: path.join(root, 'sample.pcm'),
  315. silk: path.join(root, 'sample.silk'),
  316. decoded: path.join(root, 'decoded.pcm'),
  317. };
  318. const bins = diagnostics.binaries;
  319. try {
  320. await runVoiceCommand(bins.ffmpeg.path, ['-version']);
  321. await runVoiceCommand(bins.ffprobe.path, ['-version']);
  322. await runVoiceCommand(bins.ffmpeg.path, [
  323. '-y', '-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=24000:duration=0.25',
  324. '-ac', '1', '-ar', '24000', files.wav,
  325. ]);
  326. const probe = await runVoiceCommand(bins.ffprobe.path, [
  327. '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', files.wav,
  328. ]);
  329. if (!(Number(probe.stdout.trim()) > 0)) throw new Error('ffprobe 未识别生成的 WAV');
  330. await runVoiceCommand(bins.ffmpeg.path, [
  331. '-y', '-i', files.wav, '-vn', '-ac', '1', '-ar', '24000', '-c:a', 'pcm_s16le', '-f', 's16le', files.pcm,
  332. ]);
  333. await runVoiceCommand(bins.encoder.path, [files.pcm, files.silk, '-tencent']);
  334. await runVoiceCommand(bins.decoder.path, [files.silk, files.decoded, '-Fs_API', '24000']);
  335. if (!fs.statSync(files.silk).size || !fs.statSync(files.decoded).size) {
  336. throw new Error('SILK 编解码产物为空');
  337. }
  338. return { wavBytes: fs.statSync(files.wav).size, silkBytes: fs.statSync(files.silk).size, decodedPcmBytes: fs.statSync(files.decoded).size };
  339. } finally {
  340. fs.rmSync(root, { recursive: true, force: true });
  341. }
  342. }
  343. async function commandVoiceBinaries(argv = []) {
  344. const { diagnoseVoiceBinaries } = require('../mcp/src/core/voice-binaries');
  345. const diagnostics = diagnoseVoiceBinaries();
  346. process.stdout.write(`${JSON.stringify(diagnostics, null, 2)}\n`);
  347. if (!diagnostics.ready) {
  348. process.exitCode = 1;
  349. return;
  350. }
  351. if (argv.includes('--smoke')) {
  352. const result = await smokeVoiceBinaries(diagnostics);
  353. process.stdout.write(`[ok] voice-binaries smoke: ${JSON.stringify(result)}\n`);
  354. }
  355. }
  356. function usage() {
  357. return [
  358. '企微培训工作台(单文件版)',
  359. '',
  360. '用法:',
  361. ' qiwei-workbench 启动工作台与企微监听,并打开浏览器',
  362. ' qiwei-workbench --no-open 启动但不自动打开浏览器',
  363. ' qiwei-workbench --port 4321 指定端口',
  364. ' qiwei-workbench runtime status 查看监听 Runtime 状态',
  365. ' qiwei-workbench runtime stop 停止监听 Runtime',
  366. ' qiwei-workbench update check 检查 Bun 交付包版本',
  367. ' qiwei-workbench update apply 下载并安排更新(保留本地数据)',
  368. ' qiwei-workbench dashboard 只启动工作台,不拉起监听',
  369. ' qiwei-workbench mcp 启动 qiwei-assistant MCP Server',
  370. ' qiwei-workbench voice-binaries --smoke 验证随包语音依赖与 SILK 转码',
  371. '',
  372. ].join('\n');
  373. }
  374. async function main() {
  375. const argv = process.argv.slice(2);
  376. if (argv.includes('--help') || argv.includes('-h')) {
  377. process.stdout.write(`${usage()}\n`);
  378. return;
  379. }
  380. const context = applyTrainingEnv();
  381. const [first, ...rest] = argv;
  382. switch (first) {
  383. case 'runtime':
  384. await commandRuntime(rest);
  385. return;
  386. case 'worker':
  387. await commandWorker(rest);
  388. return;
  389. case 'dashboard':
  390. await commandDashboard(rest);
  391. return;
  392. case 'mcp':
  393. await commandMcp();
  394. return;
  395. case 'voice-binaries':
  396. await commandVoiceBinaries(rest);
  397. return;
  398. case 'update':
  399. await commandUpdate(rest, context);
  400. return;
  401. case 'update-helper':
  402. await require('../mcp/src/core/update-service').runUpdateHelper(argv.slice(1));
  403. return;
  404. case 'start':
  405. case undefined:
  406. await commandStart(rest, context);
  407. return;
  408. default:
  409. if (first.startsWith('--')) {
  410. await commandStart(argv, context);
  411. return;
  412. }
  413. throw new Error(`未知命令:${first}\n\n${usage()}`);
  414. }
  415. }
  416. if (require.main === module) {
  417. main().catch(error => {
  418. process.stderr.write(`启动失败:${error.message}\n`);
  419. process.exit(1);
  420. });
  421. }
  422. module.exports = {
  423. canListen,
  424. defaultFlowPort,
  425. selectFlowPort,
  426. selectWorkbenchPort,
  427. };