diff --git a/.env.example b/.env.example index 56d9cdd..4349986 100644 --- a/.env.example +++ b/.env.example @@ -3,7 +3,7 @@ OPENAI_API_KEY= # 接口根地址:通常以 /v1 结尾,不要添加 /responses 或 /chat/completions。 OPENAI_BASE_URL=https://api-n-cd.playones.com/v1 -OPENAI_MODEL=mimo-v2.6-flash +OPENAI_MODEL=gpt-6-luna # 单次模型 HTTP 请求超时(秒) OPENAI_TIMEOUT_SECONDS=300 # 可选的后台步骤截图摘要单次超时,独立于上面的主模型请求超时。 diff --git a/README.md b/README.md index 0b144e0..cdea9dd 100644 --- a/README.md +++ b/README.md @@ -49,7 +49,7 @@ cd Artemis-Eapil 3. 输入任务目标,选择 Flash 或 Pro 后执行;在工作区查看步骤、截图和结果。 4. 通过“报告与批量”导出报告或导入 Excel 用例;任务队列支持历史搜索。 -预设模型为 `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 次;下一步恢复首选模型。视频分析与录屏默认关闭。模型及思考参数是否可用取决于所配置网关;不支持时会明确报错。 +预设模型为 `gpt-6-luna`、`gpt-6.1-sol`、`gpt-6-astra`、`mimo-v2.6-pro`,主模型与兜底模型均可选择预设或填写自定义名称。新安装默认模型是 `gpt-6-luna`,思考强度默认 `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/favorites.py b/apps/admin_console/routers/favorites.py index 02954ed..f292305 100644 --- a/apps/admin_console/routers/favorites.py +++ b/apps/admin_console/routers/favorites.py @@ -14,7 +14,7 @@ router = APIRouter(prefix="/api/automation/favorites", tags=["automation"]) @router.get("") def list_favorites(): - return favorites.store.records("favorite", 10000) + return favorites.list() @router.post("") diff --git a/apps/admin_console/services/execution_config.py b/apps/admin_console/services/execution_config.py index daca01f..d54eb55 100644 --- a/apps/admin_console/services/execution_config.py +++ b/apps/admin_console/services/execution_config.py @@ -8,8 +8,10 @@ from artemis.config.settings import settings def execution_config(item: dict) -> dict: agent = load_agent_config() model = parse_llm_config().operator + overrides = item.get("runtime_overrides") or {} + profile = overrides.get("profile") or item.get("profile") or "flash" options = { - "profile": item.get("profile") or "flash", + "profile": profile, "model": model.model, "reasoning_effort": model.reasoning_effort or "default", "request_timeout_seconds": model.timeout, @@ -20,7 +22,7 @@ def execution_config(item: dict) -> dict: "analyze_video": agent.video_analyzer.enable_ledger, "timeout_seconds": settings.ARTEMIS_TASK_TIMEOUT_SECONDS, "verification_level": verification_level_for_checker(agent.checker), - "explorer_mode": agent.explorer.resolve(profile=item.get("profile") or "flash"), + "explorer_mode": agent.explorer.resolve(profile=profile), "observation_timeout_seconds": settings.ARTEMIS_OBSERVATION_TIMEOUT_SECONDS, "observation_max_attempts": settings.ARTEMIS_OBSERVATION_MAX_ATTEMPTS, "device_command_timeout_seconds": settings.ARTEMIS_DEVICE_COMMAND_TIMEOUT_SECONDS, @@ -30,7 +32,6 @@ def execution_config(item: dict) -> dict: for key in ("device_serial", "verification_level", "explorer_mode", "locked_app_package"): if item.get(key) is not None: options[key] = item[key] - overrides = item.get("runtime_overrides") or {} options["recording"] = {**options["recording"], **(overrides.get("recording") or {})} options.update({k: v for k, v in overrides.items() if k != "recording" and v is not None}) return { diff --git a/apps/admin_console/services/favorites.py b/apps/admin_console/services/favorites.py index a284b26..4d8f4aa 100644 --- a/apps/admin_console/services/favorites.py +++ b/apps/admin_console/services/favorites.py @@ -58,12 +58,70 @@ class FavoriteService: value = self.store.get("favorite", identifier(key)) if value is None: raise KeyError("收藏不存在") + if value.get("config_version") != 1: + # Complete legacy snapshots once and preserve their list position. + # Re-read under the write lock so a concurrent edit cannot be lost. + with self.store.connection() as conn: + conn.execute("BEGIN IMMEDIATE") + row = conn.execute( + "SELECT payload FROM automation_records WHERE kind='favorite' AND id=?", (key,) + ).fetchone() + if row is None: + raise KeyError("收藏不存在") + value = self._complete(json.loads(row[0])) + conn.execute( + "UPDATE automation_records SET payload=? WHERE kind='favorite' AND id=?", + (json.dumps(value, ensure_ascii=False), key), + ) + conn.commit() return value + def list(self) -> list[dict]: + return [ + self.get(item["id"]) if item.get("config_version") != 1 else item + for item in self.store.records("favorite", 10000) + ] + + def _complete(self, favorite: dict) -> dict: + result = deepcopy(favorite) + if result.get("config_version") == 1: + return result + snapshot = result["snapshot"] + if result["kind"] == "batch": + for case in snapshot["cases"]: + case["task_config"] = execution_config( + { + "profile": snapshot.get("profile"), + "device_serial": snapshot.get("device_serial"), + "runtime_overrides": { + "timeout_seconds": snapshot.get("timeout_seconds"), + **(case.get("task_config") or {}), + }, + } + )["runtime_overrides"] + elif result["kind"] == "exploration": + config = ExplorationConfig.model_validate(snapshot.get("config") or {}).model_dump() + snapshot["config"] = config + snapshot["task_config"] = execution_config( + { + "runtime_overrides": { + **(snapshot.get("task_config") or {}), + **{ + key: config[key] + for key in ("model", "reasoning_effort", "timeout_seconds") + }, + } + } + )["runtime_overrides"] + else: + snapshot.update(execution_config(snapshot)) + result["config_version"] = 1 + return result + def _task(self, session_id: str, *, fallback: dict | None = None) -> tuple[dict, list[str]]: job = self.store.job(session_id) or {} if snapshot := job.get("execution_config"): - return deepcopy(snapshot), [] + return execution_config(snapshot), [] row = SessionRepository(self.store.path).get_session_by_id(session_id) or {} submission = job.get("submission") or fallback or {} options = { @@ -140,8 +198,8 @@ class FavoriteService: def create(self, value: FavoriteCreate) -> dict: token = f"{value.source_kind}:{value.source_id}:{value.version or ''}" key = hashlib.sha256(token.encode()).hexdigest()[:32] - if existing := self.store.get("favorite", key): - return existing + if self.store.get("favorite", key): + return self.get(key) snapshot, missing, kind, version = {}, [], value.source_kind, None source_id = value.source_id repo = ExplorationStore(self.store) @@ -254,10 +312,10 @@ class FavoriteService: } if not favorite["name"]: raise ValueError("收藏名称不能为空") - return self.store.put("favorite", key, favorite) + return self.store.put("favorite", key, self._complete(favorite)) def updated(self, favorite: dict, value: FavoriteUpdate) -> dict: - result = deepcopy(favorite) + result = self._complete(favorite) if value.name is not None: if not value.name.strip(): raise ValueError("收藏名称不能为空") diff --git a/apps/admin_console/services/model_settings_service.py b/apps/admin_console/services/model_settings_service.py index 3f72b67..e202102 100644 --- a/apps/admin_console/services/model_settings_service.py +++ b/apps/admin_console/services/model_settings_service.py @@ -80,11 +80,9 @@ def get_model_settings() -> dict: "tool_fallback_no_progress_attempts": settings.ARTEMIS_TOOL_FALLBACK_MAX_NO_PROGRESS_ATTEMPTS, "reasoning_effort": settings.OPENAI_REASONING_EFFORT, "model_presets": [ + "gpt-6-luna", "gpt-6.1-sol", "gpt-6-astra", - "gpt-5.6-luna", - "gpt-6-luna", - "mimo-v2.6-flash", "mimo-v2.6-pro", ], "env_path": str(get_env_file()), diff --git a/apps/admin_console/services/task_import.py b/apps/admin_console/services/task_import.py index 43c24fe..59310f5 100644 --- a/apps/admin_console/services/task_import.py +++ b/apps/admin_console/services/task_import.py @@ -103,7 +103,7 @@ def template() -> bytes: json.dumps( { "profile": "pro", - "model": "mimo-v2.6-flash", + "model": "gpt-6-luna", "reasoning_effort": "high", "verification_level": "final", "record_video": True, diff --git a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.html b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.html index efc63a3..c258344 100644 --- a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.html +++ b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.html @@ -8,25 +8,25 @@ @else if(!filtered().length){

