Add dark theme, richer Markdown and readable plugin results. Improve Android input evidence, response integrity and diagnostics. Unify lifecycle, guidance receipts and evidence sources across clients. Keep direct action dispatch without an added AI quota approval layer. Align version metadata at 2.2.0 and bundle Helper 1.2.2. Validation: 3063 backend tests before the version bump; 44 after. Frontend: 317 tests and production build passed.
164 lines
11 KiB
TypeScript
164 lines
11 KiB
TypeScript
import { Component, OnInit, inject, signal, output } from '@angular/core';
|
|
import { FormsModule } from '@angular/forms';
|
|
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;
|
|
model: string;
|
|
api_key_configured: boolean;
|
|
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;
|
|
message?: string;
|
|
}
|
|
|
|
@Component({
|
|
selector: 'app-model-settings',
|
|
standalone: true,
|
|
imports: [FormsModule, SettingSliderComponent, ModelSelectorComponent],
|
|
template: `
|
|
<form class="model-settings" (ngSubmit)="save()">
|
|
<p>统一使用 OpenAI Responses。保存后新任务立即生效,重启后自动加载。</p>
|
|
<label for="model-base-url">接口地址</label>
|
|
<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>
|
|
<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"
|
|
placeholder="输入新密钥" [disabled]="busy() || loading()" />
|
|
<div class="number-fields">
|
|
<label>请求超时(秒)<input name="timeout" type="number" min="1" max="3600" required
|
|
[(ngModel)]="timeout" [disabled]="busy() || loading()" /></label>
|
|
<label>失败后重试次数<input name="retries" type="number" min="0" max="10" required
|
|
[(ngModel)]="retries" [disabled]="busy() || loading()" /></label>
|
|
<label>重试间隔(秒)<input name="retryInterval" type="number" min="0" max="300" required [(ngModel)]="retryInterval" [disabled]="busy() || loading()" /></label>
|
|
</div>
|
|
<details class="recovery-settings">
|
|
<summary>稳定性与兜底模型</summary>
|
|
<div class="recovery-content">
|
|
<label>429 等待重试(秒)<input name="rateLimitRetry" type="number" min="0" max="3600" required
|
|
[(ngModel)]="rateLimitRetry" [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
|
|
[(ngModel)]="toolFallbackRetries" [disabled]="busy() || loading()" /></label>
|
|
<label>兜底无效调用上限<input name="toolFallbackNoProgress" type="number" min="1" max="10" required
|
|
[(ngModel)]="toolFallbackNoProgress" [disabled]="busy() || loading()" /></label>
|
|
</div>
|
|
<small>首选模型连续 3 次未形成有效工具调用后,本步骤使用兜底模型;下一步恢复首选模型。</small>
|
|
</div>
|
|
</details>
|
|
<div class="actions">
|
|
<button type="submit" [disabled]="busy() || loading() || !valid()">{{ busy() === 'save' ? '正在保存…' : '保存配置' }}</button>
|
|
<button type="button" (click)="test()" [disabled]="busy() || loading() || !valid()">
|
|
{{ busy() === 'test' ? '正在测试…' : '测试连接' }}
|
|
</button>
|
|
</div>
|
|
@if (loading()) { <p role="status">正在加载配置…</p> }
|
|
@if (message()) { <p role="status" class="success">{{ message() }}</p> }
|
|
@if (error()) { <p role="alert" class="error">{{ error() }}</p> }
|
|
@if (envPath()) { <small>保存位置:{{ envPath() }}</small> }
|
|
</form>
|
|
`,
|
|
styles: [`
|
|
.model-settings { display: grid; gap: 10px; color: var(--app-text-control, #1e293b); }
|
|
p, small { margin: 0; line-height: 1.6; color: var(--app-text-muted, #64748b); }
|
|
label { font-weight: 600; } label span { font-weight: 400; font-size: 12px; }
|
|
input, select { box-sizing: border-box; width: 100%; padding: 12px; border-radius: 10px; border: 1px solid rgb(var(--app-border-control-rgb, 100 116 139) / 0.4000);
|
|
background: var(--app-surface-soft, #f8fafc); color: inherit; font: inherit; }
|
|
input:focus, select:focus { outline: 2px solid #38bdf8; }
|
|
.number-fields { display: grid; grid-template-columns: 1fr 1fr; gap: 16px; margin-top: 8px; }
|
|
.number-fields input { margin-top: 8px; }
|
|
.recovery-settings { border: 1px solid var(--app-border-control, #cbd5e1); border-radius: 10px; background: var(--app-surface-soft, #f8fafc); padding: 10px 14px; }
|
|
.recovery-settings summary { cursor: pointer; font-weight: 600; color: var(--app-text-control, #334155); }
|
|
.recovery-content { display: grid; gap: 10px; padding-top: 12px; }
|
|
.recovery-content label input, .recovery-content label select { margin-top: 8px; }
|
|
.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: var(--app-success-text, #047857); } .error { color: var(--app-error-text, #be123c); }
|
|
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:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);padding:11px 12px}
|
|
input:focus,select:focus {outline:2px solid var(--app-primary-border, #a5b4fc);outline-offset:2px}
|
|
.actions {border-top:1px solid var(--app-border, #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:var(--app-surface, #fff);border-color:var(--app-border, #dbe3ec);color:var(--app-text-secondary, #475569)}
|
|
.recovery-settings {border-color:var(--app-border, #e2e8f0);border-radius:14px;padding:14px 16px}.recovery-settings summary {font-size:13px}
|
|
`]
|
|
})
|
|
export class ModelSettingsComponent implements OnInit {
|
|
readonly reasoningValues = REASONING_VALUES;
|
|
readonly reasoningLabels = REASONING_LABELS;
|
|
private http = inject(HttpClient);
|
|
saved = output<void>();
|
|
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;
|
|
busy = signal<'save' | 'test' | null>(null);
|
|
loading = signal(true); keyConfigured = signal(false); envPath = signal('');
|
|
message = signal(''); error = signal('');
|
|
|
|
ngOnInit(): void {
|
|
this.http.get<ModelSettings>('/api/system/model-settings').pipe(timeout(UI_READ_TIMEOUT_MS), finalize(() => this.loading.set(false))).subscribe({
|
|
next: config => this.apply(config),
|
|
error: () => this.error.set('配置加载失败,请刷新页面重试。')
|
|
});
|
|
}
|
|
valid(): boolean {
|
|
return !!this.baseUrl.trim() && !!this.model.trim() && (!!this.apiKey.trim() || this.keyConfigured())
|
|
&& this.timeout > 0 && this.timeout <= 3600 && Number.isInteger(this.retries) && this.retries >= 0 && this.retries <= 10 && this.retryInterval >= 0 && this.retryInterval <= 300
|
|
&& this.rateLimitRetry >= 0 && this.rateLimitRetry <= 3600 && !!this.toolFallbackModel.trim()
|
|
&& Number.isInteger(this.toolFallbackRetries) && this.toolFallbackRetries >= 0 && this.toolFallbackRetries <= 10
|
|
&& Number.isInteger(this.toolFallbackNoProgress) && this.toolFallbackNoProgress >= 1 && this.toolFallbackNoProgress <= 10;
|
|
}
|
|
private payload() {
|
|
return { base_url: this.baseUrl.trim(), model: this.model.trim(), api_key: this.apiKey.trim() || undefined,
|
|
timeout_seconds: this.timeout, max_retries: this.retries, retry_interval_seconds: this.retryInterval, reasoning_effort: this.reasoning,
|
|
rate_limit_retry_seconds: this.rateLimitRetry, tool_fallback_model: this.toolFallbackModel.trim(),
|
|
tool_fallback_reasoning_effort: this.toolFallbackReasoning, tool_fallback_max_retries: this.toolFallbackRetries,
|
|
tool_fallback_no_progress_attempts: this.toolFallbackNoProgress };
|
|
}
|
|
private apply(config: ModelSettings): void {
|
|
this.baseUrl = config.base_url; this.model = config.model; this.timeout = config.timeout_seconds;
|
|
this.modelPresets = config.model_presets;
|
|
this.retries = config.max_retries; this.retryInterval = config.retry_interval_seconds ?? 10; this.reasoning = config.reasoning_effort ?? 'high'; this.keyConfigured.set(config.api_key_configured); this.envPath.set(config.env_path);
|
|
this.rateLimitRetry = config.rate_limit_retry_seconds ?? 60;
|
|
this.toolFallbackModel = config.tool_fallback_model ?? 'gpt-6-luna';
|
|
this.toolFallbackReasoning = config.tool_fallback_reasoning_effort ?? 'high';
|
|
this.toolFallbackRetries = config.tool_fallback_max_retries ?? 3;
|
|
this.toolFallbackNoProgress = config.tool_fallback_no_progress_attempts ?? 3;
|
|
}
|
|
save(): void {
|
|
if (this.busy() || !this.valid()) return;
|
|
this.busy.set('save'); this.message.set(''); this.error.set('');
|
|
this.http.post<ModelSettings>('/api/system/model-settings', this.payload()).pipe(finalize(() => this.busy.set(null))).subscribe({
|
|
next: config => { this.apply(config); this.apiKey = ''; this.message.set(config.message || '配置已保存。'); this.saved.emit(); },
|
|
error: err => this.error.set(typeof err.error?.detail === 'string' ? err.error.detail : '保存失败,请检查配置内容。')
|
|
});
|
|
}
|
|
test(): void {
|
|
if (this.busy() || !this.valid()) return;
|
|
this.busy.set('test'); this.message.set(''); this.error.set('');
|
|
this.http.post<{ message: string }>('/api/system/model-settings/test', this.payload()).pipe(finalize(() => this.busy.set(null))).subscribe({
|
|
next: result => this.message.set(result.message),
|
|
error: err => this.error.set(typeof err.error?.detail === 'string' ? err.error.detail : '连接测试失败,请检查配置。')
|
|
});
|
|
}
|
|
}
|