feat: add configurable screenshot resolution limit
This commit is contained in:
1 parent
59f458205d
commit
ab066733b8
13 files changed
+134
-4
No files matched your search
@@ -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 连续无工具调用的最大次数;无效回复不会重复送回模型。
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
@@ -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"}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
@@ -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(
|
||||
|
||||
@@ -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."""
|
||||
|
||||
Reference in new issue
Block a user