This commit is contained in:
fengchuanhn@gmail.com
2026-05-16 11:24:18 +08:00
parent 491231bdd5
commit c88b2d0f3d
17 changed files with 2401 additions and 16 deletions

165
src-tauri/src/asr.rs Normal file
View File

@@ -0,0 +1,165 @@
//! Aliyun DashScope file-trans ASR client (qwen3-asr-flash-filetrans).
//!
//! The dashscope async transcription flow has two steps:
//! 1. POST https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription
//! header `X-DashScope-Async: enable`
//! body { model, input: { file_urls: [audio_url] }, parameters: {...} }
//! => returns task_id
//! 2. GET https://dashscope.aliyuncs.com/api/v1/tasks/{task_id}
//! poll until task_status in {SUCCEEDED, FAILED}
//!
//! On success the response carries one or more `transcription_url`s pointing
//! at JSON files containing the recognized sentences. We download the first
//! one and concatenate the sentence texts.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
use tokio::time::sleep;
const SUBMIT_URL: &str =
"https://dashscope.aliyuncs.com/api/v1/services/audio/asr/transcription";
const TASK_URL_PREFIX: &str = "https://dashscope.aliyuncs.com/api/v1/tasks/";
#[derive(Debug, Error)]
pub enum AsrError {
#[error("missing api key")]
MissingApiKey,
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("dashscope: {0}")]
Api(String),
#[error("timeout waiting for asr task")]
Timeout,
#[error("json: {0}")]
Json(#[from] serde_json::Error),
}
#[derive(Debug, Deserialize, Serialize, Clone)]
pub struct AsrSentence {
#[serde(default)]
pub text: String,
#[serde(default)]
pub begin_time: Option<i64>,
#[serde(default)]
pub end_time: Option<i64>,
}
#[derive(Debug, Serialize)]
pub struct AsrResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub text: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub sentences: Option<Vec<AsrSentence>>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn transcribe(
audio_url: &str,
api_key: &str,
model: &str,
) -> Result<AsrResult, AsrError> {
if api_key.trim().is_empty() {
return Err(AsrError::MissingApiKey);
}
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(60))
.build()?;
// 1) submit
let submit_body = json!({
"model": model,
"input": { "file_urls": [audio_url] },
"parameters": {}
});
let submit: Value = client
.post(SUBMIT_URL)
.bearer_auth(api_key)
.header("X-DashScope-Async", "enable")
.json(&submit_body)
.send()
.await?
.json()
.await?;
let task_id = submit
.pointer("/output/task_id")
.and_then(Value::as_str)
.ok_or_else(|| AsrError::Api(format!("submit: no task_id, raw={submit}")))?
.to_string();
// 2) poll
let max_polls = 60; // ~5 min @ 5s
let mut poll_resp: Value = Value::Null;
let mut succeeded = false;
for _ in 0..max_polls {
sleep(Duration::from_secs(5)).await;
poll_resp = client
.get(format!("{TASK_URL_PREFIX}{task_id}"))
.bearer_auth(api_key)
.send()
.await?
.json()
.await?;
let status = poll_resp
.pointer("/output/task_status")
.and_then(Value::as_str)
.unwrap_or("");
match status {
"SUCCEEDED" => { succeeded = true; break; }
"FAILED" | "UNKNOWN" => {
let msg = poll_resp
.pointer("/output/message")
.and_then(Value::as_str)
.unwrap_or("FAILED");
return Err(AsrError::Api(msg.to_string()));
}
_ => continue,
}
}
if !succeeded {
return Err(AsrError::Timeout);
}
// 3) fetch transcription file
let trans_url = poll_resp
.pointer("/output/results/0/transcription_url")
.and_then(Value::as_str)
.ok_or_else(|| AsrError::Api(format!("no transcription_url, raw={poll_resp}")))?
.to_string();
let trans_json: Value = client.get(&trans_url).send().await?.json().await?;
// dashscope transcription file shape:
// { "transcripts": [{ "text": "...", "sentences": [{text, begin_time, end_time}, ...] }] }
let mut full_text = String::new();
let mut sentences = Vec::<AsrSentence>::new();
if let Some(transcripts) = trans_json.get("transcripts").and_then(Value::as_array) {
for t in transcripts {
if let Some(text) = t.get("text").and_then(Value::as_str) {
if !full_text.is_empty() { full_text.push('\n'); }
full_text.push_str(text);
}
if let Some(arr) = t.get("sentences").and_then(Value::as_array) {
for s in arr {
sentences.push(AsrSentence {
text: s.get("text").and_then(Value::as_str).unwrap_or("").to_string(),
begin_time: s.get("begin_time").and_then(Value::as_i64),
end_time: s.get("end_time").and_then(Value::as_i64),
});
}
}
}
} else if let Some(text) = trans_json.get("text").and_then(Value::as_str) {
full_text.push_str(text);
}
Ok(AsrResult {
success: true,
text: Some(full_text),
sentences: if sentences.is_empty() { None } else { Some(sentences) },
error: None,
})
}

