diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 2e8ed9d5..7aa84785 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -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"; @@ -250,6 +251,7 @@ interface NewChatCapabilitiesState { harnessEnabled?: boolean; builtinTools?: string[]; temporaryEnabled?: boolean; + sandboxEndpointExportEnabled?: boolean; skillCustomizationEnabled?: boolean; } @@ -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, }; @@ -871,6 +876,8 @@ 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("confirm"); @@ -878,6 +885,7 @@ export default function App() { 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(null); @@ -890,9 +898,13 @@ export default function App() { const sandboxSessionIdRef = useRef(sandboxSession?.id ?? ""); const sandboxActiveAssistantTurnIdRef = useRef(""); const sandboxUploadRunRef = useRef(0); + const sandboxEndpointCopyTimerRef = useRef(undefined); const sandboxPreviewUrlsRef = useRef>(new Set()); sandboxSessionIdRef.current = sandboxSession?.id ?? ""; useEffect(() => () => { + if (sandboxEndpointCopyTimerRef.current !== undefined) { + window.clearTimeout(sandboxEndpointCopyTimerRef.current); + } for (const previewUrl of sandboxPreviewUrlsRef.current) { URL.revokeObjectURL(previewUrl); } @@ -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 @@ -3044,6 +3068,7 @@ export default function App() { setSandboxApproval(null); setSandboxApprovalBusy(false); setSandboxApprovalError(""); + resetSandboxEndpointCopyState(); setSandboxThreadDeleteTarget(null); setSandboxUploadBusy(false); sandboxUploadRunRef.current += 1; @@ -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; @@ -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, @@ -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" }) } @@ -5975,6 +6032,12 @@ export default function App() { } /> + setSandboxProjectUploadOpen(false)} + onRefreshAgents={() => setSandboxAgentRefreshKey((current) => current + 1)} + /> + {sandboxThreadDeleteTarget ? ( { @@ -24,6 +25,7 @@ async function getCapability(path: string): Promise { return { enabled: payload.enabled, reason: typeof payload.reason === "string" ? payload.reason : undefined, + endpointExportEnabled: payload.endpointExportEnabled === true, }; } diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts index 747a6694..a8ae66cc 100644 --- a/frontend/src/adk/sandbox.ts +++ b/frontend/src/adk/sandbox.ts @@ -4,6 +4,7 @@ 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; @@ -11,6 +12,8 @@ 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"; @@ -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; @@ -286,6 +301,13 @@ export interface AgentKitSandboxClient { sessionId: string, options?: SandboxRequestOptions, ): Promise; + getEndpoint( + sessionId: string, + options?: SandboxRequestOptions, + ): Promise; + createCodexProjectUploadAuthorization( + options?: SandboxRequestOptions, + ): Promise; listModels( sessionId: string, options?: SandboxRequestOptions, @@ -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, diff --git a/frontend/src/ui/MyAgents.css b/frontend/src/ui/MyAgents.css index 81b6b0d4..157f209b 100644 --- a/frontend/src/ui/MyAgents.css +++ b/frontend/src/ui/MyAgents.css @@ -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; @@ -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; @@ -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); @@ -639,7 +664,7 @@ gap: 12px; } - .my-agent-create-primary { + .my-agent-type-actions { align-self: flex-end; } diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx index 5ad7c6e2..dda73f96 100644 --- a/frontend/src/ui/MyAgents.tsx +++ b/frontend/src/ui/MyAgents.tsx @@ -386,6 +386,7 @@ export interface MyAgentsProps { canCreate: boolean; runtimeScope: RuntimeScope; onCreateAgent: (region: string) => void; + onOpenCodexProjectUpload?: () => void; onUseAgent: (agent: MyAgentCardData) => Promise; onViewAgentDetails: (agent: MyAgentCardData) => void; onCreateSandboxAgent: (kind: "codex" | SandboxAgentKind) => void; @@ -407,6 +408,7 @@ export function MyAgents({ canCreate, runtimeScope, onCreateAgent, + onOpenCodexProjectUpload, onUseAgent, onViewAgentDetails, onCreateSandboxAgent, @@ -649,6 +651,8 @@ export function MyAgents({ ? () => onCreateAgent(defaultCloudRegion(cloudProvider)) : () => onCreateSandboxAgent(activeType) : undefined; + const showCodexProjectUpload = + activeType === "codex" && canCreate && Boolean(onOpenCodexProjectUpload); const createDisabledReason = !canCreate ? "当前账号没有创建智能体权限" : undefined; @@ -692,16 +696,27 @@ export function MyAgents({ ))} - +
+ {showCodexProjectUpload ? ( + + ) : null} + +
{canCreate ? ( + {activeType === "codex" && onOpenCodexProjectUpload ? ( + + ) : null} + {actions.endpointCopyEnabled && actions.onCopyEndpoint ? ( + + ) : null}
diff --git a/frontend/src/ui/SandboxProjectUploadDialog.css b/frontend/src/ui/SandboxProjectUploadDialog.css new file mode 100644 index 00000000..aebdbe98 --- /dev/null +++ b/frontend/src/ui/SandboxProjectUploadDialog.css @@ -0,0 +1,367 @@ +.sandbox-project-upload-backdrop { + position: fixed; + inset: 0; + z-index: 110; + display: grid; + place-items: center; + padding: 20px; + background: hsl(var(--foreground) / 0.24); + backdrop-filter: blur(3px); +} + +.sandbox-project-upload-dialog { + width: min(680px, calc(100vw - 40px)); + max-height: min(760px, calc(100vh - 40px)); + display: flex; + flex-direction: column; + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 12px; + background: hsl(var(--panel)); + color: hsl(var(--foreground)); + box-shadow: 0 20px 56px hsl(var(--foreground) / 0.2); + animation: sandbox-project-upload-enter 180ms ease-out both; +} + +.sandbox-project-upload-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 16px; + padding: 20px 22px 16px; + border-bottom: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-head h2, +.sandbox-project-upload-head p { + margin: 0; +} + +.sandbox-project-upload-head h2 { + font-size: 17px; + font-weight: 650; + line-height: 1.3; +} + +.sandbox-project-upload-head p { + margin-top: 6px; + color: hsl(var(--muted-foreground)); + font-size: 13px; + line-height: 1.55; +} + +.sandbox-project-upload-close { + width: 32px; + height: 32px; + display: inline-grid; + flex: 0 0 32px; + place-items: center; + border: 1px solid transparent; + border-radius: 8px; + background: transparent; + color: hsl(var(--muted-foreground)); + cursor: pointer; +} + +.sandbox-project-upload-close:hover { + border-color: hsl(var(--border)); + background: hsl(var(--secondary)); + color: hsl(var(--foreground)); +} + +.sandbox-project-upload-close svg { + width: 17px; + height: 17px; +} + +.sandbox-project-upload-body { + min-height: 0; + overflow-y: auto; + display: grid; + gap: 14px; + padding: 16px 22px 20px; +} + +.sandbox-project-upload-error { + display: flex; + align-items: center; + justify-content: space-between; + gap: 12px; + padding: 10px 12px; + border: 1px solid hsl(var(--destructive) / 0.22); + border-radius: 8px; + background: hsl(var(--destructive) / 0.05); + color: hsl(var(--destructive)); + font-size: 12px; +} + +.sandbox-project-upload-error span { + min-width: 0; + overflow-wrap: anywhere; +} + +.sandbox-project-upload-steps { + display: grid; + gap: 8px; + margin: 0; + padding: 0; + list-style: none; +} + +.sandbox-project-upload-steps li { + display: grid; + grid-template-columns: 24px minmax(0, 1fr); + gap: 10px; + padding: 10px 12px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); +} + +.sandbox-project-upload-steps li > span { + width: 24px; + height: 24px; + display: inline-grid; + place-items: center; + border-radius: 999px; + background: hsl(var(--foreground)); + color: hsl(var(--background)); + font-size: 12px; + font-weight: 650; +} + +.sandbox-project-upload-steps strong { + display: block; + font-size: 13px; + font-weight: 650; + line-height: 1.35; +} + +.sandbox-project-upload-steps p { + margin: 4px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.5; +} + +.sandbox-project-upload-command { + overflow: hidden; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); +} + +.sandbox-project-upload-command-head { + display: flex; + align-items: flex-start; + justify-content: space-between; + gap: 12px; + padding: 9px 10px; + border-bottom: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-command-head > div { + min-width: 0; +} + +.sandbox-project-upload-command-head span { + min-width: 0; + color: hsl(var(--foreground)); + font-size: 12.5px; + font-weight: 650; +} + +.sandbox-project-upload-command-head p { + margin: 4px 0 0; + color: hsl(var(--muted-foreground)); + font-size: 12px; + line-height: 1.45; +} + +.sandbox-project-upload-command-head button, +.sandbox-project-upload-error button, +.sandbox-project-upload-actions button { + height: 32px; + display: inline-flex; + align-items: center; + justify-content: center; + gap: 6px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--panel)); + color: hsl(var(--foreground)); + font: inherit; + font-size: 12px; + font-weight: 600; + cursor: pointer; +} + +.sandbox-project-upload-command-head button, +.sandbox-project-upload-error button { + padding: 0 10px; +} + +.sandbox-project-upload-command-head button:hover:not(:disabled), +.sandbox-project-upload-error button:hover, +.sandbox-project-upload-actions button:hover { + background: hsl(var(--secondary)); +} + +.sandbox-project-upload-command-head button:disabled { + cursor: default; + opacity: 0.56; +} + +.sandbox-project-upload-command-head svg, +.sandbox-project-upload-error svg, +.sandbox-project-upload-actions svg { + width: 14px; + height: 14px; + flex: 0 0 14px; +} + +.sandbox-project-upload-command pre { + max-height: 230px; + overflow: auto; + margin: 0; + padding: 12px; + color: hsl(var(--foreground)); + font-size: 12px; + line-height: 1.55; + white-space: pre-wrap; + overflow-wrap: anywhere; +} + +.sandbox-project-upload-command code { + font-family: ui-monospace, SFMono-Regular, SFMono-Regular, Consolas, + "Liberation Mono", Menlo, monospace; +} + +.sandbox-project-upload-loading { + display: flex; + align-items: center; + gap: 8px; + min-height: 46px; + padding: 0 12px; + color: hsl(var(--muted-foreground)); + font-size: 12px; +} + +.sandbox-project-upload-loading i { + width: 14px; + height: 14px; + flex: 0 0 14px; + border: 2px solid hsl(var(--foreground) / 0.16); + border-top-color: hsl(var(--foreground)); + border-radius: 50%; + animation: sandbox-project-upload-spin 800ms linear infinite; +} + +.sandbox-project-upload-meta { + display: grid; + grid-template-columns: repeat(2, minmax(0, 1fr)); + gap: 8px; + margin: 0; +} + +.sandbox-project-upload-meta > div { + min-width: 0; + padding: 9px 10px; + border: 1px solid hsl(var(--border)); + border-radius: 8px; + background: hsl(var(--background)); +} + +.sandbox-project-upload-meta dt, +.sandbox-project-upload-meta dd { + margin: 0; +} + +.sandbox-project-upload-meta dt { + color: hsl(var(--muted-foreground)); + font-size: 11.5px; +} + +.sandbox-project-upload-meta dd { + margin-top: 4px; + overflow-wrap: anywhere; + font-size: 12px; +} + +.sandbox-project-upload-actions { + display: flex; + justify-content: flex-end; + gap: 8px; + padding: 12px 16px; + border-top: 1px solid hsl(var(--border)); +} + +.sandbox-project-upload-actions button { + min-width: 86px; + padding: 0 14px; +} + +.sandbox-project-upload-actions .is-primary { + border-color: hsl(var(--foreground)); + background: hsl(var(--foreground)); + color: hsl(var(--background)); +} + +.sandbox-project-upload-actions .is-primary:hover { + background: hsl(var(--foreground) / 0.9); +} + +.sandbox-project-upload-dialog button:focus-visible, +.sandbox-project-upload-command pre:focus-visible { + outline: 2px solid hsl(var(--ring) / 0.34); + outline-offset: 2px; +} + +@keyframes sandbox-project-upload-enter { + from { + opacity: 0; + transform: translateY(8px) scale(0.98); + } + to { + opacity: 1; + transform: translateY(0) scale(1); + } +} + +@keyframes sandbox-project-upload-spin { + to { + transform: rotate(360deg); + } +} + +@media (max-width: 640px) { + .sandbox-project-upload-backdrop { + padding: 10px; + } + + .sandbox-project-upload-dialog { + width: calc(100vw - 20px); + max-height: calc(100vh - 20px); + } + + .sandbox-project-upload-head, + .sandbox-project-upload-body { + padding-left: 16px; + padding-right: 16px; + } + + .sandbox-project-upload-meta { + grid-template-columns: 1fr; + } + + .sandbox-project-upload-actions button { + flex: 1; + } +} + +@media (prefers-reduced-motion: reduce) { + .sandbox-project-upload-dialog, + .sandbox-project-upload-loading i { + animation: none; + } +} diff --git a/frontend/src/ui/SandboxProjectUploadDialog.tsx b/frontend/src/ui/SandboxProjectUploadDialog.tsx new file mode 100644 index 00000000..51253a20 --- /dev/null +++ b/frontend/src/ui/SandboxProjectUploadDialog.tsx @@ -0,0 +1,300 @@ +import { useEffect, useMemo, useRef, useState } from "react"; +import { createPortal } from "react-dom"; +import { Check, Copy, RefreshCw, X } from "lucide-react"; +import { + CODEX_PROJECT_UPLOAD_AUTHORIZATION_TTL_SECONDS, + sandboxClient, + type CodexProjectUploadAuthorization, +} from "../adk/sandbox"; +import "./SandboxProjectUploadDialog.css"; + +type CopyTarget = "install" | "upload" | ""; + +export interface SandboxProjectUploadDialogProps { + open: boolean; + onClose: () => void; + onRefreshAgents: () => void; +} + +function trimStudioUrl(value: string): string { + return value.trim().replace(/\/+$/, "") || window.location.origin; +} + +function codexUploadPrompt(authorization: CodexProjectUploadAuthorization): string { + const studioUrl = trimStudioUrl(authorization.studioUrl); + return [ + "请使用 codex-sandbox-upload skill 将当前项目上传到 Studio 云端 Codex Sandbox。", + "", + "参数:", + "- repo: 当前工作目录", + `- studio_url: ${studioUrl}`, + `- authorization_code: ${authorization.authorizationCode}`, + "", + "上传完成后,请告诉我远端项目目录和恢复结果。", + ].join("\n"); +} + +const INSTALL_COMMAND = [ + "codex skill install codex-sandbox-upload", + 'test -x "$HOME/.codex/skills/codex-sandbox-upload/scripts/upload_current_dir.sh"', +].join("\n"); + +function authorizationValidityLabel(): string { + const hours = CODEX_PROJECT_UPLOAD_AUTHORIZATION_TTL_SECONDS / 3600; + return Number.isInteger(hours) ? `${hours} 小时` : "1 小时"; +} + +export function SandboxProjectUploadDialog({ + open, + onClose, + onRefreshAgents, +}: SandboxProjectUploadDialogProps) { + const dialogRef = useRef(null); + const closeRef = useRef(null); + const onCloseRef = useRef(onClose); + const requestRef = useRef(0); + const copyTimerRef = useRef(undefined); + const [authorization, setAuthorization] = + useState(null); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(""); + const [copyTarget, setCopyTarget] = useState(""); + onCloseRef.current = onClose; + + const codexPrompt = useMemo( + () => authorization ? codexUploadPrompt(authorization) : "", + [authorization], + ); + + useEffect(() => { + if (!open) return; + const controller = new AbortController(); + const requestId = ++requestRef.current; + setAuthorization(null); + setError(""); + setCopyTarget(""); + setLoading(true); + void sandboxClient + .createCodexProjectUploadAuthorization({ signal: controller.signal }) + .then((value) => { + if (requestRef.current === requestId) setAuthorization(value); + }) + .catch((cause) => { + if ((cause as Error)?.name === "AbortError") return; + if (requestRef.current === requestId) { + setError(cause instanceof Error ? cause.message : String(cause)); + } + }) + .finally(() => { + if (requestRef.current === requestId) setLoading(false); + }); + return () => { + controller.abort(); + }; + }, [open]); + + useEffect(() => () => { + if (copyTimerRef.current !== undefined) { + window.clearTimeout(copyTimerRef.current); + } + }, []); + + useEffect(() => { + if (!open) return; + const previousOverflow = document.body.style.overflow; + document.body.style.overflow = "hidden"; + const focusFrame = window.requestAnimationFrame(() => closeRef.current?.focus()); + const handleKeyDown = (event: KeyboardEvent) => { + if (event.key === "Escape") { + event.preventDefault(); + onCloseRef.current(); + return; + } + if (event.key !== "Tab") return; + const controls = dialogRef.current?.querySelectorAll( + 'button:not(:disabled), [tabindex]:not([tabindex="-1"])', + ); + if (!controls?.length) return; + const first = controls[0]; + const last = controls[controls.length - 1]; + if (event.shiftKey && document.activeElement === first) { + event.preventDefault(); + last.focus(); + } else if (!event.shiftKey && document.activeElement === last) { + event.preventDefault(); + first.focus(); + } + }; + window.addEventListener("keydown", handleKeyDown); + return () => { + window.cancelAnimationFrame(focusFrame); + document.body.style.overflow = previousOverflow; + window.removeEventListener("keydown", handleKeyDown); + }; + }, [open]); + + if (!open) return null; + + async function copy(value: string, target: CopyTarget) { + if (!value || copyTarget) return; + setError(""); + setCopyTarget(target); + try { + if (!navigator.clipboard?.writeText) { + throw new Error("当前浏览器不支持写入剪贴板。"); + } + await navigator.clipboard.writeText(value); + if (copyTimerRef.current !== undefined) { + window.clearTimeout(copyTimerRef.current); + } + copyTimerRef.current = window.setTimeout(() => { + setCopyTarget((current) => current === target ? "" : current); + copyTimerRef.current = undefined; + }, 1400); + } catch (cause) { + setCopyTarget(""); + setError(cause instanceof Error ? cause.message : String(cause)); + } + } + + return createPortal( +
{ + if (event.target === event.currentTarget) onClose(); + }} + > +
+
+
+

迁移本地 Codex 项目

+

复制 Prompt 给本地 Codex,Codex 会调用上传 Skill 创建云端智能体。

+
+ +
+ +
+ {error ? ( +
+ {error} + +
+ ) : null} + +
    +
  1. + 1 +
    + 安装或更新上传 Skill +

    已安装 codex-sandbox-upload 时可跳过。

    +
    +
  2. +
  3. + 2 +
    + 把 Prompt 交给本地 Codex +

    Codex 会使用上传 Skill 迁移当前项目,完成后可刷新列表查看。

    +
    +
  4. +
+ +
+
+ 安装 Skill + +
+
{INSTALL_COMMAND}
+
+ +
+
+
+ 上传项目 +

这是给本地 Codex 的 Prompt,复制后粘贴给 Codex 执行。

+
+ +
+ {loading ? ( +
+