11
This commit is contained in:
@@ -1,381 +0,0 @@
|
||||
// ============================================================================
|
||||
// douyin_pipeline.js
|
||||
//
|
||||
// Pure-JS port of the original `VideoRewriteIntegration.processDouyinShareComplete`
|
||||
// pipeline. All host capabilities (network, fs, ffmpeg, OSS, ASR, AI chat,
|
||||
// log, progress) come from the `__native` global injected by the Rust side
|
||||
// (see `js_runtime/native.rs`). Nothing in this file should ever depend on
|
||||
// Node.js, Electron or browser globals.
|
||||
//
|
||||
// Public entry point exposed to Rust:
|
||||
// processDouyinShare(params) -> Promise<Result>
|
||||
//
|
||||
// `params` shape mirrors the original Electron IPC payload:
|
||||
// {
|
||||
// shareText: string,
|
||||
// modelConfig: { providerId, modelId, apiUrl, apiKey,
|
||||
// asrMode: "online" | "local",
|
||||
// aliyunApiKey, ossConfig, asrServerInfo },
|
||||
// rewriteConfig: object,
|
||||
// customPrompt: string | null
|
||||
// }
|
||||
// ============================================================================
|
||||
|
||||
const log = (level, msg, fields) => __native.log(level, msg, fields || null);
|
||||
const progress = (s) => { try { __native.emitProgress(s); } catch (_) { /* ignore */ } };
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DouyinContentParser (pure-JS, no host calls)
|
||||
// ---------------------------------------------------------------------------
|
||||
const DouyinContentParser = {
|
||||
// Extract a sharable URL from a raw "share text". Handles:
|
||||
// - direct https://... links
|
||||
// - "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxx/"
|
||||
// - bare v.douyin.com short links
|
||||
parseContent(shareText) {
|
||||
if (!shareText || typeof shareText !== "string") {
|
||||
return { success: false, error: "分享文本为空" };
|
||||
}
|
||||
const urlRegex = /https?:\/\/[^\s\u4e00-\u9fa5]+/g;
|
||||
const matches = shareText.match(urlRegex);
|
||||
if (!matches || matches.length === 0) {
|
||||
return { success: false, error: "未在文本中识别到任何 URL" };
|
||||
}
|
||||
// Prefer douyin/iesdouyin domains, fallback to first match.
|
||||
const douyinUrl =
|
||||
matches.find((u) => /douyin\.com|iesdouyin\.com/i.test(u)) || matches[0];
|
||||
return { success: true, content: { videoUrl: douyinUrl, raw: shareText } };
|
||||
},
|
||||
|
||||
// Whether `url` already points at an actual media file we can fetch directly.
|
||||
isDirectVideoUrl(url) {
|
||||
if (!url) return false;
|
||||
return /\.(mp4|mov|m4v|webm|mkv|flv|avi)(\?|$)/i.test(url);
|
||||
},
|
||||
|
||||
// Derive a short title (<= 20 chars) from a long description.
|
||||
generateTitleFromDescription(desc) {
|
||||
if (!desc) return "改写视频";
|
||||
const firstLine = String(desc).split(/[\r\n]/)[0].trim();
|
||||
const cleaned = firstLine.replace(/[#@].*$/g, "").trim();
|
||||
return (cleaned || "改写视频").slice(0, 20);
|
||||
},
|
||||
};
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// DouyinVideoDownloader
|
||||
// Resolves the final media URL (following 302 redirects on the share
|
||||
// domain) and saves the binary to a host-managed temp file.
|
||||
// ---------------------------------------------------------------------------
|
||||
class DouyinVideoDownloader {
|
||||
async downloadDirectVideo(url) {
|
||||
log("info", "downloader.directVideo", { url });
|
||||
const dest = __native.tempPath("video", "mp4");
|
||||
const res = await __native.httpRequest({
|
||||
method: "GET",
|
||||
url,
|
||||
headers: { "User-Agent": this._ua() },
|
||||
response_type: "file",
|
||||
dest_path: dest,
|
||||
timeout_ms: 120000,
|
||||
});
|
||||
if (!res || res.status >= 400) {
|
||||
return { success: false, error: `直链下载失败 status=${res && res.status}` };
|
||||
}
|
||||
return { success: true, videoPath: dest };
|
||||
}
|
||||
|
||||
async downloadDouyinVideo(shareUrl) {
|
||||
log("info", "downloader.douyin", { shareUrl });
|
||||
// Step 1: follow redirect on v.douyin.com -> iesdouyin.com to get itemId
|
||||
let landing;
|
||||
try {
|
||||
landing = await __native.httpRequest({
|
||||
method: "GET",
|
||||
url: shareUrl,
|
||||
headers: { "User-Agent": this._ua() },
|
||||
response_type: "text",
|
||||
follow_redirects: true,
|
||||
timeout_ms: 30000,
|
||||
});
|
||||
} catch (e) {
|
||||
return { success: false, error: `解析跳转失败: ${e.message || e}` };
|
||||
}
|
||||
const finalUrl = (landing && landing.final_url) || shareUrl;
|
||||
const itemId =
|
||||
this._extract(/\/video\/(\d+)/, finalUrl) ||
|
||||
this._extract(/item_ids?=(\d+)/, finalUrl) ||
|
||||
this._extract(/modal_id=(\d+)/, finalUrl);
|
||||
if (!itemId) {
|
||||
return {
|
||||
success: false,
|
||||
error: "未能从分享链接中提取视频 itemId,可能是抖音改版",
|
||||
};
|
||||
}
|
||||
// Step 2: ask iesdouyin for the playable URL.
|
||||
const apiUrl =
|
||||
"https://www.iesdouyin.com/web/api/v2/aweme/iteminfo/?item_ids=" + itemId;
|
||||
let info;
|
||||
try {
|
||||
info = await __native.httpRequest({
|
||||
method: "GET",
|
||||
url: apiUrl,
|
||||
headers: { "User-Agent": this._ua() },
|
||||
response_type: "json",
|
||||
timeout_ms: 30000,
|
||||
});
|
||||
} catch (e) {
|
||||
return { success: false, error: `获取视频信息失败: ${e.message || e}` };
|
||||
}
|
||||
const item =
|
||||
info && info.body && info.body.item_list && info.body.item_list[0];
|
||||
const playAddr =
|
||||
item && item.video && item.video.play_addr && item.video.play_addr.url_list;
|
||||
if (!playAddr || !playAddr.length) {
|
||||
return { success: false, error: "iesdouyin 接口未返回播放地址" };
|
||||
}
|
||||
// Force HTTPS + replace `playwm` (watermarked) with `play` if present.
|
||||
const mediaUrl = playAddr[0]
|
||||
.replace(/^http:\/\//, "https://")
|
||||
.replace("/playwm/", "/play/");
|
||||
return this.downloadDirectVideo(mediaUrl);
|
||||
}
|
||||
|
||||
_ua() {
|
||||
return (
|
||||
"Mozilla/5.0 (Windows NT 10.0; Win64; x64) " +
|
||||
"AppleWebKit/537.36 (KHTML, like Gecko) " +
|
||||
"Chrome/124.0.0.0 Safari/537.36"
|
||||
);
|
||||
}
|
||||
_extract(re, s) {
|
||||
const m = String(s || "").match(re);
|
||||
return m ? m[1] : null;
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// AI rewrite (OpenAI-compatible chat completions)
|
||||
// ---------------------------------------------------------------------------
|
||||
const VideoRewriteOptimizer = {
|
||||
buildRewritePrompt(content, rewriteConfig, customPrompt) {
|
||||
if (customPrompt && String(customPrompt).trim().length > 0) {
|
||||
return String(customPrompt).replace(/\{\{\s*content\s*\}\}/g, content);
|
||||
}
|
||||
const cfg = rewriteConfig || {};
|
||||
const style = cfg.style || "短视频口播风格";
|
||||
const tone = cfg.tone || "自然、口语化";
|
||||
const lengthHint = cfg.length ? `控制在 ${cfg.length} 字以内` : "保持与原文相近的长度";
|
||||
return [
|
||||
"你是一名专业的短视频文案改写助手。",
|
||||
`请基于下面这段视频转写文案,用【${style}】重新仿写一版。`,
|
||||
`要求:语气 ${tone},${lengthHint},保留原意但避免与原文雷同;`,
|
||||
"不要解释、不要加任何额外标题,直接输出仿写后的正文。",
|
||||
"",
|
||||
"【原文案】",
|
||||
content,
|
||||
].join("\n");
|
||||
},
|
||||
};
|
||||
|
||||
async function rewriteWithAI(content, modelConfig, rewriteConfig, customPrompt) {
|
||||
if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) {
|
||||
log("error", "rewrite.missingConfig");
|
||||
return null;
|
||||
}
|
||||
const prompt = VideoRewriteOptimizer.buildRewritePrompt(
|
||||
content,
|
||||
rewriteConfig || {},
|
||||
customPrompt
|
||||
);
|
||||
const result = await __native.openaiChat({
|
||||
apiUrl: modelConfig.apiUrl,
|
||||
apiKey: modelConfig.apiKey,
|
||||
model: modelConfig.modelId,
|
||||
messages: [
|
||||
{ role: "system", content: "你是专业的短视频文案改写助手。" },
|
||||
{ role: "user", content: prompt },
|
||||
],
|
||||
temperature: typeof modelConfig.temperature === "number" ? modelConfig.temperature : 0.8,
|
||||
max_tokens: modelConfig.maxTokens || 1024,
|
||||
});
|
||||
if (!result || !result.success) {
|
||||
log("error", "rewrite.failed", { error: result && result.error });
|
||||
return null;
|
||||
}
|
||||
return (result.content || "").trim();
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// VideoRewriteIntegration.processDouyinShareComplete
|
||||
// The 1:1 port of the Electron version's pipeline.
|
||||
// ---------------------------------------------------------------------------
|
||||
async function processDouyinShareComplete(shareText, modelConfig, rewriteConfig, customPrompt) {
|
||||
let videoPath = null;
|
||||
let audioPath = null;
|
||||
try {
|
||||
log("info", "pipeline.start", {
|
||||
inputLength: shareText && shareText.length,
|
||||
modelConfig: {
|
||||
providerId: modelConfig && modelConfig.providerId,
|
||||
modelId: modelConfig && modelConfig.modelId,
|
||||
asrMode: modelConfig && modelConfig.asrMode,
|
||||
},
|
||||
});
|
||||
|
||||
// 1) parse share text -> video URL
|
||||
progress("正在解析视频URL...");
|
||||
const parseResult = DouyinContentParser.parseContent(shareText);
|
||||
if (!parseResult.success || !parseResult.content) {
|
||||
return { success: false, error: parseResult.error || "内容解析失败" };
|
||||
}
|
||||
const { videoUrl } = parseResult.content;
|
||||
log("info", "pipeline.urlExtracted", { videoUrl });
|
||||
|
||||
// 2) download video
|
||||
progress("正在下载视频...");
|
||||
const downloader = new DouyinVideoDownloader();
|
||||
const downloadResult = DouyinContentParser.isDirectVideoUrl(videoUrl)
|
||||
? await downloader.downloadDirectVideo(videoUrl)
|
||||
: await downloader.downloadDouyinVideo(videoUrl);
|
||||
if (!downloadResult.success || !downloadResult.videoPath) {
|
||||
return { success: false, error: downloadResult.error || "视频下载失败" };
|
||||
}
|
||||
videoPath = downloadResult.videoPath;
|
||||
log("info", "pipeline.videoDownloaded", { videoPath });
|
||||
|
||||
// 3) extract audio (ffmpeg sidecar)
|
||||
progress("视频下载完成,正在提取音频...");
|
||||
try {
|
||||
audioPath = await __native.ffmpegExtractAudio(videoPath);
|
||||
} catch (e) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频提取失败: ${(e && e.message) || e}`,
|
||||
};
|
||||
}
|
||||
log("info", "pipeline.audioExtracted", { audioPath });
|
||||
|
||||
// 4) ASR
|
||||
const asrMode = (modelConfig && modelConfig.asrMode) || "online";
|
||||
let recognizedText = "";
|
||||
if (asrMode === "online") {
|
||||
progress("音频提取完成,正在上传到OSS...");
|
||||
const ossConfig = modelConfig.ossConfig;
|
||||
if (!ossConfig || !ossConfig.bucket) {
|
||||
return { success: false, error: "未配置阿里云 OSS(bucket / region / key)" };
|
||||
}
|
||||
const upload = await __native.aliyunOssUpload(audioPath, ossConfig);
|
||||
if (!upload || !upload.success || !upload.url) {
|
||||
return {
|
||||
success: false,
|
||||
error: `音频上传失败: ${(upload && upload.error) || "unknown"}`,
|
||||
};
|
||||
}
|
||||
log("info", "pipeline.ossOk", { audioUrl: upload.url.slice(0, 100) + "..." });
|
||||
|
||||
progress("音频上传完成,正在识别文案(在线模式)...");
|
||||
const asr = await __native.aliyunAsrFiletrans(upload.url, {
|
||||
apiKey: modelConfig.aliyunApiKey,
|
||||
model: "qwen3-asr-flash-filetrans",
|
||||
});
|
||||
if (!asr || !asr.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `语音识别失败(在线模式): ${(asr && asr.error) || "unknown"}`,
|
||||
};
|
||||
}
|
||||
recognizedText = asr.text || "";
|
||||
log("info", "pipeline.asrOk", {
|
||||
method: "aliyun-online",
|
||||
len: recognizedText.length,
|
||||
});
|
||||
} else {
|
||||
progress("音频提取完成,正在识别文案(本地模式)...");
|
||||
const info = modelConfig.asrServerInfo;
|
||||
if (!info) {
|
||||
return {
|
||||
success: false,
|
||||
error: "未配置本地语音识别服务器,请在「设置」中配置",
|
||||
};
|
||||
}
|
||||
const asr = await __native.localAsr(audioPath, info);
|
||||
if (!asr || !asr.success) {
|
||||
return {
|
||||
success: false,
|
||||
error: `语音识别失败(本地模式): ${(asr && asr.error) || "unknown"}`,
|
||||
};
|
||||
}
|
||||
recognizedText = asr.text || "";
|
||||
log("info", "pipeline.asrOk", {
|
||||
method: "local",
|
||||
server: info.name,
|
||||
len: recognizedText.length,
|
||||
});
|
||||
}
|
||||
|
||||
// 5) rewrite
|
||||
if (!modelConfig || !modelConfig.apiUrl || !modelConfig.apiKey) {
|
||||
return { success: false, error: "未配置AI模型,请先在设置中配置" };
|
||||
}
|
||||
progress("文案识别完成,正在改写...");
|
||||
const rewrittenDescription = await rewriteWithAI(
|
||||
recognizedText,
|
||||
modelConfig,
|
||||
rewriteConfig,
|
||||
customPrompt
|
||||
);
|
||||
if (!rewrittenDescription) {
|
||||
return { success: false, error: "AI仿写失败,请检查模型配置和网络连接" };
|
||||
}
|
||||
const rewrittenTitle = DouyinContentParser.generateTitleFromDescription(rewrittenDescription);
|
||||
|
||||
log("info", "pipeline.success", {
|
||||
originalLength: recognizedText.length,
|
||||
rewrittenLength: rewrittenDescription.length,
|
||||
});
|
||||
progress("完成!");
|
||||
|
||||
return {
|
||||
success: true,
|
||||
original: {
|
||||
videoUrl,
|
||||
description: recognizedText,
|
||||
hashtags: [],
|
||||
title: "视频音频识别结果",
|
||||
},
|
||||
rewritten: {
|
||||
description: rewrittenDescription,
|
||||
title: rewrittenTitle,
|
||||
fullContent: rewrittenDescription,
|
||||
},
|
||||
};
|
||||
} catch (e) {
|
||||
log("error", "pipeline.error", { message: (e && e.message) || String(e) });
|
||||
return { success: false, error: (e && e.message) || String(e) };
|
||||
} finally {
|
||||
// 6) cleanup local temp files
|
||||
if (videoPath) {
|
||||
try { __native.deleteFile(videoPath); } catch (_) {}
|
||||
}
|
||||
if (audioPath) {
|
||||
try { __native.deleteFile(audioPath); } catch (_) {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// ---------------------------------------------------------------------------
|
||||
// Bootstrap: register into the global script table.
|
||||
// The Rust runtime looks the entry up by `scriptName` at invocation time
|
||||
// (see commands/quickjs.rs + js_runtime/mod.rs).
|
||||
// ---------------------------------------------------------------------------
|
||||
globalThis.__scripts = globalThis.__scripts || {};
|
||||
globalThis.__scripts["process_douyin_share"] = function (params) {
|
||||
const p = params || {};
|
||||
return processDouyinShareComplete(
|
||||
p.shareText,
|
||||
p.modelConfig,
|
||||
p.rewriteConfig,
|
||||
p.customPrompt || null
|
||||
);
|
||||
};
|
||||
@@ -1,21 +1,15 @@
|
||||
//! 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:
|
||||
//! QuickJS 流水线命令:脚本由后端 `/api/v1/quickjs-scripts` 下发,
|
||||
//! 运行时注入 `__native` 后 eval,并调用 `globalThis.__quickjsMain(params)`。
|
||||
//!
|
||||
//! 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
|
||||
//! ```ignore
|
||||
//! 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",
|
||||
//! scriptName: "douyin_pipeline.js",
|
||||
//! params: { shareText: "...", modelConfig: {...}, ... },
|
||||
//! });
|
||||
//! off();
|
||||
@@ -48,11 +42,6 @@ pub enum FrontendEvent {
|
||||
|
||||
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,
|
||||
@@ -62,7 +51,6 @@ pub async fn run_quickjs_script(
|
||||
) -> 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();
|
||||
@@ -92,7 +80,7 @@ pub async fn run_quickjs_script(
|
||||
Ok(result)
|
||||
}
|
||||
|
||||
/// Diagnostic helper — returns the list of registered script names.
|
||||
/// 列出后端 `scripts/quickjs` 目录中的 `.js` 文件名。
|
||||
#[tauri::command]
|
||||
pub async fn list_quickjs_scripts() -> Result<Vec<String>, String> {
|
||||
js_runtime::list_scripts().await.map_err(|e| e.to_string())
|
||||
|
||||
@@ -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(¶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 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 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 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()
|
||||
)
|
||||
|
||||
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<_>>()
|
||||
})
|
||||
.await?;
|
||||
.unwrap_or_default();
|
||||
|
||||
Ok(names)
|
||||
}
|
||||
|
||||
@@ -155,11 +155,11 @@ export const useWorkflowStore = defineStore("workflow", {
|
||||
},
|
||||
},
|
||||
});
|
||||
export async function processDouyinShare(shareText){
|
||||
export async function processDouyinShare(shareText) {
|
||||
const result = await invoke("run_quickjs_script", {
|
||||
scriptName:"douyin_pipeline.js",
|
||||
scriptName: "douyin_pipeline.js",
|
||||
params: {
|
||||
shareText: "复制此链接,打开Dou音搜索,直接观看视频!https://v.douyin.com/xxxxx/",
|
||||
shareText: shareText ?? "",
|
||||
modelConfig: {
|
||||
providerId: "openai",
|
||||
modelId: "gpt-4o-mini",
|
||||
|
||||
Reference in New Issue
Block a user