diff --git a/.env.example b/.env.example index 8b23d0a..b6ffe89 100644 --- a/.env.example +++ b/.env.example @@ -1,32 +1,39 @@ -# ============================================================================== -# Artemis Environment Configuration Template -# ============================================================================== -# Copy this file to .env and fill in your API credentials: -# cp .env.example .env - -# LLM Providers (Configure at least one) -GEMINI_API_KEY= -GOOGLE_API_KEY= +# 复制为 .env,或在首页「模型配置」「功能开关」中填写并保存。 +# 所有模型调用仅使用 OpenAI Responses API;环境变量优先于 .env。 OPENAI_API_KEY= -ANTHROPIC_API_KEY= -OPEN_ROUTER_API_KEY= -XAI_API_KEY= +# 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 +OPENAI_BASE_URL=https://api.openai.com/v1 +OPENAI_MODEL=gpt-5-mini +# 单次模型 HTTP 请求超时(秒) +OPENAI_TIMEOUT_SECONDS=120 +# SDK 内部重试次数;默认 0,由 Artemis 的分类恢复机制处理重试。 +OPENAI_MAX_RETRIES=0 -# Google Cloud Vision OCR (Optional - for advanced OCR processing) +# 功能开关:保存后对新任务生效 +ARTEMIS_VIDEO_ENABLED=true +ARTEMIS_OUTPUTTER_ENABLED=true +ARTEMIS_STEP_SUMMARIZER_ENABLED=true + +# 视频分段、重试与单次分析超时(秒) +ARTEMIS_VIDEO_CHUNK_SECONDS=60 +ARTEMIS_VIDEO_TIMEOUT_SECONDS=120 +ARTEMIS_VIDEO_MAX_RETRIES=1 +# 检查点、退出汇总与凭据验证超时(秒) +ARTEMIS_CHECKPOINT_TIMEOUT_SECONDS=180 +ARTEMIS_SETTLEMENT_TIMEOUT_SECONDS=120 +ARTEMIS_CREDENTIAL_TIMEOUT_SECONDS=12 +ARTEMIS_MODEL_CACHE_TTL_SECONDS=10 +# 模型重试耗尽后的暂停等待上限(秒);0 表示无限等待 +LLM_PAUSE_TIMEOUT_SECONDS=900 + +# 可选 Google Cloud Vision OCR,独立于模型调用,不配置也可运行。 OCR_API_KEY= -# Android ADB Connectivity (Optional - defaults to 127.0.0.1:5037) +# Android ADB 连接 ADB_HOST=127.0.0.1 ADB_PORT=5037 # ADB_DEVICE_SERIAL= -# Keep the display awake, falling back to a wake lock or safe heartbeat when needed. -# Set to false if Artemis is used with a personal device whose screen should time out. ARTEMIS_KEEP_DEVICE_AWAKE=true -# UI hierarchy backend: auto (Accessibility Helper, UIAutomator2 fallback) | helper | uiautomator ARTEMIS_HIERARCHY_BACKEND=auto -# Let tasks install / upgrade the Accessibility Helper APK on a device they hold. -# Set to false on shared or personal phones: then only `artemis helper install` installs it. ARTEMIS_HELPER_AUTO_INSTALL=true - -# Artemis Execution Defaults (flash or pro) ARTEMIS_DEFAULT_PROFILE=flash diff --git a/.gitignore b/.gitignore index 31cf3e9..5cda9f7 100644 --- a/.gitignore +++ b/.gitignore @@ -109,6 +109,7 @@ live_screenshot*.png *.key # OS Artifacts +artifacts/ .DS_Store .DS_Store? ._* diff --git a/README.md b/README.md index d188360..f6216b3 100644 --- a/README.md +++ b/README.md @@ -20,7 +20,7 @@ Python 3.12+ License: Apache-2.0 MCP Native - Multi-Model + OpenAI Responses AndroidWorld SOTA

@@ -104,6 +104,14 @@ cd artemis > **Tip**: Opens `http://localhost:8000` in your default browser with a device connection wizard, live screen mirroring, prompt sandbox, and execution replays. You can also run directly from CLI: `uv run artemis run "Open Settings, find Battery and tell me current level" --profile flash`. +### Model and feature settings + +The web console now uses Simplified Chinese. Open **系统设置与运行准备 → 模型配置** to edit and save the Responses base URL, model, API key, request timeout, and retry count. **测试连接** tests the current form without saving; **保存配置** persists it to `.env`. Leave the key blank to retain the existing credential. Existing keys are never returned to the browser. + +All models use **OpenAI Responses**, including custom gateways. Set `OPENAI_API_KEY`, `OPENAI_BASE_URL` (the API root, usually ending in `/v1`), and `OPENAI_MODEL` (for example `mimo-v2.6-flash`). Gemini, Anthropic and Chat Completions transports are no longer supported. Video analysis sends sampled image frames and requires image input support. Structured output uses Responses function calling with local schema validation. + +The **功能开关** section persists video analysis/recording, final reports, and Flash step summaries. Changes apply to new tasks and survive restart. Environment variables take precedence over `.env`; explicit per-node or task overrides take precedence over global defaults. See [`.env.example`](.env.example) for timeout settings and the [Chinese setup guide](README_CN.md#网页模型配置与功能开关) for details. +
diff --git a/README_CN.md b/README_CN.md index 7c425c6..031c709 100644 --- a/README_CN.md +++ b/README_CN.md @@ -20,7 +20,7 @@ Python 3.12+ License: Apache-2.0 MCP Native - Multi-Model + OpenAI Responses AndroidWorld SOTA

