Merge branch 'feature/mcp-ui' into dev
This commit is contained in:
commit
c14a22bbd1
@ -81,7 +81,6 @@ from utils.log_util.logger import init_with_fastapi
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
from routes import chat, files, projects, system, skill_manager, database, memory
|
||||
from routes.mcp_resources import router as mcp_resources_router
|
||||
from routes.webdav import wsgidav_app
|
||||
|
||||
|
||||
@ -189,6 +188,36 @@ app.mount("/public", StaticFiles(directory="public"), name="static")
|
||||
# Mount robot projects directory as static files (supports HTML/CSS/JS/images)
|
||||
app.mount("/robots", StaticFiles(directory="projects/robot", html=True), name="robots")
|
||||
|
||||
|
||||
# Daytona fallback middleware: fetch file from sandbox when local file is missing
|
||||
from utils.settings import DAYTONA_ENABLED
|
||||
|
||||
if DAYTONA_ENABLED:
|
||||
import asyncio
|
||||
from pathlib import Path
|
||||
from starlette.responses import FileResponse
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent
|
||||
|
||||
@app.middleware("http")
|
||||
async def daytona_robots_fallback(request, call_next):
|
||||
response = await call_next(request)
|
||||
if response.status_code == 404 and request.url.path.startswith("/robots/"):
|
||||
path_after = request.url.path.removeprefix("/robots/")
|
||||
parts = path_after.split("/", 1)
|
||||
if len(parts) == 2:
|
||||
bot_id, rel_path = parts
|
||||
local_path = PROJECT_ROOT / "projects" / "robot" / bot_id / rel_path
|
||||
if not local_path.is_file():
|
||||
from utils.daytona_file_fetcher import fetch_file_from_daytona
|
||||
fetched = await asyncio.to_thread(
|
||||
fetch_file_from_daytona, bot_id, rel_path, local_path
|
||||
)
|
||||
if fetched and local_path.is_file():
|
||||
return FileResponse(str(local_path))
|
||||
return response
|
||||
|
||||
|
||||
# Add CORS middleware for frontend pages
|
||||
app.add_middleware(
|
||||
CORSMiddleware,
|
||||
@ -210,8 +239,6 @@ app.include_router(skill_manager.router)
|
||||
app.include_router(database.router)
|
||||
app.include_router(memory.router)
|
||||
|
||||
# MCP App resources endpoint
|
||||
app.include_router(mcp_resources_router)
|
||||
|
||||
# 挂载 WsgiDAV(WSGI 应用通过 WSGIMiddleware 集成到 ASGI)
|
||||
|
||||
|
||||
@ -1,77 +0,0 @@
|
||||
"""
|
||||
REST endpoint for serving MCP App resources.
|
||||
|
||||
Maps ui:// URIs to static HTML App files under the bot's skill directory.
|
||||
|
||||
GET /api/v1/mcp/resources?uri=ui://data-dashboard/chart&bot_id=xxx
|
||||
-> projects/robot/{bot_id}/skills/data-dashboard/apps/chart.html
|
||||
"""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from fastapi import APIRouter, Query, HTTPException
|
||||
from fastapi.responses import HTMLResponse
|
||||
|
||||
logger = logging.getLogger(__name__)
|
||||
|
||||
router = APIRouter(prefix="/api/v1/mcp", tags=["mcp-resources"])
|
||||
|
||||
PROJECT_ROOT = Path(__file__).parent.parent
|
||||
|
||||
# In-memory cache: (bot_id, uri) -> html string
|
||||
_resource_cache: dict[tuple[str, str], str] = {}
|
||||
|
||||
|
||||
def _resolve_uri_to_path(uri: str, bot_id: str) -> Path:
|
||||
"""Resolve a ui:// URI to a local file path.
|
||||
|
||||
URI format: ui://{server-name}/{resource-name}
|
||||
File path: projects/robot/{bot_id}/skills/{server-name}/apps/{resource-name}.html
|
||||
"""
|
||||
if not uri.startswith("ui://"):
|
||||
raise ValueError(f"Invalid URI scheme: {uri}")
|
||||
|
||||
rest = uri[5:] # Remove "ui://"
|
||||
parts = rest.split("/", 1)
|
||||
if len(parts) != 2 or not parts[0] or not parts[1]:
|
||||
raise ValueError(f"Invalid URI format: {uri}")
|
||||
|
||||
server_name, resource_name = parts
|
||||
|
||||
# Prevent path traversal
|
||||
if ".." in server_name or "/" in server_name:
|
||||
raise ValueError(f"Invalid server name: {server_name}")
|
||||
safe_name = Path(resource_name).name
|
||||
if safe_name != resource_name:
|
||||
raise ValueError(f"Invalid resource name: {resource_name}")
|
||||
|
||||
return PROJECT_ROOT / "projects" / "robot" / bot_id / "skills" / server_name / "apps" / f"{resource_name}.html"
|
||||
|
||||
|
||||
@router.get("/resources")
|
||||
async def get_resource(
|
||||
uri: str = Query(..., description="Resource URI (e.g. ui://data-dashboard/chart)"),
|
||||
bot_id: str = Query(..., description="Bot ID"),
|
||||
):
|
||||
"""Fetch an MCP App HTML resource by URI and bot_id.
|
||||
|
||||
Returns the static HTML App that should be loaded into an iframe.
|
||||
The host sends tool result data to the iframe via postMessage.
|
||||
"""
|
||||
cache_key = (bot_id, uri)
|
||||
if cache_key in _resource_cache:
|
||||
return HTMLResponse(content=_resource_cache[cache_key], media_type="text/html")
|
||||
|
||||
try:
|
||||
file_path = _resolve_uri_to_path(uri, bot_id)
|
||||
except ValueError as e:
|
||||
raise HTTPException(status_code=400, detail=str(e))
|
||||
|
||||
if not file_path.is_file():
|
||||
raise HTTPException(status_code=404, detail=f"Resource not found: {uri} (bot_id={bot_id})")
|
||||
|
||||
html = file_path.read_text(encoding="utf-8")
|
||||
_resource_cache[cache_key] = html
|
||||
|
||||
return HTMLResponse(content=html, media_type="text/html")
|
||||
51
utils/daytona_file_fetcher.py
Normal file
51
utils/daytona_file_fetcher.py
Normal file
@ -0,0 +1,51 @@
|
||||
"""On-demand single file fetcher from Daytona sandbox."""
|
||||
|
||||
import logging
|
||||
from pathlib import Path
|
||||
|
||||
from utils.settings import DAYTONA_ENABLED, DAYTONA_API_KEY, DAYTONA_SERVER_URL
|
||||
|
||||
logger = logging.getLogger('app')
|
||||
|
||||
REMOTE_WORKSPACE_ROOT = "/workspace"
|
||||
|
||||
|
||||
def fetch_file_from_daytona(bot_id: str, relative_path: str, local_path: Path) -> bool:
|
||||
"""Fetch a single file from the Daytona sandbox and save it locally.
|
||||
|
||||
Args:
|
||||
bot_id: The bot ID (sandbox is named "bot-{bot_id}")
|
||||
relative_path: Path relative to the bot's workspace root
|
||||
local_path: Local filesystem path where the file should be saved
|
||||
|
||||
Returns:
|
||||
True if the file was successfully fetched and saved, False otherwise
|
||||
"""
|
||||
if not (DAYTONA_ENABLED and DAYTONA_API_KEY and DAYTONA_SERVER_URL):
|
||||
return False
|
||||
|
||||
try:
|
||||
from daytona import Daytona, DaytonaConfig
|
||||
|
||||
config = DaytonaConfig(api_key=DAYTONA_API_KEY, api_url=DAYTONA_SERVER_URL)
|
||||
client = Daytona(config)
|
||||
|
||||
sandbox_name = f"bot-{bot_id}"
|
||||
sandbox = client.get(sandbox_name)
|
||||
|
||||
if sandbox.state not in ("Started", "Creating"):
|
||||
logger.warning(f"Sandbox {sandbox_name} not running (state={sandbox.state})")
|
||||
return False
|
||||
|
||||
remote_path = f"{REMOTE_WORKSPACE_ROOT}/{relative_path}"
|
||||
content = sandbox.fs.download_file(remote_path)
|
||||
|
||||
local_path.parent.mkdir(parents=True, exist_ok=True)
|
||||
local_path.write_bytes(content)
|
||||
|
||||
logger.info(f"Fetched file from Daytona: {remote_path} -> {local_path}")
|
||||
return True
|
||||
|
||||
except Exception as e:
|
||||
logger.warning(f"Failed to fetch file from Daytona for bot {bot_id}: {e}")
|
||||
return False
|
||||
Loading…
Reference in New Issue
Block a user