1
This commit is contained in:
147
src-tauri/src/js_runtime/mod.rs
Normal file
147
src-tauri/src/js_runtime/mod.rs
Normal file
@@ -0,0 +1,147 @@
|
||||
//! Embedded QuickJS runtime that runs *named* JS scripts.
|
||||
//!
|
||||
//! 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.
|
||||
|
||||
pub mod native;
|
||||
|
||||
use std::sync::Arc;
|
||||
|
||||
use rquickjs::{async_with, promise::Promise, AsyncContext, AsyncRuntime, CatchResultExt};
|
||||
use serde_json::Value;
|
||||
use thiserror::Error;
|
||||
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 || {};";
|
||||
|
||||
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")),
|
||||
];
|
||||
|
||||
#[derive(Debug, Error)]
|
||||
pub enum JsError {
|
||||
#[error("rquickjs: {0}")]
|
||||
QuickJs(String),
|
||||
#[error("serde: {0}")]
|
||||
Serde(#[from] serde_json::Error),
|
||||
#[error("unknown script: {0}")]
|
||||
UnknownScript(String),
|
||||
}
|
||||
|
||||
impl From<rquickjs::Error> for JsError {
|
||||
fn from(e: rquickjs::Error) -> Self { JsError::QuickJs(e.to_string()) }
|
||||
}
|
||||
|
||||
/// Progress / log message produced by a running script.
|
||||
#[derive(Debug, Clone)]
|
||||
pub enum PipelineEvent {
|
||||
Progress(String),
|
||||
Log {
|
||||
level: String,
|
||||
msg: String,
|
||||
fields: Option<Value>,
|
||||
},
|
||||
}
|
||||
|
||||
/// Execute the script registered under `script_name` and return its result.
|
||||
pub async fn run_script(
|
||||
script_name: String,
|
||||
params: Value,
|
||||
events: UnboundedSender<PipelineEvent>,
|
||||
) -> Result<Value, JsError> {
|
||||
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}")))?;
|
||||
}
|
||||
|
||||
// 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()))?;
|
||||
|
||||
// 4) call entry(params)
|
||||
let params_str = serde_json::to_string(¶ms)
|
||||
.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 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}")))?,
|
||||
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")
|
||||
.map_err(|e| JsError::QuickJs(format!("get JSON.stringify: {e}")))?;
|
||||
let s: String = stringify.call((value,))
|
||||
.map_err(|e| JsError::QuickJs(format!("stringify result: {e}")))?;
|
||||
let parsed: Value = serde_json::from_str(&s)?;
|
||||
Ok::<Value, JsError>(parsed)
|
||||
})
|
||||
.await?;
|
||||
|
||||
Ok(result_json)
|
||||
}
|
||||
|
||||
/// Best-effort list of names currently registered in `__scripts`.
|
||||
/// Useful for diagnostics / a `list_quickjs_scripts` command.
|
||||
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?;
|
||||
Ok(names)
|
||||
}
|
||||
295
src-tauri/src/js_runtime/native.rs
Normal file
295
src-tauri/src/js_runtime/native.rs
Normal file
@@ -0,0 +1,295 @@
|
||||
//! `__native` bridge: the set of host functions exposed to the embedded
|
||||
//! QuickJS pipeline.
|
||||
//!
|
||||
//! Conventions:
|
||||
//! - Sync helpers (log, tempPath, deleteFile, ...) take/return primitives
|
||||
//! and may panic-free `Func::from` directly.
|
||||
//! - Async helpers always return a `String` (JSON-encoded result).
|
||||
//! Failures are encoded as `{ "__error": "..." }`; a JS shim
|
||||
//! installed at the end of `build_native_object` detects that shape
|
||||
//! and `throw`s, so JS callers can use natural `try/catch`.
|
||||
//! - On success, the JSON string is parsed by the same JS shim and
|
||||
//! returned to the caller as a plain object.
|
||||
|
||||
use std::path::PathBuf;
|
||||
use std::sync::Arc;
|
||||
|
||||
use rquickjs::{
|
||||
function::{Async, Func},
|
||||
Ctx, Object, Result as JsResult,
|
||||
};
|
||||
use serde::Deserialize;
|
||||
use serde_json::{json, Value};
|
||||
use tokio::sync::mpsc::UnboundedSender;
|
||||
use uuid::Uuid;
|
||||
|
||||
use super::PipelineEvent;
|
||||
use crate::{asr, chat, ffmpeg, http, oss};
|
||||
|
||||
/// Shared host state passed by reference into every native function.
|
||||
pub struct NativeBridge {
|
||||
pub events: UnboundedSender<PipelineEvent>,
|
||||
pub temp_dir: PathBuf,
|
||||
}
|
||||
|
||||
impl NativeBridge {
|
||||
pub fn new(events: UnboundedSender<PipelineEvent>) -> Self {
|
||||
let temp_dir = std::env::temp_dir().join("aiclient-pipeline");
|
||||
let _ = std::fs::create_dir_all(&temp_dir);
|
||||
Self { events, temp_dir }
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Helpers
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
fn err_json(msg: impl ToString) -> String {
|
||||
json!({ "__error": msg.to_string() }).to_string()
|
||||
}
|
||||
|
||||
fn value_to_json<'js>(v: &rquickjs::Value<'js>) -> Result<Value, String> {
|
||||
if v.is_null() || v.is_undefined() {
|
||||
return Ok(Value::Null);
|
||||
}
|
||||
let ctx = v.ctx().clone();
|
||||
let json: rquickjs::Object = ctx.globals().get("JSON").map_err(|e| e.to_string())?;
|
||||
let stringify: rquickjs::Function = json.get("stringify").map_err(|e| e.to_string())?;
|
||||
let s: String = stringify
|
||||
.call((v.clone(),))
|
||||
.map_err(|e| e.to_string())?;
|
||||
serde_json::from_str(&s).map_err(|e| e.to_string())
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Build the __native object
|
||||
// ---------------------------------------------------------------------------
|
||||
pub fn build_native_object<'js>(
|
||||
ctx: Ctx<'js>,
|
||||
bridge: Arc<NativeBridge>,
|
||||
) -> JsResult<Object<'js>> {
|
||||
let obj = Object::new(ctx.clone())?;
|
||||
|
||||
// ---- sync helpers ----------------------------------------------------
|
||||
{
|
||||
let b = bridge.clone();
|
||||
obj.set(
|
||||
"log",
|
||||
Func::from(move |level: String, msg: String, fields: Option<rquickjs::Value<'js>>| {
|
||||
let fields_json = fields.and_then(|v| value_to_json(&v).ok());
|
||||
let _ = b.events.send(PipelineEvent::Log {
|
||||
level: level.clone(),
|
||||
msg: msg.clone(),
|
||||
fields: fields_json.clone(),
|
||||
});
|
||||
log::info!(target: "pipeline", "[{}] {} {:?}", level, msg, fields_json);
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let b = bridge.clone();
|
||||
obj.set(
|
||||
"emitProgress",
|
||||
Func::from(move |status: String| {
|
||||
let _ = b.events.send(PipelineEvent::Progress(status));
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
{
|
||||
let b = bridge.clone();
|
||||
obj.set(
|
||||
"tempPath",
|
||||
Func::from(move |prefix: String, ext: Option<String>| -> String {
|
||||
let ext = ext.unwrap_or_else(|| "tmp".into());
|
||||
let name = format!("{}-{}.{}", prefix, Uuid::new_v4(), ext);
|
||||
b.temp_dir.join(name).to_string_lossy().into_owned()
|
||||
}),
|
||||
)?;
|
||||
}
|
||||
obj.set(
|
||||
"deleteFile",
|
||||
Func::from(|path: String| {
|
||||
let _ = std::fs::remove_file(&path);
|
||||
}),
|
||||
)?;
|
||||
obj.set(
|
||||
"fileExists",
|
||||
Func::from(|path: String| std::path::Path::new(&path).exists()),
|
||||
)?;
|
||||
obj.set(
|
||||
"now",
|
||||
Func::from(|| chrono::Utc::now().timestamp_millis() as f64),
|
||||
)?;
|
||||
obj.set("uuid", Func::from(|| Uuid::new_v4().to_string()))?;
|
||||
|
||||
// ---- async: HTTP -----------------------------------------------------
|
||||
obj.set(
|
||||
"httpRequest",
|
||||
Func::from(Async(|spec: rquickjs::Value<'js>| {
|
||||
let spec_json = value_to_json(&spec);
|
||||
async move {
|
||||
let spec_json = match spec_json {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok::<String, rquickjs::Error>(err_json(format!("invalid http spec: {e}"))),
|
||||
};
|
||||
let parsed: http::HttpRequestSpec = match serde_json::from_value(spec_json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(err_json(format!("http spec deserialize: {e}"))),
|
||||
};
|
||||
match http::execute(parsed).await {
|
||||
Ok(resp) => Ok(serde_json::to_string(&resp).unwrap_or_else(|e| err_json(e))),
|
||||
Err(e) => Ok(err_json(format!("http: {e}"))),
|
||||
}
|
||||
}
|
||||
})),
|
||||
)?;
|
||||
|
||||
// ---- async: ffmpeg extract audio ------------------------------------
|
||||
{
|
||||
let b = bridge.clone();
|
||||
obj.set(
|
||||
"ffmpegExtractAudio",
|
||||
Func::from(Async(move |video_path: String| {
|
||||
let temp_dir = b.temp_dir.clone();
|
||||
async move {
|
||||
let dest = temp_dir.join(format!("audio-{}.wav", Uuid::new_v4()));
|
||||
match ffmpeg::extract_audio(std::path::Path::new(&video_path), &dest).await {
|
||||
Ok(()) => Ok::<String, rquickjs::Error>(
|
||||
json!(dest.to_string_lossy()).to_string(),
|
||||
),
|
||||
Err(e) => Ok(err_json(format!("ffmpeg: {e}"))),
|
||||
}
|
||||
}
|
||||
})),
|
||||
)?;
|
||||
}
|
||||
|
||||
// ---- async: aliyun OSS upload ---------------------------------------
|
||||
obj.set(
|
||||
"aliyunOssUpload",
|
||||
Func::from(Async(|local: String, cfg: rquickjs::Value<'js>| {
|
||||
let cfg_json = value_to_json(&cfg);
|
||||
async move {
|
||||
let cfg_json = match cfg_json {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||
json!({"success": false, "error": format!("oss cfg: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
let cfg: oss::OssConfig = match serde_json::from_value(cfg_json) {
|
||||
Ok(c) => c,
|
||||
Err(e) => return Ok(
|
||||
json!({"success": false, "error": format!("oss cfg parse: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
let key = format!("audio/{}.wav", Uuid::new_v4());
|
||||
match oss::put_object(&cfg, std::path::Path::new(&local), &key, "audio/wav").await {
|
||||
Ok(url) => Ok(json!({"success": true, "url": url}).to_string()),
|
||||
Err(e) => Ok(json!({"success": false, "error": e.to_string()}).to_string()),
|
||||
}
|
||||
}
|
||||
})),
|
||||
)?;
|
||||
|
||||
// ---- async: aliyun dashscope ASR ------------------------------------
|
||||
obj.set(
|
||||
"aliyunAsrFiletrans",
|
||||
Func::from(Async(|audio_url: String, opts: rquickjs::Value<'js>| {
|
||||
let opts_json = value_to_json(&opts);
|
||||
async move {
|
||||
let opts_json = match opts_json {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||
json!({"success": false, "error": format!("asr opts: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
#[derive(Deserialize)]
|
||||
struct AsrOpts {
|
||||
#[serde(rename = "apiKey")]
|
||||
api_key: String,
|
||||
#[serde(default = "default_asr_model")]
|
||||
model: String,
|
||||
}
|
||||
fn default_asr_model() -> String { "qwen3-asr-flash-filetrans".into() }
|
||||
let parsed: AsrOpts = match serde_json::from_value(opts_json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(
|
||||
json!({"success": false, "error": format!("asr opts parse: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
let result = match asr::transcribe(&audio_url, &parsed.api_key, &parsed.model).await {
|
||||
Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null),
|
||||
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||
};
|
||||
Ok(result.to_string())
|
||||
}
|
||||
})),
|
||||
)?;
|
||||
|
||||
// ---- async: local ASR (placeholder; user can plug in own server) ----
|
||||
obj.set(
|
||||
"localAsr",
|
||||
Func::from(Async(|_audio_path: String, _info: rquickjs::Value<'js>| async move {
|
||||
Ok::<String, rquickjs::Error>(
|
||||
json!({
|
||||
"success": false,
|
||||
"error": "local ASR not implemented in Rust port — please call your local server explicitly"
|
||||
})
|
||||
.to_string(),
|
||||
)
|
||||
})),
|
||||
)?;
|
||||
|
||||
// ---- async: OpenAI-compatible chat ----------------------------------
|
||||
obj.set(
|
||||
"openaiChat",
|
||||
Func::from(Async(|req: rquickjs::Value<'js>| {
|
||||
let req_json = value_to_json(&req);
|
||||
async move {
|
||||
let req_json = match req_json {
|
||||
Ok(v) => v,
|
||||
Err(e) => return Ok::<String, rquickjs::Error>(
|
||||
json!({"success": false, "error": format!("chat req: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
let parsed: chat::ChatRequest = match serde_json::from_value(req_json) {
|
||||
Ok(p) => p,
|
||||
Err(e) => return Ok(
|
||||
json!({"success": false, "error": format!("chat req parse: {e}")}).to_string(),
|
||||
),
|
||||
};
|
||||
let result = match chat::complete(&parsed).await {
|
||||
Ok(r) => serde_json::to_value(r).unwrap_or(Value::Null),
|
||||
Err(e) => json!({"success": false, "error": e.to_string()}),
|
||||
};
|
||||
Ok(result.to_string())
|
||||
}
|
||||
})),
|
||||
)?;
|
||||
|
||||
// The async functions above all return JSON strings. Wrap each one in
|
||||
// a JS shim that JSON.parses the result and throws when the payload
|
||||
// carries the `__error` discriminator.
|
||||
let shim = r#"
|
||||
(function () {
|
||||
const wrap = (name) => {
|
||||
const orig = __native[name];
|
||||
__native[name] = async function (...args) {
|
||||
const s = await orig.apply(__native, args);
|
||||
if (typeof s !== "string") return s;
|
||||
let v;
|
||||
try { v = JSON.parse(s); } catch (_) { return s; }
|
||||
if (v && typeof v === "object" && typeof v.__error === "string") {
|
||||
throw new Error(v.__error);
|
||||
}
|
||||
return v;
|
||||
};
|
||||
};
|
||||
["httpRequest", "ffmpegExtractAudio", "aliyunOssUpload",
|
||||
"aliyunAsrFiletrans", "localAsr", "openaiChat"].forEach(wrap);
|
||||
})();
|
||||
"#;
|
||||
let _: () = ctx.eval(shim)?;
|
||||
|
||||
Ok(obj)
|
||||
}
|
||||
Reference in New Issue
Block a user