mirror of
https://github.com/openclaw/openclaw.git
synced 2026-08-23 10:55:31 -06:00
ea06d72e85
* feat(secrets): add gateway store settings * perf(control-ui): trim secrets startup copy * perf(control-ui): reduce secrets startup payload * fix(secrets): harden store mutation refresh * perf(control-ui): meet secrets startup budget * test(control-ui): update secrets navigation copy * fix(ui): pluralize secret-detection count and drop duplicated dialog hint * chore(protocol): regenerate gateway clients and SDK baseline after rebase * fix(gateway): merge secrets store methods after project RPCs in advertised order * chore: leave changelog to release generation * test(gateway): retain desktop launch train coverage
384 lines
14 KiB
TypeScript
384 lines
14 KiB
TypeScript
import { normalizeRouteBasePath, normalizeRoutePath } from "@openclaw/uirouter";
|
|
import type { RouteLocation } from "@openclaw/uirouter";
|
|
import { isValidWorkboardBoardId } from "@openclaw/workboard-contract";
|
|
import { DEFAULT_AGENT_PANEL, isAgentsPanel, type AgentsPanel } from "./lib/agents/panels.ts";
|
|
import type { BoardFace } from "./lib/board/settings.ts";
|
|
export const INTERNAL_AGENT_PATH_PARAM = "__openclawAgentPath";
|
|
export const INTERNAL_SESSION_PATH_PARAM = "__openclawSessionPath";
|
|
export const INTERNAL_MEMORY_PATH_PARAM = "__openclawMemoryPath";
|
|
export const INTERNAL_PLUGINS_PATH_PARAM = "__openclawPluginsPath";
|
|
export const INTERNAL_WORKBOARD_PATH_PARAM = "__openclawWorkboardPath";
|
|
|
|
export type MemoryRouteTab = "overview" | "memories" | "dreams" | "settings";
|
|
export type PluginsHubRouteTab = "installed" | "discover";
|
|
|
|
type AgentRoutePath = {
|
|
agentId: string;
|
|
panel: AgentsPanel;
|
|
panelSegment: AgentsPanel | null;
|
|
invalidPanel: boolean;
|
|
};
|
|
|
|
const APP_ROUTE_DEFINITIONS = {
|
|
chat: { path: "/chat" },
|
|
dashboard: { path: "/dashboard" },
|
|
dashboards: { path: "/dashboards" },
|
|
custodian: { path: "/custodian" },
|
|
"new-session": { path: "/new" },
|
|
activity: { path: "/activity" },
|
|
apps: { path: "/apps" },
|
|
agents: { path: "/settings/agents", aliases: ["/agents"] },
|
|
channels: { path: "/settings/channels", aliases: ["/channels"] },
|
|
connection: { path: "/settings/connection" },
|
|
config: { path: "/settings/general", aliases: ["/config"] },
|
|
profile: { path: "/settings/profile", aliases: ["/profile"] },
|
|
communications: { path: "/settings/communications", aliases: ["/communications"] },
|
|
appearance: { path: "/settings/appearance", aliases: ["/appearance"] },
|
|
lobsterdex: { path: "/settings/lobsterdex", aliases: ["/lobsterdex"] },
|
|
notifications: { path: "/settings/notifications" },
|
|
security: { path: "/settings/security" },
|
|
secrets: { path: "/settings/secrets" },
|
|
advanced: { path: "/settings/advanced" },
|
|
approvals: { path: "/settings/approvals" },
|
|
automation: { path: "/settings/automation", aliases: ["/automation"] },
|
|
mcp: { path: "/settings/mcp", aliases: ["/mcp"] },
|
|
memory: { path: "/settings/memory" },
|
|
talk: { path: "/settings/talk" },
|
|
infrastructure: { path: "/settings/infrastructure", aliases: ["/infrastructure"] },
|
|
labs: { path: "/settings/labs" },
|
|
updates: { path: "/settings/updates" },
|
|
about: { path: "/settings/about" },
|
|
"ai-agents": { path: "/settings/ai-agents", aliases: ["/ai-agents"] },
|
|
"model-setup": { path: "/settings/model-setup", aliases: ["/model-setup"] },
|
|
"model-providers": { path: "/settings/model-providers", aliases: ["/model-providers"] },
|
|
// Memory import, sessions, and worktrees are workspace destinations; the
|
|
// /settings/* aliases keep pre-restructure bookmarks and deep links working.
|
|
"memory-import": { path: "/memory-import", aliases: ["/settings/memory-import"] },
|
|
workboard: { path: "/workboard" },
|
|
worktrees: { path: "/worktrees", aliases: ["/settings/worktrees"] },
|
|
sessions: { path: "/sessions", aliases: ["/settings/sessions"] },
|
|
usage: { path: "/usage" },
|
|
debug: { path: "/debug" },
|
|
logs: { path: "/logs" },
|
|
"skill-workshop": { path: "/skills/workshop" },
|
|
skills: { path: "/skills" },
|
|
plugins: { path: "/settings/plugins" },
|
|
// Automations is the product name; /cron stays as a legacy alias for
|
|
// pre-rename bookmarks and deep links.
|
|
cron: { path: "/automations", aliases: ["/cron"] },
|
|
tasks: { path: "/tasks" },
|
|
devices: { path: "/settings/devices", aliases: ["/nodes"] },
|
|
plugin: { path: "/plugin" },
|
|
} as const;
|
|
|
|
export type RouteId = keyof typeof APP_ROUTE_DEFINITIONS;
|
|
export const APP_ROUTE_IDS = Object.keys(APP_ROUTE_DEFINITIONS) as RouteId[];
|
|
|
|
export function isRouteId(routeId: string): routeId is RouteId {
|
|
return routeId in APP_ROUTE_DEFINITIONS;
|
|
}
|
|
|
|
// Single source for page definitions: ui/src/pages/*/route.ts spreads this
|
|
// into definePage so router matching can never drift from the table that
|
|
// drives routeIdFromPath and base-path inference.
|
|
export function routePageSpec<Id extends RouteId>(
|
|
routeId: Id,
|
|
): { id: Id; path: string; aliases?: readonly string[] } {
|
|
const definition = APP_ROUTE_DEFINITIONS[routeId];
|
|
return "aliases" in definition
|
|
? { id: routeId, path: definition.path, aliases: definition.aliases }
|
|
: { id: routeId, path: definition.path };
|
|
}
|
|
|
|
export function normalizeBasePath(basePath: string): string {
|
|
return normalizeRouteBasePath(basePath);
|
|
}
|
|
|
|
function normalizePath(path: string): string {
|
|
return normalizeRoutePath(path);
|
|
}
|
|
|
|
export function pathForRoute(routeId: RouteId, basePath = ""): string {
|
|
const normalizedBasePath = normalizeBasePath(basePath);
|
|
const path = APP_ROUTE_DEFINITIONS[routeId].path;
|
|
return normalizedBasePath ? `${normalizedBasePath}${path}` : path;
|
|
}
|
|
|
|
export function pathForWorkboardBoard(boardId: string, basePath = ""): string {
|
|
if (!isValidWorkboardBoardId(boardId)) {
|
|
throw new Error("Invalid Workboard board id.");
|
|
}
|
|
const encodedBoardId = encodeURIComponent(boardId).replaceAll(".", "%2E");
|
|
return `${pathForRoute("workboard", basePath)}/${encodedBoardId}`;
|
|
}
|
|
|
|
export function pathForAgentPanel(
|
|
agentId: string,
|
|
panel: AgentsPanel | null = null,
|
|
basePath = "",
|
|
): string {
|
|
if (!agentId || agentId.includes("/") || agentId === "." || agentId === "..") {
|
|
throw new Error("Invalid agent id for a route path.");
|
|
}
|
|
const encodedAgentId = encodeURIComponent(agentId).replaceAll(".", "%2E");
|
|
const agentPath = `${pathForRoute("agents", basePath)}/${encodedAgentId}`;
|
|
return panel ? `${agentPath}/${panel}` : agentPath;
|
|
}
|
|
|
|
export function agentRouteFromPath(pathname: string, basePath = ""): AgentRoutePath | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const agentsPath = pathForRoute("agents", basePath);
|
|
const prefix = `${agentsPath}/`;
|
|
if (!normalizedPath.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
const segments = normalizedPath.slice(prefix.length).split("/");
|
|
if (segments.length > 2 || !segments[0]) {
|
|
return null;
|
|
}
|
|
let agentId: string;
|
|
try {
|
|
agentId = decodeURIComponent(segments[0]);
|
|
} catch {
|
|
return null;
|
|
}
|
|
if (!agentId || agentId.includes("/") || agentId === "." || agentId === "..") {
|
|
return null;
|
|
}
|
|
const rawPanel = segments[1] ?? null;
|
|
const panelSegment = rawPanel && isAgentsPanel(rawPanel) ? rawPanel : null;
|
|
return {
|
|
agentId,
|
|
panel: panelSegment ?? DEFAULT_AGENT_PANEL,
|
|
panelSegment,
|
|
invalidPanel: rawPanel !== null && panelSegment === null,
|
|
};
|
|
}
|
|
|
|
export function pathForMemoryTab(tab: MemoryRouteTab, basePath = ""): string {
|
|
const memoryPath = pathForRoute("memory", basePath);
|
|
return tab === "overview" ? memoryPath : `${memoryPath}/${tab}`;
|
|
}
|
|
|
|
export function memoryTabFromPath(pathname: string, basePath = ""): MemoryRouteTab | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const memoryPath = pathForRoute("memory", basePath);
|
|
if (normalizedPath === memoryPath) {
|
|
return "overview";
|
|
}
|
|
const prefix = `${memoryPath}/`;
|
|
if (!normalizedPath.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
const segment = normalizedPath.slice(prefix.length);
|
|
return segment === "memories" || segment === "dreams" || segment === "settings" ? segment : null;
|
|
}
|
|
|
|
export function pathForPluginsHubTab(tab: PluginsHubRouteTab, basePath = ""): string {
|
|
const pluginsPath = pathForRoute("plugins", basePath);
|
|
return tab === "installed" ? pluginsPath : `${pluginsPath}/discover`;
|
|
}
|
|
|
|
export function pluginsHubTabFromPath(pathname: string, basePath = ""): PluginsHubRouteTab | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const pluginsPath = pathForRoute("plugins", basePath);
|
|
if (normalizedPath === pluginsPath) {
|
|
return "installed";
|
|
}
|
|
return normalizedPath === `${pluginsPath}/discover` ? "discover" : null;
|
|
}
|
|
|
|
export function isSessionRouteId(routeId: string | null | undefined): routeId is BoardFace {
|
|
return routeId === "chat" || routeId === "dashboard";
|
|
}
|
|
|
|
export function sessionRouteNamespaceFromPath(pathname: string, basePath = ""): BoardFace | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const normalizedBasePath = normalizeBasePath(basePath);
|
|
if (
|
|
normalizedBasePath &&
|
|
normalizedPath !== normalizedBasePath &&
|
|
!normalizedPath.startsWith(`${normalizedBasePath}/`)
|
|
) {
|
|
return null;
|
|
}
|
|
const routePath = normalizedPath.slice(normalizedBasePath.length);
|
|
return routePath.startsWith("/chat/")
|
|
? "chat"
|
|
: routePath.startsWith("/dashboard/")
|
|
? "dashboard"
|
|
: null;
|
|
}
|
|
|
|
export function workboardBoardIdFromPath(pathname: string, basePath = ""): string | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const workboardPath = pathForRoute("workboard", basePath);
|
|
const prefix = `${workboardPath}/`;
|
|
if (!normalizedPath.startsWith(prefix)) {
|
|
return null;
|
|
}
|
|
const encodedBoardId = normalizedPath.slice(prefix.length);
|
|
if (!encodedBoardId || encodedBoardId.includes("/")) {
|
|
return null;
|
|
}
|
|
try {
|
|
const boardId = decodeURIComponent(encodedBoardId);
|
|
return isValidWorkboardBoardId(boardId) ? boardId : null;
|
|
} catch {
|
|
return null;
|
|
}
|
|
}
|
|
|
|
export function routeIdFromPath(pathname: string, basePath = ""): RouteId | null {
|
|
const normalizedPath = normalizePath(pathname);
|
|
const normalizedBasePath = normalizeBasePath(basePath);
|
|
const isWithinBasePath =
|
|
!normalizedBasePath ||
|
|
normalizedPath === normalizedBasePath ||
|
|
normalizedPath.startsWith(`${normalizedBasePath}/`);
|
|
if (!isWithinBasePath) {
|
|
return null;
|
|
}
|
|
const routePath = normalizedBasePath
|
|
? normalizedPath.slice(normalizedBasePath.length) || "/"
|
|
: normalizedPath;
|
|
if (agentRouteFromPath(normalizedPath, normalizedBasePath)) {
|
|
return "agents";
|
|
}
|
|
if (workboardBoardIdFromPath(normalizedPath, normalizedBasePath)) {
|
|
return "workboard";
|
|
}
|
|
if (memoryTabFromPath(normalizedPath, normalizedBasePath)) {
|
|
return "memory";
|
|
}
|
|
if (pluginsHubTabFromPath(normalizedPath, normalizedBasePath)) {
|
|
return "plugins";
|
|
}
|
|
const sessionNamespace = sessionRouteNamespaceFromPath(normalizedPath, normalizedBasePath);
|
|
if (sessionNamespace) {
|
|
return sessionNamespace;
|
|
}
|
|
for (const routeId of APP_ROUTE_IDS) {
|
|
const definition = APP_ROUTE_DEFINITIONS[routeId];
|
|
const paths: readonly string[] =
|
|
"aliases" in definition ? [definition.path, ...definition.aliases] : [definition.path];
|
|
if (paths.some((candidate) => normalizePath(candidate) === routePath)) {
|
|
return routeId;
|
|
}
|
|
}
|
|
return null;
|
|
}
|
|
|
|
function collectRoutePaths(): string[] {
|
|
return APP_ROUTE_IDS.flatMap((routeId) => {
|
|
const definition = APP_ROUTE_DEFINITIONS[routeId];
|
|
const paths: string[] = [definition.path];
|
|
if ("aliases" in definition) {
|
|
paths.push(...definition.aliases);
|
|
}
|
|
return paths;
|
|
});
|
|
}
|
|
|
|
// A candidate mount base that is a registered route ("/custodian"), or that
|
|
// sits at or below a multi-segment route namespace ("/settings", including
|
|
// "/settings/other"), is really a root-mounted deep link whose suffix happens
|
|
// to match a route path or alias. Descendants of leaf routes stay valid mount
|
|
// directories so "/apps/openclaw" keeps working. Inference is a last-resort
|
|
// fallback for pages served without the injected base path (vite dev, static
|
|
// hosting); accepted tradeoff: namespaces nested under a real mount prefix
|
|
// ("/ui/settings/other/config") are not rescued here.
|
|
function isRouteOwnedBasePath(basePath: string): boolean {
|
|
const routePaths = collectRoutePaths().map((path) => normalizePath(path));
|
|
if (routePaths.includes(basePath)) {
|
|
return true;
|
|
}
|
|
const segments = basePath.split("/").filter(Boolean);
|
|
for (let count = 1; count <= segments.length; count += 1) {
|
|
const ancestor = `/${segments.slice(0, count).join("/")}`;
|
|
if (routePaths.some((path) => path.startsWith(`${ancestor}/`))) {
|
|
return true;
|
|
}
|
|
}
|
|
return false;
|
|
}
|
|
|
|
export function inferBasePathFromPathname(pathname: string): string {
|
|
const isMountRoot = pathname.trim().endsWith("/");
|
|
const normalizedPath = normalizePath(pathname);
|
|
if (normalizedPath.toLowerCase().endsWith("/index.html")) {
|
|
return normalizeBasePath(normalizedPath.slice(0, -"/index.html".length));
|
|
}
|
|
if (normalizedPath === "/") {
|
|
return "";
|
|
}
|
|
const segments = normalizedPath.split("/").filter(Boolean);
|
|
const routePaths = collectRoutePaths();
|
|
for (let index = 0; index < segments.length; index += 1) {
|
|
const candidate = `/${segments.slice(index).join("/")}`;
|
|
const routePath = routePaths.find((path) => normalizePath(path) === candidate);
|
|
const dynamicAgentRoute = agentRouteFromPath(candidate) !== null;
|
|
const dynamicWorkboardRoute = workboardBoardIdFromPath(candidate) !== null;
|
|
const dynamicMemoryRoute = memoryTabFromPath(candidate) !== null;
|
|
const dynamicPluginsRoute = pluginsHubTabFromPath(candidate) !== null;
|
|
const sessionNamespace = sessionRouteNamespaceFromPath(candidate);
|
|
const dynamicSessionRoute = sessionNamespace !== null;
|
|
if (
|
|
!routePath &&
|
|
!dynamicAgentRoute &&
|
|
!dynamicWorkboardRoute &&
|
|
!dynamicMemoryRoute &&
|
|
!dynamicPluginsRoute &&
|
|
!dynamicSessionRoute
|
|
) {
|
|
continue;
|
|
}
|
|
const previousSegment = segments[index - 1];
|
|
const dynamicRoutePath = dynamicAgentRoute
|
|
? APP_ROUTE_DEFINITIONS.agents.path
|
|
: dynamicWorkboardRoute
|
|
? APP_ROUTE_DEFINITIONS.workboard.path
|
|
: dynamicMemoryRoute
|
|
? APP_ROUTE_DEFINITIONS.memory.path
|
|
: dynamicPluginsRoute
|
|
? APP_ROUTE_DEFINITIONS.plugins.path
|
|
: sessionNamespace
|
|
? APP_ROUTE_DEFINITIONS[sessionNamespace].path
|
|
: null;
|
|
const firstRouteSegment = (routePath ?? dynamicRoutePath ?? "").split("/").find(Boolean);
|
|
if (
|
|
index > 0 &&
|
|
previousSegment === firstRouteSegment &&
|
|
(candidate === routePath ||
|
|
dynamicAgentRoute ||
|
|
dynamicWorkboardRoute ||
|
|
dynamicMemoryRoute ||
|
|
dynamicPluginsRoute ||
|
|
dynamicSessionRoute)
|
|
) {
|
|
return "";
|
|
}
|
|
if (index === 0) {
|
|
return "";
|
|
}
|
|
const basePath = `/${segments.slice(0, index).join("/")}`;
|
|
// Mis-inferring a route-owned base ("/settings/config" -> "/settings" via
|
|
// the "/config" alias) rescopes stored gateway settings and asset URLs, so
|
|
// a connected browser deep-links straight into the login gate.
|
|
return isRouteOwnedBasePath(basePath) ? "" : basePath;
|
|
}
|
|
if (!isMountRoot || segments.length === 0) {
|
|
return "";
|
|
}
|
|
const mountRootBase = `/${segments.join("/")}`;
|
|
return isRouteOwnedBasePath(mountRootBase) ? "" : mountRootBase;
|
|
}
|
|
|
|
export function locationForRoute(routeId: RouteId, basePath: string): RouteLocation {
|
|
return {
|
|
pathname: pathForRoute(routeId, basePath),
|
|
search: "",
|
|
hash: "",
|
|
};
|
|
}
|