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,154 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDatabase } from '@/db/database';
3
+ import { getProviderById, getModelById } from '@/db/queries';
4
+ import { getProviderAdapter } from '@/server/providers';
5
+ // Ensure Node.js runtime (required for SQLite)
6
+ export const runtime = 'nodejs';
7
+ export async function POST(request) {
8
+ try {
9
+ getDatabase();
10
+ const body = await request.json();
11
+ const { provider_id, model_id } = body;
12
+ if (!provider_id || !model_id) {
13
+ return NextResponse.json({ error: 'Provider ID and Model ID are required' }, { status: 400 });
14
+ }
15
+ // Get provider and model
16
+ const provider = getProviderById(provider_id);
17
+ if (!provider) {
18
+ return NextResponse.json({ error: 'Provider not found' }, { status: 404 });
19
+ }
20
+ const model = getModelById(model_id);
21
+ if (!model || model.provider_id !== provider_id) {
22
+ return NextResponse.json({ error: 'Model not found or does not belong to this provider' }, { status: 404 });
23
+ }
24
+ // Get provider adapter
25
+ const adapter = getProviderAdapter(provider.protocol);
26
+ // Create a test request based on protocol
27
+ const testRequest = createTestRequest(provider.protocol, model.model_id);
28
+ // Send test request with timeout
29
+ const timeoutPromise = new Promise((_, reject) => {
30
+ setTimeout(() => reject(new Error('连接超时,请检查网络或稍后重试')), 10000);
31
+ });
32
+ const testPromise = adapter.forwardRequest({ ...model, provider }, testRequest);
33
+ const response = await Promise.race([testPromise, timeoutPromise]);
34
+ // Check response status
35
+ if (response.status >= 200 && response.status < 300) {
36
+ return NextResponse.json({
37
+ success: true,
38
+ message: '连接成功,模型可用',
39
+ });
40
+ }
41
+ else {
42
+ // Parse error message
43
+ let errorMessage = '连接失败';
44
+ if (response.body?.error?.message) {
45
+ errorMessage = response.body.error.message;
46
+ }
47
+ else if (typeof response.body === 'string') {
48
+ errorMessage = response.body;
49
+ }
50
+ // Map common error codes to friendly messages
51
+ if (response.status === 401 || response.status === 403) {
52
+ errorMessage = 'API Key 无效,请检查配置';
53
+ }
54
+ else if (response.status === 404) {
55
+ errorMessage = '模型不存在或不可用';
56
+ }
57
+ else if (response.status >= 500) {
58
+ errorMessage = '服务器错误,请稍后重试';
59
+ }
60
+ return NextResponse.json({
61
+ success: false,
62
+ error: errorMessage,
63
+ status: response.status,
64
+ }, { status: 200 } // Return 200 so frontend can handle the error
65
+ );
66
+ }
67
+ }
68
+ catch (error) {
69
+ console.error('Test connection error:', error);
70
+ // Handle specific error types
71
+ let errorMessage = '连接失败';
72
+ if (error.message.includes('超时')) {
73
+ errorMessage = error.message;
74
+ }
75
+ else if (error.message.includes('fetch failed') || error.message.includes('ECONNREFUSED')) {
76
+ errorMessage = '无法连接到服务器,请检查 Base URL 和网络连接';
77
+ }
78
+ else if (error.message.includes('ENOTFOUND') || error.message.includes('DNS')) {
79
+ errorMessage = 'DNS 解析失败,请检查 Base URL';
80
+ }
81
+ else {
82
+ errorMessage = error.message || '连接失败,请检查配置';
83
+ }
84
+ return NextResponse.json({
85
+ success: false,
86
+ error: errorMessage,
87
+ }, { status: 200 } // Return 200 so frontend can handle the error
88
+ );
89
+ }
90
+ }
91
+ function createTestRequest(protocol, modelId) {
92
+ switch (protocol) {
93
+ case 'openai':
94
+ return {
95
+ method: 'POST',
96
+ path: '/v1/chat/completions',
97
+ headers: {
98
+ 'Content-Type': 'application/json',
99
+ },
100
+ query: {},
101
+ body: {
102
+ model: modelId,
103
+ messages: [
104
+ {
105
+ role: 'user',
106
+ content: 'test',
107
+ },
108
+ ],
109
+ max_tokens: 5,
110
+ },
111
+ };
112
+ case 'anthropic':
113
+ return {
114
+ method: 'POST',
115
+ path: '/v1/messages',
116
+ headers: {
117
+ 'Content-Type': 'application/json',
118
+ },
119
+ query: {},
120
+ body: {
121
+ model: modelId,
122
+ max_tokens: 5,
123
+ messages: [
124
+ {
125
+ role: 'user',
126
+ content: 'test',
127
+ },
128
+ ],
129
+ },
130
+ };
131
+ case 'gemini':
132
+ return {
133
+ method: 'POST',
134
+ path: `/v1/models/${modelId}:generateContent`,
135
+ headers: {
136
+ 'Content-Type': 'application/json',
137
+ },
138
+ query: {},
139
+ body: {
140
+ contents: [
141
+ {
142
+ parts: [
143
+ {
144
+ text: 'test',
145
+ },
146
+ ],
147
+ },
148
+ ],
149
+ },
150
+ };
151
+ default:
152
+ throw new Error(`Unsupported protocol: ${protocol}`);
153
+ }
154
+ }
@@ -0,0 +1,55 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDatabase } from '@/db/database';
3
+ import { serviceManager } from '@/server/service-manager';
4
+ import { getConfig } from '@/db/queries';
5
+ import net from 'net';
6
+ // Ensure Node.js runtime (required for service manager)
7
+ export const runtime = 'nodejs';
8
+ export async function POST(request) {
9
+ try {
10
+ // Initialize database
11
+ getDatabase();
12
+ const body = await request.json().catch(() => ({}));
13
+ const port = body.port ? parseInt(body.port, 10) : null;
14
+ // Get port from config if not provided
15
+ let targetPort = port;
16
+ if (!targetPort) {
17
+ const portConfig = getConfig('port');
18
+ targetPort = portConfig ? parseInt(portConfig.value, 10) : 3000;
19
+ }
20
+ // In development, if port is 3000 (default dev server port), check for conflict
21
+ const isDev = process.env.NODE_ENV !== 'production';
22
+ if (isDev && targetPort === 3000) {
23
+ // Check if port 3000 is in use (likely by the dev server)
24
+ const testServer = net.createServer();
25
+ const portInUse = await new Promise((resolve) => {
26
+ testServer.listen(3000, () => {
27
+ testServer.close(() => resolve(false));
28
+ });
29
+ testServer.on('error', () => resolve(true));
30
+ setTimeout(() => {
31
+ testServer.close(() => resolve(false));
32
+ }, 100);
33
+ });
34
+ if (portInUse) {
35
+ return NextResponse.json({
36
+ status: 'stopped',
37
+ error: 'Port 3000 is already in use by the development server. Please configure a different port (e.g., 3001) in the settings above.'
38
+ }, { status: 400 });
39
+ }
40
+ }
41
+ // Validate port
42
+ if (isNaN(targetPort) || targetPort < 1 || targetPort > 65535) {
43
+ return NextResponse.json({ status: 'stopped', error: 'Invalid port number' }, { status: 400 });
44
+ }
45
+ const result = await serviceManager.start(targetPort);
46
+ if (result.error) {
47
+ return NextResponse.json(result, { status: 400 });
48
+ }
49
+ return NextResponse.json(result);
50
+ }
51
+ catch (error) {
52
+ console.error('Service start API error:', error);
53
+ return NextResponse.json({ status: 'stopped', error: error.message || 'Failed to start service' }, { status: 500 });
54
+ }
55
+ }
@@ -0,0 +1,17 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDatabase } from '@/db/database';
3
+ import { serviceManager } from '@/server/service-manager';
4
+ // Ensure Node.js runtime (required for service manager)
5
+ export const runtime = 'nodejs';
6
+ export async function GET(request) {
7
+ try {
8
+ // Initialize database
9
+ getDatabase();
10
+ const status = await serviceManager.getStatus();
11
+ return NextResponse.json(status);
12
+ }
13
+ catch (error) {
14
+ console.error('Service status API error:', error);
15
+ return NextResponse.json({ status: 'stopped', error: error.message || 'Failed to get service status' }, { status: 500 });
16
+ }
17
+ }
@@ -0,0 +1,20 @@
1
+ import { NextResponse } from 'next/server';
2
+ import { getDatabase } from '@/db/database';
3
+ import { serviceManager } from '@/server/service-manager';
4
+ // Ensure Node.js runtime (required for service manager)
5
+ export const runtime = 'nodejs';
6
+ export async function POST(request) {
7
+ try {
8
+ // Initialize database
9
+ getDatabase();
10
+ const result = await serviceManager.stop();
11
+ if (result.error) {
12
+ return NextResponse.json(result, { status: 400 });
13
+ }
14
+ return NextResponse.json(result);
15
+ }
16
+ catch (error) {
17
+ console.error('Service stop API error:', error);
18
+ return NextResponse.json({ status: 'stopped', error: error.message || 'Failed to stop service' }, { status: 500 });
19
+ }
20
+ }
@@ -0,0 +1,31 @@
1
+ 'use client';
2
+ export default function ConfirmDialog({ open, title, message, onConfirm, onCancel, confirmText = '确认', cancelText = '取消', type = 'info', }) {
3
+ if (!open)
4
+ return null;
5
+ const buttonColors = {
6
+ danger: 'bg-rose-500 hover:bg-rose-600 focus:ring-rose-500/50',
7
+ warning: 'bg-amber-500 hover:bg-amber-600 focus:ring-amber-500/50',
8
+ info: 'bg-emerald-600 hover:bg-emerald-700 focus:ring-emerald-500/50',
9
+ };
10
+ return (<div className="fixed z-50 inset-0 overflow-y-auto">
11
+ <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
12
+ <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={onCancel}></div>
13
+ <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-sm sm:w-full border border-emerald-100/50">
14
+ <div className="bg-white px-5 pt-5 pb-4 sm:p-6">
15
+ <div className="mb-4">
16
+ <h3 className="text-base font-bold text-slate-800 mb-1">{title}</h3>
17
+ <p className="text-xs text-slate-600">{message}</p>
18
+ </div>
19
+ <div className="flex items-center justify-end space-x-2">
20
+ <button type="button" onClick={onCancel} 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">
21
+ {cancelText}
22
+ </button>
23
+ <button type="button" onClick={onConfirm} className={`px-4 py-1.5 border border-transparent rounded-lg text-xs font-semibold text-white focus:outline-none focus:ring-2 focus:ring-offset-1 transition-all duration-300 ${buttonColors[type]}`}>
24
+ {confirmText}
25
+ </button>
26
+ </div>
27
+ </div>
28
+ </div>
29
+ </div>
30
+ </div>);
31
+ }
@@ -0,0 +1,45 @@
1
+ 'use client';
2
+ import Link from 'next/link';
3
+ import { usePathname } from 'next/navigation';
4
+ export default function Nav() {
5
+ const pathname = usePathname();
6
+ const navItems = [
7
+ { href: '/', label: '配置' },
8
+ { href: '/providers', label: '供应商' },
9
+ { href: '/models', label: '模型' },
10
+ { href: '/logs', label: '日志' },
11
+ ];
12
+ return (<nav className="bg-white/70 backdrop-blur-xl border-b border-emerald-100/30 sticky top-0 z-50">
13
+ <div className="max-w-7xl mx-auto px-6 sm:px-8 lg:px-10">
14
+ <div className="flex items-center justify-between h-16">
15
+ <div className="flex items-center space-x-12">
16
+ <Link href="/" className="flex flex-col group transition-all duration-300">
17
+ <span className="text-xl font-bold text-emerald-700 group-hover:text-emerald-600 transition-colors duration-300 leading-none">
18
+ AAR
19
+ </span>
20
+ <span className="text-[9px] font-medium text-slate-400 tracking-wider uppercase mt-0.5 leading-none">
21
+ AI Agent Router
22
+ </span>
23
+ </Link>
24
+ <div className="hidden sm:flex sm:items-center">
25
+ {navItems.map((item) => {
26
+ const isActive = pathname === item.href;
27
+ return (<Link key={item.href} href={item.href} className="relative inline-flex items-center justify-center w-[68px] h-9 text-xs font-medium transition-colors duration-300 group" style={{ minWidth: '68px', maxWidth: '68px' }}>
28
+ {/* 固定宽度和高度,完全避免抖动 - 使用绝对定位确保位置不变 */}
29
+ <span className={`absolute inset-0 flex items-center justify-center transition-colors duration-300 ${isActive
30
+ ? 'text-emerald-700 font-semibold'
31
+ : 'text-slate-500 group-hover:text-slate-700'}`}>
32
+ {item.label}
33
+ </span>
34
+ {/* 选中状态背景 - 固定尺寸,不改变布局 */}
35
+ <span className={`absolute inset-0 rounded-lg transition-all duration-300 ${isActive
36
+ ? 'bg-emerald-50/60'
37
+ : 'bg-transparent group-hover:bg-slate-50/40'}`}/>
38
+ </Link>);
39
+ })}
40
+ </div>
41
+ </div>
42
+ </div>
43
+ </div>
44
+ </nav>);
45
+ }
@@ -0,0 +1,37 @@
1
+ 'use client';
2
+ import { useEffect } from 'react';
3
+ export default function Toast({ message, type = 'info', onClose, duration = 3000 }) {
4
+ useEffect(() => {
5
+ const timer = setTimeout(() => {
6
+ onClose();
7
+ }, duration);
8
+ return () => clearTimeout(timer);
9
+ }, [duration, onClose]);
10
+ const colors = {
11
+ success: 'bg-emerald-500 text-white border-emerald-600/30 shadow-emerald-500/20',
12
+ error: 'bg-rose-500 text-white border-rose-600/30 shadow-rose-500/20',
13
+ info: 'bg-slate-600 text-white border-slate-700/30 shadow-slate-600/20',
14
+ };
15
+ const icons = {
16
+ success: (<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
17
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M5 13l4 4L19 7"/>
18
+ </svg>),
19
+ error: (<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
20
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12"/>
21
+ </svg>),
22
+ info: (<svg className="w-3.5 h-3.5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
23
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M13 16h-1v-4h-1m1-4h.01M21 12a9 9 0 11-18 0 9 9 0 0118 0z"/>
24
+ </svg>),
25
+ };
26
+ return (<div className="fixed top-20 left-1/2 -translate-x-1/2 z-50 animate-in slide-in-from-top fade-in duration-300">
27
+ <div className={`flex items-center gap-2 px-3 py-2 rounded-lg shadow-lg border backdrop-blur-sm ${colors[type]}`}>
28
+ <span className="flex-shrink-0">{icons[type]}</span>
29
+ <span className="text-xs font-medium leading-tight">{message}</span>
30
+ <button onClick={onClose} className="ml-1.5 flex-shrink-0 opacity-70 hover:opacity-100 transition-opacity p-0.5">
31
+ <svg className="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24">
32
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2.5} d="M6 18L18 6M6 6l12 12"/>
33
+ </svg>
34
+ </button>
35
+ </div>
36
+ </div>);
37
+ }
@@ -0,0 +1,21 @@
1
+ 'use client';
2
+ import { createContext, useContext, useState } from 'react';
3
+ import Toast from './Toast';
4
+ const ToastContext = createContext(undefined);
5
+ export function useToast() {
6
+ const context = useContext(ToastContext);
7
+ if (!context) {
8
+ throw new Error('useToast must be used within ToastProvider');
9
+ }
10
+ return context;
11
+ }
12
+ export function ToastProvider({ children }) {
13
+ const [toast, setToast] = useState(null);
14
+ const showToast = (message, type = 'info') => {
15
+ setToast({ message, type });
16
+ };
17
+ return (<ToastContext.Provider value={{ showToast }}>
18
+ {children}
19
+ {toast && (<Toast message={toast.message} type={toast.type} onClose={() => setToast(null)}/>)}
20
+ </ToastContext.Provider>);
21
+ }
@@ -0,0 +1,13 @@
1
+ import './globals.css';
2
+ import { ToastProvider } from './components/ToastProvider';
3
+ export const metadata = {
4
+ title: 'AAR - AI Agent Router',
5
+ description: 'Unified API gateway for managing multiple AI model providers',
6
+ };
7
+ export default function RootLayout({ children, }) {
8
+ return (<html lang="zh-CN">
9
+ <body>
10
+ <ToastProvider>{children}</ToastProvider>
11
+ </body>
12
+ </html>);
13
+ }
@@ -0,0 +1,210 @@
1
+ 'use client';
2
+ import { useState, useEffect } from 'react';
3
+ import Nav from '../components/Nav';
4
+ export default function LogsPage() {
5
+ const [logs, setLogs] = useState([]);
6
+ const [selectedLog, setSelectedLog] = useState(null);
7
+ const [loading, setLoading] = useState(true);
8
+ const [page, setPage] = useState(0);
9
+ const [total, setTotal] = useState(0);
10
+ const limit = 50;
11
+ useEffect(() => {
12
+ loadLogs();
13
+ }, [page]);
14
+ const loadLogs = async () => {
15
+ try {
16
+ const res = await fetch(`/api/logs?limit=${limit}&offset=${page * limit}`);
17
+ const data = await res.json();
18
+ setLogs(data.logs || []);
19
+ setTotal(data.total || 0);
20
+ }
21
+ catch (error) {
22
+ console.error('Failed to load logs:', error);
23
+ }
24
+ finally {
25
+ setLoading(false);
26
+ }
27
+ };
28
+ const formatJSON = (jsonString) => {
29
+ try {
30
+ const parsed = JSON.parse(jsonString);
31
+ return JSON.stringify(parsed, null, 2);
32
+ }
33
+ catch {
34
+ return jsonString;
35
+ }
36
+ };
37
+ const getStatusColor = (status) => {
38
+ if (status >= 200 && status < 300)
39
+ return 'text-green-600';
40
+ if (status >= 400 && status < 500)
41
+ return 'text-yellow-600';
42
+ if (status >= 500)
43
+ return 'text-red-600';
44
+ return 'text-gray-600';
45
+ };
46
+ if (loading) {
47
+ return (<>
48
+ <Nav />
49
+ <div className="min-h-screen flex items-center justify-center">
50
+ <div className="text-lg">加载中...</div>
51
+ </div>
52
+ </>);
53
+ }
54
+ return (<>
55
+ <Nav />
56
+ <main className="max-w-6xl mx-auto py-6 sm:px-6 lg:px-8">
57
+ <div className="px-4 py-4 sm:px-0">
58
+ <div className="mb-5">
59
+ <h1 className="text-lg font-bold text-slate-800">请求日志</h1>
60
+ <p className="text-xs text-slate-500 mt-1">查看所有 API 请求记录和响应详情</p>
61
+ </div>
62
+
63
+ <div className="bg-white/70 backdrop-blur-sm shadow-md rounded-2xl border border-emerald-100/50 overflow-hidden">
64
+ <table className="min-w-full divide-y divide-slate-100">
65
+ <thead className="bg-emerald-50/30">
66
+ <tr>
67
+ <th className="px-4 py-2.5 text-left text-xs font-semibold text-slate-600 uppercase tracking-wider">
68
+ 时间
69
+ </th>
70
+ <th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
71
+ 模型
72
+ </th>
73
+ <th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
74
+ 方法
75
+ </th>
76
+ <th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
77
+ 路径
78
+ </th>
79
+ <th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
80
+ 状态
81
+ </th>
82
+ <th className="px-6 py-3.5 text-left text-xs font-semibold text-gray-700 uppercase tracking-wider">
83
+ 响应时间
84
+ </th>
85
+ <th className="px-6 py-3.5 text-right text-xs font-semibold text-gray-700 uppercase tracking-wider">
86
+ 操作
87
+ </th>
88
+ </tr>
89
+ </thead>
90
+ <tbody className="bg-white divide-y divide-slate-100">
91
+ {logs.length === 0 ? (<tr>
92
+ <td colSpan={7} className="px-4 py-12 text-center">
93
+ <div className="text-slate-400">
94
+ <svg className="mx-auto h-10 w-10 mb-3 text-slate-300" fill="none" stroke="currentColor" viewBox="0 0 24 24">
95
+ <path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z"/>
96
+ </svg>
97
+ <p className="text-xs text-slate-500">暂无日志记录</p>
98
+ </div>
99
+ </td>
100
+ </tr>) : (logs.map((log) => (<tr key={log.id} className="hover:bg-emerald-50/20 transition-colors duration-300 cursor-pointer">
101
+ <td className="px-4 py-3 whitespace-nowrap">
102
+ <div className="text-xs text-slate-600">
103
+ {new Date(log.created_at).toLocaleString('zh-CN')}
104
+ </div>
105
+ </td>
106
+ <td className="px-4 py-3 whitespace-nowrap">
107
+ <div className="text-xs">
108
+ <div className="font-medium text-slate-800">{log.model_name || 'Unknown'}</div>
109
+ {log.provider_name && (<div className="text-[10px] text-slate-500">{log.provider_name}</div>)}
110
+ </div>
111
+ </td>
112
+ <td className="px-4 py-3 whitespace-nowrap">
113
+ <span className={`px-2 py-0.5 inline-flex text-xs leading-4 font-semibold rounded-lg border ${log.request_method === 'GET' ? 'bg-sky-50 text-sky-700 border-sky-200/50' :
114
+ log.request_method === 'POST' ? 'bg-emerald-50 text-emerald-700 border-emerald-200/50' :
115
+ 'bg-slate-50 text-slate-600 border-slate-200/50'}`}>
116
+ {log.request_method}
117
+ </span>
118
+ </td>
119
+ <td className="px-4 py-3">
120
+ <div className="text-xs text-slate-600 font-mono max-w-xs truncate">
121
+ {log.request_path}
122
+ </div>
123
+ </td>
124
+ <td className="px-4 py-3 whitespace-nowrap">
125
+ <span className={`px-2 py-0.5 inline-flex text-xs leading-4 font-semibold rounded-full border ${log.response_status >= 200 && log.response_status < 300 ? 'bg-emerald-100/80 text-emerald-700 border-emerald-200/50' :
126
+ log.response_status >= 400 && log.response_status < 500 ? 'bg-amber-100/80 text-amber-700 border-amber-200/50' :
127
+ log.response_status >= 500 ? 'bg-rose-100/80 text-rose-700 border-rose-200/50' :
128
+ 'bg-slate-100/80 text-slate-600 border-slate-200/50'}`}>
129
+ {log.response_status}
130
+ </span>
131
+ </td>
132
+ <td className="px-4 py-3 whitespace-nowrap">
133
+ <div className="text-xs text-slate-600">
134
+ {log.response_time_ms}ms
135
+ </div>
136
+ </td>
137
+ <td className="px-4 py-3 whitespace-nowrap text-right text-xs font-medium">
138
+ <button onClick={() => setSelectedLog(log)} className="text-emerald-600 hover:text-emerald-700 font-medium transition-colors duration-300">
139
+ 查看详情
140
+ </button>
141
+ </td>
142
+ </tr>)))}
143
+ </tbody>
144
+ </table>
145
+ </div>
146
+
147
+ <div className="mt-5 flex items-center justify-between">
148
+ <div className="text-xs text-slate-600">
149
+ 共 <span className="font-semibold text-slate-800">{total}</span> 条记录
150
+ </div>
151
+ <div className="flex space-x-2">
152
+ <button onClick={() => setPage(Math.max(0, page - 1))} disabled={page === 0} className="px-3 py-1.5 border border-slate-200 rounded-lg text-xs font-medium text-slate-600 bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300">
153
+ 上一页
154
+ </button>
155
+ <button onClick={() => setPage(page + 1)} disabled={(page + 1) * limit >= total} className="px-3 py-1.5 border border-slate-200 rounded-lg text-xs font-medium text-slate-600 bg-white hover:bg-slate-50 disabled:opacity-50 disabled:cursor-not-allowed transition-all duration-300">
156
+ 下一页
157
+ </button>
158
+ </div>
159
+ </div>
160
+ </div>
161
+ </main>
162
+
163
+ {selectedLog && (<div className="fixed z-50 inset-0 overflow-y-auto">
164
+ <div className="flex items-center justify-center min-h-screen pt-4 px-4 pb-20 text-center sm:block sm:p-0">
165
+ <div className="fixed inset-0 bg-slate-900/40 backdrop-blur-sm transition-opacity" onClick={() => setSelectedLog(null)}></div>
166
+ <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-3xl sm:w-full border border-emerald-100/50">
167
+ <div className="bg-white px-6 pt-5 pb-4 sm:p-6">
168
+ <div className="mb-5">
169
+ <h3 className="text-base font-bold text-slate-800 mb-1">
170
+ 请求详情
171
+ </h3>
172
+ <p className="text-xs text-slate-500">查看完整的请求和响应信息</p>
173
+ </div>
174
+ <div className="space-y-4 max-h-[60vh] overflow-y-auto">
175
+ <div>
176
+ <h4 className="text-xs font-semibold text-slate-700 mb-2">请求头</h4>
177
+ <pre className="bg-slate-50/80 border border-slate-200 p-3 rounded-xl text-[10px] overflow-x-auto font-mono text-slate-700">
178
+ {formatJSON(selectedLog.request_headers)}
179
+ </pre>
180
+ </div>
181
+ <div>
182
+ <h4 className="text-xs font-semibold text-slate-700 mb-2">请求 Query</h4>
183
+ <pre className="bg-slate-50/80 border border-slate-200 p-3 rounded-xl text-[10px] overflow-x-auto font-mono text-slate-700">
184
+ {formatJSON(selectedLog.request_query)}
185
+ </pre>
186
+ </div>
187
+ <div>
188
+ <h4 className="text-xs font-semibold text-slate-700 mb-2">请求 Body</h4>
189
+ <pre className="bg-slate-50/80 border border-slate-200 p-3 rounded-xl text-[10px] overflow-x-auto font-mono text-slate-700">
190
+ {formatJSON(selectedLog.request_body)}
191
+ </pre>
192
+ </div>
193
+ <div>
194
+ <h4 className="text-xs font-semibold text-slate-700 mb-2">响应 Body</h4>
195
+ <pre className="bg-slate-50/80 border border-slate-200 p-3 rounded-xl text-[10px] overflow-x-auto font-mono text-slate-700">
196
+ {formatJSON(selectedLog.response_body)}
197
+ </pre>
198
+ </div>
199
+ </div>
200
+ <div className="mt-5 flex justify-end">
201
+ <button type="button" onClick={() => setSelectedLog(null)} className="px-4 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">
202
+ 关闭
203
+ </button>
204
+ </div>
205
+ </div>
206
+ </div>
207
+ </div>
208
+ </div>)}
209
+ </>);
210
+ }