@@ -104,8 +104,73 @@ cd artemis > PowerShell 默认不会从当前目录查找可执行脚本,因此必须使用 `.\start.bat`,且命令末尾不要添加 `\`。如果使用传统命令提示符(CMD),则运行 `start.bat`。 +首次启动会检测 uv:已有则复用,缺少则从 Astral 官方安装。随后检查项目 `.venv`,缺少时自动安装所需的 Python 3.12 并创建环境;已有且满足 Python 3.12+ 要求的环境直接复用。依赖与 `uv.lock` 一致时跳过安装,否则只同步缺失或变化的依赖,保留已有开发工具。安装失败会停止启动并显示原因,不会删除已有环境。 + +国内网络可直接双击 **`start-cn.bat`**,或在 PowerShell 中运行: + +```powershell +.\start-cn.bat +# 仅安装 Python 环境,也支持镜像模式 +.\start-cn.bat -SetupOnly +# 与上面的入口等价 +.\start.bat -Mirror China +# 本次使用官方源 +.\start.bat -Mirror Official +``` + +下载配置位于 `config/downloads.json`。将 `mode` 改成 `China` 后,普通 `start.bat` 也默认使用国内源;命令行 `-Mirror` 优先。`timeout_seconds` 控制下载超时(默认 180 秒,范围 10~3600 秒)。国内模式使用: + +| 组件 | 下载来源 | +| --- | --- | +| uv 与 Python 依赖 | 清华 PyPI 镜像;uv 直接从 wheel 提取并校验 SHA256,无需预装 Python | +| Python 运行时 | 南京大学 python-build-standalone 镜像 | +| Node.js 与 npm 包 | npmmirror | +| ADB | 腾讯 Android SDK 镜像 | +| FFmpeg | `imageio-ffmpeg` Python 包附带的程序 | +| scrcpy | 默认仍使用 GitHub 官方发布,可用下述配置替换 | + +镜像模式从 `uv.lock` 导出固定版本和哈希,再通过所选索引安装,避免锁文件中的官方直链绕过镜像;不会改写锁文件或升级已锁定版本。npm 使用本次启动进程的 registry 配置。所有设置仅影响当前启动进程,不修改用户全局 pip、uv、npm 或 nvm 配置。已有 `UV_DEFAULT_INDEX`(或 `UV_INDEX_URL`)、`UV_PYTHON_INSTALL_MIRROR`、`npm_config_registry` 环境变量优先。 + +需要公司内网源或替换不可用镜像时,在 `config/downloads.json` 的 `overrides` 中填写对应 HTTPS 地址;空字符串表示使用所选模式的默认值。`python_index` 是以 `/simple` 结尾的索引,`python_download_mirror` 是 Python 发布目录,`node_mirror` 是包含版本子目录的 Node.js 下载根路径,`adb_url` 和 `scrcpy_url` 是可直接下载的 Windows ZIP 地址。scrcpy 暂无内置国内镜像,GitHub 不可达时请配置可信的 `scrcpy_url` 或预先安装 scrcpy。`uv_installer_url` 只用于没有设置 Python 镜像时的 PowerShell 安装器路径。镜像缺包或下载失败会显示错误;修正地址后重试即可,不会静默切换到官方源。 + +镜像说明参考:[清华 PyPI](https://mirrors.tuna.tsinghua.edu.cn/help/pypi/)、[uv 下载镜像配置](https://docs.astral.sh/uv/reference/environment/#uv_python_install_mirror)。 + +也可先单独准备 Python 环境,不启动页面、不安装设备工具、不配置 MCP: + +```powershell +.\start.bat -SetupOnly +# 环境准备好后正常启动 +.\start.bat +# 可选:指定端口、不自动打开浏览器 +.\start.bat -Port 8001 -NoOpen +``` + +源码压缩包不携带 `.venv`、`node_modules`、前端编译结果或 APK。解压到可写目录后运行 `start.bat`,首次完整启动会安装所需 Node.js/npm 并构建网页,需要联网;已有构建时跳过。`.env` 不存在时才从 `.env.example` 创建,已有配置不会覆盖。Android 辅助 APK 的源码位于 `packages/artemis-accessibility-helper`,需要该功能时按该目录的构建脚本生成。 + +重新打源码包可运行 `uv run --no-sync python scripts/package_source.py`,输出到 `artifacts/`;脚本会排除本地密钥、运行数据、缓存、依赖目录与编译产物,并检查 ZIP 完整性。 + > **提示**:启动后将自动在默认浏览器中打开 Web 控制台(`http://localhost:8000`),提供设备连接向导、实时投屏、任务演练与状态回放面板。你也可以通过命令行直接运行:`uv run artemis run "打开系统设置,找到电池选项并告诉我当前电量" --profile flash`。 +### 网页模型配置与功能开关 + +Web 控制台使用简体中文。在首页打开「系统设置与运行准备」,可以修改接口地址、模型名称、API Key、请求超时和重试次数。点击「测试连接」会使用当前表单发送一次真实的 Responses 请求;点击「保存配置」才会写入工作目录的 `.env`。密钥留空会保留现有值,网页不会读取或显示已有密钥。 + +所有模型调用统一使用 **OpenAI Responses API**,不再提供 Gemini、Anthropic 或 Chat Completions 调用路径。自定义网关必须实现 `/responses`,接口地址填写根地址(通常以 `/v1` 结尾)。例如: + +```dotenv +OPENAI_API_KEY=你的密钥 +OPENAI_BASE_URL=https://你的网关/v1 +OPENAI_MODEL=mimo-v2.6-flash +OPENAI_TIMEOUT_SECONDS=120 +OPENAI_MAX_RETRIES=0 +``` + +「功能开关」提供视频分析与录屏、最终报告生成、Flash 步骤摘要压缩,可分别保存。设置对**新任务**生效,重启后保留。环境变量优先于 `.env`;在网页保存时会同时更新当前服务进程的配置,服务的启动环境若显式定义了同名变量,后续重启仍以启动环境为准。手动配置的节点模型或任务级选项优先于全局默认值。 + +视频分析通过抽取画面帧调用模型,因此需要支持图片输入的模型。`mimo-v2.6-flash` 已验证文本、图片、工具调用、流式输出及结构化函数调用;对于不支持 `json_schema` 的网关,结构化结果使用 Responses 函数调用并在本地校验。音频输入是否可用取决于端点能力,拒绝音频时视频分析会尝试仅分析画面。 + +视频分段时长、分析超时、检查点超时等配置及单位见 [`.env.example`](.env.example)。可选 Cloud Vision OCR 独立于模型调用,未配置 OCR 密钥也可运行。 +
diff --git a/apps/admin_console/replay_manager.py b/apps/admin_console/replay_manager.py index 7174d64..14f5b75 100644 --- a/apps/admin_console/replay_manager.py +++ b/apps/admin_console/replay_manager.py @@ -86,14 +86,14 @@ class ReplayManager: It reconstructs historical execution states (SQLite database records, trace histories, and screenshots) and replays a single agent step in an isolated, deterministic sandbox environment while interacting live with a physical - Android device and Gemini. + Android device and OpenAI Responses. Key Responsibilities: - Reconstructs and preloads historical SQLite states up to a specific step. - Establishes sandboxed `ArtemisContext` and `DataEngine` instances. - Integrates with physical devices via ADB and UIAutomator. - - Hooks and spies on the Gemini API to capture thoughts and raw outputs. + - Records Responses model calls to capture thoughts and raw outputs. - Reconciles live and preloaded traces into unified chronological trees. Attributes: @@ -1552,8 +1552,8 @@ class ReplayManager: # Update values conn_temp.execute( """ - UPDATE video_recordings - SET local_video_path = ?, start_time = ?, end_time = ? + UPDATE video_recordings + SET local_video_path = ?, start_time = ?, end_time = ? WHERE session_id = ? """, ( @@ -1583,8 +1583,8 @@ class ReplayManager: conn_temp.execute( """ - UPDATE video_recordings - SET local_video_path = ?, start_time = ?, end_time = ? + UPDATE video_recordings + SET local_video_path = ?, start_time = ?, end_time = ? WHERE session_id = ? """, ( @@ -1885,121 +1885,34 @@ class ReplayManager: data_engine = ctx.data_engine - print("Executing using Live Gemini Client...") + print("Executing using OpenAI Responses...") - from google.genai.models import AsyncModels + from langchain_openai import ChatOpenAI from unittest.mock import patch - original_generate_content = AsyncModels.generate_content + original_generate = ChatOpenAI._agenerate - async def wrapped_generate_content(self, *, model, contents, config=None, **kwargs): - system_instruction = None - if config and config.system_instruction: - if isinstance(config.system_instruction, str): - system_instruction = config.system_instruction - elif hasattr(config.system_instruction, "parts"): - parts = [] - for p in config.system_instruction.parts: - if hasattr(p, "text") and p.text: - parts.append(p.text) - system_instruction = " ".join(parts) - else: - system_instruction = str(config.system_instruction) - - prompt_data = { - "model": model, - "system_instruction": system_instruction, - "contents": [], + async def wrapped_generate(model, messages, *args, **kwargs): + record = { + "prompt": { + "model": model.model_name, + "contents": [ + {"role": message.type, "parts": message.content} for message in messages + ], + }, + "response": None, + "error": None, } - - contents_list = contents if isinstance(contents, list) else [contents] - for content in contents_list: - parts_desc = [] - if hasattr(content, "parts") and content.parts: - for part in content.parts: - if hasattr(part, "text") and part.text: - parts_desc.append({"type": "text", "text": part.text}) - elif hasattr(part, "inline_data") and part.inline_data: - parts_desc.append( - { - "type": "image", - "mime_type": part.inline_data.mime_type, - "length": len(part.inline_data.data), - } - ) - elif hasattr(part, "function_call") and part.function_call: - parts_desc.append( - { - "type": "function_call", - "name": part.function_call.name, - "args": ( - dict(part.function_call.args) - if part.function_call.args - else {} - ), - } - ) - elif hasattr(part, "function_call") and part.function_response: - parts_desc.append( - { - "type": "function_response", - "name": part.function_response.name, - "response": ( - dict(part.function_response.response) - if part.function_response.response - else {} - ), - } - ) - else: - parts_desc.append({"type": "raw", "value": str(content)}) - - prompt_data["contents"].append( - { - "role": getattr(content, "role", "unknown"), - "parts": parts_desc, - } - ) - - record = {"prompt": prompt_data, "response": None, "error": None} llm_calls_log.append(record) - try: - res = await original_generate_content( - self, - model=model, - contents=contents, - config=config, - **kwargs, - ) - - thinking_parts = [] - if hasattr(res, "candidates") and res.candidates and res.candidates[0].content: - for part in res.candidates[0].content.parts: - if getattr(part, "thought", False): - thinking_parts.append(part.text) - - resp_data = { - "text": res.text if hasattr(res, "text") else None, - "function_calls": [], - "thought": ("\n".join(thinking_parts) if thinking_parts else None), - } - if hasattr(res, "function_calls") and res.function_calls: - for fc in res.function_calls: - resp_data["function_calls"].append( - { - "name": fc.name, - "args": dict(fc.args) if fc.args else {}, - } - ) - record["response"] = resp_data - return res - except Exception as err: - err_str = str(err) - record["error"] = ( - f"{err.__class__.__name__}: {err_str}" if err_str else err.__class__.__name__ - ) - raise err + result = await original_generate(model, messages, *args, **kwargs) + record["response"] = [ + generation.message.model_dump(mode="json") for generation in result.generations + ] + return result + except Exception as error: + record["error"] = error.__class__.__name__ + raise try: try: @@ -2042,7 +1955,7 @@ class ReplayManager: val = resolved_val tool_args[name] = val - with patch.object(AsyncModels, "generate_content", wrapped_generate_content): + with patch.object(ChatOpenAI, "_agenerate", wrapped_generate): outcome = await tool_fn(**tool_args) replay_result["success"] = True diff --git a/apps/admin_console/routers/system.py b/apps/admin_console/routers/system.py index 5127eca..141e272 100644 --- a/apps/admin_console/routers/system.py +++ b/apps/admin_console/routers/system.py @@ -31,6 +31,63 @@ from artemis.core.diagnostics.schema import SystemReadinessReport router = APIRouter(prefix="/api/system", tags=["system"]) +from apps.admin_console.services.model_settings_service import ( + ModelSettingsUpdate, + get_model_settings, + save_model_settings, + test_model_settings, + CapabilitySettings, + get_capability_settings, + save_capability_settings, +) + + +@router.get("/capability-settings") +async def read_capability_settings(): + return get_capability_settings() + + +@router.post("/capability-settings") +async def write_capability_settings(payload: CapabilitySettings): + try: + return save_capability_settings(payload) + except OSError as exc: + raise HTTPException( + status_code=500, detail="无法保存功能开关,请检查 .env 文件权限" + ) from exc + + +@router.get("/model-settings") +async def read_model_settings(): + return get_model_settings() + + +@router.post("/model-settings") +async def write_model_settings(payload: ModelSettingsUpdate): + try: + result = save_model_settings(payload) + except OSError as exc: + raise HTTPException(status_code=500, detail="无法写入 .env,请检查文件权限") from exc + except ValueError as exc: + raise HTTPException(status_code=400, detail="API Key 格式不正确,请检查后重试") from exc + await readiness_engine.run_all(force_refresh=True) + return {**result, "message": "配置已保存,新任务立即生效,重启后自动加载。"} + + +@router.post("/model-settings/test") +async def check_model_settings(payload: ModelSettingsUpdate): + try: + return await test_model_settings(payload) + except Exception as exc: + # SDK exceptions may contain URLs or request data; never echo credentials. + status = getattr(exc, "status_code", None) + detail = ( + f"Responses 连接失败(HTTP {status})" + if status + else "Responses 连接失败,请检查接口地址、模型、密钥和超时设置。" + ) + raise HTTPException(status_code=400, detail=detail) from exc + def _require_local_admin_request(request: Request) -> None: """Keep endpoint probing and mutation on the local administration boundary.""" @@ -264,8 +321,8 @@ 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)", + default="openai", + description="Provider identifier (openai or ocr)", ) api_key: str = Field(description="The secret API key string to configure") persist_to_env: bool = Field( @@ -277,8 +334,8 @@ 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)", + default="openai", + description="Provider identifier (openai or 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") @@ -294,9 +351,8 @@ async def get_credentials(): """ from artemis.config import settings - providers = ("google", "openai", "anthropic", "openrouter", "ocr") + providers = ("openai", "ocr") status = {name: bool(settings.get_api_key(name)) for name in providers} - status["gemini"] = status["google"] return { "providers": [ {"name": name, "configured": configured} for name, configured in status.items() @@ -415,13 +471,7 @@ async def get_model_config_and_env(): return "****" return f"****{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 @@ -430,47 +480,19 @@ async def get_model_config_and_env(): ) 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", + "description": "OpenAI Responses API 密钥", }, { "name": "OPENAI_BASE_URL", - "provider": "custom", + "provider": "openai", "is_set": bool(base_url_val), "preview": base_url_val, - "description": "Custom API endpoint (for local Ollama, vLLM, DeepSeek, or proxies)", + "description": "OpenAI Responses 接口根地址(包含 /v1,不包含 /responses)", }, { "name": "VISION_API_KEY", diff --git a/apps/admin_console/services/model_service.py b/apps/admin_console/services/model_service.py index 0ccc0bc..b8d4ca0 100644 --- a/apps/admin_console/services/model_service.py +++ b/apps/admin_console/services/model_service.py @@ -12,6 +12,8 @@ # See the License for the specific language governing permissions and # limitations under the License. +from artemis.config.settings import settings + import json import logging import time @@ -26,7 +28,7 @@ class ModelService: # parse_llm_config re-reads and re-validates the config file on every call; # /api/sessions calls this once per session row, so cache (provider, model_id) # for a short TTL. Config edits take effect within _LLM_INFO_TTL seconds. - _LLM_INFO_TTL = 10.0 + _LLM_INFO_TTL = settings.ARTEMIS_MODEL_CACHE_TTL_SECONDS _llm_info_cache: tuple[float, str, str] | None = None @classmethod @@ -35,8 +37,8 @@ class ModelService: now = time.monotonic() if cached and now - cached[0] < cls._LLM_INFO_TTL: return cached[1], cached[2] - provider = "google" - model_id = "gemini-3.7-flash" + provider = "openai" + model_id = settings.OPENAI_MODEL try: from artemis.config import parse_llm_config diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py new file mode 100644 index 0000000..2564280 --- /dev/null +++ b/apps/admin_console/services/model_settings_service.py @@ -0,0 +1,161 @@ +"""Persist the single Responses endpoint without returning credentials to the browser.""" + +import os +import tempfile +import threading +from pathlib import Path +from urllib.parse import urlsplit + +from pydantic import BaseModel, Field, SecretStr, field_validator + +from artemis.config.paths import get_env_file +from artemis.config.settings import settings + +_write_lock = threading.Lock() + + +class ModelSettingsUpdate(BaseModel): + base_url: str = "https://api.openai.com/v1" + model: str = Field(min_length=1, max_length=200) + api_key: SecretStr | None = None # Omitted/blank keeps the existing key. + timeout_seconds: float = Field(default=120, gt=0, le=3600) + max_retries: int = Field(default=0, ge=0, le=10) + + @field_validator("base_url") + @classmethod + def validate_base_url(cls, value: str) -> str: + value = value.strip().rstrip("/") + parsed = urlsplit(value) + if ( + parsed.scheme not in ("https", "http") + or not parsed.hostname + or parsed.username + or parsed.password + or parsed.query + or parsed.fragment + ): + raise ValueError("请输入有效的 HTTP(S) 接口根地址,不要包含密钥、查询参数或片段") + if value.endswith(("/responses", "/chat/completions")): + raise ValueError("请输入接口根地址,例如 https://api.openai.com/v1") + return value + + @field_validator("model") + @classmethod + def validate_model(cls, value: str) -> str: + value = value.strip() + if not value or any(c in value for c in "\r\n\x00"): + raise ValueError("模型名称不能为空或包含换行") + return value + + +def get_model_settings() -> dict: + return { + "provider": "openai", + "api_mode": "responses", + "base_url": settings.OPENAI_BASE_URL or "https://api.openai.com/v1", + "model": settings.OPENAI_MODEL, + "api_key_configured": bool(settings.OPENAI_API_KEY), + "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, + "max_retries": settings.OPENAI_MAX_RETRIES, + "env_path": str(get_env_file()), + } + + +def save_model_settings(update: ModelSettingsUpdate) -> dict: + if update.api_key and any(c in update.api_key.get_secret_value() for c in "\r\n\x00"): + raise ValueError("API Key 不能包含换行") + values = { + "OPENAI_BASE_URL": update.base_url, + "OPENAI_MODEL": update.model, + "OPENAI_TIMEOUT_SECONDS": str(update.timeout_seconds), + "OPENAI_MAX_RETRIES": str(update.max_retries), + } + if update.api_key and update.api_key.get_secret_value().strip(): + values["OPENAI_API_KEY"] = update.api_key.get_secret_value().strip() + persist_environment(values) + settings.OPENAI_BASE_URL = update.base_url + settings.OPENAI_MODEL = update.model + settings.OPENAI_TIMEOUT_SECONDS = update.timeout_seconds + settings.OPENAI_MAX_RETRIES = update.max_retries + if "OPENAI_API_KEY" in values: + settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"]) + from artemis.llm.router import ModelFactory + from apps.admin_console.services.model_service import ModelService + + ModelFactory._cache.clear() + ModelService._llm_info_cache = None + return get_model_settings() + + +def persist_environment(values: dict[str, str]) -> None: + with _write_lock: + env_path = get_env_file() + env_path.parent.mkdir(parents=True, exist_ok=True) + original = env_path.read_text(encoding="utf-8") if env_path.exists() else "" + # Preserve comments and unrelated variables; quote values to avoid dotenv interpolation. + kept = [ + line + for line in original.splitlines() + if line.removeprefix("export ").split("=", 1)[0].strip() not in values + ] + for key, value in values.items(): + escaped = value.replace("\\", "\\\\").replace("'", "\\'") + kept.append(f"{key}='{escaped}'") + content = "\n".join(kept) + "\n" + fd, temp_name = tempfile.mkstemp(prefix=".env-", dir=env_path.parent) + try: + with os.fdopen(fd, "w", encoding="utf-8", newline="\n") as stream: + stream.write(content) + stream.flush() + os.fsync(stream.fileno()) + os.replace(temp_name, env_path) + finally: + Path(temp_name).unlink(missing_ok=True) + for key, value in values.items(): + os.environ[key] = value + + +class CapabilitySettings(BaseModel): + video_enabled: bool = True + outputter_enabled: bool = True + step_summarizer_enabled: bool = True + + +def get_capability_settings() -> CapabilitySettings: + return CapabilitySettings( + video_enabled=settings.ARTEMIS_VIDEO_ENABLED, + outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED, + step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED, + ) + + +def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings: + values = { + "ARTEMIS_VIDEO_ENABLED": update.video_enabled, + "ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled, + "ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled, + } + persist_environment({key: str(value).lower() for key, value in values.items()}) + for key, value in values.items(): + setattr(settings, key, value) + return get_capability_settings() + + +async def test_model_settings(update: ModelSettingsUpdate) -> dict: + from langchain_core.messages import HumanMessage + from artemis.llm.router import ModelEndpoint, ModelFactory + + key = update.api_key.get_secret_value().strip() if update.api_key else None + endpoint = ModelEndpoint( + model_name=update.model, + api_key=key or None, + api_base=update.base_url, + timeout_seconds=update.timeout_seconds, + max_retries=0, + max_tokens=64, + ) + model = ModelFactory.create_model(endpoint) + result = await model.ainvoke([HumanMessage(content="Reply with OK only.")]) + if not result.content: + raise ValueError("Responses 接口返回空内容,请检查模型配置") + return {"success": True, "message": "Responses 连接测试成功", "model": update.model} diff --git a/apps/showcase_ui/src/app/app.config.ts b/apps/showcase_ui/src/app/app.config.ts index c5a0f87..29d6d98 100644 --- a/apps/showcase_ui/src/app/app.config.ts +++ b/apps/showcase_ui/src/app/app.config.ts @@ -1,3 +1,6 @@ +import { registerLocaleData } from '@angular/common'; +import zh from '@angular/common/locales/zh'; +registerLocaleData(zh); /** * Copyright 2026 Google LLC * @@ -14,7 +17,7 @@ * limitations under the License. */ -import { ApplicationConfig, provideZoneChangeDetection } from '@angular/core'; +import { ApplicationConfig, LOCALE_ID, provideZoneChangeDetection } from '@angular/core'; import { provideRouter } from '@angular/router'; import { provideHttpClient, withXhr } from '@angular/common/http'; @@ -22,6 +25,7 @@ import { routes } from './app.routes'; export const appConfig: ApplicationConfig = { providers: [ + { provide: LOCALE_ID, useValue: 'zh-CN' }, provideZoneChangeDetection({ eventCoalescing: true }), provideRouter(routes), provideHttpClient(withXhr()) diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts index 5c915fd..faa5b8e 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream-startup.spec.ts @@ -4,17 +4,17 @@ import { buildStartupWorkItems } from './agent-stream.component'; describe('startup Work block', () => { it('shows only the three device preparation operations', () => { const events: StartupProgressEvent[] = [ - { stage: 'submitting', message: 'Submitting the task', timestamp: 100 }, + { stage: 'submitting', message: '正在提交任务', timestamp: 100 }, { stage: 'queued', message: 'Task received and queued', timestamp: 100.9 }, { stage: 'launching', message: 'Starting the execution process', timestamp: 101 }, { stage: 'configuration', message: 'Loading the run configuration', timestamp: 103.1 }, { stage: 'device_check', message: 'Checking the Android device', timestamp: 103.2 }, - { stage: 'device_ready', message: 'Android device connected', timestamp: 123 }, + { stage: 'device_ready', message: 'Android 设备已连接', timestamp: 123 }, { stage: 'model_warmup', message: 'Warming the model connection', timestamp: 123 }, { stage: 'uiautomator', message: 'Connecting to UI Automator', timestamp: 124 }, { stage: 'uiautomator_ready', message: 'UI Automator is ready', timestamp: 128 }, { stage: 'environment', message: 'Preparing the device environment', timestamp: 128 }, - { stage: 'environment_ready', message: 'Device environment is ready', timestamp: 131 } + { stage: 'environment_ready', message: '设备环境已就绪', timestamp: 131 } ]; const items = buildStartupWorkItems(events, 131, false, true); @@ -90,7 +90,7 @@ describe('startup Work block', () => { const [item] = buildStartupWorkItems(events, 20, false, true); - expect(item.message).toBe('UI hierarchy service is ready'); + expect(item.message).toBe('界面层级服务已就绪'); }); it('uses old first_response events only as a hidden completion boundary', () => { @@ -101,7 +101,7 @@ describe('startup Work block', () => { const [item] = buildStartupWorkItems(events, 24, false, true); - expect(item.message).toBe('Device environment is ready'); + expect(item.message).toBe('设备环境已就绪'); expect(item.elapsed).toBe('3.0s'); expect(item.isActive).toBeFalse(); }); diff --git a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html index 93172d3..ca4590c 100644 --- a/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html +++ b/apps/showcase_ui/src/app/components/agent-stream/agent-stream.component.html @@ -24,11 +24,11 @@ limitations under the License. class="vscode-tab-btn" [class.active]="isTaskDropdownOpen()" (click)="toggleTaskDropdown($event)" - title="Task Queue" - aria-label="Task Queue" + title="任务队列" + aria-label="任务队列" > list_alt - Task Queue + 任务队列 @@ -57,7 +57,7 @@ limitations under the License.
list_alt - Task Queue + 任务队列
@if (activeQueue().length === 0) { } @for (session of activeQueue(); track trackSession($index, session)) { @@ -80,7 +81,7 @@ limitations under the License. @if (getTaskStatus(session) === 'running') { } - {{ getTaskStatus(session) | uppercase }} + {{ getTaskStatus(session) | uiLabel }}
@if (getDeviceSerial(session)) { @@ -98,8 +99,8 @@ limitations under the License. type="button" class="btn-stop-card" (click)="stopTask(session.session_id, $event)" - title="Stop this task" - aria-label="Stop this task" + title="停止此任务" + aria-label="停止此任务" > stop_circle @@ -109,8 +110,8 @@ limitations under the License. type="button" class="btn-delete-card" (click)="deleteTask(session.session_id, $event)" - title="Delete this task" - aria-label="Delete this task" + title="删除此任务" + aria-label="删除此任务" > delete @@ -118,7 +119,7 @@ limitations under the License.
- {{ session.initial_goal || 'Unnamed Task' }} + {{ session.initial_goal || '未命名任务' }}
@if (historyTasks().length === 0) { }
- {{ session.initial_goal || 'Unnamed Task' }} + {{ session.initial_goal || '未命名任务' }}
@@ -847,7 +500,7 @@ 3 } - 3. Android OS Boot + 3. 系统启动
@@ -856,7 +509,7 @@
check_circle
- 4. Ready + 4. 就绪 @@ -877,7 +530,7 @@
info - Android virtualization cold boot usually takes 20-45 seconds. Live progress is actively tracked above—please do not click launch repeatedly. + Android 模拟器冷启动通常需要 20–45 秒。上方会显示实时进度,请勿重复点击启动。
@@ -886,14 +539,14 @@
terminal - Emulator Console Stream (stdout / stderr) + 模拟器控制台输出(stdout / stderr)
-
@if (emulatorLaunchState()?.logs?.length) {@for (log of emulatorLaunchState()?.logs; track log) {
{{ log }}
}} @else {
Waiting for process output...
}
+
@if (emulatorLaunchState()?.logs?.length) {@for (log of emulatorLaunchState()?.logs; track log) {
{{ log }}
}} @else {
正在等待进程输出…
}
} @@ -910,11 +563,11 @@ error
-

