diff --git a/.env.example b/.env.example index 67272f1..4978170 100644 --- a/.env.example +++ b/.env.example @@ -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 连续无工具调用的最大次数;无效回复不会重复送回模型。 diff --git a/README.md b/README.md index ac747bd..54abfdb 100644 --- a/README.md +++ b/README.md @@ -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 使用 diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index 95ad6ca..ebcf93a 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -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(): diff --git a/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts b/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts index d5bede4..e7a3c04 100644 --- a/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts +++ b/apps/showcase_ui/src/app/components/model-settings/capability-settings.component.ts @@ -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 { 最终报告生成根据执行记录生成任务结果报告。 + @if (message()) {

{{ message() }}

} @if (error()) { } @@ -39,7 +43,7 @@ interface Capabilities { export class CapabilitySettingsComponent implements OnInit { changed = output(); 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('/api/system/capability-settings').pipe(finalize(() => this.loading.set(false))).subscribe({ diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts index 2ac8b69..5e37237 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.spec.ts @@ -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(); }); }); diff --git a/artemis/config/agent.py b/artemis/config/agent.py index 9cec72a..29a5080 100644 --- a/artemis/config/agent.py +++ b/artemis/config/agent.py @@ -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"} diff --git a/artemis/config/settings.py b/artemis/config/settings.py index 2fd8ddb..a3bb68c 100644 --- a/artemis/config/settings.py +++ b/artemis/config/settings.py @@ -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. diff --git a/artemis/drivers/android/adb_driver.py b/artemis/drivers/android/adb_driver.py index 4a54381..8934081 100644 --- a/artemis/drivers/android/adb_driver.py +++ b/artemis/drivers/android/adb_driver.py @@ -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, diff --git a/artemis/resources/config/artemis.jsonc b/artemis/resources/config/artemis.jsonc index 2a876f2..05d17f9 100644 --- a/artemis/resources/config/artemis.jsonc +++ b/artemis/resources/config/artemis.jsonc @@ -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", diff --git a/artemis/utils/screenshot_limit.py b/artemis/utils/screenshot_limit.py new file mode 100644 index 0000000..037d487 --- /dev/null +++ b/artemis/utils/screenshot_limit.py @@ -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 diff --git a/config/artemis.jsonc b/config/artemis.jsonc index 2a876f2..05d17f9 100644 --- a/config/artemis.jsonc +++ b/config/artemis.jsonc @@ -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", diff --git a/tests/unit/admin_console/test_model_settings.py b/tests/unit/admin_console/test_model_settings.py index 3de5387..c8d3f08 100644 --- a/tests/unit/admin_console/test_model_settings.py +++ b/tests/unit/admin_console/test_model_settings.py @@ -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( diff --git a/tests/unit/test_drivers.py b/tests/unit/test_drivers.py index 4efba16..592ebdc 100644 --- a/tests/unit/test_drivers.py +++ b/tests/unit/test_drivers.py @@ -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="", + 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."""