mirror of
https://github.com/iamlukethedev/Claw3D.git
synced 2026-07-30 03:02:37 +00:00
fix lukess findings
This commit is contained in:
@@ -61,6 +61,7 @@ Additional future departments:
|
||||
- Building systems are shared and runtime-neutral.
|
||||
- Cross-floor coordination is explicit, not accidental.
|
||||
- The gateway/runtime remains the source of truth for runtime-owned data.
|
||||
- Floor switching owns the connection lifecycle for that floor.
|
||||
|
||||
## Why Floors
|
||||
|
||||
@@ -152,6 +153,14 @@ A floor can be:
|
||||
|
||||
Multiple floors may be loaded in the same session, but they should not share runtime connection state.
|
||||
|
||||
When the user switches to another runtime-backed floor:
|
||||
|
||||
- the shell should keep the building mounted
|
||||
- the current runtime should disconnect if the target floor uses a different transport
|
||||
- the next floor should connect using that floor's saved runtime profile
|
||||
- the floor label should not get ahead of the actual runtime handoff
|
||||
- reconnect churn should collapse into one transition state instead of flashing through multiple disconnected/connecting states
|
||||
|
||||
## State Ownership
|
||||
|
||||
### Runtime-owned
|
||||
@@ -244,6 +253,7 @@ type FloorRuntimeState = {
|
||||
Important rule:
|
||||
|
||||
- floor-local runtime state should not be overwritten by switching to another floor
|
||||
- switching floors should not leave the previous runtime active under the next floor's label
|
||||
|
||||
## PR Breakdown
|
||||
|
||||
@@ -495,6 +505,7 @@ Acceptance criteria:
|
||||
- switching floors does not wipe another floor’s runtime state
|
||||
- reconnecting one floor does not reset another floor
|
||||
- Claw3D can show disconnected/configured/connected/errored state per floor
|
||||
- moving from one runtime floor to another reconnects against the target runtime before the floor is treated as live
|
||||
|
||||
### Phase 3: Per-Floor Roster Hydration
|
||||
|
||||
@@ -525,6 +536,7 @@ Acceptance criteria:
|
||||
- floor switching is UI-stateful, not route-destructive
|
||||
- the shell remains stable while floor scenes swap
|
||||
- disconnected floors remain visible as places, not absent data
|
||||
- runtime-backed floors enter through a transition/arrival flow, not by silently reusing the previous floor's live runtime
|
||||
|
||||
### Phase 5: Cross-Floor Coordination Primitives
|
||||
|
||||
|
||||
@@ -1,9 +1,15 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import type {
|
||||
SummaryPreviewSnapshot,
|
||||
SummaryStatusSnapshot,
|
||||
} from "@/features/agents/state/runtimeEventBridge";
|
||||
|
||||
import {
|
||||
fetchRemoteOfficePresenceSnapshot,
|
||||
loadOfficePresenceSnapshot,
|
||||
} from "@/lib/office/presence";
|
||||
import { buildOfficePresenceSnapshotFromGateway } from "@/lib/office/gatewayPresence";
|
||||
import { NodeGatewayClient, buildAgentMainSessionKey } from "@/lib/gateway/nodeGatewayClient";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import { resolveOfficePreference } from "@/lib/studio/settings";
|
||||
|
||||
@@ -14,13 +20,15 @@ export async function GET(request: Request) {
|
||||
const url = new URL(request.url);
|
||||
const source = url.searchParams.get("source")?.trim() || "local";
|
||||
const workspaceId = url.searchParams.get("workspaceId")?.trim() || "default";
|
||||
if (source === "remote") {
|
||||
if (source === "remote" || source === "remote_gateway") {
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url?.trim() || "";
|
||||
const officePreference = resolveOfficePreference(settings, gatewayUrl);
|
||||
if (
|
||||
!officePreference.remoteOfficeEnabled ||
|
||||
!officePreference.remoteOfficePresenceUrl.trim()
|
||||
(source === "remote"
|
||||
? !officePreference.remoteOfficePresenceUrl.trim()
|
||||
: !officePreference.remoteOfficeGatewayUrl.trim())
|
||||
) {
|
||||
return NextResponse.json(
|
||||
{
|
||||
@@ -31,22 +39,71 @@ export async function GET(request: Request) {
|
||||
{ headers: { "Cache-Control": "no-store" } }
|
||||
);
|
||||
}
|
||||
if (source === "remote") {
|
||||
const startedAt = Date.now();
|
||||
console.info("[office-presence] Fetching remote office presence.", {
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
tokenConfigured: Boolean(officePreference.remoteOfficeToken?.trim()),
|
||||
});
|
||||
const snapshot = await fetchRemoteOfficePresenceSnapshot({
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
token: officePreference.remoteOfficeToken,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
console.info("[office-presence] Remote office presence loaded.", {
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
agentCount: snapshot.agents.length,
|
||||
});
|
||||
return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } });
|
||||
}
|
||||
|
||||
const startedAt = Date.now();
|
||||
console.info("[office-presence] Fetching remote office presence.", {
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
tokenConfigured: Boolean(officePreference.remoteOfficeToken?.trim()),
|
||||
});
|
||||
const snapshot = await fetchRemoteOfficePresenceSnapshot({
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
token: officePreference.remoteOfficeToken,
|
||||
timeoutMs: 15_000,
|
||||
});
|
||||
console.info("[office-presence] Remote office presence loaded.", {
|
||||
presenceUrl: officePreference.remoteOfficePresenceUrl,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
agentCount: snapshot.agents.length,
|
||||
});
|
||||
return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } });
|
||||
const gatewayClient = new NodeGatewayClient();
|
||||
try {
|
||||
await gatewayClient.connect({
|
||||
gatewayUrl: officePreference.remoteOfficeGatewayUrl,
|
||||
token: officePreference.remoteOfficeToken,
|
||||
});
|
||||
const agentsResult = (await gatewayClient.request("agents.list", {})) as {
|
||||
mainKey?: string;
|
||||
agents?: Array<{ id?: string; name?: string; identity?: { name?: string } }>;
|
||||
};
|
||||
const statusSummary = (await gatewayClient.request(
|
||||
"status",
|
||||
{},
|
||||
)) as SummaryStatusSnapshot;
|
||||
const remoteAgentIds = Array.isArray(agentsResult.agents)
|
||||
? agentsResult.agents
|
||||
.map((agent) => (typeof agent.id === "string" ? agent.id.trim() : ""))
|
||||
.filter((agentId) => agentId.length > 0)
|
||||
: [];
|
||||
const sessionKeys = remoteAgentIds.map((agentId) =>
|
||||
buildAgentMainSessionKey(agentId, agentsResult.mainKey?.trim() || "main"),
|
||||
);
|
||||
const previewSnapshot: SummaryPreviewSnapshot | null =
|
||||
sessionKeys.length > 0
|
||||
? ((await gatewayClient.request("sessions.preview", {
|
||||
keys: sessionKeys,
|
||||
limit: 8,
|
||||
maxChars: 240,
|
||||
})) as SummaryPreviewSnapshot)
|
||||
: null;
|
||||
const snapshot = buildOfficePresenceSnapshotFromGateway({
|
||||
agentsResult,
|
||||
statusSummary,
|
||||
previewSnapshot,
|
||||
workspaceId: "remote-gateway",
|
||||
});
|
||||
console.info("[office-presence] Remote gateway presence loaded.", {
|
||||
gatewayUrl: officePreference.remoteOfficeGatewayUrl,
|
||||
elapsedMs: Date.now() - startedAt,
|
||||
agentCount: snapshot.agents.length,
|
||||
});
|
||||
return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } });
|
||||
} finally {
|
||||
gatewayClient.close();
|
||||
}
|
||||
}
|
||||
const snapshot = loadOfficePresenceSnapshot(workspaceId);
|
||||
return NextResponse.json(snapshot, { headers: { "Cache-Control": "no-store" } });
|
||||
|
||||
@@ -0,0 +1,101 @@
|
||||
import { randomUUID } from "node:crypto";
|
||||
import { NextResponse } from "next/server";
|
||||
import { NodeGatewayClient } from "@/lib/gateway/nodeGatewayClient";
|
||||
import { sendAgentHandoffViaRuntime } from "@/lib/runtime/agentMessaging";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import { resolveOfficePreference } from "@/lib/studio/settings";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const MAX_REMOTE_MESSAGE_CHARS = 2_000;
|
||||
|
||||
const stripRemoteAgentPrefix = (agentId: string) =>
|
||||
agentId.startsWith("remote:") ? agentId.slice("remote:".length) : agentId;
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const gatewayClient = new NodeGatewayClient();
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
agentId?: unknown;
|
||||
task?: unknown;
|
||||
context?: unknown;
|
||||
deliverables?: unknown;
|
||||
acceptanceCriteria?: unknown;
|
||||
};
|
||||
const requestedAgentId =
|
||||
typeof body.agentId === "string" ? stripRemoteAgentPrefix(body.agentId.trim()) : "";
|
||||
const task = typeof body.task === "string" ? body.task.trim() : "";
|
||||
const context = typeof body.context === "string" ? body.context.trim() : "";
|
||||
const acceptanceCriteria =
|
||||
typeof body.acceptanceCriteria === "string" ? body.acceptanceCriteria.trim() : "";
|
||||
const deliverables = Array.isArray(body.deliverables)
|
||||
? body.deliverables.filter((entry): entry is string => typeof entry === "string")
|
||||
: [];
|
||||
|
||||
if (!requestedAgentId) {
|
||||
return NextResponse.json({ error: "Remote agent ID is required." }, { status: 400 });
|
||||
}
|
||||
if (!task) {
|
||||
return NextResponse.json({ error: "Remote handoff task is required." }, { status: 400 });
|
||||
}
|
||||
if (task.length > MAX_REMOTE_MESSAGE_CHARS) {
|
||||
return NextResponse.json(
|
||||
{ error: `Remote handoff must be ${MAX_REMOTE_MESSAGE_CHARS} characters or fewer.` },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
const settings = loadStudioSettings();
|
||||
const gatewayUrl = settings.gateway?.url?.trim() || "";
|
||||
const officePreference = resolveOfficePreference(settings, gatewayUrl);
|
||||
if (!officePreference.remoteOfficeEnabled) {
|
||||
return NextResponse.json({ error: "Remote office is disabled." }, { status: 400 });
|
||||
}
|
||||
if (officePreference.remoteOfficeSourceKind !== "openclaw_gateway") {
|
||||
return NextResponse.json(
|
||||
{ error: "Remote handoffs currently work only with the remote gateway source." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
const remoteGatewayUrl = officePreference.remoteOfficeGatewayUrl.trim();
|
||||
if (!remoteGatewayUrl) {
|
||||
return NextResponse.json(
|
||||
{ error: "Remote office gateway URL is not configured." },
|
||||
{ status: 400 },
|
||||
);
|
||||
}
|
||||
|
||||
await gatewayClient.connect({
|
||||
gatewayUrl: remoteGatewayUrl,
|
||||
token: officePreference.remoteOfficeToken,
|
||||
});
|
||||
|
||||
const handoffResult = (await sendAgentHandoffViaRuntime(
|
||||
{ call: gatewayClient.request.bind(gatewayClient) },
|
||||
{
|
||||
targetAgentId: requestedAgentId,
|
||||
task,
|
||||
sourceLabel: "another office user",
|
||||
context: context || undefined,
|
||||
acceptanceCriteria: acceptanceCriteria || undefined,
|
||||
deliverables,
|
||||
idempotencyKey: randomUUID(),
|
||||
},
|
||||
)) as { runId?: string; status?: string };
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
agentId: requestedAgentId,
|
||||
runId:
|
||||
typeof handoffResult?.runId === "string" && handoffResult.runId.trim()
|
||||
? handoffResult.runId.trim()
|
||||
: null,
|
||||
status: typeof handoffResult?.status === "string" ? handoffResult.status : null,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
error instanceof Error ? error.message : "Failed to send remote office handoff.";
|
||||
return NextResponse.json({ error: message }, { status: 500 });
|
||||
} finally {
|
||||
gatewayClient.close();
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ import { NextResponse } from "next/server";
|
||||
import { NodeGatewayClient, buildAgentMainSessionKey } from "@/lib/gateway/nodeGatewayClient";
|
||||
import { loadStudioSettings } from "@/lib/studio/settings-store";
|
||||
import { resolveOfficePreference } from "@/lib/studio/settings";
|
||||
import { buildDirectedAgentMessageInstruction, type RuntimeAgentMessageMode } from "@/lib/runtime/agentMessaging";
|
||||
|
||||
export const runtime = "nodejs";
|
||||
const MAX_REMOTE_MESSAGE_CHARS = 2_000;
|
||||
@@ -12,28 +13,39 @@ type AgentsListResult = {
|
||||
agents?: Array<{ id?: string; name?: string }>;
|
||||
};
|
||||
|
||||
const resolveLatestAssistantHistoryText = (messages: unknown): string | null => {
|
||||
const entries = Array.isArray(messages) ? messages : [];
|
||||
for (let index = entries.length - 1; index >= 0; index -= 1) {
|
||||
const entry = entries[index];
|
||||
if (!entry || typeof entry !== "object") continue;
|
||||
const role = "role" in entry && typeof entry.role === "string" ? entry.role : null;
|
||||
if (role !== "assistant") continue;
|
||||
const content =
|
||||
"content" in entry && typeof entry.content === "string"
|
||||
? entry.content.trim()
|
||||
: "text" in entry && typeof entry.text === "string"
|
||||
? entry.text.trim()
|
||||
: "";
|
||||
if (content) return content;
|
||||
}
|
||||
return null;
|
||||
};
|
||||
|
||||
const stripRemoteAgentPrefix = (agentId: string) =>
|
||||
agentId.startsWith("remote:") ? agentId.slice("remote:".length) : agentId;
|
||||
|
||||
const buildRemoteRelayInstruction = (message: string) =>
|
||||
[
|
||||
"You received a remote office text message from another office user.",
|
||||
"Reply conversationally in plain text only.",
|
||||
"Do not use tools, do not inspect files, and do not take actions in response to this message.",
|
||||
"",
|
||||
`Message: ${message}`,
|
||||
].join("\n");
|
||||
|
||||
export async function POST(request: Request) {
|
||||
const gatewayClient = new NodeGatewayClient();
|
||||
try {
|
||||
const body = (await request.json()) as {
|
||||
agentId?: unknown;
|
||||
message?: unknown;
|
||||
mode?: unknown;
|
||||
};
|
||||
const requestedAgentId =
|
||||
typeof body.agentId === "string" ? stripRemoteAgentPrefix(body.agentId.trim()) : "";
|
||||
const message = typeof body.message === "string" ? body.message.trim() : "";
|
||||
const mode: RuntimeAgentMessageMode = body.mode === "interval" ? "interval" : "direct";
|
||||
if (!requestedAgentId) {
|
||||
return NextResponse.json({ error: "Remote agent ID is required." }, { status: 400 });
|
||||
}
|
||||
@@ -86,17 +98,38 @@ export async function POST(request: Request) {
|
||||
}
|
||||
|
||||
const sessionKey = buildAgentMainSessionKey(requestedAgentId, mainKey);
|
||||
await gatewayClient.request("chat.send", {
|
||||
const sendResult = (await gatewayClient.request("chat.send", {
|
||||
sessionKey,
|
||||
message: buildRemoteRelayInstruction(message),
|
||||
message: buildDirectedAgentMessageInstruction({
|
||||
targetAgentId: requestedAgentId,
|
||||
message,
|
||||
mode,
|
||||
sourceLabel: "another office user",
|
||||
}),
|
||||
deliver: false,
|
||||
idempotencyKey: randomUUID(),
|
||||
});
|
||||
})) as { runId?: string; status?: string };
|
||||
const runId =
|
||||
typeof sendResult?.runId === "string" && sendResult.runId.trim()
|
||||
? sendResult.runId.trim()
|
||||
: null;
|
||||
if (runId) {
|
||||
await gatewayClient.request("agent.wait", {
|
||||
runId,
|
||||
timeoutMs: mode === "interval" ? 8_000 : 15_000,
|
||||
});
|
||||
}
|
||||
const historyResult = (await gatewayClient.request("chat.history", {
|
||||
sessionKey,
|
||||
limit: 8,
|
||||
})) as { messages?: unknown };
|
||||
const assistantText = resolveLatestAssistantHistoryText(historyResult.messages);
|
||||
|
||||
return NextResponse.json({
|
||||
ok: true,
|
||||
agentId: requestedAgentId,
|
||||
sessionKey,
|
||||
assistantText,
|
||||
});
|
||||
} catch (error) {
|
||||
const message =
|
||||
|
||||
@@ -66,8 +66,10 @@ export const useRemoteOfficePresence = ({
|
||||
? presenceUrl.trim().length > 0
|
||||
: normalizedGatewayUrl.length > 0);
|
||||
const requestUrl = useMemo(() => {
|
||||
if (!active || sourceKind !== "presence_endpoint") return "";
|
||||
const searchParams = new URLSearchParams({ source: "remote" });
|
||||
if (!active) return "";
|
||||
const searchParams = new URLSearchParams({
|
||||
source: sourceKind === "presence_endpoint" ? "remote" : "remote_gateway",
|
||||
});
|
||||
return `/api/office/presence?${searchParams.toString()}`;
|
||||
}, [active, sourceKind]);
|
||||
|
||||
@@ -250,8 +252,7 @@ export const useRemoteOfficePresence = ({
|
||||
}
|
||||
}
|
||||
};
|
||||
const loadSnapshot =
|
||||
sourceKind === "presence_endpoint" ? loadFromPresenceEndpoint : loadFromGateway;
|
||||
const loadSnapshot = requestUrl ? loadFromPresenceEndpoint : loadFromGateway;
|
||||
void loadSnapshot();
|
||||
intervalId = window.setInterval(() => {
|
||||
void loadSnapshot();
|
||||
|
||||
@@ -31,7 +31,9 @@ import {
|
||||
resolveDeskAssignments,
|
||||
resolveOfficePreferencePublic,
|
||||
resolveStudioActiveFloorId,
|
||||
resolveStudioGatewayProfiles,
|
||||
type StudioGatewayAdapterType,
|
||||
type StudioGatewaySettings,
|
||||
} from "@/lib/studio/settings";
|
||||
import {
|
||||
createGatewayAgent,
|
||||
@@ -67,8 +69,6 @@ import {
|
||||
} from "@/features/office/components/RemoteAgentChatPanel";
|
||||
import { useOfficeFloorRuntimePersistence } from "@/features/office/hooks/useOfficeFloorRuntimePersistence";
|
||||
import {
|
||||
buildDirectedAgentMessageInstruction,
|
||||
sendAgentHandoffViaRuntime,
|
||||
type RuntimeAgentMessageMode,
|
||||
} from "@/lib/runtime/agentMessaging";
|
||||
import {
|
||||
@@ -324,6 +324,13 @@ type OfficeDeleteMutationBlockState = {
|
||||
sawDisconnect: boolean;
|
||||
};
|
||||
|
||||
type PendingFloorRuntimeSwitch = {
|
||||
floorId: FloorId;
|
||||
adapterType: StudioGatewayAdapterType;
|
||||
gatewayUrl: string;
|
||||
token: string;
|
||||
};
|
||||
|
||||
type PhoneCallSpeakPayload = {
|
||||
agentId: string;
|
||||
requestKey: string;
|
||||
@@ -1099,6 +1106,8 @@ export function OfficeScreen({
|
||||
Record<string, string>
|
||||
>({});
|
||||
const [activeFloorId, setActiveFloorId] = useState<FloorId>("lobby");
|
||||
const [pendingFloorRuntimeSwitch, setPendingFloorRuntimeSwitch] =
|
||||
useState<PendingFloorRuntimeSwitch | null>(null);
|
||||
const previousGatewayStatusRef = useRef<"disconnected" | "connecting" | "connected">(
|
||||
"disconnected",
|
||||
);
|
||||
@@ -1208,6 +1217,52 @@ export function OfficeScreen({
|
||||
status,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!pendingFloorRuntimeSwitch) return;
|
||||
const targetSelectedAdapter = selectedAdapterType === pendingFloorRuntimeSwitch.adapterType;
|
||||
const targetGatewayUrl = gatewayUrl.trim() === pendingFloorRuntimeSwitch.gatewayUrl;
|
||||
const targetToken = token === pendingFloorRuntimeSwitch.token;
|
||||
if (!targetSelectedAdapter || !targetGatewayUrl || !targetToken) {
|
||||
return;
|
||||
}
|
||||
if (status === "connected" || status === "connecting") {
|
||||
const runtimeMatchesTarget =
|
||||
activeAdapterType === pendingFloorRuntimeSwitch.adapterType &&
|
||||
gatewayUrl.trim() === pendingFloorRuntimeSwitch.gatewayUrl &&
|
||||
token === pendingFloorRuntimeSwitch.token;
|
||||
if (runtimeMatchesTarget) {
|
||||
setPendingFloorRuntimeSwitch(null);
|
||||
return;
|
||||
}
|
||||
disconnect();
|
||||
return;
|
||||
}
|
||||
void connect()
|
||||
.catch((error) => {
|
||||
console.error("Failed to connect floor runtime.", error);
|
||||
})
|
||||
.finally(() => {
|
||||
setPendingFloorRuntimeSwitch((current) =>
|
||||
current &&
|
||||
current.floorId === pendingFloorRuntimeSwitch.floorId &&
|
||||
current.adapterType === pendingFloorRuntimeSwitch.adapterType &&
|
||||
current.gatewayUrl === pendingFloorRuntimeSwitch.gatewayUrl &&
|
||||
current.token === pendingFloorRuntimeSwitch.token
|
||||
? null
|
||||
: current,
|
||||
);
|
||||
});
|
||||
}, [
|
||||
activeAdapterType,
|
||||
connect,
|
||||
disconnect,
|
||||
gatewayUrl,
|
||||
pendingFloorRuntimeSwitch,
|
||||
selectedAdapterType,
|
||||
status,
|
||||
token,
|
||||
]);
|
||||
|
||||
useEffect(() => {
|
||||
initJukeboxStore();
|
||||
}, [initJukeboxStore]);
|
||||
@@ -1372,19 +1427,74 @@ export function OfficeScreen({
|
||||
setOfficeCameraCenterSignal((current) => current + 1);
|
||||
|
||||
const adapterType = floor.provider as StudioGatewayAdapterType;
|
||||
setSelectedAdapterType(adapterType);
|
||||
let nextGatewayUrl = gatewayUrl.trim();
|
||||
let nextToken = token;
|
||||
|
||||
try {
|
||||
const settings = await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 });
|
||||
const envelope =
|
||||
typeof settingsCoordinator.loadSettingsEnvelope === "function"
|
||||
? await settingsCoordinator.loadSettingsEnvelope({ maxAgeMs: 30_000 })
|
||||
: {
|
||||
settings: await settingsCoordinator.loadSettings({ maxAgeMs: 30_000 }),
|
||||
};
|
||||
const settings = envelope.settings ?? null;
|
||||
const gateway = settings?.gateway ?? null;
|
||||
const gatewaySettings: StudioGatewaySettings | null = gateway
|
||||
? {
|
||||
url: typeof gateway.url === "string" ? gateway.url.trim() : "",
|
||||
token: token,
|
||||
adapterType:
|
||||
gateway.adapterType === "demo" ||
|
||||
gateway.adapterType === "hermes" ||
|
||||
gateway.adapterType === "openclaw" ||
|
||||
gateway.adapterType === "local" ||
|
||||
gateway.adapterType === "claw3d" ||
|
||||
gateway.adapterType === "custom"
|
||||
? gateway.adapterType
|
||||
: "openclaw",
|
||||
...(gateway.profiles
|
||||
? {
|
||||
profiles: Object.fromEntries(
|
||||
Object.entries(gateway.profiles).flatMap(([profileAdapterType, profile]) =>
|
||||
profile?.url
|
||||
? [
|
||||
[
|
||||
profileAdapterType,
|
||||
{
|
||||
url: profile.url.trim(),
|
||||
token: "",
|
||||
},
|
||||
],
|
||||
]
|
||||
: [],
|
||||
),
|
||||
) as StudioGatewaySettings["profiles"],
|
||||
}
|
||||
: {}),
|
||||
}
|
||||
: null;
|
||||
const { profiles } = resolveStudioGatewayProfiles({
|
||||
gateway: gatewaySettings,
|
||||
localDefaults: localGatewayDefaults,
|
||||
});
|
||||
const floorRuntime = settings?.officeFloors?.[resolved];
|
||||
const nextGatewayUrl = floorRuntime?.gatewayUrl?.trim() || "";
|
||||
if (nextGatewayUrl) {
|
||||
setGatewayUrl(nextGatewayUrl);
|
||||
}
|
||||
nextGatewayUrl =
|
||||
floorRuntime?.gatewayUrl?.trim() || profiles[adapterType]?.url?.trim() || nextGatewayUrl;
|
||||
nextToken = profiles[adapterType]?.token ?? nextToken;
|
||||
} catch (error) {
|
||||
console.error("Failed to resolve floor runtime profile.", error);
|
||||
}
|
||||
|
||||
setSelectedAdapterType(adapterType);
|
||||
setGatewayUrl(nextGatewayUrl);
|
||||
setToken(nextToken);
|
||||
setPendingFloorRuntimeSwitch({
|
||||
floorId: resolved,
|
||||
adapterType,
|
||||
gatewayUrl: nextGatewayUrl,
|
||||
token: nextToken,
|
||||
});
|
||||
|
||||
const preferredAgentId =
|
||||
floorRosterCache[resolved]?.selectedAgentId ??
|
||||
floorRosterCache[resolved]?.entries[0]?.agentId ??
|
||||
@@ -1399,10 +1509,13 @@ export function OfficeScreen({
|
||||
[
|
||||
floorRosterCache,
|
||||
focusLocalAgent,
|
||||
gatewayUrl,
|
||||
localGatewayDefaults,
|
||||
setGatewayUrl,
|
||||
setToken,
|
||||
setSelectedAdapterType,
|
||||
settingsCoordinator,
|
||||
token,
|
||||
],
|
||||
);
|
||||
const focusChatTarget = useCallback(
|
||||
@@ -3582,58 +3695,31 @@ export function OfficeScreen({
|
||||
},
|
||||
],
|
||||
}));
|
||||
const remoteClient = new GatewayClient();
|
||||
try {
|
||||
await remoteClient.connect({
|
||||
gatewayUrl: remoteOfficeGatewayUrl,
|
||||
});
|
||||
const agentsResult = (await remoteClient.call("agents.list", {})) as {
|
||||
mainKey?: string;
|
||||
agents?: Array<{ id?: string; name?: string }>;
|
||||
};
|
||||
const remoteAgents = Array.isArray(agentsResult.agents)
|
||||
? agentsResult.agents
|
||||
: [];
|
||||
if (remoteAgents.length === 0) {
|
||||
throw new Error("Remote agent list is unavailable right now.");
|
||||
}
|
||||
if (!remoteAgents.some((entry) => (entry.id?.trim() ?? "") === remoteAgentId)) {
|
||||
throw new Error("Remote agent is no longer available.");
|
||||
}
|
||||
const sessionKey = buildAgentMainSessionKey(
|
||||
remoteAgentId,
|
||||
agentsResult.mainKey?.trim() || "main",
|
||||
);
|
||||
const deliveryMode =
|
||||
(remoteChatByAgentId[agentId]?.mode ?? EMPTY_REMOTE_CHAT_SESSION.mode) === "interval"
|
||||
? "interval"
|
||||
: "direct";
|
||||
const sendResult = (await remoteClient.call("chat.send", {
|
||||
sessionKey,
|
||||
message: buildDirectedAgentMessageInstruction({
|
||||
targetAgentId: remoteAgentId,
|
||||
const response = await fetch("/api/office/remote-message", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agentId: remoteAgentId,
|
||||
message: trimmed,
|
||||
mode: deliveryMode,
|
||||
sourceLabel: "another office user",
|
||||
}),
|
||||
deliver: false,
|
||||
idempotencyKey: randomUUID(),
|
||||
})) as { runId?: string; status?: string; text?: string; content?: string; message?: string };
|
||||
const runId =
|
||||
typeof sendResult?.runId === "string" && sendResult.runId.trim()
|
||||
? sendResult.runId.trim()
|
||||
: null;
|
||||
if (runId) {
|
||||
await remoteClient.call("agent.wait", {
|
||||
runId,
|
||||
timeoutMs: deliveryMode === "interval" ? 8_000 : 15_000,
|
||||
});
|
||||
});
|
||||
const payload = (await response.json()) as {
|
||||
error?: string;
|
||||
assistantText?: string | null;
|
||||
};
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Failed to deliver the remote office message.");
|
||||
}
|
||||
const historyResult = (await remoteClient.call("chat.history", {
|
||||
sessionKey,
|
||||
limit: 8,
|
||||
})) as { messages?: unknown };
|
||||
const assistantText = resolveLatestAssistantHistoryText(historyResult?.messages);
|
||||
const assistantText =
|
||||
typeof payload.assistantText === "string" ? payload.assistantText.trim() : "";
|
||||
updateRemoteChatSession(agentId, (session) => ({
|
||||
...session,
|
||||
sending: false,
|
||||
@@ -3677,11 +3763,9 @@ export function OfficeScreen({
|
||||
},
|
||||
],
|
||||
}));
|
||||
} finally {
|
||||
remoteClient.disconnect();
|
||||
}
|
||||
},
|
||||
[remoteChatByAgentId, remoteOfficeGatewayUrl, updateRemoteChatSession],
|
||||
[remoteChatByAgentId, updateRemoteChatSession],
|
||||
);
|
||||
|
||||
const handleRemoteAgentHandoff = useCallback(
|
||||
@@ -3717,55 +3801,41 @@ export function OfficeScreen({
|
||||
},
|
||||
],
|
||||
}));
|
||||
const remoteClient = new GatewayClient();
|
||||
try {
|
||||
await remoteClient.connect({
|
||||
gatewayUrl: remoteOfficeGatewayUrl,
|
||||
});
|
||||
const historyContext = (
|
||||
sessionSnapshot.messages ?? []
|
||||
)
|
||||
.slice(-6)
|
||||
.map((entry) => `${entry.role}: ${entry.text}`)
|
||||
.join("\n");
|
||||
const agentsResult = (await remoteClient.call("agents.list", {})) as {
|
||||
mainKey?: string;
|
||||
agents?: Array<{ id?: string; name?: string }>;
|
||||
};
|
||||
const handoffResult = (await sendAgentHandoffViaRuntime(remoteClient, {
|
||||
targetAgentId: remoteAgentId,
|
||||
task: trimmed,
|
||||
sourceLabel: "another office user",
|
||||
context: sessionSnapshot.handoffContext.trim() || historyContext || undefined,
|
||||
deliverables:
|
||||
sessionSnapshot.handoffDeliverables
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean).length > 0
|
||||
? sessionSnapshot.handoffDeliverables
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: ["Acknowledge ownership", "Send the next checkpoint or blocking question"],
|
||||
acceptanceCriteria:
|
||||
sessionSnapshot.handoffAcceptance.trim() ||
|
||||
"Respond with an acknowledgement or the next concrete update.",
|
||||
idempotencyKey: randomUUID(),
|
||||
})) as { runId?: string };
|
||||
const runId =
|
||||
typeof handoffResult?.runId === "string" ? handoffResult.runId.trim() : "";
|
||||
if (runId) {
|
||||
await remoteClient.call("agent.wait", { runId, timeoutMs: 15_000 });
|
||||
const response = await fetch("/api/office/remote-handoff", {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({
|
||||
agentId: remoteAgentId,
|
||||
task: trimmed,
|
||||
context: sessionSnapshot.handoffContext.trim() || historyContext || undefined,
|
||||
deliverables:
|
||||
sessionSnapshot.handoffDeliverables
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean).length > 0
|
||||
? sessionSnapshot.handoffDeliverables
|
||||
.split(",")
|
||||
.map((entry) => entry.trim())
|
||||
.filter(Boolean)
|
||||
: ["Acknowledge ownership", "Send the next checkpoint or blocking question"],
|
||||
acceptanceCriteria:
|
||||
sessionSnapshot.handoffAcceptance.trim() ||
|
||||
"Respond with an acknowledgement or the next concrete update.",
|
||||
}),
|
||||
});
|
||||
const payload = (await response.json()) as { error?: string };
|
||||
if (!response.ok) {
|
||||
throw new Error(payload.error || "Failed to deliver the remote handoff.");
|
||||
}
|
||||
const sessionKey = buildAgentMainSessionKey(
|
||||
remoteAgentId,
|
||||
agentsResult.mainKey?.trim() || "main",
|
||||
);
|
||||
const historyResult = (await remoteClient.call("chat.history", {
|
||||
sessionKey,
|
||||
limit: 8,
|
||||
})) as { messages?: unknown };
|
||||
const assistantText = resolveLatestAssistantHistoryText(historyResult?.messages);
|
||||
updateRemoteChatSession(agentId, (session) => ({
|
||||
...session,
|
||||
handoffing: false,
|
||||
@@ -3778,16 +3848,6 @@ export function OfficeScreen({
|
||||
text: "Handoff delivered to the remote agent.",
|
||||
timestampMs: Date.now(),
|
||||
},
|
||||
...(assistantText
|
||||
? [
|
||||
{
|
||||
id: randomUUID(),
|
||||
role: "assistant" as const,
|
||||
text: assistantText,
|
||||
timestampMs: Date.now(),
|
||||
},
|
||||
]
|
||||
: []),
|
||||
],
|
||||
}));
|
||||
} catch (error) {
|
||||
@@ -3807,11 +3867,9 @@ export function OfficeScreen({
|
||||
},
|
||||
],
|
||||
}));
|
||||
} finally {
|
||||
remoteClient.disconnect();
|
||||
}
|
||||
},
|
||||
[remoteChatByAgentId, remoteOfficeGatewayUrl, updateRemoteChatSession],
|
||||
[remoteChatByAgentId, updateRemoteChatSession],
|
||||
);
|
||||
|
||||
const lastStandupTriggerKeyRef = useRef<string | null>(null);
|
||||
|
||||
@@ -19,6 +19,7 @@ type CustomRuntimeProxyInput = {
|
||||
pathname: string;
|
||||
method?: "GET" | "POST";
|
||||
body?: unknown;
|
||||
signal?: AbortSignal;
|
||||
};
|
||||
|
||||
export async function requestCustomRuntime<T = unknown>({
|
||||
@@ -26,6 +27,7 @@ export async function requestCustomRuntime<T = unknown>({
|
||||
pathname,
|
||||
method = "GET",
|
||||
body,
|
||||
signal,
|
||||
}: CustomRuntimeProxyInput): Promise<T> {
|
||||
const normalizedRuntimeUrl = normalizeCustomBaseUrl(runtimeUrl);
|
||||
if (!normalizedRuntimeUrl) {
|
||||
@@ -38,6 +40,7 @@ export async function requestCustomRuntime<T = unknown>({
|
||||
Accept: "application/json",
|
||||
},
|
||||
cache: "no-store",
|
||||
signal,
|
||||
body: JSON.stringify({
|
||||
runtimeUrl: normalizedRuntimeUrl,
|
||||
pathname,
|
||||
|
||||
@@ -142,6 +142,13 @@ const resolveAssistantTextFromResponse = (payload: unknown): string | null => {
|
||||
return text || null;
|
||||
};
|
||||
|
||||
const isAbortLikeError = (error: unknown, controller?: AbortController | null): boolean => {
|
||||
if (controller?.signal.aborted) return true;
|
||||
return error instanceof DOMException
|
||||
? error.name === "AbortError"
|
||||
: error instanceof Error && error.name === "AbortError";
|
||||
};
|
||||
|
||||
const normalizeModelChoices = (registry: CustomRuntimeRegistryResponse | null): string[] => {
|
||||
if (!registry || !isRecord(registry.models)) return [];
|
||||
return Object.keys(registry.models).map((value) => value.trim()).filter(Boolean);
|
||||
@@ -525,6 +532,7 @@ export class CustomRuntimeProvider implements RuntimeProvider {
|
||||
runtimeUrl: this.baseUrl,
|
||||
pathname: "/v1/chat/completions",
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
body: {
|
||||
model: session.model ?? undefined,
|
||||
stream: false,
|
||||
@@ -555,6 +563,12 @@ export class CustomRuntimeProvider implements RuntimeProvider {
|
||||
text: assistantText,
|
||||
};
|
||||
} catch (error) {
|
||||
if (isAbortLikeError(error, controller)) {
|
||||
return {
|
||||
status: "aborted",
|
||||
runId: runId || null,
|
||||
};
|
||||
}
|
||||
const health = await this.fetchHealth().catch(() => null);
|
||||
throw new Error(
|
||||
buildChatFailureMessage(
|
||||
|
||||
@@ -811,7 +811,14 @@ const normalizeGatewayProfiles = (
|
||||
): 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) {
|
||||
for (const adapterType of [
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"demo",
|
||||
"local",
|
||||
"claw3d",
|
||||
"custom",
|
||||
] as const) {
|
||||
const normalized = normalizeGatewayProfile(value[adapterType]);
|
||||
if (normalized) {
|
||||
profiles[adapterType] = normalized;
|
||||
@@ -871,7 +878,14 @@ const mergeGatewayProfiles = (
|
||||
const next: Partial<Record<StudioGatewayAdapterType, StudioGatewayProfile>> = {
|
||||
...(current ?? {}),
|
||||
};
|
||||
for (const adapterType of ["openclaw", "hermes", "demo", "custom"] as const) {
|
||||
for (const adapterType of [
|
||||
"openclaw",
|
||||
"hermes",
|
||||
"demo",
|
||||
"local",
|
||||
"claw3d",
|
||||
"custom",
|
||||
] as const) {
|
||||
const profilePatch = patch[adapterType];
|
||||
if (profilePatch === undefined) continue;
|
||||
if (profilePatch === null) {
|
||||
|
||||
Reference in New Issue
Block a user