95
src-tauri/src/chat.rs Normal file
View File

@@ -0,0 +1,95 @@
//! Minimal OpenAI-compatible chat completions client.
//! Used for the rewrite step.
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::{json, Value};
use thiserror::Error;
#[derive(Debug, Error)]
pub enum ChatError {
#[error("missing api url or key")]
MissingConfig,
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("api: status={status}, body={body}")]
Api { status: u16, body: String },
#[error("api response had no choices")]
NoChoices,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatMessage {
pub role: String,
pub content: String,
}
#[derive(Debug, Clone, Deserialize)]
pub struct ChatRequest {
#[serde(rename = "apiUrl")]
pub api_url: String,
#[serde(rename = "apiKey")]
pub api_key: String,
pub model: String,
pub messages: Vec<ChatMessage>,
#[serde(default)]
pub temperature: Option<f32>,
#[serde(default, rename = "max_tokens")]
pub max_tokens: Option<u32>,
}
#[derive(Debug, Serialize)]
pub struct ChatResponse {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub content: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
pub async fn complete(req: &ChatRequest) -> Result<ChatResponse, ChatError> {
if req.api_url.trim().is_empty() || req.api_key.trim().is_empty() {
return Err(ChatError::MissingConfig);
}
// Accept both `https://host` (will append /v1/chat/completions) and the
// full URL.
let url = if req.api_url.contains("/chat/completions") {
req.api_url.trim_end_matches('/').to_string()
} else {
format!("{}/v1/chat/completions", req.api_url.trim_end_matches('/'))
};
let mut body = json!({
"model": req.model,
"messages": req.messages.iter().map(|m| json!({
"role": m.role,
"content": m.content,
})).collect::<Vec<_>>(),
"stream": false,
});
if let Some(t) = req.temperature { body["temperature"] = json!(t); }
if let Some(mt) = req.max_tokens { body["max_tokens"] = json!(mt); }
let client = reqwest::Client::builder()
.timeout(Duration::from_secs(120))
.build()?;
let resp = client
.post(&url)
.bearer_auth(&req.api_key)
.json(&body)
.send()
.await?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
let txt = resp.text().await.unwrap_or_default();
return Err(ChatError::Api { status, body: txt });
}
let v: Value = resp.json().await?;
let content = v
.pointer("/choices/0/message/content")
.and_then(Value::as_str)
.ok_or(ChatError::NoChoices)?
.to_string();
Ok(ChatResponse { success: true, content: Some(content), error: None })
}

View File

@@ -0,0 +1 @@
pub mod quickjs;

View File

