diff --git a/README.md b/README.md index a20bc3ef..ba0ef475 100644 --- a/README.md +++ b/README.md @@ -107,7 +107,9 @@ tool, KnowledgeBase, or long-term memory without exposing component credentials. Studio also manages user-owned Codex, OpenClaw, and Hermes AgentKit Sessions. Users can create, reopen, inspect, and explicitly delete each Agent; leaving a Codex conversation only disconnects it, while OpenClaw and Hermes expose their -main interface and Terminal through Studio. +main interface and Terminal through Studio. When AgentKit has retained a +snapshot but no Session is running, Studio shows the Agent as wakeable and can +restore it from that snapshot before reopening it. When configuring skills, Studio can also browse account-scoped AgentKit Skill Spaces and their paginated skill lists by region and project. These requests are signed on the server, so browser clients never receive Volcengine credentials. diff --git a/docs/content/docs/framework/frontend.en.mdx b/docs/content/docs/framework/frontend.en.mdx index 9e6ff824..a2a95db4 100644 --- a/docs/content/docs/framework/frontend.en.mdx +++ b/docs/content/docs/framework/frontend.en.mdx @@ -220,6 +220,12 @@ export SANDBOX_SKILL_CREATOR= veadk frontend --agents-dir examples ``` +Enable snapshots on the Codex, OpenClaw, and Hermes Tools. When AgentKit retains +a snapshot but no Session is running, Studio marks the Agent as **Wakeable** and +restores the latest snapshot before opening it. A cloud Studio deployment enables +this capability on both newly created and explicitly configured Agent Tools; +the Skill-creation Tool remains snapshot-free. + For local Studio, configure the Ark model variables on both dedicated CodeEnv Tools. Adding a candidate to AgentKit uploads its ZIP to TOS, then creates or updates the Skill through the Skills API. It is published to a SkillSpace only diff --git a/docs/content/docs/framework/frontend.mdx b/docs/content/docs/framework/frontend.mdx index 86638a3a..0d105a8a 100644 --- a/docs/content/docs/framework/frontend.mdx +++ b/docs/content/docs/framework/frontend.mdx @@ -26,6 +26,10 @@ VeADK 自带一个 React 前端,用于渲染智能体经由 Google ADK API Ser Codex 进行多轮对话,实时展示公开思考摘要、命令、文件修改、MCP 调用和最终回复。 退出后云端 Session 会被删除,内容不会写入普通历史会话; 页面刷新后可直接新建,遗留 Session 由 AgentKit 在 TTL 到期后自动回收。 +- **沙箱智能体快照**:Codex、OpenClaw 和 Hermes Tool 开启快照后,AgentKit 已保存 + 快照但没有运行中 Session 的智能体会显示为“可唤醒”。点击后 Studio 使用最新快照 + 恢复 Session,再进入 Codex 对话或 OpenClaw/Hermes 主界面;同一智能体已有运行中 + Session 时不会重复展示快照。 - **智能搜索**:「会话」源在当前 agent 的历史消息中做全文检索;「网页」源调用该 agent 挂载的联网搜索工具实时检索(使用服务端环境变量里的凭据)。 - **消息反馈回流**:连接云端 AgentKit Runtime 时,回答下方的赞/踩按钮会将 当前问题、回答和反馈状态写入 AgentKit 评测集。每个 Agent 自动维护 @@ -200,11 +204,11 @@ ZIP 上传到 TOS,再调用 Skills API 创建或更新 Skill;只有用户填 候选 Session 的 TTL 为 30 分钟,用户重新创建或离开任务时会立即清理。任务状态保存 在 Sandbox 而非 Frontend 进程内存,因此 FaaS 请求命中不同实例时仍可继续轮询和下载。 -云上首次部署时,若没有指定 Tool ID,部署命令会使用部署 AK/SK 并行创建两个独立 -CodeEnv Tool。也可以分别使用 `--sandbox-chat-codex-tool-id` 和 -`--sandbox-skill-creator-tool-id` 指定已有 Tool。部署命令会通过 AgentKit 凭据托管 -将 Ark 凭据保存到 KMS,并把代理地址和可撤销票据绑定到两个 Tool;VeFaaS Function -只接收 Tool ID,不接收模型凭据: +云上首次部署时,若没有指定 Tool ID,部署命令会使用部署 AK/SK 并行创建 Codex、 +OpenClaw、Hermes 和 Skill Creator 四个独立 Tool,并为前三个 Tool 开启自动快照。 +也可以使用对应的 `--sandbox-*-tool-id` 参数指定已有 Tool;部署命令同样会检查并开启 +其快照能力。部署命令会通过 AgentKit 凭据托管将 Ark 凭据保存到 KMS,并把代理地址 +和可撤销票据绑定到 Tool;VeFaaS Function 只接收 Tool ID,不接收模型凭据: ```bash veadk studio deploy \ diff --git a/frontend/README.md b/frontend/README.md index 84e642ca..f3b431ea 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -41,7 +41,9 @@ server that `veadk frontend` launches — no separate backend. explicit deletion. Codex streams reasoning, tool activity, and replies into the normal conversation renderer; leaving the conversation only disconnects it, so the Agent remains available until the user deletes it. OpenClaw and - Hermes expose their main interface and Terminal through Studio. + Hermes expose their main interface and Terminal through Studio. If a running + Session has been reclaimed after AgentKit saved it, Studio presents its latest + snapshot as **Wakeable** and restores it before opening the Agent. - **AgentKit Skill center**: browse Skill Spaces and their skills with server-side pagination by region, then inspect the selected Skill content. - **Automation directory**: browse development and message-channel integrations @@ -397,18 +399,37 @@ assistant messages returned by the generator. Private chain-of-thought and credentials never enter the UI. Completed candidates still support preview, ZIP download, and AgentKit publish. -Configure separate ready AgentKit Tools for Codex, OpenClaw, Hermes, and Skill -creation before starting the server. The Tool IDs are intentionally server-only -and cannot be supplied by the browser: +Configure a temporary and a snapshot-enabled AgentKit Tool for each of Codex, +OpenClaw, and Hermes, plus a separate Skill Creator Tool, before starting the +server. The Tool IDs are intentionally server-only and cannot be supplied by +the browser. Studio defaults new agents to a recoverable session; users can +instead select a temporary session that is destroyed at expiry without keeping +a workspace snapshot. Skill Creator does not use snapshots: ```bash -export SANDBOX_CHAT_CODEX= -export SANDBOX_CHAT_OPENCLAW= -export SANDBOX_CHAT_HERMES= +export SANDBOX_CHAT_CODEX= +export SANDBOX_CHAT_CODEX_SNAPSHOT= +export SANDBOX_CHAT_OPENCLAW= +export SANDBOX_CHAT_OPENCLAW_SNAPSHOT= +export SANDBOX_CHAT_HERMES= +export SANDBOX_CHAT_HERMES_SNAPSHOT= export SANDBOX_SKILL_CREATOR= veadk frontend --agents-dir examples ``` +For backward compatibility, when only a legacy `SANDBOX_CHAT_*` Tool is +configured, Studio treats it as the recoverable Tool and disables the temporary +choice. A new cloud deployment automatically provisions the missing temporary +Tool. + +Studio creates new Agent Sandbox sessions with a +`studio--` UserSessionId. When listing snapshots, the +server follows every `NextToken` page and filters this prefix for the signed-in +user before returning results to the browser. Local administrators can see all +snapshots from the configured Tool, including legacy `studio2-*` snapshots. +Multiple restorable historical snapshots are kept in the result instead of +being collapsed to only the latest one. + Publishing a generated Skill uses TOS and the AgentKit Skills API. Set `VEADK_SKILL_CREATOR_TOS_BUCKET`, `VEADK_SKILL_CREATOR_TOS_PREFIX`, and `VEADK_SKILL_CREATOR_PROJECT_NAME` only when their defaults are unsuitable. @@ -418,13 +439,13 @@ memory, so polling and downloads continue to work when FaaS requests reach a different instance. For local Studio, run the AgentKit `credential-hosting` command and bind its -result to both CodeEnv Tools. A cloud deployment creates both Tools in parallel -when their IDs are omitted. Alternatively, select existing Tools with -`--sandbox-chat-codex-tool-id`, `--sandbox-chat-openclaw-tool-id`, -`--sandbox-chat-hermes-tool-id`, and `--sandbox-skill-creator-tool-id`. The -deploy command obtains the Ark key with the deployer's Volcengine credentials, -stores it through AgentKit credential hosting, and binds only the returned -ticket and relay URL to the Tools: +result to the Tools. A cloud deployment creates all missing Tools in parallel, +with snapshots enabled only on the three recoverable Tools. Alternatively, +select existing Tools with the `--sandbox-chat-*-tool-id` and +`--sandbox-chat-*-snapshot-tool-id` option pairs, plus +`--sandbox-skill-creator-tool-id`. The deploy command obtains the Ark key with +the deployer's Volcengine credentials, stores it through AgentKit credential +hosting, and binds only the returned ticket and relay URL to the Tools: ```bash veadk studio deploy \ diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 3159a30b..8500925a 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -133,9 +133,12 @@ import { sandboxClient, type SandboxApproval, type SandboxApprovalDecision, + type SandboxAgentResource, type SandboxAgentKind, type SandboxAgentWorkspace as SandboxAgentWorkspaceData, + type SandboxLaunchCapabilities, type SandboxPermissions, + type SandboxRetentionMode, type SandboxSession as SandboxSessionInfo, type SandboxSkill, type SandboxToolLaunch, @@ -727,15 +730,23 @@ export default function App() { const [sandboxLaunchState, setSandboxLaunchState] = useState("confirm"); const [sandboxLaunchError, setSandboxLaunchError] = useState(""); + const [sandboxLaunchCapabilities, setSandboxLaunchCapabilities] = + useState(null); + const [sandboxLaunchCapabilitiesLoading, setSandboxLaunchCapabilitiesLoading] = + useState(false); + const [sandboxLaunchCapabilitiesError, setSandboxLaunchCapabilitiesError] = + useState(""); const [sandboxLaunchKind, setSandboxLaunchKind] = useState<"codex" | SandboxAgentKind>("codex"); const [sandboxLaunchFromAgents, setSandboxLaunchFromAgents] = useState(false); const [sandboxAgentRefreshKey, setSandboxAgentRefreshKey] = useState(0); const [sandboxAgentDetailTarget, setSandboxAgentDetailTarget] = - useState(null); + useState(null); const [sandboxAgentWorkspace, setSandboxAgentWorkspace] = useState(null); const sandboxLaunchAbortRef = useRef(null); + const sandboxLaunchCapabilitiesAbortRef = + useRef(null); const sandboxMessageAbortRef = useRef(null); const sandboxSessionIdRef = useRef(sandboxSession?.id ?? ""); const sandboxActiveAssistantTurnIdRef = useRef(""); @@ -2161,6 +2172,7 @@ export default function App() { useEffect( () => () => { sandboxLaunchAbortRef.current?.abort(); + sandboxLaunchCapabilitiesAbortRef.current?.abort(); sandboxMessageAbortRef.current?.abort(); }, [], @@ -2252,14 +2264,51 @@ export default function App() { setSandboxLaunchKind(kind); setSandboxLaunchFromAgents(fromAgents); setSandboxLaunchOpen(true); + loadSandboxLaunchCapabilities(kind); + } + + function loadSandboxLaunchCapabilities( + kind: "codex" | SandboxAgentKind = sandboxLaunchKind, + ) { + sandboxLaunchCapabilitiesAbortRef.current?.abort(); + const controller = new AbortController(); + sandboxLaunchCapabilitiesAbortRef.current = controller; + setSandboxLaunchCapabilities(null); + setSandboxLaunchCapabilitiesLoading(true); + setSandboxLaunchCapabilitiesError(""); + void sandboxClient + .getLaunchCapabilities(kind, { signal: controller.signal }) + .then((capabilities) => { + if (sandboxLaunchCapabilitiesAbortRef.current !== controller) return; + setSandboxLaunchCapabilities(capabilities); + }) + .catch((capabilityError) => { + if ((capabilityError as Error)?.name === "AbortError") return; + if (sandboxLaunchCapabilitiesAbortRef.current !== controller) return; + setSandboxLaunchCapabilitiesError( + capabilityError instanceof Error + ? capabilityError.message + : String(capabilityError), + ); + }) + .finally(() => { + if (sandboxLaunchCapabilitiesAbortRef.current !== controller) return; + sandboxLaunchCapabilitiesAbortRef.current = null; + setSandboxLaunchCapabilitiesLoading(false); + }); } function cancelSandboxLaunch() { sandboxLaunchAbortRef.current?.abort(); sandboxLaunchAbortRef.current = null; + sandboxLaunchCapabilitiesAbortRef.current?.abort(); + sandboxLaunchCapabilitiesAbortRef.current = null; setSandboxLaunchOpen(false); setSandboxLaunchState("confirm"); setSandboxLaunchError(""); + setSandboxLaunchCapabilities(null); + setSandboxLaunchCapabilitiesLoading(false); + setSandboxLaunchCapabilitiesError(""); if ( !sandboxSession && newChatMode === "temporary" && @@ -2269,7 +2318,10 @@ export default function App() { } } - async function launchSandboxSession(displayName: string) { + async function launchSandboxSession( + displayName: string, + retentionMode: SandboxRetentionMode, + ) { sandboxLaunchAbortRef.current?.abort(); const controller = new AbortController(); sandboxLaunchAbortRef.current = controller; @@ -2277,12 +2329,14 @@ export default function App() { setSandboxLaunchError(""); try { const createdSession = sandboxLaunchKind === "codex" - ? await sandboxClient.startSession({ + ? await sandboxClient.startSession({ displayName, + retentionMode, signal: controller.signal, }) : await sandboxClient.startAgentSession(sandboxLaunchKind, { displayName, + retentionMode, signal: controller.signal, }); if (sandboxLaunchAbortRef.current !== controller) return; @@ -2349,8 +2403,14 @@ export default function App() { } } - async function openSandboxAgent(session: SandboxSessionInfo) { + async function openSandboxAgent(resource: SandboxAgentResource) { setError(""); + const session = resource.resourceType === "snapshot" + ? await sandboxClient.resumeSnapshot(resource.toolName, resource.snapshotId) + : resource; + if (resource.resourceType === "snapshot") { + setSandboxAgentRefreshKey((current) => current + 1); + } if (session.toolName === "codex") { const connected = await sandboxClient.connectSession(session.id); viewSidRef.current = ""; @@ -2377,7 +2437,7 @@ export default function App() { setManageAgents(false); } - function openSandboxAgentDetails(session: SandboxSessionInfo) { + function openSandboxAgentDetails(session: SandboxAgentResource) { setSandboxAgentDetailTarget(session); setSandboxAgentWorkspace(null); setMyAgents(false); @@ -2385,12 +2445,16 @@ export default function App() { setError(""); } - async function deleteSandboxAgent(session: SandboxSessionInfo) { - if (sandboxSession?.id === session.id) exitSandboxSession(); - if (session.toolName === "codex") { - await sandboxClient.deleteSession(session.id); + async function deleteSandboxAgent(session: SandboxAgentResource) { + if (session.resourceType === "snapshot") { + await sandboxClient.deleteSnapshot(session.toolName, session.snapshotId); } else { - await sandboxClient.deleteAgentSession(session.toolName, session.id); + if (sandboxSession?.id === session.id) exitSandboxSession(); + if (session.toolName === "codex") { + await sandboxClient.deleteSession(session.id); + } else { + await sandboxClient.deleteAgentSession(session.toolName, session.id); + } } setSandboxAgentDetailTarget(null); setSandboxAgentWorkspace(null); @@ -5037,8 +5101,16 @@ export default function App() { state={sandboxLaunchState} agentKind={sandboxLaunchKind} error={sandboxLaunchError} + capabilities={sandboxLaunchCapabilities} + capabilitiesLoading={sandboxLaunchCapabilitiesLoading} + capabilitiesError={sandboxLaunchCapabilitiesError} + onRetryCapabilities={() => { + loadSandboxLaunchCapabilities(); + }} onCancel={cancelSandboxLaunch} - onConfirm={(displayName) => void launchSandboxSession(displayName)} + onConfirm={(displayName, retentionMode) => { + void launchSandboxSession(displayName, retentionMode); + }} /> {sandboxSession ? ( diff --git a/frontend/src/adk/sandbox.ts b/frontend/src/adk/sandbox.ts index 930f6231..06a8f742 100644 --- a/frontend/src/adk/sandbox.ts +++ b/frontend/src/adk/sandbox.ts @@ -19,6 +19,8 @@ export function sandboxStatusLabel(status: string): string { switch (status.trim().toLowerCase()) { case "ready": return "就绪"; + case "wakeable": + return "可唤醒"; case "creating": return "创建中"; case "starting": @@ -176,11 +178,27 @@ export interface SandboxRequestOptions { onUsage?: (update: SandboxTokenUsageUpdate) => void; } +export type SandboxRetentionMode = "temporary" | "recoverable"; + +export interface SandboxRetentionCapability { + enabled: boolean; + reason?: string; +} + +export interface SandboxLaunchCapabilities { + enabled: boolean; + reason?: string; + defaultRetentionMode: SandboxRetentionMode; + retentionModes: Record; +} + export interface SandboxStartOptions extends SandboxRequestOptions { displayName?: string; + retentionMode?: SandboxRetentionMode; } export interface SandboxSession { + resourceType: "session"; id: string; toolName: "codex" | SandboxAgentKind; userSessionId: string; @@ -198,6 +216,23 @@ export interface SandboxSession { permissions: SandboxPermissions; } +export interface SandboxSnapshot { + resourceType: "snapshot"; + id: string; + snapshotId: string; + sourceSessionId: string; + toolName: "codex" | SandboxAgentKind; + userSessionId: string; + displayName: string; + status: string; + snapshotStatus: string; + reason: string; + createdAt: string; + createdBy: string; +} + +export type SandboxAgentResource = SandboxSession | SandboxSnapshot; + export interface SandboxAgentWorkspace { session: SandboxSession; kind: SandboxAgentKind; @@ -217,12 +252,16 @@ export interface SandboxReply { } export interface AgentKitSandboxClient { - listSessions(options?: SandboxRequestOptions): Promise; + getLaunchCapabilities( + kind: "codex" | SandboxAgentKind, + options?: SandboxRequestOptions, + ): Promise; + listSessions(options?: SandboxRequestOptions): Promise; startSession(options?: SandboxStartOptions): Promise; listAgentSessions( kind: SandboxAgentKind, options?: SandboxRequestOptions, - ): Promise; + ): Promise; startAgentSession( kind: SandboxAgentKind, options?: SandboxStartOptions, @@ -242,6 +281,16 @@ export interface AgentKitSandboxClient { sessionId: string, options?: SandboxRequestOptions, ): Promise; + resumeSnapshot( + kind: "codex" | SandboxAgentKind, + snapshotId: string, + options?: SandboxRequestOptions, + ): Promise; + deleteSnapshot( + kind: "codex" | SandboxAgentKind, + snapshotId: string, + options?: SandboxRequestOptions, + ): Promise; connectSession( sessionId: string, options?: SandboxRequestOptions, @@ -371,6 +420,19 @@ interface SessionResponse { interface ListSessionsResponse { sessions?: SessionResponse[]; + snapshots?: SnapshotResponse[]; +} + +interface SnapshotResponse { + snapshotId: string; + sessionId?: string; + userSessionId?: string; + displayName?: string; + status: string; + snapshotStatus?: string; + reason?: string; + createdAt?: string; + createdBy?: string; } interface SandboxErrorPayload { @@ -379,6 +441,13 @@ interface SandboxErrorPayload { message?: unknown; } +interface SandboxCapabilitiesResponse { + enabled?: unknown; + reason?: unknown; + defaultRetentionMode?: unknown; + retentionModes?: unknown; +} + interface SandboxStreamPayload { id?: unknown; kind?: unknown; @@ -440,6 +509,7 @@ function parseSession( throw new Error("AgentKit 沙箱返回了无效的 Session 信息。"); } return { + resourceType: "session", id: data.sessionId, toolName, userSessionId: data.userSessionId ?? "", @@ -458,6 +528,29 @@ function parseSession( }; } +function parseSnapshot( + data: SnapshotResponse, + toolName: SandboxSession["toolName"] = "codex", +): SandboxSnapshot { + if (!data.snapshotId || !data.status) { + throw new Error("AgentKit 沙箱返回了无效的 Snapshot 信息。"); + } + return { + resourceType: "snapshot", + id: data.snapshotId, + snapshotId: data.snapshotId, + sourceSessionId: data.sessionId ?? "", + toolName, + userSessionId: data.userSessionId ?? "", + displayName: data.displayName ?? "", + status: data.status, + snapshotStatus: data.snapshotStatus ?? "Unknown", + reason: data.reason ?? "", + createdAt: data.createdAt ?? "", + createdBy: data.createdBy ?? "", + }; +} + const DEFAULT_PERMISSIONS: SandboxPermissions = { approvalPolicy: "on-request", approvalsReviewer: "user", @@ -833,7 +926,65 @@ async function sandboxJson( return response.json(); } +function parseLaunchCapabilities( + data: SandboxCapabilitiesResponse, +): SandboxLaunchCapabilities { + const modes = data.retentionModes; + if ( + typeof data.enabled !== "boolean" || + !modes || + typeof modes !== "object" + ) { + throw new Error("AgentKit 沙箱返回了无效的会话类型配置。"); + } + const modeRecord = modes as Record; + const parseMode = (mode: SandboxRetentionMode): SandboxRetentionCapability => { + const value = modeRecord[mode]; + if (!value || typeof value !== "object") { + throw new Error("AgentKit 沙箱返回了无效的会话类型配置。"); + } + const capability = value as Record; + if (typeof capability.enabled !== "boolean") { + throw new Error("AgentKit 沙箱返回了无效的会话类型配置。"); + } + return { + enabled: capability.enabled, + ...(typeof capability.reason === "string" + ? { reason: capability.reason } + : {}), + }; + }; + const defaultRetentionMode = data.defaultRetentionMode; + if (defaultRetentionMode !== "temporary" && defaultRetentionMode !== "recoverable") { + throw new Error("AgentKit 沙箱返回了无效的默认会话类型。"); + } + return { + enabled: data.enabled, + ...(typeof data.reason === "string" ? { reason: data.reason } : {}), + defaultRetentionMode, + retentionModes: { + temporary: parseMode("temporary"), + recoverable: parseMode("recoverable"), + }, + }; +} + export const sandboxClient: AgentKitSandboxClient = { + async getLaunchCapabilities(kind, options = {}) { + const root = kind === "codex" ? "sandbox" : kind; + const response = await fetch(withAuth(`/web/${root}/capabilities`), { + method: "GET", + headers: sandboxHeaders(), + signal: requestSignal(options.signal, LIST_TIMEOUT_MS), + }); + if (!response.ok) { + throw await responseError(response, "无法读取智能体会话类型,请稍后重试。"); + } + return parseLaunchCapabilities( + (await response.json()) as SandboxCapabilitiesResponse, + ); + }, + async listSessions(options = {}) { const response = await fetch(withAuth(SANDBOX_API), { method: "GET", @@ -847,14 +998,23 @@ export const sandboxClient: AgentKitSandboxClient = { if (!Array.isArray(data.sessions)) { throw new Error("AgentKit 沙箱返回了无效的 Session 列表。"); } - return data.sessions.map((session) => parseSession(session)); + if (data.snapshots !== undefined && !Array.isArray(data.snapshots)) { + throw new Error("AgentKit 沙箱返回了无效的 Snapshot 列表。"); + } + return [ + ...data.sessions.map((session) => parseSession(session)), + ...(data.snapshots ?? []).map((snapshot) => parseSnapshot(snapshot)), + ]; }, async startSession(options = {}) { const response = await fetch(withAuth(SANDBOX_API), { method: "POST", headers: sandboxHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ displayName: options.displayName?.trim() ?? "" }), + body: JSON.stringify({ + displayName: options.displayName?.trim() ?? "", + retentionMode: options.retentionMode ?? "recoverable", + }), signal: requestSignal(options.signal, START_TIMEOUT_MS), }); if (!response.ok) { @@ -876,14 +1036,23 @@ export const sandboxClient: AgentKitSandboxClient = { if (!Array.isArray(data.sessions)) { throw new Error(`AgentKit 返回了无效的 ${kind} Session 列表。`); } - return data.sessions.map((session) => parseSession(session, kind)); + if (data.snapshots !== undefined && !Array.isArray(data.snapshots)) { + throw new Error(`AgentKit 返回了无效的 ${kind} Snapshot 列表。`); + } + return [ + ...data.sessions.map((session) => parseSession(session, kind)), + ...(data.snapshots ?? []).map((snapshot) => parseSnapshot(snapshot, kind)), + ]; }, async startAgentSession(kind, options = {}) { const response = await fetch(withAuth(`/web/${kind}/sessions`), { method: "POST", headers: sandboxHeaders({ "Content-Type": "application/json" }), - body: JSON.stringify({ displayName: options.displayName?.trim() ?? "" }), + body: JSON.stringify({ + displayName: options.displayName?.trim() ?? "", + retentionMode: options.retentionMode ?? "recoverable", + }), signal: requestSignal(options.signal, START_TIMEOUT_MS), }); if (!response.ok) { @@ -959,6 +1128,39 @@ export const sandboxClient: AgentKitSandboxClient = { } }, + async resumeSnapshot(kind, snapshotId, options = {}) { + if (!snapshotId) throw new Error("缺少要唤醒的 AgentKit Snapshot。"); + const base = kind === "codex" ? "/web/sandbox" : `/web/${kind}`; + const response = await fetch( + withAuth(`${base}/snapshots/${encodeURIComponent(snapshotId)}/resume`), + { + method: "POST", + headers: sandboxHeaders(), + signal: requestSignal(options.signal, START_TIMEOUT_MS), + }, + ); + if (!response.ok) { + throw await responseError(response, "无法从快照唤醒智能体,请稍后重试。"); + } + return parseSession((await response.json()) as SessionResponse, kind); + }, + + async deleteSnapshot(kind, snapshotId, options = {}) { + if (!snapshotId) return; + const base = kind === "codex" ? "/web/sandbox" : `/web/${kind}`; + const response = await fetch( + withAuth(`${base}/snapshots/${encodeURIComponent(snapshotId)}`), + { + method: "DELETE", + headers: sandboxHeaders(), + signal: requestSignal(options.signal, CLOSE_TIMEOUT_MS), + }, + ); + if (!response.ok && response.status !== 404) { + throw await responseError(response, "无法删除智能体快照。"); + } + }, + async connectSession(sessionId, options = {}) { if (!sessionId) throw new Error("缺少要连接的 AgentKit Session。"); const response = await fetch( diff --git a/frontend/src/ui/Composer.tsx b/frontend/src/ui/Composer.tsx index a8289059..12f0bbd7 100644 --- a/frontend/src/ui/Composer.tsx +++ b/frontend/src/ui/Composer.tsx @@ -24,7 +24,7 @@ import type { FrontendInvocation, RuntimeScope, } from "../adk/client"; -import type { SandboxSession } from "../adk/sandbox"; +import type { SandboxAgentResource } from "../adk/sandbox"; import { InvocationChips } from "./InvocationChips"; import { MediaGroup } from "./Media"; import { isImeCompositionEvent } from "./composerKeyboard"; @@ -141,7 +141,7 @@ export interface ComposerProps { selectedRuntimeId?: string; runtimeScope?: RuntimeScope; onSelectRuntime?: (runtime: CloudRuntime) => Promise; - onSelectSandboxSession?: (session: SandboxSession) => Promise; + onSelectSandboxSession?: (session: SandboxAgentResource) => Promise; } export function Composer({ diff --git a/frontend/src/ui/MyAgents.css b/frontend/src/ui/MyAgents.css index 37e33ab4..5db5989b 100644 --- a/frontend/src/ui/MyAgents.css +++ b/frontend/src/ui/MyAgents.css @@ -366,6 +366,12 @@ color: hsl(142 55% 28%); } +.my-agent-status-label[data-wakeable] { + border-color: hsl(43 90% 48% / 0.3); + background: hsl(45 96% 52% / 0.14); + color: hsl(38 78% 36%); +} + .my-agent-meta, .my-agent-meta dt, .my-agent-meta dd { diff --git a/frontend/src/ui/MyAgents.tsx b/frontend/src/ui/MyAgents.tsx index 999e7223..5c619227 100644 --- a/frontend/src/ui/MyAgents.tsx +++ b/frontend/src/ui/MyAgents.tsx @@ -12,8 +12,8 @@ import { import { sandboxClient, sandboxStatusLabel, + type SandboxAgentResource, type SandboxAgentKind, - type SandboxSession, } from "../adk/sandbox"; import { formatRequestError } from "../adk/requestError"; import type { WorkspaceAgentDraft } from "../create/agentDraftStorage"; @@ -38,7 +38,7 @@ export interface MyAgentCardData { currentVersion?: number | null; canDelete: boolean; }; - sandbox?: SandboxSession; + sandbox?: SandboxAgentResource; draft?: WorkspaceAgentDraft; } @@ -141,7 +141,7 @@ function runtimeToAgent(runtime: CloudRuntime): MyAgentCardData { }; } -function sandboxToAgent(session: SandboxSession): MyAgentCardData { +function sandboxToAgent(session: SandboxAgentResource): MyAgentCardData { return { id: session.id, name: session.displayName || `${session.toolName} 智能体`, @@ -223,7 +223,14 @@ function AgentCard({ onEditDraft?: (draft: WorkspaceAgentDraft) => void; onDeleteDraft?: (draft: WorkspaceAgentDraft) => void; }) { - const actionable = Boolean(agent.runtime || agent.sandbox); + const sandboxStatus = agent.sandbox?.status.toLowerCase(); + const wakeable = agent.sandbox?.resourceType === "snapshot"; + const actionable = Boolean( + agent.runtime || sandboxStatus === "ready" || sandboxStatus === "wakeable", + ); + const sandboxResourceId = agent.sandbox?.resourceType === "snapshot" + ? agent.sandbox.sourceSessionId || agent.sandbox.snapshotId + : agent.sandbox?.id; return (
@@ -231,8 +238,8 @@ function AgentCard({

{agent.name}

{agent.sandbox ? ( - - {agent.sandbox.id} + + {sandboxResourceId} ) : null}
@@ -244,6 +251,7 @@ function AgentCard({ {agent.description} @@ -319,15 +327,19 @@ function AgentCard({ className={`my-agent-use${connected ? " is-connected" : ""}`} disabled={!actionable || connecting || connected} aria-busy={connecting || undefined} - aria-label={connected ? `${agent.name} 已连接` : `使用 ${agent.name}`} + aria-label={connected + ? `${agent.name} 已连接` + : wakeable + ? `唤醒 ${agent.name}` + : `使用 ${agent.name}`} onClick={() => void onUse?.(agent)} > {connecting ? ( <>
@@ -132,7 +140,7 @@ export function SandboxAgentDetails({ >
删除智能体?
- 将删除“{session.displayName || `${label} 智能体`}”及其 AgentKit Session,此操作无法撤销。 + 将删除“{session.displayName || `${label} 智能体`}”的 AgentKit {wakeable ? "Snapshot" : "Session"},此操作无法撤销。
+ ) : null} +
+ ) : ( +
+ + +
+ )} +