Emulator Launch Failed

+

模拟器启动失败

{{ emulatorLaunchState()?.error || 'The Android emulator process failed to start or crashed during initialization.' }}

- @@ -922,7 +575,7 @@ @if (emulatorLaunchState()?.logs?.length) {
- Process Output / Failure Logs: + 进程输出与错误日志:
@@ -968,13 +621,14 @@
- Android Emulator + Android 模拟器
@if (installedAvds().length > 0) { - {{ installedAvds().length }} local virtual device{{ installedAvds().length > 1 ? 's' : '' }} available + {{ installedAvds().length }} 个本地虚拟设备可用 } @else { - Launch a local Android Virtual Device + + 启动本地 Android 虚拟设备 }
@@ -996,9 +650,9 @@
- USB Cable + USB 连接
- Connect physical phone via USB + 通过 USB 连接实体手机
@@ -1018,9 +672,9 @@
- Wireless ADB + 无线 ADB
- Connect over local Wi-Fi + 通过局域网 Wi-Fi 连接
@@ -1040,9 +694,9 @@
- Remote Computer + 远程计算机
- Phone connected to another computer + 连接其他计算机上的手机
@@ -1056,9 +710,9 @@
hub - Artemis is currently using - {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}. - Switch to local ADB before using a local emulator, USB cable, or Wireless ADB. + + Artemis 当前使用 + {{ adbServerStatus()?.endpoint?.host }}:{{ adbServerStatus()?.endpoint?.port }}。使用本地模拟器、USB 或无线 ADB 前,请先切换至本地 ADB。
@@ -1133,7 +787,7 @@ @@ -1155,7 +809,7 @@ (click)="setEmulatorSetupMode('studio')" > desktop_windows - Android Studio (GUI) + Android Studio(图形界面) @@ -1177,9 +831,9 @@ 1 download -
Install Android Studio
+
安装 Android Studio
- Download for {{ effectiveOs() === 'darwin' ? 'macOS' : (effectiveOs() === 'windows' ? 'Windows' : 'Linux') }} from developer.android.com/studio and install. + Download for {{ effectiveOs() === 'darwin' ? 'macOS' : (effectiveOs() === 'windows' ? 'Windows' : 'Linux') }} from developer.android.com/studio 并安装。
@@ -1188,9 +842,10 @@ 2 developer_mode -
Open Device Manager
+
打开设备管理器
- On Studio welcome screen, open More Actions > Virtual Device Manager (or Tools > Device Manager). + + 在 Android Studio 欢迎页面打开 更多操作 > 虚拟设备管理器 (或 工具 > 设备管理器).
@@ -1199,9 +854,10 @@ 3 add_circle -
Create Virtual Device
+
创建虚拟设备
- Click Create Device, choose Pixel 8, select Android 14 (API 34), and click Finish. + + 点击 创建设备,选择 Pixel 8,选择 Android 14 (API 34),然后点击 完成.
@@ -1210,9 +866,10 @@ 4 play_arrow -
Launch Emulator
+
启动模拟器
- Click ▶ Play in Device Manager. Artemis will detect and connect to it automatically! + + 点击 ▶ 播放 。Artemis 会自动检测并连接设备。
@@ -1232,7 +889,7 @@ @@ -1241,15 +898,15 @@
2
-
Download Emulator & System Image (API 34)
-

Install Android 14 Google APIs system image using SDK Manager:

+
下载模拟器与系统镜像(API 34)
+

通过 SDK 管理器安装 Android 14 Google APIs 系统镜像:

{{ effectiveOs() === 'windows' ? 'PS>' : '$' }} {{ emuSdkInstallCmd() }}
@@ -1258,15 +915,15 @@
3
-
Create Virtual Device (Pixel 8 AVD)
-

Initialize a new Android Virtual Device named Pixel_8_API_34:

+
创建虚拟设备(Pixel 8 AVD)
+

创建 Android 虚拟设备,名称为 Pixel_8_API_34:

{{ effectiveOs() === 'windows' ? 'PS>' : '$' }} {{ emuCreateAvdCmd() }}
@@ -1275,15 +932,15 @@
4
-
Start the Emulator
-

Launch the virtual device (or add -no-window for headless execution):

+
启动模拟器
+

启动虚拟设备(可添加 -no-window 以无界面模式运行):

{{ effectiveOs() === 'windows' ? 'PS>' : '$' }} emulator -avd Pixel_8_API_34 -no-boot-anim
@@ -1301,7 +958,7 @@
cable - Connect your Android phone via USB cable and enable USB Debugging: + 通过 USB 数据线连接 Android 手机,并启用 USB 调试:
@@ -1310,8 +967,8 @@ 1 usb
-
Connect USB Cable
-
Plug your Android phone into this computer with a data cable.
+
连接 USB 数据线
+
使用数据线将 Android 手机连接到此计算机。
@@ -1319,9 +976,8 @@ 2 touch_app
-
Enable Developer Options
-
In phone Settings > About Phone, tap Build Number 7 - times.
+
启用开发者选项
+
在手机上打开 设置 > 关于手机,点击 版本号 7 次。
@@ -1329,8 +985,8 @@ 3 toggle_on
-
Enable USB Debugging
-
In Settings > Developer Options, turn USB Debugging ON. +
启用 USB 调试
+
在 设置 > 开发者选项中,将 USB 调试 开启。
@@ -1339,8 +995,8 @@ 4 verified_user
-
Authorize Computer
-
Check "Always allow" and tap Allow on the phone prompt. +
授权计算机
+
勾选 “始终允许” ,然后点击 允许 完成手机上的授权提示。
@@ -1352,44 +1008,44 @@
wifi - Connect your phone and computer on the same Wi-Fi network: + 将手机和计算机连接到同一 Wi-Fi 网络:
- +
- +
router + placeholder="例如:192.168.1.108" spellcheck="false" />
- +
- +
tag
- +
- + @if (wifiConnectMessage()) {
check_circle @@ -1406,7 +1062,8 @@
help_outline
- Find IP & port in phone Settings > Developer Options > Wireless Debugging. + + 在手机中查看 IP 地址与端口: 设置 > 开发者选项 > 无线调试.
@@ -1417,13 +1074,13 @@
hub - Enter the host and port of an ADB server endpoint that this computer can access, so Artemis can discover and control a phone physically connected to another computer. + 输入本机可访问的 ADB 服务器地址和端口,即可发现并控制连接在其他计算机上的手机。
- +
dns
- +
tag @if (isConnectingRemoteAdb()) { sync - Checking... + 正在检查… } @else { bolt - Test Connection + 测试连接 }
@@ -1481,7 +1138,7 @@ [ngModel]="rememberRemoteAdb()" (ngModelChange)="rememberRemoteAdb.set($event)" /> - Remember this endpoint for future Artemis launches + 保存此地址,供后续启动使用 @if (remoteAdbMessage()) { @@ -1507,7 +1164,7 @@ {{ device.serial }}
- {{ device.state === 'device' ? 'Ready' : device.state }} + {{ device.state === 'device' ? '就绪' : device.state }}
} @@ -1523,17 +1180,18 @@ > @if (isActivatingRemoteAdb()) { sync - Connecting... + 正在连接… } @else { check - {{ remoteAdbHasReadyDevice() ? 'Use Connection' : 'Use Anyway' }} + {{ remoteAdbHasReadyDevice() ? '使用此连接' : '仍然使用' }} } }

- Artemis sends all ADB commands to this server instead of the default local server. Any phone visible to that server becomes available to Artemis. + + Artemis 将向此服务器发送 ADB 命令,并使用该服务器能够发现的手机。

} @@ -1556,14 +1214,14 @@
@if (connectedDevices().length > 1) {
- Select Active Device: + 选择当前设备:
@for (dev of connectedDevices(); track dev.serial) { } @@ -1593,14 +1251,15 @@
screen_lock_portrait
-
USB Debugging Authorization Pending
+
等待 USB 调试授权
- Your Android device was detected, but it requires authorization. Please unlock your phone screen and tap "Allow USB Debugging". + + 已检测到 Android 设备,但尚未授权。请解锁手机并点击 “允许 USB 调试”.
@@ -1612,7 +1271,7 @@
error
- Android Platform Tools (adb) not found. Please install ADB on your operating system: + 未找到 Android 平台工具(adb)。 请在当前操作系统中安装 ADB:
@@ -1621,7 +1280,7 @@
Linux (Ubuntu / Debian) @if (effectiveOs() === 'linux') { - Detected + 已检测到 }
@@ -1630,7 +1289,7 @@
@@ -1639,7 +1298,7 @@
macOS (Homebrew) @if (effectiveOs() === 'darwin') { - Detected + 已检测到 }
@@ -1648,7 +1307,7 @@
@@ -1657,7 +1316,7 @@
Windows (WinGet) @if (effectiveOs() === 'windows') { - Detected + 已检测到 }
@@ -1666,7 +1325,7 @@
@@ -1685,12 +1344,12 @@ check
-

System Ready for Autonomous Tasks

-

All dependencies, AI model credentials, and Android device are verified and ready to run.

+

系统已就绪,可以开始自动化任务

+

依赖、模型凭据和 Android 设备均已就绪。

@@ -1711,8 +1370,8 @@ {{ adbProbe()?.summary === 'Device Locked' ? 'lock' : 'warning' }} {{ adbProbe()?.summary === 'Device Locked' - ? 'Android device is locked. Unlock it and enter the home screen before running a task.' - : 'Prerequisites incomplete: runtime, credentials, or Android device is not ready. Click here to open Setup Guide.' }} + ? 'Android 设备已锁定,请先解锁并进入主屏幕。' + : '运行环境、模型凭据或 Android 设备尚未就绪。点击此处打开系统设置。' }} arrow_forward @@ -1720,18 +1379,19 @@
- + @if (showIntentSuggestion()) {
auto_awesome - Continuous monitoring or complex task detected. Recommended: Pro Mode + + 检测到持续监控或复杂任务,建议使用: Pro 专业模式
@@ -1745,7 +1405,7 @@ rows="3" [(ngModel)]="taskGoal" [disabled]="isSubmitting()" - placeholder="Describe what you want to achieve on your phone (e.g. Open Gmail and search for latest emails)..." + placeholder="描述你希望在手机上完成的任务,例如:打开 Gmail 并查找最新邮件…" (keydown.control.enter)="runTask()" (keydown.meta.enter)="runTask()" > @@ -1761,14 +1421,14 @@

Flash · - Token-efficient, rapid 3–5s/step decisions straight from the live screen. Best for everyday tasks with a clear path, however long they run. - (No plan, no result check, no ADB commands) + 节省 Token,快速响应 每步 3–5 秒 直接根据实时屏幕执行操作,适合路径明确的日常任务。 + (不规划、不检查结果、不执行 ADB 命令)

} @else {

Pro · - Plans first, checks its results and recovers when a tap is blocked. Handles 100+ step workflows and live phone monitoring (15–40s / step). + 先规划、再检查结果,遇到操作受阻时自动恢复。支持 超过 100 步的 工作流和实时手机监控(15–40 秒 / 步)。

}
@@ -1792,7 +1452,7 @@ class="model-toggle-btn btn-flash" [class.active]="selectedProfile() === 'flash'" (click)="setProfile('flash')" - title="Flash: fast single-look loop for everyday app tasks (3–5s/step)" + title="Flash:快速执行日常应用任务(每步 3–5 秒)" > bolt Flash @@ -1802,7 +1462,7 @@ class="model-toggle-btn btn-pro" [class.active]="selectedProfile() === 'pro'" (click)="setProfile('pro')" - title="Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks (15–40s/step)" + title="Pro:先规划、检查结果并恢复受阻步骤,适合长流程或复杂任务(每步 15–40 秒)" > psychology Pro @@ -1821,13 +1481,13 @@ class="btn-run-composer" [class.btn-run-pro]="selectedProfile() === 'pro'" [disabled]="!taskGoal().trim() || isSubmitting() || !isReady()" - [title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? 'Unlock the Android device before running' : 'Complete System Setup before running') : 'Run task'" + [title]="!isReady() ? (adbProbe()?.summary === 'Device Locked' ? '请先解锁 Android 设备' : '请先完成系统设置') : '执行任务'" (click)="runTask()" > @if (isSubmitting()) { - Starting... + 正在启动… } @else { - play_arrowRun Task + play_arrow执行任务 } @@ -1838,8 +1498,8 @@ @if (selectedProfile() === 'pro') {
- Expected Output - (Optional) + 预期输出 + (可选)
@@ -1856,7 +1516,7 @@ type="button" class="btn-clear-standalone" (click)="expectedOutput.set('')" - title="Clear expected output" + title="清空预期输出" > close @@ -1867,9 +1527,9 @@
- Pro settings + Pro 设置 @if (!isTuningDefault()) { - + }
@@ -1949,7 +1609,7 @@

