diff --git a/web/packages/README.md b/web/packages/README.md new file mode 100644 index 0000000000..d72e1d2079 --- /dev/null +++ b/web/packages/README.md @@ -0,0 +1,42 @@ +# @agenta/* Packages + +Internal workspace packages for the Agenta monorepo. + +## Packages + +| Package | Description | Status | +|---------|-------------|--------| +| `@agenta/shared` | Shared utilities, state atoms, and API helpers | ✅ Active | +| `@agenta/entities` | Entity definitions and data fetching atoms | 🚧 Planned | +| `@agenta/ui` | Shared UI components | 🚧 Planned | + +## Shared Configuration + +- **`tsconfig.base.json`** - Base TypeScript configuration extended by all packages +- **`eslint.config.mjs`** - Shared ESLint configuration for packages (without Next.js plugins) + +## Development + +### Linting + +Run ESLint for a specific package: + +```bash +cd web/packages/agenta-shared +pnpm lint +``` + +### Type Checking + +```bash +cd web/packages/agenta-shared +pnpm build +``` + +## Adding a New Package + +1. Create a new folder under `web/packages/` +2. Add a `package.json` with the package name `@agenta/` +3. Create a `tsconfig.json` extending `../tsconfig.base.json` +4. Add a `README.md` documenting the package +5. Export from `src/index.ts` diff --git a/web/packages/agenta-shared/README.md b/web/packages/agenta-shared/README.md new file mode 100644 index 0000000000..242d1e57af --- /dev/null +++ b/web/packages/agenta-shared/README.md @@ -0,0 +1,349 @@ +# @agenta/shared + +Shared utilities and primitives for Agenta packages and apps. + +## Installation + +This is an internal workspace package. Add it to your package's dependencies: + +```json +{ + "dependencies": { + "@agenta/shared": "workspace:*" + } +} +``` + +## Peer Dependencies + +This package requires the following peer dependencies: + +- `axios` >= 1.0.0 +- `jotai` >= 2.0.0 +- `jotai-tanstack-query` >= 0.9.0 +- `@tanstack/react-query` >= 5.0.0 + +## Usage + +### API Utilities + +```typescript +import { axios, getAgentaApiUrl, getEnv, configureAxios } from '@agenta/shared' + +// Get environment variables (supports runtime override via window.__env) +const apiUrl = getEnv('NEXT_PUBLIC_AGENTA_API_URL') + +// Use the shared axios instance +const response = await axios.get('/api/endpoint') + +// Configure axios interceptors at app startup +configureAxios({ + requestInterceptor: async (config) => { + const jwt = await getJWT() + if (jwt) config.headers.set('Authorization', `Bearer ${jwt}`) + return config + }, + errorInterceptor: (error) => { + if (error.response?.status === 401) signOut() + throw error + } +}) +``` + +### State Atoms + +```typescript +import { projectIdAtom, setProjectIdAtom } from '@agenta/shared' +import { useAtom, useSetAtom } from 'jotai' + +// Read project ID +const [projectId] = useAtom(projectIdAtom) + +// Set project ID +const setProjectId = useSetAtom(setProjectIdAtom) +setProjectId('my-project-id') +``` + +### Validation Utilities + +```typescript +import { isValidHttpUrl, isValidRegex, isValidUUID, validateUUID } from '@agenta/shared' + +isValidHttpUrl('https://example.com') // true +isValidRegex('^[a-z]+$') // true +isValidUUID('550e8400-e29b-41d4-a716-446655440000') // true + +// Throws if invalid +validateUUID(id, 'projectId') +``` + +### Date/Time Utilities + +```typescript +import { dayjs, parseEntityDate, normalizeTimestamps } from '@agenta/shared' + +// Parse dates with WebKit-compatible fallback formats +const date = parseEntityDate('2024-01-15T10:30:00.000000Z') + +// Normalize timestamps in entity data +const entity = normalizeTimestamps({ created_at: '2024-01-15T10:30:00Z', name: 'Test' }) +// { created_at: Date, name: 'Test' } + +// Use dayjs directly (with customParseFormat and utc plugins) +dayjs('2024-01-15').format('YYYY-MM-DD') +``` + +### Batch Fetcher + +```typescript +import { createBatchFetcher } from '@agenta/shared' + +const fetchUser = createBatchFetcher({ + batchFn: async (ids) => { + const users = await api.getUsers(ids) + return new Map(users.map(u => [u.id, u])) + }, + flushDelay: 50, + maxBatchSize: 100, +}) + +// Individual calls are batched automatically +const user1 = await fetchUser('id-1') +const user2 = await fetchUser('id-2') +``` + +### Path Utilities + +Navigate and manipulate nested data structures: + +```typescript +import { + getValueAtPath, + setValueAtPath, + deleteValueAtPath, + parsePath, + pathToString, +} from '@agenta/shared' + +const data = { user: { profile: { name: 'Alice' } } } + +// Get nested value +getValueAtPath(data, ['user', 'profile', 'name']) // 'Alice' + +// Set nested value (immutable) +const updated = setValueAtPath(data, ['user', 'profile', 'name'], 'Bob') +// { user: { profile: { name: 'Bob' } } } + +// Parse path strings +parsePath('user.profile.name') // ['user', 'profile', 'name'] +parsePath('items[0].name') // ['items', '0', 'name'] + +// Handles JSON strings in data +const testcase = { messages: '{"content": "hello"}' } +getValueAtPath(testcase, ['messages', 'content']) // 'hello' +``` + +### Typed Path Utilities + +Extract and combine paths with type information for UI selection components: + +```typescript +import { + extractTypedPaths, + combineTypedPaths, + buildTestcaseColumnPaths, + type TypedPathInfo, +} from '@agenta/shared' + +// Extract paths from nested data with type info +const data = { user: { name: 'Alice', age: 25 }, items: [1, 2, 3] } +const paths = extractTypedPaths(data, { source: 'output', maxDepth: 3 }) +// [ +// { path: 'user', label: 'user', valueType: 'object', source: 'output' }, +// { path: 'user.name', label: 'name', valueType: 'string', source: 'output' }, +// { path: 'user.age', label: 'age', valueType: 'number', source: 'output' }, +// { path: 'items', label: 'items', valueType: 'array', source: 'output' }, +// ] + +// Combine paths from multiple sources with deduplication +const combinedPaths = combineTypedPaths(schemaPaths, runtimePaths, testcasePaths) + +// Build paths from testcase columns +const columnPaths = buildTestcaseColumnPaths([ + { key: 'prompt', name: 'Prompt', type: 'string' }, + { key: 'expected', name: 'Expected Output' }, +]) +// [ +// { path: 'testcase.prompt', label: 'Prompt', source: 'testcase', valueType: 'string' }, +// { path: 'testcase.expected', label: 'Expected Output', source: 'testcase' }, +// ] +``` + +### Mapping Utilities + +Determine and validate mapping status for input/output connections: + +```typescript +import { + determineMappingStatus, + getMappingStatusConfig, + validateMappings, + isMappingError, + type MappingStatus, +} from '@agenta/shared' + +// Determine status of a single mapping +const status = determineMappingStatus(mapping, isRequired) +// Returns: 'auto' | 'manual' | 'missing' | 'invalid_path' | 'type_mismatch' | 'optional' + +// Get UI configuration for a status +const config = getMappingStatusConfig('auto') +// { status: 'auto', color: 'blue', label: 'Auto', severity: 'info' } + +// Validate a set of mappings +const result = validateMappings( + { input1: { isAutoMapped: true }, input2: undefined }, + ['input1', 'input2'] +) +// { +// isValid: false, +// isComplete: false, +// errorCount: 1, +// errors: [{ key: 'input2', status: 'missing' }], +// ... +// } + +// Check status type +isMappingError('missing') // true +isMappingError('auto') // false +``` + +### Formatting Utilities + +Format numbers, currency, latency, and more for display: + +```typescript +import { + formatNumber, + formatCurrency, + formatLatency, + formatSignificant, + formatPercent, + createFormatter, +} from '@agenta/shared' + +// Preset formatters +formatNumber(1234.567) // "1,234.57" +formatCurrency(0.00123) // "$0.001230" +formatLatency(0.5) // "500ms" +formatSignificant(0.00456) // "0.00456" +formatPercent(0.856) // "85.60%" + +// Custom formatter factory +const formatScore = createFormatter({ + multiplier: 100, + suffix: '%', + decimals: 1, +}) +formatScore(0.856) // "85.6%" +``` + +See [formatters/README.md](./src/utils/formatters/README.md) for full documentation. + +### OpenAPI Utilities + +Dereference OpenAPI specifications: + +```typescript +import { dereferenceSchema } from '@agenta/shared' + +const rawSpec = await fetchOpenApiSpec(uri) +const { schema, errors } = await dereferenceSchema(rawSpec) + +if (schema) { + // Use the fully resolved schema (no $ref pointers) + const properties = schema.paths['/test'].post.requestBody +} +``` + +## Subpath Exports + +**Always use subpath imports for better tree-shaking.** Importing from the root barrel (`@agenta/shared`) pulls the entire dependency graph, which increases bundle size. + +### Available Subpaths + +| Subpath | Description | Key Exports | +|---------|-------------|-------------| +| `@agenta/shared/api` | API utilities | `axios`, `getAgentaApiUrl`, `getEnv`, `configureAxios` | +| `@agenta/shared/state` | Jotai atoms | `projectIdAtom`, `setProjectIdAtom` | +| `@agenta/shared/utils` | Pure utilities | `dayjs`, `createBatchFetcher`, `isValidUUID`, `dereferenceSchema`, path utils, mapping utils, formatters | +| `@agenta/shared/hooks` | React hooks | `useDebounceInput` | +| `@agenta/shared/schemas` | Zod schemas | `MESSAGE_CONTENT_SCHEMA`, `CHAT_MESSAGE_SCHEMA`, `CHAT_MESSAGES_ARRAY_SCHEMA` | +| `@agenta/shared/types` | TypeScript types | `SimpleChatMessage`, `MessageContent`, `TextContentPart`, `ToolCall` | + +### Usage Examples + +```typescript +// API utilities +import {axios, getAgentaApiUrl, getEnv} from "@agenta/shared/api" + +// State atoms +import {projectIdAtom, setProjectIdAtom} from "@agenta/shared/state" + +// Utilities (most common) +import { + dayjs, + createBatchFetcher, + isValidUUID, + dereferenceSchema, + getValueAtPath, + setValueAtPath, + extractTypedPaths, + determineMappingStatus, + formatNumber, + formatLatency, +} from "@agenta/shared/utils" + +// React hooks +import {useDebounceInput} from "@agenta/shared/hooks" + +// Schemas (for validation) +import { + MESSAGE_CONTENT_SCHEMA, + CHAT_MESSAGE_SCHEMA, + CHAT_MESSAGES_ARRAY_SCHEMA, +} from "@agenta/shared/schemas" + +// Types (use `import type` for type-only imports) +import type { + SimpleChatMessage, + MessageContent, + TextContentPart, + ImageContentPart, + ToolCall, +} from "@agenta/shared/types" +``` + +### Migration from Root Import + +If you have code importing from `@agenta/shared`: + +```typescript +// Before (pulls entire package) +import {axios, SimpleChatMessage, MESSAGE_CONTENT_SCHEMA} from "@agenta/shared" + +// After (tree-shakeable) +import {axios} from "@agenta/shared/api" +import type {SimpleChatMessage} from "@agenta/shared/types" +import {MESSAGE_CONTENT_SCHEMA} from "@agenta/shared/schemas" +``` + +## Development + +```bash +# Type check +pnpm build + +# Lint +pnpm lint +``` diff --git a/web/packages/agenta-shared/package.json b/web/packages/agenta-shared/package.json new file mode 100644 index 0000000000..b20f4e54b7 --- /dev/null +++ b/web/packages/agenta-shared/package.json @@ -0,0 +1,39 @@ +{ + "name": "@agenta/shared", + "version": "0.75.0", + "private": true, + "sideEffects": false, + "main": "./src/index.ts", + "types": "./src/index.ts", + "scripts": { + "build": "tsc --noEmit", + "types:check": "tsc --noEmit", + "lint": "eslint --config ../eslint.config.mjs src/" + }, + "exports": { + ".": "./src/index.ts", + "./api": "./src/api/index.ts", + "./state": "./src/state/index.ts", + "./utils": "./src/utils/index.ts", + "./hooks": "./src/hooks/index.ts", + "./schemas": "./src/schemas/index.ts", + "./types": "./src/types/index.ts" + }, + "dependencies": { + "@scalar/openapi-parser": "^0.9.0", + "dayjs": "^1.11.10", + "json5": "^2.2.3", + "jsonrepair": "^3.0.0" + }, + "devDependencies": { + "@types/node": "^20.8.10", + "@types/react": "^19.0.10" + }, + "peerDependencies": { + "axios": ">=1.0.0", + "jotai": ">=2.0.0", + "jotai-tanstack-query": ">=0.9.0", + "@tanstack/react-query": ">=5.0.0", + "react": ">=18.0.0" + } +} diff --git a/web/packages/agenta-shared/src/api/axios.ts b/web/packages/agenta-shared/src/api/axios.ts new file mode 100644 index 0000000000..92b32d8a65 --- /dev/null +++ b/web/packages/agenta-shared/src/api/axios.ts @@ -0,0 +1,100 @@ +/** + * Shared Axios instance for API requests. + * + * This provides a base axios instance that can be extended by the app + * with authentication interceptors and other app-specific configuration. + * + * @example + * ```typescript + * // In app initialization (e.g., _app.tsx) + * import { configureAxios, axios } from '@agenta/shared' + * + * // Configure interceptors once at app startup + * configureAxios({ + * requestInterceptor: async (config) => { + * const jwt = await getJWT() + * if (jwt) config.headers.set('Authorization', `Bearer ${jwt}`) + * return config + * }, + * responseInterceptor: (response) => response, + * errorInterceptor: (error) => { + * if (error.response?.status === 401) signOut() + * throw error + * } + * }) + * ``` + */ + +import axiosApi, { + type AxiosInstance, + type AxiosResponse, + type InternalAxiosRequestConfig, +} from "axios" + +import {getAgentaApiUrl} from "./env" + +/** + * Create a new axios instance with Agenta API defaults. + */ +export const createAxiosInstance = (): AxiosInstance => { + return axiosApi.create({ + baseURL: getAgentaApiUrl(), + headers: { + "Content-Type": "application/json", + }, + }) +} + +/** + * Shared axios instance. + * Apps should configure interceptors on this instance for auth, error handling, etc. + */ +export const axios = createAxiosInstance() + +/** + * Configuration options for axios interceptors. + */ +export interface AxiosInterceptorConfig { + /** Request interceptor - called before each request */ + requestInterceptor?: ( + config: InternalAxiosRequestConfig, + ) => InternalAxiosRequestConfig | Promise + /** Response interceptor - called on successful responses */ + responseInterceptor?: (response: AxiosResponse) => AxiosResponse | Promise + /** Error interceptor - called on failed responses */ + errorInterceptor?: (error: unknown) => unknown +} + +let isConfigured = false + +/** + * Configure the shared axios instance with custom interceptors. + * Should be called once at app initialization. + * + * @param config - Interceptor configuration + */ +export function configureAxios(config: AxiosInterceptorConfig): void { + if (isConfigured) { + console.warn("[configureAxios] Axios already configured, skipping duplicate configuration") + return + } + + if (config.requestInterceptor) { + axios.interceptors.request.use(config.requestInterceptor) + } + + if (config.responseInterceptor || config.errorInterceptor) { + axios.interceptors.response.use(config.responseInterceptor, config.errorInterceptor) + } + + isConfigured = true +} + +/** + * Reset axios configuration (for testing). + */ +export function resetAxiosConfig(): void { + isConfigured = false +} + +export default axios diff --git a/web/packages/agenta-shared/src/api/env.ts b/web/packages/agenta-shared/src/api/env.ts new file mode 100644 index 0000000000..89cc869e77 --- /dev/null +++ b/web/packages/agenta-shared/src/api/env.ts @@ -0,0 +1,56 @@ +/** + * Environment variable access with runtime override support. + * + * Supports: + * 1. Runtime config via window.__env (for containerized deployments) + * 2. Build-time process.env values + */ + +/** + * Type declaration for runtime environment config injected via window.__env + */ +interface WindowWithEnv extends Window { + __env?: Record +} + +// Build-time environment variables +export const processEnv = { + NEXT_PUBLIC_AGENTA_LICENSE: process.env.NEXT_PUBLIC_AGENTA_LICENSE, + NEXT_PUBLIC_AGENTA_WEB_URL: process.env.NEXT_PUBLIC_AGENTA_WEB_URL, + NEXT_PUBLIC_AGENTA_API_URL: process.env.NEXT_PUBLIC_AGENTA_API_URL, + NEXT_PUBLIC_POSTHOG_API_KEY: process.env.NEXT_PUBLIC_POSTHOG_API_KEY, + NEXT_PUBLIC_CRISP_WEBSITE_ID: process.env.NEXT_PUBLIC_CRISP_WEBSITE_ID, + NEXT_PUBLIC_LOG_APP_ATOMS: process.env.NEXT_PUBLIC_LOG_APP_ATOMS, + NEXT_PUBLIC_ENABLE_ATOM_LOGS: process.env.NEXT_PUBLIC_ENABLE_ATOM_LOGS, +} + +/** + * Get environment variable value. + * Checks window.__env first (runtime), falls back to process.env (build-time). + */ +export const getEnv = (envKey: string): string => { + // Check for window.__env if in browser (runtime config) + if (typeof window !== "undefined") { + const windowEnv = (window as WindowWithEnv).__env + if (windowEnv && Object.keys(windowEnv).length > 0 && windowEnv[envKey]) { + return windowEnv[envKey] + } + } + + // Fall back to build-time environment + return processEnv[envKey as keyof typeof processEnv] || "" +} + +/** + * Get the Agenta API URL. + * Falls back to current origin if not configured. + */ +export const getAgentaApiUrl = (): string => { + const apiUrl = getEnv("NEXT_PUBLIC_AGENTA_API_URL") + + if (!apiUrl && typeof window !== "undefined") { + return `${window.location.protocol}//${window.location.hostname}` + } + + return apiUrl +} diff --git a/web/packages/agenta-shared/src/api/index.ts b/web/packages/agenta-shared/src/api/index.ts new file mode 100644 index 0000000000..0571e5fd34 --- /dev/null +++ b/web/packages/agenta-shared/src/api/index.ts @@ -0,0 +1,13 @@ +/** + * API utilities for Agenta packages. + */ + +export {getEnv, getAgentaApiUrl, processEnv} from "./env" +export {axios, createAxiosInstance, configureAxios, resetAxiosConfig} from "./axios" +export type {AxiosInterceptorConfig} from "./axios" +export type { + AxiosInstance, + AxiosRequestConfig, + AxiosResponse, + InternalAxiosRequestConfig, +} from "axios" diff --git a/web/packages/agenta-shared/src/hooks/index.ts b/web/packages/agenta-shared/src/hooks/index.ts new file mode 100644 index 0000000000..b97c0fc0f2 --- /dev/null +++ b/web/packages/agenta-shared/src/hooks/index.ts @@ -0,0 +1,5 @@ +/** + * React hooks for Agenta shared package. + */ + +export {useDebounceInput} from "./useDebounceInput" diff --git a/web/packages/agenta-shared/src/hooks/useDebounceInput.ts b/web/packages/agenta-shared/src/hooks/useDebounceInput.ts new file mode 100644 index 0000000000..3c1ae21417 --- /dev/null +++ b/web/packages/agenta-shared/src/hooks/useDebounceInput.ts @@ -0,0 +1,103 @@ +import {useState, useEffect, useRef, useCallback} from "react" + +/** + * Local implementation of debounce value hook to avoid dependency issues. + */ +function useDebounceValue(value: T, delay: number): [T, (value: T) => void] { + const [debouncedValue, setDebouncedValue] = useState(value) + + useEffect(() => { + if (delay <= 0) { + setDebouncedValue(value) + return + } + + const timer = setTimeout(() => { + setDebouncedValue(value) + }, delay) + + return () => { + clearTimeout(timer) + } + }, [value, delay]) + + const setValue = useCallback((newValue: T) => { + setDebouncedValue(newValue) + }, []) + + return [debouncedValue, setValue] +} + +/** + * A custom hook that provides debounced input handling with synchronized local and parent state. + * + * @description + * This hook manages the local state of an input while providing debounced updates to the parent component. + * It handles the common pattern of maintaining responsive UI feedback while preventing excessive updates. + * + * @template T - The type of the input value (e.g., string, number, etc.) + * + * @param value - The controlled value from the parent component + * @param onChange - Callback function to update the parent state + * @param delay - Debounce delay in milliseconds (default: 300) + * @param defaultValue - Default value to use when the input value is undefined or null + * + * @returns A tuple containing: + * - localValue: The current local state value + * - setLocalValue: Function to update the local state + * + * @example + * ```tsx + * // Using with a text input + * const TextInput = ({ value, onChange }) => { + * const [localValue, setLocalValue] = useDebounceInput(value, onChange, 300, ""); + * + * return ( + * setLocalValue(e.target.value)} + * /> + * ); + * }; + * ``` + */ +export function useDebounceInput( + value: T, + onChange: (value: T) => void, + delay = 300, + defaultValue: T, +) { + const initialValue = value ?? defaultValue + const [localValue, setLocalValue] = useState(initialValue) + const [debouncedValue, setDebouncedValue] = useDebounceValue(localValue, delay) + // Initialize lastEmittedRef to the initial value to prevent emitting on mount + const lastEmittedRef = useRef(initialValue) + + // For immediate mode (delay=0), emit directly when localValue changes + // For debounced mode, emit when debouncedValue changes + const valueToEmit = delay === 0 ? localValue : debouncedValue + + // Emit only when value differs from what was last emitted. + // This prevents update feedback loops while ensuring user changes are always emitted. + useEffect(() => { + const shouldEmit = valueToEmit !== lastEmittedRef.current + if (shouldEmit) { + lastEmittedRef.current = valueToEmit + onChange?.(valueToEmit) + } + }, [valueToEmit, onChange]) + + // Sync down stream changes from the controlled value but avoid wiping user input + // when value is temporarily undefined/null during upstream recalculations. + useEffect(() => { + if (value === undefined || value === null) return + // Update lastEmittedRef to prevent re-emitting the same value we just received + lastEmittedRef.current = value + setDebouncedValue(value) + setLocalValue((prevValue) => { + return value !== prevValue ? value : prevValue + }) + }, [value, setDebouncedValue]) + + return [localValue, setLocalValue] as const +} diff --git a/web/packages/agenta-shared/src/index.ts b/web/packages/agenta-shared/src/index.ts new file mode 100644 index 0000000000..64382aceb3 --- /dev/null +++ b/web/packages/agenta-shared/src/index.ts @@ -0,0 +1,199 @@ +/** + * @agenta/shared - Shared utilities for Agenta packages + * + * This package provides shared primitives that can be used by other packages + * and apps in the Agenta monorepo. + * + * ## API Utilities + * - `getAgentaApiUrl()` - Get the Agenta API URL + * - `getEnv(key)` - Get environment variable with runtime override support + * - `axios` - Configured axios instance + * + * ## State Atoms + * - `projectIdAtom` - Current project ID (populated by app) + * + * ## Chat Message Utilities + * - Types: `SimpleChatMessage`, `MessageContent`, `ToolCall`, etc. + * - Utilities: `extractTextFromContent`, `hasAttachments`, etc. + * - Schemas: `CHAT_MESSAGE_SCHEMA`, `CHAT_MESSAGES_ARRAY_SCHEMA` + * + * ## Hooks + * - `useDebounceInput` - Debounced input handling with synchronized state + * + * @example + * ```typescript + * import { projectIdAtom, getAgentaApiUrl, axios } from '@agenta/shared' + * + * // Use in entity atoms + * const myQueryAtom = atomWithQuery((get) => { + * const projectId = get(projectIdAtom) + * return { + * queryKey: ['my-query', projectId], + * queryFn: () => axios.get(`${getAgentaApiUrl()}/my-endpoint`), + * enabled: !!projectId, + * } + * }) + * ``` + */ + +// API utilities +export { + getEnv, + getAgentaApiUrl, + processEnv, + axios, + createAxiosInstance, + configureAxios, + resetAxiosConfig, +} from "./api" +export type {AxiosInstance, AxiosRequestConfig, AxiosResponse, AxiosInterceptorConfig} from "./api" + +// State atoms +export {projectIdAtom, setProjectIdAtom} from "./state" + +// Utilities +export { + isValidHttpUrl, + isValidRegex, + isValidUUID, + validateUUID, + createBatchFetcher, + filterItems, + formatEnumLabel, + getOptionsFromSchema, +} from "./utils" +export type { + BatchFetcher, + BatchFetcherOptions, + BatchFnResponse, + FilterItemLabel, + OptionGroup, +} from "./utils" + +// Date/time utilities +export {dayjs, normalizeTimestamps, normalizeEntityTimestamps, parseEntityDate} from "./utils" + +// Path utilities for nested data navigation +export { + getValueAtPath, + getValueAtStringPath, + setValueAtPath, + deleteValueAtPath, + hasValueAtPath, + isExpandable, + getValueType, + getChildCount, + getItemsAtPath, + parsePath, + pathToString, + getParentPath, + getLastSegment, + isChildPath, + collectPaths, + // Typed path utilities for UI selection + extractTypedPaths, + combineTypedPaths, + buildTestcaseColumnPaths, +} from "./utils" +export type { + PathSegment, + DataPath, + PathItem, + TypedPathInfo, + ExtractTypedPathsOptions, +} from "./utils" + +// Chat message utilities +export { + extractTextFromContent, + extractDisplayTextFromMessage, + hasAttachments, + getAttachmentInfo, + updateTextInContent, + addImageToContent, + addFileToContent, + removeAttachmentFromContent, + getAttachments, +} from "./utils" + +// Logger utilities +export {createLogger} from "./utils" +export type {LoggerOptions} from "./utils" + +// JSON parsing utilities +export {tryParsePartialJson, safeJson5Parse} from "./utils" + +// Key path utilities +export {keyToString, stringToKeyPath} from "./utils" + +// JSON detection utilities +export { + isPlainObject, + isJsonString, + isJsonObject, + isJsonArray, + tryParseJson, + tryParseAsObject, + tryParseAsArray, + canExpandAsJson, + tryParseJsonValue, +} from "./utils" +export type {JsonParseResult} from "./utils" + +// Editor language detection utilities +export {detectEditorLanguage, getContentLanguage, looksLikeJson, type EditorLanguage} from "./utils" + +// OpenAPI schema utilities +export {dereferenceSchema, type DereferencedSchemaResult} from "./utils" + +// Chat message types +export type { + TextContentPart, + ImageContentPart, + FileContentPart, + MessageContentPart, + MessageContent, + ToolCall, + SimpleChatMessage, +} from "./types" + +// Chat message schemas +export {MESSAGE_CONTENT_SCHEMA, CHAT_MESSAGE_SCHEMA, CHAT_MESSAGES_ARRAY_SCHEMA} from "./schemas" + +// Hooks +export {useDebounceInput} from "./hooks" + +// Formatting utilities +export { + formatNumber, + formatCompact, + formatCompactNumber, // deprecated alias + formatCurrency, + formatLatency, + formatTokens, + formatTokenUsage, // deprecated alias + formatPercent, + formatSignificant, + formatPreviewValue, + createFormatter, +} from "./utils" +export type {FormatterOptions, Formatter} from "./utils" + +// Pluralization utilities +export {pluralize, formatCount} from "./utils" + +// Mapping utilities for input/output mappings +export { + determineMappingStatus, + getMappingStatusConfig, + isMappingError, + isMappingWarning, + isMappingComplete, + validateMappings, +} from "./utils" +export type { + MappingStatus, + MappingStatusConfig, + MappingLike, + MappingValidationResult, +} from "./utils" diff --git a/web/packages/agenta-shared/src/schemas/chatMessage.ts b/web/packages/agenta-shared/src/schemas/chatMessage.ts new file mode 100644 index 0000000000..878d95417c --- /dev/null +++ b/web/packages/agenta-shared/src/schemas/chatMessage.ts @@ -0,0 +1,136 @@ +/** + * Chat Message JSON Schemas + * + * JSON Schema definitions for validating chat messages in the editor. + * These schemas support the OpenAI/Anthropic message format. + */ + +/** + * JSON Schema for message content - supports string or array of content parts. + */ +export const MESSAGE_CONTENT_SCHEMA = { + anyOf: [ + // Simple string content + {type: "string"}, + // Array of content parts + { + type: "array", + items: { + anyOf: [ + // Text content part + { + type: "object", + properties: { + type: {type: "string", const: "text"}, + text: {type: "string"}, + }, + required: ["type", "text"], + }, + // Image URL content part + { + type: "object", + properties: { + type: {type: "string", const: "image_url"}, + image_url: { + type: "object", + properties: { + url: {type: "string"}, + detail: {type: "string", enum: ["auto", "low", "high"]}, + }, + required: ["url"], + }, + }, + required: ["type", "image_url"], + }, + // File content part + { + type: "object", + properties: { + type: {type: "string", const: "file"}, + file: { + type: "object", + properties: { + file_data: {type: "string"}, + file_id: {type: "string"}, + filename: {type: "string"}, + format: {type: "string"}, + name: {type: "string"}, + mime_type: {type: "string"}, + }, + }, + }, + required: ["type", "file"], + }, + ], + }, + }, + ], +} + +/** + * JSON Schema for validating a full chat message in JSON mode. + * Includes role, content, and optional fields like name, tool_call_id, tool_calls, etc. + * + * Supports: + * - User/System messages: { role, content } + * - Assistant messages: { role, content? } or { role, tool_calls } (content optional when tool_calls present) + * - Tool messages: { role, content, name, tool_call_id } + * - Provider-specific fields: provider_specific_fields, annotations, refusal, etc. + */ +export const CHAT_MESSAGE_SCHEMA = { + type: "object", + properties: { + role: { + type: "string", + enum: ["system", "user", "assistant", "tool", "function"], + }, + content: { + anyOf: [ + MESSAGE_CONTENT_SCHEMA, + {type: "null"}, // content can be null for assistant messages with tool_calls + ], + }, + name: {type: "string"}, // Function/tool name + tool_call_id: {type: "string"}, // For tool response messages + tool_calls: { + type: "array", + items: { + type: "object", + properties: { + id: {type: "string"}, + type: {type: "string", const: "function"}, + function: { + type: "object", + properties: { + name: {type: "string"}, + arguments: {type: "string"}, + }, + required: ["name", "arguments"], + }, + }, + required: ["id", "type", "function"], + }, + }, + // Provider-specific fields (OpenAI, Anthropic, etc.) + provider_specific_fields: {type: "object"}, + annotations: {type: "array"}, + refusal: {anyOf: [{type: "string"}, {type: "null"}]}, + // Function calling (legacy) + function_call: { + type: "object", + properties: { + name: {type: "string"}, + arguments: {type: "string"}, + }, + }, + }, + required: ["role"], // Only role is always required; content is optional for assistant with tool_calls +} + +/** + * JSON Schema for validating an array of chat messages. + */ +export const CHAT_MESSAGES_ARRAY_SCHEMA = { + type: "array", + items: CHAT_MESSAGE_SCHEMA, +} diff --git a/web/packages/agenta-shared/src/schemas/index.ts b/web/packages/agenta-shared/src/schemas/index.ts new file mode 100644 index 0000000000..2e5763736a --- /dev/null +++ b/web/packages/agenta-shared/src/schemas/index.ts @@ -0,0 +1,10 @@ +/** + * JSON Schema definitions for Agenta shared package. + */ + +// Chat message schemas +export { + MESSAGE_CONTENT_SCHEMA, + CHAT_MESSAGE_SCHEMA, + CHAT_MESSAGES_ARRAY_SCHEMA, +} from "./chatMessage" diff --git a/web/packages/agenta-shared/src/state/index.ts b/web/packages/agenta-shared/src/state/index.ts new file mode 100644 index 0000000000..e330220308 --- /dev/null +++ b/web/packages/agenta-shared/src/state/index.ts @@ -0,0 +1,5 @@ +/** + * Shared state atoms for Agenta packages. + */ + +export {projectIdAtom, setProjectIdAtom} from "./project" diff --git a/web/packages/agenta-shared/src/state/project.ts b/web/packages/agenta-shared/src/state/project.ts new file mode 100644 index 0000000000..ef760631d6 --- /dev/null +++ b/web/packages/agenta-shared/src/state/project.ts @@ -0,0 +1,34 @@ +/** + * Project state atoms. + * + * These are primitive atoms that can be populated by the app. + * The app is responsible for syncing these with its own state management. + */ + +import {atom} from "jotai" + +/** + * Current project ID. + * + * This is a primitive atom that should be populated by the app. + * Entity packages read from this to scope queries. + * + * @example + * ```typescript + * // In app initialization + * import { projectIdAtom } from '@agenta/shared/state' + * import { useHydrateAtoms } from 'jotai/utils' + * + * // Hydrate from app state + * useHydrateAtoms([[projectIdAtom, routerProjectId]]) + * ``` + */ +export const projectIdAtom = atom(null as string | null) + +/** + * Set project ID action atom. + * Use this to update the project ID from app code. + */ +export const setProjectIdAtom = atom(null, (_get, set, projectId: string | null) => { + set(projectIdAtom, projectId) +}) diff --git a/web/packages/agenta-shared/src/types/chatMessage.ts b/web/packages/agenta-shared/src/types/chatMessage.ts new file mode 100644 index 0000000000..d9ea927e84 --- /dev/null +++ b/web/packages/agenta-shared/src/types/chatMessage.ts @@ -0,0 +1,74 @@ +/** + * Chat Message Types + * + * Type definitions for chat message content, attachments, and tool calls. + * These types support the OpenAI/Anthropic message format with extensions + * for attachments and provider-specific fields. + */ + +/** Text content part for complex message content */ +export interface TextContentPart { + type: "text" + text: string +} + +/** Image URL content part for image attachments */ +export interface ImageContentPart { + type: "image_url" + image_url: { + url: string + detail?: "auto" | "low" | "high" + } +} + +/** File content part for document attachments */ +export interface FileContentPart { + type: "file" + file: { + file_data?: string + file_id?: string + filename?: string + format?: string + name?: string + mime_type?: string + } +} + +/** Union type for all content part types */ +export type MessageContentPart = TextContentPart | ImageContentPart | FileContentPart + +/** Message content can be a string or an array of content parts */ +export type MessageContent = string | MessageContentPart[] | null + +/** Tool call structure for function calling */ +export interface ToolCall { + id: string + type: "function" + function: { + name: string + arguments: string + } +} + +/** + * Simple message type for the editor - uses string role for flexibility. + * Supports all standard OpenAI/Anthropic message fields plus extensions. + */ +export interface SimpleChatMessage { + role: string + content?: MessageContent + id?: string + // Tool calling fields + name?: string // Function/tool name for tool responses + tool_call_id?: string // ID of the tool call this message responds to + tool_calls?: ToolCall[] // Tool calls made by assistant + // Provider-specific fields (preserved but not edited) + provider_specific_fields?: Record + annotations?: unknown[] + refusal?: string | null + // Legacy function calling + function_call?: { + name: string + arguments: string + } +} diff --git a/web/packages/agenta-shared/src/types/index.ts b/web/packages/agenta-shared/src/types/index.ts new file mode 100644 index 0000000000..8f0a52135d --- /dev/null +++ b/web/packages/agenta-shared/src/types/index.ts @@ -0,0 +1,14 @@ +/** + * Type definitions for Agenta shared package. + */ + +// Chat message types +export type { + TextContentPart, + ImageContentPart, + FileContentPart, + MessageContentPart, + MessageContent, + ToolCall, + SimpleChatMessage, +} from "./chatMessage" diff --git a/web/packages/agenta-shared/src/utils/chatMessage.ts b/web/packages/agenta-shared/src/utils/chatMessage.ts new file mode 100644 index 0000000000..c819ee7bb9 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/chatMessage.ts @@ -0,0 +1,184 @@ +/** + * Chat Message Utilities + * + * Utility functions for working with chat message content, including + * text extraction, content updates, and attachment management. + */ + +import type { + MessageContent, + TextContentPart, + ImageContentPart, + FileContentPart, + SimpleChatMessage, +} from "../types/chatMessage" + +/** + * Extract text content from a message content value. + * Handles both string content and array content with text parts. + */ +export function extractTextFromContent(content: MessageContent): string { + if (content === null || content === undefined) { + return "" + } + if (typeof content === "string") { + return content + } + if (Array.isArray(content)) { + const textParts = content.filter((part): part is TextContentPart => part.type === "text") + return textParts.map((part) => part.text).join("\n") + } + return "" +} + +/** + * Extract display text from a message, including tool call info if present. + * For assistant messages with tool_calls, shows the function calls. + * For tool messages, shows the response content. + */ +export function extractDisplayTextFromMessage(message: SimpleChatMessage): string { + // If message has content, use it + const contentText = extractTextFromContent(message.content ?? null) + if (contentText) { + return contentText + } + + // For assistant messages with tool_calls but no content, show tool call info + if (message.tool_calls && message.tool_calls.length > 0) { + return message.tool_calls + .map((tc) => `${tc.function.name}(${tc.function.arguments})`) + .join("\n") + } + + // For legacy function_call + if (message.function_call) { + return `${message.function_call.name}(${message.function_call.arguments})` + } + + return "" +} + +/** + * Check if content has attachments (images or files) + */ +export function hasAttachments(content: MessageContent): boolean { + if (typeof content === "string") return false + if (!Array.isArray(content)) return false + return content.some((part) => part.type === "image_url" || part.type === "file") +} + +/** + * Get attachment count from content + */ +export function getAttachmentInfo(content: MessageContent): { + imageCount: number + fileCount: number +} { + if (typeof content === "string" || !Array.isArray(content)) { + return {imageCount: 0, fileCount: 0} + } + const imageCount = content.filter((part) => part.type === "image_url").length + const fileCount = content.filter((part) => part.type === "file").length + return {imageCount, fileCount} +} + +/** + * Update text content while preserving attachments. + * + * @remarks + * If the content array has multiple text parts, ALL text parts will be updated + * to the same `newText` value. This treats multiple text parts as a single + * logical text block. If you need to preserve distinct text parts, handle + * the content array directly. + */ +export function updateTextInContent(content: MessageContent, newText: string): MessageContent { + if (typeof content === "string") { + return newText + } + if (!Array.isArray(content)) { + return newText + } + // Find existing text part or create new one + const hasTextPart = content.some((part) => part.type === "text") + if (hasTextPart) { + return content.map((part) => (part.type === "text" ? {...part, text: newText} : part)) + } + // No text part, add one at the beginning + return [{type: "text", text: newText}, ...content] +} + +/** + * Add an image attachment to message content + */ +export function addImageToContent( + content: MessageContent, + imageUrl: string, + detail: "auto" | "low" | "high" = "auto", +): MessageContent { + const newPart: ImageContentPart = { + type: "image_url", + image_url: {url: imageUrl, detail}, + } + if (typeof content === "string") { + return [{type: "text", text: content}, newPart] + } + if (!Array.isArray(content)) { + return [{type: "text", text: ""}, newPart] + } + return [...content, newPart] +} + +/** + * Add a file attachment to message content + */ +export function addFileToContent( + content: MessageContent, + fileData: string, + filename: string, + format: string, +): MessageContent { + const newPart: FileContentPart = { + type: "file", + file: {file_data: fileData, filename, format}, + } + if (typeof content === "string") { + return [{type: "text", text: content}, newPart] + } + if (!Array.isArray(content)) { + return [{type: "text", text: ""}, newPart] + } + return [...content, newPart] +} + +/** + * Remove an attachment from message content by index + */ +export function removeAttachmentFromContent( + content: MessageContent, + attachmentIndex: number, +): MessageContent { + if (typeof content === "string" || !Array.isArray(content)) { + return content + } + // Find all non-text parts and remove the one at the given index + let nonTextIndex = 0 + return content.filter((part) => { + if (part.type === "text") return true + const keep = nonTextIndex !== attachmentIndex + nonTextIndex++ + return keep + }) +} + +/** + * Get attachments from content + */ +export function getAttachments(content: MessageContent): (ImageContentPart | FileContentPart)[] { + if (typeof content === "string" || !Array.isArray(content)) { + return [] + } + return content.filter( + (part): part is ImageContentPart | FileContentPart => + part.type === "image_url" || part.type === "file", + ) +} diff --git a/web/packages/agenta-shared/src/utils/createBatchFetcher.ts b/web/packages/agenta-shared/src/utils/createBatchFetcher.ts new file mode 100644 index 0000000000..17cc8409e2 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/createBatchFetcher.ts @@ -0,0 +1,195 @@ +/** + * Generic batch fetcher helper that collects individual key requests + * for a short window and executes a single bulk fetch. + * + * This mirrors the helper used in evaluation atoms so other areas + * can reuse the same batching behaviour without duplicating logic. + */ + +export type BatchFetcher = (key: K) => Promise + +export type BatchFnResponse = + | Map + | Map + | Record + | {has: (key: K | string) => boolean; get: (key: K | string) => V | undefined} + +export interface BatchFetcherOptions> { + batchFn: (keys: K[], serializedKeys: string[]) => Promise + serializeKey?: (key: K) => string + resolveResult?: (response: R, key: K, serializedKey: string) => V | undefined + flushDelay?: number + onError?: (error: unknown, keys: K[]) => void + maxBatchSize?: number +} + +interface PendingEntry { + key: K + serializedKey: string + resolvers: ((value: V) => void)[] + rejecters: ((reason?: unknown) => void)[] +} + +const DEFAULT_FLUSH_DELAY = 16 * 5 // approx. one frame at 60Hz + +const defaultSerializeKey = (key: K) => { + if (typeof key === "string" || typeof key === "number" || typeof key === "boolean") { + return String(key) + } + if (key && typeof key === "object") { + return JSON.stringify(key) + } + return String(key) +} + +const defaultResolveResult = >( + response: R, + _key: K, + serializedKey: string, +): V | undefined => { + if (!response) return undefined + if (response instanceof Map) { + // Try both original key type and serialized string key + // Cast needed: Map.has() doesn't accept different key types + if (response.has(serializedKey as K)) { + return response.get(serializedKey as K) + } + // Also try string key for Map + const stringMap = response as Map + if (stringMap.has(serializedKey)) { + return stringMap.get(serializedKey) + } + } + if (typeof (response as Record)[serializedKey] !== "undefined") { + return (response as Record)[serializedKey] + } + // Handle map-like objects with has/get methods + const maybeMapLike = response as { + has?: (key: string) => boolean + get?: (key: string) => V | undefined + } + + if (typeof maybeMapLike?.has === "function" && typeof maybeMapLike?.get === "function") { + return maybeMapLike.has(serializedKey) ? maybeMapLike.get(serializedKey) : undefined + } + return undefined +} + +const chunk = (input: T[], size: number) => { + if (size <= 0) return [input] + const output: T[][] = [] + for (let index = 0; index < input.length; index += size) { + output.push(input.slice(index, index + size)) + } + return output +} + +export const createBatchFetcher = >({ + batchFn, + serializeKey = defaultSerializeKey, + resolveResult = defaultResolveResult, + flushDelay = DEFAULT_FLUSH_DELAY, + onError, + maxBatchSize, +}: BatchFetcherOptions): BatchFetcher => { + let pending = new Map>() + const inflight = new Map>() + let flushTimer: ReturnType | null = null + + const scheduleFlush = () => { + if (flushTimer) return + flushTimer = setTimeout(flushPending, flushDelay) + } + + const runBatch = async (entries: PendingEntry[]) => { + const keys = entries.map((entry) => entry.key) + const serializedKeys = entries.map((entry) => entry.serializedKey) + let response: R + try { + response = await batchFn(keys, serializedKeys) + } catch (error) { + onError?.(error, keys) + entries.forEach((entry) => { + entry.rejecters.forEach((reject) => reject(error)) + }) + return + } + + entries.forEach((entry) => { + try { + const value = resolveResult(response, entry.key, entry.serializedKey) + if (typeof value === "undefined") { + throw new Error( + `Batch fetcher did not receive data for key "${entry.serializedKey}"`, + ) + } + entry.resolvers.forEach((resolve) => resolve(value)) + } catch (error) { + entry.rejecters.forEach((reject) => reject(error)) + } + }) + } + + const flushPending = () => { + flushTimer = null + if (pending.size === 0) { + return + } + + const entries = Array.from(pending.values()) + pending = new Map() + + if (maxBatchSize && maxBatchSize > 0 && entries.length > maxBatchSize) { + const batches = chunk(entries, maxBatchSize) + batches.forEach((batch) => { + void runBatch(batch) + }) + } else { + void runBatch(entries) + } + } + + return (key: K) => { + const serializedKey = serializeKey(key) + const inflightPromise = inflight.get(serializedKey) + if (inflightPromise) { + return inflightPromise + } + + // If a request for this key is already pending (but not yet flushed), + // return a new promise that shares the same resolvers/rejecters array. + // This is intentional: both promises will resolve together when the batch completes. + // Note: This promise is NOT added to `inflight` since only the first request + // for a key gets tracked there (after flush). This is fine because all callers + // share the same pending entry and will be resolved together. + const entry = pending.get(serializedKey) + if (entry) { + return new Promise((resolve, reject) => { + entry.resolvers.push(resolve) + entry.rejecters.push(reject) + }) + } + + const pendingEntry: PendingEntry = { + key, + serializedKey, + resolvers: [], + rejecters: [], + } + pending.set(serializedKey, pendingEntry) + + const promise = new Promise((resolve, reject) => { + pendingEntry.resolvers.push(resolve) + pendingEntry.rejecters.push(reject) + }).finally(() => { + inflight.delete(serializedKey) + }) + inflight.set(serializedKey, promise) + + scheduleFlush() + + return promise + } +} + +export default createBatchFetcher diff --git a/web/packages/agenta-shared/src/utils/createLogger.ts b/web/packages/agenta-shared/src/utils/createLogger.ts new file mode 100644 index 0000000000..8134b77f5a --- /dev/null +++ b/web/packages/agenta-shared/src/utils/createLogger.ts @@ -0,0 +1,81 @@ +/** + * Utility for creating styled console loggers for debugging and development. + * Provides a consistent logging format across components with customizable styling. + */ + +/** + * Configuration options for the logger's visual appearance and behavior. + * + * @property componentColor - CSS color string for the component name (default: cyan) + * @property eventColor - CSS color string for the event name (default: yellow) + * @property disabled - If true, suppresses all logging output + */ +export interface LoggerOptions { + componentColor?: string + eventColor?: string + disabled?: boolean +} + +/** + * Default color scheme for the logger. + * Uses bold, distinctive colors to make different parts of the log easily distinguishable. + */ +const defaultColors: LoggerOptions = { + componentColor: "color: cyan; font-weight: bold;", + eventColor: "color: yellow; font-weight: bold;", +} + +/** + * Creates a styled logger function for a specific component. + * + * The logger provides a consistent format: + * [timestamp] ComponentName - EventName (optional payload) + * + * Example usage: + * ```typescript + * const log = createLogger('SyntaxHighlight') + * log('tokenizing', { line: 1, content: 'code' }) + * // Output: [2025-03-25T16:40:22.123Z] SyntaxHighlight - tokenizing { line: 1, content: 'code' } + * ``` + * + * Features: + * - Consistent timestamp prefix + * - Color-coded component and event names + * - Optional payload logging + * - Can be disabled via options + * - Uses CSS styling for console output + * + * @param componentName - Name of the component using the logger + * @param options - Optional configuration for colors and logging behavior + * @returns A logging function that takes an event name and optional payload + */ +export function createLogger(componentName: string, options?: LoggerOptions) { + // Merge default colors with provided options + const {componentColor, eventColor} = {...defaultColors, ...options} + + // Return a closure that maintains the component context + return (eventName: string, payload?: unknown) => { + if (options?.disabled) return + const timestamp = new Date().toISOString() + if (payload !== undefined) { + // Log with payload + console.log( + `%c[${timestamp}] %c${componentName}%c - %c${eventName}`, + "color: gray;", + componentColor, + "color: inherit;", + eventColor, + payload, + ) + } else { + // Log without payload + console.log( + `%c[${timestamp}] %c${componentName}%c - %c${eventName}`, + "color: gray;", + componentColor, + "color: inherit;", + eventColor, + ) + } + } +} diff --git a/web/packages/agenta-shared/src/utils/dayjs.ts b/web/packages/agenta-shared/src/utils/dayjs.ts new file mode 100644 index 0000000000..a044db15da --- /dev/null +++ b/web/packages/agenta-shared/src/utils/dayjs.ts @@ -0,0 +1,14 @@ +/** + * Dayjs with required plugins for date parsing. + * + * Uses customParseFormat for WebKit browser compatibility. + */ + +import dayjs from "dayjs" +import customParseFormat from "dayjs/plugin/customParseFormat" +import utc from "dayjs/plugin/utc" + +dayjs.extend(customParseFormat) +dayjs.extend(utc) + +export default dayjs diff --git a/web/packages/agenta-shared/src/utils/editorLanguage.ts b/web/packages/agenta-shared/src/utils/editorLanguage.ts new file mode 100644 index 0000000000..7eca99750b --- /dev/null +++ b/web/packages/agenta-shared/src/utils/editorLanguage.ts @@ -0,0 +1,113 @@ +/** + * Editor Language Detection Utilities + * + * Utilities for detecting the appropriate language/mode for code editors + * based on content analysis. + * + * @example + * ```typescript + * import {detectEditorLanguage, getContentLanguage} from '@agenta/shared' + * + * // Detect from value (any type) + * const lang = detectEditorLanguage({key: "value"}) // "json" + * const lang2 = detectEditorLanguage('{"key": "value"}') // "json" + * + * // Detect from string content only + * const lang3 = getContentLanguage('key: value') // "yaml" + * ``` + */ + +import {isJsonString} from "./jsonDetection" + +/** + * Editor language types supported + */ +export type EditorLanguage = "json" | "yaml" | "text" | "markdown" | "code" + +/** + * Detects the appropriate editor language for a given value. + * + * Detection logic: + * - Objects/arrays → json + * - Strings that look like JSON → json + * - Strings with YAML-like structure → yaml + * - All other cases → text + * + * @param value - Any value to analyze + * @returns The detected editor language + * + * @example + * detectEditorLanguage({key: "value"}) // "json" + * detectEditorLanguage([1, 2, 3]) // "json" + * detectEditorLanguage('{"key": "value"}') // "json" + * detectEditorLanguage("key: value") // "yaml" (if has YAML structure) + * detectEditorLanguage("hello world") // "text" + */ +export function detectEditorLanguage(value: unknown): EditorLanguage { + // Objects and arrays are always JSON + if (value !== null && typeof value === "object") { + return "json" + } + + // Analyze string content + if (typeof value === "string") { + return getContentLanguage(value) + } + + // Default for primitives + return "text" +} + +/** + * Detects language from string content only. + * + * This is useful when you already have string content and want to + * determine the best syntax highlighting mode. + * + * @param content - String content to analyze + * @returns "json" | "yaml" | "text" + * + * @example + * getContentLanguage('{"key": "value"}') // "json" + * getContentLanguage('key: value\nother: thing') // "yaml" + * getContentLanguage('hello world') // "text" + */ +export function getContentLanguage(content: string): "json" | "yaml" | "text" { + const trimmed = content.trim() + + if (!trimmed) { + return "text" + } + + // Check for JSON structure first (brackets or braces) + if (isJsonString(trimmed)) { + return "json" + } + + // Check for YAML-like structure + // YAML typically has key: value patterns without JSON brackets + if (trimmed.includes(":") && !trimmed.startsWith("{") && !trimmed.startsWith("[")) { + // Simple heuristic: contains colon with space after (key: value pattern) + if (/^\s*[\w-]+\s*:\s/m.test(trimmed)) { + return "yaml" + } + } + + return "text" +} + +/** + * Checks if a string looks like it contains JSON. + * This is a quick heuristic check that doesn't validate. + * + * @param str - String to check + * @returns true if the string looks like JSON + * + * @example + * looksLikeJson('{"key": "value"}') // true + * looksLikeJson('[1, 2, 3]') // true + * looksLikeJson('hello') // false + * + * @deprecated Use `isJsonString` from jsonDetection instead. This is an alias for backward compatibility. + */ +export const looksLikeJson = isJsonString diff --git a/web/packages/agenta-shared/src/utils/entityTransforms.ts b/web/packages/agenta-shared/src/utils/entityTransforms.ts new file mode 100644 index 0000000000..89bb72fdb7 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/entityTransforms.ts @@ -0,0 +1,132 @@ +/** + * Entity Transform Utilities + * + * Provides date normalization transforms for entity molecules. + * Uses dayjs with customParseFormat plugin for WebKit compatibility. + */ + +import dayjs from "./dayjs" + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Common timestamp fields found in most entities + */ +export interface TimestampFields { + created_at?: string | Date | null + updated_at?: string | Date | null +} + +/** + * A date parser function that converts various date formats to a consistent output + */ +export type DateParser = (date: string | Date | null | undefined) => Date | string | null + +// ============================================================================ +// TRANSFORM FACTORIES +// ============================================================================ + +/** + * Creates a timestamp normalizer using the provided date parser. + * + * This factory pattern allows the package to remain dependency-free + * while consumers can inject their preferred date parsing library. + */ +function createTimestampNormalizer( + parseDate: DateParser, +): (data: T) => T { + return (data: T): T => { + const result = {...data} + + if (data.created_at !== undefined) { + result.created_at = parseDate(data.created_at) as T["created_at"] + } + + if (data.updated_at !== undefined) { + result.updated_at = parseDate(data.updated_at) as T["updated_at"] + } + + return result + } +} + +// ============================================================================ +// DATE PARSING +// ============================================================================ + +/** + * Fallback date formats to try when parsing dates. + * These cover common API response formats. + */ +const FALLBACK_FORMATS = [ + "YYYY-MM-DDTHH:mm:ss.SSSSSSZ", // Python datetime with microseconds + "YYYY-MM-DDTHH:mm:ss.SSSZ", // ISO 8601 with milliseconds + "YYYY-MM-DDTHH:mm:ssZ", // ISO 8601 + "YYYY-MM-DDTHH:mm:ss.SSSSSS", // Python datetime without TZ + "YYYY-MM-DDTHH:mm:ss.SSS", // ISO without TZ + "YYYY-MM-DDTHH:mm:ss", // Basic ISO + "YYYY-MM-DD HH:mm:ss.SSSZ", // Space separator + "YYYY-MM-DD HH:mm:ssZ", + "YYYY-MM-DD HH:mm:ss", +] + +/** + * Parses a date string with WebKit-compatible fallback formats. + * + * @param date - Date string, Date object, null, or undefined + * @returns Parsed Date object or null if invalid + */ +export function parseEntityDate(date: string | Date | null | undefined): Date | null { + if (!date) return null + + if (date instanceof Date) { + return isNaN(date.getTime()) ? null : date + } + + // Try direct parsing first (handles most ISO formats) + const direct = dayjs(date) + if (direct.isValid()) { + return direct.toDate() + } + + // Try fallback formats for edge cases (especially WebKit) + for (const format of FALLBACK_FORMATS) { + const parsed = dayjs(date, format) + if (parsed.isValid()) { + return parsed.toDate() + } + } + + return null +} + +// ============================================================================ +// EXPORTS +// ============================================================================ + +/** + * Normalizes created_at and updated_at fields in entity data. + * Uses dayjs with customParseFormat for WebKit browser compatibility. + * + * @example + * ```typescript + * import { normalizeTimestamps } from '@agenta/shared/utils' + * + * export const testcaseMolecule = createMolecule({ + * name: 'testcase', + * transform: normalizeTimestamps, + * // ... + * }) + * ``` + */ +export const normalizeTimestamps = createTimestampNormalizer(parseEntityDate) + +/** + * Type-safe normalizer for entities with timestamp fields. + * Use this when you need explicit typing. + */ +export function normalizeEntityTimestamps(data: T): T { + return normalizeTimestamps(data) as T +} diff --git a/web/packages/agenta-shared/src/utils/filterItems.ts b/web/packages/agenta-shared/src/utils/filterItems.ts new file mode 100644 index 0000000000..1b2d86a1e0 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/filterItems.ts @@ -0,0 +1,22 @@ +/** + * Filtering utilities for searchable lists. + */ + +export type FilterItemLabel = (item: T) => string + +/** + * Filter a list of items using a search term. + * + * When a label getter is provided, it will be used for filtering. + * Otherwise the function falls back to JSON-stringifying each item. + */ +export function filterItems(items: T[], searchTerm: string, getLabel?: FilterItemLabel): T[] { + if (!searchTerm.trim()) return items + + const term = searchTerm.toLowerCase().trim() + + return items.filter((item) => { + const raw = getLabel ? getLabel(item) : JSON.stringify(item) + return String(raw).toLowerCase().includes(term) + }) +} diff --git a/web/packages/agenta-shared/src/utils/formatEnumLabel.ts b/web/packages/agenta-shared/src/utils/formatEnumLabel.ts new file mode 100644 index 0000000000..485e2164c0 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/formatEnumLabel.ts @@ -0,0 +1,13 @@ +/** + * Format enum values into readable labels. + */ +export function formatEnumLabel(value: unknown): string { + if (typeof value !== "string") return String(value) + + return value + .replace(/_/g, " ") + .replace(/([a-z])([A-Z])/g, "$1 $2") + .split(" ") + .map((word) => word.charAt(0).toUpperCase() + word.slice(1).toLowerCase()) + .join(" ") +} diff --git a/web/packages/agenta-shared/src/utils/formatters/README.md b/web/packages/agenta-shared/src/utils/formatters/README.md new file mode 100644 index 0000000000..ab2a0ddb18 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/formatters/README.md @@ -0,0 +1,176 @@ +# Formatting Utilities + +A comprehensive set of number formatting utilities for displaying values in the UI. + +## Overview + +The formatters module provides: + +- **Preset formatters** for common use cases (numbers, currency, latency, tokens, percentages) +- **`formatSignificant`** for displaying values with 3 significant figures +- **`createFormatter`** factory for building custom formatters + +All formatters handle `null`, `undefined`, and `NaN` gracefully, returning a fallback string (`"-"` by default). + +## Installation + +```typescript +import { + formatNumber, + formatCurrency, + formatLatency, + formatSignificant, + createFormatter, +} from '@agenta/shared' +``` + +## Preset Formatters + +### `formatNumber(value)` + +Formats with locale-aware thousand separators and 2 decimal places. + +```typescript +formatNumber(1234.567) // "1,234.57" +formatNumber(null) // "-" +``` + +### `formatCompact(value)` + +Formats in compact notation (1K, 1M, 1B). + +```typescript +formatCompact(1234) // "1.2K" +formatCompact(1500000) // "1.5M" +``` + +### `formatCurrency(value)` + +Formats as USD currency with up to 6 decimal places. + +```typescript +formatCurrency(1234.56) // "$1,234.56" +formatCurrency(0.00123) // "$0.001230" +``` + +### `formatLatency(value)` + +Formats duration in seconds to human-readable latency. Automatically selects the appropriate unit (μs, ms, s). + +```typescript +formatLatency(0.0001) // "100μs" +formatLatency(0.5) // "500ms" +formatLatency(2.5) // "2.5s" +``` + +### `formatTokens(value)` + +Formats token counts with compact notation for large numbers. + +```typescript +formatTokens(500) // "500" +formatTokens(1500) // "1.5K" +formatTokens(1500000) // "1.5M" +``` + +### `formatPercent(value)` + +Formats a decimal (0-1) as a percentage. + +```typescript +formatPercent(0.856) // "85.60%" +formatPercent(1) // "100%" +formatPercent(0.001) // "0.10%" +``` + +### `formatSignificant(value)` + +Formats with 3 significant figures. Uses scientific notation for very large or very small numbers. + +```typescript +formatSignificant(1234) // "1230" +formatSignificant(0.00456) // "0.00456" +formatSignificant(1.5e12) // "1.50e+12" +formatSignificant(0) // "0" +``` + +## Custom Formatters + +### `createFormatter(options)` + +Creates a custom formatter function with the specified options. + +#### Options + +| Option | Type | Default | Description | +| ------ | ---- | ------- | ----------- | +| `decimals` | `number` | `2` | Number of decimal places | +| `significantFigures` | `number` | - | Use significant figures instead of fixed decimals | +| `prefix` | `string` | `""` | Prefix to prepend (e.g., "$") | +| `suffix` | `string` | `""` | Suffix to append (e.g., "%", "ms") | +| `multiplier` | `number` | `1` | Multiplier to apply before formatting | +| `fallback` | `string` | `"-"` | Fallback for null/undefined/NaN | +| `compact` | `boolean` | `false` | Use compact notation (1K, 1M) | +| `locale` | `boolean` | `false` | Use locale-aware formatting | + +#### Examples + +```typescript +// Score formatter (0-1 to percentage) +const formatScore = createFormatter({ + multiplier: 100, + suffix: '%', + decimals: 1, +}) +formatScore(0.856) // "85.6%" + +// Cost formatter +const formatCost = createFormatter({ + prefix: '$', + decimals: 4, +}) +formatCost(0.0123) // "$0.0123" + +// Duration in milliseconds +const formatMs = createFormatter({ + multiplier: 1000, + suffix: 'ms', + decimals: 0, +}) +formatMs(0.5) // "500ms" + +// Compact with prefix +const formatViews = createFormatter({ + compact: true, + suffix: ' views', +}) +formatViews(1500000) // "1.5M views" +``` + +## Migration from OSS + +If you're migrating from the OSS formatters: + +| Old (OSS) | New (@agenta/shared) | +|-----------|---------------------| +| `format3Sig(value)` | `formatSignificant(value)` | +| `formatCompactNumber(value)` | `formatCompact(value)` | +| `formatTokenUsage(value)` | `formatTokens(value)` | +| Custom `METRIC_FORMATTERS` config | `createFormatter(options)` | + +## Types + +```typescript +interface FormatterOptions { + decimals?: number + significantFigures?: number + prefix?: string + suffix?: string + multiplier?: number + fallback?: string + compact?: boolean + locale?: boolean +} + +type Formatter = (value: number | string | undefined | null) => string +``` diff --git a/web/packages/agenta-shared/src/utils/formatters/formatters.ts b/web/packages/agenta-shared/src/utils/formatters/formatters.ts new file mode 100644 index 0000000000..6781a6004d --- /dev/null +++ b/web/packages/agenta-shared/src/utils/formatters/formatters.ts @@ -0,0 +1,401 @@ +/** + * Formatting Utilities + * + * A comprehensive set of number formatting utilities for displaying values + * in the UI. Provides both preset formatters for common use cases and a + * flexible factory for custom formatting needs. + * + * @example + * ```typescript + * import { + * formatNumber, + * formatCurrency, + * formatLatency, + * formatSignificant, + * createFormatter, + * } from '@agenta/shared' + * + * // Preset formatters + * formatNumber(1234.567) // "1,234.57" + * formatCurrency(0.00123) // "$0.001230" + * formatLatency(0.5) // "500ms" + * formatSignificant(0.00456) // "0.00456" + * + * // Custom formatter + * const formatScore = createFormatter({ suffix: '%', decimals: 1 }) + * formatScore(0.856) // "85.6%" + * ``` + */ + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Configuration options for creating custom formatters + */ +export interface FormatterOptions { + /** Number of decimal places (default: 2) */ + decimals?: number + /** Use significant figures instead of fixed decimals */ + significantFigures?: number + /** Prefix to prepend (e.g., "$") */ + prefix?: string + /** Suffix to append (e.g., "%", "ms") */ + suffix?: string + /** Multiplier to apply before formatting (e.g., 100 for percentages) */ + multiplier?: number + /** Fallback string for null/undefined/NaN values (default: "-") */ + fallback?: string + /** Use compact notation (1K, 1M) */ + compact?: boolean + /** Use locale-aware formatting */ + locale?: boolean +} + +/** + * A formatter function that converts a number to a formatted string + */ +export type Formatter = (value: number | string | undefined | null) => string + +// ============================================================================ +// INTERNAL HELPERS +// ============================================================================ + +/** Cached Intl formatters for performance */ +const intlNumber = new Intl.NumberFormat("en-US", {maximumFractionDigits: 2}) +const intlCompactNumber = new Intl.NumberFormat("en-US", { + notation: "compact", + compactDisplay: "short", + maximumFractionDigits: 1, +}) +const intlCurrency = new Intl.NumberFormat("en-US", { + style: "currency", + currency: "USD", + maximumFractionDigits: 6, +}) + +/** + * Safely handles null, undefined, and NaN values + */ +const withFallback = ( + value: T | undefined | null, + callback: (v: T) => R, + fallback: R = "-" as R, +): R => { + if (value == null || (typeof value === "number" && isNaN(value))) { + return fallback + } + return callback(value) +} + +/** + * Converts a value to a number, returning null if not possible + */ +const toNumber = (value: number | string | undefined | null): number | null => { + if (value == null) return null + if (typeof value === "number") return isNaN(value) ? null : value + const parsed = Number(value) + return isNaN(parsed) ? null : parsed +} + +// ============================================================================ +// CORE FORMATTERS +// ============================================================================ + +/** + * Formats a number with 3 significant figures. + * Uses scientific notation for very large or very small numbers. + * + * @param value - Number or string to format + * @returns Formatted string with 3 significant figures + * + * @example + * ```typescript + * formatSignificant(1234) // "1230" + * formatSignificant(0.00456) // "0.00456" + * formatSignificant(1.5e12) // "1.50e+12" + * formatSignificant(0) // "0" + * ``` + */ +export const formatSignificant = (value: number | string | undefined | null): string => { + const num = toNumber(value) + if (num === null) return "-" + if (!Number.isFinite(num)) return String(num) + + const abs = Math.abs(num) + if (abs === 0) return "0" + + const exponent = Math.floor(Math.log10(abs)) + + // Use scientific notation for extreme values + if (exponent >= 10 || exponent <= -10) { + return num.toExponential(2) + } + + // Fixed-point with 3 significant digits + const decimals = Math.max(0, 2 - exponent) + const fixed = num.toFixed(decimals) + + // Strip trailing zeros and decimal point + return fixed.replace(/\.?0+$/, "") +} + +/** + * Formats a number with locale-aware thousand separators and 2 decimal places. + * + * @example + * ```typescript + * formatNumber(1234.567) // "1,234.57" + * formatNumber(null) // "-" + * ``` + */ +export const formatNumber = (value: number | undefined | null): string => { + return withFallback(value, intlNumber.format) +} + +/** + * Formats a number in compact notation (1K, 1M, 1B). + * + * @example + * ```typescript + * formatCompact(1234) // "1.2K" + * formatCompact(1500000) // "1.5M" + * ``` + */ +export const formatCompact = (value: number | undefined | null): string => { + return withFallback(value, intlCompactNumber.format) +} + +/** + * Formats a number as USD currency with up to 6 decimal places. + * + * @example + * ```typescript + * formatCurrency(1234.56) // "$1,234.56" + * formatCurrency(0.00123) // "$0.001230" + * ``` + */ +export const formatCurrency = (value: number | undefined | null): string => { + return withFallback(value, intlCurrency.format) +} + +/** + * Formats a duration in seconds to human-readable latency. + * Automatically selects appropriate unit (μs, ms, s). + * + * @param value - Duration in seconds + * @returns Formatted latency string + * + * @example + * ```typescript + * formatLatency(0.0001) // "100μs" + * formatLatency(0.5) // "500ms" + * formatLatency(2.5) // "2.5s" + * ``` + */ +export const formatLatency = (value: number | undefined | null): string => { + return withFallback(value, (v) => { + const MS_LIMIT = 1000 + const S_LIMIT = MS_LIMIT * 1000 + const S_TO_US = S_LIMIT + + let resultValue = v * S_TO_US + let unit = "μs" + + if (MS_LIMIT < resultValue && resultValue < S_LIMIT) { + resultValue = Math.round(resultValue / MS_LIMIT) + unit = "ms" + } else if (S_LIMIT <= resultValue) { + resultValue = Math.round((resultValue / S_LIMIT) * 100) / 100 + unit = "s" + } else { + resultValue = Math.round(resultValue) + } + + return `${resultValue}${unit}` + }) +} + +/** + * Formats token counts with compact notation for large numbers. + * + * @example + * ```typescript + * formatTokens(500) // "500" + * formatTokens(1500) // "1.5K" + * formatTokens(1500000) // "1.5M" + * ``` + */ +export const formatTokens = (value: number | undefined | null): string => { + return withFallback(value, (v) => { + if (v < 1000) return Math.round(v).toLocaleString() + if (v < 1_000_000) return `${(v / 1000).toFixed(1)}K` + return `${(v / 1_000_000).toFixed(1)}M` + }) +} + +/** + * Formats a decimal as a percentage. + * + * @param value - Decimal value (0.5 = 50%) + * @returns Formatted percentage string + * + * @remarks + * - Negative values are treated as 0% (use case: scores/metrics that shouldn't be negative) + * - Values >= 99.95% are rounded to "100%" + * - Values >= 10% show 1 decimal place + * - Values < 10% show 2 decimal places + * + * @example + * ```typescript + * formatPercent(0.856) // "85.6%" + * formatPercent(1) // "100%" + * formatPercent(0.001) // "0.10%" + * formatPercent(-0.1) // "0%" (negative values treated as 0) + * ``` + */ +export const formatPercent = (value: number | undefined | null): string => { + return withFallback(value, (v) => { + const percent = v * 100 + if (!Number.isFinite(percent) || percent <= 0) return "0%" + if (percent >= 99.95) return "100%" + if (percent >= 10) return `${percent.toFixed(1)}%` + return `${percent.toFixed(2)}%` + }) +} + +// ============================================================================ +// FORMATTER FACTORY +// ============================================================================ + +/** + * Creates a custom formatter function with the specified options. + * + * @param options - Formatting configuration + * @returns A formatter function + * + * @example + * ```typescript + * // Score formatter (0-1 to percentage) + * const formatScore = createFormatter({ + * multiplier: 100, + * suffix: '%', + * decimals: 1, + * }) + * formatScore(0.856) // "85.6%" + * + * // Cost formatter + * const formatCost = createFormatter({ + * prefix: '$', + * decimals: 4, + * }) + * formatCost(0.0123) // "$0.0123" + * + * // Duration in ms + * const formatMs = createFormatter({ + * multiplier: 1000, + * suffix: 'ms', + * decimals: 0, + * }) + * formatMs(0.5) // "500ms" + * ``` + */ +export const createFormatter = (options: FormatterOptions = {}): Formatter => { + const { + decimals = 2, + significantFigures, + prefix = "", + suffix = "", + multiplier = 1, + fallback = "-", + compact = false, + locale = false, + } = options + + return (value: number | string | undefined | null): string => { + const num = toNumber(value) + if (num === null) return fallback + + const adjusted = num * multiplier + + let formatted: string + if (significantFigures) { + // Use significant figures formatting + formatted = formatSignificant(adjusted) + } else if (compact) { + // Use compact notation + formatted = formatCompact(adjusted) + } else if (locale) { + // Use locale-aware formatting + formatted = adjusted.toLocaleString("en-US", { + minimumFractionDigits: decimals, + maximumFractionDigits: decimals, + }) + } else { + // Simple fixed decimal formatting + formatted = adjusted.toFixed(decimals) + } + + return `${prefix}${formatted}${suffix}` + } +} + +// ============================================================================ +// VALUE PREVIEW FORMATTERS +// ============================================================================ + +/** + * Formats an unknown value for display preview. + * Truncates long strings and provides type-aware formatting for objects/arrays. + * + * @param value - Any value to format for preview + * @param maxLength - Maximum length for string values (default: 50) + * @returns Formatted preview string + * + * @example + * ```typescript + * formatPreviewValue("hello") // '"hello"' + * formatPreviewValue("very long string...", 10) // '"very long..."' + * formatPreviewValue(123) // "123" + * formatPreviewValue(true) // "true" + * formatPreviewValue([1, 2, 3]) // "[Array(3)]" + * formatPreviewValue({a: 1, b: 2}) // "{a, b}" + * formatPreviewValue(null) // "(null)" + * formatPreviewValue(undefined) // "(undefined)" + * ``` + */ +export const formatPreviewValue = (value: unknown, maxLength = 50): string => { + if (value === undefined) return "(undefined)" + if (value === null) return "(null)" + if (typeof value === "string") { + if (value.length > maxLength) { + return `"${value.slice(0, maxLength)}..."` + } + return `"${value}"` + } + if (typeof value === "number" || typeof value === "boolean") { + return String(value) + } + if (Array.isArray(value)) { + return `[Array(${value.length})]` + } + if (typeof value === "object") { + const keys = Object.keys(value) + if (keys.length <= 3) { + return `{${keys.join(", ")}}` + } + return `{${keys.slice(0, 3).join(", ")}...}` + } + return String(value) +} + +// ============================================================================ +// LEGACY ALIASES (for backward compatibility) +// ============================================================================ + +/** @deprecated Use formatCompact instead */ +export const formatCompactNumber = formatCompact + +/** @deprecated Use formatTokens instead */ +export const formatTokenUsage = formatTokens diff --git a/web/packages/agenta-shared/src/utils/formatters/index.ts b/web/packages/agenta-shared/src/utils/formatters/index.ts new file mode 100644 index 0000000000..4672ea0436 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/formatters/index.ts @@ -0,0 +1,21 @@ +/** + * Formatting Utilities + * + * Re-exports all formatting utilities from this module. + */ + +export { + formatNumber, + formatCompact, + formatCompactNumber, + formatCurrency, + formatLatency, + formatTokens, + formatTokenUsage, + formatPercent, + formatSignificant, + formatPreviewValue, + createFormatter, +} from "./formatters" + +export type {FormatterOptions, Formatter} from "./formatters" diff --git a/web/packages/agenta-shared/src/utils/index.ts b/web/packages/agenta-shared/src/utils/index.ts new file mode 100644 index 0000000000..27fb323f7f --- /dev/null +++ b/web/packages/agenta-shared/src/utils/index.ts @@ -0,0 +1,135 @@ +/** + * Utility functions for Agenta packages. + */ + +export {isValidHttpUrl, isValidRegex, isValidUUID, validateUUID} from "./validators" +export {createBatchFetcher} from "./createBatchFetcher" +export type {BatchFetcher, BatchFetcherOptions, BatchFnResponse} from "./createBatchFetcher" + +// Filtering utilities +export {filterItems} from "./filterItems" +export type {FilterItemLabel} from "./filterItems" + +// Date/time utilities +export {default as dayjs} from "./dayjs" +export {normalizeTimestamps, normalizeEntityTimestamps, parseEntityDate} from "./entityTransforms" + +// Path utilities for nested data navigation +export { + getValueAtPath, + getValueAtStringPath, + setValueAtPath, + deleteValueAtPath, + hasValueAtPath, + isExpandable, + getValueType, + getChildCount, + getItemsAtPath, + parsePath, + pathToString, + getParentPath, + getLastSegment, + isChildPath, + collectPaths, + // Typed path utilities for UI selection components + extractTypedPaths, + combineTypedPaths, + buildTestcaseColumnPaths, +} from "./pathUtils" +export type { + PathSegment, + DataPath, + PathItem, + TypedPathInfo, + ExtractTypedPathsOptions, +} from "./pathUtils" + +// Chat message utilities +export { + extractTextFromContent, + extractDisplayTextFromMessage, + hasAttachments, + getAttachmentInfo, + updateTextInContent, + addImageToContent, + addFileToContent, + removeAttachmentFromContent, + getAttachments, +} from "./chatMessage" + +// Logger utilities +export {createLogger} from "./createLogger" +export type {LoggerOptions} from "./createLogger" + +// JSON parsing utilities +export {tryParsePartialJson, safeJson5Parse} from "./jsonParsing" + +// Key path utilities +export {keyToString, stringToKeyPath} from "./keyUtils" + +// JSON detection utilities +export { + isPlainObject, + isJsonString, + isJsonObject, + isJsonArray, + tryParseJson, + tryParseAsObject, + tryParseAsArray, + canExpandAsJson, + tryParseJsonValue, +} from "./jsonDetection" +export type {JsonParseResult} from "./jsonDetection" + +// Editor language detection utilities +export { + detectEditorLanguage, + getContentLanguage, + looksLikeJson, + type EditorLanguage, +} from "./editorLanguage" + +// OpenAPI schema utilities +export {dereferenceSchema, type DereferencedSchemaResult} from "./openapi" + +// Formatting utilities +export { + formatNumber, + formatCompact, + formatCompactNumber, // deprecated alias + formatCurrency, + formatLatency, + formatTokens, + formatTokenUsage, // deprecated alias + formatPercent, + formatSignificant, + formatPreviewValue, + createFormatter, +} from "./formatters/index" +export type {FormatterOptions, Formatter} from "./formatters/index" + +// Enum label utilities +export {formatEnumLabel} from "./formatEnumLabel" + +// Schema options utilities +export {getOptionsFromSchema} from "./schemaOptions" +export type {OptionGroup} from "./schemaOptions" + +// Pluralization utilities +export {pluralize, formatCount} from "./pluralize" + +// Mapping utilities for input/output mappings +export { + determineMappingStatus, + getMappingStatusConfig, + isMappingError, + isMappingWarning, + isMappingComplete, + validateMappings, +} from "./mappingUtils" +export type { + MappingStatus, + MappingStatusConfig, + MappingLike, + MappingValidationResult, +} from "./mappingUtils" diff --git a/web/packages/agenta-shared/src/utils/jsonDetection.ts b/web/packages/agenta-shared/src/utils/jsonDetection.ts new file mode 100644 index 0000000000..eebd96b058 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/jsonDetection.ts @@ -0,0 +1,213 @@ +/** + * JSON detection utilities for checking and parsing JSON strings. + * + * These utilities are useful for: + * - Detecting if a string looks like JSON (fast, heuristic-based) + * - Validating if a string is valid JSON (slower, parse-based) + * - Determining JSON structure (object vs array) + * - Type guards for plain objects + */ + +// ============================================================================ +// TYPE GUARDS +// ============================================================================ + +/** + * Check if a value is a plain object (not array, null, or primitive). + * This is a fundamental type guard used throughout the codebase. + * + * @param value - The value to check + * @returns true if value is a plain object + * + * @example + * isPlainObject({a: 1}) // true + * isPlainObject([1, 2]) // false (is array) + * isPlainObject(null) // false + * isPlainObject('string') // false + */ +export function isPlainObject(value: unknown): value is Record { + return value !== null && typeof value === "object" && !Array.isArray(value) +} + +// ============================================================================ +// JSON STRING DETECTION +// ============================================================================ + +/** + * Checks if a string looks like JSON (starts and ends with { } or [ ]). + * This is a fast heuristic check that doesn't validate the JSON. + * + * @param str - The string to check + * @returns true if the string looks like JSON + * + * @example + * isJsonString('{"a": 1}') // true + * isJsonString('[1, 2, 3]') // true + * isJsonString('hello') // false + */ +export function isJsonString(str: string): boolean { + if (typeof str !== "string") return false + const trimmed = str.trim() + return ( + (trimmed.startsWith("{") && trimmed.endsWith("}")) || + (trimmed.startsWith("[") && trimmed.endsWith("]")) + ) +} + +/** + * Checks if a string is a valid JSON object (not array). + * This actually parses the JSON to validate. + * + * @param str - The string to check + * @returns true if the string is valid JSON and parses to an object + * + * @example + * isJsonObject('{"a": 1}') // true + * isJsonObject('[1, 2]') // false (is array) + * isJsonObject('invalid') // false + */ +export function isJsonObject(str: string): boolean { + return tryParseAsObject(str) !== null +} + +/** + * Checks if a string is a valid JSON array. + * This actually parses the JSON to validate. + * + * @param str - The string to check + * @returns true if the string is valid JSON and parses to an array + * + * @example + * isJsonArray('[1, 2, 3]') // true + * isJsonArray('{"a": 1}') // false (is object) + * isJsonArray('invalid') // false + */ +export function isJsonArray(str: string): boolean { + return tryParseAsArray(str) !== null +} + +/** + * Attempts to parse a string as JSON. + * + * @param str - The string to parse + * @returns The parsed value, or null if parsing fails + * + * @example + * tryParseJson('{"a": 1}') // { a: 1 } + * tryParseJson('invalid') // null + */ +export function tryParseJson(str: string): T | null { + if (typeof str !== "string") return null + try { + return JSON.parse(str) as T + } catch { + return null + } +} + +/** + * Try to parse a value as a plain object (not array). + * Returns the parsed object or null if parsing fails or result is not an object. + * + * @param value - The string to parse + * @returns The parsed object, or null if invalid + * + * @example + * tryParseAsObject('{"a": 1}') // { a: 1 } + * tryParseAsObject('[1, 2]') // null (is array) + * tryParseAsObject('invalid') // null + */ +export function tryParseAsObject(value: string): Record | null { + if (!value || typeof value !== "string" || !value.trim()) return null + try { + const parsed = JSON.parse(value) + if (parsed && typeof parsed === "object" && !Array.isArray(parsed)) { + return parsed as Record + } + } catch { + // Not valid JSON + } + return null +} + +/** + * Try to parse a value as an array. + * Returns the parsed array or null if parsing fails or result is not an array. + * + * @param value - The string to parse + * @returns The parsed array, or null if invalid + * + * @example + * tryParseAsArray('[1, 2, 3]') // [1, 2, 3] + * tryParseAsArray('{"a": 1}') // null (is object) + * tryParseAsArray('invalid') // null + */ +export function tryParseAsArray(value: string): unknown[] | null { + if (!value || typeof value !== "string" || !value.trim()) return null + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) { + return parsed + } + } catch { + // Not valid JSON + } + return null +} + +/** + * Checks if a value can be expanded as JSON (is a string that looks like JSON). + * Useful for determining if a cell/field should show an "expand" button. + * + * @param value - Any value to check + * @returns true if the value is a string that looks like JSON + * + * @example + * canExpandAsJson('{"a": 1}') // true + * canExpandAsJson({a: 1}) // false (already an object) + * canExpandAsJson('hello') // false + */ +export function canExpandAsJson(value: unknown): boolean { + return typeof value === "string" && isJsonString(value) +} + +/** + * Result type for tryParseJsonValue + */ +export interface JsonParseResult { + /** The parsed value (or original value if not a JSON string) */ + parsed: unknown + /** Whether the value is JSON (object/array or valid JSON string) */ + isJson: boolean +} + +/** + * Try to parse any value as JSON, returning both the result and whether it's JSON. + * Handles objects, arrays, and JSON strings uniformly. + * + * @param value - Any value to check/parse + * @returns Object with parsed value and isJson flag + * + * @example + * tryParseJsonValue({a: 1}) // { parsed: {a: 1}, isJson: true } + * tryParseJsonValue('{"a": 1}') // { parsed: {a: 1}, isJson: true } + * tryParseJsonValue('hello') // { parsed: 'hello', isJson: false } + * tryParseJsonValue(null) // { parsed: null, isJson: false } + */ +export function tryParseJsonValue(value: unknown): JsonParseResult { + if (value === null || value === undefined) { + return {parsed: value, isJson: false} + } + // Already an object/array + if (typeof value === "object") { + return {parsed: value, isJson: true} + } + // Try to parse string as JSON + if (typeof value === "string" && isJsonString(value)) { + const parsed = tryParseJson(value) + if (parsed !== null) { + return {parsed, isJson: true} + } + } + return {parsed: value, isJson: false} +} diff --git a/web/packages/agenta-shared/src/utils/jsonParsing.ts b/web/packages/agenta-shared/src/utils/jsonParsing.ts new file mode 100644 index 0000000000..ec5543f6d8 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/jsonParsing.ts @@ -0,0 +1,389 @@ +// Pure utility version for robust partial JSON parsing + +import JSON5 from "json5" +import {jsonrepair} from "jsonrepair" + +import {createLogger} from "./createLogger" + +/** + * Attempts to parse a string containing partial or malformed JSON and extract all valid key-value pairs. + * + * This function is designed to be robust against incomplete, malformed, or user-edited JSON, + * such as those encountered in live editors where users may leave trailing commas, missing values, or + * incomplete structures. It recovers as many valid pairs as possible, skipping invalid or incomplete ones. + * + * Supported features: + * - Ignores incomplete key-value pairs, missing colons, or values. + * - Handles nested objects and arrays. + * - Recovers from missing commas and trailing pairs. + * - Returns an object containing only valid pairs, or null if none found or input is invalid. + * + * @param input - The JSON string or object to parse. If an object is given, it is returned as-is. + * @returns An object containing all valid key-value pairs, or null if none are found or input is invalid. + * + * Example: + * tryParsePartialJson('{"a": 1, "b": }') // returns { a: 1 } + * tryParsePartialJson('{"a": 1, "b": 2, "": 3}') // returns { a: 1, b: 2 } + */ +const log = createLogger("tryParsePartialJson", { + disabled: true, +}) + +export function tryParsePartialJson(input: unknown): unknown | null { + // If input is already an object, return it directly + if (typeof input !== "string") { + if (typeof input === "object" && input !== null) { + return input + } + return null + } + + // Clean invisibles only. Use jsonrepair for tolerant fixes (quote delimiters, + // trailing commas, etc.) while preserving Unicode inside string contents. + const removeInvisibles = (str: string) => str.replace(/[\u200B-\u200D\uFEFF\u00A0]/g, "") + const cleanedInput = removeInvisibles(input) + + // FIRST: Try standard JSON.parse to preserve original key ordering + try { + const parsed = JSON.parse(cleanedInput.trim()) + log( + "[tryParsePartialJson] Successfully parsed with standard JSON.parse, preserving key order", + ) + return parsed + } catch (e) { + log( + "[tryParsePartialJson] Standard JSON.parse failed, trying common fixes:", + (e as Error).message, + ) + } + + // Try jsonrepair to broadly fix malformed JSON while preserving content + try { + const repaired = jsonrepair(cleanedInput.trim()) + const parsed = JSON.parse(repaired) + log("[tryParsePartialJson] Successfully parsed after jsonrepair") + return parsed + } catch (e) { + log( + "[tryParsePartialJson] jsonrepair parse failed, falling back to heuristics:", + (e as Error).message, + ) + } + + // SECOND: Try fixing common JSON issues before falling back to manual parsing + const commonFixes = [ + // Remove trailing commas (most common issue) + (str: string) => str.replace(/,\s*([}\]])/g, "$1"), + // Remove trailing comma at end of object/array + (str: string) => str.replace(/,\s*$/, ""), + // Fix missing quotes around keys (basic case) + (str: string) => str.replace(/(\w+)\s*:/g, '"$1":'), + ] + + for (const fix of commonFixes) { + try { + const fixedInput = fix(cleanedInput.trim()) + const parsed = JSON.parse(fixedInput) + log( + "[tryParsePartialJson] Successfully parsed after applying common fixes, preserving key order", + ) + return parsed + } catch (e) { + // Continue to next fix + log("[tryParsePartialJson] Fix attempt failed:", (e as Error).message) + } + } + + // Remove outer braces if present for easier parsing, so we can focus on key-value pairs + let body = cleanedInput.trim() + if (body.startsWith("{") && body.endsWith("}")) { + body = body.slice(1, -1) + } + + /** + * Helper: Attempts to parse a value (string, object, array, literal) from a given position. + * Returns a tuple [valueString, nextIndex] or null if incomplete. + * + * Handles: + * - Strings (including escaped quotes) + * - Objects (nested braces) + * - Arrays (nested brackets) + * - Literals (true, false, null, numbers) + */ + function parseValue(str: string, start: number): [string, number] | null { + let i = start + // Skip whitespace + while (str[i] === " " || str[i] === "\n" || str[i] === "\t") i++ + if (str[i] === '"') { + // Parse quoted string value, handling escaped quotes + let end = i + 1 + let escaped = false + while (end < str.length) { + if (!escaped && str[end] === '"') break + if (str[end] === "\\") escaped = !escaped + else escaped = false + end++ + } + if (end < str.length && str[end] === '"') { + return [str.slice(i, end + 1), end + 1] + } + // If string is incomplete, return null + return null // incomplete string + } + // Parse object + if (str[i] === "{") { + // Parse nested object value by tracking brace depth + let depth = 1, + end = i + 1 + while (end < str.length && depth > 0) { + if (str[end] === "{") depth++ + if (str[end] === "}") depth-- + end++ + } + if (depth === 0) { + return [str.slice(i, end), end] + } + return null // incomplete object + } + // Parse array + if (str[i] === "[") { + // Parse array value by tracking bracket depth + let depth = 1, + end = i + 1 + while (end < str.length && depth > 0) { + if (str[end] === "[") depth++ + if (str[end] === "]") depth-- + end++ + } + if (depth === 0) { + return [str.slice(i, end), end] + } + return null // incomplete array + } + // Parse literal (true, false, null, number) + // Parse literal: true, false, null, or number + const litMatch = /^(true|false|null|-?\d+(?:\.\d+)?)/.exec(str.slice(i)) + if (litMatch) { + return [litMatch[1], i + litMatch[1].length] + } + // If nothing matches, return null + return null + } + + /** + * Helper: Skips to the next comma or quote boundary in the input. + * Used for recovery when encountering malformed JSON. + * + * @returns An object with: + * - newIndex: The updated position after skipping + * - shouldBreak: If true, the main loop should break (no boundary found) + */ + function skipToBoundary(startIdx: number): {newIndex: number; shouldBreak: boolean} { + let idx = startIdx + while (idx < body.length && body[idx] !== "," && body[idx] !== '"') { + idx++ + } + if (idx >= body.length) { + return {newIndex: idx, shouldBreak: true} + } + if (body[idx] === '"') { + return {newIndex: idx, shouldBreak: false} + } + // body[idx] === "," + return {newIndex: idx + 1, shouldBreak: false} + } + + // --- Main Parsing Loop --- + // Iterates through the input, extracting valid key-value pairs and skipping invalid/incomplete ones. + const result: string[] = [] + let i = 0 + while (i < body.length) { + // Skip whitespace and commas + while ( + i < body.length && + (body[i] === "," || body[i] === " " || body[i] === "\n" || body[i] === "\t") + ) + i++ + if (i >= body.length) break + // Parse key + // If the current character is not a quote, it's not a valid key start; skip to next candidate + /* + * Key Detection: + * If the current character is not a quote, it's not a valid key start. + * The parser skips ahead to the next comma or quote, ensuring it doesn't get stuck on malformed input. + */ + if (body[i] !== '"') { + log(`[tryParsePartialJson] Skipping non-key at i=${i}, char='${body[i]}'`) + const {newIndex, shouldBreak} = skipToBoundary(i) + i = newIndex + if (shouldBreak) break + continue + } + // Parse the key (quoted string), handling escaped quotes + /* + * Key Parsing: + * Looks for the end of the quoted key, handling escaped quotes. + * If the closing quote is not found, the key is incomplete and will be skipped. + */ + let keyEnd = i + 1 + let escaped = false + while (keyEnd < body.length) { + if (!escaped && body[keyEnd] === '"') break + if (body[keyEnd] === "\\") escaped = !escaped + else escaped = false + keyEnd++ + } + // If we couldn't find a closing quote, skip this incomplete key + if (keyEnd >= body.length || body[keyEnd] !== '"') { + log(`[tryParsePartialJson] Incomplete key at i=${i}, keyEnd=${keyEnd}`) + const {newIndex, shouldBreak} = skipToBoundary(i) + i = newIndex + if (shouldBreak) break + continue + } + const key = body.slice(i, keyEnd + 1) + log(`[tryParsePartialJson] Found key: ${key} at i=${i}`) + // Skip empty string keys ("") + /* + * Empty Key Handling: + * If the key is an empty string (""), it is skipped. + * The parser advances to the next plausible key/value boundary. + */ + if (key === '""') { + log(`[tryParsePartialJson] Skipping empty key at i=${i}`) + const {newIndex, shouldBreak} = skipToBoundary(keyEnd + 1) + i = newIndex + if (shouldBreak) break + continue + } + i = keyEnd + 1 + // Skip whitespace after key + while (i < body.length && (body[i] === " " || body[i] === "\n" || body[i] === "\t")) i++ + // Expect colon after key + /* + * Colon Expectation: + * After a key, a colon is expected. If not found, this is a malformed pair. + * The parser skips to the next comma or quote to recover. + */ + if (body[i] !== ":") { + log(`[tryParsePartialJson] Missing colon after key at i=${i}, char='${body[i]}'`) + const {newIndex, shouldBreak} = skipToBoundary(i) + i = newIndex + if (shouldBreak) break + continue + } + i++ // skip colon + // Skip whitespace after colon + while (i < body.length && (body[i] === " " || body[i] === "\n" || body[i] === "\t")) i++ + // Defensive: If the next character is a quote and what follows is a new key (quoted string + colon), + // treat the previous key as incomplete and skip it. + if (body[i] === '"') { + let lookahead = i + 1 + let esc = false + while (lookahead < body.length) { + if (!esc && body[lookahead] === '"') break + if (body[lookahead] === "\\") esc = !esc + else esc = false + lookahead++ + } + // lookahead now points to closing quote + let afterQuote = lookahead + 1 + while ( + afterQuote < body.length && + (body[afterQuote] === " " || body[afterQuote] === "\n" || body[afterQuote] === "\t") + ) + afterQuote++ + if (body[afterQuote] === ":") { + // This is a new key, so skip current key as incomplete value + const {newIndex, shouldBreak} = skipToBoundary(i) + i = newIndex + if (shouldBreak) break + continue + } + } + // Parse value for the key + /* + * Value Parsing: + * Attempts to parse a value (string, object, array, literal) using the helper. + * If successful, the value is validated and added to the result set. + * If not, the parser skips to the next plausible boundary. + */ + const valueParsed = parseValue(body, i) + if (valueParsed) { + const [valueStr, nextIdx] = valueParsed + log(`[tryParsePartialJson] Found value: ${valueStr} for key ${key} at i=${i}`) + // Check if value itself is a valid JSON + try { + const parsedValue = JSON.parse(valueStr) + // Stringify value correctly (e.g. numbers, booleans, null without quotes) + result.push( + `${key}: ${typeof parsedValue === "string" ? JSON.stringify(parsedValue) : valueStr}`, + ) + log(`[tryParsePartialJson] Added pair: ${key}: ${valueStr}`) + } catch { + log(`[tryParsePartialJson] Invalid value for key ${key}: ${valueStr}`) + // skip invalid value + } + // Advance i to next key or end of string after a valid pair + i = nextIdx + while (i < body.length && (body[i] === " " || body[i] === "\n" || body[i] === "\t")) i++ + // If the next character is a comma, skip it + if (i < body.length && body[i] === ",") { + i++ + } else if (i < body.length && body[i] !== '"') { + // If not a comma or a quote (next key), check if it's the end; if not, break + // This handles the case where input ends after a valid pair without a comma + break + } + // Otherwise, loop will handle non-key chars + } else { + /* + * Incomplete or Missing Value Recovery: + * If a value could not be parsed, the parser skips ahead to the next comma or quote, + * ensuring it does not get stuck on malformed or trailing pairs. + */ + log(`[tryParsePartialJson] Incomplete value after key at i=${i}`) + const {newIndex, shouldBreak} = skipToBoundary(i) + i = newIndex + if (shouldBreak) break + continue + } + } + + // --- Finalization --- + // Always try to return valid pairs, even if incomplete pairs were encountered + const filteredResult = result.filter(Boolean) + log("[tryParsePartialJson] Filtered result array:", filteredResult) + if (filteredResult.length > 0) { + const jsonString = `{${filteredResult.join(",")}}` + try { + // Reconstruct a valid JSON string from valid pairs + log("[tryParsePartialJson] Final constructed string:", jsonString) + const parsed = JSON.parse(jsonString) + log("[tryParsePartialJson] Parsed result:", parsed) + return parsed + } catch { + // If the reconstructed string is invalid, return null + log("[tryParsePartialJson] Final parse error, String was:", jsonString) + return null + } + } + // If no valid pairs found, return null + return null +} + +/** + * Safely parse JSON5 string with error handling. + * JSON5 allows trailing commas, unquoted keys, comments, etc. + * Falls back to tryParsePartialJson for more tolerant parsing. + * + * @param input - The string to parse + * @returns The parsed value or null if parsing fails + */ +export function safeJson5Parse(input: string): unknown | null { + try { + return JSON5.parse(input) + } catch { + return tryParsePartialJson(input) + } +} diff --git a/web/packages/agenta-shared/src/utils/keyUtils.ts b/web/packages/agenta-shared/src/utils/keyUtils.ts new file mode 100644 index 0000000000..240ccd681f --- /dev/null +++ b/web/packages/agenta-shared/src/utils/keyUtils.ts @@ -0,0 +1,35 @@ +/** + * Key path utilities for handling string or array key representations. + */ + +/** + * Converts a key (string or string array) to a dot-notation string. + * + * @param key - A key that can be a string, array of strings, or undefined + * @returns A string representation of the key path, or empty string if undefined + * + * @example + * keyToString("name") // returns "name" + * keyToString(["user", "profile", "name"]) // returns "user.profile.name" + * keyToString(undefined) // returns "" + */ +export function keyToString(key: string | string[] | undefined): string { + if (!key) return "" + return Array.isArray(key) ? key.join(".") : key +} + +/** + * Converts a dot-notation string to a key path array. + * + * @param str - A dot-notation string path + * @returns An array of path segments + * + * @example + * stringToKeyPath("user.profile.name") // returns ["user", "profile", "name"] + * stringToKeyPath("name") // returns ["name"] + * stringToKeyPath("") // returns [] + */ +export function stringToKeyPath(str: string): string[] { + if (!str) return [] + return str.split(".") +} diff --git a/web/packages/agenta-shared/src/utils/mappingUtils.ts b/web/packages/agenta-shared/src/utils/mappingUtils.ts new file mode 100644 index 0000000000..067c4759c3 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/mappingUtils.ts @@ -0,0 +1,212 @@ +/** + * Mapping Utilities + * + * Pure utility functions for determining mapping status. + * These utilities are framework-agnostic and can be used across packages. + */ + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Possible mapping status values + */ +export type MappingStatus = + | "auto" + | "manual" + | "missing" + | "invalid_path" + | "type_mismatch" + | "optional" + +/** + * Status configuration for UI rendering + */ +export interface MappingStatusConfig { + status: MappingStatus + color: "red" | "orange" | "blue" | "green" | "gray" + label: string + severity: "error" | "warning" | "info" | "success" | "default" +} + +/** + * Input mapping shape for status determination + */ +export interface MappingLike { + isAutoMapped?: boolean + status?: string +} + +// ============================================================================ +// STATUS DETERMINATION +// ============================================================================ + +/** + * Determine the mapping status based on mapping data and requirements + * + * @param mapping - The mapping object (may be undefined if unmapped) + * @param isRequired - Whether this mapping is required + * @returns The determined mapping status + * + * @example + * ```ts + * const status = determineMappingStatus(mapping, true) + * // Returns: 'auto' | 'manual' | 'missing' | 'invalid_path' | 'type_mismatch' | 'optional' + * ``` + */ +export function determineMappingStatus( + mapping: MappingLike | undefined, + isRequired: boolean, +): MappingStatus { + if (!mapping) { + return isRequired ? "missing" : "optional" + } + + if (mapping.status === "missing_source") { + return "invalid_path" + } + + if (mapping.status === "type_mismatch") { + return "type_mismatch" + } + + if (mapping.isAutoMapped) { + return "auto" + } + + return "manual" +} + +/** + * Get the full status configuration for a mapping status + * + * @param status - The mapping status + * @returns Configuration object with color, label, and severity + * + * @example + * ```ts + * const config = getMappingStatusConfig('auto') + * // Returns: { status: 'auto', color: 'blue', label: 'Auto', severity: 'info' } + * ``` + */ +export function getMappingStatusConfig(status: MappingStatus): MappingStatusConfig { + switch (status) { + case "auto": + return {status, color: "blue", label: "Auto", severity: "info"} + case "manual": + return {status, color: "green", label: "Manual", severity: "success"} + case "missing": + return {status, color: "red", label: "Missing", severity: "error"} + case "invalid_path": + return {status, color: "red", label: "Invalid Path", severity: "error"} + case "type_mismatch": + return {status, color: "orange", label: "Type Mismatch", severity: "warning"} + case "optional": + return {status, color: "gray", label: "Optional", severity: "default"} + default: + return {status: "optional", color: "gray", label: "Unknown", severity: "default"} + } +} + +/** + * Check if a mapping status indicates an error + */ +export function isMappingError(status: MappingStatus): boolean { + return status === "missing" || status === "invalid_path" +} + +/** + * Check if a mapping status indicates a warning + */ +export function isMappingWarning(status: MappingStatus): boolean { + return status === "type_mismatch" +} + +/** + * Check if a mapping is complete (either auto or manual) + */ +export function isMappingComplete(status: MappingStatus): boolean { + return status === "auto" || status === "manual" +} + +// ============================================================================ +// VALIDATION +// ============================================================================ + +/** + * Validation result for a set of mappings + */ +export interface MappingValidationResult { + isValid: boolean + isComplete: boolean + totalMappings: number + requiredMappings: number + completeMappings: number + errorCount: number + warningCount: number + errors: {key: string; status: MappingStatus}[] + warnings: {key: string; status: MappingStatus}[] +} + +/** + * Validate a set of mappings against required keys + * + * @param mappings - Record of key to mapping object + * @param requiredKeys - Array of keys that must be mapped + * @returns Validation result with counts and error details + * + * @example + * ```ts + * const result = validateMappings( + * { input1: { isAutoMapped: true }, input2: undefined }, + * ['input1', 'input2'] + * ) + * // Returns: { isValid: false, isComplete: false, errorCount: 1, ... } + * ``` + */ +export function validateMappings( + mappings: Record, + requiredKeys: string[], +): MappingValidationResult { + const errors: {key: string; status: MappingStatus}[] = [] + const warnings: {key: string; status: MappingStatus}[] = [] + let completeMappings = 0 + + const requiredSet = new Set(requiredKeys) + + for (const [key, mapping] of Object.entries(mappings)) { + const isRequired = requiredSet.has(key) + const status = determineMappingStatus(mapping, isRequired) + + if (isMappingComplete(status)) { + completeMappings++ + } else if (isMappingError(status)) { + errors.push({key, status}) + } else if (isMappingWarning(status)) { + warnings.push({key, status}) + } + } + + // Also check for required keys that have no mapping at all + for (const key of requiredKeys) { + if (!(key in mappings)) { + errors.push({key, status: "missing"}) + } + } + + const isComplete = completeMappings >= requiredKeys.length + const isValid = errors.length === 0 + + return { + isValid, + isComplete, + totalMappings: Object.keys(mappings).length, + requiredMappings: requiredKeys.length, + completeMappings, + errorCount: errors.length, + warningCount: warnings.length, + errors, + warnings, + } +} diff --git a/web/packages/agenta-shared/src/utils/openapi.ts b/web/packages/agenta-shared/src/utils/openapi.ts new file mode 100644 index 0000000000..7691163bdb --- /dev/null +++ b/web/packages/agenta-shared/src/utils/openapi.ts @@ -0,0 +1,54 @@ +/** + * OpenAPI Schema Utilities + * + * Functions for processing OpenAPI specifications, including + * dereferencing $ref references. + */ + +import {dereference} from "@scalar/openapi-parser" + +/** + * Result from dereferencing an OpenAPI spec + */ +export interface DereferencedSchemaResult { + schema: Record | null + errors?: string[] +} + +/** + * Dereference all $ref references in an OpenAPI specification. + * + * This resolves all JSON Schema $ref pointers to their actual values, + * producing a fully expanded schema that can be traversed without + * encountering any $ref objects. + * + * @param spec - The raw OpenAPI specification with potential $ref references + * @returns The dereferenced schema and any errors + * + * @example + * ```ts + * const rawSpec = await fetchOpenApiSpec(uri) + * const { schema, errors } = await dereferenceSchema(rawSpec) + * if (schema) { + * // Use the fully resolved schema + * const properties = schema.paths['/test'].post.requestBody.content['application/json'].schema.properties + * } + * ``` + */ +export async function dereferenceSchema( + spec: Record, +): Promise { + try { + const result = await dereference(spec) + return { + schema: result.schema as Record | null, + errors: result.errors?.map((e) => (typeof e === "string" ? e : JSON.stringify(e))), + } + } catch (error) { + console.error("[dereferenceSchema] Failed to dereference schema:", error) + return { + schema: null, + errors: [error instanceof Error ? error.message : "Unknown error during dereferencing"], + } + } +} diff --git a/web/packages/agenta-shared/src/utils/pathUtils.ts b/web/packages/agenta-shared/src/utils/pathUtils.ts new file mode 100644 index 0000000000..80ecbd161f --- /dev/null +++ b/web/packages/agenta-shared/src/utils/pathUtils.ts @@ -0,0 +1,668 @@ +/** + * Path Utilities for Nested Data Navigation + * + * Pure utility functions for navigating and manipulating nested data structures. + * These are data-agnostic and work with any data shape. + * + * @example + * ```typescript + * import { getValueAtPath, setValueAtPath, parsePath } from '@agenta/shared' + * + * const data = { user: { profile: { name: 'Alice' } } } + * getValueAtPath(data, ['user', 'profile', 'name']) // 'Alice' + * + * const updated = setValueAtPath(data, ['user', 'profile', 'name'], 'Bob') + * // { user: { profile: { name: 'Bob' } } } + * ``` + */ + +// ============================================================================ +// TYPES +// ============================================================================ + +/** + * Represents a path segment (string key or array index) + */ +export type PathSegment = string | number + +/** + * A path through nested data + */ +export type DataPath = PathSegment[] + +/** + * Item at a navigation level + */ +export interface PathItem { + /** Key for this item (string key or array index) */ + key: string + /** Display name */ + name: string + /** The value at this path */ + value: unknown + /** Whether this item is expandable (has children) */ + expandable: boolean + /** Number of children (for arrays/objects) */ + childCount?: number +} + +// ============================================================================ +// PATH OPERATIONS +// ============================================================================ + +/** + * Get a value at a path in nested data + * + * Handles JSON strings during traversal - if a string value is encountered + * and there are more path segments, attempts to parse it as JSON. + * + * @example + * ```typescript + * const data = { user: { profile: { name: 'Alice' } } } + * getValueAtPath(data, ['user', 'profile', 'name']) // 'Alice' + * + * // Also handles JSON strings stored in columns: + * const testcase = { messages: '{"content": "hello"}' } + * getValueAtPath(testcase, ['messages', 'content']) // 'hello' + * ``` + */ +export function getValueAtPath(data: unknown, path: DataPath): unknown { + if (!data || path.length === 0) return data + + let current: unknown = data + for (const key of path) { + if (current === null || current === undefined) return undefined + + // Handle JSON strings - if current is a string and we have more path segments, + // try to parse it as JSON + if (typeof current === "string") { + try { + current = JSON.parse(current) + } catch { + // Not valid JSON, can't traverse further + return undefined + } + } + + // Re-check after JSON parse since parsed value could be null + if (current === null || current === undefined) return undefined + + if (typeof current !== "object") { + return undefined + } + + if (Array.isArray(current)) { + const idx = typeof key === "number" ? key : parseInt(String(key), 10) + if (isNaN(idx) || idx < 0 || idx >= current.length) return undefined + current = current[idx] + } else { + current = (current as Record)[String(key)] + } + } + + return current +} + +/** + * Set a value at a path in nested data (immutable) + * + * Handles JSON strings during traversal - if a string value is encountered + * and there are more path segments, attempts to parse it as JSON, sets the + * value, and re-stringifies. + * + * @example + * ```typescript + * const data = { user: { name: 'Alice' } } + * setValueAtPath(data, ['user', 'name'], 'Bob') + * // { user: { name: 'Bob' } } + * + * // Also handles JSON strings stored in columns: + * const testcase = { messages: '{"content": "hello"}' } + * setValueAtPath(testcase, ['messages', 'content'], 'world') + * // { messages: '{"content": "world"}' } + * ``` + */ +export function setValueAtPath(data: unknown, path: DataPath, value: unknown): unknown { + if (path.length === 0) return value + + const [key, ...rest] = path + + // Handle JSON strings - if data is a string and we have path segments, + // try to parse it, set the value, and re-stringify + if (typeof data === "string") { + try { + const parsed = JSON.parse(data) + const updated = setValueAtPath(parsed, path, value) + return JSON.stringify(updated) + } catch { + // Not valid JSON, treat as regular object + } + } + + if (Array.isArray(data)) { + const idx = typeof key === "number" ? key : parseInt(String(key), 10) + // Validate array index (consistent with getValueAtPath) + if (isNaN(idx) || idx < 0) return data + const newArray = [...data] + newArray[idx] = rest.length === 0 ? value : setValueAtPath(data[idx], rest, value) + return newArray + } + + const obj = (data ?? {}) as Record + const strKey = String(key) + + if (rest.length === 0) { + return {...obj, [strKey]: value} + } + + return {...obj, [strKey]: setValueAtPath(obj[strKey], rest, value)} +} + +/** + * Delete a value at a path in nested data (immutable) + * + * Handles JSON strings during traversal - if a string value is encountered + * and there are more path segments, attempts to parse it as JSON, deletes the + * value, and re-stringifies. + * + * @example + * ```typescript + * const data = { user: { name: 'Alice', age: 30 } } + * deleteValueAtPath(data, ['user', 'age']) + * // { user: { name: 'Alice' } } + * ``` + */ +export function deleteValueAtPath(data: unknown, path: DataPath): unknown { + if (!data || path.length === 0) return data + + const [key, ...rest] = path + + // Handle JSON strings - if data is a string and we have path segments, + // try to parse it, delete the value, and re-stringify + if (typeof data === "string") { + try { + const parsed = JSON.parse(data) + const updated = deleteValueAtPath(parsed, path) + return JSON.stringify(updated) + } catch { + // Not valid JSON, return as-is + return data + } + } + + if (Array.isArray(data)) { + const idx = typeof key === "number" ? key : parseInt(String(key), 10) + if (rest.length === 0) { + return data.filter((_, i) => i !== idx) + } + const newArray = [...data] + newArray[idx] = deleteValueAtPath(data[idx], rest) + return newArray + } + + const obj = data as Record + const strKey = String(key) + + if (rest.length === 0) { + const {[strKey]: _, ...remaining} = obj + return remaining + } + + return {...obj, [strKey]: deleteValueAtPath(obj[strKey], rest)} +} + +/** + * Check if a value at path exists + * + * Handles JSON strings during traversal. + */ +export function hasValueAtPath(data: unknown, path: DataPath): boolean { + if (path.length === 0) return data !== undefined + + let value = getValueAtPath(data, path.slice(0, -1)) + const lastKey = path[path.length - 1] + + if (value === null || value === undefined) { + return false + } + + // Handle JSON strings + if (typeof value === "string") { + try { + value = JSON.parse(value) + } catch { + return false + } + } + + if (value === null || typeof value !== "object") { + return false + } + + if (Array.isArray(value)) { + const idx = typeof lastKey === "number" ? lastKey : parseInt(String(lastKey), 10) + return idx >= 0 && idx < value.length + } + + return String(lastKey) in value +} + +// ============================================================================ +// INSPECTION UTILITIES +// ============================================================================ + +/** + * Check if a value is expandable (can be navigated into) + */ +export function isExpandable(value: unknown): boolean { + if (value === null || value === undefined) return false + + // Check if it's a JSON string that can be parsed + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) + return typeof parsed === "object" && parsed !== null + } catch { + return false + } + } + + return typeof value === "object" +} + +/** + * Get the type of a value for display + */ +export function getValueType( + value: unknown, +): "string" | "number" | "boolean" | "null" | "array" | "object" | "undefined" { + if (value === null) return "null" + if (value === undefined) return "undefined" + if (Array.isArray(value)) return "array" + return typeof value as "string" | "number" | "boolean" | "object" +} + +/** + * Get the count of children in a value + */ +export function getChildCount(value: unknown): number { + if (value === null || value === undefined) return 0 + + // Handle JSON strings + if (typeof value === "string") { + try { + const parsed = JSON.parse(value) + if (Array.isArray(parsed)) return parsed.length + if (typeof parsed === "object" && parsed !== null) { + return Object.keys(parsed).length + } + } catch { + return 0 + } + } + + if (Array.isArray(value)) return value.length + if (typeof value === "object") return Object.keys(value).length + + return 0 +} + +/** + * Get items at a path for navigation + */ +export function getItemsAtPath(data: unknown, path: DataPath): PathItem[] { + const value = getValueAtPath(data, path) + + if (value === null || value === undefined) return [] + + // Handle JSON strings + let parsedValue = value + if (typeof value === "string") { + try { + parsedValue = JSON.parse(value) + } catch { + return [] + } + } + + if (typeof parsedValue !== "object") return [] + + if (Array.isArray(parsedValue)) { + return parsedValue.map((item, index) => ({ + key: String(index), + name: `[${index}]`, + value: item, + expandable: isExpandable(item), + childCount: getChildCount(item), + })) + } + + return Object.entries(parsedValue as Record).map(([key, itemValue]) => ({ + key, + name: key, + value: itemValue, + expandable: isExpandable(itemValue), + childCount: getChildCount(itemValue), + })) +} + +// ============================================================================ +// PATH UTILITIES +// ============================================================================ + +/** + * Parse a path string into segments + * + * @example + * ```typescript + * parsePath('user.profile.name') // ['user', 'profile', 'name'] + * parsePath('items[0].name') // ['items', '0', 'name'] + * ``` + */ +export function parsePath(path: string | DataPath): DataPath { + if (Array.isArray(path)) return path + if (!path) return [] + + // Handle dot notation and bracket notation + return path + .replace(/\[(\d+)\]/g, ".$1") // Convert brackets to dots + .split(".") + .filter(Boolean) +} + +/** + * Get a value at a string path (convenience wrapper for getValueAtPath + parsePath) + * + * This is a common pattern when working with dot-notation paths like "user.profile.name" + * instead of array paths like ['user', 'profile', 'name']. + * + * @example + * ```typescript + * const data = { user: { profile: { name: 'Alice' } } } + * getValueAtStringPath(data, 'user.profile.name') // 'Alice' + * getValueAtStringPath(data, 'user.profile') // { name: 'Alice' } + * ``` + */ +export function getValueAtStringPath(data: unknown, path: string): unknown { + return getValueAtPath(data, parsePath(path)) +} + +/** + * Convert path segments to a string + */ +export function pathToString(path: DataPath): string { + return path + .map((segment, index) => { + const str = String(segment) + // Use bracket notation for numeric segments + if (/^\d+$/.test(str)) { + return `[${str}]` + } + // Use dot notation for string segments + return index === 0 ? str : `.${str}` + }) + .join("") +} + +/** + * Get the parent path + */ +export function getParentPath(path: DataPath): DataPath { + return path.slice(0, -1) +} + +/** + * Get the last segment of a path + */ +export function getLastSegment(path: DataPath): PathSegment | undefined { + return path[path.length - 1] +} + +/** + * Check if one path is a child of another + */ +export function isChildPath(parent: DataPath, child: DataPath): boolean { + if (child.length <= parent.length) return false + return parent.every((segment, i) => String(segment) === String(child[i])) +} + +/** + * Collect all paths in a data structure + */ +export function collectPaths(data: unknown, maxDepth = 10, currentPath: DataPath = []): DataPath[] { + if (maxDepth <= 0) return [currentPath] + + const paths: DataPath[] = [currentPath] + + if (data === null || data === undefined || typeof data !== "object") { + return paths + } + + // Handle arrays + if (Array.isArray(data)) { + data.forEach((item, index) => { + paths.push(...collectPaths(item, maxDepth - 1, [...currentPath, index])) + }) + return paths + } + + // Handle objects + Object.entries(data).forEach(([key, value]) => { + paths.push(...collectPaths(value, maxDepth - 1, [...currentPath, key])) + }) + + return paths +} + +// ============================================================================ +// TYPED PATH INFO (for UI selection) +// ============================================================================ + +/** + * Detailed path information for UI selection components. + * Includes type information and display labels. + */ +export interface TypedPathInfo { + /** Dot-notation path string */ + path: string + /** Display string for the path (defaults to path if not provided) */ + pathString?: string + /** Display label for UI */ + label: string + /** Value type (string, number, boolean, array, object, unknown) */ + type: string + /** Same as type (for compatibility, defaults to type if not provided) */ + valueType?: string + /** Source of the path (output, testcase, etc.) */ + source: string + /** Optional sample value for preview */ + sampleValue?: unknown +} + +/** + * Options for extractTypedPaths function + */ +export interface ExtractTypedPathsOptions { + /** Prefix for generated paths */ + prefix?: string + /** Maximum recursion depth (default: 3) */ + maxDepth?: number + /** Source identifier (default: "output") */ + source?: string + /** Include sample values in output */ + includeSampleValues?: boolean +} + +/** + * Extract typed paths from a value for UI selection. + * + * Returns detailed path information including type, label, and optional sample values. + * Useful for building path selection dropdowns in mapping UIs. + * + * @example + * ```typescript + * const data = { user: { name: 'Alice', tags: ['admin'] } } + * const paths = extractTypedPaths(data) + * // [ + * // { path: 'user', label: 'user', type: 'object', ... }, + * // { path: 'user.name', label: 'name', type: 'string', ... }, + * // { path: 'user.tags', label: 'tags', type: 'array', ... }, + * // ] + * ``` + */ +export function extractTypedPaths( + value: unknown, + options: ExtractTypedPathsOptions = {}, +): TypedPathInfo[] { + const {prefix = "", maxDepth = 3, source = "output", includeSampleValues = true} = options + const paths: TypedPathInfo[] = [] + + if (maxDepth <= 0) return paths + if (value === null || value === undefined) return paths + + if (Array.isArray(value)) { + // For arrays, add the array itself as a path + const pathString = prefix || "output" + paths.push({ + path: pathString, + pathString, + label: prefix.split(".").pop() || "output", + type: "array", + valueType: "array", + source, + ...(includeSampleValues && {sampleValue: value}), + }) + // Also extract from first item if it's an object + if (value.length > 0 && typeof value[0] === "object" && value[0] !== null) { + const itemPaths = extractTypedPaths(value[0], { + prefix: prefix ? `${prefix}.0` : "0", + maxDepth: maxDepth - 1, + source, + includeSampleValues, + }) + paths.push(...itemPaths) + } + } else if (typeof value === "object") { + // For objects, add each property as a path + for (const [key, val] of Object.entries(value)) { + const currentPath = prefix ? `${prefix}.${key}` : key + const valueType = getTypedValueType(val) + + paths.push({ + path: currentPath, + pathString: currentPath, + label: key, + type: valueType, + valueType, + source, + ...(includeSampleValues && {sampleValue: val}), + }) + + // Recursively extract from nested objects + if (typeof val === "object" && val !== null && !Array.isArray(val)) { + const nestedPaths = extractTypedPaths(val, { + prefix: currentPath, + maxDepth: maxDepth - 1, + source, + includeSampleValues, + }) + paths.push(...nestedPaths) + } + } + } else { + // Primitive value at root + const valueType = getTypedValueType(value) + const pathString = prefix || "output" + paths.push({ + path: pathString, + pathString, + label: prefix.split(".").pop() || "output", + type: valueType, + valueType, + source, + ...(includeSampleValues && {sampleValue: value}), + }) + } + + return paths +} + +/** + * Get type string for a value (for TypedPathInfo) + */ +function getTypedValueType(val: unknown): string { + if (val === null || val === undefined) return "unknown" + if (Array.isArray(val)) return "array" + if (typeof val === "object") return "object" + if (typeof val === "string") return "string" + if (typeof val === "number") return "number" + if (typeof val === "boolean") return "boolean" + return "unknown" +} + +/** + * Combine multiple path arrays with deduplication. + * + * Merges paths from different sources (schema, runtime, testcase columns) + * into a single array, removing duplicates based on path string. + * + * @example + * ```typescript + * const schemaPaths = [{ path: 'output', type: 'object', ... }] + * const runtimePaths = [{ path: 'output.name', type: 'string', ... }] + * const combined = combineTypedPaths(schemaPaths, runtimePaths) + * ``` + */ +export function combineTypedPaths(...pathArrays: (TypedPathInfo[] | undefined)[]): TypedPathInfo[] { + const seen = new Set() + const result: TypedPathInfo[] = [] + + for (const paths of pathArrays) { + if (!paths) continue + for (const p of paths) { + const key = p.pathString || p.path + if (!seen.has(key)) { + seen.add(key) + result.push(p) + } + } + } + + return result +} + +/** + * Build testcase column paths from column definitions. + * + * Converts testcase column definitions into TypedPathInfo objects + * for use in path selection UIs. + * + * @example + * ```typescript + * const columns = [{ key: 'prompt', name: 'Prompt', type: 'string' }] + * const paths = buildTestcaseColumnPaths(columns) + * // [{ path: 'testcase.prompt', label: 'Prompt', type: 'string', source: 'testcase' }] + * ``` + */ +export function buildTestcaseColumnPaths( + columns: {key: string; name?: string; type?: string}[], +): TypedPathInfo[] { + return columns.map((col) => { + let valueType = "unknown" + if (col.type === "integer") { + valueType = "number" + } else if (col.type) { + valueType = col.type + } + + const pathString = `testcase.${col.key}` + return { + path: pathString, + pathString, + label: col.name || col.key, + type: valueType, + valueType, + source: "testcase", + } + }) +} diff --git a/web/packages/agenta-shared/src/utils/pluralize.ts b/web/packages/agenta-shared/src/utils/pluralize.ts new file mode 100644 index 0000000000..1ee15428d6 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/pluralize.ts @@ -0,0 +1,46 @@ +/** + * Pluralization Utility + * + * Simple utility for pluralizing words based on count. + * + * @example + * ```typescript + * import { pluralize } from '@agenta/shared' + * + * pluralize(1, "testcase") // "testcase" + * pluralize(5, "testcase") // "testcases" + * pluralize(1, "child", "children") // "child" + * pluralize(3, "child", "children") // "children" + * ``` + */ + +/** + * Returns singular or plural form based on count + * + * @param count - The number to check + * @param singular - The singular form of the word + * @param plural - Optional custom plural form (defaults to singular + "s") + * @returns The appropriate form of the word + */ +export function pluralize(count: number, singular: string, plural?: string): string { + return count === 1 ? singular : (plural ?? `${singular}s`) +} + +/** + * Returns a formatted string with count and pluralized word + * + * @param count - The number to display + * @param singular - The singular form of the word + * @param plural - Optional custom plural form (defaults to singular + "s") + * @returns Formatted string like "5 items" or "1 item" + * + * @example + * ```typescript + * formatCount(1, "testcase") // "1 testcase" + * formatCount(5, "testcase") // "5 testcases" + * formatCount(0, "item") // "0 items" + * ``` + */ +export function formatCount(count: number, singular: string, plural?: string): string { + return `${count} ${pluralize(count, singular, plural)}` +} diff --git a/web/packages/agenta-shared/src/utils/schemaOptions.ts b/web/packages/agenta-shared/src/utils/schemaOptions.ts new file mode 100644 index 0000000000..00888c7618 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/schemaOptions.ts @@ -0,0 +1,48 @@ +/** + * Schema options utilities for grouped and enum-based selections. + */ + +export interface OptionGroup { + label: string + options: {label: string; value: string}[] +} + +export interface SchemaWithOptions { + enum?: unknown[] // Allow unknown[] to be compatible with SchemaProperty + choices?: Record +} + +export function getOptionsFromSchema( + schema: TSchema | null | undefined, +): {grouped: Record; options: OptionGroup[]} | null { + if (!schema) return null + + const choices = schema.choices as Record | undefined + if (choices && typeof choices === "object" && !Array.isArray(choices)) { + const grouped = choices + const options = Object.entries(grouped).map(([group, models]) => ({ + label: group.charAt(0).toUpperCase() + group.slice(1).replace(/_/g, " "), + options: models.map((model) => ({ + label: model, + value: model, + })), + })) + return {grouped, options} + } + + const enumValues = schema.enum as string[] | undefined + if (enumValues && Array.isArray(enumValues) && enumValues.length > 0) { + const options: OptionGroup[] = [ + { + label: "Models", + options: enumValues.map((value) => ({ + label: value, + value, + })), + }, + ] + return {grouped: {Models: enumValues}, options} + } + + return null +} diff --git a/web/packages/agenta-shared/src/utils/validators.ts b/web/packages/agenta-shared/src/utils/validators.ts new file mode 100644 index 0000000000..72a7a54876 --- /dev/null +++ b/web/packages/agenta-shared/src/utils/validators.ts @@ -0,0 +1,46 @@ +/** + * Validation utilities. + */ + +export const isValidHttpUrl = (url: string) => { + try { + const newUrl = new URL(url) + return newUrl.protocol.startsWith("http") + } catch (_) { + return false + } +} + +export function isValidRegex(regex: string) { + try { + new RegExp(regex) + return true + } catch (_) { + return false + } +} + +/** + * UUID validation regex - validates standard UUID format (v1-v5) + * Used to prevent SSRF by ensuring IDs are valid UUIDs before using in URLs + */ +const UUID_REGEX = /^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/i + +/** + * Check if a string is a valid UUID + */ +export function isValidUUID(id: string): boolean { + return UUID_REGEX.test(id) +} + +/** + * Validate that a string is a valid UUID, throwing an error if not + * @param id - The string to validate + * @param paramName - The name of the parameter (for error message) + * @throws Error if the ID is not a valid UUID + */ +export function validateUUID(id: string, paramName: string): void { + if (!isValidUUID(id)) { + throw new Error(`Invalid ${paramName}: must be a valid UUID`) + } +} diff --git a/web/packages/agenta-shared/tsconfig.json b/web/packages/agenta-shared/tsconfig.json new file mode 100644 index 0000000000..5d90114bec --- /dev/null +++ b/web/packages/agenta-shared/tsconfig.json @@ -0,0 +1,10 @@ +{ + "extends": "../tsconfig.base.json", + "compilerOptions": { + "baseUrl": ".", + "rootDir": "src", + "tsBuildInfoFile": ".tsbuildinfo" + }, + "include": ["src/**/*.ts", "src/**/*.tsx"], + "exclude": ["node_modules", "dist"] +} diff --git a/web/packages/eslint.config.mjs b/web/packages/eslint.config.mjs new file mode 100644 index 0000000000..92a27de1bb --- /dev/null +++ b/web/packages/eslint.config.mjs @@ -0,0 +1,119 @@ +/** + * Shared ESLint config for @agenta/* packages + * + * This is the same as the root config without Next.js plugins. + * Packages are pure TypeScript/React libraries. + */ +import path from "node:path" +import {fileURLToPath} from "node:url" + +import {FlatCompat} from "@eslint/eslintrc" +import eslint from "@eslint/js" +import importPlugin from "eslint-plugin-import" +import eslintPluginPrettier from "eslint-plugin-prettier/recommended" +import tseslint from "typescript-eslint" + +const __filename = fileURLToPath(import.meta.url) +const __dirname = path.dirname(__filename) + +const compat = new FlatCompat({ + baseDirectory: __dirname, + recommendedConfig: eslint.configs.recommended, + allConfig: eslint.configs.all, +}) + +const tsEslintConfig = tseslint.config( + eslint.configs.recommended, + tseslint.configs.recommended, + tseslint.configs.stylistic, +) + +const config = [ + ...compat.extends("plugin:@lexical/recommended"), + ...tsEslintConfig, + { + languageOptions: { + parserOptions: { + tsconfigRootDir: __dirname, + }, + }, + plugins: { + import: importPlugin, + }, + settings: { + "import/resolver": { + typescript: { + alwaysTryTypes: true, + project: [ + path.join(__dirname, "agenta-entities/tsconfig.json"), + path.join(__dirname, "agenta-ui/tsconfig.json"), + path.join(__dirname, "agenta-shared/tsconfig.json"), + ], + }, + }, + }, + rules: { + "prefer-const": "off", + "no-self-assign": "off", + "no-empty": "off", + "no-case-declarations": "off", + "no-useless-escape": "off", + "no-prototype-builtins": "off", + "no-useless-catch": "off", + "@typescript-eslint/no-explicit-any": "off", + "@typescript-eslint/no-empty-object-type": "off", + "@typescript-eslint/ban-ts-comment": "off", + "@typescript-eslint/no-empty-function": "off", + "@typescript-eslint/no-non-null-asserted-optional-chain": "off", + "@typescript-eslint/no-non-null-assertion": "off", + "@typescript-eslint/no-unsafe-function-type": "off", + "@typescript-eslint/no-unused-expressions": "off", + "@typescript-eslint/no-unused-vars": [ + "error", + { + vars: "all", + args: "none", + caughtErrors: "none", + ignoreRestSiblings: true, + destructuredArrayIgnorePattern: "none", + varsIgnorePattern: "^_|^_.*", + }, + ], + "import/order": [ + "error", + { + alphabetize: { + order: "asc", + caseInsensitive: true, + }, + "newlines-between": "always", + groups: ["builtin", "external", "internal", "parent", "sibling", "index"], + pathGroupsExcludedImportTypes: ["react"], + pathGroups: [ + { + pattern: "react", + group: "builtin", + position: "before", + }, + ], + }, + ], + "prettier/prettier": [ + "error", + { + printWidth: 100, + tabWidth: 4, + useTabs: false, + semi: false, + bracketSpacing: false, + }, + ], + }, + }, + eslintPluginPrettier, + { + ignores: ["**/dist/**", "**/node_modules/**", "**/*.d.ts"], + }, +] + +export default config diff --git a/web/packages/tsconfig.base.json b/web/packages/tsconfig.base.json new file mode 100644 index 0000000000..c942bf55b0 --- /dev/null +++ b/web/packages/tsconfig.base.json @@ -0,0 +1,25 @@ +{ + "$schema": "https://json.schemastore.org/tsconfig", + "compilerOptions": { + "target": "esnext", + "lib": ["dom", "dom.iterable", "esnext"], + "types": ["node"], + "allowJs": false, + "skipLibCheck": true, + "strict": true, + "noImplicitAny": false, + "strictNullChecks": false, + "forceConsistentCasingInFileNames": true, + "noEmit": true, + "esModuleInterop": true, + "module": "esnext", + "moduleResolution": "node", + "resolveJsonModule": true, + "isolatedModules": true, + "jsx": "react-jsx", + "incremental": true, + "declaration": false, + "declarationMap": false, + "composite": false + } +}