tabby-ai-assistant 1.0.8 → 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.
Files changed (54) hide show
  1. package/dist/components/chat/ai-sidebar.component.d.ts +16 -3
  2. package/dist/components/chat/chat-input.component.d.ts +4 -0
  3. package/dist/components/chat/chat-interface.component.d.ts +22 -1
  4. package/dist/components/chat/chat-settings.component.d.ts +21 -11
  5. package/dist/components/settings/ai-settings-tab.component.d.ts +14 -4
  6. package/dist/components/settings/general-settings.component.d.ts +43 -12
  7. package/dist/components/settings/provider-config.component.d.ts +110 -5
  8. package/dist/components/settings/security-settings.component.d.ts +14 -4
  9. package/dist/i18n/index.d.ts +48 -0
  10. package/dist/i18n/translations/en-US.d.ts +5 -0
  11. package/dist/i18n/translations/ja-JP.d.ts +5 -0
  12. package/dist/i18n/translations/zh-CN.d.ts +5 -0
  13. package/dist/i18n/types.d.ts +198 -0
  14. package/dist/index.js +1 -1
  15. package/dist/services/chat/ai-sidebar.service.d.ts +23 -1
  16. package/dist/services/core/theme.service.d.ts +53 -0
  17. package/dist/services/core/toast.service.d.ts +15 -0
  18. package/package.json +1 -1
  19. package/src/components/chat/ai-sidebar.component.scss +468 -0
  20. package/src/components/chat/ai-sidebar.component.ts +47 -344
  21. package/src/components/chat/chat-input.component.scss +2 -2
  22. package/src/components/chat/chat-input.component.ts +16 -5
  23. package/src/components/chat/chat-interface.component.html +11 -11
  24. package/src/components/chat/chat-interface.component.scss +410 -4
  25. package/src/components/chat/chat-interface.component.ts +105 -14
  26. package/src/components/chat/chat-message.component.scss +3 -3
  27. package/src/components/chat/chat-message.component.ts +3 -2
  28. package/src/components/chat/chat-settings.component.html +95 -61
  29. package/src/components/chat/chat-settings.component.scss +224 -50
  30. package/src/components/chat/chat-settings.component.ts +56 -30
  31. package/src/components/security/risk-confirm-dialog.component.scss +7 -7
  32. package/src/components/settings/ai-settings-tab.component.html +27 -27
  33. package/src/components/settings/ai-settings-tab.component.scss +34 -20
  34. package/src/components/settings/ai-settings-tab.component.ts +59 -20
  35. package/src/components/settings/general-settings.component.html +69 -40
  36. package/src/components/settings/general-settings.component.scss +151 -58
  37. package/src/components/settings/general-settings.component.ts +168 -55
  38. package/src/components/settings/provider-config.component.html +183 -60
  39. package/src/components/settings/provider-config.component.scss +332 -153
  40. package/src/components/settings/provider-config.component.ts +268 -19
  41. package/src/components/settings/security-settings.component.html +70 -39
  42. package/src/components/settings/security-settings.component.scss +104 -8
  43. package/src/components/settings/security-settings.component.ts +48 -10
  44. package/src/i18n/index.ts +129 -0
  45. package/src/i18n/translations/en-US.ts +193 -0
  46. package/src/i18n/translations/ja-JP.ts +193 -0
  47. package/src/i18n/translations/zh-CN.ts +193 -0
  48. package/src/i18n/types.ts +224 -0
  49. package/src/index.ts +6 -0
  50. package/src/services/chat/ai-sidebar.service.ts +157 -5
  51. package/src/services/core/theme.service.ts +480 -0
  52. package/src/services/core/toast.service.ts +36 -0
  53. package/src/styles/ai-assistant.scss +8 -88
  54. package/src/styles/themes.scss +161 -0
