ai-agent-router 0.1.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.
Files changed (166) hide show
  1. package/.claude/commands/openspec/apply.md +23 -0
  2. package/.claude/commands/openspec/archive.md +27 -0
  3. package/.claude/commands/openspec/proposal.md +28 -0
  4. package/.claude/settings.local.json +12 -0
  5. package/.claude/skills/ui-ux-pro-max/SKILL.md +228 -0
  6. package/.claude/skills/ui-ux-pro-max/data/charts.csv +26 -0
  7. package/.claude/skills/ui-ux-pro-max/data/colors.csv +97 -0
  8. package/.claude/skills/ui-ux-pro-max/data/landing.csv +31 -0
  9. package/.claude/skills/ui-ux-pro-max/data/products.csv +97 -0
  10. package/.claude/skills/ui-ux-pro-max/data/prompts.csv +24 -0
  11. package/.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
  12. package/.claude/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
  13. package/.claude/skills/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
  14. package/.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
  15. package/.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
  16. package/.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
  17. package/.claude/skills/ui-ux-pro-max/data/stacks/react.csv +54 -0
  18. package/.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
  19. package/.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
  20. package/.claude/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
  21. package/.claude/skills/ui-ux-pro-max/data/styles.csv +59 -0
  22. package/.claude/skills/ui-ux-pro-max/data/typography.csv +58 -0
  23. package/.claude/skills/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
  24. package/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-311.pyc +0 -0
  25. package/.claude/skills/ui-ux-pro-max/scripts/core.py +238 -0
  26. package/.claude/skills/ui-ux-pro-max/scripts/search.py +61 -0
  27. package/.cursor/commands/openspec-apply.md +23 -0
  28. package/.cursor/commands/openspec-archive.md +27 -0
  29. package/.cursor/commands/openspec-proposal.md +28 -0
  30. package/.cursor/commands/ui-ux-pro-max.md +226 -0
  31. package/.eslintrc.json +3 -0
  32. package/.shared/ui-ux-pro-max/data/charts.csv +26 -0
  33. package/.shared/ui-ux-pro-max/data/colors.csv +97 -0
  34. package/.shared/ui-ux-pro-max/data/landing.csv +31 -0
  35. package/.shared/ui-ux-pro-max/data/products.csv +97 -0
  36. package/.shared/ui-ux-pro-max/data/prompts.csv +24 -0
  37. package/.shared/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
  38. package/.shared/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
  39. package/.shared/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
  40. package/.shared/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
  41. package/.shared/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
  42. package/.shared/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
  43. package/.shared/ui-ux-pro-max/data/stacks/react.csv +54 -0
  44. package/.shared/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
  45. package/.shared/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
  46. package/.shared/ui-ux-pro-max/data/stacks/vue.csv +50 -0
  47. package/.shared/ui-ux-pro-max/data/styles.csv +59 -0
  48. package/.shared/ui-ux-pro-max/data/typography.csv +58 -0
  49. package/.shared/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
  50. package/.shared/ui-ux-pro-max/scripts/core.py +238 -0
  51. package/.shared/ui-ux-pro-max/scripts/search.py +61 -0
  52. package/AGENTS.md +18 -0
  53. package/CLAUDE.md +18 -0
  54. package/IMPLEMENTATION.md +157 -0
  55. package/LICENSE +21 -0
  56. package/README.md +165 -0
  57. package/dist/.next/types/app/api/config/route.js +52 -0
  58. package/dist/.next/types/app/api/gateway/[...path]/route.js +52 -0
  59. package/dist/.next/types/app/api/gateway/route.js +52 -0
  60. package/dist/.next/types/app/api/logs/route.js +52 -0
  61. package/dist/.next/types/app/api/models/route.js +52 -0
  62. package/dist/.next/types/app/api/providers/route.js +52 -0
  63. package/dist/.next/types/app/api/providers/test/route.js +52 -0
  64. package/dist/.next/types/app/api/service/start/route.js +52 -0
  65. package/dist/.next/types/app/api/service/status/route.js +52 -0
  66. package/dist/.next/types/app/api/service/stop/route.js +52 -0
  67. package/dist/.next/types/app/layout.js +22 -0
  68. package/dist/.next/types/app/logs/page.js +22 -0
  69. package/dist/.next/types/app/models/page.js +22 -0
  70. package/dist/.next/types/app/page.js +22 -0
  71. package/dist/.next/types/app/providers/page.js +22 -0
  72. package/dist/src/app/api/config/route.js +43 -0
  73. package/dist/src/app/api/gateway/[...path]/route.js +83 -0
  74. package/dist/src/app/api/gateway/route.js +63 -0
  75. package/dist/src/app/api/logs/route.js +34 -0
  76. package/dist/src/app/api/models/route.js +152 -0
  77. package/dist/src/app/api/providers/route.js +118 -0
  78. package/dist/src/app/api/providers/test/route.js +154 -0
  79. package/dist/src/app/api/service/start/route.js +55 -0
  80. package/dist/src/app/api/service/status/route.js +17 -0
  81. package/dist/src/app/api/service/stop/route.js +20 -0
  82. package/dist/src/app/components/ConfirmDialog.jsx +31 -0
  83. package/dist/src/app/components/Nav.jsx +45 -0
  84. package/dist/src/app/components/Toast.jsx +37 -0
  85. package/dist/src/app/components/ToastProvider.jsx +21 -0
  86. package/dist/src/app/layout.jsx +13 -0
  87. package/dist/src/app/logs/page.jsx +210 -0
  88. package/dist/src/app/models/page.jsx +291 -0
  89. package/dist/src/app/page.jsx +236 -0
  90. package/dist/src/app/providers/page.jsx +402 -0
  91. package/dist/src/cli/index.js +90 -0
  92. package/dist/src/db/database.js +69 -0
  93. package/dist/src/db/queries.js +261 -0
  94. package/dist/src/db/schema.js +67 -0
  95. package/dist/src/server/crypto.js +22 -0
  96. package/dist/src/server/gateway-server.js +200 -0
  97. package/dist/src/server/gateway.js +76 -0
  98. package/dist/src/server/logger.js +72 -0
  99. package/dist/src/server/providers/anthropic.js +52 -0
  100. package/dist/src/server/providers/gemini.js +64 -0
  101. package/dist/src/server/providers/index.js +16 -0
  102. package/dist/src/server/providers/openai.js +86 -0
  103. package/dist/src/server/providers/types.js +1 -0
  104. package/dist/src/server/service-manager.js +286 -0
  105. package/docs/TODO.md +19 -0
  106. package/next.config.js +7 -0
  107. package/openspec/AGENTS.md +456 -0
  108. package/openspec/changes/add-logging/proposal.md +18 -0
  109. package/openspec/changes/add-logging/specs/core/spec.md +21 -0
  110. package/openspec/changes/add-logging/tasks.md +16 -0
  111. package/openspec/changes/add-provider-test-connection/proposal.md +22 -0
  112. package/openspec/changes/add-provider-test-connection/specs/model-provider/spec.md +68 -0
  113. package/openspec/changes/add-provider-test-connection/tasks.md +31 -0
  114. package/openspec/changes/improve-gateway-startup/design.md +137 -0
  115. package/openspec/changes/improve-gateway-startup/proposal.md +33 -0
  116. package/openspec/changes/improve-gateway-startup/specs/api-gateway/spec.md +94 -0
  117. package/openspec/changes/improve-gateway-startup/specs/web-ui/spec.md +67 -0
  118. package/openspec/changes/improve-gateway-startup/tasks.md +47 -0
  119. package/openspec/changes/init-api-gateway/design.md +185 -0
  120. package/openspec/changes/init-api-gateway/proposal.md +30 -0
  121. package/openspec/changes/init-api-gateway/specs/api-gateway/spec.md +42 -0
  122. package/openspec/changes/init-api-gateway/specs/cli-tool/spec.md +40 -0
  123. package/openspec/changes/init-api-gateway/specs/model-management/spec.md +47 -0
  124. package/openspec/changes/init-api-gateway/specs/model-provider/spec.md +33 -0
  125. package/openspec/changes/init-api-gateway/specs/request-logging/spec.md +54 -0
  126. package/openspec/changes/init-api-gateway/specs/web-ui/spec.md +49 -0
  127. package/openspec/changes/init-api-gateway/tasks.md +84 -0
  128. package/openspec/project.md +58 -0
  129. package/package.json +51 -0
  130. package/postcss.config.js +6 -0
  131. package/src/app/api/config/route.ts +62 -0
  132. package/src/app/api/gateway/[...path]/route.ts +118 -0
  133. package/src/app/api/gateway/route.ts +77 -0
  134. package/src/app/api/logs/route.ts +48 -0
  135. package/src/app/api/models/route.ts +210 -0
  136. package/src/app/api/providers/route.ts +162 -0
  137. package/src/app/api/providers/test/route.ts +182 -0
  138. package/src/app/api/service/start/route.ts +73 -0
  139. package/src/app/api/service/status/route.ts +22 -0
  140. package/src/app/api/service/stop/route.ts +27 -0
  141. package/src/app/components/ConfirmDialog.tsx +63 -0
  142. package/src/app/components/Nav.tsx +66 -0
  143. package/src/app/components/Toast.tsx +61 -0
  144. package/src/app/components/ToastProvider.tsx +43 -0
  145. package/src/app/globals.css +71 -0
  146. package/src/app/layout.tsx +22 -0
  147. package/src/app/logs/page.tsx +261 -0
  148. package/src/app/models/page.tsx +500 -0
  149. package/src/app/page.tsx +742 -0
  150. package/src/app/providers/page.tsx +558 -0
  151. package/src/cli/index.ts +95 -0
  152. package/src/db/database.ts +125 -0
  153. package/src/db/queries.ts +339 -0
  154. package/src/db/schema.ts +117 -0
  155. package/src/server/crypto.ts +48 -0
  156. package/src/server/gateway-server.ts +306 -0
  157. package/src/server/gateway.ts +163 -0
  158. package/src/server/logger.ts +96 -0
  159. package/src/server/providers/anthropic.ts +121 -0
  160. package/src/server/providers/gemini.ts +112 -0
  161. package/src/server/providers/index.ts +20 -0
  162. package/src/server/providers/openai.ts +235 -0
  163. package/src/server/providers/types.ts +20 -0
  164. package/src/server/service-manager.ts +321 -0
  165. package/tailwind.config.js +16 -0
  166. package/tsconfig.json +29 -0