@@ -0,0 +1,99 @@
//! Generic Tauri commands for invoking any JS script registered in the
//! embedded QuickJS runtime. Replaces the per-pipeline command so adding a
//! new flow only requires:
//!
//! 1. Drop a new `.js` file into `src-tauri/js/`.
//! 2. Add it to `js_runtime::SCRIPT_FILES`.
//! 3. Inside the JS file, register one or more entries on
//! `globalThis.__scripts[<scriptName>]`.
//!
//! Frontend usage:
//!
//! ```ts
//! import { invoke } from "@tauri-apps/api/core";
//! import { listen } from "@tauri-apps/api/event";
//!
//! const off = await listen("quickjs:event", (e) => console.log(e.payload));
//! const result = await invoke("run_quickjs_script", {
//! scriptName: "process_douyin_share",
//! params: { shareText: "...", modelConfig: {...}, ... },
//! });
//! off();
//! ```
use serde::Serialize;
use serde_json::Value;
use tauri::{AppHandle, Emitter, Window};
use tokio::sync::mpsc;
use crate::js_runtime::{self, PipelineEvent};
#[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>,
},
}
const EVENT_NAME: &str = "quickjs:event";
/// Run an arbitrary registered QuickJS script by name.
///
/// `script_name` must already be registered on `globalThis.__scripts` by
/// one of the bundled `js/*.js` files. `params` is forwarded as-is to the
/// script entry function.
#[tauri::command]
pub async fn run_quickjs_script(
app: AppHandle,
window: Window,
script_name: String,
params: Value,
) -> Result<Value, String> {
let (tx, mut rx) = mpsc::unbounded_channel::<PipelineEvent>();
// Forward script events to the frontend window.
let app_for_emit = app.clone();
let win_label = window.label().to_string();
let script_for_emit = script_name.clone();
let pump = tokio::spawn(async move {
while let Some(ev) = rx.recv().await {
let payload: FrontendEvent = match ev {
PipelineEvent::Progress(s) => FrontendEvent::Progress {
script_name: script_for_emit.clone(),
status: s,
},
PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log {
script_name: script_for_emit.clone(),
level,
msg,
fields,
},
};
let _ = app_for_emit.emit_to(&win_label, EVENT_NAME, payload);
}
});
let result = js_runtime::run_script(script_name, params, tx)
.await
.map_err(|e| e.to_string())?;
pump.await.ok();
Ok(result)
}
/// Diagnostic helper — returns the list of registered script names.
#[tauri::command]
pub async fn list_quickjs_scripts() -> Result<Vec<String>, String> {
js_runtime::list_scripts().await.map_err(|e| e.to_string())
}

83
src-tauri/src/ffmpeg.rs Normal file
View File

@@ -0,0 +1,83 @@
//! Minimal ffmpeg sidecar wrapper used by the Douyin pipeline.
//!
//! Resolution order for the ffmpeg binary:
//! 1. `AICLIENT_FFMPEG_PATH` env override
//! 2. `binaries/ffmpeg(.exe)` next to the running executable (production)
//! 3. `src-tauri/binaries/ffmpeg(.exe)` relative to CWD (development)
//! 4. plain `ffmpeg` from the user's PATH
use std::path::{Path, PathBuf};
use std::process::Stdio;
use thiserror::Error;
use tokio::process::Command;
#[derive(Debug, Error)]
pub enum FfmpegError {
#[error("ffmpeg binary not found")]
NotFound,
#[error("ffmpeg failed (status={status:?}): {stderr}")]
Failed { status: Option<i32>, stderr: String },
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
fn exe_name() -> &'static str {
if cfg!(windows) { "ffmpeg.exe" } else { "ffmpeg" }
}
pub fn locate_ffmpeg() -> Option<PathBuf> {
if let Ok(p) = std::env::var("AICLIENT_FFMPEG_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 candidate = dir.join("binaries").join(exe_name());
if candidate.exists() {
return Some(candidate);
}
let flat = dir.join(exe_name());
if flat.exists() {
return Some(flat);
}
}
}
let dev = PathBuf::from("src-tauri/binaries").join(exe_name());
if dev.exists() {
return Some(dev);
}
let dev2 = PathBuf::from("binaries").join(exe_name());
if dev2.exists() {
return Some(dev2);
}
Some(PathBuf::from(exe_name()))
}
/// Extract the audio track from `video_path` and re-encode to 16 kHz mono WAV
/// (the format dashscope/funasr accept). Returns the produced wav path.
pub async fn extract_audio(video_path: &Path, dest_wav: &Path) -> Result<(), FfmpegError> {
let ffmpeg = locate_ffmpeg().ok_or(FfmpegError::NotFound)?;
let output = Command::new(&ffmpeg)
.arg("-y")
.arg("-i").arg(video_path)
.arg("-vn")
.arg("-ac").arg("1")
.arg("-ar").arg("16000")
.arg("-acodec").arg("pcm_s16le")
.arg(dest_wav)
.stdout(Stdio::null())
.stderr(Stdio::piped())
.spawn()?
.wait_with_output()
.await?;
if !output.status.success() {
return Err(FfmpegError::Failed {
status: output.status.code(),
stderr: String::from_utf8_lossy(&output.stderr).into_owned(),
});
}
Ok(())
}

137
src-tauri/src/http.rs Normal file
View File

