342 lines
11 KiB
TypeScript
342 lines
11 KiB
TypeScript
import { resolveEmbedThemeMode } from "../embed/theme-mode.mjs";
|
|
|
|
const CHATS_IFRAME_PATH = "/page/workspace/chats_iframe/new";
|
|
const IFRAME_LOGIN_SOURCE = "iframe";
|
|
const STANDARD_CHATS_PATH = "/page/workspace/chats";
|
|
|
|
function toSearchParams(search: string): URLSearchParams {
|
|
return new URLSearchParams(search.startsWith("?") ? search : `?${search}`);
|
|
}
|
|
|
|
/** Keep the accepted URL booleans aligned with embed-session.ts. */
|
|
function isEnabledPresentationParam(search: string, key: string): boolean {
|
|
const value = toSearchParams(search).get(key)?.trim().toLowerCase();
|
|
return value === "1" || value === "true" || value === "yes";
|
|
}
|
|
|
|
export function isChatsIframeLogin(search: string): boolean {
|
|
return toSearchParams(search).get("from") === IFRAME_LOGIN_SOURCE;
|
|
}
|
|
|
|
function isExplicitFullChatMode(search: string): boolean {
|
|
return (
|
|
toSearchParams(search).get("chatMode")?.trim().toLowerCase() === "full"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A bare login URL embedded by an external host has no query parameter to
|
|
* survive the login redirect. Add the legacy source marker before building
|
|
* the private chat URL so refreshes and subsequent thread navigation retain
|
|
* the selected dedicated iframe workspace.
|
|
*/
|
|
export function buildEmbeddedIframeRedirectPath(search: string): string {
|
|
const source = toSearchParams(search);
|
|
if (!source.has("from")) {
|
|
source.set("from", IFRAME_LOGIN_SOURCE);
|
|
}
|
|
return buildChatsIframeRedirectPath(source.toString());
|
|
}
|
|
|
|
/**
|
|
* `from=iframe` predates the regular embedded-chat presentation flags. Keep
|
|
* the two concerns separate: this only answers whether the caller explicitly
|
|
* asked PageLayout to alter global chrome, not whether it is an iframe page.
|
|
*/
|
|
export function hasIframePresentationOverride(search: string): boolean {
|
|
return (
|
|
isEnabledPresentationParam(search, "hideHeader") ||
|
|
isEnabledPresentationParam(search, "hideChrome") ||
|
|
isEnabledPresentationParam(search, "embed")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* The private `chats_iframe` transport is the legacy default for
|
|
* `from=iframe`. An explicit `chatMode=iframe` always wins, while a caller
|
|
* that also supplies one of the standard presentation flags deliberately uses
|
|
* the ordinary full chat workspace instead.
|
|
*/
|
|
export function shouldUseChatsIframeProtocol(
|
|
search: string,
|
|
embeddedHost = false,
|
|
): boolean {
|
|
return (
|
|
!hasIframePresentationOverride(search) &&
|
|
(isExplicitBrainyIframeMode(search) ||
|
|
isChatsIframeLogin(search) ||
|
|
// The historical bare iframe integration enters `/login/admin` without
|
|
// any route parameters. Treat that embedded context as the legacy
|
|
// dedicated AgentSelector page, unless the caller explicitly selected
|
|
// the standard full-chat mode.
|
|
(embeddedHost && !isExplicitFullChatMode(search)))
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Build the regular full-chat destination for a legacy iframe entry that has
|
|
* opted into a standard presentation mode. Keeping the display flags in the
|
|
* address bar makes direct links and refreshes deterministic; `from` is
|
|
* intentionally omitted so the legacy protocol cannot take over again.
|
|
*/
|
|
export function buildStandardChatRedirectPath(
|
|
search: string,
|
|
fallbackPath = `${STANDARD_CHATS_PATH}/new`,
|
|
): string {
|
|
const source = toSearchParams(search);
|
|
const [pathname, existingQuery = ""] = fallbackPath.split("?");
|
|
const target = new URLSearchParams(existingQuery);
|
|
|
|
for (const key of ["hideHeader", "hideChrome", "embed"] as const) {
|
|
const value = source.get(key)?.trim();
|
|
if (value && !target.has(key)) {
|
|
target.set(key, value);
|
|
}
|
|
}
|
|
|
|
// `theme` is meaningful only for embed mode, but preserving it here keeps a
|
|
// refreshed embedded page on the same forced theme before session storage is
|
|
// restored.
|
|
const theme = source.get("theme")?.trim();
|
|
if (theme && target.get("embed")) {
|
|
target.set("theme", theme);
|
|
}
|
|
|
|
target.set("chatMode", "full");
|
|
const query = target.toString();
|
|
return query ? `${pathname}?${query}` : pathname;
|
|
}
|
|
|
|
/**
|
|
* Keep an explicitly selected standard-chat presentation while navigating
|
|
* between `/chats/new` and a concrete chat thread. Without this, a click in
|
|
* the full-chat sidebar drops `hideChrome=1&chatMode=full`; an iframe host
|
|
* then looks like the legacy bare integration and is incorrectly redirected
|
|
* to the private AgentSelector workspace.
|
|
*
|
|
* This deliberately whitelists presentation flags through
|
|
* `buildStandardChatRedirectPath` instead of copying arbitrary login/query
|
|
* values into user-facing chat URLs.
|
|
*/
|
|
export function buildFullChatNavigationPath(
|
|
destinationPath: string,
|
|
search: string,
|
|
): string {
|
|
return hasIframePresentationOverride(search) || isExplicitFullChatMode(search)
|
|
? buildStandardChatRedirectPath(search, destinationPath)
|
|
: destinationPath;
|
|
}
|
|
|
|
/**
|
|
* Preserve only the presentation parameters consumed by the iframe workspace.
|
|
* Login credentials must never be copied into the post-login address bar.
|
|
*/
|
|
export function buildChatsIframeRedirectPath(search: string): string {
|
|
const source = toSearchParams(search);
|
|
const target = new URLSearchParams();
|
|
|
|
if (isChatsIframeLogin(search)) {
|
|
target.set("from", IFRAME_LOGIN_SOURCE);
|
|
}
|
|
|
|
const requestedMode = source.get("chatMode")?.trim();
|
|
if (
|
|
requestedMode?.toLowerCase() === "iframe" ||
|
|
(!isChatsIframeLogin(search) && requestedMode)
|
|
) {
|
|
target.set("chatMode", requestedMode);
|
|
}
|
|
|
|
const theme = source.get("theme")?.trim();
|
|
if (theme) {
|
|
target.set("theme", theme);
|
|
}
|
|
|
|
const query = target.toString();
|
|
return query ? `${CHATS_IFRAME_PATH}?${query}` : CHATS_IFRAME_PATH;
|
|
}
|
|
|
|
export function buildChatsIframeThreadPath(
|
|
routeBase: string,
|
|
threadId: string,
|
|
search: string,
|
|
): string {
|
|
const base = routeBase.endsWith("/") ? routeBase.slice(0, -1) : routeBase;
|
|
const query = buildChatsIframeRedirectPath(search).split("?")[1];
|
|
const path = `${base}/chats_iframe/${encodeURIComponent(threadId)}`;
|
|
return query ? `${path}?${query}` : path;
|
|
}
|
|
|
|
export function resolvePostLoginRedirectPath(
|
|
search: string,
|
|
fallbackPath: string,
|
|
): string {
|
|
if (shouldUseChatsIframeProtocol(search)) {
|
|
return buildChatsIframeRedirectPath(search);
|
|
}
|
|
|
|
return isChatsIframeLogin(search) && hasIframePresentationOverride(search)
|
|
? buildStandardChatRedirectPath(search, fallbackPath)
|
|
: fallbackPath;
|
|
}
|
|
|
|
export function isChatsIframeWorkspacePath(pathname: string): boolean {
|
|
return (
|
|
pathname === CHATS_IFRAME_PATH ||
|
|
pathname.startsWith("/page/workspace/chats_iframe/")
|
|
);
|
|
}
|
|
|
|
/**
|
|
* A `from=iframe` link can arrive directly at a regular chat URL instead of
|
|
* passing through LoginPage. Normalize both entry paths to the same dedicated
|
|
* protocol workspace, including an existing thread id when one is supplied.
|
|
*/
|
|
export function resolveChatsIframeEntryRedirect(
|
|
pathname: string,
|
|
search: string,
|
|
embeddedHost = false,
|
|
): string | null {
|
|
const isStandardChatPath =
|
|
pathname === STANDARD_CHATS_PATH ||
|
|
pathname === `${STANDARD_CHATS_PATH}/` ||
|
|
pathname.startsWith(`${STANDARD_CHATS_PATH}/`);
|
|
|
|
// Standard presentation flags deliberately bypass the private iframe
|
|
// protocol. Normalize direct legacy URLs once so the result stays on the
|
|
// normal full-chat workspace and retains its agent/history sidebar.
|
|
if (
|
|
isChatsIframeLogin(search) &&
|
|
hasIframePresentationOverride(search) &&
|
|
!isExplicitBrainyIframeMode(search)
|
|
) {
|
|
return isStandardChatPath
|
|
? buildStandardChatRedirectPath(search, pathname)
|
|
: null;
|
|
}
|
|
|
|
const inferredEmbeddedProtocol =
|
|
embeddedHost &&
|
|
!hasIframePresentationOverride(search) &&
|
|
!isExplicitFullChatMode(search) &&
|
|
!isChatsIframeLogin(search) &&
|
|
!isExplicitBrainyIframeMode(search);
|
|
const protocolSearch = inferredEmbeddedProtocol
|
|
? `?${new URLSearchParams({
|
|
...Object.fromEntries(toSearchParams(search)),
|
|
from: IFRAME_LOGIN_SOURCE,
|
|
}).toString()}`
|
|
: search;
|
|
const requestsIframeProtocol = shouldUseChatsIframeProtocol(
|
|
protocolSearch,
|
|
embeddedHost,
|
|
);
|
|
if (!requestsIframeProtocol) {
|
|
return null;
|
|
}
|
|
|
|
if (pathname === STANDARD_CHATS_PATH || pathname === `${STANDARD_CHATS_PATH}/`) {
|
|
return buildChatsIframeRedirectPath(protocolSearch);
|
|
}
|
|
|
|
if (!pathname.startsWith(`${STANDARD_CHATS_PATH}/`)) {
|
|
return null;
|
|
}
|
|
|
|
const routeSuffix = pathname.slice(`${STANDARD_CHATS_PATH}/`.length);
|
|
if (!routeSuffix || routeSuffix.includes("/")) {
|
|
return null;
|
|
}
|
|
|
|
if (routeSuffix === "new") {
|
|
return buildChatsIframeRedirectPath(protocolSearch);
|
|
}
|
|
|
|
try {
|
|
return buildChatsIframeThreadPath(
|
|
"/page/workspace",
|
|
decodeURIComponent(routeSuffix),
|
|
protocolSearch,
|
|
);
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
/**
|
|
* The lightweight iframe page is a private transport for the Brainy
|
|
* postMessage protocol, not a general-purpose embedded chat page.
|
|
*
|
|
* `from=iframe` is the legacy equivalent of the explicit mode. Ordinary
|
|
* iframe/Wujie embedding without either marker remains standard chat.
|
|
*/
|
|
export function isExplicitBrainyIframeMode(search: string): boolean {
|
|
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
|
return (
|
|
new URLSearchParams(normalizedSearch)
|
|
.get("chatMode")
|
|
?.trim()
|
|
.toLowerCase() === "iframe"
|
|
);
|
|
}
|
|
|
|
/**
|
|
* Convert an accidental/legacy `chats_iframe` URL to the equivalent standard
|
|
* chat URL. Existing thread ids and unrelated query parameters are retained.
|
|
*/
|
|
export function resolveStandardChatFallback(
|
|
pathname: string,
|
|
search: string,
|
|
): string | null {
|
|
if (!isChatsIframeWorkspacePath(pathname)) {
|
|
return null;
|
|
}
|
|
|
|
const normalizedSearch = search.startsWith("?") ? search.slice(1) : search;
|
|
const params = new URLSearchParams(normalizedSearch);
|
|
const routePrefix = "/page/workspace/chats_iframe/";
|
|
const routeSuffix = pathname.slice(routePrefix.length);
|
|
const targetPath =
|
|
routeSuffix && routeSuffix !== "new"
|
|
? `/page/workspace/chats/${routeSuffix}`
|
|
: "/page/workspace/chats/new";
|
|
|
|
if (
|
|
isChatsIframeLogin(search) &&
|
|
hasIframePresentationOverride(search) &&
|
|
!isExplicitBrainyIframeMode(search)
|
|
) {
|
|
return buildStandardChatRedirectPath(search, targetPath);
|
|
}
|
|
|
|
if (shouldUseChatsIframeProtocol(search)) {
|
|
// The legacy marker itself is enough. Do not add chatMode=iframe here: it
|
|
// is an implementation detail and adding it caused a self-redirect loop.
|
|
return null;
|
|
}
|
|
params.delete("from");
|
|
params.set("chatMode", "full");
|
|
const targetSearch = params.toString();
|
|
return targetSearch ? `${targetPath}?${targetSearch}` : targetPath;
|
|
}
|
|
|
|
export function resolveForcedThemeForPath(
|
|
pathname: string,
|
|
search = "",
|
|
): "light" | "dark" | undefined {
|
|
if (pathname === "/landing") {
|
|
return "light";
|
|
}
|
|
|
|
const isLoginPath = pathname === "/login" || pathname.startsWith("/login/");
|
|
if (
|
|
isChatsIframeWorkspacePath(pathname) ||
|
|
(isLoginPath && shouldUseChatsIframeProtocol(search))
|
|
) {
|
|
const rawTheme = toSearchParams(search).get("theme")?.trim();
|
|
return rawTheme ? resolveEmbedThemeMode(rawTheme) : "light";
|
|
}
|
|
|
|
return undefined;
|
|
}
|