This commit is contained in:
fengchuanhn@gmail.com
2026-05-16 23:03:27 +08:00
parent c0ddaceb43
commit e6c8c18be0
6 changed files with 519 additions and 0 deletions

1
.gitignore vendored
View File

@@ -23,3 +23,4 @@ dist-ssr
*.sln *.sln
*.sw? *.sw?
src-tauri/binaries/ src-tauri/binaries/
src-tauri/resources/nodejs/

View File

@@ -1,2 +1,3 @@
pub mod auth; pub mod auth;
pub mod nodejs;
pub mod quickjs; pub mod quickjs;

View File

@@ -0,0 +1,120 @@
//! Node.js 流水线命令:脚本由后端 `/api/v1/nodejs-scripts` 下发,
//! 由打包在 `resources/nodejs/node.exe` 的 Node 解释器执行。
//!
//! 前端用法示例:
//!
//! ```ignore
//! import { invoke } from "@tauri-apps/api/core";
//! import { listen } from "@tauri-apps/api/event";
//!
//! const off = await listen("nodejs:event", (e) => console.log(e.payload));
//! const result = await invoke("run_nodejs_script", {
//! scriptName: "douyin_pipeline.js",
//! params: { shareText: "...", modelConfig: {...} },
//! });
//! off();
//! ```
//!
//! 与 QuickJS 流水线对比:
//! - 入口约定:`globalThis.__nodejsMain(params)`QuickJS 那边是 `__quickjsMain`
//! - 事件通道:`nodejs:event`QuickJS 那边是 `quickjs:event`payload 形状一致
//! - __native 只暴露 `emitProgress` / `log` 两个能力,其它 IO / 网络
//! 直接用 Node 自带 API 即可,无需 Rust 端再封一遍桥。
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Window};
use tokio::sync::mpsc;
use crate::js_runtime::PipelineEvent;
use crate::nodejs;
const EVENT_NAME: &str = "nodejs:event";
#[derive(Debug, Serialize, Clone)]
#[serde(tag = "type", rename_all = "camelCase")]
pub enum FrontendEvent {
Progress {
#[serde(rename = "scriptName")]
script_name: String,
status: String,
},
Log {
#[serde(rename = "scriptName")]
script_name: String,
level: String,
msg: String,
#[serde(skip_serializing_if = "Option::is_none")]
fields: Option<Value>,
},
}
fn spawn_event_pump(
app: AppHandle,
window: Window,
script_name: String,
mut rx: mpsc::UnboundedReceiver<PipelineEvent>,
) -> tokio::task::JoinHandle<()> {
let win_label = window.label().to_string();
tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let payload = match ev {
PipelineEvent::Progress(s) => FrontendEvent::Progress {
script_name: script_name.clone(),
status: s,
},
PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log {
script_name: script_name.clone(),
level,
msg,
fields,
},
};
let _ = app.emit_to(&win_label, EVENT_NAME, payload);
}
})
}
/// 按脚本名跑:去后端拉 `scriptName`,包装 runnerspawn 子进程,回送结果。
#[tauri::command]
pub async fn run_nodejs_script(
app: AppHandle,
window: Window,
script_name: String,
params: Value,
) -> Result<Value, String> {
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
let pump = spawn_event_pump(app, window, script_name.clone(), rx);
let result = nodejs::run_node_script(script_name, params, tx)
.await
.map_err(|e| e.to_string())?;
pump.await.ok();
Ok(result)
}
/// 调试:直接把传入的 source 当用户脚本喂给 runner不走后端。
#[tauri::command]
pub async fn run_nodejs_script_source(
app: AppHandle,
window: Window,
script_source: String,
params: Value,
) -> Result<Value, String> {
let (tx, rx) = mpsc::unbounded_channel::<PipelineEvent>();
let pump = spawn_event_pump(app, window, "<debug-source>".to_string(), rx);
let result = nodejs::run_node_script_source(script_source, params, tx)
.await
.map_err(|e| e.to_string())?;
pump.await.ok();
Ok(result)
}
/// 列出后端 `scripts/nodejs` 目录下的 `.js` 文件名。
#[tauri::command]
pub async fn list_nodejs_scripts() -> Result<Vec<String>, String> {
nodejs::list_scripts().await.map_err(|e| e.to_string())
}

View File