@@ -0,0 +1,137 @@
//! Generic HTTP helper exposed to the embedded QuickJS runtime.
//!
//! Designed to keep the JS side dumb — the JS layer only describes a request
//! and decides whether it wants the body as text/json/binary/file.
use std::collections::HashMap;
use std::path::PathBuf;
use std::time::Duration;
use serde::{Deserialize, Serialize};
use serde_json::Value;
use thiserror::Error;
use tokio::io::AsyncWriteExt;
#[derive(Debug, Error)]
pub enum HttpError {
#[error("invalid request: {0}")]
Invalid(String),
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("io: {0}")]
Io(#[from] std::io::Error),
}
#[derive(Debug, Deserialize)]
pub struct HttpRequestSpec {
#[serde(default = "default_method")]
pub method: String,
pub url: String,
#[serde(default)]
pub headers: Option<HashMap<String, String>>,
#[serde(default)]
pub body: Option<Value>,
/// "text" | "json" | "binary" | "file"
#[serde(default = "default_response_type")]
pub response_type: String,
/// Required when `response_type == "file"`.
#[serde(default)]
pub dest_path: Option<String>,
#[serde(default)]
pub timeout_ms: Option<u64>,
#[serde(default = "default_true")]
pub follow_redirects: bool,
}
fn default_method() -> String { "GET".into() }
fn default_response_type() -> String { "text".into() }
fn default_true() -> bool { true }
#[derive(Debug, Serialize)]
pub struct HttpResponse {
pub status: u16,
pub final_url: String,
pub headers: HashMap<String, String>,
/// Present when response_type == "text"
#[serde(skip_serializing_if = "Option::is_none")]
pub body: Option<Value>,
/// Present when response_type == "file" — the local path written to.
#[serde(skip_serializing_if = "Option::is_none")]
pub file_path: Option<String>,
/// Present when response_type == "binary" (base64-encoded).
#[serde(skip_serializing_if = "Option::is_none")]
pub body_base64: Option<String>,
}
pub async fn execute(spec: HttpRequestSpec) -> Result<HttpResponse, HttpError> {
let mut builder = reqwest::Client::builder();
if !spec.follow_redirects {
builder = builder.redirect(reqwest::redirect::Policy::none());
}
if let Some(ms) = spec.timeout_ms {
builder = builder.timeout(Duration::from_millis(ms));
}
let client = builder.build()?;
let method = reqwest::Method::from_bytes(spec.method.to_uppercase().as_bytes())
.map_err(|e| HttpError::Invalid(format!("bad method: {e}")))?;
let mut req = client.request(method, &spec.url);
if let Some(h) = &spec.headers {
for (k, v) in h { req = req.header(k, v); }
}
if let Some(b) = &spec.body {
req = match b {
Value::String(s) => req.body(s.clone()),
other => req.json(other),
};
}
let resp = req.send().await?;
let status = resp.status().as_u16();
let final_url = resp.url().to_string();
let headers = resp
.headers()
.iter()
.filter_map(|(k, v)| v.to_str().ok().map(|s| (k.to_string(), s.to_string())))
.collect();
let mut out = HttpResponse { status, final_url, headers, body: None, file_path: None, body_base64: None };
match spec.response_type.as_str() {
"json" => {
let v: Value = resp.json().await?;
out.body = Some(v);
}
"binary" => {
use base64::Engine as _;
let bytes = resp.bytes().await?;
out.body_base64 = Some(base64::engine::general_purpose::STANDARD.encode(&bytes));
}
"file" => {
let dest = spec.dest_path.as_ref()
.ok_or_else(|| HttpError::Invalid("dest_path is required for response_type=file".into()))?;
let path = PathBuf::from(dest);
if let Some(parent) = path.parent() {
if !parent.as_os_str().is_empty() {
let _ = tokio::fs::create_dir_all(parent).await;
}
}
let mut file = tokio::fs::File::create(&path).await?;
let mut stream = resp.bytes_stream();
use futures_util::StreamExt;
while let Some(chunk) = stream.next().await {
let chunk = chunk?;
file.write_all(&chunk).await?;
}
file.flush().await?;
out.file_path = Some(path.to_string_lossy().into_owned());
}
_ => {
let txt = resp.text().await?;
out.body = Some(Value::String(txt));
}
}
Ok(out)
}

View 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(&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 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)
}

View 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)
}

View File

