| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475 |
- #!/usr/bin/env node
- 'use strict';
- // 培训版单文件入口:同一个可执行程序按子命令扮演工作台、监听 Runtime 和好友轮询 Worker。
- // 编译后 process.execPath 指向 exe 自身,因此包根、工作区根和前端目录都以 exe 所在目录为准。
- const fs = require('fs');
- const path = require('path');
- const NODE_HOSTS = new Set(['node', 'node.exe', 'bun', 'bun.exe']);
- function isSourceRun() {
- return NODE_HOSTS.has(path.basename(process.execPath).toLowerCase());
- }
- function resolveBaseDir() {
- return isSourceRun() ? path.resolve(__dirname, '..') : path.dirname(process.execPath);
- }
- function applyTrainingEnv() {
- const baseDir = resolveBaseDir();
- const compiled = !isSourceRun();
- // A compiled delivery must be self-contained. Never inherit a developer
- // machine's package root, otherwise login persistence can target the wrong
- // .env.local instead of the directory beside the executable.
- if (compiled) {
- process.env.QIWEI_PACKAGE_ROOT = baseDir;
- process.env.QIWEI_WORKSPACE_ROOT = baseDir;
- process.env.QIWEI_OUTPUTS_DIR = path.join(baseDir, 'outputs');
- process.env.CLAUDE_CODE_WORKDIR = baseDir;
- } else {
- process.env.QIWEI_PACKAGE_ROOT ||= baseDir;
- process.env.QIWEI_WORKSPACE_ROOT ||= baseDir;
- process.env.QIWEI_OUTPUTS_DIR ||= path.join(baseDir, 'outputs');
- process.env.CLAUDE_CODE_WORKDIR ||= baseDir;
- }
- process.env.QIWEI_AGENT_CONVERSATION_MODE ||= 'session';
- if (compiled) {
- process.env.QIWEI_RUNTIME_LAUNCHER ||= 'self';
- process.env.QIWEI_DASHBOARD_STATIC_DIR ||= path.join(baseDir, 'web');
- }
- const runtimeConfig = path.join(baseDir, 'qiwei.runtime.config.mjs');
- if (fs.existsSync(runtimeConfig)) process.env.QIWEI_RUNTIME_CONFIG ||= runtimeConfig;
- fs.mkdirSync(path.join(baseDir, 'outputs', 'runtime'), { recursive: true });
- try { process.chdir(baseDir); } catch {}
- hydrateEnvFromLocalFiles();
- process.env.QIWEI_LOGIN_FLOW_UI ||= 'true';
- return { baseDir, compiled };
- }
- // 把 exe 同级的 .env.local 灌进 process.env。页面里保存的 token、现场手填的凭据,
- // 重启后必须对所有只读 process.env 的模块可见,不能只给 credentials.js 看见。
- function hydrateEnvFromLocalFiles() {
- try {
- const { readEnvFiles } = require('../mcp/src/core/credentials');
- const fileEnv = readEnvFiles();
- for (const [key, value] of Object.entries(fileEnv)) {
- process.env[key] = String(value);
- }
- } catch {}
- }
- function parsePort(argv) {
- const index = argv.indexOf('--port');
- const raw = index >= 0 ? Number(argv[index + 1]) : Number(process.env.QIWEI_DASHBOARD_PORT || 4320);
- if (!Number.isInteger(raw) || raw < 1 || raw > 65535) throw new Error('端口无效,请使用 --port 4320 这类有效端口');
- return raw;
- }
- function openBrowser(url) {
- const { spawn } = require('child_process');
- // 部分 Windows 环境的 PATH 缺少 System32,用 ComSpec 定位 cmd.exe 而不是依赖 PATH 查找。
- const comspec = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
- const [command, args] = process.platform === 'win32'
- ? [comspec, ['/d', '/s', '/c', 'start', '', url]]
- : process.platform === 'darwin'
- ? ['open', [url]]
- : ['xdg-open', [url]];
- try {
- spawn(command, args, { detached: true, stdio: 'ignore', windowsHide: true }).unref();
- } catch (error) {
- process.stderr.write(`自动打开浏览器失败,请手动访问 ${url}(${error.message})\n`);
- }
- }
- function probeWorkbench(port, workspaceId, timeoutMs = 500) {
- const http = require('http');
- return new Promise(resolve => {
- const request = http.get({
- hostname: '127.0.0.1',
- port,
- path: '/api/health',
- headers: { accept: 'application/json' },
- }, response => {
- let body = '';
- response.setEncoding('utf8');
- response.on('data', chunk => {
- if (body.length < 64 * 1024) body += chunk;
- });
- response.on('end', () => {
- try {
- const payload = JSON.parse(body);
- resolve(
- response.statusCode === 200
- && payload?.status === 'ok'
- && Number(payload?.data?.port) === port
- && payload?.data?.workspaceId === workspaceId
- );
- } catch {
- resolve(false);
- }
- });
- });
- request.setTimeout(timeoutMs, () => request.destroy());
- request.on('error', () => resolve(false));
- });
- }
- function canListen(port) {
- const net = require('net');
- return new Promise(resolve => {
- const server = net.createServer();
- server.unref();
- server.once('error', () => resolve(false));
- server.listen(port, '127.0.0.1', () => {
- server.close(error => resolve(!error));
- });
- });
- }
- async function selectWorkbenchPort(requestedPort, allowFallback, workspaceId) {
- const candidates = allowFallback
- ? Array.from({ length: Math.min(10, 65536 - requestedPort) }, (_, index) => requestedPort + index)
- : [requestedPort];
- const probes = await Promise.all(
- candidates.map(async port => ({ port, running: await probeWorkbench(port, workspaceId) }))
- );
- const running = probes.find(item => item.running);
- if (running) return { port: running.port, reuse: true };
- for (const port of candidates) {
- if (await canListen(port)) return { port, reuse: false };
- }
- const message = allowFallback
- ? `端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭占用程序后重试`
- : `端口 ${requestedPort} 已被其他程序占用,请关闭占用程序后重试`;
- throw new Error(message);
- }
- function reuseRunningWorkbench(port, argv) {
- const url = `http://127.0.0.1:${port}/#status`;
- process.stdout.write(`[培训包] 工作台已在运行,正在打开:${url}\n`);
- if (!argv.includes('--no-open')) openBrowser(url);
- }
- function runtimeStatePath(baseDir) {
- return path.join(baseDir, 'outputs', 'runtime', 'qiwei-runtime.json');
- }
- function defaultFlowPort(dashboardPort) {
- // Keep the documented single-instance default (4320 -> 4310). When a second
- // package is forced onto 4321+, give it a matching flow port so its QR/verify
- // context cannot be served by an older package still listening on 4310.
- const offset = Math.max(0, Number(dashboardPort) - 4320);
- return Math.min(65535, 4310 + offset);
- }
- async function selectFlowPort(dashboardPort) {
- const configured = Number(String(process.env.QIWEI_FLOW_PORT || '').trim());
- const requestedPort = Number.isInteger(configured) && configured >= 1 && configured <= 65535
- ? configured
- : defaultFlowPort(dashboardPort);
- const candidates = Array.from(
- { length: Math.min(10, 65536 - requestedPort) },
- (_, index) => requestedPort + index,
- );
- for (const port of candidates) {
- if (await canListen(port)) return port;
- }
- throw new Error(`登录流程端口 ${candidates[0]}-${candidates[candidates.length - 1]} 均被占用,请关闭旧工作台后重试`);
- }
- function isProcessAlive(pid) {
- const numeric = Number(pid);
- if (!Number.isInteger(numeric) || numeric <= 0) return false;
- try { process.kill(numeric, 0); return true; } catch { return false; }
- }
- function ensureCallbackRuntime(baseDir) {
- let state = {};
- try { state = JSON.parse(fs.readFileSync(runtimeStatePath(baseDir), 'utf8')); } catch {}
- if (['starting', 'running'].includes(state.status) && isProcessAlive(state.pid)) {
- process.stdout.write(`[培训包] 监听 Runtime 已在运行 (pid=${state.pid})\n`);
- return;
- }
- const { spawn } = require('child_process');
- const { callbackRuntimeLauncher } = require('../mcp/src/core/runtime-launcher');
- const launcher = callbackRuntimeLauncher(['start', '--no-dashboard']);
- const outFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stdout.log'), 'a');
- const errFd = fs.openSync(path.join(baseDir, 'outputs', 'runtime', 'runtime.stderr.log'), 'a');
- try {
- const child = spawn(launcher.command, launcher.args, {
- cwd: baseDir,
- detached: true,
- stdio: ['ignore', outFd, errFd],
- windowsHide: true,
- env: { ...process.env },
- });
- child.on('error', error => process.stderr.write(`[培训包] 拉起监听 Runtime 失败:${error.message}\n`));
- child.unref();
- process.stdout.write(`[培训包] 已拉起监听 Runtime (pid=${child.pid})\n`);
- } finally {
- fs.closeSync(outFd);
- fs.closeSync(errFd);
- }
- }
- async function commandStart(argv, context) {
- const requestedPort = parsePort(argv);
- const portConfigured = argv.includes('--port') || Boolean(String(process.env.QIWEI_DASHBOARD_PORT || '').trim());
- const { workspaceIdentity } = require('../mcp/src/core/runtime-context');
- const workspaceId = workspaceIdentity(context.baseDir);
- const selected = await selectWorkbenchPort(requestedPort, !portConfigured, workspaceId);
- const port = selected.port;
- process.env.QIWEI_DASHBOARD_PORT = String(port);
- process.env.QIWEI_FLOW_PORT = String(await selectFlowPort(port));
- if (selected.reuse) {
- reuseRunningWorkbench(port, argv);
- return;
- }
- const { startServer } = require('../mcp/src/dashboard/server');
- try {
- await startServer(port);
- } catch (error) {
- // Two rapid double-clicks can both probe before the first server starts.
- // Recheck after EADDRINUSE so the second launch becomes an idempotent open.
- if (error?.code === 'EADDRINUSE' && await probeWorkbench(port, workspaceId)) {
- reuseRunningWorkbench(port, argv);
- return;
- }
- if (error?.code === 'EADDRINUSE') {
- throw new Error(`端口 ${port} 已被其他程序占用,请关闭占用程序后重试`);
- }
- throw error;
- }
- ensureCallbackRuntime(context.baseDir);
- // Version check is deliberately asynchronous and best-effort: CDN/network
- // outages must never delay login, listening, or the dashboard first paint.
- void require('../mcp/src/core/update-service').checkForUpdate({ root: context.baseDir, timeoutMs: 3500 }).catch(() => {});
- const url = `http://127.0.0.1:${port}/#status`;
- process.stdout.write([
- '',
- `企微培训工作台已启动:${url}`,
- `登录流程地址:http://127.0.0.1:${process.env.QIWEI_FLOW_PORT}/`,
- `数据目录:${path.join(context.baseDir, 'outputs')}`,
- '',
- '现场检查顺序:填写 Fmode token(若未自动检测到)→ 开通席位 → 企微扫码登录 → 账号在线 → 白名单 → 开启监听。',
- '关闭本窗口即停止工作台;监听 Runtime 需运行 stop 子命令结束。',
- '',
- ].join('\n'));
- if (!argv.includes('--no-open')) openBrowser(url);
- }
- async function commandRuntime(argv) {
- const { runCli } = await import('../runtime/callback-service/src/index.mjs');
- await runCli(argv.length ? argv : ['start']);
- }
- async function commandWorker(argv) {
- const target = argv[0] || 'friend';
- if (target !== 'friend') throw new Error(`未知 worker:${target}`);
- require('../scripts/friend-polling-worker.js');
- }
- async function commandDashboard(argv) {
- const port = parsePort(argv);
- process.env.QIWEI_DASHBOARD_PORT = String(port);
- const { startServer } = require('../mcp/src/dashboard/server');
- await startServer(port);
- process.stdout.write(`企微工作台已启动:http://127.0.0.1:${port}/#agent\n`);
- }
- async function commandUpdate(argv, context) {
- const updates = require('../mcp/src/core/update-service');
- const action = argv[0] || 'check';
- if (action === 'check') {
- const result = await updates.checkForUpdate({ root: context.baseDir });
- process.stdout.write(`${JSON.stringify(result, null, 2)}\n`);
- return;
- }
- if (action === 'apply') {
- if (!require('../mcp/src/core/update-service').isCompiledDelivery()) {
- throw new Error('开发源码模式仅支持版本检查,交付包更新请在 Bun 工作台中执行');
- }
- const checked = await updates.checkForUpdate({ root: context.baseDir });
- if (checked.status !== 'update_available') {
- process.stdout.write(`${JSON.stringify(checked, null, 2)}\n`);
- return;
- }
- const downloaded = await updates.downloadUpdate(checked, { root: context.baseDir });
- updates.stopRuntimeBeforeUpdate(context.baseDir);
- const helper = updates.spawnUpdateHelper(downloaded.archive, context.baseDir);
- process.stdout.write(`${JSON.stringify({ status: 'update_scheduled', version: downloaded.version, helperPid: helper.pid }, null, 2)}\n`);
- return;
- }
- if (action === 'update-helper') {
- const result = await updates.runUpdateHelper(argv.slice(1));
- process.stdout.write(`${JSON.stringify({ status: 'updated', version: result.version }, null, 2)}\n`);
- return;
- }
- throw new Error(`未知更新命令:${action}(可用:check、apply)`);
- }
- async function commandMcp() {
- // The compiled executable embeds the same MCP server as the source package.
- // Keep stdio untouched: MCP clients use it as the protocol transport.
- require('../mcp/src/server.js');
- }
- function runVoiceCommand(executable, args) {
- const { spawn } = require('child_process');
- return new Promise((resolve, reject) => {
- const child = spawn(executable, args, { windowsHide: true });
- let stdout = '';
- let stderr = '';
- child.stdout.on('data', chunk => { stdout += String(chunk); });
- child.stderr.on('data', chunk => { stderr += String(chunk); });
- child.on('error', reject);
- child.on('close', code => {
- if (code === 0) resolve({ stdout, stderr });
- else reject(new Error(`${path.basename(executable)} 退出码 ${code}:${stderr.slice(-500)}`));
- });
- });
- }
- async function smokeVoiceBinaries(diagnostics) {
- const os = require('os');
- const root = fs.mkdtempSync(path.join(os.tmpdir(), 'qiwei-voice-binaries-'));
- const files = {
- wav: path.join(root, 'sample.wav'),
- pcm: path.join(root, 'sample.pcm'),
- silk: path.join(root, 'sample.silk'),
- decoded: path.join(root, 'decoded.pcm'),
- };
- const bins = diagnostics.binaries;
- try {
- await runVoiceCommand(bins.ffmpeg.path, ['-version']);
- await runVoiceCommand(bins.ffprobe.path, ['-version']);
- await runVoiceCommand(bins.ffmpeg.path, [
- '-y', '-f', 'lavfi', '-i', 'sine=frequency=880:sample_rate=24000:duration=0.25',
- '-ac', '1', '-ar', '24000', files.wav,
- ]);
- const probe = await runVoiceCommand(bins.ffprobe.path, [
- '-v', 'error', '-show_entries', 'format=duration', '-of', 'default=noprint_wrappers=1:nokey=1', files.wav,
- ]);
- if (!(Number(probe.stdout.trim()) > 0)) throw new Error('ffprobe 未识别生成的 WAV');
- await runVoiceCommand(bins.ffmpeg.path, [
- '-y', '-i', files.wav, '-vn', '-ac', '1', '-ar', '24000', '-c:a', 'pcm_s16le', '-f', 's16le', files.pcm,
- ]);
- await runVoiceCommand(bins.encoder.path, [files.pcm, files.silk, '-tencent']);
- await runVoiceCommand(bins.decoder.path, [files.silk, files.decoded, '-Fs_API', '24000']);
- if (!fs.statSync(files.silk).size || !fs.statSync(files.decoded).size) {
- throw new Error('SILK 编解码产物为空');
- }
- return { wavBytes: fs.statSync(files.wav).size, silkBytes: fs.statSync(files.silk).size, decodedPcmBytes: fs.statSync(files.decoded).size };
- } finally {
- fs.rmSync(root, { recursive: true, force: true });
- }
- }
- async function commandVoiceBinaries(argv = []) {
- const { diagnoseVoiceBinaries } = require('../mcp/src/core/voice-binaries');
- const diagnostics = diagnoseVoiceBinaries();
- process.stdout.write(`${JSON.stringify(diagnostics, null, 2)}\n`);
- if (!diagnostics.ready) {
- process.exitCode = 1;
- return;
- }
- if (argv.includes('--smoke')) {
- const result = await smokeVoiceBinaries(diagnostics);
- process.stdout.write(`[ok] voice-binaries smoke: ${JSON.stringify(result)}\n`);
- }
- }
- function usage() {
- return [
- '企微培训工作台(单文件版)',
- '',
- '用法:',
- ' qiwei-workbench 启动工作台与企微监听,并打开浏览器',
- ' qiwei-workbench --no-open 启动但不自动打开浏览器',
- ' qiwei-workbench --port 4321 指定端口',
- ' qiwei-workbench runtime status 查看监听 Runtime 状态',
- ' qiwei-workbench runtime stop 停止监听 Runtime',
- ' qiwei-workbench update check 检查 Bun 交付包版本',
- ' qiwei-workbench update apply 下载并安排更新(保留本地数据)',
- ' qiwei-workbench dashboard 只启动工作台,不拉起监听',
- ' qiwei-workbench mcp 启动 qiwei-assistant MCP Server',
- ' qiwei-workbench voice-binaries --smoke 验证随包语音依赖与 SILK 转码',
- '',
- ].join('\n');
- }
- async function main() {
- const argv = process.argv.slice(2);
- if (argv.includes('--help') || argv.includes('-h')) {
- process.stdout.write(`${usage()}\n`);
- return;
- }
- const context = applyTrainingEnv();
- const [first, ...rest] = argv;
- switch (first) {
- case 'runtime':
- await commandRuntime(rest);
- return;
- case 'worker':
- await commandWorker(rest);
- return;
- case 'dashboard':
- await commandDashboard(rest);
- return;
- case 'mcp':
- await commandMcp();
- return;
- case 'voice-binaries':
- await commandVoiceBinaries(rest);
- return;
- case 'update':
- await commandUpdate(rest, context);
- return;
- case 'update-helper':
- await require('../mcp/src/core/update-service').runUpdateHelper(argv.slice(1));
- return;
- case 'start':
- case undefined:
- await commandStart(rest, context);
- return;
- default:
- if (first.startsWith('--')) {
- await commandStart(argv, context);
- return;
- }
- throw new Error(`未知命令:${first}\n\n${usage()}`);
- }
- }
- if (require.main === module) {
- main().catch(error => {
- process.stderr.write(`启动失败:${error.message}\n`);
- process.exit(1);
- });
- }
- module.exports = {
- canListen,
- defaultFlowPort,
- selectFlowPort,
- selectWorkbenchPort,
- };
|