diff --git a/.env.example b/.env.example index 4978170..d2ee912 100644 --- a/.env.example +++ b/.env.example @@ -11,6 +11,11 @@ ARTEMIS_STEP_SUMMARIZER_TIMEOUT_SECONDS=120 # 失败后最多重试 5 次,由 Artemis 统一管理,SDK 不叠加重试。 OPENAI_MAX_RETRIES=5 OPENAI_RETRY_INTERVAL_SECONDS=10 +OPENAI_RATE_LIMIT_RETRY_SECONDS=60 +OPENAI_TOOL_FALLBACK_MODEL=gpt-6-luna +OPENAI_TOOL_FALLBACK_REASONING_EFFORT=high +OPENAI_TOOL_FALLBACK_MAX_RETRIES=3 +ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS=3 # default 表示不发送思考强度参数;接口不支持所选值时明确报错。 OPENAI_REASONING_EFFORT=high diff --git a/README.md b/README.md index 54abfdb..b722bb8 100644 --- a/README.md +++ b/README.md @@ -48,7 +48,7 @@ cd Artemis-Eapil 3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。 4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。 -预设模型为 `gpt-5.6-luna`、`mimo-v2.6-flash`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**;视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 +预设模型为 `gpt-5.6-luna`、`gpt-6-luna`、`mimo-v2.6-flash`、`mimo-v2.6-pro`,也可填写自定义名称。默认模型是 `mimo-v2.6-flash`,思考强度默认 `high`(最高可选 `max`),单次请求超时 **300 秒**,失败后最多重试 **5 次**,间隔 **10 秒**。遇到 429 请求过多时改为每次等待 **60 秒**。连续 3 次没有有效工具调用时,本步骤改用 `gpt-6-luna`(high),兜底模型最多再尝试 3 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 也可在项目 `.env` 中设置 `OPENAI_API_KEY`、`OPENAI_BASE_URL` 和 `OPENAI_MODEL`,其他参数见 [.env.example](.env.example)。新安装默认接口地址为 `https://api-n-cd.playones.com/v1`;已保存的地址不变。直接编辑环境文件后需重启相应服务。不要提交自己的密钥。 diff --git a/apps/admin_console/routers/system.py b/apps/admin_console/routers/system.py index 141e272..cedcb96 100644 --- a/apps/admin_console/routers/system.py +++ b/apps/admin_console/routers/system.py @@ -517,9 +517,12 @@ async def get_model_config_and_env(): @router.get("/server-status") async def get_server_runtime_status(): - """Retrieve runtime status, PID, port, and uptime of the Artemis server.""" + """Return this server's status without scanning listening ports.""" import os - from artemis.runtime.server_lifecycle import get_server_status + import time + + from artemis.runtime.process_probe import pid_is_alive + from artemis.runtime.server_lifecycle import read_server_info try: from apps.admin_console.core.state import state @@ -527,18 +530,34 @@ async def get_server_runtime_status(): from admin_console.core.state import state port = getattr(state, "port", 8000) - status = get_server_status(port=port) + current_pid = os.getpid() + pids = {current_pid} + started_at = None + info = read_server_info() + if info and info.get("port") == port: + saved_pid = info.get("pid") + if isinstance(saved_pid, int) and saved_pid != current_pid and pid_is_alive(saved_pid): + pids.add(saved_pid) + if saved_pid == current_pid and isinstance(info.get("started_at"), (int, float)): + started_at = float(info["started_at"]) + if started_at is None: + try: + import psutil + + started_at = psutil.Process(current_pid).create_time() + except Exception: + started_at = None # Explicit DTO: the raw metadata file additionally holds the lifecycle # token, cmdline, and filesystem paths, none of which belong on the wire. return { - "running": status["running"], - "port": status["port"], - "pids": status["pids"], - "active_pid": status["active_pid"], - "uptime_seconds": status["uptime_seconds"], - "url": status["url"], - "admin_url": status["admin_url"], - "current_pid": os.getpid(), + "running": True, + "port": port, + "pids": sorted(pids), + "active_pid": current_pid, + "uptime_seconds": max(0.0, time.time() - started_at) if started_at else None, + "url": f"http://localhost:{port}", + "admin_url": f"http://localhost:{port}/admin", + "current_pid": current_pid, } diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index ebcf93a..0eda476 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -23,6 +23,13 @@ class ModelSettingsUpdate(BaseModel): timeout_seconds: float = Field(default=300, gt=0, le=3600) max_retries: int = Field(default=5, ge=0, le=10) retry_interval_seconds: float = Field(default=10, ge=0, le=300) + rate_limit_retry_seconds: float | None = Field(default=None, ge=0, le=3600) + tool_fallback_model: str | None = Field(default=None, min_length=1, max_length=200) + tool_fallback_reasoning_effort: ( + Literal["default", "none", "minimal", "low", "medium", "high", "xhigh", "max"] | None + ) = None + tool_fallback_max_retries: int | None = Field(default=None, ge=0, le=10) + tool_fallback_no_progress_attempts: int | None = Field(default=None, ge=1, le=10) reasoning_effort: Literal[ "default", "none", "minimal", "low", "medium", "high", "xhigh", "max" ] = "high" @@ -45,9 +52,11 @@ class ModelSettingsUpdate(BaseModel): raise ValueError("请输入接口根地址,例如 https://api-n-cd.playones.com/v1") return value - @field_validator("model") + @field_validator("model", "tool_fallback_model") @classmethod - def validate_model(cls, value: str) -> str: + def validate_model(cls, value: str | None) -> str | None: + if value is None: + return None value = value.strip() if not value or any(c in value for c in "\r\n\x00"): raise ValueError("模型名称不能为空或包含换行") @@ -64,6 +73,11 @@ def get_model_settings() -> dict: "timeout_seconds": settings.OPENAI_TIMEOUT_SECONDS, "max_retries": settings.OPENAI_MAX_RETRIES, "retry_interval_seconds": settings.OPENAI_RETRY_INTERVAL_SECONDS, + "rate_limit_retry_seconds": settings.OPENAI_RATE_LIMIT_RETRY_SECONDS, + "tool_fallback_model": settings.OPENAI_TOOL_FALLBACK_MODEL, + "tool_fallback_reasoning_effort": settings.OPENAI_TOOL_FALLBACK_REASONING_EFFORT, + "tool_fallback_max_retries": settings.OPENAI_TOOL_FALLBACK_MAX_RETRIES, + "tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS, "reasoning_effort": settings.OPENAI_REASONING_EFFORT, "model_presets": [ "gpt-5.6-luna", @@ -86,6 +100,20 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict: "OPENAI_RETRY_INTERVAL_SECONDS": str(update.retry_interval_seconds), "OPENAI_REASONING_EFFORT": update.reasoning_effort, } + optional_settings = { + "rate_limit_retry_seconds": ("OPENAI_RATE_LIMIT_RETRY_SECONDS", float), + "tool_fallback_model": ("OPENAI_TOOL_FALLBACK_MODEL", str), + "tool_fallback_reasoning_effort": ("OPENAI_TOOL_FALLBACK_REASONING_EFFORT", str), + "tool_fallback_max_retries": ("OPENAI_TOOL_FALLBACK_MAX_RETRIES", int), + "tool_fallback_no_progress_attempts": ( + "ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS", + int, + ), + } + for field_name, (env_name, expected_type) in optional_settings.items(): + value = getattr(update, field_name) + if value is not None: + values[env_name] = str(value) 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) @@ -95,6 +123,10 @@ def save_model_settings(update: ModelSettingsUpdate) -> dict: settings.OPENAI_MAX_RETRIES = update.max_retries settings.OPENAI_RETRY_INTERVAL_SECONDS = update.retry_interval_seconds settings.OPENAI_REASONING_EFFORT = update.reasoning_effort + for field_name, (env_name, expected_type) in optional_settings.items(): + value = getattr(update, field_name) + if value is not None: + setattr(settings, env_name, expected_type(value)) if "OPENAI_API_KEY" in values: settings.OPENAI_API_KEY = SecretStr(values["OPENAI_API_KEY"]) from artemis.llm.router import ModelFactory diff --git a/apps/admin_console/services/report_export.py b/apps/admin_console/services/report_export.py index 0bb2457..5b16710 100644 --- a/apps/admin_console/services/report_export.py +++ b/apps/admin_console/services/report_export.py @@ -734,9 +734,12 @@ def export_batch(batch_id: str) -> Path: for path in outputs: archive.write(path, path.name) for number, report in enumerate(records, 1): + prefix = f"任务报告/{number:04d}_{archive_case_name(report)}" for path in export_session(report["id"], formats): - archive.write( - path, f"任务报告/{number:04d}_{archive_case_name(report)}/{path.name}" - ) + archive.write(path, f"{prefix}/{path.name}") + for source, name in session_screenshots(report): + archive.write(source, f"{prefix}/步骤截图/{name}") + for source, name in session_videos(report): + archive.write(source, f"{prefix}/任务录屏/{name}") temporary.replace(target) return target 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 5e37237..0f62306 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 @@ -8,6 +8,8 @@ describe('持久化模型与功能设置', () => { let http: HttpTestingController; const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash', api_key_configured: true, timeout_seconds: 300, max_retries: 5, retry_interval_seconds: 10, reasoning_effort: 'high', + rate_limit_retry_seconds: 60, tool_fallback_model: 'gpt-6-luna', tool_fallback_reasoning_effort: 'high', + tool_fallback_max_retries: 3, tool_fallback_no_progress_attempts: 3, model_presets: ['gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro'], env_path: '/app/.env' }; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); @@ -31,6 +33,9 @@ describe('持久化模型与功能设置', () => { expect(request.request.body.timeout_seconds).toBe(300); expect(request.request.body.max_retries).toBe(5); expect(request.request.body.retry_interval_seconds).toBe(10); + expect(request.request.body.rate_limit_retry_seconds).toBe(60); + expect(request.request.body.tool_fallback_model).toBe('gpt-6-luna'); + expect(request.request.body.tool_fallback_no_progress_attempts).toBe(3); expect(request.request.body.api_key).toBeUndefined(); request.flush({ ...config, model: 'another-model' }); expect(component.busy()).toBeNull(); @@ -88,6 +93,29 @@ describe('持久化模型与功能设置', () => { expect(reopened.nativeElement.querySelector('#model-reasoning').value).toBe('max'); }); + it('高级稳定性设置可编辑并保存', async () => { + const fixture = TestBed.createComponent(ModelSettingsComponent); + fixture.detectChanges(); + http.expectOne('/api/system/model-settings').flush(config); + fixture.detectChanges(); + await fixture.whenStable(); + const root: HTMLElement = fixture.nativeElement; + root.querySelector('.recovery-settings')!.open = true; + const fallback = root.querySelector('input[name="toolFallbackModel"]')!; + fallback.value = 'custom/fallback'; + fallback.dispatchEvent(new Event('input')); + const wait = root.querySelector('input[name="rateLimitRetry"]')!; + wait.value = '90'; + wait.dispatchEvent(new Event('input')); + fixture.detectChanges(); + await fixture.whenStable(); + root.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); + const request = http.expectOne('/api/system/model-settings'); + expect(request.request.body.tool_fallback_model).toBe('custom/fallback'); + expect(request.request.body.rate_limit_retry_seconds).toBe(90); + request.flush({ ...config, tool_fallback_model: 'custom/fallback', rate_limit_retry_seconds: 90 }); + }); + it('连接测试使用当前输入而不写入保存接口', () => { const fixture = TestBed.createComponent(ModelSettingsComponent); fixture.detectChanges(); diff --git a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts index baa0b0a..51d21fc 100644 --- a/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts +++ b/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts @@ -10,6 +10,11 @@ interface ModelSettings { timeout_seconds: number; max_retries: number; retry_interval_seconds: number; + rate_limit_retry_seconds: number; + tool_fallback_model: string; + tool_fallback_reasoning_effort: string; + tool_fallback_max_retries: number; + tool_fallback_no_progress_attempts: number; reasoning_effort: string; model_presets: string[]; env_path: string; @@ -56,6 +61,30 @@ interface ModelSettings { [(ngModel)]="retries" [disabled]="busy() || loading()" /> +
+ 稳定性与兜底模型 +
+ + + +
+ + +
+ 首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。 +
+