diff --git a/frontend/README.md b/frontend/README.md
index 58ef4b6d6..28f28c9ea 100644
--- a/frontend/README.md
+++ b/frontend/README.md
@@ -412,6 +412,40 @@ See [deployment and operation](service/studio_release_notifier/README.md).
deployment and never enters generated source, workflow, documentation, or
logs; cloud credentials remain GitHub Secrets or Runtime environment variables.
- **Tracing viewer**: a span tree + detail panel from the ADK debug trace.
+- **Runtime session artifacts**: in a chat that supports Studio Tools, select
+ `studio_write_artifact` for the current session and ask the Agent to save a
+ report, chart, or document. This tool runs in the Studio BFF through the
+ existing tool channel. It uses Studio's configured TOS bucket and server-side
+ credentials, including the cloud Studio IAM role's STS credentials, to write
+ `artifacts/{user_id}/{session_id}/{relative_file_path}`. User and session IDs
+ come from the authenticated tool context; model arguments do not select the
+ owner, session, bucket, or credentials. Creating and updating Agents is
+ unchanged: the tool is selected per session, with no changes to the generator,
+ default system prompt, or Runtime mount configuration.
+ The tool accepts UTF-8 text up to 1 MiB per file; saving the same relative path
+ replaces that file in the current session.
+ A persistent `会话产物` button above the chat composer opens the shared Drawer
+ and FileExplorer. Runtime access is checked before reading the signed-in
+ user's session directory. Changing sessions closes the previous preview;
+ the directory supports refresh and pagination, and refreshes when a reply
+ completes. Empty sessions and access or network failures have separate states.
+ Configure `VEADK_STUDIO_TOS_BUCKET` and `VEADK_STUDIO_TOS_REGION` in Studio;
+ the Studio execution identity needs read, write, and list access to this
+ storage. Reads and writes use the Studio bucket's region on both Volcengine
+ and BytePlus. When Studio storage is configured, previews use that bucket even
+ if the Runtime has an unrelated TOS mount. Without Studio storage, the reader
+ retains support for an existing artifact mount; configuration, authorization,
+ and TOS failures do not silently switch storage.
+ HTML, Markdown, images, JSON, and text can be previewed; other formats remain
+ downloadable. HTML uses a scriptless sandbox with inline styles and up to
+ 32 authenticated, same-session relative images. External resources and scripts
+ are disabled. Preview limits are 5 MB per file and 20 MB for embedded images;
+ larger files can be downloaded. Small files are prefetched after replies;
+ bounded per-session caches survive closing the panel, and HTML appears before
+ its relative images finish loading.
+ The BFF tool requires no Runtime TOS mount or separate mount credentials.
+ The tool saves files to the session directory; arbitrary files in `/tmp`
+ or an independent Sandbox are not collected.
- **Message feedback**: rate persisted Runtime replies with accessible,
repository-drawn like/dislike controls. Studio identifies the final ADK Event,
stores the latest rating through the existing Session state-delta API, and
diff --git a/frontend/scripts/assetImports.mjs b/frontend/scripts/assetImports.mjs
new file mode 100644
index 000000000..ee2bfc2b4
--- /dev/null
+++ b/frontend/scripts/assetImports.mjs
@@ -0,0 +1,24 @@
+import ts from "typescript";
+
+/** Read literal module references without matching examples in strings or comments. */
+export function extractJavaScriptImports(contents, fileName = "asset.js") {
+ const source = ts.createSourceFile(
+ fileName,
+ contents,
+ ts.ScriptTarget.Latest,
+ false,
+ ts.ScriptKind.JS,
+ );
+ const references = [];
+ function visit(node) {
+ const specifier = ts.isImportDeclaration(node) || ts.isExportDeclaration(node)
+ ? node.moduleSpecifier
+ : ts.isCallExpression(node) && node.expression.kind === ts.SyntaxKind.ImportKeyword
+ ? node.arguments[0]
+ : undefined;
+ if (specifier && ts.isStringLiteralLike(specifier)) references.push(specifier.text);
+ ts.forEachChild(node, visit);
+ }
+ visit(source);
+ return references;
+}
diff --git a/frontend/scripts/verifyBuiltAssets.mjs b/frontend/scripts/verifyBuiltAssets.mjs
index ff32c2768..f277f0817 100644
--- a/frontend/scripts/verifyBuiltAssets.mjs
+++ b/frontend/scripts/verifyBuiltAssets.mjs
@@ -2,6 +2,7 @@ import assert from "node:assert/strict";
import { readFile, readdir, stat } from "node:fs/promises";
import path from "node:path";
import { fileURLToPath } from "node:url";
+import { extractJavaScriptImports } from "./assetImports.mjs";
const webuiRoot = fileURLToPath(new URL("../../veadk/webui/", import.meta.url));
const baseUrl = process.argv[2]
@@ -63,11 +64,15 @@ function verifyReference(fromFile, reference) {
for (const relativeFile of relativeFiles) {
if (!/\.(?:html|css|js)$/.test(relativeFile)) continue;
const contents = await readFile(path.join(webuiRoot, relativeFile), "utf8");
+ if (relativeFile.endsWith(".js")) {
+ for (const reference of extractJavaScriptImports(contents, relativeFile)) {
+ verifyReference(relativeFile, reference);
+ }
+ continue;
+ }
const patterns = relativeFile.endsWith(".html")
? [/(?:src|href)=["']([^"']+)["']/g]
- : relativeFile.endsWith(".css")
- ? [/url\(\s*["']?([^"')]+)["']?\s*\)/g]
- : [/(?:\bfrom\s*|\bimport\s*\(\s*)["']([^"']+)["']/g];
+ : [/url\(\s*["']?([^"')]+)["']?\s*\)/g];
for (const pattern of patterns) {
for (const match of contents.matchAll(pattern)) verifyReference(relativeFile, match[1]);
}
diff --git a/frontend/server/runtime_artifacts/__init__.py b/frontend/server/runtime_artifacts/__init__.py
new file mode 100644
index 000000000..9b8122cc5
--- /dev/null
+++ b/frontend/server/runtime_artifacts/__init__.py
@@ -0,0 +1,25 @@
+# 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.
+
+"""Authorized session artifacts in Studio storage"""
+
+from .routes import mount_routes
+from .service import RuntimeArtifactAccess, RuntimeArtifactError, RuntimeArtifactService
+
+__all__ = [
+ "RuntimeArtifactAccess",
+ "RuntimeArtifactError",
+ "RuntimeArtifactService",
+ "mount_routes",
+]
diff --git a/frontend/server/runtime_artifacts/routes.py b/frontend/server/runtime_artifacts/routes.py
new file mode 100644
index 000000000..39cbff363
--- /dev/null
+++ b/frontend/server/runtime_artifacts/routes.py
@@ -0,0 +1,124 @@
+# 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.
+
+"""Authenticated list and content endpoints for Runtime session artifacts"""
+
+from __future__ import annotations
+
+import inspect
+from collections.abc import Awaitable, Callable
+from time import perf_counter
+from typing import Any
+from urllib.parse import quote
+
+from fastapi import FastAPI, HTTPException, Query, Request
+from fastapi.responses import StreamingResponse
+from starlette.background import BackgroundTask
+
+from .service import RuntimeArtifactAccess, RuntimeArtifactError, RuntimeArtifactService
+
+AccessResolver = Callable[
+ [Request, str, str, str, str],
+ RuntimeArtifactAccess | Awaitable[RuntimeArtifactAccess],
+]
+
+_CONTENT_CSP = (
+ "sandbox; default-src 'none'; script-src 'none'; "
+ "img-src 'self' data: blob:; style-src 'self' 'unsafe-inline'; "
+ "font-src 'self'; media-src 'self'; frame-ancestors 'self'; "
+ "base-uri 'none'; form-action 'none'"
+)
+
+
+def mount_routes(
+ app: FastAPI,
+ service: RuntimeArtifactService,
+ access_resolver: AccessResolver,
+) -> None:
+ async def resolve(
+ request: Request, runtime_id: str, region: str, app_name: str, session_id: str
+ ) -> RuntimeArtifactAccess:
+ access = access_resolver(request, runtime_id, region, app_name, session_id)
+ if inspect.isawaitable(access):
+ access = await access
+ return access
+
+ @app.get("/web/runtime-artifacts/{runtime_id}/sessions/{session_id}")
+ async def list_artifacts(
+ runtime_id: str,
+ session_id: str,
+ request: Request,
+ region: str = Query(
+ ..., min_length=1, max_length=64, pattern=r"^[a-z]{2}(?:-[a-z0-9]+){1,3}$"
+ ),
+ appName: str = Query(..., min_length=1, max_length=256),
+ limit: int = Query(default=200, ge=1, le=500),
+ cursor: str = Query(default="", max_length=4096),
+ ) -> dict[str, Any]:
+ access = await resolve(request, runtime_id, region, appName, session_id)
+ try:
+ return await service.list(access, session_id, limit=limit, cursor=cursor)
+ except RuntimeArtifactError as error:
+ raise HTTPException(error.status_code, detail=str(error)) from error
+
+ @app.get(
+ "/web/runtime-artifacts/{runtime_id}/sessions/{session_id}/content/{path:path}"
+ )
+ async def artifact_content(
+ runtime_id: str,
+ session_id: str,
+ path: str,
+ request: Request,
+ region: str = Query(
+ ..., min_length=1, max_length=64, pattern=r"^[a-z]{2}(?:-[a-z0-9]+){1,3}$"
+ ),
+ appName: str = Query(..., min_length=1, max_length=256),
+ download: bool = Query(default=False),
+ ) -> StreamingResponse:
+ access_started = perf_counter()
+ access = await resolve(request, runtime_id, region, appName, session_id)
+ access_ms = (perf_counter() - access_started) * 1000
+ storage_started = perf_counter()
+ try:
+ content = await service.open_content(
+ access,
+ session_id,
+ path,
+ download=download,
+ range_header=request.headers.get("range"),
+ )
+ except RuntimeArtifactError as error:
+ raise HTTPException(error.status_code, detail=str(error)) from error
+ storage_ms = (perf_counter() - storage_started) * 1000
+ disposition = "attachment" if download else "inline"
+ headers = {
+ "Content-Disposition": f"{disposition}; filename*=UTF-8''{quote(content.name, safe='')}",
+ "Content-Length": str(content.size),
+ "Cache-Control": "private, no-store",
+ "Accept-Ranges": "bytes",
+ "Content-Security-Policy": _CONTENT_CSP,
+ "X-Content-Type-Options": "nosniff",
+ "Referrer-Policy": "no-referrer",
+ "Server-Timing": f"access;dur={access_ms:.1f}, storage_open;dur={storage_ms:.1f}",
+ }
+ if content.byte_range:
+ start, end = content.byte_range
+ headers["Content-Range"] = f"bytes {start}-{end}/{content.total_size}"
+ return StreamingResponse(
+ content.chunks(),
+ status_code=206 if content.byte_range else 200,
+ media_type=content.mime_type,
+ headers=headers,
+ background=BackgroundTask(content.close),
+ )
diff --git a/frontend/server/runtime_artifacts/runtime_detail.py b/frontend/server/runtime_artifacts/runtime_detail.py
new file mode 100644
index 000000000..9f013476f
--- /dev/null
+++ b/frontend/server/runtime_artifacts/runtime_detail.py
@@ -0,0 +1,80 @@
+# 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.
+
+"""Read the mount fields missing from older AgentKit SDK response models"""
+
+from __future__ import annotations
+
+from collections.abc import Callable
+from typing import Any
+
+from pydantic import BaseModel, ConfigDict, Field, ValidationError
+
+from .service import RuntimeArtifactError
+
+
+class _MountPoint(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ bucket_name: str = Field(default="", alias="BucketName")
+ bucket_path: str = Field(default="/", alias="BucketPath")
+ local_mount_path: str = Field(default="", alias="LocalMountPath")
+
+
+class _TosMountConfig(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ enable_tos: bool = Field(default=False, alias="EnableTos")
+ mount_points: list[_MountPoint] = Field(default_factory=list, alias="MountPoints")
+
+
+class _RuntimeTag(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ key: str = Field(alias="Key")
+ value: str = Field(default="", alias="Value")
+
+
+class _RuntimeMountResponse(BaseModel):
+ model_config = ConfigDict(extra="ignore")
+
+ # Intentionally excludes credentials, authorizers and environment variables
+ tos_mount_config: _TosMountConfig | None = Field(
+ default=None, alias="TosMountConfig"
+ )
+ tags: list[_RuntimeTag] | None = Field(default=None, alias="Tags", exclude=True)
+
+
+def read_runtime_artifact_detail(
+ client: Any,
+ runtime_id: str,
+ *,
+ authorize_tags: Callable[[dict[str, str]], None] | None = None,
+) -> dict[str, Any]:
+ from agentkit.sdk.runtime.types import GetRuntimeRequest
+
+ try:
+ response = client._invoke_api(
+ api_action="GetRuntime",
+ request=GetRuntimeRequest.model_validate({"RuntimeId": runtime_id}),
+ response_type=_RuntimeMountResponse,
+ )
+ except ValidationError:
+ # Validation errors can echo the invalid input, including mount credentials
+ raise RuntimeArtifactError(
+ "Runtime 产物挂载配置无效,请检查挂载设置", 502
+ ) from None
+ if authorize_tags is not None:
+ authorize_tags({tag.key: tag.value for tag in response.tags or []})
+ return response.model_dump(by_alias=True)
diff --git a/frontend/server/runtime_artifacts/service.py b/frontend/server/runtime_artifacts/service.py
new file mode 100644
index 000000000..9c26556e9
--- /dev/null
+++ b/frontend/server/runtime_artifacts/service.py
@@ -0,0 +1,400 @@
+# 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.
+
+"""Read authorized session artifacts from Studio storage or a Runtime mount"""
+
+from __future__ import annotations
+
+import asyncio
+import mimetypes
+import re
+from collections.abc import Callable, Iterator, Mapping
+from dataclasses import dataclass, field
+from datetime import datetime
+from pathlib import PurePosixPath
+from typing import Any
+
+from frontend.server.storage import StudioStorageConfig
+
+PREVIEW_MAX_BYTES = 16 * 1024 * 1024
+DOWNLOAD_MAX_BYTES = 256 * 1024 * 1024
+MOUNT_PATH = "/mnt/artifacts"
+
+
+class RuntimeArtifactError(RuntimeError):
+ def __init__(self, message: str, status_code: int = 400) -> None:
+ super().__init__(message)
+ self.status_code = status_code
+
+
+@dataclass(frozen=True)
+class RuntimeArtifactAccess:
+ """Created after Runtime authorization with the authenticated user identity"""
+
+ user_id: str
+ provider: str
+ region: str
+ runtime_detail: Mapping[str, Any]
+
+
+@dataclass(frozen=True)
+class ArtifactMount:
+ bucket: str
+ prefix: str
+
+
+@dataclass
+class ArtifactContent:
+ stream: Any
+ name: str
+ mime_type: str
+ size: int
+ total_size: int
+ byte_range: tuple[int, int] | None
+ _closed: bool = field(default=False, init=False, repr=False)
+
+ def chunks(self) -> Iterator[bytes]:
+ remaining = self.size
+ try:
+ while remaining:
+ chunk = self.stream.read(min(64 * 1024, remaining))
+ if not isinstance(chunk, bytes) or not chunk:
+ raise RuntimeError("Artifact content stream ended unexpectedly")
+ if len(chunk) > remaining:
+ raise RuntimeError("Artifact content exceeded its declared size")
+ remaining -= len(chunk)
+ yield chunk
+ if self.stream.read(1):
+ raise RuntimeError("Artifact content exceeded its declared size")
+ finally:
+ self.close()
+
+ def close(self) -> None:
+ if self._closed:
+ return
+ self._closed = True
+ _close_response(self.stream)
+
+
+def _close_response(stream: Any) -> None:
+ close = getattr(stream, "close", None)
+ if callable(close):
+ close()
+ return
+ # TOS GetObjectOutput has no close method: its HTTP response lives under
+ # content, optionally wrapped by CRC/progress/rate-limit adapters
+ content = getattr(stream, "content", None)
+ for _ in range(8):
+ if content is None:
+ break
+ response = getattr(content, "resp", None)
+ close = getattr(response, "close", None)
+ if callable(close):
+ close()
+ return
+ content = getattr(content, "data", None)
+
+
+def _content_size(metadata: Any, download: bool) -> int:
+ size = getattr(metadata, "content_length", None)
+ if type(size) is not int or size < 0:
+ raise RuntimeArtifactError("产物大小信息无效,请稍后重试", 502)
+ maximum = DOWNLOAD_MAX_BYTES if download else PREVIEW_MAX_BYTES
+ if size > maximum:
+ message = (
+ "文件超过下载大小限制" if download else "文件超过预览大小限制,请下载后查看"
+ )
+ raise RuntimeArtifactError(message, 413)
+ return size
+
+
+def _path(value: str, *, single_segment: bool = False) -> str:
+ # Percent escapes are rejected even after URL decoding to prevent ambiguities
+ # between the browser, ASGI router, and the object key namespace
+ parts = value.split("/")
+ if (
+ not value
+ or len(value.encode("utf-8")) > 1024
+ or any(part in {"", ".", ".."} for part in parts)
+ or any(ord(character) < 32 or ord(character) == 127 for character in value)
+ or "\\" in value
+ or "%" in value
+ or (single_segment and len(parts) != 1)
+ ):
+ raise RuntimeArtifactError("产物路径无效")
+ return value
+
+
+def _mount(access: RuntimeArtifactAccess, session_id: str) -> ArtifactMount | None:
+ user_id = _path(access.user_id, single_segment=True)
+ session_id = _path(session_id, single_segment=True)
+ config = access.runtime_detail.get("TosMountConfig")
+ if not isinstance(config, Mapping) or config.get("EnableTos") is not True:
+ return None
+ points = config.get("MountPoints")
+ if not isinstance(points, list):
+ return None
+ dedicated = [
+ point
+ for point in points
+ if isinstance(point, Mapping)
+ and str(point.get("LocalMountPath") or "").rstrip("/") == MOUNT_PATH
+ ]
+ if not dedicated:
+ return None
+ if len(dedicated) != 1:
+ raise RuntimeArtifactError("Runtime 产物挂载配置存在冲突", 409)
+ point = dedicated[0]
+ bucket = str(point.get("BucketName") or "")
+ bucket_path = str(point.get("BucketPath") or "/")
+ if not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", bucket):
+ raise RuntimeArtifactError("Runtime 产物存储桶配置无效", 409)
+ if not bucket_path.startswith("/"):
+ raise RuntimeArtifactError("Runtime 产物挂载路径配置无效", 409)
+ root = bucket_path.strip("/")
+ if root:
+ root = _path(root)
+ if root == "artifacts" or root.startswith("artifacts/"):
+ parts = root.split("/")
+ if len(parts) != 2:
+ raise RuntimeArtifactError("Runtime 用户产物挂载路径配置无效", 409)
+ if parts[1] != user_id:
+ raise RuntimeArtifactError("当前用户无权访问此 Runtime 的产物存储", 403)
+ return ArtifactMount(bucket, f"{root}/{session_id}/")
+ # Keep already deployed root-mount Runtimes readable under their original layout
+ prefix = f"{root}/" if root else ""
+ return ArtifactMount(bucket, f"{prefix}artifacts/{user_id}/{session_id}/")
+
+
+def _mime_type(path: str) -> str:
+ return mimetypes.guess_type(path)[0] or "application/octet-stream"
+
+
+def _storage_error(error: Exception) -> RuntimeArtifactError:
+ status = getattr(error, "status_code", None)
+ if status == 404:
+ return RuntimeArtifactError("产物不存在或已被删除", 404)
+ if status == 403:
+ return RuntimeArtifactError("无法读取产物存储,请检查服务端 TOS 访问权限", 502)
+ if status == 412:
+ return RuntimeArtifactError("产物正在更新,请刷新后重试", 409)
+ return RuntimeArtifactError("读取产物存储失败,请稍后重试", 502)
+
+
+def _invalid_list_cursor(error: Exception) -> bool:
+ if (
+ getattr(error, "status_code", None) != 400
+ or getattr(error, "code", None) != "InvalidArgument"
+ ):
+ return False
+ message = str(getattr(error, "message", "") or "")
+ return message == "The continuation token provided is incorrect" or bool(
+ re.fullmatch(
+ r"the key-marker\[.*\] does not start with prefix\[.*\]\.", message
+ )
+ )
+
+
+def _range(header: str | None, size: int) -> tuple[int, int] | None:
+ if header is None:
+ return None
+ if len(header) > 128:
+ raise RuntimeArtifactError("文件读取范围无效", 416)
+ match = re.fullmatch(r"bytes=(\d*)-(\d*)", header)
+ if not match or size == 0 or not any(match.groups()):
+ raise RuntimeArtifactError("文件读取范围无效", 416)
+ start_text, end_text = match.groups()
+ if not start_text:
+ suffix = int(end_text)
+ if not suffix:
+ raise RuntimeArtifactError("文件读取范围无效", 416)
+ return max(size - suffix, 0), size - 1
+ start = int(start_text)
+ end = min(int(end_text), size - 1) if end_text else size - 1
+ if start >= size or end < start:
+ raise RuntimeArtifactError("文件读取范围超出产物大小", 416)
+ return start, end
+
+
+class RuntimeArtifactService:
+ def __init__(
+ self,
+ client_factory: Callable[[str, str], Any],
+ *,
+ studio_storage: StudioStorageConfig | None = None,
+ studio_client_factory: Callable[[], Any] | None = None,
+ ) -> None:
+ self._client_factory = client_factory
+ self._studio_storage = studio_storage
+ self._studio_client_factory = studio_client_factory
+
+ def _source(
+ self, access: RuntimeArtifactAccess, session_id: str
+ ) -> tuple[ArtifactMount | None, Callable[[], Any]]:
+ user_id = _path(access.user_id, single_segment=True)
+ session_id = _path(session_id, single_segment=True)
+ storage = self._studio_storage
+ if storage is not None and (storage.bucket or storage.region):
+ if (
+ not storage.configured
+ or storage.provider != access.provider
+ or not re.fullmatch(r"[a-z0-9][a-z0-9.-]{1,61}[a-z0-9]", storage.bucket)
+ or self._studio_client_factory is None
+ ):
+ raise RuntimeArtifactError("Studio 产物存储配置无效", 409)
+ return (
+ ArtifactMount(storage.bucket, f"artifacts/{user_id}/{session_id}/"),
+ self._studio_client_factory,
+ )
+ return (
+ _mount(access, session_id),
+ lambda: self._client_factory(access.provider, access.region),
+ )
+
+ async def list(
+ self,
+ access: RuntimeArtifactAccess,
+ session_id: str,
+ *,
+ limit: int = 200,
+ cursor: str = "",
+ ) -> dict[str, Any]:
+ return await asyncio.to_thread(self._list, access, session_id, limit, cursor)
+
+ def _list(
+ self, access: RuntimeArtifactAccess, session_id: str, limit: int, cursor: str
+ ) -> dict[str, Any]:
+ mount, client_factory = self._source(access, session_id)
+ if mount is None:
+ return {
+ "available": False,
+ "reason": "Studio 尚未配置会话产物存储",
+ "items": [],
+ "nextCursor": None,
+ }
+ if not 1 <= limit <= 500 or len(cursor) > 4096:
+ raise RuntimeArtifactError("产物分页参数无效")
+ try:
+ client = client_factory()
+ output = client.list_objects_type2(
+ bucket=mount.bucket,
+ prefix=mount.prefix,
+ max_keys=limit,
+ continuation_token=cursor,
+ )
+ except Exception as error:
+ if cursor and _invalid_list_cursor(error):
+ raise RuntimeArtifactError(
+ "产物分页游标无效或不属于当前会话,请重新加载产物列表", 400
+ ) from error
+ raise _storage_error(error) from error
+ items = []
+ for item in (getattr(output, "contents", None) or [])[:limit]:
+ key = str(getattr(item, "key", ""))
+ if not key.startswith(mount.prefix) or key.endswith("/"):
+ continue
+ path = key[len(mount.prefix) :]
+ try:
+ _path(path)
+ except RuntimeArtifactError:
+ continue
+ modified = getattr(item, "last_modified", None)
+ items.append(
+ {
+ "path": path,
+ "name": PurePosixPath(path).name,
+ "sizeBytes": int(getattr(item, "size", 0)),
+ "mimeType": _mime_type(path),
+ "updatedAt": modified.isoformat()
+ if isinstance(modified, datetime)
+ else None,
+ }
+ )
+ token = str(getattr(output, "next_continuation_token", "") or "")
+ truncated = bool(getattr(output, "is_truncated", False))
+ if truncated and (not token or token == cursor):
+ raise RuntimeArtifactError("产物列表分页响应无效,请稍后重试", 502)
+ return {
+ "available": True,
+ "reason": None,
+ "items": items,
+ "nextCursor": token if truncated else None,
+ }
+
+ async def open_content(
+ self,
+ access: RuntimeArtifactAccess,
+ session_id: str,
+ path: str,
+ *,
+ download: bool = False,
+ range_header: str | None = None,
+ ) -> ArtifactContent:
+ return await asyncio.to_thread(
+ self._open_content, access, session_id, path, download, range_header
+ )
+
+ def _open_content(
+ self,
+ access: RuntimeArtifactAccess,
+ session_id: str,
+ path: str,
+ download: bool,
+ range_header: str | None,
+ ) -> ArtifactContent:
+ path = _path(path)
+ mount, client_factory = self._source(access, session_id)
+ if mount is None:
+ raise RuntimeArtifactError("Studio 尚未配置会话产物存储", 409)
+ key = mount.prefix + path
+ kwargs: dict[str, Any] = {"bucket": mount.bucket, "key": key}
+ total_size = 0
+ size = 0
+ byte_range = None
+ try:
+ client = client_factory()
+ if range_header is not None:
+ metadata = client.head_object(bucket=mount.bucket, key=key)
+ total_size = _content_size(metadata, download)
+ byte_range = _range(range_header, total_size)
+ if byte_range is not None:
+ kwargs["range_start"], kwargs["range_end"] = byte_range
+ size = byte_range[1] - byte_range[0] + 1
+ etag = getattr(metadata, "etag", None)
+ if etag:
+ kwargs["if_match"] = etag
+ # GetObjectOutput includes content_length from the response headers
+ # The SDK keeps GET bodies streaming, so a separate HEAD is unnecessary
+ stream = client.get_object(**kwargs)
+ except RuntimeArtifactError:
+ raise
+ except Exception as error:
+ raise _storage_error(error) from error
+ try:
+ response_size = _content_size(stream, download)
+ if byte_range is None:
+ size = total_size = response_size
+ elif response_size != size:
+ raise RuntimeArtifactError("产物读取范围响应无效,请稍后重试", 502)
+ except RuntimeArtifactError:
+ _close_response(stream)
+ raise
+ return ArtifactContent(
+ stream,
+ PurePosixPath(path).name,
+ _mime_type(path),
+ size,
+ total_size,
+ byte_range,
+ )
diff --git a/frontend/server/runtime_artifacts/writer.py b/frontend/server/runtime_artifacts/writer.py
new file mode 100644
index 000000000..d2dd3935b
--- /dev/null
+++ b/frontend/server/runtime_artifacts/writer.py
@@ -0,0 +1,150 @@
+# 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.
+
+"""Write artifacts using the Studio identity and storage credentials"""
+
+from __future__ import annotations
+
+import hashlib
+import json
+import os
+from pathlib import Path, PurePosixPath
+from threading import Lock
+from typing import Any
+
+from frontend.server.runtime_artifacts.service import (
+ RuntimeArtifactError,
+ _mime_type,
+ _path,
+)
+from frontend.server.storage import StudioStorageConfig
+from frontend.server.storage.tos import CredentialResolver, create_tos_client_factory
+from frontend.server.studio_tools.registry import (
+ StudioToolExecutionContext,
+ StudioToolExecutionError,
+ StudioToolRuntimeError,
+)
+from veadk.utils.cloud_provider import CloudProvider, cloud_provider_from_env
+
+ARTIFACT_WRITE_MAX_BYTES = 1024 * 1024
+_IAM_CREDENTIAL_PATH = Path("/var/run/secrets/iam/credential")
+
+
+def _resolve_credentials(provider: CloudProvider) -> tuple[str, str, str | None]:
+ prefix = "BYTEPLUS" if provider == "byteplus" else "VOLCENGINE"
+ access_key = os.getenv(f"{prefix}_ACCESS_KEY")
+ secret_key = os.getenv(f"{prefix}_SECRET_KEY")
+ token = os.getenv(f"{prefix}_SESSION_TOKEN")
+ if provider == "volcengine":
+ token = token or os.getenv("VOLC_SESSIONTOKEN")
+ if access_key or secret_key or token:
+ if not access_key or not secret_key:
+ raise StudioToolRuntimeError("Studio 存储凭据不完整,请联系管理员")
+ return access_key, secret_key, token or None
+
+ try:
+ data = json.loads(_IAM_CREDENTIAL_PATH.read_text(encoding="utf-8"))
+ if not isinstance(data, dict):
+ raise ValueError("Invalid credential document")
+ access_key = data.get("access_key_id") or data.get("AccessKeyId")
+ secret_key = data.get("secret_access_key") or data.get("SecretAccessKey")
+ token = data.get("session_token") or data.get("SessionToken")
+ if not isinstance(access_key, str) or not isinstance(secret_key, str):
+ raise ValueError("Missing role credentials")
+ if not access_key or not secret_key or not isinstance(token, str) or not token:
+ raise ValueError("Missing role token")
+ return access_key, secret_key, token
+ except (OSError, ValueError):
+ raise StudioToolRuntimeError(
+ "Studio 存储角色凭据不可用,请联系管理员"
+ ) from None
+
+
+class StudioArtifactWriter:
+ def __init__(
+ self, config: StudioStorageConfig, resolve_credentials: CredentialResolver
+ ) -> None:
+ self._config = config
+ self._resolve_credentials = resolve_credentials
+ self._credentials: tuple[str, str, str | None] | None = None
+ self._client: Any = None
+ self._lock = Lock()
+
+ @classmethod
+ def from_env(cls) -> StudioArtifactWriter:
+ provider = cloud_provider_from_env()
+ return cls(
+ StudioStorageConfig.from_env(provider),
+ lambda: _resolve_credentials(provider),
+ )
+
+ def _storage_client(self) -> Any:
+ # Resolve on every invocation so a rotated role token replaces the pooled
+ # client before the next write; credentials never cross the tool channel
+ with self._lock:
+ credentials = self._resolve_credentials()
+ if self._client is None or self._credentials != credentials:
+ client = create_tos_client_factory(self._config, lambda: credentials)()
+ self._client = client
+ self._credentials = credentials
+ return self._client
+
+ def write(
+ self, arguments: dict[str, Any], context: StudioToolExecutionContext
+ ) -> dict[str, Any]:
+ if not context.owner_id or context.user_id != context.owner_id:
+ raise StudioToolExecutionError("无法确认当前会话的产物所有者")
+ path = arguments.get("path")
+ content = arguments.get("content")
+ if set(arguments) != {"path", "content"} or not isinstance(path, str):
+ raise StudioToolExecutionError("只接受相对文件路径和 UTF-8 文本内容")
+ if not isinstance(content, str):
+ raise StudioToolExecutionError("产物内容必须是 UTF-8 文本")
+ try:
+ owner = _path(context.owner_id, single_segment=True)
+ session = _path(context.session_id, single_segment=True)
+ path = _path(path)
+ key = _path(f"artifacts/{owner}/{session}/{path}")
+ encoded = content.encode("utf-8")
+ except (RuntimeArtifactError, UnicodeError):
+ raise StudioToolExecutionError("产物路径或文本内容无效") from None
+ if len(encoded) > ARTIFACT_WRITE_MAX_BYTES:
+ raise StudioToolExecutionError("单个产物不能超过 1 MiB,请拆分文件")
+ if not self._config.configured:
+ raise StudioToolRuntimeError("Studio 尚未配置产物存储,请联系管理员")
+ mime_type = _mime_type(path)
+ try:
+ self._storage_client().put_object(
+ bucket=self._config.bucket,
+ key=key,
+ content=encoded,
+ content_type=mime_type,
+ )
+ except StudioToolExecutionError:
+ raise
+ except Exception:
+ # SDK exceptions may include signed request headers or URLs
+ raise StudioToolRuntimeError(
+ "产物保存失败,请检查 Studio 存储角色权限和网络后重试"
+ ) from None
+ return {
+ "status": "saved",
+ "artifact": {
+ "path": path,
+ "name": PurePosixPath(path).name,
+ "mimeType": mime_type,
+ "size": len(encoded),
+ "sha256": hashlib.sha256(encoded).hexdigest(),
+ },
+ }
diff --git a/frontend/server/storage/tos.py b/frontend/server/storage/tos.py
index f63b9758b..e994f8910 100644
--- a/frontend/server/storage/tos.py
+++ b/frontend/server/storage/tos.py
@@ -160,8 +160,44 @@ def factory() -> Any:
return factory
+def create_cached_tos_client_factory(
+ config: StudioStorageConfig,
+ resolve_credentials: CredentialResolver,
+) -> TosClientFactory:
+ """Reuse a connection pool while resolving complete credentials on every call"""
+ current_credentials: tuple[str, str, str | None] = ("", "", None)
+ cached_credentials: tuple[str, str, str | None] | None = None
+ cached_client: Any = None
+ cache_lock = Lock()
+ # Keep the selected endpoint when credentials rotate or resolution fails
+ create_client = create_tos_client_factory(config, lambda: current_credentials)
+
+ def factory() -> Any:
+ nonlocal current_credentials, cached_credentials, cached_client
+ with cache_lock:
+ try:
+ current_credentials = resolve_credentials()
+ if not current_credentials[0] or not current_credentials[1]:
+ raise ValueError("Studio TOS credentials are unavailable")
+ if cached_client is None or current_credentials != cached_credentials:
+ cached_client = None
+ cached_credentials = None
+ cached_client = create_client()
+ cached_credentials = current_credentials
+ return cached_client
+ except Exception:
+ # A failed refresh must not return or later revive the old client
+ cached_client = None
+ cached_credentials = None
+ current_credentials = ("", "", None)
+ raise
+
+ return factory
+
+
__all__ = [
"CredentialResolver",
"TosClientFactory",
"create_tos_client_factory",
+ "create_cached_tos_client_factory",
]
diff --git a/frontend/server/studio_tools/extensions/artifacts.py b/frontend/server/studio_tools/extensions/artifacts.py
new file mode 100644
index 000000000..8f4bc85e8
--- /dev/null
+++ b/frontend/server/studio_tools/extensions/artifacts.py
@@ -0,0 +1,63 @@
+# 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.
+
+"""Save session artifacts through Studio's existing storage"""
+
+from frontend.server.runtime_artifacts.writer import (
+ ARTIFACT_WRITE_MAX_BYTES,
+ StudioArtifactWriter,
+)
+from frontend.server.studio_tools.registry import StudioTool, StudioToolRegistry
+
+
+def register_tools(registry: StudioToolRegistry) -> None:
+ writer = StudioArtifactWriter.from_env()
+ registry.register(
+ StudioTool(
+ name="studio_write_artifact",
+ display_name="保存会话产物",
+ description=(
+ "Save a UTF-8 text file (HTML, SVG, Markdown, JSON, CSV, or code) "
+ "as an artifact of the current Studio conversation. Studio "
+ "automatically selects the current user's session directory. "
+ "Use a relative path such as report/index.html; sibling assets "
+ "can use relative links. The file appears in the session artifact "
+ "explorer and preview. Writing the same path replaces its content. "
+ "Maximum file size is 1 MiB. Use this tool to deliver files instead "
+ "of writing to the Runtime filesystem."
+ ),
+ input_schema={
+ "type": "object",
+ "properties": {
+ "path": {
+ "type": "string",
+ "minLength": 1,
+ "maxLength": 1024,
+ "description": "Relative filename within this session",
+ },
+ "content": {
+ "type": "string",
+ "maxLength": ARTIFACT_WRITE_MAX_BYTES,
+ "description": "Complete UTF-8 text content of the file",
+ },
+ },
+ "required": ["path", "content"],
+ "additionalProperties": False,
+ },
+ executor=writer.write,
+ executor_revision="studio-artifacts-v1",
+ requires_context=True,
+ risk_level="low",
+ )
+ )
diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx
index 6fdb42506..bec119857 100644
--- a/frontend/src/App.tsx
+++ b/frontend/src/App.tsx
@@ -142,6 +142,7 @@ import {
} from "./adk/connections";
import { defaultCloudRegion, formatCloudRegion } from "./adk/cloudProvider";
import { Blocks, ThinkingPlaceholder } from "./ui/Blocks";
+import { RuntimeArtifacts } from "./runtime-artifacts/RuntimeArtifacts";
import { Composer } from "./ui/Composer";
import { InvocationChips } from "./ui/InvocationChips";
import { MediaGroup } from "./ui/Media";
@@ -6670,6 +6671,16 @@ export default function App() {
+ {!sandboxSession && currentRuntime && sessionId && (
+
+
+
+ )}
{sandboxSession && (
{
+ if (response.ok) return response;
+ const fallback = response.status === 403
+ ? "没有访问此会话产物的权限"
+ : response.status === 404 ? "产物不存在或已被删除" : `产物请求失败(${response.status}),请重试`;
+ if (response.headers.get("content-type")?.includes("application/json")) {
+ const payload: unknown = await response.json();
+ if (payload && typeof payload === "object" && "detail" in payload && typeof payload.detail === "string") {
+ throw new RuntimeArtifactRequestError(payload.detail, response.status);
+ }
+ }
+ throw new RuntimeArtifactRequestError(fallback, response.status);
+}
+
+export async function listRuntimeArtifacts(scope: RuntimeArtifactScope, signal?: AbortSignal, cursor?: string): Promise {
+ const response = await artifactResponse(await studioFetch(artifactUrl(scope, undefined, false, cursor), { signal }));
+ const value: unknown = await response.json();
+ if (!value || typeof value !== "object" || !("available" in value) || typeof value.available !== "boolean" || !("items" in value) || !Array.isArray(value.items)) {
+ throw new Error("产物列表响应格式不正确,请检查 Studio 服务版本");
+ }
+ const items = value.items.map((item: unknown): RuntimeArtifact => {
+ if (!item || typeof item !== "object" || !("path" in item) || typeof item.path !== "string"
+ || !("name" in item) || typeof item.name !== "string"
+ || !("sizeBytes" in item) || typeof item.sizeBytes !== "number" || !Number.isFinite(item.sizeBytes) || item.sizeBytes < 0
+ || !("mimeType" in item) || typeof item.mimeType !== "string"
+ || item.path.split("/").some(part => !part || part === "." || part === ".." || part.includes("\\"))) {
+ throw new Error("产物列表包含无效文件信息");
+ }
+ return { path: item.path, name: item.name, sizeBytes: item.sizeBytes, mimeType: item.mimeType,
+ updatedAt: "updatedAt" in item && typeof item.updatedAt === "string" ? item.updatedAt : "" };
+ });
+ return { available: value.available, items,
+ reason: "reason" in value && typeof value.reason === "string" ? value.reason : undefined,
+ nextCursor: "nextCursor" in value && typeof value.nextCursor === "string" ? value.nextCursor : null };
+}
+
+export async function getRuntimeArtifact(scope: RuntimeArtifactScope, path: string, signal?: AbortSignal, download = false): Promise {
+ const response = await artifactResponse(await studioFetch(artifactUrl(scope, path, download), { signal }, TRANSFER_REQUEST_TIMEOUT_MS));
+ if (download) return response.blob();
+ const tooLarge = () => new Error("文件超过 5 MB,请下载后查看");
+ if (Number(response.headers.get("content-length")) > MAX_ARTIFACT_PREVIEW_BYTES) {
+ await response.body?.cancel();
+ throw tooLarge();
+ }
+ const reader = response.body?.getReader();
+ if (!reader) return response.blob();
+ const chunks: Uint8Array[] = [];
+ let size = 0;
+ try {
+ for (;;) {
+ const { done, value } = await reader.read();
+ if (done) break;
+ size += value.byteLength;
+ if (size > MAX_ARTIFACT_PREVIEW_BYTES) {
+ await reader.cancel();
+ throw tooLarge();
+ }
+ chunks.push(new Uint8Array(value));
+ }
+ } finally {
+ reader.releaseLock();
+ }
+ return new Blob(chunks, { type: response.headers.get("content-type") ?? "application/octet-stream" });
+}
diff --git a/frontend/src/components/composites/FileExplorer/FileExplorer.css b/frontend/src/components/composites/FileExplorer/FileExplorer.css
index 58b5ac0da..25d534be6 100644
--- a/frontend/src/components/composites/FileExplorer/FileExplorer.css
+++ b/frontend/src/components/composites/FileExplorer/FileExplorer.css
@@ -151,6 +151,8 @@
@media (max-width: 640px) {
.studio-file-explorer { grid-template-columns: minmax(132px, 38%) minmax(0, 1fr); }
+ .studio-file-explorer[data-narrow-layout="stack"] { grid-template-columns: minmax(0, 1fr); grid-template-rows: minmax(96px, 28%) minmax(0, 1fr); }
+ .studio-file-explorer[data-narrow-layout="stack"] .studio-file-explorer__tree-area { border-right: 0; border-bottom: 1px solid var(--studio-border-subtle); }
}
@media (prefers-reduced-motion: reduce) {
diff --git a/frontend/src/components/composites/FileExplorer/FileExplorer.tsx b/frontend/src/components/composites/FileExplorer/FileExplorer.tsx
index dc239d57c..0662cec51 100644
--- a/frontend/src/components/composites/FileExplorer/FileExplorer.tsx
+++ b/frontend/src/components/composites/FileExplorer/FileExplorer.tsx
@@ -1,4 +1,4 @@
-import { useEffect, useMemo, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent } from "react";
+import { useEffect, useMemo, useRef, useState, type CSSProperties, type HTMLAttributes, type KeyboardEvent, type ReactNode } from "react";
import type { CodeBlockProps } from "../CodeBlock";
import { ScrollArea } from "../../primitives/ScrollArea";
import { FileExplorerChevron, FileExplorerFileIcon, FileExplorerFolderIcon } from "./FileExplorerIcons";
@@ -49,6 +49,12 @@ export interface FileExplorerProps extends Omit,
autoFormat?: boolean;
/** 默认 true,超长行按面板宽度折行,不增加行号 */
wordWrap?: boolean;
+ /** 自定义只读文件预览,支持异步加载、富文档和媒体;未提供时展示代码 */
+ renderPreview?: (file: FileExplorerFile | undefined) => ReactNode;
+ /** 文件浏览器高度,数字单位为 px */
+ height?: CSSProperties["height"];
+ /** 窄屏时将文件树放到预览上方 */
+ narrowLayout?: "split" | "stack";
}
interface TreeRow {
@@ -90,7 +96,11 @@ export function FileExplorer({
onSave,
autoFormat = true,
wordWrap = true,
+ renderPreview,
+ height,
+ narrowLayout = "split",
className = "",
+ style,
...props
}: FileExplorerProps) {
const [localSelection, setLocalSelection] = useState(defaultSelectedId);
@@ -209,7 +219,7 @@ export function FileExplorer({
}
return (
-
+
{rows.map((row, index) => {
@@ -253,7 +263,7 @@ export function FileExplorer({
{rows.length === 0 &&
暂无文件
}
- {file ? (
+ {renderPreview ? renderPreview(file) : file ? (
Promise }) {
+ const [content, setContent] = useState(null);
+ const [error, setError] = useState("");
+ const [imageError, setImageError] = useState("");
+ const [loadingImages, setLoadingImages] = useState(false);
+ const [attempt, setAttempt] = useState(0);
+ const [downloading, setDownloading] = useState(false);
+ const [downloadError, setDownloadError] = useState("");
+ const [zoom, setZoom] = useState(1);
+ const downloadController = useRef(null);
+ const downloadUrls = useRef(new Map());
+ const kind = file ? artifactPreviewKind(file.mimeType, file.path) : "download";
+ const tooLarge = Boolean(file && file.sizeBytes > MAX_ARTIFACT_PREVIEW_BYTES);
+
+ useEffect(() => {
+ if (!file || kind === "download" || tooLarge) return;
+ const controller = new AbortController();
+ let objectUrl: string | undefined;
+ setContent(null);
+ setError("");
+ setImageError("");
+ setLoadingImages(false);
+ let bodyReady = false;
+ void loadPreview(file.path).then(async blob => {
+ if (controller.signal.aborted) return;
+ if (kind === "image") {
+ objectUrl = URL.createObjectURL(blob);
+ if (!controller.signal.aborted) setContent({ url: objectUrl });
+ else URL.revokeObjectURL(objectUrl);
+ } else {
+ const text = await blob.text();
+ if (controller.signal.aborted) return;
+ const preview = kind === "html" ? await prepareArtifactHtml(text, file.path, path => {
+ if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
+ return loadPreview(path);
+ }, html => {
+ bodyReady = true;
+ if (!controller.signal.aborted) { setContent({ text: html }); setLoadingImages(true); }
+ }) : text;
+ if (!controller.signal.aborted) { setContent({ text: preview }); setLoadingImages(false); }
+ }
+ }).catch(cause => {
+ if (!controller.signal.aborted) {
+ const message = cause instanceof Error ? cause.message : "无法加载文件,请重试";
+ if (bodyReady) setImageError(message);
+ else setError(message);
+ setLoadingImages(false);
+ }
+ });
+ return () => { controller.abort(); if (objectUrl) URL.revokeObjectURL(objectUrl); };
+ }, [loadPreview, file?.path, file?.updatedAt, kind, tooLarge, attempt]);
+
+ useEffect(() => { setZoom(1); }, [file?.path]);
+
+ useEffect(() => {
+ return () => {
+ downloadController.current?.abort();
+ for (const [timer, url] of downloadUrls.current) { clearTimeout(timer); URL.revokeObjectURL(url); }
+ downloadUrls.current.clear();
+ };
+ }, []);
+
+ async function download() {
+ if (!file || downloading) return;
+ const controller = new AbortController();
+ downloadController.current = controller;
+ setDownloading(true);
+ setDownloadError("");
+ try {
+ const blob = await getRuntimeArtifact(scope, file.path, controller.signal, true);
+ if (controller.signal.aborted) return;
+ const url = URL.createObjectURL(blob);
+ const anchor = document.createElement("a");
+ anchor.href = url;
+ anchor.download = file.name;
+ document.body.append(anchor);
+ anchor.click();
+ anchor.remove();
+ const timer = window.setTimeout(() => { URL.revokeObjectURL(url); downloadUrls.current.delete(timer); }, 1000);
+ downloadUrls.current.set(timer, url);
+ } catch (cause) {
+ if (!controller.signal.aborted) setDownloadError(cause instanceof Error ? cause.message : "下载失败,请重试");
+ } finally {
+ if (!controller.signal.aborted) setDownloading(false);
+ }
+ }
+
+ if (!file) return
;
+ return
+
+ {downloadError && {downloadError}
}
+ {loadingImages && 正在加载图片
}
+ {imageError && 图片暂未加载:{imageError}
}
+ {tooLarge || kind === "download" ?
+ : error ?
+ : !content ? 正在加载预览
+ : kind === "html" ?
+ : kind === "image" ? <>
+
setError("无法显示此图片,请下载后查看")} />
+ {Math.round(zoom * 100)}%
+ >
+ : {kind === "markdown" ? : }}
+ ;
+}
diff --git a/frontend/src/runtime-artifacts/RuntimeArtifacts.css b/frontend/src/runtime-artifacts/RuntimeArtifacts.css
new file mode 100644
index 000000000..b131c422a
--- /dev/null
+++ b/frontend/src/runtime-artifacts/RuntimeArtifacts.css
@@ -0,0 +1,28 @@
+.runtime-artifacts { min-width: 0; }
+.runtime-artifacts__toolbar { display: flex; align-items: center; justify-content: space-between; gap: 16px; min-height: 36px; margin-bottom: 12px; }
+.runtime-artifacts__toolbar > span, .runtime-artifacts__notice { color: var(--studio-text-secondary); font-size: 13px; }
+.runtime-artifacts__notice { margin: 0 0 12px; }
+.runtime-artifacts__empty, .runtime-artifacts__failure { display: grid; justify-items: center; align-content: center; gap: 16px; min-height: 280px; padding: 24px; }
+.runtime-artifacts__failure { min-height: 160px; }
+.runtime-artifacts__more { display: flex; justify-content: center; padding-top: 12px; }
+.runtime-artifact-preview { display: flex; flex-direction: column; min-width: 0; min-height: 0; overflow: hidden; }
+.runtime-artifact-preview__header { display: flex; flex: none; align-items: center; justify-content: space-between; gap: 12px; min-height: 48px; padding: 8px 16px; border-bottom: 1px solid var(--studio-border-subtle); box-sizing: border-box; }
+.runtime-artifact-preview__identity { display: flex; align-items: center; gap: 12px; min-width: 0; font-size: 13px; }
+.runtime-artifact-preview__identity > span { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
+.runtime-artifact-preview__identity small { flex: none; font-size: 12px; color: var(--studio-text-secondary); }
+.runtime-artifact-preview__header > button { flex: none; }
+.runtime-artifact-preview__empty { display: flex; flex: 1; flex-direction: column; align-items: center; justify-content: center; gap: 16px; min-width: 0; min-height: 160px; padding: 24px; }
+.runtime-artifact-preview__error { padding: 8px 16px; color: var(--studio-danger); font-size: 13px; }
+.runtime-artifact-preview__resource-status { padding: 8px 16px; color: var(--studio-text-secondary); font-size: 13px; }
+.runtime-artifact-preview__html { flex: 1; width: 100%; min-height: 0; border: 0; background: white; color-scheme: light; }
+.runtime-artifact-preview__document { flex: 1; min-height: 0; padding: 20px; }
+.runtime-artifact-preview__image-scroll { flex: 1; min-height: 0; padding: 16px; }
+.runtime-artifact-preview__image { display: block; height: auto; object-fit: contain; }
+.runtime-artifact-preview__zoom { display: flex; flex: none; align-items: center; justify-content: center; flex-wrap: wrap; gap: 8px; min-height: 40px; padding: 8px; border-top: 1px solid var(--studio-border-subtle); font-size: 12px; }
+.runtime-artifact-entry { display: flex; justify-content: flex-end; width: 100%; max-width: 768px; min-width: 0; margin: 0 auto 8px; }
+@media (max-width: 640px) {
+ .runtime-artifact-preview__header { padding: 8px 12px; }
+ .runtime-artifact-preview__identity { flex-direction: column; align-items: flex-start; gap: 0; }
+ .runtime-artifact-preview__identity > span { max-width: 100%; }
+ .runtime-artifact-preview__document { padding: 12px; }
+}
diff --git a/frontend/src/runtime-artifacts/RuntimeArtifacts.tsx b/frontend/src/runtime-artifacts/RuntimeArtifacts.tsx
new file mode 100644
index 000000000..00add9680
--- /dev/null
+++ b/frontend/src/runtime-artifacts/RuntimeArtifacts.tsx
@@ -0,0 +1,60 @@
+import { useEffect, useMemo, useState } from "react";
+import type { RuntimeArtifactScope } from "../adk/runtimeArtifacts";
+import { Drawer } from "../components/composites/Drawer";
+import { FileExplorer } from "../components/composites/FileExplorer";
+import { Button } from "../components/primitives/Button";
+import { EmptyState } from "../components/primitives/EmptyState";
+import { ErrorState } from "../components/primitives/ErrorState";
+import { TextShimmer } from "../ui/text-shimmer/TextShimmer";
+import { artifactEntries } from "./artifactPreview";
+import { useRuntimeArtifacts } from "./useRuntimeArtifacts";
+import { RuntimeArtifactPreview } from "./RuntimeArtifactPreview";
+import { ArtifactRefreshIcon, RuntimeArtifactsIcon } from "./RuntimeArtifactsIcons";
+import "./RuntimeArtifacts.css";
+
+export function RuntimeArtifacts({ scope, busy }: { scope: RuntimeArtifactScope; busy: boolean }) {
+ const [open, setOpen] = useState(false);
+ const [selectedId, setSelectedId] = useState(null);
+ const [expandedIds, setExpandedIds] = useState([]);
+ const { listing, loading, error, previewRevision, loadPreview, refresh } = useRuntimeArtifacts(scope, open, busy);
+ const entries = useMemo(() => artifactEntries(listing?.items ?? []), [listing?.items]);
+
+ useEffect(() => {
+ if (!listing) return;
+ setSelectedId(previous => listing.items.some(item => item.path === previous) ? previous : null);
+ setExpandedIds(previous => [...new Set([...previous, ...listing.items.flatMap(item => {
+ const parts = item.path.split("/");
+ return parts.slice(0, -1).map((_part, index) => `folder:${parts.slice(0, index + 1).join("/")}`);
+ })])]);
+ }, [listing]);
+
+ return }>会话产物}
+ >
+ {open &&
+
+ {loading && listing ? 正在刷新文件 : listing?.available ? `${listing.items.length} 个文件${listing.nextCursor ? ",还有更多" : ""}` : ""}
+ } onClick={() => void refresh(undefined, true)}>刷新
+
+ {busy &&
Agent 正在生成,完成后自动刷新
}
+ {loading && !listing &&
正在加载会话产物
}
+ {error &&
}
+ {!error && listing && !listing.available ?
+ : listing?.available && listing.items.length === 0 ?
+ : listing?.available && listing.items.length > 0 ? <>
+
{
+ const artifact = listing.items.find(item => item.path === file?.id);
+ return ;
+ }} />
+ {listing.nextCursor && }
+ > : null}
+ }
+ ;
+}
diff --git a/frontend/src/runtime-artifacts/RuntimeArtifactsIcons.tsx b/frontend/src/runtime-artifacts/RuntimeArtifactsIcons.tsx
new file mode 100644
index 000000000..2e1fb4c1e
--- /dev/null
+++ b/frontend/src/runtime-artifacts/RuntimeArtifactsIcons.tsx
@@ -0,0 +1,13 @@
+import type { SVGProps } from "react";
+
+export function RuntimeArtifactsIcon(props: SVGProps) {
+ return ;
+}
+
+export function ArtifactRefreshIcon() {
+ return ;
+}
+
+export function ArtifactDownloadIcon() {
+ return ;
+}
diff --git a/frontend/src/runtime-artifacts/artifactCache.ts b/frontend/src/runtime-artifacts/artifactCache.ts
new file mode 100644
index 000000000..12225bf81
--- /dev/null
+++ b/frontend/src/runtime-artifacts/artifactCache.ts
@@ -0,0 +1,80 @@
+import type { RuntimeArtifact } from "../adk/runtimeArtifacts";
+
+interface CachedArtifact {
+ path: string;
+ controller: AbortController;
+ promise: Promise;
+ size: number;
+}
+
+function artifactKey(path: string, version?: Pick) {
+ return JSON.stringify([path, version?.updatedAt, version?.sizeBytes, version?.mimeType]);
+}
+
+/** Owned by one conversation drawer, never shared across sessions or identities */
+export class ArtifactPreviewCache {
+ private readonly entries = new Map();
+ private bytes = 0;
+
+ constructor(private readonly maxBytes = 32 * 1024 * 1024, private readonly maxEntries = 40) {}
+
+ get(path: string, version: Pick | undefined,
+ load: (signal: AbortSignal) => Promise): Promise {
+ const key = artifactKey(path, version);
+ const cached = this.entries.get(key);
+ if (cached) {
+ this.entries.delete(key);
+ this.entries.set(key, cached);
+ return cached.promise;
+ }
+ const controller = new AbortController();
+ const promise = load(controller.signal).then(blob => {
+ if (controller.signal.aborted) throw new DOMException("Aborted", "AbortError");
+ if (this.entries.get(key) === entry) {
+ entry.size = blob.size;
+ this.bytes += blob.size;
+ this.trim();
+ }
+ return blob;
+ }).catch(error => {
+ if (this.entries.get(key) === entry) this.remove(key, entry);
+ throw error;
+ });
+ const entry: CachedArtifact = { path, controller, promise, size: 0 };
+ this.entries.set(key, entry);
+ this.trim();
+ return promise;
+ }
+
+ clear(): void {
+ for (const entry of this.entries.values()) entry.controller.abort();
+ this.entries.clear();
+ this.bytes = 0;
+ }
+
+ reconcile(items: readonly RuntimeArtifact[], complete: boolean): void {
+ const versions = new Map(items.map(item => [item.path, artifactKey(item.path, item)]));
+ for (const [key, entry] of this.entries) {
+ const version = versions.get(entry.path);
+ if ((version !== undefined && version !== key) || (complete && version === undefined)) this.remove(key, entry);
+ }
+ }
+
+ removePath(path: string): void {
+ for (const [key, entry] of this.entries) if (entry.path === path) this.remove(key, entry);
+ }
+
+ private remove(key: string, entry: CachedArtifact) {
+ entry.controller.abort();
+ this.bytes -= entry.size;
+ this.entries.delete(key);
+ }
+
+ private trim() {
+ while (this.bytes > this.maxBytes || this.entries.size > this.maxEntries) {
+ const oldest = this.entries.entries().next().value;
+ if (!oldest) break;
+ this.remove(...oldest);
+ }
+ }
+}
diff --git a/frontend/src/runtime-artifacts/artifactPreview.ts b/frontend/src/runtime-artifacts/artifactPreview.ts
new file mode 100644
index 000000000..ba94411f4
--- /dev/null
+++ b/frontend/src/runtime-artifacts/artifactPreview.ts
@@ -0,0 +1,120 @@
+import type { RuntimeArtifact } from "../adk/runtimeArtifacts";
+import type { FileExplorerEntry, FileExplorerFolder } from "../components/composites/FileExplorer";
+
+export function artifactEntries(items: readonly RuntimeArtifact[]): FileExplorerEntry[] {
+ const root: FileExplorerEntry[] = [];
+ const folders = new Map();
+ for (const item of items) {
+ const parts = item.path.split("/");
+ let entries = root;
+ let prefix = "";
+ for (const part of parts.slice(0, -1)) {
+ prefix = prefix ? `${prefix}/${part}` : part;
+ let folder = folders.get(prefix);
+ if (!folder) {
+ folder = { id: `folder:${prefix}`, name: part, type: "folder", children: [] };
+ folders.set(prefix, folder);
+ entries.push(folder);
+ }
+ entries = folder.children as FileExplorerEntry[];
+ }
+ entries.push({ id: item.path, name: item.name, type: "file", content: "" });
+ }
+ function sort(entries: FileExplorerEntry[]) {
+ entries.sort((a, b) => a.type === b.type ? a.name.localeCompare(b.name) : a.type === "folder" ? -1 : 1);
+ for (const entry of entries) if (entry.type === "folder") sort(entry.children as FileExplorerEntry[]);
+ }
+ sort(root);
+ return root;
+}
+
+export function artifactPreviewKind(mimeType: string, path: string): "html" | "markdown" | "image" | "text" | "download" {
+ const mime = mimeType.split(";", 1)[0].toLowerCase();
+ if (mime === "text/html" || /\.html?$/i.test(path)) return "html";
+ if (mime === "text/markdown" || /\.(md|markdown)$/i.test(path)) return "markdown";
+ if (/^image\/(png|jpeg|gif|webp|svg\+xml|avif|bmp)$/.test(mime)) return "image";
+ if (mime.startsWith("text/") || mime === "application/json" || /\.(txt|json|csv|yaml|yml|py|js|ts|css|log)$/i.test(path)) return "text";
+ return "download";
+}
+
+/** Resolve only relative files within the current session, without accepting URL authorities. */
+export function resolveArtifactResource(documentPath: string, reference: string): string | null {
+ let path: string;
+ try { path = decodeURIComponent(reference.split(/[?#]/, 1)[0]); } catch { return null; }
+ if (!path || /^[\/\\]/.test(path) || /[:\\\u0000-\u001f]/.test(path)) return null;
+ const segments = documentPath.split("/").slice(0, -1);
+ for (const segment of path.split("/")) {
+ if (!segment || segment === ".") continue;
+ if (segment === "..") {
+ if (segments.length === 0) return null;
+ segments.pop();
+ } else segments.push(segment);
+ }
+ return segments.join("/") || null;
+}
+
+function dataUrl(blob: Blob): Promise {
+ return new Promise((resolve, reject) => {
+ const reader = new FileReader();
+ reader.onload = () => resolve(String(reader.result));
+ reader.onerror = () => reject(new Error("无法读取预览图片"));
+ reader.readAsDataURL(blob);
+ });
+}
+
+/** HTML stays in a scriptless sandbox; only explicitly fetched same-session images are embedded. */
+export async function prepareArtifactHtml(source: string, path: string, load: (path: string) => Promise, onBodyReady?: (html: string) => void): Promise {
+ const document = new DOMParser().parseFromString(source, "text/html");
+ for (const element of document.querySelectorAll("script,iframe,frame,object,embed,base,link,meta[http-equiv],form")) element.remove();
+ for (const element of document.querySelectorAll("*")) {
+ for (const attribute of Array.from(element.attributes)) {
+ if (/^on/i.test(attribute.name) || ["srcset", "srcdoc", "action", "formaction", "ping"].includes(attribute.name)) element.removeAttribute(attribute.name);
+ }
+ if (element.tagName !== "IMG") element.removeAttribute("src");
+ if (element.hasAttribute("href")) element.removeAttribute("href");
+ }
+ const images = Array.from(document.querySelectorAll("img[src]"));
+ if (images.length > 32) throw new Error("此页面图片较多,请下载完整文件后查看");
+ const pendingImages = images.map(image => {
+ const reference = image.getAttribute("src") ?? "";
+ image.removeAttribute("src");
+ if (/^data:image\/(png|jpeg|gif|webp|avif);base64,[a-z\d+/=\s]+$/i.test(reference)) {
+ image.setAttribute("src", reference);
+ return null;
+ }
+ const resource = resolveArtifactResource(path, reference);
+ return resource ? { image, resource } : null;
+ }).filter(item => item !== null);
+ const policy = document.createElement("meta");
+ policy.httpEquiv = "Content-Security-Policy";
+ policy.content = "default-src 'none'; img-src data:; style-src 'unsafe-inline'; font-src data:; base-uri 'none'; form-action 'none'";
+ document.head.prepend(policy);
+ const serialize = () => `${document.documentElement.outerHTML}`;
+ // No original image URL, active element, or event handler reaches this first paint
+ onBodyReady?.(serialize());
+ let bytes = 0;
+ const cache = new Map>();
+ async function embed({ image, resource }: NonNullable) {
+ let pending = cache.get(resource);
+ if (!pending) {
+ pending = load(resource).then(async blob => {
+ bytes += blob.size;
+ if (bytes > 20 * 1024 * 1024) throw new Error("页面图片超过 20 MB,请下载后查看");
+ if (artifactPreviewKind(blob.type, resource) !== "image") throw new Error("HTML 引用的资源不是支持的图片");
+ return dataUrl(blob);
+ });
+ cache.set(resource, pending);
+ }
+ image.setAttribute("src", await pending);
+ }
+ for (let index = 0; index < pendingImages.length; index += 4) {
+ await Promise.all(pendingImages.slice(index, index + 4).map(embed));
+ }
+ return serialize();
+}
+
+export function artifactSize(size: number): string {
+ if (size < 1024) return `${size} B`;
+ if (size < 1024 * 1024) return `${(size / 1024).toFixed(1)} KB`;
+ return `${(size / 1024 / 1024).toFixed(1)} MB`;
+}
diff --git a/frontend/src/runtime-artifacts/artifactWarmup.ts b/frontend/src/runtime-artifacts/artifactWarmup.ts
new file mode 100644
index 000000000..0480381d6
--- /dev/null
+++ b/frontend/src/runtime-artifacts/artifactWarmup.ts
@@ -0,0 +1,28 @@
+import type { RuntimeArtifact } from "../adk/runtimeArtifacts";
+import { artifactPreviewKind } from "./artifactPreview";
+
+/** Warm only a small preview-sized set, with HTML and its neighboring images first */
+export async function warmArtifactPreviews(items: readonly RuntimeArtifact[], load: (path: string) => Promise, signal: AbortSignal): Promise {
+ const priority = (item: RuntimeArtifact) => {
+ const kind = artifactPreviewKind(item.mimeType, item.path);
+ return kind === "html" ? 0 : kind === "image" ? 1 : kind === "download" ? 3 : 2;
+ };
+ const sorted = items.filter(item => priority(item) < 3).slice().sort((a, b) => priority(a) - priority(b));
+ const candidates: RuntimeArtifact[] = [];
+ let bytes = 0;
+ for (const item of sorted) {
+ if (candidates.length === 4) break;
+ if (bytes + item.sizeBytes > 1024 * 1024) continue;
+ candidates.push(item);
+ bytes += item.sizeBytes;
+ }
+ let next = 0;
+ async function worker() {
+ while (!signal.aborted && next < candidates.length) {
+ const file = candidates[next++];
+ // Optional warmup failures remain retryable through the normal preview UI
+ await load(file.path).catch(() => undefined);
+ }
+ }
+ await Promise.all([worker(), worker()]);
+}
diff --git a/frontend/src/runtime-artifacts/useRuntimeArtifacts.ts b/frontend/src/runtime-artifacts/useRuntimeArtifacts.ts
new file mode 100644
index 000000000..8af451009
--- /dev/null
+++ b/frontend/src/runtime-artifacts/useRuntimeArtifacts.ts
@@ -0,0 +1,111 @@
+import { useCallback, useEffect, useMemo, useRef, useState } from "react";
+import { getRuntimeArtifact, listRuntimeArtifacts, RuntimeArtifactRequestError, type RuntimeArtifactList, type RuntimeArtifactScope } from "../adk/runtimeArtifacts";
+import { ArtifactPreviewCache } from "./artifactCache";
+import { warmArtifactPreviews } from "./artifactWarmup";
+
+const LIST_FRESHNESS_MS = 30_000;
+
+export function useRuntimeArtifacts(scope: RuntimeArtifactScope, open: boolean, busy: boolean) {
+ const [listing, setListing] = useState(null);
+ const [loading, setLoading] = useState(false);
+ const [error, setError] = useState("");
+ const [previewRevision, setPreviewRevision] = useState(0);
+ const cache = useMemo(() => new ArtifactPreviewCache(), [scope.runtimeId, scope.region, scope.appName, scope.sessionId]);
+ const current = useRef(null);
+ const request = useRef(null);
+ const warmup = useRef(null);
+ const lastValidated = useRef(0);
+ const wasBusy = useRef(busy);
+ const refreshAgain = useRef(false);
+
+ const loadPreview = useCallback((path: string) => cache.get(
+ path, current.current?.items.find(item => item.path === path),
+ signal => getRuntimeArtifact(scope, path, signal),
+ ).catch(cause => {
+ if (cause instanceof RuntimeArtifactRequestError && (cause.status === 401 || cause.status === 403)) {
+ cache.clear();
+ warmup.current?.abort();
+ current.current = null;
+ setListing(null);
+ setError(cause.message);
+ lastValidated.current = 0;
+ } else if (cause instanceof RuntimeArtifactRequestError && cause.status === 404) {
+ cache.removePath(path);
+ }
+ throw cause;
+ }), [cache, scope.runtimeId, scope.region, scope.appName, scope.sessionId]);
+
+ const refresh = useCallback(async (cursor?: string, force = false) => {
+ if (request.current) { if (force) refreshAgain.current = true; return; }
+ const controller = new AbortController();
+ request.current = controller;
+ setLoading(true);
+ setError("");
+ try {
+ const page = await listRuntimeArtifacts(scope, controller.signal, cursor);
+ if (controller.signal.aborted) return;
+ const previous = current.current;
+ const result = cursor && previous ? { ...page, items: [...new Map([...previous.items, ...page.items].map(item => [item.path, item])).values()] } : page;
+ cache.reconcile(result.available ? result.items : [], !result.available || result.nextCursor === null);
+ const changed = JSON.stringify(previous) !== JSON.stringify(result);
+ if (changed) {
+ current.current = result;
+ setListing(result);
+ setPreviewRevision(value => value + 1);
+ }
+ lastValidated.current = Date.now();
+ if (changed && result.available) {
+ warmup.current?.abort();
+ const warming = new AbortController();
+ warmup.current = warming;
+ void warmArtifactPreviews(result.items, loadPreview, warming.signal);
+ }
+ } catch (cause) {
+ if (!controller.signal.aborted) {
+ if (cause instanceof RuntimeArtifactRequestError && [401, 403, 404].includes(cause.status)) {
+ cache.clear();
+ warmup.current?.abort();
+ current.current = null;
+ setListing(null);
+ lastValidated.current = 0;
+ }
+ setError(cause instanceof Error ? cause.message : "无法加载会话产物,请重试");
+ }
+ } finally {
+ if (!controller.signal.aborted) {
+ request.current = null;
+ setLoading(false);
+ if (refreshAgain.current) { refreshAgain.current = false; void refresh(); }
+ }
+ }
+ }, [cache, loadPreview, scope.runtimeId, scope.region, scope.appName, scope.sessionId]);
+
+ useEffect(() => {
+ current.current = null;
+ setListing(null);
+ lastValidated.current = 0;
+ void refresh();
+ return () => {
+ request.current?.abort();
+ request.current = null;
+ warmup.current?.abort();
+ cache.clear();
+ refreshAgain.current = false;
+ };
+ }, [cache, refresh]);
+
+ useEffect(() => {
+ if (wasBusy.current && !busy) void refresh(undefined, true);
+ wasBusy.current = busy;
+ }, [busy, refresh]);
+
+ useEffect(() => {
+ if (!open) return;
+ const revalidate = () => { if (Date.now() - lastValidated.current >= LIST_FRESHNESS_MS) void refresh(); };
+ revalidate();
+ const timer = window.setInterval(revalidate, LIST_FRESHNESS_MS);
+ return () => window.clearInterval(timer);
+ }, [open, refresh]);
+
+ return { listing, loading, error, previewRevision, loadPreview, refresh };
+}
diff --git a/frontend/src/ui/AgentTopology.tsx b/frontend/src/ui/AgentTopology.tsx
index e6a88f2a2..57939d711 100644
--- a/frontend/src/ui/AgentTopology.tsx
+++ b/frontend/src/ui/AgentTopology.tsx
@@ -235,7 +235,7 @@ export function AgentInfoPanel({
.map((tool) => ({
id: `studio:tool:${tool.id}`,
name: tool.id,
- label: tool.name,
+ label: studioToolLabel(tool.id, t, tool.name),
custom: true,
removable: !managedIds.has(tool.id),
}));
diff --git a/frontend/src/ui/StudioToolDialog.tsx b/frontend/src/ui/StudioToolDialog.tsx
index bae575498..2d9ae6acd 100644
--- a/frontend/src/ui/StudioToolDialog.tsx
+++ b/frontend/src/ui/StudioToolDialog.tsx
@@ -11,14 +11,15 @@ const STUDIO_TOOL_LABEL_KEYS: Record = {
get_city_weather: "studioTools.labels.get_city_weather",
get_location_weather: "studioTools.labels.get_location_weather",
web_fetch: "studioTools.labels.web_fetch",
+ studio_write_artifact: "studioTools.labels.studio_write_artifact",
};
-export function studioToolLabel(name: string, t: TFunction): string {
+export function studioToolLabel(name: string, t: TFunction, displayName?: string): string {
const catalogTool = BUILTIN_TOOLS.find(
(tool) => tool.id === name || tool.toolNames.includes(name),
);
const labelKey = STUDIO_TOOL_LABEL_KEYS[name];
- return labelKey ? t(labelKey) : catalogTool?.label ?? name;
+ return labelKey ? t(labelKey) : displayName || catalogTool?.label || name;
}
function CloseIcon() {
@@ -143,7 +144,7 @@ export function StudioToolDialog({
- {tool.name || studioToolLabel(tool.id, t)}
+ {studioToolLabel(tool.id, t, tool.name)}
{tool.id}
{tool.description}
diff --git a/frontend/tests/assetImports.test.mjs b/frontend/tests/assetImports.test.mjs
new file mode 100644
index 000000000..edef73e2c
--- /dev/null
+++ b/frontend/tests/assetImports.test.mjs
@@ -0,0 +1,57 @@
+import assert from "node:assert/strict";
+import test from "node:test";
+
+import { extractJavaScriptImports } from "../scripts/assetImports.mjs";
+
+test("ignores bundled code examples, comments, regexes, and ordinary import methods", () => {
+ const source = [
+ `const prettierExample = 'from "./module"';`,
+ `const quotedExample = "import('./string-only.js')";`,
+ 'const templateExample = `export * from "./template-only.js"`;',
+ String.raw`const regexExample = /import\("\.\/regex-only.js"\)/;`,
+ '// import "./line-comment.js";',
+ '/* export { sample } from "./block-comment.js"; */',
+ 'loader.import("./method-call.js");',
+ 'const from = "./variable.js";',
+ 'export { from };',
+ ].join("\n");
+
+ assert.deepEqual(extractJavaScriptImports(source), []);
+});
+
+test("extracts static imports and re-exports, including side-effect imports", () => {
+ assert.deepEqual(extractJavaScriptImports(`
+ import "./side-effect.js";
+ import value from "./default.js";
+ import { named as other } from "./named.js";
+ import * as namespace from "./namespace.js";
+ export { value } from "./re-export.js";
+ export * from "./all.js";
+ export * as nested from "./namespace-export.js";
+ import data from "./data.json" with { type: "json" };
+ `), [
+ "./side-effect.js", "./default.js", "./named.js", "./namespace.js",
+ "./re-export.js", "./all.js", "./namespace-export.js", "./data.json",
+ ]);
+});
+
+test("extracts literal dynamic imports inside callbacks with comments or options", () => {
+ const source = [
+ 'const lazy = () => import(/* preload */ "../chunk.js?version=1#part");',
+ 'function load() { return import("./data.json", { with: { type: "json" } }); }',
+ 'const fixedTemplate = () => import(`./fixed.js`);',
+ 'const unknown = path => import(path);',
+ 'const interpolated = name => import(`./${name}.js`);',
+ 'const meta = import.meta.url;',
+ ].join("\n");
+ assert.deepEqual(extractJavaScriptImports(source), [
+ "../chunk.js?version=1#part", "./data.json", "./fixed.js",
+ ]);
+});
+
+test("decodes escaped specifiers and handles minified imports separated by comments", () => {
+ const source = String.raw`import{a}from/* generated */".\u002fsource.js";export{a}from"./export.js";const load=()=>import(/* lazy */'./chunk.js');`;
+ assert.deepEqual(extractJavaScriptImports(source), [
+ "./source.js", "./export.js", "./chunk.js",
+ ]);
+});
diff --git a/frontend/tests/runtimeArtifactCache.test.ts b/frontend/tests/runtimeArtifactCache.test.ts
new file mode 100644
index 000000000..e7275d184
--- /dev/null
+++ b/frontend/tests/runtimeArtifactCache.test.ts
@@ -0,0 +1,92 @@
+// @vitest-environment jsdom
+import { expect, it, vi } from "vitest";
+import { ArtifactPreviewCache } from "../src/runtime-artifacts/artifactCache";
+import { warmArtifactPreviews } from "../src/runtime-artifacts/artifactWarmup";
+
+const version = { updatedAt: "v1", sizeBytes: 4, mimeType: "text/plain" };
+
+it("deduplicates in-flight reads and caches each file version within one drawer", async () => {
+ const cache = new ArtifactPreviewCache();
+ let finish!: (blob: Blob) => void;
+ const load = vi.fn(() => new Promise(resolve => { finish = resolve; }));
+ const first = cache.get("a.txt", version, load);
+ expect(cache.get("a.txt", version, load)).toBe(first);
+ finish(new Blob(["data"]));
+ await first;
+ expect(await cache.get("a.txt", version, load)).toHaveProperty("size", 4);
+ expect(load).toHaveBeenCalledTimes(1);
+ const next = vi.fn(async () => new Blob(["new!"]));
+ await cache.get("a.txt", { ...version, updatedAt: "v2" }, next);
+ expect(next).toHaveBeenCalledTimes(1);
+ await new ArtifactPreviewCache().get("a.txt", version, next);
+ expect(next).toHaveBeenCalledTimes(2);
+});
+
+it("evicts least recently used blobs to bound memory and retries failed reads", async () => {
+ const cache = new ArtifactPreviewCache(8, 2);
+ const load = vi.fn(async () => new Blob(["data"]));
+ await cache.get("a", version, load);
+ await cache.get("b", version, load);
+ await cache.get("a", version, load);
+ await cache.get("c", version, load);
+ await cache.get("a", version, load);
+ expect(load).toHaveBeenCalledTimes(3);
+ await cache.get("b", version, load);
+ expect(load).toHaveBeenCalledTimes(4);
+ const fail = vi.fn().mockRejectedValueOnce(new Error("offline")).mockResolvedValue(new Blob(["data"]));
+ await expect(cache.get("failed", version, fail)).rejects.toThrow("offline");
+ await expect(cache.get("failed", version, fail)).resolves.toHaveProperty("size", 4);
+});
+
+it("clears completed blobs and cancels in-flight reads without allowing stale cache writes", async () => {
+ const cache = new ArtifactPreviewCache();
+ let finish!: (blob: Blob) => void;
+ let signal!: AbortSignal;
+ const pending = cache.get("a", version, input => {
+ signal = input;
+ return new Promise(resolve => { finish = resolve; });
+ });
+ cache.clear();
+ expect(signal.aborted).toBe(true);
+ finish(new Blob(["old!"]));
+ await expect(pending).rejects.toHaveProperty("name", "AbortError");
+ const load = vi.fn(async () => new Blob(["new!"]));
+ await cache.get("a", version, load);
+ cache.clear();
+ await cache.get("a", version, load);
+ expect(load).toHaveBeenCalledTimes(2);
+});
+
+it("preserves unchanged versions while removing changed or deleted files", async () => {
+ const cache = new ArtifactPreviewCache();
+ const load = vi.fn(async () => new Blob(["data"]));
+ const first = { ...version, path: "a.txt", name: "a.txt" };
+ const second = { ...version, path: "b.txt", name: "b.txt" };
+ await cache.get(first.path, first, load);
+ await cache.get(second.path, second, load);
+ cache.reconcile([first, second], true);
+ await cache.get(first.path, first, load);
+ expect(load).toHaveBeenCalledTimes(2);
+ cache.reconcile([{ ...first, updatedAt: "v2" }], true);
+ await cache.get(first.path, first, load);
+ await cache.get(second.path, second, load);
+ expect(load).toHaveBeenCalledTimes(4);
+});
+
+it("bounds warmup to four small files, one MiB, and two concurrent requests", async () => {
+ const items = [
+ { path: "large.html", name: "large.html", mimeType: "text/html", sizeBytes: 2 * 1024 * 1024, updatedAt: "v1" },
+ ...["report.html", "chart.svg", "a.txt", "b.txt", "c.txt"].map(path => ({ path, name: path, mimeType: path.endsWith("html") ? "text/html" : path.endsWith("svg") ? "image/svg+xml" : "text/plain", sizeBytes: 250 * 1024, updatedAt: "v1" })),
+ ];
+ let active = 0;
+ let peak = 0;
+ const load = vi.fn(async () => {
+ peak = Math.max(peak, ++active);
+ await Promise.resolve();
+ active--;
+ return new Blob();
+ });
+ await warmArtifactPreviews(items, load, new AbortController().signal);
+ expect(load.mock.calls.map(call => call[0])).toEqual(["report.html", "chart.svg", "a.txt", "b.txt"]);
+ expect(peak).toBe(2);
+});
diff --git a/frontend/tests/runtimeArtifacts.test.ts b/frontend/tests/runtimeArtifacts.test.ts
new file mode 100644
index 000000000..5a21dae22
--- /dev/null
+++ b/frontend/tests/runtimeArtifacts.test.ts
@@ -0,0 +1,52 @@
+// @vitest-environment jsdom
+import { expect, it } from "vitest";
+import { artifactEntries, artifactPreviewKind, resolveArtifactResource, prepareArtifactHtml } from "../src/runtime-artifacts/artifactPreview";
+
+it("keeps nested artifact directories and file selection identities", () => {
+ const entries = artifactEntries([
+ { path: "reports/index.html", name: "index.html", sizeBytes: 12, mimeType: "text/html", updatedAt: "" },
+ { path: "reports/images/chart.svg", name: "chart.svg", sizeBytes: 12, mimeType: "image/svg+xml", updatedAt: "" },
+ ]);
+ expect(entries[0]).toMatchObject({ id: "folder:reports", name: "reports", type: "folder" });
+ expect(JSON.stringify(entries)).toContain('"id":"reports/images/chart.svg"');
+});
+
+it("resolves relative resources within the session and rejects external or escaping references", () => {
+ expect(resolveArtifactResource("reports/index.html", "../images/chart.svg")).toBe("images/chart.svg");
+ expect(resolveArtifactResource("index.html", "./images/chart.svg")).toBe("images/chart.svg");
+ for (const path of ["../../private.txt", "https://example.org/x", "//example.org/x", "/web/auth", "%2e%2e/private", "javascript:alert(1)", "..\\private"]) {
+ expect(resolveArtifactResource("index.html", path)).toBeNull();
+ }
+});
+
+it("previews known file formats and treats unknown binary files as downloads", () => {
+ expect(artifactPreviewKind("image/svg+xml", "plot.svg")).toBe("image");
+ expect(artifactPreviewKind("text/html", "report.html")).toBe("html");
+ expect(artifactPreviewKind("text/plain", "README.md")).toBe("markdown");
+ expect(artifactPreviewKind("application/json", "data.json")).toBe("text");
+ expect(artifactPreviewKind("application/zip", "data.zip")).toBe("download");
+});
+
+it("embeds only authenticated same-session images and blocks active HTML", async () => {
+ const requested: string[] = [];
+ const html = await prepareArtifactHtml('
', "reports/index.html", async path => {
+ requested.push(path);
+ return new Blob([''], { type: "image/svg+xml" });
+ });
+ expect(requested).toEqual(["reports/images/chart.svg"]);
+ expect(html).toContain("data:image/svg+xml;base64,");
+ expect(html).toContain("default-src 'none'");
+ expect(html).not.toMatch(/', "index.html",
+ () => new Promise(resolve => { finish = resolve; }), html => { initial = html; });
+ expect(initial).toContain("Read this immediately");
+ expect(initial).toContain("default-src 'none'");
+ expect(initial).not.toMatch(/'], { type: "image/svg+xml" }));
+ expect(await pending).toContain("data:image/svg+xml;base64,");
+});
diff --git a/frontend/tests/runtimeArtifactsInteraction.test.tsx b/frontend/tests/runtimeArtifactsInteraction.test.tsx
new file mode 100644
index 000000000..2d3dd36ed
--- /dev/null
+++ b/frontend/tests/runtimeArtifactsInteraction.test.tsx
@@ -0,0 +1,176 @@
+// @vitest-environment jsdom
+import React, { act } from "react";
+import { readFileSync } from "node:fs";
+import ts from "typescript";
+import { createRoot, type Root } from "react-dom/client";
+import { afterEach, beforeAll, beforeEach, expect, it, vi } from "vitest";
+import { RuntimeArtifacts } from "../src/runtime-artifacts/RuntimeArtifacts";
+import { listRuntimeArtifacts, getRuntimeArtifact, RuntimeArtifactRequestError, type RuntimeArtifactScope } from "../src/adk/runtimeArtifacts";
+
+vi.mock("../src/adk/runtimeArtifacts", () => ({ listRuntimeArtifacts: vi.fn(), getRuntimeArtifact: vi.fn(), MAX_ARTIFACT_PREVIEW_BYTES: 5 * 1024 * 1024,
+ RuntimeArtifactRequestError: class extends Error { constructor(message: string, readonly status: number) { super(message); } },
+}));
+vi.mock("../src/components/ai-app/ConversationFlow/ConversationRichContent", () => ({ ConversationMarkdown: ({ text }: { text: string }) => {text}
}));
+vi.mock("../src/components/composites/CodeBlock", () => ({ CodeBlock: ({ lines }: { lines: string[] }) => {lines.join("\n")} }));
+vi.mock("../src/components/composites/FileExplorer/FileExplorerPane", () => ({ fileText: () => "", FileExplorerPane: () => null }));
+
+beforeAll(() => {
+ vi.stubGlobal("IS_REACT_ACT_ENVIRONMENT", true);
+ vi.stubGlobal("ResizeObserver", class { observe() {} unobserve() {} disconnect() {} });
+ window.matchMedia = vi.fn().mockImplementation(query => ({ matches: true, media: query, addListener() {}, removeListener() {}, addEventListener() {}, removeEventListener() {} }));
+ Element.prototype.scrollIntoView = vi.fn();
+});
+
+let host: HTMLDivElement;
+let root: Root;
+const scope: RuntimeArtifactScope = { runtimeId: "runtime-1", region: "cn-beijing", appName: "runtime_artifacts", sessionId: "session-1" };
+const artifact = { path: "reports/report.html", name: "report.html", mimeType: "text/html", sizeBytes: 100, updatedAt: "v1" };
+beforeEach(() => {
+ vi.mocked(listRuntimeArtifacts).mockReset().mockResolvedValue({ available: true, items: [artifact], nextCursor: null });
+ vi.mocked(getRuntimeArtifact).mockReset().mockResolvedValue({ size: 100, text: async () => '真实产物
' } as Blob);
+ host = document.createElement("div"); document.body.append(host); root = createRoot(host);
+});
+afterEach(async () => { await act(async () => root.unmount()); host.remove(); });
+async function render(selectedScope = scope, busy = false) {
+ await act(async () => root.render());
+}
+async function click(element: Element | null) {
+ expect(element).not.toBeNull();
+ await act(async () => (element as HTMLElement).click());
+}
+function button(text: string) { return Array.from(document.querySelectorAll("button")).find(node => node.textContent === text) ?? null; }
+
+it("mounts a single artifact entry in the composer instead of inside transcript replies", () => {
+ const source = readFileSync(`${process.cwd()}/src/App.tsx`, "utf8");
+ const file = ts.createSourceFile("App.tsx", source, ts.ScriptTarget.Latest, true, ts.ScriptKind.TSX);
+ const entries: ts.JsxSelfClosingElement[] = [];
+ let composer: ts.VariableDeclaration | undefined;
+ function visit(node: ts.Node) {
+ if (ts.isVariableDeclaration(node) && node.name.getText(file) === "composer") composer = node;
+ if (ts.isJsxSelfClosingElement(node) && node.tagName.getText(file) === "RuntimeArtifacts") entries.push(node);
+ ts.forEachChild(node, visit);
+ }
+ visit(file);
+ expect(entries).toHaveLength(1);
+ expect(composer).toBeDefined();
+ expect(entries[0].pos).toBeGreaterThan(composer!.pos);
+ expect(entries[0].end).toBeLessThan(composer!.end);
+ expect(entries[0].getText(file)).toContain("busy={activeConversationBusy}");
+ expect(entries[0].getText(file)).toContain("userId");
+ expect(entries[0].getText(file)).toContain("currentRuntimeAppName || appName");
+});
+
+it("opens from the conversation, selects a nested file, and uses a scriptless preview", async () => {
+ await render();
+ expect(listRuntimeArtifacts).toHaveBeenCalledTimes(1);
+ await click(button("会话产物"));
+ expect(listRuntimeArtifacts).toHaveBeenCalledWith(scope, expect.any(AbortSignal), undefined);
+ await click(document.querySelector('[role="treeitem"][title="report.html"]'));
+ expect(getRuntimeArtifact).toHaveBeenCalledWith(scope, artifact.path, expect.any(AbortSignal));
+ const frame = document.querySelector("iframe")!;
+ expect(frame.getAttribute("sandbox")).toBe("");
+ expect(frame.srcdoc).toContain("真实产物");
+ expect(frame.srcdoc).not.toContain("