tabby-ai-assistant 1.0.9 → 1.0.10
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/dist/components/settings/provider-config.component.d.ts +28 -1
- package/dist/index.js +1 -1
- package/dist/services/core/toast.service.d.ts +15 -0
- package/package.json +1 -1
- package/src/components/settings/provider-config.component.html +46 -12
- package/src/components/settings/provider-config.component.scss +59 -0
- package/src/components/settings/provider-config.component.ts +100 -9
- package/src/services/core/toast.service.ts +36 -0
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
export interface ToastMessage {
|
|
2
|
+
id: string;
|
|
3
|
+
type: 'success' | 'error' | 'warning' | 'info';
|
|
4
|
+
message: string;
|
|
5
|
+
duration?: number;
|
|
6
|
+
}
|
|
7
|
+
export declare class ToastService {
|
|
8
|
+
private toastSubject;
|
|
9
|
+
toast$: import("rxjs").Observable<ToastMessage>;
|
|
10
|
+
show(type: ToastMessage['type'], message: string, duration?: number): void;
|
|
11
|
+
success(message: string, duration?: number): void;
|
|
12
|
+
error(message: string, duration?: number): void;
|
|
13
|
+
warning(message: string, duration?: number): void;
|
|
14
|
+
info(message: string, duration?: number): void;
|
|
15
|
+
}
|
package/package.json
CHANGED
|
@@ -37,7 +37,7 @@
|
|
|
37
37
|
<div class="form-row">
|
|
38
38
|
<div class="form-group">
|
|
39
39
|
<label>{{ t.providers.displayName }}</label>
|
|
40
|
-
<input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName">
|
|
40
|
+
<input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName" *ngIf="configs[providerName]">
|
|
41
41
|
</div>
|
|
42
42
|
<div class="form-group">
|
|
43
43
|
<label>{{ t.providers.status }}</label>
|
|
@@ -57,11 +57,28 @@
|
|
|
57
57
|
{{ t.providers[field.key] || field.label }}
|
|
58
58
|
<span *ngIf="isRequired(field)" class="required">*</span>
|
|
59
59
|
</label>
|
|
60
|
-
<input *ngIf="getFieldType(field) === 'text' || getFieldType(field) === 'password'"
|
|
61
|
-
|
|
62
|
-
|
|
63
|
-
|
|
64
|
-
|
|
60
|
+
<div class="input-with-toggle" *ngIf="configs[providerName] && (getFieldType(field) === 'text' || getFieldType(field) === 'password')">
|
|
61
|
+
<input
|
|
62
|
+
[type]="isPasswordField(field) && !isPasswordVisible(providerName, field.key) ? 'password' : 'text'"
|
|
63
|
+
class="form-control"
|
|
64
|
+
[class]="getInputValidationClass(providerName, field.key)"
|
|
65
|
+
[placeholder]="field.placeholder || ''"
|
|
66
|
+
[(ngModel)]="configs[providerName][field.key]">
|
|
67
|
+
<button *ngIf="isPasswordField(field)"
|
|
68
|
+
type="button"
|
|
69
|
+
class="btn-toggle-visibility"
|
|
70
|
+
(click)="togglePasswordVisibility(providerName, field.key)">
|
|
71
|
+
<i class="fa" [ngClass]="isPasswordVisible(providerName, field.key) ? 'fa-eye-slash' : 'fa-eye'"></i>
|
|
72
|
+
</button>
|
|
73
|
+
</div>
|
|
74
|
+
<!-- API Key 验证提示 -->
|
|
75
|
+
<div *ngIf="field.key === 'apiKey' && configs[providerName]?.[field.key]"
|
|
76
|
+
class="validation-feedback"
|
|
77
|
+
[class.valid]="validateApiKeyFormat(providerName, configs[providerName][field.key]).valid"
|
|
78
|
+
[class.invalid]="!validateApiKeyFormat(providerName, configs[providerName][field.key]).valid">
|
|
79
|
+
<i class="fa" [ngClass]="validateApiKeyFormat(providerName, configs[providerName][field.key]).valid ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
|
|
80
|
+
{{ validateApiKeyFormat(providerName, configs[providerName][field.key]).message || '格式正确' }}
|
|
81
|
+
</div>
|
|
65
82
|
</div>
|
|
66
83
|
|
|
67
84
|
<div class="form-actions">
|
|
@@ -121,7 +138,7 @@
|
|
|
121
138
|
<div class="form-row">
|
|
122
139
|
<div class="form-group">
|
|
123
140
|
<label>{{ t.providers.displayName }}</label>
|
|
124
|
-
<input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName">
|
|
141
|
+
<input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName" *ngIf="configs[providerName]">
|
|
125
142
|
</div>
|
|
126
143
|
<div class="form-group">
|
|
127
144
|
<label>{{ t.providers.status }}</label>
|
|
@@ -141,11 +158,28 @@
|
|
|
141
158
|
{{ t.providers[field.key] || field.label }}
|
|
142
159
|
<span *ngIf="isRequired(field)" class="required">*</span>
|
|
143
160
|
</label>
|
|
144
|
-
<input *ngIf="getFieldType(field) === 'text' || getFieldType(field) === 'password'"
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
|
|
161
|
+
<div class="input-with-toggle" *ngIf="configs[providerName] && (getFieldType(field) === 'text' || getFieldType(field) === 'password')">
|
|
162
|
+
<input
|
|
163
|
+
[type]="isPasswordField(field) && !isPasswordVisible(providerName, field.key) ? 'password' : 'text'"
|
|
164
|
+
class="form-control"
|
|
165
|
+
[class]="getInputValidationClass(providerName, field.key)"
|
|
166
|
+
[placeholder]="field.placeholder || ''"
|
|
167
|
+
[(ngModel)]="configs[providerName][field.key]">
|
|
168
|
+
<button *ngIf="isPasswordField(field)"
|
|
169
|
+
type="button"
|
|
170
|
+
class="btn-toggle-visibility"
|
|
171
|
+
(click)="togglePasswordVisibility(providerName, field.key)">
|
|
172
|
+
<i class="fa" [ngClass]="isPasswordVisible(providerName, field.key) ? 'fa-eye-slash' : 'fa-eye'"></i>
|
|
173
|
+
</button>
|
|
174
|
+
</div>
|
|
175
|
+
<!-- API Key 验证提示 -->
|
|
176
|
+
<div *ngIf="field.key === 'apiKey' && configs[providerName]?.[field.key]"
|
|
177
|
+
class="validation-feedback"
|
|
178
|
+
[class.valid]="validateApiKeyFormat(providerName, configs[providerName][field.key]).valid"
|
|
179
|
+
[class.invalid]="!validateApiKeyFormat(providerName, configs[providerName][field.key]).valid">
|
|
180
|
+
<i class="fa" [ngClass]="validateApiKeyFormat(providerName, configs[providerName][field.key]).valid ? 'fa-check-circle' : 'fa-exclamation-circle'"></i>
|
|
181
|
+
{{ validateApiKeyFormat(providerName, configs[providerName][field.key]).message || '格式正确' }}
|
|
182
|
+
</div>
|
|
149
183
|
</div>
|
|
150
184
|
|
|
151
185
|
<div class="form-actions">
|
|
@@ -302,6 +302,65 @@
|
|
|
302
302
|
}
|
|
303
303
|
}
|
|
304
304
|
|
|
305
|
+
/* 输入框密码显示切换 */
|
|
306
|
+
.input-with-toggle {
|
|
307
|
+
position: relative;
|
|
308
|
+
display: flex;
|
|
309
|
+
align-items: center;
|
|
310
|
+
|
|
311
|
+
.form-control {
|
|
312
|
+
padding-right: 40px;
|
|
313
|
+
}
|
|
314
|
+
|
|
315
|
+
.btn-toggle-visibility {
|
|
316
|
+
position: absolute;
|
|
317
|
+
right: 8px;
|
|
318
|
+
background: transparent;
|
|
319
|
+
border: none;
|
|
320
|
+
color: var(--ai-text-secondary);
|
|
321
|
+
cursor: pointer;
|
|
322
|
+
padding: 4px 8px;
|
|
323
|
+
display: flex;
|
|
324
|
+
align-items: center;
|
|
325
|
+
justify-content: center;
|
|
326
|
+
|
|
327
|
+
&:hover {
|
|
328
|
+
color: var(--ai-text-primary);
|
|
329
|
+
}
|
|
330
|
+
}
|
|
331
|
+
}
|
|
332
|
+
|
|
333
|
+
/* 表单验证状态 */
|
|
334
|
+
.form-control {
|
|
335
|
+
&.is-valid {
|
|
336
|
+
border-color: var(--ai-success, #4caf50);
|
|
337
|
+
}
|
|
338
|
+
|
|
339
|
+
&.is-invalid {
|
|
340
|
+
border-color: var(--ai-warning, #ff9800);
|
|
341
|
+
}
|
|
342
|
+
}
|
|
343
|
+
|
|
344
|
+
.validation-feedback {
|
|
345
|
+
font-size: 0.85em;
|
|
346
|
+
margin-top: 4px;
|
|
347
|
+
display: flex;
|
|
348
|
+
align-items: center;
|
|
349
|
+
gap: 4px;
|
|
350
|
+
|
|
351
|
+
&.valid {
|
|
352
|
+
color: var(--ai-success, #4caf50);
|
|
353
|
+
}
|
|
354
|
+
|
|
355
|
+
&.invalid {
|
|
356
|
+
color: var(--ai-warning, #ff9800);
|
|
357
|
+
}
|
|
358
|
+
|
|
359
|
+
i {
|
|
360
|
+
font-size: 0.9em;
|
|
361
|
+
}
|
|
362
|
+
}
|
|
363
|
+
|
|
305
364
|
/* 响应式设计 */
|
|
306
365
|
@media (max-width: 768px) {
|
|
307
366
|
.providers-grid {
|
|
@@ -3,6 +3,7 @@ import { Subject } from 'rxjs';
|
|
|
3
3
|
import { takeUntil } from 'rxjs/operators';
|
|
4
4
|
import { ConfigProviderService } from '../../services/core/config-provider.service';
|
|
5
5
|
import { LoggerService } from '../../services/core/logger.service';
|
|
6
|
+
import { ToastService } from '../../services/core/toast.service';
|
|
6
7
|
import { TranslateService } from '../../i18n';
|
|
7
8
|
|
|
8
9
|
@Component({
|
|
@@ -23,10 +24,19 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
23
24
|
configs: { [key: string]: any } = {};
|
|
24
25
|
expandedProvider: string = '';
|
|
25
26
|
localStatus: { [key: string]: boolean } = {};
|
|
27
|
+
passwordVisibility: { [key: string]: { [fieldKey: string]: boolean } } = {};
|
|
26
28
|
|
|
27
29
|
// 翻译对象
|
|
28
30
|
t: any;
|
|
29
31
|
|
|
32
|
+
// API Key 格式校验规则
|
|
33
|
+
private apiKeyPatterns: { [key: string]: RegExp } = {
|
|
34
|
+
'openai': /^sk-[a-zA-Z0-9]{32,}$/,
|
|
35
|
+
'anthropic': /^sk-ant-[a-zA-Z0-9-]+$/,
|
|
36
|
+
'minimax': /^[a-zA-Z0-9]{32,}$/,
|
|
37
|
+
'glm': /^[a-zA-Z0-9._-]+$/
|
|
38
|
+
};
|
|
39
|
+
|
|
30
40
|
private destroy$ = new Subject<void>();
|
|
31
41
|
|
|
32
42
|
// 云端提供商模板
|
|
@@ -101,6 +111,7 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
101
111
|
constructor(
|
|
102
112
|
private config: ConfigProviderService,
|
|
103
113
|
private logger: LoggerService,
|
|
114
|
+
private toast: ToastService,
|
|
104
115
|
private translate: TranslateService
|
|
105
116
|
) {
|
|
106
117
|
this.t = this.translate.t;
|
|
@@ -129,6 +140,33 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
129
140
|
*/
|
|
130
141
|
private loadConfigs(): void {
|
|
131
142
|
const allConfigs = this.config.getAllProviderConfigs();
|
|
143
|
+
|
|
144
|
+
// 为所有云端供应商初始化默认配置
|
|
145
|
+
for (const providerName of Object.keys(this.cloudProviderTemplates)) {
|
|
146
|
+
if (!allConfigs[providerName]) {
|
|
147
|
+
const template = this.cloudProviderTemplates[providerName];
|
|
148
|
+
allConfigs[providerName] = {
|
|
149
|
+
name: providerName,
|
|
150
|
+
displayName: template.name,
|
|
151
|
+
enabled: false,
|
|
152
|
+
...this.createDefaultConfig(template.fields)
|
|
153
|
+
};
|
|
154
|
+
}
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
// 为所有本地供应商初始化默认配置
|
|
158
|
+
for (const providerName of Object.keys(this.localProviderTemplates)) {
|
|
159
|
+
if (!allConfigs[providerName]) {
|
|
160
|
+
const template = this.localProviderTemplates[providerName];
|
|
161
|
+
allConfigs[providerName] = {
|
|
162
|
+
name: providerName,
|
|
163
|
+
displayName: template.name,
|
|
164
|
+
enabled: false,
|
|
165
|
+
...this.createDefaultConfig(template.fields)
|
|
166
|
+
};
|
|
167
|
+
}
|
|
168
|
+
}
|
|
169
|
+
|
|
132
170
|
this.configs = allConfigs;
|
|
133
171
|
this.selectedProvider = this.config.getDefaultProvider();
|
|
134
172
|
}
|
|
@@ -187,7 +225,7 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
187
225
|
const baseURL = this.configs[providerName]?.baseURL || template?.defaultURL;
|
|
188
226
|
|
|
189
227
|
if (!baseURL) {
|
|
190
|
-
|
|
228
|
+
this.toast.error(this.t.providers.baseURL + ': ' + this.t.providers.testError);
|
|
191
229
|
return;
|
|
192
230
|
}
|
|
193
231
|
|
|
@@ -201,16 +239,16 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
201
239
|
});
|
|
202
240
|
|
|
203
241
|
if (response.ok) {
|
|
204
|
-
|
|
242
|
+
this.toast.success(`${template.name}: ${this.t.providers.testSuccess}`);
|
|
205
243
|
this.localStatus[providerName] = true;
|
|
206
244
|
this.logger.info('Local provider test successful', { provider: providerName });
|
|
207
245
|
} else {
|
|
208
|
-
|
|
246
|
+
this.toast.error(`${this.t.providers.testFail}: ${response.status}`);
|
|
209
247
|
this.localStatus[providerName] = false;
|
|
210
248
|
}
|
|
211
249
|
} catch (error) {
|
|
212
250
|
const errorMessage = error instanceof Error ? error.message : this.t.providers.testError;
|
|
213
|
-
|
|
251
|
+
this.toast.error(`${template.name}\n\n${this.t.providers.testError}\n${errorMessage}`);
|
|
214
252
|
this.localStatus[providerName] = false;
|
|
215
253
|
this.logger.error('Local provider test failed', { provider: providerName, error: errorMessage });
|
|
216
254
|
}
|
|
@@ -224,6 +262,7 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
224
262
|
if (providerConfig) {
|
|
225
263
|
this.config.setProviderConfig(providerName, providerConfig);
|
|
226
264
|
this.logger.info('Provider config saved', { provider: providerName });
|
|
265
|
+
this.toast.success(`${this.getProviderTemplate(providerName)?.name || providerName} ${this.t.providers.configSaved || '配置已保存'}`);
|
|
227
266
|
}
|
|
228
267
|
}
|
|
229
268
|
|
|
@@ -277,7 +316,7 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
277
316
|
async testConnection(providerName: string): Promise<void> {
|
|
278
317
|
const providerConfig = this.configs[providerName];
|
|
279
318
|
if (!providerConfig) {
|
|
280
|
-
|
|
319
|
+
this.toast.error(this.t.providers.testError);
|
|
281
320
|
return;
|
|
282
321
|
}
|
|
283
322
|
|
|
@@ -291,7 +330,7 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
291
330
|
const baseURL = providerConfig.baseURL;
|
|
292
331
|
|
|
293
332
|
if (!apiKey) {
|
|
294
|
-
|
|
333
|
+
this.toast.error(this.t.providers.apiKey + ': ' + this.t.providers.testError);
|
|
295
334
|
return;
|
|
296
335
|
}
|
|
297
336
|
|
|
@@ -314,16 +353,16 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
314
353
|
});
|
|
315
354
|
|
|
316
355
|
if (response.ok) {
|
|
317
|
-
|
|
356
|
+
this.toast.success(this.t.providers.testSuccess);
|
|
318
357
|
this.logger.info('Connection test successful', { provider: providerName });
|
|
319
358
|
} else {
|
|
320
359
|
const errorData = await response.text();
|
|
321
|
-
|
|
360
|
+
this.toast.error(`${this.t.providers.testFail}\n\nStatus: ${response.status}\n${errorData.substring(0, 200)}`);
|
|
322
361
|
this.logger.error('Connection test failed', { provider: providerName, status: response.status });
|
|
323
362
|
}
|
|
324
363
|
} catch (error) {
|
|
325
364
|
const errorMessage = error instanceof Error ? error.message : this.t.providers.testError;
|
|
326
|
-
|
|
365
|
+
this.toast.error(`${this.t.providers.testFail}\n\n${errorMessage}`);
|
|
327
366
|
this.logger.error('Connection test error', { provider: providerName, error: errorMessage });
|
|
328
367
|
}
|
|
329
368
|
}
|
|
@@ -473,4 +512,56 @@ export class ProviderConfigComponent implements OnInit, OnDestroy {
|
|
|
473
512
|
}
|
|
474
513
|
this.configs[providerName][key] = value;
|
|
475
514
|
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 切换密码字段可见性
|
|
518
|
+
*/
|
|
519
|
+
togglePasswordVisibility(providerName: string, fieldKey: string): void {
|
|
520
|
+
if (!this.passwordVisibility[providerName]) {
|
|
521
|
+
this.passwordVisibility[providerName] = {};
|
|
522
|
+
}
|
|
523
|
+
this.passwordVisibility[providerName][fieldKey] = !this.passwordVisibility[providerName][fieldKey];
|
|
524
|
+
}
|
|
525
|
+
|
|
526
|
+
/**
|
|
527
|
+
* 获取密码字段可见性状态
|
|
528
|
+
*/
|
|
529
|
+
isPasswordVisible(providerName: string, fieldKey: string): boolean {
|
|
530
|
+
return this.passwordVisibility[providerName]?.[fieldKey] ?? false;
|
|
531
|
+
}
|
|
532
|
+
|
|
533
|
+
/**
|
|
534
|
+
* 验证 API Key 格式
|
|
535
|
+
*/
|
|
536
|
+
validateApiKeyFormat(providerName: string, apiKey: string): { valid: boolean; message: string } {
|
|
537
|
+
if (!apiKey || apiKey.trim().length === 0) {
|
|
538
|
+
return { valid: false, message: this.t?.providers?.apiKeyRequired || 'API Key 不能为空' };
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const pattern = this.apiKeyPatterns[providerName];
|
|
542
|
+
if (pattern && !pattern.test(apiKey)) {
|
|
543
|
+
const hints: { [key: string]: string } = {
|
|
544
|
+
'openai': 'OpenAI API Key 应以 sk- 开头',
|
|
545
|
+
'anthropic': 'Anthropic API Key 应以 sk-ant- 开头',
|
|
546
|
+
'minimax': 'Minimax API Key 应为 32 位以上的字母数字',
|
|
547
|
+
'glm': 'GLM API Key 格式不正确'
|
|
548
|
+
};
|
|
549
|
+
return { valid: false, message: hints[providerName] || 'API Key 格式可能不正确' };
|
|
550
|
+
}
|
|
551
|
+
|
|
552
|
+
return { valid: true, message: '' };
|
|
553
|
+
}
|
|
554
|
+
|
|
555
|
+
/**
|
|
556
|
+
* 获取输入框的验证状态类
|
|
557
|
+
*/
|
|
558
|
+
getInputValidationClass(providerName: string, fieldKey: string): string {
|
|
559
|
+
if (fieldKey !== 'apiKey') return '';
|
|
560
|
+
|
|
561
|
+
const value = this.configs[providerName]?.[fieldKey];
|
|
562
|
+
if (!value || value.trim().length === 0) return '';
|
|
563
|
+
|
|
564
|
+
const result = this.validateApiKeyFormat(providerName, value);
|
|
565
|
+
return result.valid ? 'is-valid' : 'is-invalid';
|
|
566
|
+
}
|
|
476
567
|
}
|
|
@@ -0,0 +1,36 @@
|
|
|
1
|
+
import { Injectable } from '@angular/core';
|
|
2
|
+
import { Subject } from 'rxjs';
|
|
3
|
+
|
|
4
|
+
export interface ToastMessage {
|
|
5
|
+
id: string;
|
|
6
|
+
type: 'success' | 'error' | 'warning' | 'info';
|
|
7
|
+
message: string;
|
|
8
|
+
duration?: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
@Injectable({ providedIn: 'root' })
|
|
12
|
+
export class ToastService {
|
|
13
|
+
private toastSubject = new Subject<ToastMessage>();
|
|
14
|
+
toast$ = this.toastSubject.asObservable();
|
|
15
|
+
|
|
16
|
+
show(type: ToastMessage['type'], message: string, duration = 3000): void {
|
|
17
|
+
const id = `toast-${Date.now()}`;
|
|
18
|
+
this.toastSubject.next({ id, type, message, duration });
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
success(message: string, duration = 3000): void {
|
|
22
|
+
this.show('success', message, duration);
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
error(message: string, duration = 5000): void {
|
|
26
|
+
this.show('error', message, duration);
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
warning(message: string, duration = 4000): void {
|
|
30
|
+
this.show('warning', message, duration);
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
info(message: string, duration = 3000): void {
|
|
34
|
+
this.show('info', message, duration);
|
|
35
|
+
}
|
|
36
|
+
}
|