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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
34 changes: 34 additions & 0 deletions frontend/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
24 changes: 24 additions & 0 deletions frontend/scripts/assetImports.mjs
Original file line number Diff line number Diff line change
@@ -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;
}
11 changes: 8 additions & 3 deletions frontend/scripts/verifyBuiltAssets.mjs
Original file line number Diff line number Diff line change
Expand Up @@ -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]
Expand Down Expand Up @@ -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]);
}
Expand Down
25 changes: 25 additions & 0 deletions frontend/server/runtime_artifacts/__init__.py
Original file line number Diff line number Diff line change
@@ -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",
]
124 changes: 124 additions & 0 deletions frontend/server/runtime_artifacts/routes.py
Original file line number Diff line number Diff line change
@@ -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),
)
80 changes: 80 additions & 0 deletions frontend/server/runtime_artifacts/runtime_detail.py
Original file line number Diff line number Diff line change
@@ -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)
Loading
Loading