mirror of
https://github.com/CherryHQ/cherry-studio.git
synced 2026-01-03 02:59: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/extension-table-plus': resolve('packages/extension-table-plus/src'),
|
||||||
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'),
|
'@cherrystudio/ai-sdk-provider': resolve('packages/ai-sdk-provider/src'),
|
||||||
'@cherrystudio/ui/icons': resolve('packages/ui/src/components/icons'),
|
'@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: {
|
optimizeDeps: {
|
||||||
|
|||||||
@ -72,7 +72,7 @@ packages/catalog/
|
|||||||
```typescript
|
```typescript
|
||||||
// packages/catalog/src/schemas/model.schema.ts
|
// packages/catalog/src/schemas/model.schema.ts
|
||||||
|
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
import { EndpointTypeSchema } from './provider.schema'
|
import { EndpointTypeSchema } from './provider.schema'
|
||||||
|
|
||||||
// 模态类型
|
// 模态类型
|
||||||
@ -206,7 +206,7 @@ export type ModelConfig = z.infer<typeof ModelConfigSchema>
|
|||||||
```typescript
|
```typescript
|
||||||
// packages/catalog/src/schemas/provider.schema.ts
|
// packages/catalog/src/schemas/provider.schema.ts
|
||||||
|
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
|
|
||||||
// 端点类型
|
// 端点类型
|
||||||
export const EndpointTypeSchema = z.enum([
|
export const EndpointTypeSchema = z.enum([
|
||||||
@ -311,7 +311,7 @@ export type ProviderConfig = z.infer<typeof ProviderConfigSchema>
|
|||||||
```typescript
|
```typescript
|
||||||
// packages/catalog/src/schemas/override.schema.ts
|
// packages/catalog/src/schemas/override.schema.ts
|
||||||
|
|
||||||
import { z } from 'zod'
|
import * as z from 'zod'
|
||||||
import { ModelCapabilityTypeSchema, ModelPricingSchema, ParameterSupportSchema } from './model.schema'
|
import { ModelCapabilityTypeSchema, ModelPricingSchema, ParameterSupportSchema } from './model.schema'
|
||||||
|
|
||||||
export const ProviderModelOverrideSchema = z.object({
|
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",
|
"models": "models.json",
|
||||||
"overrides": "overrides.json"
|
"overrides": "overrides.json"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@ -47,5 +47,8 @@
|
|||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"json-schema": "^0.4.0"
|
"json-schema": "^0.4.0"
|
||||||
}
|
},
|
||||||
|
"workspaces": [
|
||||||
|
"web"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -51,4 +51,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -25,4 +25,4 @@
|
|||||||
"priority": 100
|
"priority": 100
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -50,4 +50,4 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
@ -74,9 +74,6 @@ interface ProviderConfig {
|
|||||||
name: string
|
name: string
|
||||||
description?: string
|
description?: string
|
||||||
authentication: string
|
authentication: string
|
||||||
pricing_model: string
|
|
||||||
model_routing: string
|
|
||||||
behaviors: Record<string, boolean>
|
|
||||||
supported_endpoints: string[]
|
supported_endpoints: string[]
|
||||||
api_compatibility?: Record<string, boolean>
|
api_compatibility?: Record<string, boolean>
|
||||||
special_config?: Record<string, any>
|
special_config?: Record<string, any>
|
||||||
@ -257,46 +254,11 @@ export class MigrationTool {
|
|||||||
for (const [providerId, providerData] of Object.entries(this.providerEndpointsData.providers)) {
|
for (const [providerId, providerData] of Object.entries(this.providerEndpointsData.providers)) {
|
||||||
const supported_endpoints = this.privateConvertEndpointsToCapabilities(providerData.endpoints)
|
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 = {
|
const provider: ProviderConfig = {
|
||||||
id: providerId,
|
id: providerId,
|
||||||
name: providerData.display_name,
|
name: providerData.display_name,
|
||||||
description: `Provider: ${providerData.display_name}`,
|
description: `Provider: ${providerData.display_name}`,
|
||||||
authentication: 'API_KEY',
|
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,
|
supported_endpoints,
|
||||||
api_compatibility: {
|
api_compatibility: {
|
||||||
supports_array_content: providerData.endpoints.chat_completions || false,
|
supports_array_content: providerData.endpoints.chat_completions || false,
|
||||||
@ -313,12 +275,7 @@ export class MigrationTool {
|
|||||||
website: providerData.url,
|
website: providerData.url,
|
||||||
deprecated: false,
|
deprecated: false,
|
||||||
maintenance_mode: false,
|
maintenance_mode: false,
|
||||||
config_version: '1.0.0',
|
config_version: '1.0.0'
|
||||||
metadata: {
|
|
||||||
source: 'litellm-endpoints',
|
|
||||||
tags: [isDirectProvider ? 'official' : isProxyProvider ? 'proxy' : 'cloud'],
|
|
||||||
reliability: isDirectProvider ? 'high' : 'medium'
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
providers.push(provider)
|
providers.push(provider)
|
||||||
@ -556,7 +513,9 @@ export class MigrationTool {
|
|||||||
|
|
||||||
console.log('\n✅ Migration completed successfully!')
|
console.log('\n✅ Migration completed successfully!')
|
||||||
console.log(`📊 Migration Summary:`)
|
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(` Base Models: ${models.length}`)
|
||||||
console.log(` Overrides: ${overrides.length}`)
|
console.log(` Overrides: ${overrides.length}`)
|
||||||
console.log(`\n📁 Output Files:`)
|
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/ai-sdk-provider": ["./packages/ai-sdk-provider/src/index.ts"],
|
||||||
"@cherrystudio/ui/icons": ["./packages/ui/src/components/icons/index.ts"],
|
"@cherrystudio/ui/icons": ["./packages/ui/src/components/icons/index.ts"],
|
||||||
"@cherrystudio/ui": ["./packages/ui/src/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,
|
"experimentalDecorators": true,
|
||||||
"emitDecoratorMetadata": true,
|
"emitDecoratorMetadata": true,
|
||||||
|
|||||||
Loading…
Reference in New Issue
Block a user