22
This commit is contained in:
321
scripts/nodejs/pipeline_native.js
Normal file
321
scripts/nodejs/pipeline_native.js
Normal file
@@ -0,0 +1,321 @@
|
||||
"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 } = 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 locateFfmpeg() {
|
||||
if (process.env.AICLIENT_FFMPEG_PATH && fs.existsSync(process.env.AICLIENT_FFMPEG_PATH)) {
|
||||
return process.env.AICLIENT_FFMPEG_PATH;
|
||||
}
|
||||
return process.platform === "win32" ? "ffmpeg.exe" : "ffmpeg";
|
||||
}
|
||||
|
||||
async function ffmpegExtractAudio(videoPath) {
|
||||
const dest = tempPath("audio", "wav");
|
||||
const ffmpeg = locateFfmpeg();
|
||||
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,
|
||||
};
|
||||
Reference in New Issue
Block a user