mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2025-12-24 10:40:07 +08:00
feat: implement API client with SWR integration for catalog management
- Added a new Textarea component for user input. - Configured ESLint with custom rules and global ignores. - Developed a comprehensive API client with CRUD operations and error handling. - Defined catalog types and schemas using Zod for type safety. - Created utility functions for class name merging and validation. - Established Next.js configuration for API rewrites and static file headers. - Set up package.json with necessary dependencies and scripts. - Configured PostCSS for Tailwind CSS integration. - Added SVG assets for UI components. - Configured TypeScript with strict settings and module resolution.
This commit is contained in:
parent
d98d69e28d
commit
67f726afb7
@ -113,7 +113,8 @@ export default defineConfig({
|
||||
'@cherrystudio/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'),
|
||||
'@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'),
|
||||
'@cherrystudio/ui': resolve('packages/ui/src')
|
||||
'@cherrystudio/ui': resolve('packages/ui/src'),
|
||||
'@cherrystudio/catalog': resolve('packages/catalog/src')
|
||||
}
|
||||
},
|
||||
optimizeDeps: {
|
||||
|
||||
@ -72,7 +72,7 @@ packages/catalog/
|
||||
```typescript
|
||||
// packages/catalog/src/schemas/model.schema.ts
|
||||
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { EndpointTypeSchema } from './provider.schema'
|
||||
|
||||
// 模态类型
|
||||
@ -206,7 +206,7 @@ export type ModelConfig = z.infer<typeof ModelConfigSchema>
|
||||
```typescript
|
||||
// packages/catalog/src/schemas/provider.schema.ts
|
||||
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
|
||||
// 端点类型
|
||||
export const EndpointTypeSchema = z.enum([
|
||||
@ -311,7 +311,7 @@ export type ProviderConfig = z.infer<typeof ProviderConfigSchema>
|
||||
```typescript
|
||||
// packages/catalog/src/schemas/override.schema.ts
|
||||
|
||||
import { z } from 'zod'
|
||||
import * as z from 'zod'
|
||||
import { ModelCapabilityTypeSchema, ModelPricingSchema, ParameterSupportSchema } from './model.schema'
|
||||
|
||||
export const ProviderModelOverrideSchema = z.object({
|
||||
|
||||
627
packages/catalog/api/openapi.json
Normal file
627
packages/catalog/api/openapi.json
Normal file
@ -0,0 +1,627 @@
|
||||
{
|
||||
"openapi": "3.0.3",
|
||||
"info": {
|
||||
"title": "Cherry Studio Catalog API",
|
||||
"description": "REST API for managing AI models and providers catalog",
|
||||
"version": "1.0.0",
|
||||
"contact": {
|
||||
"name": "Cherry Studio Team"
|
||||
}
|
||||
},
|
||||
"servers": [
|
||||
{
|
||||
"url": "http://localhost:3000/api",
|
||||
"description": "Development server"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"/catalog/models": {
|
||||
"get": {
|
||||
"summary": "List models with pagination and filtering",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "capabilities",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "providers",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paginated list of models",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaginatedModels"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update models configuration",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ModelsConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Models updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/models/{modelId}": {
|
||||
"get": {
|
||||
"summary": "Get specific model details",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "modelId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Model details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update specific model",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "modelId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Model updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/providers": {
|
||||
"get": {
|
||||
"summary": "List providers with pagination and filtering",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "page",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"default": 1
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "limit",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "integer",
|
||||
"minimum": 1,
|
||||
"maximum": 100,
|
||||
"default": 20
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "search",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "authentication",
|
||||
"in": "query",
|
||||
"schema": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Paginated list of providers",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/PaginatedProviders"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update providers configuration",
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ProvidersConfig"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Providers updated successfully"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/providers/{providerId}": {
|
||||
"get": {
|
||||
"summary": "Get specific provider details",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "providerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider details",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Provider"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/models/{modelId}/overrides": {
|
||||
"get": {
|
||||
"summary": "Get provider-specific overrides for a model",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "modelId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Provider overrides for the model",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"additionalProperties": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/models/{modelId}/providers/{providerId}": {
|
||||
"get": {
|
||||
"summary": "Get model configuration as seen by specific provider",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "modelId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "providerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Model configuration with provider-specific overrides applied",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"put": {
|
||||
"summary": "Update model configuration for specific provider (auto-detects if override is needed)",
|
||||
"parameters": [
|
||||
{
|
||||
"name": "modelId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
},
|
||||
{
|
||||
"name": "providerId",
|
||||
"in": "path",
|
||||
"required": true,
|
||||
"schema": {
|
||||
"type": "string"
|
||||
}
|
||||
}
|
||||
],
|
||||
"requestBody": {
|
||||
"required": true,
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Model configuration updated (override created/updated if needed)",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"updated": {
|
||||
"type": "string",
|
||||
"enum": ["base_model", "override", "both"]
|
||||
},
|
||||
"model": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/stats": {
|
||||
"get": {
|
||||
"summary": "Get catalog statistics",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Catalog statistics",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/CatalogStats"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"/catalog/validate": {
|
||||
"post": {
|
||||
"summary": "Validate catalog configuration",
|
||||
"responses": {
|
||||
"200": {
|
||||
"description": "Validation results",
|
||||
"content": {
|
||||
"application/json": {
|
||||
"schema": {
|
||||
"$ref": "#/components/schemas/ValidationResult"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"components": {
|
||||
"schemas": {
|
||||
"Model": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"owned_by": { "type": "string" },
|
||||
"capabilities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"input_modalities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"output_modalities": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"context_window": { "type": "integer" },
|
||||
"max_output_tokens": { "type": "integer" },
|
||||
"max_input_tokens": { "type": "integer" },
|
||||
"pricing": {
|
||||
"$ref": "#/components/schemas/Pricing"
|
||||
},
|
||||
"parameters": {
|
||||
"$ref": "#/components/schemas/Parameters"
|
||||
},
|
||||
"endpoint_types": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"metadata": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"Provider": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"id": { "type": "string" },
|
||||
"name": { "type": "string" },
|
||||
"description": { "type": "string" },
|
||||
"authentication": { "type": "string" },
|
||||
"pricing_model": { "type": "string" },
|
||||
"model_routing": { "type": "string" },
|
||||
"behaviors": { "type": "object" },
|
||||
"supported_endpoints": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"api_compatibility": { "type": "object" },
|
||||
"special_config": { "type": "object" },
|
||||
"documentation": { "type": "string" },
|
||||
"website": { "type": "string" },
|
||||
"deprecated": { "type": "boolean" },
|
||||
"maintenance_mode": { "type": "boolean" },
|
||||
"config_version": { "type": "string" },
|
||||
"metadata": { "type": "object" }
|
||||
}
|
||||
},
|
||||
"Override": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"provider_id": { "type": "string" },
|
||||
"model_id": { "type": "string" },
|
||||
"disabled": { "type": "boolean" },
|
||||
"reason": { "type": "string" },
|
||||
"last_updated": { "type": "string" },
|
||||
"updated_by": { "type": "string" },
|
||||
"priority": { "type": "integer" },
|
||||
"limits": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"context_window": { "type": "integer" },
|
||||
"max_output_tokens": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"$ref": "#/components/schemas/Pricing"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pricing": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"input": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_million_tokens": { "type": "number" },
|
||||
"currency": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"output": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"per_million_tokens": { "type": "number" },
|
||||
"currency": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"Parameters": {
|
||||
"type": "object",
|
||||
"additionalProperties": true
|
||||
},
|
||||
"PaginatedModels": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PaginatedProviders": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Provider"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
}
|
||||
}
|
||||
},
|
||||
"PaginatedOverrides": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"data": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Override"
|
||||
}
|
||||
},
|
||||
"pagination": {
|
||||
"$ref": "#/components/schemas/Pagination"
|
||||
}
|
||||
}
|
||||
},
|
||||
"Pagination": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"page": { "type": "integer" },
|
||||
"limit": { "type": "integer" },
|
||||
"total": { "type": "integer" },
|
||||
"totalPages": { "type": "integer" }
|
||||
}
|
||||
},
|
||||
"ModelsConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"models": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Model"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"ProvidersConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"providers": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Provider"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"OverridesConfig": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"version": { "type": "string" },
|
||||
"overrides": {
|
||||
"type": "array",
|
||||
"items": {
|
||||
"$ref": "#/components/schemas/Override"
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
"CatalogStats": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"total_models": { "type": "integer" },
|
||||
"total_providers": { "type": "integer" },
|
||||
"total_overrides": { "type": "integer" },
|
||||
"models_by_provider": { "type": "object" },
|
||||
"overrides_by_provider": { "type": "object" },
|
||||
"last_updated": { "type": "string" }
|
||||
}
|
||||
},
|
||||
"ValidationResult": {
|
||||
"type": "object",
|
||||
"properties": {
|
||||
"valid": { "type": "boolean" },
|
||||
"errors": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
},
|
||||
"warnings": {
|
||||
"type": "array",
|
||||
"items": { "type": "string" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -85,4 +85,4 @@
|
||||
"models": "models.json",
|
||||
"overrides": "overrides.json"
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@ -47,5 +47,8 @@
|
||||
},
|
||||
"dependencies": {
|
||||
"json-schema": "^0.4.0"
|
||||
}
|
||||
},
|
||||
"workspaces": [
|
||||
"web"
|
||||
]
|
||||
}
|
||||
|
||||
@ -51,4 +51,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -25,4 +25,4 @@
|
||||
"priority": 100
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -50,4 +50,4 @@
|
||||
}
|
||||
}
|
||||
]
|
||||
}
|
||||
}
|
||||
|
||||
@ -74,9 +74,6 @@ interface ProviderConfig {
|
||||
name: string
|
||||
description?: string
|
||||
authentication: string
|
||||
pricing_model: string
|
||||
model_routing: string
|
||||
behaviors: Record<string, boolean>
|
||||
supported_endpoints: string[]
|
||||
api_compatibility?: Record<string, boolean>
|
||||
special_config?: Record<string, any>
|
||||
@ -257,46 +254,11 @@ export class MigrationTool {
|
||||
for (const [providerId, providerData] of Object.entries(this.providerEndpointsData.providers)) {
|
||||
const supported_endpoints = this.privateConvertEndpointsToCapabilities(providerData.endpoints)
|
||||
|
||||
// Determine provider characteristics
|
||||
const isDirectProvider = ['anthropic', 'openai', 'google'].includes(providerId)
|
||||
const isCloudProvider = ['azure', 'aws', 'gcp'].some((cloud) => providerId.includes(cloud))
|
||||
const isProxyProvider = ['openrouter', 'litellm', 'together_ai'].includes(providerId)
|
||||
|
||||
let pricing_model = 'PER_MODEL'
|
||||
let model_routing = 'DIRECT'
|
||||
|
||||
if (isProxyProvider) {
|
||||
pricing_model = 'UNIFIED'
|
||||
model_routing = 'INTELLIGENT'
|
||||
} else if (isCloudProvider) {
|
||||
pricing_model = 'PER_MODEL'
|
||||
model_routing = 'DIRECT'
|
||||
}
|
||||
|
||||
const provider: ProviderConfig = {
|
||||
id: providerId,
|
||||
name: providerData.display_name,
|
||||
description: `Provider: ${providerData.display_name}`,
|
||||
authentication: 'API_KEY',
|
||||
pricing_model,
|
||||
model_routing,
|
||||
behaviors: {
|
||||
supports_custom_models: providerData.endpoints.batches || false,
|
||||
provides_model_mapping: isProxyProvider,
|
||||
supports_model_versioning: true,
|
||||
provides_fallback_routing: isProxyProvider,
|
||||
has_auto_retry: isProxyProvider,
|
||||
supports_health_check: isDirectProvider,
|
||||
has_real_time_metrics: isDirectProvider || isProxyProvider,
|
||||
provides_usage_analytics: isDirectProvider,
|
||||
supports_webhook_events: false,
|
||||
requires_api_key_validation: true,
|
||||
supports_rate_limiting: isDirectProvider,
|
||||
provides_usage_limits: isDirectProvider,
|
||||
supports_streaming: providerData.endpoints.chat_completions || providerData.endpoints.messages,
|
||||
supports_batch_processing: providerData.endpoints.batches || false,
|
||||
supports_model_fine_tuning: providerId === 'openai'
|
||||
},
|
||||
supported_endpoints,
|
||||
api_compatibility: {
|
||||
supports_array_content: providerData.endpoints.chat_completions || false,
|
||||
@ -313,12 +275,7 @@ export class MigrationTool {
|
||||
website: providerData.url,
|
||||
deprecated: false,
|
||||
maintenance_mode: false,
|
||||
config_version: '1.0.0',
|
||||
metadata: {
|
||||
source: 'litellm-endpoints',
|
||||
tags: [isDirectProvider ? 'official' : isProxyProvider ? 'proxy' : 'cloud'],
|
||||
reliability: isDirectProvider ? 'high' : 'medium'
|
||||
}
|
||||
config_version: '1.0.0'
|
||||
}
|
||||
|
||||
providers.push(provider)
|
||||
@ -556,7 +513,9 @@ export class MigrationTool {
|
||||
|
||||
console.log('\n✅ Migration completed successfully!')
|
||||
console.log(`📊 Migration Summary:`)
|
||||
console.log(` Providers: ${providers.length} (${providersByType.direct} direct, ${providersByType.cloud} cloud, ${providersByType.proxy} proxy, ${providersByType.self_hosted} self-hosted)`)
|
||||
console.log(
|
||||
` Providers: ${providers.length} (${providersByType.direct} direct, ${providersByType.cloud} cloud, ${providersByType.proxy} proxy, ${providersByType.self_hosted} self-hosted)`
|
||||
)
|
||||
console.log(` Base Models: ${models.length}`)
|
||||
console.log(` Overrides: ${overrides.length}`)
|
||||
console.log(`\n📁 Output Files:`)
|
||||
|
||||
41
packages/catalog/web/.gitignore
vendored
Normal file
41
packages/catalog/web/.gitignore
vendored
Normal file
@ -0,0 +1,41 @@
|
||||
# See https://help.github.com/articles/ignoring-files/ for more about ignoring files.
|
||||
|
||||
# dependencies
|
||||
/node_modules
|
||||
/.pnp
|
||||
.pnp.*
|
||||
.yarn/*
|
||||
!.yarn/patches
|
||||
!.yarn/plugins
|
||||
!.yarn/releases
|
||||
!.yarn/versions
|
||||
|
||||
# testing
|
||||
/coverage
|
||||
|
||||
# next.js
|
||||
/.next/
|
||||
/out/
|
||||
|
||||
# production
|
||||
/build
|
||||
|
||||
# misc
|
||||
.DS_Store
|
||||
*.pem
|
||||
|
||||
# debug
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
.pnpm-debug.log*
|
||||
|
||||
# env files (can opt-in for committing if needed)
|
||||
.env*
|
||||
|
||||
# vercel
|
||||
.vercel
|
||||
|
||||
# typescript
|
||||
*.tsbuildinfo
|
||||
next-env.d.ts
|
||||
36
packages/catalog/web/README.md
Normal file
36
packages/catalog/web/README.md
Normal file
@ -0,0 +1,36 @@
|
||||
This is a [Next.js](https://nextjs.org) project bootstrapped with [`create-next-app`](https://nextjs.org/docs/app/api-reference/cli/create-next-app).
|
||||
|
||||
## Getting Started
|
||||
|
||||
First, run the development server:
|
||||
|
||||
```bash
|
||||
npm run dev
|
||||
# or
|
||||
yarn dev
|
||||
# or
|
||||
pnpm dev
|
||||
# or
|
||||
bun dev
|
||||
```
|
||||
|
||||
Open [http://localhost:3000](http://localhost:3000) with your browser to see the result.
|
||||
|
||||
You can start editing the page by modifying `app/page.tsx`. The page auto-updates as you edit the file.
|
||||
|
||||
This project uses [`next/font`](https://nextjs.org/docs/app/building-your-application/optimizing/fonts) to automatically optimize and load [Geist](https://vercel.com/font), a new font family for Vercel.
|
||||
|
||||
## Learn More
|
||||
|
||||
To learn more about Next.js, take a look at the following resources:
|
||||
|
||||
- [Next.js Documentation](https://nextjs.org/docs) - learn about Next.js features and API.
|
||||
- [Learn Next.js](https://nextjs.org/learn) - an interactive Next.js tutorial.
|
||||
|
||||
You can check out [the Next.js GitHub repository](https://github.com/vercel/next.js) - your feedback and contributions are welcome!
|
||||
|
||||
## Deploy on Vercel
|
||||
|
||||
The easiest way to deploy your Next.js app is to use the [Vercel Platform](https://vercel.com/new?utm_medium=default-template&filter=next.js&utm_source=create-next-app&utm_campaign=create-next-app-readme) from the creators of Next.js.
|
||||
|
||||
Check out our [Next.js deployment documentation](https://nextjs.org/docs/app/building-your-application/deploying) for more details.
|
||||
@ -0,0 +1,285 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
|
||||
import type { Model, ProviderModelOverride, OverridesDataFile } from '@/lib/catalog-types'
|
||||
import {
|
||||
ModelSchema,
|
||||
ModelsDataFileSchema,
|
||||
ProvidersDataFileSchema,
|
||||
OverridesDataFileSchema
|
||||
} from '@/lib/catalog-types'
|
||||
import { safeParseWithValidation, validateString, ValidationError, createErrorResponse } from '@/lib/validation'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
// Type-safe helper function to apply overrides to base model
|
||||
function applyOverrides(baseModel: Model, override: ProviderModelOverride | null): Model {
|
||||
if (!override) return baseModel
|
||||
|
||||
return {
|
||||
...baseModel,
|
||||
...(override.limits && {
|
||||
context_window: override.limits.context_window ?? baseModel.context_window,
|
||||
max_output_tokens: override.limits.max_output_tokens ?? baseModel.max_output_tokens
|
||||
}),
|
||||
...(override.pricing && { pricing: override.pricing })
|
||||
}
|
||||
}
|
||||
|
||||
// Type-safe helper function to detect model modifications
|
||||
function detectModifications(
|
||||
baseModel: Model,
|
||||
updatedModel: Partial<Model>
|
||||
): {
|
||||
pricing: Model['pricing'] | undefined
|
||||
limits:
|
||||
| {
|
||||
context_window?: number
|
||||
max_output_tokens?: number
|
||||
}
|
||||
| undefined
|
||||
} | null {
|
||||
const modifications: {
|
||||
pricing: Model['pricing'] | undefined
|
||||
limits:
|
||||
| {
|
||||
context_window?: number
|
||||
max_output_tokens?: number
|
||||
}
|
||||
| undefined
|
||||
} = {
|
||||
pricing: undefined,
|
||||
limits: undefined
|
||||
}
|
||||
|
||||
// Check for differences in pricing
|
||||
if (JSON.stringify(baseModel.pricing) !== JSON.stringify(updatedModel.pricing)) {
|
||||
modifications.pricing = updatedModel.pricing
|
||||
}
|
||||
|
||||
// Check for differences in limits
|
||||
if (
|
||||
baseModel.context_window !== updatedModel.context_window ||
|
||||
baseModel.max_output_tokens !== updatedModel.max_output_tokens
|
||||
) {
|
||||
modifications.limits = {}
|
||||
if (baseModel.context_window !== updatedModel.context_window) {
|
||||
modifications.limits.context_window = updatedModel.context_window
|
||||
}
|
||||
if (baseModel.max_output_tokens !== updatedModel.max_output_tokens) {
|
||||
modifications.limits.max_output_tokens = updatedModel.max_output_tokens
|
||||
}
|
||||
}
|
||||
|
||||
return modifications.pricing || modifications.limits ? modifications : null
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { modelId: string; providerId: string } }) {
|
||||
try {
|
||||
const { modelId, providerId } = params
|
||||
|
||||
// Validate parameters
|
||||
const validModelId = validateString(modelId, 'modelId')
|
||||
const validProviderId = validateString(providerId, 'providerId')
|
||||
|
||||
// Read and validate all data files
|
||||
const [modelsDataRaw, providersDataRaw, overridesDataRaw] = await Promise.all([
|
||||
fs.readFile(path.join(DATA_DIR, 'models.json'), 'utf-8'),
|
||||
fs.readFile(path.join(DATA_DIR, 'providers.json'), 'utf-8'),
|
||||
fs.readFile(path.join(DATA_DIR, 'overrides.json'), 'utf-8')
|
||||
])
|
||||
|
||||
const modelsData = await safeParseWithValidation(
|
||||
modelsDataRaw,
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in file'
|
||||
)
|
||||
const providersData = await safeParseWithValidation(
|
||||
providersDataRaw,
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in file'
|
||||
)
|
||||
const overridesData = await safeParseWithValidation(
|
||||
overridesDataRaw,
|
||||
OverridesDataFileSchema,
|
||||
'Invalid overrides data format in file'
|
||||
)
|
||||
|
||||
// Find base model
|
||||
const baseModel = modelsData.models.find((m) => m.id === validModelId)
|
||||
if (!baseModel) {
|
||||
return NextResponse.json(createErrorResponse('Model not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
// Find provider override for this model
|
||||
const override = overridesData.overrides.find(
|
||||
(o) => o.model_id === validModelId && o.provider_id === validProviderId
|
||||
)
|
||||
|
||||
// Apply override if exists
|
||||
const finalModel = applyOverrides(baseModel, override || null)
|
||||
|
||||
return NextResponse.json(ModelSchema.parse(finalModel))
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error fetching provider model:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse(
|
||||
'Failed to fetch model configuration',
|
||||
500,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Response schema for provider model updates
|
||||
const ProviderModelUpdateResponseSchema = z.object({
|
||||
updated: z.enum(['base_model', 'override', 'override_updated', 'override_removed']),
|
||||
model: ModelSchema
|
||||
})
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { modelId: string; providerId: string } }) {
|
||||
try {
|
||||
const { modelId, providerId } = params
|
||||
|
||||
// Validate parameters
|
||||
const validModelId = validateString(modelId, 'modelId')
|
||||
const validProviderId = validateString(providerId, 'providerId')
|
||||
|
||||
// Validate request body
|
||||
const requestBody = await request.json()
|
||||
const updatedModel = await safeParseWithValidation(
|
||||
JSON.stringify(requestBody),
|
||||
ModelSchema.partial(),
|
||||
'Invalid model data in request body'
|
||||
)
|
||||
|
||||
// Read and validate current data
|
||||
const [modelsDataRaw, providersDataRaw, overridesDataRaw] = await Promise.all([
|
||||
fs.readFile(path.join(DATA_DIR, 'models.json'), 'utf-8'),
|
||||
fs.readFile(path.join(DATA_DIR, 'providers.json'), 'utf-8'),
|
||||
fs.readFile(path.join(DATA_DIR, 'overrides.json'), 'utf-8')
|
||||
])
|
||||
|
||||
const modelsData = await safeParseWithValidation(
|
||||
modelsDataRaw,
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in file'
|
||||
)
|
||||
const providersData = await safeParseWithValidation(
|
||||
providersDataRaw,
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in file'
|
||||
)
|
||||
const overridesData = await safeParseWithValidation(
|
||||
overridesDataRaw,
|
||||
OverridesDataFileSchema,
|
||||
'Invalid overrides data format in file'
|
||||
)
|
||||
|
||||
// Find base model and existing override
|
||||
const baseModelIndex = modelsData.models.findIndex((m) => m.id === validModelId)
|
||||
const existingOverrideIndex = overridesData.overrides.findIndex(
|
||||
(o) => o.model_id === validModelId && o.provider_id === validProviderId
|
||||
)
|
||||
|
||||
if (baseModelIndex === -1) {
|
||||
return NextResponse.json(createErrorResponse('Base model not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
const baseModel = modelsData.models[baseModelIndex]
|
||||
|
||||
// Detect what needs to be overridden
|
||||
const modifications = detectModifications(baseModel, updatedModel)
|
||||
|
||||
let updated: 'base_model' | 'override' | 'override_updated' | 'override_removed' = 'base_model'
|
||||
let overrideCreated = false
|
||||
|
||||
if (modifications) {
|
||||
// Create or update override
|
||||
const override: ProviderModelOverride = {
|
||||
provider_id: validProviderId,
|
||||
model_id: validModelId,
|
||||
disabled: false,
|
||||
reason: 'Manual configuration update',
|
||||
last_updated: new Date().toISOString().split('T')[0],
|
||||
updated_by: 'web-interface',
|
||||
priority: 100,
|
||||
...modifications
|
||||
}
|
||||
|
||||
const updatedOverrides = [...overridesData.overrides]
|
||||
|
||||
if (existingOverrideIndex >= 0) {
|
||||
updatedOverrides[existingOverrideIndex] = {
|
||||
...updatedOverrides[existingOverrideIndex],
|
||||
...override,
|
||||
last_updated: new Date().toISOString().split('T')[0]
|
||||
}
|
||||
} else {
|
||||
updatedOverrides.push(override)
|
||||
overrideCreated = true
|
||||
}
|
||||
|
||||
const updatedOverridesData: OverridesDataFile = {
|
||||
...overridesData,
|
||||
overrides: updatedOverrides
|
||||
}
|
||||
|
||||
updated = overrideCreated ? 'override' : 'override_updated'
|
||||
|
||||
// Save changes to overrides file
|
||||
await fs.writeFile(path.join(DATA_DIR, 'overrides.json'), JSON.stringify(updatedOverridesData, null, 2), 'utf-8')
|
||||
} else if (existingOverrideIndex >= 0) {
|
||||
// Remove override if no differences exist
|
||||
const updatedOverrides = overridesData.overrides.filter((_, index) => index !== existingOverrideIndex)
|
||||
|
||||
const updatedOverridesData: OverridesDataFile = {
|
||||
...overridesData,
|
||||
overrides: updatedOverrides
|
||||
}
|
||||
|
||||
updated = 'override_removed'
|
||||
|
||||
// Save changes to overrides file
|
||||
await fs.writeFile(path.join(DATA_DIR, 'overrides.json'), JSON.stringify(updatedOverridesData, null, 2), 'utf-8')
|
||||
}
|
||||
|
||||
// Return the final model configuration
|
||||
const finalOverride = overridesData.overrides.find(
|
||||
(o) => o.model_id === validModelId && o.provider_id === validProviderId
|
||||
)
|
||||
const finalModel = applyOverrides(baseModel, finalOverride || null)
|
||||
|
||||
const response = ProviderModelUpdateResponseSchema.parse({
|
||||
updated,
|
||||
model: finalModel
|
||||
})
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error updating provider model:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse(
|
||||
'Failed to update model configuration',
|
||||
500,
|
||||
error instanceof Error ? error.message : 'Unknown error'
|
||||
),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
113
packages/catalog/web/app/api/catalog/models/[modelId]/route.ts
Normal file
113
packages/catalog/web/app/api/catalog/models/[modelId]/route.ts
Normal file
@ -0,0 +1,113 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
import type { ModelsDataFile } from '@/lib/catalog-types'
|
||||
import { ModelSchema, ModelsDataFileSchema, ModelUpdateResponseSchema } from '@/lib/catalog-types'
|
||||
import { createErrorResponse, safeParseWithValidation, ValidationError } from '@/lib/validation'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { modelId: string } }) {
|
||||
try {
|
||||
const { modelId } = params
|
||||
|
||||
// Read and validate models data using Zod
|
||||
const modelsDataPath = path.join(DATA_DIR, 'models.json')
|
||||
const modelsDataRaw = await fs.readFile(modelsDataPath, 'utf-8')
|
||||
const modelsData = await safeParseWithValidation(
|
||||
modelsDataRaw,
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in file'
|
||||
)
|
||||
|
||||
// Find the model with type safety
|
||||
const model = modelsData.models.find((m) => m.id === modelId)
|
||||
if (!model) {
|
||||
return NextResponse.json(createErrorResponse('Model not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(model)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error fetching model:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to fetch model', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { modelId: string } }) {
|
||||
try {
|
||||
const { modelId } = params
|
||||
|
||||
// Read and validate request body using Zod
|
||||
const requestBody = await request.json()
|
||||
const updatedModel = await safeParseWithValidation(
|
||||
JSON.stringify(requestBody),
|
||||
ModelSchema,
|
||||
'Invalid model data in request body'
|
||||
)
|
||||
|
||||
// Validate that the model ID matches
|
||||
if (updatedModel.id !== modelId) {
|
||||
return NextResponse.json(createErrorResponse('Model ID in request body must match URL parameter', 400), {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
// Read current models data using Zod
|
||||
const modelsDataPath = path.join(DATA_DIR, 'models.json')
|
||||
const modelsDataRaw = await fs.readFile(modelsDataPath, 'utf-8')
|
||||
const modelsData = await safeParseWithValidation(
|
||||
modelsDataRaw,
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in file'
|
||||
)
|
||||
|
||||
// Find and update the model
|
||||
const modelIndex = modelsData.models.findIndex((m) => m.id === modelId)
|
||||
if (modelIndex === -1) {
|
||||
return NextResponse.json(createErrorResponse('Model not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
// Create updated models array (immutability)
|
||||
const updatedModels = [
|
||||
...modelsData.models.slice(0, modelIndex),
|
||||
updatedModel,
|
||||
...modelsData.models.slice(modelIndex + 1)
|
||||
]
|
||||
|
||||
const updatedModelsData: ModelsDataFile = {
|
||||
...modelsData,
|
||||
models: updatedModels
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(modelsDataPath, JSON.stringify(updatedModelsData, null, 2), 'utf-8')
|
||||
|
||||
const response = ModelUpdateResponseSchema.parse({
|
||||
success: true,
|
||||
model: updatedModel
|
||||
})
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error updating model:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to update model', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
156
packages/catalog/web/app/api/catalog/models/route.ts
Normal file
156
packages/catalog/web/app/api/catalog/models/route.ts
Normal file
@ -0,0 +1,156 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
import type { Model } from '@/lib/catalog-types'
|
||||
import {
|
||||
ModelSchema,
|
||||
ModelsDataFileSchema
|
||||
} from '@/lib/catalog-types'
|
||||
import {
|
||||
createErrorResponse,
|
||||
safeParseWithValidation,
|
||||
validatePaginatedResponse,
|
||||
validateQueryParams,
|
||||
ValidationError
|
||||
} from '@/lib/validation'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
function filterModels(
|
||||
models: readonly Model[],
|
||||
search?: string,
|
||||
capabilities?: string[],
|
||||
providers?: string[]
|
||||
): Model[] {
|
||||
let filtered = [...models]
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(model) =>
|
||||
model.id.toLowerCase().includes(searchLower) ||
|
||||
model.name?.toLowerCase().includes(searchLower) ||
|
||||
model.owned_by?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
if (capabilities && capabilities.length > 0) {
|
||||
filtered = filtered.filter((model) => capabilities.some((cap) => model.capabilities.includes(cap)))
|
||||
}
|
||||
|
||||
if (providers && providers.length > 0) {
|
||||
filtered = filtered.filter((model) => model.owned_by && providers.includes(model.owned_by))
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
function paginateItems<T>(
|
||||
items: readonly T[],
|
||||
page: number,
|
||||
limit: number
|
||||
): {
|
||||
items: T[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
} {
|
||||
const total = items.length
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const offset = (page - 1) * limit
|
||||
const paginatedItems = items.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
items: paginatedItems,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Validate query parameters using Zod
|
||||
const validatedParams = validateQueryParams(searchParams)
|
||||
|
||||
// Read and validate models data using Zod
|
||||
const modelsDataPath = path.join(DATA_DIR, 'models.json')
|
||||
const modelsDataRaw = await fs.readFile(modelsDataPath, 'utf-8')
|
||||
const modelsData = await safeParseWithValidation(
|
||||
modelsDataRaw,
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in file'
|
||||
)
|
||||
|
||||
// Filter models with type safety
|
||||
const filteredModels = filterModels(
|
||||
modelsData.models,
|
||||
validatedParams.search,
|
||||
validatedParams.capabilities,
|
||||
validatedParams.providers
|
||||
)
|
||||
|
||||
// Paginate results
|
||||
const { items, pagination } = paginateItems(filteredModels, validatedParams.page, validatedParams.limit)
|
||||
|
||||
// Create paginated response using Zod schema
|
||||
const response = validatePaginatedResponse({ data: items, pagination }, ModelSchema)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error fetching models:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to fetch models', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the data structure using Zod
|
||||
const validatedData = await safeParseWithValidation(
|
||||
JSON.stringify(body),
|
||||
ModelsDataFileSchema,
|
||||
'Invalid models data format in request body'
|
||||
)
|
||||
|
||||
// Write validated data back to file
|
||||
const modelsDataPath = path.join(DATA_DIR, 'models.json')
|
||||
await fs.writeFile(modelsDataPath, JSON.stringify(validatedData, null, 2), 'utf-8')
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error updating models:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to update models', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
@ -0,0 +1,113 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
import type { ProvidersDataFile } from '@/lib/catalog-types'
|
||||
import { ProviderSchema, ProvidersDataFileSchema, ProviderUpdateResponseSchema } from '@/lib/catalog-types'
|
||||
import { createErrorResponse, safeParseWithValidation, ValidationError } from '@/lib/validation'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
export async function GET(request: NextRequest, { params }: { params: { providerId: string } }) {
|
||||
try {
|
||||
const { providerId } = params
|
||||
|
||||
// Read and validate providers data using Zod
|
||||
const providersDataPath = path.join(DATA_DIR, 'providers.json')
|
||||
const providersDataRaw = await fs.readFile(providersDataPath, 'utf-8')
|
||||
const providersData = await safeParseWithValidation(
|
||||
providersDataRaw,
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in file'
|
||||
)
|
||||
|
||||
// Find the provider with type safety
|
||||
const provider = providersData.providers.find((p) => p.id === providerId)
|
||||
if (!provider) {
|
||||
return NextResponse.json(createErrorResponse('Provider not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
return NextResponse.json(provider)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error fetching provider:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to fetch provider', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest, { params }: { params: { providerId: string } }) {
|
||||
try {
|
||||
const { providerId } = params
|
||||
|
||||
// Read and validate request body using Zod
|
||||
const requestBody = await request.json()
|
||||
const updatedProvider = await safeParseWithValidation(
|
||||
JSON.stringify(requestBody),
|
||||
ProviderSchema,
|
||||
'Invalid provider data in request body'
|
||||
)
|
||||
|
||||
// Validate that the provider ID matches
|
||||
if (updatedProvider.id !== providerId) {
|
||||
return NextResponse.json(createErrorResponse('Provider ID in request body must match URL parameter', 400), {
|
||||
status: 400
|
||||
})
|
||||
}
|
||||
|
||||
// Read current providers data using Zod
|
||||
const providersDataPath = path.join(DATA_DIR, 'providers.json')
|
||||
const providersDataRaw = await fs.readFile(providersDataPath, 'utf-8')
|
||||
const providersData = await safeParseWithValidation(
|
||||
providersDataRaw,
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in file'
|
||||
)
|
||||
|
||||
// Find and update the provider
|
||||
const providerIndex = providersData.providers.findIndex((p) => p.id === providerId)
|
||||
if (providerIndex === -1) {
|
||||
return NextResponse.json(createErrorResponse('Provider not found', 404), { status: 404 })
|
||||
}
|
||||
|
||||
// Create updated providers array (immutability)
|
||||
const updatedProviders = [
|
||||
...providersData.providers.slice(0, providerIndex),
|
||||
updatedProvider,
|
||||
...providersData.providers.slice(providerIndex + 1)
|
||||
]
|
||||
|
||||
const updatedProvidersData: ProvidersDataFile = {
|
||||
...providersData,
|
||||
providers: updatedProviders
|
||||
}
|
||||
|
||||
// Write back to file
|
||||
await fs.writeFile(providersDataPath, JSON.stringify(updatedProvidersData, null, 2), 'utf-8')
|
||||
|
||||
const response = ProviderUpdateResponseSchema.parse({
|
||||
success: true,
|
||||
provider: updatedProvider
|
||||
})
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error updating provider:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to update provider', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
146
packages/catalog/web/app/api/catalog/providers/route.ts
Normal file
146
packages/catalog/web/app/api/catalog/providers/route.ts
Normal file
@ -0,0 +1,146 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import type { NextRequest } from 'next/server'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
|
||||
import type { Provider } from '@/lib/catalog-types'
|
||||
import {
|
||||
ProviderSchema,
|
||||
ProvidersDataFileSchema
|
||||
} from '@/lib/catalog-types'
|
||||
import {
|
||||
createErrorResponse,
|
||||
safeParseWithValidation,
|
||||
validatePaginatedResponse,
|
||||
validateQueryParams,
|
||||
ValidationError
|
||||
} from '@/lib/validation'
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
function filterProviders(providers: readonly Provider[], search?: string, authentication?: string[]): Provider[] {
|
||||
let filtered = [...providers]
|
||||
|
||||
if (search) {
|
||||
const searchLower = search.toLowerCase()
|
||||
filtered = filtered.filter(
|
||||
(provider) =>
|
||||
provider.id.toLowerCase().includes(searchLower) ||
|
||||
provider.name.toLowerCase().includes(searchLower) ||
|
||||
provider.description?.toLowerCase().includes(searchLower)
|
||||
)
|
||||
}
|
||||
|
||||
if (authentication && authentication.length > 0) {
|
||||
filtered = filtered.filter((provider) => authentication.includes(provider.authentication))
|
||||
}
|
||||
|
||||
return filtered
|
||||
}
|
||||
|
||||
function paginateItems<T>(
|
||||
items: readonly T[],
|
||||
page: number,
|
||||
limit: number
|
||||
): {
|
||||
items: T[]
|
||||
pagination: {
|
||||
page: number
|
||||
limit: number
|
||||
total: number
|
||||
totalPages: number
|
||||
hasNext: boolean
|
||||
hasPrev: boolean
|
||||
}
|
||||
} {
|
||||
const total = items.length
|
||||
const totalPages = Math.ceil(total / limit)
|
||||
const offset = (page - 1) * limit
|
||||
const paginatedItems = items.slice(offset, offset + limit)
|
||||
|
||||
return {
|
||||
items: paginatedItems,
|
||||
pagination: {
|
||||
page,
|
||||
limit,
|
||||
total,
|
||||
totalPages,
|
||||
hasNext: page < totalPages,
|
||||
hasPrev: page > 1
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const { searchParams } = new URL(request.url)
|
||||
|
||||
// Validate query parameters using Zod
|
||||
const validatedParams = validateQueryParams(searchParams)
|
||||
|
||||
// Read and validate providers data using Zod
|
||||
const providersDataPath = path.join(DATA_DIR, 'providers.json')
|
||||
const providersDataRaw = await fs.readFile(providersDataPath, 'utf-8')
|
||||
const providersData = await safeParseWithValidation(
|
||||
providersDataRaw,
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in file'
|
||||
)
|
||||
|
||||
// Filter providers with type safety
|
||||
const filteredProviders = filterProviders(
|
||||
providersData.providers,
|
||||
validatedParams.search,
|
||||
validatedParams.authentication
|
||||
)
|
||||
|
||||
// Paginate results
|
||||
const { items, pagination } = paginateItems(filteredProviders, validatedParams.page, validatedParams.limit)
|
||||
|
||||
// Create paginated response using Zod schema
|
||||
const response = validatePaginatedResponse({ data: items, pagination }, ProviderSchema)
|
||||
|
||||
return NextResponse.json(response)
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error fetching providers:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to fetch providers', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json()
|
||||
|
||||
// Validate the data structure using Zod
|
||||
const validatedData = await safeParseWithValidation(
|
||||
JSON.stringify(body),
|
||||
ProvidersDataFileSchema,
|
||||
'Invalid providers data format in request body'
|
||||
)
|
||||
|
||||
// Write validated data back to file
|
||||
const providersDataPath = path.join(DATA_DIR, 'providers.json')
|
||||
await fs.writeFile(providersDataPath, JSON.stringify(validatedData, null, 2), 'utf-8')
|
||||
|
||||
return NextResponse.json({ success: true })
|
||||
} catch (error) {
|
||||
if (error instanceof ValidationError) {
|
||||
console.error('Validation error:', error.message, error.details)
|
||||
return NextResponse.json(createErrorResponse(error.message, 400, error.details), { status: 400 })
|
||||
}
|
||||
|
||||
console.error('Error updating providers:', error)
|
||||
return NextResponse.json(
|
||||
createErrorResponse('Failed to update providers', 500, error instanceof Error ? error.message : 'Unknown error'),
|
||||
{ status: 500 }
|
||||
)
|
||||
}
|
||||
}
|
||||
70
packages/catalog/web/app/api/catalog/stats/route.ts
Normal file
70
packages/catalog/web/app/api/catalog/stats/route.ts
Normal file
@ -0,0 +1,70 @@
|
||||
import { promises as fs } from 'fs'
|
||||
import { NextResponse } from 'next/server'
|
||||
import path from 'path'
|
||||
import { z } from 'zod'
|
||||
|
||||
// Define schema for stats response
|
||||
const StatsResponseSchema = z.object({
|
||||
total_models: z.number(),
|
||||
total_providers: z.number(),
|
||||
total_overrides: z.number(),
|
||||
last_updated: z.string().optional(),
|
||||
migration_status: z.enum(['completed', 'in_progress', 'failed']).optional()
|
||||
})
|
||||
|
||||
const DATA_DIR = path.join(process.cwd(), '../data')
|
||||
|
||||
// Define schema for migration report
|
||||
const MigrationReportSchema = z.object({
|
||||
summary: z.object({
|
||||
total_base_models: z.number(),
|
||||
total_providers: z.number(),
|
||||
total_overrides: z.number()
|
||||
})
|
||||
})
|
||||
|
||||
const ModelsDataSchema = z.object({
|
||||
version: z.string(),
|
||||
models: z.array(z.any())
|
||||
})
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
// Read migration report for stats with Zod validation
|
||||
const reportData = await fs.readFile(path.join(DATA_DIR, 'migration-report.json'), 'utf-8')
|
||||
const report = MigrationReportSchema.parse(JSON.parse(reportData))
|
||||
|
||||
// Read actual data for last updated timestamp with Zod validation
|
||||
const modelsData = await fs.readFile(path.join(DATA_DIR, 'models.json'), 'utf-8')
|
||||
const models = ModelsDataSchema.parse(JSON.parse(modelsData))
|
||||
|
||||
const stats = {
|
||||
total_models: report.summary.total_base_models,
|
||||
total_providers: report.summary.total_providers,
|
||||
total_overrides: report.summary.total_overrides,
|
||||
last_updated: new Date().toISOString(),
|
||||
version: models.version
|
||||
}
|
||||
|
||||
// Validate response with Zod schema
|
||||
const validatedStats = StatsResponseSchema.parse(stats)
|
||||
|
||||
return NextResponse.json(validatedStats)
|
||||
} catch (error) {
|
||||
console.error('Error fetching stats:', error)
|
||||
|
||||
// Try to provide a minimal fallback response
|
||||
const fallbackStats = {
|
||||
total_models: 0,
|
||||
total_providers: 0,
|
||||
total_overrides: 0
|
||||
}
|
||||
|
||||
try {
|
||||
const validatedFallback = StatsResponseSchema.parse(fallbackStats)
|
||||
return NextResponse.json(validatedFallback)
|
||||
} catch {
|
||||
return NextResponse.json({ error: 'Failed to fetch stats' }, { status: 500 })
|
||||
}
|
||||
}
|
||||
}
|
||||
BIN
packages/catalog/web/app/favicon.ico
Normal file
BIN
packages/catalog/web/app/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 25 KiB |
26
packages/catalog/web/app/globals.css
Normal file
26
packages/catalog/web/app/globals.css
Normal file
@ -0,0 +1,26 @@
|
||||
@import 'tailwindcss';
|
||||
|
||||
:root {
|
||||
--background: #ffffff;
|
||||
--foreground: #171717;
|
||||
}
|
||||
|
||||
@theme inline {
|
||||
--color-background: var(--background);
|
||||
--color-foreground: var(--foreground);
|
||||
--font-sans: var(--font-geist-sans);
|
||||
--font-mono: var(--font-geist-mono);
|
||||
}
|
||||
|
||||
@media (prefers-color-scheme: dark) {
|
||||
:root {
|
||||
--background: #0a0a0a;
|
||||
--foreground: #ededed;
|
||||
}
|
||||
}
|
||||
|
||||
body {
|
||||
background: var(--background);
|
||||
color: var(--foreground);
|
||||
font-family: Arial, Helvetica, sans-serif;
|
||||
}
|
||||
31
packages/catalog/web/app/layout.tsx
Normal file
31
packages/catalog/web/app/layout.tsx
Normal file
@ -0,0 +1,31 @@
|
||||
import './globals.css'
|
||||
|
||||
import type { Metadata } from 'next'
|
||||
import { Geist, Geist_Mono } from 'next/font/google'
|
||||
|
||||
const geistSans = Geist({
|
||||
variable: '--font-geist-sans',
|
||||
subsets: ['latin']
|
||||
})
|
||||
|
||||
const geistMono = Geist_Mono({
|
||||
variable: '--font-geist-mono',
|
||||
subsets: ['latin']
|
||||
})
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Create Next App',
|
||||
description: 'Generated by create next app'
|
||||
}
|
||||
|
||||
export default function RootLayout({
|
||||
children
|
||||
}: Readonly<{
|
||||
children: React.ReactNode
|
||||
}>) {
|
||||
return (
|
||||
<html lang="en">
|
||||
<body className={`${geistSans.variable} ${geistMono.variable} antialiased`}>{children}</body>
|
||||
</html>
|
||||
)
|
||||
}
|
||||
348
packages/catalog/web/app/page.tsx
Normal file
348
packages/catalog/web/app/page.tsx
Normal file
@ -0,0 +1,348 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Navigation } from '@/components/navigation'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
// Import SWR hooks and utilities
|
||||
import { getErrorMessage, useDebounce, useModels, useUpdateModel } from '@/lib/api-client'
|
||||
import type { CapabilityType, Model } from '@/lib/catalog-types'
|
||||
|
||||
// Type-safe capabilities list
|
||||
const CAPABILITIES: readonly CapabilityType[] = [
|
||||
'FUNCTION_CALL',
|
||||
'REASONING',
|
||||
'IMAGE_RECOGNITION',
|
||||
'IMAGE_GENERATION',
|
||||
'AUDIO_RECOGNITION',
|
||||
'AUDIO_GENERATION',
|
||||
'EMBEDDING',
|
||||
'RERANK',
|
||||
'AUDIO_TRANSCRIPT',
|
||||
'VIDEO_RECOGNITION',
|
||||
'VIDEO_GENERATION',
|
||||
'STRUCTURED_OUTPUT',
|
||||
'FILE_INPUT',
|
||||
'WEB_SEARCH',
|
||||
'CODE_EXECUTION',
|
||||
'FILE_SEARCH',
|
||||
'COMPUTER_USE'
|
||||
] as const
|
||||
|
||||
// Simple Pagination Component
|
||||
function SimplePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}: {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
const pages = Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
if (totalPages <= 5) return i + 1
|
||||
if (currentPage <= 3) return i + 1
|
||||
if (currentPage >= totalPages - 2) return totalPages - 4 + i
|
||||
return currentPage - 2 + i
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage <= 1}>
|
||||
Previous
|
||||
</Button>
|
||||
{pages.map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function CatalogReview() {
|
||||
// Form state
|
||||
const [search, setSearch] = useState('')
|
||||
const [selectedCapabilities, setSelectedCapabilities] = useState<string[]>([])
|
||||
const [selectedProviders, setSelectedProviders] = useState<string[]>([])
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [editingModel, setEditingModel] = useState<Model | null>(null)
|
||||
const [jsonContent, setJsonContent] = useState('')
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
// SWR hook for fetching models
|
||||
const {
|
||||
data: modelsData,
|
||||
error,
|
||||
isLoading
|
||||
} = useModels({
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
search: debouncedSearch,
|
||||
capabilities: selectedCapabilities.length > 0 ? selectedCapabilities : undefined,
|
||||
providers: selectedProviders.length > 0 ? selectedProviders : undefined
|
||||
})
|
||||
|
||||
// SWR mutation for updating models
|
||||
const { trigger: updateModel, isMutating: isUpdating } = useUpdateModel()
|
||||
|
||||
// Extract data from SWR response
|
||||
const models = modelsData?.data || []
|
||||
const pagination = modelsData?.pagination || {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
}
|
||||
|
||||
const handleEdit = (model: Model) => {
|
||||
setEditingModel(model)
|
||||
setJsonContent(JSON.stringify(model, null, 2))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingModel) return
|
||||
|
||||
try {
|
||||
// Validate JSON before sending
|
||||
const updatedModel = JSON.parse(jsonContent) as unknown
|
||||
|
||||
// Basic validation - the API will do thorough validation
|
||||
if (!updatedModel || typeof updatedModel !== 'object') {
|
||||
throw new Error('Invalid JSON format')
|
||||
}
|
||||
|
||||
// Use SWR mutation for optimistic update
|
||||
await updateModel({
|
||||
id: editingModel.id,
|
||||
data: updatedModel as Partial<Model>
|
||||
})
|
||||
|
||||
// Close dialog and reset form
|
||||
setEditingModel(null)
|
||||
setJsonContent('')
|
||||
} catch (error) {
|
||||
console.error('Error saving model:', error)
|
||||
// Error will be handled by SWR and displayed in UI
|
||||
}
|
||||
}
|
||||
|
||||
// Type-safe function to extract unique providers
|
||||
const getUniqueProviders = (): string[] => {
|
||||
return [
|
||||
...new Set(models.map((model) => model.owned_by).filter((provider): provider is string => Boolean(provider)))
|
||||
]
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Catalog Review</h1>
|
||||
<p className="text-muted-foreground">Review and validate model configurations after migration</p>
|
||||
</div>
|
||||
<Navigation />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>Filter models to review specific configurations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Search models..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Capabilities</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{CAPABILITIES.map((capability) => (
|
||||
<Badge
|
||||
key={capability}
|
||||
variant={selectedCapabilities.includes(capability) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedCapabilities((prev) =>
|
||||
prev.includes(capability) ? prev.filter((c) => c !== capability) : [...prev, capability]
|
||||
)
|
||||
}}>
|
||||
{capability.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="text-sm font-medium mb-2 block">Providers</label>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
{getUniqueProviders().map((provider) => (
|
||||
<Badge
|
||||
key={provider}
|
||||
variant={selectedProviders.includes(provider) ? 'default' : 'outline'}
|
||||
className="cursor-pointer"
|
||||
onClick={() => {
|
||||
setSelectedProviders((prev) =>
|
||||
prev.includes(provider) ? prev.filter((p) => p !== provider) : [...prev, provider]
|
||||
)
|
||||
}}>
|
||||
{provider}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{getErrorMessage(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Models ({pagination.total})</CardTitle>
|
||||
<CardDescription>Review migrated model configurations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-pulse">Loading models...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Provider</TableHead>
|
||||
<TableHead>Capabilities</TableHead>
|
||||
<TableHead>Context Window</TableHead>
|
||||
<TableHead>Modalities</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{models.map((model) => (
|
||||
<TableRow key={model.id}>
|
||||
<TableCell className="font-mono text-sm">{model.id}</TableCell>
|
||||
<TableCell>{model.name || model.id}</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{model.owned_by}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{model.capabilities.slice(0, 3).map((cap) => (
|
||||
<Badge key={cap} variant="secondary" className="text-xs">
|
||||
{cap.replace('_', ' ')}
|
||||
</Badge>
|
||||
))}
|
||||
{model.capabilities.length > 3 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{model.capabilities.length - 3}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>{model.context_window.toLocaleString()}</TableCell>
|
||||
<TableCell>
|
||||
<div className="text-sm">
|
||||
<div>In: {model.input_modalities?.join(', ')}</div>
|
||||
<div>Out: {model.output_modalities?.join(', ')}</div>
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(model)}>
|
||||
Edit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Model Configuration</DialogTitle>
|
||||
<DialogDescription>
|
||||
Modify the JSON configuration for {model.name || model.id}
|
||||
</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={jsonContent}
|
||||
onChange={(e) => setJsonContent(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setEditingModel(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} models
|
||||
</div>
|
||||
<SimplePagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
323
packages/catalog/web/app/providers/page.tsx
Normal file
323
packages/catalog/web/app/providers/page.tsx
Normal file
@ -0,0 +1,323 @@
|
||||
'use client'
|
||||
|
||||
import { useState } from 'react'
|
||||
|
||||
import { Navigation } from '@/components/navigation'
|
||||
import { Alert, AlertDescription } from '@/components/ui/alert'
|
||||
import { Badge } from '@/components/ui/badge'
|
||||
import { Button } from '@/components/ui/button'
|
||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'
|
||||
import {
|
||||
Dialog,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogHeader,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
} from '@/components/ui/dialog'
|
||||
import { Input } from '@/components/ui/input'
|
||||
import { Separator } from '@/components/ui/separator'
|
||||
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table'
|
||||
import { Textarea } from '@/components/ui/textarea'
|
||||
// Import SWR hooks and utilities
|
||||
import { getErrorMessage, useDebounce, useProviders, useUpdateProvider } from '@/lib/api-client'
|
||||
import type { Provider } from '@/lib/catalog-types'
|
||||
|
||||
// Simple Pagination Component
|
||||
function SimplePagination({
|
||||
currentPage,
|
||||
totalPages,
|
||||
onPageChange
|
||||
}: {
|
||||
currentPage: number
|
||||
totalPages: number
|
||||
onPageChange: (page: number) => void
|
||||
}) {
|
||||
const pages = Array.from({ length: Math.min(5, totalPages) }, (_, i) => {
|
||||
if (totalPages <= 5) return i + 1
|
||||
if (currentPage <= 3) return i + 1
|
||||
if (currentPage >= totalPages - 2) return totalPages - 4 + i
|
||||
return currentPage - 2 + i
|
||||
})
|
||||
|
||||
return (
|
||||
<div className="flex items-center gap-2">
|
||||
<Button variant="outline" size="sm" onClick={() => onPageChange(currentPage - 1)} disabled={currentPage <= 1}>
|
||||
Previous
|
||||
</Button>
|
||||
{pages.map((page) => (
|
||||
<Button
|
||||
key={page}
|
||||
variant={currentPage === page ? 'default' : 'outline'}
|
||||
size="sm"
|
||||
onClick={() => onPageChange(page)}>
|
||||
{page}
|
||||
</Button>
|
||||
))}
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => onPageChange(currentPage + 1)}
|
||||
disabled={currentPage >= totalPages}>
|
||||
Next
|
||||
</Button>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function ProvidersPage() {
|
||||
// Form state
|
||||
const [search, setSearch] = useState('')
|
||||
const [currentPage, setCurrentPage] = useState(1)
|
||||
const [editingProvider, setEditingProvider] = useState<Provider | null>(null)
|
||||
const [jsonContent, setJsonContent] = useState('')
|
||||
|
||||
// Debounce search to avoid excessive API calls
|
||||
const debouncedSearch = useDebounce(search, 300)
|
||||
|
||||
// SWR hook for fetching providers
|
||||
const {
|
||||
data: providersData,
|
||||
error,
|
||||
isLoading,
|
||||
mutate: refetchProviders
|
||||
} = useProviders({
|
||||
page: currentPage,
|
||||
limit: 20,
|
||||
search: debouncedSearch
|
||||
})
|
||||
|
||||
// SWR mutation for updating providers
|
||||
const { trigger: updateProvider, isMutating: isUpdating } = useUpdateProvider()
|
||||
|
||||
// Extract data from SWR response
|
||||
const providers = providersData?.data || []
|
||||
const pagination = providersData?.pagination || {
|
||||
page: 1,
|
||||
limit: 20,
|
||||
total: 0,
|
||||
totalPages: 0,
|
||||
hasNext: false,
|
||||
hasPrev: false
|
||||
}
|
||||
|
||||
const handleEdit = (provider: Provider) => {
|
||||
setEditingProvider(provider)
|
||||
setJsonContent(JSON.stringify(provider, null, 2))
|
||||
}
|
||||
|
||||
const handleSave = async () => {
|
||||
if (!editingProvider) return
|
||||
|
||||
try {
|
||||
// Validate JSON before sending
|
||||
const updatedProvider = JSON.parse(jsonContent) as unknown
|
||||
|
||||
// Basic validation - the API will do thorough validation
|
||||
if (!updatedProvider || typeof updatedProvider !== 'object') {
|
||||
throw new Error('Invalid JSON format')
|
||||
}
|
||||
|
||||
// Use SWR mutation for optimistic update
|
||||
await updateProvider({
|
||||
id: editingProvider.id,
|
||||
data: updatedProvider as Partial<Provider>
|
||||
})
|
||||
|
||||
// Close dialog and reset form
|
||||
setEditingProvider(null)
|
||||
setJsonContent('')
|
||||
} catch (error) {
|
||||
console.error('Error saving provider:', error)
|
||||
// Error will be handled by SWR and displayed in UI
|
||||
}
|
||||
}
|
||||
|
||||
// Type-safe function to extract provider capabilities
|
||||
const getCapabilities = (behaviors: Record<string, unknown>): string[] => {
|
||||
return Object.entries(behaviors)
|
||||
.filter(([_, value]) => value === true)
|
||||
.map(([key, _]) => key.replace(/_/g, ' ').replace(/\b\w/g, (letter) => letter.toUpperCase()))
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="container mx-auto p-6 space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-3xl font-bold">Provider Management</h1>
|
||||
<p className="text-muted-foreground">Review and validate provider configurations</p>
|
||||
</div>
|
||||
<Navigation />
|
||||
</div>
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Filters</CardTitle>
|
||||
<CardDescription>Filter providers to review specific configurations</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent className="space-y-4">
|
||||
<div className="flex gap-4">
|
||||
<Input
|
||||
placeholder="Search providers..."
|
||||
value={search}
|
||||
onChange={(e) => setSearch(e.target.value)}
|
||||
className="max-w-sm"
|
||||
/>
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
|
||||
{/* Error Display */}
|
||||
{error && (
|
||||
<Alert variant="destructive">
|
||||
<AlertDescription>{getErrorMessage(error)}</AlertDescription>
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<Card>
|
||||
<CardHeader>
|
||||
<CardTitle>Providers ({pagination.total})</CardTitle>
|
||||
<CardDescription>Review provider configurations and capabilities</CardDescription>
|
||||
</CardHeader>
|
||||
<CardContent>
|
||||
{isLoading ? (
|
||||
<div className="text-center py-8">
|
||||
<div className="animate-pulse">Loading providers...</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<Table>
|
||||
<TableHeader>
|
||||
<TableRow>
|
||||
<TableHead>ID</TableHead>
|
||||
<TableHead>Name</TableHead>
|
||||
<TableHead>Authentication</TableHead>
|
||||
<TableHead>Pricing Model</TableHead>
|
||||
<TableHead>Endpoints</TableHead>
|
||||
<TableHead>Capabilities</TableHead>
|
||||
<TableHead>Status</TableHead>
|
||||
<TableHead>Actions</TableHead>
|
||||
</TableRow>
|
||||
</TableHeader>
|
||||
<TableBody>
|
||||
{providers.map((provider) => (
|
||||
<TableRow key={provider.id}>
|
||||
<TableCell className="font-mono text-sm">{provider.id}</TableCell>
|
||||
<TableCell>
|
||||
<div>
|
||||
<div className="font-medium">{provider.name}</div>
|
||||
{provider.description && (
|
||||
<div className="text-sm text-muted-foreground">{provider.description}</div>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="outline">{provider.authentication}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Badge variant="secondary">{provider.pricing_model}</Badge>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{provider.supported_endpoints.slice(0, 2).map((endpoint) => (
|
||||
<Badge key={endpoint} variant="outline" className="text-xs">
|
||||
{endpoint}
|
||||
</Badge>
|
||||
))}
|
||||
{provider.supported_endpoints.length > 2 && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
+{provider.supported_endpoints.length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="flex flex-wrap gap-1 max-w-xs">
|
||||
{getCapabilities(provider.behaviors)
|
||||
.slice(0, 2)
|
||||
.map((capability) => (
|
||||
<Badge key={capability} variant="secondary" className="text-xs">
|
||||
{capability}
|
||||
</Badge>
|
||||
))}
|
||||
{getCapabilities(provider.behaviors).length > 2 && (
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
+{getCapabilities(provider.behaviors).length - 2}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<div className="space-y-1">
|
||||
{provider.deprecated && (
|
||||
<Badge variant="destructive" className="text-xs">
|
||||
Deprecated
|
||||
</Badge>
|
||||
)}
|
||||
{provider.maintenance_mode && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Maintenance
|
||||
</Badge>
|
||||
)}
|
||||
{!provider.deprecated && !provider.maintenance_mode && (
|
||||
<Badge variant="default" className="text-xs">
|
||||
Active
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</TableCell>
|
||||
<TableCell>
|
||||
<Dialog>
|
||||
<DialogTrigger asChild>
|
||||
<Button variant="outline" size="sm" onClick={() => handleEdit(provider)}>
|
||||
Edit
|
||||
</Button>
|
||||
</DialogTrigger>
|
||||
<DialogContent className="max-w-4xl max-h-[80vh] overflow-auto">
|
||||
<DialogHeader>
|
||||
<DialogTitle>Edit Provider Configuration</DialogTitle>
|
||||
<DialogDescription>Modify the JSON configuration for {provider.name}</DialogDescription>
|
||||
</DialogHeader>
|
||||
<div className="space-y-4">
|
||||
<Textarea
|
||||
value={jsonContent}
|
||||
onChange={(e) => setJsonContent(e.target.value)}
|
||||
className="min-h-[400px] font-mono text-sm"
|
||||
/>
|
||||
<div className="flex gap-2 justify-end">
|
||||
<Button variant="outline" onClick={() => setEditingProvider(null)}>
|
||||
Cancel
|
||||
</Button>
|
||||
<Button onClick={handleSave} disabled={isUpdating}>
|
||||
{isUpdating ? 'Saving...' : 'Save Changes'}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
</TableCell>
|
||||
</TableRow>
|
||||
))}
|
||||
</TableBody>
|
||||
</Table>
|
||||
|
||||
<Separator className="my-4" />
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-sm text-muted-foreground">
|
||||
Showing {(pagination.page - 1) * pagination.limit + 1} to{' '}
|
||||
{Math.min(pagination.page * pagination.limit, pagination.total)} of {pagination.total} providers
|
||||
</div>
|
||||
<SimplePagination
|
||||
currentPage={pagination.page}
|
||||
totalPages={pagination.totalPages}
|
||||
onPageChange={setCurrentPage}
|
||||
/>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</CardContent>
|
||||
</Card>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
20
packages/catalog/web/components.json
Normal file
20
packages/catalog/web/components.json
Normal file
@ -0,0 +1,20 @@
|
||||
{
|
||||
"$schema": "https://ui.shadcn.com/schema.json",
|
||||
"style": "new-york",
|
||||
"rsc": true,
|
||||
"tsx": true,
|
||||
"tailwind": {
|
||||
"config": "tailwind.config.ts",
|
||||
"css": "app/globals.css",
|
||||
"baseColor": "slate",
|
||||
"cssVariables": true,
|
||||
"prefix": ""
|
||||
},
|
||||
"aliases": {
|
||||
"components": "@/components",
|
||||
"utils": "@/lib/utils",
|
||||
"ui": "@/components/ui",
|
||||
"lib": "@/lib",
|
||||
"hooks": "@/hooks"
|
||||
}
|
||||
}
|
||||
32
packages/catalog/web/components/navigation.tsx
Normal file
32
packages/catalog/web/components/navigation.tsx
Normal file
@ -0,0 +1,32 @@
|
||||
'use client'
|
||||
|
||||
import Link from 'next/link'
|
||||
import { usePathname } from 'next/navigation'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const navigation = [
|
||||
{ name: 'Models', href: '/' },
|
||||
{ name: 'Providers', href: '/providers' },
|
||||
{ name: 'Overrides', href: '/overrides' }
|
||||
]
|
||||
|
||||
export function Navigation() {
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<nav className="flex space-x-8">
|
||||
{navigation.map((item) => (
|
||||
<Link
|
||||
key={item.name}
|
||||
href={item.href}
|
||||
className={cn(
|
||||
'text-sm font-medium transition-colors hover:text-primary',
|
||||
pathname === item.href ? 'text-foreground' : 'text-muted-foreground'
|
||||
)}>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
)
|
||||
}
|
||||
59
packages/catalog/web/components/ui/alert.tsx
Normal file
59
packages/catalog/web/components/ui/alert.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from "react"
|
||||
import { cva, type VariantProps } from "class-variance-authority"
|
||||
|
||||
import { cn } from "@/lib/utils"
|
||||
|
||||
const alertVariants = cva(
|
||||
"relative w-full rounded-lg border px-4 py-3 text-sm [&>svg+div]:translate-y-[-3px] [&>svg]:absolute [&>svg]:left-4 [&>svg]:top-4 [&>svg]:text-foreground [&>svg~*]:pl-7",
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: "bg-background text-foreground",
|
||||
destructive:
|
||||
"border-destructive/50 text-destructive dark:border-destructive [&>svg]:text-destructive",
|
||||
},
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: "default",
|
||||
},
|
||||
}
|
||||
)
|
||||
|
||||
const Alert = React.forwardRef<
|
||||
HTMLDivElement,
|
||||
React.HTMLAttributes<HTMLDivElement> & VariantProps<typeof alertVariants>
|
||||
>(({ className, variant, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
role="alert"
|
||||
className={cn(alertVariants({ variant }), className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
Alert.displayName = "Alert"
|
||||
|
||||
const AlertTitle = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLHeadingElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<h5
|
||||
ref={ref}
|
||||
className={cn("mb-1 font-medium leading-none tracking-tight", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertTitle.displayName = "AlertTitle"
|
||||
|
||||
const AlertDescription = React.forwardRef<
|
||||
HTMLParagraphElement,
|
||||
React.HTMLAttributes<HTMLParagraphElement>
|
||||
>(({ className, ...props }, ref) => (
|
||||
<div
|
||||
ref={ref}
|
||||
className={cn("text-sm [&_p]:leading-relaxed", className)}
|
||||
{...props}
|
||||
/>
|
||||
))
|
||||
AlertDescription.displayName = "AlertDescription"
|
||||
|
||||
export { Alert, AlertTitle, AlertDescription }
|
||||
29
packages/catalog/web/components/ui/badge.tsx
Normal file
29
packages/catalog/web/components/ui/badge.tsx
Normal file
@ -0,0 +1,29 @@
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const badgeVariants = cva(
|
||||
'inline-flex items-center rounded-md border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'border-transparent bg-primary text-primary-foreground shadow hover:bg-primary/80',
|
||||
secondary: 'border-transparent bg-secondary text-secondary-foreground hover:bg-secondary/80',
|
||||
destructive: 'border-transparent bg-destructive text-destructive-foreground shadow hover:bg-destructive/80',
|
||||
outline: 'text-foreground'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface BadgeProps extends React.HTMLAttributes<HTMLDivElement>, VariantProps<typeof badgeVariants> {}
|
||||
|
||||
function Badge({ className, variant, ...props }: BadgeProps) {
|
||||
return <div className={cn(badgeVariants({ variant }), className)} {...props} />
|
||||
}
|
||||
|
||||
export { Badge, badgeVariants }
|
||||
52
packages/catalog/web/components/ui/button.tsx
Normal file
52
packages/catalog/web/components/ui/button.tsx
Normal file
@ -0,0 +1,52 @@
|
||||
import { Slot } from '@radix-ui/react-slot'
|
||||
import { cva, type VariantProps } from 'class-variance-authority'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const buttonVariants = cva(
|
||||
'inline-flex items-center justify-center gap-2 whitespace-nowrap 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 [&_svg]:pointer-events-none [&_svg]:size-4 [&_svg]:shrink-0',
|
||||
{
|
||||
variants: {
|
||||
variant: {
|
||||
default: 'bg-primary text-primary-foreground shadow hover:bg-primary/90',
|
||||
destructive: 'bg-destructive text-destructive-foreground shadow-sm hover:bg-destructive/90',
|
||||
outline: 'border border-input bg-background shadow-sm hover:bg-accent hover:text-accent-foreground',
|
||||
secondary: 'bg-secondary text-secondary-foreground shadow-sm hover:bg-secondary/80',
|
||||
ghost: 'hover:bg-accent hover:text-accent-foreground',
|
||||
link: 'text-primary underline-offset-4 hover:underline'
|
||||
},
|
||||
size: {
|
||||
default: 'h-9 px-4 py-2',
|
||||
sm: 'h-8 rounded-md px-3 text-xs',
|
||||
lg: 'h-10 rounded-md px-8',
|
||||
icon: 'h-9 w-9'
|
||||
}
|
||||
},
|
||||
defaultVariants: {
|
||||
variant: 'default',
|
||||
size: 'default'
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
export interface ButtonProps
|
||||
extends React.ButtonHTMLAttributes<HTMLButtonElement>,
|
||||
VariantProps<typeof buttonVariants> {
|
||||
asChild?: boolean
|
||||
}
|
||||
|
||||
const Button = ({
|
||||
ref,
|
||||
className,
|
||||
variant,
|
||||
size,
|
||||
asChild = false,
|
||||
...props
|
||||
}: ButtonProps & { ref?: React.RefObject<HTMLButtonElement | null> }) => {
|
||||
const Comp = asChild ? Slot : 'button'
|
||||
return <Comp className={cn(buttonVariants({ variant, size, className }))} ref={ref} {...props} />
|
||||
}
|
||||
Button.displayName = 'Button'
|
||||
|
||||
export { Button, buttonVariants }
|
||||
59
packages/catalog/web/components/ui/card.tsx
Normal file
59
packages/catalog/web/components/ui/card.tsx
Normal file
@ -0,0 +1,59 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Card = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('rounded-xl border bg-card text-card-foreground shadow', className)} {...props} />
|
||||
)
|
||||
Card.displayName = 'Card'
|
||||
|
||||
const CardHeader = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('flex flex-col space-y-1.5 p-6', className)} {...props} />
|
||||
)
|
||||
CardHeader.displayName = 'CardHeader'
|
||||
|
||||
const CardTitle = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('font-semibold leading-none tracking-tight', className)} {...props} />
|
||||
)
|
||||
CardTitle.displayName = 'CardTitle'
|
||||
|
||||
const CardDescription = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
CardDescription.displayName = 'CardDescription'
|
||||
|
||||
const CardContent = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
CardContent.displayName = 'CardContent'
|
||||
|
||||
const CardFooter = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLDivElement> & { ref?: React.RefObject<HTMLDivElement | null> }) => (
|
||||
<div ref={ref} className={cn('flex items-center p-6 pt-0', className)} {...props} />
|
||||
)
|
||||
CardFooter.displayName = 'CardFooter'
|
||||
|
||||
export { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle }
|
||||
107
packages/catalog/web/components/ui/dialog.tsx
Normal file
107
packages/catalog/web/components/ui/dialog.tsx
Normal file
@ -0,0 +1,107 @@
|
||||
'use client'
|
||||
|
||||
import * as DialogPrimitive from '@radix-ui/react-dialog'
|
||||
import { Cross2Icon } from '@radix-ui/react-icons'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Dialog = DialogPrimitive.Root
|
||||
|
||||
const DialogTrigger = DialogPrimitive.Trigger
|
||||
|
||||
const DialogPortal = DialogPrimitive.Portal
|
||||
|
||||
const DialogClose = DialogPrimitive.Close
|
||||
|
||||
const DialogOverlay = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Overlay> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Overlay> | null>
|
||||
}) => (
|
||||
<DialogPrimitive.Overlay
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed inset-0 z-50 bg-black/80 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogOverlay.displayName = DialogPrimitive.Overlay.displayName
|
||||
|
||||
const DialogContent = ({
|
||||
ref,
|
||||
className,
|
||||
children,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Content> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Content> | null>
|
||||
}) => (
|
||||
<DialogPortal>
|
||||
<DialogOverlay />
|
||||
<DialogPrimitive.Content
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'fixed left-[50%] top-[50%] z-50 grid w-full max-w-lg translate-x-[-50%] translate-y-[-50%] gap-4 border bg-background p-6 shadow-lg duration-200 data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[state=closed]:slide-out-to-left-1/2 data-[state=closed]:slide-out-to-top-[48%] data-[state=open]:slide-in-from-left-1/2 data-[state=open]:slide-in-from-top-[48%] sm:rounded-lg',
|
||||
className
|
||||
)}
|
||||
{...props}>
|
||||
{children}
|
||||
<DialogPrimitive.Close className="absolute right-4 top-4 rounded-sm opacity-70 ring-offset-background transition-opacity hover:opacity-100 focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2 disabled:pointer-events-none data-[state=open]:bg-accent data-[state=open]:text-muted-foreground">
|
||||
<Cross2Icon className="h-4 w-4" />
|
||||
<span className="sr-only">Close</span>
|
||||
</DialogPrimitive.Close>
|
||||
</DialogPrimitive.Content>
|
||||
</DialogPortal>
|
||||
)
|
||||
DialogContent.displayName = DialogPrimitive.Content.displayName
|
||||
|
||||
const DialogHeader = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col space-y-1.5 text-center sm:text-left', className)} {...props} />
|
||||
)
|
||||
DialogHeader.displayName = 'DialogHeader'
|
||||
|
||||
const DialogFooter = ({ className, ...props }: React.HTMLAttributes<HTMLDivElement>) => (
|
||||
<div className={cn('flex flex-col-reverse sm:flex-row sm:justify-end sm:space-x-2', className)} {...props} />
|
||||
)
|
||||
DialogFooter.displayName = 'DialogFooter'
|
||||
|
||||
const DialogTitle = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Title> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Title> | null>
|
||||
}) => (
|
||||
<DialogPrimitive.Title
|
||||
ref={ref}
|
||||
className={cn('text-lg font-semibold leading-none tracking-tight', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
DialogTitle.displayName = DialogPrimitive.Title.displayName
|
||||
|
||||
const DialogDescription = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof DialogPrimitive.Description> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof DialogPrimitive.Description> | null>
|
||||
}) => <DialogPrimitive.Description ref={ref} className={cn('text-sm text-muted-foreground', className)} {...props} />
|
||||
DialogDescription.displayName = DialogPrimitive.Description.displayName
|
||||
|
||||
export {
|
||||
Dialog,
|
||||
DialogClose,
|
||||
DialogContent,
|
||||
DialogDescription,
|
||||
DialogFooter,
|
||||
DialogHeader,
|
||||
DialogOverlay,
|
||||
DialogPortal,
|
||||
DialogTitle,
|
||||
DialogTrigger
|
||||
}
|
||||
25
packages/catalog/web/components/ui/input.tsx
Normal file
25
packages/catalog/web/components/ui/input.tsx
Normal file
@ -0,0 +1,25 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Input = ({
|
||||
ref,
|
||||
className,
|
||||
type,
|
||||
...props
|
||||
}: React.ComponentProps<'input'> & { ref?: React.RefObject<HTMLInputElement | null> }) => {
|
||||
return (
|
||||
<input
|
||||
type={type}
|
||||
className={cn(
|
||||
'flex h-9 w-full rounded-md border border-input bg-transparent px-3 py-1 text-base shadow-sm transition-colors file:border-0 file:bg-transparent file:text-sm file:font-medium file:text-foreground placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Input.displayName = 'Input'
|
||||
|
||||
export { Input }
|
||||
88
packages/catalog/web/components/ui/pagination.tsx
Normal file
88
packages/catalog/web/components/ui/pagination.tsx
Normal file
@ -0,0 +1,88 @@
|
||||
import { ChevronLeftIcon, ChevronRightIcon, DotsHorizontalIcon } from '@radix-ui/react-icons'
|
||||
import * as React from 'react'
|
||||
|
||||
import type { ButtonProps } from '@/components/ui/button'
|
||||
import { buttonVariants } from '@/components/ui/button'
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Pagination = ({ className, ...props }: React.ComponentProps<'nav'>) => (
|
||||
<nav
|
||||
role="navigation"
|
||||
aria-label="pagination"
|
||||
className={cn('mx-auto flex w-full justify-center', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Pagination.displayName = 'Pagination'
|
||||
|
||||
const PaginationContent = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'ul'> & { ref?: React.RefObject<HTMLUListElement | null> }) => (
|
||||
<ul ref={ref} className={cn('flex flex-row items-center gap-1', className)} {...props} />
|
||||
)
|
||||
PaginationContent.displayName = 'PaginationContent'
|
||||
|
||||
const PaginationItem = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'li'> & { ref?: React.RefObject<HTMLLIElement | null> }) => (
|
||||
<li ref={ref} className={cn('', className)} {...props} />
|
||||
)
|
||||
PaginationItem.displayName = 'PaginationItem'
|
||||
|
||||
type PaginationLinkProps = {
|
||||
isActive?: boolean
|
||||
} & Pick<ButtonProps, 'size'> &
|
||||
React.ComponentProps<'a'>
|
||||
|
||||
const PaginationLink = ({ className, isActive, size = 'icon', ...props }: PaginationLinkProps) => (
|
||||
<a
|
||||
aria-current={isActive ? 'page' : undefined}
|
||||
className={cn(
|
||||
buttonVariants({
|
||||
variant: isActive ? 'outline' : 'ghost',
|
||||
size
|
||||
}),
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
PaginationLink.displayName = 'PaginationLink'
|
||||
|
||||
const PaginationPrevious = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to previous page" size="default" className={cn('gap-1 pl-2.5', className)} {...props}>
|
||||
<ChevronLeftIcon className="h-4 w-4" />
|
||||
<span>Previous</span>
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationPrevious.displayName = 'PaginationPrevious'
|
||||
|
||||
const PaginationNext = ({ className, ...props }: React.ComponentProps<typeof PaginationLink>) => (
|
||||
<PaginationLink aria-label="Go to next page" size="default" className={cn('gap-1 pr-2.5', className)} {...props}>
|
||||
<span>Next</span>
|
||||
<ChevronRightIcon className="h-4 w-4" />
|
||||
</PaginationLink>
|
||||
)
|
||||
PaginationNext.displayName = 'PaginationNext'
|
||||
|
||||
const PaginationEllipsis = ({ className, ...props }: React.ComponentProps<'span'>) => (
|
||||
<span aria-hidden className={cn('flex h-9 w-9 items-center justify-center', className)} {...props}>
|
||||
<DotsHorizontalIcon className="h-4 w-4" />
|
||||
<span className="sr-only">More pages</span>
|
||||
</span>
|
||||
)
|
||||
PaginationEllipsis.displayName = 'PaginationEllipsis'
|
||||
|
||||
export {
|
||||
Pagination,
|
||||
PaginationContent,
|
||||
PaginationEllipsis,
|
||||
PaginationItem,
|
||||
PaginationLink,
|
||||
PaginationNext,
|
||||
PaginationPrevious
|
||||
}
|
||||
27
packages/catalog/web/components/ui/separator.tsx
Normal file
27
packages/catalog/web/components/ui/separator.tsx
Normal file
@ -0,0 +1,27 @@
|
||||
'use client'
|
||||
|
||||
import * as SeparatorPrimitive from '@radix-ui/react-separator'
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Separator = ({
|
||||
ref,
|
||||
className,
|
||||
orientation = 'horizontal',
|
||||
decorative = true,
|
||||
...props
|
||||
}: React.ComponentPropsWithoutRef<typeof SeparatorPrimitive.Root> & {
|
||||
ref?: React.RefObject<React.ElementRef<typeof SeparatorPrimitive.Root> | null>
|
||||
}) => (
|
||||
<SeparatorPrimitive.Root
|
||||
ref={ref}
|
||||
decorative={decorative}
|
||||
orientation={orientation}
|
||||
className={cn('shrink-0 bg-border', orientation === 'horizontal' ? 'h-[1px] w-full' : 'h-full w-[1px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
Separator.displayName = SeparatorPrimitive.Root.displayName
|
||||
|
||||
export { Separator }
|
||||
94
packages/catalog/web/components/ui/table.tsx
Normal file
94
packages/catalog/web/components/ui/table.tsx
Normal file
@ -0,0 +1,94 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Table = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableElement> & { ref?: React.RefObject<HTMLTableElement | null> }) => (
|
||||
<div className="relative w-full overflow-auto">
|
||||
<table ref={ref} className={cn('w-full caption-bottom text-sm', className)} {...props} />
|
||||
</div>
|
||||
)
|
||||
Table.displayName = 'Table'
|
||||
|
||||
const TableHeader = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement> & { ref?: React.RefObject<HTMLTableSectionElement | null> }) => (
|
||||
<thead ref={ref} className={cn('[&_tr]:border-b', className)} {...props} />
|
||||
)
|
||||
TableHeader.displayName = 'TableHeader'
|
||||
|
||||
const TableBody = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement> & { ref?: React.RefObject<HTMLTableSectionElement | null> }) => (
|
||||
<tbody ref={ref} className={cn('[&_tr:last-child]:border-0', className)} {...props} />
|
||||
)
|
||||
TableBody.displayName = 'TableBody'
|
||||
|
||||
const TableFooter = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableSectionElement> & { ref?: React.RefObject<HTMLTableSectionElement | null> }) => (
|
||||
<tfoot ref={ref} className={cn('border-t bg-muted/50 font-medium [&>tr]:last:border-b-0', className)} {...props} />
|
||||
)
|
||||
TableFooter.displayName = 'TableFooter'
|
||||
|
||||
const TableRow = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableRowElement> & { ref?: React.RefObject<HTMLTableRowElement | null> }) => (
|
||||
<tr
|
||||
ref={ref}
|
||||
className={cn('border-b transition-colors hover:bg-muted/50 data-[state=selected]:bg-muted', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
TableRow.displayName = 'TableRow'
|
||||
|
||||
const TableHead = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ThHTMLAttributes<HTMLTableCellElement> & { ref?: React.RefObject<HTMLTableCellElement | null> }) => (
|
||||
<th
|
||||
ref={ref}
|
||||
className={cn(
|
||||
'h-10 px-2 text-left align-middle font-medium text-muted-foreground [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]',
|
||||
className
|
||||
)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
TableHead.displayName = 'TableHead'
|
||||
|
||||
const TableCell = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.TdHTMLAttributes<HTMLTableCellElement> & { ref?: React.RefObject<HTMLTableCellElement | null> }) => (
|
||||
<td
|
||||
ref={ref}
|
||||
className={cn('p-2 align-middle [&:has([role=checkbox])]:pr-0 [&>[role=checkbox]]:translate-y-[2px]', className)}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
TableCell.displayName = 'TableCell'
|
||||
|
||||
const TableCaption = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.HTMLAttributes<HTMLTableCaptionElement> & { ref?: React.RefObject<HTMLTableCaptionElement | null> }) => (
|
||||
<caption ref={ref} className={cn('mt-4 text-sm text-muted-foreground', className)} {...props} />
|
||||
)
|
||||
TableCaption.displayName = 'TableCaption'
|
||||
|
||||
export { Table, TableBody, TableCaption, TableCell, TableFooter, TableHead, TableHeader, TableRow }
|
||||
23
packages/catalog/web/components/ui/textarea.tsx
Normal file
23
packages/catalog/web/components/ui/textarea.tsx
Normal file
@ -0,0 +1,23 @@
|
||||
import * as React from 'react'
|
||||
|
||||
import { cn } from '@/lib/utils'
|
||||
|
||||
const Textarea = ({
|
||||
ref,
|
||||
className,
|
||||
...props
|
||||
}: React.ComponentProps<'textarea'> & { ref?: React.RefObject<HTMLTextAreaElement | null> }) => {
|
||||
return (
|
||||
<textarea
|
||||
className={cn(
|
||||
'flex min-h-[60px] w-full rounded-md border border-input bg-transparent px-3 py-2 text-base shadow-sm placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:cursor-not-allowed disabled:opacity-50 md:text-sm',
|
||||
className
|
||||
)}
|
||||
ref={ref}
|
||||
{...props}
|
||||
/>
|
||||
)
|
||||
}
|
||||
Textarea.displayName = 'Textarea'
|
||||
|
||||
export { Textarea }
|
||||
18
packages/catalog/web/eslint.config.mjs
Normal file
18
packages/catalog/web/eslint.config.mjs
Normal file
@ -0,0 +1,18 @@
|
||||
import { defineConfig, globalIgnores } from 'eslint/config'
|
||||
import nextVitals from 'eslint-config-next/core-web-vitals'
|
||||
import nextTs from 'eslint-config-next/typescript'
|
||||
|
||||
const eslintConfig = defineConfig([
|
||||
...nextVitals,
|
||||
...nextTs,
|
||||
// Override default ignores of eslint-config-next.
|
||||
globalIgnores([
|
||||
// Default ignores of eslint-config-next:
|
||||
'.next/**',
|
||||
'out/**',
|
||||
'build/**',
|
||||
'next-env.d.ts'
|
||||
])
|
||||
])
|
||||
|
||||
export default eslintConfig
|
||||
299
packages/catalog/web/lib/api-client.ts
Normal file
299
packages/catalog/web/lib/api-client.ts
Normal file
@ -0,0 +1,299 @@
|
||||
/**
|
||||
* API Client with SWR integration for catalog management
|
||||
*
|
||||
* This file provides:
|
||||
* - Custom SWR fetchers with Zod validation
|
||||
* - Mutations for CRUD operations with optimistic updates
|
||||
* - Error handling utilities
|
||||
* - Type-safe API interactions
|
||||
*/
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import type { SWRConfiguration, SWRResponse } from 'swr'
|
||||
import useSWR from 'swr'
|
||||
import useSWRMutation from 'swr/mutation'
|
||||
import type { z } from 'zod'
|
||||
|
||||
// Import catalog types and schemas
|
||||
import type { Model, PaginatedResponse, Provider } from './catalog-types'
|
||||
import {
|
||||
ModelSchema,
|
||||
ModelUpdateResponseSchema,
|
||||
PaginatedResponseSchema,
|
||||
ProviderSchema,
|
||||
ProviderUpdateResponseSchema
|
||||
} from './catalog-types'
|
||||
|
||||
// API base configuration
|
||||
const API_BASE = '/api/catalog'
|
||||
|
||||
// Extended error interface for better error handling
|
||||
export interface ExtendedApiError {
|
||||
error: string
|
||||
status?: number
|
||||
info?: unknown
|
||||
}
|
||||
|
||||
// Generic API fetcher with Zod validation
|
||||
async function apiFetcher<T extends z.ZodType>(url: string, schema: T, options?: RequestInit): Promise<z.infer<T>> {
|
||||
const response = await fetch(url, {
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...options?.headers
|
||||
},
|
||||
...options
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = response.headers.get('content-type')?.includes('application/json')
|
||||
? await response.json()
|
||||
: { error: response.statusText }
|
||||
|
||||
const error: ExtendedApiError = {
|
||||
error: errorData.error || `HTTP ${response.status}`,
|
||||
status: response.status,
|
||||
info: errorData
|
||||
}
|
||||
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return schema.parse(data)
|
||||
}
|
||||
|
||||
// API Client class for organized endpoint management
|
||||
export class ApiClient {
|
||||
// Models endpoints
|
||||
static models = {
|
||||
// Get models with pagination and filtering
|
||||
list: (
|
||||
params: { page?: number; limit?: number; search?: string; capabilities?: string[]; providers?: string[] } = {}
|
||||
) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.page) searchParams.set('page', params.page.toString())
|
||||
if (params.limit) searchParams.set('limit', params.limit.toString())
|
||||
if (params.search) searchParams.set('search', params.search)
|
||||
if (params.capabilities?.length) searchParams.set('capabilities', params.capabilities.join(','))
|
||||
if (params.providers?.length) searchParams.set('providers', params.providers.join(','))
|
||||
|
||||
return `${API_BASE}/models?${searchParams.toString()}`
|
||||
},
|
||||
|
||||
// Update a model
|
||||
update: (id: string, data: Partial<Model>) => ({
|
||||
url: `${API_BASE}/models/${id}`,
|
||||
method: 'PUT',
|
||||
body: data
|
||||
}),
|
||||
|
||||
// Delete a model (if implemented)
|
||||
delete: (id: string) => ({
|
||||
url: `${API_BASE}/models/${id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
|
||||
// Providers endpoints
|
||||
static providers = {
|
||||
// Get providers with pagination and filtering
|
||||
list: (params: { page?: number; limit?: number; search?: string } = {}) => {
|
||||
const searchParams = new URLSearchParams()
|
||||
|
||||
if (params.page) searchParams.set('page', params.page.toString())
|
||||
if (params.limit) searchParams.set('limit', params.limit.toString())
|
||||
if (params.search) searchParams.set('search', params.search)
|
||||
|
||||
return `${API_BASE}/providers?${searchParams.toString()}`
|
||||
},
|
||||
|
||||
// Update a provider
|
||||
update: (id: string, data: Partial<Provider>) => ({
|
||||
url: `${API_BASE}/providers/${id}`,
|
||||
method: 'PUT',
|
||||
body: data
|
||||
}),
|
||||
|
||||
// Delete a provider (if implemented)
|
||||
delete: (id: string) => ({
|
||||
url: `${API_BASE}/providers/${id}`,
|
||||
method: 'DELETE'
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// SWR Hooks for Models
|
||||
export function useModels(
|
||||
params: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
capabilities?: string[]
|
||||
providers?: string[]
|
||||
} = {},
|
||||
config?: SWRConfiguration<PaginatedResponse<Model>, ExtendedApiError>
|
||||
): SWRResponse<PaginatedResponse<Model>, ExtendedApiError> {
|
||||
const url = ApiClient.models.list(params)
|
||||
|
||||
return useSWR<PaginatedResponse<Model>, ExtendedApiError>(
|
||||
url,
|
||||
(url) => apiFetcher(url, PaginatedResponseSchema(ModelSchema)),
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
dedupingInterval: 5000,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 1000,
|
||||
...config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// SWR Hooks for Providers
|
||||
export function useProviders(
|
||||
params: {
|
||||
page?: number
|
||||
limit?: number
|
||||
search?: string
|
||||
} = {},
|
||||
config?: SWRConfiguration<PaginatedResponse<Provider>, ExtendedApiError>
|
||||
): SWRResponse<PaginatedResponse<Provider>, ExtendedApiError> {
|
||||
const url = ApiClient.providers.list(params)
|
||||
|
||||
return useSWR<PaginatedResponse<Provider>, ExtendedApiError>(
|
||||
url,
|
||||
(url) => apiFetcher(url, PaginatedResponseSchema(ProviderSchema)),
|
||||
{
|
||||
revalidateOnFocus: true,
|
||||
revalidateOnReconnect: true,
|
||||
dedupingInterval: 5000,
|
||||
errorRetryCount: 3,
|
||||
errorRetryInterval: 1000,
|
||||
...config
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Mutation for updating models
|
||||
export function useUpdateModel() {
|
||||
return useSWRMutation(
|
||||
'/api/catalog/models',
|
||||
async (url: string, { arg }: { arg: { id: string; data: Partial<Model> } }) => {
|
||||
const response = await fetch(`${url}/${arg.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg.data)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const error: ExtendedApiError = {
|
||||
error: errorData.error || 'Failed to update model',
|
||||
status: response.status,
|
||||
info: errorData
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return ModelUpdateResponseSchema.parse(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Mutation for updating providers
|
||||
export function useUpdateProvider() {
|
||||
return useSWRMutation(
|
||||
'/api/catalog/providers',
|
||||
async (url: string, { arg }: { arg: { id: string; data: Partial<Provider> } }) => {
|
||||
const response = await fetch(`${url}/${arg.id}`, {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(arg.data)
|
||||
})
|
||||
|
||||
if (!response.ok) {
|
||||
const errorData = await response.json()
|
||||
const error: ExtendedApiError = {
|
||||
error: errorData.error || 'Failed to update provider',
|
||||
status: response.status,
|
||||
info: errorData
|
||||
}
|
||||
throw error
|
||||
}
|
||||
|
||||
const data = await response.json()
|
||||
return ProviderUpdateResponseSchema.parse(data)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
// Utility function for global error handling
|
||||
export function handleApiError(error: unknown): ExtendedApiError {
|
||||
if (error && typeof error === 'object' && 'error' in error) {
|
||||
return error as ExtendedApiError
|
||||
}
|
||||
|
||||
return {
|
||||
error: error instanceof Error ? error.message : 'Unknown error occurred'
|
||||
}
|
||||
}
|
||||
|
||||
// Utility function to get user-friendly error messages
|
||||
export function getErrorMessage(error: unknown): string {
|
||||
const apiError = handleApiError(error)
|
||||
|
||||
// Map common error codes to user-friendly messages
|
||||
switch (apiError.status) {
|
||||
case 400:
|
||||
return 'Invalid request. Please check your input and try again.'
|
||||
case 401:
|
||||
return 'Authentication required. Please log in and try again.'
|
||||
case 403:
|
||||
return 'You do not have permission to perform this action.'
|
||||
case 404:
|
||||
return 'The requested resource was not found.'
|
||||
case 429:
|
||||
return 'Too many requests. Please wait a moment and try again.'
|
||||
case 500:
|
||||
return 'Server error. Please try again later.'
|
||||
default:
|
||||
return apiError.error || 'An unexpected error occurred.'
|
||||
}
|
||||
}
|
||||
|
||||
// Custom hook for debounced search
|
||||
export function useDebounce<T>(value: T, delay: number): T {
|
||||
const [debouncedValue, setDebouncedValue] = useState(value)
|
||||
|
||||
useEffect(() => {
|
||||
const handler = setTimeout(() => {
|
||||
setDebouncedValue(value)
|
||||
}, delay)
|
||||
|
||||
return () => {
|
||||
clearTimeout(handler)
|
||||
}
|
||||
}, [value, delay])
|
||||
|
||||
return debouncedValue
|
||||
}
|
||||
|
||||
// Export all types for use in components
|
||||
export type { SWRResponse }
|
||||
|
||||
// Re-export SWR types for convenience
|
||||
export type { SWRConfiguration } from 'swr'
|
||||
|
||||
// Legacy API Error class for backward compatibility
|
||||
export class ApiError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public status: number,
|
||||
public details?: unknown
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ApiError'
|
||||
}
|
||||
}
|
||||
86
packages/catalog/web/lib/catalog-types.ts
Normal file
86
packages/catalog/web/lib/catalog-types.ts
Normal file
@ -0,0 +1,86 @@
|
||||
/**
|
||||
* Type definitions for catalog management system
|
||||
* Now using Zod inferred types for complete type safety
|
||||
*
|
||||
* This file serves as the main export point for all types and schemas.
|
||||
* Types are now inferred from Zod schemas to ensure compile-time and runtime consistency.
|
||||
*/
|
||||
|
||||
// Import all types from Zod validation schemas
|
||||
export type {
|
||||
// Response and error types
|
||||
ApiError,
|
||||
AuthenticationType,
|
||||
// Utility enum types
|
||||
CapabilityType,
|
||||
EndpointType,
|
||||
ModalityType,
|
||||
// Core data types (inferred from Zod schemas)
|
||||
Model,
|
||||
ModelListRequest,
|
||||
ModelsDataFile,
|
||||
ModelUpdateResponse,
|
||||
OverridesDataFile,
|
||||
PaginatedResponse,
|
||||
// Pagination and response types
|
||||
PaginationInfo,
|
||||
Provider,
|
||||
ProviderListRequest,
|
||||
ProviderModelOverride,
|
||||
ProvidersDataFile,
|
||||
ProviderUpdateResponse,
|
||||
SuccessResponse
|
||||
} from './validation'
|
||||
|
||||
// Import Zod schemas for direct use if needed
|
||||
export {
|
||||
ApiErrorSchema,
|
||||
AuthenticationTypeSchema,
|
||||
// Utility schemas
|
||||
CapabilityTypeSchema,
|
||||
EndpointTypeSchema,
|
||||
ModalityTypeSchema,
|
||||
ModelListRequestSchema,
|
||||
// Core schemas
|
||||
ModelSchema,
|
||||
ModelsDataFileSchema,
|
||||
ModelUpdateResponseSchema,
|
||||
OverridesDataFileSchema,
|
||||
PaginatedResponseSchema,
|
||||
ProviderModelOverrideSchema,
|
||||
// Response schemas
|
||||
PaginationInfoSchema,
|
||||
ProviderListRequestSchema,
|
||||
ProviderSchema,
|
||||
ProvidersDataFileSchema,
|
||||
ProviderUpdateResponseSchema,
|
||||
QueryParamsSchema,
|
||||
SuccessResponseSchema
|
||||
} from './validation'
|
||||
|
||||
// Import validation utilities for easy access
|
||||
export {
|
||||
createErrorResponse,
|
||||
formatZodError,
|
||||
// Type guard functions (powered by Zod)
|
||||
isModel,
|
||||
isModelsDataFile,
|
||||
isProvider,
|
||||
isProvidersDataFile,
|
||||
safeParseWithValidation,
|
||||
safeTypeCast,
|
||||
validatePaginatedResponse,
|
||||
validateQueryParams,
|
||||
validateString,
|
||||
// Validation functions
|
||||
ValidationError
|
||||
} from './validation'
|
||||
|
||||
// Legacy convenience types (for backward compatibility)
|
||||
// These are now re-exports of the Zod-inferred types above
|
||||
export type {
|
||||
// Re-export core types with legacy names for compatibility
|
||||
Model as CatalogModel,
|
||||
Provider as CatalogProvider,
|
||||
PaginatedResponse as CatalogResponse
|
||||
} from './validation'
|
||||
6
packages/catalog/web/lib/utils.ts
Normal file
6
packages/catalog/web/lib/utils.ts
Normal file
@ -0,0 +1,6 @@
|
||||
import { type ClassValue, clsx } from 'clsx'
|
||||
import { twMerge } from 'tailwind-merge'
|
||||
|
||||
export function cn(...inputs: ClassValue[]) {
|
||||
return twMerge(clsx(inputs))
|
||||
}
|
||||
431
packages/catalog/web/lib/validation.ts
Normal file
431
packages/catalog/web/lib/validation.ts
Normal file
@ -0,0 +1,431 @@
|
||||
/**
|
||||
* Zod v4 schemas for comprehensive runtime type validation
|
||||
* Replaces manual validation with strict type-safe schemas
|
||||
*/
|
||||
|
||||
//TODO: 从catalog导入
|
||||
|
||||
import * as z from 'zod'
|
||||
|
||||
// Base parameter schemas
|
||||
const ParameterRangeSchema = z.object({
|
||||
supported: z.literal(true),
|
||||
min: z.number().positive(),
|
||||
max: z.number().positive(),
|
||||
default: z.number().positive()
|
||||
})
|
||||
|
||||
const ParameterBooleanSchema = z.object({
|
||||
supported: z.boolean()
|
||||
})
|
||||
|
||||
const ParameterUnsupportedSchema = z.object({
|
||||
supported: z.literal(false)
|
||||
})
|
||||
|
||||
const ParameterValueSchema = z.union([ParameterRangeSchema, ParameterBooleanSchema, ParameterUnsupportedSchema])
|
||||
|
||||
// Model parameters schema
|
||||
const ModelParametersSchema = z
|
||||
.object({
|
||||
temperature: ParameterValueSchema.optional(),
|
||||
max_tokens: z.union([
|
||||
z.boolean(), // Simple boolean support indicator
|
||||
z.object({
|
||||
supported: z.literal(true),
|
||||
default: z.number().positive().optional()
|
||||
})
|
||||
]).optional(),
|
||||
system_message: z.boolean().optional(), // Simple boolean support indicator
|
||||
top_p: z.union([ParameterValueSchema, ParameterUnsupportedSchema]).optional()
|
||||
})
|
||||
.loose() // Allow additional parameter types
|
||||
|
||||
// Pricing schema
|
||||
const PricingInfoSchema = z.object({
|
||||
input: z.object({
|
||||
per_million_tokens: z.number().nonnegative(),
|
||||
currency: z.string().length(3) // ISO 4217 currency codes
|
||||
}),
|
||||
output: z.object({
|
||||
per_million_tokens: z.number().nonnegative(),
|
||||
currency: z.string().length(3)
|
||||
})
|
||||
})
|
||||
|
||||
// Model metadata schema
|
||||
const ModelMetadataSchema = z
|
||||
.object({
|
||||
source: z.string().optional(),
|
||||
original_provider: z.string().optional(),
|
||||
supports_caching: z.boolean().optional()
|
||||
})
|
||||
.loose() // Allow additional metadata
|
||||
|
||||
// Complete Model schema
|
||||
export const ModelSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().optional(),
|
||||
owned_by: z.string().optional(),
|
||||
capabilities: z.array(z.string()),
|
||||
input_modalities: z.array(z.string()),
|
||||
output_modalities: z.array(z.string()),
|
||||
context_window: z.number().positive(),
|
||||
max_output_tokens: z.number().positive(),
|
||||
max_input_tokens: z.number().positive().optional(),
|
||||
pricing: PricingInfoSchema.optional(),
|
||||
parameters: ModelParametersSchema.optional(),
|
||||
endpoint_types: z.array(z.string()).optional(),
|
||||
metadata: ModelMetadataSchema.optional()
|
||||
})
|
||||
|
||||
// Provider behaviors schema
|
||||
const ProviderBehaviorsSchema = z
|
||||
.object({
|
||||
supports_custom_models: z.boolean(),
|
||||
provides_model_mapping: z.boolean(),
|
||||
supports_model_versioning: z.boolean(),
|
||||
provides_fallback_routing: z.boolean(),
|
||||
has_auto_retry: z.boolean(),
|
||||
supports_health_check: z.boolean(),
|
||||
has_real_time_metrics: z.boolean(),
|
||||
provides_usage_analytics: z.boolean(),
|
||||
supports_webhook_events: z.boolean(),
|
||||
requires_api_key_validation: z.boolean(),
|
||||
supports_rate_limiting: z.boolean(),
|
||||
provides_usage_limits: z.boolean(),
|
||||
supports_streaming: z.boolean(),
|
||||
supports_batch_processing: z.boolean(),
|
||||
supports_model_fine_tuning: z.boolean()
|
||||
})
|
||||
.loose() // Allow extensions
|
||||
|
||||
// API compatibility schema
|
||||
const ApiCompatibilitySchema = z
|
||||
.object({
|
||||
supports_array_content: z.boolean().optional(),
|
||||
supports_stream_options: z.boolean().optional(),
|
||||
supports_developer_role: z.boolean().optional(),
|
||||
supports_service_tier: z.boolean().optional(),
|
||||
supports_thinking_control: z.boolean().optional(),
|
||||
supports_api_version: z.boolean().optional(),
|
||||
supports_parallel_tools: z.boolean().optional(),
|
||||
supports_multimodal: z.boolean().optional()
|
||||
})
|
||||
.loose()
|
||||
|
||||
// Special configuration schema (flexible)
|
||||
const SpecialConfigSchema = z.record(z.string(), z.unknown())
|
||||
|
||||
// Provider metadata schema
|
||||
const ProviderMetadataSchema = z
|
||||
.object({
|
||||
source: z.string().optional(),
|
||||
tags: z.array(z.string()).optional(),
|
||||
reliability: z.enum(['low', 'medium', 'high']).optional()
|
||||
})
|
||||
.loose()
|
||||
|
||||
// Complete Provider schema
|
||||
export const ProviderSchema = z.object({
|
||||
id: z.string().min(1),
|
||||
name: z.string().min(1),
|
||||
description: z.string().optional(),
|
||||
authentication: z.string().min(1),
|
||||
pricing_model: z.string().min(1),
|
||||
model_routing: z.string().min(1),
|
||||
behaviors: ProviderBehaviorsSchema,
|
||||
supported_endpoints: z.array(z.string()),
|
||||
api_compatibility: ApiCompatibilitySchema.optional(),
|
||||
default_api_host: z.url().optional(),
|
||||
default_rate_limit: z.number().positive().optional(),
|
||||
model_id_patterns: z.array(z.string()).optional(),
|
||||
alias_model_ids: z.record(z.string(), z.string()).optional(),
|
||||
documentation: z.string().url().optional(),
|
||||
website: z.string().url().optional(),
|
||||
deprecated: z.boolean(),
|
||||
maintenance_mode: z.boolean(),
|
||||
config_version: z.string().min(1),
|
||||
special_config: SpecialConfigSchema.optional(),
|
||||
metadata: ProviderMetadataSchema.optional()
|
||||
})
|
||||
|
||||
// Data file schemas
|
||||
export const ModelsDataFileSchema = z.object({
|
||||
version: z.string().min(1),
|
||||
models: z.array(ModelSchema)
|
||||
})
|
||||
|
||||
export const ProvidersDataFileSchema = z.object({
|
||||
version: z.string().min(1),
|
||||
providers: z.array(ProviderSchema)
|
||||
})
|
||||
|
||||
// Override schemas
|
||||
const OverrideLimitsSchema = z.object({
|
||||
context_window: z.number().positive().optional(),
|
||||
max_output_tokens: z.number().positive().optional()
|
||||
})
|
||||
|
||||
export const ProviderModelOverrideSchema = z.object({
|
||||
provider_id: z.string().min(1),
|
||||
model_id: z.string().min(1),
|
||||
disabled: z.boolean().default(false),
|
||||
reason: z.string().optional(),
|
||||
last_updated: z.string().optional(),
|
||||
updated_by: z.string().optional(),
|
||||
priority: z.number().default(100),
|
||||
limits: OverrideLimitsSchema.optional(),
|
||||
pricing: PricingInfoSchema.optional()
|
||||
})
|
||||
|
||||
export const OverridesDataFileSchema = z.object({
|
||||
version: z.string().min(1),
|
||||
overrides: z.array(ProviderModelOverrideSchema)
|
||||
})
|
||||
|
||||
// Pagination schemas
|
||||
export const PaginationInfoSchema = z.object({
|
||||
page: z.number().positive(),
|
||||
limit: z.number().positive().max(100),
|
||||
total: z.number().nonnegative(),
|
||||
totalPages: z.number().nonnegative(),
|
||||
hasNext: z.boolean(),
|
||||
hasPrev: z.boolean()
|
||||
})
|
||||
|
||||
export const PaginatedResponseSchema = <T extends z.ZodType>(itemSchema: T) =>
|
||||
z.object({
|
||||
data: z.array(itemSchema),
|
||||
pagination: PaginationInfoSchema
|
||||
})
|
||||
|
||||
// Query parameter schemas
|
||||
export const QueryParamsSchema = z.object({
|
||||
page: z.coerce.number().positive().default(1),
|
||||
limit: z.coerce.number().positive().max(100).default(20),
|
||||
search: z.string().trim().optional(),
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
providers: z.array(z.string()).optional(),
|
||||
authentication: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
// Request schemas for API endpoints
|
||||
export const ModelListRequestSchema = QueryParamsSchema.extend({
|
||||
capabilities: z.array(z.string()).optional(),
|
||||
providers: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
export const ProviderListRequestSchema = QueryParamsSchema.extend({
|
||||
authentication: z.array(z.string()).optional()
|
||||
})
|
||||
|
||||
// Response schemas
|
||||
export const ApiErrorSchema = z.object({
|
||||
error: z.string(),
|
||||
details: z.unknown().optional()
|
||||
})
|
||||
|
||||
export const SuccessResponseSchema = z.object({
|
||||
success: z.literal(true)
|
||||
})
|
||||
|
||||
export const ModelUpdateResponseSchema = SuccessResponseSchema.extend({
|
||||
model: ModelSchema
|
||||
})
|
||||
|
||||
export const ProviderUpdateResponseSchema = SuccessResponseSchema.extend({
|
||||
provider: ProviderSchema
|
||||
})
|
||||
|
||||
// Utility types for strict typing
|
||||
export const CapabilityTypeSchema = z.enum([
|
||||
'FUNCTION_CALL',
|
||||
'REASONING',
|
||||
'IMAGE_RECOGNITION',
|
||||
'IMAGE_GENERATION',
|
||||
'AUDIO_RECOGNITION',
|
||||
'AUDIO_GENERATION',
|
||||
'EMBEDDING',
|
||||
'RERANK',
|
||||
'AUDIO_TRANSCRIPT',
|
||||
'VIDEO_RECOGNITION',
|
||||
'VIDEO_GENERATION',
|
||||
'STRUCTURED_OUTPUT',
|
||||
'FILE_INPUT',
|
||||
'WEB_SEARCH',
|
||||
'CODE_EXECUTION',
|
||||
'FILE_SEARCH',
|
||||
'COMPUTER_USE'
|
||||
])
|
||||
|
||||
export const ModalityTypeSchema = z.enum(['TEXT', 'VISION', 'AUDIO', 'VIDEO'])
|
||||
|
||||
export const AuthenticationTypeSchema = z.enum(['API_KEY', 'OAUTH', 'NONE', 'CUSTOM'])
|
||||
|
||||
export const EndpointTypeSchema = z.enum(['CHAT_COMPLETIONS', 'MESSAGES', 'RESPONSES', 'EMBEDDINGS', 'RERANK'])
|
||||
|
||||
// Validation utilities using Zod
|
||||
|
||||
// Custom error class for Zod validation errors
|
||||
export class ValidationError extends Error {
|
||||
constructor(
|
||||
message: string,
|
||||
public details?: unknown,
|
||||
public zodError?: z.ZodError
|
||||
) {
|
||||
super(message)
|
||||
this.name = 'ValidationError'
|
||||
}
|
||||
}
|
||||
|
||||
// String validation function
|
||||
export function validateString(value: string, fieldName: string): string {
|
||||
if (typeof value !== 'string' || value.trim().length === 0) {
|
||||
throw new ValidationError(`${fieldName} must be a non-empty string`)
|
||||
}
|
||||
return value.trim()
|
||||
}
|
||||
|
||||
// Safe JSON parsing with Zod validation
|
||||
export async function safeParseWithValidation<T>(
|
||||
jsonString: string,
|
||||
schema: z.ZodType<T>,
|
||||
errorMessage: string
|
||||
): Promise<T> {
|
||||
try {
|
||||
const parsed = JSON.parse(jsonString)
|
||||
const result = schema.safeParse(parsed)
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationError(`${errorMessage}: ${result.error.message}`, result.error.issues, result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
} catch (error) {
|
||||
if (error instanceof SyntaxError) {
|
||||
throw new ValidationError('Invalid JSON format', { originalError: error.message })
|
||||
}
|
||||
if (error instanceof ValidationError) {
|
||||
throw error
|
||||
}
|
||||
throw new ValidationError(
|
||||
`Unexpected error during validation: ${error instanceof Error ? error.message : 'Unknown error'}`
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Validate API response structure using Zod
|
||||
export function validatePaginatedResponse<T>(
|
||||
data: unknown,
|
||||
itemSchema: z.ZodType<T>
|
||||
): z.infer<ReturnType<typeof PaginatedResponseSchema<typeof itemSchema>>> {
|
||||
const schema = PaginatedResponseSchema(itemSchema)
|
||||
const result = schema.safeParse(data)
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationError(`Invalid response format: ${result.error.message}`, result.error.issues, result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Validate and sanitize query parameters using Zod
|
||||
export function validateQueryParams(params: URLSearchParams): z.infer<typeof QueryParamsSchema> {
|
||||
const queryParams: Record<string, string | string[]> = {}
|
||||
|
||||
// Handle all parameters - Array.from() for compatibility
|
||||
Array.from(params.entries()).forEach(([key, value]) => {
|
||||
if (['capabilities', 'providers', 'authentication'].includes(key)) {
|
||||
if (!queryParams[key]) {
|
||||
queryParams[key] = []
|
||||
}
|
||||
;(queryParams[key] as string[]).push(value)
|
||||
} else {
|
||||
queryParams[key] = value
|
||||
}
|
||||
})
|
||||
|
||||
const result = QueryParamsSchema.safeParse(queryParams)
|
||||
|
||||
if (!result.success) {
|
||||
throw new ValidationError(`Invalid query parameters: ${result.error.message}`, result.error.issues, result.error)
|
||||
}
|
||||
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Type-safe error response creation
|
||||
export function createErrorResponse(
|
||||
message: string,
|
||||
status: number = 500,
|
||||
details?: unknown
|
||||
): z.infer<typeof ApiErrorSchema> {
|
||||
const error: z.infer<typeof ApiErrorSchema> = { error: message }
|
||||
if (details !== undefined) {
|
||||
;(error as any).details = details
|
||||
}
|
||||
return error
|
||||
}
|
||||
|
||||
// Safe type casting utility using Zod
|
||||
export function safeTypeCast<T>(value: unknown, schema: z.ZodType<T>, typeName?: string): T {
|
||||
const result = schema.safeParse(value)
|
||||
if (!result.success) {
|
||||
throw new ValidationError(
|
||||
`Expected ${typeName || schema.description || 'valid type'}, but validation failed: ${result.error.message}`,
|
||||
result.error.issues,
|
||||
result.error
|
||||
)
|
||||
}
|
||||
return result.data
|
||||
}
|
||||
|
||||
// Utility function to extract validation error details
|
||||
export function formatZodError(error: z.ZodError): string {
|
||||
return error.issues
|
||||
.map((issue) => {
|
||||
const path = issue.path.join('.')
|
||||
return `${path ? `${path}: ` : ''}${issue.message}`
|
||||
})
|
||||
.join('; ')
|
||||
}
|
||||
|
||||
// Export inferred types
|
||||
export type Model = z.infer<typeof ModelSchema>
|
||||
export type Provider = z.infer<typeof ProviderSchema>
|
||||
export type ProviderModelOverride = z.infer<typeof ProviderModelOverrideSchema>
|
||||
export type ModelsDataFile = z.infer<typeof ModelsDataFileSchema>
|
||||
export type ProvidersDataFile = z.infer<typeof ProvidersDataFileSchema>
|
||||
export type OverridesDataFile = z.infer<typeof OverridesDataFileSchema>
|
||||
export type PaginationInfo = z.infer<typeof PaginationInfoSchema>
|
||||
export type PaginatedResponse<T> = z.infer<ReturnType<typeof PaginatedResponseSchema<z.ZodType<T>>>>
|
||||
export type ModelListRequest = z.infer<typeof ModelListRequestSchema>
|
||||
export type ProviderListRequest = z.infer<typeof ProviderListRequestSchema>
|
||||
export type ApiError = z.infer<typeof ApiErrorSchema>
|
||||
export type SuccessResponse = z.infer<typeof SuccessResponseSchema>
|
||||
export type ModelUpdateResponse = z.infer<typeof ModelUpdateResponseSchema>
|
||||
export type ProviderUpdateResponse = z.infer<typeof ProviderUpdateResponseSchema>
|
||||
|
||||
// Export enum types for convenience
|
||||
export type CapabilityType = z.infer<typeof CapabilityTypeSchema>
|
||||
export type ModalityType = z.infer<typeof ModalityTypeSchema>
|
||||
export type AuthenticationType = z.infer<typeof AuthenticationTypeSchema>
|
||||
export type EndpointType = z.infer<typeof EndpointTypeSchema>
|
||||
|
||||
// Legacy compatibility type guards (now using Zod internally)
|
||||
export function isModel(obj: unknown): obj is Model {
|
||||
return ModelSchema.safeParse(obj).success
|
||||
}
|
||||
|
||||
export function isProvider(obj: unknown): obj is Provider {
|
||||
return ProviderSchema.safeParse(obj).success
|
||||
}
|
||||
|
||||
export function isModelsDataFile(obj: unknown): obj is ModelsDataFile {
|
||||
return ModelsDataFileSchema.safeParse(obj).success
|
||||
}
|
||||
|
||||
export function isProvidersDataFile(obj: unknown): obj is ProvidersDataFile {
|
||||
return ProvidersDataFileSchema.safeParse(obj).success
|
||||
}
|
||||
40
packages/catalog/web/next.config.ts
Normal file
40
packages/catalog/web/next.config.ts
Normal file
@ -0,0 +1,40 @@
|
||||
import type { NextConfig } from 'next'
|
||||
|
||||
const nextConfig: NextConfig = {
|
||||
// Configure static file serving from external directory
|
||||
async rewrites() {
|
||||
return [
|
||||
// Proxy API requests to the catalog API
|
||||
{
|
||||
source: '/api/catalog/:path*',
|
||||
destination: 'http://localhost:3001/api/catalog/:path*'
|
||||
}
|
||||
]
|
||||
},
|
||||
// Add custom headers for static files
|
||||
async headers() {
|
||||
return [
|
||||
{
|
||||
source: '/data/:path*',
|
||||
headers: [
|
||||
{
|
||||
key: 'Cache-Control',
|
||||
value: 'public, max-age=3600, must-revalidate'
|
||||
},
|
||||
{
|
||||
key: 'Access-Control-Allow-Origin',
|
||||
value: '*'
|
||||
}
|
||||
]
|
||||
}
|
||||
]
|
||||
},
|
||||
// Configure serving static files from outside public directory
|
||||
outputFileTracingExcludes: {
|
||||
'*': ['./**/__tests__/**/*']
|
||||
},
|
||||
// Basic Turbopack configuration to silence warning
|
||||
turbopack: {}
|
||||
}
|
||||
|
||||
export default nextConfig
|
||||
32
packages/catalog/web/package.json
Normal file
32
packages/catalog/web/package.json
Normal file
@ -0,0 +1,32 @@
|
||||
{
|
||||
"name": "@cherrystudio/catalog-web",
|
||||
"version": "0.1.0",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "next dev",
|
||||
"build": "next build",
|
||||
"start": "next start",
|
||||
"lint": "eslint"
|
||||
},
|
||||
"dependencies": {
|
||||
"@radix-ui/react-dialog": "^1.1.15",
|
||||
"@radix-ui/react-icons": "^1.3.2",
|
||||
"@radix-ui/react-separator": "^1.1.8",
|
||||
"@radix-ui/react-slot": "^1.2.4",
|
||||
"next": "16.0.6",
|
||||
"react": "19.2.0",
|
||||
"react-dom": "19.2.0",
|
||||
"swr": "^2.3.7",
|
||||
"zod": "^4.0.0"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/postcss": "^4",
|
||||
"@types/node": "^20",
|
||||
"@types/react": "^19",
|
||||
"@types/react-dom": "^19",
|
||||
"eslint": "^9",
|
||||
"eslint-config-next": "16.0.6",
|
||||
"tailwindcss": "^4",
|
||||
"typescript": "^5"
|
||||
}
|
||||
}
|
||||
7
packages/catalog/web/postcss.config.mjs
Normal file
7
packages/catalog/web/postcss.config.mjs
Normal file
@ -0,0 +1,7 @@
|
||||
const config = {
|
||||
plugins: {
|
||||
'@tailwindcss/postcss': {}
|
||||
}
|
||||
}
|
||||
|
||||
export default config
|
||||
1
packages/catalog/web/public/file.svg
Normal file
1
packages/catalog/web/public/file.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" viewBox="0 0 16 16" xmlns="http://www.w3.org/2000/svg"><path d="M14.5 13.5V5.41a1 1 0 0 0-.3-.7L9.8.29A1 1 0 0 0 9.08 0H1.5v13.5A2.5 2.5 0 0 0 4 16h8a2.5 2.5 0 0 0 2.5-2.5m-1.5 0v-7H8v-5H3v12a1 1 0 0 0 1 1h8a1 1 0 0 0 1-1M9.5 5V2.12L12.38 5zM5.13 5h-.62v1.25h2.12V5zm-.62 3h7.12v1.25H4.5zm.62 3h-.62v1.25h7.12V11z" clip-rule="evenodd" fill="#666" fill-rule="evenodd"/></svg>
|
||||
|
After Width: | Height: | Size: 391 B |
1
packages/catalog/web/public/globe.svg
Normal file
1
packages/catalog/web/public/globe.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><g clip-path="url(#a)"><path fill-rule="evenodd" clip-rule="evenodd" d="M10.27 14.1a6.5 6.5 0 0 0 3.67-3.45q-1.24.21-2.7.34-.31 1.83-.97 3.1M8 16A8 8 0 1 0 8 0a8 8 0 0 0 0 16m.48-1.52a7 7 0 0 1-.96 0H7.5a4 4 0 0 1-.84-1.32q-.38-.89-.63-2.08a40 40 0 0 0 3.92 0q-.25 1.2-.63 2.08a4 4 0 0 1-.84 1.31zm2.94-4.76q1.66-.15 2.95-.43a7 7 0 0 0 0-2.58q-1.3-.27-2.95-.43a18 18 0 0 1 0 3.44m-1.27-3.54a17 17 0 0 1 0 3.64 39 39 0 0 1-4.3 0 17 17 0 0 1 0-3.64 39 39 0 0 1 4.3 0m1.1-1.17q1.45.13 2.69.34a6.5 6.5 0 0 0-3.67-3.44q.65 1.26.98 3.1M8.48 1.5l.01.02q.41.37.84 1.31.38.89.63 2.08a40 40 0 0 0-3.92 0q.25-1.2.63-2.08a4 4 0 0 1 .85-1.32 7 7 0 0 1 .96 0m-2.75.4a6.5 6.5 0 0 0-3.67 3.44 29 29 0 0 1 2.7-.34q.31-1.83.97-3.1M4.58 6.28q-1.66.16-2.95.43a7 7 0 0 0 0 2.58q1.3.27 2.95.43a18 18 0 0 1 0-3.44m.17 4.71q-1.45-.12-2.69-.34a6.5 6.5 0 0 0 3.67 3.44q-.65-1.27-.98-3.1" fill="#666"/></g><defs><clipPath id="a"><path fill="#fff" d="M0 0h16v16H0z"/></clipPath></defs></svg>
|
||||
|
After Width: | Height: | Size: 1.0 KiB |
1
packages/catalog/web/public/next.svg
Normal file
1
packages/catalog/web/public/next.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" fill="none" viewBox="0 0 394 80"><path fill="#000" d="M262 0h68.5v12.7h-27.2v66.6h-13.6V12.7H262V0ZM149 0v12.7H94v20.4h44.3v12.6H94v21h55v12.6H80.5V0h68.7zm34.3 0h-17.8l63.8 79.4h17.9l-32-39.7 32-39.6h-17.9l-23 28.6-23-28.6zm18.3 56.7-9-11-27.1 33.7h17.8l18.3-22.7z"/><path fill="#000" d="M81 79.3 17 0H0v79.3h13.6V17l50.2 62.3H81Zm252.6-.4c-1 0-1.8-.4-2.5-1s-1.1-1.6-1.1-2.6.3-1.8 1-2.5 1.6-1 2.6-1 1.8.3 2.5 1a3.4 3.4 0 0 1 .6 4.3 3.7 3.7 0 0 1-3 1.8zm23.2-33.5h6v23.3c0 2.1-.4 4-1.3 5.5a9.1 9.1 0 0 1-3.8 3.5c-1.6.8-3.5 1.3-5.7 1.3-2 0-3.7-.4-5.3-1s-2.8-1.8-3.7-3.2c-.9-1.3-1.4-3-1.4-5h6c.1.8.3 1.6.7 2.2s1 1.2 1.6 1.5c.7.4 1.5.5 2.4.5 1 0 1.8-.2 2.4-.6a4 4 0 0 0 1.6-1.8c.3-.8.5-1.8.5-3V45.5zm30.9 9.1a4.4 4.4 0 0 0-2-3.3 7.5 7.5 0 0 0-4.3-1.1c-1.3 0-2.4.2-3.3.5-.9.4-1.6 1-2 1.6a3.5 3.5 0 0 0-.3 4c.3.5.7.9 1.3 1.2l1.8 1 2 .5 3.2.8c1.3.3 2.5.7 3.7 1.2a13 13 0 0 1 3.2 1.8 8.1 8.1 0 0 1 3 6.5c0 2-.5 3.7-1.5 5.1a10 10 0 0 1-4.4 3.5c-1.8.8-4.1 1.2-6.8 1.2-2.6 0-4.9-.4-6.8-1.2-2-.8-3.4-2-4.5-3.5a10 10 0 0 1-1.7-5.6h6a5 5 0 0 0 3.5 4.6c1 .4 2.2.6 3.4.6 1.3 0 2.5-.2 3.5-.6 1-.4 1.8-1 2.4-1.7a4 4 0 0 0 .8-2.4c0-.9-.2-1.6-.7-2.2a11 11 0 0 0-2.1-1.4l-3.2-1-3.8-1c-2.8-.7-5-1.7-6.6-3.2a7.2 7.2 0 0 1-2.4-5.7 8 8 0 0 1 1.7-5 10 10 0 0 1 4.3-3.5c2-.8 4-1.2 6.4-1.2 2.3 0 4.4.4 6.2 1.2 1.8.8 3.2 2 4.3 3.4 1 1.4 1.5 3 1.5 5h-5.8z"/></svg>
|
||||
|
After Width: | Height: | Size: 1.3 KiB |
1
packages/catalog/web/public/vercel.svg
Normal file
1
packages/catalog/web/public/vercel.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1155 1000"><path d="m577.3 0 577.4 1000H0z" fill="#fff"/></svg>
|
||||
|
After Width: | Height: | Size: 128 B |
1
packages/catalog/web/public/window.svg
Normal file
1
packages/catalog/web/public/window.svg
Normal file
@ -0,0 +1 @@
|
||||
<svg fill="none" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 16 16"><path fill-rule="evenodd" clip-rule="evenodd" d="M1.5 2.5h13v10a1 1 0 0 1-1 1h-11a1 1 0 0 1-1-1zM0 1h16v11.5a2.5 2.5 0 0 1-2.5 2.5h-11A2.5 2.5 0 0 1 0 12.5zm3.75 4.5a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5M7 4.75a.75.75 0 1 1-1.5 0 .75.75 0 0 1 1.5 0m1.75.75a.75.75 0 1 0 0-1.5.75.75 0 0 0 0 1.5" fill="#666"/></svg>
|
||||
|
After Width: | Height: | Size: 385 B |
27
packages/catalog/web/tsconfig.json
Normal file
27
packages/catalog/web/tsconfig.json
Normal file
@ -0,0 +1,27 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"target": "ES2017",
|
||||
"lib": ["dom", "dom.iterable", "esnext"],
|
||||
"allowJs": true,
|
||||
"skipLibCheck": true,
|
||||
"strict": true,
|
||||
"noEmit": true,
|
||||
"esModuleInterop": true,
|
||||
"module": "esnext",
|
||||
"moduleResolution": "bundler",
|
||||
"resolveJsonModule": true,
|
||||
"isolatedModules": true,
|
||||
"jsx": "react-jsx",
|
||||
"incremental": true,
|
||||
"plugins": [
|
||||
{
|
||||
"name": "next"
|
||||
}
|
||||
],
|
||||
"paths": {
|
||||
"@/*": ["./*"]
|
||||
}
|
||||
},
|
||||
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts", ".next/dev/types/**/*.ts", "**/*.mts"],
|
||||
"exclude": ["node_modules"]
|
||||
}
|
||||
@ -33,8 +33,9 @@
|
||||
"@cherrystudio/ai-sdk-provider": ["./packages/ai-sdk-provider/src/index.ts"],
|
||||
"@cherrystudio/ui/icons": ["./packages/ui/src/components/icons/index.ts"],
|
||||
"@cherrystudio/ui": ["./packages/ui/src/index.ts"],
|
||||
"@cherrystudio/ui/*": ["./packages/ui/src/*"]
|
||||
|
||||
"@cherrystudio/ui/*": ["./packages/ui/src/*"],
|
||||
"@cherrystudio/catalog": ["./packages/catalog/src/index.ts"],
|
||||
"@cherrystudio/catalog/*": ["./packages/catalog/src/*"]
|
||||
},
|
||||
"experimentalDecorators": true,
|
||||
"emitDecoratorMetadata": true,
|
||||
|
||||
Loading…
Reference in New Issue
Block a user