feat: add configurable screenshot resolution limit

This commit is contained in:
chenmingxuan committed 2026-09-29 17:12:22 +08:00
1 parent 59f458205d
commit ab066733b8
13 files changed
+134 -4

No files matched your search

+2
View File
@@ -19,6 +19,8 @@ ARTEMIS_VIDEO_ENABLED=false
ARTEMIS_RECORDING_ENABLED=false
ARTEMIS_OUTPUTTER_ENABLED=true
ARTEMIS_STEP_SUMMARIZER_ENABLED=true
# 默认将任务截图长边压至 2048;false 保留设备原始截图。长边值可在 config/artemis.jsonc 的 agent.screenshot 中覆盖。
ARTEMIS_SCREENSHOT_LIMIT_ENABLED=true
# Pro 连续无工具调用或参数校验失败的最大次数;耗尽后明确失败,避免空转。
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS=3
# Flash 连续无工具调用的最大次数;无效回复不会重复送回模型。
+2
View File
@@ -52,6 +52,8 @@ cd Artemis-Eapil
也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。
“功能开关”默认将任务截图长边限制为 2048 像素;关闭后保留设备原始分辨率。可在 [config/artemis.jsonc](config/artemis.jsonc) 的 `agent.screenshot` 中覆盖 `enabled` 和 `max_long_edge`,重启后对新任务生效。
报告会区分“执行完成”和“验证通过”;没有验证证据的结果标为“未验证”。任务步骤与截图可查看,设备动作回放不提供。
## 让 AI 使用
@@ -9,6 +9,7 @@ from urllib.parse import urlsplit
from pydantic import BaseModel, Field, SecretStr, field_validator
from artemis.config.agent import load_agent_config
from artemis.config.paths import get_env_file
from artemis.config.settings import settings
@@ -137,14 +138,19 @@ class CapabilitySettings(BaseModel):
recording_enabled: bool = False
outputter_enabled: bool = True
step_summarizer_enabled: bool = True
screenshot_limit_enabled: bool = True
screenshot_max_long_edge: int = 2048
def get_capability_settings() -> CapabilitySettings:
screenshot = load_agent_config().screenshot
return CapabilitySettings(
video_enabled=settings.ARTEMIS_VIDEO_ENABLED,
recording_enabled=settings.ARTEMIS_RECORDING_ENABLED,
outputter_enabled=settings.ARTEMIS_OUTPUTTER_ENABLED,
step_summarizer_enabled=settings.ARTEMIS_STEP_SUMMARIZER_ENABLED,
screenshot_limit_enabled=screenshot.enabled,
screenshot_max_long_edge=screenshot.max_long_edge,
)
@@ -154,6 +160,7 @@ def save_capability_settings(update: CapabilitySettings) -> CapabilitySettings:
"ARTEMIS_RECORDING_ENABLED": update.recording_enabled,
"ARTEMIS_OUTPUTTER_ENABLED": update.outputter_enabled,
"ARTEMIS_STEP_SUMMARIZER_ENABLED": update.step_summarizer_enabled,
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED": update.screenshot_limit_enabled,
}
persist_environment({key: str(value).lower() for key, value in values.items()})
for key, value in values.items():
@@ -8,6 +8,8 @@ interface Capabilities {
recording_enabled: boolean;
outputter_enabled: boolean;
step_summarizer_enabled: boolean;
screenshot_limit_enabled: boolean;
screenshot_max_long_edge: number;
}
@Component({
@@ -24,6 +26,8 @@ interface Capabilities {
<span>最终报告生成<small>根据执行记录生成任务结果报告。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.step_summarizer_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>步骤摘要压缩<small>Flash 模式下在后台生成摘要,减少长任务的上下文占用。</small></span></label>
<label><input type="checkbox" role="switch" [(ngModel)]="config.screenshot_limit_enabled" [disabled]="busy() || loading() || !loaded()" />
<span>限制截图分辨率<small>开启时长边最多 {{ config.screenshot_max_long_edge }} 像素;关闭后使用设备原始分辨率。JSON 配置可覆盖开关和上限。</small></span></label>
<button type="button" (click)="save()" [disabled]="busy() || loading() || !loaded()">{{ busy() ? '正在保存…' : '保存功能设置' }}</button>
@if (message()) { <p role="status">{{ message() }}</p> }
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
@@ -39,7 +43,7 @@ interface Capabilities {
export class CapabilitySettingsComponent implements OnInit {
changed = output<Capabilities>();
private http = inject(HttpClient);
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true };
config: Capabilities = { video_enabled: false, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: true, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 };
busy = signal(false); loading = signal(true); loaded = signal(false); message = signal(''); error = signal('');
ngOnInit(): void {
this.http.get<Capabilities>('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({
@@ -110,17 +110,19 @@ describe('持久化模型与功能设置', () => {
});
it('录屏与视频分析独立保存,重新打开后保留各自状态', () => {
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false };
const capabilities = { video_enabled: true, recording_enabled: false, outputter_enabled: true, step_summarizer_enabled: false, screenshot_limit_enabled: true, screenshot_max_long_edge: 2048 };
const fixture = TestBed.createComponent(CapabilitySettingsComponent);
fixture.detectChanges();
http.expectOne('/api/system/capability-settings').flush(capabilities);
fixture.componentInstance.config.video_enabled = false;
fixture.componentInstance.config.recording_enabled = true;
fixture.componentInstance.config.screenshot_limit_enabled = false;
fixture.componentInstance.save();
const request = http.expectOne('/api/system/capability-settings');
expect(request.request.body.video_enabled).toBeFalse();
expect(request.request.body.recording_enabled).toBeTrue();
const saved = { ...capabilities, video_enabled: false, recording_enabled: true };
expect(request.request.body.screenshot_limit_enabled).toBeFalse();
const saved = { ...capabilities, video_enabled: false, recording_enabled: true, screenshot_limit_enabled: false };
request.flush(saved);
fixture.destroy();
const reopened = TestBed.createComponent(CapabilitySettingsComponent);
@@ -128,5 +130,6 @@ describe('持久化模型与功能设置', () => {
http.expectOne('/api/system/capability-settings').flush(saved);
expect(reopened.componentInstance.config.video_enabled).toBeFalse();
expect(reopened.componentInstance.config.recording_enabled).toBeTrue();
expect(reopened.componentInstance.config.screenshot_limit_enabled).toBeFalse();
});
});
+11
View File
@@ -835,6 +835,13 @@ class ProProfileConfig(BaseModel):
model_config = {"extra": "allow"}
class ScreenshotConfig(BaseModel):
"""Limit screenshots sent to agents while retaining physical device coordinates."""
enabled: bool = Field(default_factory=lambda: settings.ARTEMIS_SCREENSHOT_LIMIT_ENABLED)
max_long_edge: int = Field(default=2048, ge=256, le=8192)
class AgentGlobalConfig(BaseModel):
"""Global configuration parsed from artemis.jsonc / agent_config.json."""
@@ -889,6 +896,10 @@ class AgentGlobalConfig(BaseModel):
default_factory=MemoryConfig,
description="Unified step-memory runtime and transcript scrub options.",
)
screenshot: ScreenshotConfig = Field(
default_factory=ScreenshotConfig,
description="Agent screenshot size limit; device/UI coordinates remain native.",
)
model_config = {"extra": "allow"}
+1
View File
@@ -112,6 +112,7 @@ class Settings(BaseSettings):
ARTEMIS_RECORDING_ENABLED: bool = False
ARTEMIS_OUTPUTTER_ENABLED: bool = True
ARTEMIS_STEP_SUMMARIZER_ENABLED: bool = True
ARTEMIS_SCREENSHOT_LIMIT_ENABLED: bool = True
# Consecutive Pro responses without a valid tool call before failing the task.
ARTEMIS_OPERATOR_MAX_NO_PROGRESS_ATTEMPTS: int = Field(default=3, ge=1, le=20)
# Consecutive Flash responses without any tool call before failing the task.
+10
View File
@@ -25,12 +25,14 @@ from artemis.clients.ui_automator_client import (
UIAutomatorClient,
_parse_hierarchy_xml_to_elements,
)
from artemis.config.agent import load_agent_config
from artemis.config.paths import get_temp_dir
from artemis.drivers.base import BaseDeviceDriver, KeyCode, ScreenData, SwipeDirection
from artemis.toolchain import find_ffmpeg, find_scrcpy
from artemis.utils.video import build_scrcpy_record_command
from artemis.utils.ui_filter import filter_ui_hierarchy
from artemis.utils.logger import get_logger
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
logger = get_logger(__name__)
@@ -88,6 +90,7 @@ class AndroidAdbDriver(BaseDeviceDriver):
self._device: AdbDevice | None = None
self._recording_process: asyncio.subprocess.Process | None = None
self._recording_output_path: Path | None = None
self._screenshot_config = load_agent_config().screenshot
@property
def device_id(self) -> str:
@@ -215,6 +218,13 @@ class AndroidAdbDriver(BaseDeviceDriver):
if isinstance(screenshot_bytes, bytes) and screenshot_bytes
else base64.b64decode(clean_b64)
)
if self._screenshot_config.enabled:
limited_bytes = limit_screenshot_long_edge(
clean_bytes, self._screenshot_config.max_long_edge
)
if limited_bytes is not clean_bytes:
clean_bytes = limited_bytes
clean_b64 = base64.b64encode(clean_bytes).decode("utf-8")
return ScreenData(
screenshot_bytes=clean_bytes,
+4
View File
@@ -27,6 +27,10 @@
"checker": {}
},
"agent": {
"screenshot": {
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
"max_long_edge": 2048
},
"flash": {
"max_turns": 0,
"explorer_mode": "flash",
+23
View File
@@ -0,0 +1,23 @@
"""Prepare Android screenshots for model input without changing device coordinates."""
from io import BytesIO
from PIL import Image
def limit_screenshot_long_edge(image_bytes: bytes, max_long_edge: int) -> bytes:
"""Downscale oversized screenshots; keep smaller images byte-for-byte unchanged."""
try:
with Image.open(BytesIO(image_bytes)) as image:
long_edge = max(image.size)
if long_edge <= max_long_edge:
return image_bytes
scale = max_long_edge / long_edge
size = tuple(max(1, round(side * scale)) for side in image.size)
resized = image.convert("RGB").resize(size, Image.Resampling.LANCZOS)
output = BytesIO()
resized.save(output, format="JPEG", quality=80, optimize=True)
return output.getvalue()
except OSError:
# Preserve the capture path's previous behavior for invalid placeholder images.
return image_bytes
+4
View File
@@ -27,6 +27,10 @@
"checker": {}
},
"agent": {
"screenshot": {
"enabled": "${env:ARTEMIS_SCREENSHOT_LIMIT_ENABLED:-true}",
"max_long_edge": 2048
},
"flash": {
"max_turns": 0,
"explorer_mode": "flash",
@@ -27,6 +27,7 @@ def isolated_settings(monkeypatch, tmp_path):
"ARTEMIS_VIDEO_ENABLED",
"ARTEMIS_OUTPUTTER_ENABLED",
"ARTEMIS_STEP_SUMMARIZER_ENABLED",
"ARTEMIS_SCREENSHOT_LIMIT_ENABLED",
]
for key in keys:
monkeypatch.setattr(settings, key, getattr(settings, key))
@@ -93,21 +94,38 @@ def test_rejects_invalid_base_url(url):
def test_capabilities_persist_and_apply_to_new_tasks(isolated_settings):
result = service.save_capability_settings(
service.CapabilitySettings(
video_enabled=False, outputter_enabled=False, step_summarizer_enabled=False
video_enabled=False,
outputter_enabled=False,
step_summarizer_enabled=False,
screenshot_limit_enabled=False,
)
)
assert result.video_enabled is False
saved = dotenv_values(isolated_settings)
assert saved["ARTEMIS_VIDEO_ENABLED"] == "false"
assert saved["ARTEMIS_SCREENSHOT_LIMIT_ENABLED"] == "false"
config = load_agent_config()
assert config.video_analyzer.enabled is False
assert config.pro.video_analyzer.enabled is False
assert config.outputter.enabled is False
assert config.flash.step_summarizer.enabled is False
assert config.screenshot.enabled is False
service.save_capability_settings(service.CapabilitySettings(video_enabled=True))
assert load_agent_config().video_analyzer.enabled is True
def test_screenshot_json_can_override_environment(monkeypatch, tmp_path):
config_path = tmp_path / "artemis.jsonc"
config_path.write_text(
'{"agent": {"screenshot": {"enabled": true, "max_long_edge": 1024}}}',
encoding="utf-8",
)
monkeypatch.setenv("ARTEMIS_SCREENSHOT_LIMIT_ENABLED", "false")
screenshot = load_agent_config(config_path).screenshot
assert screenshot.enabled is True
assert screenshot.max_long_edge == 1024
def test_failed_write_keeps_running_settings(monkeypatch, isolated_settings):
previous = settings.OPENAI_MODEL
monkeypatch.setattr(
+41
View File
@@ -15,14 +15,18 @@
"""Unit tests for Unified Base Device Driver and implementations."""
import base64
from io import BytesIO
from types import SimpleNamespace
from unittest.mock import AsyncMock, MagicMock
import pytest
from PIL import Image
from artemis.drivers.base import KeyCode, ScreenData, SwipeDirection
from artemis.drivers.mock.mock_driver import MockDeviceDriver
from artemis.drivers.android.adb_driver import AndroidAdbDriver
from artemis.config.agent import ScreenshotConfig
from artemis.utils.screenshot_limit import limit_screenshot_long_edge
_ONE_PIXEL_PNG = base64.b64encode(
@@ -32,6 +36,13 @@ _ONE_PIXEL_PNG = base64.b64encode(
).decode("ascii")
def test_screenshot_limit_keeps_small_and_invalid_images_unchanged():
small_image = base64.b64decode(_ONE_PIXEL_PNG)
assert limit_screenshot_long_edge(small_image, 2048) is small_image
invalid_image = b"not an image"
assert limit_screenshot_long_edge(invalid_image, 2048) is invalid_image
@pytest.mark.asyncio
async def test_mock_driver_actions():
"""Verify MockDeviceDriver records and performs all core actions."""
@@ -141,6 +152,36 @@ async def test_android_driver_preserves_elements_from_combined_screen_data():
mock_ui_client.get_hierarchy.assert_not_called()
@pytest.mark.asyncio
@pytest.mark.parametrize("enabled", [True, False])
async def test_android_screenshot_limit_preserves_native_coordinates(enabled):
image = Image.new("RGB", (1200, 2600), "white")
buffer = BytesIO()
image.save(buffer, format="JPEG")
original = buffer.getvalue()
elements = [{"text": "目标", "bounds": "[100,200][300,400]"}]
ui_client = MagicMock()
ui_client.get_screen_data.return_value = SimpleNamespace(
base64=base64.b64encode(original).decode("ascii"),
hierarchy_xml="<hierarchy />",
elements=elements,
width=1200,
height=2600,
)
driver = AndroidAdbDriver("emulator-5554", MagicMock(), ui_adb_client=ui_client)
driver._screenshot_config = ScreenshotConfig(enabled=enabled, max_long_edge=2048)
screen = await driver.get_screen_data(skip_settling=True)
with Image.open(BytesIO(screen.screenshot_bytes)) as result:
assert result.size == ((945, 2048) if enabled else (1200, 2600))
assert screen.width == 1200 and screen.height == 2600
assert screen.ui_elements == elements
assert screen.screenshot_base64 == base64.b64encode(screen.screenshot_bytes).decode("ascii")
if not enabled:
assert screen.screenshot_bytes == original
@pytest.mark.asyncio
async def test_android_driver_parses_xml_hierarchy_fallback():
"""XML-only clients remain supported without discarding their hierarchy."""