494 lines
16 KiB
TypeScript
494 lines
16 KiB
TypeScript
|
|
import type { ComputerMode, ComputerReleaseReason } from "@rakazo/contracts";
|
|||
|
|
import { useLocalSearchParams, useNavigation } from "expo-router";
|
|||
|
|
import { useEffect, useLayoutEffect, useRef, useState } from "react";
|
|||
|
|
import { Modal, Pressable, Text, View } from "react-native";
|
|||
|
|
import {
|
|||
|
|
initialWindowMetrics,
|
|||
|
|
SafeAreaProvider,
|
|||
|
|
SafeAreaView,
|
|||
|
|
} from "react-native-safe-area-context";
|
|||
|
|
import { WebView } from "react-native-webview";
|
|||
|
|
import { ComputerMaintenanceActions } from "../components/computer-maintenance-actions";
|
|||
|
|
import { ComputerModePicker } from "../components/computer-mode-picker";
|
|||
|
|
import { NativeSymbol } from "../components/native-symbol";
|
|||
|
|
import { currentApiBase, rpc } from "../lib/api";
|
|||
|
|
import {
|
|||
|
|
COMPUTER_HEARTBEAT_MS,
|
|||
|
|
type ComputerStatus,
|
|||
|
|
computerLabel,
|
|||
|
|
controlLabel,
|
|||
|
|
embeddableScreenUrl,
|
|||
|
|
previewPlaceholder,
|
|||
|
|
readScreenUrl,
|
|||
|
|
SCREEN_URL_OPEN_ATTEMPTS,
|
|||
|
|
} from "../lib/computer";
|
|||
|
|
|
|||
|
|
export default function Computer() {
|
|||
|
|
const navigation = useNavigation();
|
|||
|
|
const { botId, name: nameParam } = useLocalSearchParams<{ botId?: string; name?: string }>();
|
|||
|
|
const name = nameParam || "Bot";
|
|||
|
|
const [computer, setComputer] = useState<ComputerStatus | null>(null);
|
|||
|
|
const [screenUrl, setScreenUrl] = useState<string | null>(null);
|
|||
|
|
const [screenError, setScreenError] = useState<string | null>(null);
|
|||
|
|
const [error, setError] = useState<string | null>(null);
|
|||
|
|
const [ready, setReady] = useState(false);
|
|||
|
|
const [booting, setBooting] = useState(false);
|
|||
|
|
const [switching, setSwitching] = useState(false);
|
|||
|
|
const [computerOpen, setComputerOpen] = useState(false);
|
|||
|
|
const autoBooted = useRef<string | null>(null);
|
|||
|
|
|
|||
|
|
const embeddedScreenUrl = embeddableScreenUrl(screenUrl, currentApiBase());
|
|||
|
|
const hasControl = computer?.controlHolder === "user" && computer.controlBotId === botId;
|
|||
|
|
const label = computerLabel(computer?.mode, name);
|
|||
|
|
|
|||
|
|
useLayoutEffect(() => {
|
|||
|
|
navigation.setOptions({ title: label });
|
|||
|
|
}, [label, navigation]);
|
|||
|
|
|
|||
|
|
async function refreshScreen(attempts: number) {
|
|||
|
|
if (!botId) return;
|
|||
|
|
try {
|
|||
|
|
setScreenUrl(
|
|||
|
|
await readScreenUrl(() => rpc<{ url: string | null }>("computer/screenUrl", { botId }), {
|
|||
|
|
attempts,
|
|||
|
|
}),
|
|||
|
|
);
|
|||
|
|
} catch {
|
|||
|
|
// Keep the last known URL. Cold boots often fail this RPC once, then succeed.
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function refresh(options?: { screenAttempts?: number }) {
|
|||
|
|
if (!botId) return;
|
|||
|
|
const status = await rpc<ComputerStatus>("computer/status", { botId });
|
|||
|
|
setComputer(status);
|
|||
|
|
await refreshScreen(options?.screenAttempts ?? 1);
|
|||
|
|
setReady(true);
|
|||
|
|
return status;
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
void refresh().catch((err: Error) => {
|
|||
|
|
setError(err.message);
|
|||
|
|
setReady(true);
|
|||
|
|
});
|
|||
|
|
const timer = setInterval(() => void refresh().catch(() => undefined), 2000);
|
|||
|
|
return () => clearInterval(timer);
|
|||
|
|
}, [botId]);
|
|||
|
|
|
|||
|
|
async function bootComputer({
|
|||
|
|
takeControl,
|
|||
|
|
overlay,
|
|||
|
|
force = false,
|
|||
|
|
}: {
|
|||
|
|
takeControl: boolean;
|
|||
|
|
overlay: boolean;
|
|||
|
|
force?: boolean;
|
|||
|
|
}) {
|
|||
|
|
if (!botId) return;
|
|||
|
|
const needsBoot = force || computer?.state !== "running" || !screenUrl;
|
|||
|
|
if (overlay && needsBoot) setBooting(true);
|
|||
|
|
try {
|
|||
|
|
if (needsBoot) await rpc("computer/boot", { botId });
|
|||
|
|
if (takeControl) await rpc("computer/takeover", { botId });
|
|||
|
|
await refresh({ screenAttempts: SCREEN_URL_OPEN_ATTEMPTS });
|
|||
|
|
setError(null);
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Could not open computer");
|
|||
|
|
throw err;
|
|||
|
|
} finally {
|
|||
|
|
setBooting(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!ready || !botId) return;
|
|||
|
|
if (computer?.state === "booting" || computer?.state === "suspended") return;
|
|||
|
|
if (autoBooted.current === botId) return;
|
|||
|
|
autoBooted.current = botId;
|
|||
|
|
void bootComputer({
|
|||
|
|
takeControl: false,
|
|||
|
|
overlay: computer?.state !== "running",
|
|||
|
|
force: true,
|
|||
|
|
}).catch(() => undefined);
|
|||
|
|
}, [ready, botId, computer?.state]);
|
|||
|
|
|
|||
|
|
useEffect(() => {
|
|||
|
|
if (!botId || computer?.state !== "running") return;
|
|||
|
|
const ping = () => void rpc("computer/heartbeat", { botId }).catch(() => undefined);
|
|||
|
|
ping();
|
|||
|
|
const timer = setInterval(ping, COMPUTER_HEARTBEAT_MS);
|
|||
|
|
return () => clearInterval(timer);
|
|||
|
|
}, [botId, computer?.state]);
|
|||
|
|
|
|||
|
|
async function openComputer() {
|
|||
|
|
if (!botId) return;
|
|||
|
|
const needsTakeover = !(computer?.controlHolder === "user" && computer.controlBotId === botId);
|
|||
|
|
try {
|
|||
|
|
await bootComputer({
|
|||
|
|
takeControl: needsTakeover,
|
|||
|
|
overlay: needsTakeover || computer?.state !== "running",
|
|||
|
|
force: computer?.state !== "running",
|
|||
|
|
});
|
|||
|
|
setComputerOpen(true);
|
|||
|
|
setScreenError(null);
|
|||
|
|
} catch {
|
|||
|
|
// error already set
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function releaseComputer(reason?: ComputerReleaseReason) {
|
|||
|
|
if (!botId) return;
|
|||
|
|
await rpc("computer/release", { botId, reason }).catch(() => undefined);
|
|||
|
|
setComputerOpen(false);
|
|||
|
|
await refresh().catch(() => undefined);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
async function setComputerMode(mode: ComputerMode) {
|
|||
|
|
if (!botId || mode === computer?.mode) return;
|
|||
|
|
setSwitching(true);
|
|||
|
|
setError(null);
|
|||
|
|
try {
|
|||
|
|
if (hasControl) {
|
|||
|
|
await rpc("computer/release", {
|
|||
|
|
botId,
|
|||
|
|
reason: computer?.takeoverRequested ? "skipped" : undefined,
|
|||
|
|
});
|
|||
|
|
}
|
|||
|
|
await rpc("bots/setComputer", { botId, mode });
|
|||
|
|
setComputer(null);
|
|||
|
|
setScreenUrl(null);
|
|||
|
|
autoBooted.current = null;
|
|||
|
|
await refresh();
|
|||
|
|
} catch (err) {
|
|||
|
|
setError(err instanceof Error ? err.message : "Could not switch computer");
|
|||
|
|
} finally {
|
|||
|
|
setSwitching(false);
|
|||
|
|
}
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
const placeholder =
|
|||
|
|
screenError ?? previewPlaceholder(computer?.state, booting, name, computer?.mode);
|
|||
|
|
|
|||
|
|
return (
|
|||
|
|
<View style={{ flex: 1, backgroundColor: "#0A0A0B", padding: 24 }}>
|
|||
|
|
{error ? <Text style={{ color: "#85858A", marginBottom: 12 }}>{error}</Text> : null}
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
flex: 1,
|
|||
|
|
minHeight: 220,
|
|||
|
|
borderRadius: 14,
|
|||
|
|
overflow: "hidden",
|
|||
|
|
backgroundColor: "#0E0E10",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
{computerOpen ? (
|
|||
|
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center" }}>
|
|||
|
|
<Text style={{ color: "#6C6C70" }}>Open in full window</Text>
|
|||
|
|
</View>
|
|||
|
|
) : computer?.state === "running" && embeddedScreenUrl ? (
|
|||
|
|
<ScreenWebView
|
|||
|
|
url={embeddedScreenUrl}
|
|||
|
|
interactive={false}
|
|||
|
|
onError={() =>
|
|||
|
|
setScreenError("Could not load the desktop. This device cannot reach the screen URL.")
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<View style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}>
|
|||
|
|
<Text style={{ color: "#6C6C70", textAlign: "center" }}>{placeholder}</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityLabel="Open computer"
|
|||
|
|
onPress={() => void openComputer()}
|
|||
|
|
style={{ position: "absolute", top: 0, right: 0, bottom: 0, left: 0 }}
|
|||
|
|
/>
|
|||
|
|
</View>
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
marginTop: 16,
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
gap: 12,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#85858A", flex: 1 }}>{controlLabel(computer, name, botId)}</Text>
|
|||
|
|
{hasControl ? (
|
|||
|
|
<ComputerReleaseActions
|
|||
|
|
takeoverRequested={computer?.takeoverRequested ?? false}
|
|||
|
|
onRelease={releaseComputer}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<Pressable
|
|||
|
|
onPress={() => void openComputer()}
|
|||
|
|
style={{
|
|||
|
|
backgroundColor: "#1A1A1D",
|
|||
|
|
paddingHorizontal: 14,
|
|||
|
|
paddingVertical: 10,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#ECECEE" }}>Take control</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
)}
|
|||
|
|
</View>
|
|||
|
|
{computer?.state === "error" ||
|
|||
|
|
computer?.state === "stopped" ||
|
|||
|
|
(computer?.state === "running" && !embeddedScreenUrl) ? (
|
|||
|
|
<ComputerMaintenanceActions
|
|||
|
|
botId={botId ?? ""}
|
|||
|
|
computer={computer}
|
|||
|
|
onChanged={async () => {
|
|||
|
|
await refresh();
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
) : null}
|
|||
|
|
<ComputerModePicker
|
|||
|
|
value={computer?.mode}
|
|||
|
|
disabled={switching}
|
|||
|
|
onChange={(mode) => void setComputerMode(mode)}
|
|||
|
|
/>
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
marginTop: 18,
|
|||
|
|
borderRadius: 12,
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: "#232326",
|
|||
|
|
padding: 14,
|
|||
|
|
gap: 8,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#85858A", fontSize: 14 }}>Teach a task</Text>
|
|||
|
|
<Text style={{ color: "#6C6C70", fontSize: 13.5, lineHeight: 20 }}>
|
|||
|
|
Recording a live demonstration needs desktop or web with the full computer view. You can
|
|||
|
|
still ask this bot to run saved skills from chat.
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
|
|||
|
|
<Modal
|
|||
|
|
visible={booting || computerOpen}
|
|||
|
|
animationType="fade"
|
|||
|
|
presentationStyle="fullScreen"
|
|||
|
|
onRequestClose={() => {
|
|||
|
|
if (!booting) setComputerOpen(false);
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<SafeAreaProvider initialMetrics={initialWindowMetrics}>
|
|||
|
|
{booting ? (
|
|||
|
|
<SafeAreaView
|
|||
|
|
edges={["top", "left", "right"]}
|
|||
|
|
style={{
|
|||
|
|
flex: 1,
|
|||
|
|
backgroundColor: "rgba(4,4,5,0.96)",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
gap: 22,
|
|||
|
|
padding: 24,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text
|
|||
|
|
style={{ color: "#F1F1F2", fontSize: 19, fontWeight: "500", textAlign: "center" }}
|
|||
|
|
>
|
|||
|
|
Booting {label}
|
|||
|
|
</Text>
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
height: 5,
|
|||
|
|
width: "70%",
|
|||
|
|
maxWidth: 420,
|
|||
|
|
overflow: "hidden",
|
|||
|
|
borderRadius: 999,
|
|||
|
|
backgroundColor: "#232327",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
height: "100%",
|
|||
|
|
width: "66%",
|
|||
|
|
borderRadius: 999,
|
|||
|
|
backgroundColor: "#F1F1EF",
|
|||
|
|
}}
|
|||
|
|
/>
|
|||
|
|
</View>
|
|||
|
|
</SafeAreaView>
|
|||
|
|
) : (
|
|||
|
|
<View style={{ flex: 1, backgroundColor: "#050506" }}>
|
|||
|
|
<SafeAreaView
|
|||
|
|
edges={["top", "left", "right"]}
|
|||
|
|
style={{
|
|||
|
|
flexDirection: "row",
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "space-between",
|
|||
|
|
gap: 12,
|
|||
|
|
borderBottomWidth: 1,
|
|||
|
|
borderBottomColor: "#171719",
|
|||
|
|
paddingHorizontal: 18,
|
|||
|
|
paddingVertical: 14,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<View style={{ flex: 1, minWidth: 0, gap: 8 }}>
|
|||
|
|
<Text
|
|||
|
|
numberOfLines={1}
|
|||
|
|
style={{ color: "#ECECEE", fontSize: 15.5, fontWeight: "500" }}
|
|||
|
|
>
|
|||
|
|
{label}
|
|||
|
|
</Text>
|
|||
|
|
{hasControl ? (
|
|||
|
|
<View
|
|||
|
|
style={{
|
|||
|
|
alignSelf: "flex-start",
|
|||
|
|
borderRadius: 999,
|
|||
|
|
backgroundColor: "rgba(48,162,75,0.14)",
|
|||
|
|
paddingHorizontal: 11,
|
|||
|
|
paddingVertical: 4,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#4ECB71", fontSize: 13 }}>You have control</Text>
|
|||
|
|
</View>
|
|||
|
|
) : null}
|
|||
|
|
</View>
|
|||
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 12 }}>
|
|||
|
|
{hasControl ? (
|
|||
|
|
<ComputerReleaseActions
|
|||
|
|
takeoverRequested={computer?.takeoverRequested ?? false}
|
|||
|
|
onRelease={releaseComputer}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<Pressable
|
|||
|
|
onPress={() =>
|
|||
|
|
void bootComputer({ takeControl: true, overlay: false }).catch(
|
|||
|
|
() => undefined,
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
hitSlop={8}
|
|||
|
|
style={{
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: "#26262A",
|
|||
|
|
paddingHorizontal: 12,
|
|||
|
|
paddingVertical: 8,
|
|||
|
|
borderRadius: 10,
|
|||
|
|
minHeight: 44,
|
|||
|
|
justifyContent: "center",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#ECECEE" }}>Take control</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
)}
|
|||
|
|
<Pressable
|
|||
|
|
accessibilityLabel="Close computer"
|
|||
|
|
hitSlop={8}
|
|||
|
|
onPress={() => setComputerOpen(false)}
|
|||
|
|
style={{
|
|||
|
|
minWidth: 44,
|
|||
|
|
minHeight: 44,
|
|||
|
|
alignItems: "center",
|
|||
|
|
justifyContent: "center",
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<NativeSymbol ios="xmark" android="close" size={16} color="#85858A" />
|
|||
|
|
</Pressable>
|
|||
|
|
</View>
|
|||
|
|
</SafeAreaView>
|
|||
|
|
<View style={{ flex: 1, backgroundColor: "#0E0E10" }}>
|
|||
|
|
{computer?.state === "running" && embeddedScreenUrl ? (
|
|||
|
|
<ScreenWebView
|
|||
|
|
url={embeddedScreenUrl}
|
|||
|
|
interactive={hasControl}
|
|||
|
|
onError={() =>
|
|||
|
|
setScreenError(
|
|||
|
|
"Could not load the desktop. This device cannot reach the screen URL.",
|
|||
|
|
)
|
|||
|
|
}
|
|||
|
|
/>
|
|||
|
|
) : (
|
|||
|
|
<View
|
|||
|
|
style={{ flex: 1, alignItems: "center", justifyContent: "center", padding: 16 }}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: "#6C6C70", textAlign: "center" }}>
|
|||
|
|
{computer?.state === "suspended"
|
|||
|
|
? "Computer is asleep"
|
|||
|
|
: computerLabel(computer?.mode, name)}
|
|||
|
|
</Text>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
</View>
|
|||
|
|
</View>
|
|||
|
|
)}
|
|||
|
|
</SafeAreaProvider>
|
|||
|
|
</Modal>
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ComputerReleaseActions({
|
|||
|
|
takeoverRequested,
|
|||
|
|
onRelease,
|
|||
|
|
}: {
|
|||
|
|
takeoverRequested: boolean;
|
|||
|
|
onRelease: (reason?: ComputerReleaseReason) => Promise<void>;
|
|||
|
|
}) {
|
|||
|
|
const actions: Array<{ label: string; reason?: ComputerReleaseReason; primary?: boolean }> =
|
|||
|
|
takeoverRequested
|
|||
|
|
? [
|
|||
|
|
{ label: "Skip", reason: "skipped" },
|
|||
|
|
{ label: "I’m done", reason: "done", primary: true },
|
|||
|
|
]
|
|||
|
|
: [{ label: "Release" }];
|
|||
|
|
return (
|
|||
|
|
<View style={{ flexDirection: "row", alignItems: "center", gap: 8 }}>
|
|||
|
|
{actions.map((action) => (
|
|||
|
|
<Pressable
|
|||
|
|
key={action.label}
|
|||
|
|
accessibilityLabel={action.label}
|
|||
|
|
onPress={() => void onRelease(action.reason)}
|
|||
|
|
hitSlop={8}
|
|||
|
|
style={{
|
|||
|
|
minHeight: 44,
|
|||
|
|
justifyContent: "center",
|
|||
|
|
borderWidth: 1,
|
|||
|
|
borderColor: action.primary ? "#F1F1EF" : "#26262A",
|
|||
|
|
backgroundColor: action.primary ? "#F1F1EF" : "#1A1A1D",
|
|||
|
|
paddingHorizontal: 12,
|
|||
|
|
paddingVertical: 8,
|
|||
|
|
borderRadius: 10,
|
|||
|
|
}}
|
|||
|
|
>
|
|||
|
|
<Text style={{ color: action.primary ? "#17171A" : "#ECECEE" }}>{action.label}</Text>
|
|||
|
|
</Pressable>
|
|||
|
|
))}
|
|||
|
|
</View>
|
|||
|
|
);
|
|||
|
|
}
|
|||
|
|
|
|||
|
|
function ScreenWebView({
|
|||
|
|
url,
|
|||
|
|
interactive,
|
|||
|
|
onError,
|
|||
|
|
}: {
|
|||
|
|
url: string;
|
|||
|
|
interactive: boolean;
|
|||
|
|
onError: () => void;
|
|||
|
|
}) {
|
|||
|
|
return (
|
|||
|
|
<WebView
|
|||
|
|
key={url}
|
|||
|
|
source={{ uri: url }}
|
|||
|
|
style={{ flex: 1, backgroundColor: "#000" }}
|
|||
|
|
pointerEvents={interactive ? "auto" : "none"}
|
|||
|
|
javaScriptEnabled
|
|||
|
|
domStorageEnabled
|
|||
|
|
allowsInlineMediaPlayback
|
|||
|
|
mediaPlaybackRequiresUserAction={false}
|
|||
|
|
originWhitelist={["*"]}
|
|||
|
|
mixedContentMode="always"
|
|||
|
|
setSupportMultipleWindows={false}
|
|||
|
|
scrollEnabled={false}
|
|||
|
|
overScrollMode="never"
|
|||
|
|
onError={onError}
|
|||
|
|
onHttpError={onError}
|
|||
|
|
/>
|
|||
|
|
);
|
|||
|
|
}
|