| 123456789101112131415161718192021222324252627282930313233343536373839404142434445464748495051525354555657585960616263646566676869707172737475767778798081828384858687888990919293949596979899100101102103104105106107108109110111112113114115116117118119120121122123124125126127128129130131132133134135136137138139140141142143144145146147148149150151152153154155156157158159160161162163164165166167168169170171172173174175176177178179180181182183184185186187188189190191192193194195196197198199200201202203204205206207208209210211212213214215216217218219220221222223224225226227228229230231232233234235236237238239240241242243244245246247248249250251252253254255256257258259260261262263264265266267268269270271272273274275276277278279280281282283284285286287288289290291292293294295296297298299300301302303304305306307308309310311312313314315316317318319320321322323324325326327328329330331332333334335336337338339340341342343344345346347348349350351352353354355356357358359360361362363364365366367368369370371372373374375376377378379380381382383384385386387388389390391392393394395396397398399400401402403404405406407408409410411412413414415416417418419420421422423424425426427428429430431432433434435436437438439440441442443444445446447448449450451452453454455456457458459460461462463464465466467468469470471472473474475476477478479480481482483484485486487488489490491492493494495496497498499500501502503504505506507508509510511512513514515516517518519520521522523524525526527528529530531532533534535536537538539540541542543544545546547548549550551552553554555556 |
- #!/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 <clean-dir>
- 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);
- }
|