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,52 @@
|
|
|
1
|
+
import { decryptApiKey } from '../crypto.js';
|
|
2
|
+
export class AnthropicAdapter {
|
|
3
|
+
async forwardRequest(model, request) {
|
|
4
|
+
const apiKey = decryptApiKey(model.provider.api_key);
|
|
5
|
+
const baseUrl = model.provider.base_url || 'https://api.anthropic.com/v1';
|
|
6
|
+
// Build the target URL
|
|
7
|
+
let targetPath = request.path;
|
|
8
|
+
if (targetPath.startsWith('/v1/')) {
|
|
9
|
+
targetPath = targetPath.substring(4);
|
|
10
|
+
}
|
|
11
|
+
const url = `${baseUrl}/${targetPath}`;
|
|
12
|
+
// Prepare headers
|
|
13
|
+
const headers = {
|
|
14
|
+
'x-api-key': apiKey,
|
|
15
|
+
'anthropic-version': '2023-06-01',
|
|
16
|
+
'Content-Type': 'application/json',
|
|
17
|
+
...request.headers,
|
|
18
|
+
};
|
|
19
|
+
delete headers['host'];
|
|
20
|
+
delete headers['connection'];
|
|
21
|
+
delete headers['authorization'];
|
|
22
|
+
// Make the request
|
|
23
|
+
const response = await fetch(url, {
|
|
24
|
+
method: request.method,
|
|
25
|
+
headers,
|
|
26
|
+
body: request.method !== 'GET' && request.method !== 'HEAD' ? JSON.stringify(request.body) : undefined,
|
|
27
|
+
});
|
|
28
|
+
const responseBody = await response.text();
|
|
29
|
+
let parsedBody;
|
|
30
|
+
try {
|
|
31
|
+
parsedBody = JSON.parse(responseBody);
|
|
32
|
+
}
|
|
33
|
+
catch {
|
|
34
|
+
parsedBody = responseBody;
|
|
35
|
+
}
|
|
36
|
+
return {
|
|
37
|
+
status: response.status,
|
|
38
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
39
|
+
body: parsedBody,
|
|
40
|
+
};
|
|
41
|
+
}
|
|
42
|
+
async listModels(provider) {
|
|
43
|
+
// Anthropic doesn't have a public models endpoint
|
|
44
|
+
// Return common models
|
|
45
|
+
return [
|
|
46
|
+
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet' },
|
|
47
|
+
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus' },
|
|
48
|
+
{ id: 'claude-3-sonnet-20240229', name: 'Claude 3 Sonnet' },
|
|
49
|
+
{ id: 'claude-3-haiku-20240307', name: 'Claude 3 Haiku' },
|
|
50
|
+
];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import { decryptApiKey } from '../crypto.js';
|
|
2
|
+
export class GeminiAdapter {
|
|
3
|
+
async forwardRequest(model, request) {
|
|
4
|
+
const apiKey = decryptApiKey(model.provider.api_key);
|
|
5
|
+
const baseUrl = model.provider.base_url || 'https://generativelanguage.googleapis.com/v1';
|
|
6
|
+
// Build the target URL
|
|
7
|
+
let targetPath = request.path;
|
|
8
|
+
if (targetPath.startsWith('/v1/')) {
|
|
9
|
+
targetPath = targetPath.substring(4);
|
|
10
|
+
}
|
|
11
|
+
// Add API key to query params for Gemini
|
|
12
|
+
const url = `${baseUrl}/${targetPath}?key=${apiKey}`;
|
|
13
|
+
// Prepare headers
|
|
14
|
+
const headers = {
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
...request.headers,
|
|
17
|
+
};
|
|
18
|
+
delete headers['host'];
|
|
19
|
+
delete headers['connection'];
|
|
20
|
+
delete headers['authorization'];
|
|
21
|
+
// Make the request
|
|
22
|
+
const response = await fetch(url, {
|
|
23
|
+
method: request.method,
|
|
24
|
+
headers,
|
|
25
|
+
body: request.method !== 'GET' && request.method !== 'HEAD' ? JSON.stringify(request.body) : undefined,
|
|
26
|
+
});
|
|
27
|
+
const responseBody = await response.text();
|
|
28
|
+
let parsedBody;
|
|
29
|
+
try {
|
|
30
|
+
parsedBody = JSON.parse(responseBody);
|
|
31
|
+
}
|
|
32
|
+
catch {
|
|
33
|
+
parsedBody = responseBody;
|
|
34
|
+
}
|
|
35
|
+
return {
|
|
36
|
+
status: response.status,
|
|
37
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
38
|
+
body: parsedBody,
|
|
39
|
+
};
|
|
40
|
+
}
|
|
41
|
+
async listModels(provider) {
|
|
42
|
+
const apiKey = decryptApiKey(provider.api_key);
|
|
43
|
+
const baseUrl = provider.base_url || 'https://generativelanguage.googleapis.com/v1';
|
|
44
|
+
try {
|
|
45
|
+
const response = await fetch(`${baseUrl}/models?key=${apiKey}`, {
|
|
46
|
+
headers: {
|
|
47
|
+
'Content-Type': 'application/json',
|
|
48
|
+
},
|
|
49
|
+
});
|
|
50
|
+
if (!response.ok) {
|
|
51
|
+
throw new Error(`Failed to fetch models: ${response.statusText}`);
|
|
52
|
+
}
|
|
53
|
+
const data = await response.json();
|
|
54
|
+
return (data.models || []).map((model) => ({
|
|
55
|
+
id: model.name.replace('models/', ''),
|
|
56
|
+
name: model.displayName || model.name,
|
|
57
|
+
}));
|
|
58
|
+
}
|
|
59
|
+
catch (error) {
|
|
60
|
+
console.error('Error fetching Gemini models:', error);
|
|
61
|
+
throw error;
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { OpenAIAdapter } from './openai.js';
|
|
2
|
+
import { AnthropicAdapter } from './anthropic.js';
|
|
3
|
+
import { GeminiAdapter } from './gemini.js';
|
|
4
|
+
export function getProviderAdapter(protocol) {
|
|
5
|
+
switch (protocol) {
|
|
6
|
+
case 'openai':
|
|
7
|
+
return new OpenAIAdapter();
|
|
8
|
+
case 'anthropic':
|
|
9
|
+
return new AnthropicAdapter();
|
|
10
|
+
case 'gemini':
|
|
11
|
+
return new GeminiAdapter();
|
|
12
|
+
default:
|
|
13
|
+
throw new Error(`Unsupported protocol: ${protocol}`);
|
|
14
|
+
}
|
|
15
|
+
}
|
|
16
|
+
export { OpenAIAdapter, AnthropicAdapter, GeminiAdapter };
|
|
@@ -0,0 +1,86 @@
|
|
|
1
|
+
import { decryptApiKey } from '../crypto.js';
|
|
2
|
+
export class OpenAIAdapter {
|
|
3
|
+
async forwardRequest(model, request) {
|
|
4
|
+
const apiKey = decryptApiKey(model.provider.api_key);
|
|
5
|
+
const baseUrl = model.provider.base_url || 'https://api.openai.com/v1';
|
|
6
|
+
// Build the target URL
|
|
7
|
+
let targetPath = request.path;
|
|
8
|
+
if (targetPath.startsWith('/v1/')) {
|
|
9
|
+
targetPath = targetPath.substring(4);
|
|
10
|
+
}
|
|
11
|
+
const url = `${baseUrl}/${targetPath}`;
|
|
12
|
+
// Prepare headers
|
|
13
|
+
const headers = {
|
|
14
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
15
|
+
'Content-Type': 'application/json',
|
|
16
|
+
...request.headers,
|
|
17
|
+
};
|
|
18
|
+
delete headers['host'];
|
|
19
|
+
delete headers['connection'];
|
|
20
|
+
// Make the request
|
|
21
|
+
const response = await fetch(url, {
|
|
22
|
+
method: request.method,
|
|
23
|
+
headers,
|
|
24
|
+
body: request.method !== 'GET' && request.method !== 'HEAD' ? JSON.stringify(request.body) : undefined,
|
|
25
|
+
});
|
|
26
|
+
const responseBody = await response.text();
|
|
27
|
+
let parsedBody;
|
|
28
|
+
try {
|
|
29
|
+
parsedBody = JSON.parse(responseBody);
|
|
30
|
+
}
|
|
31
|
+
catch {
|
|
32
|
+
parsedBody = responseBody;
|
|
33
|
+
}
|
|
34
|
+
return {
|
|
35
|
+
status: response.status,
|
|
36
|
+
headers: Object.fromEntries(response.headers.entries()),
|
|
37
|
+
body: parsedBody,
|
|
38
|
+
};
|
|
39
|
+
}
|
|
40
|
+
async listModels(provider) {
|
|
41
|
+
const apiKey = decryptApiKey(provider.api_key);
|
|
42
|
+
let baseUrl = provider.base_url || 'https://api.openai.com/v1';
|
|
43
|
+
// Ensure baseUrl doesn't end with / and has /v1
|
|
44
|
+
baseUrl = baseUrl.trim().replace(/\/+$/, ''); // Remove trailing slashes
|
|
45
|
+
if (!baseUrl.endsWith('/v1')) {
|
|
46
|
+
baseUrl = baseUrl.endsWith('/') ? baseUrl + 'v1' : baseUrl + '/v1';
|
|
47
|
+
}
|
|
48
|
+
const url = `${baseUrl}/models`;
|
|
49
|
+
console.log('[OpenAI] Fetching models:', {
|
|
50
|
+
baseUrl: provider.base_url,
|
|
51
|
+
normalizedBaseUrl: baseUrl,
|
|
52
|
+
url,
|
|
53
|
+
apiKeyPrefix: apiKey.substring(0, 10) + '...',
|
|
54
|
+
apiKeyLength: apiKey.length,
|
|
55
|
+
});
|
|
56
|
+
try {
|
|
57
|
+
const response = await fetch(url, {
|
|
58
|
+
headers: {
|
|
59
|
+
'Authorization': `Bearer ${apiKey}`,
|
|
60
|
+
'Content-Type': 'application/json',
|
|
61
|
+
},
|
|
62
|
+
});
|
|
63
|
+
console.log('[OpenAI] Response status:', response.status, response.statusText);
|
|
64
|
+
if (!response.ok) {
|
|
65
|
+
const errorText = await response.text();
|
|
66
|
+
console.error('[OpenAI] Error response body:', errorText);
|
|
67
|
+
throw new Error(`Failed to fetch models: ${response.status} ${response.statusText} - ${errorText.substring(0, 200)}`);
|
|
68
|
+
}
|
|
69
|
+
const data = await response.json();
|
|
70
|
+
console.log('[OpenAI] Fetched models count:', data.data?.length || 0);
|
|
71
|
+
return (data.data || []).map((model) => ({
|
|
72
|
+
id: model.id,
|
|
73
|
+
name: model.id, // OpenAI uses model ID as name
|
|
74
|
+
}));
|
|
75
|
+
}
|
|
76
|
+
catch (error) {
|
|
77
|
+
console.error('[OpenAI] Error fetching models:', {
|
|
78
|
+
error: error instanceof Error ? error.message : String(error),
|
|
79
|
+
stack: error instanceof Error ? error.stack : undefined,
|
|
80
|
+
url,
|
|
81
|
+
baseUrl: provider.base_url,
|
|
82
|
+
});
|
|
83
|
+
throw error;
|
|
84
|
+
}
|
|
85
|
+
}
|
|
86
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,286 @@
|
|
|
1
|
+
import { spawn, exec } from 'child_process';
|
|
2
|
+
import { promisify } from 'util';
|
|
3
|
+
import { getServiceStatus, setServiceStatus, updateServiceStatus } from '../db/queries.js';
|
|
4
|
+
import path from 'path';
|
|
5
|
+
import fs from 'fs';
|
|
6
|
+
import net from 'net';
|
|
7
|
+
const execAsync = promisify(exec);
|
|
8
|
+
class ServiceManager {
|
|
9
|
+
constructor() {
|
|
10
|
+
this.childProcess = null;
|
|
11
|
+
this.isStarting = false;
|
|
12
|
+
}
|
|
13
|
+
/**
|
|
14
|
+
* Check if a process with the given PID exists
|
|
15
|
+
*/
|
|
16
|
+
async checkProcessExists(pid) {
|
|
17
|
+
try {
|
|
18
|
+
// Use 'ps' command to check if process exists
|
|
19
|
+
// This works on Unix-like systems (macOS, Linux)
|
|
20
|
+
await execAsync(`ps -p ${pid} -o pid=`);
|
|
21
|
+
return true;
|
|
22
|
+
}
|
|
23
|
+
catch {
|
|
24
|
+
return false;
|
|
25
|
+
}
|
|
26
|
+
}
|
|
27
|
+
/**
|
|
28
|
+
* Check if a port is available
|
|
29
|
+
*/
|
|
30
|
+
async checkPortAvailable(port) {
|
|
31
|
+
return new Promise((resolve) => {
|
|
32
|
+
const server = net.createServer();
|
|
33
|
+
server.listen(port, () => {
|
|
34
|
+
server.once('close', () => resolve(true));
|
|
35
|
+
server.close();
|
|
36
|
+
});
|
|
37
|
+
server.on('error', () => {
|
|
38
|
+
resolve(false);
|
|
39
|
+
});
|
|
40
|
+
});
|
|
41
|
+
}
|
|
42
|
+
/**
|
|
43
|
+
* Check if a port is in use and get process info
|
|
44
|
+
*/
|
|
45
|
+
async checkPortInUse(port) {
|
|
46
|
+
return new Promise((resolve) => {
|
|
47
|
+
const server = net.createServer();
|
|
48
|
+
server.listen(port, () => {
|
|
49
|
+
server.once('close', () => resolve({ inUse: false }));
|
|
50
|
+
server.close();
|
|
51
|
+
});
|
|
52
|
+
server.on('error', (err) => {
|
|
53
|
+
if (err.code === 'EADDRINUSE') {
|
|
54
|
+
// Try to get process info
|
|
55
|
+
execAsync(`lsof -ti:${port} 2>/dev/null || echo ''`)
|
|
56
|
+
.then(({ stdout }) => {
|
|
57
|
+
const pid = stdout.trim();
|
|
58
|
+
if (pid) {
|
|
59
|
+
resolve({ inUse: true, processInfo: `Port ${port} is in use by process ${pid}` });
|
|
60
|
+
}
|
|
61
|
+
else {
|
|
62
|
+
resolve({ inUse: true, processInfo: `Port ${port} is already in use` });
|
|
63
|
+
}
|
|
64
|
+
})
|
|
65
|
+
.catch(() => {
|
|
66
|
+
resolve({ inUse: true, processInfo: `Port ${port} is already in use` });
|
|
67
|
+
});
|
|
68
|
+
}
|
|
69
|
+
else {
|
|
70
|
+
resolve({ inUse: false });
|
|
71
|
+
}
|
|
72
|
+
});
|
|
73
|
+
});
|
|
74
|
+
}
|
|
75
|
+
/**
|
|
76
|
+
* Get current service status, verifying process existence
|
|
77
|
+
*/
|
|
78
|
+
async getStatus() {
|
|
79
|
+
const dbStatus = getServiceStatus();
|
|
80
|
+
if (!dbStatus) {
|
|
81
|
+
return { status: 'stopped' };
|
|
82
|
+
}
|
|
83
|
+
// If status is running, verify process actually exists
|
|
84
|
+
if (dbStatus.status === 'running' && dbStatus.pid) {
|
|
85
|
+
const processExists = await this.checkProcessExists(dbStatus.pid);
|
|
86
|
+
if (!processExists) {
|
|
87
|
+
// Process doesn't exist, update database
|
|
88
|
+
updateServiceStatus({ status: 'stopped', pid: null });
|
|
89
|
+
return { status: 'stopped' };
|
|
90
|
+
}
|
|
91
|
+
}
|
|
92
|
+
return {
|
|
93
|
+
status: dbStatus.status,
|
|
94
|
+
port: dbStatus.port,
|
|
95
|
+
pid: dbStatus.pid,
|
|
96
|
+
started_at: dbStatus.started_at,
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
/**
|
|
100
|
+
* Start the gateway service
|
|
101
|
+
*/
|
|
102
|
+
async start(port) {
|
|
103
|
+
// Prevent concurrent starts
|
|
104
|
+
if (this.isStarting) {
|
|
105
|
+
return { status: 'stopped', error: 'Service is already starting' };
|
|
106
|
+
}
|
|
107
|
+
// Check if service is already running
|
|
108
|
+
const currentStatus = await this.getStatus();
|
|
109
|
+
if (currentStatus.status === 'running') {
|
|
110
|
+
return { status: 'running', error: 'Service is already running', port: currentStatus.port, pid: currentStatus.pid };
|
|
111
|
+
}
|
|
112
|
+
// Check port availability with more details
|
|
113
|
+
const portCheck = await this.checkPortInUse(port);
|
|
114
|
+
if (portCheck.inUse) {
|
|
115
|
+
// In development, if port 3000 is in use, suggest using a different port
|
|
116
|
+
const isDev = process.env.NODE_ENV !== 'production';
|
|
117
|
+
if (isDev && port === 3000) {
|
|
118
|
+
return {
|
|
119
|
+
status: 'stopped',
|
|
120
|
+
error: `Port 3000 is already in use (likely by the development server). Please configure a different port (e.g., 3001) in the settings.`
|
|
121
|
+
};
|
|
122
|
+
}
|
|
123
|
+
return { status: 'stopped', error: portCheck.processInfo || `Port ${port} is already in use` };
|
|
124
|
+
}
|
|
125
|
+
this.isStarting = true;
|
|
126
|
+
try {
|
|
127
|
+
const cliPath = path.join(process.cwd(), 'dist', 'src', 'cli', 'index.js');
|
|
128
|
+
const tsCliPath = path.join(process.cwd(), 'src', 'cli', 'index.ts');
|
|
129
|
+
let child;
|
|
130
|
+
// Create isolated environment for child process
|
|
131
|
+
// Remove Next.js dev server specific env vars to avoid conflicts
|
|
132
|
+
const childEnv = { ...process.env };
|
|
133
|
+
// Remove PORT if it might conflict
|
|
134
|
+
if (childEnv.PORT && parseInt(childEnv.PORT) === 3000) {
|
|
135
|
+
delete childEnv.PORT;
|
|
136
|
+
}
|
|
137
|
+
// Remove Next.js specific env vars that might cause conflicts
|
|
138
|
+
delete childEnv.NEXT_TELEMETRY_DISABLED;
|
|
139
|
+
// Gateway server doesn't need Next.js, so we can use any NODE_ENV
|
|
140
|
+
childEnv.NODE_ENV = process.env.NODE_ENV || 'production';
|
|
141
|
+
// Check if compiled code exists
|
|
142
|
+
if (fs.existsSync(cliPath)) {
|
|
143
|
+
// Use compiled JavaScript
|
|
144
|
+
child = spawn(process.execPath, [cliPath, 'start', '-p', port.toString()], {
|
|
145
|
+
detached: false, // Keep attached for proper tracking
|
|
146
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
147
|
+
cwd: process.cwd(),
|
|
148
|
+
env: childEnv,
|
|
149
|
+
});
|
|
150
|
+
}
|
|
151
|
+
else if (fs.existsSync(tsCliPath)) {
|
|
152
|
+
// In development, try to use tsx via npx
|
|
153
|
+
// Note: tsx should be installed as dev dependency for this to work
|
|
154
|
+
child = spawn('npx', ['--yes', 'tsx', tsCliPath, 'start', '-p', port.toString()], {
|
|
155
|
+
detached: false, // Keep attached for proper tracking
|
|
156
|
+
stdio: ['ignore', 'pipe', 'pipe'],
|
|
157
|
+
cwd: process.cwd(),
|
|
158
|
+
env: childEnv,
|
|
159
|
+
shell: true, // Use shell to resolve npx
|
|
160
|
+
});
|
|
161
|
+
}
|
|
162
|
+
else {
|
|
163
|
+
this.isStarting = false;
|
|
164
|
+
return {
|
|
165
|
+
status: 'stopped',
|
|
166
|
+
error: 'CLI not found. Please run "npm run build" first, or ensure src/cli/index.ts exists.'
|
|
167
|
+
};
|
|
168
|
+
}
|
|
169
|
+
this.childProcess = child;
|
|
170
|
+
// Collect error output for better error messages
|
|
171
|
+
let errorOutput = '';
|
|
172
|
+
let hasExited = false;
|
|
173
|
+
let exitCode = null;
|
|
174
|
+
// Handle process output (optional, for debugging)
|
|
175
|
+
// Use setImmediate to avoid blocking the event loop
|
|
176
|
+
child.stdout?.on('data', (data) => {
|
|
177
|
+
setImmediate(() => {
|
|
178
|
+
const output = data.toString();
|
|
179
|
+
// Only log if it's not empty and not just whitespace
|
|
180
|
+
if (output.trim()) {
|
|
181
|
+
console.log(`[Gateway Service] ${output}`);
|
|
182
|
+
}
|
|
183
|
+
});
|
|
184
|
+
});
|
|
185
|
+
child.stderr?.on('data', (data) => {
|
|
186
|
+
setImmediate(() => {
|
|
187
|
+
const errorText = data.toString();
|
|
188
|
+
console.error(`[Gateway Service Error] ${errorText}`);
|
|
189
|
+
errorOutput += errorText;
|
|
190
|
+
});
|
|
191
|
+
});
|
|
192
|
+
// Handle process exit
|
|
193
|
+
child.on('exit', (code, signal) => {
|
|
194
|
+
hasExited = true;
|
|
195
|
+
exitCode = code;
|
|
196
|
+
console.log(`[Gateway Service] Process exited with code ${code}, signal ${signal}`);
|
|
197
|
+
this.childProcess = null;
|
|
198
|
+
this.isStarting = false;
|
|
199
|
+
// Update database status
|
|
200
|
+
updateServiceStatus({ status: 'stopped', pid: null });
|
|
201
|
+
});
|
|
202
|
+
// Wait a bit to see if process starts successfully
|
|
203
|
+
await new Promise((resolve) => setTimeout(resolve, 2000));
|
|
204
|
+
// Check if process exited during startup
|
|
205
|
+
if (hasExited || child.killed || child.exitCode !== null) {
|
|
206
|
+
this.isStarting = false;
|
|
207
|
+
this.childProcess = null;
|
|
208
|
+
// Extract meaningful error message
|
|
209
|
+
let errorMessage = 'Service failed to start';
|
|
210
|
+
if (errorOutput) {
|
|
211
|
+
// Try to extract the main error message
|
|
212
|
+
const errorMatch = errorOutput.match(/Error: ([^\n]+)/);
|
|
213
|
+
if (errorMatch) {
|
|
214
|
+
errorMessage = errorMatch[1];
|
|
215
|
+
}
|
|
216
|
+
else if (errorOutput.includes('production build')) {
|
|
217
|
+
errorMessage = 'Production build not found. Please run "npm run build" first, or use development mode.';
|
|
218
|
+
}
|
|
219
|
+
else {
|
|
220
|
+
// Use first meaningful line of error
|
|
221
|
+
const lines = errorOutput.split('\n').filter(line => line.trim());
|
|
222
|
+
if (lines.length > 0) {
|
|
223
|
+
errorMessage = lines[0].substring(0, 200); // Limit length
|
|
224
|
+
}
|
|
225
|
+
}
|
|
226
|
+
}
|
|
227
|
+
return { status: 'stopped', error: errorMessage };
|
|
228
|
+
}
|
|
229
|
+
// Save status to database
|
|
230
|
+
const pid = child.pid || null;
|
|
231
|
+
const startedAt = new Date().toISOString();
|
|
232
|
+
setServiceStatus({
|
|
233
|
+
status: 'running',
|
|
234
|
+
port,
|
|
235
|
+
pid,
|
|
236
|
+
started_at: startedAt,
|
|
237
|
+
});
|
|
238
|
+
this.isStarting = false;
|
|
239
|
+
return {
|
|
240
|
+
status: 'running',
|
|
241
|
+
port,
|
|
242
|
+
pid,
|
|
243
|
+
started_at: startedAt,
|
|
244
|
+
};
|
|
245
|
+
}
|
|
246
|
+
catch (error) {
|
|
247
|
+
this.isStarting = false;
|
|
248
|
+
this.childProcess = null;
|
|
249
|
+
return { status: 'stopped', error: error.message || 'Failed to start service' };
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
/**
|
|
253
|
+
* Stop the gateway service
|
|
254
|
+
*/
|
|
255
|
+
async stop() {
|
|
256
|
+
const currentStatus = await this.getStatus();
|
|
257
|
+
if (currentStatus.status === 'stopped') {
|
|
258
|
+
return { status: 'stopped' };
|
|
259
|
+
}
|
|
260
|
+
try {
|
|
261
|
+
// If we have a reference to the child process, kill it
|
|
262
|
+
if (this.childProcess) {
|
|
263
|
+
this.childProcess.kill('SIGTERM');
|
|
264
|
+
this.childProcess = null;
|
|
265
|
+
}
|
|
266
|
+
else if (currentStatus.pid) {
|
|
267
|
+
// Try to kill by PID
|
|
268
|
+
try {
|
|
269
|
+
process.kill(currentStatus.pid, 'SIGTERM');
|
|
270
|
+
}
|
|
271
|
+
catch (error) {
|
|
272
|
+
// Process might not exist
|
|
273
|
+
console.warn(`Failed to kill process ${currentStatus.pid}:`, error);
|
|
274
|
+
}
|
|
275
|
+
}
|
|
276
|
+
// Update database status
|
|
277
|
+
updateServiceStatus({ status: 'stopped', pid: null });
|
|
278
|
+
return { status: 'stopped' };
|
|
279
|
+
}
|
|
280
|
+
catch (error) {
|
|
281
|
+
return { status: 'stopped', error: error.message || 'Failed to stop service' };
|
|
282
|
+
}
|
|
283
|
+
}
|
|
284
|
+
}
|
|
285
|
+
// Export singleton instance
|
|
286
|
+
export const serviceManager = new ServiceManager();
|
package/docs/TODO.md
ADDED
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
# TODO
|
|
2
|
+
|
|
3
|
+
## 2026-01-11
|
|
4
|
+
|
|
5
|
+
- [ ] [需求] 模型列表支持按供应商分类,支持搜索
|
|
6
|
+
- [ ] [修复] 一键拉取模型出现了 成功拉取了undefine个模型的提示
|
|
7
|
+
- [ ] [需求] 供应商测试连接应该只能选取该供应商下的模型,并支持搜索
|
|
8
|
+
- [ ] [需求] 开启AI网关之后,支持点击测试按钮进行测试,模型可以搜索选择,可以给出示例链接配置,以 json 的格式
|
|
9
|
+
|
|
10
|
+
## 2026-01-10
|
|
11
|
+
|
|
12
|
+
- [x] [需求] 需要简化 cli 指令为 aar
|
|
13
|
+
- [x] [优化] 一键拉取模型列表 名称改为 快捷拉取
|
|
14
|
+
- [x] [优化] 供应商和网关配置 API Key 需要加上小眼睛切换按钮,方便查看和拷贝
|
|
15
|
+
- [x] [修复] 供应商 API Key 无法保存的问题修复
|
|
16
|
+
- [x] [优化] Toast 的位置需要水平居中
|
|
17
|
+
- [x] [需求] 数据库存储到用户目录下,路径如 `~/.aar/`
|
|
18
|
+
- [x] [需求] 点击启动之后,后台服务需要保持启动状态,不能因为刷新页面就没了
|
|
19
|
+
- [x] [需求] 供应商添加测试连接按钮,可下拉选择某个模型进行测试连接
|