feat: add runtime seam, Hermes adapter support, and demo gateway mode (#89)

* fix: include kanbanImmersive in immersiveOverlayActive calculation

When Kanban board is open, HUD elements (camera preset buttons, edit toolbar, overlays) should be suppressed. The kanbanImmersive flag was defined but not included in the immersiveOverlayActive condition, causing HUD elements to remain visible.

This fix adds kanbanImmersive to the immersiveOverlayActive calculation so HUD elements are properly hidden when the Kanban board is open.

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>

* Fix: Hide mini status bar when Kanban immersive overlay is open

Wraps the bottom-left mini status bar (showing agent stats, vibe score, and
control hints) with !immersiveOverlayActive check to match the behavior of
other HUD elements like camera controls and toolbar.

This ensures the status bar is properly hidden when the Kanban board or any
other immersive overlay is active, maintaining a clean immersive experience.

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>

* chore: drop unrelated package-lock line from branch

Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>

* universal-backend-plan

* backend-neutral runtime seam

* package.json update

* feat: add Hermes gateway adapter as alternative to OpenClaw

Adds a WebSocket adapter that lets Claw3D connect to a Hermes AI agent
runtime without any changes to the frontend. The adapter implements the
full Claw3D gateway protocol and bridges it to the Hermes HTTP API.

Changes:
- server/hermes-gateway-adapter.js: WebSocket bridge implementing the
  Claw3D gateway protocol against the Hermes HTTP API. Supports all
  core methods (agents, sessions, chat streaming, cron, config, files,
  approvals) and multi-agent orchestration via spawn_agent/delegate_task
  tools. Persists conversation history to ~/.hermes/clawd3d-history.json.
- scripts/clawd3d-start.sh: All-in-one startup script that launches
  Hermes, the adapter, and the Next.js dev server with auto port
  conflict resolution. Alias as `claw3d` for convenience.
- src/features/office/hooks/useCronAgents.ts: Hook that polls the
  gateway for cron-scheduled agents and surfaces them in the 3D office.
- package.json: adds `hermes-adapter` npm script
- .env.example: documents Hermes config vars
- docs/hermes-gateway.md: setup guide and protocol reference

Usage:
  npm run hermes-adapter   # start adapter (connect to http://localhost:8642)
  npm run dev              # start Claw3D, point browser at localhost:3000
  # or: bash scripts/clawd3d-start.sh  (starts everything automatically)

Both OpenClaw and Hermes are supported simultaneously — the gateway URL
in NEXT_PUBLIC_GATEWAY_URL determines which backend Claw3D connects to.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: add read_agent_context tool for cross-agent coordination

Agents can now read each other's conversation history via the
read_agent_context tool, enabling the orchestrator to check what
a sub-agent has done before re-delegating work.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>

* feat: wire Hermes office UX and role-aware runtime updates

* feature update - demomode & hermes adapter

* fix lint blockers

* lintfix #2

* fix: stabilize retro office camera preset callbacks

* Initial plan

* fix: stabilize retro office overview preset hooks

Agent-Logs-Url: https://github.com/gsknnft/Claw3D/sessions/9cc71555-591e-44cf-aec4-25affbdcb405

Co-authored-by: gsknnft <123185582+gsknnft@users.noreply.github.com>

* feat: add truthful backend selection, Hermes adapter hardening, and demo gateway mode

* fix: address bugbot review and finalize backend selection

* fixed - onboarding and hermes calls

* office systems roadmap

* feat specs in docs

* specs ready

* feat: continue custom runtime seam and gateway alignment

* custom lane wired

* feat: add custom runtime provider path and office runtime alignment

* runtime fixes

* fix lukes findings

* fix lukes findings #2

* stable UI & connect screen page -> overlay

* better baseline for connection

* stable providers & ui rendering

* best launch yet

* nearly no gateway on reconnect

* auto reconnect last state

* fix: preserve selected runtime across reconnects

Keep backend selection aligned with the operator's chosen runtime instead of reviving a mismatched last-known-good adapter, and keep custom runtimes prompting for reconnect when Studio cannot auto-connect them.

Made-with: Cursor

---------

Co-authored-by: Cursor Agent <cursoragent@cursor.com>
Co-authored-by: Luke The Dev <iamlukethedev@users.noreply.github.com>
Co-authored-by: Elias Pfeffer <eliaspfeffer@gmail.com>
Co-authored-by: Claude Sonnet 4.6 <noreply@anthropic.com>
Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
Co-authored-by: iamlukethedev <lucas.guilherme@smartwayslfl.com>
This commit is contained in:
gsknnft
2026-04-02 16:27:24 -04:00
committed by GitHub
parent a997f13601
commit 083c146aac
77 changed files with 10045 additions and 399 deletions
+74
View File
@@ -0,0 +1,74 @@
import { NextResponse } from "next/server";
export const runtime = "nodejs";
type CustomRuntimeRequestBody = {
runtimeUrl?: string;
pathname?: string;
method?: string;
body?: unknown;
};
const normalizeRuntimeUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) {
throw new Error("runtimeUrl is required.");
}
const parsed = new URL(trimmed);
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
if (parsed.protocol !== "http:" && parsed.protocol !== "https:") {
throw new Error("runtimeUrl must use http, https, ws, or wss.");
}
parsed.username = "";
parsed.password = "";
return parsed.toString().replace(/\/$/, "");
};
const normalizePathname = (value: unknown): string => {
if (typeof value !== "string" || !value.trim()) {
throw new Error("pathname is required.");
}
const trimmed = value.trim();
return trimmed.startsWith("/") ? trimmed : `/${trimmed}`;
};
const normalizeMethod = (value: unknown): "GET" | "POST" => {
if (typeof value !== "string") return "GET";
const upper = value.trim().toUpperCase();
if (upper === "POST") return "POST";
return "GET";
};
export async function POST(request: Request) {
try {
const payload = (await request.json()) as CustomRuntimeRequestBody;
const runtimeUrl = normalizeRuntimeUrl(payload.runtimeUrl ?? "");
const pathname = normalizePathname(payload.pathname);
const method = normalizeMethod(payload.method);
const response = await fetch(`${runtimeUrl}${pathname}`, {
method,
headers: {
Accept: "application/json",
...(method === "POST" ? { "Content-Type": "application/json" } : null),
},
body: method === "POST" ? JSON.stringify(payload.body ?? {}) : undefined,
cache: "no-store",
});
const text = await response.text();
return new NextResponse(text, {
status: response.status,
headers: {
"Content-Type": response.headers.get("content-type") ?? "application/json",
"Cache-Control": "no-store",
},
});
} catch (error) {
const message =
error instanceof Error ? error.message : "Custom runtime proxy failed.";
return NextResponse.json({ error: message }, { status: 400 });
}
}
+5 -1
View File
@@ -36,7 +36,11 @@ export async function GET() {
export async function PUT(request: Request) {
try {
const body = (await request.json()) as unknown;
const rawBody = await request.text();
if (!rawBody.trim()) {
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
}
const body = JSON.parse(rawBody) as unknown;
if (!isPatch(body)) {
return NextResponse.json({ error: "Invalid settings payload." }, { status: 400 });
}
@@ -125,6 +125,7 @@ type AgentChatPanelProps = {
stopBusy: boolean;
stopDisabledReason?: string | null;
onLoadMoreHistory: () => void;
onOpenSettings?: () => void;
onRename?: (name: string) => Promise<boolean>;
onNewSession?: () => Promise<void> | void;
onModelChange: (value: string | null) => void;
@@ -1201,6 +1202,7 @@ export const AgentChatPanel = ({
stopBusy,
stopDisabledReason = null,
onLoadMoreHistory,
onOpenSettings,
onRename,
onNewSession,
onModelChange,
@@ -1590,6 +1592,18 @@ export const AgentChatPanel = ({
</div>
<div className="mt-0.5 flex items-center gap-2">
{onOpenSettings ? (
<button
className="nodrag ui-btn-icon ui-btn-icon-sm shrink-0"
type="button"
data-testid="agent-settings-toggle"
aria-label="Open behavior"
title="Open behavior"
onClick={onOpenSettings}
>
<ChevronRight className="h-3.5 w-3.5" />
</button>
) : null}
<button
className="nodrag inline-flex items-center whitespace-nowrap rounded border border-[color:var(--status-approval-border)] bg-[color:var(--status-approval-bg)] px-2 py-0.5 font-mono text-[9px] font-medium tracking-[0.02em] text-white transition hover:bg-[color:var(--status-approval-bg)] hover:text-white disabled:cursor-not-allowed disabled:opacity-40"
type="button"
@@ -1,14 +1,20 @@
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
import { X } from "lucide-react";
import { resolveGatewayStatusBadgeClass, resolveGatewayStatusLabel } from "./colorSemantics";
type ConnectionPanelProps = {
gatewayUrl: string;
token: string;
selectedAdapterType: StudioGatewayAdapterType;
activeAdapterType: StudioGatewayAdapterType;
localGatewayUrl?: string | null;
localGatewayToken?: string | null;
status: GatewayStatus;
error: string | null;
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
onConnect: () => void;
onDisconnect: () => void;
onClose?: () => void;
@@ -17,16 +23,37 @@ type ConnectionPanelProps = {
export const ConnectionPanel = ({
gatewayUrl,
token,
selectedAdapterType,
activeAdapterType,
localGatewayUrl = null,
localGatewayToken = null,
status,
error,
onGatewayUrlChange,
onTokenChange,
onAdapterTypeChange,
onConnect,
onDisconnect,
onClose,
}: ConnectionPanelProps) => {
const isConnected = status === "connected";
const isConnecting = status === "connecting";
const tokenOptional =
selectedAdapterType === "hermes" ||
selectedAdapterType === "demo" ||
selectedAdapterType === "custom";
const applyDemoPreset = () => {
onAdapterTypeChange("demo");
};
const applyHermesPreset = () => {
onAdapterTypeChange("hermes");
};
const applyCustomPreset = () => {
onAdapterTypeChange("custom");
};
const applyOpenClawPreset = () => {
onAdapterTypeChange("openclaw");
};
return (
<div className="fade-up-delay flex flex-col gap-3">
@@ -73,17 +100,52 @@ export const ConnectionPanel = ({
/>
</label>
<label className="flex flex-col gap-1 font-mono text-[10px] font-semibold tracking-[0.06em] text-muted-foreground">
Upstream token
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
<input
className="ui-input h-10 rounded-md px-4 font-sans text-sm text-foreground outline-none"
type="password"
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="gateway token"
placeholder={tokenOptional ? "optional token" : "gateway token"}
spellCheck={false}
/>
</label>
</div>
<div className="flex flex-wrap items-center gap-2 text-[11px] text-muted-foreground">
<span className="font-mono">Selected backend: {selectedAdapterType}</span>
<span className="font-mono">Active backend: {activeAdapterType}</span>
<span>Each backend keeps its own saved URL and token.</span>
</div>
<div className="flex flex-wrap gap-2">
<button
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
type="button"
onClick={applyDemoPreset}
>
Demo backend
</button>
<button
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
type="button"
onClick={applyHermesPreset}
>
Hermes backend
</button>
<button
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
type="button"
onClick={applyCustomPreset}
>
Custom backend
</button>
<button
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
type="button"
onClick={applyOpenClawPreset}
>
OpenClaw backend
</button>
</div>
{error ? (
<p className="ui-alert-danger rounded-md px-4 py-2 text-sm">
{error}
@@ -2,18 +2,21 @@ import { useMemo, useState } from "react";
import { Check, Copy, Eye, EyeOff } from "lucide-react";
import type { GatewayStatus } from "@/lib/gateway/GatewayClient";
import { isLocalGatewayUrl } from "@/lib/gateway/local-gateway";
import type { StudioGatewaySettings } from "@/lib/studio/settings";
import type { StudioGatewayAdapterType, StudioGatewaySettings } from "@/lib/studio/settings";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
type GatewayConnectScreenProps = {
gatewayUrl: string;
token: string;
selectedAdapterType: StudioGatewayAdapterType;
activeAdapterType: StudioGatewayAdapterType;
localGatewayDefaults: StudioGatewaySettings | null;
status: GatewayStatus;
error: string | null;
showApprovalHint: boolean;
onGatewayUrlChange: (value: string) => void;
onTokenChange: (value: string) => void;
onAdapterTypeChange: (value: StudioGatewayAdapterType) => void;
onUseLocalDefaults: () => void;
onConnect: () => void;
};
@@ -30,17 +33,24 @@ const resolveLocalGatewayPort = (gatewayUrl: string): number => {
export const GatewayConnectScreen = ({
gatewayUrl,
token,
selectedAdapterType,
activeAdapterType,
localGatewayDefaults,
status,
error,
showApprovalHint,
onGatewayUrlChange,
onTokenChange,
onAdapterTypeChange,
onUseLocalDefaults,
onConnect,
}: GatewayConnectScreenProps) => {
const [copyStatus, setCopyStatus] = useState<"idle" | "copied" | "failed">("idle");
const [showToken, setShowToken] = useState(false);
const tokenOptional =
selectedAdapterType === "hermes" ||
selectedAdapterType === "demo" ||
selectedAdapterType === "custom";
const isLocal = useMemo(() => isLocalGatewayUrl(gatewayUrl), [gatewayUrl]);
const localPort = useMemo(() => resolveLocalGatewayPort(gatewayUrl), [gatewayUrl]);
const localGatewayCommand = useMemo(
@@ -51,6 +61,22 @@ export const GatewayConnectScreen = ({
() => `pnpm openclaw gateway run --bind loopback --port ${localPort} --verbose`,
[localPort]
);
const localDemoCommand = useMemo(
() => `npm run demo-gateway`,
[]
);
const useDemoPreset = () => {
onAdapterTypeChange("demo");
};
const useHermesPreset = () => {
onAdapterTypeChange("hermes");
};
const useOpenClawPreset = () => {
onAdapterTypeChange("openclaw");
};
const useCustomPreset = () => {
onAdapterTypeChange("custom");
};
const statusCopy = useMemo(() => {
if (status === "connecting" && isLocal) {
return `Local gateway detected on port ${localPort}. Connecting…`;
@@ -133,14 +159,14 @@ export const GatewayConnectScreen = ({
</div>
<label className="flex flex-col gap-1 text-[11px] font-medium text-foreground/90">
Upstream token
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
<div className="relative">
<input
className="ui-input h-10 w-full rounded-md px-4 pr-10 font-sans text-sm text-foreground outline-none"
type={showToken ? "text" : "password"}
value={token}
onChange={(event) => onTokenChange(event.target.value)}
placeholder="gateway token"
placeholder={tokenOptional ? "optional token" : "gateway token"}
spellCheck={false}
/>
<button
@@ -168,13 +194,13 @@ export const GatewayConnectScreen = ({
</button>
{status === "connecting" ? (
<p className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<div className="inline-flex items-center gap-1.5 text-xs text-muted-foreground">
<RunningAvatarLoader size={16} trackWidth={32} inline />
Connecting
</p>
</div>
) : null}
{error ? <p className="ui-text-danger text-xs leading-snug">{error}</p> : null}
{showApprovalHint ? (
{showApprovalHint && selectedAdapterType === "openclaw" ? (
<div className="rounded-md border border-border bg-muted/40 px-3 py-3 text-xs text-muted-foreground">
<p className="leading-snug">
If the first connection attempt did not work, go to your OpenClaw computer and approve this
@@ -208,7 +234,45 @@ export const GatewayConnectScreen = ({
<p className="font-mono text-[10px] font-medium tracking-[0.06em] text-muted-foreground">
Remote gateway (recommended)
</p>
<p className="mt-2 text-sm text-foreground/90">Default: enter your URL and token to connect.</p>
<p className="mt-2 text-sm text-foreground/90">
Choose a backend, then connect to its gateway URL.
</p>
<p className="mt-2 font-mono text-[11px] text-muted-foreground">
Selected backend: {selectedAdapterType} | Active backend: {activeAdapterType}
</p>
<p className="mt-1 text-xs text-muted-foreground">
Each backend keeps its own saved URL and token.
</p>
<div className="mt-3 flex flex-wrap gap-2">
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
onClick={useDemoPreset}
>
Demo backend
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
onClick={useHermesPreset}
>
Hermes backend
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
onClick={useCustomPreset}
>
Custom backend
</button>
<button
type="button"
className="ui-btn-secondary px-3 py-1.5 text-[11px] font-semibold tracking-[0.05em]"
onClick={useOpenClawPreset}
>
OpenClaw backend
</button>
</div>
</div>
{remoteForm}
</div>
@@ -224,6 +288,31 @@ export const GatewayConnectScreen = ({
</div>
<div className="mt-3 space-y-3">
{commandField}
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
<p className="text-xs font-medium text-foreground">Just want to see the office?</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">
Run <span className="font-mono text-foreground">{localDemoCommand}</span> to start a built-in mock gateway with demo agents.
Then choose <span className="font-mono text-foreground">Demo backend</span> and connect.
</p>
</div>
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
<p className="text-xs font-medium text-foreground">Using Hermes locally?</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">
Run <span className="font-mono text-foreground">npm run hermes-adapter</span>, then choose
<span className="font-mono text-foreground"> Hermes backend</span>. The default local URL is
<span className="font-mono text-foreground"> ws://localhost:18789</span>.
</p>
</div>
<div className="rounded-md border border-border bg-muted/30 px-3 py-3">
<p className="text-xs font-medium text-foreground">Using a custom runtime locally?</p>
<p className="mt-1 text-xs leading-snug text-muted-foreground">
Choose <span className="font-mono text-foreground">Custom backend</span> and point the URL
at your orchestrator or runtime boundary, for example
<span className="font-mono text-foreground"> http://localhost:7770</span>. Direct custom
runtime chat flows are not wired into Studio yet in this slice, but the provider seam and
metadata scaffold are now in place.
</p>
</div>
{localGatewayDefaults ? (
<div className="ui-input rounded-md px-3 py-3">
<div className="space-y-2">
@@ -155,11 +155,22 @@ export async function hydrateAgentFleetFromGateway(params: {
}
}
let agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
const helloSnapshotFallback = resolveAgentsListFromHelloSnapshot(
params.client.getLastHello?.()?.snapshot
);
let agentsResult: AgentsListResult;
try {
agentsResult = (await params.client.call("agents.list", {})) as AgentsListResult;
} catch (err) {
if (helloSnapshotFallback) {
agentsResult = helloSnapshotFallback;
} else {
throw err;
}
}
if (!Array.isArray(agentsResult?.agents) || agentsResult.agents.length === 0) {
const fallback = resolveAgentsListFromHelloSnapshot(params.client.getLastHello?.()?.snapshot);
if (fallback) {
agentsResult = fallback;
if (helloSnapshotFallback) {
agentsResult = helloSnapshotFallback;
}
}
agentsResult = {
@@ -23,6 +23,7 @@ type AgentsListResult = {
agents: Array<{
id: string;
name?: string;
role?: string;
identity?: {
name?: string;
theme?: string;
@@ -251,6 +252,7 @@ export const deriveHydrateAgentFleetResult = (
return {
agentId: agent.id,
name,
role: typeof agent.role === "string" && agent.role.trim() ? agent.role.trim() : null,
sessionKey: buildAgentMainSessionKey(agent.id, mainKey),
avatarSeed,
avatarProfile,
@@ -22,6 +22,25 @@ type GatewayClientLike = {
call: (method: string, params: unknown) => Promise<unknown>;
};
const extractImmediateAssistantText = (payload: unknown): string | null => {
if (!payload || typeof payload !== "object") return null;
const value = payload as {
text?: unknown;
content?: unknown;
message?: unknown;
};
if (typeof value.text === "string" && value.text.trim()) {
return value.text.trim();
}
if (typeof value.content === "string" && value.content.trim()) {
return value.content.trim();
}
if (typeof value.message === "string" && value.message.trim()) {
return value.message.trim();
}
return null;
};
const resolveLatestTranscriptTimestampMs = (agent: AgentState): number | null => {
const entries = agent.transcriptEntries;
let latest: number | null = null;
@@ -200,6 +219,33 @@ export async function sendChatMessageViaStudio(params: {
}
if (resolveChatSendCompletionMode(sendResult, runId) === "terminal-immediate") {
const assistantText = extractImmediateAssistantText(sendResult);
if (assistantText) {
const assistantTimestamp = now();
params.dispatch({
type: "appendOutput",
agentId,
line: assistantText,
transcript: {
source: "local-send",
runId,
sessionKey: params.sessionKey,
timestampMs: assistantTimestamp,
role: "assistant",
kind: "assistant",
},
});
params.dispatch({
type: "updateAgent",
agentId,
patch: {
lastResult: assistantText,
latestPreview: assistantText,
lastAssistantMessageAt: assistantTimestamp,
lastActivityAt: assistantTimestamp,
},
});
}
params.dispatch({
type: "updateAgent",
agentId,
@@ -0,0 +1,20 @@
import type { SettingsRouteTab } from "@/features/agents/operations/settingsRouteWorkflow";
export type SettingsSidebarEntry = {
id: SettingsRouteTab;
label: string;
};
const BASE_SETTINGS_SIDEBAR_ENTRIES: readonly SettingsSidebarEntry[] = [
{ id: "personality", label: "Behavior" },
{ id: "capabilities", label: "Capabilities" },
{ id: "skills", label: "Skills" },
{ id: "system", label: "System setup" },
{ id: "automations", label: "Automations" },
{ id: "advanced", label: "Advanced" },
];
export const resolveSettingsSidebarEntries = (runtimeSupportsCron: boolean) =>
BASE_SETTINGS_SIDEBAR_ENTRIES.filter(
(entry) => runtimeSupportsCron || entry.id !== "automations"
);
@@ -54,6 +54,7 @@ type AgentForSettingsMutation = Pick<AgentState, "agentId" | "name" | "sessionKe
export type UseAgentSettingsMutationControllerParams = {
client: GatewayClient;
status: GatewayStatus;
runtimeSupportsCron: boolean;
isLocalGateway: boolean;
agents: AgentForSettingsMutation[];
hasCreateBlock: boolean;
@@ -99,6 +100,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
useState<RestartingMutationBlockState | null>(null);
const REMOTE_MUTATION_EXEC_TIMEOUT_MS = 45_000;
const SKILL_INSTALL_TIMEOUT_MS = 120_000;
const CRON_UNSUPPORTED_MESSAGE = "This runtime does not support automations.";
const hasRenameMutationBlock = restartingMutationBlock?.kind === "rename-agent";
const hasDeleteMutationBlock = restartingMutationBlock?.kind === "delete-agent";
@@ -218,6 +220,12 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const loadCronJobsForSettingsAgent = useCallback(
async (agentId: string) => {
if (!params.runtimeSupportsCron) {
setSettingsCronJobs([]);
setSettingsCronLoading(false);
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
return;
}
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId) {
setSettingsCronJobs([]);
@@ -241,7 +249,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setSettingsCronLoading(false);
}
},
[params.client]
[params.client, params.runtimeSupportsCron]
);
useEffect(() => {
@@ -466,6 +474,10 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
const handleCreateCronJob = useCallback(
async (agentId: string, draft: CronCreateDraft) => {
if (!params.runtimeSupportsCron) {
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
return;
}
const decision = planAgentSettingsMutation(
{ kind: "create-cron-job", agentId },
mutationContext
@@ -499,11 +511,22 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
throw err;
}
},
[cronCreateBusy, cronDeleteBusyJobId, cronRunBusyJobId, mutationContext, params.client]
[
cronCreateBusy,
cronDeleteBusyJobId,
cronRunBusyJobId,
mutationContext,
params.client,
params.runtimeSupportsCron,
]
);
const handleRunCronJob = useCallback(
async (agentId: string, jobId: string) => {
if (!params.runtimeSupportsCron) {
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
return;
}
const decision = planAgentSettingsMutation(
{ kind: "run-cron-job", agentId, jobId },
mutationContext
@@ -530,11 +553,15 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronRunBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
);
const handleDeleteCronJob = useCallback(
async (agentId: string, jobId: string) => {
if (!params.runtimeSupportsCron) {
setSettingsCronError(CRON_UNSUPPORTED_MESSAGE);
return;
}
const decision = planAgentSettingsMutation(
{ kind: "delete-cron-job", agentId, jobId },
mutationContext
@@ -564,7 +591,7 @@ export function useAgentSettingsMutationController(params: UseAgentSettingsMutat
setCronDeleteBusyJobId((current) => (current === resolvedJobId ? null : current));
}
},
[loadCronJobsForSettingsAgent, mutationContext, params.client]
[loadCronJobsForSettingsAgent, mutationContext, params.client, params.runtimeSupportsCron]
);
const handleRenameAgent = useCallback(
@@ -21,6 +21,7 @@ const defaultLogError = (message: string, err: unknown) => {
export type UseGatewayConfigSyncControllerParams = {
client: GatewayClient;
status: GatewayConnectionStatus;
enabled?: boolean;
settingsRouteActive: boolean;
inspectSidebarAgentId: string | null;
gatewayConfigSnapshot: GatewayModelPolicySnapshot | null;
@@ -49,6 +50,7 @@ export function useGatewayConfigSyncController(
const {
client,
status,
enabled = true,
settingsRouteActive,
inspectSidebarAgentId,
gatewayConfigSnapshot,
@@ -63,6 +65,7 @@ export function useGatewayConfigSyncController(
const logError = params.logError ?? defaultLogError;
const refreshGatewayConfigSnapshot = useCallback(async () => {
if (!enabled) return null;
if (status !== "connected") return null;
try {
const snapshot = await client.call<GatewayModelPolicySnapshot>("config.get", {});
@@ -74,9 +77,17 @@ export function useGatewayConfigSyncController(
}
return null;
}
}, [client, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
}, [client, enabled, isDisconnectLikeError, setGatewayConfigSnapshot, status, logError]);
useEffect(() => {
if (enabled) return;
setGatewayModels([]);
setGatewayModelsError(null);
setGatewayConfigSnapshot(null);
}, [enabled, setGatewayConfigSnapshot, setGatewayModels, setGatewayModelsError]);
useEffect(() => {
if (!enabled) return;
const repairIntent = resolveSandboxRepairIntent({
status,
attempted: sandboxRepairAttemptedRef.current,
@@ -107,9 +118,10 @@ export function useGatewayConfigSyncController(
await loadAgents();
},
});
}, [client, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
}, [client, enabled, enqueueConfigMutation, gatewayConfigSnapshot, loadAgents, status]);
useEffect(() => {
if (!enabled) return;
if (
!shouldRefreshGatewayConfigForSettingsRoute({
status,
@@ -120,9 +132,12 @@ export function useGatewayConfigSyncController(
return;
}
void refreshGatewayConfigSnapshot();
}, [inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
}, [enabled, inspectSidebarAgentId, refreshGatewayConfigSnapshot, settingsRouteActive, status]);
useEffect(() => {
if (!enabled) {
return;
}
const syncIntent = resolveGatewayModelsSyncIntent({ status });
if (syncIntent.kind === "clear") {
setGatewayModels([]);
@@ -175,6 +190,7 @@ export function useGatewayConfigSyncController(
setGatewayConfigSnapshot,
setGatewayModels,
setGatewayModelsError,
enabled,
status,
logError,
]);
@@ -17,9 +17,7 @@ import { EmptyStatePanel } from "@/features/agents/components/EmptyStatePanel";
import {
isHeartbeatPrompt,
} from "@/lib/text/message-extract";
import {
useGatewayConnection,
} from "@/lib/gateway/GatewayClient";
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
import {
type GatewayModelChoice,
type GatewayModelPolicySnapshot,
@@ -111,6 +109,7 @@ import {
import { useAgentSettingsMutationController } from "@/features/agents/operations/useAgentSettingsMutationController";
import { useRuntimeSyncController } from "@/features/agents/operations/useRuntimeSyncController";
import { useChatInteractionController } from "@/features/agents/operations/useChatInteractionController";
import { resolveSettingsSidebarEntries } from "@/features/agents/operations/settingsSidebarTabs";
import {
SETTINGS_ROUTE_AGENT_ID_QUERY_PARAM,
parseSettingsRouteAgentIdFromQueryParam,
@@ -221,11 +220,14 @@ const AgentsPageScreen = () => {
const [settingsCoordinator] = useState(() => createStudioSettingsCoordinator());
const {
client,
provider,
status,
connectPromptReady,
shouldPromptForConnect,
gatewayUrl,
token,
selectedAdapterType,
activeAdapterType,
localGatewayDefaults,
error: gatewayError,
connect,
@@ -233,7 +235,12 @@ const AgentsPageScreen = () => {
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
} = useGatewayConnection(settingsCoordinator);
setSelectedAdapterType,
supportsCapability,
} = useRuntimeConnection(settingsCoordinator);
const runtimeSupportsConfig = supportsCapability("config");
const runtimeSupportsModels = supportsCapability("models");
const runtimeSupportsCron = supportsCapability("cron");
const {
loaded: voiceRepliesLoaded,
preference: voiceRepliesPreference,
@@ -443,6 +450,10 @@ const AgentsPageScreen = () => {
const settingsHeaderThinking =
settingsHeaderThinkingRaw.charAt(0).toUpperCase() + settingsHeaderThinkingRaw.slice(1);
const activeSettingsSidebarItem: SettingsSidebarItem = settingsSidebarItem;
const settingsSidebarEntries = useMemo(
() => resolveSettingsSidebarEntries(runtimeSupportsCron),
[runtimeSupportsCron]
);
useEffect(() => {
const selector = 'link[data-agent-favicon="true"]';
@@ -472,7 +483,10 @@ const AgentsPageScreen = () => {
const specialLatestUpdate = useMemo(() => {
return createSpecialLatestUpdateOperation({
callGateway: (method, params) => client.call(method, params),
listCronJobs: () => listCronJobs(client, { includeDisabled: true }),
listCronJobs: () =>
runtimeSupportsCron
? listCronJobs(client, { includeDisabled: true })
: Promise.resolve({ jobs: [] }),
resolveCronJobForAgent,
formatCronJobDisplay,
dispatchUpdateAgent: (agentId, patch) => {
@@ -481,7 +495,7 @@ const AgentsPageScreen = () => {
isDisconnectLikeError: isGatewayDisconnectLikeError,
logError: (message) => console.error(message),
});
}, [client, dispatch, resolveCronJobForAgent]);
}, [client, dispatch, resolveCronJobForAgent, runtimeSupportsCron]);
const refreshHeartbeatLatestUpdate = useCallback(() => {
const agents = stateRef.current.agents;
@@ -503,7 +517,7 @@ const AgentsPageScreen = () => {
setLoading(true);
try {
const commands = await runStudioBootstrapLoadOperation({
client,
client: provider,
gatewayUrl,
cachedConfigSnapshot: gatewayConfigSnapshot,
loadStudioSettings,
@@ -527,6 +541,7 @@ const AgentsPageScreen = () => {
}
}, [
client,
provider,
dispatch,
hydrateAgents,
setError,
@@ -547,6 +562,7 @@ const AgentsPageScreen = () => {
const { refreshGatewayConfigSnapshot } = useGatewayConfigSyncController({
client,
status,
enabled: runtimeSupportsConfig && runtimeSupportsModels,
settingsRouteActive,
inspectSidebarAgentId,
gatewayConfigSnapshot,
@@ -561,6 +577,7 @@ const AgentsPageScreen = () => {
const settingsMutationController = useAgentSettingsMutationController({
client,
status,
runtimeSupportsCron,
isLocalGateway,
agents,
hasCreateBlock: Boolean(createAgentBlock),
@@ -792,7 +809,7 @@ const AgentsPageScreen = () => {
loadMoreAgentHistory,
clearHistoryInFlight,
} = useRuntimeSyncController({
client,
client: provider,
status,
agents,
focusedAgentId,
@@ -815,7 +832,7 @@ const AgentsPageScreen = () => {
queueLivePatch,
clearPendingLivePatch,
} = useChatInteractionController({
client,
client: provider,
status,
agents,
dispatch,
@@ -1396,10 +1413,15 @@ const AgentsPageScreen = () => {
<ConnectionPanel
gatewayUrl={gatewayUrl}
token={token}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
localGatewayUrl={localGatewayDefaults?.url ?? null}
localGatewayToken={localGatewayDefaults?.token ?? null}
status={status}
error={gatewayError}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onAdapterTypeChange={setSelectedAdapterType}
onConnect={() => void connect()}
onDisconnect={disconnect}
onClose={() => setShowConnectionPanel(false)}
@@ -1410,12 +1432,15 @@ const AgentsPageScreen = () => {
<GatewayConnectScreen
gatewayUrl={gatewayUrl}
token={token}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
localGatewayDefaults={localGatewayDefaults}
status={status}
error={gatewayError}
showApprovalHint={didAttemptGatewayConnect}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onAdapterTypeChange={setSelectedAdapterType}
onUseLocalDefaults={useLocalGatewayDefaults}
onConnect={() => void connect()}
/>
@@ -1462,10 +1487,15 @@ const AgentsPageScreen = () => {
<ConnectionPanel
gatewayUrl={gatewayUrl}
token={token}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
localGatewayUrl={localGatewayDefaults?.url ?? null}
localGatewayToken={localGatewayDefaults?.token ?? null}
status={status}
error={gatewayError}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onAdapterTypeChange={setSelectedAdapterType}
onConnect={() => void connect()}
onDisconnect={disconnect}
onClose={() => setShowConnectionPanel(false)}
@@ -1505,16 +1535,7 @@ const AgentsPageScreen = () => {
</button>
</div>
<nav className="py-3">
{(
[
{ id: "personality", label: "Behavior" },
{ id: "capabilities", label: "Capabilities" },
{ id: "skills", label: "Skills" },
{ id: "system", label: "System setup" },
{ id: "automations", label: "Automations" },
{ id: "advanced", label: "Advanced" },
] as const
).map((entry) => {
{settingsSidebarEntries.map((entry) => {
const active = activeSettingsSidebarItem === entry.id;
return (
<button
@@ -1739,6 +1760,7 @@ const AgentsPageScreen = () => {
stopBusy={stopBusyAgentId === focusedAgent.agentId}
stopDisabledReason={focusedAgentStopDisabledReason}
onLoadMoreHistory={() => loadMoreAgentHistory(focusedAgent.agentId)}
onOpenSettings={() => handleOpenAgentSettingsRoute(focusedAgent.agentId)}
onRename={(name) =>
settingsMutationController.handleRenameAgent(focusedAgent.agentId, name)
}
+1
View File
@@ -26,6 +26,7 @@ export type FocusFilter = "all" | "running" | "approvals";
export type AgentStoreSeed = {
agentId: string;
name: string;
role?: string | null;
sessionKey: string;
avatarSeed?: string | null;
avatarProfile?: AgentAvatarProfile | null;
@@ -194,7 +194,7 @@ export function CompanyBuilderModal({
</div>
<h2 className="mt-1 text-lg font-semibold">Design an AI company from one prompt</h2>
<p className="mt-1 text-sm text-white/55">
Uses your connected OpenClaw runtime
Uses your connected runtime
{plannerAgentName ? ` via ${plannerAgentName}.` : "."}
</p>
</div>
@@ -329,7 +329,7 @@ export function CompanyBuilderModal({
Company Actions
</p>
<p className="mt-1 text-[11px] text-white/45">
Generate the org, then create it in OpenClaw.
Generate the org, then create it in your connected runtime.
</p>
</div>
{replacesExistingAgents ? (
@@ -341,7 +341,7 @@ export function CompanyBuilderModal({
) : null}
{!canUseAi ? (
<p className="text-xs text-amber-200/80">
Connect to OpenClaw and keep at least one available planning agent in the fleet
Connect to a runtime and keep at least one available planning agent in the fleet
to use AI suggestions.
</p>
) : null}
@@ -416,7 +416,7 @@ export function CompanyBuilderModal({
<div>
<p className="text-sm font-semibold text-white">Org structure</p>
<p className="text-xs text-white/55">
Edit the team before creating agents in OpenClaw.
Edit the team before creating agents in your connected runtime.
</p>
</div>
<button
@@ -704,7 +704,7 @@ export function CompanyBuilderModal({
{statusLine?.trim() || "Working on your company."}
</p>
<p className="mt-2 text-xs leading-5 text-white/55">
Claw3D is using your OpenClaw runtime right now. Please wait until this finishes.
Claw3D is using your connected runtime right now. Please wait until this finishes.
</p>
<div className="mt-5 flex gap-2">
{Array.from({ length: 4 }, (_, index) => (
@@ -897,7 +897,8 @@ export function CompanyBuilderModal({
What should the company do?
</p>
<p className="mt-2 text-sm text-white/55">
As soon as you submit this, OpenClaw will improve the brief automatically.
As soon as you submit this, Claw3D will improve the brief using your connected
runtime.
</p>
</div>
<button
@@ -923,9 +924,12 @@ export function CompanyBuilderModal({
disabled={busy}
/>
<div className="mt-5 flex items-center justify-between gap-3">
<p className="text-xs text-white/45">
The improved brief becomes the main editable input for generation.
</p>
<div>
<p className="text-xs text-white/45">
The improved brief becomes the main editable input for generation.
</p>
{error ? <p className="mt-2 text-xs text-red-200">{error}</p> : null}
</div>
<button
type="button"
className="inline-flex items-center gap-2 rounded-md bg-amber-500 px-4 py-2 text-sm font-semibold text-[#1a1206] transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-40"
+1 -1
View File
@@ -311,7 +311,7 @@ const normalizeRole = (value: ParsedCompanyRole, index: number): CompanyBuilderR
export const buildImproveCompanyBriefPrompt = (businessDescription: string) =>
[
"You are helping a user describe the company they want to build inside Claw3D.",
"Rewrite their brief so another OpenClaw agent can generate a clean org structure from it.",
"Rewrite their brief so another connected runtime agent can generate a clean org structure from it.",
"Keep the answer short, concrete, and useful.",
"Return markdown with these sections only:",
"## Company",
@@ -111,6 +111,7 @@ const DatePickerField = ({
export function AnalyticsPanel({
client,
status,
approvalsEnabled = true,
agents,
runLog,
gatewayUrl,
@@ -119,6 +120,7 @@ export function AnalyticsPanel({
}: {
client: GatewayClient;
status: GatewayStatus;
approvalsEnabled?: boolean;
agents: AgentState[];
runLog: RunRecord[];
gatewayUrl: string;
@@ -142,7 +144,12 @@ export function AnalyticsPanel({
settingsCoordinator,
});
const approvalMetrics = useApprovalMetrics({ client, status, agents });
const approvalMetrics = useApprovalMetrics({
client,
status,
enabled: approvalsEnabled,
agents,
});
const performance = usePerformanceAnalytics({
agents,
runLog,
@@ -140,11 +140,13 @@ const formatRelativeDateTime = (timestampMs?: number) => {
export function PlaybooksPanel({
client,
status,
cronEnabled = true,
agents,
standup,
}: {
client: GatewayClient;
status: GatewayStatus;
cronEnabled?: boolean;
agents: AgentState[];
standup: OfficeStandupController;
}) {
@@ -217,8 +219,10 @@ export function PlaybooksPanel({
}, [standup.config, standupAgentId]);
const loadJobs = useCallback(async () => {
if (status !== "connected") {
if (!cronEnabled || status !== "connected") {
setJobs([]);
setError(null);
setLoading(false);
return;
}
setLoading(true);
@@ -235,13 +239,17 @@ export function PlaybooksPanel({
} finally {
setLoading(false);
}
}, [client, status]);
}, [client, cronEnabled, status]);
useEffect(() => {
void loadJobs();
}, [loadJobs]);
const handleCreate = useCallback(async () => {
if (!cronEnabled) {
setError("This runtime does not expose scheduled playbooks.");
return;
}
if (!activeTemplate) return;
const agent = agentById.get(selectedAgentId);
if (!agent) {
@@ -265,10 +273,14 @@ export function PlaybooksPanel({
} finally {
setCreateBusy(false);
}
}, [activeTemplate, agentById, client, loadJobs, nameOverride, selectedAgentId]);
}, [activeTemplate, agentById, client, cronEnabled, loadJobs, nameOverride, selectedAgentId]);
const handleRunNow = useCallback(
async (jobId: string) => {
if (!cronEnabled) {
setError("This runtime does not expose scheduled playbooks.");
return;
}
setRunBusyJobId(jobId);
setError(null);
setActionMessage(null);
@@ -282,11 +294,15 @@ export function PlaybooksPanel({
setRunBusyJobId(null);
}
},
[client, loadJobs]
[client, cronEnabled, loadJobs]
);
const handleDelete = useCallback(
async (jobId: string) => {
if (!cronEnabled) {
setError("This runtime does not expose scheduled playbooks.");
return;
}
setDeleteBusyJobId(jobId);
setError(null);
setActionMessage(null);
@@ -300,7 +316,7 @@ export function PlaybooksPanel({
setDeleteBusyJobId(null);
}
},
[client, loadJobs]
[client, cronEnabled, loadJobs]
);
const handleSaveStandupConfig = useCallback(async () => {
@@ -385,11 +401,17 @@ export function PlaybooksPanel({
<button
type="button"
onClick={() => void loadJobs()}
disabled={!cronEnabled}
className="rounded border border-cyan-500/20 bg-cyan-500/10 px-2 py-1 font-mono text-[10px] uppercase tracking-[0.16em] text-cyan-200 transition-colors hover:border-cyan-400/40 hover:text-cyan-100"
>
Refresh
</button>
</div>
{!cronEnabled ? (
<div className="mt-2 font-mono text-[11px] text-white/35">
This runtime does not expose scheduled playbooks.
</div>
) : null}
{error ? <div className="mt-2 font-mono text-[11px] text-rose-300">{error}</div> : null}
{actionMessage ? (
<div className="mt-2 font-mono text-[11px] text-emerald-300">{actionMessage}</div>
@@ -2,11 +2,19 @@
import { useState } from "react";
import { CURATED_ELEVENLABS_VOICES } from "@/lib/voiceReply/catalog";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
type SettingsPanelProps = {
gatewayStatus?: string;
gatewayUrl?: string;
gatewayToken?: string;
selectedAdapterType?: StudioGatewayAdapterType;
activeAdapterType?: StudioGatewayAdapterType;
onGatewayDisconnect?: () => void;
onGatewayConnect?: () => void;
onGatewayUrlChange?: (value: string) => void;
onGatewayTokenChange?: (value: string) => void;
onGatewayAdapterTypeChange?: (value: StudioGatewayAdapterType) => void;
onOpenOnboarding?: () => void;
officeTitle: string;
officeTitleLoaded: boolean;
@@ -36,7 +44,14 @@ type SettingsPanelProps = {
export function SettingsPanel({
gatewayStatus,
gatewayUrl,
gatewayToken,
selectedAdapterType = "openclaw",
activeAdapterType = "openclaw",
onGatewayDisconnect,
onGatewayConnect,
onGatewayUrlChange,
onGatewayTokenChange,
onGatewayAdapterTypeChange,
onOpenOnboarding,
officeTitle,
officeTitleLoaded,
@@ -63,10 +78,17 @@ export function SettingsPanel({
onVoiceRepliesPreview,
}: SettingsPanelProps) {
const normalizedGatewayUrl = gatewayUrl?.trim() ?? "";
const normalizedGatewayToken = gatewayToken ?? "";
const gatewayStateLabel = gatewayStatus
? gatewayStatus.charAt(0).toUpperCase() + gatewayStatus.slice(1)
: "Unknown";
const gatewayDisconnectDisabled = gatewayStatus !== "connected";
const isGatewayConnected = gatewayStatus === "connected";
const gatewayDisconnectDisabled = !isGatewayConnected;
const gatewayConnectDisabled = normalizedGatewayUrl.length === 0;
const tokenOptional =
selectedAdapterType === "hermes" ||
selectedAdapterType === "demo" ||
selectedAdapterType === "custom";
const [remoteOfficeTokenDraft, setRemoteOfficeTokenDraft] = useState("");
return (
@@ -101,28 +123,100 @@ export function SettingsPanel({
<div>
<div className="text-[11px] font-medium text-white">Gateway</div>
<div className="mt-1 text-[10px] text-white/75">
Current studio connection and endpoint details.
Switch the active backend and update its saved endpoint details.
</div>
</div>
<span className="font-mono text-[10px] uppercase tracking-[0.14em] text-cyan-200/70">
{gatewayStateLabel}
</span>
</div>
<div className="mt-3 rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[10px] text-cyan-100/80">
{normalizedGatewayUrl || "No gateway URL configured."}
<div className="mt-3 flex flex-wrap gap-2">
{(
[
["demo", "Demo"],
["hermes", "Hermes"],
["custom", "Custom"],
["openclaw", "OpenClaw"],
] as const
).map(([adapterType, label]) => {
const selected = selectedAdapterType === adapterType;
return (
<button
key={adapterType}
type="button"
onClick={() => onGatewayAdapterTypeChange?.(adapterType)}
className={`rounded-md border px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] transition-colors ${
selected
? "border-cyan-400/35 bg-cyan-500/12 text-cyan-50"
: "border-cyan-500/10 bg-black/20 text-white/75 hover:border-cyan-400/25 hover:text-cyan-50"
}`}
>
{label}
</button>
);
})}
</div>
<div className="mt-3 grid gap-3">
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
Upstream URL
</div>
<input
type="text"
value={gatewayUrl ?? ""}
onChange={(event) => onGatewayUrlChange?.(event.target.value)}
placeholder={
selectedAdapterType === "custom"
? "http://localhost:7770"
: "ws://localhost:18789"
}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 font-mono text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</div>
<div>
<div className="mb-1 text-[10px] uppercase tracking-[0.14em] text-cyan-100/65">
{tokenOptional ? "Upstream token (optional)" : "Upstream token"}
</div>
<input
type="password"
value={normalizedGatewayToken}
onChange={(event) => onGatewayTokenChange?.(event.target.value)}
placeholder={tokenOptional ? "optional token" : "gateway token"}
className="w-full rounded-md border border-cyan-500/10 bg-black/25 px-3 py-2 text-[11px] text-cyan-100 outline-none transition-colors placeholder:text-cyan-100/30 focus:border-cyan-400/30"
/>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2 text-[10px] text-white/60">
<span className="font-mono">
Selected backend: {selectedAdapterType}
</span>
<span className="font-mono">
Active backend: {activeAdapterType}
</span>
<span>Each backend keeps its own saved URL and token.</span>
</div>
<div className="mt-3 flex items-center justify-between gap-3">
<div className="text-[10px] text-white/60">
Disconnecting returns you to the gateway connect screen.
Connect to apply the selected backend, or disconnect to return to the connection screen.
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => onGatewayConnect?.()}
disabled={gatewayConnectDisabled}
className="rounded-md border border-cyan-500/20 bg-cyan-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-cyan-50 transition-colors hover:border-cyan-400/40 hover:bg-cyan-500/15 disabled:cursor-not-allowed disabled:opacity-40"
>
{gatewayStatus === "connecting" ? "Connecting..." : "Connect"}
</button>
<button
type="button"
onClick={() => onGatewayDisconnect?.()}
disabled={gatewayDisconnectDisabled}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
>
Disconnect gateway
</button>
</div>
<button
type="button"
onClick={() => onGatewayDisconnect?.()}
disabled={gatewayDisconnectDisabled}
className="rounded-md border border-rose-500/20 bg-rose-500/10 px-3 py-1.5 text-[10px] font-medium uppercase tracking-[0.14em] text-rose-100 transition-colors hover:border-rose-400/40 hover:bg-rose-500/15 disabled:cursor-not-allowed disabled:opacity-40"
>
Disconnect gateway
</button>
</div>
</div>
<div className="mt-3 rounded-lg border border-cyan-500/10 bg-black/20 px-4 py-3">
+14 -10
View File
@@ -36,20 +36,24 @@ const MAX_APPROVAL_RECORDS = 300;
export const useApprovalMetrics = ({
client,
status,
enabled = true,
agents,
}: {
client: GatewayClient;
status: GatewayStatus;
enabled?: boolean;
agents: AgentState[];
}) => {
const [records, setRecords] = useState<ApprovalRecord[]>([]);
const agentsRef = useRef(agents);
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
if (!enabled) return;
if (status !== "connected") return;
return client.onEvent((event) => {
const requested = parseExecApprovalRequested(event);
@@ -105,11 +109,11 @@ export const useApprovalMetrics = ({
return [fallbackRecord, ...current].slice(0, MAX_APPROVAL_RECORDS);
});
});
}, [client, status]);
}, [client, enabled, status]);
const byAgent = useMemo(() => {
const metrics = new Map<string, ApprovalAgentMetrics>();
for (const record of records) {
for (const record of visibleRecords) {
const agentId = record.agentId?.trim() ?? "";
if (!agentId) continue;
const current = metrics.get(agentId) ?? {
@@ -135,20 +139,20 @@ export const useApprovalMetrics = ({
}
return left.agentId.localeCompare(right.agentId);
});
}, [records]);
}, [visibleRecords]);
const totals = useMemo(() => {
return {
requestedCount: records.length,
resolvedCount: records.filter((record) => record.decision !== null).length,
deniedCount: records.filter((record) => record.decision === "deny").length,
allowOnceCount: records.filter((record) => record.decision === "allow-once").length,
allowAlwaysCount: records.filter((record) => record.decision === "allow-always").length,
requestedCount: visibleRecords.length,
resolvedCount: visibleRecords.filter((record) => record.decision !== null).length,
deniedCount: visibleRecords.filter((record) => record.decision === "deny").length,
allowOnceCount: visibleRecords.filter((record) => record.decision === "allow-once").length,
allowAlwaysCount: visibleRecords.filter((record) => record.decision === "allow-always").length,
};
}, [records]);
}, [visibleRecords]);
return {
records,
records: visibleRecords,
byAgent,
totals,
};
@@ -35,10 +35,12 @@ const isSkillEnabledForAgent = (params: {
export const useOfficeSkillTriggers = ({
client,
status,
enabled = true,
agents,
}: {
client: GatewayClient;
status: GatewayStatus;
enabled?: boolean;
agents: AgentState[];
}) => {
const requestIdRef = useRef(0);
@@ -55,7 +57,10 @@ export const useOfficeSkillTriggers = ({
[agentIdsKey],
);
const shouldLoadTriggers =
status === "connected" && stableAgentIds.length > 0 && packagedTriggers.length > 0;
enabled &&
status === "connected" &&
stableAgentIds.length > 0 &&
packagedTriggers.length > 0;
useEffect(() => {
if (!shouldLoadTriggers) {
@@ -30,6 +30,7 @@ type MarketplaceMessage = {
export const useOfficeSkillsMarketplace = ({
client,
status,
enabled = true,
agents,
preferredAgentId,
onSkillActivityStart,
@@ -37,6 +38,7 @@ export const useOfficeSkillsMarketplace = ({
}: {
client: GatewayClient;
status: GatewayStatus;
enabled?: boolean;
agents: AgentState[];
preferredAgentId?: string | null;
onSkillActivityStart?: (agentId: string) => void;
@@ -88,7 +90,7 @@ export const useOfficeSkillsMarketplace = ({
const loadMarketplace = useCallback(
async (agentId: string) => {
const resolvedAgentId = agentId.trim();
if (!resolvedAgentId || status !== "connected") {
if (!enabled || !resolvedAgentId || status !== "connected") {
setSkillsReport(null);
setSkillsAllowlist(undefined);
setLoading(false);
@@ -132,11 +134,11 @@ export const useOfficeSkillsMarketplace = ({
}
}
},
[client, status],
[client, enabled, status],
);
useEffect(() => {
if (!selectedAgentId || status !== "connected") {
if (!enabled || !selectedAgentId || status !== "connected") {
requestIdRef.current += 1;
setSkillsReport(null);
setSkillsAllowlist(undefined);
@@ -144,14 +146,15 @@ export const useOfficeSkillsMarketplace = ({
return;
}
void loadMarketplace(selectedAgentId);
}, [loadMarketplace, selectedAgentId, status]);
}, [enabled, loadMarketplace, selectedAgentId, status]);
const refresh = useCallback(async () => {
if (!enabled) return;
if (!selectedAgentId) {
return;
}
await loadMarketplace(selectedAgentId);
}, [loadMarketplace, selectedAgentId]);
}, [enabled, loadMarketplace, selectedAgentId]);
const runSkillMutation = useCallback(
async (params: {
@@ -162,6 +165,13 @@ export const useOfficeSkillsMarketplace = ({
const agentId = selectedAgentId?.trim() ?? "";
const report = skillsReport;
const normalizedSkillKey = params.skillKey.trim();
if (!enabled) {
setMessage({
kind: "error",
text: "This runtime does not expose skill management.",
});
return;
}
if (!agentId || !report) {
setMessage({
kind: "error",
@@ -201,13 +211,13 @@ export const useOfficeSkillsMarketplace = ({
);
}
},
[loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
[enabled, loadMarketplace, onSkillActivityEnd, onSkillActivityStart, selectedAgentId, skillsReport],
);
const handleSetSkillEnabled = useCallback(
async (skillName: string, enabled: boolean) => {
const entry =
skillsReport?.skills.find(
skillsReport?.skills?.find(
(skill) => skill.name.trim() === skillName.trim(),
) ?? null;
await runSkillMutation({
+7 -3
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import type { AgentState } from "@/features/agents/state/store";
import type { AgentEventPayload } from "@/features/agents/state/runtimeEventBridge";
import type { GatewayClient, GatewayStatus } from "@/lib/gateway/GatewayClient";
@@ -57,22 +57,26 @@ const findAgentForRunEvent = (
export const useRunLog = ({
client,
status,
enabled = true,
agents,
maxRecords = MAX_RUN_RECORDS,
}: {
client: GatewayClient;
status: GatewayStatus;
enabled?: boolean;
agents: AgentState[];
maxRecords?: number;
}) => {
const [records, setRecords] = useState<RunRecord[]>([]);
const agentsRef = useRef(agents);
const visibleRecords = useMemo(() => (enabled ? records : []), [enabled, records]);
useEffect(() => {
agentsRef.current = agents;
}, [agents]);
useEffect(() => {
if (!enabled) return;
if (status !== "connected") return;
return client.onEvent((event) => {
if (event.event !== "agent") return;
@@ -126,7 +130,7 @@ export const useRunLog = ({
return [fallbackRecord, ...current].slice(0, Math.max(1, maxRecords));
});
});
}, [client, maxRecords, status]);
}, [client, enabled, maxRecords, status]);
return records;
return visibleRecords;
};
+188 -86
View File
@@ -12,16 +12,17 @@ import { useRouter, useSearchParams } from "next/navigation";
import { MessageSquare, ChevronDown, Mic } from "lucide-react";
import { RetroOffice3D } from "@/features/retro-office/RetroOffice3D";
import type { OfficeAgent } from "@/features/retro-office/core/types";
import { RunningAvatarLoader } from "@/features/agents/components/RunningAvatarLoader";
import { GatewayConnectScreen } from "@/features/agents/components/GatewayConnectScreen";
import { useAgentStore, type AgentState } from "@/features/agents/state/store";
import {
GatewayClient,
buildAgentMainSessionKey,
useGatewayConnection,
type EventFrame,
isSameSessionKey,
parseAgentIdFromSessionKey,
} from "@/lib/gateway/GatewayClient";
import { useRuntimeConnection } from "@/lib/runtime/useRuntimeConnection";
import {
createStudioSettingsCoordinator,
type StudioSettingsLoadOptions,
@@ -214,6 +215,8 @@ const MAIN_AGENT_ID = "main";
const MAX_OPENCLAW_LOG_ENTRIES = 200;
const MAX_OPENCLAW_AGENT_OUTPUT_LINES = 12;
const OFFICE_DANCE_MS = 60_000;
const GATEWAY_LOADING_OVERLAY_DELAY_MS = 1_200;
const GATEWAY_CONNECT_OVERLAY_DELAY_MS = 1_500;
const getLatestUserRequestForAgent = (
agent: AgentState,
@@ -510,6 +513,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
return {
id: agent.agentId,
name: agent.name || "Unknown",
subtitle: agent.role ?? null,
status: "error",
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
@@ -520,6 +524,7 @@ const mapAgentToOffice = (agent: AgentState): OfficeAgent => {
return {
id: agent.agentId,
name: agent.name || "Unknown",
subtitle: agent.role ?? null,
status: isWorking ? "working" : "idle",
color: stringToColor(agent.agentId),
item: getDeterministicItem(agent.agentId),
@@ -835,11 +840,14 @@ export function OfficeScreen({
);
const {
client,
provider,
status,
connectPromptReady,
shouldPromptForConnect,
gatewayUrl,
token,
selectedAdapterType,
activeAdapterType,
localGatewayDefaults,
error: gatewayError,
connect,
@@ -847,12 +855,23 @@ export function OfficeScreen({
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
setSelectedAdapterType,
supportsCapability,
} =
useGatewayConnection(settingsCoordinator);
useRuntimeConnection(settingsCoordinator);
const runtimeSupportsSkills = supportsCapability("skills");
const runtimeSupportsApprovals = supportsCapability("approvals");
const runtimeSupportsCron = supportsCapability("cron");
const runtimeSupportsModels = supportsCapability("models");
const runtimeSupportsRunLifecycle = supportsCapability("runtime-agent-events");
const { state, dispatch, hydrateAgents, setError, setLoading } =
useAgentStore();
const [agentsLoaded, setAgentsLoaded] = useState(false);
const [didAttemptGatewayConnect, setDidAttemptGatewayConnect] = useState(false);
const [showDelayedGatewayLoadingOverlay, setShowDelayedGatewayLoadingOverlay] =
useState(false);
const [showDelayedGatewayConnectOverlay, setShowDelayedGatewayConnectOverlay] =
useState(false);
const [clockTick, setClockTick] = useState(0);
const [debugRows, setDebugRows] = useState<OfficeDebugRow[]>([]);
const [feedEvents, setFeedEvents] = useState<OfficeFeedEvent[]>([]);
@@ -1129,14 +1148,33 @@ export function OfficeScreen({
},
[dispatch, gatewayUrl, settingsCoordinator],
);
const focusLocalAgent = useCallback(
(agentId: string, options?: { openChat?: boolean }) => {
setSelectedChatAgentId(agentId);
if (options?.openChat !== false) {
setChatOpen(true);
}
dispatch({ type: "selectAgent", agentId });
},
[dispatch],
);
const focusChatTarget = useCallback(
(agentId: string) => {
setSelectedChatAgentId(agentId);
setChatOpen(true);
if (!isRemoteOfficeAgentId(agentId)) {
dispatch({ type: "selectAgent", agentId });
}
},
[dispatch],
);
const openAgentEditor = useCallback(
(agentId: string, initialSection: AgentEditorSection = "avatar") => {
setAgentEditorAgentId(agentId);
setAgentEditorInitialSection(initialSection);
setSelectedChatAgentId(agentId);
dispatch({ type: "selectAgent", agentId });
focusLocalAgent(agentId, { openChat: false });
},
[dispatch],
[focusLocalAgent],
);
const handleDeskAssignmentChange = useCallback(
@@ -1353,7 +1391,7 @@ export function OfficeScreen({
? { force: true }
: { maxAgeMs: options?.settingsMaxAgeMs ?? 60_000 };
const commands = await runStudioBootstrapLoadOperation({
client,
client: provider,
gatewayUrl,
cachedConfigSnapshot: gatewayConfigSnapshot.current,
loadStudioSettings: () => loadStudioSettings(settingsLoadOptions),
@@ -1389,7 +1427,7 @@ export function OfficeScreen({
}
try {
const inference = await inferRunningFromAgentSessions({
client,
client: provider,
agentId: agent.agentId,
});
if (connectionEpochAtStart !== connectionEpochRef.current) {
@@ -1570,7 +1608,7 @@ export function OfficeScreen({
const runCompanyBuilderAiTask = useCallback(
async (prompt: string, statusText: string) => {
if (status !== "connected") {
throw new Error("Connect to OpenClaw before using the company builder.");
throw new Error("Connect to a runtime before using the company builder.");
}
const livePlannerAgent = resolveCompanyPlanningAgent({
agents: stateRef.current.agents,
@@ -1598,7 +1636,7 @@ export function OfficeScreen({
try {
const improvedBrief = await runCompanyBuilderAiTask(
buildImproveCompanyBriefPrompt(brief),
"Improving your company brief with OpenClaw.",
"Improving your company brief with the connected runtime.",
);
setCompanyBuilderInput((current) => ({
...current,
@@ -1625,7 +1663,7 @@ export function OfficeScreen({
try {
const response = await runCompanyBuilderAiTask(
buildGenerateCompanyPlanPrompt(brief),
"Generating your AI company structure with OpenClaw.",
"Generating your AI company structure with the connected runtime.",
);
const parsedPlan = parseCompanyPlanFromAssistantText(response);
const nextInput: CompanyBuilderInput = {
@@ -1668,7 +1706,7 @@ export function OfficeScreen({
const handleCreateCompanyFromPlan = useCallback(
async (params: { input: CompanyBuilderInput; plan: CompanyBuilderPlan }) => {
if (status !== "connected") {
const message = "Connect to OpenClaw before creating the company.";
const message = "Connect to a runtime before creating the company.";
setCompanyBuilderError(message);
throw new Error(message);
}
@@ -1772,8 +1810,7 @@ export function OfficeScreen({
persistSnapshot: persistCompanyBuilderSnapshot,
setOfficeTitle,
selectAgent: (agentId) => {
dispatch({ type: "selectAgent", agentId });
setSelectedChatAgentId(agentId);
focusLocalAgent(agentId);
},
setStatusLine: setCompanyBuilderStatusLine,
});
@@ -1887,11 +1924,7 @@ export function OfficeScreen({
);
}
}
dispatch({
type: "selectAgent",
agentId: completion.agentId,
});
setSelectedChatAgentId(completion.agentId);
focusLocalAgent(completion.agentId);
setCreateAgentBlock(null);
setCreateAgentModalError(null);
},
@@ -1911,6 +1944,7 @@ export function OfficeScreen({
createAgentBusy,
dispatch,
enqueueConfigMutation,
focusLocalAgent,
hasDeleteMutationBlock,
loadAgents,
setError,
@@ -2105,7 +2139,7 @@ export function OfficeScreen({
const requestedSessionKey = params.sessionKey?.trim() ?? "";
if (requestedSessionKey) {
try {
const history = await client.call<{
const history = await provider.call<{
messages?: Record<string, unknown>[];
}>("chat.history", {
sessionKey: requestedSessionKey,
@@ -2117,7 +2151,7 @@ export function OfficeScreen({
const derived = buildHistoryLines(messages);
let lastUser = derived.lastUser?.trim() ?? "";
if (!lastUser) {
const previewResult = await client.call<SummaryPreviewSnapshot>(
const previewResult = await provider.call<SummaryPreviewSnapshot>(
"sessions.preview",
{
keys: [requestedSessionKey],
@@ -2213,7 +2247,7 @@ export function OfficeScreen({
return;
}
const commands = await runHistorySyncOperation({
client,
client: provider,
agentId: params.agentId,
getAgent: (agentId) =>
stateRef.current.agents.find((entry) => entry.agentId === agentId) ??
@@ -2239,7 +2273,7 @@ export function OfficeScreen({
});
}
},
[client, debugEnabled, dispatch, status],
[debugEnabled, dispatch, provider, status],
);
const refreshRecentTransportSessionHistory = useCallback(
@@ -2324,7 +2358,6 @@ export function OfficeScreen({
useEffect(() => {
if (status === "disconnected") {
connectionEpochRef.current += 1;
setAgentsLoaded(false);
setCreateAgentWizardOpen(false);
setCreateAgentBusy(false);
setCreateAgentModalError(null);
@@ -2333,7 +2366,11 @@ export function OfficeScreen({
loadAgentsInFlightRef.current = null;
gatewayConfigSnapshot.current = null;
lastLoadAgentsStartedAtRef.current = 0;
hydrateAgents([]);
setLoading(false);
if (stateRef.current.agents.length === 0) {
setAgentsLoaded(false);
hydrateAgents([]);
}
setFeedEvents([]);
setDebugRows([]);
setRunCountByAgentId({});
@@ -2341,7 +2378,7 @@ export function OfficeScreen({
prevAssistantPreviewRef.current = {};
lastGatewayActivityAtRef.current = 0;
}
}, [hydrateAgents, status]);
}, [hydrateAgents, setLoading, status]);
useEffect(() => {
if (!agentsLoaded) return;
@@ -2602,10 +2639,11 @@ export function OfficeScreen({
useEffect(() => {
if (status !== "connected") return;
if (!runtimeSupportsModels) return;
let cancelled = false;
void (async () => {
try {
const result = await client.call<{ models: GatewayModelChoice[] }>(
const result = await provider.call<{ models: GatewayModelChoice[] }>(
"models.list",
{},
);
@@ -2624,7 +2662,7 @@ export function OfficeScreen({
return () => {
cancelled = true;
};
}, [status, client]);
}, [status, provider, runtimeSupportsModels]);
useEffect(() => {
if (chatOpen && !selectedChatAgentId && state.agents.length > 0) {
@@ -2638,7 +2676,7 @@ export function OfficeScreen({
);
const chatController = useChatInteractionController({
client,
client: provider,
status,
agents: state.agents,
dispatch: (action) => dispatch(action as never),
@@ -2676,7 +2714,12 @@ export function OfficeScreen({
setAgentEditorAgentId(null);
}, [agentEditorAgentId, state.agents]);
const runLog = useRunLog({ client, status, agents: state.agents });
const runLog = useRunLog({
client,
status,
enabled: runtimeSupportsRunLifecycle,
agents: state.agents,
});
const standupAgentSnapshots = useMemo<StandupAgentSnapshot[]>(
() =>
state.agents.map((agent) => ({
@@ -2696,6 +2739,7 @@ export function OfficeScreen({
settingsCoordinator,
client,
status,
cronEnabled: runtimeSupportsCron,
agents: state.agents,
runLog,
standup: standupController,
@@ -2723,6 +2767,7 @@ export function OfficeScreen({
const marketplace = useOfficeSkillsMarketplace({
client,
status,
enabled: runtimeSupportsSkills,
agents: state.agents,
preferredAgentId: selectedLocalChatAgentId,
onSkillActivityStart: handleMarketplaceGymStart,
@@ -2731,6 +2776,7 @@ export function OfficeScreen({
const skillTriggers = useOfficeSkillTriggers({
client,
status,
enabled: runtimeSupportsSkills,
agents: state.agents,
});
const animationNowMs = Date.now();
@@ -2863,9 +2909,8 @@ export function OfficeScreen({
useEffect(() => {
if (!activeGithubReviewAgentId) return;
setSelectedChatAgentId(activeGithubReviewAgentId);
dispatch({ type: "selectAgent", agentId: activeGithubReviewAgentId });
}, [activeGithubReviewAgentId, dispatch]);
focusLocalAgent(activeGithubReviewAgentId);
}, [activeGithubReviewAgentId, focusLocalAgent]);
useEffect(() => {
setQaTestingAgentId(activeQaTestingAgentId);
@@ -2873,9 +2918,8 @@ export function OfficeScreen({
useEffect(() => {
if (!activeQaTestingAgentId) return;
setSelectedChatAgentId(activeQaTestingAgentId);
dispatch({ type: "selectAgent", agentId: activeQaTestingAgentId });
}, [activeQaTestingAgentId, dispatch]);
focusLocalAgent(activeQaTestingAgentId);
}, [activeQaTestingAgentId, focusLocalAgent]);
useEffect(() => {
const activeKeys = new Set(
@@ -2929,9 +2973,7 @@ export function OfficeScreen({
promptedPhoneCallKeysRef.current.delete(request.key);
return;
}
setSelectedChatAgentId(agentId);
setChatOpen(true);
dispatch({ type: "selectAgent", agentId });
focusLocalAgent(agentId);
dispatch({
type: "appendOutput",
agentId,
@@ -2989,7 +3031,7 @@ export function OfficeScreen({
prepareScenarioForAgent(agentId, request);
}
}
}, [dispatch, phoneCallByAgentId, state.agents]);
}, [dispatch, focusLocalAgent, phoneCallByAgentId, state.agents]);
const activePhoneBoothAgentId = useMemo(
() =>
@@ -3011,10 +3053,9 @@ export function OfficeScreen({
({ agentId, requestKey }: PhoneCallSpeakPayload) => {
if (spokenPhoneCallKeysRef.current.has(requestKey)) return;
spokenPhoneCallKeysRef.current.add(requestKey);
setSelectedChatAgentId(agentId);
dispatch({ type: "selectAgent", agentId });
focusLocalAgent(agentId);
},
[dispatch],
[focusLocalAgent],
);
const handlePhoneCallComplete = useCallback(
@@ -3092,9 +3133,7 @@ export function OfficeScreen({
promptedTextMessageKeysRef.current.delete(request.key);
return;
}
setSelectedChatAgentId(agentId);
setChatOpen(true);
dispatch({ type: "selectAgent", agentId });
focusLocalAgent(agentId);
dispatch({
type: "appendOutput",
agentId,
@@ -3152,7 +3191,7 @@ export function OfficeScreen({
prepareScenarioForAgent(agentId, request);
}
}
}, [dispatch, state.agents, textMessageByAgentId]);
}, [dispatch, focusLocalAgent, state.agents, textMessageByAgentId]);
const activeSmsBoothAgentId = useMemo(
() =>
@@ -3219,13 +3258,9 @@ export function OfficeScreen({
const handleOpenAgentChat = useCallback(
(agentId: string) => {
setSelectedChatAgentId(agentId);
setChatOpen(true);
if (!isRemoteOfficeAgentId(agentId)) {
dispatch({ type: "selectAgent", agentId });
}
focusChatTarget(agentId);
},
[dispatch],
[focusChatTarget],
);
const updateRemoteChatSession = useCallback(
(
@@ -3752,6 +3787,15 @@ export function OfficeScreen({
state.agents,
workingUntilByAgentId,
]);
const streamingTextByAgentId = useMemo(() => {
const map: Record<string, string | null> = {};
for (const agent of state.agents) {
if (agent.streamText?.trim()) {
map[agent.agentId] = agent.streamText.trim();
}
}
return map;
}, [state.agents]);
const openClawLiveStateText = useMemo(() => {
const lines = ["== LIVE OPENCLAW STATE =="];
if (state.agents.length === 0) {
@@ -4087,43 +4131,52 @@ export function OfficeScreen({
// No longer force-close the jukebox panel when skill is disabled;
// the panel handles the disabled state itself.
if (
useEffect(() => {
if (
status === "connecting" &&
!agentsLoaded &&
gatewayUrl.trim().length > 0 &&
!shouldPromptForConnect
) {
const timeoutId = window.setTimeout(() => {
setShowDelayedGatewayLoadingOverlay(true);
}, GATEWAY_LOADING_OVERLAY_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
setShowDelayedGatewayLoadingOverlay(false);
}, [agentsLoaded, gatewayUrl, shouldPromptForConnect, status]);
useEffect(() => {
if (
status === "disconnected" &&
!agentsLoaded &&
didAttemptGatewayConnect &&
!shouldPromptForConnect
) {
const timeoutId = window.setTimeout(() => {
setShowDelayedGatewayConnectOverlay(true);
}, GATEWAY_CONNECT_OVERLAY_DELAY_MS);
return () => {
window.clearTimeout(timeoutId);
};
}
setShowDelayedGatewayConnectOverlay(false);
}, [agentsLoaded, didAttemptGatewayConnect, shouldPromptForConnect, status]);
const showGatewayLoadingOverlay =
!agentsLoaded &&
(!connectPromptReady ||
(gatewayUrl.trim().length > 0 &&
!shouldPromptForConnect &&
(!didAttemptGatewayConnect || status === "connecting")))
) {
return (
<div className="flex min-h-screen items-center justify-center bg-black font-mono text-[#4FC3F7]">
CONNECTING TO GATEWAY...
</div>
);
}
if (
((!didAttemptGatewayConnect && showDelayedGatewayLoadingOverlay) ||
(status === "connecting" && showDelayedGatewayLoadingOverlay))));
const showGatewayConnectOverlay =
connectPromptReady &&
status === "disconnected" &&
!agentsLoaded &&
(shouldPromptForConnect || didAttemptGatewayConnect)
) {
return (
<main className="min-h-screen bg-black px-4 py-10">
<GatewayConnectScreen
gatewayUrl={gatewayUrl}
token={token}
localGatewayDefaults={localGatewayDefaults}
status={status}
error={gatewayError}
showApprovalHint={didAttemptGatewayConnect}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onUseLocalDefaults={useLocalGatewayDefaults}
onConnect={() => void connect()}
/>
</main>
);
}
(shouldPromptForConnect || showDelayedGatewayConnectOverlay);
const runningCount = state.agents.filter(
(agent) =>
@@ -4145,7 +4198,44 @@ export function OfficeScreen({
"Connected to the gateway, but no agents were loaded into the office.";
return (
<main className="h-full w-full overflow-hidden bg-black">
<main className="relative h-full w-full overflow-hidden bg-black">
{showGatewayLoadingOverlay ? (
<div
className="pointer-events-none absolute inset-0 z-40 flex items-center justify-center bg-[#120a05]/76"
aria-label="Connecting to runtime"
role="status"
>
<div className="rounded-xl border border-amber-700/45 bg-[#1a1008] px-8 py-6 shadow-2xl">
<RunningAvatarLoader
size={28}
trackWidth={76}
label="Connecting to your runtime..."
labelClassName="text-amber-100/80"
/>
</div>
</div>
) : null}
{showGatewayConnectOverlay ? (
<div className="pointer-events-auto absolute inset-0 z-50 flex items-start justify-center bg-[#120a05]/76 px-4 py-10">
<div className="w-full max-w-[860px] rounded-2xl border border-amber-900/55 bg-[#120a05]/98 p-3 shadow-2xl">
<GatewayConnectScreen
gatewayUrl={gatewayUrl}
token={token}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
localGatewayDefaults={localGatewayDefaults}
status={status}
error={gatewayError}
showApprovalHint={didAttemptGatewayConnect}
onGatewayUrlChange={setGatewayUrl}
onTokenChange={setToken}
onAdapterTypeChange={setSelectedAdapterType}
onUseLocalDefaults={useLocalGatewayDefaults}
onConnect={() => void connect()}
/>
</div>
</div>
) : null}
<section className="relative h-full min-h-0 min-w-0 overflow-hidden">
<RetroOffice3D
agents={allVisibleAgents}
@@ -4202,12 +4292,21 @@ export function OfficeScreen({
gatewayUrl,
settingsCoordinator,
}}
gatewayUrl={gatewayUrl}
gatewayToken={token}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
onGatewayDisconnect={disconnect}
onGatewayConnect={() => void connect()}
onGatewayUrlChange={setGatewayUrl}
onGatewayTokenChange={setToken}
onGatewayAdapterTypeChange={setSelectedAdapterType}
onOpenOnboarding={handleOpenOnboarding}
feedEvents={feedEvents}
gatewayStatus={status}
runCountByAgentId={runCountByAgentId}
lastSeenByAgentId={lastSeenByAgentId}
streamingTextByAgentId={streamingTextByAgentId}
standupMeeting={standupController.meeting}
standupAutoOpenBoard={standupController.openBoardByDefault}
onStandupArrivalsChange={(arrivedAgentIds) => {
@@ -4224,8 +4323,7 @@ export function OfficeScreen({
onMonitorSelect={(agentId) => {
setMonitorAgentId(agentId);
if (agentId && !isRemoteOfficeAgentId(agentId)) {
setSelectedChatAgentId(agentId);
dispatch({ type: "selectAgent", agentId });
focusLocalAgent(agentId, { openChat: false });
}
}}
onAgentChatSelect={(agentId) => {
@@ -4268,7 +4366,6 @@ export function OfficeScreen({
taskBoard.sharedTasksError ?? taskBoard.gatewayTasksError ?? taskBoard.cronError
}
taskBoardCaptureDebug={showOpenClawConsole ? taskBoard.taskCaptureDebug : undefined}
preferredKanbanAgentId={selectedChatAgentId ?? state.selectedAgentId}
onTaskBoardCreateCard={() => {
taskBoard.createManualCard();
}}
@@ -4487,6 +4584,7 @@ export function OfficeScreen({
<PlaybooksPanel
client={client}
status={status}
cronEnabled={runtimeSupportsCron}
agents={state.agents}
standup={standupController}
/>
@@ -4495,6 +4593,7 @@ export function OfficeScreen({
<AnalyticsPanel
client={client}
status={status}
approvalsEnabled={runtimeSupportsApprovals}
agents={state.agents}
runLog={runLog}
gatewayUrl={gatewayUrl}
@@ -4833,6 +4932,9 @@ export function OfficeScreen({
chatController.stopBusyAgentId === focusedChatAgent.agentId
}
onLoadMoreHistory={() => {}}
onOpenSettings={() =>
openAgentEditor(focusedChatAgent.agentId, "IDENTITY.md")
}
onNewSession={() =>
chatController.handleNewSession(focusedChatAgent.agentId)
}
@@ -649,6 +649,7 @@ export const useTaskBoardController = ({
settingsCoordinator,
client,
status,
cronEnabled = true,
agents,
runLog,
standup,
@@ -657,6 +658,7 @@ export const useTaskBoardController = ({
settingsCoordinator: StudioSettingsCoordinator;
client: GatewayClient;
status: GatewayStatus;
cronEnabled?: boolean;
agents: AgentState[];
runLog: RunRecord[];
standup: OfficeStandupController;
@@ -843,9 +845,10 @@ export const useTaskBoardController = ({
}, [gatewayUrl, settingsCoordinator, state.cards, state.selectedCardId]);
const refreshCronJobs = useCallback(async () => {
if (status !== "connected") {
if (!cronEnabled || status !== "connected") {
setCronJobs([]);
setCronError(null);
setCronLoading(false);
return;
}
setCronLoading(true);
@@ -860,7 +863,7 @@ export const useTaskBoardController = ({
} finally {
setCronLoading(false);
}
}, [client, status]);
}, [client, cronEnabled, status]);
const refreshSharedTasks = useCallback(async () => {
if (!sharedTasksSupported) {
@@ -35,7 +35,7 @@ export const CompanyStep = ({
{
icon: Sparkles,
title: "Improve the brief",
description: "Use your connected OpenClaw runtime to sharpen the company prompt.",
description: "Use your connected runtime to sharpen the company prompt.",
},
{
icon: Users,
@@ -45,7 +45,7 @@ export const CompanyStep = ({
{
icon: Wand2,
title: "Create everything",
description: "Write agent files and create the team directly in OpenClaw.",
description: "Write agent files and create the team directly in the connected runtime.",
},
].map(({ icon: Icon, title, description }) => (
<div
@@ -73,7 +73,7 @@ export const CompanyStep = ({
</div>
) : (
<div className="rounded-md border border-amber-500/20 bg-amber-500/10 px-4 py-3 text-xs text-amber-100/80">
Connect to OpenClaw and keep at least one planning agent available to generate the
Connect to a runtime and keep at least one planning agent available to generate the
company with AI.
</div>
)}
@@ -62,7 +62,7 @@ export const CompleteStep = ({
</p>
<p className="max-w-sm text-sm text-white/60">
{companyCreated
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in OpenClaw and placed into the office.`
? `${companyName?.trim() || "Your company"} is ready. Your new team has been created in the connected runtime and placed into the office.`
: "Your gateway is connected and your agents are ready. Step inside and explore the 3D workspace where your AI team operates."}
</p>
</div>
@@ -32,11 +32,11 @@ export const WelcomeStep = () => (
<p className="text-sm leading-relaxed text-white/80">
Claw3D turns your AI automation into a{" "}
<span className="font-medium text-white">visual workplace</span> an
office where your OpenClaw agents collaborate, code, test, and execute
office where your AI agents collaborate, code, test, and execute
tasks in a shared 3D environment.
</p>
<p className="text-sm text-white/60">
This wizard will help you connect to your OpenClaw gateway and get
This wizard will help you connect to your runtime gateway and get
started in about two minutes.
</p>
</div>
+1 -1
View File
@@ -49,7 +49,7 @@ export const ONBOARDING_STEPS: OnboardingStep[] = [
{
id: "connect",
title: "Connect Your Gateway",
description: "Link to your OpenClaw instance",
description: "Link to your runtime instance",
skippable: false,
},
{
@@ -4,7 +4,7 @@
* Uses localStorage so the wizard only shows once per browser.
* The key is scoped to the Claw3D app to avoid collisions.
*/
import { useCallback, useState } from "react";
import { useCallback, useEffect, useState } from "react";
const STORAGE_KEY = "claw3d:onboarding:completed";
@@ -40,7 +40,11 @@ export type OnboardingStateReturn = {
};
export const useOnboardingState = (): OnboardingStateReturn => {
const [completed, setCompleted] = useState(readCompleted);
const [completed, setCompleted] = useState<boolean | null>(null);
useEffect(() => {
setCompleted(readCompleted());
}, []);
const completeOnboarding = useCallback(() => {
setCompleted(true);
@@ -53,7 +57,7 @@ export const useOnboardingState = (): OnboardingStateReturn => {
}, []);
return {
showOnboarding: !completed,
showOnboarding: completed === false,
completeOnboarding,
resetOnboarding,
};
+196 -123
View File
@@ -51,6 +51,7 @@ import type { OfficeDeskMonitor } from "@/lib/office/deskMonitor";
import type { OfficeAnimationState } from "@/lib/office/eventTriggers";
import type { StandupMeeting } from "@/lib/office/standup/types";
import type { SkillStatusEntry } from "@/lib/skills/types";
import type { StudioGatewayAdapterType } from "@/lib/studio/settings";
import type {
TaskBoardCard,
TaskBoardStatus,
@@ -2358,12 +2359,21 @@ export function RetroOffice3D({
onVoiceRepliesSpeedChange,
onVoiceRepliesPreview,
onGatewayDisconnect,
onGatewayConnect,
onGatewayUrlChange,
onGatewayTokenChange,
onGatewayAdapterTypeChange,
onOpenOnboarding,
atmAnalytics = null,
feedEvents = EMPTY_FEED_EVENTS,
gatewayStatus = "disconnected",
gatewayUrl = "",
gatewayToken = "",
selectedAdapterType = "openclaw",
activeAdapterType = "openclaw",
runCountByAgentId = EMPTY_NUMBER_RECORD,
lastSeenByAgentId = EMPTY_NUMBER_RECORD,
streamingTextByAgentId = {},
onStandupArrivalsChange,
onStandupStartRequested,
onMonitorSelect,
@@ -2395,7 +2405,6 @@ export function RetroOffice3D({
taskBoardCronLoading = false,
taskBoardCronError = null,
taskBoardCaptureDebug,
preferredKanbanAgentId = null,
onTaskBoardCreateCard,
onTaskBoardMoveCard,
onTaskBoardSelectCard,
@@ -2465,12 +2474,21 @@ export function RetroOffice3D({
onVoiceRepliesSpeedChange?: (speed: number) => void;
onVoiceRepliesPreview?: (voiceId: string | null, voiceName: string) => void;
onGatewayDisconnect?: () => void;
onGatewayConnect?: () => void;
onGatewayUrlChange?: (value: string) => void;
onGatewayTokenChange?: (value: string) => void;
onGatewayAdapterTypeChange?: (value: StudioGatewayAdapterType) => void;
onOpenOnboarding?: () => void;
atmAnalytics?: OfficeUsageAnalyticsParams | null;
feedEvents?: FeedEvent[];
gatewayStatus?: string;
gatewayUrl?: string;
gatewayToken?: string;
selectedAdapterType?: StudioGatewayAdapterType;
activeAdapterType?: StudioGatewayAdapterType;
runCountByAgentId?: Record<string, number>;
lastSeenByAgentId?: Record<string, number>;
streamingTextByAgentId?: Record<string, string | null>;
onStandupArrivalsChange?: (arrivedAgentIds: string[]) => void;
onStandupStartRequested?: () => void;
onMonitorSelect?: (agentId: string | null) => void;
@@ -2506,7 +2524,6 @@ export function RetroOffice3D({
taskBoardCaptureDebug?: ComponentProps<
typeof KanbanImmersiveScreen
>["taskCaptureDebug"];
preferredKanbanAgentId?: string | null;
onTaskBoardCreateCard?: () => void;
onTaskBoardMoveCard?: (cardId: string, status: TaskBoardStatus) => void;
onTaskBoardSelectCard?: (cardId: string | null) => void;
@@ -2664,6 +2681,40 @@ export function RetroOffice3D({
target: [number, number, number];
zoom?: number;
} | null>(null);
const LOCAL_CAMERA_TARGET = useMemo(
() =>
toWorld(LOCAL_OFFICE_CANVAS_WIDTH / 2, LOCAL_OFFICE_CANVAS_HEIGHT / 2),
[],
);
const CAM_POS = useMemo<[number, number, number]>(() => {
if (remoteOfficeEnabled) return DISTRICT_CAMERA_POSITION;
return [
LOCAL_CAMERA_TARGET[0] +
(DISTRICT_CAMERA_POSITION[0] - DISTRICT_CAMERA_TARGET[0]),
LOCAL_CAMERA_TARGET[1] +
(DISTRICT_CAMERA_POSITION[1] - DISTRICT_CAMERA_TARGET[1]),
LOCAL_CAMERA_TARGET[2] +
(DISTRICT_CAMERA_POSITION[2] - DISTRICT_CAMERA_TARGET[2]),
];
}, [LOCAL_CAMERA_TARGET, remoteOfficeEnabled]);
const cameraTarget = remoteOfficeEnabled
? DISTRICT_CAMERA_TARGET
: LOCAL_CAMERA_TARGET;
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
const overviewPreset = useMemo(
() => ({ pos: CAM_POS, target: cameraTarget, zoom: cameraZoom }),
[CAM_POS, cameraTarget, cameraZoom]
);
const canvasResetKey = useMemo(
() =>
[
remoteOfficeEnabled ? "remote" : "local",
gatewayStatus ?? "unknown",
String(agents.length),
String(officeCenterSignal),
].join(":"),
[agents.length, gatewayStatus, officeCenterSignal, remoteOfficeEnabled],
);
// New Idea 7: heatmap mode.
const [heatmapMode, setHeatmapMode] = useState(false);
const [trailMode, setTrailMode] = useState(false);
@@ -2945,9 +2996,7 @@ export function RetroOffice3D({
const [wx, , wz] = toWorld(agent.x, agent.y);
orbitRef.current.target.set(wx, 0, wz);
orbitRef.current.update();
if (isRemoteOfficeAgentId(agentId)) {
onAgentChatSelect?.(agentId);
}
onAgentChatSelect?.(agentId);
},
[onAgentChatSelect, renderAgentLookupRef],
);
@@ -3110,7 +3159,8 @@ export function RetroOffice3D({
phoneBoothImmersive ||
githubImmersive ||
qaImmersive ||
standupImmersive;
standupImmersive ||
kanbanImmersive;
const compactRosterAgents = useMemo(
() => agents.slice(0, COMPACT_AGENT_BADGE_LIMIT),
[agents],
@@ -3310,7 +3360,7 @@ export function RetroOffice3D({
!activeGithubTerminalUid &&
!activeQaTerminalUid
) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
}, [
activeAtmUid,
@@ -3318,6 +3368,7 @@ export function RetroOffice3D({
activeQaTerminalUid,
followAgentId,
monitorAgentId,
overviewPreset,
]);
const closeManualSmsBoothView = useCallback(() => {
@@ -3339,7 +3390,7 @@ export function RetroOffice3D({
!activeGithubTerminalUid &&
!activeQaTerminalUid
) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
}, [
activeAtmUid,
@@ -3347,6 +3398,7 @@ export function RetroOffice3D({
activeQaTerminalUid,
followAgentId,
monitorAgentId,
overviewPreset,
]);
const getBoothAudioContext = useCallback(async () => {
@@ -3946,7 +3998,7 @@ export function RetroOffice3D({
? `agent:${smsBoothAgentId}`
: null;
if (!activeViewKey && prevSmsBoothViewRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!activeViewKey || !activeSmsBooth) {
prevSmsBoothViewRef.current = activeViewKey;
@@ -3966,6 +4018,7 @@ export function RetroOffice3D({
}, [
activeSmsBooth,
manualSmsBoothOpen,
overviewPreset,
smsBoothAgentId,
smsBoothCommandArrived,
]);
@@ -4094,7 +4147,7 @@ export function RetroOffice3D({
? `agent:${phoneBoothAgentId}`
: null;
if (!activeViewKey && prevPhoneBoothViewRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!activeViewKey || !activePhoneBooth) {
prevPhoneBoothViewRef.current = activeViewKey;
@@ -4114,6 +4167,7 @@ export function RetroOffice3D({
}, [
activePhoneBooth,
manualPhoneBoothOpen,
overviewPreset,
phoneBoothAgentId,
phoneBoothCommandArrived,
]);
@@ -4241,7 +4295,7 @@ export function RetroOffice3D({
useEffect(() => {
if (!monitorAgentId && prevMonitorAgentIdRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!monitorAgentId || !activeMonitorComputer) {
prevMonitorAgentIdRef.current = monitorAgentId;
@@ -4257,7 +4311,7 @@ export function RetroOffice3D({
zoom: 330,
};
prevMonitorAgentIdRef.current = monitorAgentId;
}, [activeMonitorComputer, monitorAgentId]);
}, [activeMonitorComputer, monitorAgentId, overviewPreset]);
useEffect(() => {
if (activeAtmUid && !activeAtm) {
@@ -4268,7 +4322,7 @@ export function RetroOffice3D({
window.clearTimeout(timer);
};
}
}, [activeAtm, activeAtmUid]);
}, [activeAtm, activeAtmUid, overviewPreset]);
useEffect(() => {
if (activeKanbanUid && !activeKanbanBoard) {
@@ -4305,7 +4359,7 @@ export function RetroOffice3D({
useEffect(() => {
if (!activeAtmUid && prevAtmUidRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!activeAtmUid || !activeAtm) {
prevAtmUidRef.current = activeAtmUid;
@@ -4326,7 +4380,7 @@ export function RetroOffice3D({
zoom: 250,
};
prevAtmUidRef.current = activeAtmUid;
}, [activeAtm, activeAtmUid]);
}, [activeAtm, activeAtmUid, overviewPreset]);
useEffect(() => {
prevKanbanUidRef.current = activeKanbanUid;
@@ -4339,7 +4393,7 @@ export function RetroOffice3D({
? `agent:${githubReviewAgentId}`
: null;
if (!activeViewKey && prevGithubViewRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!activeViewKey || !activeGithubTerminal) {
prevGithubViewRef.current = activeViewKey;
@@ -4365,6 +4419,7 @@ export function RetroOffice3D({
activeGithubTerminalUid,
githubCommandArrived,
githubReviewAgentId,
overviewPreset,
]);
useEffect(() => {
@@ -4374,7 +4429,7 @@ export function RetroOffice3D({
? `agent:${qaTestingAgentId}`
: null;
if (!activeViewKey && prevQaViewRef.current) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
if (!activeViewKey || !activeQaTerminal) {
prevQaViewRef.current = activeViewKey;
@@ -4398,6 +4453,7 @@ export function RetroOffice3D({
}, [
activeQaTerminal,
activeQaTerminalUid,
overviewPreset,
qaCommandArrived,
qaTestingAgentId,
]);
@@ -4687,6 +4743,8 @@ export function RetroOffice3D({
onStandupStartRequested,
qaTerminal,
resolveAgentIdForDeskItem,
planPath,
renderAgentsRef,
serverTerminal,
voiceRepliesEnabled,
voiceRepliesLoaded,
@@ -4738,7 +4796,7 @@ export function RetroOffice3D({
!activeGithubTerminalUid &&
!activeQaTerminalUid
) {
cameraPresetRef.current = overviewPresetRef.current;
cameraPresetRef.current = overviewPreset;
}
}, [
activeAtmUid,
@@ -4746,6 +4804,7 @@ export function RetroOffice3D({
activeQaTerminalUid,
followAgentId,
monitorAgentId,
overviewPreset,
]);
useEffect(() => {
@@ -5158,37 +5217,17 @@ export function RetroOffice3D({
return () => clearTimeout(timer);
}, [spotlightAgentId]);
// Camera constants.
const LOCAL_CAMERA_TARGET = useMemo(
() =>
toWorld(LOCAL_OFFICE_CANVAS_WIDTH / 2, LOCAL_OFFICE_CANVAS_HEIGHT / 2),
[],
);
const CAM_POS = useMemo<[number, number, number]>(() => {
if (remoteOfficeEnabled) return DISTRICT_CAMERA_POSITION;
return [
LOCAL_CAMERA_TARGET[0] + (DISTRICT_CAMERA_POSITION[0] - DISTRICT_CAMERA_TARGET[0]),
LOCAL_CAMERA_TARGET[1] + (DISTRICT_CAMERA_POSITION[1] - DISTRICT_CAMERA_TARGET[1]),
LOCAL_CAMERA_TARGET[2] + (DISTRICT_CAMERA_POSITION[2] - DISTRICT_CAMERA_TARGET[2]),
];
}, [remoteOfficeEnabled, LOCAL_CAMERA_TARGET]);
const cameraTarget = remoteOfficeEnabled
? DISTRICT_CAMERA_TARGET
: LOCAL_CAMERA_TARGET;
const cameraZoom = remoteOfficeEnabled ? DISTRICT_CAMERA_ZOOM : 56;
const overviewPresetRef = useRef({ pos: CAM_POS, target: cameraTarget, zoom: cameraZoom });
overviewPresetRef.current = { pos: CAM_POS, target: cameraTarget, zoom: cameraZoom };
const lastOfficeCenterSignalRef = useRef(officeCenterSignal);
useEffect(() => {
cameraPresetRef.current = overviewPresetRef.current;
}, [CAM_POS, cameraTarget, cameraZoom]);
cameraPresetRef.current = overviewPreset;
}, [overviewPreset]);
useEffect(() => {
if (officeCenterSignal === lastOfficeCenterSignalRef.current) return;
lastOfficeCenterSignalRef.current = officeCenterSignal;
cameraPresetRef.current = overviewPresetRef.current;
}, [officeCenterSignal, CAM_POS, cameraTarget, cameraZoom]);
cameraPresetRef.current = overviewPreset;
}, [officeCenterSignal, overviewPreset]);
return (
<div className="relative w-full h-full bg-[#1a1008] font-mono text-white overflow-hidden">
@@ -5215,6 +5254,7 @@ export function RetroOffice3D({
*/}
{!immersiveOverlayActive ? (
<Canvas
key={canvasResetKey}
orthographic
dpr={[0.85, 1.5]}
camera={{
@@ -5746,6 +5786,7 @@ export function RetroOffice3D({
key={agent.id}
agentId={agent.id}
name={agent.name}
subtitle={"subtitle" in agent ? agent.subtitle ?? null : null}
status={agent.status}
color={agentColorMap.get(agent.id) ?? "#888"}
appearance={
@@ -5764,14 +5805,17 @@ export function RetroOffice3D({
? false
: standupMeeting?.phase === "in_progress"
? Boolean(standupSpeechTextByAgentId[agent.id])
: speechAgentIds.has(agent.id)
: speechAgentIds.has(agent.id) ||
Boolean(streamingTextByAgentId[agent.id])
}
speechText={
isJanitor
? null
: standupMeeting?.phase === "in_progress"
? (standupSpeechTextByAgentId[agent.id] ?? null)
: (speechTextByAgentId[agent.id] ?? null)
: (speechTextByAgentId[agent.id] ??
streamingTextByAgentId[agent.id] ??
null)
}
suppressSpeechBubble={
suppressSceneSpeechBubbles &&
@@ -5931,12 +5975,14 @@ export function RetroOffice3D({
{/* Title — top center overlay. */}
{!immersiveOverlayActive ? (
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex items-center gap-3 pointer-events-none select-none z-10">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
{officeTitle}
</span>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
<div className="absolute top-3 left-1/2 -translate-x-1/2 flex flex-col items-center gap-2 pointer-events-none select-none z-10">
<div className="flex items-center gap-3">
<div className="h-px w-12 bg-gradient-to-r from-transparent to-amber-500/40" />
<span className="text-sm tracking-[0.3em] text-amber-300/80 font-bold uppercase">
{officeTitle}
</span>
<div className="h-px w-12 bg-gradient-to-l from-transparent to-amber-500/40" />
</div>
</div>
) : null}
@@ -7005,6 +7051,18 @@ export function RetroOffice3D({
<span>Add</span>
</button>
) : null}
<div
className={`flex h-7 items-center rounded-md border px-2 text-[10px] font-mono uppercase tracking-[0.12em] ${
gatewayStatus === "connected"
? "border-emerald-400/25 bg-emerald-500/10 text-emerald-100"
: gatewayStatus === "connecting"
? "border-amber-400/25 bg-amber-500/10 text-amber-100"
: "border-rose-400/25 bg-rose-500/10 text-rose-100"
}`}
title={`Runtime: ${activeAdapterType} (${gatewayStatus})`}
>
{activeAdapterType} {gatewayStatus}
</div>
{/* New Idea 7: Heatmap toggle. */}
<button
onClick={() => setHeatmapMode((p) => !p)}
@@ -7098,11 +7156,22 @@ export function RetroOffice3D({
<div className="min-h-0 flex-1 overflow-y-auto">
<SettingsPanel
gatewayStatus={gatewayStatus}
gatewayUrl={atmAnalytics?.gatewayUrl}
gatewayUrl={gatewayUrl}
gatewayToken={gatewayToken}
selectedAdapterType={selectedAdapterType}
activeAdapterType={activeAdapterType}
onGatewayDisconnect={() => {
onGatewayDisconnect?.();
setSettingsModalOpen(false);
}}
onGatewayConnect={() => {
onGatewayConnect?.();
}}
onGatewayUrlChange={(value) => onGatewayUrlChange?.(value)}
onGatewayTokenChange={(value) => onGatewayTokenChange?.(value)}
onGatewayAdapterTypeChange={(value) =>
onGatewayAdapterTypeChange?.(value)
}
onOpenOnboarding={() => {
onOpenOnboarding?.();
setSettingsModalOpen(false);
@@ -7156,81 +7225,85 @@ export function RetroOffice3D({
</div>
) : null}
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
<div className="absolute bottom-3 left-3 flex flex-col items-start gap-1.5 z-10 pointer-events-none select-none">
{/* Idea 3: Activity feed entries — newest on bottom. */}
{statusFeedEvents
.slice(0, 4)
.reverse()
.map((ev) => (
<div
key={`${ev.id}-${ev.ts}`}
className="flex items-center gap-2 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono"
>
<span className="text-amber-400/80 font-semibold">{ev.name}</span>
<span className="text-amber-600/70">{ev.text}</span>
</div>
))}
{/* Ideas 6 + 8: Gateway status, agent counts, vibe score. */}
<div className="flex items-center gap-3 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono">
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "working").length} working
</span>
<span className="opacity-30">·</span>
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "idle").length} idle
</span>
<span className="opacity-30">·</span>
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "error").length} error
</span>
{/* New Idea 6: Vibe score with animated EQ bars. */}
{(() => {
const workingCount = agents.filter(
(a) => a.status === "working",
).length;
const ratio = workingCount / Math.max(agents.length, 1);
const label =
ratio < 0.2 ? "quiet" : ratio < 0.6 ? "active" : "buzzing";
const animDur = ratio < 0.2 ? "1.8s" : ratio < 0.6 ? "1s" : "0.5s";
return (
<>
<span className="opacity-30">·</span>
<span
className="flex items-end gap-px h-3"
style={{ ["--eq-dur" as string]: animDur }}
{!immersiveOverlayActive ? (
<>
{/* Ideas 3 + 6 + 8: Mini status bar — bottom left. */}
<div className="absolute bottom-3 left-3 flex flex-col items-start gap-1.5 z-10 pointer-events-none select-none">
{/* Idea 3: Activity feed entries — newest on bottom. */}
{statusFeedEvents
.slice(0, 4)
.reverse()
.map((ev) => (
<div
key={`${ev.id}-${ev.ts}`}
className="flex items-center gap-2 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono"
>
{[0.6, 1, 0.7].map((h, i) => (
<span
key={i}
className="w-[3px] bg-amber-500/60 rounded-sm"
style={{
height: `${h * 100}%`,
animation: `eq-bar ${animDur} ${i * 0.15}s infinite ease-in-out alternate`,
}}
/>
))}
</span>
<span className="text-amber-500/50">{label}</span>
</>
);
})()}
{!editMode && !spaceDown && (
<>
<span className="opacity-30">·</span>
<span className="text-amber-400/40">
drag · scroll · space+drag · dbl-click
<span className="text-amber-400/80 font-semibold">{ev.name}</span>
<span className="text-amber-600/70">{ev.text}</span>
</div>
))}
{/* Ideas 6 + 8: Gateway status, agent counts, vibe score. */}
<div className="flex items-center gap-3 bg-black/60 backdrop-blur-sm rounded-full px-3 py-1 text-[10px] font-mono">
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "working").length} working
</span>
</>
)}
{spaceDown && (
<>
<span className="opacity-30">·</span>
<span className="text-amber-300/80">pan mode</span>
</>
)}
</div>
</div>
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "idle").length} idle
</span>
<span className="opacity-30">·</span>
<span className="text-amber-500/60">
{agents.filter((a) => a.status === "error").length} error
</span>
{/* New Idea 6: Vibe score with animated EQ bars. */}
{(() => {
const workingCount = agents.filter(
(a) => a.status === "working",
).length;
const ratio = workingCount / Math.max(agents.length, 1);
const label =
ratio < 0.2 ? "quiet" : ratio < 0.6 ? "active" : "buzzing";
const animDur = ratio < 0.2 ? "1.8s" : ratio < 0.6 ? "1s" : "0.5s";
return (
<>
<span className="opacity-30">·</span>
<span
className="flex items-end gap-px h-3"
style={{ ["--eq-dur" as string]: animDur }}
>
{[0.6, 1, 0.7].map((h, i) => (
<span
key={i}
className="w-[3px] bg-amber-500/60 rounded-sm"
style={{
height: `${h * 100}%`,
animation: `eq-bar ${animDur} ${i * 0.15}s infinite ease-in-out alternate`,
}}
/>
))}
</span>
<span className="text-amber-500/50">{label}</span>
</>
);
})()}
{!editMode && !spaceDown && (
<>
<span className="opacity-30">·</span>
<span className="text-amber-400/40">
drag · scroll · space+drag · dbl-click
</span>
</>
)}
{spaceDown && (
<>
<span className="opacity-30">·</span>
<span className="text-amber-300/80">pan mode</span>
</>
)}
</div>
</div>
</>
) : null}
<style>{`
@keyframes eq-bar {
from { transform: scaleY(0.3); }
+1
View File
@@ -4,6 +4,7 @@ import type { OfficeInteractionTargetId } from "@/lib/office/places";
export type OfficeAgent = {
id: string;
name: string;
subtitle?: string | null;
status: "working" | "idle" | "error";
color: string;
item: string;
+19 -4
View File
@@ -27,6 +27,7 @@ const formatAgentNameplateText = (value: string): string => {
export const AgentModel = memo(function AgentModel({
agentId,
name,
subtitle,
status,
color,
appearance,
@@ -640,6 +641,7 @@ export const AgentModel = memo(function AgentModel({
: "transparent";
const speechBubbleBorderInset = activeSpeechBubble ? 0.03 : 0;
const nameplateText = name ? formatAgentNameplateText(name) : "";
const subtitleText = typeof subtitle === "string" ? subtitle.trim() : "";
const nameplateFontSize =
nameplateText.length > 9 ? 0.118 : nameplateText.length > 7 ? 0.13 : 0.144;
@@ -1070,19 +1072,19 @@ export const AgentModel = memo(function AgentModel({
{!activeSpeechBubble && nameplateText ? (
<Billboard position={[0, 1.05, 0]}>
<mesh position={[0, 0, -0.001]}>
<planeGeometry args={[0.82, 0.24]} />
<planeGeometry args={[0.82, subtitleText ? 0.34 : 0.24]} />
<meshBasicMaterial color="#080c14" transparent opacity={0.9} />
</mesh>
<mesh position={[-0.392, 0, 0]}>
<planeGeometry args={[0.028, 0.24]} />
<planeGeometry args={[0.028, subtitleText ? 0.34 : 0.24]} />
<meshBasicMaterial color={color} />
</mesh>
<mesh position={[0.355, 0, 0]}>
<mesh position={[0.355, subtitleText ? 0.05 : 0, 0]}>
<circleGeometry args={[0.052, 14]} />
<meshBasicMaterial ref={statusDotMatRef} color="#ef4444" />
</mesh>
<Text
position={[-0.02, 0, 0.001]}
position={[-0.02, subtitleText ? 0.05 : 0, 0.001]}
fontSize={nameplateFontSize}
color="#e8dfc0"
anchorX="center"
@@ -1092,6 +1094,19 @@ export const AgentModel = memo(function AgentModel({
>
{nameplateText}
</Text>
{subtitleText ? (
<Text
position={[-0.02, -0.085, 0.001]}
fontSize={0.082}
color="#8ab4ff"
anchorX="center"
anchorY="middle"
maxWidth={0.68}
font={undefined}
>
{subtitleText}
</Text>
) : null}
</Billboard>
) : null}
<group ref={awayBubbleRef} visible={false}>
@@ -30,6 +30,7 @@ export type InteractiveFurnitureModelProps = {
export type AgentModelProps = {
agentId: string;
name: string;
subtitle?: string | null;
status: OfficeAgent["status"];
color: string;
appearance?: AgentAvatarProfile | null;
+435 -25
View File
@@ -7,6 +7,8 @@ import {
type GatewayHelloOk,
} from "./openclaw/GatewayBrowserClient";
import type {
StudioGatewayProfilePublic,
StudioGatewayAdapterType,
StudioGatewaySettings,
StudioSettings,
StudioSettingsPatch,
@@ -20,6 +22,18 @@ import { resolveStudioProxyGatewayUrl } from "@/lib/gateway/proxy-url";
import { ensureGatewayReloadModeHotForLocalStudio } from "@/lib/gateway/gatewayReloadMode";
import { GatewayResponseError } from "@/lib/gateway/errors";
const gatewayDebugEnabled = process.env.NODE_ENV !== "production";
const gatewayDebugLog = (message: string, details?: Record<string, unknown>) => {
if (!gatewayDebugEnabled) return;
if (details) {
console.info("[gateway-client]", message, details);
return;
}
console.info("[gateway-client]", message);
};
import { probeCustomRuntime } from "@/lib/runtime/custom/http";
export type ReqFrame = {
type: "req";
id: string;
@@ -82,7 +96,7 @@ export const isSameSessionKey = (a: string, b: string) => {
};
const CONNECT_FAILED_CLOSE_CODE = 4008;
const GATEWAY_CONNECT_TIMEOUT_MS = 8_000;
const GATEWAY_CONNECT_TIMEOUT_MS = 13_000;
const parseConnectFailedCloseReason = (
reason: string
@@ -100,10 +114,67 @@ const parseConnectFailedCloseReason = (
const DEFAULT_UPSTREAM_GATEWAY_URL =
process.env.NEXT_PUBLIC_GATEWAY_URL || "ws://localhost:18789";
const DEFAULT_CUSTOM_RUNTIME_URL = "http://localhost:7770";
const INITIAL_AUTO_CONNECT_DELAY_MS = 900;
const INITIAL_CONNECT_RETRY_DELAY_MS = 1_200;
const isAutoManagedAdapter = (adapterType: StudioGatewayAdapterType) =>
adapterType !== "custom";
export const resolveInitialGatewayAutoConnectDelayMs = (
adapterType: StudioGatewayAdapterType
): number => {
switch (adapterType) {
case "hermes":
case "demo":
return INITIAL_AUTO_CONNECT_DELAY_MS;
default:
return 0;
}
};
export const resolveInitialGatewayConnectAttemptCount = (
adapterType: StudioGatewayAdapterType,
hasConnectedOnce: boolean
): number => {
switch (adapterType) {
case "hermes":
case "demo":
return 2;
default:
if (hasConnectedOnce) return 1;
return 1;
}
};
const resolveDefaultGatewayProfile = (
adapterType: StudioGatewayAdapterType,
localDefaults: StudioGatewaySettings | null
): { url: string; token: string } => {
switch (adapterType) {
case "custom":
return { url: DEFAULT_CUSTOM_RUNTIME_URL, token: "" };
case "demo":
case "hermes":
return { url: "ws://localhost:18789", token: "" };
case "openclaw":
default:
return {
url: localDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL,
token: localDefaults?.token ?? "",
};
}
};
const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown; token?: unknown; tokenConfigured?: unknown };
const raw = value as {
url?: unknown;
token?: unknown;
tokenConfigured?: unknown;
adapterType?: unknown;
profiles?: unknown;
};
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return null;
// Accept both full settings ({ url, token }) and the sanitized public
@@ -111,7 +182,40 @@ const normalizeLocalGatewayDefaults = (value: unknown): StudioGatewaySettings |
// tokenConfigured is present the actual token isn't available on the
// client — leave it empty so the connection dialog can prompt if needed.
const token = typeof raw.token === "string" ? raw.token.trim() : "";
return { url, token };
const adapterType =
raw.adapterType === "demo" ||
raw.adapterType === "hermes" ||
raw.adapterType === "openclaw" ||
raw.adapterType === "custom"
? raw.adapterType
: "openclaw";
const profiles = normalizeGatewayProfilesPublic(raw.profiles);
return { url, token, adapterType, ...(profiles ? { profiles } : {}) };
};
const normalizeGatewayProfilePublic = (
value: unknown
): { url: string; token: string } | null => {
if (!value || typeof value !== "object") return null;
const raw = value as { url?: unknown };
const url = typeof raw.url === "string" ? raw.url.trim() : "";
if (!url) return null;
return { url, token: "" };
};
const normalizeGatewayProfilesPublic = (
value: unknown
): Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> | undefined => {
if (!value || typeof value !== "object") return undefined;
const raw = value as Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePublic>>;
const profiles: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>> = {};
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
const profile = normalizeGatewayProfilePublic(raw[adapterType]);
if (profile) {
profiles[adapterType] = profile;
}
}
return Object.keys(profiles).length > 0 ? profiles : undefined;
};
type StatusHandler = (status: GatewayStatus) => void;
@@ -419,14 +523,30 @@ export const syncGatewaySessionSettings = async ({
const doctorFixHint =
"Run `npx openclaw doctor --fix` on the gateway host (or `pnpm openclaw doctor --fix` in a source checkout).";
const protocolMismatchHint =
"This gateway looks too old for Claw3D's protocol v3. Upgrade OpenClaw, use the Hermes adapter, or run `npm run demo-gateway` for a no-framework office demo.";
const isGatewayProtocolMismatchError = (error: GatewayResponseError) => {
if (error.code.trim().toUpperCase() !== "INVALID_REQUEST") return false;
const message = error.message.trim();
if (!message) return false;
return /minProtocol|maxProtocol/i.test(message);
};
const formatGatewayError = (error: unknown) => {
if (error instanceof GatewayResponseError) {
if (isGatewayProtocolMismatchError(error)) {
return `Gateway error (${error.code}): ${error.message}. ${protocolMismatchHint}`;
}
if (error.code === "INVALID_REQUEST" && /invalid config/i.test(error.message)) {
return `Gateway error (${error.code}): ${error.message}. ${doctorFixHint}`;
}
return `Gateway error (${error.code}): ${error.message}`;
}
if (error instanceof Error) {
if (/timed out connecting to the gateway/i.test(error.message)) {
return `${error.message} If you are testing locally, an older OpenClaw build may be speaking an incompatible protocol. Try upgrading OpenClaw, using the Hermes adapter, or running \`npm run demo-gateway\`.`;
}
return error.message;
}
return "Unknown gateway error.";
@@ -437,6 +557,9 @@ export type GatewayConnectionState = {
status: GatewayStatus;
gatewayUrl: string;
token: string;
selectedAdapterType: StudioGatewayAdapterType;
detectedAdapterType: StudioGatewayAdapterType | null;
activeAdapterType: StudioGatewayAdapterType;
localGatewayDefaults: StudioGatewaySettings | null;
error: string | null;
connectPromptReady: boolean;
@@ -446,6 +569,7 @@ export type GatewayConnectionState = {
useLocalGatewayDefaults: () => void;
setGatewayUrl: (value: string) => void;
setToken: (value: string) => void;
setSelectedAdapterType: (value: StudioGatewayAdapterType) => void;
clearError: () => void;
};
@@ -523,13 +647,27 @@ export const useGatewayConnection = (
const [client] = useState(() => new GatewayClient());
const didAutoConnect = useRef(false);
const hasConnectedOnceRef = useRef(false);
const loadedGatewaySettings = useRef<{ gatewayUrl: string; token: string } | null>(null);
const loadedGatewaySettings = useRef<{
gatewayUrl: string;
token: string;
adapterType: StudioGatewayAdapterType;
profiles?: Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>;
hasLastKnownGood: boolean;
} | null>(null);
const retryAttemptRef = useRef(0);
const retryTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const autoConnectTimerRef = useRef<number | null>(null);
const wasManualDisconnectRef = useRef(false);
const [gatewayUrl, setGatewayUrl] = useState(DEFAULT_UPSTREAM_GATEWAY_URL);
const [token, setToken] = useState("");
const [selectedAdapterType, setSelectedAdapterTypeState] =
useState<StudioGatewayAdapterType>("openclaw");
const [adapterProfiles, setAdapterProfiles] = useState<
Partial<Record<StudioGatewayAdapterType, { url: string; token: string }>>
>({});
const [detectedAdapterType, setDetectedAdapterType] =
useState<StudioGatewayAdapterType | null>(null);
const [localGatewayDefaults, setLocalGatewayDefaults] = useState<StudioGatewaySettings | null>(
null
);
@@ -537,6 +675,19 @@ export const useGatewayConnection = (
const [error, setError] = useState<string | null>(null);
const [connectErrorCode, setConnectErrorCode] = useState<string | null>(null);
const [settingsLoaded, setSettingsLoaded] = useState(false);
const [hasLastKnownGoodState, setHasLastKnownGoodState] = useState(false);
const setSelectedAdapterType = useCallback(
(value: StudioGatewayAdapterType) => {
setSelectedAdapterTypeState(value);
const profile =
adapterProfiles[value] ?? resolveDefaultGatewayProfile(value, localGatewayDefaults);
setGatewayUrl(profile.url);
setToken(profile.token);
setError(null);
setConnectErrorCode(null);
},
[adapterProfiles, localGatewayDefaults]
);
useEffect(() => {
let cancelled = false;
@@ -554,26 +705,92 @@ export const useGatewayConnection = (
if (cancelled) return;
const normalizedDefaults = normalizeLocalGatewayDefaults(envelope.localGatewayDefaults);
setLocalGatewayDefaults(normalizedDefaults);
const lastKnownGood =
gateway && "lastKnownGood" in gateway && gateway.lastKnownGood
? {
url:
typeof gateway.lastKnownGood.url === "string"
? gateway.lastKnownGood.url
: "",
token:
"token" in gateway.lastKnownGood &&
typeof gateway.lastKnownGood.token === "string"
? gateway.lastKnownGood.token
: "",
adapterType:
gateway.lastKnownGood.adapterType === "demo" ||
gateway.lastKnownGood.adapterType === "hermes" ||
gateway.lastKnownGood.adapterType === "openclaw" ||
gateway.lastKnownGood.adapterType === "custom"
? gateway.lastKnownGood.adapterType
: "openclaw",
}
: null;
// When the user has no saved gateway URL, prefer the runtime
// localGatewayDefaults (from openclaw.json / CLAW3D_GATEWAY_URL)
// over the build-time NEXT_PUBLIC_GATEWAY_URL which may be stale
// or empty if the operator forgot to rebuild after .env changes.
const hasSavedUrl = Boolean(gateway?.url?.trim());
const savedAdapterType =
hasSavedUrl && gateway && "adapterType" in gateway && typeof gateway.adapterType === "string"
? ((gateway.adapterType === "demo" ||
gateway.adapterType === "hermes" ||
gateway.adapterType === "openclaw" ||
gateway.adapterType === "custom"
? gateway.adapterType
: "openclaw") as StudioGatewayAdapterType)
: null;
const nextAdapterType =
savedAdapterType ??
lastKnownGood?.adapterType ??
normalizedDefaults?.adapterType ??
"openclaw";
const lastKnownGoodForSelectedAdapter =
lastKnownGood?.adapterType === nextAdapterType ? lastKnownGood : null;
const resolvedUrl = hasSavedUrl
? gateway!.url
: normalizedDefaults?.url || DEFAULT_UPSTREAM_GATEWAY_URL;
const nextGatewayUrl = resolvedUrl;
const nextToken = hasSavedUrl
? (gateway && "token" in gateway && typeof gateway.token === "string"
? gateway.token
: "")
: normalizedDefaults?.token ?? "";
: lastKnownGoodForSelectedAdapter?.url ||
normalizedDefaults?.url ||
DEFAULT_UPSTREAM_GATEWAY_URL;
const baseProfiles = {
...(gateway?.profiles
? normalizeGatewayProfilesPublic(gateway.profiles)
: undefined),
...(normalizedDefaults?.profiles ?? {}),
};
const mergedProfiles = {
...baseProfiles,
...(hasSavedUrl
? {
[nextAdapterType]: {
url: resolvedUrl,
token:
gateway && "token" in gateway && typeof gateway.token === "string"
? gateway.token
: "",
},
}
: {}),
};
const selectedProfile = (
mergedProfiles[nextAdapterType] ??
lastKnownGoodForSelectedAdapter ??
resolveDefaultGatewayProfile(nextAdapterType, normalizedDefaults)
);
const nextGatewayUrl = selectedProfile.url;
const nextToken = selectedProfile.token;
loadedGatewaySettings.current = {
gatewayUrl: nextGatewayUrl.trim(),
token: nextToken,
adapterType: nextAdapterType,
profiles: mergedProfiles,
hasLastKnownGood: Boolean(lastKnownGoodForSelectedAdapter?.url),
};
setGatewayUrl(nextGatewayUrl);
setToken(nextToken);
setSelectedAdapterTypeState(nextAdapterType);
setAdapterProfiles(mergedProfiles);
setHasLastKnownGoodState(Boolean(lastKnownGoodForSelectedAdapter?.url));
} catch (err) {
if (!cancelled) {
const message = err instanceof Error ? err.message : "Failed to load gateway settings.";
@@ -585,6 +802,9 @@ export const useGatewayConnection = (
loadedGatewaySettings.current = {
gatewayUrl: DEFAULT_UPSTREAM_GATEWAY_URL.trim(),
token: "",
adapterType: "openclaw",
profiles: undefined,
hasLastKnownGood: false,
};
}
setSettingsLoaded(true);
@@ -599,11 +819,14 @@ export const useGatewayConnection = (
useEffect(() => {
return client.onStatus((nextStatus) => {
gatewayDebugLog("status", { nextStatus });
setStatus(nextStatus);
if (nextStatus !== "connecting") {
setError(null);
if (nextStatus === "connected") {
setConnectErrorCode(null);
} else {
setDetectedAdapterType(null);
}
}
});
@@ -611,6 +834,10 @@ export const useGatewayConnection = (
useEffect(() => {
return () => {
if (autoConnectTimerRef.current) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
@@ -620,35 +847,145 @@ export const useGatewayConnection = (
}, [client]);
const connect = useCallback(async () => {
if (autoConnectTimerRef.current) {
clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
if (retryTimerRef.current) {
clearTimeout(retryTimerRef.current);
retryTimerRef.current = null;
}
gatewayDebugLog("connect:start", {
selectedAdapterType,
gatewayUrl,
hasToken: Boolean(token),
});
setError(null);
setConnectErrorCode(null);
retryAttemptRef.current = 0;
wasManualDisconnectRef.current = false;
if (selectedAdapterType === "custom") {
setStatus("connecting");
try {
await settingsCoordinator.flushPending();
await probeCustomRuntime(gatewayUrl);
setDetectedAdapterType("custom");
setStatus("connected");
setConnectErrorCode(null);
retryAttemptRef.current = 0;
gatewayDebugLog("connect:custom-success", { gatewayUrl });
} catch (err) {
setStatus("disconnected");
setDetectedAdapterType(null);
setConnectErrorCode("studio.custom_runtime_probe_failed");
setError(formatGatewayError(err));
gatewayDebugLog("connect:custom-failed", {
message: err instanceof Error ? err.message : String(err),
});
}
return;
}
try {
await settingsCoordinator.flushPending();
await client.connect({
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
clientName: "openclaw-control-ui",
});
const maxAttempts = resolveInitialGatewayConnectAttemptCount(
selectedAdapterType,
hasConnectedOnceRef.current
);
let lastError: unknown = null;
for (let attempt = 0; attempt < maxAttempts; attempt += 1) {
try {
await client.connect({
gatewayUrl: resolveStudioProxyGatewayUrl(),
token,
authScopeKey: gatewayUrl,
clientName: "openclaw-control-ui",
disableDeviceAuth: selectedAdapterType !== "openclaw",
});
lastError = null;
break;
} catch (err) {
lastError = err;
gatewayDebugLog("connect:attempt-failed", {
selectedAdapterType,
attempt: attempt + 1,
maxAttempts,
message: err instanceof Error ? err.message : String(err),
});
if (attempt + 1 >= maxAttempts) {
throw err;
}
client.disconnect();
await new Promise<void>((resolve) => {
window.setTimeout(resolve, INITIAL_CONNECT_RETRY_DELAY_MS);
});
}
}
if (lastError) {
throw lastError;
}
await ensureGatewayReloadModeHotForLocalStudio({
client,
upstreamGatewayUrl: gatewayUrl,
});
const hello = client.getLastHello();
const nextDetectedAdapterType =
hello?.adapterType === "demo" ||
hello?.adapterType === "hermes" ||
hello?.adapterType === "openclaw" ||
hello?.adapterType === "custom"
? hello.adapterType
: "openclaw";
setDetectedAdapterType(nextDetectedAdapterType);
retryAttemptRef.current = 0;
setHasLastKnownGoodState(nextDetectedAdapterType === selectedAdapterType);
settingsCoordinator.schedulePatch({
gateway: {
lastKnownGood: {
url: gatewayUrl.trim(),
token,
adapterType: nextDetectedAdapterType,
},
},
});
gatewayDebugLog("connect:success", {
selectedAdapterType,
detectedAdapterType: nextDetectedAdapterType,
});
} catch (err) {
setConnectErrorCode(err instanceof GatewayResponseError ? err.code : null);
setError(formatGatewayError(err));
gatewayDebugLog("connect:failed", {
selectedAdapterType,
code: err instanceof GatewayResponseError ? err.code : null,
message: err instanceof Error ? err.message : String(err),
});
}
}, [client, gatewayUrl, settingsCoordinator, token]);
}, [client, gatewayUrl, selectedAdapterType, settingsCoordinator, token]);
useEffect(() => {
if (didAutoConnect.current) return;
if (!settingsLoaded) return;
if (!hasLastKnownGoodState) return;
if (!gatewayUrl.trim()) return;
if (!isAutoManagedAdapter(selectedAdapterType)) return;
didAutoConnect.current = true;
void connect();
}, [connect, gatewayUrl, settingsLoaded]);
const delayMs = resolveInitialGatewayAutoConnectDelayMs(selectedAdapterType);
gatewayDebugLog("auto-connect", {
selectedAdapterType,
gatewayUrl,
delayMs,
});
autoConnectTimerRef.current = window.setTimeout(() => {
autoConnectTimerRef.current = null;
void connect();
}, delayMs);
return () => {
if (autoConnectTimerRef.current) {
window.clearTimeout(autoConnectTimerRef.current);
autoConnectTimerRef.current = null;
}
};
}, [connect, gatewayUrl, hasLastKnownGoodState, selectedAdapterType, settingsLoaded]);
// Auto-retry on disconnect (gateway busy, network blip, etc.)
useEffect(() => {
@@ -663,9 +1000,21 @@ export const useGatewayConnection = (
connectErrorCode,
attempt,
});
if (!isAutoManagedAdapter(selectedAdapterType)) return;
if (delay === null) return;
gatewayDebugLog("auto-retry-scheduled", {
selectedAdapterType,
attempt: attempt + 1,
delay,
gatewayUrl,
status,
});
retryTimerRef.current = setTimeout(() => {
retryAttemptRef.current = attempt + 1;
gatewayDebugLog("auto-retry-fire", {
selectedAdapterType,
attempt: retryAttemptRef.current,
});
void connect();
}, delay);
@@ -675,7 +1024,7 @@ export const useGatewayConnection = (
retryTimerRef.current = null;
}
};
}, [connect, connectErrorCode, error, gatewayUrl, status]);
}, [connect, connectErrorCode, error, gatewayUrl, selectedAdapterType, status]);
// Reset retry count on successful connection
useEffect(() => {
@@ -685,12 +1034,46 @@ export const useGatewayConnection = (
}
}, [status]);
useEffect(() => {
if (!settingsLoaded) return;
setAdapterProfiles((current) => {
const nextProfile = {
url: gatewayUrl.trim(),
token,
};
const existing = current[selectedAdapterType];
if (
existing &&
existing.url === nextProfile.url &&
existing.token === nextProfile.token
) {
return current;
}
return {
...current,
[selectedAdapterType]: nextProfile,
};
});
}, [gatewayUrl, selectedAdapterType, settingsLoaded, token]);
useEffect(() => {
if (!settingsLoaded) return;
const baseline = loadedGatewaySettings.current;
if (!baseline) return;
const nextGatewayUrl = gatewayUrl.trim();
if (nextGatewayUrl === baseline.gatewayUrl && token === baseline.token) {
const nextProfiles = {
...adapterProfiles,
[selectedAdapterType]: {
url: nextGatewayUrl,
token,
},
};
if (
nextGatewayUrl === baseline.gatewayUrl &&
token === baseline.token &&
selectedAdapterType === baseline.adapterType &&
JSON.stringify(nextProfiles) === JSON.stringify(baseline.profiles ?? {})
) {
return;
}
settingsCoordinator.schedulePatch(
@@ -698,11 +1081,13 @@ export const useGatewayConnection = (
gateway: {
url: nextGatewayUrl,
token,
adapterType: selectedAdapterType,
profiles: nextProfiles,
},
},
400
);
}, [gatewayUrl, settingsCoordinator, settingsLoaded, token]);
}, [adapterProfiles, gatewayUrl, selectedAdapterType, settingsCoordinator, settingsLoaded, token]);
const useLocalGatewayDefaults = useCallback(() => {
if (!localGatewayDefaults) {
@@ -710,17 +1095,31 @@ export const useGatewayConnection = (
}
setGatewayUrl(localGatewayDefaults.url);
setToken(localGatewayDefaults.token);
setAdapterProfiles((current) => ({
...current,
[localGatewayDefaults.adapterType]: {
url: localGatewayDefaults.url,
token: localGatewayDefaults.token,
},
}));
setSelectedAdapterTypeState(localGatewayDefaults.adapterType);
setError(null);
setConnectErrorCode(null);
}, [localGatewayDefaults]);
const disconnect = useCallback(() => {
gatewayDebugLog("disconnect", { selectedAdapterType });
setError(null);
setConnectErrorCode(null);
wasManualDisconnectRef.current = true;
setDetectedAdapterType(null);
if (selectedAdapterType === "custom") {
setStatus("disconnected");
return;
}
client.disconnect();
clearGatewayBrowserSessionStorage();
}, [client]);
}, [client, selectedAdapterType]);
const clearError = useCallback(() => {
setError(null);
@@ -728,16 +1127,26 @@ export const useGatewayConnection = (
}, []);
const connectPromptReady = settingsLoaded;
const activeAdapterType =
status === "connected" ? detectedAdapterType ?? selectedAdapterType : selectedAdapterType;
const shouldPromptForConnect =
settingsLoaded &&
status !== "connected" &&
(!gatewayUrl.trim() || !token.trim() || wasManualDisconnectRef.current || Boolean(error));
(selectedAdapterType === "custom" ||
!hasLastKnownGoodState ||
!gatewayUrl.trim() ||
(selectedAdapterType === "openclaw" && !token.trim()) ||
wasManualDisconnectRef.current ||
Boolean(error));
return {
client,
status,
gatewayUrl,
token,
selectedAdapterType,
detectedAdapterType,
activeAdapterType,
localGatewayDefaults,
error,
connectPromptReady,
@@ -747,6 +1156,7 @@ export const useGatewayConnection = (
useLocalGatewayDefaults,
setGatewayUrl,
setToken,
setSelectedAdapterType,
clearError,
};
};
@@ -6,6 +6,21 @@
import { getPublicKeyAsync, signAsync, utils } from "@noble/ed25519";
import { GatewayResponseError } from "@/lib/gateway/errors";
const gatewayBrowserDebugEnabled =
process.env.NODE_ENV !== "production";
const gatewayBrowserDebugLog = (
message: string,
details?: Record<string, unknown>
) => {
if (!gatewayBrowserDebugEnabled) return;
if (details) {
console.info("[gateway-browser]", message, details);
return;
}
console.info("[gateway-browser]", message);
};
const GATEWAY_CLIENT_NAMES = {
CONTROL_UI: "openclaw-control-ui",
} as const;
@@ -350,6 +365,7 @@ export type GatewayResponseFrame = {
export type GatewayHelloOk = {
type: "hello-ok";
protocol: number;
adapterType?: "openclaw" | "hermes" | "demo" | "custom";
features?: { methods?: string[]; events?: string[] };
snapshot?: unknown;
auth?: {
@@ -415,11 +431,19 @@ export class GatewayBrowserClient {
start() {
this.closed = false;
gatewayBrowserDebugLog("start", {
url: this.opts.url,
authScopeKey: this.opts.authScopeKey ?? null,
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
clientName: this.opts.clientName ?? null,
mode: this.opts.mode ?? null,
});
this.connect();
}
stop() {
this.closed = true;
gatewayBrowserDebugLog("stop");
this.ws?.close();
this.ws = null;
this.flushPending(new Error("gateway client stopped"));
@@ -431,18 +455,23 @@ export class GatewayBrowserClient {
private connect() {
if (this.closed) return;
gatewayBrowserDebugLog("connect:open-socket", { url: this.opts.url });
this.ws = new WebSocket(this.opts.url);
this.ws.onopen = () => this.queueConnect();
this.ws.onopen = () => {
gatewayBrowserDebugLog("socket:open");
this.queueConnect();
};
this.ws.onmessage = (ev) => this.handleMessage(String(ev.data ?? ""));
this.ws.onclose = (ev) => {
const reason = String(ev.reason ?? "");
gatewayBrowserDebugLog("socket:close", { code: ev.code, reason });
this.ws = null;
this.flushPending(new Error(`gateway closed (${ev.code}): ${reason}`));
this.opts.onClose?.({ code: ev.code, reason });
this.scheduleReconnect();
};
this.ws.onerror = () => {
// ignored; close handler will fire
gatewayBrowserDebugLog("socket:error");
};
}
@@ -450,6 +479,7 @@ export class GatewayBrowserClient {
if (this.closed) return;
const delay = this.backoffMs;
this.backoffMs = Math.min(this.backoffMs * 1.7, 15_000);
gatewayBrowserDebugLog("schedule-reconnect", { delay });
window.setTimeout(() => this.connect(), delay);
}
@@ -468,6 +498,12 @@ export class GatewayBrowserClient {
const isSecureContext =
!this.opts.disableDeviceAuth && typeof crypto !== "undefined" && !!crypto.subtle;
gatewayBrowserDebugLog("send-connect", {
url: this.opts.url,
disableDeviceAuth: Boolean(this.opts.disableDeviceAuth),
hasNonce: Boolean(this.connectNonce),
isSecureContext,
});
const scopes = ["operator.admin", "operator.approvals", "operator.pairing"];
const role = "operator";
@@ -547,6 +583,10 @@ export class GatewayBrowserClient {
void this.request<GatewayHelloOk>("connect", params)
.then((hello) => {
gatewayBrowserDebugLog("hello-ok", {
protocol: hello?.protocol ?? null,
hasAuthToken: Boolean(hello?.auth?.deviceToken),
});
if (hello?.auth?.deviceToken && deviceIdentity) {
storeDeviceAuthToken({
deviceId: deviceIdentity.deviceId,
@@ -560,6 +600,9 @@ export class GatewayBrowserClient {
this.opts.onHello?.(hello);
})
.catch((err) => {
gatewayBrowserDebugLog("connect-failed", {
message: err instanceof Error ? err.message : String(err),
});
if (canFallbackToShared && deviceIdentity) {
clearDeviceAuthToken({ deviceId: deviceIdentity.deviceId, role, scope: authScopeKey });
}
@@ -587,6 +630,7 @@ export class GatewayBrowserClient {
if (frame.type === "event") {
const evt = parsed as GatewayEventFrame;
if (evt.event === "connect.challenge") {
gatewayBrowserDebugLog("connect-challenge");
const payload = evt.payload as { nonce?: unknown } | undefined;
const nonce = payload && typeof payload.nonce === "string" ? payload.nonce : null;
if (nonce) {
@@ -650,6 +694,7 @@ export class GatewayBrowserClient {
this.connectNonce = null;
this.connectSent = false;
if (this.connectTimer !== null) window.clearTimeout(this.connectTimer);
gatewayBrowserDebugLog("queue-connect", { delayMs: 750 });
this.connectTimer = window.setTimeout(() => {
void this.sendConnect();
}, 750);
+24
View File
@@ -0,0 +1,24 @@
import { CustomRuntimeProvider } from "@/lib/runtime/custom/provider";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { DemoRuntimeProvider } from "@/lib/runtime/demo/provider";
import { HermesRuntimeProvider } from "@/lib/runtime/hermes/provider";
import { OpenClawRuntimeProvider } from "@/lib/runtime/openclaw/provider";
import type { RuntimeProvider } from "@/lib/runtime/types";
export const createRuntimeProvider = (
providerId: RuntimeProvider["id"],
client: GatewayClient,
runtimeUrl: string
): RuntimeProvider => {
switch (providerId) {
case "custom":
return new CustomRuntimeProvider(client, runtimeUrl);
case "demo":
return new DemoRuntimeProvider(client);
case "hermes":
return new HermesRuntimeProvider(client);
case "openclaw":
default:
return new OpenClawRuntimeProvider(client);
}
};
+66
View File
@@ -0,0 +1,66 @@
export const normalizeCustomBaseUrl = (value: string): string => {
const trimmed = value.trim();
if (!trimmed) return "";
try {
const parsed = new URL(trimmed);
if (parsed.protocol === "ws:") {
parsed.protocol = "http:";
} else if (parsed.protocol === "wss:") {
parsed.protocol = "https:";
}
return parsed.toString().replace(/\/$/, "");
} catch {
return trimmed.replace(/\/$/, "");
}
};
type CustomRuntimeProxyInput = {
runtimeUrl: string;
pathname: string;
method?: "GET" | "POST";
body?: unknown;
};
export async function requestCustomRuntime<T = unknown>({
runtimeUrl,
pathname,
method = "GET",
body,
}: CustomRuntimeProxyInput): Promise<T> {
const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl);
if (!normalizedRuntimeUrl) {
throw new Error("Custom runtime URL is not configured.");
}
const response = await fetch("/api/runtime/custom", {
method: "POST",
headers: {
"Content-Type": "application/json",
Accept: "application/json",
},
cache: "no-store",
body: JSON.stringify({
runtimeUrl: normalizedRuntimeUrl,
pathname,
method,
body,
}),
});
if (!response.ok) {
const text = await response.text();
throw new Error(
text.trim() || `Custom runtime request failed (${response.status}) for ${pathname}.`
);
}
return (await response.json()) as T;
}
export async function fetchCustomRuntimeJson<T = unknown>(
runtimeUrl: string,
pathname: string
): Promise<T> {
return requestCustomRuntime<T>({ runtimeUrl, pathname, method: "GET" });
}
export async function probeCustomRuntime(runtimeUrl: string): Promise<void> {
await fetchCustomRuntimeJson(runtimeUrl, "/health");
}
+614
View File
@@ -0,0 +1,614 @@
import type {
EventFrame,
GatewayConnectOptions,
GatewayGapInfo,
GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import {
buildAgentMainSessionKey,
parseAgentIdFromSessionKey,
} from "@/lib/gateway/GatewayClient";
import {
fetchCustomRuntimeJson,
normalizeCustomBaseUrl,
requestCustomRuntime,
} from "@/lib/runtime/custom/http";
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
const CUSTOM_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
"agents",
"sessions",
"chat",
"models",
"agent-roles",
]);
type CustomRuntimeStateResponse = {
profileName?: string | null;
registry_profile?: string | null;
active?: Record<string, unknown> | null;
profile?: string | null;
identity?: {
name?: string | null;
role?: string | null;
lane?: string | null;
model_id?: string | null;
} | null;
runtime?: {
name?: string | null;
version?: string | null;
vendor?: string | null;
status?: string | null;
active_model?: string | null;
governance?: string | null;
} | null;
[key: string]: unknown;
};
type CustomRuntimeRegistryResponse = {
models?: Record<string, unknown> | null;
[key: string]: unknown;
};
type CustomRuntimeHealthResponse = {
ok?: boolean;
status?: string;
[key: string]: unknown;
};
type SyntheticAgent = {
id: string;
name: string;
role: string | null;
};
type SessionMessage = {
role: "user" | "assistant";
text: string;
timestamp: number;
};
type SessionRecord = {
sessionKey: string;
agentId: string;
role: string | null;
model: string | null;
updatedAt: number | null;
messages: SessionMessage[];
};
type ActiveRunRecord = {
runId: string;
sessionKey: string;
controller: AbortController;
};
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const titleCase = (value: string): string =>
value
.split(/[-_\s]+/)
.filter(Boolean)
.map((part) => part.slice(0, 1).toUpperCase() + part.slice(1))
.join(" ");
const resolveRouteProfile = (state: CustomRuntimeStateResponse | null): string | null => {
if (!state) return null;
if (typeof state.profileName === "string" && state.profileName.trim()) return state.profileName.trim();
if (typeof state.registry_profile === "string" && state.registry_profile.trim()) {
return state.registry_profile.trim();
}
if (typeof state.profile === "string" && state.profile.trim()) return state.profile.trim();
return null;
};
const extractContentText = (content: unknown): string => {
if (typeof content === "string") return content.trim();
if (Array.isArray(content)) {
return content
.map((item) => {
if (typeof item === "string") return item;
if (isRecord(item) && typeof item.text === "string") return item.text;
return "";
})
.join("")
.trim();
}
return "";
};
const resolveAssistantTextFromResponse = (payload: unknown): string | null => {
if (!isRecord(payload)) return null;
const choices = Array.isArray(payload.choices) ? payload.choices : [];
const first = choices[0];
if (!isRecord(first)) return null;
const message = isRecord(first.message) ? first.message : null;
const direct = extractContentText(message?.content);
if (direct) return direct;
const text = extractContentText(first.text);
return text || null;
};
const normalizeModelChoices = (registry: CustomRuntimeRegistryResponse | null): string[] => {
if (!registry || !isRecord(registry.models)) return [];
return Object.keys(registry.models).map((value) => value.trim()).filter(Boolean);
};
const resolveOptionalString = (value: unknown): string | null =>
typeof value === "string" && value.trim() ? value.trim() : null;
const resolveDefaultModelId = (
state: CustomRuntimeStateResponse | null,
modelChoices: string[]
): string | null => {
return (
resolveOptionalString(state?.identity?.model_id) ??
resolveOptionalString(state?.runtime?.active_model) ??
modelChoices[0] ??
null
);
};
const buildIdentityAgent = (
state: CustomRuntimeStateResponse | null,
runtimeName: string
): SyntheticAgent | null => {
const name = resolveOptionalString(state?.identity?.name);
const role = resolveOptionalString(state?.identity?.role) ?? "assistant";
const lane = resolveOptionalString(state?.identity?.lane);
if (!name && !lane && !role) return null;
return {
id: lane ?? role ?? "main",
name: name ?? titleCase(lane ?? runtimeName),
role,
};
};
const buildChatFailureMessage = (
statusCode: number,
responseText: string,
health: CustomRuntimeHealthResponse | null
): string => {
const trimmed = responseText.trim();
if (trimmed) return trimmed;
const healthStatus = resolveOptionalString(health?.status);
if (healthStatus) {
return `Custom runtime chat failed (${statusCode}). Runtime health is ${healthStatus}.`;
}
return `Custom runtime chat failed (${statusCode}).`;
};
const buildSyntheticAgents = (
state: CustomRuntimeStateResponse | null,
runtimeName: string
): SyntheticAgent[] => {
const active = isRecord(state?.active) ? state.active : null;
if (active) {
const agents: SyntheticAgent[] = [];
for (const [roleKey, value] of Object.entries(active)) {
const role = roleKey.trim();
if (!role) continue;
const hasModels =
(typeof value === "string" && value.trim()) ||
(Array.isArray(value) && value.some((entry) => typeof entry === "string" && entry.trim()));
if (!hasModels) continue;
agents.push({
id: role,
name: titleCase(role),
role,
});
}
if (agents.length > 0) {
return agents;
}
}
const identityAgent = buildIdentityAgent(state, runtimeName);
if (identityAgent) {
return [identityAgent];
}
return [
{
id: "main",
name: runtimeName,
role: "assistant",
},
];
};
export class CustomRuntimeProvider implements RuntimeProvider {
readonly id = "custom" as const;
readonly label = "Custom";
readonly capabilities = CUSTOM_RUNTIME_CAPABILITIES;
readonly metadata;
private readonly baseUrl: string;
private readonly sessions = new Map<string, SessionRecord>();
private readonly activeRunsByRunId = new Map<string, ActiveRunRecord>();
private readonly activeRunIdBySessionKey = new Map<string, string>();
constructor(
readonly client: GatewayClient,
runtimeUrl: string
) {
this.baseUrl = normalizeCustomBaseUrl(runtimeUrl);
this.metadata = {
id: this.id,
label: this.label,
runtimeName: "Custom Runtime",
routeProfile: null,
};
}
connect(options: GatewayConnectOptions): Promise<void> {
return this.client.connect(options);
}
disconnect(): void {
this.client.disconnect();
}
async call<T = unknown>(method: string, params: unknown): Promise<T> {
switch (method) {
case "agents.list":
return (await this.callAgentsList()) as T;
case "sessions.list":
return (await this.callSessionsList(params)) as T;
case "status":
return (await this.callStatus()) as T;
case "models.list":
return (await this.callModelsList()) as T;
case "sessions.preview":
return (await this.callSessionsPreview(params)) as T;
case "chat.history":
return (await this.callChatHistory(params)) as T;
case "chat.send":
return (await this.callChatSend(params)) as T;
case "chat.abort":
return (await this.callChatAbort(params)) as T;
case "sessions.reset":
return (await this.callSessionsReset(params)) as T;
case "agent.wait":
return (await this.callAgentWait(params)) as T;
case "exec.approvals.get":
return ({ file: { agents: {} } } as T);
case "config.get":
case "config.patch":
case "config.set":
throw new Error(`Custom runtime does not support ${method}.`);
default:
throw new Error(`Custom runtime does not implement ${method}.`);
}
}
onStatus(handler: (status: GatewayStatus) => void): () => void {
return this.client.onStatus(handler);
}
onGap(handler: (info: GatewayGapInfo) => void): () => void {
return this.client.onGap(handler);
}
onEvent(handler: (event: EventFrame) => void): () => void {
return this.client.onEvent(handler);
}
onRuntimeEvent(_handler: (event: RuntimeEvent) => void): () => void {
return () => {};
}
async fetchHealth(): Promise<CustomRuntimeHealthResponse> {
return this.fetchJson<CustomRuntimeHealthResponse>("/health");
}
async fetchState(): Promise<CustomRuntimeStateResponse> {
return this.fetchJson<CustomRuntimeStateResponse>("/state");
}
async fetchRegistry(): Promise<CustomRuntimeRegistryResponse> {
return this.fetchJson<CustomRuntimeRegistryResponse>("/registry");
}
async describeRuntime() {
const [health, state, registry] = await Promise.all([
this.fetchHealth().catch(() => null),
this.fetchState().catch(() => null),
this.fetchRegistry().catch(() => null),
]);
const routeProfile = resolveRouteProfile(state);
const runtimeName =
typeof state?.runtime?.name === "string" && state.runtime.name.trim()
? state.runtime.name.trim()
: this.metadata.runtimeName;
const runtimeVersion =
typeof state?.runtime?.version === "string" && state.runtime.version.trim()
? state.runtime.version.trim()
: null;
const vendor =
typeof state?.runtime?.vendor === "string" && state.runtime.vendor.trim()
? state.runtime.vendor.trim()
: null;
return {
metadata: {
...this.metadata,
runtimeName,
runtimeVersion,
vendor,
routeProfile,
},
health,
state,
registry,
};
}
private async callAgentsList() {
const descriptor = await this.describeRuntime();
const runtimeName = descriptor.metadata.runtimeName ?? this.metadata.runtimeName ?? "Custom Runtime";
const agents = buildSyntheticAgents(descriptor.state, runtimeName);
return {
defaultId: agents[0]?.id ?? "main",
mainKey: "main",
scope: "custom",
agents: agents.map((agent) => ({
id: agent.id,
name: agent.name,
role: agent.role,
})),
};
}
private async callSessionsList(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const agentId = typeof params.agentId === "string" ? params.agentId.trim() : "";
const descriptor = await this.describeRuntime();
const modelChoices = normalizeModelChoices(descriptor.registry);
const sessions = agentId
? [this.ensureSession(agentId, agentId, resolveDefaultModelId(descriptor.state, modelChoices))]
: [...this.sessions.values()];
return {
sessions: sessions.map((session) => ({
key: session.sessionKey,
updatedAt: session.updatedAt,
displayName: session.agentId,
origin: {
label: descriptor.metadata.runtimeName ?? "Custom Runtime",
provider: "custom",
},
modelProvider: "custom",
model: session.model,
})),
};
}
private async callStatus() {
return {
sessions: {
recent: [...this.sessions.values()].map((session) => ({
key: session.sessionKey,
updatedAt: session.updatedAt,
})),
byAgent: [...this.sessions.values()].map((session) => ({
agentId: session.agentId,
recent: [
{
key: session.sessionKey,
updatedAt: session.updatedAt,
},
],
})),
},
};
}
private async callModelsList() {
const descriptor = await this.describeRuntime();
const modelIds = normalizeModelChoices(descriptor.registry);
return {
models: modelIds.map((id) => ({
id,
name: id,
provider: "custom",
})),
};
}
private async callSessionsPreview(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const keys = Array.isArray(params.keys)
? params.keys.filter((value): value is string => typeof value === "string")
: [];
return {
ts: Date.now(),
previews: keys.map((key) => {
const session = this.sessions.get(key) ?? null;
const items = session
? session.messages.slice(-8).map((message) => ({
role: message.role,
text: message.text,
timestamp: message.timestamp,
}))
: [];
return {
key,
status: items.length > 0 ? "ok" : "empty",
items,
};
}),
};
}
private async callChatHistory(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
if (!sessionKey) {
throw new Error("Custom runtime requires sessionKey for chat.history.");
}
const session = this.sessions.get(sessionKey) ?? null;
return {
sessionKey,
messages: (session?.messages ?? []).map((message) => ({
role: message.role,
content: message.text,
timestamp: message.timestamp,
})),
};
}
private async callChatSend(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
const message = typeof params.message === "string" ? params.message.trim() : "";
const runId = typeof params.idempotencyKey === "string" ? params.idempotencyKey.trim() : "";
if (!sessionKey || !message) {
throw new Error("Custom runtime requires sessionKey and message for chat.send.");
}
const agentId = parseAgentIdFromSessionKey(sessionKey) ?? "main";
const descriptor = await this.describeRuntime();
const modelChoices = normalizeModelChoices(descriptor.registry);
const session = this.ensureSession(
sessionKey,
agentId,
resolveDefaultModelId(descriptor.state, modelChoices)
);
const resolvedRole =
session.role ??
resolveOptionalString(descriptor.state?.identity?.role) ??
undefined;
const resolvedLane =
resolveOptionalString(descriptor.state?.identity?.lane) ??
session.role ??
undefined;
const controller = new AbortController();
if (runId) {
const activeRun: ActiveRunRecord = { runId, sessionKey, controller };
this.activeRunsByRunId.set(runId, activeRun);
this.activeRunIdBySessionKey.set(sessionKey, runId);
}
const userTimestamp = Date.now();
session.messages.push({
role: "user",
text: message,
timestamp: userTimestamp,
});
session.updatedAt = userTimestamp;
try {
const payload = (await requestCustomRuntime({
runtimeUrl: this.baseUrl,
pathname: "/v1/chat/completions",
method: "POST",
body: {
model: session.model ?? undefined,
stream: false,
role: resolvedRole,
lane: resolvedLane,
conversation_id: sessionKey,
session_id: sessionKey,
messages: session.messages.map((entry) => ({
role: entry.role,
content: entry.text,
})),
},
})) as unknown;
const assistantText = resolveAssistantTextFromResponse(payload);
if (!assistantText) {
throw new Error("Custom runtime returned an empty assistant response.");
}
const assistantTimestamp = Date.now();
session.messages.push({
role: "assistant",
text: assistantText,
timestamp: assistantTimestamp,
});
session.updatedAt = assistantTimestamp;
return {
status: "completed",
runId: runId || null,
text: assistantText,
};
} catch (error) {
const health = await this.fetchHealth().catch(() => null);
throw new Error(
buildChatFailureMessage(
502,
error instanceof Error ? error.message : String(error),
health
)
);
} finally {
if (runId) {
this.activeRunsByRunId.delete(runId);
const activeSessionRunId = this.activeRunIdBySessionKey.get(sessionKey);
if (activeSessionRunId === runId) {
this.activeRunIdBySessionKey.delete(sessionKey);
}
}
}
}
private async callChatAbort(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
const sessionKey = typeof params.sessionKey === "string" ? params.sessionKey.trim() : "";
const targetRunId = runId || (sessionKey ? this.activeRunIdBySessionKey.get(sessionKey) ?? "" : "");
if (!targetRunId) {
return { ok: true };
}
const activeRun = this.activeRunsByRunId.get(targetRunId) ?? null;
activeRun?.controller.abort();
this.activeRunsByRunId.delete(targetRunId);
if (activeRun?.sessionKey) {
const activeSessionRunId = this.activeRunIdBySessionKey.get(activeRun.sessionKey);
if (activeSessionRunId === targetRunId) {
this.activeRunIdBySessionKey.delete(activeRun.sessionKey);
}
}
return { ok: true };
}
private async callSessionsReset(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const key = typeof params.key === "string" ? params.key.trim() : "";
if (!key) {
throw new Error("Custom runtime requires key for sessions.reset.");
}
this.sessions.delete(key);
const activeRunId = this.activeRunIdBySessionKey.get(key);
if (activeRunId) {
this.activeRunsByRunId.get(activeRunId)?.controller.abort();
this.activeRunsByRunId.delete(activeRunId);
this.activeRunIdBySessionKey.delete(key);
}
return { ok: true };
}
private async callAgentWait(rawParams: unknown) {
const params = isRecord(rawParams) ? rawParams : {};
const runId = typeof params.runId === "string" ? params.runId.trim() : "";
return {
status: runId && this.activeRunsByRunId.has(runId) ? "running" : "done",
};
}
private ensureSession(sessionKey: string, agentId: string, model: string | null): SessionRecord {
const existing = this.sessions.get(sessionKey);
if (existing) return existing;
const session: SessionRecord = {
sessionKey,
agentId,
role: agentId || null,
model,
updatedAt: null,
messages: [],
};
this.sessions.set(sessionKey, session);
return session;
}
private async fetchJson<T = unknown>(pathname: string): Promise<T> {
return fetchCustomRuntimeJson<T>(this.baseUrl, pathname);
}
}
+64
View File
@@ -0,0 +1,64 @@
import type {
EventFrame,
GatewayConnectOptions,
GatewayGapInfo,
GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
const DEMO_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
"agents",
"sessions",
"chat",
"streaming",
"approvals",
"config",
"models",
"files",
"agent-roles",
]);
export class DemoRuntimeProvider implements RuntimeProvider {
readonly id = "demo" as const;
readonly label = "Demo";
readonly metadata = {
id: this.id,
label: this.label,
runtimeName: "Demo",
} as const;
readonly capabilities = DEMO_RUNTIME_CAPABILITIES;
constructor(readonly client: GatewayClient) {}
connect(options: GatewayConnectOptions): Promise<void> {
return this.client.connect(options);
}
disconnect(): void {
this.client.disconnect();
}
call<T = unknown>(method: string, params: unknown): Promise<T> {
return this.client.call<T>(method, params);
}
onStatus(handler: (status: GatewayStatus) => void): () => void {
return this.client.onStatus(handler);
}
onGap(handler: (info: GatewayGapInfo) => void): () => void {
return this.client.onGap(handler);
}
onEvent(handler: (event: EventFrame) => void): () => void {
return this.client.onEvent(handler);
}
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
return this.client.onEvent((event) => {
handler(normalizeGatewayEvent(event));
});
}
}
+66
View File
@@ -0,0 +1,66 @@
import type {
EventFrame,
GatewayConnectOptions,
GatewayGapInfo,
GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
const HERMES_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
"agents",
"sessions",
"chat",
"streaming",
"approvals",
"config",
"models",
"skills",
"cron",
"files",
"agent-roles",
]);
export class HermesRuntimeProvider implements RuntimeProvider {
readonly id = "hermes" as const;
readonly label = "Hermes";
readonly metadata = {
id: this.id,
label: this.label,
runtimeName: "Hermes",
} as const;
readonly capabilities = HERMES_RUNTIME_CAPABILITIES;
constructor(readonly client: GatewayClient) {}
connect(options: GatewayConnectOptions): Promise<void> {
return this.client.connect(options);
}
disconnect(): void {
this.client.disconnect();
}
call<T = unknown>(method: string, params: unknown): Promise<T> {
return this.client.call<T>(method, params);
}
onStatus(handler: (status: GatewayStatus) => void): () => void {
return this.client.onStatus(handler);
}
onGap(handler: (info: GatewayGapInfo) => void): () => void {
return this.client.onGap(handler);
}
onEvent(handler: (event: EventFrame) => void): () => void {
return this.client.onEvent(handler);
}
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
return this.client.onEvent((event) => {
handler(normalizeGatewayEvent(event));
});
}
}
@@ -0,0 +1,122 @@
import type { EventFrame } from "@/lib/gateway/GatewayClient";
import type { RuntimeEvent } from "@/lib/runtime/types";
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object" && !Array.isArray(value));
const coerceTimestamp = (value: unknown): number | null => {
if (typeof value === "number" && Number.isFinite(value) && value > 0) {
return value;
}
if (typeof value === "string") {
const parsed = Date.parse(value);
if (Number.isFinite(parsed) && parsed > 0) {
return parsed;
}
}
return null;
};
const resolveTimestamp = (payload: Record<string, unknown> | null): number => {
if (!payload) return Date.now();
return (
coerceTimestamp(payload.timestamp) ??
coerceTimestamp(payload.createdAt) ??
coerceTimestamp(payload.updatedAt) ??
coerceTimestamp(payload.at) ??
Date.now()
);
};
const resolveChatText = (payload: Record<string, unknown> | null): string | null => {
if (!payload) return null;
const message = isRecord(payload.message) ? payload.message : null;
const directText = typeof payload.text === "string" ? payload.text.trim() : "";
if (directText) return directText;
const content = typeof message?.content === "string" ? message.content.trim() : "";
return content || null;
};
export const normalizeGatewayEvent = (frame: EventFrame): RuntimeEvent => {
const payload = isRecord(frame.payload) ? frame.payload : null;
const at = resolveTimestamp(payload);
if (frame.event === "presence" || frame.event === "heartbeat") {
return {
type: "summary-refresh",
at,
frame,
};
}
if (frame.event === "chat") {
const state = typeof payload?.state === "string" ? payload.state.trim() : "";
const runId = typeof payload?.runId === "string" ? payload.runId.trim() || null : null;
const sessionKey =
typeof payload?.sessionKey === "string" ? payload.sessionKey.trim() || null : null;
const text = resolveChatText(payload);
if (state === "delta") {
return {
type: "chat.delta",
at,
frame,
runId,
sessionKey,
text,
};
}
if (state === "final") {
return {
type: "chat.final",
at,
frame,
runId,
sessionKey,
text,
};
}
if (state === "error") {
return {
type: "chat.error",
at,
frame,
runId,
sessionKey,
text,
};
}
if (state === "aborted") {
return {
type: "chat.aborted",
at,
frame,
runId,
sessionKey,
text,
};
}
}
if (frame.event === "agent") {
const stream = typeof payload?.stream === "string" ? payload.stream.trim() : "";
const data = isRecord(payload?.data) ? payload.data : null;
const phase = typeof data?.phase === "string" ? data.phase.trim() : "";
if (stream === "lifecycle" && (phase === "start" || phase === "end" || phase === "error")) {
return {
type: "run.lifecycle",
at,
frame,
runId: typeof payload?.runId === "string" ? payload.runId.trim() || null : null,
sessionKey:
typeof payload?.sessionKey === "string" ? payload.sessionKey.trim() || null : null,
phase,
};
}
}
return {
type: "unknown",
at,
frame,
};
};
+67
View File
@@ -0,0 +1,67 @@
import type {
EventFrame,
GatewayConnectOptions,
GatewayGapInfo,
GatewayStatus,
} from "@/lib/gateway/GatewayClient";
import type { GatewayClient } from "@/lib/gateway/GatewayClient";
import { normalizeGatewayEvent } from "@/lib/runtime/openclaw/normalizeGatewayEvent";
import type { RuntimeCapability, RuntimeEvent, RuntimeProvider } from "@/lib/runtime/types";
const OPENCLAW_RUNTIME_CAPABILITIES: ReadonlySet<RuntimeCapability> = new Set([
"agents",
"sessions",
"chat",
"streaming",
"runtime-agent-events",
"approvals",
"config",
"models",
"skills",
"cron",
"files",
"agent-roles",
]);
export class OpenClawRuntimeProvider implements RuntimeProvider {
readonly id = "openclaw" as const;
readonly label = "OpenClaw";
readonly metadata = {
id: this.id,
label: this.label,
runtimeName: "OpenClaw",
} as const;
readonly capabilities = OPENCLAW_RUNTIME_CAPABILITIES;
constructor(readonly client: GatewayClient) {}
connect(options: GatewayConnectOptions): Promise<void> {
return this.client.connect(options);
}
disconnect(): void {
this.client.disconnect();
}
call<T = unknown>(method: string, params: unknown): Promise<T> {
return this.client.call<T>(method, params);
}
onStatus(handler: (status: GatewayStatus) => void): () => void {
return this.client.onStatus(handler);
}
onGap(handler: (info: GatewayGapInfo) => void): () => void {
return this.client.onGap(handler);
}
onEvent(handler: (event: EventFrame) => void): () => void {
return this.client.onEvent(handler);
}
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void {
return this.client.onEvent((event) => {
handler(normalizeGatewayEvent(event));
});
}
}
+99
View File
@@ -0,0 +1,99 @@
import type {
EventFrame,
GatewayClient,
GatewayConnectOptions,
GatewayGapInfo,
GatewayStatus,
} from "@/lib/gateway/GatewayClient";
export type RuntimeCapability =
| "agents"
| "sessions"
| "chat"
| "streaming"
| "runtime-agent-events"
| "approvals"
| "config"
| "models"
| "skills"
| "cron"
| "files"
| "agent-roles";
export type RuntimeProviderId = "openclaw" | "hermes" | "demo" | "custom";
export type RuntimeProviderMetadata = {
id: RuntimeProviderId;
label: string;
runtimeName?: string | null;
vendor?: string | null;
runtimeVersion?: string | null;
routeProfile?: string | null;
};
export type RuntimeSummaryEvent = {
type: "summary-refresh";
at: number;
frame: EventFrame;
};
export type RuntimeChatEvent =
| {
type: "chat.delta";
at: number;
frame: EventFrame;
runId: string | null;
sessionKey: string | null;
text: string | null;
}
| {
type: "chat.final" | "chat.error" | "chat.aborted";
at: number;
frame: EventFrame;
runId: string | null;
sessionKey: string | null;
text: string | null;
};
export type RuntimeLifecycleEvent = {
type: "run.lifecycle";
at: number;
frame: EventFrame;
runId: string | null;
sessionKey: string | null;
phase: "start" | "end" | "error";
};
export type RuntimeUnknownEvent = {
type: "unknown";
at: number;
frame: EventFrame;
};
export type RuntimeEvent =
| RuntimeSummaryEvent
| RuntimeChatEvent
| RuntimeLifecycleEvent
| RuntimeUnknownEvent;
export type RuntimeStatus = GatewayStatus;
export interface RuntimeProvider {
readonly id: RuntimeProviderId;
readonly label: string;
readonly metadata: RuntimeProviderMetadata;
readonly capabilities: ReadonlySet<RuntimeCapability>;
readonly client: GatewayClient;
connect(options: GatewayConnectOptions): Promise<void>;
disconnect(): void;
call<T = unknown>(method: string, params: unknown): Promise<T>;
onStatus(handler: (status: RuntimeStatus) => void): () => void;
onGap(handler: (info: GatewayGapInfo) => void): () => void;
onEvent(handler: (event: EventFrame) => void): () => void;
onRuntimeEvent(handler: (event: RuntimeEvent) => void): () => void;
}
export const hasRuntimeCapability = (
capabilities: ReadonlySet<RuntimeCapability>,
capability: RuntimeCapability
): boolean => capabilities.has(capability);
+42
View File
@@ -0,0 +1,42 @@
"use client";
import { useMemo } from "react";
import { type GatewayConnectionState, useGatewayConnection } from "@/lib/gateway/GatewayClient";
import { createRuntimeProvider } from "@/lib/runtime/createRuntimeProvider";
import {
hasRuntimeCapability,
type RuntimeCapability,
type RuntimeProvider,
} from "@/lib/runtime/types";
import type { StudioSettingsCoordinator } from "@/lib/studio/coordinator";
export type RuntimeConnectionState = GatewayConnectionState & {
provider: RuntimeProvider;
providerId: RuntimeProvider["id"];
providerLabel: string;
providerMetadata: RuntimeProvider["metadata"];
capabilities: ReadonlySet<RuntimeCapability>;
supportsCapability: (capability: RuntimeCapability) => boolean;
};
export const useRuntimeConnection = (
settingsCoordinator: StudioSettingsCoordinator
): RuntimeConnectionState => {
const gateway = useGatewayConnection(settingsCoordinator);
const provider = useMemo(
() => createRuntimeProvider(gateway.activeAdapterType, gateway.client, gateway.gatewayUrl),
[gateway.activeAdapterType, gateway.client, gateway.gatewayUrl]
);
const capabilities = provider.capabilities;
return {
...gateway,
provider,
providerId: provider.id,
providerLabel: provider.label,
providerMetadata: provider.metadata,
capabilities,
supportsCapability: (capability) => hasRuntimeCapability(capabilities, capability),
};
};
+17 -5
View File
@@ -23,7 +23,11 @@ export const resolveStudioSettingsPath = () =>
const isRecord = (value: unknown): value is Record<string, unknown> =>
Boolean(value && typeof value === "object");
const readOpenclawGatewayDefaults = (): { url: string; token: string } | null => {
const readOpenclawGatewayDefaults = (): {
url: string;
token: string;
adapterType: "openclaw";
} | null => {
try {
const configPath = path.join(resolveStateDir(), OPENCLAW_CONFIG_FILENAME);
if (!fs.existsSync(configPath)) return null;
@@ -38,20 +42,24 @@ const readOpenclawGatewayDefaults = (): { url: string; token: string } | null =>
if (!token) return null;
const url = port ? `ws://localhost:${port}` : "";
if (!url) return null;
return { url, token };
return { url, token, adapterType: "openclaw" };
} catch {
return null;
}
};
export const loadLocalGatewayDefaults = (): { url: string; token: string } | null => {
export const loadLocalGatewayDefaults = (): {
url: string;
token: string;
adapterType: "openclaw";
} | null => {
const fromFile = readOpenclawGatewayDefaults();
if (fromFile) return fromFile;
// Fall back to env vars so operators can configure the gateway URL at
// runtime without openclaw.json and without a rebuild.
const envUrl = process.env.CLAW3D_GATEWAY_URL?.trim();
const envToken = process.env.CLAW3D_GATEWAY_TOKEN?.trim();
if (envUrl) return { url: envUrl, token: envToken ?? "" };
if (envUrl) return { url: envUrl, token: envToken ?? "", adapterType: "openclaw" };
return null;
};
@@ -71,7 +79,11 @@ export const loadStudioSettings = (): StudioSettings => {
return {
...settings,
gateway: settings.gateway?.url?.trim()
? { url: settings.gateway.url.trim(), token: gateway.token }
? {
url: settings.gateway.url.trim(),
token: gateway.token,
adapterType: settings.gateway.adapterType,
}
: gateway,
};
}
+191 -1
View File
@@ -18,16 +18,60 @@ import {
export type StudioGatewaySettings = {
url: string;
token: string;
adapterType: StudioGatewayAdapterType;
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>>;
lastKnownGood?: StudioGatewayConnectionState;
};
export type StudioGatewayAdapterType = "openclaw" | "hermes" | "demo" | "custom";
export type StudioGatewayProfile = {
url: string;
token: string;
};
export type StudioGatewayConnectionState = {
url: string;
token: string;
adapterType: StudioGatewayAdapterType;
};
export type StudioGatewaySettingsPublic = {
url: string;
tokenConfigured: boolean;
adapterType: StudioGatewayAdapterType;
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePublic>>;
lastKnownGood?: StudioGatewayConnectionStatePublic;
};
export type StudioGatewayProfilePublic = {
url: string;
tokenConfigured: boolean;
};
export type StudioGatewayConnectionStatePublic = {
url: string;
tokenConfigured: boolean;
adapterType: StudioGatewayAdapterType;
};
export type StudioGatewaySettingsPatch = {
url?: string | null;
token?: string | null;
adapterType?: StudioGatewayAdapterType | null;
profiles?: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePatch | null>> | null;
lastKnownGood?: StudioGatewayConnectionStatePatch | null;
};
export type StudioGatewayProfilePatch = {
url?: string | null;
token?: string | null;
};
export type StudioGatewayConnectionStatePatch = {
url?: string | null;
token?: string | null;
adapterType?: StudioGatewayAdapterType | null;
};
export type FocusFilter = "all" | "running" | "approvals";
@@ -208,7 +252,7 @@ const normalizeGatewayUrl = (value: unknown) => {
};
const normalizeGatewayKey = (value: unknown) => {
const key = coerceString(value);
const key = normalizeGatewayUrl(value);
return key ? key : null;
};
@@ -636,6 +680,23 @@ const normalizeFocusedPreference = (
};
const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null => {
if (!isRecord(value)) return null;
const url = normalizeGatewayUrl(value.url);
if (!url) return null;
const token = coerceString(value.token);
const adapterType = normalizeGatewayAdapterType(value.adapterType);
const profiles = normalizeGatewayProfiles(value.profiles);
const lastKnownGood = normalizeGatewayConnectionState(value.lastKnownGood);
return {
url,
token,
adapterType,
...(profiles ? { profiles } : {}),
...(lastKnownGood ? { lastKnownGood } : {}),
};
};
const normalizeGatewayProfile = (value: unknown): StudioGatewayProfile | null => {
if (!isRecord(value)) return null;
const url = normalizeGatewayUrl(value.url);
if (!url) return null;
@@ -643,6 +704,31 @@ const normalizeGatewaySettings = (value: unknown): StudioGatewaySettings | null
return { url, token };
};
const normalizeGatewayProfiles = (
value: unknown
): Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined => {
if (!isRecord(value)) return undefined;
const profiles: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {};
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
const normalized = normalizeGatewayProfile(value[adapterType]);
if (normalized) {
profiles[adapterType] = normalized;
}
}
return Object.keys(profiles).length > 0 ? profiles : undefined;
};
const normalizeGatewayConnectionState = (
value: unknown
): StudioGatewayConnectionState | null => {
if (!isRecord(value)) return null;
const url = normalizeGatewayUrl(value.url);
if (!url) return null;
const token = coerceString(value.token);
const adapterType = normalizeGatewayAdapterType(value.adapterType);
return { url, token, adapterType };
};
const mergeGatewaySettings = (
current: StudioGatewaySettings | null,
patch: StudioGatewaySettingsPatch | null,
@@ -653,12 +739,97 @@ const mergeGatewaySettings = (
if (!nextUrl) return null;
const nextToken =
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
const nextAdapterType =
patch.adapterType === undefined
? current?.adapterType ?? "openclaw"
: normalizeGatewayAdapterType(patch.adapterType);
const nextProfiles = mergeGatewayProfiles(current?.profiles, patch.profiles);
const nextLastKnownGood = mergeGatewayConnectionState(
current?.lastKnownGood ?? null,
patch.lastKnownGood
);
return {
url: nextUrl,
token: nextToken,
adapterType: nextAdapterType,
...(nextProfiles ? { profiles: nextProfiles } : {}),
...(nextLastKnownGood ? { lastKnownGood: nextLastKnownGood } : {}),
};
};
const mergeGatewayProfiles = (
current: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined,
patch:
| Partial<Record<StudioGatewayAdapterType, StudioGatewayProfilePatch | null>>
| null
| undefined,
): Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> | undefined => {
if (patch === null) return undefined;
if (patch === undefined) return current;
const next: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
...(current ?? {}),
};
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
const profilePatch = patch[adapterType];
if (profilePatch === undefined) continue;
if (profilePatch === null) {
delete next[adapterType];
continue;
}
const existing = current?.[adapterType] ?? null;
const nextUrl =
profilePatch.url === undefined
? existing?.url ?? ""
: normalizeGatewayUrl(profilePatch.url);
if (!nextUrl) {
delete next[adapterType];
continue;
}
const nextToken =
profilePatch.token === undefined ? existing?.token ?? "" : coerceString(profilePatch.token);
next[adapterType] = { url: nextUrl, token: nextToken };
}
return Object.keys(next).length > 0 ? next : undefined;
};
const mergeGatewayConnectionState = (
current: StudioGatewayConnectionState | null,
patch: StudioGatewayConnectionStatePatch | null | undefined
): StudioGatewayConnectionState | null => {
if (patch === null) return null;
if (patch === undefined) return current;
const nextUrl =
patch.url === undefined ? current?.url ?? "" : normalizeGatewayUrl(patch.url);
if (!nextUrl) return null;
const nextToken =
patch.token === undefined ? current?.token ?? "" : coerceString(patch.token);
const nextAdapterType =
patch.adapterType === undefined
? current?.adapterType ?? "openclaw"
: normalizeGatewayAdapterType(patch.adapterType);
return {
url: nextUrl,
token: nextToken,
adapterType: nextAdapterType,
};
};
const normalizeGatewayAdapterType = (
value: unknown,
fallback: StudioGatewayAdapterType = "openclaw"
): StudioGatewayAdapterType => {
const adapterType = coerceString(value).toLowerCase();
if (
adapterType === "demo" ||
adapterType === "hermes" ||
adapterType === "openclaw" ||
adapterType === "custom"
) {
return adapterType;
}
return fallback;
};
const normalizeFocused = (value: unknown): Record<string, StudioFocusedPreference> => {
if (!isRecord(value)) return {};
const focused: Record<string, StudioFocusedPreference> = {};
@@ -866,6 +1037,25 @@ export const sanitizeStudioGatewaySettings = (
return {
url: value.url,
tokenConfigured: value.token.length > 0,
adapterType: value.adapterType,
profiles: value.profiles
? Object.fromEntries(
Object.entries(value.profiles).map(([adapterType, profile]) => [
adapterType,
{
url: profile.url,
tokenConfigured: profile.token.length > 0,
},
]),
)
: undefined,
lastKnownGood: value.lastKnownGood
? {
url: value.lastKnownGood.url,
tokenConfigured: value.lastKnownGood.token.length > 0,
adapterType: value.lastKnownGood.adapterType,
}
: undefined,
};
};
+1 -1
View File
@@ -1,6 +1,6 @@
import crypto from "node:crypto";
import fs from "node:fs";
import os from "node:os";
// import os from "node:os";
import path from "node:path";
import type { TaskBoardCard, TaskBoardSource, TaskBoardStatus } from "@/features/office/tasks/types";