feat: unify model selection and complete favorite settings

This commit is contained in:
chenmingxuan committed 2026-10-18 20:03:41 +08:00
1 parent a4a5430922
commit 50638d4130
24 files changed
+347 -90

No files matched your search

+1 -1
View File
@@ -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("")
@@ -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 {
+63 -5
View File
@@ -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("收藏名称不能为空")
@@ -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()),
+1 -1
View File
@@ -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,
@@ -8,25 +8,25 @@
@else if(!filtered().length){<p class="empty">没有匹配的精选任务。</p>}
<div class="cards">@for(item of filtered(); track item.id){
<article class="card" [class.expanded]="expanded() === item.id">
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span></div>
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3>{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
@if(item.case_version){<p class="hint">固定用例 v{{item.case_version}}</p>}
@if(item.inherited_fields.length){<p class="hint inherited" title="部分历史配置未记录,收藏时已采用当前默认值:{{item.inherited_fields.join('、')}}">{{item.inherited_fields[0].startsWith('首次') ? item.inherited_fields[0] : '旧任务部分配置缺失,已采用收藏时的默认值'}}</p>}
<div class="card-overview">
<div class="card-heading"><span class="mode" [class.pro]="options(item)['profile'] === 'pro'" [class.explore]="item.kind === 'exploration'">{{mode(item)}}</span>@if(item.case_version){<span class="case-version">用例 v{{item.case_version}}</span>}</div>
<div class="card-title"><button class="star" type="button" (click)="remove(item)" [disabled]="!!busy()" aria-label="取消收藏" title="取消收藏"><span class="material-symbols-outlined" aria-hidden="true">star</span></button><h3 [title]="item.name">{{item.name}}</h3></div><p class="model-summary">{{summary(item)}}</p>
<div class="card-actions"><button type="button" class="configure" (click)="toggle(item)" [attr.aria-expanded]="expanded() === item.id"><span class="material-symbols-outlined" aria-hidden="true">tune</span>配置<span class="material-symbols-outlined" aria-hidden="true">{{expanded() === item.id ? 'expand_less' : 'expand_more'}}</span></button><button type="button" class="primary" (click)="run(item)" [disabled]="!!busy()"><span class="material-symbols-outlined" aria-hidden="true">play_arrow</span>{{busy() === item.id ? '处理中…' : item.kind === 'exploration' ? '重新探索' : '运行'}}</button></div>
</div>
@if(expanded() === item.id){@let d = draft(item);
<div class="configuration"><label>收藏名称<input [(ngModel)]="d.name" maxlength="200" aria-label="收藏名称"></label>
@if(item.kind === 'batch'){
<label class="checkbox"><input type="checkbox" [(ngModel)]="d.uniform">本次统一模型和思考强度</label>
@if(d.uniform){<label>整批模型<input [(ngModel)]="d.model" list="favorite-models" placeholder="输入模型名称"></label><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
@if(d.uniform){<app-model-selector label="整批模型" [(value)]="d.model" /><app-setting-slider label="整批思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />}
@for(c of item.snapshot.cases; track $index; let i = $index){<details><summary>{{i + 1}}. {{c.name}}</summary>
<label>执行模式<select [(ngModel)]="d.cases[i].profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>
<label>模型<input [(ngModel)]="d.cases[i].model" list="favorite-models" [disabled]="d.uniform"></label>
<app-model-selector [label]="'用例 ' + (i + 1) + ' 模型'" [(value)]="d.cases[i].model" [disabled]="d.uniform" />
<app-setting-slider label="思考强度" [(value)]="d.cases[i].reasoning_effort" [values]="efforts" [labels]="effortLabels" [disabled]="d.uniform" />
@if(!computer(c.task_config)){<label>执行设备<select [(ngModel)]="d.cases[i].device_serial"><option value="">请选择在线设备</option>@if(d.cases[i].device_serial){<option [value]="d.cases[i].device_serial">原选择:{{d.cases[i].device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
</details>}
}@else{
@if(item.kind !== 'exploration'){<label>执行模式<select [(ngModel)]="d.profile"><option value="flash">Flash</option><option value="pro">Pro</option></select></label>}
<label>模型<input [(ngModel)]="d.model" list="favorite-models" aria-label="复跑模型"></label>
<app-model-selector label="复跑模型" [(value)]="d.model" />
<app-setting-slider label="思考强度" [(value)]="d.reasoning_effort" [values]="efforts" [labels]="effortLabels" />
@if(!computer(options(item))){<label>执行设备<select [(ngModel)]="d.device_serial" aria-label="复跑设备"><option value="">请选择在线设备</option>@if(d.device_serial){<option [value]="d.device_serial">原选择:{{d.device_serial}}</option>}@for(device of system.connectedDevices(); track device.serial){<option [value]="device.serial">{{device.serial}}</option>}</select></label>}
}
@@ -35,5 +35,4 @@
}
</article>
}</div>
<datalist id="favorite-models">@for(model of models; track model){<option [value]="model"></option>}</datalist>
</section>
@@ -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; }
}
@@ -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();
@@ -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<string | null>(null); error = signal(''); notice = signal('');
drafts = new Map<string, Draft>();
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();
@@ -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();
});
});
@@ -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: `
<label [for]="id">{{label()}}</label>
<div class="selector">
<select [id]="id" [value]="selection()" [disabled]="disabled()"
(change)="choose($any($event.target).value)">
@for (preset of presets(); track preset) { <option [value]="preset">{{preset}}</option> }
<option value="__custom__">自定义模型…</option>
</select>
<span class="material-symbols-outlined" aria-hidden="true">expand_more</span>
</div>
@if (selection() === '__custom__') {
<input type="text" [value]="value()" (input)="valueChange.emit($any($event.target).value)"
[attr.aria-label]="label() + '(自定义)'" [disabled]="disabled()" required maxlength="200"
autocomplete="off" autocapitalize="off" spellcheck="false" placeholder="输入接口支持的模型名称" />
}
`,
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<string>();
presets = input<string[]>(MODEL_PRESETS);
disabled = input(false);
valueChange = output<string>();
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);
}
}
@@ -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<HTMLButtonElement>('.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<HTMLSelectElement>('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<HTMLInputElement>('#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<HTMLInputElement>('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<HTMLFormElement>('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<HTMLDetailsElement>('.recovery-settings')!.open = true;
const fallback = root.querySelector<HTMLInputElement>('input[name="toolFallbackModel"]')!;
const fallbackSelect = root.querySelectorAll<HTMLSelectElement>('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<HTMLInputElement>('input[aria-label="兜底模型名称(自定义)"]')!;
fallback.value = 'custom/fallback';
fallback.dispatchEvent(new Event('input'));
const wait = root.querySelector<HTMLInputElement>('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('成功');
@@ -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: `
<form class="model-settings" (ngSubmit)="save()">
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
@@ -34,17 +36,8 @@ interface ModelSettings {
<input id="model-base-url" name="baseUrl" type="url" [(ngModel)]="baseUrl" required
placeholder="https://api-n-cd.playones.com/v1" [disabled]="busy() || loading()" />
<small>填写接口根地址,通常包含 /v1,无需添加 /responses。</small>
<label for="model-name">模型名称</label>
<div class="model-presets" role="group" aria-label="预设模型">
@for (preset of modelPresets; track preset) {
<button type="button" [attr.aria-pressed]="model === preset" (click)="model = preset"
[disabled]="busy() || loading()">{{ preset }}</button>
}
</div>
<input id="model-name" name="llmModelName" type="text" autocomplete="off" autocapitalize="off" spellcheck="false"
[(ngModel)]="model" required placeholder="输入接口支持的模型名" aria-describedby="model-name-help"
[disabled]="busy() || loading()" />
<small id="model-name-help">点击预设填入,或直接输入自定义模型名称;保存后可继续使用。</small>
<app-model-selector label="模型名称" [(value)]="model" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<small>选择预设模型,或选择“自定义模型”填写名称。</small>
<app-setting-slider label="思考强度" [(value)]="reasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" hint="更高的强度适合复杂推理;需接口支持,也可选择模型默认。" />
<label for="model-key">API Key <span>{{ keyConfigured() ? '(已配置,留空保留)' : '(尚未配置)' }}</span></label>
<input id="model-key" name="apiKey" type="password" [(ngModel)]="apiKey" autocomplete="new-password"
@@ -61,9 +54,7 @@ interface ModelSettings {
<div class="recovery-content">
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
[(ngModel)]="rateLimitRetry" [disabled]="busy() || loading()" /></label>
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
[disabled]="busy() || loading()" /></label>
<app-model-selector label="兜底模型名称" [(value)]="toolFallbackModel" [presets]="modelPresets" [disabled]="!!busy() || loading()" />
<app-setting-slider label="兜底思考强度" [(value)]="toolFallbackReasoning" [values]="reasoningValues" [labels]="reasoningLabels" [disabled]="!!busy() || loading()" />
<div class="number-fields">
<label>兜底请求重试次数<input name="toolFallbackRetries" type="number" min="0" max="10" required
@@ -102,16 +93,11 @@ interface ModelSettings {
.actions { display: flex; gap: 12px; margin-top: 8px; }
button { padding: 11px 20px; border-radius: 10px; border: 1px solid #38bdf877; background: #0284c7; color: white; cursor: pointer; font: inherit; }
button:disabled { opacity: .5; cursor: default; } .success { color: #047857; } .error { color: #be123c; }
.model-presets { display: flex; flex-wrap: wrap; gap: 8px; }
.model-presets button { padding: 8px 12px; background: #f8fafc; color: #475569; border-color: #cbd5e1; font-size: 14px; }
.model-presets button:hover:not(:disabled) { background: #e0f2fe; border-color: #38bdf8; }
.model-presets button[aria-pressed="true"] { background: #e0f2fe; border-color: #0284c7; color: #075985; }
button:focus-visible { outline: 2px solid #0284c7; outline-offset: 2px; }
small { overflow-wrap: anywhere; } @media(max-width: 480px) { .number-fields { grid-template-columns: 1fr; } }
.model-settings {gap:12px;font-size:13px} label {margin-top:6px}input,select {background:#fff;border-color:#dbe3ec;padding:11px 12px}
input:focus,select:focus {outline:2px solid #a5b4fc;outline-offset:2px}
.actions {border-top:1px solid #e2e8f0;padding-top:16px;align-items:center}.actions button {min-height:40px;background:#4f46e5;border-color:#4f46e5;font-weight:600;border-radius:12px}.actions button[type=button] {background:#fff;border-color:#dbe3ec;color:#475569}
.model-presets button {border-radius:20px;font-size:12px}.model-presets button[aria-pressed=true] {background:#eef2ff;border-color:#a5b4fc;color:#4338ca}
.recovery-settings {border-color:#e2e8f0;border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
`]
})
@@ -120,7 +106,7 @@ export class ModelSettingsComponent implements OnInit {
readonly reasoningLabels = REASONING_LABELS;
private http = inject(HttpClient);
saved = output<void>();
modelPresets: string[] = [];
modelPresets: string[] = MODEL_PRESETS;
baseUrl = ''; model = ''; apiKey = ''; timeout = 300; retries = 5; retryInterval = 10; reasoning = 'high';
rateLimitRetry = 60; toolFallbackModel = 'gpt-6-luna'; toolFallbackReasoning = 'high';
toolFallbackRetries = 3; toolFallbackNoProgress = 3;
@@ -0,0 +1 @@
export const MODEL_PRESETS = ['gpt-6-luna', 'gpt-6.1-sol', 'gpt-6-astra', 'mimo-v2.6-pro'];