@@ -1,15 +1,20 @@
1
- import { Component, Output, EventEmitter, OnInit } from '@angular/core';
1
+ import { Component, Output, EventEmitter, OnInit, OnDestroy, ViewEncapsulation } from '@angular/core';
2
+ import { Subject } from 'rxjs';
3
+ import { takeUntil } from 'rxjs/operators';
2
4
  import { AiAssistantService } from '../../services/core/ai-assistant.service';
3
5
  import { ConfigProviderService } from '../../services/core/config-provider.service';
4
6
  import { LoggerService } from '../../services/core/logger.service';
7
+ import { ThemeService, ThemeType } from '../../services/core/theme.service';
5
8
  import { ConfigService } from 'tabby-core';
9
+ import { TranslateService, SupportedLanguage } from '../../i18n';
6
10
 
7
11
  @Component({
8
12
  selector: 'app-general-settings',
9
13
  templateUrl: './general-settings.component.html',
10
- styleUrls: ['./general-settings.component.scss']
14
+ styleUrls: ['./general-settings.component.scss'],
15
+ encapsulation: ViewEncapsulation.None
11
16
  })
12
- export class GeneralSettingsComponent implements OnInit {
17
+ export class GeneralSettingsComponent implements OnInit, OnDestroy {
13
18
  @Output() providerChanged = new EventEmitter<string>();
14
19
 
15
20
  availableProviders: any[] = [];
@@ -18,16 +23,25 @@ export class GeneralSettingsComponent implements OnInit {
18
23
  language: string = 'zh-CN';
19
24
  theme: string = 'auto';
20
25
 
26
+ // 翻译对象
27
+ t: any;
28
+
29
+ // 本地供应商状态缓存
30
+ private localProviderStatus: { [key: string]: { text: string; color: string; icon: string; time: number } } = {};
31
+ private readonly statusCacheDuration = 30000; // 30秒缓存
32
+ private destroy$ = new Subject<void>();
33
+
21
34
  languages = [
22
- { value: 'zh-CN', label: '简体中文' },
23
- { value: 'en-US', label: 'English' },
24
- { value: 'ja-JP', label: '日本語' }
35
+ { value: 'zh-CN', label: '简体中文', flag: '🇨🇳' },
36
+ { value: 'en-US', label: 'English', flag: '🇺🇸' }
25
37
  ];
26
38
 
27
39
  themes = [
28
40
  { value: 'auto', label: '跟随系统' },
29
41
  { value: 'light', label: '浅色主题' },
30
- { value: 'dark', label: '深色主题' }
42
+ { value: 'dark', label: '深色主题' },
43
+ { value: 'pixel', label: '像素复古' },
44
+ { value: 'tech', label: '赛博科技' }
31
45
  ];
32
46
 
33
47
  // 提供商模板,用于显示名称
@@ -35,23 +49,57 @@ export class GeneralSettingsComponent implements OnInit {
35
49
  'openai': 'OpenAI',
36
50
  'anthropic': 'Anthropic Claude',
37
51
  'minimax': 'Minimax',
38
- 'glm': 'GLM (ChatGLM)'
52
+ 'glm': 'GLM (ChatGLM)',
53
+ 'openai-compatible': 'OpenAI Compatible',
54
+ 'ollama': 'Ollama (本地)',
55
+ 'vllm': 'vLLM (本地)'
39
56
  };
40
57
 
41
58
  constructor(
42
59
  private aiService: AiAssistantService,
43
60
  private config: ConfigProviderService,
44
61
  private tabbyConfig: ConfigService,
45
- private logger: LoggerService
46
- ) { }
62
+ private logger: LoggerService,
63
+ private translate: TranslateService,
64
+ private themeService: ThemeService
65
+ ) {
66
+ this.t = this.translate.t;
67
+ }
47
68
 
48
69
  ngOnInit(): void {
70
+ // 监听语言变化
71
+ this.translate.translation$.pipe(
72
+ takeUntil(this.destroy$)
73
+ ).subscribe(translation => {
74
+ this.t = translation;
75
+ // 更新主题翻译
76
+ this.updateThemeLabels();
77
+ });
78
+
49
79
  this.loadSettings();
50
80
  this.loadProviders();
51
81
  // 应用当前主题
52
82
  this.applyTheme(this.theme);
53
83
  }
54
84
 
85
+ ngOnDestroy(): void {
86
+ this.destroy$.next();
87
+ this.destroy$.complete();
88
+ }
89
+
90
+ /**
91
+ * 更新主题标签翻译
92
+ */
93
+ private updateThemeLabels(): void {
94
+ this.themes = [
95
+ { value: 'auto', label: this.t.general.themeAuto },
96
+ { value: 'light', label: this.t.general.themeLight },
97
+ { value: 'dark', label: this.t.general.themeDark },
98
+ { value: 'pixel', label: this.t.general.themePixel || '像素复古' },
99
+ { value: 'tech', label: this.t.general.themeTech || '赛博科技' }
100
+ ];
101
+ }
102
+
55
103
  /**
56
104
  * 加载设置
57
105
  */
@@ -63,22 +111,124 @@ export class GeneralSettingsComponent implements OnInit {
63
111
  }
64
112
 
65
113
  /**
66
- * 加载可用提供商 - 从配置服务读取已配置的提供商
114
+ * 加载可用提供商 - 支持云端和本地供应商
67
115
  */
68
116
  private loadProviders(): void {
69
117
  const allConfigs = this.config.getAllProviderConfigs();
118
+
119
+ // 本地供应商列表(不需要 API Key)
120
+ const localProviders = ['ollama', 'vllm'];
70
121
  this.availableProviders = Object.keys(allConfigs)
71
- .filter(key => allConfigs[key] && allConfigs[key].apiKey)
122
+ .filter(key => {
123
+ const config = allConfigs[key];
124
+ if (!config) return false;
125
+
126
+ // 本地供应商:只需要有配置即可
127
+ if (localProviders.includes(key)) {
128
+ return config.enabled !== false;
129
+ }
130
+
131
+ // 云端供应商:需要 API Key
132
+ return !!config.apiKey;
133
+ })
72
134
  .map(key => ({
73
135
  name: key,
74
136
  displayName: allConfigs[key].displayName || this.providerNames[key] || key,
75
- description: `已配置的 ${this.providerNames[key] || key} 提供商`,
76
- enabled: allConfigs[key].enabled !== false
137
+ description: this.getProviderDescription(key),
138
+ enabled: allConfigs[key].enabled !== false,
139
+ isLocal: localProviders.includes(key)
77
140
  }));
78
141
 
79
142
  this.logger.info('Loaded providers from config', { count: this.availableProviders.length });
80
143
  }
81
144
 
145
+ /**
146
+ * 获取供应商描述
147
+ */
148
+ private getProviderDescription(key: string): string {
149
+ const descriptions: { [key: string]: string } = {
150
+ 'openai': '云端 OpenAI GPT 系列模型',
151
+ 'anthropic': '云端 Anthropic Claude 系列模型',
152
+ 'minimax': '云端 Minimax 大模型',
153
+ 'glm': '云端 智谱 ChatGLM 模型',
154
+ 'openai-compatible': '兼容 OpenAI API 的第三方服务',
155
+ 'ollama': '本地运行的 Ollama 服务 (端口 11434)',
156
+ 'vllm': '本地运行的 vLLM 服务 (端口 8000)'
157
+ };
158
+ return descriptions[key] || `${this.providerNames[key] || key} 提供商`;
159
+ }
160
+
161
+ /**
162
+ * 获取云端提供商状态(同步返回)
163
+ */
164
+ getProviderStatus(providerName: string): { text: string; color: string; icon: string } {
165
+ const providerConfig = this.config.getProviderConfig(providerName);
166
+ if (providerConfig && providerConfig.apiKey) {
167
+ return {
168
+ text: providerConfig.enabled !== false ? '已启用' : '已禁用',
169
+ color: providerConfig.enabled !== false ? '#4caf50' : '#ff9800',
170
+ icon: providerConfig.enabled !== false ? 'fa-check-circle' : 'fa-pause-circle'
171
+ };
172
+ }
173
+ return { text: '未配置', color: '#9e9e9e', icon: 'fa-question-circle' };
174
+ }
175
+
176
+ /**
177
+ * 检测本地供应商状态(异步)
178
+ */
179
+ private async checkLocalProviderStatus(providerName: string): Promise<boolean> {
180
+ const urls: { [key: string]: string } = {
181
+ 'ollama': 'http://localhost:11434/v1/models',
182
+ 'vllm': 'http://localhost:8000/v1/models'
183
+ };
184
+
185
+ try {
186
+ const controller = new AbortController();
187
+ const timeoutId = setTimeout(() => controller.abort(), 2000);
188
+
189
+ const response = await fetch(urls[providerName], {
190
+ method: 'GET',
191
+ signal: controller.signal
192
+ });
193
+
194
+ clearTimeout(timeoutId);
195
+ return response.ok;
196
+ } catch {
197
+ return false;
198
+ }
199
+ }
200
+
201
+ /**
202
+ * 获取本地供应商状态(同步返回,异步更新缓存)
203
+ */
204
+ getLocalProviderStatus(providerName: string): { text: string; color: string; icon: string } {
205
+ const now = Date.now();
206
+ const cached = this.localProviderStatus[providerName];
207
+
208
+ // 检查缓存是否有效(30秒内)
209
+ if (cached && (now - cached.time) < this.statusCacheDuration) {
210
+ return { text: cached.text, color: cached.color, icon: cached.icon };
211
+ }
212
+
213
+ // 返回默认状态并异步更新
214
+ const defaultStatus = { text: '检测中...', color: '#ff9800', icon: 'fa-spinner fa-spin' };
215
+ this.localProviderStatus[providerName] = { ...defaultStatus, time: now };
216
+
217
+ // 异步检查实际状态
218
+ this.checkLocalProviderStatus(providerName).then(isOnline => {
219
+ const status = isOnline
220
+ ? { text: '在线', color: '#4caf50', icon: 'fa-check-circle', time: now }
221
+ : { text: '离线', color: '#f44336', icon: 'fa-times-circle', time: now };
222
+ this.localProviderStatus[providerName] = status;
223
+ this.logger.debug('Local provider status updated', { provider: providerName, isOnline });
224
+ }).catch(() => {
225
+ const status = { text: '离线', color: '#f44336', icon: 'fa-times-circle', time: now };
226
+ this.localProviderStatus[providerName] = status;
227
+ });
228
+
229
+ return defaultStatus;
230
+ }
231
+
82
232
  /**
83
233
  * 更新默认提供商
84
234
  */
@@ -103,7 +253,7 @@ export class GeneralSettingsComponent implements OnInit {
103
253
  */
104
254
  updateLanguage(language: string): void {
105
255
  this.language = language;
106
- this.config.set('language', language);
256
+ this.translate.setLanguage(language as SupportedLanguage);
107
257
  this.logger.info('Language updated', { language });
108
258
  }
109
259
 
@@ -113,51 +263,14 @@ export class GeneralSettingsComponent implements OnInit {
113
263
  updateTheme(theme: string): void {
114
264
  this.theme = theme;
115
265
  this.config.set('theme', theme);
116
- this.applyTheme(theme);
266
+ this.themeService.applyTheme(theme as ThemeType);
117
267
  this.logger.info('Theme updated', { theme });
118
268
  }
119
269
 
120
270
  /**
121
- * 应用主题 - 同步 Tabby 主题或手动设置
271
+ * 应用主题 - 使用 ThemeService
122
272
  */
123
273
  private applyTheme(theme: string): void {
124
- const root = document.documentElement;
125
-
126
- // 移除现有主题类
127
- root.classList.remove('ai-theme-light', 'ai-theme-dark');
128
- document.body.classList.remove('ai-theme-light', 'ai-theme-dark');
129
-
130
- if (theme === 'auto') {
131
- // 跟随 Tabby 主题
132
- const tabbyTheme = this.tabbyConfig.store?.appearance?.theme || 'dark';
133
- const isDark = tabbyTheme.toLowerCase().includes('dark');
134
- const themeClass = isDark ? 'ai-theme-dark' : 'ai-theme-light';
135
- root.classList.add(themeClass);
136
- document.body.classList.add(themeClass);
137
- } else if (theme === 'light') {
138
- root.classList.add('ai-theme-light');
139
- document.body.classList.add('ai-theme-light');
140
- } else {
141
- root.classList.add('ai-theme-dark');
142
- document.body.classList.add('ai-theme-dark');
143
- }
144
- }
145
-
146
- /**
147
- * 获取提供商状态
148
- */
149
- getProviderStatus(providerName: string): { text: string; color: string } {
150
- const providerConfig = this.config.getProviderConfig(providerName);
151
- if (providerConfig && providerConfig.apiKey) {
152
- return {
153
- text: providerConfig.enabled !== false ? '已启用' : '已禁用',
154
- color: providerConfig.enabled !== false ? '#4caf50' : '#ff9800'
155
- };
156
- }
157
- return {
158
- text: '未配置',
159
- color: '#9e9e9e'
160
- };
274
+ this.themeService.applyTheme(theme as ThemeType);
161
275
  }
162
276
  }
163
-
@@ -1,75 +1,198 @@
1
1
  <div class="provider-config">
2
- <h3>AI提供商配置</h3>
2
+ <h3>{{ t.providers.title }}</h3>
3
3
 
4
- <!-- 可用提供商列表 -->
5
- <div class="providers-list">
6
- <div *ngFor="let providerName of Object.keys(providerTemplates)" class="provider-item"
7
- [class.configured]="hasConfig(providerName)">
8
- <div class="provider-info">
9
- <h4>{{ providerTemplates[providerName].name }}</h4>
10
- <p>{{ providerTemplates[providerName].description }}</p>
11
- </div>
12
- <div class="provider-actions">
13
- <button *ngIf="!hasConfig(providerName)" class="btn btn-primary" (click)="addProvider(providerName)">
14
- <i class="fa fa-plus"></i>
15
- 添加
16
- </button>
17
- <button *ngIf="hasConfig(providerName)" class="btn btn-secondary"
18
- (click)="testConnection(providerName)">
19
- <i class="fa fa-plug"></i>
20
- 测试
21
- </button>
22
- <button *ngIf="hasConfig(providerName)" class="btn btn-danger" (click)="removeProvider(providerName)">
23
- <i class="fa fa-trash"></i>
24
- 删除
25
- </button>
26
- </div>
4
+ <!-- 云端提供商分组 -->
5
+ <div class="provider-section">
6
+ <div class="section-header">
7
+ <i class="fa fa-cloud"></i>
8
+ <h4>{{ t.providers.cloudProviders }}</h4>
9
+ <span class="section-desc">{{ t.providers.cloudProvidersDesc }}</span>
10
+ </div>
11
+
12
+ <div class="providers-grid">
13
+ <div *ngFor="let providerName of Object.keys(cloudProviderTemplates)"
14
+ class="provider-card"
15
+ [class.configured]="hasConfig(providerName)"
16
+ [class.expanded]="expandedProvider === providerName">
27
17
 
28
- <!-- 配置表单 -->
29
- <div *ngIf="hasConfig(providerName)" class="config-form">
30
- <div class="form-row">
31
- <div class="form-group">
32
- <label>显示名称</label>
33
- <input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName">
18
+ <!-- 卡片头部 -->
19
+ <div class="card-header" (click)="toggleExpand(providerName)">
20
+ <div class="provider-icon" [class.local]="isLocalProvider(providerName)">
21
+ <i class="fa" [ngClass]="getProviderIcon(providerName)"></i>
34
22
  </div>
35
- <div class="form-group">
36
- <label>状态</label>
37
- <div class="toggle-switch">
38
- <input type="checkbox" [id]="'enabled-' + providerName"
39
- [checked]="configs[providerName].enabled"
40
- (change)="toggleProviderEnabled(providerName)">
41
- <label [for]="'enabled-' + providerName">
42
- {{ configs[providerName].enabled ? '已启用' : '已禁用' }}
23
+ <div class="provider-info">
24
+ <h5>{{ t.providerNames[providerName] || cloudProviderTemplates[providerName].name }}</h5>
25
+ <span class="status-badge" [class.active]="configs[providerName]?.enabled">
26
+ {{ hasConfig(providerName) ? (configs[providerName]?.enabled ? t.common.enabled : t.common.disabled) : t.common.notConfigured }}
27
+ </span>
28
+ </div>
29
+ <div class="expand-icon">
30
+ <i class="fa" [ngClass]="expandedProvider === providerName ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
31
+ </div>
32
+ </div>
33
+
34
+ <!-- 卡片内容(展开时显示) -->
35
+ <div class="card-body" *ngIf="expandedProvider === providerName">
36
+ <div class="config-form">
37
+ <div class="form-row">
38
+ <div class="form-group">
39
+ <label>{{ t.providers.displayName }}</label>
40
+ <input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName" *ngIf="configs[providerName]">
41
+ </div>
42
+ <div class="form-group">
43
+ <label>{{ t.providers.status }}</label>
44
+ <div class="toggle-switch">
45
+ <input type="checkbox" [id]="'enabled-' + providerName"
46
+ [checked]="configs[providerName]?.enabled"
47
+ (change)="toggleProviderEnabled(providerName)">
48
+ <label [for]="'enabled-' + providerName">
49
+ {{ configs[providerName]?.enabled ? t.common.enabled : t.common.disabled }}
50
+ </label>
51
+ </div>
52
+ </div>
53
+ </div>
54
+
55
+ <div *ngFor="let field of cloudProviderTemplates[providerName].fields" class="form-group">
56
+ <label>
57
+ {{ t.providers[field.key] || field.label }}
58
+ <span *ngIf="isRequired(field)" class="required">*</span>
43
59
  </label>
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>
82
+ </div>
83
+
84
+ <div class="form-actions">
85
+ <button class="btn btn-primary" (click)="saveConfig(providerName)">
86
+ <i class="fa fa-save"></i> {{ t.providers.saveConfig }}
87
+ </button>
88
+ <button class="btn btn-secondary" (click)="testConnection(providerName)">
89
+ <i class="fa fa-plug"></i> {{ t.providers.testConnection }}
90
+ </button>
44
91
  </div>
45
92
  </div>
46
93
  </div>
94
+ </div>
95
+ </div>
96
+ </div>
47
97
 
48
- <div *ngFor="let field of providerTemplates[providerName].fields" class="form-group">
49
- <label>
50
- {{ field.label }}
51
- <span *ngIf="isRequired(field)" class="required">*</span>
52
- </label>
53
- <!-- 文本和密码输入框 -->
54
- <input *ngIf="getFieldType(field) === 'text' || getFieldType(field) === 'password'"
55
- [type]="isPasswordField(field) ? 'password' : 'text'" class="form-control"
56
- [placeholder]="field.placeholder || ''" [(ngModel)]="configs[providerName][field.key]">
57
- <!-- 下拉选择框 -->
58
- <select *ngIf="getFieldType(field) === 'select'" class="form-control"
59
- [(ngModel)]="configs[providerName][field.key]">
60
- <option *ngFor="let option of getFieldOptions(field)" [value]="option">
61
- {{ option }}
62
- </option>
63
- </select>
98
+ <!-- 本地提供商分组 -->
99
+ <div class="provider-section local">
100
+ <div class="section-header">
101
+ <i class="fa fa-server"></i>
102
+ <h4>{{ t.providers.localProviders }}</h4>
103
+ <span class="section-desc">{{ t.providers.localProvidersDesc }}</span>
104
+ </div>
105
+
106
+ <div class="providers-grid">
107
+ <div *ngFor="let providerName of Object.keys(localProviderTemplates)"
108
+ class="provider-card"
109
+ [class.configured]="hasConfig(providerName)"
110
+ [class.expanded]="expandedProvider === providerName">
111
+
112
+ <!-- 卡片头部 -->
113
+ <div class="card-header" (click)="toggleExpand(providerName)">
114
+ <div class="provider-icon local">
115
+ <i class="fa" [ngClass]="getProviderIcon(providerName)"></i>
116
+ </div>
117
+ <div class="provider-info">
118
+ <h5>{{ t.providerNames[providerName] || localProviderTemplates[providerName].name }}</h5>
119
+ <div class="status-row">
120
+ <span class="status-badge" [class.active]="configs[providerName]?.enabled">
121
+ {{ hasConfig(providerName) ? (configs[providerName]?.enabled ? t.common.enabled : t.common.disabled) : t.common.notConfigured }}
122
+ </span>
123
+ <!-- 在线状态指示器 -->
124
+ <span class="online-status" [style.color]="getLocalStatus(providerName).color">
125
+ <i class="fa" [ngClass]="getLocalStatus(providerName).icon"></i>
126
+ {{ getLocalStatus(providerName).text }}
127
+ </span>
128
+ </div>
129
+ </div>
130
+ <div class="expand-icon">
131
+ <i class="fa" [ngClass]="expandedProvider === providerName ? 'fa-chevron-up' : 'fa-chevron-down'"></i>
132
+ </div>
64
133
  </div>
65
134
 
66
- <div class="form-actions">
67
- <button class="btn btn-primary" (click)="saveConfig(providerName)">
68
- <i class="fa fa-save"></i>
69
- 保存配置
70
- </button>
135
+ <!-- 卡片内容(展开时显示) -->
136
+ <div class="card-body" *ngIf="expandedProvider === providerName">
137
+ <div class="config-form">
138
+ <div class="form-row">
139
+ <div class="form-group">
140
+ <label>{{ t.providers.displayName }}</label>
141
+ <input type="text" class="form-control" [(ngModel)]="configs[providerName].displayName" *ngIf="configs[providerName]">
142
+ </div>
143
+ <div class="form-group">
144
+ <label>{{ t.providers.status }}</label>
145
+ <div class="toggle-switch">
146
+ <input type="checkbox" [id]="'enabled-' + providerName"
147
+ [checked]="configs[providerName]?.enabled"
148
+ (change)="toggleProviderEnabled(providerName)">
149
+ <label [for]="'enabled-' + providerName">
150
+ {{ configs[providerName]?.enabled ? t.common.enabled : t.common.disabled }}
151
+ </label>
152
+ </div>
153
+ </div>
154
+ </div>
155
+
156
+ <div *ngFor="let field of localProviderTemplates[providerName].fields" class="form-group">
157
+ <label>
158
+ {{ t.providers[field.key] || field.label }}
159
+ <span *ngIf="isRequired(field)" class="required">*</span>
160
+ </label>
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>
183
+ </div>
184
+
185
+ <div class="form-actions">
186
+ <button class="btn btn-primary" (click)="saveConfig(providerName)">
187
+ <i class="fa fa-save"></i> {{ t.providers.saveConfig }}
188
+ </button>
189
+ <button class="btn btn-info" (click)="testConnection(providerName)">
190
+ <i class="fa fa-wifi"></i> {{ t.providers.detectService }}
191
+ </button>
192
+ </div>
193
+ </div>
71
194
  </div>
72
195
  </div>
73
196
  </div>
74
197
  </div>
75
- </div>
198
+ </div>