This commit is contained in:
fengchuanhn@gmail.com
2026-05-17 12:54:13 +08:00
parent 1b8ca10ec9
commit 3db327d93b
38 changed files with 2820 additions and 14 deletions

View 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,
};