458 lines
14 KiB
JavaScript
458 lines
14 KiB
JavaScript
"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");
|
||
}
|
||
|
||
/** 私有桶:ASR 需可公网 GET 的签名 URL(对齐 Electron generateSignedUrl) */
|
||
function ossSignedGetUrl(cfg, objectKey, expiresSec = 3600) {
|
||
const expireTime = Math.floor(Date.now() / 1000) + expiresSec;
|
||
const resource = `/${cfg.bucket}/${objectKey}`;
|
||
const stringToSign = `GET\n\n\n${expireTime}\n${resource}`;
|
||
const signature = encodeURIComponent(signOss(cfg.accessKeySecret, stringToSign));
|
||
const host = `${cfg.bucket}.${cfg.region}.aliyuncs.com`;
|
||
return `https://${host}/${objectKey}?OSSAccessKeyId=${encodeURIComponent(cfg.accessKeyId)}&Expires=${expireTime}&Signature=${signature}`;
|
||
}
|
||
|
||
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) {
|
||
const signedUrl = ossSignedGetUrl(
|
||
{ accessKeyId: ak, accessKeySecret: sk, bucket, region },
|
||
objectKey,
|
||
3600,
|
||
);
|
||
resolve({ success: true, url: signedUrl });
|
||
} 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();
|
||
});
|
||
}
|
||
|
||
/** filetrans: output.result;批量模型: output.results[0] */
|
||
function extractTranscriptionUrl(output) {
|
||
if (!output || typeof output !== "object") return null;
|
||
if (output.result?.transcription_url) return output.result.transcription_url;
|
||
const results = output.results;
|
||
if (Array.isArray(results) && results[0]?.transcription_url) {
|
||
return results[0].transcription_url;
|
||
}
|
||
return null;
|
||
}
|
||
|
||
function parseTranscriptionText(trans) {
|
||
let fullText = "";
|
||
if (trans?.transcripts?.length) {
|
||
for (const t of trans.transcripts) {
|
||
if (t.sentences?.length) {
|
||
for (const s of t.sentences) {
|
||
if (s.text) {
|
||
if (fullText) fullText += "\n";
|
||
fullText += s.text;
|
||
}
|
||
}
|
||
} else if (t.text) {
|
||
if (fullText) fullText += "\n";
|
||
fullText += t.text;
|
||
}
|
||
}
|
||
} else if (trans?.text) {
|
||
fullText = trans.text;
|
||
}
|
||
return fullText;
|
||
}
|
||
|
||
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 fileUrl = String(audioUrl || "").trim();
|
||
if (!/^https?:\/\//i.test(fileUrl)) {
|
||
return { success: false, error: "ASR 需要可访问的 http(s) 音频 URL(请检查 OSS 上传与签名)" };
|
||
}
|
||
const input =
|
||
model === "qwen3-asr-flash-filetrans"
|
||
? { file_url: fileUrl }
|
||
: { file_urls: [fileUrl] };
|
||
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,
|
||
parameters: {
|
||
channel_id: [0],
|
||
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 = extractTranscriptionUrl(poll?.output);
|
||
if (!transUrl) {
|
||
log("error", "voice recognition output", poll?.output);
|
||
return {
|
||
success: false,
|
||
error: "ASR 成功但无 transcription_url(期望 output.result 或 output.results[0])",
|
||
};
|
||
}
|
||
const trans = await fetchJson(transUrl);
|
||
const fullText = parseTranscriptionText(trans);
|
||
return { success: true, text: fullText };
|
||
}
|
||
if (status === "FAILED" || status === "UNKNOWN") {
|
||
const detail =
|
||
poll?.output?.message ||
|
||
poll?.output?.code ||
|
||
poll?.message ||
|
||
(poll?.output ? JSON.stringify(poll.output).slice(0, 300) : null);
|
||
return {
|
||
success: false,
|
||
error: detail || "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,
|
||
};
|