Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
63 changes: 63 additions & 0 deletions frontend/src/App.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -200,6 +200,7 @@ import {
import { SandboxAgentDetails } from "./ui/SandboxAgentDetails";
import { SandboxAgentWorkspace } from "./ui/SandboxAgentWorkspace";
import { SandboxComposer } from "./ui/SandboxComposer";
import { SandboxProjectUploadDialog } from "./ui/SandboxProjectUploadDialog";
import { sandboxSnapshotTurns } from "./ui/sandboxCommands";
import { useSandboxCodexCommands } from "./ui/useSandboxCodexCommands";
import { StudioConfirmDialog } from "./ui/StudioConfirmDialog";
Expand Down Expand Up @@ -250,6 +251,7 @@ interface NewChatCapabilitiesState {
harnessEnabled?: boolean;
builtinTools?: string[];
temporaryEnabled?: boolean;
sandboxEndpointExportEnabled?: boolean;
skillCustomizationEnabled?: boolean;
}

Expand All @@ -268,6 +270,9 @@ async function probeNewChatCapabilities(
builtinTools: harnessResult.status === "fulfilled" ? harnessResult.value : [],
temporaryEnabled:
sandboxResult.status === "fulfilled" && sandboxResult.value.enabled,
sandboxEndpointExportEnabled:
sandboxResult.status === "fulfilled" &&
sandboxResult.value.endpointExportEnabled === true,
skillCustomizationEnabled:
skillResult.status === "fulfilled" && skillResult.value.enabled,
};
Expand Down Expand Up @@ -871,13 +876,16 @@ export default function App() {
const [sandboxApprovalBusy, setSandboxApprovalBusy] = useState(false);
const [sandboxApprovalError, setSandboxApprovalError] = useState("");
const [sandboxUploadBusy, setSandboxUploadBusy] = useState(false);
const [sandboxEndpointCopyState, setSandboxEndpointCopyState] =
useState<"idle" | "copying" | "copied">("idle");
const [sandboxLaunchOpen, setSandboxLaunchOpen] = useState(false);
const [sandboxLaunchState, setSandboxLaunchState] =
useState<SandboxLaunchState>("confirm");
const [sandboxLaunchError, setSandboxLaunchError] = useState("");
const [sandboxLaunchKind, setSandboxLaunchKind] =
useState<"codex" | SandboxAgentKind>("codex");
const [sandboxLaunchFromAgents, setSandboxLaunchFromAgents] = useState(false);
const [sandboxProjectUploadOpen, setSandboxProjectUploadOpen] = useState(false);
const [sandboxAgentRefreshKey, setSandboxAgentRefreshKey] = useState(0);
const [sandboxAgentDetailTarget, setSandboxAgentDetailTarget] =
useState<SandboxAgentResource | null>(null);
Expand All @@ -890,9 +898,13 @@ export default function App() {
const sandboxSessionIdRef = useRef(sandboxSession?.id ?? "");
const sandboxActiveAssistantTurnIdRef = useRef("");
const sandboxUploadRunRef = useRef(0);
const sandboxEndpointCopyTimerRef = useRef<number | undefined>(undefined);
const sandboxPreviewUrlsRef = useRef<Set<string>>(new Set());
sandboxSessionIdRef.current = sandboxSession?.id ?? "";
useEffect(() => () => {
if (sandboxEndpointCopyTimerRef.current !== undefined) {
window.clearTimeout(sandboxEndpointCopyTimerRef.current);
}
for (const previewUrl of sandboxPreviewUrlsRef.current) {
URL.revokeObjectURL(previewUrl);
}
Expand All @@ -917,6 +929,18 @@ export default function App() {
sandboxPreviewUrlsRef.current.clear();
}

const resetSandboxEndpointCopyState = useCallback(() => {
if (sandboxEndpointCopyTimerRef.current !== undefined) {
window.clearTimeout(sandboxEndpointCopyTimerRef.current);
sandboxEndpointCopyTimerRef.current = undefined;
}
setSandboxEndpointCopyState("idle");
}, []);

useEffect(() => {
resetSandboxEndpointCopyState();
}, [resetSandboxEndpointCopyState, sandboxSession?.id]);

// Turns are stored PER SESSION, so a background stream can keep updating its
// own session's transcript while you view another one — no cross-session
// leak, no data loss, and no re-fetch when you switch back (its entry is
Expand Down Expand Up @@ -3044,6 +3068,7 @@ export default function App() {
setSandboxApproval(null);
setSandboxApprovalBusy(false);
setSandboxApprovalError("");
resetSandboxEndpointCopyState();
setSandboxThreadDeleteTarget(null);
setSandboxUploadBusy(false);
sandboxUploadRunRef.current += 1;
Expand Down Expand Up @@ -3077,6 +3102,33 @@ export default function App() {
}
}

async function copySandboxEndpoint() {
const activeSession = sandboxSession;
if (!activeSession || sandboxEndpointCopyState === "copying") return;
setSandboxEndpointCopyState("copying");
setError("");
try {
if (!navigator.clipboard?.writeText) {
throw new Error("当前浏览器不支持写入剪贴板。");
}
const exported = await sandboxClient.getEndpoint(activeSession.id);
await navigator.clipboard.writeText(exported.endpoint);
if (sandboxSessionIdRef.current !== activeSession.id) return;
setSandboxEndpointCopyState("copied");
if (sandboxEndpointCopyTimerRef.current !== undefined) {
window.clearTimeout(sandboxEndpointCopyTimerRef.current);
}
sandboxEndpointCopyTimerRef.current = window.setTimeout(() => {
setSandboxEndpointCopyState("idle");
sandboxEndpointCopyTimerRef.current = undefined;
}, 1600);
} catch (cause) {
if (sandboxSessionIdRef.current !== activeSession.id) return;
setSandboxEndpointCopyState("idle");
setError(cause instanceof Error ? cause.message : String(cause));
}
}

async function saveSandboxPermissions(value: SandboxPermissions) {
const activeSession = sandboxSession;
if (!activeSession || sandboxSettingsBusy) return;
Expand Down Expand Up @@ -5028,6 +5080,10 @@ export default function App() {
setSandboxSettingsError("");
setSandboxWorkspaceOpen(true);
},
onCopyEndpoint: copySandboxEndpoint,
endpointCopyEnabled:
newChatCapabilities.sandboxEndpointExportEnabled === true,
endpointCopyState: sandboxEndpointCopyState,
workspaceLocked: sandboxSession.workspaceLocked,
settingsBusy: sandboxSettingsBusy,
uploadBusy: sandboxUploadBusy || sandboxBusy,
Expand Down Expand Up @@ -5298,6 +5354,7 @@ export default function App() {
canCreate={canCreateAgents}
runtimeScope={access.capabilities.runtimeScope}
onCreateAgent={openAgentCreateFromMyAgents}
onOpenCodexProjectUpload={() => setSandboxProjectUploadOpen(true)}
onUseAgent={(agent) =>
connectMyAgent(agent, { source: "my_agents" })
}
Expand Down Expand Up @@ -5975,6 +6032,12 @@ export default function App() {
}
/>

<SandboxProjectUploadDialog
open={sandboxProjectUploadOpen}
onClose={() => setSandboxProjectUploadOpen(false)}
onRefreshAgents={() => setSandboxAgentRefreshKey((current) => current + 1)}
/>

{sandboxThreadDeleteTarget ? (
<StudioConfirmDialog
title="删除 Codex 历史会话"
Expand Down
2 changes: 2 additions & 0 deletions frontend/src/adk/newChatCapabilities.ts
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ const CAPABILITY_TIMEOUT_MS = 10_000;
export interface NewChatModeCapability {
enabled: boolean;
reason?: string;
endpointExportEnabled?: boolean;
}

async function getCapability(path: string): Promise<NewChatModeCapability> {
Expand All @@ -24,6 +25,7 @@ async function getCapability(path: string): Promise<NewChatModeCapability> {
return {
enabled: payload.enabled,
reason: typeof payload.reason === "string" ? payload.reason : undefined,
endpointExportEnabled: payload.endpointExportEnabled === true,
};
}

Expand Down
83 changes: 83 additions & 0 deletions frontend/src/adk/sandbox.ts
Original file line number Diff line number Diff line change
Expand Up @@ -4,13 +4,16 @@ import { requestSignal } from "./timeout";
import type { Block } from "../blocks";

const SANDBOX_API = "/web/sandbox/sessions";
const CODEX_PROJECT_UPLOAD_API = "/web/sandbox/codex-project-upload";
const LIST_TIMEOUT_MS = 30_000;
const START_TIMEOUT_MS = 330_000;
const CONNECT_TIMEOUT_MS = 60_000;
const MESSAGE_TIMEOUT_MS = 600_000;
const CLOSE_TIMEOUT_MS = 15_000;
const SETTINGS_TIMEOUT_MS = 60_000;
const UPLOAD_TIMEOUT_MS = 330_000;
const CODEX_PROJECT_UPLOAD_TIMEOUT_MS = 30_000;
export const CODEX_PROJECT_UPLOAD_AUTHORIZATION_TTL_SECONDS = 60 * 60;

export const SANDBOX_DISPLAY_NAME_MAX_LENGTH = 40;
export type SandboxAgentKind = "openclaw" | "hermes";
Expand Down Expand Up @@ -95,6 +98,18 @@ export interface SandboxToolLaunch {
shellSessionId?: string;
}

export interface SandboxEndpointExport {
endpoint: string;
sessionId: string;
expireAt?: string;
}

export interface CodexProjectUploadAuthorization {
authorizationCode: string;
expireAt: string;
studioUrl: string;
}

export interface SandboxUploadedFile {
id: string;
path: string;
Expand Down Expand Up @@ -286,6 +301,13 @@ export interface AgentKitSandboxClient {
sessionId: string,
options?: SandboxRequestOptions,
): Promise<SandboxStatus>;
getEndpoint(
sessionId: string,
options?: SandboxRequestOptions,
): Promise<SandboxEndpointExport>;
createCodexProjectUploadAuthorization(
options?: SandboxRequestOptions,
): Promise<CodexProjectUploadAuthorization>;
listModels(
sessionId: string,
options?: SandboxRequestOptions,
Expand Down Expand Up @@ -1151,6 +1173,67 @@ export const sandboxClient: AgentKitSandboxClient = {
};
},

async getEndpoint(sessionId, options = {}) {
const value = recordOf(await sandboxJson(sessionId, "endpoint", {
options,
fallback: "无法读取 Sandbox Endpoint。",
}));
if (typeof value?.endpoint !== "string" || !value.endpoint.trim()) {
throw new Error("Sandbox 返回了无效 Endpoint。");
}
return {
endpoint: value.endpoint,
sessionId:
typeof value.sessionId === "string" ? value.sessionId : sessionId,
...(typeof value.expireAt === "string"
? { expireAt: value.expireAt }
: {}),
};
},

async createCodexProjectUploadAuthorization(options = {}) {
const response = await fetch(
withAuth(`${CODEX_PROJECT_UPLOAD_API}/authorizations`),
{
method: "POST",
headers: sandboxHeaders({
Accept: "application/json",
"Content-Type": "application/json",
}),
body: JSON.stringify({
ttlSeconds: CODEX_PROJECT_UPLOAD_AUTHORIZATION_TTL_SECONDS,
}),
signal: requestSignal(
options.signal,
CODEX_PROJECT_UPLOAD_TIMEOUT_MS,
),
},
);
if (!response.ok) {
throw await responseError(
response,
"无法生成 Codex 项目上传授权码。",
);
}
const value = recordOf(await response.json());
if (
typeof value?.authorizationCode !== "string" ||
!value.authorizationCode.trim() ||
typeof value.expireAt !== "string" ||
!value.expireAt.trim()
) {
throw new Error("Studio 返回了无效的 Codex 项目上传授权码。");
}
const studioUrl = typeof value.studioUrl === "string" && value.studioUrl.trim()
? value.studioUrl.trim()
: window.location.origin;
return {
authorizationCode: value.authorizationCode,
expireAt: value.expireAt,
studioUrl,
};
},

async listModels(sessionId, options = {}) {
const value = recordOf(await sandboxJson(sessionId, "models", {
options,
Expand Down
27 changes: 26 additions & 1 deletion frontend/src/ui/MyAgents.css
Original file line number Diff line number Diff line change
Expand Up @@ -102,6 +102,13 @@
gap: 8px;
}

.my-agent-type-actions {
flex: 0 0 auto;
display: inline-flex;
align-items: center;
gap: 8px;
}

.my-agent-type-pill {
min-height: 30px;
padding: 0 13px;
Expand All @@ -127,6 +134,23 @@
color: hsl(var(--background));
}

.my-agent-create-secondary {
height: 32px;
padding: 0 13px;
border: 1px solid hsl(var(--border));
border-radius: 8px;
background: hsl(var(--panel));
color: hsl(var(--foreground));
cursor: pointer;
font: inherit;
font-size: 12px;
font-weight: 600;
}

.my-agent-create-secondary:hover {
background: hsl(var(--secondary));
}

.my-agent-results {
flex: 1;
min-height: 0;
Expand Down Expand Up @@ -602,6 +626,7 @@

.my-agent-actions button:focus-visible,
.my-agent-type-pill:focus-visible,
.my-agent-create-secondary:focus-visible,
.my-agent-create-primary:focus-visible,
.my-agent-empty button:focus-visible {
outline: 2px solid hsl(var(--ring) / 0.65);
Expand Down Expand Up @@ -639,7 +664,7 @@
gap: 12px;
}

.my-agent-create-primary {
.my-agent-type-actions {
align-self: flex-end;
}

Expand Down
Loading
Loading