173 lines
12 KiB
TypeScript
173 lines
12 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 {
|
|
transport?: 'http' | 'websocket';
|
|
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>
|
|
<label for="model-transport">传输协议</label>
|
|
<select id="model-transport" name="transport" [(ngModel)]="transport" [disabled]="busy() || loading()">
|
|
<option value="http">HTTP(兼容模式)</option>
|
|
<option value="websocket">WebSocket(增量传输)</option>
|
|
</select>
|
|
<small>WebSocket 可减少重复历史与截图的上传,需要接口支持。保存后对新任务生效。</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 {
|
|
transport: 'http' | 'websocket' = 'websocket';
|
|
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(), transport: this.transport, 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.transport = config.transport ?? 'websocket';
|
|
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 : '连接测试失败,请检查配置。')
|
|
});
|
|
}
|
|
}
|