@@ -5,6 +5,7 @@ pub mod commands;
pub mod ffmpeg; pub mod ffmpeg;
pub mod http; pub mod http;
pub mod js_runtime; pub mod js_runtime;
pub mod nodejs;
pub mod oss; pub mod oss;
use tauri::Manager; use tauri::Manager;
@@ -28,6 +29,9 @@ pub fn run() {
commands::quickjs::run_quickjs_script, commands::quickjs::run_quickjs_script,
commands::quickjs::run_quickjs_script_source, commands::quickjs::run_quickjs_script_source,
commands::quickjs::list_quickjs_scripts, commands::quickjs::list_quickjs_scripts,
commands::nodejs::run_nodejs_script,
commands::nodejs::run_nodejs_script_source,
commands::nodejs::list_nodejs_scripts,
]) ])
.run(tauri::generate_context!()) .run(tauri::generate_context!())
.expect("error while running tauri application"); .expect("error while running tauri application");

392
src-tauri/src/nodejs.rs Normal file
View File

@@ -0,0 +1,392 @@
//! Bundled Node.js sidecar runner.
//!
//! 与 QuickJS 路径完全对齐:
//! - 脚本源码由后端 `GET /api/v1/nodejs-scripts?name=<file>.js` 下发,
//! 约定响应形如 `{ ok: true, data: { source: "..." } }`
//! - 在 `src-tauri/resources/nodejs/node.exe`(开发期)/ 同目录
//! `resources/nodejs/node.exe`(打包后)的 Node 解释器中执行;
//! - 进度 / 日志由用户脚本通过 `globalThis.__native.emitProgress(s)`
//! / `__native.log(level, msg, fields)` 写到 stderrRust 这边解析后
//! 转成 `PipelineEvent` 推给前端;
//! - 用户脚本的最终结果通过赋值 / `return` 的方式(约定函数名为
//! `globalThis.__nodejsMain(params)`返回runner 把 JSON.stringify 后
//! 的结果以 `__RESULT__<json>` 这一行写到 stdoutRust 抓回来反序列化。
//!
//! 这套协议有意保持极小:跟 QuickJS 的 `globalThis.__quickjsMain` / `__native`
//! 看起来完全对称,方便用户在两种运行时之间迁移脚本。
use std::path::PathBuf;
use std::process::Stdio;
use serde_json::Value;
use thiserror::Error;
use tokio::io::{AsyncBufReadExt, AsyncWriteExt, BufReader};
use tokio::process::Command;
use tokio::sync::mpsc::UnboundedSender;
use uuid::Uuid;
use crate::js_runtime::PipelineEvent;
#[derive(Debug, Error)]
pub enum NodeError {
#[error("node.exe 未找到(请检查 resources/nodejs/ 是否存在)")]
NotFound,
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("拉取脚本失败: {0}")]
FetchScript(String),
#[error("node 进程非零退出 status={status:?}, stderr={stderr}")]
NonZero { status: Option<i32>, stderr: String },
#[error("脚本未输出 __RESULT__ 行")]
NoResult,
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
// ---------------------------------------------------------------------------
// 定位 node.exe
// ---------------------------------------------------------------------------
fn exe_name() -> &'static str {
if cfg!(windows) { "node.exe" } else { "node" }
}
pub fn locate_node() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_NODE_PATH") {
let pb = PathBuf::from(p);
if pb.exists() {
return Some(pb);
}
}
if let Ok(exe) = std::env::current_exe() {
if let Some(dir) = exe.parent() {
let c = dir.join("resources").join("nodejs").join(exe_name());
if c.exists() {
return Some(c);
}
}
}
let dev = PathBuf::from("src-tauri/resources/nodejs").join(exe_name());
if dev.exists() {
return Some(dev);
}
let dev2 = PathBuf::from("resources/nodejs").join(exe_name());
if dev2.exists() {
return Some(dev2);
}
Some(PathBuf::from(exe_name()))
}
/// 用于设置 `cwd`:让用户脚本里的 `require('xxx')` 能解析到
/// `resources/nodejs/node_modules/` 内置依赖。
pub fn nodejs_dir() -> Option<PathBuf> {
locate_node().and_then(|p| p.parent().map(|x| x.to_path_buf()))
}
// ---------------------------------------------------------------------------
// Runner 模板
//
// HEADER 安装 __native 桥,开始 IIFE 异步函数;
// 用户脚本原样夹在中间(通常用户脚本里会赋值 globalThis.__nodejsMain
// FOOTER 调用 __nodejsMain(params)、捕获异常、写 __RESULT__。
// ---------------------------------------------------------------------------
const RUNNER_HEADER: &str = r#"
// ---- aiclient auto-generated nodejs runner (do not edit) ----
"use strict";
(function () {
function emit(obj) {
try {
process.stderr.write("__EVT__" + JSON.stringify(obj) + "\n");
} catch (_) {}
}
globalThis.__native = {
emitProgress(status) {
emit({ type: "progress", status: String(status) });
},
log(level, msg, fields) {
emit({
type: "log",
level: String(level || "info"),
msg: String(msg),
fields: fields == null ? null : fields,
});
},
};
})();
(async () => {
// 1) read params JSON from stdin (one-shot)
let raw = "";
process.stdin.setEncoding("utf8");
for await (const chunk of process.stdin) raw += chunk;
const params = raw ? JSON.parse(raw) : null;
// 2) inline user script begins ↓↓↓
"#;
const RUNNER_FOOTER: &str = r#"
// ↑↑↑ inline user script ends
// 3) dispatch to __nodejsMain(params)
if (typeof globalThis.__nodejsMain !== "function") {
process.stderr.write(
"__EVT__" + JSON.stringify({
type: "log",
level: "error",
msg: "脚本未注册 globalThis.__nodejsMain",
}) + "\n"
);
process.exit(1);
}
try {
const result = await globalThis.__nodejsMain(params);
process.stdout.write(
"__RESULT__" + JSON.stringify(result === undefined ? null : result) + "\n"
);
process.exit(0);
} catch (e) {
const msg = (e && (e.stack || e.message)) || String(e);
process.stderr.write(
"__EVT__" + JSON.stringify({ type: "log", level: "error", msg }) + "\n"
);
process.exit(2);
}
})();
"#;
fn build_runner(user_source: &str) -> String {
let mut s = String::with_capacity(
RUNNER_HEADER.len() + user_source.len() + RUNNER_FOOTER.len(),
);
s.push_str(RUNNER_HEADER);
s.push_str(user_source);
s.push_str(RUNNER_FOOTER);
s
}
// ---------------------------------------------------------------------------
// 从后端拉脚本源码
// ---------------------------------------------------------------------------
fn api_base() -> String {
std::env::var("AICLIENT_API_BASE").unwrap_or_else(|_| "http://127.0.0.1:8001".to_string())
}
async fn fetch_node_script_source(script_name: &str) -> Result<String, NodeError> {
let base = api_base().trim_end_matches('/').to_string();
let q = urlencoding::encode(script_name);
let url = format!("{base}/api/v1/nodejs-scripts?name={q}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let body = res
.text()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let v: Value = serde_json::from_str(&body)
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
let msg = v
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("未知错误");
return Err(NodeError::FetchScript(msg.to_string()));
}
let source = v
.get("data")
.and_then(|d| d.get("source"))
.and_then(|s| s.as_str())
.ok_or_else(|| NodeError::FetchScript("响应缺少 data.source".into()))?;
Ok(source.to_string())
}
/// 后端 `scripts/nodejs` 目录下的 `.js` 文件名列表。
pub async fn list_scripts() -> Result<Vec<String>, NodeError> {
let base = api_base().trim_end_matches('/').to_string();
let url = format!("{base}/api/v1/nodejs-scripts/list");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let body = res
.text()
.await
.map_err(|e| NodeError::FetchScript(e.to_string()))?;
let v: Value = serde_json::from_str(&body)
.map_err(|e| NodeError::FetchScript(format!("响应非 JSON: {e}")))?;
if !v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false) {
let msg = v
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("未知错误");
return Err(NodeError::FetchScript(msg.to_string()));
}
let names = v
.get("data")
.and_then(|d| d.get("names"))
.and_then(|n| n.as_array())
.map(|arr| {
arr.iter()
.filter_map(|x| x.as_str().map(|s| s.to_string()))
.collect::<Vec<_>>()
})
.unwrap_or_default();
Ok(names)
}
// ---------------------------------------------------------------------------
// 公开入口
// ---------------------------------------------------------------------------
/// 按脚本名运行:去后端拉源码 → 包装 runner → 启动 node → 收集结果。
pub async fn run_node_script(
script_name: String,
params: Value,
events: UnboundedSender<PipelineEvent>,
) -> Result<Value, NodeError> {
let user_source = fetch_node_script_source(&script_name).await?;
run_inline_source(user_source, params, events).await
}
/// 调试入口:直接传源码字符串,跳过后端拉取。
pub async fn run_node_script_source(
source: String,
params: Value,
events: UnboundedSender<PipelineEvent>,
) -> Result<Value, NodeError> {
run_inline_source(source, params, events).await
}
async fn run_inline_source(
user_source: String,
params: Value,
events: UnboundedSender<PipelineEvent>,
) -> Result<Value, NodeError> {
let node = locate_node().ok_or(NodeError::NotFound)?;
let cwd = nodejs_dir().unwrap_or_else(std::env::temp_dir);
// 写到 OS 临时目录而非 resources/,避免污染只读资源
let tmp_dir = std::env::temp_dir().join("aiclient-node");
tokio::fs::create_dir_all(&tmp_dir).await?;
let script_path = tmp_dir.join(format!("script-{}.cjs", Uuid::new_v4()));
tokio::fs::write(&script_path, build_runner(&user_source)).await?;
let mut child = Command::new(&node)
.arg(&script_path)
.current_dir(&cwd)
.stdin(Stdio::piped())
.stdout(Stdio::piped())
.stderr(Stdio::piped())
.spawn()?;
// 把 params JSON 灌进 stdin
if let Some(mut stdin) = child.stdin.take() {
let json = serde_json::to_vec(&params)?;
stdin.write_all(&json).await?;
stdin.shutdown().await?;
}
// stderr → 解析 __EVT__<json> → PipelineEvent
let stderr = child.stderr.take().expect("stderr piped");
let events_tx = events.clone();
let stderr_task = tokio::spawn(async move {
let mut reader = BufReader::new(stderr).lines();
let mut tail = String::new();
while let Ok(Some(line)) = reader.next_line().await {
if let Some(rest) = line.strip_prefix("__EVT__") {
if let Ok(v) = serde_json::from_str::<Value>(rest) {
let typ = v.get("type").and_then(|x| x.as_str()).unwrap_or("");
match typ {
"progress" => {
let s = v
.get("status")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let _ = events_tx.send(PipelineEvent::Progress(s));
}
"log" => {
let level = v
.get("level")
.and_then(|x| x.as_str())
.unwrap_or("info")
.to_string();
let msg = v
.get("msg")
.and_then(|x| x.as_str())
.unwrap_or("")
.to_string();
let fields = v.get("fields").cloned().filter(|x| !x.is_null());
let _ = events_tx.send(PipelineEvent::Log { level, msg, fields });
}
_ => {
// 兜底:未知 __EVT__ 类型也当 log 抛出
let _ = events_tx.send(PipelineEvent::Log {
level: "info".into(),
msg: rest.to_string(),
fields: None,
});
}
}
}
} else {
// 纯文本 stderr比如 node 自己打印的 warning → 当 info log
let _ = events_tx.send(PipelineEvent::Log {
level: "warn".into(),
msg: line.clone(),
fields: None,
});
tail.push_str(&line);
tail.push('\n');
}
}
tail
});
// stdout → 找 __RESULT__ 那一行
let stdout = child.stdout.take().expect("stdout piped");
let events_tx2 = events.clone();
let stdout_task = tokio::spawn(async move {
let mut reader = BufReader::new(stdout).lines();
let mut result: Option<String> = None;
while let Ok(Some(line)) = reader.next_line().await {
if let Some(rest) = line.strip_prefix("__RESULT__") {
result = Some(rest.to_string());
} else if !line.is_empty() {
// 用户 console.log 的内容也透传给前端
let _ = events_tx2.send(PipelineEvent::Log {
level: "info".into(),
msg: line,
fields: None,
});
}
}
result
});
let status = child.wait().await?;
let stderr_tail = stderr_task.await.unwrap_or_default();
let result_line = stdout_task.await.unwrap_or(None);
let _ = tokio::fs::remove_file(&script_path).await;
if !status.success() {
return Err(NodeError::NonZero {
status: status.code(),
stderr: stderr_tail,
});
}
let result_str = result_line.ok_or(NodeError::NoResult)?;
let v: Value = serde_json::from_str(&result_str)?;
Ok(v)
}

View File

@@ -27,6 +27,7 @@
}, },
"bundle": { "bundle": {
"resources": [ "resources": [
"resources/**/*",
"binaries/*" "binaries/*"
], ],
"active": true, "active": true,