diff --git a/components/ai/ai-chat-iframe.tsx b/components/ai/ai-chat-iframe.tsx index f03581f..093559b 100644 --- a/components/ai/ai-chat-iframe.tsx +++ b/components/ai/ai-chat-iframe.tsx @@ -4,6 +4,10 @@ import { useEffect, useMemo, useRef } from "react" import type { AiChatContext } from "@/lib/ai/chat-context" import { buildEmbedSearchParams } from "@/lib/ai/chat-context" import { buildAiEmbedUrl, resolveAiEmbedOrigin } from "@/lib/ai/embed-url" +import { + useAiIframeExternalLinks, + useAiIframeNavigation, +} from "@/lib/ai/use-ai-iframe-navigation" import { useTheme } from "next-themes" type AiChatIframeProps = { @@ -21,6 +25,9 @@ export function AiChatIframe({ publicPath = "/ai", context, className }: AiChatI return buildAiEmbedUrl(publicPath, qs) }, [publicPath, context]) + useAiIframeNavigation(iframeRef, publicPath) + useAiIframeExternalLinks(embedOrigin) + useEffect(() => { const iframe = iframeRef.current if (!iframe?.contentWindow || !embedOrigin) return diff --git a/lib/ai/use-ai-iframe-navigation.ts b/lib/ai/use-ai-iframe-navigation.ts new file mode 100644 index 0000000..104943a --- /dev/null +++ b/lib/ai/use-ai-iframe-navigation.ts @@ -0,0 +1,91 @@ +import { useEffect } from "react" +import type { RefObject } from "react" +import { resolveAiEmbedOrigin } from "@/lib/ai/embed-url" + +function normalizePublicPath(publicPath: string): string { + const path = (publicPath || "/ai").replace(/\/$/, "") || "/ai" + return path.startsWith("/") ? path : `/${path}` +} + +/** Ultimail suite routes — never prefix with /ai (would 404 in OpenWebUI). */ +const SUITE_ROOT_PATHS = new Set([ + "/mail", + "/drive", + "/chat", + "/contacts", + "/cloud", + "/agenda", + "/office", +]) + +function isSuiteRoute(pathname: string): boolean { + for (const root of SUITE_ROOT_PATHS) { + if (pathname === root || pathname.startsWith(`${root}/`)) return true + } + return false +} + +/** Keep UltiAI iframe on the OpenWebUI subpath (e.g. /ai) — block suite landing at /. */ +export function useAiIframeNavigation( + iframeRef: RefObject, + publicPath: string +) { + useEffect(() => { + const iframe = iframeRef.current + if (!iframe) return + + const base = normalizePublicPath(publicPath) + const embedOrigin = resolveAiEmbedOrigin(publicPath) + + const enforceBasePath = () => { + try { + const win = iframe.contentWindow + if (!win) return + + const { pathname, search, hash, origin } = win.location + if (embedOrigin && origin !== embedOrigin) return + + const inBase = + pathname === base || pathname.startsWith(`${base}/`) + + if (inBase) return + + // Suite landing or explicit suite module — stay on OpenWebUI home. + if (pathname === "/" || pathname === "" || isSuiteRoute(pathname)) { + win.location.replace(`${base}/${search}${hash}`) + return + } + + // OpenWebUI route without /ai prefix (e.g. /notes, /workspace) — preserve path. + win.location.replace(`${base}${pathname}${search}${hash}`) + } catch { + // Cross-origin — parent cannot read location. + } + } + + iframe.addEventListener("load", enforceBasePath) + const timer = window.setInterval(enforceBasePath, 400) + + return () => { + iframe.removeEventListener("load", enforceBasePath) + window.clearInterval(timer) + } + }, [iframeRef, publicPath]) +} + +/** Open external links from the embed in the parent tab, not inside the iframe. */ +export function useAiIframeExternalLinks(embedOrigin: string) { + useEffect(() => { + if (!embedOrigin) return + + const onMessage = (event: MessageEvent) => { + if (event.origin !== embedOrigin) return + const data = event.data as { type?: string; href?: string } | null + if (data?.type !== "ULTI_OPEN_LINK" || typeof data.href !== "string") return + window.open(data.href, "_blank", "noopener,noreferrer") + } + + window.addEventListener("message", onMessage) + return () => window.removeEventListener("message", onMessage) + }, [embedOrigin]) +}