diff --git a/package-lock.json b/package-lock.json index 045a0f2..f64aed6 100644 --- a/package-lock.json +++ b/package-lock.json @@ -11,8 +11,11 @@ "@primeuix/themes": "^1.2.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "monaco-editor": "^0.55.1", + "monaco-editor-vue3": "^1.0.5", "pinia": "^3.0.3", "primevue": "^4.3.6", + "vite-plugin-monaco-editor": "^1.1.0", "vue": "^3.5.17", "vue-router": "^4.5.1" }, @@ -1406,6 +1409,12 @@ "integrity": "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w==", "dev": true }, + "node_modules/@types/trusted-types": { + "version": "2.0.7", + "resolved": "https://registry.npmjs.org/@types/trusted-types/-/trusted-types-2.0.7.tgz", + "integrity": "sha512-ScaPdn1dQczgbl0QFTeTOmVHFULt394XJgOQNoyVhZ6r2vLnMLJfBPd53SB52T/3G36VI1/g2MZaX0cwDuXsfw==", + "optional": true + }, "node_modules/@vitejs/plugin-vue": { "version": "6.0.7", "resolved": "https://registry.npmjs.org/@vitejs/plugin-vue/-/plugin-vue-6.0.7.tgz", @@ -1680,6 +1689,14 @@ "node": ">=8" } }, + "node_modules/dompurify": { + "version": "3.2.7", + "resolved": "https://registry.npmjs.org/dompurify/-/dompurify-3.2.7.tgz", + "integrity": "sha512-WhL/YuveyGXJaerVlMYGWhvQswa7myDG17P7Vu65EWC05o8vfeNbvNf4d/BOvH99+ZW+LlQsc1GDKMa1vNK6dw==", + "optionalDependencies": { + "@types/trusted-types": "^2.0.7" + } + }, "node_modules/electron-to-chromium": { "version": "1.5.356", "resolved": "https://registry.npmjs.org/electron-to-chromium/-/electron-to-chromium-1.5.356.tgz", @@ -2097,11 +2114,40 @@ "@jridgewell/sourcemap-codec": "^1.5.5" } }, + "node_modules/marked": { + "version": "14.0.0", + "resolved": "https://registry.npmjs.org/marked/-/marked-14.0.0.tgz", + "integrity": "sha512-uIj4+faQ+MgHgwUW1l2PsPglZLOLOT1uErt06dAPtx2kjteLAkbsd/0FiYg/MGS+i7ZKLb7w2WClxHkzOOuryQ==", + "bin": { + "marked": "bin/marked.js" + }, + "engines": { + "node": ">= 18" + } + }, "node_modules/mitt": { "version": "3.0.1", "resolved": "https://registry.npmjs.org/mitt/-/mitt-3.0.1.tgz", "integrity": "sha512-vKivATfr97l2/QBCYAkXYDbrIWPM2IIKEl7YPhjCvKlG3kE2gm+uBo6nEXK3M5/Ffh/FLpKExzOQ3JJoJGFKBw==" }, + "node_modules/monaco-editor": { + "version": "0.55.1", + "resolved": "https://registry.npmjs.org/monaco-editor/-/monaco-editor-0.55.1.tgz", + "integrity": "sha512-jz4x+TJNFHwHtwuV9vA9rMujcZRb0CEilTEwG2rRSpe/A7Jdkuj8xPKttCgOh+v/lkHy7HsZ64oj+q3xoAFl9A==", + "dependencies": { + "dompurify": "3.2.7", + "marked": "14.0.0" + } + }, + "node_modules/monaco-editor-vue3": { + "version": "1.0.5", + "resolved": "https://registry.npmjs.org/monaco-editor-vue3/-/monaco-editor-vue3-1.0.5.tgz", + "integrity": "sha512-1n4ayGcuYpZcQ6A4PUYZYqXuf6n+A/2pUNdLnb8I39WZV0zkhfvSRNau3hB0qzq6vp6wSNZ7MgcjQRCJCiGHWQ==", + "peerDependencies": { + "monaco-editor": ">= 0.25.0 < 1", + "vue": "^3" + } + }, "node_modules/nanoid": { "version": "3.3.12", "resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.12.tgz", @@ -2444,6 +2490,14 @@ } } }, + "node_modules/vite-plugin-monaco-editor": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/vite-plugin-monaco-editor/-/vite-plugin-monaco-editor-1.1.0.tgz", + "integrity": "sha512-IvtUqZotrRoVqwT0PBBDIZPNraya3BxN/bfcNfnxZ5rkJiGcNtO5eAOWWSgT7zullIAEqQwxMU83yL9J5k7gww==", + "peerDependencies": { + "monaco-editor": ">=0.33.0" + } + }, "node_modules/vue": { "version": "3.5.34", "resolved": "https://registry.npmjs.org/vue/-/vue-3.5.34.tgz", diff --git a/package.json b/package.json index fc4e1ea..9a1f65b 100644 --- a/package.json +++ b/package.json @@ -13,6 +13,8 @@ "@primeuix/themes": "^1.2.1", "@tauri-apps/api": "^2", "@tauri-apps/plugin-opener": "^2", + "monaco-editor": "^0.55.1", + "monaco-editor-vue3": "^1.0.5", "pinia": "^3.0.3", "primevue": "^4.3.6", "vue": "^3.5.17", @@ -25,6 +27,7 @@ "autoprefixer": "^10.4.21", "postcss": "^8.5.6", "tailwindcss": "^4.1.11", - "vite": "^6.0.3" + "vite": "^6.0.3", + "vite-plugin-monaco-editor": "^1.1.0" } } diff --git a/src-tauri/src/commands/quickjs.rs b/src-tauri/src/commands/quickjs.rs index ac8ed54..079b52e 100644 --- a/src-tauri/src/commands/quickjs.rs +++ b/src-tauri/src/commands/quickjs.rs @@ -1,5 +1,6 @@ //! QuickJS 流水线命令:脚本由后端 `/api/v1/quickjs-scripts` 下发, //! 运行时注入 `__native` 后 eval,并调用 `globalThis.__quickjsMain(params)`。 +//! 调试命令 `run_quickjs_script_source` 则将源码当作异步函数体直接执行,并注入变量 `params`。 //! //! 前端用法示例: //! @@ -80,6 +81,45 @@ pub async fn run_quickjs_script( Ok(result) } +/// 调试:直接执行传入的 QuickJS 源码(异步函数体),可使用 `__native`、`params`、`return`,无需 `__quickjsMain`。 +#[tauri::command] +pub async fn run_quickjs_script_source( + app: AppHandle, + window: Window, + script_source: String, + params: Value, +) -> Result { + let (tx, mut rx) = mpsc::unbounded_channel::(); + + let app_for_emit = app.clone(); + let win_label = window.label().to_string(); + const EMIT_LABEL: &str = ""; + 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: EMIT_LABEL.to_string(), + status: s, + }, + PipelineEvent::Log { level, msg, fields } => FrontendEvent::Log { + script_name: EMIT_LABEL.to_string(), + level, + msg, + fields, + }, + }; + let _ = app_for_emit.emit_to(&win_label, EVENT_NAME, payload); + } + }); + + let result = js_runtime::run_script_source(script_source, params, tx) + .await + .map_err(|e| e.to_string())?; + + pump.await.ok(); + Ok(result) +} + /// 列出后端 `scripts/quickjs` 目录中的 `.js` 文件名。 #[tauri::command] pub async fn list_quickjs_scripts() -> Result, String> { diff --git a/src-tauri/src/js_runtime/mod.rs b/src-tauri/src/js_runtime/mod.rs index a98c54d..7c45736 100644 --- a/src-tauri/src/js_runtime/mod.rs +++ b/src-tauri/src/js_runtime/mod.rs @@ -1,5 +1,6 @@ //! Embedded QuickJS runtime:脚本源码由 **Python 后端** 按文件名下发, -//! 桌面端通过 HTTP 拉取后在运行时 `eval`,入口为单一的 `globalThis.__quickjsMain`。 +//! 或由调试接口直接传入字符串:**调试路径**下整段源码作为异步函数体执行,并注入变量 `params`; +//! **服务端脚本路径**仍在 eval 后调用 `globalThis.__quickjsMain(params)`。 //! //! 后端:`GET /api/v1/quickjs-scripts?name=<文件名.js>`(`ApiResponse`,`data.source`)。 //! API 基址:`AICLIENT_API_BASE` 环境变量,默认 `http://127.0.0.1:8001`。 @@ -20,6 +21,9 @@ const ENTRY_GLOBAL: &str = "__quickjsMain"; const RESET_ENTRY_JS: &str = "globalThis.__quickjsMain = undefined;"; +/// 调试执行路径:暂存 invoke 传入的 `params`,eval 前写入 globalThis,立即读入局部变量 `params` 后删除。 +const INLINE_PARAMS_GLOBAL: &str = "__quickjsInlineParams"; + #[derive(Debug, Error)] pub enum JsError { #[error("rquickjs: {0}")] @@ -94,29 +98,34 @@ pub enum PipelineEvent { }, } -/// 从服务端拉取 `script_name` 对应源码,执行 `__quickjsMain(params)` 并返回 JSON 结果。 -pub async fn run_script( - script_name: String, +async fn eval_quickjs_main_entry( + source: String, + eval_label: String, params: Value, events: UnboundedSender, ) -> Result { - 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 label_for_eval = eval_label.clone(); + let label_for_await = eval_label; + let result_json: Value = async_with!(context => |ctx| { 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}")))?; + // async 类原生函数依赖全局 __native,须在挂到 globalThis 之后再包一层解析 JSON / 抛错 + let _: () = ctx.eval(native::NATIVE_ASYNC_SHIM).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("native async shim: {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)))?; + .map_err(|e| JsError::QuickJs(format!("eval {label_for_eval}: {e}")))?; let main_fn: rquickjs::Function = ctx.globals().get(ENTRY_GLOBAL) .map_err(|_| JsError::MissingMainEntry)?; @@ -130,7 +139,7 @@ pub async fn run_script( .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 {}: {e}", script_name)))?, + .map_err(|e| JsError::QuickJs(format!("await {label_for_await}: {e}")))?, Err(_) => raw, }; @@ -148,6 +157,89 @@ pub async fn run_script( Ok(result_json) } +/// 调试:`source` 作为异步函数体执行,可使用 `params`、`await`、`return`;不要求 `__quickjsMain`。 +async fn eval_quickjs_inline_source( + source: String, + params: Value, + events: UnboundedSender, +) -> Result { + let bridge = Arc::new(NativeBridge::new(events)); + + let runtime = AsyncRuntime::new()?; + let context = AsyncContext::full(&runtime).await?; + + let wrapped = format!( + r#"(async () => {{ + const params = globalThis.{global}; + try {{ delete globalThis.{global}; }} catch (_) {{}} +{user} +}})()"#, + global = INLINE_PARAMS_GLOBAL, + user = source, + ); + + let result_json: Value = async_with!(context => |ctx| { + 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}")))?; + + let _: () = ctx.eval(native::NATIVE_ASYNC_SHIM).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("native async shim: {e}")))?; + + let _: () = ctx.eval(RESET_ENTRY_JS).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("reset entry: {e}")))?; + + 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}")))?; + ctx.globals().set(INLINE_PARAMS_GLOBAL, params_value).map_err(|e| { + JsError::QuickJs(format!("set {}: {e}", INLINE_PARAMS_GLOBAL)) + })?; + + let raw: rquickjs::Value = ctx.eval(wrapped.as_str()).catch(&ctx) + .map_err(|e| JsError::QuickJs(format!("eval : {e}")))?; + + let value: rquickjs::Value = match Promise::from_value(raw.clone()) { + Ok(p) => p.into_future().await + .map_err(|e| JsError::QuickJs(format!("await : {e}")))?, + Err(_) => raw, + }; + + 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::(parsed) + }) + .await?; + + Ok(result_json) +} + +/// 从服务端拉取 `script_name` 对应源码,执行 `__quickjsMain(params)` 并返回 JSON 结果。 +pub async fn run_script( + script_name: String, + params: Value, + events: UnboundedSender, +) -> Result { + let source = fetch_script_source(&script_name).await?; + eval_quickjs_main_entry(source, script_name, params, events).await +} + +/// 调试执行:整段 `source` 作为异步函数体运行,注入 `params`,无需 `__quickjsMain`。 +pub async fn run_script_source( + source: String, + params: Value, + events: UnboundedSender, +) -> Result { + eval_quickjs_inline_source(source, params, events).await +} + /// 当前服务端 `scripts/quickjs` 目录下的 `.js` 文件名列表。 pub async fn list_scripts() -> Result, JsError> { let base = api_base().trim_end_matches('/').to_string(); diff --git a/src-tauri/src/js_runtime/native.rs b/src-tauri/src/js_runtime/native.rs index 960c2da..4afde04 100644 --- a/src-tauri/src/js_runtime/native.rs +++ b/src-tauri/src/js_runtime/native.rs @@ -6,8 +6,8 @@ //! 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`. +//! installed **after** `__native` is assigned on `globalThis` (see `mod.rs`) +//! 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. @@ -26,6 +26,27 @@ use uuid::Uuid; use super::PipelineEvent; use crate::{asr, chat, ffmpeg, http, oss}; +/// 必须在 `globalThis.__native` 已赋值之后执行,否则会访问未定义的 `__native`。 +pub const NATIVE_ASYNC_SHIM: &str = 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); +})(); +"#; + /// Shared host state passed by reference into every native function. pub struct NativeBridge { pub events: UnboundedSender, @@ -267,29 +288,5 @@ pub fn build_native_object<'js>( })), )?; - // 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) } diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index cb783b6..feadf8f 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -26,6 +26,7 @@ pub fn run() { .invoke_handler(tauri::generate_handler![ commands::auth::sync_auth_session, commands::quickjs::run_quickjs_script, + commands::quickjs::run_quickjs_script_source, commands::quickjs::list_quickjs_scripts, ]) .run(tauri::generate_context!()) diff --git a/src/components/AppSidebar.vue b/src/components/AppSidebar.vue index 508b17d..a69e79e 100644 --- a/src/components/AppSidebar.vue +++ b/src/components/AppSidebar.vue @@ -4,7 +4,7 @@ import { useRoute } from "vue-router"; const route = useRoute(); -const menuItems = [ +const baseMenuItems = [ { name: "home", label: "首页", to: "/", icon: "home" }, { name: "avatar", label: "形象", to: "/avatar", icon: "avatar" }, { name: "material", label: "素材", to: "/material", icon: "material" }, @@ -16,6 +16,13 @@ const menuItems = [ { name: "help", label: "帮助", to: "/help", icon: "help" }, ]; +const menuItems = computed(() => { + if (import.meta.env.DEV) { + return [...baseMenuItems, { name: "debug", label: "调试", to: "/debug", icon: "debug" }]; + } + return baseMenuItems; +}); + const activeName = computed(() => route.name); @@ -65,6 +72,11 @@ const activeName = computed(() => route.name); + + + + + {{ item.label }} diff --git a/src/router/index.js b/src/router/index.js index fb3782a..b021fa3 100644 --- a/src/router/index.js +++ b/src/router/index.js @@ -1,101 +1,112 @@ -import { createRouter, createWebHistory } from "vue-router"; -import { useAuthStore } from "../stores/auth"; - -const placeholder = () => import("../views/PlaceholderView.vue"); - -const routes = [ - { - path: "/", - component: () => import("../layouts/MainLayout.vue"), - meta: { requiresAuth: true }, - children: [ - { - path: "", - name: "home", - component: () => import("../views/HomeView.vue"), - }, - { - path: "avatar", - name: "avatar", - component: placeholder, - meta: { title: "形象" }, - }, - { - path: "material", - name: "material", - component: placeholder, - meta: { title: "素材" }, - }, - { - path: "voice", - name: "voice", - component: placeholder, - meta: { title: "声音" }, - }, - { - path: "compose", - name: "compose", - component: placeholder, - meta: { title: "合成" }, - }, - { - path: "extract", - name: "extract", - component: placeholder, - meta: { title: "提取" }, - }, - { - path: "design", - name: "design", - component: placeholder, - meta: { title: "设计" }, - }, - { - path: "model", - name: "model", - component: placeholder, - meta: { title: "模型" }, - }, - { - path: "help", - name: "help", - component: placeholder, - meta: { title: "帮助" }, - }, - ], - }, - { - path: "/login", - name: "login", - component: () => import("../views/LoginView.vue"), - }, - { - path: "/register", - name: "register", - component: () => import("../views/RegisterView.vue"), - }, - { - path: "/:pathMatch(.*)*", - redirect: "/login", - }, -]; - -const router = createRouter({ - history: createWebHistory(), - routes, -}); - -router.beforeEach((to) => { - const auth = useAuthStore(); - const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); - - if (requiresAuth && !auth.isAuthenticated) { - return { name: "login" }; - } - - if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) { - return { name: "home" }; - } -}); - -export default router; +import { createRouter, createWebHistory } from "vue-router"; +import { useAuthStore } from "../stores/auth"; + +const placeholder = () => import("../views/PlaceholderView.vue"); + +const mainChildren = [ + { + path: "", + name: "home", + component: () => import("../views/HomeView.vue"), + }, + { + path: "avatar", + name: "avatar", + component: placeholder, + meta: { title: "形象" }, + }, + { + path: "material", + name: "material", + component: placeholder, + meta: { title: "素材" }, + }, + { + path: "voice", + name: "voice", + component: placeholder, + meta: { title: "声音" }, + }, + { + path: "compose", + name: "compose", + component: placeholder, + meta: { title: "合成" }, + }, + { + path: "extract", + name: "extract", + component: placeholder, + meta: { title: "提取" }, + }, + { + path: "design", + name: "design", + component: placeholder, + meta: { title: "设计" }, + }, + { + path: "model", + name: "model", + component: placeholder, + meta: { title: "模型" }, + }, + { + path: "help", + name: "help", + component: placeholder, + meta: { title: "帮助" }, + }, +]; + +if (import.meta.env.DEV) { + mainChildren.push({ + path: "debug", + name: "debug", + component: () => import("../views/DebugView.vue"), + meta: { title: "调试", devOnly: true }, + }); +} + +const routes = [ + { + path: "/", + component: () => import("../layouts/MainLayout.vue"), + meta: { requiresAuth: true }, + children: mainChildren, + }, + { + path: "/login", + name: "login", + component: () => import("../views/LoginView.vue"), + }, + { + path: "/register", + name: "register", + component: () => import("../views/RegisterView.vue"), + }, + { + path: "/:pathMatch(.*)*", + redirect: "/login", + }, +]; + +const router = createRouter({ + history: createWebHistory(), + routes, +}); + +router.beforeEach((to) => { + const auth = useAuthStore(); + const requiresAuth = to.matched.some((record) => record.meta.requiresAuth); + + if (requiresAuth && !auth.isAuthenticated) { + return { name: "login" }; + } + + if ((to.name === "login" || to.name === "register") && auth.isAuthenticated) { + return { name: "home" }; + } +}); + +export default router; diff --git a/src/views/DebugView.vue b/src/views/DebugView.vue new file mode 100644 index 0000000..0f34792 --- /dev/null +++ b/src/views/DebugView.vue @@ -0,0 +1,123 @@ + + + diff --git a/vite.config.js b/vite.config.js index ddfdf57..73bab0a 100644 --- a/vite.config.js +++ b/vite.config.js @@ -1,11 +1,17 @@ import { defineConfig } from "vite"; import vue from "@vitejs/plugin-vue"; import tailwindcss from "@tailwindcss/vite"; +import monacoEditorPluginRaw from "vite-plugin-monaco-editor"; + +const monacoEditorPlugin = + typeof monacoEditorPluginRaw === "function" + ? monacoEditorPluginRaw + : monacoEditorPluginRaw.default; const host = process.env.TAURI_DEV_HOST; export default defineConfig({ - plugins: [vue(), tailwindcss()], + plugins: [monacoEditorPlugin({}), vue(), tailwindcss()], clearScreen: false, server: { port: 1420,