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.
- package/.claude/commands/openspec/apply.md +23 -0
- package/.claude/commands/openspec/archive.md +27 -0
- package/.claude/commands/openspec/proposal.md +28 -0
- package/.claude/settings.local.json +12 -0
- package/.claude/skills/ui-ux-pro-max/SKILL.md +228 -0
- package/.claude/skills/ui-ux-pro-max/data/charts.csv +26 -0
- package/.claude/skills/ui-ux-pro-max/data/colors.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/landing.csv +31 -0
- package/.claude/skills/ui-ux-pro-max/data/products.csv +97 -0
- package/.claude/skills/ui-ux-pro-max/data/prompts.csv +24 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/react.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/.claude/skills/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/.claude/skills/ui-ux-pro-max/data/styles.csv +59 -0
- package/.claude/skills/ui-ux-pro-max/data/typography.csv +58 -0
- package/.claude/skills/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- package/.claude/skills/ui-ux-pro-max/scripts/__pycache__/core.cpython-311.pyc +0 -0
- package/.claude/skills/ui-ux-pro-max/scripts/core.py +238 -0
- package/.claude/skills/ui-ux-pro-max/scripts/search.py +61 -0
- package/.cursor/commands/openspec-apply.md +23 -0
- package/.cursor/commands/openspec-archive.md +27 -0
- package/.cursor/commands/openspec-proposal.md +28 -0
- package/.cursor/commands/ui-ux-pro-max.md +226 -0
- package/.eslintrc.json +3 -0
- package/.shared/ui-ux-pro-max/data/charts.csv +26 -0
- package/.shared/ui-ux-pro-max/data/colors.csv +97 -0
- package/.shared/ui-ux-pro-max/data/landing.csv +31 -0
- package/.shared/ui-ux-pro-max/data/products.csv +97 -0
- package/.shared/ui-ux-pro-max/data/prompts.csv +24 -0
- package/.shared/ui-ux-pro-max/data/stacks/flutter.csv +53 -0
- package/.shared/ui-ux-pro-max/data/stacks/html-tailwind.csv +56 -0
- package/.shared/ui-ux-pro-max/data/stacks/nextjs.csv +53 -0
- package/.shared/ui-ux-pro-max/data/stacks/nuxt-ui.csv +51 -0
- package/.shared/ui-ux-pro-max/data/stacks/nuxtjs.csv +59 -0
- package/.shared/ui-ux-pro-max/data/stacks/react-native.csv +52 -0
- package/.shared/ui-ux-pro-max/data/stacks/react.csv +54 -0
- package/.shared/ui-ux-pro-max/data/stacks/svelte.csv +54 -0
- package/.shared/ui-ux-pro-max/data/stacks/swiftui.csv +51 -0
- package/.shared/ui-ux-pro-max/data/stacks/vue.csv +50 -0
- package/.shared/ui-ux-pro-max/data/styles.csv +59 -0
- package/.shared/ui-ux-pro-max/data/typography.csv +58 -0
- package/.shared/ui-ux-pro-max/data/ux-guidelines.csv +100 -0
- package/.shared/ui-ux-pro-max/scripts/core.py +238 -0
- package/.shared/ui-ux-pro-max/scripts/search.py +61 -0
- package/AGENTS.md +18 -0
- package/CLAUDE.md +18 -0
- package/IMPLEMENTATION.md +157 -0
- package/LICENSE +21 -0
- package/README.md +165 -0
- package/dist/.next/types/app/api/config/route.js +52 -0
- package/dist/.next/types/app/api/gateway/[...path]/route.js +52 -0
- package/dist/.next/types/app/api/gateway/route.js +52 -0
- package/dist/.next/types/app/api/logs/route.js +52 -0
- package/dist/.next/types/app/api/models/route.js +52 -0
- package/dist/.next/types/app/api/providers/route.js +52 -0
- package/dist/.next/types/app/api/providers/test/route.js +52 -0
- package/dist/.next/types/app/api/service/start/route.js +52 -0
- package/dist/.next/types/app/api/service/status/route.js +52 -0
- package/dist/.next/types/app/api/service/stop/route.js +52 -0
- package/dist/.next/types/app/layout.js +22 -0
- package/dist/.next/types/app/logs/page.js +22 -0
- package/dist/.next/types/app/models/page.js +22 -0
- package/dist/.next/types/app/page.js +22 -0
- package/dist/.next/types/app/providers/page.js +22 -0
- package/dist/src/app/api/config/route.js +43 -0
- package/dist/src/app/api/gateway/[...path]/route.js +83 -0
- package/dist/src/app/api/gateway/route.js +63 -0
- package/dist/src/app/api/logs/route.js +34 -0
- package/dist/src/app/api/models/route.js +152 -0
- package/dist/src/app/api/providers/route.js +118 -0
- package/dist/src/app/api/providers/test/route.js +154 -0
- package/dist/src/app/api/service/start/route.js +55 -0
- package/dist/src/app/api/service/status/route.js +17 -0
- package/dist/src/app/api/service/stop/route.js +20 -0
- package/dist/src/app/components/ConfirmDialog.jsx +31 -0
- package/dist/src/app/components/Nav.jsx +45 -0
- package/dist/src/app/components/Toast.jsx +37 -0
- package/dist/src/app/components/ToastProvider.jsx +21 -0
- package/dist/src/app/layout.jsx +13 -0
- package/dist/src/app/logs/page.jsx +210 -0
- package/dist/src/app/models/page.jsx +291 -0
- package/dist/src/app/page.jsx +236 -0
- package/dist/src/app/providers/page.jsx +402 -0
- package/dist/src/cli/index.js +90 -0
- package/dist/src/db/database.js +69 -0
- package/dist/src/db/queries.js +261 -0
- package/dist/src/db/schema.js +67 -0
- package/dist/src/server/crypto.js +22 -0
- package/dist/src/server/gateway-server.js +200 -0
- package/dist/src/server/gateway.js +76 -0
- package/dist/src/server/logger.js +72 -0
- package/dist/src/server/providers/anthropic.js +52 -0
- package/dist/src/server/providers/gemini.js +64 -0
- package/dist/src/server/providers/index.js +16 -0
- package/dist/src/server/providers/openai.js +86 -0
- package/dist/src/server/providers/types.js +1 -0
- package/dist/src/server/service-manager.js +286 -0
- package/docs/TODO.md +19 -0
- package/next.config.js +7 -0
- package/openspec/AGENTS.md +456 -0
- package/openspec/changes/add-logging/proposal.md +18 -0
- package/openspec/changes/add-logging/specs/core/spec.md +21 -0
- package/openspec/changes/add-logging/tasks.md +16 -0
- package/openspec/changes/add-provider-test-connection/proposal.md +22 -0
- package/openspec/changes/add-provider-test-connection/specs/model-provider/spec.md +68 -0
- package/openspec/changes/add-provider-test-connection/tasks.md +31 -0
- package/openspec/changes/improve-gateway-startup/design.md +137 -0
- package/openspec/changes/improve-gateway-startup/proposal.md +33 -0
- package/openspec/changes/improve-gateway-startup/specs/api-gateway/spec.md +94 -0
- package/openspec/changes/improve-gateway-startup/specs/web-ui/spec.md +67 -0
- package/openspec/changes/improve-gateway-startup/tasks.md +47 -0
- package/openspec/changes/init-api-gateway/design.md +185 -0
- package/openspec/changes/init-api-gateway/proposal.md +30 -0
- package/openspec/changes/init-api-gateway/specs/api-gateway/spec.md +42 -0
- package/openspec/changes/init-api-gateway/specs/cli-tool/spec.md +40 -0
- package/openspec/changes/init-api-gateway/specs/model-management/spec.md +47 -0
- package/openspec/changes/init-api-gateway/specs/model-provider/spec.md +33 -0
- package/openspec/changes/init-api-gateway/specs/request-logging/spec.md +54 -0
- package/openspec/changes/init-api-gateway/specs/web-ui/spec.md +49 -0
- package/openspec/changes/init-api-gateway/tasks.md +84 -0
- package/openspec/project.md +58 -0
- package/package.json +51 -0
- package/postcss.config.js +6 -0
- package/src/app/api/config/route.ts +62 -0
- package/src/app/api/gateway/[...path]/route.ts +118 -0
- package/src/app/api/gateway/route.ts +77 -0
- package/src/app/api/logs/route.ts +48 -0
- package/src/app/api/models/route.ts +210 -0
- package/src/app/api/providers/route.ts +162 -0
- package/src/app/api/providers/test/route.ts +182 -0
- package/src/app/api/service/start/route.ts +73 -0
- package/src/app/api/service/status/route.ts +22 -0
- package/src/app/api/service/stop/route.ts +27 -0
- package/src/app/components/ConfirmDialog.tsx +63 -0
- package/src/app/components/Nav.tsx +66 -0
- package/src/app/components/Toast.tsx +61 -0
- package/src/app/components/ToastProvider.tsx +43 -0
- package/src/app/globals.css +71 -0
- package/src/app/layout.tsx +22 -0
- package/src/app/logs/page.tsx +261 -0
- package/src/app/models/page.tsx +500 -0
- package/src/app/page.tsx +742 -0
- package/src/app/providers/page.tsx +558 -0
- package/src/cli/index.ts +95 -0
- package/src/db/database.ts +125 -0
- package/src/db/queries.ts +339 -0
- package/src/db/schema.ts +117 -0
- package/src/server/crypto.ts +48 -0
- package/src/server/gateway-server.ts +306 -0
- package/src/server/gateway.ts +163 -0
- package/src/server/logger.ts +96 -0
- package/src/server/providers/anthropic.ts +121 -0
- package/src/server/providers/gemini.ts +112 -0
- package/src/server/providers/index.ts +20 -0
- package/src/server/providers/openai.ts +235 -0
- package/src/server/providers/types.ts +20 -0
- package/src/server/service-manager.ts +321 -0
- package/tailwind.config.js +16 -0
- package/tsconfig.json +29 -0
|
@@ -0,0 +1,558 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { useState, useEffect } from 'react';
|
|
4
|
+
import Nav from '../components/Nav';
|
|
5
|
+
import { useToast } from '../components/ToastProvider';
|
|
6
|
+
import ConfirmDialog from '../components/ConfirmDialog';
|
|
7
|
+
|
|
8
|
+
interface Provider {
|
|
9
|
+
id: number;
|
|
10
|
+
name: string;
|
|
11
|
+
protocol: string;
|
|
12
|
+
base_url: string;
|
|
13
|
+
api_key: string;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
interface Model {
|
|
17
|
+
id: number;
|
|
18
|
+
provider_id: number;
|
|
19
|
+
name: string;
|
|
20
|
+
model_id: string;
|
|
21
|
+
enabled: boolean;
|
|
22
|
+
}
|
|
23
|
+
|
|
24
|
+
export default function ProvidersPage() {
|
|
25
|
+
const [providers, setProviders] = useState<Provider[]>([]);
|
|
26
|
+
const [models, setModels] = useState<Model[]>([]);
|
|
27
|
+
const [loading, setLoading] = useState(true);
|
|
28
|
+
const [showModal, setShowModal] = useState(false);
|
|
29
|
+
const [editing, setEditing] = useState<Provider | null>(null);
|
|
30
|
+
const [showConfirm, setShowConfirm] = useState(false);
|
|
31
|
+
const [deleteId, setDeleteId] = useState<number | null>(null);
|
|
32
|
+
const [testingProviderId, setTestingProviderId] = useState<number | null>(null);
|
|
33
|
+
const [selectedModelId, setSelectedModelId] = useState<string>('');
|
|
34
|
+
const [testing, setTesting] = useState(false);
|
|
35
|
+
const [searchQuery, setSearchQuery] = useState('');
|
|
36
|
+
const [formData, setFormData] = useState({
|
|
37
|
+
name: '',
|
|
38
|
+
protocol: 'openai',
|
|
39
|
+
base_url: '',
|
|
40
|
+
api_key: '',
|
|
41
|
+
});
|
|
42
|
+
const [showApiKey, setShowApiKey] = useState(false);
|
|
43
|
+
const { showToast } = useToast();
|
|
44
|
+
|
|
45
|
+
useEffect(() => {
|
|
46
|
+
loadProviders();
|
|
47
|
+
loadModels();
|
|
48
|
+
}, []);
|
|
49
|
+
|
|
50
|
+
const loadProviders = async () => {
|
|
51
|
+
try {
|
|
52
|
+
const res = await fetch('/api/providers');
|
|
53
|
+
const data = await res.json();
|
|
54
|
+
setProviders(data);
|
|
55
|
+
} catch (error) {
|
|
56
|
+
console.error('Failed to load providers:', error);
|
|
57
|
+
} finally {
|
|
58
|
+
setLoading(false);
|
|
59
|
+
}
|
|
60
|
+
};
|
|
61
|
+
|
|
62
|
+
const loadModels = async () => {
|
|
63
|
+
try {
|
|
64
|
+
const res = await fetch('/api/models');
|
|
65
|
+
const data = await res.json();
|
|
66
|
+
setModels(data);
|
|
67
|
+
} catch (error) {
|
|
68
|
+
console.error('Failed to load models:', error);
|
|
69
|
+
}
|
|
70
|
+
};
|
|
71
|
+
|
|
72
|
+
const loadProviderForEdit = async (id: number) => {
|
|
73
|
+
try {
|
|
74
|
+
const res = await fetch(`/api/providers?id=${id}&includeKey=true`);
|
|
75
|
+
if (!res.ok) {
|
|
76
|
+
throw new Error('Failed to load provider');
|
|
77
|
+
}
|
|
78
|
+
const data = await res.json();
|
|
79
|
+
return data;
|
|
80
|
+
} catch (error) {
|
|
81
|
+
console.error('Failed to load provider for edit:', error);
|
|
82
|
+
throw error;
|
|
83
|
+
}
|
|
84
|
+
};
|
|
85
|
+
|
|
86
|
+
const handleSubmit = async (e: React.FormEvent) => {
|
|
87
|
+
e.preventDefault();
|
|
88
|
+
try {
|
|
89
|
+
let response: Response;
|
|
90
|
+
if (editing) {
|
|
91
|
+
const updateData: any = {
|
|
92
|
+
id: editing.id,
|
|
93
|
+
name: formData.name,
|
|
94
|
+
protocol: formData.protocol,
|
|
95
|
+
base_url: formData.base_url,
|
|
96
|
+
};
|
|
97
|
+
if (formData.api_key && formData.api_key.trim() !== '') {
|
|
98
|
+
updateData.api_key = formData.api_key.trim();
|
|
99
|
+
}
|
|
100
|
+
response = await fetch('/api/providers', {
|
|
101
|
+
method: 'PUT',
|
|
102
|
+
headers: { 'Content-Type': 'application/json' },
|
|
103
|
+
body: JSON.stringify(updateData),
|
|
104
|
+
});
|
|
105
|
+
} else {
|
|
106
|
+
if (!formData.api_key || formData.api_key.trim() === '') {
|
|
107
|
+
showToast('API Key 不能为空', 'error');
|
|
108
|
+
return;
|
|
109
|
+
}
|
|
110
|
+
response = await fetch('/api/providers', {
|
|
111
|
+
method: 'POST',
|
|
112
|
+
headers: { 'Content-Type': 'application/json' },
|
|
113
|
+
body: JSON.stringify({
|
|
114
|
+
...formData,
|
|
115
|
+
api_key: formData.api_key.trim(),
|
|
116
|
+
}),
|
|
117
|
+
});
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (!response.ok) {
|
|
121
|
+
const errorData = await response.json().catch(() => ({}));
|
|
122
|
+
const errorMessage = errorData.error || '保存失败';
|
|
123
|
+
showToast(errorMessage, 'error');
|
|
124
|
+
return;
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
setShowModal(false);
|
|
128
|
+
setEditing(null);
|
|
129
|
+
setFormData({ name: '', protocol: 'openai', base_url: '', api_key: '' });
|
|
130
|
+
setShowApiKey(false);
|
|
131
|
+
loadProviders();
|
|
132
|
+
showToast('供应商保存成功', 'success');
|
|
133
|
+
} catch (error: any) {
|
|
134
|
+
console.error('Failed to save provider:', error);
|
|
135
|
+
showToast('保存失败: ' + (error.message || '未知错误'), 'error');
|
|
136
|
+
}
|
|
137
|
+
};
|
|
138
|
+
|
|
139
|
+
const handleEdit = async (provider: Provider) => {
|
|
140
|
+
try {
|
|
141
|
+
// Load provider with API key for editing
|
|
142
|
+
const providerWithKey = await loadProviderForEdit(provider.id);
|
|
143
|
+
setEditing(provider);
|
|
144
|
+
setFormData({
|
|
145
|
+
name: providerWithKey.name,
|
|
146
|
+
protocol: providerWithKey.protocol,
|
|
147
|
+
base_url: providerWithKey.base_url,
|
|
148
|
+
api_key: providerWithKey.api_key || '', // Show existing key
|
|
149
|
+
});
|
|
150
|
+
setShowApiKey(false); // Start with hidden
|
|
151
|
+
setShowModal(true);
|
|
152
|
+
} catch (error) {
|
|
153
|
+
console.error('Failed to load provider for edit:', error);
|
|
154
|
+
showToast('加载供应商信息失败', 'error');
|
|
155
|
+
}
|
|
156
|
+
};
|
|
157
|
+
|
|
158
|
+
const handleDelete = (id: number) => {
|
|
159
|
+
setDeleteId(id);
|
|
160
|
+
setShowConfirm(true);
|
|
161
|
+
};
|
|
162
|
+
|
|
163
|
+
const confirmDelete = async () => {
|
|
164
|
+
if (deleteId) {
|
|
165
|
+
try {
|
|
166
|
+
await fetch(`/api/providers?id=${deleteId}`, { method: 'DELETE' });
|
|
167
|
+
loadProviders();
|
|
168
|
+
showToast('供应商已删除', 'success');
|
|
169
|
+
} catch (error) {
|
|
170
|
+
console.error('Failed to delete provider:', error);
|
|
171
|
+
showToast('删除失败', 'error');
|
|
172
|
+
}
|
|
173
|
+
}
|
|
174
|
+
setShowConfirm(false);
|
|
175
|
+
setDeleteId(null);
|
|
176
|
+
};
|
|
177
|
+
|
|
178
|
+
const handleTestConnection = (providerId: number) => {
|
|
179
|
+
const providerModels = models.filter(m => m.provider_id === providerId);
|
|
180
|
+
if (providerModels.length === 0) {
|
|
181
|
+
showToast('该供应商下没有模型,请先添加或拉取模型', 'error');
|
|
182
|
+
return;
|
|
183
|
+
}
|
|
184
|
+
setTestingProviderId(providerId);
|
|
185
|
+
setSelectedModelId('');
|
|
186
|
+
setSearchQuery('');
|
|
187
|
+
};
|
|
188
|
+
|
|
189
|
+
const startTest = async () => {
|
|
190
|
+
if (!testingProviderId || !selectedModelId) {
|
|
191
|
+
showToast('请选择要测试的模型', 'error');
|
|
192
|
+
return;
|
|
193
|
+
}
|
|
194
|
+
|
|
195
|
+
setTesting(true);
|
|
196
|
+
try {
|
|
197
|
+
const res = await fetch('/api/providers/test', {
|
|
198
|
+
method: 'POST',
|
|
199
|
+
headers: { 'Content-Type': 'application/json' },
|
|
200
|
+
body: JSON.stringify({
|
|
201
|
+
provider_id: testingProviderId,
|
|
202
|
+
model_id: parseInt(selectedModelId),
|
|
203
|
+
}),
|
|
204
|
+
});
|
|
205
|
+
|
|
206
|
+
const data = await res.json();
|
|
207
|
+
if (data.success) {
|
|
208
|
+
showToast(data.message || '连接成功,模型可用', 'success');
|
|
209
|
+
} else {
|
|
210
|
+
showToast(data.error || '连接失败', 'error');
|
|
211
|
+
}
|
|
212
|
+
} catch (error: any) {
|
|
213
|
+
console.error('Failed to test connection:', error);
|
|
214
|
+
showToast('测试连接失败: ' + (error.message || '未知错误'), 'error');
|
|
215
|
+
} finally {
|
|
216
|
+
setTesting(false);
|
|
217
|
+
setTestingProviderId(null);
|
|
218
|
+
setSelectedModelId('');
|
|
219
|
+
}
|
|
220
|
+
};
|
|
221
|
+
|
|
222
|
+
const cancelTest = () => {
|
|
223
|
+
setTestingProviderId(null);
|
|
224
|
+
setSelectedModelId('');
|
|
225
|
+
setSearchQuery('');
|
|
226
|
+
};
|
|
227
|
+
|
|
228
|
+
const getProviderModels = (providerId: number) => {
|
|
229
|
+
const providerModels = models.filter(m => m.provider_id === providerId);
|
|
230
|
+
if (!searchQuery.trim()) {
|
|
231
|
+
return providerModels;
|
|
232
|
+
}
|
|
233
|
+
const query = searchQuery.toLowerCase().trim();
|
|
234
|
+
return providerModels.filter(model =>
|
|
235
|
+
model.name.toLowerCase().includes(query) ||
|
|
236
|
+
model.model_id.toLowerCase().includes(query)
|
|
237
|
+
);
|
|
238
|
+
};
|
|
239
|
+
|
|
240
|
+
if (loading) {
|
|
241
|
+
return (
|
|
242
|
+
<>
|
|
243
|
+
<Nav />
|
|
244
|
+
<div className="min-h-screen flex items-center justify-center">
|
|
245
|
+
<div className="text-lg">加载中...</div>
|
|
246
|
+
</div>
|
|
247
|
+
</>
|
|
248
|
+
);
|
|
249
|
+
}
|
|
250
|
+
|
|
251
|
+
return (
|
|
252
|
+
<>
|
|
253
|
+
<Nav />
|
|
254
|
+
<main className="max-w-6xl mx-auto py-6 sm:px-6 lg:px-8">
|
|
255
|
+
<div className="px-4 py-4 sm:px-0">
|
|
256
|
+
<div className="flex justify-between items-center mb-5">
|
|
257
|
+
<div>
|
|
258
|
+
<h1 className="text-lg font-bold text-slate-800">供应商管理</h1>
|
|
259
|
+
<p className="text-xs text-slate-500 mt-1">管理 AI 模型供应商配置</p>
|
|
260
|
+
</div>
|
|
261
|
+
<button
|
|
262
|
+
onClick={() => {
|
|
263
|
+
setEditing(null);
|
|
264
|
+
setFormData({ name: '', protocol: 'openai', base_url: '', api_key: '' });
|
|
265
|
+
setShowApiKey(false);
|
|
266
|
+
setShowModal(true);
|
|
267
|
+
}}
|
|
268
|
+
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"
|
|
269
|
+
>
|
|
270
|
+
<svg className="w-3.5 h-3.5 mr-1.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
271
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 4v16m8-8H4" />
|
|
272
|
+
</svg>
|
|
273
|
+
添加供应商
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
|
|
277
|
+
<div className="bg-white/70 backdrop-blur-sm shadow-md rounded-2xl border border-emerald-100/50 overflow-hidden">
|
|
278
|
+
<table className="min-w-full divide-y divide-slate-100">
|
|
279
|
+
<thead className="bg-emerald-50/30">
|
|
280
|
+
<tr>
|
|
281
|
+
<th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
|
282
|
+
名称
|
|
283
|
+
</th>
|
|
284
|
+
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
|
285
|
+
协议
|
|
286
|
+
</th>
|
|
287
|
+
<th className="px-6 py-4 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
|
|
288
|
+
Base URL
|
|
289
|
+
</th>
|
|
290
|
+
<th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-700 uppercase tracking-wider">
|
|
291
|
+
操作
|
|
292
|
+
</th>
|
|
293
|
+
</tr>
|
|
294
|
+
</thead>
|
|
295
|
+
<tbody className="bg-white divide-y divide-slate-100">
|
|
296
|
+
{providers.length === 0 ? (
|
|
297
|
+
<tr>
|
|
298
|
+
<td colSpan={4} className="px-4 py-12 text-center">
|
|
299
|
+
<div className="text-slate-400">
|
|
300
|
+
<svg className="mx-auto h-10 w-10 mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
301
|
+
<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" />
|
|
302
|
+
</svg>
|
|
303
|
+
<p className="text-xs text-slate-500">暂无供应商,请先添加供应商</p>
|
|
304
|
+
</div>
|
|
305
|
+
</td>
|
|
306
|
+
</tr>
|
|
307
|
+
) : (
|
|
308
|
+
providers.map((provider) => (
|
|
309
|
+
<tr key={provider.id} className="hover:bg-emerald-50/20 transition-colors duration-300">
|
|
310
|
+
<td className="px-4 py-3 whitespace-nowrap">
|
|
311
|
+
<div className="text-xs font-medium text-slate-800">{provider.name}</div>
|
|
312
|
+
</td>
|
|
313
|
+
<td className="px-4 py-3 whitespace-nowrap">
|
|
314
|
+
<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">
|
|
315
|
+
{provider.protocol}
|
|
316
|
+
</span>
|
|
317
|
+
</td>
|
|
318
|
+
<td className="px-4 py-3 whitespace-nowrap">
|
|
319
|
+
<div className="text-xs text-slate-600 font-mono">{provider.base_url}</div>
|
|
320
|
+
</td>
|
|
321
|
+
<td className="px-4 py-3 whitespace-nowrap text-right text-xs font-medium">
|
|
322
|
+
<div className="flex items-center justify-end space-x-3">
|
|
323
|
+
<button
|
|
324
|
+
onClick={() => handleTestConnection(provider.id)}
|
|
325
|
+
className="text-blue-600 hover:text-blue-700 font-medium transition-colors duration-300"
|
|
326
|
+
>
|
|
327
|
+
测试连接
|
|
328
|
+
</button>
|
|
329
|
+
<button
|
|
330
|
+
onClick={() => handleEdit(provider)}
|
|
331
|
+
className="text-emerald-600 hover:text-emerald-700 font-medium transition-colors duration-300"
|
|
332
|
+
>
|
|
333
|
+
编辑
|
|
334
|
+
</button>
|
|
335
|
+
<button
|
|
336
|
+
onClick={() => handleDelete(provider.id)}
|
|
337
|
+
className="text-rose-500 hover:text-rose-600 font-medium transition-colors duration-300"
|
|
338
|
+
>
|
|
339
|
+
删除
|
|
340
|
+
</button>
|
|
341
|
+
</div>
|
|
342
|
+
</td>
|
|
343
|
+
</tr>
|
|
344
|
+
))
|
|
345
|
+
)}
|
|
346
|
+
</tbody>
|
|
347
|
+
</table>
|
|
348
|
+
</div>
|
|
349
|
+
</div>
|
|
350
|
+
</main>
|
|
351
|
+
|
|
352
|
+
{showModal && (
|
|
353
|
+
<div className="fixed z-50 inset-0 overflow-y-auto">
|
|
354
|
+
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
355
|
+
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={() => setShowModal(false)}></div>
|
|
356
|
+
<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">
|
|
357
|
+
<form onSubmit={handleSubmit} className="bg-white px-6 pt-5 pb-4 sm:p-6">
|
|
358
|
+
<div className="mb-5">
|
|
359
|
+
<h3 className="text-base font-bold text-slate-800 mb-1">
|
|
360
|
+
{editing ? '编辑供应商' : '添加供应商'}
|
|
361
|
+
</h3>
|
|
362
|
+
<p className="text-xs text-slate-500">配置 AI 模型供应商信息</p>
|
|
363
|
+
</div>
|
|
364
|
+
<div className="space-y-4">
|
|
365
|
+
<div>
|
|
366
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">名称</label>
|
|
367
|
+
<input
|
|
368
|
+
type="text"
|
|
369
|
+
required
|
|
370
|
+
value={formData.name}
|
|
371
|
+
onChange={(e) => setFormData({ ...formData, name: e.target.value })}
|
|
372
|
+
autoComplete="off"
|
|
373
|
+
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"
|
|
374
|
+
placeholder="例如: OpenAI"
|
|
375
|
+
/>
|
|
376
|
+
</div>
|
|
377
|
+
<div>
|
|
378
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">协议</label>
|
|
379
|
+
<select
|
|
380
|
+
value={formData.protocol}
|
|
381
|
+
onChange={(e) => setFormData({ ...formData, protocol: e.target.value })}
|
|
382
|
+
autoComplete="off"
|
|
383
|
+
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"
|
|
384
|
+
>
|
|
385
|
+
<option value="openai">OpenAI</option>
|
|
386
|
+
<option value="anthropic">Anthropic</option>
|
|
387
|
+
<option value="gemini">Gemini</option>
|
|
388
|
+
</select>
|
|
389
|
+
</div>
|
|
390
|
+
<div>
|
|
391
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">Base URL</label>
|
|
392
|
+
<input
|
|
393
|
+
type="url"
|
|
394
|
+
required
|
|
395
|
+
value={formData.base_url}
|
|
396
|
+
onChange={(e) => setFormData({ ...formData, base_url: e.target.value })}
|
|
397
|
+
autoComplete="off"
|
|
398
|
+
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"
|
|
399
|
+
placeholder="https://api.openai.com/v1"
|
|
400
|
+
/>
|
|
401
|
+
</div>
|
|
402
|
+
<div>
|
|
403
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">
|
|
404
|
+
API Key
|
|
405
|
+
{editing && (
|
|
406
|
+
<span className="ml-1 text-[10px] text-slate-400 font-normal">(已保存,输入新值可更新)</span>
|
|
407
|
+
)}
|
|
408
|
+
</label>
|
|
409
|
+
<div className="relative">
|
|
410
|
+
<input
|
|
411
|
+
type={showApiKey ? 'text' : 'password'}
|
|
412
|
+
required={!editing}
|
|
413
|
+
value={formData.api_key}
|
|
414
|
+
onChange={(e) => setFormData({ ...formData, api_key: e.target.value })}
|
|
415
|
+
autoComplete="new-password"
|
|
416
|
+
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"
|
|
417
|
+
placeholder={editing ? '留空则不更新,输入新值可更新 API Key' : '输入 API Key'}
|
|
418
|
+
/>
|
|
419
|
+
<button
|
|
420
|
+
type="button"
|
|
421
|
+
onClick={() => setShowApiKey(!showApiKey)}
|
|
422
|
+
className="absolute right-2 top-1/2 -translate-y-1/2 p-1 text-slate-400 hover:text-slate-600 transition-colors"
|
|
423
|
+
tabIndex={-1}
|
|
424
|
+
>
|
|
425
|
+
{showApiKey ? (
|
|
426
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
427
|
+
<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" />
|
|
428
|
+
</svg>
|
|
429
|
+
) : (
|
|
430
|
+
<svg className="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
431
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 12a3 3 0 11-6 0 3 3 0 016 0z" />
|
|
432
|
+
<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" />
|
|
433
|
+
</svg>
|
|
434
|
+
)}
|
|
435
|
+
</button>
|
|
436
|
+
</div>
|
|
437
|
+
</div>
|
|
438
|
+
</div>
|
|
439
|
+
<div className="mt-5 flex items-center justify-end space-x-2">
|
|
440
|
+
<button
|
|
441
|
+
type="button"
|
|
442
|
+
onClick={() => {
|
|
443
|
+
setShowModal(false);
|
|
444
|
+
setEditing(null);
|
|
445
|
+
}}
|
|
446
|
+
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"
|
|
447
|
+
>
|
|
448
|
+
取消
|
|
449
|
+
</button>
|
|
450
|
+
<button
|
|
451
|
+
type="submit"
|
|
452
|
+
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"
|
|
453
|
+
>
|
|
454
|
+
保存
|
|
455
|
+
</button>
|
|
456
|
+
</div>
|
|
457
|
+
</form>
|
|
458
|
+
</div>
|
|
459
|
+
</div>
|
|
460
|
+
</div>
|
|
461
|
+
)}
|
|
462
|
+
|
|
463
|
+
<ConfirmDialog
|
|
464
|
+
open={showConfirm}
|
|
465
|
+
title="确认删除"
|
|
466
|
+
message="确定要删除这个供应商吗?此操作不可恢复。"
|
|
467
|
+
onConfirm={confirmDelete}
|
|
468
|
+
onCancel={() => {
|
|
469
|
+
setShowConfirm(false);
|
|
470
|
+
setDeleteId(null);
|
|
471
|
+
}}
|
|
472
|
+
confirmText="删除"
|
|
473
|
+
cancelText="取消"
|
|
474
|
+
type="danger"
|
|
475
|
+
/>
|
|
476
|
+
|
|
477
|
+
{/* Test Connection Modal */}
|
|
478
|
+
{testingProviderId && (
|
|
479
|
+
<div className="fixed z-50 inset-0 overflow-y-auto">
|
|
480
|
+
<div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
|
|
481
|
+
<div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={cancelTest}></div>
|
|
482
|
+
<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">
|
|
483
|
+
<div className="bg-white px-6 pt-5 pb-4 sm:p-6">
|
|
484
|
+
<div className="mb-5">
|
|
485
|
+
<h3 className="text-base font-bold text-slate-800 mb-1">测试连接</h3>
|
|
486
|
+
<p className="text-xs text-slate-500">选择一个模型进行连接测试</p>
|
|
487
|
+
</div>
|
|
488
|
+
<div className="space-y-4">
|
|
489
|
+
<div>
|
|
490
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">搜索模型</label>
|
|
491
|
+
<div className="relative">
|
|
492
|
+
<input
|
|
493
|
+
type="text"
|
|
494
|
+
value={searchQuery}
|
|
495
|
+
onChange={(e) => setSearchQuery(e.target.value)}
|
|
496
|
+
disabled={testing}
|
|
497
|
+
autoComplete="off"
|
|
498
|
+
className="block w-full rounded-lg border border-slate-200 bg-white/80 pl-9 pr-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"
|
|
499
|
+
placeholder="输入模型名称或ID进行搜索..."
|
|
500
|
+
/>
|
|
501
|
+
<svg className="absolute left-3 top-1/2 -translate-y-1/2 w-3.5 h-3.5 text-slate-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
502
|
+
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z" />
|
|
503
|
+
</svg>
|
|
504
|
+
</div>
|
|
505
|
+
</div>
|
|
506
|
+
<div>
|
|
507
|
+
<label className="block text-xs font-semibold text-slate-700 mb-1.5">选择模型</label>
|
|
508
|
+
<select
|
|
509
|
+
value={selectedModelId}
|
|
510
|
+
onChange={(e) => setSelectedModelId(e.target.value)}
|
|
511
|
+
disabled={testing}
|
|
512
|
+
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"
|
|
513
|
+
>
|
|
514
|
+
<option value="">请选择模型</option>
|
|
515
|
+
{getProviderModels(testingProviderId).map((model) => (
|
|
516
|
+
<option key={model.id} value={model.id}>
|
|
517
|
+
{model.name} ({model.model_id})
|
|
518
|
+
</option>
|
|
519
|
+
))}
|
|
520
|
+
</select>
|
|
521
|
+
</div>
|
|
522
|
+
</div>
|
|
523
|
+
<div className="mt-5 flex items-center justify-end space-x-2">
|
|
524
|
+
<button
|
|
525
|
+
type="button"
|
|
526
|
+
onClick={cancelTest}
|
|
527
|
+
disabled={testing}
|
|
528
|
+
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"
|
|
529
|
+
>
|
|
530
|
+
取消
|
|
531
|
+
</button>
|
|
532
|
+
<button
|
|
533
|
+
type="button"
|
|
534
|
+
onClick={startTest}
|
|
535
|
+
disabled={testing || !selectedModelId}
|
|
536
|
+
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"
|
|
537
|
+
>
|
|
538
|
+
{testing ? (
|
|
539
|
+
<>
|
|
540
|
+
<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">
|
|
541
|
+
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
542
|
+
<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>
|
|
543
|
+
</svg>
|
|
544
|
+
测试中...
|
|
545
|
+
</>
|
|
546
|
+
) : (
|
|
547
|
+
'开始测试'
|
|
548
|
+
)}
|
|
549
|
+
</button>
|
|
550
|
+
</div>
|
|
551
|
+
</div>
|
|
552
|
+
</div>
|
|
553
|
+
</div>
|
|
554
|
+
</div>
|
|
555
|
+
)}
|
|
556
|
+
</>
|
|
557
|
+
);
|
|
558
|
+
}
|
package/src/cli/index.ts
ADDED
|
@@ -0,0 +1,95 @@
|
|
|
1
|
+
#!/usr/bin/env node
|
|
2
|
+
|
|
3
|
+
import { Command } from 'commander';
|
|
4
|
+
import { GatewayServer } from '../server/gateway-server';
|
|
5
|
+
import { getDatabase } from '../db/database';
|
|
6
|
+
import { getConfig, setConfig } from '../db/queries';
|
|
7
|
+
|
|
8
|
+
const program = new Command();
|
|
9
|
+
|
|
10
|
+
program
|
|
11
|
+
.name('aar')
|
|
12
|
+
.description('AI Agent Router - Unified gateway for managing multiple AI model providers')
|
|
13
|
+
.version('0.1.0');
|
|
14
|
+
|
|
15
|
+
program
|
|
16
|
+
.command('start')
|
|
17
|
+
.description('Start the API gateway server (gateway only, no Web UI)')
|
|
18
|
+
.option('-p, --port <port>', 'Port to listen on', '3000')
|
|
19
|
+
.option('--hostname <hostname>', 'Hostname to listen on', 'localhost')
|
|
20
|
+
.action(async (options) => {
|
|
21
|
+
const port = parseInt(options.port || '3000');
|
|
22
|
+
const hostname = options.hostname || 'localhost';
|
|
23
|
+
|
|
24
|
+
// Initialize database to get config
|
|
25
|
+
try {
|
|
26
|
+
getDatabase();
|
|
27
|
+
} catch (error: any) {
|
|
28
|
+
console.error('Failed to initialize database:', error);
|
|
29
|
+
process.exit(1);
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
// Get API key from config if configured
|
|
33
|
+
const apiKeyConfig = getConfig('api_key');
|
|
34
|
+
const apiKey = apiKeyConfig ? apiKeyConfig.value : undefined;
|
|
35
|
+
|
|
36
|
+
console.log(`Starting AI Agent Router Gateway Server`);
|
|
37
|
+
console.log(` Port: ${port}`);
|
|
38
|
+
console.log(` Hostname: ${hostname}`);
|
|
39
|
+
if (apiKey) {
|
|
40
|
+
console.log(` API Key: Configured (authentication enabled)`);
|
|
41
|
+
} else {
|
|
42
|
+
console.log(` API Key: Not configured (authentication disabled)`);
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
// Create and start gateway server
|
|
46
|
+
const server = new GatewayServer({
|
|
47
|
+
port,
|
|
48
|
+
hostname,
|
|
49
|
+
apiKey,
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
try {
|
|
53
|
+
await server.start();
|
|
54
|
+
} catch (error: any) {
|
|
55
|
+
console.error(`Failed to start gateway server: ${error.message}`);
|
|
56
|
+
process.exit(1);
|
|
57
|
+
}
|
|
58
|
+
});
|
|
59
|
+
|
|
60
|
+
program
|
|
61
|
+
.command('config')
|
|
62
|
+
.description('Manage gateway configuration')
|
|
63
|
+
.option('--get <key>', 'Get configuration value')
|
|
64
|
+
.option('--set <key> <value>', 'Set configuration value')
|
|
65
|
+
.action(async (options) => {
|
|
66
|
+
// Initialize database
|
|
67
|
+
try {
|
|
68
|
+
getDatabase();
|
|
69
|
+
} catch (error: any) {
|
|
70
|
+
console.error('Failed to initialize database:', error);
|
|
71
|
+
process.exit(1);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
if (options.get) {
|
|
75
|
+
const config = getConfig(options.get);
|
|
76
|
+
if (config) {
|
|
77
|
+
console.log(config.value);
|
|
78
|
+
} else {
|
|
79
|
+
console.error(`Config key "${options.get}" not found`);
|
|
80
|
+
process.exit(1);
|
|
81
|
+
}
|
|
82
|
+
} else if (options.set) {
|
|
83
|
+
const [key, value] = options.set.split(' ');
|
|
84
|
+
if (!key || !value) {
|
|
85
|
+
console.error('Usage: --set <key> <value>');
|
|
86
|
+
process.exit(1);
|
|
87
|
+
}
|
|
88
|
+
setConfig(key, value);
|
|
89
|
+
console.log(`Config "${key}" set to "${value}"`);
|
|
90
|
+
} else {
|
|
91
|
+
program.help();
|
|
92
|
+
}
|
|
93
|
+
});
|
|
94
|
+
|
|
95
|
+
program.parse();
|