{{ slider.level.tagline }}

- What happens + 运行方式
    @for (line of slider.level.runs; track $index) {
  • {{ line }}
  • @@ -1958,7 +1618,7 @@
@if (slider.level.skips?.length) {
- Not included + 不包含的能力
    @for (line of slider.level.skips; track $index) {
  • {{ line }}
  • @@ -1967,7 +1627,7 @@
}
- Use it for + 适用场景 {{ slider.level.bestFor }}
@@ -1986,7 +1646,7 @@
@@ -1997,7 +1657,7 @@ [class.active]="selectedCategory() === 'all'" (click)="setCategory('all')" > - All + 全部
-
@@ -2071,7 +1731,7 @@ + [title]="item.profile === 'pro' ? '使用 ARTEMIS Pro:多智能体协作' : '使用 ARTEMIS Flash:快速执行'"> {{ item.profile === 'pro' ? 'Pro' : 'Flash' }} @@ -2086,7 +1746,7 @@ diff --git a/apps/showcase_ui/src/app/pages/home/home.component.ts b/apps/showcase_ui/src/app/pages/home/home.component.ts index e189b9c..6667d1e 100644 --- a/apps/showcase_ui/src/app/pages/home/home.component.ts +++ b/apps/showcase_ui/src/app/pages/home/home.component.ts @@ -1,3 +1,5 @@ +import { CapabilitySettingsComponent } from '../../components/model-settings/capability-settings.component'; +import { ModelSettingsComponent } from '../../components/model-settings/model-settings.component'; /** * Copyright 2026 Google LLC * @@ -14,9 +16,9 @@ * limitations under the License. */ -import { Component, signal, computed, effect, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; +import { Component, signal, computed, inject, OnInit, OnDestroy, ChangeDetectionStrategy } from '@angular/core'; import { FormsModule } from '@angular/forms'; -import { Router, RouterLink } from '@angular/router'; +import { Router } from '@angular/router'; import { AgentService } from '../../services/agent.service'; import { SystemService } from '../../services/system.service'; import { @@ -103,7 +105,7 @@ type AdbGuideTab = 'emulator' | 'usb' | 'wifi' | 'remote'; @Component({ selector: 'app-home', standalone: true, - imports: [FormsModule], + imports: [FormsModule, ModelSettingsComponent, CapabilitySettingsComponent], templateUrl: './home.component.html', changeDetection: ChangeDetectionStrategy.Eager, styleUrl: './home.component.scss' @@ -121,32 +123,6 @@ export class HomeComponent implements OnInit, OnDestroy { public activeAdbGuideTab = signal('emulator'); public emulatorSetupMode = signal<'studio' | 'cli'>('studio'); - // Interactive guide tab for LLM / OCR credentials: 'gemini' | 'ocr' - public modelSetupMode = signal<'gemini' | 'custom'>('gemini'); - public showOcrConfig = signal(false); - public showFullConfigFile = signal(false); - - // Model & Environment configuration from backend - public modelConfigEnv = computed(() => this.systemService.modelConfigEnv()); - - // Google Gemini API Key State - public geminiKeyInput = signal(''); - public showGeminiKey = signal(false); - public isSavingGeminiKey = signal(false); - public isTestingGeminiKey = signal(false); - public geminiSaveMessage = signal(null); - public geminiSaveError = signal(null); - public isGeminiKeyEdited = signal(false); - - // Vision OCR API Key State - public ocrKeyInput = signal(''); - public showOcrKey = signal(false); - public isSavingOcrKey = signal(false); - public isTestingOcrKey = signal(false); - public ocrSaveMessage = signal(null); - public ocrSaveError = signal(null); - public isOcrKeyEdited = signal(false); - // Clipboard copy state tracker for interactive feedback public copiedId = signal(null); @@ -198,7 +174,6 @@ export class HomeComponent implements OnInit, OnDestroy { // Pro Mode Outputter & Structured Output Configuration public expectedOutput = signal(''); - public enableOutputter = signal(true); public showOutputterDrawer = signal(false); // Slider indexes map to the API ids in VERIFICATION_LEVELS / EXPLORER_MODES. @@ -238,8 +213,8 @@ export class HomeComponent implements OnInit, OnDestroy { return [ { kind: 'verify', - name: 'Result check', - ends: ['Off', 'Strict'], + name: "结果检查", + ends: ["关闭", "严格检查"], ladder: VERIFICATION_LEVELS, index: vi, level: VERIFICATION_LEVELS[vi], @@ -247,8 +222,8 @@ export class HomeComponent implements OnInit, OnDestroy { }, { kind: 'explore', - name: 'Screen reading', - ends: ['Faster', 'Sharper'], + name: "屏幕识别", + ends: ["更快", "更精细"], ladder: EXPLORER_MODES, index: ei, level: EXPLORER_MODES[ei], @@ -352,13 +327,13 @@ export class HomeComponent implements OnInit, OnDestroy { public emulatorLaunchState = computed(() => this.systemService.emulatorLaunchState()); public isEmulatorLaunching = computed(() => this.systemService.isEmulatorLaunching()); public showLaunchLogs = signal(false); - + // Probes public pythonProbe = computed(() => this.systemService.pythonProbe()); public configProbe = computed(() => this.systemService.configProbe()); public adbProbe = computed(() => this.systemService.adbProbe()); public llmProbe = computed(() => this.systemService.llmProbe()); - public geminiProbe = computed(() => this.systemService.geminiProbe()); + public openaiProbe = computed(() => this.systemService.openaiProbe()); public ocrProbe = computed(() => this.systemService.ocrProbe()); public toolchainProbe = computed(() => this.systemService.toolchainProbe()); @@ -426,16 +401,16 @@ export class HomeComponent implements OnInit, OnDestroy { public emuHypervisorTitle = computed(() => { const os = this.effectiveOs(); - if (os === 'windows') return 'Enable Windows Hypervisor Platform (WHPX)'; - if (os === 'darwin') return 'Verify macOS Hypervisor / Install Tools'; - return 'Enable KVM Hardware Acceleration (Linux)'; + if (os === 'windows') return "启用 Windows 虚拟机监控程序平台(WHPX)"; + if (os === 'darwin') return "检查 macOS 虚拟化并安装工具"; + return "启用 KVM 硬件加速(Linux)"; }); public emuHypervisorDesc = computed(() => { const os = this.effectiveOs(); - if (os === 'windows') return 'Enable Windows Hypervisor Platform in PowerShell (Run as Administrator):'; - if (os === 'darwin') return 'macOS uses native Hypervisor.framework. Install SDK tools via Homebrew (or Studio):'; - return 'Ensure virtualization permissions are granted to your user account:'; + if (os === 'windows') return "以管理员身份打开 PowerShell,启用 Windows 虚拟机监控程序平台:"; + if (os === 'darwin') return "macOS 使用系统 Hypervisor.framework。通过 Homebrew 或 Android Studio 安装 SDK 工具:"; + return "确保当前用户拥有虚拟化权限:"; }); public emuHypervisorCmd = computed(() => { @@ -467,27 +442,6 @@ export class HomeComponent implements OnInit, OnDestroy { return meta?.['configured'] === true; }); - public currentApiKey = computed(() => this.systemService.currentApiKey()); - public apiKeysMap = computed>(() => this.systemService.apiKeysMap()); - - public savedGeminiKey = computed(() => { - const keys = this.apiKeysMap(); - return keys['google'] || this.currentApiKey() || ''; - }); - - public isGeminiModified = computed(() => { - return this.geminiKeyInput().trim() !== this.savedGeminiKey().trim(); - }); - - public savedOcrKey = computed(() => { - const keys = this.apiKeysMap(); - return keys['ocr'] || ''; - }); - - public isOcrModified = computed(() => { - return this.ocrKeyInput().trim() !== this.savedOcrKey().trim(); - }); - // Rich Smart Suggestions Library (Device-Aware, Flash vs Pro Tailored) public readonly allSuggestions = this.taskRecService.allTasks; @@ -520,21 +474,6 @@ export class HomeComponent implements OnInit, OnDestroy { this.systemService.fetchReadiness().subscribe(); }; - constructor() { - effect(() => { - const keys = this.systemService.apiKeysMap(); - const current = this.systemService.currentApiKey(); - const googleKey = keys['google'] || current || ''; - const ocrKey = keys['ocr'] || ''; - if (!this.isGeminiKeyEdited()) { - this.geminiKeyInput.set(googleKey); - } - if (!this.isOcrKeyEdited()) { - this.ocrKeyInput.set(ocrKey); - } - }); - } - ngOnInit(): void { this.loadProTuningDefaults(); @@ -581,234 +520,15 @@ export class HomeComponent implements OnInit, OnDestroy { this.emulatorSetupMode.set(mode); } - public setModelSetupMode(mode: 'gemini' | 'custom'): void { - this.modelSetupMode.set(mode); - if (mode === 'custom') { - this.systemService.setSkipCredentialsCheck(true); - this.systemService.fetchModelConfigEnv().subscribe(); - } else { - this.systemService.setSkipCredentialsCheck(false); - } - } - public toggleFullConfigFile(): void { - this.showFullConfigFile.update(v => !v); - } - public toggleGeminiKeyVisibility(): void { - this.showGeminiKey.update(v => !v); - } - public toggleOcrKeyVisibility(): void { - this.showOcrKey.update(v => !v); - } - public toggleOcrConfig(): void { - this.showOcrConfig.update(v => !v); - } - - public onGeminiKeyChange(val: string): void { - this.geminiKeyInput.set(val); - this.isGeminiKeyEdited.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - } - - public onOcrKeyChange(val: string): void { - this.ocrKeyInput.set(val); - this.isOcrKeyEdited.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - } - - public saveGeminiKey(): void { - const key = this.geminiKeyInput().trim(); - if (!key) return; - this.isSavingGeminiKey.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - this.systemService.updateApiKey('google', key, true).subscribe({ - next: (res) => { - this.isSavingGeminiKey.set(false); - this.isGeminiKeyEdited.set(false); - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key verified & saved successfully.'); - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to update Gemini API key.'); - } - }); - } - - public clearGeminiKey(): void { - this.geminiKeyInput.set(''); - this.isGeminiKeyEdited.set(false); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - if (this.savedGeminiKey().trim()) { - this.isSavingGeminiKey.set(true); - this.systemService.updateApiKey('google', '', true).subscribe({ - next: (res) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key cleared.'); - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Gemini API key.'); - } - }); - } else { - this.geminiSaveMessage.set('✓ Gemini API key cleared.'); - setTimeout(() => this.geminiSaveMessage.set(null), 3000); - } - } - - public saveOcrKey(): void { - const key = this.ocrKeyInput().trim(); - if (!key) return; - this.isSavingOcrKey.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - this.systemService.updateApiKey('ocr', key, true).subscribe({ - next: (res) => { - this.isSavingOcrKey.set(false); - this.isOcrKeyEdited.set(false); - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key verified & saved.'); - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to update Vision OCR key.'); - } - }); - } - - public clearOcrKey(): void { - this.ocrKeyInput.set(''); - this.isOcrKeyEdited.set(false); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - if (this.savedOcrKey().trim()) { - this.isSavingOcrKey.set(true); - this.systemService.updateApiKey('ocr', '', true).subscribe({ - next: (res) => { - this.isSavingOcrKey.set(false); - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key cleared.'); - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isSavingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Failed to clear Vision OCR key.'); - } - }); - } else { - this.ocrSaveMessage.set('✓ Vision OCR API key cleared.'); - setTimeout(() => this.ocrSaveMessage.set(null), 3000); - } - } - - public testGeminiKey(): void { - const key = this.geminiKeyInput().trim(); - if (!key) return; - this.isTestingGeminiKey.set(true); - this.geminiSaveError.set(null); - this.geminiSaveMessage.set(null); - - this.systemService.testApiKey('google', key).subscribe({ - next: (res) => { - this.isTestingGeminiKey.set(false); - if (res?.valid) { - this.geminiSaveMessage.set(res?.message || '✓ Gemini API key is valid!'); - } else { - this.geminiSaveError.set(res?.message || 'Gemini API key verification failed.'); - } - setTimeout(() => this.geminiSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isTestingGeminiKey.set(false); - this.geminiSaveError.set(err?.error?.detail || err?.message || 'Gemini API key test failed.'); - } - }); - } - - public testOcrKey(): void { - const key = this.ocrKeyInput().trim(); - if (!key) return; - this.isTestingOcrKey.set(true); - this.ocrSaveError.set(null); - this.ocrSaveMessage.set(null); - - this.systemService.testApiKey('ocr', key).subscribe({ - next: (res) => { - this.isTestingOcrKey.set(false); - if (res?.valid) { - this.ocrSaveMessage.set(res?.message || '✓ Vision OCR API key is valid!'); - } else { - this.ocrSaveError.set(res?.message || 'Vision OCR API key verification failed.'); - } - setTimeout(() => this.ocrSaveMessage.set(null), 5000); - }, - error: (err) => { - this.isTestingOcrKey.set(false); - this.ocrSaveError.set(err?.error?.detail || err?.message || 'Vision OCR API key test failed.'); - } - }); - } - - public getProviderDisplayName(tab: string): string { - switch (tab) { - case 'gemini': return 'Gemini'; - case 'ocr': return 'Vision OCR'; - default: return tab; - } - } - - public getProviderEnvVar(tab: string): string { - switch (tab) { - case 'gemini': return 'GEMINI_API_KEY'; - case 'ocr': return 'GOOGLE_VISION_API_KEY'; - default: return 'API_KEY'; - } - } - - public getProviderHint(tab: string): string { - switch (tab) { - case 'gemini': - return 'For a quick start, Google Gemini provides a free API key. Artemis also supports other models (OpenAI, Claude, OpenRouter, etc.)—you can configure your own API keys directly in .env or your environment.'; - case 'ocr': - return 'Google Cloud Vision API key for on-screen OCR text detection and UI grounding.'; - default: - return 'Configure your API key or use environment definitions.'; - } - } public skipCredentialsCheck(): void { this.systemService.skipCredentialsCheck(); } - public getApiKeyPlaceholder(tab: string): string { - switch (tab) { - case 'gemini': return 'Enter Gemini API Key (e.g. AIzaSy...)'; - case 'ocr': return 'Enter Vision OCR API Key (e.g. AIzaSy...)'; - default: return 'Enter API Key...'; - } - } - - public isTabProviderActive(tab: string): boolean { - if (tab === 'ocr') { - return this.isOcrConfigured(); - } - const targetProvider = tab === 'gemini' ? 'google' : tab; - const providers = this.configuredLlmProviders(); - return providers.some(p => p.provider === targetProvider); - } - public refreshReadiness(): void { this.isRefreshingDiagnostics.set(true); this.systemService.fetchReadiness(false, true).subscribe({ @@ -829,12 +549,12 @@ export class HomeComponent implements OnInit, OnDestroy { this.systemService.restartAdb().subscribe({ next: (res) => { this.adbRestartFeedback.set( - res?.restart_result?.skipped ? 'Devices Refreshed ✓' : 'ADB Refreshed ✓' + res?.restart_result?.skipped ? "设备已刷新 ✓" : "ADB 已刷新 ✓" ); setTimeout(() => this.adbRestartFeedback.set(null), 2500); }, error: () => { - this.adbRestartFeedback.set('Restart Failed'); + this.adbRestartFeedback.set("重启失败"); setTimeout(() => this.adbRestartFeedback.set(null), 3000); } }); @@ -842,7 +562,7 @@ export class HomeComponent implements OnInit, OnDestroy { public connectWifiDevice(): void { if (this.isRemoteAdbServer()) { - this.wifiConnectError.set('Switch to local ADB before connecting a Wireless ADB device.'); + this.wifiConnectError.set("连接无线设备前,请先切换至本地 ADB。"); return; } const host = this.wifiHost().trim(); @@ -850,7 +570,7 @@ export class HomeComponent implements OnInit, OnDestroy { const port = parseInt(portStr, 10) || 5555; if (!host) { - this.wifiConnectError.set('Please enter a valid IP address.'); + this.wifiConnectError.set("请输入有效的 IP 地址。"); return; } @@ -863,15 +583,15 @@ export class HomeComponent implements OnInit, OnDestroy { this.isConnectingWifi.set(false); const cr = res?.connect_result; if (cr?.success) { - this.wifiConnectMessage.set(`Connected to ${host}:${port}!`); + this.wifiConnectMessage.set(`已连接至 ${host}:${port}!`); setTimeout(() => this.wifiConnectMessage.set(null), 4000); } else { - this.wifiConnectError.set(cr?.message || 'Connection failed. Please check phone IP & Wi-Fi.'); + this.wifiConnectError.set(cr?.message || "连接失败,请检查手机 IP 地址和 Wi-Fi。"); } }, error: (err) => { this.isConnectingWifi.set(false); - this.wifiConnectError.set(err?.error?.detail || 'Failed to connect. Please check adb connection.'); + this.wifiConnectError.set(err?.error?.detail || "连接失败,请检查 ADB 连接。"); } }); } @@ -881,11 +601,11 @@ export class HomeComponent implements OnInit, OnDestroy { const port = Number(this.remoteAdbPort().trim()); if (!host) { - this.remoteAdbError.set('Enter the host name or IP address of the ADB server.'); + this.remoteAdbError.set("请输入 ADB 服务器的主机名或 IP 地址。"); return; } if (!Number.isInteger(port) || port < 1 || port > 65535) { - this.remoteAdbError.set('Enter a port between 1 and 65535.'); + this.remoteAdbError.set("请输入 1–65535 之间的端口。"); return; } @@ -910,7 +630,7 @@ export class HomeComponent implements OnInit, OnDestroy { error: error => { this.isConnectingRemoteAdb.set(false); this.remoteAdbError.set( - error?.error?.detail || 'Unable to test the ADB server endpoint.' + error?.error?.detail || "无法测试 ADB 服务器连接。" ); } }); @@ -919,7 +639,7 @@ export class HomeComponent implements OnInit, OnDestroy { public activateRemoteAdbServer(): void { const tested = this.remoteAdbProbeResult(); if (!tested?.success) { - this.remoteAdbError.set('Test the endpoint before using it.'); + this.remoteAdbError.set("请先测试服务器连接。"); return; } @@ -944,7 +664,7 @@ export class HomeComponent implements OnInit, OnDestroy { error: error => { this.isActivatingRemoteAdb.set(false); this.remoteAdbError.set( - error?.error?.detail || 'Unable to use the ADB server endpoint.' + error?.error?.detail || "无法使用此 ADB 服务器。" ); } }); @@ -979,7 +699,7 @@ export class HomeComponent implements OnInit, OnDestroy { error: error => { this.isSwitchingToLocalAdb.set(false); this.remoteAdbError.set( - error?.error?.detail || 'Unable to switch back to the local ADB server.' + error?.error?.detail || "无法切换回本地 ADB 服务器。" ); } }); @@ -1069,12 +789,12 @@ export class HomeComponent implements OnInit, OnDestroy { public runTask(): void { const goal = this.taskGoal().trim(); if (!goal) { - this.errorMessage.set('Please enter a task goal before running.'); + this.errorMessage.set("请先输入任务目标。"); return; } if (!this.isReady()) { - this.errorMessage.set('System prerequisites are not satisfied. Please review System Setup first.'); + this.errorMessage.set("运行条件尚未满足,请先检查系统设置。"); this.activeTab.set('diagnostics'); return; } @@ -1089,7 +809,7 @@ export class HomeComponent implements OnInit, OnDestroy { this.selectedProfile() === 'pro' && this.expectedOutput().trim() ? this.expectedOutput().trim() : undefined, - this.selectedProfile() === 'pro' ? this.enableOutputter() : undefined, + undefined, this.selectedProfile() === 'pro' ? { verificationLevel: this.verificationLevel().id, explorerMode: this.explorerMode().id } : undefined @@ -1103,7 +823,7 @@ export class HomeComponent implements OnInit, OnDestroy { console.error('Failed to submit task from home page:', err); this.isSubmitting.set(false); this.errorMessage.set( - err?.error?.detail || 'Failed to submit task. Please check server connection.' + err?.error?.detail || "提交任务失败,请检查服务器连接。" ); } }); diff --git a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html index a44cd84..fedc937 100644 --- a/apps/showcase_ui/src/app/pages/workspace/workspace.component.html +++ b/apps/showcase_ui/src/app/pages/workspace/workspace.component.html @@ -27,7 +27,7 @@ limitations under the License.
error {{ errorMessage() }} -
@@ -89,7 +89,7 @@ limitations under the License.
- Start a new task... + 开始新任务…
@@ -104,7 +104,7 @@ limitations under the License. (blur)="onInputBlur()" rows="1" [disabled]="isSubmitting()" - [placeholder]="selectedProfile() === 'pro' + [placeholder]="selectedProfile() === 'pro' ? 'Pro: plans first, checks results and recovers from blocked steps. For long or complex tasks' : 'Flash: fast single-look loop for everyday app tasks'" class="dock-textarea" @@ -115,7 +115,7 @@ limitations under the License. @if (taskInput.trim() && !isSubmitting()) { } @@ -137,7 +137,7 @@ limitations under the License. [disabled]="!taskInput.trim() || isSubmitting()" (click)="submitTask()" [title]="isSubmitting() ? 'Starting task...' : 'Start task (Enter)'" - aria-label="Start task" + aria-label="开始任务" > @if (isSubmitting()) { sync @@ -152,16 +152,16 @@ limitations under the License.