feat: introduce MCP server, cross-platform runtime, and Showcase UI reorganization
- Showcase UI Reorganization: * Move web UI dashboard from root directory into apps/showcase_ui/ for cleaner project structure * Add 'artemis ui' CLI command (artemis/interfaces/cli/commands/ui.py) and launcher scripts for the interactive showcase console - Standalone MCP Server & Notifiers (mcp_server/): * Implement dedicated MCP server package with mobile automation tools (device_state, inspect_trace, task_manager, task_runner) * Add multi-channel notifiers (AgentAPI, Desktop notifications, Webhooks, File output, and Composite routing) * Introduce background task runner and trace store for autonomous mobile execution monitoring - Cross-Platform Runtime & Toolchain Support: * Add artemis/platform/ with OS-specific implementations for Linux, macOS (Darwin), and Windows * Add artemis/runtime/supervisor.py for resilient process and agent runtime supervision * Add artemis/toolchain/ with dependency resolution and SDK descriptors * Add cross-platform launcher scripts (start.sh, start.bat) and installer scripts (scripts/install_deps.*) - Core Enhancements & Diagnostics: * Enhance coordinate normalization, OCR API fallback, and perception pipeline * Add credential validation (credentials_validator.py) and system diagnostics * Update bilingual documentation (README.md, README_CN.md) and architecture diagrams - Test Suite Expansion: * Add integration tests for cloud gateway, remote ADB, and Artemis login * Add unit tests covering core diagnostics, platform detection, toolchain, credentials validator, optional OCR, and MCP tools
This commit is contained in:
1 parent
78e478f502
commit
9a5aaf99b7
249 files changed
+37226
-9339
No files matched your search
@@ -26,10 +26,9 @@ except ImportError:
|
||||
router = APIRouter(tags=["media"])
|
||||
|
||||
|
||||
@router.get("/", response_class=HTMLResponse)
|
||||
@router.get("/admin", response_class=HTMLResponse)
|
||||
@router.get("/debug", response_class=HTMLResponse)
|
||||
async def get_index():
|
||||
async def get_admin_index():
|
||||
admin_index = Path(__file__).resolve().parent.parent / "index.html"
|
||||
if admin_index.exists():
|
||||
return admin_index.read_text(encoding="utf-8")
|
||||
|
||||
@@ -0,0 +1,348 @@
|
||||
# Copyright 2026 Google LLC
|
||||
#
|
||||
# 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.
|
||||
|
||||
"""System Readiness & Diagnostics Router for Artemis Admin Console."""
|
||||
|
||||
from fastapi import APIRouter, HTTPException
|
||||
from pydantic import BaseModel, Field
|
||||
|
||||
from artemis.core.diagnostics import readiness_engine
|
||||
from artemis.core.diagnostics.schema import SystemReadinessReport
|
||||
|
||||
router = APIRouter(prefix="/api/system", tags=["system"])
|
||||
|
||||
|
||||
class SelectDeviceRequest(BaseModel):
|
||||
"""Payload to select an active target Android device."""
|
||||
|
||||
serial: str = Field(description="Serial number or identifier of the Android device to select")
|
||||
|
||||
|
||||
@router.get("/readiness", response_model=SystemReadinessReport)
|
||||
async def get_system_readiness() -> SystemReadinessReport:
|
||||
"""Execute all diagnostic probes and return a comprehensive system readiness report."""
|
||||
return await readiness_engine.run_all()
|
||||
|
||||
|
||||
@router.post("/devices/select")
|
||||
async def select_active_device(request: SelectDeviceRequest):
|
||||
"""Select the active Android device or emulator for subsequent automated tasks."""
|
||||
serial = request.serial.strip()
|
||||
if not serial:
|
||||
raise HTTPException(status_code=400, detail="Device serial cannot be empty.")
|
||||
|
||||
readiness_engine.set_active_device_serial(serial)
|
||||
# Return updated readiness
|
||||
report = await readiness_engine.run_all()
|
||||
return {
|
||||
"status": "success",
|
||||
"selected_serial": serial,
|
||||
"report": report,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/adb/restart")
|
||||
async def restart_adb_server():
|
||||
"""Restart local ADB server and return an updated readiness check."""
|
||||
restart_result = await readiness_engine.restart_adb_server()
|
||||
updated_report = await readiness_engine.run_all()
|
||||
return {
|
||||
"restart_result": restart_result,
|
||||
"report": updated_report,
|
||||
}
|
||||
|
||||
|
||||
class ConnectAdbRequest(BaseModel):
|
||||
"""Payload to connect to an Android device over Wi-Fi."""
|
||||
|
||||
host: str = Field(description="IP address of Android device")
|
||||
port: int = Field(default=5555, description="Port number")
|
||||
|
||||
|
||||
@router.post("/adb/connect")
|
||||
async def connect_wireless_adb(request: ConnectAdbRequest):
|
||||
"""Connect to a device over Wi-Fi and return updated readiness."""
|
||||
connect_result = await readiness_engine.connect_wireless_adb(request.host, request.port)
|
||||
updated_report = await readiness_engine.run_all()
|
||||
return {
|
||||
"connect_result": connect_result,
|
||||
"report": updated_report,
|
||||
}
|
||||
|
||||
|
||||
class LaunchEmulatorRequest(BaseModel):
|
||||
"""Payload to launch a local Android Virtual Device (AVD)."""
|
||||
|
||||
avd_name: str = Field(
|
||||
description="Name of the installed AVD emulator to launch (e.g. Android_2)"
|
||||
)
|
||||
|
||||
|
||||
@router.post("/emulator/launch")
|
||||
async def launch_emulator(request: LaunchEmulatorRequest):
|
||||
"""Launch an Android emulator in the background and return initiation status."""
|
||||
avd_name = request.avd_name.strip()
|
||||
if not avd_name:
|
||||
raise HTTPException(status_code=400, detail="AVD name cannot be empty.")
|
||||
|
||||
launch_res = await readiness_engine.launch_emulator(avd_name)
|
||||
return launch_res
|
||||
|
||||
|
||||
@router.get("/emulator/status")
|
||||
async def get_emulator_status():
|
||||
"""Query real-time progress and logs of background emulator launch."""
|
||||
return readiness_engine.get_emulator_status()
|
||||
|
||||
|
||||
@router.post("/emulator/stop")
|
||||
async def stop_emulator():
|
||||
"""Stop active emulator process."""
|
||||
return await readiness_engine.stop_emulator()
|
||||
|
||||
|
||||
@router.post("/emulator/dismiss")
|
||||
async def dismiss_emulator():
|
||||
"""Dismiss emulator launch tracking state."""
|
||||
return readiness_engine.dismiss_emulator()
|
||||
|
||||
|
||||
class UpdateCredentialsRequest(BaseModel):
|
||||
"""Payload to update and configure LLM or Vision OCR API credentials."""
|
||||
|
||||
provider: str = Field(
|
||||
default="google",
|
||||
description="Provider identifier (e.g. google, gemini, openai, anthropic, openrouter, ocr)",
|
||||
)
|
||||
api_key: str = Field(description="The secret API key string to configure")
|
||||
persist_to_env: bool = Field(
|
||||
default=True, description="Whether to persist the key to .env file"
|
||||
)
|
||||
|
||||
|
||||
class ValidateCredentialsRequest(BaseModel):
|
||||
"""Payload to test and verify LLM or Vision OCR API credentials without saving."""
|
||||
|
||||
provider: str = Field(
|
||||
default="google",
|
||||
description="Provider identifier (e.g. google, gemini, openai, anthropic, openrouter, ocr)",
|
||||
)
|
||||
api_key: str = Field(description="The secret API key string to test")
|
||||
base_url: str | None = Field(default=None, description="Optional custom base URL")
|
||||
|
||||
|
||||
@router.get("/credentials")
|
||||
async def get_credentials():
|
||||
"""Retrieve current configured API keys for management."""
|
||||
from artemis.config import settings
|
||||
|
||||
gemini_k = settings.get_api_key("google")
|
||||
openai_k = settings.get_api_key("openai")
|
||||
anthropic_k = settings.get_api_key("anthropic")
|
||||
openrouter_k = settings.get_api_key("openrouter")
|
||||
ocr_k = settings.get_api_key("ocr")
|
||||
return {
|
||||
"google": gemini_k.get_secret_value() if gemini_k else None,
|
||||
"gemini": gemini_k.get_secret_value() if gemini_k else None,
|
||||
"openai": openai_k.get_secret_value() if openai_k else None,
|
||||
"anthropic": anthropic_k.get_secret_value() if anthropic_k else None,
|
||||
"openrouter": openrouter_k.get_secret_value() if openrouter_k else None,
|
||||
"ocr": ocr_k.get_secret_value() if ocr_k else None,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/credentials/test")
|
||||
async def test_credentials(request: ValidateCredentialsRequest):
|
||||
"""Test and verify whether an API key is valid and usable with the corresponding provider endpoint."""
|
||||
from artemis.utils.credentials_validator import validate_api_key
|
||||
|
||||
provider = request.provider.strip().lower()
|
||||
key = request.api_key.strip()
|
||||
|
||||
if not key:
|
||||
raise HTTPException(status_code=400, detail="API key cannot be empty.")
|
||||
|
||||
is_valid, message = await validate_api_key(
|
||||
provider=provider,
|
||||
api_key=key,
|
||||
base_url=request.base_url,
|
||||
)
|
||||
if not is_valid:
|
||||
raise HTTPException(status_code=400, detail=message)
|
||||
|
||||
return {
|
||||
"valid": True,
|
||||
"provider": provider,
|
||||
"message": message,
|
||||
}
|
||||
|
||||
|
||||
@router.post("/credentials")
|
||||
async def update_credentials(request: UpdateCredentialsRequest):
|
||||
"""Dynamically configure and persist LLM or Vision API key, returning updated readiness report."""
|
||||
from artemis.utils.credentials_validator import validate_api_key
|
||||
|
||||
provider = request.provider.strip().lower()
|
||||
key = request.api_key.strip()
|
||||
|
||||
# If a non-empty key is provided, verify it before saving
|
||||
if key:
|
||||
is_valid, validation_msg = await validate_api_key(provider=provider, api_key=key)
|
||||
if not is_valid:
|
||||
raise HTTPException(
|
||||
status_code=400,
|
||||
detail=f"API key verification failed: {validation_msg}",
|
||||
)
|
||||
|
||||
try:
|
||||
from artemis.config import settings
|
||||
|
||||
settings.set_api_key(provider, key, persist_to_env=request.persist_to_env)
|
||||
|
||||
# Re-run all diagnostic probes to build updated report
|
||||
updated_report = await readiness_engine.run_all()
|
||||
action_desc = (
|
||||
"successfully verified, updated, and applied" if key else "successfully cleared"
|
||||
)
|
||||
return {
|
||||
"status": "success",
|
||||
"message": f"API key for {provider} {action_desc}.",
|
||||
"provider": provider,
|
||||
"report": updated_report,
|
||||
}
|
||||
except HTTPException:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise HTTPException(status_code=500, detail=f"Failed to update credentials: {exc}")
|
||||
|
||||
|
||||
@router.get("/model-config-env")
|
||||
async def get_model_config_and_env():
|
||||
"""Retrieve the current active artemis.jsonc configuration and .env status for custom setup."""
|
||||
import os
|
||||
from artemis.config.paths import ROOT_DIR, get_config_path
|
||||
from artemis.config import settings
|
||||
from artemis.utils.file import load_jsonc
|
||||
|
||||
from artemis.config.settings import is_placeholder_key
|
||||
|
||||
# 1. Config file resolution
|
||||
config_path = None
|
||||
config_content = ""
|
||||
parsed_config = {}
|
||||
try:
|
||||
config_path_obj = get_config_path("artemis.jsonc")
|
||||
config_path = str(config_path_obj)
|
||||
config_content = config_path_obj.read_text(encoding="utf-8")
|
||||
with open(config_path_obj, encoding="utf-8") as f:
|
||||
parsed_config = load_jsonc(f)
|
||||
except Exception as e:
|
||||
config_content = f"// Error reading config: {e}"
|
||||
|
||||
# 2. Env file resolution
|
||||
env_path = ROOT_DIR / ".env"
|
||||
|
||||
# 3. Relevant Env Variables status
|
||||
def get_real_env_value(key_name: str, provider: str) -> str | None:
|
||||
k = settings.get_api_key(provider)
|
||||
if k and not is_placeholder_key(k):
|
||||
return k.get_secret_value()
|
||||
val = os.environ.get(key_name)
|
||||
if val and val.strip() and not is_placeholder_key(val):
|
||||
return val.strip()
|
||||
return None
|
||||
|
||||
def mask_key(k: str | None) -> str | None:
|
||||
if not k:
|
||||
return None
|
||||
if len(k) <= 8:
|
||||
return "****"
|
||||
return f"{k[:4]}...{k[-4:]}"
|
||||
|
||||
gemini_real = get_real_env_value("GEMINI_API_KEY", "google") or get_real_env_value(
|
||||
"GOOGLE_API_KEY", "google"
|
||||
)
|
||||
openai_real = get_real_env_value("OPENAI_API_KEY", "openai")
|
||||
anthropic_real = get_real_env_value("ANTHROPIC_API_KEY", "anthropic")
|
||||
openrouter_real = get_real_env_value("OPEN_ROUTER_API_KEY", "openrouter")
|
||||
xai_real = get_real_env_value("XAI_API_KEY", "xai")
|
||||
base_url_val = settings.OPENAI_BASE_URL or os.environ.get("OPENAI_BASE_URL")
|
||||
if base_url_val and is_placeholder_key(base_url_val):
|
||||
base_url_val = None
|
||||
ocr_real = get_real_env_value("OCR_API_KEY", "ocr") or get_real_env_value(
|
||||
"VISION_API_KEY", "ocr"
|
||||
)
|
||||
|
||||
env_vars = [
|
||||
{
|
||||
"name": "GEMINI_API_KEY",
|
||||
"provider": "google",
|
||||
"is_set": bool(gemini_real),
|
||||
"preview": mask_key(gemini_real),
|
||||
"description": "Google Gemini multimodal vision API key (free tier available)",
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_API_KEY",
|
||||
"provider": "openai",
|
||||
"is_set": bool(openai_real),
|
||||
"preview": mask_key(openai_real),
|
||||
"description": "OpenAI API key (GPT-4o, GPT-4o-mini)",
|
||||
},
|
||||
{
|
||||
"name": "ANTHROPIC_API_KEY",
|
||||
"provider": "anthropic",
|
||||
"is_set": bool(anthropic_real),
|
||||
"preview": mask_key(anthropic_real),
|
||||
"description": "Anthropic Claude API key (Claude 3.5 Sonnet, Claude 3.7)",
|
||||
},
|
||||
{
|
||||
"name": "OPEN_ROUTER_API_KEY",
|
||||
"provider": "openrouter",
|
||||
"is_set": bool(openrouter_real),
|
||||
"preview": mask_key(openrouter_real),
|
||||
"description": "OpenRouter unified API gateway key",
|
||||
},
|
||||
{
|
||||
"name": "XAI_API_KEY",
|
||||
"provider": "xai",
|
||||
"is_set": bool(xai_real),
|
||||
"preview": mask_key(xai_real),
|
||||
"description": "xAI Grok vision API key",
|
||||
},
|
||||
{
|
||||
"name": "OPENAI_BASE_URL",
|
||||
"provider": "custom",
|
||||
"is_set": bool(base_url_val),
|
||||
"preview": base_url_val,
|
||||
"description": "Custom API endpoint (for local Ollama, vLLM, DeepSeek, or proxies)",
|
||||
},
|
||||
{
|
||||
"name": "VISION_API_KEY",
|
||||
"provider": "ocr",
|
||||
"is_set": bool(ocr_real),
|
||||
"preview": mask_key(ocr_real),
|
||||
"description": "Google Cloud Vision OCR key for screen text detection (optional)",
|
||||
},
|
||||
]
|
||||
|
||||
return {
|
||||
"config_path": config_path or "config/artemis.jsonc",
|
||||
"config_filename": "artemis.jsonc",
|
||||
"config_content": config_content,
|
||||
"default_model": parsed_config.get("default", {}),
|
||||
"presets": parsed_config.get("presets", {}),
|
||||
"env_path": str(env_path),
|
||||
"env_filename": ".env",
|
||||
"env_vars": env_vars,
|
||||
}
|
||||
@@ -23,6 +23,7 @@ try:
|
||||
from admin_console.schemas.task_schema import RunRequest
|
||||
from admin_console.services.ipc_service import ipc_service
|
||||
from admin_console.services.model_service import model_service
|
||||
from admin_console.services.task_preset_catalog import task_recommendation_engine
|
||||
from admin_console.services.task_queue_service import task_queue_service
|
||||
except ImportError:
|
||||
from apps.admin_console.core.state import state
|
||||
@@ -30,12 +31,37 @@ except ImportError:
|
||||
from apps.admin_console.schemas.task_schema import RunRequest
|
||||
from apps.admin_console.services.ipc_service import ipc_service
|
||||
from apps.admin_console.services.model_service import model_service
|
||||
from apps.admin_console.services.task_preset_catalog import task_recommendation_engine
|
||||
from apps.admin_console.services.task_queue_service import task_queue_service
|
||||
|
||||
|
||||
router = APIRouter(tags=["tasks"])
|
||||
|
||||
|
||||
@router.get("/api/tasks/presets")
|
||||
async def get_task_presets(
|
||||
category: str = "recommended",
|
||||
packages: str | None = None,
|
||||
limit: int = 24,
|
||||
):
|
||||
"""Retrieve smart task recommendations optionally tailored to detected device packages."""
|
||||
pkg_list = [p.strip() for p in packages.split(",") if p.strip()] if packages else None
|
||||
return task_recommendation_engine.recommend_tasks(
|
||||
installed_packages=pkg_list,
|
||||
category=category,
|
||||
limit=limit,
|
||||
)
|
||||
|
||||
|
||||
@router.get("/api/tasks/catalog")
|
||||
async def get_task_catalog():
|
||||
"""Retrieve full catalog of predefined tasks and app package registry."""
|
||||
return {
|
||||
"tasks": [t.model_dump() for t in task_recommendation_engine.get_all_tasks()],
|
||||
"app_registry": task_recommendation_engine.get_app_registry(),
|
||||
}
|
||||
|
||||
|
||||
@router.post("/api/run")
|
||||
async def run_task(request: RunRequest):
|
||||
incoming_goals = []
|
||||
@@ -51,7 +77,12 @@ async def run_task(request: RunRequest):
|
||||
)
|
||||
|
||||
return await task_queue_service.enqueue_tasks(
|
||||
incoming_goals, profile=request.profile or "flash"
|
||||
incoming_goals,
|
||||
profile=request.profile or "flash",
|
||||
expected_output=request.expected_output,
|
||||
enable_outputter=request.enable_outputter,
|
||||
locked_app_package=request.locked_app_package,
|
||||
app_path=request.app_path,
|
||||
)
|
||||
|
||||
|
||||
@@ -73,15 +104,33 @@ async def resume_task():
|
||||
|
||||
@router.get("/api/status")
|
||||
async def get_status():
|
||||
# Watchdog check to ensure background worker is alive
|
||||
task_queue_service.ensure_worker_running()
|
||||
|
||||
latest_session = session_repo.get_latest_session()
|
||||
latest_session_id = latest_session.get("session_id") if latest_session else None
|
||||
bg_tasks = session_repo.get_background_tasks(latest_session_id) if latest_session_id else []
|
||||
|
||||
is_running = state.is_running
|
||||
running_sid = state.active_session_id or (
|
||||
session_repo.get_running_session_id() if is_running else None
|
||||
running_task = next(
|
||||
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "running"), None
|
||||
)
|
||||
if not running_task and is_running:
|
||||
running_task = next(
|
||||
(t for t in state.queue_items if isinstance(t, dict) and t.get("status") == "pending"),
|
||||
None,
|
||||
)
|
||||
|
||||
running_sid = (
|
||||
state.active_session_id
|
||||
or (running_task.get("session_id") if running_task else None)
|
||||
or (session_repo.get_running_session_id() if is_running else None)
|
||||
)
|
||||
running_goal = state.current_goal or (running_task.get("goal") if running_task else None)
|
||||
|
||||
active_profile = state.current_profile or (
|
||||
running_task.get("profile") if running_task else None
|
||||
)
|
||||
active_profile = state.current_profile
|
||||
if not active_profile and (running_sid or latest_session_id):
|
||||
check_sid = running_sid or latest_session_id
|
||||
sess_row = session_repo.get_session_by_id(check_sid)
|
||||
@@ -95,30 +144,17 @@ async def get_status():
|
||||
model_info = model_service.get_active_model_info(active_profile)
|
||||
queue_data = state.queue_tasks
|
||||
|
||||
if state.current_process:
|
||||
if state.current_process.returncode is None:
|
||||
is_paused = state.is_paused
|
||||
return {
|
||||
"status": "paused" if is_paused else "running",
|
||||
"goal": state.current_goal,
|
||||
"pid": state.current_process.pid,
|
||||
"session_id": running_sid,
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": model_info,
|
||||
}
|
||||
else:
|
||||
completed_status = {
|
||||
"status": "completed",
|
||||
"goal": state.current_goal,
|
||||
"returncode": state.current_process.returncode,
|
||||
"session_id": latest_session_id,
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": model_info,
|
||||
}
|
||||
state.current_process = None
|
||||
return completed_status
|
||||
if is_running:
|
||||
is_paused = state.is_paused
|
||||
return {
|
||||
"status": "paused" if is_paused else "running",
|
||||
"goal": running_goal,
|
||||
"pid": state.current_process.pid if state.current_process else None,
|
||||
"session_id": running_sid,
|
||||
"background_tasks": bg_tasks,
|
||||
"queue": queue_data,
|
||||
"model_info": model_info,
|
||||
}
|
||||
|
||||
if latest_session_id and str(latest_session_id) in state.active_connections:
|
||||
conn_info = state.active_connections[str(latest_session_id)]
|
||||
@@ -151,21 +187,27 @@ async def stream_events(session_id: str, client: str | None = None):
|
||||
|
||||
def callback(event_type, data):
|
||||
try:
|
||||
# Filter events by session_id when subscribed to a specific session
|
||||
if session_id and session_id not in ("all", "active"):
|
||||
evt_session_id = None
|
||||
if isinstance(data, dict):
|
||||
evt_session_id = data.get("session_id")
|
||||
# Global queue lifecycle events should always be delivered
|
||||
if event_type not in (
|
||||
"session_started",
|
||||
"session_ended",
|
||||
"background_tasks_updated",
|
||||
):
|
||||
# Filter events by session_id when subscribed to a specific session
|
||||
if session_id and session_id not in ("all", "active"):
|
||||
evt_session_id = None
|
||||
if isinstance(data, dict):
|
||||
evt_session_id = data.get("session_id")
|
||||
|
||||
if evt_session_id and str(evt_session_id) != str(session_id):
|
||||
return
|
||||
if evt_session_id and str(evt_session_id) != str(session_id):
|
||||
return
|
||||
|
||||
if (
|
||||
not evt_session_id
|
||||
and state.active_session_id
|
||||
and str(state.active_session_id) != str(session_id)
|
||||
):
|
||||
return
|
||||
if (
|
||||
not evt_session_id
|
||||
and state.active_session_id
|
||||
and str(state.active_session_id) != str(session_id)
|
||||
):
|
||||
return
|
||||
|
||||
sanitized_data = ipc_service.sanitize_event_data(event_type, data)
|
||||
loop = asyncio.get_running_loop()
|
||||
|
||||
Reference in new issue
Block a user