Files
yaoyaoai/scripts/nodejs/pipeline_native.js
fengchuanhn@gmail.com caaa898d90 1
2026-05-18 00:27:16 +08:00

401 lines
12 KiB
JavaScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"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 });
/** Tauri 子进程优先 __native.log独立 node 调试时回退 console */
function log(level, msg, fields) {
const payload =
fields == null
? null
: typeof fields === "object" && !Array.isArray(fields)
? fields
: { value: fields };
if (globalThis.__native?.log) {
globalThis.__native.log(level, msg, payload);
return;
}
const tag = `[pipeline_native.${level}] ${msg}`;
if (payload != null) {
if (level === "error") console.error(tag, payload);
else if (level === "warn") console.warn(tag, payload);
else console.log(tag, payload);
} else if (level === "error") console.error(tag);
else if (level === "warn") console.warn(tag);
else console.log(tag);
}
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";
log("info", "apiKey",apiKey);
log("info", "model",model);
log("info", "audioUrl",audioUrl);
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: {
channel_id: [0],
// 音频通道ID
enable_itn: false
// 是否启用逆文本归一化
}
}),
}
);
const taskId = submit?.output?.task_id;
if (!taskId) {
return { success: false, error: `ASR 提交失败: ${JSON.stringify(submit).slice(0, 200)}` };
}
log("info", "voice recognition taskId",taskId);
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;
log("info", "voice recognition status",status);
if (status === "SUCCEEDED") {
const transUrl = poll?.output?.results?.[0]?.transcription_url;
if (!transUrl) {
log("error", "voice recognition transUrl",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,
};