synapse-gateway 2.0.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/README.md +385 -0
- package/bin/synapse.js +242 -0
- package/docs/PLAN.md +1723 -0
- package/docs/PRD.md +1799 -0
- package/drizzle.config.ts +12 -0
- package/next.config.ts +8 -0
- package/package.json +82 -0
- package/postcss.config.mjs +7 -0
- package/public/file.svg +1 -0
- package/public/globe.svg +1 -0
- package/public/next.svg +1 -0
- package/public/vercel.svg +1 -0
- package/public/window.svg +1 -0
- package/src/app/api/analytics/cost/route.ts +13 -0
- package/src/app/api/analytics/usage/route.ts +16 -0
- package/src/app/api/auth/login/route.ts +42 -0
- package/src/app/api/cache/route.ts +19 -0
- package/src/app/api/dashboard/route.ts +35 -0
- package/src/app/api/distill/route.ts +10 -0
- package/src/app/api/events/route.ts +54 -0
- package/src/app/api/health/route.ts +10 -0
- package/src/app/api/intelligence/forensics/route.ts +23 -0
- package/src/app/api/intelligence/neural-router/route.ts +23 -0
- package/src/app/api/keys/route.ts +34 -0
- package/src/app/api/mcp/route.ts +49 -0
- package/src/app/api/memory/route.ts +10 -0
- package/src/app/api/models/benchmark/route.ts +13 -0
- package/src/app/api/models/route.ts +39 -0
- package/src/app/api/namespace/route.ts +25 -0
- package/src/app/api/plugins/route.ts +41 -0
- package/src/app/api/providers/accounts/route.ts +91 -0
- package/src/app/api/providers/fetch-models/route.ts +52 -0
- package/src/app/api/providers/health/route.ts +10 -0
- package/src/app/api/providers/route.ts +46 -0
- package/src/app/api/routes/pipeline/route.ts +20 -0
- package/src/app/api/settings/route.ts +33 -0
- package/src/app/api/skills/route.ts +39 -0
- package/src/app/api/v1/chat/completions/route.ts +156 -0
- package/src/app/api/v1/models/route.ts +44 -0
- package/src/app/dashboard/intelligence/loading.tsx +14 -0
- package/src/app/dashboard/intelligence/page.tsx +125 -0
- package/src/app/dashboard/layout.tsx +143 -0
- package/src/app/dashboard/loading.tsx +17 -0
- package/src/app/dashboard/memory/loading.tsx +15 -0
- package/src/app/dashboard/memory/page.tsx +71 -0
- package/src/app/dashboard/models/loading.tsx +13 -0
- package/src/app/dashboard/models/page.tsx +107 -0
- package/src/app/dashboard/page.tsx +183 -0
- package/src/app/dashboard/playground/loading.tsx +17 -0
- package/src/app/dashboard/playground/page.tsx +212 -0
- package/src/app/dashboard/providers/loading.tsx +15 -0
- package/src/app/dashboard/providers/page.tsx +248 -0
- package/src/app/dashboard/routes/loading.tsx +15 -0
- package/src/app/dashboard/routes/page.tsx +72 -0
- package/src/app/dashboard/settings/loading.tsx +20 -0
- package/src/app/dashboard/settings/page.tsx +208 -0
- package/src/app/dashboard/skills/loading.tsx +26 -0
- package/src/app/dashboard/skills/page.tsx +137 -0
- package/src/app/dashboard/vault/loading.tsx +18 -0
- package/src/app/dashboard/vault/page.tsx +139 -0
- package/src/app/favicon.ico +0 -0
- package/src/app/globals.css +59 -0
- package/src/app/layout.tsx +32 -0
- package/src/app/login/page.tsx +87 -0
- package/src/app/page.tsx +5 -0
- package/src/components/ui/badge.tsx +32 -0
- package/src/components/ui/button.tsx +38 -0
- package/src/components/ui/card.tsx +50 -0
- package/src/components/ui/error-boundary.tsx +47 -0
- package/src/components/ui/index.ts +11 -0
- package/src/components/ui/input.tsx +26 -0
- package/src/components/ui/select.tsx +24 -0
- package/src/components/ui/skeleton.tsx +53 -0
- package/src/components/ui/toast.tsx +51 -0
- package/src/instrumentation.ts +6 -0
- package/src/lib/__tests__/auth.test.ts +42 -0
- package/src/lib/__tests__/format.test.ts +94 -0
- package/src/lib/__tests__/namespace.test.ts +102 -0
- package/src/lib/__tests__/squeezer.test.ts +93 -0
- package/src/lib/__tests__/utils.test.ts +28 -0
- package/src/lib/analytics/index.ts +187 -0
- package/src/lib/auth/guard.tsx +71 -0
- package/src/lib/auth/index.ts +105 -0
- package/src/lib/auth/middleware.ts +64 -0
- package/src/lib/benchmark/index.ts +137 -0
- package/src/lib/bootstrap.ts +122 -0
- package/src/lib/cache/index.ts +1 -0
- package/src/lib/cache/semantic.ts +211 -0
- package/src/lib/config/defaults.ts +61 -0
- package/src/lib/config/index.ts +72 -0
- package/src/lib/config/schema.ts +63 -0
- package/src/lib/db/index.ts +22 -0
- package/src/lib/db/migrate.ts +327 -0
- package/src/lib/db/schema.ts +303 -0
- package/src/lib/distiller/index.ts +331 -0
- package/src/lib/fallback/index.ts +153 -0
- package/src/lib/forensics/index.ts +188 -0
- package/src/lib/format/anthropic.ts +139 -0
- package/src/lib/format/gemini.ts +130 -0
- package/src/lib/format/index.ts +3 -0
- package/src/lib/format/openai.ts +78 -0
- package/src/lib/health/index.ts +158 -0
- package/src/lib/mcp/builtin.ts +83 -0
- package/src/lib/mcp/index.ts +1 -0
- package/src/lib/mcp/registry.ts +49 -0
- package/src/lib/memory/index.ts +3 -0
- package/src/lib/memory/store.ts +215 -0
- package/src/lib/memory/types.ts +56 -0
- package/src/lib/namespace/index.ts +89 -0
- package/src/lib/neural/features.ts +74 -0
- package/src/lib/neural/index.ts +85 -0
- package/src/lib/neural/strategies.ts +124 -0
- package/src/lib/pipeline/index.ts +84 -0
- package/src/lib/pipeline/types.ts +77 -0
- package/src/lib/plugins/builtin.ts +79 -0
- package/src/lib/plugins/index.ts +65 -0
- package/src/lib/prediction/index.ts +113 -0
- package/src/lib/providers/api-key/anthropic.ts +96 -0
- package/src/lib/providers/api-key/deepseek.ts +108 -0
- package/src/lib/providers/api-key/gemini.ts +112 -0
- package/src/lib/providers/api-key/openai.ts +122 -0
- package/src/lib/providers/api-key/openrouter.ts +112 -0
- package/src/lib/providers/base-adapter.ts +122 -0
- package/src/lib/providers/registry.ts +46 -0
- package/src/lib/providers/types.ts +121 -0
- package/src/lib/router/index.ts +82 -0
- package/src/lib/skills/forge.ts +57 -0
- package/src/lib/skills/index.ts +3 -0
- package/src/lib/skills/registry.ts +195 -0
- package/src/lib/skills/types.ts +44 -0
- package/src/lib/squeezer/index.ts +158 -0
- package/src/lib/utils/cn.ts +6 -0
- package/src/lib/utils/logger.ts +16 -0
- package/src/middleware.ts +60 -0
- package/tsconfig.json +34 -0
|
@@ -0,0 +1,87 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useState } from 'react'
|
|
4
|
+
import { Zap } from 'lucide-react'
|
|
5
|
+
|
|
6
|
+
export default function LoginPage() {
|
|
7
|
+
const [password, setPassword] = useState('')
|
|
8
|
+
const [error, setError] = useState('')
|
|
9
|
+
const [loading, setLoading] = useState(false)
|
|
10
|
+
|
|
11
|
+
async function handleSubmit(e: React.FormEvent) {
|
|
12
|
+
e.preventDefault()
|
|
13
|
+
if (!password) return
|
|
14
|
+
setLoading(true)
|
|
15
|
+
setError('')
|
|
16
|
+
|
|
17
|
+
try {
|
|
18
|
+
const res = await fetch('/api/auth/login', {
|
|
19
|
+
method: 'POST',
|
|
20
|
+
headers: { 'Content-Type': 'application/json' },
|
|
21
|
+
body: JSON.stringify({ password }),
|
|
22
|
+
})
|
|
23
|
+
const data = await res.json()
|
|
24
|
+
|
|
25
|
+
if (!res.ok) {
|
|
26
|
+
setError(data.error || 'Login failed')
|
|
27
|
+
return
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
localStorage.setItem('synapse_token', data.token)
|
|
31
|
+
localStorage.setItem('synapse_user', JSON.stringify(data.user))
|
|
32
|
+
window.location.href = '/dashboard'
|
|
33
|
+
} catch {
|
|
34
|
+
setError('Network error')
|
|
35
|
+
} finally {
|
|
36
|
+
setLoading(false)
|
|
37
|
+
}
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
return (
|
|
41
|
+
<div className="min-h-screen flex items-center justify-center bg-background p-4">
|
|
42
|
+
<div className="w-full max-w-sm space-y-8">
|
|
43
|
+
<div className="text-center space-y-2">
|
|
44
|
+
<div className="flex items-center justify-center gap-2 mb-4">
|
|
45
|
+
<div className="p-2 rounded-lg bg-primary/10">
|
|
46
|
+
<Zap className="h-6 w-6 text-primary" />
|
|
47
|
+
</div>
|
|
48
|
+
</div>
|
|
49
|
+
<h1 className="text-2xl font-bold tracking-tight">Synapse</h1>
|
|
50
|
+
<p className="text-sm text-muted-foreground">AI Gateway & Intelligence Platform</p>
|
|
51
|
+
</div>
|
|
52
|
+
|
|
53
|
+
<form onSubmit={handleSubmit} className="space-y-4">
|
|
54
|
+
<div>
|
|
55
|
+
<label htmlFor="password" className="text-sm font-medium block mb-1.5">Password</label>
|
|
56
|
+
<input
|
|
57
|
+
id="password"
|
|
58
|
+
type="password"
|
|
59
|
+
value={password}
|
|
60
|
+
onChange={(e) => setPassword(e.target.value)}
|
|
61
|
+
placeholder="Enter admin password"
|
|
62
|
+
className="w-full bg-muted border border-border rounded-md px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-ring"
|
|
63
|
+
autoFocus
|
|
64
|
+
disabled={loading}
|
|
65
|
+
/>
|
|
66
|
+
</div>
|
|
67
|
+
|
|
68
|
+
{error && (
|
|
69
|
+
<div className="text-sm text-destructive bg-destructive/10 px-3 py-2 rounded-md">{error}</div>
|
|
70
|
+
)}
|
|
71
|
+
|
|
72
|
+
<button
|
|
73
|
+
type="submit"
|
|
74
|
+
disabled={loading || !password}
|
|
75
|
+
className="w-full py-2 bg-primary text-primary-foreground rounded-md text-sm font-medium hover:bg-primary/90 transition-colors disabled:opacity-50 disabled:cursor-not-allowed"
|
|
76
|
+
>
|
|
77
|
+
{loading ? 'Signing in...' : 'Sign In'}
|
|
78
|
+
</button>
|
|
79
|
+
</form>
|
|
80
|
+
|
|
81
|
+
<p className="text-xs text-center text-muted-foreground">
|
|
82
|
+
Default password: <code className="font-mono bg-muted px-1.5 py-0.5 rounded">changeme</code>
|
|
83
|
+
</p>
|
|
84
|
+
</div>
|
|
85
|
+
</div>
|
|
86
|
+
)
|
|
87
|
+
}
|
package/src/app/page.tsx
ADDED
|
@@ -0,0 +1,32 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cva, type VariantProps } from 'class-variance-authority'
|
|
3
|
+
import { cn } from '@/lib/utils/cn'
|
|
4
|
+
|
|
5
|
+
const badgeVariants = cva(
|
|
6
|
+
'inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium transition-colors',
|
|
7
|
+
{
|
|
8
|
+
variants: {
|
|
9
|
+
variant: {
|
|
10
|
+
default: 'bg-primary/10 text-primary',
|
|
11
|
+
secondary: 'bg-secondary text-secondary-foreground',
|
|
12
|
+
accent: 'bg-accent/10 text-accent',
|
|
13
|
+
destructive: 'bg-destructive/10 text-destructive',
|
|
14
|
+
outline: 'border border-border text-foreground',
|
|
15
|
+
warning: 'bg-yellow-500/10 text-yellow-500',
|
|
16
|
+
},
|
|
17
|
+
},
|
|
18
|
+
defaultVariants: {
|
|
19
|
+
variant: 'default',
|
|
20
|
+
},
|
|
21
|
+
},
|
|
22
|
+
)
|
|
23
|
+
|
|
24
|
+
export interface BadgeProps
|
|
25
|
+
extends React.HTMLAttributes<HTMLSpanElement>,
|
|
26
|
+
VariantProps<typeof badgeVariants> {}
|
|
27
|
+
|
|
28
|
+
function Badge({ className, variant, ...props }: BadgeProps) {
|
|
29
|
+
return <span className={cn(badgeVariants({ variant }), className)} {...props} />
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export { Badge, badgeVariants }
|
|
@@ -0,0 +1,38 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface ButtonProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
|
|
5
|
+
variant?: 'default' | 'secondary' | 'outline' | 'ghost' | 'destructive'
|
|
6
|
+
size?: 'sm' | 'md' | 'lg' | 'icon'
|
|
7
|
+
}
|
|
8
|
+
|
|
9
|
+
const Button = React.forwardRef<HTMLButtonElement, ButtonProps>(
|
|
10
|
+
({ className, variant = 'default', size = 'md', ...props }, ref) => {
|
|
11
|
+
return (
|
|
12
|
+
<button
|
|
13
|
+
className={cn(
|
|
14
|
+
'inline-flex items-center justify-center gap-2 rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50',
|
|
15
|
+
{
|
|
16
|
+
'bg-primary text-primary-foreground hover:bg-primary/90': variant === 'default',
|
|
17
|
+
'bg-secondary text-secondary-foreground hover:bg-secondary/80': variant === 'secondary',
|
|
18
|
+
'border border-border bg-transparent hover:bg-muted': variant === 'outline',
|
|
19
|
+
'hover:bg-muted': variant === 'ghost',
|
|
20
|
+
'bg-destructive text-destructive-foreground hover:bg-destructive/90': variant === 'destructive',
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
'h-7 px-2.5 text-xs': size === 'sm',
|
|
24
|
+
'h-9 px-4': size === 'md',
|
|
25
|
+
'h-11 px-6 text-base': size === 'lg',
|
|
26
|
+
'h-8 w-8 p-0': size === 'icon',
|
|
27
|
+
},
|
|
28
|
+
className,
|
|
29
|
+
)}
|
|
30
|
+
ref={ref}
|
|
31
|
+
{...props}
|
|
32
|
+
/>
|
|
33
|
+
)
|
|
34
|
+
},
|
|
35
|
+
)
|
|
36
|
+
Button.displayName = 'Button'
|
|
37
|
+
|
|
38
|
+
export { Button }
|
|
@@ -0,0 +1,50 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils/cn'
|
|
3
|
+
|
|
4
|
+
const Card = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
5
|
+
({ className, ...props }, ref) => (
|
|
6
|
+
<div
|
|
7
|
+
ref={ref}
|
|
8
|
+
className={cn('rounded-lg border border-border bg-card text-card-foreground', className)}
|
|
9
|
+
{...props}
|
|
10
|
+
/>
|
|
11
|
+
),
|
|
12
|
+
)
|
|
13
|
+
Card.displayName = 'Card'
|
|
14
|
+
|
|
15
|
+
const CardHeader = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
16
|
+
({ className, ...props }, ref) => (
|
|
17
|
+
<div ref={ref} className={cn('px-4 py-3 border-b border-border', className)} {...props} />
|
|
18
|
+
),
|
|
19
|
+
)
|
|
20
|
+
CardHeader.displayName = 'CardHeader'
|
|
21
|
+
|
|
22
|
+
const CardTitle = React.forwardRef<HTMLHeadingElement, React.HTMLAttributes<HTMLHeadingElement>>(
|
|
23
|
+
({ className, ...props }, ref) => (
|
|
24
|
+
<h3 ref={ref} className={cn('text-sm font-medium', className)} {...props} />
|
|
25
|
+
),
|
|
26
|
+
)
|
|
27
|
+
CardTitle.displayName = 'CardTitle'
|
|
28
|
+
|
|
29
|
+
const CardDescription = React.forwardRef<HTMLParagraphElement, React.HTMLAttributes<HTMLParagraphElement>>(
|
|
30
|
+
({ className, ...props }, ref) => (
|
|
31
|
+
<p ref={ref} className={cn('text-xs text-muted-foreground', className)} {...props} />
|
|
32
|
+
),
|
|
33
|
+
)
|
|
34
|
+
CardDescription.displayName = 'CardDescription'
|
|
35
|
+
|
|
36
|
+
const CardContent = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
37
|
+
({ className, ...props }, ref) => (
|
|
38
|
+
<div ref={ref} className={cn('p-4', className)} {...props} />
|
|
39
|
+
),
|
|
40
|
+
)
|
|
41
|
+
CardContent.displayName = 'CardContent'
|
|
42
|
+
|
|
43
|
+
const CardFooter = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
|
|
44
|
+
({ className, ...props }, ref) => (
|
|
45
|
+
<div ref={ref} className={cn('px-4 py-3 border-t border-border flex items-center', className)} {...props} />
|
|
46
|
+
),
|
|
47
|
+
)
|
|
48
|
+
CardFooter.displayName = 'CardFooter'
|
|
49
|
+
|
|
50
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter }
|
|
@@ -0,0 +1,47 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { Component, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
children: ReactNode
|
|
7
|
+
fallback?: ReactNode
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
interface State {
|
|
11
|
+
hasError: boolean
|
|
12
|
+
error: Error | null
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export class ErrorBoundary extends Component<Props, State> {
|
|
16
|
+
constructor(props: Props) {
|
|
17
|
+
super(props)
|
|
18
|
+
this.state = { hasError: false, error: null }
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
static getDerivedStateFromError(error: Error): State {
|
|
22
|
+
return { hasError: true, error }
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
render() {
|
|
26
|
+
if (this.state.hasError) {
|
|
27
|
+
if (this.props.fallback) return this.props.fallback
|
|
28
|
+
|
|
29
|
+
return (
|
|
30
|
+
<div className="flex items-center justify-center min-h-[200px] p-8">
|
|
31
|
+
<div className="text-center space-y-3">
|
|
32
|
+
<div className="text-destructive text-sm font-medium">Something went wrong</div>
|
|
33
|
+
<p className="text-xs text-muted-foreground max-w-md">{this.state.error?.message || 'An unexpected error occurred'}</p>
|
|
34
|
+
<button
|
|
35
|
+
onClick={() => this.setState({ hasError: false, error: null })}
|
|
36
|
+
className="px-3 py-1.5 text-xs border border-border rounded-md hover:bg-muted transition-colors"
|
|
37
|
+
>
|
|
38
|
+
Try Again
|
|
39
|
+
</button>
|
|
40
|
+
</div>
|
|
41
|
+
</div>
|
|
42
|
+
)
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
return this.props.children
|
|
46
|
+
}
|
|
47
|
+
}
|
|
@@ -0,0 +1,11 @@
|
|
|
1
|
+
export { Button } from './button'
|
|
2
|
+
export type { ButtonProps } from './button'
|
|
3
|
+
export { Card, CardHeader, CardTitle, CardDescription, CardContent, CardFooter } from './card'
|
|
4
|
+
export { Badge } from './badge'
|
|
5
|
+
export type { BadgeProps } from './badge'
|
|
6
|
+
export { Input } from './input'
|
|
7
|
+
export type { InputProps } from './input'
|
|
8
|
+
export { Select } from './select'
|
|
9
|
+
export { Skeleton, CardSkeleton, TableSkeleton, StatsSkeleton } from './skeleton'
|
|
10
|
+
export { ErrorBoundary } from './error-boundary'
|
|
11
|
+
export { ToastProvider, useToast } from './toast'
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface InputProps extends React.InputHTMLAttributes<HTMLInputElement> {
|
|
5
|
+
error?: boolean
|
|
6
|
+
}
|
|
7
|
+
|
|
8
|
+
const Input = React.forwardRef<HTMLInputElement, InputProps>(
|
|
9
|
+
({ className, type, error, ...props }, ref) => {
|
|
10
|
+
return (
|
|
11
|
+
<input
|
|
12
|
+
type={type}
|
|
13
|
+
className={cn(
|
|
14
|
+
'flex h-9 w-full rounded-md border border-border bg-muted px-3 py-1.5 text-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
|
15
|
+
error && 'border-destructive focus-visible:ring-destructive',
|
|
16
|
+
className,
|
|
17
|
+
)}
|
|
18
|
+
ref={ref}
|
|
19
|
+
{...props}
|
|
20
|
+
/>
|
|
21
|
+
)
|
|
22
|
+
},
|
|
23
|
+
)
|
|
24
|
+
Input.displayName = 'Input'
|
|
25
|
+
|
|
26
|
+
export { Input }
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
import * as React from 'react'
|
|
2
|
+
import { cn } from '@/lib/utils/cn'
|
|
3
|
+
|
|
4
|
+
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
|
|
5
|
+
|
|
6
|
+
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
|
|
7
|
+
({ className, children, ...props }, ref) => {
|
|
8
|
+
return (
|
|
9
|
+
<select
|
|
10
|
+
className={cn(
|
|
11
|
+
'flex h-9 w-full rounded-md border border-border bg-muted px-3 py-1.5 text-sm transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50',
|
|
12
|
+
className,
|
|
13
|
+
)}
|
|
14
|
+
ref={ref}
|
|
15
|
+
{...props}
|
|
16
|
+
>
|
|
17
|
+
{children}
|
|
18
|
+
</select>
|
|
19
|
+
)
|
|
20
|
+
},
|
|
21
|
+
)
|
|
22
|
+
Select.displayName = 'Select'
|
|
23
|
+
|
|
24
|
+
export { Select }
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
export function Skeleton({ className }: { className?: string }) {
|
|
2
|
+
return (
|
|
3
|
+
<div className={`animate-pulse rounded-md bg-muted ${className || ''}`} />
|
|
4
|
+
)
|
|
5
|
+
}
|
|
6
|
+
|
|
7
|
+
export function CardSkeleton() {
|
|
8
|
+
return (
|
|
9
|
+
<div className="bg-card border border-border rounded-lg p-4 space-y-3">
|
|
10
|
+
<div className="flex items-center justify-between">
|
|
11
|
+
<Skeleton className="h-4 w-32" />
|
|
12
|
+
<Skeleton className="h-5 w-16 rounded-full" />
|
|
13
|
+
</div>
|
|
14
|
+
<Skeleton className="h-3 w-48" />
|
|
15
|
+
<div className="flex gap-4">
|
|
16
|
+
<Skeleton className="h-3 w-16" />
|
|
17
|
+
<Skeleton className="h-3 w-20" />
|
|
18
|
+
</div>
|
|
19
|
+
</div>
|
|
20
|
+
)
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export function TableSkeleton({ rows = 5 }: { rows?: number }) {
|
|
24
|
+
return (
|
|
25
|
+
<div className="bg-card border border-border rounded-lg overflow-hidden">
|
|
26
|
+
<div className="border-b border-border bg-muted/50 px-4 py-2.5">
|
|
27
|
+
<Skeleton className="h-3 w-full" />
|
|
28
|
+
</div>
|
|
29
|
+
{Array.from({ length: rows }).map((_, i) => (
|
|
30
|
+
<div key={i} className="border-b border-border px-4 py-3 flex gap-4">
|
|
31
|
+
<Skeleton className="h-3 w-32" />
|
|
32
|
+
<Skeleton className="h-3 w-20" />
|
|
33
|
+
<Skeleton className="h-3 w-16" />
|
|
34
|
+
<Skeleton className="h-3 w-12" />
|
|
35
|
+
<Skeleton className="h-3 w-16" />
|
|
36
|
+
</div>
|
|
37
|
+
))}
|
|
38
|
+
</div>
|
|
39
|
+
)
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
export function StatsSkeleton() {
|
|
43
|
+
return (
|
|
44
|
+
<div className="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-4 gap-4">
|
|
45
|
+
{Array.from({ length: 4 }).map((_, i) => (
|
|
46
|
+
<div key={i} className="bg-card border border-border rounded-lg p-4 space-y-2">
|
|
47
|
+
<Skeleton className="h-3 w-20" />
|
|
48
|
+
<Skeleton className="h-7 w-24" />
|
|
49
|
+
</div>
|
|
50
|
+
))}
|
|
51
|
+
</div>
|
|
52
|
+
)
|
|
53
|
+
}
|
|
@@ -0,0 +1,51 @@
|
|
|
1
|
+
'use client'
|
|
2
|
+
|
|
3
|
+
import { useEffect, useState, useCallback, createContext, useContext, type ReactNode } from 'react'
|
|
4
|
+
|
|
5
|
+
interface Toast {
|
|
6
|
+
id: string
|
|
7
|
+
message: string
|
|
8
|
+
type: 'success' | 'error' | 'info'
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
interface ToastContextValue {
|
|
12
|
+
toast: (message: string, type?: Toast['type']) => void
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
const ToastContext = createContext<ToastContextValue>({ toast: () => {} })
|
|
16
|
+
|
|
17
|
+
export function useToast() {
|
|
18
|
+
return useContext(ToastContext)
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
export function ToastProvider({ children }: { children: ReactNode }) {
|
|
22
|
+
const [toasts, setToasts] = useState<Toast[]>([])
|
|
23
|
+
|
|
24
|
+
const toast = useCallback((message: string, type: Toast['type'] = 'info') => {
|
|
25
|
+
const id = crypto.randomUUID()
|
|
26
|
+
setToasts((prev) => [...prev, { id, message, type }])
|
|
27
|
+
setTimeout(() => {
|
|
28
|
+
setToasts((prev) => prev.filter((t) => t.id !== id))
|
|
29
|
+
}, 4000)
|
|
30
|
+
}, [])
|
|
31
|
+
|
|
32
|
+
return (
|
|
33
|
+
<ToastContext.Provider value={{ toast }}>
|
|
34
|
+
{children}
|
|
35
|
+
<div className="fixed bottom-4 right-4 z-50 space-y-2 pointer-events-none">
|
|
36
|
+
{toasts.map((t) => (
|
|
37
|
+
<div
|
|
38
|
+
key={t.id}
|
|
39
|
+
className={`pointer-events-auto px-4 py-2.5 rounded-md text-sm shadow-lg border animate-in slide-in-from-right fade-in duration-200 ${
|
|
40
|
+
t.type === 'success' ? 'bg-primary/10 border-primary/20 text-primary' :
|
|
41
|
+
t.type === 'error' ? 'bg-destructive/10 border-destructive/20 text-destructive' :
|
|
42
|
+
'bg-card border-border text-foreground'
|
|
43
|
+
}`}
|
|
44
|
+
>
|
|
45
|
+
{t.message}
|
|
46
|
+
</div>
|
|
47
|
+
))}
|
|
48
|
+
</div>
|
|
49
|
+
</ToastContext.Provider>
|
|
50
|
+
)
|
|
51
|
+
}
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { hashApiKey, verifyJwt, signJwt } from '../auth'
|
|
3
|
+
|
|
4
|
+
describe('Auth', () => {
|
|
5
|
+
describe('hashApiKey', () => {
|
|
6
|
+
it('produces consistent SHA-256 hashes', async () => {
|
|
7
|
+
const hash1 = await hashApiKey('test-key')
|
|
8
|
+
const hash2 = await hashApiKey('test-key')
|
|
9
|
+
expect(hash1).toBe(hash2)
|
|
10
|
+
expect(hash1).toHaveLength(64)
|
|
11
|
+
})
|
|
12
|
+
|
|
13
|
+
it('produces different hashes for different inputs', async () => {
|
|
14
|
+
const hash1 = await hashApiKey('key-1')
|
|
15
|
+
const hash2 = await hashApiKey('key-2')
|
|
16
|
+
expect(hash1).not.toBe(hash2)
|
|
17
|
+
})
|
|
18
|
+
})
|
|
19
|
+
|
|
20
|
+
describe('JWT', () => {
|
|
21
|
+
it('signs and verifies a token', async () => {
|
|
22
|
+
const token = await signJwt({ sub: 'admin', role: 'admin', name: 'Admin' })
|
|
23
|
+
expect(token).toBeTruthy()
|
|
24
|
+
|
|
25
|
+
const payload = await verifyJwt(token)
|
|
26
|
+
expect(payload).not.toBeNull()
|
|
27
|
+
expect(payload!.sub).toBe('admin')
|
|
28
|
+
expect(payload!.role).toBe('admin')
|
|
29
|
+
expect(payload!.name).toBe('Admin')
|
|
30
|
+
})
|
|
31
|
+
|
|
32
|
+
it('rejects invalid tokens', async () => {
|
|
33
|
+
const payload = await verifyJwt('invalid-token')
|
|
34
|
+
expect(payload).toBeNull()
|
|
35
|
+
})
|
|
36
|
+
|
|
37
|
+
it('rejects empty tokens', async () => {
|
|
38
|
+
const payload = await verifyJwt('')
|
|
39
|
+
expect(payload).toBeNull()
|
|
40
|
+
})
|
|
41
|
+
})
|
|
42
|
+
})
|
|
@@ -0,0 +1,94 @@
|
|
|
1
|
+
import { describe, it, expect } from 'vitest'
|
|
2
|
+
import { toOpenAIRequest, fromOpenAIResponse } from '../format/openai'
|
|
3
|
+
import { toAnthropicRequest, fromAnthropicResponse } from '../format/anthropic'
|
|
4
|
+
import { toGeminiRequest, fromGeminiResponse } from '../format/gemini'
|
|
5
|
+
import type { NormalizedRequest } from '../providers/types'
|
|
6
|
+
|
|
7
|
+
function makeReq(messages: Array<{ role: 'system' | 'user' | 'assistant'; content: string }>): NormalizedRequest {
|
|
8
|
+
return {
|
|
9
|
+
model: 'test-model',
|
|
10
|
+
messages: messages.map((m) => ({ ...m, role: m.role as 'system' | 'user' | 'assistant' | 'tool' })),
|
|
11
|
+
}
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
describe('Format Translators', () => {
|
|
15
|
+
describe('OpenAI', () => {
|
|
16
|
+
it('converts normalized request to OpenAI format', () => {
|
|
17
|
+
const req = makeReq([
|
|
18
|
+
{ role: 'user', content: 'Hello' },
|
|
19
|
+
{ role: 'assistant', content: 'Hi' },
|
|
20
|
+
])
|
|
21
|
+
const result = toOpenAIRequest(req)
|
|
22
|
+
expect(result.messages).toHaveLength(2)
|
|
23
|
+
expect(result.messages[0].role).toBe('user')
|
|
24
|
+
expect(result.messages[1].role).toBe('assistant')
|
|
25
|
+
})
|
|
26
|
+
|
|
27
|
+
it('parses OpenAI response', () => {
|
|
28
|
+
const raw = {
|
|
29
|
+
id: 'chatcmpl-123',
|
|
30
|
+
model: 'gpt-4o',
|
|
31
|
+
choices: [{ index: 0, message: { role: 'assistant', content: 'Hello!' }, finish_reason: 'stop' }],
|
|
32
|
+
usage: { prompt_tokens: 10, completion_tokens: 5, total_tokens: 15 },
|
|
33
|
+
}
|
|
34
|
+
const result = fromOpenAIResponse(raw)
|
|
35
|
+
expect(result.id).toBe('chatcmpl-123')
|
|
36
|
+
expect(result.choices[0].message.content).toBe('Hello!')
|
|
37
|
+
expect(result.usage.totalTokens).toBe(15)
|
|
38
|
+
})
|
|
39
|
+
})
|
|
40
|
+
|
|
41
|
+
describe('Anthropic', () => {
|
|
42
|
+
it('extracts system message from anthropic format', () => {
|
|
43
|
+
const req = makeReq([
|
|
44
|
+
{ role: 'system', content: 'You are helpful' },
|
|
45
|
+
{ role: 'user', content: 'Hello' },
|
|
46
|
+
])
|
|
47
|
+
const result = toAnthropicRequest(req)
|
|
48
|
+
expect(result.system).toBe('You are helpful')
|
|
49
|
+
})
|
|
50
|
+
|
|
51
|
+
it('parses Anthropic response', () => {
|
|
52
|
+
const raw = {
|
|
53
|
+
id: 'msg-123',
|
|
54
|
+
model: 'claude-sonnet-4',
|
|
55
|
+
content: [{ type: 'text', text: 'Hello!' }],
|
|
56
|
+
stop_reason: 'end_turn',
|
|
57
|
+
usage: { input_tokens: 10, output_tokens: 5 },
|
|
58
|
+
}
|
|
59
|
+
const result = fromAnthropicResponse(raw)
|
|
60
|
+
expect(result.id).toBe('msg-123')
|
|
61
|
+
expect(result.choices[0].message.content).toBe('Hello!')
|
|
62
|
+
})
|
|
63
|
+
})
|
|
64
|
+
|
|
65
|
+
describe('Gemini', () => {
|
|
66
|
+
it('creates Gemini contents array', () => {
|
|
67
|
+
const req = makeReq([
|
|
68
|
+
{ role: 'user', content: 'Hello' },
|
|
69
|
+
{ role: 'assistant', content: 'Hi' },
|
|
70
|
+
])
|
|
71
|
+
const result = toGeminiRequest(req)
|
|
72
|
+
expect(result.contents).toBeDefined()
|
|
73
|
+
expect(result.contents.length).toBeGreaterThan(0)
|
|
74
|
+
})
|
|
75
|
+
|
|
76
|
+
it('extracts systemInstruction', () => {
|
|
77
|
+
const req = makeReq([
|
|
78
|
+
{ role: 'system', content: 'Be helpful' },
|
|
79
|
+
{ role: 'user', content: 'Hello' },
|
|
80
|
+
])
|
|
81
|
+
const result = toGeminiRequest(req)
|
|
82
|
+
expect(result.systemInstruction).toBeDefined()
|
|
83
|
+
})
|
|
84
|
+
|
|
85
|
+
it('parses Gemini response', () => {
|
|
86
|
+
const raw = {
|
|
87
|
+
candidates: [{ content: { parts: [{ text: 'Hello!' }] }, finishReason: 'STOP' }],
|
|
88
|
+
usageMetadata: { promptTokenCount: 10, candidatesTokenCount: 5, totalTokenCount: 15 },
|
|
89
|
+
}
|
|
90
|
+
const result = fromGeminiResponse(raw)
|
|
91
|
+
expect(result.choices[0].message.content).toBe('Hello!')
|
|
92
|
+
})
|
|
93
|
+
})
|
|
94
|
+
})
|