"use strict"; /** * Node 流水线宿主能力(对齐 QuickJS __native / Rust js_runtime/native.rs) */ const fs = require("fs"); const path = require("path"); const os = require("os"); const crypto = require("crypto"); const { spawn, execFileSync } = require("child_process"); const https = require("https"); const http = require("http"); const TEMP_DIR = path.join(os.tmpdir(), "aiclient-node-pipeline"); fs.mkdirSync(TEMP_DIR, { recursive: true }); function sleep(ms) { return new Promise((r) => setTimeout(r, ms)); } function tempPath(prefix, ext) { return path.join(TEMP_DIR, `${prefix}-${Date.now()}-${Math.random().toString(36).slice(2)}.${ext || "tmp"}`); } function deleteFile(p) { try { if (p && fs.existsSync(p)) fs.unlinkSync(p); } catch (_) { /* ignore */ } } function runCommand(cmd, args, timeoutMs = 300000) { return new Promise((resolve, reject) => { const child = spawn(cmd, args, { stdio: ["ignore", "pipe", "pipe"] }); let stderr = ""; child.stderr.on("data", (d) => { stderr += d.toString(); }); const timer = setTimeout(() => { child.kill(); reject(new Error(`命令超时: ${cmd}`)); }, timeoutMs); child.on("error", (e) => { clearTimeout(timer); reject(e); }); child.on("close", (code) => { clearTimeout(timer); if (code === 0) resolve(); else reject(new Error(`${cmd} 退出码 ${code}: ${stderr.slice(0, 500)}`)); }); }); } function exeName() { return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg"; } function whichSync(cmd) { try { if (process.platform === "win32") { const out = execFileSync("where", [cmd], { encoding: "utf8", windowsHide: true }); const line = out.split(/\r?\n/).map((s) => s.trim()).find(Boolean); return line && fs.existsSync(line) ? line : null; } const out = execFileSync("which", [cmd], { encoding: "utf8" }); const line = out.trim().split(/\r?\n/)[0]; return line && fs.existsSync(line) ? line : null; } catch { return null; } } /** 与 Rust `ffmpeg::locate_ffmpeg` 顺序对齐 */ function locateFfmpeg() { const name = exeName(); const envPath = process.env.AICLIENT_FFMPEG_PATH; if (envPath && fs.existsSync(envPath)) { return envPath; } const candidates = []; const nodeDir = path.dirname(process.execPath); candidates.push(path.join(nodeDir, "binaries", name)); candidates.push(path.join(nodeDir, name)); candidates.push(path.join(nodeDir, "..", "binaries", name)); candidates.push(path.join(nodeDir, "..", "..", "binaries", name)); candidates.push(path.join(nodeDir, "..", "..", "..", "binaries", name)); candidates.push(path.join(process.cwd(), "src-tauri", "binaries", name)); candidates.push(path.join(process.cwd(), "binaries", name)); for (const p of candidates) { try { if (fs.existsSync(p)) return p; } catch { /* ignore */ } } return whichSync(name); } async function ffmpegExtractAudio(videoPath) { const dest = tempPath("audio", "wav"); const ffmpeg = locateFfmpeg(); if (!ffmpeg) { throw new Error( "未找到 ffmpeg。请安装并加入 PATH,或将可执行文件放到 src-tauri/binaries/ffmpeg.exe," + "或在环境变量中设置 AICLIENT_FFMPEG_PATH(桌面端会在启动 Node 时自动注入已解析路径)", ); } await runCommand(ffmpeg, [ "-y", "-i", videoPath, "-vn", "-acodec", "pcm_s16le", "-ar", "16000", "-ac", "1", dest, ]); return dest; } function rfc1123Date() { return new Date().toUTCString(); } function signOss(secret, str) { return crypto.createHmac("sha1", secret).update(str).digest("base64"); } async function aliyunOssUpload(localPath, cfg) { const ak = cfg.accessKeyId; const sk = cfg.accessKeySecret; const bucket = cfg.bucket; const region = cfg.region; if (!ak || !sk || !bucket || !region) { return { success: false, error: "OSS 配置不完整" }; } const objectKey = `audio/${Date.now()}-${Math.random().toString(36).slice(2)}.wav`; const host = `${bucket}.${region}.aliyuncs.com`; const endpoint = (cfg.endpoint || `https://${host}`).replace(/\/$/, ""); const url = `${endpoint}/${objectKey}`; const body = fs.readFileSync(localPath); const date = rfc1123Date(); const contentType = "audio/wav"; const canonicalizedResource = `/${bucket}/${objectKey}`; const strToSign = `PUT\n\n${contentType}\n${date}\n${canonicalizedResource}`; const signature = signOss(sk, strToSign); const authorization = `OSS ${ak}:${signature}`; return new Promise((resolve) => { const u = new URL(url); const opts = { hostname: u.hostname, port: u.port || 443, path: u.pathname, method: "PUT", headers: { Date: date, "Content-Type": contentType, Authorization: authorization, "Content-Length": body.length, }, }; const req = https.request(opts, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { if (res.statusCode >= 200 && res.statusCode < 300) { resolve({ success: true, url }); } else { resolve({ success: false, error: `OSS HTTP ${res.statusCode}: ${Buffer.concat(chunks).toString().slice(0, 200)}`, }); } }); }); req.on("error", (e) => resolve({ success: false, error: e.message })); req.write(body); req.end(); }); } async function fetchJson(url, options = {}) { const res = await fetch(url, options); const text = await res.text(); let data; try { data = JSON.parse(text); } catch { data = { _raw: text }; } if (!res.ok) { throw new Error(`HTTP ${res.status}: ${text.slice(0, 300)}`); } return data; } async function aliyunAsrFiletrans(audioUrl, opts) { const apiKey = opts.apiKey || opts.api_key; const model = opts.model || "qwen3-asr-flash-filetrans"; if (!apiKey) { return { success: false, error: "缺少 DashScope apiKey" }; } const submit = await fetchJson( "https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription", { method: "POST", headers: { Authorization: `Bearer ${apiKey}`, "Content-Type": "application/json", "X-DashScope-Async": "enable", }, body: JSON.stringify({ model, input: { file_urls: [audioUrl] }, parameters: {}, }), } ); const taskId = submit?.output?.task_id; if (!taskId) { return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` }; } for (let i = 0; i < 60; i++) { await sleep(5000); const poll = await fetchJson( `https://dashscope.aliyuncs.com/api/v1/tasks/${taskId}`, { headers: { Authorization: `Bearer ${apiKey}` } } ); const status = poll?.output?.task_status; if (status === "SUCCEEDED") { const transUrl = poll?.output?.results?.[0]?.transcription_url; if (!transUrl) { return { success: false, error: "ASR 成功但无 transcription_url" }; } const trans = await fetchJson(transUrl); let fullText = ""; if (trans.transcripts && trans.transcripts.length) { fullText = trans.transcripts.map((t) => t.text || "").filter(Boolean).join("\n"); } else if (trans.text) { fullText = trans.text; } return { success: true, text: fullText }; } if (status === "FAILED" || status === "UNKNOWN") { return { success: false, error: poll?.output?.message || "ASR 任务失败", }; } } return { success: false, error: "ASR 轮询超时" }; } async function localAsr(audioPath, info) { const baseUrl = (info.baseUrl || info.localPath || "").replace(/\/$/, ""); if (!baseUrl) { return { success: false, error: "未配置本地 ASR 服务地址" }; } const asrPath = info.asrPath || "/asr"; const url = `${baseUrl}${asrPath.startsWith("/") ? asrPath : `/${asrPath}`}`; const boundary = `----aiclient${Date.now()}`; const fileBuf = fs.readFileSync(audioPath); const head = Buffer.from( `--${boundary}\r\nContent-Disposition: form-data; name="audio"; filename="audio.wav"\r\nContent-Type: audio/wav\r\n\r\n`, "utf8" ); const tail = Buffer.from(`\r\n--${boundary}--\r\n`, "utf8"); const body = Buffer.concat([head, fileBuf, tail]); const u = new URL(url); const client = u.protocol === "https:" ? https : http; return new Promise((resolve) => { const req = client.request( { hostname: u.hostname, port: u.port || (u.protocol === "https:" ? 443 : 80), path: u.pathname + u.search, method: "POST", headers: { "Content-Type": `multipart/form-data; boundary=${boundary}`, "Content-Length": body.length, }, }, (res) => { const chunks = []; res.on("data", (c) => chunks.push(c)); res.on("end", () => { try { const raw = Buffer.concat(chunks).toString("utf8"); const data = JSON.parse(raw); if (data.code === 0 || data.success) { const records = data.data?.data?.records || data.data?.records || data.records || []; const text = Array.isArray(records) ? records.map((r) => r.text || "").join("") : data.text || data.data?.text || ""; resolve({ success: true, text: String(text) }); } else { resolve({ success: false, error: data.msg || data.error || raw.slice(0, 200) }); } } catch (e) { resolve({ success: false, error: e.message }); } }); } ); req.on("error", (e) => resolve({ success: false, error: e.message })); req.write(body); req.end(); }); } async function openaiChat(req) { let apiUrl = (req.apiUrl || "").replace(/\/$/, ""); if (!apiUrl.includes("/chat/completions")) { apiUrl = apiUrl.endsWith("/v1") ? `${apiUrl}/chat/completions` : `${apiUrl}/v1/chat/completions`; } const body = { model: req.model, messages: req.messages, stream: false, temperature: typeof req.temperature === "number" ? req.temperature : 0.8, max_tokens: req.max_tokens || req.maxTokens || 2000, }; try { const data = await fetchJson(apiUrl, { method: "POST", headers: { Authorization: `Bearer ${req.apiKey}`, "Content-Type": "application/json", }, body: JSON.stringify(body), }); const content = data?.choices?.[0]?.message?.content; if (!content) { return { success: false, error: "模型返回为空" }; } return { success: true, content: String(content).trim() }; } catch (e) { return { success: false, error: e.message }; } } module.exports = { tempPath, deleteFile, ffmpegExtractAudio, aliyunOssUpload, aliyunAsrFiletrans, localAsr, openaiChat, };