aicodeswitch 1.4.1 → 1.5.0

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.
@@ -0,0 +1,552 @@
1
+ import { useState, useEffect } from 'react';
2
+ import { api } from '../api/client';
3
+ import type { Route, Rule, APIService, ContentType, Vendor } from '../../types';
4
+
5
+ const CONTENT_TYPE_OPTIONS = [
6
+ { value: 'default', label: '默认' },
7
+ { value: 'background', label: '后台' },
8
+ { value: 'thinking', label: '思考' },
9
+ { value: 'long-context', label: '长上下文' },
10
+ { value: 'image-understanding', label: '图像理解' },
11
+ { value: 'model-mapping', label: '模型顶替' },
12
+ ];
13
+
14
+ const TARGET_TYPE_OPTIONS = [
15
+ { value: 'claude-code', label: 'Claude Code' },
16
+ { value: 'codex', label: 'Codex' },
17
+ ];
18
+
19
+ export default function RoutesPage() {
20
+ const [routes, setRoutes] = useState<Route[]>([]);
21
+ const [rules, setRules] = useState<Rule[]>([]);
22
+ const [vendors, setVendors] = useState<Vendor[]>([]);
23
+ const [allServices, setAllServices] = useState<APIService[]>([]);
24
+ const [services, setServices] = useState<APIService[]>([]);
25
+ const [selectedRoute, setSelectedRoute] = useState<Route | null>(null);
26
+ const [showRouteModal, setShowRouteModal] = useState(false);
27
+ const [showRuleModal, setShowRuleModal] = useState(false);
28
+ const [editingRoute, setEditingRoute] = useState<Route | null>(null);
29
+ const [editingRule, setEditingRule] = useState<Rule | null>(null);
30
+ const [selectedVendor, setSelectedVendor] = useState<string>('');
31
+ const [selectedService, setSelectedService] = useState<string>('');
32
+ const [selectedModel, setSelectedModel] = useState<string>('');
33
+ const [selectedReplacedModel, setSelectedReplacedModel] = useState<string>('');
34
+ const [selectedSortOrder, setSelectedSortOrder] = useState<number>(0);
35
+ const [selectedContentType, setSelectedContentType] = useState<string>(editingRule?.contentType || '');
36
+ const [hoveredRuleId, setHoveredRuleId] = useState<string | null>(null);
37
+
38
+ useEffect(() => {
39
+ loadRoutes();
40
+ loadVendors();
41
+ loadAllServices();
42
+ }, []);
43
+
44
+ useEffect(() => {
45
+ if (selectedRoute) {
46
+ loadRules(selectedRoute.id);
47
+ }
48
+ }, [selectedRoute]);
49
+
50
+ useEffect(() => {
51
+ if (selectedVendor) {
52
+ setServices(allServices.filter(service => service.vendorId === selectedVendor));
53
+ } else {
54
+ setServices([]);
55
+ }
56
+ setSelectedService('');
57
+ setSelectedModel('');
58
+ }, [selectedVendor, allServices]);
59
+
60
+ const loadRoutes = async () => {
61
+ const data = await api.getRoutes();
62
+ setRoutes(data);
63
+ if (data.length > 0 && !selectedRoute) {
64
+ setSelectedRoute(data[0]);
65
+ }
66
+ };
67
+
68
+ const loadRules = async (routeId: string) => {
69
+ const data = await api.getRules(routeId);
70
+ setRules(data);
71
+ };
72
+
73
+ const loadVendors = async () => {
74
+ const data = await api.getVendors();
75
+ setVendors(data);
76
+ };
77
+
78
+ const loadAllServices = async () => {
79
+ const data = await api.getAPIServices();
80
+ setAllServices(data);
81
+ };
82
+
83
+ const handleActivateRoute = async (id: string) => {
84
+ await api.activateRoute(id);
85
+ loadRoutes();
86
+ };
87
+
88
+ const handleDeactivateRoute = async (id: string) => {
89
+ await api.deactivateRoute(id);
90
+ loadRoutes();
91
+ };
92
+
93
+ const handleSaveRoute = async (e: React.FormEvent<HTMLFormElement>) => {
94
+ e.preventDefault();
95
+ const formData = new FormData(e.currentTarget);
96
+ const route = {
97
+ name: formData.get('name') as string,
98
+ description: formData.get('description') as string,
99
+ targetType: formData.get('targetType') as 'claude-code' | 'codex',
100
+ isActive: false,
101
+ };
102
+
103
+ if (editingRoute) {
104
+ await api.updateRoute(editingRoute.id, route);
105
+ } else {
106
+ await api.createRoute(route);
107
+ }
108
+
109
+ setShowRouteModal(false);
110
+ loadRoutes();
111
+ };
112
+
113
+ const handleDeleteRoute = async (id: string) => {
114
+ if (confirm('确定要删除此路由吗')) {
115
+ await api.deleteRoute(id);
116
+ loadRoutes();
117
+ if (selectedRoute && selectedRoute.id === id) {
118
+ setSelectedRoute(null);
119
+ setRules([]);
120
+ }
121
+ }
122
+ };
123
+
124
+ const handleSaveRule = async (e: React.FormEvent<HTMLFormElement>) => {
125
+ e.preventDefault();
126
+ const formData = new FormData(e.currentTarget);
127
+ const rule = {
128
+ routeId: selectedRoute!.id,
129
+ contentType: formData.get('contentType') as ContentType,
130
+ targetServiceId: selectedService,
131
+ targetModel: selectedModel || undefined,
132
+ replacedModel: selectedReplacedModel || undefined,
133
+ sortOrder: selectedSortOrder,
134
+ };
135
+
136
+ if (editingRule) {
137
+ await api.updateRule(editingRule.id, rule);
138
+ } else {
139
+ await api.createRule(rule);
140
+ }
141
+
142
+ setShowRuleModal(false);
143
+ if (selectedRoute) {
144
+ loadRules(selectedRoute.id);
145
+ }
146
+ };
147
+
148
+ const handleDeleteRule = async (id: string) => {
149
+ if (confirm('确定要删除此路由吗')) {
150
+ await api.deleteRule(id);
151
+ if (selectedRoute) {
152
+ loadRules(selectedRoute.id);
153
+ }
154
+ }
155
+ };
156
+
157
+ const getAvailableContentTypes = () => {
158
+ // 取消对象请求类型的互斥限制,允许添加多个相同类型的规则
159
+ // 通过 sort_order 字段区分优先级
160
+ return CONTENT_TYPE_OPTIONS;
161
+ };
162
+
163
+ const handleEditRule = (rule: Rule) => {
164
+ setEditingRule(rule);
165
+ setSelectedContentType(rule.contentType);
166
+ const service = allServices.find(s => s.id === rule.targetServiceId);
167
+ if (service) {
168
+ setSelectedVendor(service.vendorId);
169
+ // 直接设置当前供应商的服务列表,避免 useEffect 的异步延迟
170
+ setServices(allServices.filter(s => s.vendorId === service.vendorId));
171
+ // 使用 setTimeout 确保状态更新完成后再设置 selectedService 和 selectedModel
172
+ setTimeout(() => {
173
+ setSelectedService(service.id);
174
+ setSelectedModel(rule.targetModel || '');
175
+ setSelectedReplacedModel(rule.replacedModel || '');
176
+ setSelectedSortOrder(rule.sortOrder || 0);
177
+ }, 0);
178
+ }
179
+ setShowRuleModal(true);
180
+ };
181
+
182
+ const handleNewRule = () => {
183
+ setEditingRule(null);
184
+ setSelectedContentType('');
185
+ setSelectedVendor('');
186
+ setSelectedService('');
187
+ setSelectedModel('');
188
+ setSelectedReplacedModel('');
189
+ setSelectedSortOrder(0);
190
+ setShowRuleModal(true);
191
+ };
192
+
193
+ return (
194
+ <div>
195
+ <div className="page-header">
196
+ <h1>路由管理</h1>
197
+ <p>管理API路由和路由配置</p>
198
+ </div>
199
+
200
+ <div style={{ display: 'flex', gap: '20px' }}>
201
+ <div className="card" style={{ flex: '0 0 33%' }}>
202
+ <div className="toolbar">
203
+ <h3>路由</h3>
204
+ <button className="btn btn-primary" onClick={() => setShowRouteModal(true)}>新建</button>
205
+ </div>
206
+ {routes.length === 0 ? (
207
+ <div className="empty-state"><p>暂无路由</p></div>
208
+ ) : (
209
+ <div style={{ marginTop: '10px' }}>
210
+ {routes.map((route) => (
211
+ <div
212
+ key={route.id}
213
+ onClick={() => setSelectedRoute(route)}
214
+ style={{
215
+ padding: '12px',
216
+ marginBottom: '8px',
217
+ backgroundColor: selectedRoute && selectedRoute.id === route.id
218
+ ? 'var(--bg-route-item-selected)'
219
+ : 'var(--bg-route-item)',
220
+ borderRadius: '8px',
221
+ cursor: 'pointer',
222
+ border: '1px solid var(--border-primary)',
223
+ transition: 'all 0.2s ease',
224
+ }}
225
+ onMouseEnter={(e) => {
226
+ if (selectedRoute?.id !== route.id) {
227
+ e.currentTarget.style.backgroundColor = 'var(--bg-route-item-hover)';
228
+ }
229
+ }}
230
+ onMouseLeave={(e) => {
231
+ if (selectedRoute?.id !== route.id) {
232
+ e.currentTarget.style.backgroundColor = 'var(--bg-route-item)';
233
+ }
234
+ }}
235
+ >
236
+ <div>
237
+ <div style={{ display: 'flex', justifyContent: 'space-between', alignItems: 'center' }}>
238
+ <div style={{ fontWeight: 500 }}>{route.name}</div>
239
+ {route.isActive && <span className="badge badge-success">{TARGET_TYPE_OPTIONS.find(opt => opt.value === route.targetType)?.label} 已激活</span>}
240
+ </div>
241
+ <div style={{ fontSize: '12px', color: 'var(--text-route-muted)', marginTop: '2px' }}>
242
+ 路由对象: {TARGET_TYPE_OPTIONS.find(opt => opt.value === route.targetType)?.label}
243
+ </div>
244
+ <div className="action-buttons" style={{ marginTop: '8px' }}>
245
+ {!route.isActive ? (
246
+ <button
247
+ className="btn btn-success"
248
+ style={{ padding: '4px 8px', fontSize: '12px' }}
249
+ onClick={(e) => {
250
+ e.stopPropagation();
251
+ handleActivateRoute(route.id);
252
+ }}
253
+ >激活</button>
254
+ ) : (
255
+ <button
256
+ className="btn btn-warning"
257
+ style={{ padding: '4px 8px', fontSize: '12px' }}
258
+ onClick={(e) => {
259
+ e.stopPropagation();
260
+ handleDeactivateRoute(route.id);
261
+ }}
262
+ >停用</button>
263
+ )}
264
+ <button
265
+ className="btn btn-secondary"
266
+ style={{ padding: '4px 8px', fontSize: '12px' }}
267
+ onClick={(e) => {
268
+ e.stopPropagation();
269
+ setEditingRoute(route);
270
+ setShowRouteModal(true);
271
+ }}
272
+ >编辑</button>
273
+ <button
274
+ className="btn btn-danger"
275
+ style={{ padding: '4px 8px', fontSize: '12px' }}
276
+ onClick={(e) => {
277
+ e.stopPropagation();
278
+ handleDeleteRoute(route.id);
279
+ }}
280
+ disabled={route.isActive}
281
+ >删除</button>
282
+ </div>
283
+ </div>
284
+ </div>
285
+ ))}
286
+ </div>
287
+ )}
288
+ </div>
289
+
290
+ <div className="card" style={{ flex: 1 }}>
291
+ <div className="toolbar">
292
+ <h3>规则列表</h3>
293
+ {selectedRoute && (
294
+ <button className="btn btn-primary" onClick={handleNewRule}>新建规则</button>
295
+ )}
296
+ </div>
297
+ {!selectedRoute ? (
298
+ <div className="empty-state"><p>请先选择一个路由</p></div>
299
+ ) : rules.length === 0 ? (
300
+ <div className="empty-state"><p>暂无路由</p></div>
301
+ ) : (
302
+ <table>
303
+ <thead>
304
+ <tr>
305
+ <th>排序</th>
306
+ <th>请求类型</th>
307
+ <th>供应商</th>
308
+ <th>API服务</th>
309
+ <th>模型</th>
310
+ <th>操作</th>
311
+ </tr>
312
+ </thead>
313
+ <tbody>
314
+ {rules.map((rule) => {
315
+ const service = allServices.find(s => s.id === rule.targetServiceId);
316
+ const vendor = vendors.find(v => v.id === service?.vendorId);
317
+ const contentTypeLabel = CONTENT_TYPE_OPTIONS.find(opt => opt.value === rule.contentType)?.label;
318
+ return (
319
+ <tr key={rule.id}>
320
+ <td>{rule.sortOrder || 0}</td>
321
+ <td>
322
+ <div style={{ display: 'flex', alignItems: 'center', gap: '6px' }}>
323
+ <span>{contentTypeLabel}</span>
324
+ {rule.contentType === 'model-mapping' && rule.replacedModel && (
325
+ <div
326
+ style={{ position: 'relative', display: 'inline-block' }}
327
+ onMouseEnter={() => setHoveredRuleId(rule.id)}
328
+ onMouseLeave={() => setHoveredRuleId(null)}
329
+ >
330
+ <span
331
+ style={{
332
+ cursor: 'help',
333
+ fontSize: '14px',
334
+ color: 'var(--text-info)',
335
+ fontWeight: 'bold',
336
+ }}
337
+ >
338
+
339
+ </span>
340
+ {hoveredRuleId === rule.id && (
341
+ <div
342
+ style={{
343
+ position: 'absolute',
344
+ left: '50%',
345
+ transform: 'translateX(-50%)',
346
+ bottom: 'calc(100% + 8px)',
347
+ backgroundColor: 'var(--bg-popover, #333)',
348
+ color: 'var(--text-popover, #fff)',
349
+ padding: '6px 10px',
350
+ borderRadius: '4px',
351
+ fontSize: '12px',
352
+ whiteSpace: 'nowrap',
353
+ zIndex: 1000,
354
+ boxShadow: '0 2px 8px rgba(0,0,0,0.15)',
355
+ }}
356
+ >
357
+ 被顶替的模型是: {rule.replacedModel}
358
+ <div
359
+ style={{
360
+ position: 'absolute',
361
+ left: '50%',
362
+ transform: 'translateX(-50%)',
363
+ bottom: '-4px',
364
+ width: '0',
365
+ height: '0',
366
+ borderLeft: '4px solid transparent',
367
+ borderRight: '4px solid transparent',
368
+ borderTop: '4px solid var(--bg-popover, #333)',
369
+ }}
370
+ />
371
+ </div>
372
+ )}
373
+ </div>
374
+ )}
375
+ </div>
376
+ </td>
377
+ <td>{vendor ? vendor.name : 'Unknown'}</td>
378
+ <td>{service ? service.name : 'Unknown'}</td>
379
+ <td>{rule.targetModel || '-'}</td>
380
+ <td>
381
+ <div className="action-buttons">
382
+ <button className="btn btn-secondary" onClick={() => handleEditRule(rule)}>编辑</button>
383
+ <button className="btn btn-danger" onClick={() => handleDeleteRule(rule.id)}>删除</button>
384
+ </div>
385
+ </td>
386
+ </tr>
387
+ );
388
+ })}
389
+ </tbody>
390
+ </table>
391
+ )}
392
+ {selectedRoute && rules.length > 0 && (
393
+ <div style={{
394
+ fontSize: '12px',
395
+ color: '#666',
396
+ marginTop: '16px',
397
+ padding: '12px',
398
+ backgroundColor: '#f8f9fa',
399
+ borderRadius: '6px',
400
+ border: '1px solid #e0e0e0',
401
+ lineHeight: '1.6'
402
+ }}>
403
+ <strong>💡 智能故障切换机制</strong>
404
+ <div style={{ marginTop: '6px' }}>
405
+ • 当同一请求类型配置多个规则时,系统会按排序优先使用第一个<br/>
406
+ • 如果某个服务报错(4xx/5xx),将自动切换到下一个可用服务<br/>
407
+ • 报错的服务会被标记为不可用,有效期10分钟<br/>
408
+ • 10分钟后自动解除标记,如果再次报错则重新标记<br/>
409
+ • 确保您的请求始终路由到稳定可用的服务<br/>
410
+ • 如不需要此功能,可在<strong>设置</strong>页面关闭"启用智能故障切换"选项
411
+ </div>
412
+ </div>
413
+ )}
414
+ </div>
415
+ </div>
416
+
417
+ {showRouteModal && (
418
+ <div className="modal-overlay">
419
+ <div className="modal" onClick={(e) => e.stopPropagation()}>
420
+ <div className="modal-header">
421
+ <h2>{editingRoute ? '编辑路由' : '新建路由'}</h2>
422
+ </div>
423
+ <form onSubmit={handleSaveRoute}>
424
+ <div className="form-group">
425
+ <label>路由名称</label>
426
+ <input type="text" name="name" defaultValue={editingRoute ? editingRoute.name : ''} required />
427
+ </div>
428
+ <div className="form-group">
429
+ <label>描述</label>
430
+ <textarea name="description" rows={3} defaultValue={editingRoute ? editingRoute.description : ''} />
431
+ </div>
432
+ <div className="form-group">
433
+ <label>路由对象</label>
434
+ <select name="targetType" defaultValue={editingRoute ? editingRoute.targetType : 'claude-code'} required>
435
+ {TARGET_TYPE_OPTIONS.map(opt => (
436
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
437
+ ))}
438
+ </select>
439
+ </div>
440
+
441
+ <div className="modal-footer">
442
+ <button type="button" className="btn btn-secondary" onClick={() => setShowRouteModal(false)}>取消</button>
443
+ <button type="submit" className="btn btn-primary">保存</button>
444
+ </div>
445
+ </form>
446
+ </div>
447
+ </div>
448
+ )}
449
+
450
+ {showRuleModal && (
451
+ <div className="modal-overlay">
452
+ <div className="modal" onClick={(e) => e.stopPropagation()}>
453
+ <div className="modal-header">
454
+ <h2>{editingRule ? '编辑规则' : '新建规则'}</h2>
455
+ </div>
456
+ <form onSubmit={handleSaveRule}>
457
+ <div className="form-group">
458
+ <label>对象请求类型</label>
459
+ <select
460
+ name="contentType"
461
+ value={selectedContentType}
462
+ required
463
+ onChange={(e) => {
464
+ setSelectedContentType(e.target.value);
465
+ }}
466
+ >
467
+ <option value="" disabled>请选择对象请求类型</option>
468
+ {getAvailableContentTypes().map(opt => (
469
+ <option key={opt.value} value={opt.value}>{opt.label}</option>
470
+ ))}
471
+ </select>
472
+ </div>
473
+
474
+ {/* 新增:被顶替模型字段,仅在选择模型顶替时显示 */}
475
+ {selectedContentType === 'model-mapping' && (
476
+ <div className="form-group">
477
+ <label>被顶替模型 <small>(可在日志中找出想要顶替的模型名)</small></label>
478
+ <input
479
+ type="text"
480
+ value={selectedReplacedModel}
481
+ onChange={(e) => setSelectedReplacedModel(e.target.value)}
482
+ placeholder="例如:gpt-4"
483
+ />
484
+ </div>
485
+ )}
486
+
487
+ {/* 新增:排序字段 */}
488
+ <div className="form-group">
489
+ <label>排序(值越大优先级越高)</label>
490
+ <input
491
+ type="number"
492
+ value={selectedSortOrder}
493
+ onChange={(e) => setSelectedSortOrder(parseInt(e.target.value) || 0)}
494
+ min="0"
495
+ max="1000"
496
+ />
497
+ </div>
498
+
499
+ <div className="form-group">
500
+ <label>供应商</label>
501
+ <select
502
+ value={selectedVendor}
503
+ onChange={(e) => setSelectedVendor(e.target.value)}
504
+ required
505
+ >
506
+ <option value="" disabled>请选择供应商</option>
507
+ {vendors.map(vendor => (
508
+ <option key={vendor.id} value={vendor.id}>{vendor.name}</option>
509
+ ))}
510
+ </select>
511
+ </div>
512
+ <div className="form-group">
513
+ <label>API服务</label>
514
+ <select
515
+ value={selectedService}
516
+ onChange={(e) => {
517
+ setSelectedService(e.target.value);
518
+ setSelectedModel('');
519
+ }}
520
+ required
521
+ disabled={!selectedVendor}
522
+ >
523
+ <option value="" disabled>请选择API服务</option>
524
+ {services.map(service => (
525
+ <option key={service.id} value={service.id}>{service.name}</option>
526
+ ))}
527
+ </select>
528
+ </div>
529
+ <div className="form-group">
530
+ <label>模型</label>
531
+ <select
532
+ value={selectedModel}
533
+ onChange={(e) => setSelectedModel(e.target.value)}
534
+ disabled={!selectedService}
535
+ >
536
+ <option value="" disabled>请选择模型</option>
537
+ {allServices.find(s => s.id === selectedService)?.supportedModels?.map(model => (
538
+ <option key={model} value={model}>{model}</option>
539
+ ))}
540
+ </select>
541
+ </div>
542
+ <div className="modal-footer">
543
+ <button type="button" className="btn btn-secondary" onClick={() => setShowRuleModal(false)}>取消</button>
544
+ <button type="submit" className="btn btn-primary">保存</button>
545
+ </div>
546
+ </form>
547
+ </div>
548
+ </div>
549
+ )}
550
+ </div>
551
+ );
552
+ }