feat: add AI Agents feature with multi-provider support and documentation
- Add Agent, AgentCollection, AgentTool, UserApiKey, Conversation, Message models to Prisma schema - Create agent types and Zod schemas in @tpmjs/types - Implement AES-256 API key encryption utilities - Add CRUD API endpoints for agents, tools, collections, and user API keys - Create conversation streaming endpoint with SSE events - Build agent tool builder to merge collections and individual tools - Add dashboard pages: agents list, new agent form, agent detail/edit, chat interface - Add API keys settings page for managing provider keys - Add comprehensive Agents documentation section to /docs - Update navigation to include Agents link in header and mobile menu - Add new icons: terminal, puzzle, message, key, info, send Supported providers: OpenAI, Anthropic, Google, Groq, Mistral
This commit is contained in:
parent
4cbd84edb8
commit
552f319583
28 changed files with 5082 additions and 2 deletions
|
|
@ -96,6 +96,7 @@ model Tool {
|
|||
simulations Simulation[]
|
||||
healthChecks HealthCheck[]
|
||||
collections CollectionTool[]
|
||||
agents AgentTool[]
|
||||
|
||||
@@unique([packageId, name])
|
||||
@@index([qualityScore])
|
||||
|
|
@ -333,6 +334,8 @@ model User {
|
|||
sessions Session[]
|
||||
accounts Account[]
|
||||
collections Collection[]
|
||||
agents Agent[]
|
||||
apiKeys UserApiKey[]
|
||||
|
||||
@@map("users")
|
||||
}
|
||||
|
|
@ -412,6 +415,7 @@ model Collection {
|
|||
|
||||
// Relations
|
||||
tools CollectionTool[]
|
||||
agents AgentCollection[]
|
||||
|
||||
// Unique constraint: user can't have duplicate collection names
|
||||
@@unique([userId, name])
|
||||
|
|
@ -446,3 +450,193 @@ model CollectionTool {
|
|||
@@index([toolId])
|
||||
@@map("collection_tools")
|
||||
}
|
||||
|
||||
// ============================================================================
|
||||
// Agent Models
|
||||
// ============================================================================
|
||||
|
||||
/// AI Provider enum - supported LLM providers
|
||||
enum AIProvider {
|
||||
OPENAI
|
||||
ANTHROPIC
|
||||
GOOGLE
|
||||
GROQ
|
||||
MISTRAL
|
||||
}
|
||||
|
||||
/// Message role enum - conversation message types
|
||||
enum MessageRole {
|
||||
USER
|
||||
ASSISTANT
|
||||
TOOL
|
||||
SYSTEM
|
||||
}
|
||||
|
||||
/// Agent - user-owned AI agent configurations
|
||||
model Agent {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Owner relationship
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Agent identity
|
||||
uid String @unique @db.VarChar(50) // URL-friendly identifier
|
||||
name String @db.VarChar(100)
|
||||
description String? @db.VarChar(500)
|
||||
|
||||
// Model configuration
|
||||
provider AIProvider
|
||||
modelId String @map("model_id") @db.VarChar(100)
|
||||
systemPrompt String? @map("system_prompt") @db.Text
|
||||
temperature Float @default(0.7)
|
||||
maxToolCallsPerTurn Int @default(20) @map("max_tool_calls_per_turn")
|
||||
maxMessagesInContext Int @default(10) @map("max_messages_in_context")
|
||||
|
||||
// Visibility
|
||||
isPublic Boolean @default(false) @map("is_public")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
collections AgentCollection[]
|
||||
tools AgentTool[]
|
||||
conversations Conversation[]
|
||||
|
||||
@@unique([userId, name])
|
||||
@@index([userId])
|
||||
@@index([uid])
|
||||
@@index([isPublic])
|
||||
@@index([createdAt])
|
||||
@@map("agents")
|
||||
}
|
||||
|
||||
/// AgentCollection - junction table for Agent -> Collection (many-to-many)
|
||||
model AgentCollection {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relationships
|
||||
agentId String @map("agent_id")
|
||||
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
collectionId String @map("collection_id")
|
||||
collection Collection @relation(fields: [collectionId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Ordering
|
||||
position Int @default(0)
|
||||
|
||||
// Timestamps
|
||||
addedAt DateTime @default(now()) @map("added_at")
|
||||
|
||||
@@unique([agentId, collectionId])
|
||||
@@index([agentId])
|
||||
@@index([collectionId])
|
||||
@@map("agent_collections")
|
||||
}
|
||||
|
||||
/// AgentTool - junction table for Agent -> Tool (many-to-many)
|
||||
model AgentTool {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Relationships
|
||||
agentId String @map("agent_id")
|
||||
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
toolId String @map("tool_id")
|
||||
tool Tool @relation(fields: [toolId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Ordering
|
||||
position Int @default(0)
|
||||
|
||||
// Timestamps
|
||||
addedAt DateTime @default(now()) @map("added_at")
|
||||
|
||||
@@unique([agentId, toolId])
|
||||
@@index([agentId])
|
||||
@@index([toolId])
|
||||
@@map("agent_tools")
|
||||
}
|
||||
|
||||
/// UserApiKey - encrypted API keys per user per provider
|
||||
model UserApiKey {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Owner relationship
|
||||
userId String @map("user_id")
|
||||
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Provider identification
|
||||
provider AIProvider
|
||||
|
||||
// Encrypted key storage
|
||||
encryptedKey String @map("encrypted_key") @db.Text
|
||||
keyIv String @map("key_iv") @db.VarChar(32)
|
||||
keyHint String? @map("key_hint") @db.VarChar(10) // Last 4 chars
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
@@unique([userId, provider])
|
||||
@@index([userId])
|
||||
@@map("user_api_keys")
|
||||
}
|
||||
|
||||
/// Conversation - chat session with an agent
|
||||
model Conversation {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Agent relationship
|
||||
agentId String @map("agent_id")
|
||||
agent Agent @relation(fields: [agentId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Conversation identity
|
||||
slug String @db.VarChar(100) // User-chosen unique ID
|
||||
|
||||
// Metadata
|
||||
title String? @db.VarChar(200)
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
updatedAt DateTime @updatedAt @map("updated_at")
|
||||
|
||||
// Relations
|
||||
messages Message[]
|
||||
|
||||
@@unique([agentId, slug])
|
||||
@@index([agentId])
|
||||
@@index([createdAt])
|
||||
@@map("conversations")
|
||||
}
|
||||
|
||||
/// Message - individual message in a conversation
|
||||
model Message {
|
||||
id String @id @default(cuid())
|
||||
|
||||
// Conversation relationship
|
||||
conversationId String @map("conversation_id")
|
||||
conversation Conversation @relation(fields: [conversationId], references: [id], onDelete: Cascade)
|
||||
|
||||
// Message content
|
||||
role MessageRole
|
||||
content String @db.Text
|
||||
|
||||
// Tool call metadata (for role=ASSISTANT with tool calls)
|
||||
toolCalls Json? @map("tool_calls") @db.JsonB
|
||||
|
||||
// Tool result metadata (for role=TOOL)
|
||||
toolCallId String? @map("tool_call_id") @db.VarChar(100)
|
||||
toolName String? @map("tool_name") @db.VarChar(200)
|
||||
toolResult Json? @map("tool_result") @db.JsonB
|
||||
|
||||
// Token tracking
|
||||
inputTokens Int? @map("input_tokens")
|
||||
outputTokens Int? @map("output_tokens")
|
||||
|
||||
// Timestamps
|
||||
createdAt DateTime @default(now()) @map("created_at")
|
||||
|
||||
@@index([conversationId])
|
||||
@@index([createdAt])
|
||||
@@map("messages")
|
||||
}
|
||||
|
|
|
|||
|
|
@ -28,6 +28,10 @@
|
|||
"./collection": {
|
||||
"types": "./dist/collection.d.ts",
|
||||
"default": "./dist/collection.js"
|
||||
},
|
||||
"./agent": {
|
||||
"types": "./dist/agent.d.ts",
|
||||
"default": "./dist/agent.js"
|
||||
}
|
||||
},
|
||||
"files": ["dist"],
|
||||
|
|
|
|||
235
packages/types/src/agent.ts
Normal file
235
packages/types/src/agent.ts
Normal file
|
|
@ -0,0 +1,235 @@
|
|||
import { z } from 'zod';
|
||||
|
||||
// Regex for valid agent UID: lowercase alphanumeric and hyphens
|
||||
const UID_REGEX = /^[a-z0-9][a-z0-9-]*[a-z0-9]$|^[a-z0-9]$/;
|
||||
|
||||
// ============================================================================
|
||||
// Enums
|
||||
// ============================================================================
|
||||
|
||||
export const AIProviderSchema = z.enum(['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL']);
|
||||
|
||||
export const MessageRoleSchema = z.enum(['USER', 'ASSISTANT', 'TOOL', 'SYSTEM']);
|
||||
|
||||
export type AIProvider = z.infer<typeof AIProviderSchema>;
|
||||
export type MessageRole = z.infer<typeof MessageRoleSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Agent Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const CreateAgentSchema = z.object({
|
||||
name: z.string().min(1, 'Name is required').max(100, 'Name must be 100 characters or less'),
|
||||
uid: z
|
||||
.string()
|
||||
.min(1, 'UID is required')
|
||||
.max(50, 'UID must be 50 characters or less')
|
||||
.regex(UID_REGEX, 'UID must be lowercase alphanumeric with hyphens')
|
||||
.optional(),
|
||||
description: z.string().max(500, 'Description must be 500 characters or less').optional(),
|
||||
provider: AIProviderSchema,
|
||||
modelId: z.string().min(1, 'Model ID is required').max(100),
|
||||
systemPrompt: z.string().max(10000, 'System prompt must be 10,000 characters or less').optional(),
|
||||
temperature: z.number().min(0).max(2).default(0.7),
|
||||
maxToolCallsPerTurn: z.number().int().min(1).max(100).default(20),
|
||||
maxMessagesInContext: z.number().int().min(1).max(100).default(10),
|
||||
isPublic: z.boolean().default(false),
|
||||
collectionIds: z.array(z.string()).optional(),
|
||||
toolIds: z.array(z.string()).optional(),
|
||||
});
|
||||
|
||||
export const UpdateAgentSchema = z.object({
|
||||
name: z.string().min(1).max(100).optional(),
|
||||
uid: z.string().min(1).max(50).regex(UID_REGEX).optional(),
|
||||
description: z.string().max(500).nullable().optional(),
|
||||
provider: AIProviderSchema.optional(),
|
||||
modelId: z.string().min(1).max(100).optional(),
|
||||
systemPrompt: z.string().max(10000).nullable().optional(),
|
||||
temperature: z.number().min(0).max(2).optional(),
|
||||
maxToolCallsPerTurn: z.number().int().min(1).max(100).optional(),
|
||||
maxMessagesInContext: z.number().int().min(1).max(100).optional(),
|
||||
isPublic: z.boolean().optional(),
|
||||
});
|
||||
|
||||
export const AddCollectionToAgentSchema = z.object({
|
||||
collectionId: z.string().min(1, 'Collection ID is required'),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
export const AddToolToAgentSchema = z.object({
|
||||
toolId: z.string().min(1, 'Tool ID is required'),
|
||||
position: z.number().int().min(0).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// User API Key Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const SUPPORTED_PROVIDERS = ['OPENAI', 'ANTHROPIC', 'GOOGLE', 'GROQ', 'MISTRAL'] as const;
|
||||
|
||||
export const AddApiKeySchema = z.object({
|
||||
provider: AIProviderSchema,
|
||||
apiKey: z.string().min(10, 'API key is required'),
|
||||
});
|
||||
|
||||
export const ApiKeyInfoSchema = z.object({
|
||||
provider: AIProviderSchema,
|
||||
keyHint: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Conversation Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const CreateConversationSchema = z.object({
|
||||
slug: z
|
||||
.string()
|
||||
.min(1)
|
||||
.max(100)
|
||||
.regex(/^[a-z0-9-]+$/, 'Slug must be lowercase alphanumeric with hyphens')
|
||||
.optional(),
|
||||
title: z.string().max(200).optional(),
|
||||
});
|
||||
|
||||
export const SendMessageSchema = z.object({
|
||||
message: z.string().min(1, 'Message is required').max(50000, 'Message too long'),
|
||||
env: z.record(z.string(), z.string()).optional(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Message Schemas
|
||||
// ============================================================================
|
||||
|
||||
export const ToolCallSchema = z.object({
|
||||
id: z.string(),
|
||||
name: z.string(),
|
||||
arguments: z.string(),
|
||||
});
|
||||
|
||||
export const MessageSchema = z.object({
|
||||
id: z.string(),
|
||||
role: MessageRoleSchema,
|
||||
content: z.string(),
|
||||
toolCalls: z.array(ToolCallSchema).nullable(),
|
||||
toolCallId: z.string().nullable(),
|
||||
toolName: z.string().nullable(),
|
||||
toolResult: z.any().nullable(),
|
||||
inputTokens: z.number().nullable(),
|
||||
outputTokens: z.number().nullable(),
|
||||
createdAt: z.date(),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Response Types
|
||||
// ============================================================================
|
||||
|
||||
export const AgentSchema = z.object({
|
||||
id: z.string(),
|
||||
uid: z.string(),
|
||||
name: z.string(),
|
||||
description: z.string().nullable(),
|
||||
provider: AIProviderSchema,
|
||||
modelId: z.string(),
|
||||
systemPrompt: z.string().nullable(),
|
||||
temperature: z.number(),
|
||||
maxToolCallsPerTurn: z.number(),
|
||||
maxMessagesInContext: z.number(),
|
||||
isPublic: z.boolean(),
|
||||
toolCount: z.number(),
|
||||
collectionCount: z.number(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
});
|
||||
|
||||
export const ConversationSchema = z.object({
|
||||
id: z.string(),
|
||||
agentId: z.string(),
|
||||
slug: z.string(),
|
||||
title: z.string().nullable(),
|
||||
createdAt: z.date(),
|
||||
updatedAt: z.date(),
|
||||
messageCount: z.number().optional(),
|
||||
});
|
||||
|
||||
export const ConversationWithMessagesSchema = ConversationSchema.extend({
|
||||
messages: z.array(MessageSchema),
|
||||
});
|
||||
|
||||
// ============================================================================
|
||||
// Type Exports
|
||||
// ============================================================================
|
||||
|
||||
export type CreateAgentInput = z.infer<typeof CreateAgentSchema>;
|
||||
export type UpdateAgentInput = z.infer<typeof UpdateAgentSchema>;
|
||||
export type AddCollectionToAgentInput = z.infer<typeof AddCollectionToAgentSchema>;
|
||||
export type AddToolToAgentInput = z.infer<typeof AddToolToAgentSchema>;
|
||||
export type AddApiKeyInput = z.infer<typeof AddApiKeySchema>;
|
||||
export type ApiKeyInfo = z.infer<typeof ApiKeyInfoSchema>;
|
||||
export type CreateConversationInput = z.infer<typeof CreateConversationSchema>;
|
||||
export type SendMessageInput = z.infer<typeof SendMessageSchema>;
|
||||
export type ToolCall = z.infer<typeof ToolCallSchema>;
|
||||
export type Message = z.infer<typeof MessageSchema>;
|
||||
export type Agent = z.infer<typeof AgentSchema>;
|
||||
export type Conversation = z.infer<typeof ConversationSchema>;
|
||||
export type ConversationWithMessages = z.infer<typeof ConversationWithMessagesSchema>;
|
||||
|
||||
// ============================================================================
|
||||
// Constants
|
||||
// ============================================================================
|
||||
|
||||
export const AGENT_LIMITS = {
|
||||
MAX_AGENTS_PER_USER: 20,
|
||||
MAX_COLLECTIONS_PER_AGENT: 10,
|
||||
MAX_TOOLS_PER_AGENT: 50,
|
||||
MAX_SYSTEM_PROMPT_LENGTH: 10000,
|
||||
MAX_NAME_LENGTH: 100,
|
||||
MAX_DESCRIPTION_LENGTH: 500,
|
||||
MAX_UID_LENGTH: 50,
|
||||
} as const;
|
||||
|
||||
export const CONVERSATION_LIMITS = {
|
||||
MAX_CONVERSATIONS_PER_AGENT: 100,
|
||||
MAX_MESSAGE_LENGTH: 50000,
|
||||
MAX_TITLE_LENGTH: 200,
|
||||
MAX_SLUG_LENGTH: 100,
|
||||
} as const;
|
||||
|
||||
// ============================================================================
|
||||
// Provider Model Mappings
|
||||
// ============================================================================
|
||||
|
||||
export const PROVIDER_MODELS = {
|
||||
OPENAI: [
|
||||
{ id: 'gpt-4o', name: 'GPT-4o', contextWindow: 128000 },
|
||||
{ id: 'gpt-4o-mini', name: 'GPT-4o Mini', contextWindow: 128000 },
|
||||
{ id: 'gpt-4-turbo', name: 'GPT-4 Turbo', contextWindow: 128000 },
|
||||
{ id: 'gpt-3.5-turbo', name: 'GPT-3.5 Turbo', contextWindow: 16385 },
|
||||
],
|
||||
ANTHROPIC: [
|
||||
{ id: 'claude-3-5-sonnet-20241022', name: 'Claude 3.5 Sonnet', contextWindow: 200000 },
|
||||
{ id: 'claude-3-5-haiku-20241022', name: 'Claude 3.5 Haiku', contextWindow: 200000 },
|
||||
{ id: 'claude-3-opus-20240229', name: 'Claude 3 Opus', contextWindow: 200000 },
|
||||
],
|
||||
GOOGLE: [
|
||||
{ id: 'gemini-2.0-flash-exp', name: 'Gemini 2.0 Flash', contextWindow: 1000000 },
|
||||
{ id: 'gemini-1.5-pro', name: 'Gemini 1.5 Pro', contextWindow: 1000000 },
|
||||
{ id: 'gemini-1.5-flash', name: 'Gemini 1.5 Flash', contextWindow: 1000000 },
|
||||
],
|
||||
GROQ: [
|
||||
{ id: 'llama-3.3-70b-versatile', name: 'Llama 3.3 70B', contextWindow: 131072 },
|
||||
{ id: 'llama-3.1-8b-instant', name: 'Llama 3.1 8B', contextWindow: 131072 },
|
||||
{ id: 'mixtral-8x7b-32768', name: 'Mixtral 8x7B', contextWindow: 32768 },
|
||||
],
|
||||
MISTRAL: [
|
||||
{ id: 'mistral-large-latest', name: 'Mistral Large', contextWindow: 128000 },
|
||||
{ id: 'mistral-small-latest', name: 'Mistral Small', contextWindow: 128000 },
|
||||
],
|
||||
} as const;
|
||||
|
||||
export type ProviderModel = {
|
||||
id: string;
|
||||
name: string;
|
||||
contextWindow: number;
|
||||
};
|
||||
|
|
@ -1,7 +1,7 @@
|
|||
import { defineConfig } from 'tsup';
|
||||
|
||||
export default defineConfig({
|
||||
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts'],
|
||||
entry: ['src/tool.ts', 'src/registry.ts', 'src/tpmjs.ts', 'src/collection.ts', 'src/agent.ts'],
|
||||
format: ['esm'],
|
||||
dts: true,
|
||||
clean: true,
|
||||
|
|
|
|||
|
|
@ -84,6 +84,30 @@ export const icons = {
|
|||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm-1 17.93c-3.95-.49-7-3.85-7-7.93 0-.62.08-1.21.21-1.79L9 15v1c0 1.1.9 2 2 2v1.93zm6.9-2.54c-.26-.81-1-1.39-1.9-1.39h-1v-3c0-.55-.45-1-1-1H8v-2h2c.55 0 1-.45 1-1V7h2c1.1 0 2-.9 2-2v-.41c2.93 1.19 5 4.06 5 7.41 0 2.08-.8 3.97-2.1 5.39z',
|
||||
},
|
||||
terminal: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M20 19V7c0-1.1-.9-2-2-2H6c-1.1 0-2 .9-2 2v12c0 1.1.9 2 2 2h12c1.1 0 2-.9 2-2zM7.5 12.79l1.41-1.41L11.08 13.55l-2.17 2.17-1.41-1.41.76-.76-.76-.76zm9 4.71h-6v-1.5h6v1.5z',
|
||||
},
|
||||
puzzle: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M20.5 11H19V7c0-1.1-.9-2-2-2h-4V3.5C13 2.12 11.88 1 10.5 1S8 2.12 8 3.5V5H4c-1.1 0-1.99.9-1.99 2v3.8H3.5c1.49 0 2.7 1.21 2.7 2.7s-1.21 2.7-2.7 2.7H2V20c0 1.1.9 2 2 2h3.8v-1.5c0-1.49 1.21-2.7 2.7-2.7 1.49 0 2.7 1.21 2.7 2.7V22H17c1.1 0 2-.9 2-2v-4h1.5c1.38 0 2.5-1.12 2.5-2.5S21.88 11 20.5 11z',
|
||||
},
|
||||
message: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M20 2H4c-1.1 0-1.99.9-1.99 2L2 22l4-4h14c1.1 0 2-.9 2-2V4c0-1.1-.9-2-2-2zm-2 12H6v-2h12v2zm0-3H6V9h12v2zm0-3H6V6h12v2z',
|
||||
},
|
||||
key: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12.65 10C11.83 7.67 9.61 6 7 6c-3.31 0-6 2.69-6 6s2.69 6 6 6c2.61 0 4.83-1.67 5.65-4H17v4h4v-4h2v-4H12.65zM7 14c-1.1 0-2-.9-2-2s.9-2 2-2 2 .9 2 2-.9 2-2 2z',
|
||||
},
|
||||
info: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M12 2C6.48 2 2 6.48 2 12s4.48 10 10 10 10-4.48 10-10S17.52 2 12 2zm1 15h-2v-6h2v6zm0-8h-2V7h2v2z',
|
||||
},
|
||||
send: {
|
||||
viewBox: '0 0 24 24',
|
||||
path: 'M2.01 21L23 12 2.01 3 2 10l15 2-15 2z',
|
||||
},
|
||||
} as const;
|
||||
|
||||
export type IconName = keyof typeof icons;
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue