Files
Artemis-Eapil/apps/showcase_ui/src/app/components/model-settings/model-settings.component.ts
T

182 lines
12 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
import { Component, OnInit, inject, signal, output } from '@angular/core';
import { FormsModule } from '@angular/forms';
import { HttpClient } from '@angular/common/http';
import { finalize } from 'rxjs';
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],
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-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>
<label for="model-reasoning">思考强度</label>
<select id="model-reasoning" name="reasoning" [(ngModel)]="reasoning" [disabled]="busy() || loading()">
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
<option value="medium">中(medium)</option><option value="high">高(high)</option>
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
</select>
<small>接口需支持所选强度;不支持时会明确报错,可手动选择“由模型默认”。</small>
<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>
<label>兜底模型名称<input name="toolFallbackModel" type="text" required autocomplete="off"
autocapitalize="off" spellcheck="false" [(ngModel)]="toolFallbackModel"
[disabled]="busy() || loading()" /></label>
<label>兜底思考强度<select name="toolFallbackReasoning" [(ngModel)]="toolFallbackReasoning"
[disabled]="busy() || loading()">
<option value="default">由模型默认</option><option value="none">关闭(none)</option>
<option value="minimal">最低(minimal)</option><option value="low">低(low)</option>
<option value="medium">中(medium)</option><option value="high">高(high)</option>
<option value="xhigh">极高(xhigh)</option><option value="max">最高(max)</option>
</select></label>
<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: #1e293b; }
p, small { margin: 0; line-height: 1.6; color: #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 #64748b66;
background: #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 #cbd5e1; border-radius: 10px; background: #f8fafc; padding: 10px 14px; }
.recovery-settings summary { cursor: pointer; font-weight: 600; color: #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: #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; } }
`]
})
export class ModelSettingsComponent implements OnInit {
private http = inject(HttpClient);
saved = output<void>();
modelPresets: string[] = [];
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(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 : '连接测试失败,请检查配置。')
});
}
}