diff --git a/docs/content/docs/framework/frontend.mdx b/docs/content/docs/framework/frontend.mdx index a43e4530e..3596a47c0 100644 --- a/docs/content/docs/framework/frontend.mdx +++ b/docs/content/docs/framework/frontend.mdx @@ -518,3 +518,15 @@ export function RevenueChart({ node, ctx }: ComponentRendererProps) { 未注册渲染器的未知组件会回退到可折叠的 JSON 视图,因此目录与渲染器不匹配也不会导致界面崩溃。 + + +## 沙箱版本更新 + +管理员可在「系统信息 → 沙箱信息」检查沙箱版本。Studio 按云厂商和沙箱实际区域 +查询发布镜像,展示当前版本和目标版本;存在差异时可点击对应沙箱的更新按钮。 +Volcengine 和 BytePlus 使用各自的凭据及接口,镜像目录不会跨厂商、区域复用。 + +Codex 和 DeepSeek Harness 共用 Tool 时,两处同步显示更新状态,只需更新一次。 +快照版使用对应的 Tool 单独检查。Codex 缺失的模型环境变量仍会从原 `CODEX_*` +配置补齐。更新完成表示 Tool 已就绪且镜像匹配目标版本,不代表已有 Session 或 +历史快照已切换镜像。版本查询失败可点击「检查更新」重试。 diff --git a/frontend/README.md b/frontend/README.md index 3815af74f..9a9467625 100644 --- a/frontend/README.md +++ b/frontend/README.md @@ -5,6 +5,18 @@ server that `veadk frontend` launches — no separate backend. ## Features +- **Sandbox updates** in System Information compare each Tool's current image + with `ListToolTypes` for its cloud provider and actual region. Volcengine and + BytePlus use their own credentials and API hosts; catalogs are cached for + 60 seconds and fetched again before an update. Admins can update configured + prebuilt Tools with `UpdateTool.ImageUrl`. Codex and DeepSeek Harness share + update state when they reference the same Tool; snapshot Tools are checked + independently. Missing Codex `MODEL_AGENT_API_KEY` / `MODEL_AGENT_BASE_URL` + values are still backfilled from `CODEX_*`, preserving existing variables. + Completion requires Tool `Ready` and the target image; this does not verify + existing Sessions or rebuild their snapshots. BytePlus has automated coverage, + but its image update has not been verified against a live account. + - **Streaming chat** over the ADK `/run_sse` event stream. While an Agent is generating, the composer exposes a stop control that cancels only the active response, preserves content already received, and immediately enables the diff --git a/frontend/server/sandbox_updates.py b/frontend/server/sandbox_updates.py new file mode 100644 index 000000000..6faa41694 --- /dev/null +++ b/frontend/server/sandbox_updates.py @@ -0,0 +1,74 @@ +# Copyright (c) 2025 Beijing Volcano Engine Technology Co., Ltd. and/or its affiliates. +# +# Licensed under the Apache License, Version 2.0 (the "License"); +# you may not use this file except in compliance with the License. +# You may obtain a copy of the License at +# +# http://www.apache.org/licenses/LICENSE-2.0 +# +# Unless required by applicable law or agreed to in writing, software +# distributed under the License is distributed on an "AS IS" BASIS, +# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. +# See the License for the specific language governing permissions and +# limitations under the License. + +"""Admin-only sandbox image inspection and update routes.""" + +from __future__ import annotations + +import asyncio +from collections.abc import Callable +from typing import Any + +from fastapi import FastAPI, HTTPException, Request + +from veadk.cli.studio_sandbox_updates import SandboxToolUpdates, SandboxUpdateError + + +def register_sandbox_update_routes( + app: FastAPI, + *, + service: SandboxToolUpdates, + require_admin: Callable[[Request], None], + configured_tools: Callable[[], dict[str, str]], +) -> None: + @app.get("/web/system-info/sandbox-tools/updates") + async def inspect_updates(request: Request): + require_admin(request) + # Codex and DSH aliases resolve to one physical resource. + tool_ids = set(configured_tools().values()) - {""} + + async def inspect(tool_id: str) -> dict[str, Any]: + try: + return await asyncio.wait_for( + asyncio.to_thread(service.inspect, tool_id), timeout=15 + ) + except TimeoutError: + return {"toolId": tool_id, "error": "查询沙箱版本超时,请刷新重试"} + except Exception: + # SDK errors can include request bodies or credentials. Keep + # this response separate from the raw control-plane exception. + return { + "toolId": tool_id, + "error": "查询沙箱版本失败,请检查凭据、区域及接口权限后重试", + } + + return {"tools": await asyncio.gather(*(inspect(t) for t in sorted(tool_ids)))} + + @app.post("/web/system-info/sandbox-tools/{kind}/update") + async def update(request: Request, kind: str): + require_admin(request) + tool_id = configured_tools().get(kind) + if tool_id is None: + raise HTTPException(status_code=400, detail="Unsupported Sandbox Tool kind") + if not tool_id: + raise HTTPException(status_code=400, detail="未配置 Sandbox Tool ID") + try: + result = await asyncio.to_thread(service.update, tool_id) + except (SandboxUpdateError, TimeoutError) as error: + raise HTTPException(status_code=409, detail=str(error)) from error + except Exception as error: + raise HTTPException( + status_code=502, detail="Sandbox 更新失败,请刷新检查实际状态及接口权限" + ) from error + return {"kind": kind, "toolId": tool_id, **result} diff --git a/frontend/src/adk/client.ts b/frontend/src/adk/client.ts index e8cd6135a..191b287a6 100644 --- a/frontend/src/adk/client.ts +++ b/frontend/src/adk/client.ts @@ -5040,3 +5040,58 @@ export async function deleteGeneratedAgentTestRun(runId: string): Promise throw new Error(await httpErrorMessage(res, adkT("client.cleanupDebugRunFailed"))); } } + +export interface SandboxImageState { + toolId: string; + error: string; + provider?: CloudProvider; + region?: string; + toolType?: string; + status?: string; + currentImage?: string; + latestImage?: string; + needsImageUpdate?: boolean; + needsModelEnvUpdate?: boolean; + canUpdateModelEnv?: boolean; + modelEnvError?: string; + canUpdate?: boolean; +} + +function sandboxImageState(value: unknown): SandboxImageState { + if (!value || typeof value !== "object") throw new Error(adkT("client.invalidSandboxVersion")); + const row = value as SandboxImageState; + if (typeof row.toolId !== "string" || typeof row.error !== "string") { + throw new Error(adkT("client.invalidSandboxVersion")); + } + if (!row.error && ( + (row.provider !== "volcengine" && row.provider !== "byteplus") || + typeof row.region !== "string" || typeof row.status !== "string" || + typeof row.currentImage !== "string" || typeof row.latestImage !== "string" || + typeof row.needsImageUpdate !== "boolean" || typeof row.canUpdate !== "boolean" || + typeof row.needsModelEnvUpdate !== "boolean" || + typeof row.canUpdateModelEnv !== "boolean" || typeof row.modelEnvError !== "string" + )) throw new Error(adkT("client.invalidSandboxVersion")); + return row; +} + +export async function getSandboxImageUpdates(signal?: AbortSignal): Promise { + const response = await apiFetch("/web/system-info/sandbox-tools/updates", { signal }); + if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.loadSandboxVersionsFailed"))); + const payload = await response.json() as { tools?: unknown }; + if (!Array.isArray(payload.tools)) throw new Error(adkT("client.invalidSandboxVersion")); + return payload.tools.map(sandboxImageState); +} + +export async function updateSandboxTool(kind: SandboxToolKind): Promise<{ + updated: boolean; + state: SandboxImageState; +}> { + const response = await apiFetch( + `/web/system-info/sandbox-tools/${encodeURIComponent(kind)}/update`, + { method: "POST" }, {}, 330_000, + ); + if (!response.ok) throw new Error(await httpErrorMessage(response, adkT("client.updateSandboxFailed"))); + const payload = await response.json() as { updated?: unknown; state?: unknown }; + if (typeof payload.updated !== "boolean") throw new Error(adkT("client.invalidSandboxUpdate")); + return { updated: payload.updated, state: sandboxImageState(payload.state) }; +} diff --git a/frontend/src/i18n/resources/en-US/adk.json b/frontend/src/i18n/resources/en-US/adk.json index ce730e1ba..9ad4906ce 100644 --- a/frontend/src/i18n/resources/en-US/adk.json +++ b/frontend/src/i18n/resources/en-US/adk.json @@ -326,6 +326,10 @@ "unsafeToolUrl": "{{label}} returned an unsafe URL." }, "client": { + "invalidSandboxVersion": "Invalid sandbox version response", + "loadSandboxVersionsFailed": "Failed to check sandbox versions", + "updateSandboxFailed": "Failed to update sandbox", + "invalidSandboxUpdate": "Invalid sandbox update response", "errorWithDetailAndRawResponse": "{{context}}\n{{detail}}\nRaw response:\n{{response}}", "errorWithRawResponse": "{{context}}\nRaw response:\n{{response}}", "loadArkApiKeysFailed": "Failed to load Ark API keys", diff --git a/frontend/src/i18n/resources/en-US/ui.json b/frontend/src/i18n/resources/en-US/ui.json index 439cabab5..84037d96d 100644 --- a/frontend/src/i18n/resources/en-US/ui.json +++ b/frontend/src/i18n/resources/en-US/ui.json @@ -48,6 +48,13 @@ "console": "Console" }, "systemInfo": { + "checkUpdates": "Check for updates", + "checkingVersions": "Checking versions…", + "versionCheckError": "Unable to check sandbox versions. Check credentials, region and API permissions, then retry.", + "sandboxUpdateError": "Sandbox update failed. Refresh its status before retrying.", + "modelEnvRepairUnavailable": "Cannot repair model configuration. Check CODEX_API_KEY and CODEX_BASE_URL.", + "updateSandbox": "Update {{variant}}{{name}}", + "updatingSandbox": "Updating", "title": "System information", "description": "View the current Studio version and related infrastructure resources", "general": "General", diff --git a/frontend/src/i18n/resources/zh-CN/adk.json b/frontend/src/i18n/resources/zh-CN/adk.json index 928ec2495..741d2f38d 100644 --- a/frontend/src/i18n/resources/zh-CN/adk.json +++ b/frontend/src/i18n/resources/zh-CN/adk.json @@ -326,6 +326,10 @@ "unsafeToolUrl": "{{label}} 返回了不安全的地址。" }, "client": { + "invalidSandboxVersion": "沙箱版本响应格式无效", + "loadSandboxVersionsFailed": "查询沙箱版本失败", + "updateSandboxFailed": "更新 Sandbox 失败", + "invalidSandboxUpdate": "沙箱更新响应格式无效", "errorWithDetailAndRawResponse": "{{context}}\n{{detail}}\n原始响应:\n{{response}}", "errorWithRawResponse": "{{context}}\n原始响应:\n{{response}}", "loadArkApiKeysFailed": "加载 Ark API Key 失败", diff --git a/frontend/src/i18n/resources/zh-CN/ui.json b/frontend/src/i18n/resources/zh-CN/ui.json index c0047f122..4fa888cb9 100644 --- a/frontend/src/i18n/resources/zh-CN/ui.json +++ b/frontend/src/i18n/resources/zh-CN/ui.json @@ -48,6 +48,13 @@ "console": "控制台" }, "systemInfo": { + "checkUpdates": "检查更新", + "checkingVersions": "正在检查版本…", + "versionCheckError": "查询沙箱版本失败,请检查凭据、区域及接口权限后重试", + "sandboxUpdateError": "Sandbox 更新失败,请刷新检查实际状态后重试", + "modelEnvRepairUnavailable": "无法补齐模型环境变量,请检查 CODEX_API_KEY 和 CODEX_BASE_URL", + "updateSandbox": "更新{{variant}}{{name}}", + "updatingSandbox": "更新中", "title": "系统信息", "description": "查看当前 Studio 版本及关联的基础资源", "general": "通用", diff --git a/frontend/src/ui/SystemInfo.css b/frontend/src/ui/SystemInfo.css index 222ed6657..ab8415d9c 100644 --- a/frontend/src/ui/SystemInfo.css +++ b/frontend/src/ui/SystemInfo.css @@ -390,3 +390,16 @@ opacity: 1; } } + +.system-info-refresh { + border: 0; + background: transparent; + color: hsl(var(--muted-foreground)); + font: inherit; + cursor: pointer; + padding: 4px 0; + margin-bottom: 8px; +} +.system-info-refresh:hover { color: hsl(var(--foreground)); } +.system-info-refresh:disabled { cursor: wait; opacity: 0.6; } +.system-info-refresh:focus-visible { outline: 2px solid hsl(var(--primary)); outline-offset: 2px; } diff --git a/frontend/src/ui/SystemInfo.tsx b/frontend/src/ui/SystemInfo.tsx index b9b33550e..0369649ef 100644 --- a/frontend/src/ui/SystemInfo.tsx +++ b/frontend/src/ui/SystemInfo.tsx @@ -1,16 +1,15 @@ import { useEffect, useRef, useState } from "react"; -import { RefreshCw } from "lucide-react"; import { useTranslation } from "react-i18next"; import { getEnvironmentResources, getSystemInfo, listIdentityUserPools, - updateCodexSandboxToolModelEnv, - type CodexSandboxToolKind, + getSandboxImageUpdates, + updateSandboxTool, + type SandboxImageState, type IdentityUserPool, type EnvironmentResourcesResponse, type SandboxToolInfo, - type SandboxToolKind, type StudioRole, } from "../adk/client"; import type { CloudProvider } from "../adk/cloudProvider"; @@ -65,8 +64,14 @@ function isMissingLocalCredentials(cause: unknown): boolean { ); } -function isCodexSandboxToolKind(kind: SandboxToolKind): kind is CodexSandboxToolKind { - return kind === "codex" || kind === "codex_snapshot"; +function SandboxUpdateIcon({ spinning }: { spinning: boolean }) { + return ( + + ); } interface SandboxToolUpdateState { @@ -105,9 +110,23 @@ export function SystemInfo({ const [environmentResourcesReloadKey, setEnvironmentResourcesReloadKey] = useState(0); const mountedRef = useRef(false); const [sandboxToolUpdates, setSandboxToolUpdates] = useState< - Partial> + Record >({}); + const [imageStates, setImageStates] = useState>({}); + const [imageError, setImageError] = useState(""); + const [imageLoading, setImageLoading] = useState(true); + const pendingTools = useRef(new Set()); + const imageRequestVersion = useRef(0); + const scope = `${provider}:${region}`; + const scopeRef = useRef(scope); + scopeRef.current = scope; + + useEffect(() => { + setImageStates({}); + setSandboxToolUpdates({}); + }, [scope]); + useEffect(() => { mountedRef.current = true; return () => { @@ -116,7 +135,7 @@ export function SystemInfo({ }, []); function patchSandboxToolUpdate( - kind: CodexSandboxToolKind, + kind: string, patch: Partial, ) { setSandboxToolUpdates((current) => ({ @@ -130,44 +149,58 @@ export function SystemInfo({ } async function updateSandboxToolModelEnv(tool: SandboxToolInfo) { - if (!isCodexSandboxToolKind(tool.kind) || !tool.toolId) return; - const currentState = - sandboxToolUpdates[tool.kind] ?? defaultSandboxToolUpdateState(); - if (currentState.busy) return; - patchSandboxToolUpdate(tool.kind, { busy: true, error: "", message: "" }); + if (!tool.toolId || pendingTools.current.has(tool.toolId)) return; + const updateScope = scope; + pendingTools.current.add(tool.toolId); + imageRequestVersion.current += 1; + patchSandboxToolUpdate(tool.toolId, { busy: true, error: "", message: "" }); try { - const result = await updateCodexSandboxToolModelEnv(tool.kind); - if (!mountedRef.current) return; - setSandboxTools((current) => - current.map((item) => - item.kind === tool.kind - ? { - ...item, - needsModelEnvUpdate: false, - canUpdateModelEnv: false, - modelEnvError: "", - modelEnvErrorCode: "", - } - : item, - ), - ); - patchSandboxToolUpdate(tool.kind, { - busy: false, - error: "", - message: result.updated - ? t("systemInfo.modelEnvUpdated") - : t("systemInfo.modelEnvAlreadyCurrent"), + const result = await updateSandboxTool(tool.kind); + if (!mountedRef.current || scopeRef.current !== updateScope) return; + imageRequestVersion.current += 1; + setImageStates((current) => ({ ...current, [tool.toolId]: result.state })); + patchSandboxToolUpdate(tool.toolId, { + busy: false, error: "", + message: result.updated ? t("systemInfo.modelEnvUpdated") : t("systemInfo.modelEnvAlreadyCurrent"), }); } catch (cause) { - if (!mountedRef.current) return; - patchSandboxToolUpdate(tool.kind, { + if (!mountedRef.current || scopeRef.current !== updateScope) return; + imageRequestVersion.current += 1; + patchSandboxToolUpdate(tool.toolId, { busy: false, - error: t("systemInfo.modelEnvUpdateError"), + error: t("systemInfo.sandboxUpdateError"), message: "", }); + } finally { + pendingTools.current.delete(tool.toolId); } } + useEffect(() => { + if (!isAdmin) return; + const controller = new AbortController(); + const version = ++imageRequestVersion.current; + setImageError(""); + setImageLoading(true); + void getSandboxImageUpdates(controller.signal).then((states) => { + if (controller.signal.aborted || version !== imageRequestVersion.current) return; + setImageStates(Object.fromEntries(states.map((state) => [state.toolId, state]))); + }).catch(() => { + if (!controller.signal.aborted && version === imageRequestVersion.current) { + setImageError(t("systemInfo.versionCheckError")); + } + }).finally(() => { + if (!controller.signal.aborted) setImageLoading(false); + }); + return () => controller.abort(); + }, [isAdmin, provider, region, sandboxReloadKey]); + + useEffect(() => { + if (!Object.values(imageStates).some((state) => state.status === "Updating" || state.status === "Creating")) return; + const timer = window.setTimeout(() => setSandboxReloadKey((key) => key + 1), 5000); + return () => window.clearTimeout(timer); + }, [imageStates]); + useEffect(() => { if (!isAdmin) { setTosAddress(""); @@ -181,6 +214,7 @@ export function SystemInfo({ setSandboxError(""); void getSystemInfo(controller.signal) .then((systemInfo) => { + if (controller.signal.aborted) return; setTosAddress(systemInfo.storage.tosAddress); setSandboxTools(systemInfo.sandboxTools); }) @@ -192,7 +226,7 @@ export function SystemInfo({ if (!controller.signal.aborted) setSandboxLoading(false); }); return () => controller.abort(); - }, [isAdmin, sandboxReloadKey]); + }, [isAdmin, provider, region, sandboxReloadKey]); useEffect(() => { if (!isAdmin) { @@ -380,6 +414,10 @@ export function SystemInfo({ aria-labelledby="sandbox-tool-title" >

{t("systemInfo.sandboxInfo")}

+ {sandboxLoading ? (
) : (
+ {imageError ? {imageError} : null} {sandboxTools.map((tool) => { - const codexKind = isCodexSandboxToolKind(tool.kind) - ? tool.kind - : null; - const updateState = codexKind - ? sandboxToolUpdates[codexKind] - : undefined; - const updateVisible = - codexKind !== null && - Boolean(tool.toolId) && - tool.needsModelEnvUpdate && - tool.canUpdateModelEnv; - const inlineError = codexKind - ? updateState?.error || tool.modelEnvError - : ""; + const imageState = imageStates[tool.toolId]; + const updateState = sandboxToolUpdates[tool.toolId]; + const updateVisible = Boolean(tool.toolId) && Boolean(imageState?.canUpdate); + const inlineError = updateState?.error || (imageState?.error ? t("systemInfo.versionCheckError") : imageState?.modelEnvError ? t("systemInfo.modelEnvRepairUnavailable") : ""); return (
@@ -430,7 +459,7 @@ export function SystemInfo({ void updateSandboxToolModelEnv(tool)} > -