没有匹配的精选任务。

}
@for(item of filtered(); track item.id){
-
{{mode(item)}}
-

{{item.name}}

{{summary(item)}}

- @if(item.case_version){

固定用例 v{{item.case_version}}

} - @if(item.inherited_fields.length){

{{item.inherited_fields[0].startsWith('首次') ? item.inherited_fields[0] : '旧任务部分配置缺失,已采用收藏时的默认值'}}

} +
+
{{mode(item)}}@if(item.case_version){用例 v{{item.case_version}}}
+

{{item.name}}

{{summary(item)}}

+
@if(expanded() === item.id){@let d = draft(item);
@if(item.kind === 'batch'){ - @if(d.uniform){} + @if(d.uniform){} @for(c of item.snapshot.cases; track $index; let i = $index){
{{i + 1}}. {{c.name}} - + @if(!computer(c.task_config)){}
} }@else{ @if(item.kind !== 'exploration'){} - + @if(!computer(options(item))){} } @@ -35,5 +35,4 @@ }
}
- @for(model of models; track model){} diff --git a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.scss b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.scss index 76c44d5..60ff6ba 100644 --- a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.scss +++ b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.scss @@ -1 +1,60 @@ -:host{display:block;width:100%;color:#334155}.featured{margin-top:26px}.heading{display:flex;align-items:center;justify-content:space-between;gap:16px}h2{display:flex;align-items:center;gap:7px;margin:0;font-size:16px;font-weight:650;color:#1e293b}h2 span{font-size:20px;color:#d29a32}.heading p{font-size:12px;color:#64748b;margin:6px 0 0}.heading>input{max-width:210px;width:40%;border-radius:20px;background:#fff}.filters{display:flex;gap:5px;flex-wrap:wrap;margin:17px 0}.filters button{border:0;padding:6px 13px;border-radius:18px;font-size:12px;background:transparent;color:#64748b}.filters button.selected{background:#e0e7ff;color:#4338ca;font-weight:600}.cards{display:grid;grid-template-columns:repeat(auto-fit,minmax(min(100%,260px),1fr));gap:14px;align-items:start}.card{padding:16px;border:1px solid #e2e8f0;border-radius:16px;background:#fff;min-width:0;transition:border-color .15s,box-shadow .15s}.card:hover{border-color:#c7d2fe;box-shadow:0 4px 16px #33415508}.card.expanded{border-color:#a5b4fc}.card-heading{display:flex;align-items:center;justify-content:space-between}.mode{padding:3px 8px;background:#f1f5f9;color:#475569;border-radius:6px;font-size:11px;line-height:16px}.mode.pro{background:#ede9fe;color:#6d28d9}.mode.explore{background:#e0f2fe;color:#0369a1}.star{display:flex;padding:3px;border:0;background:transparent;color:#c58b23}.star span{font-size:19px;font-variation-settings:'FILL' 1}h3{font-size:14px;line-height:1.5;margin:13px 0 7px;overflow-wrap:anywhere;color:#1e293b}.model-summary{font-size:12px;color:#64748b;margin:0;overflow-wrap:anywhere}.card-title{display:flex;align-items:flex-start;gap:6px;margin:13px 0 7px}.card-title h3{flex:1;min-width:0;margin:0}.card-title .star{flex-shrink:0;width:24px;height:24px;align-items:center;justify-content:center;padding:0}.card-actions{display:flex;align-items:center;justify-content:space-between;margin-top:18px;gap:10px}.card-actions button{display:flex;align-items:center;gap:4px;padding:7px 10px;border-radius:8px;font-size:12px;white-space:nowrap}.card-actions span{font-size:16px}.configure{color:#64748b;border:0;background:transparent}.primary{border:1px solid #6366f1;background:#6366f1;color:white}.primary:hover{background:#4f46e5}.configuration{border-top:1px solid #e2e8f0;margin-top:14px;padding-top:14px;display:grid;gap:12px}.configuration label{display:grid;gap:6px;font-size:12px;color:#475569}.configuration .checkbox{display:flex;align-items:center}.checkbox input{width:auto}input,select{box-sizing:border-box;min-width:0;width:100%;padding:8px 10px;border:1px solid #cbd5e1;border-radius:8px;color:#334155;background:white;font:inherit;font-size:12px}option{color:#334155;background:#fff}.hint{font-size:11px;color:#64748b;line-height:1.5;margin:7px 0 0}.inherited{color:#927044}.save{justify-self:start;padding:7px 10px;border:1px solid #cbd5e1;background:#fff;color:#475569;border-radius:8px;font-size:12px}details{border:1px solid #e2e8f0;padding:10px;border-radius:9px}details summary{font-size:12px;cursor:pointer;overflow-wrap:anywhere}details[open]>label,details[open]>app-setting-slider{margin-top:12px;display:block}button{font-family:inherit;cursor:pointer}button:disabled{opacity:.5;cursor:default}button:focus-visible,input:focus-visible,select:focus-visible,summary:focus-visible{outline:2px solid #818cf8;outline-offset:2px}.empty{display:flex;flex-direction:column;align-items:center;text-align:center;padding:28px 18px;border:1px dashed #cbd5e1;border-radius:16px;color:#64748b;font-size:12px;line-height:1.7}.empty>span{font-size:28px;color:#c5a365;margin-bottom:9px}.empty strong{font-size:14px;color:#475569}.empty p{max-width:440px;margin:8px 0 0}.error,.notice{padding:10px 12px;border-radius:8px;font-size:12px;line-height:1.6}.error{background:#fff1f2;color:#b91c1c}.notice{background:#f0fdf4;color:#166534}@media(max-width:560px){.heading{align-items:flex-start;flex-direction:column}.heading>input{width:100%;max-width:none}.cards{grid-template-columns:1fr}} +:host { display: block; width: 100%; color: #334155; } +.featured { margin-top: 26px; } +.heading { display: flex; align-items: center; justify-content: space-between; gap: 16px; } +h2 { display: flex; align-items: center; gap: 7px; margin: 0; font-size: 16px; font-weight: 650; color: #1e293b; } +h2 span { font-size: 20px; color: #d29a32; } +.heading p { font-size: 12px; color: #64748b; margin: 6px 0 0; } +.heading > input { max-width: 210px; width: 40%; border-radius: 20px; } +.filters { display: flex; gap: 5px; flex-wrap: wrap; margin: 17px 0; } +.filters button { border: 0; padding: 6px 13px; border-radius: 18px; font-size: 12px; background: transparent; color: #64748b; } +.filters button.selected { background: #e0e7ff; color: #4338ca; font-weight: 600; } +.cards { display: grid; grid-template-columns: repeat(auto-fill, minmax(min(100%, 260px), 1fr)); gap: 14px; align-items: start; } +.card { padding: 16px; border: 1px solid #e2e8f0; border-radius: 16px; background: #fff; min-width: 0; transition: border-color .15s, box-shadow .15s; } +.card:hover { border-color: #c7d2fe; box-shadow: 0 4px 16px #33415508; } +.card.expanded { border-color: #a5b4fc; } +.card-overview { display: flex; flex-direction: column; min-height: 178px; } +.card-heading { display: flex; align-items: center; justify-content: space-between; gap: 6px; min-height: 22px; } +.mode { padding: 3px 8px; background: #f1f5f9; color: #475569; border-radius: 6px; font-size: 11px; line-height: 16px; } +.mode.pro { background: #ede9fe; color: #6d28d9; } +.mode.explore { background: #e0f2fe; color: #0369a1; } +.case-version { font-size: 11px; color: #94a3b8; white-space: nowrap; } +.card-title { display: flex; align-items: flex-start; gap: 6px; margin: 13px 0 7px; min-height: 42px; } +.card-title h3 { flex: 1; min-width: 0; margin: 0; font-size: 14px; line-height: 21px; overflow-wrap: anywhere; color: #1e293b; + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } +.star { display: flex; flex-shrink: 0; width: 24px; height: 24px; align-items: center; justify-content: center; padding: 0; border: 0; background: transparent; color: #c58b23; } +.star span { font-size: 19px; font-variation-settings: 'FILL' 1; } +.model-summary { font-size: 12px; line-height: 18px; color: #64748b; margin: 0 0 12px; min-height: 36px; overflow-wrap: anywhere; + display: -webkit-box; -webkit-box-orient: vertical; -webkit-line-clamp: 2; overflow: hidden; } +.card-actions { display: flex; align-items: center; justify-content: space-between; margin-top: auto; gap: 10px; } +.card-actions button { display: flex; align-items: center; gap: 4px; min-height: 34px; padding: 7px 10px; border-radius: 8px; font-size: 12px; white-space: nowrap; } +.card-actions span { font-size: 16px; } +.configure { color: #64748b; border: 0; background: transparent; } +.primary { border: 1px solid #6366f1; background: #6366f1; color: white; } +.primary:hover { background: #4f46e5; } +.configuration { border-top: 1px solid #e2e8f0; margin-top: 14px; padding-top: 14px; display: grid; gap: 12px; } +.configuration label { display: grid; gap: 6px; font-size: 12px; color: #475569; } +.configuration .checkbox { display: flex; align-items: center; } +.checkbox input { width: auto; min-height: auto; } +input, select { box-sizing: border-box; min-width: 0; width: 100%; min-height: 40px; padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; color: #334155; background: white; font: inherit; font-size: 13px; } +option { color: #334155; background: #fff; } +.hint { font-size: 11px; color: #64748b; line-height: 1.5; margin: 7px 0 0; } +.save { justify-self: start; padding: 7px 10px; border: 1px solid #cbd5e1; background: #fff; color: #475569; border-radius: 8px; font-size: 12px; } +details { border: 1px solid #e2e8f0; padding: 10px; border-radius: 9px; } +details summary { font-size: 12px; cursor: pointer; overflow-wrap: anywhere; } +details[open] > label, details[open] > app-setting-slider { margin-top: 12px; display: block; } +details[open] > app-model-selector { margin-top: 12px; } +button { font-family: inherit; cursor: pointer; } +button:disabled { opacity: .5; cursor: default; } +button:focus-visible, input:focus-visible, select:focus-visible, summary:focus-visible { outline: 2px solid #818cf8; outline-offset: 2px; } +.empty { display: flex; flex-direction: column; align-items: center; text-align: center; padding: 28px 18px; border: 1px dashed #cbd5e1; border-radius: 16px; color: #64748b; font-size: 12px; line-height: 1.7; } +.empty > span { font-size: 28px; color: #c5a365; margin-bottom: 9px; } +.empty strong { font-size: 14px; color: #475569; } +.empty p { max-width: 440px; margin: 8px 0 0; } +.error, .notice { padding: 10px 12px; border-radius: 8px; font-size: 12px; line-height: 1.6; } +.error { background: #fff1f2; color: #b91c1c; } +.notice { background: #f0fdf4; color: #166534; } +@media (max-width: 560px) { + .heading { align-items: flex-start; flex-direction: column; } + .heading > input { width: 100%; max-width: none; } + .cards { grid-template-columns: 1fr; } +} diff --git a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.spec.ts b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.spec.ts index df0b547..1c5b2cb 100644 --- a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.spec.ts +++ b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.spec.ts @@ -29,6 +29,20 @@ describe('Featured tasks',()=>{ expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'}});expect(api.update).not.toHaveBeenCalled();expect(favorite.snapshot.runtime_overrides!['model']).toBe('original-model'); void c.save(favorite);flushMicrotasks();expect(api.update).toHaveBeenCalledWith('favorite',{task_config:{model:'temporary'},name:'检查页面'}); })); + it('uses the shared model picker and automatically keeps completed legacy settings without a warning',fakeAsync(()=>{ + api.items.set([{...structuredClone(favorite),inherited_fields:['record_video','recording']}]); + const fixture=TestBed.createComponent(FeaturedTasksComponent);fixture.detectChanges(); + expect(fixture.nativeElement.textContent).not.toContain('旧任务部分配置缺失'); + fixture.nativeElement.querySelector('.configure').click();fixture.detectChanges();flushMicrotasks(); + const select:HTMLSelectElement=fixture.nativeElement.querySelector('app-model-selector select'); + expect(Array.from(select.options,option=>option.value)).toEqual(['gpt-6-luna','gpt-6.1-sol','gpt-6-astra','mimo-v2.6-pro','__custom__']); + expect(select.value).toBe('__custom__'); + expect(fixture.nativeElement.querySelector('input[aria-label="复跑模型(自定义)"]').value).toBe('original-model'); + select.value='gpt-6-luna';select.dispatchEvent(new Event('change'));fixture.detectChanges(); + fixture.nativeElement.querySelector('.primary').click();flushMicrotasks(); + expect(api.run).toHaveBeenCalledWith('favorite',{task_config:{model:'gpt-6-luna'}}); + expect(api.update).not.toHaveBeenCalled(); + })); it('computer-only favorites need no Android device and can be filtered by name',fakeAsync(()=>{ const pc:Favorite={...structuredClone(favorite),kind:'exploration_case',case_version:1,snapshot:{goal:'检查电脑',runtime_overrides:{profile:'flash',model:'original-model',reasoning_effort:'high',execution_target:'computer'}}};api.items.set([pc]);system.connectedDevices.set([]); const fixture=TestBed.createComponent(FeaturedTasksComponent),c=fixture.componentInstance;c.query.set('不存在');expect(c.filtered()).toEqual([]);c.query.set('页面');expect(c.filtered().length).toBe(1);void c.run(pc);flushMicrotasks();expect(api.run).toHaveBeenCalled(); diff --git a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.ts b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.ts index c8f1046..fa8fcd2 100644 --- a/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.ts +++ b/apps/showcase_ui/src/app/components/featured-tasks/featured-tasks.component.ts @@ -5,12 +5,13 @@ import { FavoritesService, Favorite } from '../../services/favorites.service'; import { AgentService } from '../../services/agent.service'; import { SystemService } from '../../services/system.service'; import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component'; +import { ModelSelectorComponent } from '../model-selector/model-selector.component'; interface ModelDraft {profile: string; model: string; reasoning_effort: string; device_serial: string;} interface Draft extends ModelDraft {name: string; uniform: boolean; cases: ModelDraft[];} @Component({ - selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent], + selector: 'app-featured-tasks', standalone: true, imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent], templateUrl: './featured-tasks.component.html', styleUrl: './featured-tasks.component.scss', changeDetection: ChangeDetectionStrategy.OnPush }) @@ -20,7 +21,6 @@ export class FeaturedTasksComponent { busy = signal(null); error = signal(''); notice = signal(''); drafts = new Map(); readonly efforts = REASONING_VALUES; readonly effortLabels = REASONING_LABELS; - readonly models = ['gpt-6.1-sol','gpt-6-astra','gpt-6-luna','mimo-v2.6-pro','mimo-v2.6-flash']; readonly filters = [{id:'all',label:'全部'},{id:'flash',label:'Flash'},{id:'pro',label:'Pro'},{id:'exploration',label:'探索'},{id:'batch',label:'批量'}]; filtered = computed(() => this.api.items().filter(item => { const filter = this.filter(), query = this.query().trim().toLowerCase(); diff --git a/apps/showcase_ui/src/app/components/model-selector/model-selector.component.spec.ts b/apps/showcase_ui/src/app/components/model-selector/model-selector.component.spec.ts new file mode 100644 index 0000000..13141e1 --- /dev/null +++ b/apps/showcase_ui/src/app/components/model-selector/model-selector.component.spec.ts @@ -0,0 +1,28 @@ +import { TestBed } from '@angular/core/testing'; +import { ModelSelectorComponent } from './model-selector.component'; + +describe('Shared model selector', () => { + it('preserves a saved custom or retired model without silently replacing it', () => { + const fixture = TestBed.createComponent(ModelSelectorComponent); + fixture.componentRef.setInput('value', 'mimo-v2.6-flash'); + fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('select').value).toBe('__custom__'); + expect(fixture.nativeElement.querySelector('input').value).toBe('mimo-v2.6-flash'); + expect(Array.from(fixture.nativeElement.querySelectorAll('option'), (o: any) => o.value)) + .not.toContain('mimo-v2.6-flash'); + }); + + it('allows editing custom names even when the typed name matches a preset', () => { + const fixture = TestBed.createComponent(ModelSelectorComponent); + fixture.componentRef.setInput('value', 'gpt-6-luna'); + fixture.detectChanges(); + const select: HTMLSelectElement = fixture.nativeElement.querySelector('select'); + select.value = '__custom__'; select.dispatchEvent(new Event('change')); fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6-luna'); + fixture.componentRef.setInput('value', 'gpt-6.1-sol'); fixture.detectChanges(); + expect(fixture.nativeElement.querySelector('input').value).toBe('gpt-6.1-sol'); + fixture.componentRef.setInput('disabled', true); fixture.detectChanges(); + expect(select.disabled).toBeTrue(); + expect(fixture.nativeElement.querySelector('input').disabled).toBeTrue(); + }); +}); diff --git a/apps/showcase_ui/src/app/components/model-selector/model-selector.component.ts b/apps/showcase_ui/src/app/components/model-selector/model-selector.component.ts new file mode 100644 index 0000000..9aa2340 --- /dev/null +++ b/apps/showcase_ui/src/app/components/model-selector/model-selector.component.ts @@ -0,0 +1,56 @@ +import { ChangeDetectionStrategy, Component, computed, input, output, signal } from '@angular/core'; +import { MODEL_PRESETS } from '../../core/model-presets'; + +let nextId = 0; + +@Component({ + selector: 'app-model-selector', + standalone: true, + changeDetection: ChangeDetectionStrategy.OnPush, + template: ` + +
+ + +
+ @if (selection() === '__custom__') { + + } + `, + styles: [` + :host { display: grid; gap: 7px; min-width: 0; color: #334155; font-size: 13px; } + label { font-weight: 600; line-height: 1.5; } + .selector { position: relative; min-width: 0; } + select, input { box-sizing: border-box; width: 100%; min-width: 0; min-height: 40px; + padding: 10px 12px; border: 1px solid #dbe3ec; border-radius: 10px; background: #fff; + color: inherit; font: inherit; } + select { appearance: none; padding-right: 36px; cursor: pointer; text-overflow: ellipsis; } + .selector > span { position: absolute; right: 11px; top: 50%; transform: translateY(-50%); + pointer-events: none; font-size: 19px; color: #64748b; } + select:hover:not(:disabled), input:hover:not(:disabled) { border-color: #a5b4fc; } + select:focus-visible, input:focus-visible { outline: 2px solid #a5b4fc; outline-offset: 2px; } + select:disabled, input:disabled { opacity: .5; cursor: default; } + option { background: #fff; color: #334155; } + `] +}) +export class ModelSelectorComponent { + readonly id = `model-selector-${nextId++}`; + label = input('模型'); + value = input.required(); + presets = input(MODEL_PRESETS); + disabled = input(false); + valueChange = output(); + private custom = signal(false); + selection = computed(() => this.custom() || !this.presets().includes(this.value()) ? '__custom__' : this.value()); + + choose(value: string): void { + this.custom.set(value === '__custom__'); + if (value !== '__custom__') this.valueChange.emit(value); + } +} 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 0ec5bb4..161687a 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 @@ -7,11 +7,11 @@ import { CapabilitySettingsComponent } from './capability-settings.component'; describe('持久化模型与功能设置', () => { let http: HttpTestingController; - const config = { base_url: 'https://example.test/v1', model: 'mimo-v2.6-flash', + const config = { base_url: 'https://example.test/v1', model: 'gpt-6-luna', 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' }; + model_presets: ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'], env_path: '/app/.env' }; beforeEach(() => { TestBed.configureTestingModule({ providers: [provideHttpClient(), provideHttpClientTesting()] }); http = TestBed.inject(HttpTestingController); @@ -55,32 +55,23 @@ describe('持久化模型与功能设置', () => { expect(component.message()).toContain('保存'); }); - it('预设按钮填入名称,自定义模型与 max 可以保存并重新加载', async () => { + it('统一选择预设或自定义模型,自定义名称与 max 可以保存并重新加载', 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; - const presets = root.querySelectorAll('.model-presets button'); - expect(Array.from(presets, button => button.textContent?.trim())).toEqual([ - 'gpt-5.6-luna', 'gpt-6-luna', 'mimo-v2.6-flash', 'mimo-v2.6-pro' - ]); - presets[0].click(); + const select = root.querySelector('app-model-selector select')!; + expect(Array.from(select.options, option => option.value)).toEqual([...config.model_presets, '__custom__']); + expect(select.value).toBe('gpt-6-luna'); + select.value = 'mimo-v2.6-pro'; + select.dispatchEvent(new Event('change')); + expect(fixture.componentInstance.model).toBe('mimo-v2.6-pro'); + select.value = '__custom__'; + select.dispatchEvent(new Event('change')); fixture.detectChanges(); - await fixture.whenStable(); - const input = root.querySelector('#model-name')!; - expect(input.value).toBe('gpt-5.6-luna'); - expect(presets[0].getAttribute('aria-pressed')).toBe('true'); - presets[1].click(); - fixture.detectChanges(); - await fixture.whenStable(); - expect(input.value).toBe('gpt-6-luna'); - presets[3].click(); - fixture.detectChanges(); - await fixture.whenStable(); - expect(input.value).toBe('mimo-v2.6-pro'); - expect(input.getAttribute('list')).toBeNull(); + const input = root.querySelector('input[aria-label="模型名称(自定义)"]')!; expect(input.autocomplete).toBe('off'); input.value = 'custom/model-v2'; input.dispatchEvent(new Event('input')); @@ -89,7 +80,6 @@ describe('持久化模型与功能设置', () => { reasoning.dispatchEvent(new Event('input')); fixture.detectChanges(); await fixture.whenStable(); - expect(presets[0].getAttribute('aria-pressed')).toBe('false'); root.querySelector('form')!.dispatchEvent(new Event('submit', { cancelable: true })); const request = http.expectOne('/api/system/model-settings'); expect(request.request.body.model).toBe('custom/model-v2'); @@ -102,7 +92,7 @@ describe('持久化模型与功能设置', () => { http.expectOne('/api/system/model-settings').flush(saved); reopened.detectChanges(); await reopened.whenStable(); - expect(reopened.nativeElement.querySelector('#model-name').value).toBe('custom/model-v2'); + expect(reopened.nativeElement.querySelector('input[aria-label="模型名称(自定义)"]').value).toBe('custom/model-v2'); expect(reopened.nativeElement.querySelector('input[aria-label="思考强度"]').getAttribute('aria-valuetext')).toBe('最高 · max'); }); @@ -114,7 +104,12 @@ describe('持久化模型与功能设置', () => { await fixture.whenStable(); const root: HTMLElement = fixture.nativeElement; root.querySelector('.recovery-settings')!.open = true; - const fallback = root.querySelector('input[name="toolFallbackModel"]')!; + const fallbackSelect = root.querySelectorAll('app-model-selector select')[1]; + expect(Array.from(fallbackSelect.options, option => option.value)).toEqual([...config.model_presets, '__custom__']); + fallbackSelect.value = '__custom__'; + fallbackSelect.dispatchEvent(new Event('change')); + fixture.detectChanges(); + const fallback = root.querySelector('input[aria-label="兜底模型名称(自定义)"]')!; fallback.value = 'custom/fallback'; fallback.dispatchEvent(new Event('input')); const wait = root.querySelector('input[name="rateLimitRetry"]')!; @@ -135,7 +130,7 @@ describe('持久化模型与功能设置', () => { http.expectOne('/api/system/model-settings').flush(config); fixture.componentInstance.test(); const request = http.expectOne('/api/system/model-settings/test'); - expect(request.request.body.model).toBe('mimo-v2.6-flash'); + expect(request.request.body.model).toBe('gpt-6-luna'); request.flush({ message: 'Responses 连接测试成功' }); http.expectNone('/api/system/model-settings'); expect(fixture.componentInstance.message()).toContain('成功'); 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 608ada3..fb2b074 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 @@ -4,6 +4,8 @@ import { HttpClient } from '@angular/common/http'; import { finalize, timeout } from 'rxjs'; import { UI_READ_TIMEOUT_MS } from '../../core/http-timeouts'; import { SettingSliderComponent, REASONING_VALUES, REASONING_LABELS } from '../setting-slider/setting-slider.component'; +import { ModelSelectorComponent } from '../model-selector/model-selector.component'; +import { MODEL_PRESETS } from '../../core/model-presets'; interface ModelSettings { base_url: string; @@ -26,7 +28,7 @@ interface ModelSettings { @Component({ selector: 'app-model-settings', standalone: true, - imports: [FormsModule, SettingSliderComponent], + imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent], template: `

统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。

@@ -34,17 +36,8 @@ interface ModelSettings { 填写接口根地址,通常包含 /v1,无需添加 /responses。 - -
- @for (preset of modelPresets; track preset) { - - } -
- - 点击预设填入,或直接输入自定义模型名称;保存后可继续使用。 + + 选择预设模型,或选择“自定义模型”填写名称。 - +