#!/usr/bin/env node // 培训版单文件打包:bun build --compile 生成 exe,并把必须留在磁盘上的资产复制到同级目录。 // 用法: // node scripts/build-training-package.mjs --check 仅检查打包前置条件 // node scripts/build-training-package.mjs 执行打包 // node scripts/build-training-package.mjs --target bun-windows-x64 --outdir dist/qiwei-training // node scripts/build-training-package.mjs --fresh --outdir import fs from 'node:fs'; import path from 'node:path'; import os from 'node:os'; import { spawnSync } from 'node:child_process'; import { fileURLToPath } from 'node:url'; const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..'); const ENTRY = path.join('bin', 'qiwei-training.js'); const EXE_NAME = 'qiwei-workbench.exe'; const LICENSE_FILE = 'LICENSE.md'; const VERSION_FILE = 'version.json'; const DELIVERY_VERSION_FILE = 'bun-delivery-version.json'; // index.html 直接引用 /dashboard/echarts.min.js,漏掉它会让图表页整页报错。 const STATIC_FILES = [ 'index.html', 'app.js', 'styles.css', 'echarts.min.js', 'driver.min.js', 'driver.css', 'onboarding-guide.js', 'onboarding.css', ]; // 语音能力依赖的可选包,默认打包进 exe(方案 A) // 若需要外置以减小体积,传 --external-voice 标志 const VOICE_EXTERNALS = [ '@ffmpeg-installer/ffmpeg', '@ffprobe-installer/ffprobe', '@binsee/wx-voice', ]; function parseArgs(argv = process.argv.slice(2)) { const valueOf = (flag, fallback) => { const index = argv.indexOf(flag); return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback; }; return { check: argv.includes('--check'), // 发布/交付场景必须显式选择全新目录,避免把现场凭据和运行数据带入包。 fresh: argv.includes('--fresh'), externalVoice: argv.includes('--external-voice'), target: valueOf('--target', 'bun-windows-x64'), outDir: path.resolve(ROOT, valueOf('--outdir', path.join('dist', 'qiwei-training'))), }; } function runCommand(command, args, options = {}) { const base = { cwd: ROOT, encoding: 'utf8', windowsHide: true, ...options }; // 绝对路径直接执行;裸命令名才需要经由 shell 解析。部分 Windows 环境的 PATH 缺少 System32, // 因此用 ComSpec 定位 cmd.exe,而不是依赖 PATH 查找。 if (process.platform !== 'win32' || path.isAbsolute(command)) { return spawnSync(command, args, base); } const comspec = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe'; return spawnSync(comspec, ['/d', '/s', '/c', command, ...args], base); } // 刚安装 bun 的终端尚未刷新 PATH,因此在 PATH 之外再探测约定安装位置。 function bunCandidates() { const exe = process.platform === 'win32' ? 'bun.exe' : 'bun'; const home = os.homedir(); const roots = [process.env.BUN_INSTALL, path.join(home, '.bun')].filter(Boolean); if (process.platform === 'win32') roots.push('D:\\bun', 'C:\\bun'); // Prefer a concrete binary on Windows. The PATH shim can launch through // cmd.exe and terminate the compile child before Bun reports its result. return [...roots.map(root => path.join(root, 'bin', exe)), 'bun']; } function detectBun() { for (const candidate of bunCandidates()) { if (candidate !== 'bun' && !fs.existsSync(candidate)) continue; const result = runCommand(candidate, ['--version'], { stdio: 'pipe' }); if (result.status === 0) { return { available: true, version: String(result.stdout || '').trim(), binary: candidate }; } } return { available: false, version: '', binary: '' }; } function bunInstallHint() { return process.platform === 'win32' ? 'powershell -NoProfile -ExecutionPolicy Bypass -c "irm bun.sh/install.ps1 | iex"' : 'curl -fsSL https://bun.sh/install | bash'; } function checkPreconditions() { const problems = []; const notes = []; const bun = detectBun(); if (!bun.available) problems.push(`未检测到 bun,请先安装:${bunInstallHint()}`); else notes.push(`bun ${bun.version}${bun.binary === 'bun' ? '' : `(${bun.binary})`}`); if (!fs.existsSync(path.join(ROOT, ENTRY))) problems.push(`缺少培训入口 ${ENTRY}`); if (!fs.existsSync(path.join(ROOT, LICENSE_FILE))) problems.push(`缺少许可证文件 ${LICENSE_FILE}`); if (!fs.existsSync(path.join(ROOT, DELIVERY_VERSION_FILE))) problems.push(`缺少 Bun 交付版本文件 ${DELIVERY_VERSION_FILE}`); if (!fs.existsSync(path.join(ROOT, 'node_modules'))) problems.push('缺少 node_modules,请先执行 npm install'); for (const file of STATIC_FILES) { if (!fs.existsSync(path.join(ROOT, 'mcp', 'src', 'dashboard', file))) { problems.push(`缺少前端资源 mcp/src/dashboard/${file}`); } } if (!fs.existsSync(path.join(ROOT, 'knowledge'))) problems.push('缺少 knowledge 目录'); if (!fs.existsSync(path.join(ROOT, 'knowledge-base', 'catalog.json'))) problems.push('缺少 knowledge-base/catalog.json'); notes.push(`node ${process.version}`); notes.push(`平台 ${os.platform()}-${os.arch()}`); return { problems, notes, bun }; } function copyTree(source, destination) { const stat = fs.statSync(source); if (stat.isDirectory()) { fs.mkdirSync(destination, { recursive: true }); for (const entry of fs.readdirSync(source)) { copyTree(path.join(source, entry), path.join(destination, entry)); } return; } fs.mkdirSync(path.dirname(destination), { recursive: true }); fs.copyFileSync(source, destination); } function writeFieldGuide(outDir) { const body = [ '# 企微培训工作台(单文件版)', '', '## 快速开始', '', '### 第一步:配置认证 Token(必须!)', '', '**重要:不配置 Token 会导致所有功能无法使用,面板显示为空。**', '', '有两种配置方式(任选其一):', '', '#### 方式 A:手动编辑配置文件(推荐)', '', '1. 打开 `.env.local` 文件', '2. 找到 `QIWEI_AUTH_TOKEN=` 这一行', '3. 在等号后面粘贴你的 Fmode token(格式:`r:xxx` 或 `sk-xxx`)', '4. 保存文件', '', '**Token 获取方式:**', '- 独立工作台:在页面粘贴凭据,或写入包目录 `.env.local`', '- 飞马平台:登录后在个人中心获取', '- Fmode 控制台:https://server.fmode.cn/', '', '#### 方式 B:在工作台页面中配置', '', '1. 双击 `启动工作台.bat`(也可直接运行 `qiwei-workbench.exe`)', '2. 浏览器打开后,在页面中粘贴 token', '3. 点击验证', '', '### 第二步:启动工作台', '', '把整个文件夹拷到 Windows 电脑(**不要只拷 exe**),双击 `启动工作台.bat`。浏览器会自动打开工作台;重复双击会打开已经运行的工作台。', '', '### 第三步:完成企微登录', '', '按页面提示走完三步,**不需要安装 Bun,也不需要会改环境变量**:', '', '1. **确认席位**:新账号优先使用上游提供的 7 天试用席位;试用到期或没有试用时,流程页会引导开通服务。', '2. **企微扫码**:用企业微信扫二维码,必要时输入手机上的 6 位验证码。', '3. **开启监听**:回到工作台确认账号在线,加测试白名单,打开 AI 监听。', '', '## 现场启动', '', '1. 保持目录结构完整。', '2. 双击 `启动工作台.bat`。若 Windows 弹出「已保护你的电脑」,选「更多信息」→「仍要运行」。', '3. 浏览器打开后按上面四步操作。token 也可以预先写进 `.env.local` 的 `QIWEI_AUTH_TOKEN=`。', '', '## 目录说明', '', '| 路径 | 用途 |', '| --- | --- |', '| `启动工作台.bat` | 推荐的双击启动入口,失败时保留错误提示 |', '| `停止工作台.bat` | 一键关闭工作台及其监听进程,释放本机端口 |', '| `qiwei-workbench.exe` | 工作台与企微监听主程序 |', '| `.env.local` | **本机凭据(必须配置 Token!)** |', '| `.mcp.json` | MCP 客户端配置,已注册 `qiwei-assistant` |', '| `web/` | 前端页面资源 |', '| `knowledge/` | 客服知识库,可现场编辑 |', '| `qiwei.runtime.config.mjs` | 监听轮询配置 |', '| `outputs/` | 运行数据,首次启动后生成 |', '', '## MCP 工具', '', '包内 `.mcp.json` 已注册 `qiwei-assistant`,指向同目录的工作台程序。支持 MCP 的客户端可直接读取该配置;首次连接会自动完成初始化并发现企微工具。', '', '## 常见问题', '', '### Q1: 工作台打开后所有面板都是空的?', '', '**原因:** 没有配置 `QIWEI_AUTH_TOKEN`。', '', '**解决:** 编辑 `.env.local` 文件,在 `QIWEI_AUTH_TOKEN=` 后面填入你的 Fmode token,保存后刷新页面。', '', '### Q2: 在哪里获取 Fmode Token?', '', '- 独立工作台:在页面粘贴凭据,或写入包目录 `.env.local`', '- 如果是新机器:登录飞马平台(https://fmode.cn)或 Fmode 控制台(https://server.fmode.cn)获取', '', '### Q3: 扫码登录后显示离线?', '', '- 检查 `.env.local` 中 `QIWEI_GUID` 是否已自动填充', '- 刷新工作台页面', '- 查看控制台是否有错误提示', '', '## 常用命令', '', '```text', 'qiwei-workbench.exe 启动工作台与监听', 'qiwei-workbench.exe --port 4321 指定端口', 'qiwei-workbench.exe runtime status 查看监听状态', 'qiwei-workbench.exe runtime stop 停止监听', '```', '', '## 现场注意', '', '- 关闭工作台窗口不会停止监听,需要执行 `runtime stop`。', '- 智能回复依赖本机已安装的 Claude Code;未安装时消息仍会进入工作台,只是不自动生成草稿。', '- 防火墙若询问,请允许程序访问本机网络(127.0.0.1:4320 与 4310)。', '- `outputs/` 含真实客户会话,培训结束后请随包删除。', '', '## 语音克隆功能', '', '本版本已包含完整语音克隆能力(基于你的 Fmode Token):', '', '1. **录制参考音频**:5-30 秒本人录音(清晰、无背景噪音)', '2. **初始化声音档案**:工作台会自动保存到 `outputs/voice/`', '3. **合成并发送**:自动选择语气(自然/友好/致歉/关怀/提醒),编码为企微 SILK 格式并发送', '', '**注意**:语音合成使用 Fmode 飞马余额计费(9元/万字符),请确保余额充足。', '', ].join('\n'); fs.writeFileSync(path.join(outDir, 'README.md'), body, 'utf8'); } function writeEnvTemplate(outDir) { const target = path.join(outDir, '.env.local'); if (fs.existsSync(target)) return; const body = [ '# ============================================================', '# 企微培训工作台 - 本机凭据配置', '# ============================================================', '# 重要:本文件包含认证凭据,切勿提交到代码仓库或外传!', '#', '# 首次使用必须配置 QIWEI_AUTH_TOKEN,否则所有功能无法使用。', '# 可以在此手动填写,或在工作台页面(http://127.0.0.1:4320)中粘贴。', '', '# ---------- 必填项 ----------', '# Fmode 认证 token(必须!格式:r:xxx 或 sk-xxx)', '# 获取方式:', '# 1. 独立工作台:页面提交或包目录 .env.local', '# 2. 飞马平台:登录后在个人中心获取', '# 3. Fmode 控制台:https://server.fmode.cn/', 'QIWEI_AUTH_TOKEN=', '', '# ---------- 自动生成项(登录后自动填充)----------', '# 企微账号唯一标识(首次启动后自动生成)', 'QIWEI_UID=', '', '# 企微设备 GUID(扫码登录后自动保存)', 'QIWEI_GUID=', '', '# Fmode 网关地址(通常不需要修改)', 'QIWEI_API_BASE=https://server.fmode.cn/api/qiwei', '', '# ---------- 可选配置 ----------', '# 指定本机 Claude Code 可执行文件路径(用于智能回复)', '# CLAUDE_CODE_EXECUTABLE=', '', ].join('\n'); fs.writeFileSync(target, body, 'utf8'); } function writeWindowsLauncher(outDir) { const body = [ '@echo off', 'setlocal', 'chcp 65001 >nul', 'cd /d "%~dp0"', 'title Fmode 企微智能助手工作台', 'if not exist "%~dp0qiwei-workbench.exe" (', ' echo [错误] 未找到 qiwei-workbench.exe,请保持交付文件夹完整。', ' pause', ' exit /b 2', ')', 'echo 正在启动工作台,请稍候...', '"%~dp0qiwei-workbench.exe" %*', 'set "EXIT_CODE=%ERRORLEVEL%"', 'if not "%EXIT_CODE%"=="0" (', ' echo.', ' echo 工作台启动失败,错误码:%EXIT_CODE%', ' echo 请保留本窗口内容并联系技术支持。', ' pause', ')', 'endlocal & exit /b %EXIT_CODE%', ].join('\r\n'); fs.writeFileSync(path.join(outDir, '启动工作台.bat'), `${body}\r\n`, 'utf8'); } function writeWindowsStopper(outDir) { const body = [ '@echo off', 'setlocal', 'chcp 65001 >nul', 'cd /d "%~dp0"', 'title 停止 Fmode 企微智能助手工作台', 'echo 正在关闭工作台及其监听进程,请稍候...', 'taskkill /IM qiwei-workbench.exe /T /F >nul 2>&1', 'if "%ERRORLEVEL%"=="0" (', ' echo 工作台进程已关闭。', ') else (', ' echo 未发现正在运行的工作台进程。', ')', 'echo 端口 4310/4320/4321 将随工作台释放;如仍被占用,请稍后重试。', 'echo.', 'pause', 'endlocal', ].join('\r\n'); fs.writeFileSync(path.join(outDir, '停止工作台.bat'), `${body}\r\n`, 'utf8'); } function writeMcpConfig(options) { // Keep the delivered package portable. The executable resolves its own // package root from process.execPath, so the MCP client only needs a // package-local command and working directory. const command = String(options.target || '').includes('windows') ? '.\\qiwei-workbench.exe' : './qiwei-workbench'; const config = { mcpServers: { 'qiwei-assistant': { command, args: ['mcp'], cwd: '.', env: { QIWEI_PACKAGE_ROOT: '.', QIWEI_WORKSPACE_ROOT: '.', QIWEI_OUTPUTS_DIR: './outputs', CLAUDE_CODE_WORKDIR: '.', QIWEI_RUNTIME_CONFIG: './qiwei.runtime.config.mjs', }, }, }, }; fs.writeFileSync(path.join(options.outDir, '.mcp.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8'); } function deliveryPlatform(target) { const normalized = String(target || '').toLowerCase(); if (normalized.includes('windows') || normalized.includes('win')) return 'win-x64'; if (normalized.includes('darwin') && normalized.includes('arm64')) return 'mac-arm64'; if (normalized.includes('darwin')) return 'mac-x64'; if (normalized.includes('linux') && normalized.includes('arm64')) return 'linux-arm64'; return 'linux-x64'; } function writeVersionMetadata(outDir, target) { const deliveryVersion = JSON.parse(fs.readFileSync(path.join(ROOT, DELIVERY_VERSION_FILE), 'utf8').replace(/^\uFEFF/, '')); const metadata = { product: deliveryVersion.product || 'fmode-qiwei-training', version: String(deliveryVersion.version || '0.0.0'), platform: deliveryPlatform(target), buildTime: new Date().toISOString(), updateChannel: deliveryVersion.channel || 'stable', notes: deliveryVersion.notes || '', }; fs.writeFileSync(path.join(outDir, VERSION_FILE), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8'); return metadata; } // 普通重打包保留现场配置,便于本地迭代;--fresh 用于发布,完全清空目标目录, // 防止把凭据、会话和其他运行数据带入交付包。 const REBUILD_PRESERVED = new Set([ '.env.local', 'outputs', 'qiwei.runtime.config.mjs', LICENSE_FILE, ]); function assertFreshTarget(outDir) { const resolved = path.resolve(outDir); const forbidden = new Set([ROOT, path.parse(ROOT).root, process.cwd()]); if (forbidden.has(resolved)) { throw new Error(`--fresh 拒绝清理危险目录:${resolved}`); } if (resolved.length < 8) { throw new Error(`--fresh 目标路径过短,拒绝清理:${resolved}`); } } function prepareOutDir(outDir, { fresh = false } = {}) { if (!fs.existsSync(outDir)) { fs.mkdirSync(outDir, { recursive: true }); return; } if (fresh) { assertFreshTarget(outDir); for (const entry of fs.readdirSync(outDir)) { const target = path.join(outDir, entry); const stat = fs.lstatSync(target); if (stat.isDirectory()) fs.rmSync(target, { recursive: true, force: true }); else fs.unlinkSync(target); } return; } for (const entry of fs.readdirSync(outDir)) { if (REBUILD_PRESERVED.has(entry)) continue; const target = path.join(outDir, entry); // Node 24 on Windows can crash while rmSync removes a regular file whose // name contains CJK characters. Use unlinkSync for files and reserve the // recursive path for directories. const stat = fs.lstatSync(target); if (stat.isDirectory()) fs.rmSync(target, { recursive: true, force: true }); else fs.unlinkSync(target); } } function buildExecutable(options) { const outFile = path.join(options.outDir, EXE_NAME); const args = [ 'build', ENTRY, '--compile', '--target', options.target, '--outfile', outFile, ]; if (options.externalVoice) { for (const item of VOICE_EXTERNALS) args.push('--external', item); } process.stdout.write(`\n执行:${options.bunBinary} ${args.join(' ')}\n\n`); // Bun's Windows compiler can close an inherited PTY before spawnSync returns. // Capture its output and forward it after completion so the build process gets // a reliable exit status in Codex, deployment scripts, and ordinary terminals. const result = runCommand(options.bunBinary, args, { stdio: 'pipe' }); if (result.stdout) process.stdout.write(result.stdout); if (result.stderr) process.stderr.write(result.stderr); if (result.status !== 0) throw new Error('bun build --compile 失败,请查看上方输出'); return outFile; } function copyRuntimeAssets(options) { fs.copyFileSync(path.join(ROOT, LICENSE_FILE), path.join(options.outDir, LICENSE_FILE)); const webDir = path.join(options.outDir, 'web'); fs.mkdirSync(webDir, { recursive: true }); for (const file of STATIC_FILES) { fs.copyFileSync(path.join(ROOT, 'mcp', 'src', 'dashboard', file), path.join(webDir, file)); } copyTree(path.join(ROOT, 'knowledge'), path.join(options.outDir, 'knowledge')); // The catalog is the runtime registry for the shipped knowledge tree. Keep // the physical paths intact; the updater preserves the customer-edited tree. copyTree(path.join(ROOT, 'knowledge-base'), path.join(options.outDir, 'knowledge-base')); copyVoiceBinaries(options); const runtimeConfig = path.join(options.outDir, 'qiwei.runtime.config.mjs'); if (!fs.existsSync(runtimeConfig)) { fs.copyFileSync(path.join(ROOT, 'qiwei.runtime.config.example.mjs'), runtimeConfig); } writeEnvTemplate(options.outDir); writeWindowsLauncher(options.outDir); writeWindowsStopper(options.outDir); writeMcpConfig(options); writeVersionMetadata(options.outDir, options.target); writeFieldGuide(options.outDir); } function targetVoicePlatform(target) { const normalized = String(target || '').toLowerCase(); if (normalized.includes('windows') || normalized.includes('win')) return 'win32-x64'; if (normalized.includes('darwin') && normalized.includes('arm64')) return 'darwin-arm64'; if (normalized.includes('darwin')) return 'darwin-x64'; if (normalized.includes('linux') && normalized.includes('arm64')) return 'linux-arm64'; return 'linux-x64'; } function copyVoiceBinaries(options) { const platform = targetVoicePlatform(options.target); const suffix = platform.startsWith('win32') ? '.exe' : ''; const sources = { ffmpeg: path.join(ROOT, 'node_modules', '@ffmpeg-installer', platform, `ffmpeg${suffix}`), ffprobe: path.join(ROOT, 'node_modules', '@ffprobe-installer', platform, `ffprobe${suffix}`), encoder: path.join(ROOT, 'node_modules', `@binsee/wx-voice-silk-${platform}`, `encoder${suffix}`), decoder: path.join(ROOT, 'node_modules', `@binsee/wx-voice-silk-${platform}`, `decoder${suffix}`), }; const missing = Object.entries(sources).filter(([, source]) => !fs.existsSync(source)); if (missing.length) throw new Error(`缺少 ${platform} 语音运行时:${missing.map(([kind, source]) => `${kind} (${source})`).join(', ')}`); const destination = path.join(options.outDir, 'voice-binaries', platform); fs.mkdirSync(destination, { recursive: true }); for (const [kind, source] of Object.entries(sources)) { fs.copyFileSync(source, path.join(destination, path.basename(source))); } } function summarize(outFile, options) { const sizeMb = (fs.statSync(outFile).size / (1024 * 1024)).toFixed(1); const version = JSON.parse(fs.readFileSync(path.join(options.outDir, VERSION_FILE), 'utf8')).version; const voiceNote = options.externalVoice ? `已外置语音依赖:${VOICE_EXTERNALS.join(', ')}(需手动安装 node_modules)` : '已打包语音依赖(ffmpeg/wx-voice),支持完整语音克隆能力'; process.stdout.write([ '', '打包完成。', ` 产物目录:${options.outDir}`, ` 版本:${version}`, ` 可执行文件:${EXE_NAME}(${sizeMb} MB)`, ` 目标平台:${options.target}`, ` ${voiceNote}`, '', '下一步:把整个产物目录拷到培训机,填写 .env.local 后双击运行。', '', ].join('\n')); } function main() { const options = parseArgs(); const { problems, notes, bun } = checkPreconditions(); options.bunBinary = bun.binary; process.stdout.write(`打包环境:${notes.join(' · ')}\n`); if (problems.length) { process.stderr.write(`\n打包前置条件未满足:\n${problems.map(item => ` - ${item}`).join('\n')}\n\n`); process.exit(1); } if (options.check) { process.stdout.write('前置条件检查通过,可以执行打包。\n'); return; } prepareOutDir(options.outDir, { fresh: options.fresh }); const outFile = buildExecutable(options); copyRuntimeAssets(options); summarize(outFile, options); } try { main(); } catch (error) { process.stderr.write(`打包失败:${error.message}\n`); process.exit(1); }