feat: add Chinese UI, Responses models, and mirrored Windows setup
CI / Python 3.12 / ubuntu-latest (push) Canceled after 0s
CI / Python 3.12 / windows-latest (push) Canceled after 0s
CI / Frontend tests and production build (push) Canceled after 0s
CI / Build and smoke-test Python packages (push) Canceled after 0s

Localize the web console and persist custom model and capability settings.
Use OpenAI Responses exclusively and move runtime limits into environment configuration.
Bootstrap uv and the virtual environment on Windows, support optional China mirrors,
and package source distributions without environments, build output, or credentials.

Update regression tests and repository quality checks for the new behavior.
This commit is contained in:
chenmingxuan committed 2026-09-22 19:16:17 +08:00
1 parent 371aa6df56
commit 2ddec53954
183 files changed
+3427 -11857

No files matched your search

+28 -115
View File
@@ -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
+65 -43
View File
@@ -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",
+5 -3
View File
@@ -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
@@ -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}