@@ -1,7 +1,17 @@
pub mod asr;
pub mod chat;
pub mod commands;
pub mod ffmpeg;
pub mod http;
pub mod js_runtime;
pub mod oss;
use tauri::Manager;
#[cfg_attr(mobile, tauri::mobile_entry_point)]
pub fn run() {
let _ = env_logger::try_init();
tauri::Builder::default()
.plugin(tauri_plugin_single_instance::init(|app, _args, _cwd| {
if let Some(window) = app.get_webview_window("main") {
@@ -11,6 +21,10 @@ pub fn run() {
}
}))
.plugin(tauri_plugin_opener::init())
.invoke_handler(tauri::generate_handler![
commands::quickjs::run_quickjs_script,
commands::quickjs::list_quickjs_scripts,
])
.run(tauri::generate_context!())
.expect("error while running tauri application");
}

126
src-tauri/src/oss.rs Normal file
View File

@@ -0,0 +1,126 @@
//! Minimal Aliyun OSS PUT-Object client.
//!
//! Only the subset required by the Douyin pipeline is implemented:
//! - PUT a single local file under `audio/<uuid>.<ext>`
//! - Sign with HMAC-SHA1 using AccessKey ID + Secret
//! - Return the public https URL
//!
//! No external `aliyun-oss` crate is used so that the dependency footprint
//! stays small.
use std::path::Path;
use base64::{engine::general_purpose::STANDARD as B64, Engine as _};
use chrono::Utc;
use hmac::{Hmac, Mac};
use serde::{Deserialize, Serialize};
use sha1::Sha1;
use thiserror::Error;
#[derive(Debug, Error)]
pub enum OssError {
#[error("missing field: {0}")]
MissingField(&'static str),
#[error("io: {0}")]
Io(#[from] std::io::Error),
#[error("http: {0}")]
Http(#[from] reqwest::Error),
#[error("oss returned status={status}, body={body}")]
BadStatus { status: u16, body: String },
#[error("hmac error")]
Hmac,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct OssConfig {
#[serde(rename = "accessKeyId")]
pub access_key_id: String,
#[serde(rename = "accessKeySecret")]
pub access_key_secret: String,
pub bucket: String,
pub region: String,
#[serde(default)]
pub endpoint: Option<String>,
}
#[derive(Debug, Serialize)]
pub struct OssUploadResult {
pub success: bool,
#[serde(skip_serializing_if = "Option::is_none")]
pub url: Option<String>,
#[serde(skip_serializing_if = "Option::is_none")]
pub error: Option<String>,
}
type HmacSha1 = Hmac<Sha1>;
fn rfc1123_now() -> String {
Utc::now().format("%a, %d %b %Y %H:%M:%S GMT").to_string()
}
fn sign(secret: &str, str_to_sign: &str) -> Result<String, OssError> {
let mut mac = HmacSha1::new_from_slice(secret.as_bytes()).map_err(|_| OssError::Hmac)?;
mac.update(str_to_sign.as_bytes());
Ok(B64.encode(mac.finalize().into_bytes()))
}
fn require<'a>(v: &'a str, name: &'static str) -> Result<&'a str, OssError> {
if v.trim().is_empty() {
Err(OssError::MissingField(name))
} else {
Ok(v)
}
}
pub async fn put_object(
cfg: &OssConfig,
local_path: &Path,
object_key: &str,
content_type: &str,
) -> Result<String, OssError> {
let ak = require(&cfg.access_key_id, "accessKeyId")?;
let sk = require(&cfg.access_key_secret, "accessKeySecret")?;
let bucket = require(&cfg.bucket, "bucket")?;
let region = require(&cfg.region, "region")?;
let host = format!("{bucket}.{region}.aliyuncs.com");
let endpoint = cfg
.endpoint
.clone()
.unwrap_or_else(|| format!("https://{host}"));
let url = format!("{}/{}", endpoint.trim_end_matches('/'), object_key);
let body = tokio::fs::read(local_path).await?;
let date = rfc1123_now();
// String-to-sign for OSS V1:
// VERB\nContent-MD5\nContent-Type\nDate\nCanonicalizedOSSHeaders+CanonicalizedResource
let canonicalized_resource = format!("/{bucket}/{object_key}");
let str_to_sign = format!(
"PUT\n\n{ct}\n{date}\n{res}",
ct = content_type,
date = date,
res = canonicalized_resource
);
let signature = sign(sk, &str_to_sign)?;
let authorization = format!("OSS {ak}:{signature}");
let client = reqwest::Client::builder()
.build()
.map_err(OssError::Http)?;
let resp = client
.put(&url)
.header("Date", &date)
.header("Content-Type", content_type)
.header("Authorization", authorization)
.body(body)
.send()
.await?;
let status = resp.status().as_u16();
if !(200..300).contains(&status) {
let body = resp.text().await.unwrap_or_default();
return Err(OssError::BadStatus { status, body });
}
Ok(url)
}