@@ -0,0 +1,402 @@
1
+ 'use client';
2
+ import { useState, useEffect } from 'react';
3
+ import Nav from '../components/Nav';
4
+ import { useToast } from '../components/ToastProvider';
5
+ import ConfirmDialog from '../components/ConfirmDialog';
6
+ export default function ProvidersPage() {
7
+ const [providers, setProviders] = useState([]);
8
+ const [models, setModels] = useState([]);
9
+ const [loading, setLoading] = useState(true);
10
+ const [showModal, setShowModal] = useState(false);
11
+ const [editing, setEditing] = useState(null);
12
+ const [showConfirm, setShowConfirm] = useState(false);
13
+ const [deleteId, setDeleteId] = useState(null);
14
+ const [testingProviderId, setTestingProviderId] = useState(null);
15
+ const [selectedModelId, setSelectedModelId] = useState('');
16
+ const [testing, setTesting] = useState(false);
17
+ const [formData, setFormData] = useState({
18
+ name: '',
19
+ protocol: 'openai',
20
+ base_url: '',
21
+ api_key: '',
22
+ });
23
+ const [showApiKey, setShowApiKey] = useState(false);
24
+ const { showToast } = useToast();
25
+ useEffect(() => {
26
+ loadProviders();
27
+ loadModels();
28
+ }, []);
29
+ const loadProviders = async () => {
30
+ try {
31
+ const res = await fetch('/api/providers');
32
+ const data = await res.json();
33
+ setProviders(data);
34
+ }
35
+ catch (error) {
36
+ console.error('Failed to load providers:', error);
37
+ }
38
+ finally {
39
+ setLoading(false);
40
+ }
41
+ };
42
+ const loadModels = async () => {
43
+ try {
44
+ const res = await fetch('/api/models');
45
+ const data = await res.json();
46
+ setModels(data);
47
+ }
48
+ catch (error) {
49
+ console.error('Failed to load models:', error);
50
+ }
51
+ };
52
+ const loadProviderForEdit = async (id) => {
53
+ try {
54
+ const res = await fetch(`/api/providers?id=${id}&includeKey=true`);
55
+ if (!res.ok) {
56
+ throw new Error('Failed to load provider');
57
+ }
58
+ const data = await res.json();
59
+ return data;
60
+ }
61
+ catch (error) {
62
+ console.error('Failed to load provider for edit:', error);
63
+ throw error;
64
+ }
65
+ };
66
+ const handleSubmit = async (e) => {
67
+ e.preventDefault();
68
+ try {
69
+ let response;
70
+ if (editing) {
71
+ const updateData = {
72
+ id: editing.id,
73
+ name: formData.name,
74
+ protocol: formData.protocol,
75
+ base_url: formData.base_url,
76
+ };
77
+ if (formData.api_key && formData.api_key.trim() !== '') {
78
+ updateData.api_key = formData.api_key.trim();
79
+ }
80
+ response = await fetch('/api/providers', {
81
+ method: 'PUT',
82
+ headers: { 'Content-Type': 'application/json' },
83
+ body: JSON.stringify(updateData),
84
+ });
85
+ }
86
+ else {
87
+ if (!formData.api_key || formData.api_key.trim() === '') {
88
+ showToast('API Key 不能为空', 'error');
89
+ return;
90
+ }
91
+ response = await fetch('/api/providers', {
92
+ method: 'POST',
93
+ headers: { 'Content-Type': 'application/json' },
94
+ body: JSON.stringify({
95
+ ...formData,
96
+ api_key: formData.api_key.trim(),
97
+ }),
98
+ });
99
+ }
100
+ if (!response.ok) {
101
+ const errorData = await response.json().catch(() => ({}));
102
+ const errorMessage = errorData.error || '保存失败';
103
+ showToast(errorMessage, 'error');
104
+ return;
105
+ }
106
+ setShowModal(false);
107
+ setEditing(null);
108
+ setFormData({ name: '', protocol: 'openai', base_url: '', api_key: '' });
109
+ setShowApiKey(false);
110
+ loadProviders();
111
+ showToast('供应商保存成功', 'success');
112
+ }
113
+ catch (error) {
114
+ console.error('Failed to save provider:', error);
115
+ showToast('保存失败: ' + (error.message || '未知错误'), 'error');
116
+ }
117
+ };
118
+ const handleEdit = async (provider) => {
119
+ try {
120
+ // Load provider with API key for editing
121
+ const providerWithKey = await loadProviderForEdit(provider.id);
122
+ setEditing(provider);
123
+ setFormData({
124
+ name: providerWithKey.name,
125
+ protocol: providerWithKey.protocol,
126
+ base_url: providerWithKey.base_url,
127
+ api_key: providerWithKey.api_key || '', // Show existing key
128
+ });
129
+ setShowApiKey(false); // Start with hidden
130
+ setShowModal(true);
131
+ }
132
+ catch (error) {
133
+ console.error('Failed to load provider for edit:', error);
134
+ showToast('加载供应商信息失败', 'error');
135
+ }
136
+ };
137
+ const handleDelete = (id) => {
138
+ setDeleteId(id);
139
+ setShowConfirm(true);
140
+ };
141
+ const confirmDelete = async () => {
142
+ if (deleteId) {
143
+ try {
144
+ await fetch(`/api/providers?id=${deleteId}`, { method: 'DELETE' });
145
+ loadProviders();
146
+ showToast('供应商已删除', 'success');
147
+ }
148
+ catch (error) {
149
+ console.error('Failed to delete provider:', error);
150
+ showToast('删除失败', 'error');
151
+ }
152
+ }
153
+ setShowConfirm(false);
154
+ setDeleteId(null);
155
+ };
156
+ const handleTestConnection = (providerId) => {
157
+ const providerModels = models.filter(m => m.provider_id === providerId);
158
+ if (providerModels.length === 0) {
159
+ showToast('该供应商下没有模型,请先添加或拉取模型', 'error');
160
+ return;
161
+ }
162
+ setTestingProviderId(providerId);
163
+ setSelectedModelId('');
164
+ };
165
+ const startTest = async () => {
166
+ if (!testingProviderId || !selectedModelId) {
167
+ showToast('请选择要测试的模型', 'error');
168
+ return;
169
+ }
170
+ setTesting(true);
171
+ try {
172
+ const res = await fetch('/api/providers/test', {
173
+ method: 'POST',
174
+ headers: { 'Content-Type': 'application/json' },
175
+ body: JSON.stringify({
176
+ provider_id: testingProviderId,
177
+ model_id: parseInt(selectedModelId),
178
+ }),
179
+ });
180
+ const data = await res.json();
181
+ if (data.success) {
182
+ showToast(data.message || '连接成功,模型可用', 'success');
183
+ }
184
+ else {
185
+ showToast(data.error || '连接失败', 'error');
186
+ }
187
+ }
188
+ catch (error) {
189
+ console.error('Failed to test connection:', error);
190
+ showToast('测试连接失败: ' + (error.message || '未知错误'), 'error');
191
+ }
192
+ finally {
193
+ setTesting(false);
194
+ setTestingProviderId(null);
195
+ setSelectedModelId('');
196
+ }
197
+ };
198
+ const cancelTest = () => {
199
+ setTestingProviderId(null);
200
+ setSelectedModelId('');
201
+ };
202
+ const getProviderModels = (providerId) => {
203
+ return models.filter(m => m.provider_id === providerId);
204
+ };
205
+ if (loading) {
206
+ return (<>
207
+ <Nav />
208
+ <div className="min-h-screen flex items-center justify-center">
209
+ <div className="text-lg">加载中...</div>
210
+ </div>
211
+ </>);
212
+ }
213
+ return (<>
214
+ <Nav />
215
+ <main className="max-w-6xl mx-auto py-6 sm:px-6 lg:px-8">
216
+ <div className="px-4 py-4 sm:px-0">
217
+ <div className="flex justify-between items-center mb-5">
218
+ <div>
219
+ <h1 className="text-lg font-bold text-slate-800">供应商管理</h1>
220
+ <p className="text-xs text-slate-500 mt-1">管理 AI 模型供应商配置</p>
221
+ </div>
222
+ <button onClick={() => {
223
+ setEditing(null);
224
+ setFormData({ name: '', protocol: 'openai', base_url: '', api_key: '' });
225
+ setShowApiKey(false);
226
+ setShowModal(true);
227
+ }} className="inline-flex items-center px-3.5 py-1.5 border border-transparent text-xs font-semibold rounded-lg shadow-sm text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-emerald-500/50 transition-all duration-300">
228
+ <svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
229
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4"/>
230
+ </svg>
231
+ 添加供应商
232
+ </button>
233
+ </div>
234
+
235
+ <div className="bg-white/70 backdrop-blur-sm shadow-md rounded-2xl border border-emerald-100/50 overflow-hidden">
236
+ <table className="min-w-full divide-y divide-slate-100">
237
+ <thead className="bg-emerald-50/30">
238
+ <tr>
239
+ <th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
240
+ 名称
241
+ </th>
242
+ <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
243
+ 协议
244
+ </th>
245
+ <th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
246
+ Base URL
247
+ </th>
248
+ <th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-700 uppercase tracking-wider">
249
+ 操作
250
+ </th>
251
+ </tr>
252
+ </thead>
253
+ <tbody className="bg-white divide-y divide-slate-100">
254
+ {providers.length === 0 ? (<tr>
255
+ <td colSpan={4} className="px-4 py-12 text-center">
256
+ <div className="text-slate-400">
257
+ <svg className="mx-auto h-10 w-10 mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
258
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 11H5m14 0a2 2 0 012 2v6a2 2 0 01-2 2H5a2 2 0 01-2-2v-6a2 2 0 012-2m14 0V9a2 2 0 00-2-2M5 11V9a2 2 0 012-2m0 0V5a2 2 0 012-2h6a2 2 0 012 2v2M7 7h10"/>
259
+ </svg>
260
+ <p className="text-xs text-slate-500">暂无供应商,请先添加供应商</p>
261
+ </div>
262
+ </td>
263
+ </tr>) : (providers.map((provider) => (<tr key={provider.id} className="hover:bg-emerald-50/20 transition-colors duration-300">
264
+ <td className="px-4 py-3 whitespace-nowrap">
265
+ <div className="text-xs font-medium text-slate-800">{provider.name}</div>
266
+ </td>
267
+ <td className="px-4 py-3 whitespace-nowrap">
268
+ <span className="px-2 py-0.5 inline-flex text-xs leading-4 font-semibold rounded-full bg-emerald-100/80 text-emerald-700 border border-emerald-200/50">
269
+ {provider.protocol}
270
+ </span>
271
+ </td>
272
+ <td className="px-4 py-3 whitespace-nowrap">
273
+ <div className="text-xs text-slate-600 font-mono">{provider.base_url}</div>
274
+ </td>
275
+ <td className="px-4 py-3 whitespace-nowrap text-right text-xs font-medium">
276
+ <div className="flex items-center justify-end space-x-3">
277
+ <button onClick={() => handleTestConnection(provider.id)} className="text-blue-600 hover:text-blue-700 font-medium transition-colors duration-300">
278
+ 测试连接
279
+ </button>
280
+ <button onClick={() => handleEdit(provider)} className="text-emerald-600 hover:text-emerald-700 font-medium transition-colors duration-300">
281
+ 编辑
282
+ </button>
283
+ <button onClick={() => handleDelete(provider.id)} className="text-rose-500 hover:text-rose-600 font-medium transition-colors duration-300">
284
+ 删除
285
+ </button>
286
+ </div>
287
+ </td>
288
+ </tr>)))}
289
+ </tbody>
290
+ </table>
291
+ </div>
292
+ </div>
293
+ </main>
294
+
295
+ {showModal && (<div className="fixed z-50 inset-0 overflow-y-auto">
296
+ <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
297
+ <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={() => setShowModal(false)}></div>
298
+ <div className="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full border border-emerald-100/50">
299
+ <form onSubmit={handleSubmit} className="bg-white px-6 pt-5 pb-4 sm:p-6">
300
+ <div className="mb-5">
301
+ <h3 className="text-base font-bold text-slate-800 mb-1">
302
+ {editing ? '编辑供应商' : '添加供应商'}
303
+ </h3>
304
+ <p className="text-xs text-slate-500">配置 AI 模型供应商信息</p>
305
+ </div>
306
+ <div className="space-y-4">
307
+ <div>
308
+ <label className="block text-xs font-semibold text-slate-700 mb-1.5">名称</label>
309
+ <input type="text" required value={formData.name} onChange={(e) => setFormData({ ...formData, name: e.target.value })} autoComplete="off" className="block w-full rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-xs shadow-sm transition-all duration-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 focus:ring-offset-1" placeholder="例如: OpenAI"/>
310
+ </div>
311
+ <div>
312
+ <label className="block text-xs font-semibold text-slate-700 mb-1.5">协议</label>
313
+ <select value={formData.protocol} onChange={(e) => setFormData({ ...formData, protocol: e.target.value })} autoComplete="off" className="block w-full rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-xs shadow-sm transition-all duration-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 focus:ring-offset-1">
314
+ <option value="openai">OpenAI</option>
315
+ <option value="anthropic">Anthropic</option>
316
+ <option value="gemini">Gemini</option>
317
+ </select>
318
+ </div>
319
+ <div>
320
+ <label className="block text-xs font-semibold text-slate-700 mb-1.5">Base URL</label>
321
+ <input type="url" required value={formData.base_url} onChange={(e) => setFormData({ ...formData, base_url: e.target.value })} autoComplete="off" className="block w-full rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-xs shadow-sm transition-all duration-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 focus:ring-offset-1" placeholder="https://api.openai.com/v1"/>
322
+ </div>
323
+ <div>
324
+ <label className="block text-xs font-semibold text-slate-700 mb-1.5">
325
+ API Key
326
+ {editing && (<span className="ml-1 text-[10px] text-slate-400 font-normal">(已保存,输入新值可更新)</span>)}
327
+ </label>
328
+ <div className="relative">
329
+ <input type={showApiKey ? 'text' : 'password'} required={!editing} value={formData.api_key} onChange={(e) => setFormData({ ...formData, api_key: e.target.value })} autoComplete="new-password" className="block w-full rounded-lg border border-slate-200 bg-white/80 px-3 py-2 pr-9 text-xs shadow-sm transition-all duration-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 focus:ring-offset-1" placeholder={editing ? '留空则不更新,输入新值可更新 API Key' : '输入 API Key'}/>
330
+ <button type="button" onClick={() => setShowApiKey(!showApiKey)} className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 transition-colors" tabIndex={-1}>
331
+ {showApiKey ? (<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
332
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M13.875 18.825A10.05 10.05 0 0112 19c-4.478 0-8.268-2.943-9.543-7a9.97 9.97 0 011.563-3.029m5.858.908a3 3 0 114.243 4.243M9.878 9.878l4.242 4.242M9.88 9.88l-3.29-3.29m7.532 7.532l3.29 3.29M3 3l3.59 3.59m0 0A9.953 9.953 0 0112 5c4.478 0 8.268 2.943 9.543 7a10.025 10.025 0 01-4.132 5.411m0 0L21 21"/>
333
+ </svg>) : (<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
334
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z"/>
335
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M2.458 12C3.732 7.943 7.523 5 12 5c4.478 0 8.268 2.943 9.542 7-1.274 4.057-5.064 7-9.542 7-4.477 0-8.268-2.943-9.542-7z"/>
336
+ </svg>)}
337
+ </button>
338
+ </div>
339
+ </div>
340
+ </div>
341
+ <div className="mt-5 flex items-center justify-end space-x-2">
342
+ <button type="button" onClick={() => {
343
+ setShowModal(false);
344
+ setEditing(null);
345
+ }} className="px-3.5 py-1.5 border border-slate-200 rounded-lg text-xs font-semibold text-slate-600 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-slate-400/30 transition-all duration-300">
346
+ 取消
347
+ </button>
348
+ <button type="submit" className="px-4 py-1.5 border border-transparent rounded-lg text-xs font-semibold text-white bg-emerald-600 hover:bg-emerald-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-emerald-500/50 transition-all duration-300">
349
+ 保存
350
+ </button>
351
+ </div>
352
+ </form>
353
+ </div>
354
+ </div>
355
+ </div>)}
356
+
357
+ <ConfirmDialog open={showConfirm} title="确认删除" message="确定要删除这个供应商吗?此操作不可恢复。" onConfirm={confirmDelete} onCancel={() => {
358
+ setShowConfirm(false);
359
+ setDeleteId(null);
360
+ }} confirmText="删除" cancelText="取消" type="danger"/>
361
+
362
+ {/* Test Connection Modal */}
363
+ {testingProviderId && (<div className="fixed z-50 inset-0 overflow-y-auto">
364
+ <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
365
+ <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={cancelTest}></div>
366
+ <div className="inline-block align-bottom bg-white rounded-2xl text-left overflow-hidden shadow-xl transform transition-all sm:my-8 sm:align-middle sm:max-w-md sm:w-full border border-emerald-100/50">
367
+ <div className="bg-white px-6 pt-5 pb-4 sm:p-6">
368
+ <div className="mb-5">
369
+ <h3 className="text-base font-bold text-slate-800 mb-1">测试连接</h3>
370
+ <p className="text-xs text-slate-500">选择一个模型进行连接测试</p>
371
+ </div>
372
+ <div className="space-y-4">
373
+ <div>
374
+ <label className="block text-xs font-semibold text-slate-700 mb-1.5">选择模型</label>
375
+ <select value={selectedModelId} onChange={(e) => setSelectedModelId(e.target.value)} disabled={testing} className="block w-full rounded-lg border border-slate-200 bg-white/80 px-3 py-2 text-xs shadow-sm transition-all duration-300 focus:border-emerald-400 focus:ring-2 focus:ring-emerald-400/20 focus:ring-offset-1 disabled:opacity-50 disabled:cursor-not-allowed">
376
+ <option value="">请选择模型</option>
377
+ {getProviderModels(testingProviderId).map((model) => (<option key={model.id} value={model.id}>
378
+ {model.name} ({model.model_id})
379
+ </option>))}
380
+ </select>
381
+ </div>
382
+ </div>
383
+ <div className="mt-5 flex items-center justify-end space-x-2">
384
+ <button type="button" onClick={cancelTest} disabled={testing} className="px-3.5 py-1.5 border border-slate-200 rounded-lg text-xs font-semibold text-slate-600 bg-white hover:bg-slate-50 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-slate-400/30 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed">
385
+ 取消
386
+ </button>
387
+ <button type="button" onClick={startTest} disabled={testing || !selectedModelId} className="px-4 py-1.5 border border-transparent rounded-lg text-xs font-semibold text-white bg-blue-600 hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-offset-1 focus:ring-blue-500/50 transition-all duration-300 disabled:opacity-50 disabled:cursor-not-allowed inline-flex items-center">
388
+ {testing ? (<>
389
+ <svg className="animate-spin -ml-1 mr-2 h-3 w-3 text-white" xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 24 24">
390
+ <circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
391
+ <path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z"></path>
392
+ </svg>
393
+ 测试中...
394
+ </>) : ('开始测试')}
395
+ </button>
396
+ </div>
397
+ </div>
398
+ </div>
399
+ </div>
400
+ </div>)}
401
+ </>);
402
+ }
@@ -0,0 +1,90 @@
1
+ #!/usr/bin/env node
2
+ import { Command } from 'commander';
3
+ import { GatewayServer } from '../server/gateway-server.js';
4
+ import { getDatabase } from '../db/database.js';
5
+ import { getConfig, setConfig } from '../db/queries.js';
6
+ const program = new Command();
7
+ program
8
+ .name('aar')
9
+ .description('AI Agent Router - Unified gateway for managing multiple AI model providers')
10
+ .version('0.1.0');
11
+ program
12
+ .command('start')
13
+ .description('Start the API gateway server (gateway only, no Web UI)')
14
+ .option('-p, --port <port>', 'Port to listen on', '3000')
15
+ .option('--hostname <hostname>', 'Hostname to listen on', 'localhost')
16
+ .action(async (options) => {
17
+ const port = parseInt(options.port || '3000');
18
+ const hostname = options.hostname || 'localhost';
19
+ // Initialize database to get config
20
+ try {
21
+ getDatabase();
22
+ }
23
+ catch (error) {
24
+ console.error('Failed to initialize database:', error);
25
+ process.exit(1);
26
+ }
27
+ // Get API key from config if configured
28
+ const apiKeyConfig = getConfig('api_key');
29
+ const apiKey = apiKeyConfig ? apiKeyConfig.value : undefined;
30
+ console.log(`Starting AI Agent Router Gateway Server`);
31
+ console.log(` Port: ${port}`);
32
+ console.log(` Hostname: ${hostname}`);
33
+ if (apiKey) {
34
+ console.log(` API Key: Configured (authentication enabled)`);
35
+ }
36
+ else {
37
+ console.log(` API Key: Not configured (authentication disabled)`);
38
+ }
39
+ // Create and start gateway server
40
+ const server = new GatewayServer({
41
+ port,
42
+ hostname,
43
+ apiKey,
44
+ });
45
+ try {
46
+ await server.start();
47
+ }
48
+ catch (error) {
49
+ console.error(`Failed to start gateway server: ${error.message}`);
50
+ process.exit(1);
51
+ }
52
+ });
53
+ program
54
+ .command('config')
55
+ .description('Manage gateway configuration')
56
+ .option('--get <key>', 'Get configuration value')
57
+ .option('--set <key> <value>', 'Set configuration value')
58
+ .action(async (options) => {
59
+ // Initialize database
60
+ try {
61
+ getDatabase();
62
+ }
63
+ catch (error) {
64
+ console.error('Failed to initialize database:', error);
65
+ process.exit(1);
66
+ }
67
+ if (options.get) {
68
+ const config = getConfig(options.get);
69
+ if (config) {
70
+ console.log(config.value);
71
+ }
72
+ else {
73
+ console.error(`Config key "${options.get}" not found`);
74
+ process.exit(1);
75
+ }
76
+ }
77
+ else if (options.set) {
78
+ const [key, value] = options.set.split(' ');
79
+ if (!key || !value) {
80
+ console.error('Usage: --set <key> <value>');
81
+ process.exit(1);
82
+ }
83
+ setConfig(key, value);
84
+ console.log(`Config "${key}" set to "${value}"`);
85
+ }
86
+ else {
87
+ program.help();
88
+ }
89
+ });
90
+ program.parse();
@@ -0,0 +1,69 @@
1
+ import Database from 'better-sqlite3';
2
+ import { CREATE_TABLES_SQL } from './schema.js';
3
+ import path from 'path';
4
+ import fs from 'fs';
5
+ import os from 'os';
6
+ const DB_PATH = process.env.DB_PATH || path.join(os.homedir(), '.aar', 'gateway.db');
7
+ let dbInstance = null;
8
+ export function getDatabase() {
9
+ if (!dbInstance) {
10
+ try {
11
+ // Ensure directory exists
12
+ const dbDir = path.dirname(DB_PATH);
13
+ if (!fs.existsSync(dbDir)) {
14
+ fs.mkdirSync(dbDir, { recursive: true });
15
+ }
16
+ dbInstance = new Database(DB_PATH, {
17
+ // Add timeout for busy operations to avoid blocking
18
+ timeout: 5000,
19
+ });
20
+ // Enable WAL mode for better concurrency
21
+ dbInstance.pragma('journal_mode = WAL');
22
+ // Set busy timeout to handle concurrent access
23
+ dbInstance.pragma('busy_timeout = 5000');
24
+ // Initialize schema with error handling for concurrent access
25
+ try {
26
+ dbInstance.exec(CREATE_TABLES_SQL);
27
+ }
28
+ catch (schemaError) {
29
+ // Ignore "table already exists" errors (concurrent initialization)
30
+ if (!schemaError.message.includes('already exists') &&
31
+ !schemaError.message.includes('duplicate')) {
32
+ throw schemaError;
33
+ }
34
+ }
35
+ }
36
+ catch (error) {
37
+ console.error('Database initialization error:', error);
38
+ // Don't throw if it's a busy/locked error, retry might work
39
+ if (error.message && error.message.includes('database is locked')) {
40
+ // Wait a bit and retry once
41
+ setTimeout(() => {
42
+ if (!dbInstance) {
43
+ try {
44
+ dbInstance = new Database(DB_PATH, { timeout: 5000 });
45
+ dbInstance.pragma('journal_mode = WAL');
46
+ dbInstance.pragma('busy_timeout = 5000');
47
+ }
48
+ catch (retryError) {
49
+ console.error('Database retry failed:', retryError);
50
+ }
51
+ }
52
+ }, 100);
53
+ // Return a temporary instance or throw
54
+ throw new Error(`Database is temporarily locked: ${error.message}`);
55
+ }
56
+ throw new Error(`Failed to initialize database: ${error.message}`);
57
+ }
58
+ }
59
+ return dbInstance;
60
+ }
61
+ export function closeDatabase() {
62
+ if (dbInstance) {
63
+ dbInstance.close();
64
+ dbInstance = null;
65
+ }
66
+ }
67
+ export function getDbPath() {
68
+ return DB_PATH;
69
+ }