build-training-package.mjs 22 KB

123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556
  1. #!/usr/bin/env node
  2. // 培训版单文件打包:bun build --compile 生成 exe,并把必须留在磁盘上的资产复制到同级目录。
  3. // 用法:
  4. // node scripts/build-training-package.mjs --check 仅检查打包前置条件
  5. // node scripts/build-training-package.mjs 执行打包
  6. // node scripts/build-training-package.mjs --target bun-windows-x64 --outdir dist/qiwei-training
  7. // node scripts/build-training-package.mjs --fresh --outdir <clean-dir>
  8. import fs from 'node:fs';
  9. import path from 'node:path';
  10. import os from 'node:os';
  11. import { spawnSync } from 'node:child_process';
  12. import { fileURLToPath } from 'node:url';
  13. const ROOT = path.resolve(path.dirname(fileURLToPath(import.meta.url)), '..');
  14. const ENTRY = path.join('bin', 'qiwei-training.js');
  15. const EXE_NAME = 'qiwei-workbench.exe';
  16. const LICENSE_FILE = 'LICENSE.md';
  17. const VERSION_FILE = 'version.json';
  18. const DELIVERY_VERSION_FILE = 'bun-delivery-version.json';
  19. // index.html 直接引用 /dashboard/echarts.min.js,漏掉它会让图表页整页报错。
  20. const STATIC_FILES = [
  21. 'index.html',
  22. 'app.js',
  23. 'styles.css',
  24. 'echarts.min.js',
  25. 'driver.min.js',
  26. 'driver.css',
  27. 'onboarding-guide.js',
  28. 'onboarding.css',
  29. ];
  30. // 语音能力依赖的可选包,默认打包进 exe(方案 A)
  31. // 若需要外置以减小体积,传 --external-voice 标志
  32. const VOICE_EXTERNALS = [
  33. '@ffmpeg-installer/ffmpeg',
  34. '@ffprobe-installer/ffprobe',
  35. '@binsee/wx-voice',
  36. ];
  37. function parseArgs(argv = process.argv.slice(2)) {
  38. const valueOf = (flag, fallback) => {
  39. const index = argv.indexOf(flag);
  40. return index >= 0 && argv[index + 1] ? argv[index + 1] : fallback;
  41. };
  42. return {
  43. check: argv.includes('--check'),
  44. // 发布/交付场景必须显式选择全新目录,避免把现场凭据和运行数据带入包。
  45. fresh: argv.includes('--fresh'),
  46. externalVoice: argv.includes('--external-voice'),
  47. target: valueOf('--target', 'bun-windows-x64'),
  48. outDir: path.resolve(ROOT, valueOf('--outdir', path.join('dist', 'qiwei-training'))),
  49. };
  50. }
  51. function runCommand(command, args, options = {}) {
  52. const base = { cwd: ROOT, encoding: 'utf8', windowsHide: true, ...options };
  53. // 绝对路径直接执行;裸命令名才需要经由 shell 解析。部分 Windows 环境的 PATH 缺少 System32,
  54. // 因此用 ComSpec 定位 cmd.exe,而不是依赖 PATH 查找。
  55. if (process.platform !== 'win32' || path.isAbsolute(command)) {
  56. return spawnSync(command, args, base);
  57. }
  58. const comspec = process.env.ComSpec || 'C:\\Windows\\System32\\cmd.exe';
  59. return spawnSync(comspec, ['/d', '/s', '/c', command, ...args], base);
  60. }
  61. // 刚安装 bun 的终端尚未刷新 PATH,因此在 PATH 之外再探测约定安装位置。
  62. function bunCandidates() {
  63. const exe = process.platform === 'win32' ? 'bun.exe' : 'bun';
  64. const home = os.homedir();
  65. const roots = [process.env.BUN_INSTALL, path.join(home, '.bun')].filter(Boolean);
  66. if (process.platform === 'win32') roots.push('D:\\bun', 'C:\\bun');
  67. // Prefer a concrete binary on Windows. The PATH shim can launch through
  68. // cmd.exe and terminate the compile child before Bun reports its result.
  69. return [...roots.map(root => path.join(root, 'bin', exe)), 'bun'];
  70. }
  71. function detectBun() {
  72. for (const candidate of bunCandidates()) {
  73. if (candidate !== 'bun' && !fs.existsSync(candidate)) continue;
  74. const result = runCommand(candidate, ['--version'], { stdio: 'pipe' });
  75. if (result.status === 0) {
  76. return { available: true, version: String(result.stdout || '').trim(), binary: candidate };
  77. }
  78. }
  79. return { available: false, version: '', binary: '' };
  80. }
  81. function bunInstallHint() {
  82. return process.platform === 'win32'
  83. ? 'powershell -NoProfile -ExecutionPolicy Bypass -c "irm bun.sh/install.ps1 | iex"'
  84. : 'curl -fsSL https://bun.sh/install | bash';
  85. }
  86. function checkPreconditions() {
  87. const problems = [];
  88. const notes = [];
  89. const bun = detectBun();
  90. if (!bun.available) problems.push(`未检测到 bun,请先安装:${bunInstallHint()}`);
  91. else notes.push(`bun ${bun.version}${bun.binary === 'bun' ? '' : `(${bun.binary})`}`);
  92. if (!fs.existsSync(path.join(ROOT, ENTRY))) problems.push(`缺少培训入口 ${ENTRY}`);
  93. if (!fs.existsSync(path.join(ROOT, LICENSE_FILE))) problems.push(`缺少许可证文件 ${LICENSE_FILE}`);
  94. if (!fs.existsSync(path.join(ROOT, DELIVERY_VERSION_FILE))) problems.push(`缺少 Bun 交付版本文件 ${DELIVERY_VERSION_FILE}`);
  95. if (!fs.existsSync(path.join(ROOT, 'node_modules'))) problems.push('缺少 node_modules,请先执行 npm install');
  96. for (const file of STATIC_FILES) {
  97. if (!fs.existsSync(path.join(ROOT, 'mcp', 'src', 'dashboard', file))) {
  98. problems.push(`缺少前端资源 mcp/src/dashboard/${file}`);
  99. }
  100. }
  101. if (!fs.existsSync(path.join(ROOT, 'knowledge'))) problems.push('缺少 knowledge 目录');
  102. if (!fs.existsSync(path.join(ROOT, 'knowledge-base', 'catalog.json'))) problems.push('缺少 knowledge-base/catalog.json');
  103. notes.push(`node ${process.version}`);
  104. notes.push(`平台 ${os.platform()}-${os.arch()}`);
  105. return { problems, notes, bun };
  106. }
  107. function copyTree(source, destination) {
  108. const stat = fs.statSync(source);
  109. if (stat.isDirectory()) {
  110. fs.mkdirSync(destination, { recursive: true });
  111. for (const entry of fs.readdirSync(source)) {
  112. copyTree(path.join(source, entry), path.join(destination, entry));
  113. }
  114. return;
  115. }
  116. fs.mkdirSync(path.dirname(destination), { recursive: true });
  117. fs.copyFileSync(source, destination);
  118. }
  119. function writeFieldGuide(outDir) {
  120. const body = [
  121. '# 企微培训工作台(单文件版)',
  122. '',
  123. '## 快速开始',
  124. '',
  125. '### 第一步:配置认证 Token(必须!)',
  126. '',
  127. '**重要:不配置 Token 会导致所有功能无法使用,面板显示为空。**',
  128. '',
  129. '有两种配置方式(任选其一):',
  130. '',
  131. '#### 方式 A:手动编辑配置文件(推荐)',
  132. '',
  133. '1. 打开 `.env.local` 文件',
  134. '2. 找到 `QIWEI_AUTH_TOKEN=` 这一行',
  135. '3. 在等号后面粘贴你的 Fmode token(格式:`r:xxx` 或 `sk-xxx`)',
  136. '4. 保存文件',
  137. '',
  138. '**Token 获取方式:**',
  139. '- 独立工作台:在页面粘贴凭据,或写入包目录 `.env.local`',
  140. '- 飞马平台:登录后在个人中心获取',
  141. '- Fmode 控制台:https://server.fmode.cn/',
  142. '',
  143. '#### 方式 B:在工作台页面中配置',
  144. '',
  145. '1. 双击 `启动工作台.bat`(也可直接运行 `qiwei-workbench.exe`)',
  146. '2. 浏览器打开后,在页面中粘贴 token',
  147. '3. 点击验证',
  148. '',
  149. '### 第二步:启动工作台',
  150. '',
  151. '把整个文件夹拷到 Windows 电脑(**不要只拷 exe**),双击 `启动工作台.bat`。浏览器会自动打开工作台;重复双击会打开已经运行的工作台。',
  152. '',
  153. '### 第三步:完成企微登录',
  154. '',
  155. '按页面提示走完三步,**不需要安装 Bun,也不需要会改环境变量**:',
  156. '',
  157. '1. **确认席位**:新账号优先使用上游提供的 7 天试用席位;试用到期或没有试用时,流程页会引导开通服务。',
  158. '2. **企微扫码**:用企业微信扫二维码,必要时输入手机上的 6 位验证码。',
  159. '3. **开启监听**:回到工作台确认账号在线,加测试白名单,打开 AI 监听。',
  160. '',
  161. '## 现场启动',
  162. '',
  163. '1. 保持目录结构完整。',
  164. '2. 双击 `启动工作台.bat`。若 Windows 弹出「已保护你的电脑」,选「更多信息」→「仍要运行」。',
  165. '3. 浏览器打开后按上面四步操作。token 也可以预先写进 `.env.local` 的 `QIWEI_AUTH_TOKEN=`。',
  166. '',
  167. '## 目录说明',
  168. '',
  169. '| 路径 | 用途 |',
  170. '| --- | --- |',
  171. '| `启动工作台.bat` | 推荐的双击启动入口,失败时保留错误提示 |',
  172. '| `停止工作台.bat` | 一键关闭工作台及其监听进程,释放本机端口 |',
  173. '| `qiwei-workbench.exe` | 工作台与企微监听主程序 |',
  174. '| `.env.local` | **本机凭据(必须配置 Token!)** |',
  175. '| `.mcp.json` | MCP 客户端配置,已注册 `qiwei-assistant` |',
  176. '| `web/` | 前端页面资源 |',
  177. '| `knowledge/` | 客服知识库,可现场编辑 |',
  178. '| `qiwei.runtime.config.mjs` | 监听轮询配置 |',
  179. '| `outputs/` | 运行数据,首次启动后生成 |',
  180. '',
  181. '## MCP 工具',
  182. '',
  183. '包内 `.mcp.json` 已注册 `qiwei-assistant`,指向同目录的工作台程序。支持 MCP 的客户端可直接读取该配置;首次连接会自动完成初始化并发现企微工具。',
  184. '',
  185. '## 常见问题',
  186. '',
  187. '### Q1: 工作台打开后所有面板都是空的?',
  188. '',
  189. '**原因:** 没有配置 `QIWEI_AUTH_TOKEN`。',
  190. '',
  191. '**解决:** 编辑 `.env.local` 文件,在 `QIWEI_AUTH_TOKEN=` 后面填入你的 Fmode token,保存后刷新页面。',
  192. '',
  193. '### Q2: 在哪里获取 Fmode Token?',
  194. '',
  195. '- 独立工作台:在页面粘贴凭据,或写入包目录 `.env.local`',
  196. '- 如果是新机器:登录飞马平台(https://fmode.cn)或 Fmode 控制台(https://server.fmode.cn)获取',
  197. '',
  198. '### Q3: 扫码登录后显示离线?',
  199. '',
  200. '- 检查 `.env.local` 中 `QIWEI_GUID` 是否已自动填充',
  201. '- 刷新工作台页面',
  202. '- 查看控制台是否有错误提示',
  203. '',
  204. '## 常用命令',
  205. '',
  206. '```text',
  207. 'qiwei-workbench.exe 启动工作台与监听',
  208. 'qiwei-workbench.exe --port 4321 指定端口',
  209. 'qiwei-workbench.exe runtime status 查看监听状态',
  210. 'qiwei-workbench.exe runtime stop 停止监听',
  211. '```',
  212. '',
  213. '## 现场注意',
  214. '',
  215. '- 关闭工作台窗口不会停止监听,需要执行 `runtime stop`。',
  216. '- 智能回复依赖本机已安装的 Claude Code;未安装时消息仍会进入工作台,只是不自动生成草稿。',
  217. '- 防火墙若询问,请允许程序访问本机网络(127.0.0.1:4320 与 4310)。',
  218. '- `outputs/` 含真实客户会话,培训结束后请随包删除。',
  219. '',
  220. '## 语音克隆功能',
  221. '',
  222. '本版本已包含完整语音克隆能力(基于你的 Fmode Token):',
  223. '',
  224. '1. **录制参考音频**:5-30 秒本人录音(清晰、无背景噪音)',
  225. '2. **初始化声音档案**:工作台会自动保存到 `outputs/voice/`',
  226. '3. **合成并发送**:自动选择语气(自然/友好/致歉/关怀/提醒),编码为企微 SILK 格式并发送',
  227. '',
  228. '**注意**:语音合成使用 Fmode 飞马余额计费(9元/万字符),请确保余额充足。',
  229. '',
  230. ].join('\n');
  231. fs.writeFileSync(path.join(outDir, 'README.md'), body, 'utf8');
  232. }
  233. function writeEnvTemplate(outDir) {
  234. const target = path.join(outDir, '.env.local');
  235. if (fs.existsSync(target)) return;
  236. const body = [
  237. '# ============================================================',
  238. '# 企微培训工作台 - 本机凭据配置',
  239. '# ============================================================',
  240. '# 重要:本文件包含认证凭据,切勿提交到代码仓库或外传!',
  241. '#',
  242. '# 首次使用必须配置 QIWEI_AUTH_TOKEN,否则所有功能无法使用。',
  243. '# 可以在此手动填写,或在工作台页面(http://127.0.0.1:4320)中粘贴。',
  244. '',
  245. '# ---------- 必填项 ----------',
  246. '# Fmode 认证 token(必须!格式:r:xxx 或 sk-xxx)',
  247. '# 获取方式:',
  248. '# 1. 独立工作台:页面提交或包目录 .env.local',
  249. '# 2. 飞马平台:登录后在个人中心获取',
  250. '# 3. Fmode 控制台:https://server.fmode.cn/',
  251. 'QIWEI_AUTH_TOKEN=',
  252. '',
  253. '# ---------- 自动生成项(登录后自动填充)----------',
  254. '# 企微账号唯一标识(首次启动后自动生成)',
  255. 'QIWEI_UID=',
  256. '',
  257. '# 企微设备 GUID(扫码登录后自动保存)',
  258. 'QIWEI_GUID=',
  259. '',
  260. '# Fmode 网关地址(通常不需要修改)',
  261. 'QIWEI_API_BASE=https://server.fmode.cn/api/qiwei',
  262. '',
  263. '# ---------- 可选配置 ----------',
  264. '# 指定本机 Claude Code 可执行文件路径(用于智能回复)',
  265. '# CLAUDE_CODE_EXECUTABLE=',
  266. '',
  267. ].join('\n');
  268. fs.writeFileSync(target, body, 'utf8');
  269. }
  270. function writeWindowsLauncher(outDir) {
  271. const body = [
  272. '@echo off',
  273. 'setlocal',
  274. 'chcp 65001 >nul',
  275. 'cd /d "%~dp0"',
  276. 'title Fmode 企微智能助手工作台',
  277. 'if not exist "%~dp0qiwei-workbench.exe" (',
  278. ' echo [错误] 未找到 qiwei-workbench.exe,请保持交付文件夹完整。',
  279. ' pause',
  280. ' exit /b 2',
  281. ')',
  282. 'echo 正在启动工作台,请稍候...',
  283. '"%~dp0qiwei-workbench.exe" %*',
  284. 'set "EXIT_CODE=%ERRORLEVEL%"',
  285. 'if not "%EXIT_CODE%"=="0" (',
  286. ' echo.',
  287. ' echo 工作台启动失败,错误码:%EXIT_CODE%',
  288. ' echo 请保留本窗口内容并联系技术支持。',
  289. ' pause',
  290. ')',
  291. 'endlocal & exit /b %EXIT_CODE%',
  292. ].join('\r\n');
  293. fs.writeFileSync(path.join(outDir, '启动工作台.bat'), `${body}\r\n`, 'utf8');
  294. }
  295. function writeWindowsStopper(outDir) {
  296. const body = [
  297. '@echo off',
  298. 'setlocal',
  299. 'chcp 65001 >nul',
  300. 'cd /d "%~dp0"',
  301. 'title 停止 Fmode 企微智能助手工作台',
  302. 'echo 正在关闭工作台及其监听进程,请稍候...',
  303. 'taskkill /IM qiwei-workbench.exe /T /F >nul 2>&1',
  304. 'if "%ERRORLEVEL%"=="0" (',
  305. ' echo 工作台进程已关闭。',
  306. ') else (',
  307. ' echo 未发现正在运行的工作台进程。',
  308. ')',
  309. 'echo 端口 4310/4320/4321 将随工作台释放;如仍被占用,请稍后重试。',
  310. 'echo.',
  311. 'pause',
  312. 'endlocal',
  313. ].join('\r\n');
  314. fs.writeFileSync(path.join(outDir, '停止工作台.bat'), `${body}\r\n`, 'utf8');
  315. }
  316. function writeMcpConfig(options) {
  317. // Keep the delivered package portable. The executable resolves its own
  318. // package root from process.execPath, so the MCP client only needs a
  319. // package-local command and working directory.
  320. const command = String(options.target || '').includes('windows')
  321. ? '.\\qiwei-workbench.exe'
  322. : './qiwei-workbench';
  323. const config = {
  324. mcpServers: {
  325. 'qiwei-assistant': {
  326. command,
  327. args: ['mcp'],
  328. cwd: '.',
  329. env: {
  330. QIWEI_PACKAGE_ROOT: '.',
  331. QIWEI_WORKSPACE_ROOT: '.',
  332. QIWEI_OUTPUTS_DIR: './outputs',
  333. CLAUDE_CODE_WORKDIR: '.',
  334. QIWEI_RUNTIME_CONFIG: './qiwei.runtime.config.mjs',
  335. },
  336. },
  337. },
  338. };
  339. fs.writeFileSync(path.join(options.outDir, '.mcp.json'), `${JSON.stringify(config, null, 2)}\n`, 'utf8');
  340. }
  341. function deliveryPlatform(target) {
  342. const normalized = String(target || '').toLowerCase();
  343. if (normalized.includes('windows') || normalized.includes('win')) return 'win-x64';
  344. if (normalized.includes('darwin') && normalized.includes('arm64')) return 'mac-arm64';
  345. if (normalized.includes('darwin')) return 'mac-x64';
  346. if (normalized.includes('linux') && normalized.includes('arm64')) return 'linux-arm64';
  347. return 'linux-x64';
  348. }
  349. function writeVersionMetadata(outDir, target) {
  350. const deliveryVersion = JSON.parse(fs.readFileSync(path.join(ROOT, DELIVERY_VERSION_FILE), 'utf8').replace(/^\uFEFF/, ''));
  351. const metadata = {
  352. product: deliveryVersion.product || 'fmode-qiwei-training',
  353. version: String(deliveryVersion.version || '0.0.0'),
  354. platform: deliveryPlatform(target),
  355. buildTime: new Date().toISOString(),
  356. updateChannel: deliveryVersion.channel || 'stable',
  357. notes: deliveryVersion.notes || '',
  358. };
  359. fs.writeFileSync(path.join(outDir, VERSION_FILE), `${JSON.stringify(metadata, null, 2)}\n`, 'utf8');
  360. return metadata;
  361. }
  362. // 普通重打包保留现场配置,便于本地迭代;--fresh 用于发布,完全清空目标目录,
  363. // 防止把凭据、会话和其他运行数据带入交付包。
  364. const REBUILD_PRESERVED = new Set([
  365. '.env.local',
  366. 'outputs',
  367. 'qiwei.runtime.config.mjs',
  368. LICENSE_FILE,
  369. ]);
  370. function assertFreshTarget(outDir) {
  371. const resolved = path.resolve(outDir);
  372. const forbidden = new Set([ROOT, path.parse(ROOT).root, process.cwd()]);
  373. if (forbidden.has(resolved)) {
  374. throw new Error(`--fresh 拒绝清理危险目录:${resolved}`);
  375. }
  376. if (resolved.length < 8) {
  377. throw new Error(`--fresh 目标路径过短,拒绝清理:${resolved}`);
  378. }
  379. }
  380. function prepareOutDir(outDir, { fresh = false } = {}) {
  381. if (!fs.existsSync(outDir)) {
  382. fs.mkdirSync(outDir, { recursive: true });
  383. return;
  384. }
  385. if (fresh) {
  386. assertFreshTarget(outDir);
  387. for (const entry of fs.readdirSync(outDir)) {
  388. const target = path.join(outDir, entry);
  389. const stat = fs.lstatSync(target);
  390. if (stat.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
  391. else fs.unlinkSync(target);
  392. }
  393. return;
  394. }
  395. for (const entry of fs.readdirSync(outDir)) {
  396. if (REBUILD_PRESERVED.has(entry)) continue;
  397. const target = path.join(outDir, entry);
  398. // Node 24 on Windows can crash while rmSync removes a regular file whose
  399. // name contains CJK characters. Use unlinkSync for files and reserve the
  400. // recursive path for directories.
  401. const stat = fs.lstatSync(target);
  402. if (stat.isDirectory()) fs.rmSync(target, { recursive: true, force: true });
  403. else fs.unlinkSync(target);
  404. }
  405. }
  406. function buildExecutable(options) {
  407. const outFile = path.join(options.outDir, EXE_NAME);
  408. const args = [
  409. 'build',
  410. ENTRY,
  411. '--compile',
  412. '--target', options.target,
  413. '--outfile', outFile,
  414. ];
  415. if (options.externalVoice) {
  416. for (const item of VOICE_EXTERNALS) args.push('--external', item);
  417. }
  418. process.stdout.write(`\n执行:${options.bunBinary} ${args.join(' ')}\n\n`);
  419. // Bun's Windows compiler can close an inherited PTY before spawnSync returns.
  420. // Capture its output and forward it after completion so the build process gets
  421. // a reliable exit status in Codex, deployment scripts, and ordinary terminals.
  422. const result = runCommand(options.bunBinary, args, { stdio: 'pipe' });
  423. if (result.stdout) process.stdout.write(result.stdout);
  424. if (result.stderr) process.stderr.write(result.stderr);
  425. if (result.status !== 0) throw new Error('bun build --compile 失败,请查看上方输出');
  426. return outFile;
  427. }
  428. function copyRuntimeAssets(options) {
  429. fs.copyFileSync(path.join(ROOT, LICENSE_FILE), path.join(options.outDir, LICENSE_FILE));
  430. const webDir = path.join(options.outDir, 'web');
  431. fs.mkdirSync(webDir, { recursive: true });
  432. for (const file of STATIC_FILES) {
  433. fs.copyFileSync(path.join(ROOT, 'mcp', 'src', 'dashboard', file), path.join(webDir, file));
  434. }
  435. copyTree(path.join(ROOT, 'knowledge'), path.join(options.outDir, 'knowledge'));
  436. // The catalog is the runtime registry for the shipped knowledge tree. Keep
  437. // the physical paths intact; the updater preserves the customer-edited tree.
  438. copyTree(path.join(ROOT, 'knowledge-base'), path.join(options.outDir, 'knowledge-base'));
  439. copyVoiceBinaries(options);
  440. const runtimeConfig = path.join(options.outDir, 'qiwei.runtime.config.mjs');
  441. if (!fs.existsSync(runtimeConfig)) {
  442. fs.copyFileSync(path.join(ROOT, 'qiwei.runtime.config.example.mjs'), runtimeConfig);
  443. }
  444. writeEnvTemplate(options.outDir);
  445. writeWindowsLauncher(options.outDir);
  446. writeWindowsStopper(options.outDir);
  447. writeMcpConfig(options);
  448. writeVersionMetadata(options.outDir, options.target);
  449. writeFieldGuide(options.outDir);
  450. }
  451. function targetVoicePlatform(target) {
  452. const normalized = String(target || '').toLowerCase();
  453. if (normalized.includes('windows') || normalized.includes('win')) return 'win32-x64';
  454. if (normalized.includes('darwin') && normalized.includes('arm64')) return 'darwin-arm64';
  455. if (normalized.includes('darwin')) return 'darwin-x64';
  456. if (normalized.includes('linux') && normalized.includes('arm64')) return 'linux-arm64';
  457. return 'linux-x64';
  458. }
  459. function copyVoiceBinaries(options) {
  460. const platform = targetVoicePlatform(options.target);
  461. const suffix = platform.startsWith('win32') ? '.exe' : '';
  462. const sources = {
  463. ffmpeg: path.join(ROOT, 'node_modules', '@ffmpeg-installer', platform, `ffmpeg${suffix}`),
  464. ffprobe: path.join(ROOT, 'node_modules', '@ffprobe-installer', platform, `ffprobe${suffix}`),
  465. encoder: path.join(ROOT, 'node_modules', `@binsee/wx-voice-silk-${platform}`, `encoder${suffix}`),
  466. decoder: path.join(ROOT, 'node_modules', `@binsee/wx-voice-silk-${platform}`, `decoder${suffix}`),
  467. };
  468. const missing = Object.entries(sources).filter(([, source]) => !fs.existsSync(source));
  469. if (missing.length) throw new Error(`缺少 ${platform} 语音运行时:${missing.map(([kind, source]) => `${kind} (${source})`).join(', ')}`);
  470. const destination = path.join(options.outDir, 'voice-binaries', platform);
  471. fs.mkdirSync(destination, { recursive: true });
  472. for (const [kind, source] of Object.entries(sources)) {
  473. fs.copyFileSync(source, path.join(destination, path.basename(source)));
  474. }
  475. }
  476. function summarize(outFile, options) {
  477. const sizeMb = (fs.statSync(outFile).size / (1024 * 1024)).toFixed(1);
  478. const version = JSON.parse(fs.readFileSync(path.join(options.outDir, VERSION_FILE), 'utf8')).version;
  479. const voiceNote = options.externalVoice
  480. ? `已外置语音依赖:${VOICE_EXTERNALS.join(', ')}(需手动安装 node_modules)`
  481. : '已打包语音依赖(ffmpeg/wx-voice),支持完整语音克隆能力';
  482. process.stdout.write([
  483. '',
  484. '打包完成。',
  485. ` 产物目录:${options.outDir}`,
  486. ` 版本:${version}`,
  487. ` 可执行文件:${EXE_NAME}(${sizeMb} MB)`,
  488. ` 目标平台:${options.target}`,
  489. ` ${voiceNote}`,
  490. '',
  491. '下一步:把整个产物目录拷到培训机,填写 .env.local 后双击运行。',
  492. '',
  493. ].join('\n'));
  494. }
  495. function main() {
  496. const options = parseArgs();
  497. const { problems, notes, bun } = checkPreconditions();
  498. options.bunBinary = bun.binary;
  499. process.stdout.write(`打包环境:${notes.join(' · ')}\n`);
  500. if (problems.length) {
  501. process.stderr.write(`\n打包前置条件未满足:\n${problems.map(item => ` - ${item}`).join('\n')}\n\n`);
  502. process.exit(1);
  503. }
  504. if (options.check) {
  505. process.stdout.write('前置条件检查通过,可以执行打包。\n');
  506. return;
  507. }
  508. prepareOutDir(options.outDir, { fresh: options.fresh });
  509. const outFile = buildExecutable(options);
  510. copyRuntimeAssets(options);
  511. summarize(outFile, options);
  512. }
  513. try {
  514. main();
  515. } catch (error) {
  516. process.stderr.write(`打包失败:${error.message}\n`);
  517. process.exit(1);
  518. }