This commit is contained in:
fengchuanhn@gmail.com
2026-05-16 11:41:42 +08:00
parent c88b2d0f3d
commit f2d33ee356
4 changed files with 121 additions and 467 deletions

View File

@@ -1,14 +1,8 @@
//! Embedded QuickJS runtime that runs *named* JS scripts.
//! Embedded QuickJS runtime:脚本源码由 **Python 后端** 按文件名下发,
//! 桌面端通过 HTTP 拉取后在运行时 `eval`,入口为单一的 `globalThis.__quickjsMain`。
//!
//! Each script file under `src-tauri/js/*.js` is compiled into the binary
//! via `include_str!`. At startup the runtime evaluates all of them in
//! order; each file is expected to register one or more handlers into the
//! global `__scripts` table:
//!
//! globalThis.__scripts = globalThis.__scripts || {};
//! globalThis.__scripts["my_script"] = function (params) { ... };
//!
//! The Rust side then resolves `__scripts[name]` at invocation time.
//! 后端:`GET /api/v1/quickjs-scripts?name=<文件名.js>``ApiResponse``data.source`)。
//! API 基址:`AICLIENT_API_BASE` 环境变量,默认 `http://127.0.0.1:8001`。
pub mod native;
@@ -21,18 +15,10 @@ use tokio::sync::mpsc::UnboundedSender;
use native::NativeBridge;
// ---------------------------------------------------------------------------
// Bundled script registry
//
// Add a new line here whenever you drop a new file into `src-tauri/js/`.
// The order matters only if scripts depend on each other; ours don't.
// ---------------------------------------------------------------------------
const BOOTSTRAP_JS: &str = "globalThis.__scripts = globalThis.__scripts || {};";
/// QuickJS 侧约定的唯一入口(服务端下发的脚本末尾应赋值此全局)。
const ENTRY_GLOBAL: &str = "__quickjsMain";
const SCRIPT_FILES: &[(&str, &str)] = &[
("douyin_pipeline.js", include_str!("../../js/douyin_pipeline.js")),
// ("my_next_pipeline.js", include_str!("../../js/my_next_pipeline.js")),
];
const RESET_ENTRY_JS: &str = "globalThis.__quickjsMain = undefined;";
#[derive(Debug, Error)]
pub enum JsError {
@@ -40,12 +26,61 @@ pub enum JsError {
QuickJs(String),
#[error("serde: {0}")]
Serde(#[from] serde_json::Error),
#[error("unknown script: {0}")]
UnknownScript(String),
#[error("拉取脚本失败: {0}")]
FetchScript(String),
#[error("脚本未注册全局入口 __quickjsMain")]
MissingMainEntry,
}
impl From<rquickjs::Error> for JsError {
fn from(e: rquickjs::Error) -> Self { JsError::QuickJs(e.to_string()) }
fn from(e: rquickjs::Error) -> Self {
JsError::QuickJs(e.to_string())
}
}
fn api_base() -> String {
std::env::var("AICLIENT_API_BASE").unwrap_or_else(|_| "http://127.0.0.1:8001".to_string())
}
async fn fetch_script_source(script_name: &str) -> Result<String, JsError> {
let base = api_base().trim_end_matches('/').to_string();
let q = urlencoding::encode(script_name);
let url = format!("{base}/api/v1/quickjs-scripts?name={q}");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(120))
.build()
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let body = res
.text()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let v: Value =
serde_json::from_str(&body).map_err(|e| JsError::FetchScript(format!("响应非 JSON: {e}")))?;
let ok = v.get("ok").and_then(|x| x.as_bool()).unwrap_or(false);
if !ok {
let msg = v
.get("message")
.and_then(|x| x.as_str())
.unwrap_or("未知错误");
return Err(JsError::FetchScript(msg.to_string()));
}
let source = v
.get("data")
.and_then(|d| d.get("source"))
.and_then(|s| s.as_str())
.ok_or_else(|| JsError::FetchScript("响应缺少 data.source".into()))?;
Ok(source.to_string())
}
/// Progress / log message produced by a running script.
@@ -59,57 +94,46 @@ pub enum PipelineEvent {
},
}
/// Execute the script registered under `script_name` and return its result.
/// 从服务端拉取 `script_name` 对应源码,执行 `__quickjsMain(params)` 并返回 JSON 结果。
pub async fn run_script(
script_name: String,
params: Value,
events: UnboundedSender<PipelineEvent>,
) -> Result<Value, JsError> {
let source = fetch_script_source(&script_name).await?;
let bridge = Arc::new(NativeBridge::new(events));
let runtime = AsyncRuntime::new()?;
let context = AsyncContext::full(&runtime).await?;
let result_json: Value = async_with!(context => |ctx| {
// 1) install the __native bridge
let native_obj = native::build_native_object(ctx.clone(), bridge.clone())
.map_err(|e| JsError::QuickJs(format!("install native: {e}")))?;
ctx.globals().set("__native", native_obj)
.map_err(|e| JsError::QuickJs(format!("set __native: {e}")))?;
// 2) bootstrap + evaluate every registered script file
let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("eval bootstrap: {e}")))?;
for (file_name, source) in SCRIPT_FILES {
let _: () = ctx.eval(*source).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?;
}
let _: () = ctx.eval(RESET_ENTRY_JS).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("reset entry: {e}")))?;
let _: () = ctx.eval(source.as_str()).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("eval {}: {e}", script_name)))?;
// 3) look up __scripts[script_name]
let scripts: rquickjs::Object = ctx.globals().get("__scripts")
.map_err(|e| JsError::QuickJs(format!("get __scripts: {e}")))?;
let entry: rquickjs::Function = scripts
.get(script_name.as_str())
.map_err(|_| JsError::UnknownScript(script_name.clone()))?;
let main_fn: rquickjs::Function = ctx.globals().get(ENTRY_GLOBAL)
.map_err(|_| JsError::MissingMainEntry)?;
// 4) call entry(params)
let params_str = serde_json::to_string(&params)
.map_err(|e| JsError::QuickJs(format!("serialize params: {e}")))?;
let params_value: rquickjs::Value = ctx.json_parse(params_str)
.map_err(|e| JsError::QuickJs(format!("parse params: {e}")))?;
// The entry may be sync (returns Value) or async (returns Promise).
// Cast the return value to Value first, then promote to Promise if it
// *is* a Promise; otherwise use it as-is.
let raw: rquickjs::Value = entry.call((params_value,))
.map_err(|e| JsError::QuickJs(format!("call entry: {e}")))?;
let raw: rquickjs::Value = main_fn.call((params_value,))
.map_err(|e| JsError::QuickJs(format!("call __quickjsMain: {e}")))?;
let value: rquickjs::Value = match Promise::from_value(raw.clone()) {
Ok(p) => p.into_future().await
.map_err(|e| JsError::QuickJs(format!("await {script_name}: {e}")))?,
.map_err(|e| JsError::QuickJs(format!("await {}: {e}", script_name)))?,
Err(_) => raw,
};
// 5) JS value -> serde_json::Value via JSON.stringify
let json_global: rquickjs::Object = ctx.globals().get("JSON")
.map_err(|e| JsError::QuickJs(format!("get JSON: {e}")))?;
let stringify: rquickjs::Function = json_global.get("stringify")
@@ -124,24 +148,47 @@ pub async fn run_script(
Ok(result_json)
}
/// Best-effort list of names currently registered in `__scripts`.
/// Useful for diagnostics / a `list_quickjs_scripts` command.
/// 当前服务端 `scripts/quickjs` 目录下的 `.js` 文件名列表。
pub async fn list_scripts() -> Result<Vec<String>, JsError> {
let runtime = AsyncRuntime::new()?;
let context = AsyncContext::full(&runtime).await?;
let names: Vec<String> = async_with!(context => |ctx| {
let _: () = ctx.eval(BOOTSTRAP_JS).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("bootstrap: {e}")))?;
for (file_name, src) in SCRIPT_FILES {
let _: () = ctx.eval(*src).catch(&ctx)
.map_err(|e| JsError::QuickJs(format!("eval {file_name}: {e}")))?;
}
let keys: String = ctx.eval(r#"Object.keys(globalThis.__scripts || {}).join(",")"#)
.map_err(|e| JsError::QuickJs(format!("collect keys: {e}")))?;
Ok::<Vec<String>, JsError>(
keys.split(',').filter(|s| !s.is_empty()).map(|s| s.to_string()).collect()
)
})
.await?;
let base = api_base().trim_end_matches('/').to_string();
let url = format!("{base}/api/v1/quickjs-scripts/list");
let client = reqwest::Client::builder()
.timeout(std::time::Duration::from_secs(30))
.build()
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let res = client
.get(&url)
.send()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let body = res
.text()
.await
.map_err(|e| JsError::FetchScript(e.to_string()))?;
let v: Value =
serde_json::from_str(&body).map_err(|e| JsError::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(JsError::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)
}