feat: add Chinese UI, Responses models, and mirrored Windows setup
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:
1 parent
371aa6df56
commit
2ddec53954
183 files changed
+3427
-11857
No files matched your search
@@ -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
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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}
|
||||
Reference in new issue
Block a user