mirror of
https://github.com/tinyhumansai/openhuman.git
synced 2026-07-27 21:08:00 +00:00
Merge branch 'develop' of github.com:vezuresdotxyz/frontend-runner-alphahuman into develop
This commit is contained in:
+1
-1
Submodule skills updated: 87d75f69b7...26341b5297
@@ -29,8 +29,9 @@ tauri-plugin-os = "2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
serde_json = "1"
|
||||
|
||||
# HTTP client (rustls for cross-platform TLS support including Android)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "stream"] }
|
||||
# HTTP client: rustls for cross-platform TLS (Android), native-tls for desktop skill HTTP
|
||||
# (some APIs/CDNs use TLS configs that rustls can't negotiate — native-tls uses OS TLS stack)
|
||||
reqwest = { version = "0.12", default-features = false, features = ["json", "blocking", "rustls-tls", "native-tls", "stream"] }
|
||||
|
||||
# Async runtime
|
||||
tokio = { version = "1", features = ["full", "sync"] }
|
||||
|
||||
@@ -51,6 +51,7 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
|
||||
|
||||
let timeout_secs = options.timeout.unwrap_or(30);
|
||||
let client = reqwest::blocking::Client::builder()
|
||||
.use_native_tls()
|
||||
.timeout(std::time::Duration::from_secs(timeout_secs))
|
||||
.build()
|
||||
.map_err(|e| format!("Failed to create HTTP client: {e}"))?;
|
||||
@@ -74,7 +75,17 @@ fn do_fetch(url: &str, options_json: &str) -> Result<String, String> {
|
||||
req = req.body(body);
|
||||
}
|
||||
|
||||
let resp = req.send().map_err(|e| format!("HTTP request failed: {e}"))?;
|
||||
let resp = req.send().map_err(|e| {
|
||||
// Walk the full error chain so the caller sees the root cause
|
||||
// (TLS, DNS, timeout, etc.) not just "error sending request for url".
|
||||
let mut msg = format!("HTTP request failed: {e}");
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(" | caused by: {cause}"));
|
||||
source = std::error::Error::source(cause);
|
||||
}
|
||||
msg
|
||||
})?;
|
||||
|
||||
let status = resp.status().as_u16();
|
||||
let headers: HashMap<String, String> = resp
|
||||
|
||||
@@ -227,6 +227,8 @@ impl QjsSkillInstance {
|
||||
return;
|
||||
}
|
||||
|
||||
restore_oauth_credential(&ctx, &config.skill_id).await;
|
||||
|
||||
// Call init() lifecycle
|
||||
if let Err(e) = call_lifecycle(&ctx, "init").await {
|
||||
let mut s = state.write();
|
||||
@@ -389,6 +391,8 @@ async fn handle_message(
|
||||
) -> bool {
|
||||
match msg {
|
||||
SkillMessage::CallTool { tool_name, arguments, reply } => {
|
||||
// Lazy-load persisted OAuth credential before calling the tool
|
||||
restore_oauth_credential(ctx, skill_id).await;
|
||||
let result = handle_tool_call(ctx, &tool_name, arguments).await;
|
||||
let _ = reply.send(result);
|
||||
}
|
||||
@@ -400,9 +404,23 @@ async fn handle_message(
|
||||
}
|
||||
SkillMessage::Stop { reply } => {
|
||||
let _ = call_lifecycle(ctx, "stop").await;
|
||||
|
||||
// Clear OAuth credential from memory and mark as disconnected in store
|
||||
let clear_code = r#"(function() {
|
||||
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {
|
||||
globalThis.oauth.__setCredential(null);
|
||||
}
|
||||
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {
|
||||
globalThis.state.set('__oauth_credential', '');
|
||||
}
|
||||
})()"#;
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
|
||||
}).await;
|
||||
state.write().status = SkillStatus::Stopped;
|
||||
log::info!("[skill:{}] Stopped", skill_id);
|
||||
log::info!("[skill:{}] Stopped (OAuth credential cleared)", skill_id);
|
||||
let _ = reply.send(());
|
||||
|
||||
return true; // Signal to stop
|
||||
}
|
||||
SkillMessage::SetupStart { reply } => {
|
||||
@@ -444,23 +462,40 @@ async fn handle_message(
|
||||
SkillMessage::Rpc { method, params, reply } => {
|
||||
let result = match method.as_str() {
|
||||
"oauth/complete" => {
|
||||
// Set credential on the oauth bridge, then call onOAuthComplete
|
||||
let set_cred_code = format!(
|
||||
"if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{ globalThis.oauth.__setCredential({}); }}",
|
||||
serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string())
|
||||
// Set credential on the oauth bridge + persist to store
|
||||
let cred_json = serde_json::to_string(¶ms).unwrap_or_else(|_| "null".to_string());
|
||||
let code = format!(
|
||||
r#"(function() {{
|
||||
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {{
|
||||
globalThis.oauth.__setCredential({cred});
|
||||
}}
|
||||
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {{
|
||||
globalThis.state.set('__oauth_credential', {cred});
|
||||
}}
|
||||
}})()"#,
|
||||
cred = cred_json
|
||||
);
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(set_cred_code.as_bytes());
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(code.as_bytes());
|
||||
}).await;
|
||||
log::info!("[skill:{}] OAuth credential set and persisted to store", skill_id);
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_call(ctx, "onOAuthComplete", ¶ms_str).await
|
||||
}
|
||||
"oauth/revoked" => {
|
||||
// Clear credential on the oauth bridge, then call onOAuthRevoked
|
||||
let clear_code = "if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) { globalThis.oauth.__setCredential(null); }";
|
||||
// Clear credential: set to empty string so it's clearly "disconnected"
|
||||
let clear_code = r#"(function() {
|
||||
if (typeof globalThis.oauth !== 'undefined' && globalThis.oauth.__setCredential) {
|
||||
globalThis.oauth.__setCredential(null);
|
||||
}
|
||||
if (typeof globalThis.state !== 'undefined' && globalThis.state.set) {
|
||||
globalThis.state.set('__oauth_credential', '');
|
||||
}
|
||||
})()"#;
|
||||
ctx.with(|js_ctx| {
|
||||
let _ = js_ctx.eval::<rquickjs::Value, _>(clear_code.as_bytes());
|
||||
}).await;
|
||||
log::info!("[skill:{}] OAuth credential cleared from store", skill_id);
|
||||
let params_str = serde_json::to_string(¶ms).unwrap_or_else(|_| "{}".to_string());
|
||||
handle_js_void_call(ctx, "onOAuthRevoked", ¶ms_str).await
|
||||
.map(|()| serde_json::json!({ "ok": true }))
|
||||
@@ -749,3 +784,26 @@ async fn handle_js_void_call(
|
||||
.map(|_| ())
|
||||
}).await
|
||||
}
|
||||
|
||||
/// Load a persisted OAuth credential from the skill's store and inject it
|
||||
/// into the JS context so tools have access to the credential.
|
||||
/// An empty string means "disconnected" — only non-empty values are restored.
|
||||
async fn restore_oauth_credential(ctx: &rquickjs::AsyncContext, skill_id: &str) {
|
||||
let code = r#"(function() {
|
||||
if (typeof globalThis.state === 'undefined' || typeof globalThis.oauth === 'undefined') return false;
|
||||
var cred = globalThis.state.get('__oauth_credential');
|
||||
if (cred && cred !== '' && globalThis.oauth.__setCredential) {
|
||||
globalThis.oauth.__setCredential(cred);
|
||||
return true;
|
||||
}
|
||||
return false;
|
||||
})()"#;
|
||||
|
||||
let restored = ctx.with(|js_ctx| {
|
||||
js_ctx.eval::<bool, _>(code.as_bytes()).unwrap_or(false)
|
||||
}).await;
|
||||
|
||||
if restored {
|
||||
log::info!("[skill:{}] Restored OAuth credential from store", skill_id);
|
||||
}
|
||||
}
|
||||
|
||||
+8
-8
@@ -807,12 +807,12 @@ globalThis.data = {
|
||||
// OAuth Bridge API (credential management and authenticated proxy)
|
||||
// ============================================================================
|
||||
(function () {
|
||||
var __oauthCredential = null;
|
||||
globalThis.__oauthCredential = null;
|
||||
|
||||
globalThis.oauth = {
|
||||
/** Get the current OAuth credential, or null if not connected. */
|
||||
getCredential: function () {
|
||||
return __oauthCredential;
|
||||
return globalThis.__oauthCredential;
|
||||
},
|
||||
|
||||
/**
|
||||
@@ -820,7 +820,7 @@ globalThis.data = {
|
||||
* Path is relative to manifest's apiBaseUrl.
|
||||
*/
|
||||
fetch: function (path, options) {
|
||||
if (!__oauthCredential) {
|
||||
if (!globalThis.__oauthCredential) {
|
||||
return {
|
||||
status: 401,
|
||||
headers: {},
|
||||
@@ -828,9 +828,9 @@ globalThis.data = {
|
||||
};
|
||||
}
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var cleanPath = path.charAt(0) === '/' ? path.slice(1) : path;
|
||||
var proxyUrl = backendUrl + '/proxy/by-id/' + __oauthCredential.credentialId + '/' + cleanPath;
|
||||
var proxyUrl = backendUrl + '/proxy/by-id/' + globalThis.__oauthCredential.credentialId + '/' + cleanPath;
|
||||
var method = (options && options.method) || 'GET';
|
||||
var headers = { 'Content-Type': 'application/json' };
|
||||
if (jwtToken) {
|
||||
@@ -856,7 +856,7 @@ globalThis.data = {
|
||||
if (__oauthCredential) {
|
||||
try {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var revokeOpts = JSON.stringify({
|
||||
method: 'DELETE',
|
||||
headers: {
|
||||
@@ -993,7 +993,7 @@ globalThis.model = {
|
||||
*/
|
||||
generate: function (prompt, options) {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var body = { prompt: prompt };
|
||||
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
|
||||
if (options && options.temperature) body.temperature = options.temperature;
|
||||
@@ -1023,7 +1023,7 @@ globalThis.model = {
|
||||
*/
|
||||
summarize: function (text, options) {
|
||||
var backendUrl = __platform.env('BACKEND_URL') || 'https://api.alphahuman.xyz';
|
||||
var jwtToken = __platform.env('JWT_TOKEN') || '';
|
||||
var jwtToken = __ops.get_session_token() || '';
|
||||
var body = { text: text };
|
||||
if (options && options.maxTokens) body.maxTokens = options.maxTokens;
|
||||
var result = __net.fetch(backendUrl + '/api/ai/summarize', JSON.stringify({
|
||||
|
||||
@@ -81,6 +81,12 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, timer_state: Arc<RwLock<
|
||||
}
|
||||
}))?;
|
||||
|
||||
ops.set("get_session_token", Function::new(ctx.clone(), || -> String {
|
||||
let token = crate::commands::auth::SESSION_SERVICE.get_token().unwrap_or_default();
|
||||
log::info!("[js] get_session_token: {}", token);
|
||||
return token;
|
||||
}))?;
|
||||
|
||||
// ========================================================================
|
||||
// Timers (2)
|
||||
// ========================================================================
|
||||
|
||||
@@ -21,7 +21,10 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
|
||||
let headers_obj = opts["headers"].as_object();
|
||||
let body = opts["body"].as_str();
|
||||
|
||||
let client = reqwest::Client::new();
|
||||
let client = reqwest::Client::builder()
|
||||
.use_native_tls()
|
||||
.build()
|
||||
.map_err(|e| js_err(e.to_string()))?;
|
||||
let mut req = match method {
|
||||
"GET" => client.get(&url),
|
||||
"POST" => client.post(&url),
|
||||
@@ -42,7 +45,15 @@ pub fn register<'js>(ctx: &Ctx<'js>, ops: &Object<'js>, ws_state: Arc<RwLock<Web
|
||||
req = req.body(b.to_string());
|
||||
}
|
||||
|
||||
let response = req.send().await.map_err(|e| js_err(e.to_string()))?;
|
||||
let response = req.send().await.map_err(|e| {
|
||||
let mut msg = e.to_string();
|
||||
let mut source = std::error::Error::source(&e);
|
||||
while let Some(cause) = source {
|
||||
msg.push_str(&format!(" | caused by: {cause}"));
|
||||
source = std::error::Error::source(cause);
|
||||
}
|
||||
js_err(msg)
|
||||
})?;
|
||||
|
||||
let status = response.status().as_u16();
|
||||
let status_text = response.status().canonical_reason().unwrap_or("").to_string();
|
||||
|
||||
@@ -121,6 +121,8 @@ pub struct WebSocketState {
|
||||
|
||||
pub const ALLOWED_ENV_VARS: &[&str] = &[
|
||||
"VITE_BACKEND_URL",
|
||||
"BACKEND_URL",
|
||||
"JWT_TOKEN",
|
||||
"VITE_TELEGRAM_BOT_USERNAME",
|
||||
"VITE_TELEGRAM_BOT_ID",
|
||||
"NODE_ENV",
|
||||
@@ -135,5 +137,5 @@ pub fn check_telegram_skill(skill_id: &str) -> Result<(), String> {
|
||||
}
|
||||
|
||||
pub fn js_err(msg: String) -> rquickjs::Error {
|
||||
rquickjs::Error::new_from_js_message("ops", "Error", msg)
|
||||
rquickjs::Error::new_from_js_message("skill", "RuntimeError", msg)
|
||||
}
|
||||
|
||||
@@ -9,7 +9,7 @@ interface ConnectionIndicatorProps {
|
||||
|
||||
const ConnectionIndicator = ({
|
||||
status: overrideStatus,
|
||||
description = 'Your browser is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.',
|
||||
description = 'Your device is now connected to the AlphaHuman AI. Keep the app running to keep the connection alive. You can message your assistant with the button below.',
|
||||
className = '',
|
||||
}: ConnectionIndicatorProps) => {
|
||||
// Use socket store status, but allow override via props
|
||||
|
||||
@@ -22,6 +22,7 @@ import {
|
||||
setSkillStatus,
|
||||
setSkillError,
|
||||
setSkillSetupComplete,
|
||||
setSkillOAuthCredential,
|
||||
setSkillTools,
|
||||
setSkillState,
|
||||
} from "../../store/skillsSlice";
|
||||
@@ -98,6 +99,15 @@ class SkillManager {
|
||||
if (setupRequired) {
|
||||
store.dispatch(setSkillStatus({ skillId, status: "setup_required" }));
|
||||
} else {
|
||||
// Re-inject persisted OAuth credential if available
|
||||
const oauthCred = skillState?.oauthCredential;
|
||||
if (oauthCred) {
|
||||
try {
|
||||
await runtime.oauthComplete(oauthCred);
|
||||
} catch (err) {
|
||||
console.warn(`[SkillManager] Failed to restore OAuth credential for ${skillId}:`, err);
|
||||
}
|
||||
}
|
||||
// Skill is ready — list tools
|
||||
await this.activateSkill(skillId);
|
||||
}
|
||||
@@ -251,12 +261,16 @@ class SkillManager {
|
||||
|
||||
const manifest = store.getState().skills.skills[skillId]?.manifest;
|
||||
|
||||
await runtime.oauthComplete({
|
||||
const credential = {
|
||||
credentialId: integrationId,
|
||||
provider: provider ?? manifest?.setup?.oauth?.provider ?? "unknown",
|
||||
grantedScopes: manifest?.setup?.oauth?.scopes ?? [],
|
||||
});
|
||||
};
|
||||
|
||||
await runtime.oauthComplete(credential);
|
||||
|
||||
// Persist credential so it survives app restarts
|
||||
store.dispatch(setSkillOAuthCredential({ skillId, credential }));
|
||||
// Mark setup as complete and activate
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: true }));
|
||||
await this.activateSkill(skillId);
|
||||
@@ -298,6 +312,7 @@ class SkillManager {
|
||||
async disconnectSkill(skillId: string): Promise<void> {
|
||||
await this.stopSkill(skillId);
|
||||
store.dispatch(setSkillSetupComplete({ skillId, complete: false }));
|
||||
store.dispatch(setSkillOAuthCredential({ skillId, credential: undefined }));
|
||||
store.dispatch(setSkillState({ skillId, state: {} }));
|
||||
}
|
||||
|
||||
|
||||
@@ -182,11 +182,19 @@ export interface SkillHostConnectionState {
|
||||
// Redux skill state shape
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
export interface OAuthCredential {
|
||||
credentialId: string;
|
||||
provider: string;
|
||||
grantedScopes?: string[];
|
||||
}
|
||||
|
||||
export interface SkillState {
|
||||
manifest: SkillManifest;
|
||||
status: SkillStatus;
|
||||
error?: string;
|
||||
setupComplete: boolean;
|
||||
tools: SkillToolDefinition[];
|
||||
/** Persisted OAuth credential so it survives app restarts. */
|
||||
oauthCredential?: OAuthCredential;
|
||||
}
|
||||
|
||||
|
||||
@@ -1,7 +1,5 @@
|
||||
import { useState } from 'react';
|
||||
|
||||
import ConnectionIndicator from '../../../components/ConnectionIndicator';
|
||||
|
||||
interface GetStartedStepProps {
|
||||
onComplete: () => void | Promise<void>;
|
||||
}
|
||||
@@ -32,8 +30,6 @@ const GetStartedStep = ({ onComplete }: GetStartedStepProps) => {
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<ConnectionIndicator description="Your browser is now connected to the AlphaHuman AI Models. Please keep this tab open." />
|
||||
|
||||
{error && <p className="text-coral-500 text-sm mb-3 text-center">{error}</p>}
|
||||
|
||||
<button
|
||||
|
||||
+34
-1
@@ -1,3 +1,4 @@
|
||||
import type { Middleware } from '@reduxjs/toolkit';
|
||||
import { configureStore } from '@reduxjs/toolkit';
|
||||
import { createLogger } from 'redux-logger';
|
||||
import {
|
||||
@@ -13,6 +14,7 @@ import {
|
||||
import storage from 'redux-persist/lib/storage';
|
||||
|
||||
import { IS_DEV } from '../utils/config';
|
||||
import { storeSession } from '../utils/tauriCommands';
|
||||
import aiReducer from './aiSlice';
|
||||
import authReducer, { setOnboardedForUser, setToken } from './authSlice';
|
||||
import skillsReducer from './skillsSlice';
|
||||
@@ -37,6 +39,37 @@ const persistedAuthReducer = persistReducer(authPersistConfig, authReducer);
|
||||
const persistedAiReducer = persistReducer(aiPersistConfig, aiReducer);
|
||||
const persistedSkillsReducer = persistReducer(skillsPersistConfig, skillsReducer);
|
||||
|
||||
/**
|
||||
* Middleware that syncs the JWT token to the Rust SESSION_SERVICE whenever
|
||||
* setToken is dispatched or auth state is rehydrated from persist.
|
||||
*/
|
||||
const syncTokenToRust: Middleware = () => next => action => {
|
||||
const result = next(action);
|
||||
|
||||
const syncToken = (token: string) => {
|
||||
// Pass a minimal user object — the token is what matters for SESSION_SERVICE
|
||||
storeSession(token, { id: '' }).catch(err =>
|
||||
console.warn('[syncTokenToRust] Failed to sync token:', err)
|
||||
);
|
||||
};
|
||||
|
||||
// Sync on explicit setToken
|
||||
if (setToken.match(action) && action.payload) {
|
||||
syncToken(action.payload);
|
||||
}
|
||||
|
||||
// Sync on rehydration (app restart — persist loads token from localStorage)
|
||||
const a = action as { type?: string; key?: string; payload?: { token?: string } };
|
||||
if (a.type === REHYDRATE && a.key === 'auth') {
|
||||
const token = a.payload?.token;
|
||||
if (token) {
|
||||
syncToken(token);
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
};
|
||||
|
||||
export const store = configureStore({
|
||||
reducer: {
|
||||
auth: persistedAuthReducer,
|
||||
@@ -49,7 +82,7 @@ export const store = configureStore({
|
||||
middleware: getDefaultMiddleware => {
|
||||
const middleware = getDefaultMiddleware({
|
||||
serializableCheck: { ignoredActions: [FLUSH, REHYDRATE, PAUSE, PERSIST, PURGE, REGISTER] },
|
||||
});
|
||||
}).concat(syncTokenToRust);
|
||||
|
||||
// Add redux-logger in development with collapsed groups
|
||||
if (IS_DEV) {
|
||||
|
||||
@@ -1,6 +1,7 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
import type {
|
||||
OAuthCredential,
|
||||
SkillManifest,
|
||||
SkillState,
|
||||
SkillStatus,
|
||||
@@ -57,6 +58,13 @@ const skillsSlice = createSlice({
|
||||
}
|
||||
},
|
||||
|
||||
setSkillOAuthCredential(state, action: PayloadAction<{ skillId: string; credential: OAuthCredential | undefined }>) {
|
||||
const { skillId, credential } = action.payload;
|
||||
if (state.skills[skillId]) {
|
||||
state.skills[skillId].oauthCredential = credential;
|
||||
}
|
||||
},
|
||||
|
||||
setSkillTools(state, action: PayloadAction<{ skillId: string; tools: SkillToolDefinition[] }>) {
|
||||
const { skillId, tools } = action.payload;
|
||||
if (state.skills[skillId]) {
|
||||
@@ -87,6 +95,7 @@ export const {
|
||||
setSkillStatus,
|
||||
setSkillError,
|
||||
setSkillSetupComplete,
|
||||
setSkillOAuthCredential,
|
||||
setSkillTools,
|
||||
setSkillState,
|
||||
removeSkill,
|
||||
|
||||
@@ -127,7 +127,7 @@ export const setupDesktopDeepLinkListener = async () => {
|
||||
|
||||
if (typeof window !== 'undefined') {
|
||||
// window.__simulateDeepLink('alphahuman://auth?token=1234567890')
|
||||
// window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989178fad6dbfe9b137f577&skillId=notion')
|
||||
// window.__simulateDeepLink('alphahuman://oauth/success?integrationId=6989ef9c8e8bf1b6d991a08c&skillId=notion')
|
||||
(
|
||||
window as Window & { __simulateDeepLink?: (url: string) => Promise<void> }
|
||||
).__simulateDeepLink = (url: string) => handleDeepLinkUrls([url]);
|
||||
|
||||
Reference in New Issue
Block a user